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,547 @@
1
+ /**
2
+ * Subscribable SQL Code Generator
3
+ * Generates PostgreSQL functions for live query subscriptions
4
+ *
5
+ * For each subscribable, generates:
6
+ * 1. get_<name>(p_params, p_user_id) - Query function that builds the document
7
+ * 2. <name>_can_subscribe(p_user_id, p_params) - Access control check
8
+ * 3. <name>_affected_keys(p_table, p_op, p_data) - Determines which subscriptions are affected
9
+ */
10
+
11
+ import type { SubscribableIR, IncludeIR, EntityIR } from "../../shared/ir.js";
12
+
13
+ export function generateSubscribableSQL(name: string, sub: SubscribableIR, entities: Record<string, EntityIR> = {}): string {
14
+ const sections: string[] = [];
15
+
16
+ sections.push(generateHeader(name, sub));
17
+ sections.push(generateCanSubscribeFunction(name, sub));
18
+ sections.push(generateGetFunction(name, sub, entities));
19
+ sections.push(generateAffectedKeysFunction(name, sub));
20
+
21
+ return sections.join('\n\n');
22
+ }
23
+
24
+ function generateHeader(name: string, sub: SubscribableIR): string {
25
+ return `-- ============================================================================
26
+ -- Subscribable: ${name}
27
+ -- Root Entity: ${sub.root.entity}
28
+ -- Scope Tables: ${sub.scopeTables.join(', ')}
29
+ -- Generated: ${new Date().toISOString()}
30
+ -- ============================================================================`;
31
+ }
32
+
33
+ function generateCanSubscribeFunction(name: string, sub: SubscribableIR): string {
34
+ const subscribePaths = sub.canSubscribe || [];
35
+
36
+ // If no paths, it's public
37
+ if (subscribePaths.length === 0) {
38
+ return `CREATE OR REPLACE FUNCTION dzql_v2.${name}_can_subscribe(
39
+ p_user_id INT,
40
+ p_params JSONB
41
+ ) RETURNS BOOLEAN
42
+ LANGUAGE plpgsql
43
+ STABLE
44
+ SECURITY DEFINER
45
+ SET search_path = dzql_v2, public
46
+ AS $$
47
+ BEGIN
48
+ RETURN TRUE; -- Public access
49
+ END;
50
+ $$;`;
51
+ }
52
+
53
+ // Generate permission checks using RECORD dot notation
54
+ // Pass the root key so we can map param references to root entity's id
55
+ const compiledChecks = subscribePaths.map(path =>
56
+ compileSubscribePermission(sub.root.entity, path, sub.root.key)
57
+ );
58
+
59
+ // If all checks compile to FALSE (unsupported paths), fall back to authenticated users
60
+ const allFalse = compiledChecks.every(c => c === 'FALSE');
61
+ const checks = allFalse
62
+ ? 'p_user_id IS NOT NULL -- Fallback: multi-level paths not yet supported'
63
+ : compiledChecks.join(' OR\n ');
64
+
65
+ // Extract params
66
+ const paramNames = Object.keys(sub.params);
67
+ const paramDecls = paramNames.map(p => ` v_${p} ${sub.params[p]};`).join('\n');
68
+ const paramExtracts = paramNames.map(p =>
69
+ ` v_${p} := (p_params->>'${p}')::${sub.params[p]};`
70
+ ).join('\n');
71
+
72
+ return `CREATE OR REPLACE FUNCTION dzql_v2.${name}_can_subscribe(
73
+ p_user_id INT,
74
+ p_params JSONB
75
+ ) RETURNS BOOLEAN
76
+ LANGUAGE plpgsql
77
+ STABLE
78
+ SECURITY DEFINER
79
+ SET search_path = dzql_v2, public
80
+ AS $$
81
+ DECLARE
82
+ ${paramDecls}
83
+ v_root RECORD;
84
+ BEGIN
85
+ -- Extract parameters
86
+ ${paramExtracts}
87
+
88
+ -- Fetch root entity
89
+ SELECT * INTO v_root
90
+ FROM ${sub.root.entity}
91
+ WHERE id = v_${sub.root.key};
92
+
93
+ IF NOT FOUND THEN
94
+ RETURN FALSE;
95
+ END IF;
96
+
97
+ -- Check permissions
98
+ RETURN (
99
+ ${checks}
100
+ );
101
+ END;
102
+ $$;`;
103
+ }
104
+
105
+ /**
106
+ * Compile permission for subscribable can_subscribe function.
107
+ * Uses RECORD dot notation since v_root is a RECORD, not JSONB.
108
+ *
109
+ * @param entityName - The root entity name
110
+ * @param rule - The permission rule (e.g., "@org_id->acts_for[org_id=$]{active}.user_id")
111
+ * @param rootKey - The param key used to look up the root entity (e.g., "org_id")
112
+ * When @field matches rootKey, it maps to v_root.id (the root entity's PK)
113
+ */
114
+ function compileSubscribePermission(entityName: string, rule: string, rootKey: string = ''): string {
115
+ // Helper to resolve field reference - if field matches rootKey param, use 'id' instead
116
+ const resolveField = (field: string): string => {
117
+ return field === rootKey ? 'id' : field;
118
+ };
119
+
120
+ // Case 1: Simple Field Check (@org_id)
121
+ // Implies: EXISTS (SELECT 1 FROM acts_for WHERE acts_for.org_id = v_root.id AND acts_for.user_id = p_user_id)
122
+ if (rule.match(/^@[a-zA-Z0-9_]+$/)) {
123
+ const field = rule.substring(1);
124
+ const rootField = resolveField(field);
125
+ // For simple field checks, we check acts_for membership
126
+ // The acts_for join uses the original field name (org_id), but v_root uses resolved field (id)
127
+ return `EXISTS (SELECT 1 FROM acts_for WHERE acts_for.${field} = v_root.${rootField} AND acts_for.user_id = p_user_id AND acts_for.active)`;
128
+ }
129
+
130
+ // Case 2: Graph Traversal (@field->table[filter]{condition}.user_id)
131
+ if (rule.includes('->')) {
132
+ const parts = rule.split('->');
133
+ const startField = parts[0].substring(1); // Remove @
134
+ const rootField = resolveField(startField);
135
+
136
+ // Multi-level paths not fully supported
137
+ if (parts.length > 2) {
138
+ console.warn(`[Compiler] Warning: Multi-level permission path '${rule}' not fully supported.`);
139
+ return 'FALSE';
140
+ }
141
+
142
+ const targetPart = parts[1];
143
+
144
+ const tableMatch = targetPart.match(/^([a-zA-Z0-9_]+)/);
145
+ if (!tableMatch) return 'FALSE';
146
+ const targetTable = tableMatch[1];
147
+
148
+ // Extract filter: [org_id=$]
149
+ const filterMatch = targetPart.match(/\[([a-zA-Z0-9_]+)=\$\]/);
150
+ const joinField = filterMatch ? filterMatch[1] : null;
151
+
152
+ // Extract condition: {active}
153
+ const condMatch = targetPart.match(/\{([^}]+)\}/);
154
+ const condition = condMatch ? condMatch[1] : null;
155
+
156
+ let sql = `EXISTS (SELECT 1 FROM ${targetTable} WHERE `;
157
+
158
+ // Join Clause - use RECORD dot notation
159
+ // The target table uses its own column name (e.g., acts_for.org_id)
160
+ // The root uses the resolved field (e.g., v_root.id when startField matches rootKey)
161
+ if (joinField) {
162
+ sql += `${targetTable}.${joinField} = v_root.${rootField}`;
163
+ } else {
164
+ // Implicit join: table.id = v_root.field
165
+ sql += `${targetTable}.id = v_root.${rootField}`;
166
+ }
167
+
168
+ // User Check
169
+ if (rule.endsWith('.user_id')) {
170
+ sql += ` AND ${targetTable}.user_id = p_user_id`;
171
+ }
172
+
173
+ // Condition
174
+ if (condition) {
175
+ sql += ` AND ${targetTable}.${condition}`;
176
+ }
177
+
178
+ sql += `)`;
179
+ return sql;
180
+ }
181
+
182
+ return 'FALSE'; // Default deny
183
+ }
184
+
185
+ /** Column info from EntityIR */
186
+ interface ColumnInfo {
187
+ name: string;
188
+ type: string;
189
+ isArray: boolean;
190
+ }
191
+
192
+ /**
193
+ * Build a SQL expression to select visible columns (excluding hidden) from a table.
194
+ * Returns row_to_json(alias.*) if no hidden fields, otherwise builds explicit jsonb_build_object.
195
+ */
196
+ function buildVisibleRowJson(alias: string, entityName: string, entities: Record<string, EntityIR>): string {
197
+ const entity = entities[entityName];
198
+ const hidden = entity?.hidden || [];
199
+
200
+ if (hidden.length === 0) {
201
+ return `row_to_json(${alias}.*)`;
202
+ }
203
+
204
+ // Build explicit column list excluding hidden fields
205
+ const columns: ColumnInfo[] = entity?.columns || [];
206
+ const visibleCols = columns.filter((c: ColumnInfo) => !hidden.includes(c.name));
207
+
208
+ if (visibleCols.length === 0) {
209
+ return `row_to_json(${alias}.*)`;
210
+ }
211
+
212
+ const pairs = visibleCols.map((c: ColumnInfo) => `'${c.name}', ${alias}.${c.name}`).join(', ');
213
+ return `jsonb_build_object(${pairs})`;
214
+ }
215
+
216
+ function generateGetFunction(name: string, sub: SubscribableIR, entities: Record<string, EntityIR> = {}): string {
217
+ const paramNames = Object.keys(sub.params);
218
+ const paramDecls = paramNames.map(p => ` v_${p} ${sub.params[p]};`).join('\n');
219
+ const paramExtracts = paramNames.map(p =>
220
+ ` v_${p} := (p_params->>'${p}')::${sub.params[p]};`
221
+ ).join('\n');
222
+
223
+ // Handle special @user_id root key
224
+ const rootKey = sub.root.key;
225
+ const isUserIdRoot = rootKey === '@user_id';
226
+ const rootWhereValue = isUserIdRoot ? 'p_user_id' : `v_${rootKey}`;
227
+
228
+ // Build root select expression excluding hidden fields
229
+ const rootSelectExpr = buildVisibleRowJson('root', sub.root.entity, entities);
230
+
231
+ // Build relation subqueries, passing param names and entities for reference resolution
232
+ const relationSelects = generateRelationSelects(sub.includes, sub.root.entity, 'root', paramNames, entities);
233
+
234
+ // Build schema with path mapping and scope tables
235
+ const pathMapping = buildPathMapping(sub.root.entity, sub.includes);
236
+ const schemaJson = JSON.stringify({
237
+ root: sub.root.entity,
238
+ paths: pathMapping,
239
+ scopeTables: sub.scopeTables
240
+ }).replace(/'/g, "''"); // Escape single quotes for SQL
241
+
242
+ return `CREATE OR REPLACE FUNCTION dzql_v2.get_${name}(
243
+ p_params JSONB,
244
+ p_user_id INT
245
+ ) RETURNS JSONB
246
+ LANGUAGE plpgsql
247
+ SECURITY DEFINER
248
+ SET search_path = dzql_v2, public
249
+ AS $$
250
+ DECLARE
251
+ ${paramDecls}
252
+ v_data JSONB;
253
+ BEGIN
254
+ -- Extract parameters
255
+ ${paramExtracts}
256
+
257
+ -- Check access control
258
+ IF NOT dzql_v2.${name}_can_subscribe(p_user_id, p_params) THEN
259
+ RAISE EXCEPTION 'permission_denied';
260
+ END IF;
261
+
262
+ -- Build document with root and all relations
263
+ SELECT jsonb_build_object(
264
+ '${sub.root.entity}', ${rootSelectExpr}${relationSelects}
265
+ )
266
+ INTO v_data
267
+ FROM ${sub.root.entity} root
268
+ WHERE root.id = ${rootWhereValue};
269
+
270
+ -- Return data with embedded schema for atomic updates
271
+ RETURN jsonb_build_object(
272
+ 'data', v_data,
273
+ 'schema', '${schemaJson}'::jsonb
274
+ );
275
+ END;
276
+ $$;`;
277
+ }
278
+
279
+ function singularize(name: string): string {
280
+ // Simple singularization: remove trailing 's'
281
+ if (name.endsWith('ies')) return name.slice(0, -3) + 'y';
282
+ if (name.endsWith('s') && !name.endsWith('ss')) return name.slice(0, -1);
283
+ return name;
284
+ }
285
+
286
+ function generateRelationSelects(
287
+ includes: Record<string, IncludeIR>,
288
+ rootEntity: string,
289
+ parentAlias: string = 'root',
290
+ paramNames: string[] = [],
291
+ entities: Record<string, EntityIR> = {}
292
+ ): string {
293
+ if (!includes || Object.keys(includes).length === 0) {
294
+ return '';
295
+ }
296
+
297
+ const selects = Object.entries(includes).map(([relName, relConfig]) => {
298
+ const relEntity = relConfig.entity;
299
+ const filter = relConfig.filter || {};
300
+ const hasNestedIncludes = relConfig.includes && Object.keys(relConfig.includes).length > 0;
301
+
302
+ // Detect relation type:
303
+ // - If relation name != entity name (e.g., "org" vs "organisations"), it's a PARENT relation
304
+ // - If relation name == entity name (e.g., "sites" == "sites"), it's a CHILD collection
305
+ // Parent relations use FK like root.org_id -> organisations.id
306
+ // Child relations use FK like sites.venue_id -> root.id
307
+ const isParentRelation = relName !== relEntity && !hasNestedIncludes && Object.keys(filter).length === 0;
308
+
309
+ // Build WHERE clause from filter
310
+ const whereClauses: string[] = [];
311
+ for (const [field, value] of Object.entries(filter)) {
312
+ if (value.startsWith('@')) {
313
+ const refName = value.substring(1);
314
+ // Check if this is a param reference or parent field reference
315
+ if (paramNames.includes(refName) || refName === 'user_id') {
316
+ // Param reference: use v_param_name or p_user_id
317
+ const varName = refName === 'user_id' ? 'p_user_id' : `v_${refName}`;
318
+ whereClauses.push(`rel.${field} = ${varName}`);
319
+ } else {
320
+ // Parent field reference: @id -> root.id
321
+ whereClauses.push(`rel.${field} = ${parentAlias}.${refName}`);
322
+ }
323
+ } else {
324
+ whereClauses.push(`rel.${field} = '${value}'`);
325
+ }
326
+ }
327
+
328
+ // Default FK based on relation type
329
+ if (whereClauses.length === 0) {
330
+ if (isParentRelation) {
331
+ // Parent relation: root.{relName}_id = rel.id
332
+ whereClauses.push(`rel.id = ${parentAlias}.${relName}_id`);
333
+ } else {
334
+ // Child relation: rel.{singularRoot}_id = root.id
335
+ const singularRoot = singularize(rootEntity);
336
+ whereClauses.push(`rel.${singularRoot}_id = ${parentAlias}.id`);
337
+ }
338
+ }
339
+
340
+ const whereSQL = whereClauses.join(' AND ');
341
+
342
+ // Build select expression excluding hidden fields
343
+ const relSelectExpr = buildVisibleRowJson('rel', relEntity, entities);
344
+
345
+ // Handle nested includes recursively
346
+ let nestedSelects = '';
347
+ if (relConfig.includes) {
348
+ nestedSelects = generateNestedSelects(relConfig.includes, relEntity, entities);
349
+ }
350
+
351
+ if (isParentRelation) {
352
+ // Parent relation returns single object, not array
353
+ return `,
354
+ '${relName}', (
355
+ SELECT ${relSelectExpr}
356
+ FROM ${relEntity} rel
357
+ WHERE ${whereSQL}
358
+ )`;
359
+ }
360
+
361
+ if (nestedSelects) {
362
+ // Flatten: merge entity fields with nested includes into one object
363
+ // Use to_jsonb() for consistent jsonb types (row_to_json returns json, not jsonb)
364
+ const relSelectJsonb = relSelectExpr.replace('row_to_json', 'to_jsonb');
365
+ return `,
366
+ '${relName}', COALESCE((
367
+ SELECT jsonb_agg(
368
+ ${relSelectJsonb} || jsonb_build_object(${nestedSelects.substring(1)})
369
+ )
370
+ FROM ${relEntity} rel
371
+ WHERE ${whereSQL}
372
+ ), '[]'::jsonb)`;
373
+ }
374
+
375
+ return `,
376
+ '${relName}', COALESCE((
377
+ SELECT jsonb_agg(${relSelectExpr})
378
+ FROM ${relEntity} rel
379
+ WHERE ${whereSQL}
380
+ ), '[]'::jsonb)`;
381
+ });
382
+
383
+ return selects.join('');
384
+ }
385
+
386
+ function generateNestedSelects(
387
+ includes: Record<string, IncludeIR>,
388
+ parentEntity: string,
389
+ entities: Record<string, EntityIR> = {}
390
+ ): string {
391
+ if (!includes || Object.keys(includes).length === 0) {
392
+ return '';
393
+ }
394
+
395
+ const selects = Object.entries(includes).map(([relName, relConfig]) => {
396
+ const relEntity = relConfig.entity;
397
+ const filter = relConfig.filter || {};
398
+ const hasNestedIncludes = relConfig.includes && Object.keys(relConfig.includes).length > 0;
399
+
400
+ // Detect relation type: parent (single FK lookup) vs child (array)
401
+ // Parent: relation name != entity name (e.g., "org" vs "organisations")
402
+ // Child: relation name == entity name (e.g., "allocations" == "allocations")
403
+ const isParentRelation = relName !== relEntity && !hasNestedIncludes && Object.keys(filter).length === 0;
404
+
405
+ // Build WHERE clause
406
+ const whereClauses: string[] = [];
407
+ for (const [field, value] of Object.entries(filter)) {
408
+ if (value.startsWith('@')) {
409
+ const parentField = value.substring(1);
410
+ whereClauses.push(`nested.${field} = rel.${parentField}`);
411
+ } else {
412
+ whereClauses.push(`nested.${field} = '${value}'`);
413
+ }
414
+ }
415
+
416
+ if (whereClauses.length === 0) {
417
+ if (isParentRelation) {
418
+ // Parent relation: rel.{relName}_id = nested.id
419
+ whereClauses.push(`nested.id = rel.${relName}_id`);
420
+ } else {
421
+ // Child relation: FK is singular form of parent entity
422
+ const singularParent = singularize(parentEntity);
423
+ whereClauses.push(`nested.${singularParent}_id = rel.id`);
424
+ }
425
+ }
426
+
427
+ // Build select expression excluding hidden fields
428
+ const nestedSelectExpr = buildVisibleRowJson('nested', relEntity, entities);
429
+
430
+ if (isParentRelation) {
431
+ // Parent relation returns single object
432
+ return `,
433
+ '${relName}', (
434
+ SELECT ${nestedSelectExpr}
435
+ FROM ${relEntity} nested
436
+ WHERE ${whereClauses.join(' AND ')}
437
+ )`;
438
+ }
439
+
440
+ return `,
441
+ '${relName}', COALESCE((
442
+ SELECT jsonb_agg(${nestedSelectExpr})
443
+ FROM ${relEntity} nested
444
+ WHERE ${whereClauses.join(' AND ')}
445
+ ), '[]'::jsonb)`;
446
+ });
447
+
448
+ return selects.join('');
449
+ }
450
+
451
+ function generateAffectedKeysFunction(name: string, sub: SubscribableIR): string {
452
+ const cases: string[] = [];
453
+ const paramKey = sub.root.key;
454
+
455
+ // Root entity case
456
+ cases.push(` WHEN '${sub.root.entity}' THEN
457
+ RETURN ARRAY['${name}:' || (p_data->>'id')];`);
458
+
459
+ // Get singular form of root entity for FK fields
460
+ const singularRootEntity = singularize(sub.root.entity);
461
+
462
+ // Related entity cases
463
+ const addRelationCase = (relName: string, relConfig: IncludeIR, parentEntity: string) => {
464
+ const relEntity = relConfig.entity;
465
+ const filter = relConfig.filter || {};
466
+
467
+ // Find the FK field that points to root (use singular form)
468
+ let fkField = `${singularRootEntity}_id`;
469
+ for (const [field, value] of Object.entries(filter)) {
470
+ if (value === '@id' || value === `@${paramKey}`) {
471
+ fkField = field;
472
+ break;
473
+ }
474
+ }
475
+
476
+ const singularParent = singularize(parentEntity);
477
+
478
+ // For nested relations, we need to traverse up
479
+ if (parentEntity !== sub.root.entity) {
480
+ cases.push(` WHEN '${relEntity}' THEN
481
+ -- Nested: traverse via ${parentEntity}
482
+ SELECT ARRAY_AGG('${name}:' || parent.${singularRootEntity}_id)
483
+ INTO v_keys
484
+ FROM ${parentEntity} parent
485
+ WHERE parent.id = (p_data->>'${singularParent}_id')::int;
486
+ RETURN COALESCE(v_keys, ARRAY[]::text[]);`);
487
+ } else {
488
+ cases.push(` WHEN '${relEntity}' THEN
489
+ RETURN ARRAY['${name}:' || (p_data->>'${fkField}')];`);
490
+ }
491
+
492
+ // Recurse for nested includes
493
+ if (relConfig.includes) {
494
+ for (const [nestedName, nestedConfig] of Object.entries(relConfig.includes)) {
495
+ addRelationCase(nestedName, nestedConfig, relEntity);
496
+ }
497
+ }
498
+ };
499
+
500
+ for (const [relName, relConfig] of Object.entries(sub.includes)) {
501
+ addRelationCase(relName, relConfig, sub.root.entity);
502
+ }
503
+
504
+ return `CREATE OR REPLACE FUNCTION dzql_v2.${name}_affected_keys(
505
+ p_table TEXT,
506
+ p_op TEXT,
507
+ p_data JSONB
508
+ ) RETURNS TEXT[]
509
+ LANGUAGE plpgsql
510
+ IMMUTABLE
511
+ AS $$
512
+ DECLARE
513
+ v_keys TEXT[];
514
+ BEGIN
515
+ CASE p_table
516
+ ${cases.join('\n')}
517
+ ELSE
518
+ RETURN ARRAY[]::text[];
519
+ END CASE;
520
+ END;
521
+ $$;`;
522
+ }
523
+
524
+ function buildPathMapping(
525
+ rootEntity: string,
526
+ includes: Record<string, IncludeIR>,
527
+ parentPath: string = ''
528
+ ): Record<string, string> {
529
+ const paths: Record<string, string> = {};
530
+
531
+ // Root entity maps to top level
532
+ paths[rootEntity] = '.';
533
+
534
+ const buildPaths = (incl: Record<string, IncludeIR>, parent: string) => {
535
+ for (const [relName, relConfig] of Object.entries(incl)) {
536
+ const currentPath = parent ? `${parent}.${relName}` : relName;
537
+ paths[relConfig.entity] = currentPath;
538
+
539
+ if (relConfig.includes) {
540
+ buildPaths(relConfig.includes, currentPath);
541
+ }
542
+ }
543
+ };
544
+
545
+ buildPaths(includes, '');
546
+ return paths;
547
+ }