dzql 0.6.13 → 0.6.14

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/docs/README.md CHANGED
@@ -278,32 +278,75 @@ console.log(store.documents);
278
278
 
279
279
  ### How Realtime Works
280
280
 
281
- The WebSocket is a pure transport layer - it does not manage or cache data. Stores own their data.
281
+ DZQL uses a simple, unified broadcast pattern for all stores:
282
282
 
283
- When data changes in the database:
284
- 1. PostgreSQL triggers emit atomic events to the `dzql_v2.events` table
285
- 2. The runtime calls `*_affected_keys()` to find which subscriptions are affected
286
- 3. Events are broadcast to subscribed clients via WebSocket
287
- 4. The **store's** `subscription_event` function receives the event
288
- 5. The store's `applyPatch` function updates its local document in-place
289
- 6. Vue reactivity updates the UI automatically
283
+ 1. **Database events:** PostgreSQL triggers emit events to `dzql_v2.events`
284
+ 2. **Server broadcasts:** Runtime sends `{table}:{op}` messages (e.g., `venues:update`) to clients based on:
285
+ - **Subscriptions:** Connections with matching `affected_keys`
286
+ - **Notifications:** Users in `notify_users` (from entity notification paths)
287
+ 3. **Auto-dispatch:** The WebSocket client routes broadcasts to registered store handlers
288
+ 4. **Store updates:** Each store's `table_changed` method applies updates to local data
289
+ 5. **Vue reactivity:** UI updates automatically
290
290
 
291
- **No refetching required** - changes are applied incrementally via patching.
291
+ **No refetching required** - changes are applied incrementally.
292
292
 
293
- ### Patch Events
293
+ ### The `table_changed` Pattern
294
294
 
295
- The store receives events with this structure:
295
+ Every generated store implements `table_changed` and self-registers with the WebSocket client:
296
+
297
+ ```typescript
298
+ // Generated store (simplified)
299
+ export const useVenuesStore = defineStore('venues-store', () => {
300
+ const records = ref([]);
301
+
302
+ function table_changed(table: string, op: string, pk: Record<string, unknown>, data: unknown) {
303
+ if (table !== 'venues') return;
304
+ // Update records based on op (insert/update/delete)
305
+ }
306
+
307
+ // Self-register - no manual setup needed!
308
+ ws.registerStore(table_changed);
309
+
310
+ return { records, get, save, search, table_changed };
311
+ });
312
+ ```
313
+
314
+ **User code - just works:**
315
+ ```typescript
316
+ const venuesStore = useVenuesStore(); // Auto-registers for broadcasts
317
+ await venuesStore.search({ org_id: 1 });
318
+ // records update automatically when broadcasts arrive - no setup needed!
319
+ ```
320
+
321
+ ### Broadcast Message Format
296
322
 
297
323
  ```typescript
298
324
  {
299
- table: 'sites', // Which table changed
300
- op: 'insert' | 'update' | 'delete',
301
- pk: { id: 123 }, // Primary key of affected row
302
- data: { ... } // Full row data
325
+ "jsonrpc": "2.0",
326
+ "method": "venues:update", // {table}:{op}
327
+ "params": {
328
+ "pk": { "id": 123 },
329
+ "data": { "id": 123, "name": "Updated Venue", ... }
330
+ }
303
331
  }
304
332
  ```
305
333
 
306
- The generated `applyPatch` function routes events to the correct location in the document graph based on the subscribable's `includes` structure.
334
+ ### Entity Notifications
335
+
336
+ Entities can define `notifications` paths to specify who receives broadcasts:
337
+
338
+ ```typescript
339
+ export const entities = {
340
+ venues: {
341
+ schema: { id: 'serial PRIMARY KEY', org_id: 'int', name: 'text' },
342
+ notifications: {
343
+ members: ['@org_id->acts_for[org_id=$]{active}.user_id']
344
+ }
345
+ }
346
+ };
347
+ ```
348
+
349
+ When a venue is created/updated/deleted, all active members of that org receive the broadcast.
307
350
 
308
351
  ## Why "Compile-Only"?
309
352
 
package/docs/for_ai.md CHANGED
@@ -262,9 +262,59 @@ for (const [key, docState] of Object.entries(store.documents)) {
262
262
  - Same params = same cached subscription (deduplication by JSON key)
263
263
  - The `ready` Promise is stored for repeat callers to await
264
264
  - **Stores own their data** - the WebSocket is just transport
265
- - Realtime patches are applied by the store's `applyPatch()` function
266
265
  - Data is reactive - changes trigger Vue reactivity automatically
267
- - The store routes patch events by table name to the correct location in the document graph
266
+
267
+ ### How Realtime Works
268
+
269
+ DZQL uses a unified `table_changed` pattern for all stores:
270
+
271
+ 1. **Database events:** PostgreSQL triggers emit events to `dzql_v2.events`
272
+ 2. **Server broadcasts:** Runtime sends `{table}:{op}` messages (e.g., `venues:update`) to clients
273
+ 3. **Auto-dispatch:** WebSocket client routes broadcasts to registered store handlers
274
+ 4. **Store updates:** Each store's `table_changed` method applies updates to local data
275
+
276
+ **Stores self-register - no manual setup needed:**
277
+
278
+ ```typescript
279
+ // Generated store (simplified)
280
+ export const useVenuesStore = defineStore('venues-store', () => {
281
+ const records = ref([]);
282
+
283
+ function table_changed(table: string, op: string, pk: Record<string, unknown>, data: unknown) {
284
+ if (table !== 'venues') return;
285
+ // Update records based on op (insert/update/delete)
286
+ }
287
+
288
+ // Self-register with WebSocket
289
+ ws.registerStore(table_changed);
290
+
291
+ return { records, get, save, search, table_changed };
292
+ });
293
+ ```
294
+
295
+ **User code - just works:**
296
+ ```typescript
297
+ const venuesStore = useVenuesStore(); // Auto-registers for broadcasts
298
+ await venuesStore.search({ org_id: 1 });
299
+ // records update automatically when broadcasts arrive!
300
+ ```
301
+
302
+ ### Entity Notifications
303
+
304
+ Entities can define `notifications` paths to specify who receives broadcasts:
305
+
306
+ ```javascript
307
+ export const entities = {
308
+ venues: {
309
+ schema: { id: 'serial PRIMARY KEY', org_id: 'int', name: 'text' },
310
+ notifications: {
311
+ members: ['@org_id->acts_for[org_id=$]{active}.user_id']
312
+ }
313
+ }
314
+ };
315
+ ```
316
+
317
+ When a venue is created/updated/deleted, all active members of that org receive the broadcast
268
318
 
269
319
  ### Common Patterns
270
320
 
@@ -387,6 +387,8 @@ async function deleteSite(id: number) {
387
387
  </script>
388
388
  ```
389
389
 
390
+ **How realtime works:** Stores self-register with the WebSocket client on creation. When the server broadcasts `{table}:{op}` messages (e.g., `sites:insert`), each store's `table_changed` handler automatically applies the update to local data. No manual dispatcher setup required - just use the store and it works.
391
+
390
392
  ## 10. CLI Database Access with invj
391
393
 
392
394
  Create `tasks.js` in the project root to enable CLI database operations:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dzql",
3
- "version": "0.6.13",
3
+ "version": "0.6.14",
4
4
  "description": "Database-first real-time framework with TypeScript support",
5
5
  "repository": {
6
6
  "type": "git",
@@ -24,7 +24,9 @@
24
24
  "README.md"
25
25
  ],
26
26
  "scripts": {
27
- "test": "bun test"
27
+ "test": "bun run test:setup && bun test; bun run test:teardown",
28
+ "test:setup": "docker compose down -v && docker compose up -d --wait",
29
+ "test:teardown": "docker compose down -v"
28
30
  },
29
31
  "keywords": [
30
32
  "postgresql",
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Notification Path Code Generator
3
+ * Generates PostgreSQL functions to resolve notification paths to user IDs
4
+ */
5
+
6
+ import type { EntityIR } from "../../shared/ir.js";
7
+ import { compilePermission } from "../compiler/permissions.js";
8
+
9
+ /**
10
+ * Generate notification resolution function for an entity
11
+ * Returns a function that resolves notification paths to an array of user IDs
12
+ */
13
+ export function generateNotificationFunction(name: string, entityIR: EntityIR): string {
14
+ const notifications = entityIR.notifications || {};
15
+ const pathNames = Object.keys(notifications);
16
+
17
+ if (pathNames.length === 0) {
18
+ // No notifications - return empty function
19
+ return `
20
+ -- Notification resolution for ${name} (no paths configured)
21
+ CREATE OR REPLACE FUNCTION dzql_v2.${name}_notify_users(
22
+ p_user_id INT,
23
+ p_data JSONB
24
+ ) RETURNS INT[]
25
+ LANGUAGE plpgsql
26
+ STABLE
27
+ SECURITY DEFINER
28
+ SET search_path = dzql_v2, public
29
+ AS $$
30
+ BEGIN
31
+ RETURN ARRAY[]::INT[];
32
+ END;
33
+ $$;`;
34
+ }
35
+
36
+ // Generate SQL for each notification path
37
+ const pathQueries: string[] = [];
38
+
39
+ for (const [pathName, paths] of Object.entries(notifications)) {
40
+ if (!paths || !Array.isArray(paths)) continue;
41
+
42
+ for (const path of paths) {
43
+ // Use the same permission compiler - it generates EXISTS subqueries
44
+ // but we need SELECT user_id instead
45
+ const userQuery = compileNotificationPath(name, path);
46
+ if (userQuery) {
47
+ pathQueries.push(` -- ${pathName}: ${path}
48
+ v_users := v_users || ARRAY(${userQuery});`);
49
+ }
50
+ }
51
+ }
52
+
53
+ const pathSQL = pathQueries.length > 0
54
+ ? pathQueries.join('\n\n')
55
+ : ' -- No valid notification paths';
56
+
57
+ return `
58
+ -- Notification resolution for ${name}
59
+ CREATE OR REPLACE FUNCTION dzql_v2.${name}_notify_users(
60
+ p_user_id INT,
61
+ p_data JSONB
62
+ ) RETURNS INT[]
63
+ LANGUAGE plpgsql
64
+ STABLE
65
+ SECURITY DEFINER
66
+ SET search_path = dzql_v2, public
67
+ AS $$
68
+ DECLARE
69
+ v_users INT[] := ARRAY[]::INT[];
70
+ BEGIN
71
+ ${pathSQL}
72
+
73
+ -- Return unique user IDs (excluding the acting user to avoid self-notification)
74
+ RETURN ARRAY(SELECT DISTINCT unnest(v_users) WHERE unnest != p_user_id);
75
+ END;
76
+ $$;`;
77
+ }
78
+
79
+ /**
80
+ * Compile a notification path to a SELECT query returning user IDs
81
+ *
82
+ * Path formats:
83
+ * - @field_id -> Direct field reference (must be user_id)
84
+ * - @field->table[filter]{temporal}.user_id -> Traverse to get user_id
85
+ */
86
+ function compileNotificationPath(entityName: string, path: string): string | null {
87
+ path = path.trim();
88
+
89
+ // Direct field reference: @author_id (field must contain user_id)
90
+ if (path.match(/^@\w+$/) && !path.includes('->')) {
91
+ const field = path.slice(1);
92
+ return `SELECT (p_data->>'${field}')::int WHERE (p_data->>'${field}') IS NOT NULL`;
93
+ }
94
+
95
+ // Traversal path: @org_id->acts_for[org_id=$]{active}.user_id
96
+ const traversalMatch = path.match(/^@(\w+)->(.+)\.(\w+)$/);
97
+ if (traversalMatch) {
98
+ const [, startField, middle, endField] = traversalMatch;
99
+ return compileTraversalPath(startField, middle, endField);
100
+ }
101
+
102
+ // Multi-hop: @venue_id->venues.org_id->acts_for[org_id=$]{active}.user_id
103
+ const multiHopMatch = path.match(/^@(\w+)->(.+)\.(\w+)->(.+)\.(\w+)$/);
104
+ if (multiHopMatch) {
105
+ const [, field1, table1, field2, rest, endField] = multiHopMatch;
106
+ return compileMultiHopPath(field1, table1, field2, rest, endField);
107
+ }
108
+
109
+ // Table-first: table[filter]{temporal}.field->...
110
+ if (!path.startsWith('@')) {
111
+ return compileTableFirstPath(path);
112
+ }
113
+
114
+ console.warn(`[Notification] Unsupported path format: ${path}`);
115
+ return null;
116
+ }
117
+
118
+ /**
119
+ * Compile single-hop traversal: @org_id->acts_for[org_id=$]{active}.user_id
120
+ */
121
+ function compileTraversalPath(startField: string, middle: string, endField: string): string {
122
+ // Parse: acts_for[org_id=$]{active}
123
+ const tableMatch = middle.match(/^(\w+)(?:\[([^\]]+)\])?(?:\{([^}]+)\})?$/);
124
+ if (!tableMatch) {
125
+ console.warn(`[Notification] Cannot parse traversal: ${middle}`);
126
+ return `SELECT NULL::int WHERE FALSE`;
127
+ }
128
+
129
+ const [, table, filterStr, temporalField] = tableMatch;
130
+ const conditions: string[] = [];
131
+
132
+ // Parse filters: org_id=$
133
+ if (filterStr) {
134
+ const filters = filterStr.split(',').map(f => f.trim());
135
+ for (const filter of filters) {
136
+ const [field, value] = filter.split('=').map(s => s.trim());
137
+ if (value === '$') {
138
+ // $ means "use the start field value"
139
+ conditions.push(`${table}.${field} = (p_data->>'${startField}')::int`);
140
+ } else if (value.startsWith('@')) {
141
+ conditions.push(`${table}.${field} = (p_data->>'${value.slice(1)}')::int`);
142
+ } else {
143
+ conditions.push(`${table}.${field} = ${value}`);
144
+ }
145
+ }
146
+ } else {
147
+ // Default: join on start field
148
+ conditions.push(`${table}.id = (p_data->>'${startField}')::int`);
149
+ }
150
+
151
+ // Temporal filter: {active} means active = true AND valid_to IS NULL
152
+ if (temporalField) {
153
+ conditions.push(`${table}.${temporalField} = true`);
154
+ conditions.push(`${table}.valid_to IS NULL`);
155
+ }
156
+
157
+ const whereClause = conditions.join(' AND ');
158
+
159
+ return `SELECT ${table}.${endField} FROM ${table} WHERE ${whereClause}`;
160
+ }
161
+
162
+ /**
163
+ * Compile multi-hop path: @venue_id->venues.org_id->acts_for[org_id=$]{active}.user_id
164
+ */
165
+ function compileMultiHopPath(
166
+ field1: string,
167
+ table1: string,
168
+ field2: string,
169
+ rest: string,
170
+ endField: string
171
+ ): string {
172
+ // First hop: get field2 from table1
173
+ const subquery1 = `(SELECT ${field2} FROM ${table1} WHERE id = (p_data->>'${field1}')::int)`;
174
+
175
+ // Parse second hop: acts_for[org_id=$]{active}
176
+ const tableMatch = rest.match(/^(\w+)(?:\[([^\]]+)\])?(?:\{([^}]+)\})?$/);
177
+ if (!tableMatch) {
178
+ console.warn(`[Notification] Cannot parse second hop: ${rest}`);
179
+ return `SELECT NULL::int WHERE FALSE`;
180
+ }
181
+
182
+ const [, table2, filterStr, temporalField] = tableMatch;
183
+ const conditions: string[] = [];
184
+
185
+ // Parse filters
186
+ if (filterStr) {
187
+ const filters = filterStr.split(',').map(f => f.trim());
188
+ for (const filter of filters) {
189
+ const [field, value] = filter.split('=').map(s => s.trim());
190
+ if (value === '$') {
191
+ // $ in second hop means "use the result of first hop"
192
+ conditions.push(`${table2}.${field} = ${subquery1}`);
193
+ } else if (value.startsWith('@')) {
194
+ conditions.push(`${table2}.${field} = (p_data->>'${value.slice(1)}')::int`);
195
+ } else {
196
+ conditions.push(`${table2}.${field} = ${value}`);
197
+ }
198
+ }
199
+ }
200
+
201
+ // Temporal filter
202
+ if (temporalField) {
203
+ conditions.push(`${table2}.${temporalField} = true`);
204
+ conditions.push(`${table2}.valid_to IS NULL`);
205
+ }
206
+
207
+ const whereClause = conditions.join(' AND ');
208
+
209
+ return `SELECT ${table2}.${endField} FROM ${table2} WHERE ${whereClause}`;
210
+ }
211
+
212
+ /**
213
+ * Compile table-first path: contractor_rights[package_id=@package_id]{active}.contractor_org_id->...
214
+ */
215
+ function compileTableFirstPath(path: string): string {
216
+ // This is complex - for now return null and log warning
217
+ console.warn(`[Notification] Table-first paths not yet supported: ${path}`);
218
+ return `SELECT NULL::int WHERE FALSE`;
219
+ }
@@ -60,14 +60,6 @@ export interface ${pascalName} {
60
60
  ${columnTypes}
61
61
  }
62
62
 
63
- /** Event payload for table changes */
64
- export interface TableChangedPayload {
65
- table: string;
66
- operation: 'insert' | 'update' | 'delete';
67
- pk: { ${pkField}: ${pkType} };
68
- data: ${pascalName} | null;
69
- }
70
-
71
63
  export const use${pascalName}Store = defineStore('${entityName}-store', () => {
72
64
  const records: Ref<${pascalName}[]> = ref([]);
73
65
  const loading: Ref<boolean> = ref(false);
@@ -132,33 +124,37 @@ export const use${pascalName}Store = defineStore('${entityName}-store', () => {
132
124
  }
133
125
  }
134
126
 
135
- function table_changed(payload: TableChangedPayload): void {
136
- if (payload.table === '${entityName}') {
137
- const pkValue = payload.pk?.${pkField};
138
- const existingIndex = records.value.findIndex((r: ${pascalName}) => r.${pkField} === pkValue);
139
-
140
- switch (payload.operation) {
141
- case 'insert':
142
- if (payload.data && existingIndex === -1) {
143
- records.value.push(payload.data);
144
- }
145
- break;
146
- case 'update':
147
- if (payload.data && existingIndex !== -1) {
148
- Object.assign(records.value[existingIndex], payload.data);
149
- } else if (payload.data) {
150
- records.value.push(payload.data);
151
- }
152
- break;
153
- case 'delete':
154
- if (existingIndex !== -1) {
155
- records.value.splice(existingIndex, 1);
156
- }
157
- break;
158
- }
127
+ // Broadcast handler - automatically registered with ws
128
+ function table_changed(table: string, op: string, pk: Record<string, unknown>, data: ${pascalName} | null): void {
129
+ if (table !== '${entityName}') return;
130
+
131
+ const pkValue = pk?.${pkField} as ${pkType};
132
+ const existingIndex = records.value.findIndex((r) => r.${pkField} === pkValue);
133
+
134
+ switch (op) {
135
+ case 'insert':
136
+ if (data && existingIndex === -1) {
137
+ records.value.push(data);
138
+ }
139
+ break;
140
+ case 'update':
141
+ if (data && existingIndex !== -1) {
142
+ Object.assign(records.value[existingIndex], data);
143
+ } else if (data) {
144
+ records.value.push(data);
145
+ }
146
+ break;
147
+ case 'delete':
148
+ if (existingIndex !== -1) {
149
+ records.value.splice(existingIndex, 1);
150
+ }
151
+ break;
159
152
  }
160
153
  }
161
154
 
155
+ // Self-register with WebSocket for broadcasts
156
+ ws.registerStore(table_changed);
157
+
162
158
  return {
163
159
  records,
164
160
  loading,
@@ -50,12 +50,28 @@ CREATE TABLE IF NOT EXISTS dzql_v2.events (
50
50
  data jsonb,
51
51
  old_data jsonb,
52
52
  user_id int,
53
+ affected_keys text[] DEFAULT ARRAY[]::text[],
54
+ notify_users int[] DEFAULT ARRAY[]::int[],
53
55
  created_at timestamptz DEFAULT now()
54
56
  );
55
57
 
56
58
  -- Commit Sequence
57
59
  CREATE SEQUENCE IF NOT EXISTS dzql_v2.commit_seq;
58
60
 
61
+ -- Default compute_affected_keys (returns empty array, overwritten when subscribables exist)
62
+ CREATE OR REPLACE FUNCTION dzql_v2.compute_affected_keys(
63
+ p_table TEXT,
64
+ p_op TEXT,
65
+ p_data JSONB
66
+ ) RETURNS TEXT[]
67
+ LANGUAGE plpgsql
68
+ IMMUTABLE
69
+ AS $$
70
+ BEGIN
71
+ RETURN ARRAY[]::text[];
72
+ END;
73
+ $$;
74
+
59
75
  -- === AUTH FUNCTIONS ===
60
76
 
61
77
  -- Register User
@@ -309,6 +325,7 @@ DECLARE
309
325
  v_old_data jsonb;
310
326
  v_commit_id bigint;
311
327
  v_op text;
328
+ v_notify_users int[];
312
329
  ${m2mVarDeclarations}
313
330
  BEGIN
314
331
  v_commit_id := nextval('dzql_v2.commit_seq');
@@ -349,8 +366,11 @@ ${m2mExtraction}
349
366
  ${m2mSync}
350
367
  ${m2mExpansion}
351
368
 
352
- -- Emit Event
353
- INSERT INTO dzql_v2.events (commit_id, table_name, op, pk, data, old_data, user_id)
369
+ -- Resolve notification recipients
370
+ v_notify_users := dzql_v2.${name}_notify_users(p_user_id, v_result);
371
+
372
+ -- Emit Event with pre-computed affected keys and notify users
373
+ INSERT INTO dzql_v2.events (commit_id, table_name, op, pk, data, old_data, user_id, affected_keys, notify_users)
354
374
  VALUES (
355
375
  v_commit_id,
356
376
  '${name}',
@@ -358,7 +378,9 @@ ${m2mExpansion}
358
378
  ${pkJsonbExpr},
359
379
  v_result,
360
380
  v_old_data, -- NULL for insert
361
- p_user_id
381
+ p_user_id,
382
+ dzql_v2.compute_affected_keys('${name}', v_op, v_result),
383
+ v_notify_users
362
384
  );
363
385
 
364
386
  -- Notify Runtime
@@ -419,6 +441,7 @@ AS $$
419
441
  DECLARE
420
442
  v_old_data jsonb;
421
443
  v_commit_id bigint;
444
+ v_notify_users int[];
422
445
  BEGIN
423
446
  v_commit_id := nextval('dzql_v2.commit_seq');
424
447
 
@@ -440,8 +463,11 @@ BEGIN
440
463
  -- Perform ${softDelete ? 'Soft ' : ''}Delete
441
464
  ${deleteOperation};
442
465
 
443
- -- Emit Event (always 'delete' operation for client-side removal)
444
- INSERT INTO dzql_v2.events (commit_id, table_name, op, pk, data, old_data, user_id)
466
+ -- Resolve notification recipients
467
+ v_notify_users := dzql_v2.${name}_notify_users(p_user_id, v_old_data);
468
+
469
+ -- Emit Event with pre-computed affected keys and notify users (always 'delete' operation for client-side removal)
470
+ INSERT INTO dzql_v2.events (commit_id, table_name, op, pk, data, old_data, user_id, affected_keys, notify_users)
445
471
  VALUES (
446
472
  v_commit_id,
447
473
  '${name}',
@@ -449,7 +475,9 @@ BEGIN
449
475
  ${pkJsonbExpr},
450
476
  v_old_data, -- Include full data for subscription resolution
451
477
  v_old_data,
452
- p_user_id
478
+ p_user_id,
479
+ dzql_v2.compute_affected_keys('${name}', 'delete', v_old_data),
480
+ v_notify_users
453
481
  );
454
482
 
455
483
  -- Notify Runtime
@@ -689,7 +717,11 @@ $$;
689
717
 
690
718
  // === AGGREGATE GENERATOR ===
691
719
  export function generateEntitySQL(name: string, entityIR: EntityIR): string {
720
+ // Import here to avoid circular dependency
721
+ const { generateNotificationFunction } = require('./notification.js');
722
+
692
723
  return [
724
+ generateNotificationFunction(name, entityIR),
693
725
  generateSaveFunction(name, entityIR),
694
726
  generateDeleteFunction(name, entityIR),
695
727
  generateGetFunction(name, entityIR),