dzql 0.5.6 → 0.5.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,242 @@
1
+ # Atomic Updates for Subscribables
2
+
3
+ Atomic updates enable efficient real-time synchronization by sending only the changes (insert/update/delete) to subscribed clients, instead of re-querying and sending the entire document on every change.
4
+
5
+ ## Overview
6
+
7
+ ### Problem Solved
8
+
9
+ Previously, when any data changed that affected a subscription, the server would:
10
+ 1. Re-query the entire document using `get_<subscribable>()`
11
+ 2. Send the complete document to the client
12
+ 3. Client replaces its entire local state
13
+
14
+ This approach has several problems:
15
+ - **Network inefficiency**: Sends full product catalogue when one task template duration changes
16
+ - **Database load**: Re-executes complex queries on every tiny change
17
+ - **Client state loss**: Replaces entire local state, losing UI state (scroll position, expanded rows, etc.)
18
+
19
+ ### Solution: Atomic Updates
20
+
21
+ With atomic updates, the server:
22
+ 1. Forwards the raw event (table, operation, primary key, data) directly to clients
23
+ 2. Client applies the patch to their local copy of the document
24
+ 3. Only changed data traverses the network
25
+
26
+ Benefits:
27
+ - **Efficient**: O(change size) instead of O(document size) per update
28
+ - **Preserved state**: Client UI state remains intact
29
+ - **Reduced database load**: No re-querying on every change
30
+
31
+ ## How It Works
32
+
33
+ ### 1. Subscribe: Initial Data + Schema
34
+
35
+ When a client subscribes, they receive:
36
+ - The full initial document (unchanged)
37
+ - A **schema** that maps table names to document paths
38
+
39
+ ```javascript
40
+ const { data, schema, unsubscribe } = await ws.api.subscribe_venue_detail(
41
+ { venue_id: 1 },
42
+ (updated) => console.log('Updated:', updated)
43
+ );
44
+
45
+ // schema = {
46
+ // root: 'venues',
47
+ // paths: {
48
+ // 'venues': '.', // Root entity
49
+ // 'organisations': 'org', // FK expansion
50
+ // 'sites': 'sites', // Child collection
51
+ // 'packages': 'packages' // Child collection
52
+ // }
53
+ // }
54
+ ```
55
+
56
+ ### 2. On Changes: Atomic Events
57
+
58
+ When data changes, instead of re-querying, the server sends a `subscription:event` message:
59
+
60
+ ```json
61
+ {
62
+ "jsonrpc": "2.0",
63
+ "method": "subscription:event",
64
+ "params": {
65
+ "subscription_id": "550e8400-e29b-41d4-a716-446655440000",
66
+ "subscribable": "venue_detail",
67
+ "event": {
68
+ "table": "sites",
69
+ "op": "update",
70
+ "pk": { "id": 5 },
71
+ "data": { "id": 5, "name": "Updated Site Name", "venue_id": 1 },
72
+ "before": { "id": 5, "name": "Old Name", "venue_id": 1 }
73
+ }
74
+ }
75
+ }
76
+ ```
77
+
78
+ ### 3. Client: Apply Patch
79
+
80
+ The client uses the schema to locate where the change belongs in the document and applies it:
81
+
82
+ - **insert**: Adds new item to the appropriate array
83
+ - **update**: Finds item by primary key and merges changes
84
+ - **delete**: Finds item by primary key and removes it
85
+
86
+ The callback receives the updated local document, preserving any UI state.
87
+
88
+ ## Scope Tables
89
+
90
+ Each subscribable tracks which tables are "in scope" - tables that can affect the document:
91
+
92
+ ```sql
93
+ SELECT scope_tables FROM dzql.subscribables WHERE name = 'venue_detail';
94
+ -- Returns: ['venues', 'organisations', 'sites', 'packages']
95
+ ```
96
+
97
+ This enables an optimization: events from tables not in scope are immediately skipped, avoiding unnecessary `_affected_documents()` calls.
98
+
99
+ ### Automatic Extraction
100
+
101
+ Scope tables are automatically extracted when registering a subscribable:
102
+
103
+ ```sql
104
+ SELECT dzql.register_subscribable(
105
+ 'venue_detail',
106
+ '{"subscribe": [...]}'::jsonb,
107
+ '{"venue_id": "int"}'::jsonb,
108
+ 'venues', -- root_entity -> scope includes 'venues'
109
+ '{
110
+ "org": "organisations", -- scope includes 'organisations'
111
+ "sites": {"entity": "sites", ...} -- scope includes 'sites'
112
+ }'::jsonb
113
+ );
114
+
115
+ -- scope_tables automatically set to: ['venues', 'organisations', 'sites']
116
+ ```
117
+
118
+ ## Path Mapping
119
+
120
+ The path mapping tells the client where each table's data lives in the document structure:
121
+
122
+ | Table | Path | Meaning |
123
+ |-------|------|---------|
124
+ | `venues` | `.` | Root level |
125
+ | `organisations` | `org` | `document.org` |
126
+ | `sites` | `sites` | `document.sites[]` |
127
+ | `allocations` | `packages.allocations` | `document.packages[].allocations[]` |
128
+
129
+ ### Nested Relations
130
+
131
+ For nested relations, paths are dot-separated:
132
+
133
+ ```javascript
134
+ relations = {
135
+ packages: {
136
+ entity: 'packages',
137
+ filter: 'venue_id=$venue_id',
138
+ include: {
139
+ allocations: 'allocations'
140
+ }
141
+ }
142
+ }
143
+
144
+ // Results in paths:
145
+ // 'packages' -> 'packages'
146
+ // 'allocations' -> 'packages.allocations'
147
+ ```
148
+
149
+ ## Client-Side Patching
150
+
151
+ The `WebSocketManager` automatically handles `subscription:event` messages:
152
+
153
+ ```javascript
154
+ // Internally, when subscription:event is received:
155
+ applyAtomicUpdate(sub, event) {
156
+ const { table, op, pk, data } = event;
157
+ const path = sub.schema.paths[table];
158
+
159
+ if (path === '.') {
160
+ // Root entity update
161
+ Object.assign(sub.localData[sub.schema.root], data);
162
+ } else {
163
+ // Relation update
164
+ const arr = getArrayAtPath(sub.localData, path);
165
+ if (op === 'insert') arr.push(data);
166
+ if (op === 'update') {
167
+ const idx = arr.findIndex(item => pkMatch(item, pk));
168
+ if (idx !== -1) Object.assign(arr[idx], data);
169
+ }
170
+ if (op === 'delete') {
171
+ const idx = arr.findIndex(item => pkMatch(item, pk));
172
+ if (idx !== -1) arr.splice(idx, 1);
173
+ }
174
+ }
175
+
176
+ // Trigger callback with patched document
177
+ sub.callback(sub.localData);
178
+ }
179
+ ```
180
+
181
+ ## Composite Primary Keys
182
+
183
+ Atomic updates support composite primary keys:
184
+
185
+ ```json
186
+ {
187
+ "pk": { "product_id": 1, "part_id": 2 }
188
+ }
189
+ ```
190
+
191
+ The client matches all key fields when finding items to update or delete.
192
+
193
+ ## Migration from Full Re-queries
194
+
195
+ If you have existing subscribables, atomic updates are enabled automatically when:
196
+ 1. The `scope_tables` column is populated (happens on `register_subscribable`)
197
+ 2. The client receives the `schema` in the subscribe response
198
+
199
+ No code changes required - the system is backward compatible.
200
+
201
+ ## Debugging
202
+
203
+ ### Check Scope Tables
204
+
205
+ ```sql
206
+ SELECT name, scope_tables
207
+ FROM dzql.subscribables
208
+ WHERE name = 'your_subscribable';
209
+ ```
210
+
211
+ ### Verify Path Mapping
212
+
213
+ Subscribe and log the schema:
214
+
215
+ ```javascript
216
+ const { schema } = await ws.api.subscribe_venue_detail(
217
+ { venue_id: 1 },
218
+ () => {}
219
+ );
220
+ console.log('Path mapping:', schema.paths);
221
+ ```
222
+
223
+ ### Server Logs
224
+
225
+ Enable debug logging to see atomic events:
226
+
227
+ ```
228
+ Sent atomic event to subscription abc123... (sites:update)
229
+ ```
230
+
231
+ ## Limitations
232
+
233
+ 1. **Deeply nested updates**: For very deep nesting (3+ levels), paths become complex. Consider flattening your subscribable structure.
234
+
235
+ 2. **Cascading deletes**: When a parent is deleted, the client may receive the parent delete before child deletes. The client handles missing parents gracefully.
236
+
237
+ 3. **Permission changes**: If a user loses access mid-subscription, they may receive one final event before the subscription is terminated.
238
+
239
+ ## See Also
240
+
241
+ - [Subscriptions Guide](./subscriptions.md) - Full subscribable documentation
242
+ - [Getting Started with Subscriptions](../getting-started/subscriptions-quick-start.md)
@@ -19,7 +19,9 @@ Live Query Subscriptions (Pattern 1 from vision.md) enable clients to subscribe
19
19
  - `get_<name>(params, user_id)` - Query function
20
20
  - `<name>_affected_documents(table, op, old, new)` - Change detection
21
21
  3. **Subscribe**: Client calls `ws.api.subscribe_<name>(params, callback)`
22
- 4. **Update**: Database changes trigger NOTIFY → server asks PostgreSQL which subscriptions are affected server re-queries and sends updates
22
+ 4. **Update**: Database changes trigger NOTIFY → server forwards atomic eventsclient applies patches locally
23
+
24
+ > **Note**: DZQL uses [Atomic Updates](./atomic-updates.md) for efficient real-time sync. Instead of re-querying the full document on every change, the server forwards the raw event and the client patches its local copy. This reduces network traffic and preserves client-side UI state.
23
25
 
24
26
  ## Quick Start
25
27
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dzql",
3
- "version": "0.5.6",
3
+ "version": "0.5.7",
4
4
  "description": "PostgreSQL-powered framework with zero boilerplate CRUD operations and real-time WebSocket synchronization",
5
5
  "type": "module",
6
6
  "main": "src/server/index.js",
package/src/client/ws.js CHANGED
@@ -334,16 +334,28 @@ class WebSocketManager {
334
334
  resolve(message.result);
335
335
  }
336
336
  } else {
337
- // Handle subscription updates
337
+ // Handle subscription updates (legacy full document replacement)
338
338
  if (message.method === "subscription:update") {
339
339
  const { subscription_id, data } = message.params;
340
340
  const sub = this.subscriptions.get(subscription_id);
341
341
  if (sub && sub.callback) {
342
+ // Update local data and call callback
343
+ sub.localData = data;
342
344
  sub.callback(data);
343
345
  }
344
346
  return;
345
347
  }
346
348
 
349
+ // Handle atomic subscription events (new efficient patching)
350
+ if (message.method === "subscription:event") {
351
+ const { subscription_id, event } = message.params;
352
+ const sub = this.subscriptions.get(subscription_id);
353
+ if (sub) {
354
+ this.applyAtomicUpdate(sub, event);
355
+ }
356
+ return;
357
+ }
358
+
347
359
  // Handle broadcasts and SID requests
348
360
 
349
361
  // Check if this is a SID request from server
@@ -361,6 +373,117 @@ class WebSocketManager {
361
373
  }
362
374
  }
363
375
 
376
+ /**
377
+ * Apply an atomic update to a subscription's local data
378
+ * @private
379
+ * @param {Object} sub - Subscription object with localData, schema, callback
380
+ * @param {Object} event - Event with table, op, pk, data, before
381
+ */
382
+ applyAtomicUpdate(sub, event) {
383
+ const { table, op, pk, data, before } = event;
384
+ const { schema, localData, callback } = sub;
385
+
386
+ // Fallback: if no schema or localData, we can't apply atomic updates
387
+ if (!schema || !localData) {
388
+ console.warn('Cannot apply atomic update: missing schema or localData');
389
+ // If we have data, just call callback with it as a fallback
390
+ if (data) {
391
+ callback(data);
392
+ }
393
+ return;
394
+ }
395
+
396
+ const path = schema.paths?.[table];
397
+ if (!path) {
398
+ console.warn(`Unknown table ${table} for subscribable, cannot apply patch`);
399
+ return;
400
+ }
401
+
402
+ // Apply the update based on where the table lives in the document
403
+ if (path === '.' || table === schema.root) {
404
+ // Root entity changed
405
+ this.applyRootUpdate(localData, schema.root, op, data, before);
406
+ } else {
407
+ // Relation changed - find and update in nested structure
408
+ this.applyRelationUpdate(localData, path, op, pk, data);
409
+ }
410
+
411
+ // Trigger callback with updated document
412
+ callback(localData);
413
+ }
414
+
415
+ /**
416
+ * Apply update to root entity
417
+ * @private
418
+ */
419
+ applyRootUpdate(localData, rootKey, op, data, before) {
420
+ if (op === 'update' && data) {
421
+ // Merge update into root entity
422
+ if (localData[rootKey]) {
423
+ Object.assign(localData[rootKey], data);
424
+ }
425
+ } else if (op === 'delete') {
426
+ // Mark root as deleted (or set to null)
427
+ localData[rootKey] = null;
428
+ }
429
+ // insert at root level would be a new document, handled by initial subscribe
430
+ }
431
+
432
+ /**
433
+ * Apply update to a relation (nested array)
434
+ * @private
435
+ */
436
+ applyRelationUpdate(localData, path, op, pk, data) {
437
+ const arr = this.getArrayAtPath(localData, path);
438
+ if (!arr || !Array.isArray(arr)) {
439
+ console.warn(`Could not find array at path ${path}`);
440
+ return;
441
+ }
442
+
443
+ if (op === 'insert' && data) {
444
+ arr.push(data);
445
+ } else if (op === 'update' && data && pk) {
446
+ const idx = arr.findIndex(item => this.pkMatch(item, pk));
447
+ if (idx !== -1) {
448
+ Object.assign(arr[idx], data);
449
+ } else {
450
+ // Item not found, might be a new item that passes the filter - add it
451
+ arr.push(data);
452
+ }
453
+ } else if (op === 'delete' && pk) {
454
+ const idx = arr.findIndex(item => this.pkMatch(item, pk));
455
+ if (idx !== -1) {
456
+ arr.splice(idx, 1);
457
+ }
458
+ }
459
+ }
460
+
461
+ /**
462
+ * Get array at a dot-separated path in an object
463
+ * @private
464
+ */
465
+ getArrayAtPath(obj, path) {
466
+ const parts = path.split('.');
467
+ let current = obj;
468
+ for (const part of parts) {
469
+ if (!current || typeof current !== 'object') return null;
470
+ current = current[part];
471
+ }
472
+ return current;
473
+ }
474
+
475
+ /**
476
+ * Check if an item matches a primary key
477
+ * @private
478
+ */
479
+ pkMatch(item, pk) {
480
+ if (!item || !pk) return false;
481
+ for (const [key, value] of Object.entries(pk)) {
482
+ if (item[key] !== value) return false;
483
+ }
484
+ return true;
485
+ }
486
+
364
487
  attemptReconnect() {
365
488
  if (this.reconnectAttempts < this.maxReconnectAttempts) {
366
489
  this.reconnectAttempts++;
@@ -409,19 +532,23 @@ class WebSocketManager {
409
532
  /**
410
533
  * Subscribe to a live query
411
534
  *
535
+ * Subscribes to real-time updates for a document. The server returns the initial
536
+ * data along with a schema that enables efficient atomic updates (patching).
537
+ *
412
538
  * @param {string} method - Method name (subscribe_<subscribable>)
413
539
  * @param {object} params - Subscription parameters
414
540
  * @param {function} callback - Callback function for updates
415
- * @returns {Promise<{data, subscription_id, unsubscribe}>} Initial data and unsubscribe function
541
+ * @returns {Promise<{data, subscription_id, schema, unsubscribe}>} Initial data, schema, and unsubscribe function
416
542
  *
417
543
  * @example
418
- * const { data, unsubscribe } = await ws.api.subscribe_venue_detail(
544
+ * const { data, schema, unsubscribe } = await ws.api.subscribe_venue_detail(
419
545
  * { venue_id: 1 },
420
546
  * (updated) => console.log('Updated:', updated)
421
547
  * );
422
548
  *
423
549
  * // Use initial data
424
550
  * console.log('Initial:', data);
551
+ * console.log('Schema:', schema); // { root: 'venues', paths: { venues: '.', sites: 'sites', ... } }
425
552
  *
426
553
  * // Later: unsubscribe
427
554
  * unsubscribe();
@@ -433,7 +560,7 @@ class WebSocketManager {
433
560
 
434
561
  // Call server to register subscription
435
562
  const result = await this.call(method, params);
436
- const { subscription_id, data } = result;
563
+ const { subscription_id, data, schema } = result;
437
564
 
438
565
  // Create unsubscribe function
439
566
  const unsubscribeFn = async () => {
@@ -442,16 +569,19 @@ class WebSocketManager {
442
569
  this.subscriptions.delete(subscription_id);
443
570
  };
444
571
 
445
- // Store callback for updates
572
+ // Store callback, schema, and local data for atomic updates
446
573
  this.subscriptions.set(subscription_id, {
447
574
  callback,
448
- unsubscribe: unsubscribeFn
575
+ unsubscribe: unsubscribeFn,
576
+ schema, // Schema for path mapping (enables atomic updates)
577
+ localData: data // Local copy for patching
449
578
  });
450
579
 
451
- // Return initial data and unsubscribe function
580
+ // Return initial data, schema, and unsubscribe function
452
581
  return {
453
582
  data,
454
583
  subscription_id,
584
+ schema,
455
585
  unsubscribe: unsubscribeFn
456
586
  };
457
587
  }
@@ -433,6 +433,71 @@ $$ LANGUAGE plpgsql IMMUTABLE;`;
433
433
  jsonb_build_object('${firstParam}', COALESCE((p_new->>'${relFK}')::int, (p_old->>'${relFK}')::int))
434
434
  ];`;
435
435
  }
436
+
437
+ /**
438
+ * Extract all tables in scope for this subscribable
439
+ * Used for efficient event filtering - only events from these tables need consideration
440
+ * @returns {string[]} Array of table names
441
+ */
442
+ extractScopeTables() {
443
+ const tables = new Set([this.rootEntity]);
444
+
445
+ const extractFromRelations = (relations) => {
446
+ for (const [relName, relConfig] of Object.entries(relations || {})) {
447
+ const entity = typeof relConfig === 'string' ? relConfig : relConfig.entity;
448
+ if (entity) tables.add(entity);
449
+
450
+ // Handle nested relations (include or relations)
451
+ if (typeof relConfig === 'object') {
452
+ if (relConfig.include) {
453
+ extractFromRelations(relConfig.include);
454
+ }
455
+ if (relConfig.relations) {
456
+ extractFromRelations(relConfig.relations);
457
+ }
458
+ }
459
+ }
460
+ };
461
+
462
+ extractFromRelations(this.relations);
463
+ return Array.from(tables);
464
+ }
465
+
466
+ /**
467
+ * Build path mapping for client-side patching
468
+ * Maps table names to their path in the document structure
469
+ * @returns {Object} Map of table name -> document path
470
+ */
471
+ buildPathMapping() {
472
+ const paths = {};
473
+
474
+ // Root entity maps to top level
475
+ paths[this.rootEntity] = '.';
476
+
477
+ const buildPaths = (relations, parentPath = '') => {
478
+ for (const [relName, relConfig] of Object.entries(relations || {})) {
479
+ const entity = typeof relConfig === 'string' ? relConfig : relConfig.entity;
480
+ const currentPath = parentPath ? `${parentPath}.${relName}` : relName;
481
+
482
+ if (entity) {
483
+ paths[entity] = currentPath;
484
+ }
485
+
486
+ // Handle nested relations
487
+ if (typeof relConfig === 'object') {
488
+ if (relConfig.include) {
489
+ buildPaths(relConfig.include, currentPath);
490
+ }
491
+ if (relConfig.relations) {
492
+ buildPaths(relConfig.relations, currentPath);
493
+ }
494
+ }
495
+ }
496
+ };
497
+
498
+ buildPaths(this.relations);
499
+ return paths;
500
+ }
436
501
  }
437
502
 
438
503
  /**
@@ -444,3 +509,23 @@ export function generateSubscribable(subscribable) {
444
509
  const codegen = new SubscribableCodegen(subscribable);
445
510
  return codegen.generate();
446
511
  }
512
+
513
+ /**
514
+ * Extract scope tables from subscribable config
515
+ * @param {Object} subscribable - Subscribable configuration
516
+ * @returns {string[]} Array of table names in scope
517
+ */
518
+ export function extractScopeTables(subscribable) {
519
+ const codegen = new SubscribableCodegen(subscribable);
520
+ return codegen.extractScopeTables();
521
+ }
522
+
523
+ /**
524
+ * Build path mapping from subscribable config
525
+ * @param {Object} subscribable - Subscribable configuration
526
+ * @returns {Object} Map of table name -> document path
527
+ */
528
+ export function buildPathMapping(subscribable) {
529
+ const codegen = new SubscribableCodegen(subscribable);
530
+ return codegen.buildPathMapping();
531
+ }
@@ -13,6 +13,7 @@ CREATE TABLE IF NOT EXISTS dzql.subscribables (
13
13
  param_schema JSONB NOT NULL DEFAULT '{}'::jsonb,
14
14
  root_entity TEXT NOT NULL,
15
15
  relations JSONB NOT NULL DEFAULT '{}'::jsonb,
16
+ scope_tables TEXT[] NOT NULL DEFAULT '{}',
16
17
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
17
18
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
18
19
  );
@@ -35,6 +36,9 @@ COMMENT ON COLUMN dzql.subscribables.root_entity IS
35
36
  COMMENT ON COLUMN dzql.subscribables.relations IS
36
37
  'Related entities to include (e.g., {"org": "organisations", "sites": {"entity": "sites", "filter": "venue_id=$venue_id"}})';
37
38
 
39
+ COMMENT ON COLUMN dzql.subscribables.scope_tables IS
40
+ 'Array of table names that are in scope for this subscribable (root + all relations). Used for efficient event filtering.';
41
+
38
42
  -- Index for quick lookups
39
43
  CREATE INDEX IF NOT EXISTS idx_subscribables_root_entity
40
44
  ON dzql.subscribables(root_entity);
@@ -110,6 +114,7 @@ RETURNS TABLE (
110
114
  param_schema JSONB,
111
115
  root_entity TEXT,
112
116
  relations JSONB,
117
+ scope_tables TEXT[],
113
118
  created_at TIMESTAMPTZ,
114
119
  updated_at TIMESTAMPTZ
115
120
  ) AS $$
@@ -121,6 +126,7 @@ BEGIN
121
126
  s.param_schema,
122
127
  s.root_entity,
123
128
  s.relations,
129
+ s.scope_tables,
124
130
  s.created_at,
125
131
  s.updated_at
126
132
  FROM dzql.subscribables s
@@ -139,6 +145,7 @@ RETURNS TABLE (
139
145
  param_schema JSONB,
140
146
  root_entity TEXT,
141
147
  relations JSONB,
148
+ scope_tables TEXT[],
142
149
  created_at TIMESTAMPTZ,
143
150
  updated_at TIMESTAMPTZ
144
151
  ) AS $$
@@ -150,6 +157,7 @@ BEGIN
150
157
  s.param_schema,
151
158
  s.root_entity,
152
159
  s.relations,
160
+ s.scope_tables,
153
161
  s.created_at,
154
162
  s.updated_at
155
163
  FROM dzql.subscribables s
@@ -168,6 +176,7 @@ RETURNS TABLE (
168
176
  param_schema JSONB,
169
177
  root_entity TEXT,
170
178
  relations JSONB,
179
+ scope_tables TEXT[],
171
180
  created_at TIMESTAMPTZ,
172
181
  updated_at TIMESTAMPTZ
173
182
  ) AS $$
@@ -179,6 +188,7 @@ BEGIN
179
188
  s.param_schema,
180
189
  s.root_entity,
181
190
  s.relations,
191
+ s.scope_tables,
182
192
  s.created_at,
183
193
  s.updated_at
184
194
  FROM dzql.subscribables s
@@ -0,0 +1,150 @@
1
+ -- Migration 010: Atomic Updates for Subscribables
2
+ -- Adds extract_scope_tables function and updates register_subscribable to auto-populate scope_tables
3
+
4
+ -- ============================================================================
5
+ -- Helper function to extract scope tables from relations
6
+ -- ============================================================================
7
+
8
+ CREATE OR REPLACE FUNCTION dzql.extract_scope_tables(
9
+ p_root_entity TEXT,
10
+ p_relations JSONB
11
+ ) RETURNS TEXT[] AS $$
12
+ DECLARE
13
+ v_tables TEXT[];
14
+ v_key TEXT;
15
+ v_value JSONB;
16
+ v_entity TEXT;
17
+ v_nested JSONB;
18
+ BEGIN
19
+ -- Start with root entity
20
+ v_tables := ARRAY[p_root_entity];
21
+
22
+ -- Return early if no relations
23
+ IF p_relations IS NULL OR p_relations = '{}'::jsonb THEN
24
+ RETURN v_tables;
25
+ END IF;
26
+
27
+ -- Iterate through relations
28
+ FOR v_key, v_value IN SELECT * FROM jsonb_each(p_relations)
29
+ LOOP
30
+ -- Handle string relation (simple FK expansion): "org": "organisations"
31
+ IF jsonb_typeof(v_value) = 'string' THEN
32
+ v_entity := v_value #>> '{}';
33
+ IF v_entity IS NOT NULL AND v_entity != '' THEN
34
+ v_tables := array_append(v_tables, v_entity);
35
+ END IF;
36
+ -- Handle object relation: {"entity": "sites", "filter": "..."}
37
+ ELSIF jsonb_typeof(v_value) = 'object' THEN
38
+ v_entity := v_value ->> 'entity';
39
+ IF v_entity IS NOT NULL AND v_entity != '' THEN
40
+ v_tables := array_append(v_tables, v_entity);
41
+ END IF;
42
+
43
+ -- Recursively handle nested relations (include or relations)
44
+ v_nested := v_value -> 'include';
45
+ IF v_nested IS NOT NULL AND jsonb_typeof(v_nested) = 'object' THEN
46
+ v_tables := v_tables || dzql.extract_scope_tables(NULL, v_nested);
47
+ END IF;
48
+
49
+ v_nested := v_value -> 'relations';
50
+ IF v_nested IS NOT NULL AND jsonb_typeof(v_nested) = 'object' THEN
51
+ v_tables := v_tables || dzql.extract_scope_tables(NULL, v_nested);
52
+ END IF;
53
+ END IF;
54
+ END LOOP;
55
+
56
+ -- Remove duplicates and nulls
57
+ SELECT array_agg(DISTINCT t) INTO v_tables
58
+ FROM unnest(v_tables) t
59
+ WHERE t IS NOT NULL;
60
+
61
+ RETURN COALESCE(v_tables, ARRAY[]::TEXT[]);
62
+ END;
63
+ $$ LANGUAGE plpgsql IMMUTABLE;
64
+
65
+ COMMENT ON FUNCTION dzql.extract_scope_tables IS
66
+ 'Extract all table names from a subscribable definition (root entity + all relations recursively)';
67
+
68
+ -- ============================================================================
69
+ -- Update register_subscribable to auto-populate scope_tables
70
+ -- ============================================================================
71
+
72
+ CREATE OR REPLACE FUNCTION dzql.register_subscribable(
73
+ p_name TEXT,
74
+ p_permission_paths JSONB,
75
+ p_param_schema JSONB,
76
+ p_root_entity TEXT,
77
+ p_relations JSONB
78
+ ) RETURNS TEXT AS $$
79
+ DECLARE
80
+ v_result TEXT;
81
+ v_scope_tables TEXT[];
82
+ BEGIN
83
+ -- Validate inputs
84
+ IF p_name IS NULL OR p_name = '' THEN
85
+ RAISE EXCEPTION 'Subscribable name cannot be empty';
86
+ END IF;
87
+
88
+ IF p_root_entity IS NULL OR p_root_entity = '' THEN
89
+ RAISE EXCEPTION 'Root entity cannot be empty';
90
+ END IF;
91
+
92
+ -- Extract scope tables from root entity and relations
93
+ v_scope_tables := dzql.extract_scope_tables(p_root_entity, p_relations);
94
+
95
+ -- Insert or update subscribable
96
+ INSERT INTO dzql.subscribables (
97
+ name,
98
+ permission_paths,
99
+ param_schema,
100
+ root_entity,
101
+ relations,
102
+ scope_tables,
103
+ created_at,
104
+ updated_at
105
+ ) VALUES (
106
+ p_name,
107
+ COALESCE(p_permission_paths, '{}'::jsonb),
108
+ COALESCE(p_param_schema, '{}'::jsonb),
109
+ p_root_entity,
110
+ COALESCE(p_relations, '{}'::jsonb),
111
+ v_scope_tables,
112
+ NOW(),
113
+ NOW()
114
+ )
115
+ ON CONFLICT (name) DO UPDATE SET
116
+ permission_paths = EXCLUDED.permission_paths,
117
+ param_schema = EXCLUDED.param_schema,
118
+ root_entity = EXCLUDED.root_entity,
119
+ relations = EXCLUDED.relations,
120
+ scope_tables = EXCLUDED.scope_tables,
121
+ updated_at = NOW();
122
+
123
+ v_result := format('Subscribable "%s" registered successfully with scope tables: %s',
124
+ p_name, array_to_string(v_scope_tables, ', '));
125
+
126
+ RAISE NOTICE '%', v_result;
127
+
128
+ RETURN v_result;
129
+ END;
130
+ $$ LANGUAGE plpgsql;
131
+
132
+ -- ============================================================================
133
+ -- Backfill existing subscribables with scope_tables
134
+ -- ============================================================================
135
+
136
+ UPDATE dzql.subscribables s
137
+ SET scope_tables = dzql.extract_scope_tables(s.root_entity, s.relations)
138
+ WHERE scope_tables = '{}' OR scope_tables IS NULL;
139
+
140
+ -- ============================================================================
141
+ -- Verification
142
+ -- ============================================================================
143
+
144
+ DO $$
145
+ BEGIN
146
+ RAISE NOTICE 'Migration 010: Atomic Updates - Complete';
147
+ RAISE NOTICE 'Created dzql.extract_scope_tables() function';
148
+ RAISE NOTICE 'Updated dzql.register_subscribable() to auto-populate scope_tables';
149
+ RAISE NOTICE 'Backfilled existing subscribables';
150
+ END $$;
@@ -2,7 +2,7 @@ import { createWebSocketHandlers, verify_jwt_token } from "./ws.js";
2
2
  import { closeConnections, setupListeners, sql, db } from "./db.js";
3
3
  import * as defaultApi from "./api.js";
4
4
  import { serverLogger, notifyLogger } from "./logger.js";
5
- import { getSubscriptionsBySubscribable, paramsMatch } from "./subscriptions.js";
5
+ import { getSubscriptionsBySubscribable, paramsMatch, getSubscribableScopeTables } from "./subscriptions.js";
6
6
 
7
7
  // Re-export commonly used utilities
8
8
  export { sql, db } from "./db.js";
@@ -11,12 +11,12 @@ export { createMCPRoute } from "./mcp.js";
11
11
 
12
12
  /**
13
13
  * Process subscription updates when a database event occurs
14
- * Checks if any active subscriptions are affected and sends updates
14
+ * Forwards atomic events to affected subscriptions for client-side patching
15
15
  * @param {Object} event - Database event {table, op, pk, before, after}
16
16
  * @param {Function} broadcast - Broadcast function from WebSocket handlers
17
17
  */
18
18
  async function processSubscriptionUpdates(event, broadcast) {
19
- const { table, op, before, after } = event;
19
+ const { table, op, pk, before, after } = event;
20
20
 
21
21
  // Get all active subscriptions grouped by subscribable
22
22
  const subscriptionsByName = getSubscriptionsBySubscribable();
@@ -30,6 +30,14 @@ async function processSubscriptionUpdates(event, broadcast) {
30
30
  // For each unique subscribable, check if this event affects any subscriptions
31
31
  for (const [subscribableName, subs] of subscriptionsByName.entries()) {
32
32
  try {
33
+ // Check if this table is in scope for this subscribable
34
+ // This is an optimization to avoid calling _affected_documents for unrelated tables
35
+ const scopeTables = await getSubscribableScopeTables(subscribableName, sql);
36
+ if (scopeTables.length > 0 && !scopeTables.includes(table)) {
37
+ notifyLogger.debug(`Table ${table} not in scope for ${subscribableName}, skipping`);
38
+ continue;
39
+ }
40
+
33
41
  // Ask PostgreSQL which subscription instances are affected
34
42
  const result = await sql.unsafe(
35
43
  `SELECT ${subscribableName}_affected_documents($1, $2, $3, $4) as affected`,
@@ -42,7 +50,7 @@ async function processSubscriptionUpdates(event, broadcast) {
42
50
  continue; // This subscribable not affected
43
51
  }
44
52
 
45
- notifyLogger.debug(`${subscribableName}: ${affectedParamSets.length} param set(s) affected`);
53
+ notifyLogger.debug(`${subscribableName}: ${affectedParamSets.length} param set(s) affected by ${table}:${op}`);
46
54
 
47
55
  // Match affected params to active subscriptions
48
56
  for (const affectedParams of affectedParamSets) {
@@ -50,33 +58,32 @@ async function processSubscriptionUpdates(event, broadcast) {
50
58
  // Check if this subscription matches the affected params
51
59
  if (paramsMatch(sub.params, affectedParams)) {
52
60
  try {
53
- // Re-execute query to get updated data
54
- const updated = await sql.unsafe(
55
- `SELECT get_${subscribableName}($1, $2) as data`,
56
- [sub.params, sub.user_id]
57
- );
58
-
59
- const data = updated[0]?.data;
60
-
61
- // Send update to specific connection
61
+ // Forward atomic event instead of re-querying the full document
62
+ // Client will apply the patch to their local copy
62
63
  const message = JSON.stringify({
63
64
  jsonrpc: "2.0",
64
- method: "subscription:update",
65
+ method: "subscription:event",
65
66
  params: {
66
67
  subscription_id: sub.subscriptionId,
67
68
  subscribable: subscribableName,
68
- data
69
+ event: {
70
+ table,
71
+ op,
72
+ pk,
73
+ data: after,
74
+ before
75
+ }
69
76
  }
70
77
  });
71
78
 
72
79
  const sent = broadcast.toConnection(sub.connection_id, message);
73
80
  if (sent) {
74
- notifyLogger.debug(`Sent update to subscription ${sub.subscriptionId.slice(0, 8)}...`);
81
+ notifyLogger.debug(`Sent atomic event to subscription ${sub.subscriptionId.slice(0, 8)}... (${table}:${op})`);
75
82
  } else {
76
- notifyLogger.warn(`Failed to send update to connection ${sub.connection_id.slice(0, 8)}...`);
83
+ notifyLogger.warn(`Failed to send event to connection ${sub.connection_id.slice(0, 8)}...`);
77
84
  }
78
85
  } catch (error) {
79
- notifyLogger.error(`Failed to update subscription ${sub.subscriptionId}:`, error.message);
86
+ notifyLogger.error(`Failed to send event to subscription ${sub.subscriptionId}:`, error.message);
80
87
  }
81
88
  }
82
89
  }
@@ -18,6 +18,12 @@ const subscriptions = new Map();
18
18
  */
19
19
  const connectionSubscriptions = new Map();
20
20
 
21
+ /**
22
+ * Cache for subscribable metadata (scope tables, path mappings)
23
+ * Structure: subscribable_name -> { scopeTables, pathMapping, rootEntity, relations }
24
+ */
25
+ const subscribableMetadataCache = new Map();
26
+
21
27
  /**
22
28
  * Register a new subscription
23
29
  * @param {string} subscribableName - Name of the subscribable
@@ -207,3 +213,122 @@ export function getAllSubscriptions() {
207
213
  ...sub
208
214
  }));
209
215
  }
216
+
217
+ /**
218
+ * Get subscribable metadata (scope tables, path mapping) with caching
219
+ * @param {string} subscribableName - Name of the subscribable
220
+ * @param {function} sql - Database query function
221
+ * @returns {Promise<{scopeTables: string[], pathMapping: object, rootEntity: string, relations: object}>}
222
+ */
223
+ export async function getSubscribableMetadata(subscribableName, sql) {
224
+ // Check cache first
225
+ if (subscribableMetadataCache.has(subscribableName)) {
226
+ return subscribableMetadataCache.get(subscribableName);
227
+ }
228
+
229
+ // Fetch from database
230
+ const result = await sql`
231
+ SELECT scope_tables, root_entity, relations
232
+ FROM dzql.subscribables
233
+ WHERE name = ${subscribableName}
234
+ `;
235
+
236
+ if (!result || result.length === 0) {
237
+ // Return empty metadata if subscribable not found
238
+ const emptyMetadata = {
239
+ scopeTables: [],
240
+ pathMapping: {},
241
+ rootEntity: null,
242
+ relations: {}
243
+ };
244
+ return emptyMetadata;
245
+ }
246
+
247
+ const { scope_tables, root_entity, relations } = result[0];
248
+
249
+ // Build path mapping from relations
250
+ const pathMapping = buildPathMappingFromRelations(root_entity, relations);
251
+
252
+ const metadata = {
253
+ scopeTables: scope_tables || [],
254
+ pathMapping,
255
+ rootEntity: root_entity,
256
+ relations: relations || {}
257
+ };
258
+
259
+ // Cache for future use
260
+ subscribableMetadataCache.set(subscribableName, metadata);
261
+
262
+ wsLogger.debug(`Cached metadata for subscribable ${subscribableName}:`, {
263
+ scopeTables: metadata.scopeTables,
264
+ pathMapping: metadata.pathMapping
265
+ });
266
+
267
+ return metadata;
268
+ }
269
+
270
+ /**
271
+ * Build path mapping from relations configuration
272
+ * Maps table names to their document path for client-side patching
273
+ * @param {string} rootEntity - Root table name
274
+ * @param {object} relations - Relations configuration
275
+ * @returns {object} - Map of table name -> document path
276
+ */
277
+ function buildPathMappingFromRelations(rootEntity, relations) {
278
+ const paths = {};
279
+
280
+ // Root entity maps to top level
281
+ if (rootEntity) {
282
+ paths[rootEntity] = '.';
283
+ }
284
+
285
+ const buildPaths = (rels, parentPath = '') => {
286
+ for (const [relName, relConfig] of Object.entries(rels || {})) {
287
+ const entity = typeof relConfig === 'string' ? relConfig : relConfig?.entity;
288
+ const currentPath = parentPath ? `${parentPath}.${relName}` : relName;
289
+
290
+ if (entity) {
291
+ paths[entity] = currentPath;
292
+ }
293
+
294
+ // Handle nested relations
295
+ if (typeof relConfig === 'object' && relConfig !== null) {
296
+ if (relConfig.include) {
297
+ buildPaths(relConfig.include, currentPath);
298
+ }
299
+ if (relConfig.relations) {
300
+ buildPaths(relConfig.relations, currentPath);
301
+ }
302
+ }
303
+ }
304
+ };
305
+
306
+ buildPaths(relations);
307
+ return paths;
308
+ }
309
+
310
+ /**
311
+ * Clear the subscribable metadata cache
312
+ * Called when subscribables are reregistered or updated
313
+ * @param {string} [subscribableName] - Optional: clear specific subscribable, or all if not provided
314
+ */
315
+ export function clearSubscribableMetadataCache(subscribableName = null) {
316
+ if (subscribableName) {
317
+ subscribableMetadataCache.delete(subscribableName);
318
+ wsLogger.debug(`Cleared metadata cache for ${subscribableName}`);
319
+ } else {
320
+ subscribableMetadataCache.clear();
321
+ wsLogger.debug('Cleared all subscribable metadata cache');
322
+ }
323
+ }
324
+
325
+ /**
326
+ * Get scope tables for a subscribable (convenience function)
327
+ * @param {string} subscribableName - Name of the subscribable
328
+ * @param {function} sql - Database query function
329
+ * @returns {Promise<string[]>} - Array of table names in scope
330
+ */
331
+ export async function getSubscribableScopeTables(subscribableName, sql) {
332
+ const metadata = await getSubscribableMetadata(subscribableName, sql);
333
+ return metadata.scopeTables;
334
+ }
package/src/server/ws.js CHANGED
@@ -11,7 +11,8 @@ import {
11
11
  registerSubscription,
12
12
  unregisterSubscription,
13
13
  unregisterSubscriptionByParams,
14
- removeConnectionSubscriptions
14
+ removeConnectionSubscriptions,
15
+ getSubscribableMetadata
15
16
  } from "./subscriptions.js";
16
17
 
17
18
  // Environment configuration
@@ -327,6 +328,9 @@ export function createRPCHandler(customHandlers = {}) {
327
328
 
328
329
  const data = queryResult[0]?.data;
329
330
 
331
+ // Get subscribable metadata for schema (path mapping for atomic updates)
332
+ const metadata = await getSubscribableMetadata(subscribableName, sql);
333
+
330
334
  // Register subscription in memory
331
335
  const subscriptionId = registerSubscription(
332
336
  subscribableName,
@@ -335,9 +339,15 @@ export function createRPCHandler(customHandlers = {}) {
335
339
  params
336
340
  );
337
341
 
342
+ // Build result with schema for client-side patching
338
343
  const result = {
339
344
  subscription_id: subscriptionId,
340
- data
345
+ data,
346
+ // Include schema for atomic update support
347
+ schema: {
348
+ root: metadata.rootEntity,
349
+ paths: metadata.pathMapping
350
+ }
341
351
  };
342
352
 
343
353
  wsLogger.response(method, result, Date.now() - startTime);