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