The Multisynq Data API provides secure bulk data storage for your applications. Instead of sending large files through the event system, you can upload data to Multisynq’s servers and share lightweight data handles that other users can use to fetch the content.

Core Concepts

The Data API separates large binary data from your synchronized model state, providing efficient and secure file sharing capabilities.

Data flow overview

// 1. Upload data and get a handle
const dataHandle = await session.data.store(arrayBuffer);

// 2. Share the lightweight handle through events
this.publish("game", "asset-uploaded", { handle: dataHandle });

// 3. Other users fetch data using the handle
const data = await session.data.fetch(dataHandle);

// 4. Use the data (create images, audio, etc.)
const blob = new Blob([data], { type: "image/jpeg" });
const imageUrl = URL.createObjectURL(blob);

Benefits: Efficient network usage, automatic caching, end-to-end encryption, and cleaner model state.

Basic File Upload and Sharing

Image sharing application

class ImageSharingModel extends Multisynq.Model {
    init(options, persisted) {
        this.images = new Map();
        this.nextImageId = 1;
        
        // Restore from persistence
        if (persisted) {
            this.restoreFromPersistence(persisted);
        }
        
        this.subscribe("images", "upload", this.handleImageUpload);
        this.subscribe("images", "delete", this.handleImageDelete);
    }
    
    handleImageUpload(data) {
        const imageId = this.nextImageId++;
        
        const imageInfo = {
            id: imageId,
            name: data.fileName,
            type: data.fileType,
            size: data.fileSize,
            handle: data.handle,
            uploader: data.userId,
            uploadTime: this.now(),
            tags: data.tags || [],
            description: data.description || ""
        };
        
        this.images.set(imageId, imageInfo);
        
        // Notify all users
        this.publish("images", "image-added", imageInfo);
        
        // Persist the session data
        this.persistSession(this.saveForPersistence);
    }
    
    handleImageDelete(data) {
        const image = this.images.get(data.imageId);
        
        // Only uploader can delete
        if (image && image.uploader === data.userId) {
            this.images.delete(data.imageId);
            
            this.publish("images", "image-deleted", {
                imageId: data.imageId,
                fileName: image.name
            });
            
            this.persistSession(this.saveForPersistence);
        }
    }
    
    saveForPersistence() {
        // Convert handles to persistable IDs
        const imageData = {};
        
        for (const [id, image] of this.images) {
            imageData[id] = {
                ...image,
                handleId: Multisynq.Data.toId(image.handle) // Convert to string
            };
            delete imageData[id].handle; // Remove non-serializable handle
        }
        
        return {
            images: imageData,
            nextImageId: this.nextImageId
        };
    }
    
    restoreFromPersistence(saved) {
        this.nextImageId = saved.nextImageId || 1;
        
        // Restore images and recreate handles
        for (const [id, imageData] of Object.entries(saved.images || {})) {
            const image = {
                ...imageData,
                handle: Multisynq.Data.fromId(imageData.handleId) // Recreate handle
            };
            delete imageData.handleId; // Remove the ID string
            
            this.images.set(parseInt(id), image);
        }
    }
}

ImageSharingModel.register("ImageSharingModel");

class ImageSharingView extends Multisynq.View {
    init() {
        this.model = this.wellKnownModel("ImageSharingModel");
        this.userId = this.viewId;
        this.imageElements = new Map();
        
        this.setupUI();
        this.setupFileHandlers();
        this.displayExistingImages();
        
        // Subscribe to events
        this.subscribe("images", "image-added", this.onImageAdded);
        this.subscribe("images", "image-deleted", this.onImageDeleted);
    }
    
    setupUI() {
        document.body.innerHTML = `
            <div id="app">
                <header>
                    <h1>Image Sharing Gallery</h1>
                    <div class="upload-area" id="upload-area">
                        <p>Drop images here or click to upload</p>
                        <input type="file" id="file-input" accept="image/*" multiple style="display: none;">
                    </div>
                </header>
                <main>
                    <div id="image-gallery" class="gallery"></div>
                </main>
                <div id="status" class="status"></div>
            </div>
        `;
        
        // Style the application
        const style = document.createElement('style');
        style.textContent = `
            * { box-sizing: border-box; margin: 0; padding: 0; }
            body { font-family: Arial, sans-serif; background: #f5f5f5; }
            #app { max-width: 1200px; margin: 0 auto; padding: 20px; }
            header { margin-bottom: 30px; }
            h1 { color: #333; margin-bottom: 20px; }
            .upload-area {
                border: 3px dashed #ccc; border-radius: 10px; padding: 40px;
                text-align: center; cursor: pointer; transition: all 0.3s;
                background: white;
            }
            .upload-area:hover { border-color: #007bff; background: #f8f9fa; }
            .upload-area.dragover { border-color: #007bff; background: #e7f3ff; }
            .gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px; }
            .image-card {
                background: white; border-radius: 10px; overflow: hidden;
                box-shadow: 0 2px 10px rgba(0,0,0,0.1); transition: transform 0.2s;
            }
            .image-card:hover { transform: translateY(-5px); }
            .image-container { width: 100%; height: 200px; overflow: hidden; }
            .image-container img { width: 100%; height: 100%; object-fit: cover; }
            .image-info { padding: 15px; }
            .image-name { font-weight: bold; margin-bottom: 5px; }
            .image-meta { color: #666; font-size: 12px; margin-bottom: 10px; }
            .image-actions { display: flex; gap: 10px; }
            .btn { padding: 8px 16px; border: none; border-radius: 5px; cursor: pointer; }
            .btn-delete { background: #dc3545; color: white; }
            .btn-download { background: #007bff; color: white; }
            .status { position: fixed; bottom: 20px; right: 20px; padding: 10px 20px; 
                     background: #333; color: white; border-radius: 5px; display: none; }
        `;
        document.head.appendChild(style);
    }
    
    setupFileHandlers() {
        const uploadArea = document.getElementById('upload-area');
        const fileInput = document.getElementById('file-input');
        
        // Click to upload
        uploadArea.addEventListener('click', () => fileInput.click());
        
        // File input change
        fileInput.addEventListener('change', (e) => {
            this.handleFiles(Array.from(e.target.files));
            fileInput.value = ''; // Reset for repeated uploads
        });
        
        // Drag and drop
        uploadArea.addEventListener('dragover', (e) => {
            e.preventDefault();
            uploadArea.classList.add('dragover');
        });
        
        uploadArea.addEventListener('dragleave', () => {
            uploadArea.classList.remove('dragover');
        });
        
        uploadArea.addEventListener('drop', (e) => {
            e.preventDefault();
            uploadArea.classList.remove('dragover');
            
            const files = Array.from(e.dataTransfer.files).filter(file => 
                file.type.startsWith('image/')
            );
            
            if (files.length > 0) {
                this.handleFiles(files);
            } else {
                this.showStatus('Please drop image files only', 'error');
            }
        });
    }
    
    async handleFiles(files) {
        for (const file of files) {
            await this.uploadFile(file);
        }
    }
    
    async uploadFile(file) {
        try {
            this.showStatus(`Reading ${file.name}...`);
            
            // Read file data
            const arrayBuffer = await this.readFileAsArrayBuffer(file);
            
            this.showStatus(`Uploading ${file.name} (${this.formatFileSize(arrayBuffer.byteLength)})...`);
            
            // Store data and get handle
            const handle = await this.session.data.store(arrayBuffer);
            
            // Publish upload event
            this.publish("images", "upload", {
                fileName: file.name,
                fileType: file.type,
                fileSize: arrayBuffer.byteLength,
                handle: handle,
                userId: this.userId,
                tags: [],
                description: ""
            });
            
            this.showStatus(`${file.name} uploaded successfully!`, 'success');
            
        } catch (error) {
            console.error('Upload failed:', error);
            this.showStatus(`Failed to upload ${file.name}: ${error.message}`, 'error');
        }
    }
    
    readFileAsArrayBuffer(file) {
        return new Promise((resolve, reject) => {
            const reader = new FileReader();
            reader.onload = () => resolve(reader.result);
            reader.onerror = () => reject(new Error('Failed to read file'));
            reader.readAsArrayBuffer(file);
        });
    }
    
    displayExistingImages() {
        // Display images that are already in the model
        for (const [id, image] of this.model.images) {
            this.onImageAdded(image);
        }
    }
    
    async onImageAdded(imageInfo) {
        try {
            this.showStatus(`Loading ${imageInfo.name}...`);
            
            // Fetch the image data
            const data = await this.session.data.fetch(imageInfo.handle);
            
            // Create blob and image URL
            const blob = new Blob([data], { type: imageInfo.type });
            const imageUrl = URL.createObjectURL(blob);
            
            // Create image card
            this.createImageCard(imageInfo, imageUrl);
            
            this.showStatus(`${imageInfo.name} loaded`, 'success');
            
        } catch (error) {
            console.error('Failed to load image:', error);
            this.showStatus(`Failed to load ${imageInfo.name}`, 'error');
        }
    }
    
    createImageCard(imageInfo, imageUrl) {
        const gallery = document.getElementById('image-gallery');
        
        const card = document.createElement('div');
        card.className = 'image-card';
        card.id = `image-${imageInfo.id}`;
        
        const uploadDate = new Date(imageInfo.uploadTime).toLocaleDateString();
        const isOwner = imageInfo.uploader === this.userId;
        
        card.innerHTML = `
            <div class="image-container">
                <img src="${imageUrl}" alt="${imageInfo.name}" loading="lazy">
            </div>
            <div class="image-info">
                <div class="image-name">${imageInfo.name}</div>
                <div class="image-meta">
                    ${this.formatFileSize(imageInfo.size)}${uploadDate}
                    ${imageInfo.description ? `<br>${imageInfo.description}` : ''}
                </div>
                <div class="image-actions">
                    <button class="btn btn-download" onclick="this.saveImage('${imageUrl}', '${imageInfo.name}')">
                        Download
                    </button>
                    ${isOwner ? `
                        <button class="btn btn-delete" onclick="this.deleteImage(${imageInfo.id})">
                            Delete
                        </button>
                    ` : ''}
                </div>
            </div>
        `;
        
        gallery.appendChild(card);
        this.imageElements.set(imageInfo.id, { card, url: imageUrl });
    }
    
    onImageDeleted(data) {
        const element = this.imageElements.get(data.imageId);
        if (element) {
            // Clean up blob URL
            URL.revokeObjectURL(element.url);
            
            // Remove from DOM
            element.card.remove();
            
            // Remove from tracking
            this.imageElements.delete(data.imageId);
            
            this.showStatus(`${data.fileName} deleted`, 'info');
        }
    }
    
    deleteImage(imageId) {
        if (confirm('Are you sure you want to delete this image?')) {
            this.publish("images", "delete", {
                imageId: imageId,
                userId: this.userId
            });
        }
    }
    
    saveImage(url, filename) {
        const a = document.createElement('a');
        a.href = url;
        a.download = filename;
        document.body.appendChild(a);
        a.click();
        document.body.removeChild(a);
    }
    
    formatFileSize(bytes) {
        if (bytes === 0) return '0 Bytes';
        const k = 1024;
        const sizes = ['Bytes', 'KB', 'MB', 'GB'];
        const i = Math.floor(Math.log(bytes) / Math.log(k));
        return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
    }
    
    showStatus(message, type = 'info') {
        const status = document.getElementById('status');
        status.textContent = message;
        status.className = `status ${type}`;
        status.style.display = 'block';
        
        // Auto-hide after 3 seconds
        setTimeout(() => {
            status.style.display = 'none';
        }, 3000);
        
        console.log(`[${type.toUpperCase()}] ${message}`);
    }
}

ImageSharingView.register("ImageSharingView");

// Launch the application
document.addEventListener('DOMContentLoaded', () => {
    Multisynq.Session.join({
        apiKey: "your_api_key_here",  // Get from multisynq.io/coder
        appId: "com.example.image-sharing",
        name: Multisynq.App.autoSession(),
        password: Multisynq.App.autoPassword(),
        model: ImageSharingModel,
        view: ImageSharingView,
        tps: 0 // No regular model updates needed
    });
});

Persistence Integration

Data handles can be persisted and restored across sessions using Data.toId() and Data.fromId().

Converting handles for storage

class PersistentDataModel extends Multisynq.Model {
    init(options, persisted) {
        this.userDocuments = new Map();
        this.sharedAssets = new Map();
        
        // Restore from persistence
        if (persisted) {
            this.restoreFromPersistence(persisted);
        }
        
        this.subscribe("docs", "save", this.saveDocument);
        this.subscribe("assets", "share", this.shareAsset);
    }
    
    saveDocument(data) {
        const docInfo = {
            id: data.docId,
            name: data.fileName,
            type: data.fileType,
            size: data.fileSize,
            handle: data.handle,
            owner: data.userId,
            lastModified: this.now(),
            version: 1
        };
        
        this.userDocuments.set(data.docId, docInfo);
        
        // Automatically persist when documents are saved
        this.persistSession(this.saveForPersistence);
        
        this.publish("docs", "document-saved", docInfo);
    }
    
    shareAsset(data) {
        // Create shareable handle for cross-session sharing
        const asset = this.userDocuments.get(data.docId);
        if (asset && asset.owner === data.userId) {
            
            this.sharedAssets.set(data.shareId, {
                shareId: data.shareId,
                docId: data.docId,
                name: asset.name,
                handle: asset.handle,
                sharedBy: data.userId,
                shareTime: this.now(),
                permissions: data.permissions || ["read"]
            });
            
            this.persistSession(this.saveForPersistence);
            
            this.publish("assets", "asset-shared", {
                shareId: data.shareId,
                name: asset.name
            });
        }
    }
    
    saveForPersistence() {
        // Convert handles to persistable string IDs
        const persistableData = {
            documents: {},
            sharedAssets: {}
        };
        
        // Save documents
        for (const [id, doc] of this.userDocuments) {
            persistableData.documents[id] = {
                ...doc,
                handleId: Multisynq.Data.toId(doc.handle) // Convert to string
            };
            delete persistableData.documents[id].handle;
        }
        
        // Save shared assets
        for (const [id, asset] of this.sharedAssets) {
            persistableData.sharedAssets[id] = {
                ...asset,
                handleId: Multisynq.Data.toId(asset.handle)
            };
            delete persistableData.sharedAssets[id].handle;
        }
        
        return persistableData;
    }
    
    restoreFromPersistence(saved) {
        // Restore documents
        for (const [id, docData] of Object.entries(saved.documents || {})) {
            const doc = {
                ...docData,
                handle: Multisynq.Data.fromId(docData.handleId) // Recreate handle
            };
            delete doc.handleId;
            
            this.userDocuments.set(id, doc);
        }
        
        // Restore shared assets
        for (const [id, assetData] of Object.entries(saved.sharedAssets || {})) {
            const asset = {
                ...assetData,
                handle: Multisynq.Data.fromId(assetData.handleId)
            };
            delete asset.handleId;
            
            this.sharedAssets.set(id, asset);
        }
    }
}

PersistentDataModel.register("PersistentDataModel");

Best Practices

🏗️ Model-View Separation

Keep data operations in the view

// ✅ Good - Views handle data operations
class GameView extends Multisynq.View {
    async uploadAsset(file) {
        // View handles file I/O
        const data = await this.readFile(file);
        const handle = await this.session.data.store(data);
        
        // Notify model with handle
        this.publish("assets", "uploaded", { handle });
    }
}

class GameModel extends Multisynq.Model {
    handleAssetUpload(data) {
        // Model stores handle metadata
        this.assets.push({
            id: this.nextId++,
            handle: data.handle,
            uploadTime: this.now()
        });
    }
}

🔒 Security Considerations

Handle data securely

// ✅ Validate and sanitize
class SecureDataView extends Multisynq.View {
    async uploadFile(file) {
        // Validate file type
        if (!this.isAllowedType(file.type)) {
            throw new Error('File type not allowed');
        }
        
        // Check file size
        if (file.size > 10 * 1024 * 1024) { // 10MB limit
            throw new Error('File too large');
        }
        
        // Sanitize filename
        const safeName = this.sanitizeFilename(file.name);
        
        const data = await this.readFileAsArrayBuffer(file);
        const handle = await this.session.data.store(data);
        
        this.publish("files", "upload", {
            handle,
            fileName: safeName,
            fileSize: data.byteLength
        });
    }
    
    isAllowedType(mimeType) {
        const allowed = [
            'image/jpeg', 'image/png', 'image/gif',
            'audio/mpeg', 'audio/wav',
            'text/plain', 'application/pdf'
        ];
        return allowed.includes(mimeType);
    }
    
    sanitizeFilename(filename) {
        return filename
            .replace(/[^a-zA-Z0-9.-]/g, '_')
            .substring(0, 255);
    }
}

⚡ Performance Optimization

Optimize data handling

// ✅ Efficient data management
class OptimizedDataView extends Multisynq.View {
    init() {
        this.imageCache = new Map();
        this.preloadQueue = [];
        
        // Pre-load critical assets
        this.preloadCriticalAssets();
    }
    
    async loadImage(imageHandle, priority = 'normal') {
        // Check cache first
        if (this.imageCache.has(imageHandle)) {
            return this.imageCache.get(imageHandle);
        }
        
        // Queue for background loading
        if (priority === 'low') {
            this.preloadQueue.push(imageHandle);
            this.processPreloadQueue();
            return null;
        }
        
        // Load immediately
        const data = await this.session.data.fetch(imageHandle);
        const blob = new Blob([data]);
        const url = URL.createObjectURL(blob);
        
        // Cache the result
        this.imageCache.set(imageHandle, url);
        
        return url;
    }
    
    async processPreloadQueue() {
        if (this.preloadQueue.length === 0) return;
        
        // Process one item every 100ms to avoid blocking
        const handle = this.preloadQueue.shift();
        await this.loadImage(handle, 'normal');
        
        setTimeout(() => this.processPreloadQueue(), 100);
    }
    
    // Clean up when view is destroyed
    destroy() {
        // Revoke all blob URLs to free memory
        for (const url of this.imageCache.values()) {
            URL.revokeObjectURL(url);
        }
        this.imageCache.clear();
    }
}

📱 Offline Handling

Handle network issues gracefully

// ✅ Robust offline support
class OfflineDataView extends Multisynq.View {
    init() {
        this.uploadQueue = [];
        this.isOnline = navigator.onLine;
        
        window.addEventListener('online', () => {
            this.isOnline = true;
            this.processUploadQueue();
        });
        
        window.addEventListener('offline', () => {
            this.isOnline = false;
        });
    }
    
    async uploadFile(file) {
        const fileData = await this.readFileAsArrayBuffer(file);
        
        if (this.isOnline && this.session) {
            return this.doUpload(file, fileData);
        } else {
            // Queue for later upload
            this.uploadQueue.push({ file, fileData });
            this.showMessage('Queued for upload when online');
        }
    }
    
    async processUploadQueue() {
        while (this.uploadQueue.length > 0 && this.isOnline && this.session) {
            const { file, fileData } = this.uploadQueue.shift();
            
            try {
                await this.doUpload(file, fileData);
                this.showMessage(`Uploaded ${file.name}`);
            } catch (error) {
                // Re-queue on failure
                this.uploadQueue.unshift({ file, fileData });
                console.error('Upload failed, re-queued:', error);
                break;
            }
        }
    }
    
    async doUpload(file, fileData) {
        const handle = await this.session.data.store(fileData);
        
        this.publish("files", "upload", {
            handle,
            fileName: file.name,
            fileType: file.type,
            fileSize: fileData.byteLength
        });
        
        return handle;
    }
}

Common Patterns

API Reference

Next Steps

The Data API enables powerful file sharing capabilities while maintaining security and performance. Use it for media content, documents, and any large binary data that would be inefficient to send through the event system. Remember to handle data operations in views and store only lightweight handles in your models.