dzql 0.5.33 → 0.6.0
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/.env.sample +28 -0
- package/compose.yml +28 -0
- package/dist/client/index.ts +1 -0
- package/dist/client/stores/useMyProfileStore.ts +114 -0
- package/dist/client/stores/useOrgDashboardStore.ts +131 -0
- package/dist/client/stores/useVenueDetailStore.ts +117 -0
- package/dist/client/ws.ts +716 -0
- package/dist/db/migrations/000_core.sql +92 -0
- package/dist/db/migrations/20251229T212912022Z_schema.sql +3020 -0
- package/dist/db/migrations/20251229T212912022Z_subscribables.sql +371 -0
- package/dist/runtime/manifest.json +1562 -0
- package/docs/README.md +293 -36
- package/docs/feature-requests/applyPatch-bug-report.md +85 -0
- package/docs/feature-requests/connection-ready-profile.md +57 -0
- package/docs/feature-requests/hidden-bug-report.md +111 -0
- package/docs/feature-requests/hidden-fields-subscribables.md +34 -0
- package/docs/feature-requests/subscribable-param-key-bug.md +38 -0
- package/docs/feature-requests/todo.md +146 -0
- package/docs/for_ai.md +641 -0
- package/docs/project-setup.md +432 -0
- package/examples/blog.ts +50 -0
- package/examples/invalid.ts +18 -0
- package/examples/venues.js +485 -0
- package/package.json +23 -60
- package/src/cli/codegen/client.ts +99 -0
- package/src/cli/codegen/manifest.ts +95 -0
- package/src/cli/codegen/pinia.ts +174 -0
- package/src/cli/codegen/realtime.ts +58 -0
- package/src/cli/codegen/sql.ts +698 -0
- package/src/cli/codegen/subscribable_sql.ts +547 -0
- package/src/cli/codegen/subscribable_store.ts +184 -0
- package/src/cli/codegen/types.ts +142 -0
- package/src/cli/compiler/analyzer.ts +52 -0
- package/src/cli/compiler/graph_rules.ts +251 -0
- package/src/cli/compiler/ir.ts +233 -0
- package/src/cli/compiler/loader.ts +132 -0
- package/src/cli/compiler/permissions.ts +227 -0
- package/src/cli/index.ts +164 -0
- package/src/client/index.ts +1 -0
- package/src/client/ws.ts +286 -0
- package/src/create/.env.example +8 -0
- package/src/create/README.md +101 -0
- package/src/create/compose.yml +14 -0
- package/src/create/domain.ts +153 -0
- package/src/create/package.json +24 -0
- package/src/create/server.ts +18 -0
- package/src/create/setup.sh +11 -0
- package/src/create/tsconfig.json +15 -0
- package/src/runtime/auth.ts +39 -0
- package/src/runtime/db.ts +33 -0
- package/src/runtime/errors.ts +51 -0
- package/src/runtime/index.ts +98 -0
- package/src/runtime/js_functions.ts +63 -0
- package/src/runtime/manifest_loader.ts +29 -0
- package/src/runtime/namespace.ts +483 -0
- package/src/runtime/server.ts +87 -0
- package/src/runtime/ws.ts +197 -0
- package/src/shared/ir.ts +197 -0
- package/tests/client.test.ts +38 -0
- package/tests/codegen.test.ts +71 -0
- package/tests/compiler.test.ts +45 -0
- package/tests/graph_rules.test.ts +173 -0
- package/tests/integration/db.test.ts +174 -0
- package/tests/integration/e2e.test.ts +65 -0
- package/tests/integration/features.test.ts +922 -0
- package/tests/integration/full_stack.test.ts +262 -0
- package/tests/integration/setup.ts +45 -0
- package/tests/ir.test.ts +32 -0
- package/tests/namespace.test.ts +395 -0
- package/tests/permissions.test.ts +55 -0
- package/tests/pinia.test.ts +48 -0
- package/tests/realtime.test.ts +22 -0
- package/tests/runtime.test.ts +80 -0
- package/tests/subscribable_gen.test.ts +72 -0
- package/tests/subscribable_reactivity.test.ts +258 -0
- package/tests/venues_gen.test.ts +25 -0
- package/tsconfig.json +20 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/README.md +0 -90
- package/bin/cli.js +0 -727
- package/docs/compiler/ADVANCED_FILTERS.md +0 -183
- package/docs/compiler/CODING_STANDARDS.md +0 -415
- package/docs/compiler/COMPARISON.md +0 -673
- package/docs/compiler/QUICKSTART.md +0 -326
- package/docs/compiler/README.md +0 -134
- package/docs/examples/README.md +0 -38
- package/docs/examples/blog.sql +0 -160
- package/docs/examples/venue-detail-simple.sql +0 -8
- package/docs/examples/venue-detail-subscribable.sql +0 -45
- package/docs/for-ai/claude-guide.md +0 -1210
- package/docs/getting-started/quickstart.md +0 -125
- package/docs/getting-started/subscriptions-quick-start.md +0 -203
- package/docs/getting-started/tutorial.md +0 -1104
- package/docs/guides/atomic-updates.md +0 -299
- package/docs/guides/client-stores.md +0 -730
- package/docs/guides/composite-primary-keys.md +0 -158
- package/docs/guides/custom-functions.md +0 -362
- package/docs/guides/drop-semantics.md +0 -554
- package/docs/guides/field-defaults.md +0 -240
- package/docs/guides/interpreter-vs-compiler.md +0 -237
- package/docs/guides/many-to-many.md +0 -929
- package/docs/guides/subscriptions.md +0 -537
- package/docs/reference/api.md +0 -1373
- package/docs/reference/client.md +0 -224
- package/src/client/stores/index.js +0 -8
- package/src/client/stores/useAppStore.js +0 -285
- package/src/client/stores/useWsStore.js +0 -289
- package/src/client/ws.js +0 -762
- package/src/compiler/cli/compile-example.js +0 -33
- package/src/compiler/cli/compile-subscribable.js +0 -43
- package/src/compiler/cli/debug-compile.js +0 -44
- package/src/compiler/cli/debug-parse.js +0 -26
- package/src/compiler/cli/debug-path-parser.js +0 -18
- package/src/compiler/cli/debug-subscribable-parser.js +0 -21
- package/src/compiler/cli/index.js +0 -174
- package/src/compiler/codegen/auth-codegen.js +0 -153
- package/src/compiler/codegen/drop-semantics-codegen.js +0 -553
- package/src/compiler/codegen/graph-rules-codegen.js +0 -450
- package/src/compiler/codegen/notification-codegen.js +0 -232
- package/src/compiler/codegen/operation-codegen.js +0 -1382
- package/src/compiler/codegen/permission-codegen.js +0 -318
- package/src/compiler/codegen/subscribable-codegen.js +0 -827
- package/src/compiler/compiler.js +0 -371
- package/src/compiler/index.js +0 -11
- package/src/compiler/parser/entity-parser.js +0 -440
- package/src/compiler/parser/path-parser.js +0 -290
- package/src/compiler/parser/subscribable-parser.js +0 -244
- package/src/database/dzql-core.sql +0 -161
- package/src/database/migrations/001_schema.sql +0 -60
- package/src/database/migrations/002_functions.sql +0 -890
- package/src/database/migrations/003_operations.sql +0 -1135
- package/src/database/migrations/004_search.sql +0 -581
- package/src/database/migrations/005_entities.sql +0 -730
- package/src/database/migrations/006_auth.sql +0 -94
- package/src/database/migrations/007_events.sql +0 -133
- package/src/database/migrations/008_hello.sql +0 -18
- package/src/database/migrations/008a_meta.sql +0 -172
- package/src/database/migrations/009_subscriptions.sql +0 -240
- package/src/database/migrations/010_atomic_updates.sql +0 -157
- package/src/database/migrations/010_fix_m2m_events.sql +0 -94
- package/src/index.js +0 -40
- package/src/server/api.js +0 -9
- package/src/server/db.js +0 -442
- package/src/server/index.js +0 -317
- package/src/server/logger.js +0 -259
- package/src/server/mcp.js +0 -594
- package/src/server/meta-route.js +0 -251
- package/src/server/namespace.js +0 -292
- package/src/server/subscriptions.js +0 -351
- package/src/server/ws.js +0 -573
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { Manifest } from "./manifest.js";
|
|
2
|
+
import { SubscribableIR, IncludeIR } from "../../shared/ir.js";
|
|
3
|
+
|
|
4
|
+
function toPascalCase(str: string): string {
|
|
5
|
+
return str.replace(/(^|_)([a-z])/g, (g) => g[g.length - 1].toUpperCase());
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function generateSubscribableStore(manifest: Manifest, subName: string): string {
|
|
9
|
+
const sub = manifest.subscribables[subName];
|
|
10
|
+
if (!sub) {
|
|
11
|
+
throw new Error(`Subscribable '${subName}' not found in manifest.`);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const pascalName = toPascalCase(subName);
|
|
15
|
+
|
|
16
|
+
// Generate params interface
|
|
17
|
+
const paramEntries = Object.entries(sub.params);
|
|
18
|
+
const paramsInterface = paramEntries.length > 0
|
|
19
|
+
? paramEntries.map(([name, pgType]) => {
|
|
20
|
+
let tsType = 'unknown';
|
|
21
|
+
const type = pgType.toLowerCase();
|
|
22
|
+
if (type.includes('int')) tsType = 'number';
|
|
23
|
+
else if (type.includes('text') || type.includes('varchar')) tsType = 'string';
|
|
24
|
+
else if (type.includes('bool')) tsType = 'boolean';
|
|
25
|
+
return ` ${name}: ${tsType};`;
|
|
26
|
+
}).join('\n')
|
|
27
|
+
: ' [key: string]: unknown;';
|
|
28
|
+
|
|
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.${fkField}) {\n` +
|
|
63
|
+
` const parent = doc.${level1Rel}?.find((p: { id: number }) => p.id === event.data.${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();
|
|
83
|
+
|
|
84
|
+
return `// Generated by TZQL Compiler v${manifest.version}
|
|
85
|
+
// Do not edit this file directly.
|
|
86
|
+
|
|
87
|
+
import { defineStore } from 'pinia';
|
|
88
|
+
import { ref, type Ref } from 'vue';
|
|
89
|
+
import { ws } from '../index.js';
|
|
90
|
+
|
|
91
|
+
/** Parameters for ${subName} subscription */
|
|
92
|
+
export interface ${pascalName}Params {
|
|
93
|
+
${paramsInterface}
|
|
94
|
+
}
|
|
95
|
+
|
|
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
|
+
/** Document wrapper with loading state */
|
|
105
|
+
export interface DocumentWrapper<T> {
|
|
106
|
+
data: T;
|
|
107
|
+
loading: boolean;
|
|
108
|
+
ready: Promise<void>;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export const use${pascalName}Store = defineStore('sub-${subName}', () => {
|
|
112
|
+
const documents: Ref<Record<string, DocumentWrapper<Record<string, unknown>>>> = ref({});
|
|
113
|
+
const unsubscribers = new Map<string, () => void>();
|
|
114
|
+
|
|
115
|
+
async function bind(params: ${pascalName}Params): Promise<DocumentWrapper<Record<string, unknown>>> {
|
|
116
|
+
const key = JSON.stringify(params);
|
|
117
|
+
|
|
118
|
+
if (documents.value[key]) {
|
|
119
|
+
const existing = documents.value[key];
|
|
120
|
+
if (existing.loading) {
|
|
121
|
+
await existing.ready;
|
|
122
|
+
}
|
|
123
|
+
return existing;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let resolveReady!: () => void;
|
|
127
|
+
const ready = new Promise<void>((resolve) => { resolveReady = resolve; });
|
|
128
|
+
|
|
129
|
+
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
|
+
|
|
145
|
+
unsubscribers.set(key, unsubscribe as () => void);
|
|
146
|
+
await ready;
|
|
147
|
+
return documents.value[key];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function unbind(params: ${pascalName}Params): void {
|
|
151
|
+
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
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function applyPatch(doc: Record<string, unknown>, event: PatchEvent): void {
|
|
161
|
+
if (!doc) return;
|
|
162
|
+
switch (event.table) {
|
|
163
|
+
${patchCases}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function handleArrayPatch(arr: unknown[] | undefined, event: PatchEvent): void {
|
|
168
|
+
if (!arr || !Array.isArray(arr)) return;
|
|
169
|
+
const pkValue = event.pk?.id;
|
|
170
|
+
const idx = arr.findIndex((i: unknown) => (i as { id: number }).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] as object, event.data);
|
|
176
|
+
} else if (event.op === 'delete') {
|
|
177
|
+
if (idx !== -1) arr.splice(idx, 1);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return { bind, unbind, documents };
|
|
182
|
+
});
|
|
183
|
+
`;
|
|
184
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { EntityIR, SubscribableIR } from "../../shared/ir.js";
|
|
2
|
+
|
|
3
|
+
const TYPE_MAP: Record<string, string> = {
|
|
4
|
+
'text': 'string',
|
|
5
|
+
'int': 'number',
|
|
6
|
+
'integer': 'number',
|
|
7
|
+
'serial': 'number',
|
|
8
|
+
'bigint': 'number', // JS numbers are doubles, usually fine for IDs, or use string/bigint
|
|
9
|
+
'boolean': 'boolean',
|
|
10
|
+
'jsonb': 'any',
|
|
11
|
+
'timestamptz': 'string',
|
|
12
|
+
'date': 'string',
|
|
13
|
+
'decimal': 'number'
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// Map param types from subscribable definitions
|
|
17
|
+
const PARAM_TYPE_MAP: Record<string, string> = {
|
|
18
|
+
'int': 'number',
|
|
19
|
+
'integer': 'number',
|
|
20
|
+
'text': 'string',
|
|
21
|
+
'string': 'string',
|
|
22
|
+
'boolean': 'boolean',
|
|
23
|
+
'date': 'string',
|
|
24
|
+
'timestamptz': 'string'
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function generateTypeDefinitions(
|
|
28
|
+
entities: Record<string, EntityIR>,
|
|
29
|
+
subscribables?: Record<string, SubscribableIR>
|
|
30
|
+
): string {
|
|
31
|
+
let output = "";
|
|
32
|
+
|
|
33
|
+
// --- Common Filter Types ---
|
|
34
|
+
output += `// Filter operators for search queries
|
|
35
|
+
export interface FilterOperators<T> {
|
|
36
|
+
eq?: T;
|
|
37
|
+
ne?: T;
|
|
38
|
+
gt?: T;
|
|
39
|
+
gte?: T;
|
|
40
|
+
lt?: T;
|
|
41
|
+
lte?: T;
|
|
42
|
+
in?: T[];
|
|
43
|
+
not_in?: T[];
|
|
44
|
+
ilike?: string;
|
|
45
|
+
is_null?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type FilterValue<T> = T | FilterOperators<T>;
|
|
49
|
+
|
|
50
|
+
`;
|
|
51
|
+
|
|
52
|
+
for (const [name, entity] of Object.entries(entities)) {
|
|
53
|
+
const pascalName = toPascalCase(name);
|
|
54
|
+
|
|
55
|
+
// 1. Generate Entity Interface (Read Model)
|
|
56
|
+
output += `export interface ${pascalName} {\n`;
|
|
57
|
+
for (const col of entity.columns) {
|
|
58
|
+
const tsType = mapPostgresToTs(col.type);
|
|
59
|
+
const nullable = !col.type.toUpperCase().includes('NOT NULL') && !col.type.toUpperCase().includes('PRIMARY KEY');
|
|
60
|
+
output += ` ${col.name}${nullable ? '?' : ''}: ${tsType};\n`;
|
|
61
|
+
}
|
|
62
|
+
output += `}\n\n`;
|
|
63
|
+
|
|
64
|
+
// 2. Generate Save Params Interface (Write Model)
|
|
65
|
+
output += `export interface Save${pascalName}Params {\n`;
|
|
66
|
+
for (const col of entity.columns) {
|
|
67
|
+
const tsType = mapPostgresToTs(col.type);
|
|
68
|
+
const isPk = entity.primaryKey.includes(col.name);
|
|
69
|
+
const hasDefault = col.type.toUpperCase().includes('DEFAULT') || col.type.includes('serial');
|
|
70
|
+
const isOptional = isPk || hasDefault || !col.type.toUpperCase().includes('NOT NULL');
|
|
71
|
+
output += ` ${col.name}${isOptional ? '?' : ''}: ${tsType};\n`;
|
|
72
|
+
}
|
|
73
|
+
output += `}\n\n`;
|
|
74
|
+
|
|
75
|
+
// 3. Generate Get/Delete Params (PK)
|
|
76
|
+
output += `export interface ${pascalName}PK {\n`;
|
|
77
|
+
for (const pkCol of entity.primaryKey) {
|
|
78
|
+
const col = entity.columns.find(c => c.name === pkCol);
|
|
79
|
+
if (col) {
|
|
80
|
+
output += ` ${col.name}: ${mapPostgresToTs(col.type)};\n`;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
output += `}\n\n`;
|
|
84
|
+
|
|
85
|
+
// 4. Generate Search Filters Interface
|
|
86
|
+
output += `export interface ${pascalName}Filters {\n`;
|
|
87
|
+
for (const col of entity.columns) {
|
|
88
|
+
const tsType = mapPostgresToTs(col.type);
|
|
89
|
+
output += ` ${col.name}?: FilterValue<${tsType}>;\n`;
|
|
90
|
+
}
|
|
91
|
+
output += `}\n\n`;
|
|
92
|
+
|
|
93
|
+
// 5. Generate Search Params Interface
|
|
94
|
+
output += `export interface Search${pascalName}Params {
|
|
95
|
+
filters?: ${pascalName}Filters;
|
|
96
|
+
sort_field?: keyof ${pascalName};
|
|
97
|
+
sort_order?: 'asc' | 'desc';
|
|
98
|
+
limit?: number;
|
|
99
|
+
offset?: number;
|
|
100
|
+
}\n\n`;
|
|
101
|
+
|
|
102
|
+
// 6. Generate Lookup Params Interface
|
|
103
|
+
output += `export interface Lookup${pascalName}Params {
|
|
104
|
+
q?: string;
|
|
105
|
+
limit?: number;
|
|
106
|
+
}\n\n`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// --- Subscribable Params ---
|
|
110
|
+
if (subscribables) {
|
|
111
|
+
for (const [name, sub] of Object.entries(subscribables)) {
|
|
112
|
+
const pascalName = toPascalCase(name);
|
|
113
|
+
|
|
114
|
+
output += `export interface ${pascalName}Params {\n`;
|
|
115
|
+
for (const [paramName, paramType] of Object.entries(sub.params)) {
|
|
116
|
+
const tsType = PARAM_TYPE_MAP[paramType as string] || 'any';
|
|
117
|
+
output += ` ${paramName}: ${tsType};\n`;
|
|
118
|
+
}
|
|
119
|
+
output += `}\n\n`;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return output;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function mapPostgresToTs(pgType: string): string {
|
|
127
|
+
// Handle arrays: text[] -> string[]
|
|
128
|
+
if (pgType.endsWith('[]')) {
|
|
129
|
+
const base = pgType.slice(0, -2);
|
|
130
|
+
return mapPostgresToTs(base) + '[]';
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Simple map
|
|
134
|
+
for (const [key, val] of Object.entries(TYPE_MAP)) {
|
|
135
|
+
if (pgType.toLowerCase().includes(key)) return val;
|
|
136
|
+
}
|
|
137
|
+
return 'any';
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function toPascalCase(str: string) {
|
|
141
|
+
return str.replace(/(^|_)([a-z])/g, (g) => (g.at(-1) ?? '').toUpperCase());
|
|
142
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { DomainConfig, EntityConfig, SubscribableConfig, IncludeConfig } from "../../shared/ir.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Analyzes a domain configuration for errors and inconsistencies
|
|
5
|
+
*/
|
|
6
|
+
export function analyzeDomain(domain: DomainConfig): string[] {
|
|
7
|
+
const errors: string[] = [];
|
|
8
|
+
const entities = domain.entities;
|
|
9
|
+
const subscribables = domain.subscribables || {};
|
|
10
|
+
|
|
11
|
+
// 1. Validate Subscribables
|
|
12
|
+
for (const [subName, subConfig] of Object.entries(subscribables)) {
|
|
13
|
+
const rootEntity = subConfig.root?.entity;
|
|
14
|
+
|
|
15
|
+
// Check root entity existence
|
|
16
|
+
if (!rootEntity) {
|
|
17
|
+
errors.push(`Subscribable '${subName}' missing root entity.`);
|
|
18
|
+
} else if (!entities[rootEntity]) {
|
|
19
|
+
errors.push(`Subscribable '${subName}' references unknown root entity '${rootEntity}'.`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Recursively check includes
|
|
23
|
+
const validateIncludes = (includes: Record<string, string | IncludeConfig> | undefined, parentEntity: string) => {
|
|
24
|
+
if (!includes) return;
|
|
25
|
+
|
|
26
|
+
for (const [relName, relConfig] of Object.entries(includes)) {
|
|
27
|
+
const targetEntity = typeof relConfig === 'string'
|
|
28
|
+
? relConfig
|
|
29
|
+
: relConfig.entity;
|
|
30
|
+
|
|
31
|
+
if (!targetEntity) {
|
|
32
|
+
errors.push(`Relation '${relName}' in '${subName}' missing target entity.`);
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (!entities[targetEntity]) {
|
|
36
|
+
errors.push(`Relation '${relName}' in '${subName}' references unknown entity '${targetEntity}'.`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Recursively validate nested includes
|
|
40
|
+
if (typeof relConfig !== 'string' && relConfig.includes) {
|
|
41
|
+
validateIncludes(relConfig.includes, targetEntity);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
if (subConfig.includes) {
|
|
47
|
+
validateIncludes(subConfig.includes, rootEntity);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return errors;
|
|
52
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import type { GraphRuleIR } from "../../shared/ir.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolves a value reference (like @id, @user_id, @before.field, @after.field)
|
|
5
|
+
* into the appropriate SQL expression based on the trigger context.
|
|
6
|
+
* @param value - The value to resolve (may be @variable or literal)
|
|
7
|
+
* @param trigger - The trigger context ('create', 'update', 'delete')
|
|
8
|
+
* @param castToInt - Whether to cast the result to integer (for FK columns)
|
|
9
|
+
*/
|
|
10
|
+
function resolveValue(value: string, trigger: string, castToInt: boolean = false): string {
|
|
11
|
+
if (typeof value !== 'string') {
|
|
12
|
+
return String(value);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (!value.startsWith('@')) {
|
|
16
|
+
// Literal string value
|
|
17
|
+
return `'${value}'`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const varName = value.substring(1);
|
|
21
|
+
const intCast = castToInt ? '::int' : '';
|
|
22
|
+
|
|
23
|
+
// Special @before.field references (for update/delete triggers)
|
|
24
|
+
if (varName.startsWith('before.')) {
|
|
25
|
+
const field = varName.substring(7);
|
|
26
|
+
return `(v_old_data->>'${field}')${intCast}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Special @after.field references (for update triggers)
|
|
30
|
+
if (varName.startsWith('after.')) {
|
|
31
|
+
const field = varName.substring(6);
|
|
32
|
+
return `(v_result->>'${field}')${intCast}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Handle special keywords
|
|
36
|
+
switch (varName) {
|
|
37
|
+
case 'user_id':
|
|
38
|
+
return 'p_user_id';
|
|
39
|
+
case 'today':
|
|
40
|
+
return 'CURRENT_DATE';
|
|
41
|
+
case 'now':
|
|
42
|
+
return 'NOW()';
|
|
43
|
+
default:
|
|
44
|
+
// Generic field reference - use appropriate record based on trigger
|
|
45
|
+
if (trigger === 'delete') {
|
|
46
|
+
return `(v_old_data->>'${varName}')${intCast}`;
|
|
47
|
+
} else {
|
|
48
|
+
return `(v_result->>'${varName}')${intCast}`;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Resolves a condition string, replacing @before.field, @after.field, etc.
|
|
55
|
+
* with the appropriate SQL expressions.
|
|
56
|
+
*/
|
|
57
|
+
function resolveCondition(condition: string, trigger: string): string {
|
|
58
|
+
let sql = condition;
|
|
59
|
+
|
|
60
|
+
// Replace @before.field references
|
|
61
|
+
sql = sql.replace(/@before\.(\w+)/g, (_match, field) => {
|
|
62
|
+
return `(v_old_data->>'${field}')`;
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Replace @after.field references
|
|
66
|
+
sql = sql.replace(/@after\.(\w+)/g, (_match, field) => {
|
|
67
|
+
if (trigger === 'update' || trigger === 'create') {
|
|
68
|
+
return `(v_result->>'${field}')`;
|
|
69
|
+
}
|
|
70
|
+
return `(v_result->>'${field}')`;
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// Replace @field references (current record)
|
|
74
|
+
sql = sql.replace(/@(\w+)(?!\w)/g, (_match, field) => {
|
|
75
|
+
if (field === 'user_id') {
|
|
76
|
+
return 'p_user_id';
|
|
77
|
+
} else if (field === 'id') {
|
|
78
|
+
if (trigger === 'delete') {
|
|
79
|
+
return `(p_pk->>'id')`;
|
|
80
|
+
} else {
|
|
81
|
+
return `(v_result->>'id')`;
|
|
82
|
+
}
|
|
83
|
+
} else {
|
|
84
|
+
if (trigger === 'delete') {
|
|
85
|
+
return `(v_old_data->>'${field}')`;
|
|
86
|
+
} else {
|
|
87
|
+
return `(v_result->>'${field}')`;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
return sql;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function compileGraphRules(entity: string, trigger: string, rules: GraphRuleIR[]): string {
|
|
96
|
+
if (!rules || rules.length === 0) return "";
|
|
97
|
+
|
|
98
|
+
let sql = "";
|
|
99
|
+
|
|
100
|
+
// Group rules by condition for efficient IF block generation
|
|
101
|
+
// For now, handle each rule individually (can optimize later for same conditions)
|
|
102
|
+
for (const rule of rules) {
|
|
103
|
+
const comment = rule.description || rule.ruleName || rule.action;
|
|
104
|
+
let actionSql = "";
|
|
105
|
+
|
|
106
|
+
// === REACTOR ===
|
|
107
|
+
if (rule.action === 'reactor') {
|
|
108
|
+
const name = rule.target;
|
|
109
|
+
const params = rule.params || {};
|
|
110
|
+
|
|
111
|
+
// Build JSON object using jsonb_build_object
|
|
112
|
+
const jsonArgs: string[] = [];
|
|
113
|
+
for (const [key, val] of Object.entries(params)) {
|
|
114
|
+
const valueExpr = resolveValue(val, trigger);
|
|
115
|
+
jsonArgs.push(`'${key}', ${valueExpr}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const dataJson = jsonArgs.length > 0
|
|
119
|
+
? `jsonb_build_object(${jsonArgs.join(', ')})`
|
|
120
|
+
: `'{}'::jsonb`;
|
|
121
|
+
|
|
122
|
+
actionSql = `
|
|
123
|
+
-- Graph Rule: Reactor ${name}
|
|
124
|
+
INSERT INTO dzql_v2.events (commit_id, table_name, op, pk, data, user_id)
|
|
125
|
+
VALUES (
|
|
126
|
+
v_commit_id,
|
|
127
|
+
'${entity}',
|
|
128
|
+
'reactor:${name}',
|
|
129
|
+
jsonb_build_object('id', ${trigger === 'delete' ? "(p_pk->>'id')::text" : "(v_result->>'id')::text"}),
|
|
130
|
+
${dataJson},
|
|
131
|
+
p_user_id
|
|
132
|
+
);`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// === CREATE SIDE EFFECT ===
|
|
136
|
+
if (rule.action === 'create') {
|
|
137
|
+
const target = rule.target;
|
|
138
|
+
const data = rule.params || {};
|
|
139
|
+
const cols: string[] = [];
|
|
140
|
+
const vals: string[] = [];
|
|
141
|
+
|
|
142
|
+
for (const [key, val] of Object.entries(data)) {
|
|
143
|
+
cols.push(key);
|
|
144
|
+
// Cast to int for columns that look like FK or ID columns
|
|
145
|
+
const needsIntCast = key.endsWith('_id') || key === 'id';
|
|
146
|
+
vals.push(resolveValue(val, trigger, needsIntCast));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
actionSql = `
|
|
150
|
+
-- Graph Rule: Create ${target}
|
|
151
|
+
INSERT INTO ${target} (${cols.join(', ')})
|
|
152
|
+
VALUES (${vals.join(', ')});`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// === UPDATE SIDE EFFECT ===
|
|
156
|
+
if (rule.action === 'update') {
|
|
157
|
+
const target = rule.target;
|
|
158
|
+
const data = rule.params || {};
|
|
159
|
+
const match = rule.match || {};
|
|
160
|
+
|
|
161
|
+
const setClauses: string[] = [];
|
|
162
|
+
for (const [key, val] of Object.entries(data)) {
|
|
163
|
+
const needsIntCast = key.endsWith('_id') || key === 'id';
|
|
164
|
+
setClauses.push(`${key} = ${resolveValue(val, trigger, needsIntCast)}`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const whereClauses: string[] = [];
|
|
168
|
+
for (const [key, val] of Object.entries(match)) {
|
|
169
|
+
const needsIntCast = key.endsWith('_id') || key === 'id';
|
|
170
|
+
whereClauses.push(`${key} = ${resolveValue(val, trigger, needsIntCast)}`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const whereClause = whereClauses.length > 0 ? whereClauses.join(' AND ') : 'TRUE';
|
|
174
|
+
|
|
175
|
+
actionSql = `
|
|
176
|
+
-- Graph Rule: Update ${target}
|
|
177
|
+
UPDATE ${target}
|
|
178
|
+
SET ${setClauses.join(', ')}
|
|
179
|
+
WHERE ${whereClause};`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// === DELETE CASCADE ===
|
|
183
|
+
if (rule.action === 'delete') {
|
|
184
|
+
const target = rule.target;
|
|
185
|
+
const match = rule.match || rule.params || {};
|
|
186
|
+
const whereClauses: string[] = [];
|
|
187
|
+
|
|
188
|
+
for (const [key, val] of Object.entries(match)) {
|
|
189
|
+
const needsIntCast = key.endsWith('_id') || key === 'id';
|
|
190
|
+
whereClauses.push(`${key} = ${resolveValue(val, trigger, needsIntCast)}`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const whereClause = whereClauses.length > 0 ? whereClauses.join(' AND ') : 'TRUE';
|
|
194
|
+
|
|
195
|
+
actionSql = `
|
|
196
|
+
-- Graph Rule: Delete ${target}
|
|
197
|
+
DELETE FROM ${target} WHERE ${whereClause};`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// === VALIDATE ===
|
|
201
|
+
if (rule.action === 'validate') {
|
|
202
|
+
const functionName = rule.target;
|
|
203
|
+
const params = rule.params || {};
|
|
204
|
+
const errorMessage = rule.error_message || 'Validation failed';
|
|
205
|
+
|
|
206
|
+
const paramList: string[] = [];
|
|
207
|
+
for (const [key, val] of Object.entries(params)) {
|
|
208
|
+
paramList.push(`${key} => ${resolveValue(val, trigger)}`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const paramSQL = paramList.length > 0 ? paramList.join(', ') : '';
|
|
212
|
+
|
|
213
|
+
actionSql = `
|
|
214
|
+
-- Graph Rule: Validate (${comment})
|
|
215
|
+
IF NOT ${functionName}(${paramSQL}) THEN
|
|
216
|
+
RAISE EXCEPTION '${errorMessage}';
|
|
217
|
+
END IF;`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// === EXECUTE ===
|
|
221
|
+
if (rule.action === 'execute') {
|
|
222
|
+
const functionName = rule.target;
|
|
223
|
+
const params = rule.params || {};
|
|
224
|
+
|
|
225
|
+
const paramList: string[] = [];
|
|
226
|
+
for (const [key, val] of Object.entries(params)) {
|
|
227
|
+
paramList.push(`${key} => ${resolveValue(val, trigger)}`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const paramSQL = paramList.length > 0 ? paramList.join(', ') : '';
|
|
231
|
+
|
|
232
|
+
actionSql = `
|
|
233
|
+
-- Graph Rule: Execute ${functionName}
|
|
234
|
+
PERFORM ${functionName}(${paramSQL});`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Wrap in condition IF block if condition is present
|
|
238
|
+
if (rule.condition && actionSql) {
|
|
239
|
+
const conditionSQL = resolveCondition(rule.condition, trigger);
|
|
240
|
+
sql += `
|
|
241
|
+
-- Condition: ${rule.condition}
|
|
242
|
+
IF ${conditionSQL} THEN${actionSql}
|
|
243
|
+
END IF;
|
|
244
|
+
`;
|
|
245
|
+
} else if (actionSql) {
|
|
246
|
+
sql += actionSql + '\n';
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return sql;
|
|
251
|
+
}
|