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.
- package/docs/guides/atomic-updates.md +242 -0
- package/docs/guides/subscriptions.md +3 -1
- package/package.json +1 -1
- package/src/client/ws.js +137 -7
- package/src/compiler/codegen/subscribable-codegen.js +85 -0
- package/src/database/migrations/009_subscriptions.sql +10 -0
- package/src/database/migrations/010_atomic_updates.sql +150 -0
- package/src/server/index.js +25 -18
- package/src/server/subscriptions.js +125 -0
- package/src/server/ws.js +12 -2
|
@@ -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
|
|
22
|
+
4. **Update**: Database changes trigger NOTIFY → server forwards atomic events → client 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
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 $$;
|
package/src/server/index.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
//
|
|
54
|
-
|
|
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:
|
|
65
|
+
method: "subscription:event",
|
|
65
66
|
params: {
|
|
66
67
|
subscription_id: sub.subscriptionId,
|
|
67
68
|
subscribable: subscribableName,
|
|
68
|
-
|
|
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
|
|
81
|
+
notifyLogger.debug(`Sent atomic event to subscription ${sub.subscriptionId.slice(0, 8)}... (${table}:${op})`);
|
|
75
82
|
} else {
|
|
76
|
-
notifyLogger.warn(`Failed to send
|
|
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
|
|
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);
|