dzql 0.2.0 → 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.
@@ -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
@@ -912,7 +912,7 @@ SELECT dzql.register_subscribable(
912
912
 
913
913
  ```bash
914
914
  # Compile subscribable to PostgreSQL functions
915
- node packages/dzql/compile-subscribable.js venue.sql | psql $DATABASE_URL
915
+ bun packages/dzql/src/compiler/cli/compile-subscribable.js venue.sql | psql $DATABASE_URL
916
916
  ```
917
917
 
918
918
  This generates three functions:
@@ -0,0 +1,203 @@
1
+ # Live Query Subscriptions - Quick Start
2
+
3
+ Get up and running with live query subscriptions in 5 minutes.
4
+
5
+ ## Step 1: Create a Subscribable (2 min)
6
+
7
+ Create `my_subscribable.sql`:
8
+
9
+ ```sql
10
+ SELECT dzql.register_subscribable(
11
+ 'venue_detail', -- Name (use in API)
12
+ '{"subscribe": ["@org_id->acts_for[org_id=$]{active}.user_id"]}'::jsonb, -- Who can subscribe
13
+ '{"venue_id": "int"}'::jsonb, -- Subscription parameters
14
+ 'venues', -- Root table
15
+ '{"org": "organisations", "sites": {"entity": "sites", "filter": "venue_id=$venue_id"}}'::jsonb -- Related data
16
+ );
17
+ ```
18
+
19
+ ## Step 2: Compile and Deploy (1 min)
20
+
21
+ ```bash
22
+ # Compile to PostgreSQL functions
23
+ bun packages/dzql/src/compiler/cli/compile-subscribable.js my_subscribable.sql | psql $DATABASE_URL
24
+ ```
25
+
26
+ This creates 3 functions:
27
+ - `venue_detail_can_subscribe(user_id, params)` - permission check
28
+ - `get_venue_detail(params, user_id)` - query builder
29
+ - `venue_detail_affected_documents(table, op, old, new)` - change detector
30
+
31
+ ## Step 3: Subscribe from Client (2 min)
32
+
33
+ ```javascript
34
+ import { WebSocketManager } from '@dzql/client';
35
+
36
+ const ws = new WebSocketManager('ws://localhost:3000/ws');
37
+ await ws.connect();
38
+
39
+ // Subscribe - get initial data + live updates
40
+ const { data, unsubscribe } = await ws.api.subscribe_venue_detail(
41
+ { venue_id: 123 },
42
+ (updatedData) => {
43
+ console.log('Venue changed!', updatedData);
44
+ }
45
+ );
46
+
47
+ console.log('Initial data:', data);
48
+
49
+ // Later: cleanup
50
+ await unsubscribe();
51
+ ```
52
+
53
+ ## That's It!
54
+
55
+ Your client now receives real-time updates whenever:
56
+ - The venue record changes
57
+ - Related organisation changes
58
+ - Related sites change
59
+
60
+ All change detection happens in PostgreSQL - zero configuration needed on the server!
61
+
62
+ ## Next Steps
63
+
64
+ - [Full Documentation](./LIVE_QUERY_SUBSCRIPTIONS.md)
65
+ - [Permission Paths Guide](./PATH_DSL.md)
66
+ - [Example Subscribables](../packages/dzql/examples/subscribables/)
67
+
68
+ ## Common Patterns
69
+
70
+ ### Simple Document (Single Table)
71
+
72
+ ```sql
73
+ SELECT dzql.register_subscribable(
74
+ 'user_settings',
75
+ '{"subscribe": ["@user_id"]}'::jsonb, -- Only owner
76
+ '{"user_id": "int"}'::jsonb,
77
+ 'user_settings',
78
+ '{}'::jsonb -- No relations
79
+ );
80
+ ```
81
+
82
+ ### With One Relation
83
+
84
+ ```sql
85
+ SELECT dzql.register_subscribable(
86
+ 'booking_summary',
87
+ '{"subscribe": ["@user_id"]}'::jsonb,
88
+ '{"booking_id": "int"}'::jsonb,
89
+ 'bookings',
90
+ '{"venue": "venues"}'::jsonb -- Include venue
91
+ );
92
+ ```
93
+
94
+ ### With Filtered Relations
95
+
96
+ ```sql
97
+ SELECT dzql.register_subscribable(
98
+ 'organisation_dashboard',
99
+ '{"subscribe": ["@id->acts_for[org_id=$]{active}.user_id"]}'::jsonb,
100
+ '{"org_id": "int"}'::jsonb,
101
+ 'organisations',
102
+ '{
103
+ "members": {
104
+ "entity": "acts_for",
105
+ "filter": "org_id=$org_id AND valid_to IS NULL"
106
+ },
107
+ "venues": {
108
+ "entity": "venues",
109
+ "filter": "org_id=$org_id"
110
+ }
111
+ }'::jsonb
112
+ );
113
+ ```
114
+
115
+ ### Multiple Permission Paths (OR logic)
116
+
117
+ ```sql
118
+ SELECT dzql.register_subscribable(
119
+ 'venue_admin',
120
+ '{
121
+ "subscribe": [
122
+ "@owner_id", -- Direct owner
123
+ "@org_id->acts_for[org_id=$]{active}.user_id" -- OR org member
124
+ ]
125
+ }'::jsonb,
126
+ '{"venue_id": "int"}'::jsonb,
127
+ 'venues',
128
+ '{"sites": {"entity": "sites", "filter": "venue_id=$venue_id"}}'::jsonb
129
+ );
130
+ ```
131
+
132
+ ## Debugging Tips
133
+
134
+ ### Test the functions manually:
135
+
136
+ ```sql
137
+ -- Check permission
138
+ SELECT venue_detail_can_subscribe(1, '{"venue_id": 123}'::jsonb);
139
+
140
+ -- Get data
141
+ SELECT get_venue_detail('{"venue_id": 123}'::jsonb, 1);
142
+
143
+ -- Test change detection
144
+ SELECT venue_detail_affected_documents(
145
+ 'venues',
146
+ 'update',
147
+ '{"id": 123}'::jsonb,
148
+ '{"id": 123, "name": "New"}'::jsonb
149
+ );
150
+ ```
151
+
152
+ ### Check active subscriptions:
153
+
154
+ ```javascript
155
+ // Client-side
156
+ console.log('My subscriptions:', ws.subscriptions.size);
157
+ ```
158
+
159
+ ## FAQ
160
+
161
+ **Q: When should I use subscriptions vs. simple queries?**
162
+ A: Use subscriptions when data changes frequently and client needs to stay in sync. Use simple queries for one-time lookups.
163
+
164
+ **Q: What happens when client disconnects?**
165
+ A: Server automatically cleans up all subscriptions for that connection.
166
+
167
+ **Q: Can multiple clients subscribe to the same data?**
168
+ A: Yes! Each subscription is independent. All will receive updates.
169
+
170
+ **Q: How do I update the subscribable definition?**
171
+ A: Re-compile and deploy. The `register_subscribable()` call uses `ON CONFLICT UPDATE`, so it's safe to run repeatedly.
172
+
173
+ **Q: What if the underlying data is deleted?**
174
+ A: The `get_<name>()` function returns `null`. Handle this in your callback:
175
+ ```javascript
176
+ (data) => {
177
+ if (!data) {
178
+ console.log('Record was deleted');
179
+ return;
180
+ }
181
+ updateUI(data);
182
+ }
183
+ ```
184
+
185
+ **Q: How do I subscribe to a list of items?**
186
+ A: Create a subscribable with array parameters or use multiple subscriptions. For dashboard-style views, consider a single subscribable that returns an array.
187
+
188
+ ## Performance Tips
189
+
190
+ 1. **Index your joins**: Make sure foreign keys are indexed
191
+ 2. **Keep _affected_documents() simple**: Early return for unrelated tables
192
+ 3. **Limit relation depth**: Avoid deeply nested relations (max 2-3 levels)
193
+ 4. **Use specific subscription keys**: `venue_id` is better than `org_id` (fewer false positives)
194
+ 5. **Unsubscribe when done**: Always cleanup to free server resources
195
+
196
+ ## Architecture Benefits
197
+
198
+ - ✅ **PostgreSQL-First**: All logic in database, not application code
199
+ - ✅ **Zero Configuration**: No server changes needed for new subscribables
200
+ - ✅ **Type Safe**: Compiled functions validated at deploy time
201
+ - ✅ **Efficient**: In-memory registry, PostgreSQL does matching
202
+ - ✅ **Secure**: Permission paths enforced at database level
203
+ - ✅ **Scalable**: Stateless server, can add instances freely