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 +59 -16
- package/docs/for_ai.md +52 -2
- package/docs/project-setup.md +2 -0
- package/package.json +4 -2
- package/src/cli/codegen/notification.ts +219 -0
- package/src/cli/codegen/pinia.ts +28 -32
- package/src/cli/codegen/sql.ts +38 -6
- package/src/cli/codegen/subscribable_sql.ts +66 -9
- package/src/cli/codegen/subscribable_store.ts +101 -102
- package/src/cli/index.ts +4 -1
- package/src/client/ws.ts +177 -93
- package/src/runtime/index.ts +87 -18
- package/src/runtime/subscriptions.ts +189 -0
- package/src/runtime/ws.ts +74 -55
|
@@ -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
|
|
529
|
-
|
|
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
|
-
|
|
540
|
+
fkOnChild = field; // e.g., user_id, venue_id
|
|
533
541
|
break;
|
|
534
542
|
}
|
|
535
543
|
}
|
|
536
544
|
|
|
537
|
-
|
|
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
|
|
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->>'${
|
|
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
|
-
//
|
|
30
|
-
const
|
|
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
|
-
|
|
146
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
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[] = [];
|