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.
@@ -292,7 +292,7 @@ ${paramExtracts}
292
292
 
293
293
  -- Return data with embedded schema for atomic updates
294
294
  RETURN jsonb_build_object(
295
- '${sub.root.entity}', v_data,
295
+ 'data', jsonb_build_object('${sub.root.entity}', v_data),
296
296
  'schema', '${schemaJson}'::jsonb
297
297
  );
298
298
  END;
@@ -521,33 +521,49 @@ function generateAffectedKeysFunction(name: string, sub: SubscribableIR): string
521
521
  const singularRootEntity = singularize(sub.root.entity);
522
522
 
523
523
  // Related entity cases
524
+ // Track FK relationships: parentEntity -> { childEntity: { fkOnParent, fkOnChild } }
525
+ const relationships: Record<string, Record<string, { fkOnParent: string }>> = {};
526
+
524
527
  const addRelationCase = (relName: string, relConfig: IncludeIR, parentEntity: string) => {
525
528
  const relEntity = relConfig.entity;
526
529
  const filter = relConfig.filter || {};
527
530
 
528
- // Find the FK field that points to root (use singular form)
529
- let fkField = `${singularRootEntity}_id`;
531
+ // Find the FK field on parent that points to child
532
+ // For includes like `org: 'organisations'`, the FK is `org_id` on the parent
533
+ // For includes like `{ entity: 'comments', filter: { post_id: '@id' } }`, the FK is on child
534
+ let fkOnParent = `${relName}_id`; // Default: relation name + _id (e.g., org -> org_id)
535
+
536
+ // Check if filter references @id - if so, the FK is on the child pointing to parent
537
+ let fkOnChild: string | null = null;
530
538
  for (const [field, value] of Object.entries(filter)) {
531
539
  if (value === '@id' || value === `@${paramKey}`) {
532
- fkField = field;
540
+ fkOnChild = field; // e.g., user_id, venue_id
533
541
  break;
534
542
  }
535
543
  }
536
544
 
537
- const singularParent = singularize(parentEntity);
545
+ // Store relationship for nested lookups
546
+ if (!relationships[parentEntity]) relationships[parentEntity] = {};
547
+ relationships[parentEntity][relEntity] = { fkOnParent };
538
548
 
539
- // For nested relations, we need to traverse up
549
+ // For nested relations, we need to traverse up via the FK on parent
540
550
  if (parentEntity !== sub.root.entity) {
551
+ // Get the FK that parent uses to reference this child entity
552
+ const parentRel = relationships[parentEntity]?.[relEntity];
553
+ const fkField = parentRel?.fkOnParent || `${relName}_id`;
554
+
541
555
  cases.push(` WHEN '${relEntity}' THEN
542
- -- Nested: traverse via ${parentEntity}
556
+ -- Nested: traverse via ${parentEntity}.${fkField}
543
557
  SELECT ARRAY_AGG('${name}:' || parent.${singularRootEntity}_id)
544
558
  INTO v_keys
545
559
  FROM ${parentEntity} parent
546
- WHERE parent.id = (p_data->>'${singularParent}_id')::int;
560
+ WHERE parent.${fkField} = (p_data->>'id')::int;
547
561
  RETURN COALESCE(v_keys, ARRAY[]::text[]);`);
548
562
  } else {
563
+ // Direct child of root - use the FK on child that points to root
564
+ const keyField = fkOnChild || `${singularRootEntity}_id`;
549
565
  cases.push(` WHEN '${relEntity}' THEN
550
- RETURN ARRAY['${name}:' || (p_data->>'${fkField}')];`);
566
+ RETURN ARRAY['${name}:' || (p_data->>'${keyField}')];`);
551
567
  }
552
568
 
553
569
  // Recurse for nested includes
@@ -582,6 +598,47 @@ END;
582
598
  $$;`;
583
599
  }
584
600
 
601
+ /**
602
+ * Generate the central compute_affected_keys function that aggregates all subscribable affected keys.
603
+ * This is called from save/delete functions to compute all affected subscription keys at write time.
604
+ */
605
+ export function generateComputeAffectedKeysFunction(subscribableNames: string[]): string {
606
+ if (subscribableNames.length === 0) {
607
+ return `-- No subscribables defined, empty compute_affected_keys function
608
+ CREATE OR REPLACE FUNCTION dzql_v2.compute_affected_keys(
609
+ p_table TEXT,
610
+ p_op TEXT,
611
+ p_data JSONB
612
+ ) RETURNS TEXT[]
613
+ LANGUAGE plpgsql
614
+ IMMUTABLE
615
+ AS $$
616
+ BEGIN
617
+ RETURN ARRAY[]::text[];
618
+ END;
619
+ $$;`;
620
+ }
621
+
622
+ const calls = subscribableNames.map(name =>
623
+ `dzql_v2.${name}_affected_keys(p_table, p_op, p_data)`
624
+ ).join(' || ');
625
+
626
+ return `-- Central function to compute all affected subscription keys
627
+ -- Called from save/delete functions at write time
628
+ CREATE OR REPLACE FUNCTION dzql_v2.compute_affected_keys(
629
+ p_table TEXT,
630
+ p_op TEXT,
631
+ p_data JSONB
632
+ ) RETURNS TEXT[]
633
+ LANGUAGE plpgsql
634
+ IMMUTABLE
635
+ AS $$
636
+ BEGIN
637
+ RETURN ${calls};
638
+ END;
639
+ $$;`;
640
+ }
641
+
585
642
  function buildPathMapping(
586
643
  rootEntity: string,
587
644
  includes: Record<string, IncludeIR>,
@@ -1,5 +1,4 @@
1
1
  import { Manifest } from "./manifest.js";
2
- import { SubscribableIR, IncludeIR } from "../../shared/ir.js";
3
2
 
4
3
  function toPascalCase(str: string): string {
5
4
  return str.replace(/(^|_)([a-z])/g, (g) => g[g.length - 1].toUpperCase());
@@ -12,6 +11,8 @@ export function generateSubscribableStore(manifest: Manifest, subName: string):
12
11
  }
13
12
 
14
13
  const pascalName = toPascalCase(subName);
14
+ const scopeTables = sub.scopeTables || [];
15
+ const rootEntity = sub.root?.entity || '';
15
16
 
16
17
  // Generate params interface
17
18
  const paramEntries = Object.entries(sub.params);
@@ -26,60 +27,8 @@ export function generateSubscribableStore(manifest: Manifest, subName: string):
26
27
  }).join('\n')
27
28
  : ' [key: string]: unknown;';
28
29
 
29
- // Helper to generate patch cases recursively
30
- const generatePatchCases = (): string => {
31
- const cases: string[] = [];
32
-
33
- // 1. Root Entity
34
- cases.push(
35
- ` case '${sub.root.entity}':\n` +
36
- ` if (event.op === 'update' && doc.${sub.root.entity}) {\n` +
37
- ` Object.assign(doc.${sub.root.entity}, event.data);\n` +
38
- ` }\n` +
39
- ` break;`
40
- );
41
-
42
- // 2. Recursive Includes
43
- const traverse = (includes: Record<string, IncludeIR>, pathStack: string[]) => {
44
- for (const [key, include] of Object.entries(includes)) {
45
- const entityName = include.entity;
46
-
47
- if (pathStack.length === 0) {
48
- // Level 1
49
- cases.push(
50
- ` case '${entityName}':\n` +
51
- ` handleArrayPatch(doc.${key}, event);\n` +
52
- ` break;`
53
- );
54
- } else {
55
- // Level 2+
56
- const level1Rel = pathStack[0];
57
- const singularParent = level1Rel.endsWith('s') ? level1Rel.slice(0, -1) : level1Rel;
58
- const fkField = `${singularParent}_id`;
59
-
60
- cases.push(
61
- ` case '${entityName}':\n` +
62
- ` if (event.data && (event.data as any).${fkField}) {\n` +
63
- ` const parent = (doc.${level1Rel} as any[])?.find((p: any) => p.id === (event.data as any).${fkField});\n` +
64
- ` if (parent && parent.${key}) {\n` +
65
- ` handleArrayPatch(parent.${key}, event);\n` +
66
- ` }\n` +
67
- ` }\n` +
68
- ` break;`
69
- );
70
- }
71
-
72
- if (include.includes) {
73
- traverse(include.includes, [...pathStack, key]);
74
- }
75
- }
76
- };
77
-
78
- traverse(sub.includes, []);
79
- return cases.join('\n');
80
- };
81
-
82
- const patchCases = generatePatchCases();
30
+ // Generate path mappings for atomic updates
31
+ const pathMappings = sub.includes ? generatePathMappings(rootEntity, sub.includes) : {};
83
32
 
84
33
  return `// Generated by DZQL Compiler v${manifest.version}
85
34
  // Do not edit this file directly.
@@ -93,14 +42,6 @@ export interface ${pascalName}Params {
93
42
  ${paramsInterface}
94
43
  }
95
44
 
96
- /** Event from server for patching */
97
- export interface PatchEvent {
98
- table: string;
99
- op: 'insert' | 'update' | 'delete';
100
- pk: { id: number };
101
- data: Record<string, unknown> | null;
102
- }
103
-
104
45
  /** Document wrapper with loading state */
105
46
  export interface DocumentWrapper<T> {
106
47
  data: T;
@@ -108,9 +49,14 @@ export interface DocumentWrapper<T> {
108
49
  ready: Promise<void>;
109
50
  }
110
51
 
52
+ // Scope tables this subscribable cares about
53
+ const SCOPE_TABLES = ${JSON.stringify(scopeTables)};
54
+
55
+ // Path mappings: table -> path in document
56
+ const PATH_MAPPINGS: Record<string, string> = ${JSON.stringify(pathMappings)};
57
+
111
58
  export const use${pascalName}Store = defineStore('sub-${subName}', () => {
112
59
  const documents: Ref<Record<string, DocumentWrapper<Record<string, unknown>>>> = ref({});
113
- const unsubscribers = new Map<string, () => void>();
114
60
 
115
61
  async function bind(params: ${pascalName}Params): Promise<DocumentWrapper<Record<string, unknown>>> {
116
62
  const key = JSON.stringify(params);
@@ -127,58 +73,111 @@ export const use${pascalName}Store = defineStore('sub-${subName}', () => {
127
73
  const ready = new Promise<void>((resolve) => { resolveReady = resolve; });
128
74
 
129
75
  documents.value[key] = { data: {}, loading: true, ready };
130
- let isFirst = true;
131
-
132
- const unsubscribe = await ws.api.subscribe_${subName}(params, (eventData: unknown) => {
133
- if (isFirst) {
134
- // Initial data - merge into existing object to preserve reactivity
135
- Object.assign(documents.value[key].data, eventData as Record<string, unknown>);
136
- documents.value[key].loading = false;
137
- isFirst = false;
138
- resolveReady();
139
- } else {
140
- // Patch event
141
- applyPatch(documents.value[key].data, eventData as PatchEvent);
142
- }
143
- });
144
76
 
145
- unsubscribers.set(key, unsubscribe as () => void);
146
- await ready;
77
+ // Subscribe to register interest and get initial data
78
+ // Updates come via broadcasts to table_changed, not via callback
79
+ const result = await ws.call('subscribe_${subName}', params) as { data: Record<string, unknown>; schema: unknown };
80
+ Object.assign(documents.value[key].data, result.data);
81
+ documents.value[key].loading = false;
82
+ resolveReady();
83
+
147
84
  return documents.value[key];
148
85
  }
149
86
 
150
87
  function unbind(params: ${pascalName}Params): void {
151
88
  const key = JSON.stringify(params);
152
- const unsubscribe = unsubscribers.get(key);
153
- if (unsubscribe) {
154
- unsubscribe();
155
- unsubscribers.delete(key);
156
- delete documents.value[key];
157
- }
89
+ // Unsubscribe to release server-side subscription
90
+ ws.call('unsubscribe_${subName}', params).catch(() => {});
91
+ delete documents.value[key];
158
92
  }
159
93
 
160
- function applyPatch(doc: Record<string, unknown>, event: PatchEvent): void {
161
- if (!doc) return;
162
- switch (event.table) {
163
- ${patchCases}
94
+ // Broadcast handler - called by global dispatcher
95
+ function table_changed(table: string, op: string, pk: Record<string, unknown>, data: unknown): void {
96
+ if (!SCOPE_TABLES.includes(table)) return;
97
+
98
+ const path = PATH_MAPPINGS[table];
99
+
100
+ // Update all bound documents that might be affected
101
+ for (const [key, doc] of Object.entries(documents.value)) {
102
+ if (doc.loading) continue;
103
+ applyUpdate(doc.data, table, path, op, pk, data);
164
104
  }
165
105
  }
166
106
 
167
- function handleArrayPatch(arr: any, event: PatchEvent): void {
168
- if (!arr || !Array.isArray(arr)) return;
169
- const pkValue = event.pk?.id;
170
- const idx = arr.findIndex((i: any) => i?.id === pkValue);
171
-
172
- if (event.op === 'insert') {
173
- if (idx === -1 && event.data) arr.push(event.data);
174
- } else if (event.op === 'update') {
175
- if (idx !== -1 && event.data) Object.assign(arr[idx], event.data);
176
- } else if (event.op === 'delete') {
177
- if (idx !== -1) arr.splice(idx, 1);
107
+ function applyUpdate(
108
+ doc: Record<string, unknown>,
109
+ table: string,
110
+ path: string | undefined,
111
+ op: string,
112
+ pk: Record<string, unknown>,
113
+ data: unknown
114
+ ): void {
115
+ if (!path) return;
116
+
117
+ // Navigate to target
118
+ const parts = path.split('.');
119
+ let target: any = doc;
120
+ for (let i = 0; i < parts.length - 1; i++) {
121
+ target = target?.[parts[i]];
122
+ if (!target) return;
123
+ }
124
+
125
+ const key = parts[parts.length - 1];
126
+ const value = target[key];
127
+
128
+ // If target is an object (single entity), update it directly
129
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
130
+ if (op === 'update' && data) {
131
+ Object.assign(value, data as Record<string, unknown>);
132
+ }
133
+ return;
134
+ }
135
+
136
+ // If target is an array, handle insert/update/delete
137
+ if (Array.isArray(value)) {
138
+ const pkField = Object.keys(pk)[0] || 'id';
139
+ const pkValue = pk[pkField];
140
+ const idx = value.findIndex((item: any) => item?.[pkField] === pkValue);
141
+
142
+ switch (op) {
143
+ case 'insert':
144
+ if (idx === -1 && data) value.push(data);
145
+ break;
146
+ case 'update':
147
+ if (idx !== -1 && data) Object.assign(value[idx], data);
148
+ break;
149
+ case 'delete':
150
+ if (idx !== -1) value.splice(idx, 1);
151
+ break;
152
+ }
178
153
  }
179
154
  }
180
155
 
181
- return { bind, unbind, documents };
156
+ // Self-register with WebSocket for broadcasts
157
+ ws.registerStore(table_changed);
158
+
159
+ return { bind, unbind, documents, table_changed };
182
160
  });
183
161
  `;
184
162
  }
163
+
164
+ function generatePathMappings(rootEntity: string, includes: Record<string, any>): Record<string, string> {
165
+ const mappings: Record<string, string> = {};
166
+ // Root entity is at its own key in the document (e.g., { venues: {...} })
167
+ mappings[rootEntity] = rootEntity;
168
+
169
+ function traverse(inc: Record<string, any>, prefix: string) {
170
+ for (const [key, value] of Object.entries(inc)) {
171
+ const entity = typeof value === 'string' ? value : value.entity || key;
172
+ const path = prefix ? `${prefix}.${key}` : key;
173
+ mappings[entity] = path;
174
+
175
+ if (typeof value === 'object' && value.includes) {
176
+ traverse(value.includes, path);
177
+ }
178
+ }
179
+ }
180
+
181
+ traverse(includes, '');
182
+ return mappings;
183
+ }
package/src/cli/index.ts CHANGED
@@ -3,7 +3,7 @@ import { loadDomain } from "./compiler/loader.js";
3
3
  import { analyzeDomain } from "./compiler/analyzer.js";
4
4
  import { generateIR } from "./compiler/ir.js";
5
5
  import { generateCoreSQL, generateEntitySQL, generateSchemaSQL } from "./codegen/sql.js";
6
- import { generateSubscribableSQL } from "./codegen/subscribable_sql.js";
6
+ import { generateSubscribableSQL, generateComputeAffectedKeysFunction } from "./codegen/subscribable_sql.js";
7
7
  import { generateManifest } from "./codegen/manifest.js";
8
8
  import { generateSubscribableStore } from "./codegen/subscribable_store.js";
9
9
  import { generateClientSDK } from "./codegen/client.js";
@@ -80,10 +80,13 @@ async function main() {
80
80
 
81
81
  // Generate subscribable SQL functions
82
82
  const subscribableSQL: string[] = [];
83
+ const subscribableNames = Object.keys(ir.subscribables);
83
84
  for (const [name, subIR] of Object.entries(ir.subscribables)) {
84
85
  console.log(`[Compiler] Generating SQL for subscribable: ${name}`);
85
86
  subscribableSQL.push(generateSubscribableSQL(name, subIR as any, ir.entities));
86
87
  }
88
+ // Generate central compute_affected_keys function that aggregates all subscribables
89
+ subscribableSQL.push(generateComputeAffectedKeysFunction(subscribableNames));
87
90
 
88
91
  // Collect custom functions SQL
89
92
  const customFunctionSQL: string[] = [];