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.
- 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 +89 -12
- 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 +91 -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;
|
|
@@ -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
|
-
|
|
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
|
|
529
|
-
|
|
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
|
-
|
|
548
|
+
fkOnChild = field; // e.g., user_id, venue_id
|
|
533
549
|
break;
|
|
534
550
|
}
|
|
535
551
|
}
|
|
536
552
|
|
|
537
|
-
|
|
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
|
-
|
|
542
|
-
|
|
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
|
|
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
|
-
|
|
550
|
-
|
|
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
|
-
//
|
|
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[] = [];
|