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.
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dzql",
3
- "version": "0.1.6",
3
+ "version": "0.2.1",
4
4
  "description": "PostgreSQL-powered framework with zero boilerplate CRUD operations and real-time WebSocket synchronization",
5
5
  "type": "module",
6
6
  "main": "src/server/index.js",
@@ -22,13 +22,12 @@
22
22
  ],
23
23
  "scripts": {
24
24
  "test": "bun test",
25
- "prepublishOnly": "echo '✅ Publishing DZQL v0.1.5...'"
25
+ "prepublishOnly": "echo '✅ Publishing DZQL v0.2.1...'"
26
26
  },
27
27
  "dependencies": {
28
28
  "jose": "^6.1.0",
29
29
  "postgres": "^3.4.7"
30
30
  },
31
-
32
31
  "keywords": [
33
32
  "postgresql",
34
33
  "postgres",
package/src/client/ws.js CHANGED
@@ -59,12 +59,11 @@ class WebSocketManager {
59
59
  this.pendingRequests = new Map();
60
60
  this.broadcastCallbacks = new Set();
61
61
  this.sidRequestHandlers = new Set();
62
+ this.subscriptions = new Map(); // subscription_id -> { callback, unsubscribe }
62
63
  this.reconnectAttempts = 0;
63
64
  this.maxReconnectAttempts = options.maxReconnectAttempts ?? 5;
64
65
  this.isShuttingDown = false;
65
66
 
66
- // Ad
67
-
68
67
  // DZQL nested proxy API - matches server-side db.api pattern
69
68
  // Proxy handles both DZQL operations and custom functions
70
69
  const dzqlOps = {
@@ -137,6 +136,18 @@ class WebSocketManager {
137
136
  if (prop in target) {
138
137
  return target[prop];
139
138
  }
139
+ // Handle subscribe_* methods specially
140
+ if (prop.startsWith('subscribe_')) {
141
+ return (params = {}, callback) => {
142
+ return this.subscribe(prop, params, callback);
143
+ };
144
+ }
145
+ // Handle unsubscribe_* methods
146
+ if (prop.startsWith('unsubscribe_')) {
147
+ return (params = {}) => {
148
+ return this.unsubscribe(prop, params);
149
+ };
150
+ }
140
151
  // All other properties are treated as custom function calls
141
152
  return (params = {}) => {
142
153
  return this.call(prop, params);
@@ -314,6 +325,16 @@ class WebSocketManager {
314
325
  resolve(message.result);
315
326
  }
316
327
  } else {
328
+ // Handle subscription updates
329
+ if (message.method === "subscription:update") {
330
+ const { subscription_id, data } = message.params;
331
+ const sub = this.subscriptions.get(subscription_id);
332
+ if (sub && sub.callback) {
333
+ sub.callback(data);
334
+ }
335
+ return;
336
+ }
337
+
317
338
  // Handle broadcasts and SID requests
318
339
 
319
340
  // Check if this is a SID request from server
@@ -376,6 +397,70 @@ class WebSocketManager {
376
397
  });
377
398
  }
378
399
 
400
+ /**
401
+ * Subscribe to a live query
402
+ *
403
+ * @param {string} method - Method name (subscribe_<subscribable>)
404
+ * @param {object} params - Subscription parameters
405
+ * @param {function} callback - Callback function for updates
406
+ * @returns {Promise<{data, subscription_id, unsubscribe}>} Initial data and unsubscribe function
407
+ *
408
+ * @example
409
+ * const { data, unsubscribe } = await ws.api.subscribe_venue_detail(
410
+ * { venue_id: 1 },
411
+ * (updated) => console.log('Updated:', updated)
412
+ * );
413
+ *
414
+ * // Use initial data
415
+ * console.log('Initial:', data);
416
+ *
417
+ * // Later: unsubscribe
418
+ * unsubscribe();
419
+ */
420
+ async subscribe(method, params = {}, callback) {
421
+ if (!callback || typeof callback !== 'function') {
422
+ throw new Error('Subscribe requires a callback function');
423
+ }
424
+
425
+ // Call server to register subscription
426
+ const result = await this.call(method, params);
427
+ const { subscription_id, data } = result;
428
+
429
+ // Create unsubscribe function
430
+ const unsubscribeFn = async () => {
431
+ const unsubMethod = method.replace('subscribe_', 'unsubscribe_');
432
+ await this.call(unsubMethod, params);
433
+ this.subscriptions.delete(subscription_id);
434
+ };
435
+
436
+ // Store callback for updates
437
+ this.subscriptions.set(subscription_id, {
438
+ callback,
439
+ unsubscribe: unsubscribeFn
440
+ });
441
+
442
+ // Return initial data and unsubscribe function
443
+ return {
444
+ data,
445
+ subscription_id,
446
+ unsubscribe: unsubscribeFn
447
+ };
448
+ }
449
+
450
+ /**
451
+ * Unsubscribe from a live query
452
+ *
453
+ * @param {string} method - Method name (unsubscribe_<subscribable>)
454
+ * @param {object} params - Subscription parameters
455
+ * @returns {Promise<{success: boolean}>}
456
+ *
457
+ * @example
458
+ * await ws.api.unsubscribe_venue_detail({ venue_id: 1 });
459
+ */
460
+ async unsubscribe(method, params = {}) {
461
+ return await this.call(method, params);
462
+ }
463
+
379
464
  /**
380
465
  * Register callback for real-time broadcast events
381
466
  *
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { readFileSync } from 'fs';
4
+ import { compileSubscribablesFromSQL } from '../compiler.js';
5
+
6
+ const sqlContent = readFileSync('./examples/subscribables/venue_detail_simple.sql', 'utf-8');
7
+
8
+ console.log('Compiling subscribable...\n');
9
+
10
+ try {
11
+ const result = compileSubscribablesFromSQL(sqlContent);
12
+
13
+ console.log('Summary:', result.summary);
14
+
15
+ if (result.errors.length > 0) {
16
+ console.log('\nErrors:');
17
+ result.errors.forEach(err => {
18
+ console.log(` ${err.subscribable}: ${err.error}`);
19
+ });
20
+ }
21
+
22
+ if (result.results.length > 0) {
23
+ const compiled = result.results[0];
24
+ console.log(`\n✓ Compiled '${compiled.name}' successfully!`);
25
+ console.log(` Checksum: ${compiled.checksum.substring(0, 16)}...`);
26
+ console.log(` Time: ${compiled.compilationTime}ms`);
27
+ console.log('\nGenerated SQL:\n');
28
+ console.log(compiled.sql);
29
+ }
30
+ } catch (error) {
31
+ console.error('Error:', error.message);
32
+ process.exit(1);
33
+ }
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env bun
2
+
3
+ /**
4
+ * Compile subscribable and output SQL only (for piping to psql)
5
+ */
6
+
7
+ import { readFileSync } from 'fs';
8
+ import { compileSubscribablesFromSQL } from '../compiler.js';
9
+
10
+ const args = process.argv.slice(2);
11
+ if (args.length === 0) {
12
+ console.error('Usage: compile-subscribable.js <sql-file>');
13
+ process.exit(1);
14
+ }
15
+
16
+ const sqlFile = args[0];
17
+
18
+ try {
19
+ const sqlContent = readFileSync(sqlFile, 'utf-8');
20
+ const result = compileSubscribablesFromSQL(sqlContent);
21
+
22
+ if (result.errors.length > 0) {
23
+ console.error('Compilation errors:');
24
+ result.errors.forEach(err => {
25
+ console.error(` ${err.subscribable}: ${err.error}`);
26
+ });
27
+ process.exit(1);
28
+ }
29
+
30
+ if (result.results.length === 0) {
31
+ console.error('No subscribables found in', sqlFile);
32
+ process.exit(1);
33
+ }
34
+
35
+ // Output just the SQL
36
+ for (const compiled of result.results) {
37
+ console.log(compiled.sql);
38
+ console.log(''); // Blank line between subscribables
39
+ }
40
+ } catch (error) {
41
+ console.error('Error:', error.message);
42
+ process.exit(1);
43
+ }
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env bun
2
+
3
+ /**
4
+ * Test script for subscribable compilation
5
+ */
6
+
7
+ import { readFileSync } from 'fs';
8
+ import { compileSubscribablesFromSQL } from './src/compiler/compiler.js';
9
+
10
+ // Read the example subscribable
11
+ const sqlContent = readFileSync('./examples/subscribables/venue_detail_subscribable.sql', 'utf-8');
12
+
13
+ console.log('Compiling subscribable...\n');
14
+
15
+ try {
16
+ const result = compileSubscribablesFromSQL(sqlContent);
17
+
18
+ console.log('Compilation Summary:');
19
+ console.log(` Total: ${result.summary.total}`);
20
+ console.log(` Successful: ${result.summary.successful}`);
21
+ console.log(` Failed: ${result.summary.failed}\n`);
22
+
23
+ if (result.errors.length > 0) {
24
+ console.log('Errors:');
25
+ result.errors.forEach(err => {
26
+ console.log(` - ${err.subscribable}: ${err.error}`);
27
+ });
28
+ console.log('');
29
+ }
30
+
31
+ if (result.results.length > 0) {
32
+ const compiled = result.results[0];
33
+ console.log(`Generated SQL for '${compiled.name}':`);
34
+ console.log('='.repeat(80));
35
+ console.log(compiled.sql);
36
+ console.log('='.repeat(80));
37
+ console.log(`\nChecksum: ${compiled.checksum}`);
38
+ console.log(`Compilation time: ${compiled.compilationTime}ms`);
39
+ }
40
+ } catch (error) {
41
+ console.error('Compilation failed:', error.message);
42
+ console.error(error.stack);
43
+ process.exit(1);
44
+ }
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env bun
2
+
3
+ /**
4
+ * Test script for subscribable parsing (debug)
5
+ */
6
+
7
+ import { readFileSync } from 'fs';
8
+ import { SubscribableParser } from './src/compiler/parser/subscribable-parser.js';
9
+
10
+ // Read the example subscribable
11
+ const sqlContent = readFileSync('./examples/subscribables/venue_detail_subscribable.sql', 'utf-8');
12
+
13
+ console.log('Parsing subscribable...\n');
14
+
15
+ const parser = new SubscribableParser();
16
+ const subscribables = parser.parseAllFromSQL(sqlContent);
17
+
18
+ console.log('Found', subscribables.length, 'subscribables\n');
19
+
20
+ for (const sub of subscribables) {
21
+ console.log('Subscribable:', sub.name);
22
+ console.log('Root Entity:', sub.rootEntity);
23
+ console.log('Permission Paths:', JSON.stringify(sub.permissionPaths, null, 2));
24
+ console.log('Param Schema:', JSON.stringify(sub.paramSchema, null, 2));
25
+ console.log('Relations:', JSON.stringify(sub.relations, null, 2));
26
+ }
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { PathParser } from './src/compiler/parser/path-parser.js';
4
+
5
+ const parser = new PathParser();
6
+
7
+ const testPath = '@org_id->acts_for[org_id=$]{active}.user_id';
8
+
9
+ console.log('Parsing path:', testPath);
10
+ console.log('');
11
+
12
+ try {
13
+ const ast = parser.parse(testPath);
14
+ console.log('AST:', JSON.stringify(ast, null, 2));
15
+ } catch (error) {
16
+ console.error('Error:', error.message);
17
+ console.error(error.stack);
18
+ }
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { SubscribableParser } from '../parser/subscribable-parser.js';
4
+ import { readFileSync } from 'fs';
5
+
6
+ const sqlContent = readFileSync('./examples/subscribables/venue_detail_simple.sql', 'utf-8');
7
+
8
+ console.log('Parsing subscribable...\n');
9
+
10
+ const parser = new SubscribableParser();
11
+ const subscribables = parser.parseAllFromSQL(sqlContent);
12
+
13
+ console.log('Found:', subscribables.length, 'subscribables\n');
14
+
15
+ for (const sub of subscribables) {
16
+ console.log('Name:', sub.name);
17
+ console.log('Root Entity:', sub.rootEntity);
18
+ console.log('Permission Paths:', JSON.stringify(sub.permissionPaths, null, 2));
19
+ console.log('Param Schema:', JSON.stringify(sub.paramSchema, null, 2));
20
+ console.log('Relations:', JSON.stringify(sub.relations, null, 2));
21
+ }