Home Doh Ref
Dohballs
  • 📁 doh_chat
  • 📁 doh_modules
    • 📦 dataforge
    • 📦 express
    • 📁 sso
    • 📁 user

Remote State: Real-Time Synchronized State

Remote State provides real-time state synchronization between server and all connected clients. The server is authoritative, and changes propagate automatically via Socket.IO.

What It Does

Remote State enables shared state across all clients:

  • Server holds authoritative state (in-memory + optional persistence)
  • Clients subscribe to state paths and receive initial + ongoing updates
  • Client changes sync to server, then broadcast to all other clients
  • Permission-based access control with sub-path patterns
  • Lock system for structural protection of synchronized values

Architecture

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:

  1. remote_state (server) - manages store, permissions, locks, persistence
  2. use_remote (client) - subscribes, syncs local state, receives updates
  3. Changes are batched (16ms debounce) for performance
  4. _isFromRemote flag prevents sync loops
  5. Locks provide structural protection, enforced on both sides

Server-Side: remote_state Module

Registration

Doh.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);
  }
});

State Management API

// 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');

Lock API (Server)

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', ... }
  // }
});

Lock Modes

Soft Lock (default):

  • Blocks deletion of the locked path
  • Blocks type changes (object → string, etc.)
  • Blocks primitive value changes (strings, numbers, booleans)
  • Allows object/array assignment (syncs via stable proxy references)
  • Allows modifying existing nested properties

Hard Lock:

  • All soft lock protections
  • Additionally blocks adding new properties to the locked object

Permission Configuration

// 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

Client-Side: use_remote Module

Subscription

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');
});

Subscription Options

// 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);

Lock Awareness API (Client)

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();
});

Handling Permission Denials

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
    });
  }
});

Lock Synchronization Flow

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.

Persistence

// 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)

Complete Example

// ═══════════════════════════════════════════════════════════════════════════
// 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?.();
    }
  });
});

Best Practices

Server-Side

  • Register paths at module load time, before clients connect
  • Use persist: true for data that should survive restarts
  • Lock system-critical fields (IDs, timestamps, ownership)
  • Use permission strings for simple cases, functions for complex logic

Client-Side

  • Subscribe early in your module initialization
  • Clean up subscriptions when components are destroyed
  • Use observe_remote_locks to update UI when locks change
  • Check is_remote_locked before attempting edits that might fail

Locks

  • Use soft locks for values that shouldn't change but can be replaced wholesale
  • Use hard locks for objects where structure must be frozen
  • Lock system fields (IDs, creation timestamps) to prevent corruption
  • Provide lockedBy for user-facing lock status messages

Performance

  • Changes are batched (16ms debounce) automatically
  • Subscribe only to paths you need
  • Use sub-path permissions rather than per-item paths when possible

Troubleshooting

State not syncing:

  • Verify socket connection: Doh.socket.connected
  • Check permissions match between server registration and client config
  • Look for rejection errors in _remote_rejection_buffer

Locks not working:

  • Verify path matches exactly (case-sensitive)
  • Check is_remote_locked() on client
  • Server locks are memory-only - cleared on restart

Permission denied:

  • Check user has required permissions via Doh.permit()
  • Verify permission strings match between register and user grants
  • Sub-path patterns must match the exact path being accessed

API Reference

Server (remote_state)

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

Client (Doh.*)

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
Last updated: 2/10/2026