dzql 0.6.13 → 0.6.15

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;
@@ -512,42 +512,78 @@ function generateNestedSelects(
512
512
  function generateAffectedKeysFunction(name: string, sub: SubscribableIR): string {
513
513
  const cases: string[] = [];
514
514
  const paramKey = sub.root.key;
515
+ const hasParams = Object.keys(sub.params).length > 0;
515
516
 
516
517
  // Root entity case
517
- cases.push(` WHEN '${sub.root.entity}' THEN
518
+ // For subscribables with no params (list feeds), just return the subscribable name
519
+ // For subscribables with params, include the root entity's id
520
+ if (hasParams) {
521
+ cases.push(` WHEN '${sub.root.entity}' THEN
518
522
  RETURN ARRAY['${name}:' || (p_data->>'id')];`);
523
+ } else {
524
+ cases.push(` WHEN '${sub.root.entity}' THEN
525
+ RETURN ARRAY['${name}'];`);
526
+ }
519
527
 
520
528
  // Get singular form of root entity for FK fields
521
529
  const singularRootEntity = singularize(sub.root.entity);
522
530
 
523
531
  // Related entity cases
532
+ // Track FK relationships: parentEntity -> { childEntity: { fkOnParent, fkOnChild } }
533
+ const relationships: Record<string, Record<string, { fkOnParent: string }>> = {};
534
+
524
535
  const addRelationCase = (relName: string, relConfig: IncludeIR, parentEntity: string) => {
525
536
  const relEntity = relConfig.entity;
526
537
  const filter = relConfig.filter || {};
527
538
 
528
- // Find the FK field that points to root (use singular form)
529
- let fkField = `${singularRootEntity}_id`;
539
+ // Find the FK field on parent that points to child
540
+ // For includes like `org: 'organisations'`, the FK is `org_id` on the parent
541
+ // For includes like `{ entity: 'comments', filter: { post_id: '@id' } }`, the FK is on child
542
+ let fkOnParent = `${relName}_id`; // Default: relation name + _id (e.g., org -> org_id)
543
+
544
+ // Check if filter references @id - if so, the FK is on the child pointing to parent
545
+ let fkOnChild: string | null = null;
530
546
  for (const [field, value] of Object.entries(filter)) {
531
547
  if (value === '@id' || value === `@${paramKey}`) {
532
- fkField = field;
548
+ fkOnChild = field; // e.g., user_id, venue_id
533
549
  break;
534
550
  }
535
551
  }
536
552
 
537
- const singularParent = singularize(parentEntity);
553
+ // Store relationship for nested lookups
554
+ if (!relationships[parentEntity]) relationships[parentEntity] = {};
555
+ relationships[parentEntity][relEntity] = { fkOnParent };
538
556
 
539
- // For nested relations, we need to traverse up
557
+ // For nested relations, we need to traverse up via the FK on parent
540
558
  if (parentEntity !== sub.root.entity) {
541
- cases.push(` WHEN '${relEntity}' THEN
542
- -- Nested: traverse via ${parentEntity}
559
+ // Get the FK that parent uses to reference this child entity
560
+ const parentRel = relationships[parentEntity]?.[relEntity];
561
+ const fkField = parentRel?.fkOnParent || `${relName}_id`;
562
+
563
+ if (hasParams) {
564
+ cases.push(` WHEN '${relEntity}' THEN
565
+ -- Nested: traverse via ${parentEntity}.${fkField}
543
566
  SELECT ARRAY_AGG('${name}:' || parent.${singularRootEntity}_id)
544
567
  INTO v_keys
545
568
  FROM ${parentEntity} parent
546
- WHERE parent.id = (p_data->>'${singularParent}_id')::int;
569
+ WHERE parent.${fkField} = (p_data->>'id')::int;
547
570
  RETURN COALESCE(v_keys, ARRAY[]::text[]);`);
571
+ } else {
572
+ // No params - just return the subscribable name
573
+ cases.push(` WHEN '${relEntity}' THEN
574
+ RETURN ARRAY['${name}'];`);
575
+ }
548
576
  } else {
549
- cases.push(` WHEN '${relEntity}' THEN
550
- RETURN ARRAY['${name}:' || (p_data->>'${fkField}')];`);
577
+ // Direct child of root - use the FK on child that points to root
578
+ const keyField = fkOnChild || `${singularRootEntity}_id`;
579
+ if (hasParams) {
580
+ cases.push(` WHEN '${relEntity}' THEN
581
+ RETURN ARRAY['${name}:' || (p_data->>'${keyField}')];`);
582
+ } else {
583
+ // No params - just return the subscribable name
584
+ cases.push(` WHEN '${relEntity}' THEN
585
+ RETURN ARRAY['${name}'];`);
586
+ }
551
587
  }
552
588
 
553
589
  // Recurse for nested includes
@@ -582,6 +618,47 @@ END;
582
618
  $$;`;
583
619
  }
584
620
 
621
+ /**
622
+ * Generate the central compute_affected_keys function that aggregates all subscribable affected keys.
623
+ * This is called from save/delete functions to compute all affected subscription keys at write time.
624
+ */
625
+ export function generateComputeAffectedKeysFunction(subscribableNames: string[]): string {
626
+ if (subscribableNames.length === 0) {
627
+ return `-- No subscribables defined, empty compute_affected_keys function
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 ARRAY[]::text[];
638
+ END;
639
+ $$;`;
640
+ }
641
+
642
+ const calls = subscribableNames.map(name =>
643
+ `dzql_v2.${name}_affected_keys(p_table, p_op, p_data)`
644
+ ).join(' || ');
645
+
646
+ return `-- Central function to compute all affected subscription keys
647
+ -- Called from save/delete functions at write time
648
+ CREATE OR REPLACE FUNCTION dzql_v2.compute_affected_keys(
649
+ p_table TEXT,
650
+ p_op TEXT,
651
+ p_data JSONB
652
+ ) RETURNS TEXT[]
653
+ LANGUAGE plpgsql
654
+ IMMUTABLE
655
+ AS $$
656
+ BEGIN
657
+ RETURN ${calls};
658
+ END;
659
+ $$;`;
660
+ }
661
+
585
662
  function buildPathMapping(
586
663
  rootEntity: string,
587
664
  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[] = [];