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
@@ -1,827 +0,0 @@
1
- /**
2
- * Subscribable Code Generator
3
- * Generates PostgreSQL functions for live query subscriptions
4
- *
5
- * For each subscribable, generates:
6
- * 1. get_<name>(params, user_id) - Query function that builds the document
7
- * 2. <name>_affected_documents(table, op, old, new) - Determines which subscription instances are affected
8
- * 3. <name>_can_subscribe(user_id, params) - Access control check
9
- */
10
-
11
- import { PathParser } from '../parser/path-parser.js';
12
-
13
- export class SubscribableCodegen {
14
- constructor(subscribable) {
15
- this.name = subscribable.name;
16
- this.permissionPaths = subscribable.permissionPaths || {};
17
- this.paramSchema = subscribable.paramSchema || {};
18
- this.rootEntity = subscribable.rootEntity;
19
- this.relations = subscribable.relations || {};
20
- this.parser = new PathParser();
21
- }
22
-
23
- /**
24
- * Generate all functions for this subscribable
25
- * @returns {string} SQL for all subscribable functions
26
- */
27
- generate() {
28
- const sections = [];
29
-
30
- // Header comment
31
- sections.push(this._generateHeader());
32
-
33
- // 1. Access control function
34
- sections.push(this._generateAccessControlFunction());
35
-
36
- // 2. Query function (builds the document)
37
- sections.push(this._generateQueryFunction());
38
-
39
- // 3. Affected documents function (determines which subscriptions to update)
40
- sections.push(this._generateAffectedDocumentsFunction());
41
-
42
- return sections.join('\n\n');
43
- }
44
-
45
- /**
46
- * Generate header comment
47
- * @private
48
- */
49
- _generateHeader() {
50
- return `-- ============================================================================
51
- -- Subscribable: ${this.name}
52
- -- Root Entity: ${this.rootEntity}
53
- -- Generated: ${new Date().toISOString()}
54
- -- ============================================================================`;
55
- }
56
-
57
- /**
58
- * Generate access control function
59
- * @private
60
- */
61
- _generateAccessControlFunction() {
62
- let subscribePaths = this.permissionPaths.subscribe || [];
63
-
64
- // Ensure it's an array
65
- if (!Array.isArray(subscribePaths)) {
66
- subscribePaths = [subscribePaths];
67
- }
68
-
69
- // If no paths, it's public
70
- if (subscribePaths.length === 0) {
71
- return `CREATE OR REPLACE FUNCTION ${this.name}_can_subscribe(
72
- p_user_id INT,
73
- p_params JSONB
74
- ) RETURNS BOOLEAN AS $$
75
- BEGIN
76
- RETURN TRUE; -- Public access
77
- END;
78
- $$ LANGUAGE plpgsql STABLE SECURITY DEFINER;`;
79
- }
80
-
81
- // Check if any path references root entity fields (needs database lookup)
82
- const needsEntityLookup = subscribePaths.some(path => {
83
- const ast = this.parser.parse(path);
84
- return ast.type === 'direct_field' || ast.type === 'field_ref';
85
- });
86
-
87
- // Generate permission check logic
88
- const checks = subscribePaths.map(path => {
89
- const ast = this.parser.parse(path);
90
- return this._generatePathCheck(ast, needsEntityLookup ? 'entity' : 'p_params', 'p_user_id');
91
- });
92
-
93
- const checkSQL = checks.join(' OR\n ');
94
-
95
- // If we need entity lookup, fetch it first
96
- if (needsEntityLookup) {
97
- const params = Object.keys(this.paramSchema);
98
- const paramDeclarations = params.map(p => ` v_${p} ${this.paramSchema[p]};`).join('\n');
99
- const paramExtractions = params.map(p =>
100
- ` v_${p} := (p_params->>'${p}')::${this.paramSchema[p]};`
101
- ).join('\n');
102
-
103
- const rootFilter = this._generateRootFilter();
104
-
105
- return `CREATE OR REPLACE FUNCTION ${this.name}_can_subscribe(
106
- p_user_id INT,
107
- p_params JSONB
108
- ) RETURNS BOOLEAN AS $$
109
- DECLARE
110
- ${paramDeclarations}
111
- entity RECORD;
112
- BEGIN
113
- -- Extract parameters
114
- ${paramExtractions}
115
-
116
- -- Fetch entity
117
- SELECT * INTO entity
118
- FROM ${this.rootEntity} root
119
- WHERE ${rootFilter};
120
-
121
- -- Entity not found
122
- IF NOT FOUND THEN
123
- RETURN FALSE;
124
- END IF;
125
-
126
- -- Check permissions
127
- RETURN (
128
- ${checkSQL}
129
- );
130
- END;
131
- $$ LANGUAGE plpgsql STABLE SECURITY DEFINER;`;
132
- }
133
-
134
- return `CREATE OR REPLACE FUNCTION ${this.name}_can_subscribe(
135
- p_user_id INT,
136
- p_params JSONB
137
- ) RETURNS BOOLEAN AS $$
138
- BEGIN
139
- RETURN (
140
- ${checkSQL}
141
- );
142
- END;
143
- $$ LANGUAGE plpgsql STABLE SECURITY DEFINER;`;
144
- }
145
-
146
- /**
147
- * Generate path check SQL from AST
148
- * @private
149
- */
150
- _generatePathCheck(ast, recordVar, userIdVar) {
151
- // Handle direct field reference: @owner_id
152
- if (ast.type === 'direct_field' || ast.type === 'field_ref') {
153
- // If recordVar is 'entity' (RECORD type), access directly
154
- if (recordVar === 'entity') {
155
- return `${recordVar}.${ast.field} = ${userIdVar}`;
156
- }
157
- // Otherwise it's p_params (JSONB type)
158
- return `(${recordVar}->>'${ast.field}')::int = ${userIdVar}`;
159
- }
160
-
161
- // Handle traversal with steps: @org_id->acts_for[org_id=$]{active}.user_id
162
- if (ast.type === 'traversal' && ast.steps) {
163
- const fieldRef = ast.steps[0]; // First step is the field reference
164
- const tableRef = ast.steps[1]; // Second step is the table reference
165
-
166
- if (!fieldRef || !tableRef || tableRef.type !== 'table_ref') {
167
- return 'FALSE';
168
- }
169
-
170
- const targetTable = tableRef.table;
171
- const targetField = tableRef.targetField;
172
-
173
- // Build WHERE clause
174
- const whereClauses = [];
175
-
176
- // Add filter conditions from the table_ref
177
- if (tableRef.filter && tableRef.filter.length > 0) {
178
- for (const filterCondition of tableRef.filter) {
179
- const field = filterCondition.field;
180
- if (filterCondition.value.type === 'param') {
181
- // Parameter reference: org_id=$ means use the filter field name as the param key
182
- // e.g., acts_for[organisation_id=$] -> (p_params->>'organisation_id')::int
183
- const paramValue = `(${recordVar}->>'${field}')::int`;
184
- whereClauses.push(`${targetTable}.${field} = ${paramValue}`);
185
- } else {
186
- // Literal value
187
- whereClauses.push(`${targetTable}.${field} = '${filterCondition.value}'`);
188
- }
189
- }
190
- }
191
-
192
- // Add temporal marker if present
193
- if (tableRef.temporal) {
194
- whereClauses.push(`${targetTable}.valid_to IS NULL`);
195
- }
196
-
197
- return `EXISTS (
198
- SELECT 1 FROM ${targetTable}
199
- WHERE ${whereClauses.join('\n AND ')}
200
- AND ${targetTable}.${targetField} = ${userIdVar}
201
- )`;
202
- }
203
-
204
- return 'FALSE';
205
- }
206
-
207
- /**
208
- * Generate filter SQL
209
- * @private
210
- */
211
- _generateFilterSQL(filter, tableAlias) {
212
- const conditions = [];
213
- for (const [key, value] of Object.entries(filter)) {
214
- if (value === '$') {
215
- // Placeholder - will be replaced with actual value
216
- conditions.push(`${tableAlias}.${key} = ${tableAlias}.${key}`);
217
- } else {
218
- conditions.push(`${tableAlias}.${key} = '${value}'`);
219
- }
220
- }
221
- return conditions.join(' AND ');
222
- }
223
-
224
- /**
225
- * Generate query function that builds the document
226
- * @private
227
- */
228
- _generateQueryFunction() {
229
- const params = Object.keys(this.paramSchema);
230
- const paramDeclarations = params.map(p => ` v_${p} ${this.paramSchema[p]};`).join('\n');
231
- const paramExtractions = params.map(p =>
232
- ` v_${p} := (p_params->>'${p}')::${this.paramSchema[p]};`
233
- ).join('\n');
234
-
235
- // Build root WHERE clause based on params
236
- const rootFilter = this._generateRootFilter();
237
-
238
- // Build relation subqueries
239
- const relationSelects = this._generateRelationSelects();
240
-
241
- // Build schema with path mapping and scope tables (baked in at compile time)
242
- const pathMapping = this.buildPathMapping();
243
- const scopeTables = this.extractScopeTables();
244
- const schemaJson = JSON.stringify({
245
- root: this.rootEntity,
246
- paths: pathMapping,
247
- scopeTables: scopeTables
248
- });
249
-
250
- // Pure collection mode: no rootEntity, only relations
251
- if (!this.rootEntity) {
252
- // Build relation-only selects (strip leading comma)
253
- const collectionSelects = this._generateCollectionOnlySelects();
254
-
255
- return `CREATE OR REPLACE FUNCTION get_${this.name}(
256
- p_params JSONB,
257
- p_user_id INT
258
- ) RETURNS JSONB AS $$
259
- DECLARE
260
- v_data JSONB;
261
- BEGIN
262
- -- Check access control
263
- IF NOT ${this.name}_can_subscribe(p_user_id, p_params) THEN
264
- RAISE EXCEPTION 'Permission denied';
265
- END IF;
266
-
267
- -- Build document with collections only (no root entity)
268
- SELECT jsonb_build_object(
269
- ${collectionSelects}
270
- )
271
- INTO v_data;
272
-
273
- -- Return data with embedded schema for atomic updates
274
- RETURN jsonb_build_object(
275
- 'data', v_data,
276
- 'schema', '${schemaJson}'::jsonb
277
- );
278
- END;
279
- $$ LANGUAGE plpgsql SECURITY DEFINER;`;
280
- }
281
-
282
- // Dashboard mode: empty paramSchema with rootEntity means aggregate root as array
283
- if (params.length === 0) {
284
- return `CREATE OR REPLACE FUNCTION get_${this.name}(
285
- p_params JSONB,
286
- p_user_id INT
287
- ) RETURNS JSONB AS $$
288
- DECLARE
289
- v_data JSONB;
290
- BEGIN
291
- -- Check access control
292
- IF NOT ${this.name}_can_subscribe(p_user_id, p_params) THEN
293
- RAISE EXCEPTION 'Permission denied';
294
- END IF;
295
-
296
- -- Build document with root as array (dashboard mode)
297
- SELECT jsonb_build_object(
298
- '${this.rootEntity}', COALESCE((
299
- SELECT jsonb_agg(row_to_json(root.*))
300
- FROM ${this.rootEntity} root
301
- ), '[]'::jsonb)${relationSelects}
302
- )
303
- INTO v_data;
304
-
305
- -- Return data with embedded schema for atomic updates
306
- RETURN jsonb_build_object(
307
- 'data', v_data,
308
- 'schema', '${schemaJson}'::jsonb
309
- );
310
- END;
311
- $$ LANGUAGE plpgsql SECURITY DEFINER;`;
312
- }
313
-
314
- return `CREATE OR REPLACE FUNCTION get_${this.name}(
315
- p_params JSONB,
316
- p_user_id INT
317
- ) RETURNS JSONB AS $$
318
- DECLARE
319
- ${paramDeclarations}
320
- v_data JSONB;
321
- BEGIN
322
- -- Extract parameters
323
- ${paramExtractions}
324
-
325
- -- Check access control
326
- IF NOT ${this.name}_can_subscribe(p_user_id, p_params) THEN
327
- RAISE EXCEPTION 'Permission denied';
328
- END IF;
329
-
330
- -- Build document with root and all relations
331
- SELECT jsonb_build_object(
332
- '${this.rootEntity}', row_to_json(root.*)${relationSelects}
333
- )
334
- INTO v_data
335
- FROM ${this.rootEntity} root
336
- WHERE ${rootFilter};
337
-
338
- -- Return data with embedded schema for atomic updates
339
- RETURN jsonb_build_object(
340
- 'data', v_data,
341
- 'schema', '${schemaJson}'::jsonb
342
- );
343
- END;
344
- $$ LANGUAGE plpgsql SECURITY DEFINER;`;
345
- }
346
-
347
- /**
348
- * Generate root filter based on params
349
- * @private
350
- */
351
- _generateRootFilter() {
352
- const params = Object.keys(this.paramSchema);
353
-
354
- // Assume first param is the root entity ID
355
- // TODO: Make this more flexible based on param naming conventions
356
- if (params.length > 0) {
357
- const firstParam = params[0];
358
- // Convention: venue_id -> id, org_id -> id, etc.
359
- return `root.id = v_${firstParam}`;
360
- }
361
-
362
- return 'TRUE';
363
- }
364
-
365
- /**
366
- * Generate relation subqueries
367
- * @private
368
- */
369
- _generateRelationSelects() {
370
- if (Object.keys(this.relations).length === 0) {
371
- return '';
372
- }
373
-
374
- const selects = Object.entries(this.relations).map(([relName, relConfig]) => {
375
- const relEntity = typeof relConfig === 'string' ? relConfig : relConfig.entity;
376
- const relFilter = typeof relConfig === 'object' ? relConfig.filter : null;
377
- const relIncludes = typeof relConfig === 'object' ? relConfig.include : null;
378
- const relVia = typeof relConfig === 'object' ? relConfig.via : null;
379
-
380
- // Build nested includes if any
381
- let nestedSelect = 'row_to_json(rel.*)';
382
- if (relIncludes) {
383
- const nestedFields = Object.entries(relIncludes).map(([nestedName, nestedEntity]) => {
384
- return `'${nestedName}', (
385
- SELECT COALESCE(jsonb_agg(row_to_json(nested.*)), '[]'::jsonb)
386
- FROM ${nestedEntity} nested
387
- WHERE nested.${relEntity}_id = rel.id
388
- )`;
389
- }).join(',\n ');
390
-
391
- nestedSelect = `jsonb_build_object(
392
- '${relEntity}', row_to_json(rel.*),
393
- ${nestedFields}
394
- )`;
395
- }
396
-
397
- // Handle via relations with JOINs
398
- if (relVia) {
399
- const { joinClause, whereClause } = this._generateViaJoin(relConfig);
400
- return `,
401
- '${relName}', COALESCE((
402
- SELECT jsonb_agg(${nestedSelect})
403
- FROM ${relEntity} rel
404
- ${joinClause}
405
- WHERE ${whereClause}
406
- ), '[]'::jsonb)`;
407
- }
408
-
409
- // Direct relation (no via)
410
- let filterSQL = this._generateRelationFilter(relFilter, relEntity, relConfig);
411
-
412
- return `,
413
- '${relName}', COALESCE((
414
- SELECT jsonb_agg(${nestedSelect})
415
- FROM ${relEntity} rel
416
- WHERE ${filterSQL}
417
- ), '[]'::jsonb)`;
418
- }).join('');
419
-
420
- return selects;
421
- }
422
-
423
- /**
424
- * Generate collection-only selects (no root entity)
425
- * Used when rootEntity is null/empty - pure collection mode
426
- * @private
427
- */
428
- _generateCollectionOnlySelects() {
429
- if (Object.keys(this.relations).length === 0) {
430
- return "'_empty', '{}'::jsonb";
431
- }
432
-
433
- const selects = Object.entries(this.relations).map(([relName, relConfig]) => {
434
- const relEntity = typeof relConfig === 'string' ? relConfig : relConfig.entity;
435
- const relFilter = typeof relConfig === 'object' ? relConfig.filter : null;
436
-
437
- // For collection mode, filter should be TRUE or we fetch all
438
- const filterSQL = relFilter === 'TRUE' ? 'TRUE' : 'TRUE';
439
-
440
- return `'${relName}', COALESCE((
441
- SELECT jsonb_agg(row_to_json(rel.*))
442
- FROM ${relEntity} rel
443
- WHERE ${filterSQL}
444
- ), '[]'::jsonb)`;
445
- });
446
-
447
- return selects.join(',\n ');
448
- }
449
-
450
- /**
451
- * Generate JOIN clause for via relations
452
- * Handles multi-hop via chains by looking up each intermediate table
453
- * @private
454
- * @param {Object} relConfig - Relation config with via property
455
- * @returns {Object} { joinClause, whereClause }
456
- */
457
- _generateViaJoin(relConfig) {
458
- const via = relConfig.via;
459
- const fk = relConfig.foreignKey;
460
-
461
- // Parse via: "products.id" -> table: products, column: id
462
- const [viaTable, viaColumn] = via.split('.');
463
-
464
- // Build the join chain by following via references
465
- const joinClauses = [];
466
- const viaChain = this._buildViaChain(viaTable);
467
-
468
- // First join: rel -> first via table
469
- joinClauses.push(`JOIN ${viaTable} via_${viaTable} ON rel.${fk} = via_${viaTable}.${viaColumn}`);
470
-
471
- // Additional joins for multi-hop
472
- for (let i = 0; i < viaChain.length; i++) {
473
- const current = viaChain[i];
474
- const next = viaChain[i + 1];
475
-
476
- if (next) {
477
- // Join current via table to next via table
478
- joinClauses.push(`JOIN ${next.table} via_${next.table} ON via_${current.table}.${current.fk} = via_${next.table}.${next.column}`);
479
- }
480
- }
481
-
482
- // Final WHERE clause connects to root
483
- const lastVia = viaChain.length > 0 ? viaChain[viaChain.length - 1] : { table: viaTable };
484
- const params = Object.keys(this.paramSchema);
485
- const rootFK = params[0] || `${this.rootEntity}_id`;
486
- const whereClause = `via_${lastVia.table}.${rootFK} = root.id`;
487
-
488
- return { joinClause: joinClauses.join('\n '), whereClause };
489
- }
490
-
491
- /**
492
- * Build the via chain from a starting table back to root
493
- * @private
494
- * @param {string} startTable - The table to start from
495
- * @returns {Array} Chain of {table, fk, column} objects
496
- */
497
- _buildViaChain(startTable) {
498
- const chain = [];
499
- let currentTable = startTable;
500
-
501
- // Find the relation config for this table
502
- const findRelConfig = (tableName) => {
503
- for (const [relName, relConfig] of Object.entries(this.relations)) {
504
- if (typeof relConfig === 'object' && relConfig.entity === tableName) {
505
- return relConfig;
506
- }
507
- if (relName === tableName && typeof relConfig === 'object') {
508
- return relConfig;
509
- }
510
- }
511
- return null;
512
- };
513
-
514
- // Follow via chain until we reach a table without via (direct to root)
515
- let relConfig = findRelConfig(currentTable);
516
- while (relConfig && relConfig.via) {
517
- const [nextTable, nextColumn] = relConfig.via.split('.');
518
- chain.push({
519
- table: currentTable,
520
- fk: relConfig.foreignKey,
521
- column: nextColumn
522
- });
523
- currentTable = nextTable;
524
- relConfig = findRelConfig(currentTable);
525
- }
526
-
527
- // Add final table (direct connection to root)
528
- if (currentTable !== startTable) {
529
- const finalConfig = findRelConfig(currentTable);
530
- chain.push({
531
- table: currentTable,
532
- fk: finalConfig ? finalConfig.foreignKey : `${this.rootEntity}_id`,
533
- column: 'id'
534
- });
535
- }
536
-
537
- return chain;
538
- }
539
-
540
- /**
541
- * Generate filter for relation subquery
542
- * @private
543
- * @param {string|null} filter - Optional filter expression
544
- * @param {string} relEntity - Related entity table name
545
- * @param {Object} relConfig - Full relation config (may contain foreignKey)
546
- */
547
- _generateRelationFilter(filter, relEntity, relConfig) {
548
- if (!filter) {
549
- // Use explicit foreignKey from config, or default to root_id
550
- const fk = (typeof relConfig === 'object' && relConfig.foreignKey)
551
- ? relConfig.foreignKey
552
- : `${this.rootEntity}_id`;
553
- return `rel.${fk} = root.id`;
554
- }
555
-
556
- // Dashboard collection: filter="TRUE" means fetch ALL rows (no FK filter)
557
- if (filter === 'TRUE') {
558
- return 'TRUE';
559
- }
560
-
561
- // Parse filter expression like "venue_id=$venue_id"
562
- // Replace $param with v_param variable
563
- return filter.replace(/\$(\w+)/g, 'v_$1');
564
- }
565
-
566
- /**
567
- * Generate affected documents function
568
- * @private
569
- */
570
- _generateAffectedDocumentsFunction() {
571
- const cases = [];
572
- const seenEntities = new Set();
573
-
574
- // Case 1: Root entity changed (only if rootEntity exists)
575
- if (this.rootEntity) {
576
- cases.push(this._generateRootAffectedCase());
577
- seenEntities.add(this.rootEntity);
578
- }
579
-
580
- // Case 2: Related entities changed (skip duplicates)
581
- for (const [relName, relConfig] of Object.entries(this.relations)) {
582
- const relEntity = typeof relConfig === 'string' ? relConfig : relConfig.entity;
583
- if (!seenEntities.has(relEntity)) {
584
- cases.push(this._generateRelationAffectedCase(relName, relConfig));
585
- seenEntities.add(relEntity);
586
- }
587
- }
588
-
589
- const casesSQL = cases.join('\n\n ');
590
-
591
- return `CREATE OR REPLACE FUNCTION ${this.name}_affected_documents(
592
- p_table_name TEXT,
593
- p_op TEXT,
594
- p_data JSONB
595
- ) RETURNS JSONB[] AS $$
596
- DECLARE
597
- v_affected JSONB[];
598
- BEGIN
599
- CASE p_table_name
600
- ${casesSQL}
601
-
602
- ELSE
603
- v_affected := ARRAY[]::JSONB[];
604
- END CASE;
605
-
606
- RETURN v_affected;
607
- END;
608
- $$ LANGUAGE plpgsql IMMUTABLE;`;
609
- }
610
-
611
- /**
612
- * Generate case for root entity changes
613
- * @private
614
- */
615
- _generateRootAffectedCase() {
616
- const params = Object.keys(this.paramSchema);
617
-
618
- // Dashboard mode: empty paramSchema means notify ALL subscribers
619
- if (params.length === 0) {
620
- return `-- Root entity (${this.rootEntity}) changed - dashboard mode, notify all
621
- WHEN '${this.rootEntity}' THEN
622
- v_affected := ARRAY['{}'::jsonb];`;
623
- }
624
-
625
- const firstParam = params[0];
626
-
627
- return `-- Root entity (${this.rootEntity}) changed
628
- WHEN '${this.rootEntity}' THEN
629
- v_affected := ARRAY[
630
- jsonb_build_object('${firstParam}', (p_data->>'id')::int)
631
- ];`;
632
- }
633
-
634
- /**
635
- * Generate case for related entity changes
636
- * @private
637
- */
638
- _generateRelationAffectedCase(relName, relConfig) {
639
- const relEntity = typeof relConfig === 'string' ? relConfig : relConfig.entity;
640
- const relFK = typeof relConfig === 'object' && relConfig.foreignKey
641
- ? relConfig.foreignKey
642
- : `${this.rootEntity}_id`;
643
- const relVia = typeof relConfig === 'object' ? relConfig.via : null;
644
- const relFilter = typeof relConfig === 'object' ? relConfig.filter : null;
645
-
646
- const params = Object.keys(this.paramSchema);
647
- const firstParam = params[0] || 'id';
648
-
649
- // Dashboard collection: filter="TRUE" means notify ALL subscribers
650
- // This relation is independent from the root entity
651
- if (relFilter === 'TRUE') {
652
- return `-- Dashboard collection (${relEntity}) - notify all subscribers
653
- WHEN '${relEntity}' THEN
654
- v_affected := ARRAY['{}'::jsonb];`;
655
- }
656
-
657
- // Check if this is a nested relation (has parent FK)
658
- const nestedIncludes = typeof relConfig === 'object' ? relConfig.include : null;
659
-
660
- // Handle via relations: need to traverse via path to root
661
- if (relVia) {
662
- const [viaTable, viaColumn] = relVia.split('.');
663
- const viaChain = this._buildViaChain(viaTable);
664
-
665
- // Build multi-hop join chain for affected documents
666
- if (viaChain.length > 1) {
667
- // Multi-hop: need to join through intermediate tables
668
- const joins = [];
669
- const pathDesc = [relEntity, ...viaChain.map(v => v.table), this.rootEntity].join(' -> ');
670
-
671
- // First table in chain
672
- let prevAlias = 'via_0';
673
- joins.push(`FROM ${viaTable} ${prevAlias}`);
674
-
675
- // Join through chain
676
- for (let i = 0; i < viaChain.length - 1; i++) {
677
- const current = viaChain[i];
678
- const next = viaChain[i + 1];
679
- const nextAlias = `via_${i + 1}`;
680
- joins.push(`JOIN ${next.table} ${nextAlias} ON ${prevAlias}.${current.fk} = ${nextAlias}.${next.column}`);
681
- prevAlias = nextAlias;
682
- }
683
-
684
- return `-- Via relation (${relEntity} via chain) changed
685
- WHEN '${relEntity}' THEN
686
- -- Traverse via path: ${pathDesc}
687
- SELECT ARRAY_AGG(DISTINCT jsonb_build_object('${firstParam}', ${prevAlias}.${firstParam}))
688
- INTO v_affected
689
- ${joins.join('\n ')}
690
- WHERE via_0.${viaColumn} = (p_data->>'${relFK}')::int;`;
691
- }
692
-
693
- // Single-hop via
694
- return `-- Via relation (${relEntity} via ${viaTable}) changed
695
- WHEN '${relEntity}' THEN
696
- -- Traverse via path: ${relEntity} -> ${viaTable} -> ${this.rootEntity}
697
- SELECT ARRAY_AGG(DISTINCT jsonb_build_object('${firstParam}', via_tbl.${firstParam}))
698
- INTO v_affected
699
- FROM ${viaTable} via_tbl
700
- WHERE via_tbl.${viaColumn} = (p_data->>'${relFK}')::int;`;
701
- }
702
-
703
- if (nestedIncludes) {
704
- // Nested relation: need to traverse up to root
705
- return `-- Nested relation (${relEntity}) changed
706
- WHEN '${relEntity}' THEN
707
- -- Find parent and then root
708
- SELECT ARRAY_AGG(jsonb_build_object('${firstParam}', parent.${this.rootEntity}_id))
709
- INTO v_affected
710
- FROM ${relEntity} rel
711
- JOIN ${Object.keys(nestedIncludes)[0]} parent ON parent.id = rel.${Object.keys(nestedIncludes)[0]}_id
712
- WHERE rel.id = (p_data->>'id')::int;`;
713
- }
714
-
715
- return `-- Related entity (${relEntity}) changed
716
- WHEN '${relEntity}' THEN
717
- v_affected := ARRAY[
718
- jsonb_build_object('${firstParam}', (p_data->>'${relFK}')::int)
719
- ];`;
720
- }
721
-
722
- /**
723
- * Extract all tables in scope for this subscribable
724
- * Used for efficient event filtering - only events from these tables need consideration
725
- * @returns {string[]} Array of table names
726
- */
727
- extractScopeTables() {
728
- const tables = new Set();
729
- if (this.rootEntity) {
730
- tables.add(this.rootEntity);
731
- }
732
-
733
- const extractFromRelations = (relations) => {
734
- for (const [relName, relConfig] of Object.entries(relations || {})) {
735
- const entity = typeof relConfig === 'string' ? relConfig : relConfig.entity;
736
- if (entity) tables.add(entity);
737
-
738
- // Extract table from via path: "products.id" -> "products"
739
- if (typeof relConfig === 'object' && relConfig.via) {
740
- const viaTable = relConfig.via.split('.')[0];
741
- tables.add(viaTable);
742
- }
743
-
744
- // Handle nested relations (include or relations)
745
- if (typeof relConfig === 'object') {
746
- if (relConfig.include) {
747
- extractFromRelations(relConfig.include);
748
- }
749
- if (relConfig.relations) {
750
- extractFromRelations(relConfig.relations);
751
- }
752
- }
753
- }
754
- };
755
-
756
- extractFromRelations(this.relations);
757
- return Array.from(tables);
758
- }
759
-
760
- /**
761
- * Build path mapping for client-side patching
762
- * Maps table names to their path in the document structure
763
- * @returns {Object} Map of table name -> document path
764
- */
765
- buildPathMapping() {
766
- const paths = {};
767
-
768
- // Root entity maps to top level (only if it exists)
769
- if (this.rootEntity) {
770
- paths[this.rootEntity] = '.';
771
- }
772
-
773
- const buildPaths = (relations, parentPath = '') => {
774
- for (const [relName, relConfig] of Object.entries(relations || {})) {
775
- const entity = typeof relConfig === 'string' ? relConfig : relConfig.entity;
776
- const currentPath = parentPath ? `${parentPath}.${relName}` : relName;
777
-
778
- if (entity) {
779
- paths[entity] = currentPath;
780
- }
781
-
782
- // Handle nested relations
783
- if (typeof relConfig === 'object') {
784
- if (relConfig.include) {
785
- buildPaths(relConfig.include, currentPath);
786
- }
787
- if (relConfig.relations) {
788
- buildPaths(relConfig.relations, currentPath);
789
- }
790
- }
791
- }
792
- };
793
-
794
- buildPaths(this.relations);
795
- return paths;
796
- }
797
- }
798
-
799
- /**
800
- * Generate subscribable functions from config
801
- * @param {Object} subscribable - Subscribable configuration
802
- * @returns {string} Generated SQL
803
- */
804
- export function generateSubscribable(subscribable) {
805
- const codegen = new SubscribableCodegen(subscribable);
806
- return codegen.generate();
807
- }
808
-
809
- /**
810
- * Extract scope tables from subscribable config
811
- * @param {Object} subscribable - Subscribable configuration
812
- * @returns {string[]} Array of table names in scope
813
- */
814
- export function extractScopeTables(subscribable) {
815
- const codegen = new SubscribableCodegen(subscribable);
816
- return codegen.extractScopeTables();
817
- }
818
-
819
- /**
820
- * Build path mapping from subscribable config
821
- * @param {Object} subscribable - Subscribable configuration
822
- * @returns {Object} Map of table name -> document path
823
- */
824
- export function buildPathMapping(subscribable) {
825
- const codegen = new SubscribableCodegen(subscribable);
826
- return codegen.buildPathMapping();
827
- }