dzql 0.5.33 → 0.6.1

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 (142) 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 +309 -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 +653 -0
  20. package/docs/project-setup.md +456 -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 +166 -0
  39. package/src/client/index.ts +1 -0
  40. package/src/client/ws.ts +286 -0
  41. package/src/runtime/auth.ts +39 -0
  42. package/src/runtime/db.ts +33 -0
  43. package/src/runtime/errors.ts +51 -0
  44. package/src/runtime/index.ts +98 -0
  45. package/src/runtime/js_functions.ts +63 -0
  46. package/src/runtime/manifest_loader.ts +29 -0
  47. package/src/runtime/namespace.ts +483 -0
  48. package/src/runtime/server.ts +87 -0
  49. package/src/runtime/ws.ts +197 -0
  50. package/src/shared/ir.ts +197 -0
  51. package/tests/client.test.ts +38 -0
  52. package/tests/codegen.test.ts +71 -0
  53. package/tests/compiler.test.ts +45 -0
  54. package/tests/graph_rules.test.ts +173 -0
  55. package/tests/integration/db.test.ts +174 -0
  56. package/tests/integration/e2e.test.ts +65 -0
  57. package/tests/integration/features.test.ts +922 -0
  58. package/tests/integration/full_stack.test.ts +262 -0
  59. package/tests/integration/setup.ts +45 -0
  60. package/tests/ir.test.ts +32 -0
  61. package/tests/namespace.test.ts +395 -0
  62. package/tests/permissions.test.ts +55 -0
  63. package/tests/pinia.test.ts +48 -0
  64. package/tests/realtime.test.ts +22 -0
  65. package/tests/runtime.test.ts +80 -0
  66. package/tests/subscribable_gen.test.ts +72 -0
  67. package/tests/subscribable_reactivity.test.ts +258 -0
  68. package/tests/venues_gen.test.ts +25 -0
  69. package/tsconfig.json +20 -0
  70. package/tsconfig.tsbuildinfo +1 -0
  71. package/README.md +0 -90
  72. package/bin/cli.js +0 -727
  73. package/docs/compiler/ADVANCED_FILTERS.md +0 -183
  74. package/docs/compiler/CODING_STANDARDS.md +0 -415
  75. package/docs/compiler/COMPARISON.md +0 -673
  76. package/docs/compiler/QUICKSTART.md +0 -326
  77. package/docs/compiler/README.md +0 -134
  78. package/docs/examples/README.md +0 -38
  79. package/docs/examples/blog.sql +0 -160
  80. package/docs/examples/venue-detail-simple.sql +0 -8
  81. package/docs/examples/venue-detail-subscribable.sql +0 -45
  82. package/docs/for-ai/claude-guide.md +0 -1210
  83. package/docs/getting-started/quickstart.md +0 -125
  84. package/docs/getting-started/subscriptions-quick-start.md +0 -203
  85. package/docs/getting-started/tutorial.md +0 -1104
  86. package/docs/guides/atomic-updates.md +0 -299
  87. package/docs/guides/client-stores.md +0 -730
  88. package/docs/guides/composite-primary-keys.md +0 -158
  89. package/docs/guides/custom-functions.md +0 -362
  90. package/docs/guides/drop-semantics.md +0 -554
  91. package/docs/guides/field-defaults.md +0 -240
  92. package/docs/guides/interpreter-vs-compiler.md +0 -237
  93. package/docs/guides/many-to-many.md +0 -929
  94. package/docs/guides/subscriptions.md +0 -537
  95. package/docs/reference/api.md +0 -1373
  96. package/docs/reference/client.md +0 -224
  97. package/src/client/stores/index.js +0 -8
  98. package/src/client/stores/useAppStore.js +0 -285
  99. package/src/client/stores/useWsStore.js +0 -289
  100. package/src/client/ws.js +0 -762
  101. package/src/compiler/cli/compile-example.js +0 -33
  102. package/src/compiler/cli/compile-subscribable.js +0 -43
  103. package/src/compiler/cli/debug-compile.js +0 -44
  104. package/src/compiler/cli/debug-parse.js +0 -26
  105. package/src/compiler/cli/debug-path-parser.js +0 -18
  106. package/src/compiler/cli/debug-subscribable-parser.js +0 -21
  107. package/src/compiler/cli/index.js +0 -174
  108. package/src/compiler/codegen/auth-codegen.js +0 -153
  109. package/src/compiler/codegen/drop-semantics-codegen.js +0 -553
  110. package/src/compiler/codegen/graph-rules-codegen.js +0 -450
  111. package/src/compiler/codegen/notification-codegen.js +0 -232
  112. package/src/compiler/codegen/operation-codegen.js +0 -1382
  113. package/src/compiler/codegen/permission-codegen.js +0 -318
  114. package/src/compiler/codegen/subscribable-codegen.js +0 -827
  115. package/src/compiler/compiler.js +0 -371
  116. package/src/compiler/index.js +0 -11
  117. package/src/compiler/parser/entity-parser.js +0 -440
  118. package/src/compiler/parser/path-parser.js +0 -290
  119. package/src/compiler/parser/subscribable-parser.js +0 -244
  120. package/src/database/dzql-core.sql +0 -161
  121. package/src/database/migrations/001_schema.sql +0 -60
  122. package/src/database/migrations/002_functions.sql +0 -890
  123. package/src/database/migrations/003_operations.sql +0 -1135
  124. package/src/database/migrations/004_search.sql +0 -581
  125. package/src/database/migrations/005_entities.sql +0 -730
  126. package/src/database/migrations/006_auth.sql +0 -94
  127. package/src/database/migrations/007_events.sql +0 -133
  128. package/src/database/migrations/008_hello.sql +0 -18
  129. package/src/database/migrations/008a_meta.sql +0 -172
  130. package/src/database/migrations/009_subscriptions.sql +0 -240
  131. package/src/database/migrations/010_atomic_updates.sql +0 -157
  132. package/src/database/migrations/010_fix_m2m_events.sql +0 -94
  133. package/src/index.js +0 -40
  134. package/src/server/api.js +0 -9
  135. package/src/server/db.js +0 -442
  136. package/src/server/index.js +0 -317
  137. package/src/server/logger.js +0 -259
  138. package/src/server/mcp.js +0 -594
  139. package/src/server/meta-route.js +0 -251
  140. package/src/server/namespace.js +0 -292
  141. package/src/server/subscriptions.js +0 -351
  142. 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
+ }