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.
Files changed (150) hide show
  1. package/.env.sample +28 -0
  2. package/compose.yml +28 -0
  3. package/dist/client/index.ts +1 -0
  4. package/dist/client/stores/useMyProfileStore.ts +114 -0
  5. package/dist/client/stores/useOrgDashboardStore.ts +131 -0
  6. package/dist/client/stores/useVenueDetailStore.ts +117 -0
  7. package/dist/client/ws.ts +716 -0
  8. package/dist/db/migrations/000_core.sql +92 -0
  9. package/dist/db/migrations/20251229T212912022Z_schema.sql +3020 -0
  10. package/dist/db/migrations/20251229T212912022Z_subscribables.sql +371 -0
  11. package/dist/runtime/manifest.json +1562 -0
  12. package/docs/README.md +293 -36
  13. package/docs/feature-requests/applyPatch-bug-report.md +85 -0
  14. package/docs/feature-requests/connection-ready-profile.md +57 -0
  15. package/docs/feature-requests/hidden-bug-report.md +111 -0
  16. package/docs/feature-requests/hidden-fields-subscribables.md +34 -0
  17. package/docs/feature-requests/subscribable-param-key-bug.md +38 -0
  18. package/docs/feature-requests/todo.md +146 -0
  19. package/docs/for_ai.md +641 -0
  20. package/docs/project-setup.md +432 -0
  21. package/examples/blog.ts +50 -0
  22. package/examples/invalid.ts +18 -0
  23. package/examples/venues.js +485 -0
  24. package/package.json +23 -60
  25. package/src/cli/codegen/client.ts +99 -0
  26. package/src/cli/codegen/manifest.ts +95 -0
  27. package/src/cli/codegen/pinia.ts +174 -0
  28. package/src/cli/codegen/realtime.ts +58 -0
  29. package/src/cli/codegen/sql.ts +698 -0
  30. package/src/cli/codegen/subscribable_sql.ts +547 -0
  31. package/src/cli/codegen/subscribable_store.ts +184 -0
  32. package/src/cli/codegen/types.ts +142 -0
  33. package/src/cli/compiler/analyzer.ts +52 -0
  34. package/src/cli/compiler/graph_rules.ts +251 -0
  35. package/src/cli/compiler/ir.ts +233 -0
  36. package/src/cli/compiler/loader.ts +132 -0
  37. package/src/cli/compiler/permissions.ts +227 -0
  38. package/src/cli/index.ts +164 -0
  39. package/src/client/index.ts +1 -0
  40. package/src/client/ws.ts +286 -0
  41. package/src/create/.env.example +8 -0
  42. package/src/create/README.md +101 -0
  43. package/src/create/compose.yml +14 -0
  44. package/src/create/domain.ts +153 -0
  45. package/src/create/package.json +24 -0
  46. package/src/create/server.ts +18 -0
  47. package/src/create/setup.sh +11 -0
  48. package/src/create/tsconfig.json +15 -0
  49. package/src/runtime/auth.ts +39 -0
  50. package/src/runtime/db.ts +33 -0
  51. package/src/runtime/errors.ts +51 -0
  52. package/src/runtime/index.ts +98 -0
  53. package/src/runtime/js_functions.ts +63 -0
  54. package/src/runtime/manifest_loader.ts +29 -0
  55. package/src/runtime/namespace.ts +483 -0
  56. package/src/runtime/server.ts +87 -0
  57. package/src/runtime/ws.ts +197 -0
  58. package/src/shared/ir.ts +197 -0
  59. package/tests/client.test.ts +38 -0
  60. package/tests/codegen.test.ts +71 -0
  61. package/tests/compiler.test.ts +45 -0
  62. package/tests/graph_rules.test.ts +173 -0
  63. package/tests/integration/db.test.ts +174 -0
  64. package/tests/integration/e2e.test.ts +65 -0
  65. package/tests/integration/features.test.ts +922 -0
  66. package/tests/integration/full_stack.test.ts +262 -0
  67. package/tests/integration/setup.ts +45 -0
  68. package/tests/ir.test.ts +32 -0
  69. package/tests/namespace.test.ts +395 -0
  70. package/tests/permissions.test.ts +55 -0
  71. package/tests/pinia.test.ts +48 -0
  72. package/tests/realtime.test.ts +22 -0
  73. package/tests/runtime.test.ts +80 -0
  74. package/tests/subscribable_gen.test.ts +72 -0
  75. package/tests/subscribable_reactivity.test.ts +258 -0
  76. package/tests/venues_gen.test.ts +25 -0
  77. package/tsconfig.json +20 -0
  78. package/tsconfig.tsbuildinfo +1 -0
  79. package/README.md +0 -90
  80. package/bin/cli.js +0 -727
  81. package/docs/compiler/ADVANCED_FILTERS.md +0 -183
  82. package/docs/compiler/CODING_STANDARDS.md +0 -415
  83. package/docs/compiler/COMPARISON.md +0 -673
  84. package/docs/compiler/QUICKSTART.md +0 -326
  85. package/docs/compiler/README.md +0 -134
  86. package/docs/examples/README.md +0 -38
  87. package/docs/examples/blog.sql +0 -160
  88. package/docs/examples/venue-detail-simple.sql +0 -8
  89. package/docs/examples/venue-detail-subscribable.sql +0 -45
  90. package/docs/for-ai/claude-guide.md +0 -1210
  91. package/docs/getting-started/quickstart.md +0 -125
  92. package/docs/getting-started/subscriptions-quick-start.md +0 -203
  93. package/docs/getting-started/tutorial.md +0 -1104
  94. package/docs/guides/atomic-updates.md +0 -299
  95. package/docs/guides/client-stores.md +0 -730
  96. package/docs/guides/composite-primary-keys.md +0 -158
  97. package/docs/guides/custom-functions.md +0 -362
  98. package/docs/guides/drop-semantics.md +0 -554
  99. package/docs/guides/field-defaults.md +0 -240
  100. package/docs/guides/interpreter-vs-compiler.md +0 -237
  101. package/docs/guides/many-to-many.md +0 -929
  102. package/docs/guides/subscriptions.md +0 -537
  103. package/docs/reference/api.md +0 -1373
  104. package/docs/reference/client.md +0 -224
  105. package/src/client/stores/index.js +0 -8
  106. package/src/client/stores/useAppStore.js +0 -285
  107. package/src/client/stores/useWsStore.js +0 -289
  108. package/src/client/ws.js +0 -762
  109. package/src/compiler/cli/compile-example.js +0 -33
  110. package/src/compiler/cli/compile-subscribable.js +0 -43
  111. package/src/compiler/cli/debug-compile.js +0 -44
  112. package/src/compiler/cli/debug-parse.js +0 -26
  113. package/src/compiler/cli/debug-path-parser.js +0 -18
  114. package/src/compiler/cli/debug-subscribable-parser.js +0 -21
  115. package/src/compiler/cli/index.js +0 -174
  116. package/src/compiler/codegen/auth-codegen.js +0 -153
  117. package/src/compiler/codegen/drop-semantics-codegen.js +0 -553
  118. package/src/compiler/codegen/graph-rules-codegen.js +0 -450
  119. package/src/compiler/codegen/notification-codegen.js +0 -232
  120. package/src/compiler/codegen/operation-codegen.js +0 -1382
  121. package/src/compiler/codegen/permission-codegen.js +0 -318
  122. package/src/compiler/codegen/subscribable-codegen.js +0 -827
  123. package/src/compiler/compiler.js +0 -371
  124. package/src/compiler/index.js +0 -11
  125. package/src/compiler/parser/entity-parser.js +0 -440
  126. package/src/compiler/parser/path-parser.js +0 -290
  127. package/src/compiler/parser/subscribable-parser.js +0 -244
  128. package/src/database/dzql-core.sql +0 -161
  129. package/src/database/migrations/001_schema.sql +0 -60
  130. package/src/database/migrations/002_functions.sql +0 -890
  131. package/src/database/migrations/003_operations.sql +0 -1135
  132. package/src/database/migrations/004_search.sql +0 -581
  133. package/src/database/migrations/005_entities.sql +0 -730
  134. package/src/database/migrations/006_auth.sql +0 -94
  135. package/src/database/migrations/007_events.sql +0 -133
  136. package/src/database/migrations/008_hello.sql +0 -18
  137. package/src/database/migrations/008a_meta.sql +0 -172
  138. package/src/database/migrations/009_subscriptions.sql +0 -240
  139. package/src/database/migrations/010_atomic_updates.sql +0 -157
  140. package/src/database/migrations/010_fix_m2m_events.sql +0 -94
  141. package/src/index.js +0 -40
  142. package/src/server/api.js +0 -9
  143. package/src/server/db.js +0 -442
  144. package/src/server/index.js +0 -317
  145. package/src/server/logger.js +0 -259
  146. package/src/server/mcp.js +0 -594
  147. package/src/server/meta-route.js +0 -251
  148. package/src/server/namespace.js +0 -292
  149. package/src/server/subscriptions.js +0 -351
  150. 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
+ }