dzql 0.5.32 → 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,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
+ }