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 CHANGED
@@ -1,11 +1,12 @@
1
1
  # DZQL
2
2
 
3
- PostgreSQL-powered framework with automatic CRUD operations and real-time WebSocket synchronization.
3
+ PostgreSQL-powered framework with automatic CRUD operations, live query subscriptions, and real-time WebSocket synchronization.
4
4
 
5
5
  ## Documentation
6
6
 
7
7
  - **[Getting Started Guide](docs/GETTING_STARTED.md)** - Complete tutorial with working todo app
8
8
  - **[API Reference](docs/REFERENCE.md)** - Complete API documentation
9
+ - **[Live Query Subscriptions](../../docs/SUBSCRIPTIONS_QUICK_START.md)** - Real-time denormalized documents (NEW in v0.2.0)
9
10
  - **[Compiler Documentation](docs/compiler/)** - Entity compilation guide and coding standards
10
11
  - **[Claude Guide](docs/CLAUDE.md)** - Development guide for AI assistants
11
12
  - **[Venues Example](../venues/)** - Full working application
@@ -29,6 +30,12 @@ await ws.connect();
29
30
  // All 5 operations work automatically
30
31
  const user = await ws.api.save.users({ name: 'Alice' });
31
32
  const results = await ws.api.search.users({ filters: { name: 'alice' } });
33
+
34
+ // NEW in v0.2.0: Live query subscriptions
35
+ const { data, unsubscribe } = await ws.api.subscribe_venue_detail(
36
+ { venue_id: 123 },
37
+ (updated) => console.log('Venue changed!', updated)
38
+ );
32
39
  ```
33
40
 
34
41
  ## DZQL Compiler
@@ -48,6 +55,21 @@ const result = compiler.compileFromSQL(sqlContent);
48
55
 
49
56
  See **[Compiler Documentation](docs/compiler/)** for complete usage guide, coding standards, and advanced features.
50
57
 
58
+ ## Testing
59
+
60
+ ```bash
61
+ # Start test database
62
+ cd tests/test-utils && docker compose up -d
63
+
64
+ # Run tests
65
+ bun test
66
+
67
+ # Stop database
68
+ cd tests/test-utils && docker compose down
69
+ ```
70
+
71
+ All tests use `bun:test` framework with automatic database setup/teardown. See **[tests/test-utils/README.md](tests/test-utils/README.md)** for details.
72
+
51
73
  ## License
52
74
 
53
75
  MIT
@@ -0,0 +1,535 @@
1
+ # Live Query Subscriptions
2
+
3
+ Live Query Subscriptions (Pattern 1 from vision.md) enable clients to subscribe to denormalized documents and receive real-time updates when the underlying data changes.
4
+
5
+ ## Overview
6
+
7
+ ### Architecture Principles
8
+
9
+ - **PostgreSQL-First**: All matching logic is compiled to PostgreSQL functions, not JavaScript
10
+ - **In-Memory Registry**: Server holds active subscriptions in memory for performance
11
+ - **Zero Runtime Interpretation**: All logic pre-compiled during deployment
12
+ - **Denormalized Documents**: Subscribables combine data from multiple tables into client-friendly views
13
+
14
+ ### How It Works
15
+
16
+ 1. **Define Subscribable**: Register a subscribable with permissions, parameters, and relations
17
+ 2. **Compile**: Generate three PostgreSQL functions:
18
+ - `<name>_can_subscribe(user_id, params)` - Permission check
19
+ - `get_<name>(params, user_id)` - Query function
20
+ - `<name>_affected_documents(table, op, old, new)` - Change detection
21
+ 3. **Subscribe**: Client calls `ws.api.subscribe_<name>(params, callback)`
22
+ 4. **Update**: Database changes trigger NOTIFY → server asks PostgreSQL which subscriptions are affected → server re-queries and sends updates
23
+
24
+ ## Quick Start
25
+
26
+ ### 1. Define a Subscribable
27
+
28
+ Create a SQL file with your subscribable definition:
29
+
30
+ ```sql
31
+ -- examples/subscribables/venue_detail.sql
32
+ SELECT dzql.register_subscribable(
33
+ 'venue_detail',
34
+ '{"subscribe": ["@org_id->acts_for[org_id=$]{active}.user_id"]}'::jsonb,
35
+ '{"venue_id": "int"}'::jsonb,
36
+ 'venues',
37
+ '{
38
+ "org": "organisations",
39
+ "sites": {
40
+ "entity": "sites",
41
+ "filter": "venue_id=$venue_id"
42
+ }
43
+ }'::jsonb
44
+ );
45
+ ```
46
+
47
+ **Parameters:**
48
+ - `name`: Identifier used in API calls (e.g., `venue_detail`)
49
+ - `permission_paths`: Access control using path DSL
50
+ - `param_schema`: Parameters required to subscribe (subscription key)
51
+ - `root_entity`: Primary table
52
+ - `relations`: Related entities to include in the document
53
+
54
+ ### 2. Compile and Deploy
55
+
56
+ ```bash
57
+ # Compile subscribable to SQL functions
58
+ bun packages/dzql/src/compiler/cli/compile-subscribable.js \
59
+ examples/subscribables/venue_detail.sql \
60
+ > /tmp/venue_detail.sql
61
+
62
+ # Deploy to database
63
+ psql $DATABASE_URL < /tmp/venue_detail.sql
64
+ ```
65
+
66
+ This generates three functions:
67
+ - `venue_detail_can_subscribe(user_id, params)`
68
+ - `get_venue_detail(params, user_id)`
69
+ - `venue_detail_affected_documents(table, op, old, new)`
70
+
71
+ ### 3. Client Usage
72
+
73
+ ```javascript
74
+ import { WebSocketManager } from '@dzql/client';
75
+
76
+ const ws = new WebSocketManager('ws://localhost:3000/ws');
77
+ await ws.connect();
78
+
79
+ // Subscribe to venue updates
80
+ const { data, subscription_id, unsubscribe } = await ws.api.subscribe_venue_detail(
81
+ { venue_id: 123 },
82
+ (updatedData) => {
83
+ console.log('Venue updated:', updatedData);
84
+ // updatedData = { id: 123, name: 'Venue Name', org: {...}, sites: [...] }
85
+ }
86
+ );
87
+
88
+ // Initial data is returned immediately
89
+ console.log('Initial venue data:', data);
90
+
91
+ // Later: unsubscribe when done
92
+ await unsubscribe();
93
+ ```
94
+
95
+ ## Subscribable Definition
96
+
97
+ ### Permission Paths
98
+
99
+ Control who can subscribe using the path DSL:
100
+
101
+ ```javascript
102
+ {
103
+ "subscribe": [
104
+ "@org_id->acts_for[org_id=$]{active}.user_id"
105
+ ]
106
+ }
107
+ ```
108
+
109
+ This allows users who:
110
+ - Have an active `acts_for` relationship
111
+ - Where `org_id` matches the venue's `org_id`
112
+
113
+ Multiple paths can be provided for OR logic:
114
+
115
+ ```javascript
116
+ {
117
+ "subscribe": [
118
+ "@owner_id", // Direct owner
119
+ "@org_id->acts_for[org_id=$]{active}.user_id" // OR org member
120
+ ]
121
+ }
122
+ ```
123
+
124
+ ### Parameter Schema
125
+
126
+ Define the subscription key (what makes each subscription unique):
127
+
128
+ ```javascript
129
+ {
130
+ "venue_id": "int"
131
+ }
132
+ ```
133
+
134
+ Clients must provide these parameters when subscribing.
135
+
136
+ ### Relations
137
+
138
+ Include related data in the denormalized document:
139
+
140
+ ```javascript
141
+ {
142
+ // Simple relation - include entire related record
143
+ "org": "organisations",
144
+
145
+ // Filtered relation - include sites filtered by venue_id
146
+ "sites": {
147
+ "entity": "sites",
148
+ "filter": "venue_id=$venue_id"
149
+ },
150
+
151
+ // Nested relations
152
+ "org": {
153
+ "entity": "organisations",
154
+ "relations": {
155
+ "users": {
156
+ "entity": "acts_for",
157
+ "filter": "org_id=$org_id AND valid_to IS NULL"
158
+ }
159
+ }
160
+ }
161
+ }
162
+ ```
163
+
164
+ ## Generated Functions
165
+
166
+ ### 1. Permission Check: `<name>_can_subscribe`
167
+
168
+ ```sql
169
+ CREATE FUNCTION venue_detail_can_subscribe(
170
+ p_user_id INT,
171
+ p_params JSONB
172
+ ) RETURNS BOOLEAN;
173
+ ```
174
+
175
+ Returns `true` if the user can subscribe with the given parameters.
176
+
177
+ Called automatically when client subscribes.
178
+
179
+ ### 2. Query Function: `get_<name>`
180
+
181
+ ```sql
182
+ CREATE FUNCTION get_venue_detail(
183
+ p_params JSONB,
184
+ p_user_id INT
185
+ ) RETURNS JSONB;
186
+ ```
187
+
188
+ Builds the denormalized document from the database.
189
+
190
+ Called:
191
+ - Initially when client subscribes (returns first data)
192
+ - After each change that affects the subscription (returns updated data)
193
+
194
+ ### 3. Change Detection: `<name>_affected_documents`
195
+
196
+ ```sql
197
+ CREATE FUNCTION venue_detail_affected_documents(
198
+ p_table TEXT,
199
+ p_op TEXT,
200
+ p_old JSONB,
201
+ p_new JSONB
202
+ ) RETURNS JSONB[];
203
+ ```
204
+
205
+ Determines which subscription instances are affected by a database change.
206
+
207
+ Returns array of parameter sets (subscription keys) that need updates.
208
+
209
+ Example:
210
+ ```sql
211
+ -- When venue 123 is updated
212
+ SELECT venue_detail_affected_documents(
213
+ 'venues',
214
+ 'update',
215
+ '{"id": 123, "name": "Old"}'::jsonb,
216
+ '{"id": 123, "name": "New"}'::jsonb
217
+ );
218
+ -- Returns: [{"venue_id": 123}]
219
+ ```
220
+
221
+ ## Server Integration
222
+
223
+ The server automatically:
224
+ 1. Handles `subscribe_<name>` and `unsubscribe_<name>` RPC calls
225
+ 2. Maintains in-memory subscription registry
226
+ 3. Listens to database NOTIFY events
227
+ 4. Calls `_affected_documents()` to find affected subscriptions
228
+ 5. Re-executes `get_<name>()` to get fresh data
229
+ 6. Sends updates to subscribed clients
230
+
231
+ No server code changes needed when adding new subscribables!
232
+
233
+ ## WebSocket Protocol
234
+
235
+ ### Subscribe
236
+
237
+ ```json
238
+ {
239
+ "jsonrpc": "2.0",
240
+ "id": 1,
241
+ "method": "subscribe_venue_detail",
242
+ "params": {
243
+ "venue_id": 123
244
+ }
245
+ }
246
+ ```
247
+
248
+ **Response:**
249
+ ```json
250
+ {
251
+ "jsonrpc": "2.0",
252
+ "id": 1,
253
+ "result": {
254
+ "subscription_id": "550e8400-e29b-41d4-a716-446655440000",
255
+ "data": {
256
+ "id": 123,
257
+ "name": "Venue Name",
258
+ "org": { "id": 1, "name": "Organization" },
259
+ "sites": [...]
260
+ }
261
+ }
262
+ }
263
+ ```
264
+
265
+ ### Updates
266
+
267
+ When data changes, server sends:
268
+
269
+ ```json
270
+ {
271
+ "jsonrpc": "2.0",
272
+ "method": "subscription:update",
273
+ "params": {
274
+ "subscription_id": "550e8400-e29b-41d4-a716-446655440000",
275
+ "subscribable": "venue_detail",
276
+ "data": {
277
+ "id": 123,
278
+ "name": "Updated Venue Name",
279
+ "org": { "id": 1, "name": "Organization" },
280
+ "sites": [...]
281
+ }
282
+ }
283
+ }
284
+ ```
285
+
286
+ Client's callback is invoked automatically with the new data.
287
+
288
+ ### Unsubscribe
289
+
290
+ ```json
291
+ {
292
+ "jsonrpc": "2.0",
293
+ "id": 2,
294
+ "method": "unsubscribe_venue_detail",
295
+ "params": {
296
+ "venue_id": 123
297
+ }
298
+ }
299
+ ```
300
+
301
+ Or call the returned `unsubscribe()` function:
302
+
303
+ ```javascript
304
+ const { unsubscribe } = await ws.api.subscribe_venue_detail(...);
305
+ await unsubscribe();
306
+ ```
307
+
308
+ ## Advanced Examples
309
+
310
+ ### User Profile with Nested Relations
311
+
312
+ ```sql
313
+ SELECT dzql.register_subscribable(
314
+ 'user_profile',
315
+ '{"subscribe": ["@id"]}'::jsonb,
316
+ '{"user_id": "int"}'::jsonb,
317
+ 'users',
318
+ '{
319
+ "organisations": {
320
+ "entity": "acts_for",
321
+ "filter": "user_id=$user_id AND valid_to IS NULL",
322
+ "relations": {
323
+ "org": "organisations"
324
+ }
325
+ },
326
+ "permissions": {
327
+ "entity": "user_permissions",
328
+ "filter": "user_id=$user_id"
329
+ }
330
+ }'::jsonb
331
+ );
332
+ ```
333
+
334
+ ### Multi-Parameter Subscription
335
+
336
+ ```sql
337
+ SELECT dzql.register_subscribable(
338
+ 'booking_detail',
339
+ '{"subscribe": ["@user_id", "@venue_id->venues.org_id->acts_for[org_id=$]{active}.user_id"]}'::jsonb,
340
+ '{"booking_id": "int", "venue_id": "int"}'::jsonb,
341
+ 'bookings',
342
+ '{
343
+ "venue": "venues",
344
+ "customer": "users",
345
+ "items": {
346
+ "entity": "booking_items",
347
+ "filter": "booking_id=$booking_id"
348
+ }
349
+ }'::jsonb
350
+ );
351
+ ```
352
+
353
+ ## Performance Considerations
354
+
355
+ ### In-Memory Registry
356
+
357
+ Active subscriptions are stored in-memory on the server:
358
+ - Fast lookup without database queries
359
+ - Automatically cleaned up when WebSocket closes
360
+ - Scale by adding more server instances (subscriptions are connection-local)
361
+
362
+ ### Change Detection
363
+
364
+ The `_affected_documents()` function runs for every database change:
365
+ - Keep logic simple and indexed
366
+ - Return only truly affected subscriptions
367
+ - Use early returns for unrelated tables
368
+
369
+ Example optimization:
370
+
371
+ ```sql
372
+ CREATE FUNCTION my_subscribable_affected_documents(...)
373
+ RETURNS JSONB[] AS $$
374
+ BEGIN
375
+ -- Early return for unrelated tables
376
+ IF p_table NOT IN ('venues', 'sites') THEN
377
+ RETURN ARRAY[]::JSONB[];
378
+ END IF;
379
+
380
+ -- Use indexed fields
381
+ IF p_table = 'venues' THEN
382
+ RETURN ARRAY[jsonb_build_object('venue_id', (p_new->>'id')::int)];
383
+ END IF;
384
+
385
+ -- ... more logic
386
+ END;
387
+ $$ LANGUAGE plpgsql STABLE;
388
+ ```
389
+
390
+ ### Query Efficiency
391
+
392
+ The `get_<name>()` function runs on every update:
393
+ - Use JOINs and indexes appropriately
394
+ - Consider materialized views for complex aggregations
395
+ - Limit relation depth to avoid N+1 queries
396
+
397
+ ## Debugging
398
+
399
+ ### Check Active Subscriptions
400
+
401
+ ```javascript
402
+ // Server-side (in development)
403
+ import { getAllSubscriptions, getStats } from './server/subscriptions.js';
404
+
405
+ console.log('Active subscriptions:', getAllSubscriptions());
406
+ console.log('Stats:', getStats());
407
+ ```
408
+
409
+ ### Test Functions Manually
410
+
411
+ ```sql
412
+ -- Test permission check
413
+ SELECT venue_detail_can_subscribe(1, '{"venue_id": 123}'::jsonb);
414
+
415
+ -- Test query
416
+ SELECT get_venue_detail('{"venue_id": 123}'::jsonb, 1);
417
+
418
+ -- Test change detection
419
+ SELECT venue_detail_affected_documents(
420
+ 'venues',
421
+ 'update',
422
+ '{"id": 123}'::jsonb,
423
+ '{"id": 123, "name": "New Name"}'::jsonb
424
+ );
425
+ ```
426
+
427
+ ### Enable Debug Logging
428
+
429
+ ```javascript
430
+ // Server logs subscription events
431
+ import { wsLogger } from './server/logger.js';
432
+
433
+ wsLogger.level = 'debug'; // See all subscription operations
434
+ ```
435
+
436
+ ## Migration Guide
437
+
438
+ ### From Polling
439
+
440
+ Before:
441
+ ```javascript
442
+ // Poll every 5 seconds
443
+ setInterval(async () => {
444
+ const venue = await fetch(`/api/venues/${venueId}`).then(r => r.json());
445
+ updateUI(venue);
446
+ }, 5000);
447
+ ```
448
+
449
+ After:
450
+ ```javascript
451
+ // Real-time updates
452
+ const { data, unsubscribe } = await ws.api.subscribe_venue_detail(
453
+ { venue_id: venueId },
454
+ (venue) => updateUI(venue)
455
+ );
456
+
457
+ updateUI(data); // Initial data
458
+ ```
459
+
460
+ ### From Pattern 2 (Need to Know)
461
+
462
+ Pattern 2 notifications tell you "something changed":
463
+ ```javascript
464
+ ws.onBroadcast('venues:updated', (params) => {
465
+ // Manually fetch updated data
466
+ refetchVenue(params.id);
467
+ });
468
+ ```
469
+
470
+ Pattern 1 subscriptions give you the data:
471
+ ```javascript
472
+ ws.api.subscribe_venue_detail(
473
+ { venue_id: 123 },
474
+ (venue) => {
475
+ // Fresh data automatically provided
476
+ updateUI(venue);
477
+ }
478
+ );
479
+ ```
480
+
481
+ ## Best Practices
482
+
483
+ 1. **One subscribable per use case**: Create focused subscribables for specific UI needs
484
+ 2. **Minimize relations**: Only include data the client actually needs
485
+ 3. **Use specific parameters**: Subscription keys should be precise (e.g., `venue_id`, not `org_id`)
486
+ 4. **Clean up subscriptions**: Always unsubscribe when component unmounts
487
+ 5. **Handle reconnection**: Client automatically re-authenticates, but may need to re-subscribe
488
+ 6. **Test permissions thoroughly**: Use path DSL carefully to prevent unauthorized access
489
+
490
+ ## Troubleshooting
491
+
492
+ ### Subscription Not Receiving Updates
493
+
494
+ 1. Check that `_affected_documents()` returns correct parameter sets:
495
+ ```sql
496
+ SELECT my_subscribable_affected_documents('table', 'update', old, new);
497
+ ```
498
+
499
+ 2. Verify subscription is registered:
500
+ ```javascript
501
+ console.log('Subscriptions:', ws.subscriptions.size);
502
+ ```
503
+
504
+ 3. Confirm WebSocket is connected:
505
+ ```javascript
506
+ console.log('Connected:', ws.socket?.readyState === 1);
507
+ ```
508
+
509
+ ### Permission Denied
510
+
511
+ 1. Test permission function directly:
512
+ ```sql
513
+ SELECT my_subscribable_can_subscribe(user_id, params);
514
+ ```
515
+
516
+ 2. Check path DSL syntax in subscribable definition
517
+
518
+ 3. Verify user has required relationships (e.g., `acts_for` records)
519
+
520
+ ### Compilation Errors
521
+
522
+ 1. Validate JSON syntax in subscribable definition
523
+ 2. Check that all referenced tables exist
524
+ 3. Ensure parameter names match between schema and filters
525
+ 4. Test parser separately:
526
+ ```bash
527
+ bun packages/dzql/tests/subscriptions/test-subscribable-parse.js
528
+ ```
529
+
530
+ ## See Also
531
+
532
+ - [Vision Document](../vision.md) - Architecture overview and patterns
533
+ - [Path DSL](./PATH_DSL.md) - Permission path syntax
534
+ - [WebSocket API](./WEBSOCKET_API.md) - Full WebSocket protocol reference
535
+ - [Compiler Reference](./COMPILER.md) - Code generation details