| title | description |
|---|---|
Persistence |
Learn how to save application data across code changes and versions using Multisynq's persistence mechanism |
Multisynq automatically handles session state through snapshots, but what happens when you update your code? This tutorial covers explicit persistence - how to save and restore application data across different versions of your application.
**Built-in session persistence**- Works automatically without any code
- Preserves state when users leave and return
- Lost when code changes (new session created)
- Perfect for temporary game sessions
// This data persists automatically between sessions
class GameModel extends Multisynq.Model {
init() {
this.players = new Map();
this.score = 0;
}
}- Requires explicit implementation
- Survives code changes and updates
- Selective - save only what matters
- Essential for collaborative applications
// This data can survive code changes
class CollaborativeEditor extends Multisynq.Model {
init(options, persisted) {
if (persisted) {
this.restoreFromPersisted(persisted);
} else {
this.documents = new Map();
this.users = new Map();
}
}
save() {
this.persistSession(() => ({
documents: Array.from(this.documents.entries()),
lastSaved: Date.now()
}));
}
}Understanding how Multisynq identifies sessions is crucial for persistence:
**Code-dependent session identifier**- Combination of
appId,name, and code hash - Changes with any code modification
- Used for snapshots and live sessions
// These create the same sessionId:
Session.join({ appId: "myapp", name: "room1" });
// With identical code
// This creates a different sessionId:
Session.join({ appId: "myapp", name: "room1" });
// With modified code (even tiny changes)- Combination of
appIdandnameonly - Survives code changes
- Used for persistence lookup
// These share the same persistentId:
Session.join({ appId: "myapp", name: "room1" });
// Regardless of code changes
// Different persistentId:
Session.join({ appId: "myapp", name: "room2" });- Multisynq looks up persistent data by
persistentId - If found, passes it to your model's
init()method - Your model restores state from persistent data
class MyModel extends Multisynq.Model {
init(options, persisted) {
if (persisted) {
console.log("Restoring from persistent data:", persisted);
this.restoreState(persisted);
} else {
console.log("Fresh session - no persistent data");
this.initializeDefaults();
}
}
}save() {
this.persistSession(() => {
return {
version: 1,
data: this.collectImportantData(),
timestamp: Date.now()
};
});
}class SimpleAppModel extends Multisynq.Model {
init(options, persisted) {
// Regular setup
this.users = new Map();
this.content = "";
this.lastModified = null;
if (persisted) {
// Restore from persistent data
this.content = persisted.content || "";
this.lastModified = persisted.lastModified;
// Restore users (handle Map serialization)
if (persisted.users) {
this.users = new Map(persisted.users);
}
}
}
save() {
this.persistSession(() => {
return {
content: this.content,
lastModified: this.lastModified,
users: Array.from(this.users.entries()) // Convert Map to Array
};
});
}
updateContent(newContent) {
this.content = newContent;
this.lastModified = Date.now();
// Save after important changes
this.save();
}
}class ComplexAppModel extends Multisynq.Model {
init(options, persisted) {
if (persisted) {
this.loadFromPersisted(persisted);
} else {
this.initializeDefaults();
}
}
initializeDefaults() {
this.documentA = DocumentModel.create();
this.documentB = DocumentModel.create();
this.metadata = { created: Date.now() };
}
loadFromPersisted(persisted) {
// Handle versioning
switch (persisted.version) {
case 1:
this.loadVersion1(persisted);
break;
case 2:
this.loadVersion2(persisted);
break;
default:
console.warn("Unknown version:", persisted.version);
this.initializeDefaults();
}
}
loadVersion1(data) {
// Restore from version 1 format
this.documentA = DocumentModel.create(data.documents.a);
this.documentB = DocumentModel.create(data.documents.b);
this.metadata = data.metadata || { created: Date.now() };
}
loadVersion2(data) {
// Handle newer format with migration
this.loadVersion1(data); // Reuse v1 loading
this.newFeature = data.newFeature || null;
}
save() {
this.persistSession(() => this.toSaveData());
}
toSaveData() {
return {
version: 2, // Current version
documents: {
a: this.documentA.toSaveData(),
b: this.documentB.toSaveData()
},
metadata: this.metadata,
newFeature: this.newFeature
};
}
}
class DocumentModel extends Multisynq.Model {
init(options, persisted) {
this.title = "";
this.content = "";
if (persisted) {
this.fromSaveData(persisted);
}
}
toSaveData() {
return {
title: this.title,
content: this.content
};
}
fromSaveData(data) {
this.title = data.title || "";
this.content = data.content || "";
}
save() {
// Trigger root model save
this.wellKnownModel("modelRoot").save();
}
}const safeData = {
strings: "text",
numbers: 42,
booleans: true,
arrays: [1, 2, 3],
objects: { nested: "value" },
nulls: null
};
this.persistSession(() => safeData);class MyModel extends Multisynq.Model {
init() {
this.userMap = new Map();
this.userSet = new Set();
this.createdAt = new Date();
}
save() {
this.persistSession(() => ({
// Convert Map to Array
users: Array.from(this.userMap.entries()),
// Convert Set to Array
tags: Array.from(this.userSet),
// Convert Date to timestamp
createdAt: this.createdAt.getTime(),
// Don't include functions or DOM elements
// functions: this.myFunction, // ❌ Won't work
// element: this.domElement, // ❌ Won't work
}));
}
restoreFromPersisted(data) {
// Restore Map from Array
this.userMap = new Map(data.users || []);
// Restore Set from Array
this.userSet = new Set(data.tags || []);
// Restore Date from timestamp
this.createdAt = new Date(data.createdAt || Date.now());
}
}- Collaborative editors: Documents must survive code updates
- Creative tools: User creations are valuable
- Configuration apps: Settings should persist
- Score tracking: High scores across game updates
- Chat applications: Message history preservation
// Example: Collaborative whiteboard
class WhiteboardModel extends Multisynq.Model {
init(options, persisted) {
this.shapes = persisted?.shapes || [];
this.users = new Map(persisted?.users || []);
}
addShape(shape) {
this.shapes.push(shape);
this.save(); // Save after important changes
}
save() {
this.persistSession(() => ({
shapes: this.shapes,
users: Array.from(this.users.entries())
}));
}
}- Simple games: No long-term state to preserve
- Temporary demonstrations: Short-lived sessions
- Real-time only: No data worth preserving
- Prototype applications: Data structure still changing
// Example: Simple multiplayer pong game
class PongModel extends Multisynq.Model {
init() {
// Game state that resets each session
this.ball = { x: 50, y: 50, vx: 1, vy: 1 };
this.players = new Map();
this.score = { left: 0, right: 0 };
// No persistence needed - games start fresh
}
}class DocumentEditor extends Multisynq.Model {
addDocument(doc) {
this.documents.set(doc.id, doc);
this.save(); // Major change - save immediately
}
deleteDocument(id) {
this.documents.delete(id);
this.save(); // Major change - save immediately
}
updateCursor(position) {
this.cursors.set(this.viewId, position);
// Minor change - don't save for every cursor move
}
}class TextEditor extends Multisynq.Model {
init() {
this.content = "";
this.saveTimer = null;
this.hasChanges = false;
}
updateText(newText) {
this.content = newText;
this.hasChanges = true;
// Reset save timer
if (this.saveTimer) {
this.saveTimer.cancel();
}
// Save 30 seconds after last change
this.saveTimer = this.future(30000).saveIfChanged();
}
saveIfChanged() {
if (this.hasChanges) {
this.save();
this.hasChanges = false;
}
this.saveTimer = null;
}
save() {
this.persistSession(() => ({
content: this.content,
lastSaved: Date.now()
}));
}
}class SafeModel extends Multisynq.Model {
init(options, persisted) {
if (persisted) {
delete this.loadingPersistentDataErrored;
this.loadingPersistentData = true;
try {
this.fromSavedData(persisted);
console.log("Successfully loaded persistent data");
} catch (error) {
console.error("Error loading persistent data:", error);
this.loadingPersistentDataErrored = true;
// Fallback to defaults
this.initializeDefaults();
} finally {
delete this.loadingPersistentData;
}
} else {
this.initializeDefaults();
}
}
save() {
// Don't save while loading (prevents corruption)
if (this.loadingPersistentData) return;
// Don't save if loading failed (prevents overwriting good data)
if (this.loadingPersistentDataErrored) return;
this.persistSession(() => this.toSaveData());
}
fromSavedData(persisted) {
// Validate data structure
if (!persisted.version) {
throw new Error("Missing version in persistent data");
}
if (persisted.version > 2) {
throw new Error(`Unsupported version: ${persisted.version}`);
}
// Restore data
this.documents = new Map(persisted.documents || []);
this.metadata = persisted.metadata || {};
}
toSaveData() {
return {
version: 2,
documents: Array.from(this.documents.entries()),
metadata: this.metadata
};
}
}-
Test saving logic:
// Set breakpoint in toSaveData() toSaveData() { const data = { version: 1, content: this.content }; console.log("Saving data:", data); // Check structure return data; }
-
Test loading logic:
// Modify code slightly to create new sessionId // Add console.log("Testing v1.1"); to trigger new session fromSavedData(persisted) { console.log("Loading data:", persisted); // Verify structure // Set breakpoint here to inspect data }
-
Use separate deployments:
- Production version: Create persistent data
- Development version: Test loading persistent data
- Prevents corrupting production data during development
class VersionedModel extends Multisynq.Model {
fromSavedData(persisted) {
switch (persisted.version) {
case 1:
this.loadVersion1(persisted);
break;
case 2:
this.loadVersion2(persisted);
break;
case 3:
this.loadVersion3(persisted);
break;
default:
if (persisted.version > 3) {
throw new Error(`Future version not supported: ${persisted.version}`);
} else {
// Legacy data without version
this.loadLegacy(persisted);
}
}
}
loadVersion1(data) {
// Original format
this.documents = new Map(data.documents || []);
}
loadVersion2(data) {
// Added user permissions
this.loadVersion1(data);
this.permissions = new Map(data.permissions || []);
}
loadVersion3(data) {
// Added collaboration features
this.loadVersion2(data);
this.collaborators = new Set(data.collaborators || []);
this.lastActivity = data.lastActivity || Date.now();
}
toSaveData() {
return {
version: 3, // Always save in latest format
documents: Array.from(this.documents.entries()),
permissions: Array.from(this.permissions.entries()),
collaborators: Array.from(this.collaborators),
lastActivity: this.lastActivity
};
}
}class MigrationModel extends Multisynq.Model {
fromSavedData(persisted) {
if (persisted.version === 1) {
// Major breaking change: documents now have structure
const oldDocuments = persisted.documents || [];
this.documents = new Map();
// Migrate old format to new format
oldDocuments.forEach((oldDoc, index) => {
const newDoc = {
id: `doc-${index}`,
title: oldDoc.name || "Untitled",
content: oldDoc.text || "",
created: oldDoc.timestamp || Date.now(),
modified: oldDoc.timestamp || Date.now()
};
this.documents.set(newDoc.id, newDoc);
});
console.log(`Migrated ${oldDocuments.length} documents from v1 to v2`);
} else {
// Handle current versions normally
this.documents = new Map(persisted.documents || []);
}
}
}// In Session.join()
Session.join({
appId: "myapp",
name: "session1",
model: MyModel,
view: MyView,
debug: "session" // Enables persistence logging
});
// Or via URL parameter
// https://myapp.com/#session1&debug=sessionConsole output:
[Multisynq] Session loaded from persistent data (persistentId: myapp-session1)
[Multisynq] Restoring 1.2KB of persistent data
[Multisynq] Persistent data saved (2.3KB)
class DebugModel extends Multisynq.Model {
save() {
const data = this.toSaveData();
// Log before saving
console.log("About to save:", JSON.stringify(data, null, 2));
console.log("Data size:", JSON.stringify(data).length, "characters");
this.persistSession(() => data);
}
fromSavedData(persisted) {
console.log("Loading persistent data:", persisted);
console.log("Data keys:", Object.keys(persisted));
// Validate structure
this.validatePersistentData(persisted);
}
validatePersistentData(data) {
const required = ['version', 'documents'];
const missing = required.filter(key => !(key in data));
if (missing.length > 0) {
console.warn("Missing required keys:", missing);
}
console.log("Validation passed ✓");
}
}- All persistent data is encrypted
- Only clients with session password can decrypt
- Server cannot read your data
- Suitable for sensitive information
// This data is automatically encrypted
this.persistSession(() => ({
confidentialDocument: "sensitive content",
userSecrets: privateData
}));- Lost password = lost data (unrecoverable)
- Consider password storage strategy
- Balance security vs. convenience
// Option 1: User-managed passwords (most secure)
Session.join({
name: userProvidedSessionName,
password: userProvidedPassword // User remembers
});
// Option 2: Server-stored passwords (convenient but less secure)
const sessionInfo = await getSessionFromServer(userId);
Session.join({
name: sessionInfo.name,
password: sessionInfo.password // Server provides
});- Plan persistence from the start
- Can't add persistence to existing sessions
- Consider what data needs to survive updates
- Design for data format evolution
// ✅ Good: Plan persistence early
class MyModel extends Multisynq.Model {
init(options, persisted) {
// Handle both cases from the start
if (persisted) {
this.restoreData(persisted);
} else {
this.initDefaults();
}
}
}- Save after major changes only
- Use timers for burst activity
- Keep persistent data minimal
- Clean up unnecessary data
// ✅ Good: Strategic saving
save() {
this.persistSession(() => ({
// Only essential data
documents: this.getEssentialDocs(),
metadata: this.coreMetadata,
// Skip: temporary UI state, caches, etc.
}));
}- Validate persistent data structure
- Handle version mismatches
- Don't save during loading
- Test with corrupted data
// ✅ Good: Safe error handling
try {
this.fromSavedData(persisted);
} catch (error) {
console.error("Load failed:", error);
this.initDefaults(); // Fallback
this.loadingErrored = true;
}- Test fresh sessions
- Test loading from persistence
- Test data migrations
- Use separate deployments for testing
// Test both paths
console.log(persisted ? "Loading saved data" : "Fresh start"); this.setupAutosave();
}
setupAutosave() {
if (this.settings.autosave) {
this.future(30000).autosave();
}
}
autosave() {
if (this.hasUnsavedChanges) {
this.save();
this.hasUnsavedChanges = false;
}
this.setupAutosave(); // Schedule next autosave
}
createDocument(title, content) {
const doc = {
id: this.generateId(),
title,
content,
created: Date.now(),
modified: Date.now()
};
this.documents.set(doc.id, doc);
this.hasUnsavedChanges = true;
this.save(); // Save immediately for major changes
}
updateDocument(id, changes) {
const doc = this.documents.get(id);
if (doc) {
Object.assign(doc, changes, { modified: Date.now() });
this.hasUnsavedChanges = true;
// Don't save immediately - let autosave handle it
}
}
save() {
this.persistSession(() => ({
version: 1,
documents: Array.from(this.documents.entries()),
users: Array.from(this.users.entries()),
settings: this.settings
}));
}
}
</Accordion>
<Accordion title="🎨 Creative Canvas" icon="palette">
```js
class CanvasModel extends Multisynq.Model {
init(options, persisted) {
this.loadingPersistentData = !!persisted;
try {
if (persisted) {
this.fromSavedData(persisted);
} else {
this.shapes = [];
this.layers = [{ id: 'default', name: 'Layer 1', visible: true }];
this.canvasSize = { width: 800, height: 600 };
}
} catch (error) {
console.error("Failed to load canvas data:", error);
this.shapes = [];
this.layers = [{ id: 'default', name: 'Layer 1', visible: true }];
this.canvasSize = { width: 800, height: 600 };
this.loadingErrored = true;
} finally {
this.loadingPersistentData = false;
}
}
addShape(shape) {
shape.id = this.generateId();
shape.created = Date.now();
this.shapes.push(shape);
this.save(); // Save after adding shapes
}
deleteShape(shapeId) {
const index = this.shapes.findIndex(s => s.id === shapeId);
if (index >= 0) {
this.shapes.splice(index, 1);
this.save(); // Save after deleting shapes
}
}
save() {
if (this.loadingPersistentData || this.loadingErrored) return;
this.persistSession(() => ({
version: 1,
shapes: this.shapes,
layers: this.layers,
canvasSize: this.canvasSize,
metadata: {
shapeCount: this.shapes.length,
lastModified: Date.now()
}
}));
}
fromSavedData(persisted) {
if (persisted.version !== 1) {
throw new Error(`Unsupported version: ${persisted.version}`);
}
this.shapes = persisted.shapes || [];
this.layers = persisted.layers || [];
this.canvasSize = persisted.canvasSize || { width: 800, height: 600 };
console.log(`Restored ${this.shapes.length} shapes`);
}
}