dzql 0.1.6 → 0.2.1
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/README.md +23 -1
- package/docs/LIVE_QUERY_SUBSCRIPTIONS.md +535 -0
- package/docs/LIVE_QUERY_SUBSCRIPTIONS_STRATEGY.md +488 -0
- package/docs/REFERENCE.md +139 -0
- package/docs/SUBSCRIPTIONS_QUICK_START.md +203 -0
- package/package.json +2 -3
- package/src/client/ws.js +87 -2
- package/src/compiler/cli/compile-example.js +33 -0
- package/src/compiler/cli/compile-subscribable.js +43 -0
- package/src/compiler/cli/debug-compile.js +44 -0
- package/src/compiler/cli/debug-parse.js +26 -0
- package/src/compiler/cli/debug-path-parser.js +18 -0
- package/src/compiler/cli/debug-subscribable-parser.js +21 -0
- package/src/compiler/codegen/subscribable-codegen.js +446 -0
- package/src/compiler/compiler.js +115 -0
- package/src/compiler/parser/subscribable-parser.js +242 -0
- package/src/database/migrations/009_subscriptions.sql +230 -0
- package/src/server/index.js +90 -1
- package/src/server/subscriptions.js +209 -0
- package/src/server/ws.js +78 -2
- package/src/client/stores/README.md +0 -95
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
# Live Query Subscriptions Strategy
|
|
2
|
+
|
|
3
|
+
**Date:** 2025-11-16
|
|
4
|
+
**Status:** Phase 1 Complete (Compiler), Phase 2-4 Pending
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Overview
|
|
9
|
+
|
|
10
|
+
Live Query Subscriptions implement **Pattern 1** from `vision.md` - allowing clients to subscribe to denormalized documents and receive automatic updates when any related data changes.
|
|
11
|
+
|
|
12
|
+
### Architecture Principles
|
|
13
|
+
|
|
14
|
+
1. **PostgreSQL-First**: Database determines which subscriptions are affected
|
|
15
|
+
2. **Compiler-Driven**: All logic compiled to PostgreSQL functions (zero runtime interpretation)
|
|
16
|
+
3. **In-Memory Subscriptions**: Server holds active subscriptions in memory for performance
|
|
17
|
+
4. **Naming Convention**: `subscribe_<name>` / `unsubscribe_<name>` for pattern matching
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Core Concept: Subscribables
|
|
22
|
+
|
|
23
|
+
**Subscribables** are separate from entities - they define denormalized documents that:
|
|
24
|
+
- Combine data from multiple entities (root + relations)
|
|
25
|
+
- Have their own access control (permission paths)
|
|
26
|
+
- Define subscription parameters (subscription key)
|
|
27
|
+
- Compile to PostgreSQL functions that determine affected documents
|
|
28
|
+
|
|
29
|
+
### Example Subscribable
|
|
30
|
+
|
|
31
|
+
```sql
|
|
32
|
+
SELECT dzql.register_subscribable(
|
|
33
|
+
'venue_detail', -- Subscribable name
|
|
34
|
+
|
|
35
|
+
-- Permission: who can subscribe?
|
|
36
|
+
jsonb_build_object(
|
|
37
|
+
'subscribe', ARRAY['@org_id->acts_for[org_id=$]{active}.user_id']
|
|
38
|
+
),
|
|
39
|
+
|
|
40
|
+
-- Parameters: subscription key
|
|
41
|
+
jsonb_build_object(
|
|
42
|
+
'venue_id', 'int'
|
|
43
|
+
),
|
|
44
|
+
|
|
45
|
+
-- Root entity
|
|
46
|
+
'venues',
|
|
47
|
+
|
|
48
|
+
-- Relations to include
|
|
49
|
+
jsonb_build_object(
|
|
50
|
+
'org', 'organisations',
|
|
51
|
+
'sites', jsonb_build_object(
|
|
52
|
+
'entity', 'sites',
|
|
53
|
+
'filter', 'venue_id=$venue_id'
|
|
54
|
+
),
|
|
55
|
+
'packages', jsonb_build_object(
|
|
56
|
+
'entity', 'packages',
|
|
57
|
+
'filter', 'venue_id=$venue_id',
|
|
58
|
+
'include', jsonb_build_object(
|
|
59
|
+
'allocations', 'allocations'
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
);
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## Generated PostgreSQL Functions
|
|
69
|
+
|
|
70
|
+
For each subscribable, the compiler generates 3 functions:
|
|
71
|
+
|
|
72
|
+
###1. Access Control: `<name>_can_subscribe(user_id, params)`
|
|
73
|
+
|
|
74
|
+
```sql
|
|
75
|
+
CREATE OR REPLACE FUNCTION venue_detail_can_subscribe(
|
|
76
|
+
p_user_id INT,
|
|
77
|
+
p_params JSONB
|
|
78
|
+
) RETURNS BOOLEAN AS $$
|
|
79
|
+
BEGIN
|
|
80
|
+
-- Check permission path: @org_id->acts_for[org_id=$]{active}.user_id
|
|
81
|
+
RETURN EXISTS (
|
|
82
|
+
SELECT 1
|
|
83
|
+
FROM venues v
|
|
84
|
+
JOIN acts_for af ON af.org_id = v.org_id
|
|
85
|
+
WHERE v.id = (p_params->>'venue_id')::int
|
|
86
|
+
AND af.user_id = p_user_id
|
|
87
|
+
AND af.valid_to IS NULL
|
|
88
|
+
);
|
|
89
|
+
END;
|
|
90
|
+
$$ LANGUAGE plpgsql STABLE SECURITY DEFINER;
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### 2. Query Function: `get_<name>(params, user_id)`
|
|
94
|
+
|
|
95
|
+
```sql
|
|
96
|
+
CREATE OR REPLACE FUNCTION get_venue_detail(
|
|
97
|
+
p_params JSONB,
|
|
98
|
+
p_user_id INT
|
|
99
|
+
) RETURNS JSONB AS $$
|
|
100
|
+
DECLARE
|
|
101
|
+
v_venue_id int;
|
|
102
|
+
v_result JSONB;
|
|
103
|
+
BEGIN
|
|
104
|
+
v_venue_id := (p_params->>'venue_id')::int;
|
|
105
|
+
|
|
106
|
+
-- Check access control
|
|
107
|
+
IF NOT venue_detail_can_subscribe(p_user_id, p_params) THEN
|
|
108
|
+
RAISE EXCEPTION 'Permission denied';
|
|
109
|
+
END IF;
|
|
110
|
+
|
|
111
|
+
-- Build document with root and all relations
|
|
112
|
+
SELECT jsonb_build_object(
|
|
113
|
+
'venues', row_to_json(root.*),
|
|
114
|
+
'org', (SELECT row_to_json(o.*) FROM organisations o WHERE o.id = root.org_id),
|
|
115
|
+
'sites', (SELECT jsonb_agg(s.*) FROM sites s WHERE s.venue_id = root.id),
|
|
116
|
+
'packages', (
|
|
117
|
+
SELECT jsonb_agg(
|
|
118
|
+
jsonb_build_object(
|
|
119
|
+
'package', row_to_json(p.*),
|
|
120
|
+
'allocations', (SELECT jsonb_agg(a.*) FROM allocations a WHERE a.package_id = p.id)
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
FROM packages p WHERE p.venue_id = root.id
|
|
124
|
+
)
|
|
125
|
+
)
|
|
126
|
+
INTO v_result
|
|
127
|
+
FROM venues root
|
|
128
|
+
WHERE root.id = v_venue_id;
|
|
129
|
+
|
|
130
|
+
RETURN v_result;
|
|
131
|
+
END;
|
|
132
|
+
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### 3. Affected Documents: `<name>_affected_documents(table, op, old, new)`
|
|
136
|
+
|
|
137
|
+
```sql
|
|
138
|
+
CREATE OR REPLACE FUNCTION venue_detail_affected_documents(
|
|
139
|
+
p_table_name TEXT,
|
|
140
|
+
p_op TEXT,
|
|
141
|
+
p_old JSONB,
|
|
142
|
+
p_new JSONB
|
|
143
|
+
) RETURNS JSONB[] AS $$
|
|
144
|
+
DECLARE
|
|
145
|
+
v_affected JSONB[];
|
|
146
|
+
BEGIN
|
|
147
|
+
CASE p_table_name
|
|
148
|
+
-- Venue changed: affects subscription for that venue
|
|
149
|
+
WHEN 'venues' THEN
|
|
150
|
+
v_affected := ARRAY[
|
|
151
|
+
jsonb_build_object('venue_id', COALESCE(p_new->>'id', p_old->>'id')::int)
|
|
152
|
+
];
|
|
153
|
+
|
|
154
|
+
-- Organisation changed: affects all venues in that org
|
|
155
|
+
WHEN 'organisations' THEN
|
|
156
|
+
SELECT ARRAY_AGG(jsonb_build_object('venue_id', v.id))
|
|
157
|
+
INTO v_affected
|
|
158
|
+
FROM venues v
|
|
159
|
+
WHERE v.org_id = COALESCE((p_new->>'id')::int, (p_old->>'id')::int);
|
|
160
|
+
|
|
161
|
+
-- Site changed: affects parent venue
|
|
162
|
+
WHEN 'sites' THEN
|
|
163
|
+
v_affected := ARRAY[
|
|
164
|
+
jsonb_build_object('venue_id', COALESCE(p_new->>'venue_id', p_old->>'venue_id')::int)
|
|
165
|
+
];
|
|
166
|
+
|
|
167
|
+
-- Package changed: affects parent venue
|
|
168
|
+
WHEN 'packages' THEN
|
|
169
|
+
v_affected := ARRAY[
|
|
170
|
+
jsonb_build_object('venue_id', COALESCE(p_new->>'venue_id', p_old->>'venue_id')::int)
|
|
171
|
+
];
|
|
172
|
+
|
|
173
|
+
-- Allocation changed: affects venue via package
|
|
174
|
+
WHEN 'allocations' THEN
|
|
175
|
+
SELECT ARRAY_AGG(jsonb_build_object('venue_id', p.venue_id))
|
|
176
|
+
INTO v_affected
|
|
177
|
+
FROM packages p
|
|
178
|
+
WHERE p.id = COALESCE((p_new->>'package_id')::int, (p_old->>'package_id')::int);
|
|
179
|
+
|
|
180
|
+
ELSE
|
|
181
|
+
v_affected := ARRAY[]::JSONB[];
|
|
182
|
+
END CASE;
|
|
183
|
+
|
|
184
|
+
RETURN v_affected;
|
|
185
|
+
END;
|
|
186
|
+
$$ LANGUAGE plpgsql IMMUTABLE;
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
---
|
|
190
|
+
|
|
191
|
+
## Server Implementation (In-Memory)
|
|
192
|
+
|
|
193
|
+
### Subscription Registry
|
|
194
|
+
|
|
195
|
+
```javascript
|
|
196
|
+
// In-memory storage
|
|
197
|
+
const subscriptions = new Map();
|
|
198
|
+
// subscription_id -> { subscribable, user_id, connection_id, params }
|
|
199
|
+
|
|
200
|
+
const connectionSubscriptions = new Map();
|
|
201
|
+
// connection_id -> Set<subscription_id>
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
### RPC Handlers
|
|
205
|
+
|
|
206
|
+
```javascript
|
|
207
|
+
// Pattern matching on method names
|
|
208
|
+
if (method.startsWith('subscribe_')) {
|
|
209
|
+
const subscribableName = method.replace('subscribe_', '');
|
|
210
|
+
const subscriptionId = crypto.randomUUID();
|
|
211
|
+
|
|
212
|
+
// Execute initial query (checks permissions)
|
|
213
|
+
const data = await db.query(
|
|
214
|
+
`SELECT get_${subscribableName}($1, $2) as data`,
|
|
215
|
+
[params, userId]
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
// Store in memory
|
|
219
|
+
subscriptions.set(subscriptionId, {
|
|
220
|
+
subscribable: subscribableName,
|
|
221
|
+
user_id: userId,
|
|
222
|
+
connection_id: connectionId,
|
|
223
|
+
params
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
subscription_id: subscriptionId,
|
|
228
|
+
data: data.rows[0].data
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (method.startsWith('unsubscribe_')) {
|
|
233
|
+
// Remove from in-memory registry
|
|
234
|
+
// Find and delete by params + connection
|
|
235
|
+
}
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
### Event Listener
|
|
239
|
+
|
|
240
|
+
```javascript
|
|
241
|
+
setupListeners(async (event) => {
|
|
242
|
+
const { table, op, before, after } = event;
|
|
243
|
+
|
|
244
|
+
// EXISTING: Pattern 2 - Need to Know notifications
|
|
245
|
+
broadcast(...);
|
|
246
|
+
|
|
247
|
+
// NEW: Pattern 1 - Live Query subscriptions
|
|
248
|
+
|
|
249
|
+
// Group subscriptions by subscribable name
|
|
250
|
+
const subsByName = new Map();
|
|
251
|
+
for (const [subId, sub] of subscriptions.entries()) {
|
|
252
|
+
if (!subsByName.has(sub.subscribable)) {
|
|
253
|
+
subsByName.set(sub.subscribable, []);
|
|
254
|
+
}
|
|
255
|
+
subsByName.get(sub.subscribable).push({ subId, ...sub });
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// For each subscribable, ask PostgreSQL which instances are affected
|
|
259
|
+
for (const [subscribableName, subs] of subsByName.entries()) {
|
|
260
|
+
const result = await db.query(
|
|
261
|
+
`SELECT ${subscribableName}_affected_documents($1, $2, $3, $4) as affected`,
|
|
262
|
+
[table, op, before, after]
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
const affectedParamSets = result.rows[0]?.affected || [];
|
|
266
|
+
|
|
267
|
+
// Match affected params to active subscriptions (in-memory)
|
|
268
|
+
for (const affectedParams of affectedParamSets) {
|
|
269
|
+
for (const sub of subs) {
|
|
270
|
+
if (paramsMatch(sub.params, affectedParams)) {
|
|
271
|
+
// Re-execute query
|
|
272
|
+
const updated = await db.query(
|
|
273
|
+
`SELECT get_${subscribableName}($1, $2) as data`,
|
|
274
|
+
[sub.params, sub.user_id]
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
// Broadcast to connection
|
|
278
|
+
broadcastToConnection(sub.connection_id, {
|
|
279
|
+
method: 'subscription:update',
|
|
280
|
+
params: {
|
|
281
|
+
subscription_id: sub.subId,
|
|
282
|
+
data: updated.rows[0].data
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
---
|
|
293
|
+
|
|
294
|
+
## Client API
|
|
295
|
+
|
|
296
|
+
```javascript
|
|
297
|
+
// Subscribe
|
|
298
|
+
const { data, unsubscribe } = await ws.api.subscribe_venue_detail(
|
|
299
|
+
{ venue_id: 1 },
|
|
300
|
+
(updated) => {
|
|
301
|
+
console.log('Venue updated:', updated);
|
|
302
|
+
// Update UI
|
|
303
|
+
}
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
// Initial data available immediately
|
|
307
|
+
console.log('Initial:', data);
|
|
308
|
+
|
|
309
|
+
// Unsubscribe
|
|
310
|
+
unsubscribe();
|
|
311
|
+
|
|
312
|
+
// Or call directly
|
|
313
|
+
await ws.api.unsubscribe_venue_detail({ venue_id: 1 });
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
---
|
|
317
|
+
|
|
318
|
+
## Implementation Status
|
|
319
|
+
|
|
320
|
+
### ✅ Phase 1: Compiler (COMPLETE)
|
|
321
|
+
|
|
322
|
+
**Files Created:**
|
|
323
|
+
- `/packages/dzql/src/compiler/codegen/subscribable-codegen.js` - Code generation
|
|
324
|
+
- `/packages/dzql/src/compiler/parser/subscribable-parser.js` - SQL parsing
|
|
325
|
+
- `/packages/dzql/src/compiler/compiler.js` - Extended with subscribable support
|
|
326
|
+
|
|
327
|
+
**Exports:**
|
|
328
|
+
- `compileSubscribable(subscribable)` - Compile single subscribable
|
|
329
|
+
- `compileAllSubscribables(subscribables[])` - Compile multiple
|
|
330
|
+
- `compileSubscribablesFromSQL(sqlContent)` - Parse and compile from SQL file
|
|
331
|
+
|
|
332
|
+
**Generated Functions:**
|
|
333
|
+
- `<name>_can_subscribe(user_id, params)` - Access control
|
|
334
|
+
- `get_<name>(params, user_id)` - Query function
|
|
335
|
+
- `<name>_affected_documents(table, op, old, new)` - Affected params
|
|
336
|
+
|
|
337
|
+
**Known Issue:**
|
|
338
|
+
- Parser needs improvement for nested `jsonb_build_object()` calls
|
|
339
|
+
- Test compilation failing on parameter splitting
|
|
340
|
+
|
|
341
|
+
---
|
|
342
|
+
|
|
343
|
+
### 🔨 Phase 2: Database Schema (TODO)
|
|
344
|
+
|
|
345
|
+
**Tasks:**
|
|
346
|
+
1. Create `dzql.subscribables` table (metadata only)
|
|
347
|
+
2. Create `register_subscribable()` SQL function
|
|
348
|
+
3. Migration file: `011_subscriptions.sql`
|
|
349
|
+
|
|
350
|
+
**Schema:**
|
|
351
|
+
```sql
|
|
352
|
+
CREATE TABLE IF NOT EXISTS dzql.subscribables (
|
|
353
|
+
name TEXT PRIMARY KEY,
|
|
354
|
+
permission_paths jsonb NOT NULL,
|
|
355
|
+
param_schema jsonb NOT NULL,
|
|
356
|
+
root_entity text NOT NULL,
|
|
357
|
+
relations jsonb NOT NULL,
|
|
358
|
+
created_at timestamptz DEFAULT NOW()
|
|
359
|
+
);
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
---
|
|
363
|
+
|
|
364
|
+
### 🔨 Phase 3: Server Integration (TODO)
|
|
365
|
+
|
|
366
|
+
**Files to Modify:**
|
|
367
|
+
1. `/packages/dzql/src/server/ws.js`
|
|
368
|
+
- Add in-memory subscription registry
|
|
369
|
+
- Add `subscribe_*` / `unsubscribe_*` handlers
|
|
370
|
+
- Add `broadcastToConnection()` function
|
|
371
|
+
|
|
372
|
+
2. `/packages/dzql/src/server/index.js`
|
|
373
|
+
- Extend event listener to check affected subscriptions
|
|
374
|
+
- Re-execute queries and broadcast updates
|
|
375
|
+
|
|
376
|
+
**Estimated Effort:** 2-3 days
|
|
377
|
+
|
|
378
|
+
---
|
|
379
|
+
|
|
380
|
+
### 🔨 Phase 4: Client Integration (TODO)
|
|
381
|
+
|
|
382
|
+
**Files to Modify:**
|
|
383
|
+
1. `/packages/dzql/src/client/ws.js`
|
|
384
|
+
- Add `subscribe_*` method handling
|
|
385
|
+
- Handle `subscription:update` messages
|
|
386
|
+
- Return `{ data, unsubscribe }` pattern
|
|
387
|
+
|
|
388
|
+
**Estimated Effort:** 1-2 days
|
|
389
|
+
|
|
390
|
+
---
|
|
391
|
+
|
|
392
|
+
## Testing Strategy
|
|
393
|
+
|
|
394
|
+
### Unit Tests (Compiler)
|
|
395
|
+
```javascript
|
|
396
|
+
test('generates subscribable functions', () => {
|
|
397
|
+
const result = compileSubscribable({
|
|
398
|
+
name: 'venue_detail',
|
|
399
|
+
permissionPaths: { subscribe: ['@org_id->acts_for...'] },
|
|
400
|
+
paramSchema: { venue_id: 'int' },
|
|
401
|
+
rootEntity: 'venues',
|
|
402
|
+
relations: { org: 'organisations', sites: 'sites' }
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
expect(result.sql).toContain('venue_detail_can_subscribe');
|
|
406
|
+
expect(result.sql).toContain('get_venue_detail');
|
|
407
|
+
expect(result.sql).toContain('venue_detail_affected_documents');
|
|
408
|
+
});
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
### Integration Tests (Database)
|
|
412
|
+
```sql
|
|
413
|
+
-- Test affected documents function
|
|
414
|
+
SELECT venue_detail_affected_documents(
|
|
415
|
+
'venues', 'update',
|
|
416
|
+
'{"id": 1, "name": "Old"}'::jsonb,
|
|
417
|
+
'{"id": 1, "name": "New"}'::jsonb
|
|
418
|
+
);
|
|
419
|
+
-- Should return: [{"venue_id": 1}]
|
|
420
|
+
|
|
421
|
+
-- Test query function
|
|
422
|
+
SELECT get_venue_detail('{"venue_id": 1}'::jsonb, 5);
|
|
423
|
+
-- Should return denormalized document
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
### E2E Tests
|
|
427
|
+
```javascript
|
|
428
|
+
test('subscription receives updates', async () => {
|
|
429
|
+
const updates = [];
|
|
430
|
+
|
|
431
|
+
const { data, unsubscribe } = await ws.api.subscribe_venue_detail(
|
|
432
|
+
{ venue_id: 1 },
|
|
433
|
+
(updated) => updates.push(updated)
|
|
434
|
+
);
|
|
435
|
+
|
|
436
|
+
// Trigger change
|
|
437
|
+
await ws.api.save.venues({ id: 1, name: 'Updated' });
|
|
438
|
+
|
|
439
|
+
await waitFor(() => updates.length > 0);
|
|
440
|
+
expect(updates[0].venues.name).toBe('Updated');
|
|
441
|
+
|
|
442
|
+
unsubscribe();
|
|
443
|
+
});
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
---
|
|
447
|
+
|
|
448
|
+
## Next Steps
|
|
449
|
+
|
|
450
|
+
1. **Fix Parser** - Handle nested `jsonb_build_object()` correctly
|
|
451
|
+
2. **Test Compilation** - Verify generated SQL is correct
|
|
452
|
+
3. **Create Migration** - `011_subscriptions.sql` with schema
|
|
453
|
+
4. **Implement Server Handlers** - In-memory subscriptions + event processing
|
|
454
|
+
5. **Implement Client Support** - `subscribe_*` methods
|
|
455
|
+
6. **Integration Testing** - End-to-end subscription flow
|
|
456
|
+
7. **Documentation** - API docs and examples
|
|
457
|
+
|
|
458
|
+
---
|
|
459
|
+
|
|
460
|
+
## Estimated Timeline
|
|
461
|
+
|
|
462
|
+
| Phase | Tasks | Effort |
|
|
463
|
+
|-------|-------|--------|
|
|
464
|
+
| Phase 1: Compiler | ✅ Complete | 4 hours |
|
|
465
|
+
| Phase 2: Database | Schema + migration | 1 day |
|
|
466
|
+
| Phase 3: Server | Handlers + event processing | 2-3 days |
|
|
467
|
+
| Phase 4: Client | Client API | 1-2 days |
|
|
468
|
+
| Testing | Unit + Integration + E2E | 2-3 days |
|
|
469
|
+
| **Total** | | **7-10 days** |
|
|
470
|
+
|
|
471
|
+
---
|
|
472
|
+
|
|
473
|
+
## Success Criteria
|
|
474
|
+
|
|
475
|
+
- ✅ Compiler generates 3 PostgreSQL functions per subscribable
|
|
476
|
+
- ⏳ PostgreSQL determines affected subscription instances (not server)
|
|
477
|
+
- ⏳ Server holds subscriptions in-memory (fast lookup)
|
|
478
|
+
- ⏳ Naming convention: `subscribe_<name>` / `unsubscribe_<name>`
|
|
479
|
+
- ⏳ Client receives automatic updates on data changes
|
|
480
|
+
- ⏳ < 100ms latency from DB change to client update
|
|
481
|
+
- ⏳ Supports 1000+ concurrent subscriptions per server
|
|
482
|
+
- ⏳ Zero runtime interpretation (all logic compiled)
|
|
483
|
+
|
|
484
|
+
---
|
|
485
|
+
|
|
486
|
+
**Implementation by:** Claude Sonnet 4.5
|
|
487
|
+
**Project:** DZQL Live Query Subscriptions
|
|
488
|
+
**Version:** 0.1.4+subscriptions
|
package/docs/REFERENCE.md
CHANGED
|
@@ -12,6 +12,7 @@ Complete API documentation for DZQL framework. For tutorials, see [GETTING_START
|
|
|
12
12
|
- [Custom Functions](#custom-functions)
|
|
13
13
|
- [Authentication](#authentication)
|
|
14
14
|
- [Real-time Events](#real-time-events)
|
|
15
|
+
- [Live Query Subscriptions](#live-query-subscriptions)
|
|
15
16
|
- [Temporal Relationships](#temporal-relationships)
|
|
16
17
|
- [Error Messages](#error-messages)
|
|
17
18
|
|
|
@@ -864,6 +865,144 @@ ws.onBroadcast((method, params) => {
|
|
|
864
865
|
|
|
865
866
|
---
|
|
866
867
|
|
|
868
|
+
## Live Query Subscriptions
|
|
869
|
+
|
|
870
|
+
Subscribe to denormalized documents and receive automatic updates when underlying data changes. Subscriptions use a PostgreSQL-first architecture where all change detection happens in the database.
|
|
871
|
+
|
|
872
|
+
For complete documentation, see **[Live Query Subscriptions Guide](../../../docs/LIVE_QUERY_SUBSCRIPTIONS.md)** and **[Quick Start](../../../docs/SUBSCRIPTIONS_QUICK_START.md)**.
|
|
873
|
+
|
|
874
|
+
### Quick Example
|
|
875
|
+
|
|
876
|
+
```javascript
|
|
877
|
+
// Subscribe to venue with all related data
|
|
878
|
+
const { data, unsubscribe } = await ws.api.subscribe_venue_detail(
|
|
879
|
+
{ venue_id: 123 },
|
|
880
|
+
(updatedVenue) => {
|
|
881
|
+
// Called automatically when venue, org, or sites change
|
|
882
|
+
console.log('Updated:', updatedVenue);
|
|
883
|
+
// updatedVenue = { id: 123, name: '...', org: {...}, sites: [...] }
|
|
884
|
+
}
|
|
885
|
+
);
|
|
886
|
+
|
|
887
|
+
// Initial data available immediately
|
|
888
|
+
console.log('Initial:', data);
|
|
889
|
+
|
|
890
|
+
// Later: cleanup
|
|
891
|
+
await unsubscribe();
|
|
892
|
+
```
|
|
893
|
+
|
|
894
|
+
### Creating a Subscribable
|
|
895
|
+
|
|
896
|
+
Define subscribables in SQL:
|
|
897
|
+
|
|
898
|
+
```sql
|
|
899
|
+
SELECT dzql.register_subscribable(
|
|
900
|
+
'venue_detail', -- Name
|
|
901
|
+
'{"subscribe": ["@org_id->acts_for[org_id=$]{active}.user_id"]}'::jsonb, -- Permissions
|
|
902
|
+
'{"venue_id": "int"}'::jsonb, -- Parameters
|
|
903
|
+
'venues', -- Root table
|
|
904
|
+
'{
|
|
905
|
+
"org": "organisations",
|
|
906
|
+
"sites": {"entity": "sites", "filter": "venue_id=$venue_id"}
|
|
907
|
+
}'::jsonb -- Relations
|
|
908
|
+
);
|
|
909
|
+
```
|
|
910
|
+
|
|
911
|
+
### Compile and Deploy
|
|
912
|
+
|
|
913
|
+
```bash
|
|
914
|
+
# Compile subscribable to PostgreSQL functions
|
|
915
|
+
bun packages/dzql/src/compiler/cli/compile-subscribable.js venue.sql | psql $DATABASE_URL
|
|
916
|
+
```
|
|
917
|
+
|
|
918
|
+
This generates three functions:
|
|
919
|
+
- `venue_detail_can_subscribe(user_id, params)` - Permission check
|
|
920
|
+
- `get_venue_detail(params, user_id)` - Query builder
|
|
921
|
+
- `venue_detail_affected_documents(table, op, old, new)` - Change detector
|
|
922
|
+
|
|
923
|
+
### Subscription Lifecycle
|
|
924
|
+
|
|
925
|
+
1. **Subscribe**: Client calls `ws.api.subscribe_<name>(params, callback)`
|
|
926
|
+
2. **Permission Check**: `<name>_can_subscribe()` validates access
|
|
927
|
+
3. **Initial Query**: `get_<name>()` returns denormalized document
|
|
928
|
+
4. **Register**: Server stores subscription in-memory
|
|
929
|
+
5. **Database Change**: Any relevant table modification
|
|
930
|
+
6. **Detect**: `<name>_affected_documents()` identifies affected subscriptions
|
|
931
|
+
7. **Re-query**: `get_<name>()` fetches fresh data
|
|
932
|
+
8. **Update**: Callback invoked with new data
|
|
933
|
+
|
|
934
|
+
### Unsubscribe
|
|
935
|
+
|
|
936
|
+
```javascript
|
|
937
|
+
// Method 1: Use returned unsubscribe function
|
|
938
|
+
const { unsubscribe } = await ws.api.subscribe_venue_detail(...);
|
|
939
|
+
await unsubscribe();
|
|
940
|
+
|
|
941
|
+
// Method 2: Direct unsubscribe call
|
|
942
|
+
await ws.api.unsubscribe_venue_detail({ venue_id: 123 });
|
|
943
|
+
```
|
|
944
|
+
|
|
945
|
+
### Architecture Benefits
|
|
946
|
+
|
|
947
|
+
- **PostgreSQL-First**: All logic executes in database, not application code
|
|
948
|
+
- **Zero Configuration**: Pattern matching on method names - no server changes needed
|
|
949
|
+
- **Type Safe**: Compiled functions validated at deploy time
|
|
950
|
+
- **Efficient**: In-memory registry, PostgreSQL does matching
|
|
951
|
+
- **Secure**: Permission paths enforced at database level
|
|
952
|
+
- **Scalable**: Stateless server, can add instances freely
|
|
953
|
+
|
|
954
|
+
### Common Patterns
|
|
955
|
+
|
|
956
|
+
**Single Table:**
|
|
957
|
+
```sql
|
|
958
|
+
SELECT dzql.register_subscribable(
|
|
959
|
+
'user_settings',
|
|
960
|
+
'{"subscribe": ["@user_id"]}'::jsonb,
|
|
961
|
+
'{"user_id": "int"}'::jsonb,
|
|
962
|
+
'user_settings',
|
|
963
|
+
'{}'::jsonb
|
|
964
|
+
);
|
|
965
|
+
```
|
|
966
|
+
|
|
967
|
+
**With Relations:**
|
|
968
|
+
```sql
|
|
969
|
+
SELECT dzql.register_subscribable(
|
|
970
|
+
'booking_detail',
|
|
971
|
+
'{"subscribe": ["@user_id"]}'::jsonb,
|
|
972
|
+
'{"booking_id": "int"}'::jsonb,
|
|
973
|
+
'bookings',
|
|
974
|
+
'{
|
|
975
|
+
"venue": "venues",
|
|
976
|
+
"customer": "users",
|
|
977
|
+
"items": {"entity": "booking_items", "filter": "booking_id=$booking_id"}
|
|
978
|
+
}'::jsonb
|
|
979
|
+
);
|
|
980
|
+
```
|
|
981
|
+
|
|
982
|
+
**Multiple Permission Paths (OR logic):**
|
|
983
|
+
```sql
|
|
984
|
+
SELECT dzql.register_subscribable(
|
|
985
|
+
'venue_admin',
|
|
986
|
+
'{
|
|
987
|
+
"subscribe": [
|
|
988
|
+
"@owner_id",
|
|
989
|
+
"@org_id->acts_for[org_id=$]{active}.user_id"
|
|
990
|
+
]
|
|
991
|
+
}'::jsonb,
|
|
992
|
+
'{"venue_id": "int"}'::jsonb,
|
|
993
|
+
'venues',
|
|
994
|
+
'{"sites": {"entity": "sites", "filter": "venue_id=$venue_id"}}'::jsonb
|
|
995
|
+
);
|
|
996
|
+
```
|
|
997
|
+
|
|
998
|
+
### See Also
|
|
999
|
+
|
|
1000
|
+
- **[Live Query Subscriptions Guide](../../../docs/LIVE_QUERY_SUBSCRIPTIONS.md)** - Complete reference
|
|
1001
|
+
- **[Quick Start Guide](../../../docs/SUBSCRIPTIONS_QUICK_START.md)** - 5-minute tutorial
|
|
1002
|
+
- **[Permission Paths](#permission--notification-paths)** - Path DSL syntax
|
|
1003
|
+
|
|
1004
|
+
---
|
|
1005
|
+
|
|
867
1006
|
## Temporal Relationships
|
|
868
1007
|
|
|
869
1008
|
Handle time-based relationships with `valid_from`/`valid_to` fields.
|