dzql 0.5.5 → 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/bin/cli.js +7 -0
- package/docs/guides/atomic-updates.md +242 -0
- package/docs/guides/drop-semantics.md +554 -0
- package/docs/guides/subscriptions.md +3 -1
- package/package.json +1 -1
- package/src/client/ws.js +137 -7
- package/src/compiler/codegen/drop-semantics-codegen.js +553 -0
- package/src/compiler/codegen/subscribable-codegen.js +85 -0
- package/src/compiler/compiler.js +13 -3
- 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
package/bin/cli.js
CHANGED
|
@@ -346,6 +346,13 @@ $$;
|
|
|
346
346
|
writeFileSync(checksumsFile, JSON.stringify(checksums, null, 2), 'utf-8');
|
|
347
347
|
|
|
348
348
|
console.log(` ✓ checksums.json`);
|
|
349
|
+
|
|
350
|
+
// Write drop-semantics.json (drag-and-drop manifest for canvas UI)
|
|
351
|
+
if (result.dropSemantics) {
|
|
352
|
+
const semanticsFile = resolve(options.output, 'drop-semantics.json');
|
|
353
|
+
writeFileSync(semanticsFile, JSON.stringify(result.dropSemantics, null, 2), 'utf-8');
|
|
354
|
+
console.log(` ✓ drop-semantics.json`);
|
|
355
|
+
}
|
|
349
356
|
}
|
|
350
357
|
|
|
351
358
|
console.log(`\n✅ Compilation complete!\n`);
|
|
@@ -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)
|