Remote State provides real-time state synchronization between server and all connected clients. The server is authoritative, and changes propagate automatically via Socket.IO.
Remote State enables shared state across all clients:
Server Clients
┌────────────────────┐ ┌─────────────────┐
│ remoteStore │ │ use_state() │
│ (authoritative) │◄────────►│ (local copy) │
│ │ │ │
│ ┌──────────────┐ │ Socket │ ┌───────────┐ │
│ │ remoteLocks │ │◄────────►│ │_remote_ │ │
│ │ (protection) │ │ IO │ │ locks │ │
│ └──────────────┘ │ │ └───────────┘ │
└────────────────────┘ └─────────────────┘
Flow:
1. Server registers path with config (permissions, persistence)
2. Client calls use_remote(path) - subscribes via socket
3. Server sends current state + locks to client
4. Client changes → batched → sent to server → validated → broadcast
5. Other clients receive updates → apply to local state
Key Points:
remote_state (server) - manages store, permissions, locks, persistenceuse_remote (client) - subscribes, syncs local state, receives updates_isFromRemote flag prevents sync loopsDoh.Module('my_routes', ['express_router', 'remote_state'],
async function(Router, remote_state) {
// Register a path with configuration
await remote_state.register('my_data', {
persist: true, // Save to Dataforge database
read: 'read:my_data', // Permission string for reading
write: 'update:my_data', // Permission string for writing
key_visible: 'view:my_data' // Permission to see keys (optional)
});
// Load initial data
const existing = remote_state.get('my_data');
if (!existing) {
await remote_state.set('my_data', defaultData);
}
});
// Set state (bypasses permissions, broadcasts to all clients)
await remote_state.set('my_data', value);
await remote_state.set('my_data.nested.path', nestedValue);
// Get current state
const value = remote_state.get('my_data');
const nested = remote_state.get('my_data.nested');
// Delete state
await remote_state.delete('my_data');
// Get all registered paths
const paths = remote_state.paths(); // ['my_data', 'other_data', ...]
// Get subscriber count
const count = remote_state.subscriberCount('my_data');
Locks provide structural protection. Server locks automatically broadcast to all subscribed clients.
Doh.Module('my_routes', ['remote_state'], function(remote_state) {
// ═══════════════════════════════════════════════════════════════════════
// BASIC LOCKING
// ═══════════════════════════════════════════════════════════════════════
// Lock a path (returns this for chaining)
// mode: 'soft' (default) or 'hard'
// lockedBy: identifier string (default: 'system')
remote_state.lock('my_data.critical', 'soft', 'admin');
// Hard lock - also blocks adding new properties
remote_state.lock('my_data.config', 'hard', 'system');
// Unlock a path (returns true if was locked)
const wasLocked = remote_state.unlock('my_data.critical');
// ═══════════════════════════════════════════════════════════════════════
// PROPERTY LOCKING
// ═══════════════════════════════════════════════════════════════════════
// Lock all existing properties + auto-lock any new ones
remote_state.lockProperties('my_data.settings', 'soft', 'admin');
// Unlock all properties + disable auto-lock
remote_state.unlockProperties('my_data.settings');
// ═══════════════════════════════════════════════════════════════════════
// LOCK INSPECTION
// ═══════════════════════════════════════════════════════════════════════
// Check if a specific path is locked
const lockInfo = remote_state.isLocked('my_data.critical');
// Returns: { mode: 'soft', lockedBy: 'admin', timestamp: 1234567890 }
// Or: null if not locked
// Get all locks under a path
const locks = remote_state.getLocks('my_data');
// Returns: {
// 'my_data.critical': { mode: 'soft', lockedBy: 'admin', ... },
// 'my_data.config': { mode: 'hard', lockedBy: 'system', ... }
// }
});
Soft Lock (default):
Hard Lock:
// Simple string permissions (checked via Doh.permit)
await remote_state.register('data', {
read: 'read:data',
write: 'write:data'
});
// Function permissions
await remote_state.register('data', {
read: (user, context) => user?.role === 'admin',
write: (user, context) => user?.id === context.ownerId
});
// Sub-path patterns
await remote_state.register('users', {
read: {
'users.*.profile': true, // Public
'users.*.private': (user, ctx) => ctx.path.includes(user?.id) // Own only
},
write: {
'users.*': (user, ctx) => ctx.path.includes(user?.id) // Own only
}
});
// Pre-defined permission groups
// remote_state_viewer - read + key_visible
// remote_state_editor - inherits viewer + write
// remote_state_admin - all permissions
Doh.Module('my_ui', ['use_remote'], function() {
const state = Doh.use_state();
// Subscribe to remote state (returns cleanup function)
const cleanup = Doh.use_remote('my_data');
// State automatically syncs
console.log(state.my_data); // Current value from server
// Changes sync automatically
state.my_data.field = 'new value'; // Sends to server → broadcasts to all
// Unsubscribe when done
cleanup();
// Or: Doh.use_remove_remote('my_data');
});
// Multiple paths
Doh.use_remote(['todos', 'users', 'settings']);
// With configuration (must match server registration)
Doh.use_remote('admin_data', {
persist: true,
read: 'admin:view',
write: 'admin:edit'
});
// Array of paths
const paths = ['path1', 'path2'];
Doh.use_remote(paths);
Clients automatically receive server locks and apply them locally via Doh.use_lock(). The client can query lock status:
Doh.Module('my_ui', ['use_remote'], function() {
// Check if a path is locked by the server
const lockInfo = Doh.is_remote_locked('my_data.critical');
// Returns: { mode: 'soft', lockedBy: 'admin' }
// Or: null if not locked by server
// Get all server locks under a path
const locks = Doh.get_remote_locks('my_data');
// Returns: { 'my_data.field': { mode, lockedBy }, ... }
// Observe lock changes (for UI updates)
const cleanup = Doh.observe_remote_locks('my_data', ({ path, isLocked, mode, lockedBy }) => {
if (isLocked) {
disableEditButton(path);
showLockIcon(path, lockedBy);
} else {
enableEditButton(path);
hideLockIcon(path);
}
});
// Stop observing
cleanup();
});
Doh.use_remote('restricted_data');
// Listen for permission errors
Doh.observe_use('_remote_rejection_buffer', ({ newValue }) => {
if (newValue?.length) {
newValue.forEach(rejection => {
console.warn(`Update rejected: ${rejection.path} - ${rejection.error}`);
// Optionally show toast or revert UI
});
}
});
Server Client
│ │
│ remote_state.lock('path', 'soft') │
│ │ │
│ ▼ │
│ Store in remoteLocks │
│ │ │
│ ▼ │
│ io.emit('remote_state:lock') ────────►│
│ │ _handleRemoteLock()
│ │ │
│ │ ▼
│ │ Store in _remote_locks
│ │ │
│ │ ▼
│ │ Doh.use_lock(path, mode)
│ │ │
│ │ ▼
│ │ Notify observers
│ │
│ ◄──── Client attempts edit │
│ (blocked locally by use_lock) │
│ │
│ OR if local lock bypassed: │
│ ◄──── remote_state:update_batch │
│ │ │
│ ▼ │
│ checkLockForUpdate() → blocked │
│ │ │
│ ▼ │
│ Return error in results ──────────────►│
On reconnection, the subscribe response includes all current locks, which are automatically reapplied.
// Register with persistence
await remote_state.register('my_data', {
persist: true // Uses Dataforge/SQLite
});
// Data stored in: remote_state.state table
// Format: { id: path, payload: value, updated: timestamp }
// State survives server restarts
// Locks are NOT persisted (memory-only, cleared on restart)
// ═══════════════════════════════════════════════════════════════════════════
// SERVER: my_app_routes.doh.js
// ═══════════════════════════════════════════════════════════════════════════
Doh.Module('my_app_routes', ['express_router', 'remote_state'],
async function(Router, remote_state) {
// Register collaborative document state
await remote_state.register('documents', {
persist: true,
read: 'read:document',
write: 'write:document'
});
// Seed initial data if empty
if (!remote_state.get('documents')) {
await remote_state.set('documents', []);
}
// Lock system-critical fields
remote_state.lock('documents.0.id', 'soft', 'system');
remote_state.lock('documents.0.created_at', 'soft', 'system');
// API endpoint for admin lock control
Router.AddRoute('/api/documents/:id/lock', async function(data, req, res, callback) {
if (!await Doh.permit(req.user, 'admin:documents')) {
Router.SendJSON(res, { error: 'Forbidden' }, callback, 403);
return false;
}
const path = `documents.${req.params.id}`;
if (data.lock) {
remote_state.lock(path, data.mode || 'soft', req.user.username);
} else {
remote_state.unlock(path);
}
Router.SendJSON(res, { success: true }, callback);
return false;
});
});
// ═══════════════════════════════════════════════════════════════════════════
// CLIENT: my_app_ui.doh.js
// ═══════════════════════════════════════════════════════════════════════════
Doh.Module('my_app_ui', ['html', 'use_remote'], function() {
const state = Doh.use_state();
// Subscribe to documents
Doh.use_remote('documents');
Pattern('DocumentEditor', 'html', {
docId: null,
object_phase: function() {
// Watch for lock changes on this document
this._lockCleanup = Doh.observe_remote_locks(
`documents.${this.docId}`,
({ path, isLocked, lockedBy }) => {
this.updateLockUI(isLocked, lockedBy);
}
);
},
updateLockUI: function(isLocked, lockedBy) {
if (isLocked) {
this.editor.e.attr('disabled', true);
this.lockBanner.html = `Locked by ${lockedBy}`;
this.lockBanner.css.display = 'block';
} else {
this.editor.e.removeAttr('disabled');
this.lockBanner.css.display = 'none';
}
},
saveDocument: function() {
const docPath = `documents.${this.docId}`;
// Check if locked before attempting save
const lockInfo = Doh.is_remote_locked(docPath);
if (lockInfo) {
Doh.toast(`Cannot save: locked by ${lockInfo.lockedBy}`, 'warning');
return;
}
// Update state (syncs to server automatically)
state.documents[this.docId].content = this.editor.getValue();
state.documents[this.docId].updated_at = Date.now();
},
destroy: function() {
this._lockCleanup?.();
}
});
});
persist: true for data that should survive restartsobserve_remote_locks to update UI when locks changeis_remote_locked before attempting edits that might faillockedBy for user-facing lock status messagesState not syncing:
Doh.socket.connected_remote_rejection_bufferLocks not working:
is_remote_locked() on clientPermission denied:
Doh.permit()| Method | Description |
|---|---|
register(path, config) |
Register path with permissions and persistence |
get(path) |
Get current value |
set(path, value) |
Set value (bypasses permissions, broadcasts) |
delete(path) |
Delete value |
paths() |
Get all registered paths |
subscriberCount(path) |
Get number of subscribed clients |
lock(path, mode?, lockedBy?) |
Lock a path |
unlock(path) |
Unlock a path |
lockProperties(path, mode?, lockedBy?) |
Lock all properties + auto-lock |
unlockProperties(path) |
Unlock all properties |
isLocked(path) |
Check if path is locked |
getLocks(basePath) |
Get all locks under path |
| Method | Description |
|---|---|
use_remote(paths, config?) |
Subscribe to remote state |
use_remove_remote(paths) |
Unsubscribe from remote state |
is_remote_locked(path) |
Check if server-locked |
get_remote_locks(basePath) |
Get all server locks under path |
observe_remote_locks(path, callback) |
Watch for lock changes |