dzql 0.1.6 → 0.2.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.
package/README.md CHANGED
@@ -1,11 +1,12 @@
1
1
  # DZQL
2
2
 
3
- PostgreSQL-powered framework with automatic CRUD operations and real-time WebSocket synchronization.
3
+ PostgreSQL-powered framework with automatic CRUD operations, live query subscriptions, and real-time WebSocket synchronization.
4
4
 
5
5
  ## Documentation
6
6
 
7
7
  - **[Getting Started Guide](docs/GETTING_STARTED.md)** - Complete tutorial with working todo app
8
8
  - **[API Reference](docs/REFERENCE.md)** - Complete API documentation
9
+ - **[Live Query Subscriptions](../../docs/SUBSCRIPTIONS_QUICK_START.md)** - Real-time denormalized documents (NEW in v0.2.0)
9
10
  - **[Compiler Documentation](docs/compiler/)** - Entity compilation guide and coding standards
10
11
  - **[Claude Guide](docs/CLAUDE.md)** - Development guide for AI assistants
11
12
  - **[Venues Example](../venues/)** - Full working application
@@ -29,6 +30,12 @@ await ws.connect();
29
30
  // All 5 operations work automatically
30
31
  const user = await ws.api.save.users({ name: 'Alice' });
31
32
  const results = await ws.api.search.users({ filters: { name: 'alice' } });
33
+
34
+ // NEW in v0.2.0: Live query subscriptions
35
+ const { data, unsubscribe } = await ws.api.subscribe_venue_detail(
36
+ { venue_id: 123 },
37
+ (updated) => console.log('Venue changed!', updated)
38
+ );
32
39
  ```
33
40
 
34
41
  ## DZQL Compiler
package/docs/REFERENCE.md CHANGED
@@ -12,6 +12,7 @@ Complete API documentation for DZQL framework. For tutorials, see [GETTING_START
12
12
  - [Custom Functions](#custom-functions)
13
13
  - [Authentication](#authentication)
14
14
  - [Real-time Events](#real-time-events)
15
+ - [Live Query Subscriptions](#live-query-subscriptions)
15
16
  - [Temporal Relationships](#temporal-relationships)
16
17
  - [Error Messages](#error-messages)
17
18
 
@@ -864,6 +865,144 @@ ws.onBroadcast((method, params) => {
864
865
 
865
866
  ---
866
867
 
868
+ ## Live Query Subscriptions
869
+
870
+ Subscribe to denormalized documents and receive automatic updates when underlying data changes. Subscriptions use a PostgreSQL-first architecture where all change detection happens in the database.
871
+
872
+ For complete documentation, see **[Live Query Subscriptions Guide](../../../docs/LIVE_QUERY_SUBSCRIPTIONS.md)** and **[Quick Start](../../../docs/SUBSCRIPTIONS_QUICK_START.md)**.
873
+
874
+ ### Quick Example
875
+
876
+ ```javascript
877
+ // Subscribe to venue with all related data
878
+ const { data, unsubscribe } = await ws.api.subscribe_venue_detail(
879
+ { venue_id: 123 },
880
+ (updatedVenue) => {
881
+ // Called automatically when venue, org, or sites change
882
+ console.log('Updated:', updatedVenue);
883
+ // updatedVenue = { id: 123, name: '...', org: {...}, sites: [...] }
884
+ }
885
+ );
886
+
887
+ // Initial data available immediately
888
+ console.log('Initial:', data);
889
+
890
+ // Later: cleanup
891
+ await unsubscribe();
892
+ ```
893
+
894
+ ### Creating a Subscribable
895
+
896
+ Define subscribables in SQL:
897
+
898
+ ```sql
899
+ SELECT dzql.register_subscribable(
900
+ 'venue_detail', -- Name
901
+ '{"subscribe": ["@org_id->acts_for[org_id=$]{active}.user_id"]}'::jsonb, -- Permissions
902
+ '{"venue_id": "int"}'::jsonb, -- Parameters
903
+ 'venues', -- Root table
904
+ '{
905
+ "org": "organisations",
906
+ "sites": {"entity": "sites", "filter": "venue_id=$venue_id"}
907
+ }'::jsonb -- Relations
908
+ );
909
+ ```
910
+
911
+ ### Compile and Deploy
912
+
913
+ ```bash
914
+ # Compile subscribable to PostgreSQL functions
915
+ node packages/dzql/compile-subscribable.js venue.sql | psql $DATABASE_URL
916
+ ```
917
+
918
+ This generates three functions:
919
+ - `venue_detail_can_subscribe(user_id, params)` - Permission check
920
+ - `get_venue_detail(params, user_id)` - Query builder
921
+ - `venue_detail_affected_documents(table, op, old, new)` - Change detector
922
+
923
+ ### Subscription Lifecycle
924
+
925
+ 1. **Subscribe**: Client calls `ws.api.subscribe_<name>(params, callback)`
926
+ 2. **Permission Check**: `<name>_can_subscribe()` validates access
927
+ 3. **Initial Query**: `get_<name>()` returns denormalized document
928
+ 4. **Register**: Server stores subscription in-memory
929
+ 5. **Database Change**: Any relevant table modification
930
+ 6. **Detect**: `<name>_affected_documents()` identifies affected subscriptions
931
+ 7. **Re-query**: `get_<name>()` fetches fresh data
932
+ 8. **Update**: Callback invoked with new data
933
+
934
+ ### Unsubscribe
935
+
936
+ ```javascript
937
+ // Method 1: Use returned unsubscribe function
938
+ const { unsubscribe } = await ws.api.subscribe_venue_detail(...);
939
+ await unsubscribe();
940
+
941
+ // Method 2: Direct unsubscribe call
942
+ await ws.api.unsubscribe_venue_detail({ venue_id: 123 });
943
+ ```
944
+
945
+ ### Architecture Benefits
946
+
947
+ - **PostgreSQL-First**: All logic executes in database, not application code
948
+ - **Zero Configuration**: Pattern matching on method names - no server changes needed
949
+ - **Type Safe**: Compiled functions validated at deploy time
950
+ - **Efficient**: In-memory registry, PostgreSQL does matching
951
+ - **Secure**: Permission paths enforced at database level
952
+ - **Scalable**: Stateless server, can add instances freely
953
+
954
+ ### Common Patterns
955
+
956
+ **Single Table:**
957
+ ```sql
958
+ SELECT dzql.register_subscribable(
959
+ 'user_settings',
960
+ '{"subscribe": ["@user_id"]}'::jsonb,
961
+ '{"user_id": "int"}'::jsonb,
962
+ 'user_settings',
963
+ '{}'::jsonb
964
+ );
965
+ ```
966
+
967
+ **With Relations:**
968
+ ```sql
969
+ SELECT dzql.register_subscribable(
970
+ 'booking_detail',
971
+ '{"subscribe": ["@user_id"]}'::jsonb,
972
+ '{"booking_id": "int"}'::jsonb,
973
+ 'bookings',
974
+ '{
975
+ "venue": "venues",
976
+ "customer": "users",
977
+ "items": {"entity": "booking_items", "filter": "booking_id=$booking_id"}
978
+ }'::jsonb
979
+ );
980
+ ```
981
+
982
+ **Multiple Permission Paths (OR logic):**
983
+ ```sql
984
+ SELECT dzql.register_subscribable(
985
+ 'venue_admin',
986
+ '{
987
+ "subscribe": [
988
+ "@owner_id",
989
+ "@org_id->acts_for[org_id=$]{active}.user_id"
990
+ ]
991
+ }'::jsonb,
992
+ '{"venue_id": "int"}'::jsonb,
993
+ 'venues',
994
+ '{"sites": {"entity": "sites", "filter": "venue_id=$venue_id"}}'::jsonb
995
+ );
996
+ ```
997
+
998
+ ### See Also
999
+
1000
+ - **[Live Query Subscriptions Guide](../../../docs/LIVE_QUERY_SUBSCRIPTIONS.md)** - Complete reference
1001
+ - **[Quick Start Guide](../../../docs/SUBSCRIPTIONS_QUICK_START.md)** - 5-minute tutorial
1002
+ - **[Permission Paths](#permission--notification-paths)** - Path DSL syntax
1003
+
1004
+ ---
1005
+
867
1006
  ## Temporal Relationships
868
1007
 
869
1008
  Handle time-based relationships with `valid_from`/`valid_to` fields.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dzql",
3
- "version": "0.1.6",
3
+ "version": "0.2.0",
4
4
  "description": "PostgreSQL-powered framework with zero boilerplate CRUD operations and real-time WebSocket synchronization",
5
5
  "type": "module",
6
6
  "main": "src/server/index.js",
@@ -22,7 +22,7 @@
22
22
  ],
23
23
  "scripts": {
24
24
  "test": "bun test",
25
- "prepublishOnly": "echo '✅ Publishing DZQL v0.1.5...'"
25
+ "prepublishOnly": "echo '✅ Publishing DZQL v0.2.0...'"
26
26
  },
27
27
  "dependencies": {
28
28
  "jose": "^6.1.0",
package/src/client/ws.js CHANGED
@@ -59,12 +59,11 @@ class WebSocketManager {
59
59
  this.pendingRequests = new Map();
60
60
  this.broadcastCallbacks = new Set();
61
61
  this.sidRequestHandlers = new Set();
62
+ this.subscriptions = new Map(); // subscription_id -> { callback, unsubscribe }
62
63
  this.reconnectAttempts = 0;
63
64
  this.maxReconnectAttempts = options.maxReconnectAttempts ?? 5;
64
65
  this.isShuttingDown = false;
65
66
 
66
- // Ad
67
-
68
67
  // DZQL nested proxy API - matches server-side db.api pattern
69
68
  // Proxy handles both DZQL operations and custom functions
70
69
  const dzqlOps = {
@@ -137,6 +136,18 @@ class WebSocketManager {
137
136
  if (prop in target) {
138
137
  return target[prop];
139
138
  }
139
+ // Handle subscribe_* methods specially
140
+ if (prop.startsWith('subscribe_')) {
141
+ return (params = {}, callback) => {
142
+ return this.subscribe(prop, params, callback);
143
+ };
144
+ }
145
+ // Handle unsubscribe_* methods
146
+ if (prop.startsWith('unsubscribe_')) {
147
+ return (params = {}) => {
148
+ return this.unsubscribe(prop, params);
149
+ };
150
+ }
140
151
  // All other properties are treated as custom function calls
141
152
  return (params = {}) => {
142
153
  return this.call(prop, params);
@@ -314,6 +325,16 @@ class WebSocketManager {
314
325
  resolve(message.result);
315
326
  }
316
327
  } else {
328
+ // Handle subscription updates
329
+ if (message.method === "subscription:update") {
330
+ const { subscription_id, data } = message.params;
331
+ const sub = this.subscriptions.get(subscription_id);
332
+ if (sub && sub.callback) {
333
+ sub.callback(data);
334
+ }
335
+ return;
336
+ }
337
+
317
338
  // Handle broadcasts and SID requests
318
339
 
319
340
  // Check if this is a SID request from server
@@ -376,6 +397,70 @@ class WebSocketManager {
376
397
  });
377
398
  }
378
399
 
400
+ /**
401
+ * Subscribe to a live query
402
+ *
403
+ * @param {string} method - Method name (subscribe_<subscribable>)
404
+ * @param {object} params - Subscription parameters
405
+ * @param {function} callback - Callback function for updates
406
+ * @returns {Promise<{data, subscription_id, unsubscribe}>} Initial data and unsubscribe function
407
+ *
408
+ * @example
409
+ * const { data, unsubscribe } = await ws.api.subscribe_venue_detail(
410
+ * { venue_id: 1 },
411
+ * (updated) => console.log('Updated:', updated)
412
+ * );
413
+ *
414
+ * // Use initial data
415
+ * console.log('Initial:', data);
416
+ *
417
+ * // Later: unsubscribe
418
+ * unsubscribe();
419
+ */
420
+ async subscribe(method, params = {}, callback) {
421
+ if (!callback || typeof callback !== 'function') {
422
+ throw new Error('Subscribe requires a callback function');
423
+ }
424
+
425
+ // Call server to register subscription
426
+ const result = await this.call(method, params);
427
+ const { subscription_id, data } = result;
428
+
429
+ // Create unsubscribe function
430
+ const unsubscribeFn = async () => {
431
+ const unsubMethod = method.replace('subscribe_', 'unsubscribe_');
432
+ await this.call(unsubMethod, params);
433
+ this.subscriptions.delete(subscription_id);
434
+ };
435
+
436
+ // Store callback for updates
437
+ this.subscriptions.set(subscription_id, {
438
+ callback,
439
+ unsubscribe: unsubscribeFn
440
+ });
441
+
442
+ // Return initial data and unsubscribe function
443
+ return {
444
+ data,
445
+ subscription_id,
446
+ unsubscribe: unsubscribeFn
447
+ };
448
+ }
449
+
450
+ /**
451
+ * Unsubscribe from a live query
452
+ *
453
+ * @param {string} method - Method name (unsubscribe_<subscribable>)
454
+ * @param {object} params - Subscription parameters
455
+ * @returns {Promise<{success: boolean}>}
456
+ *
457
+ * @example
458
+ * await ws.api.unsubscribe_venue_detail({ venue_id: 1 });
459
+ */
460
+ async unsubscribe(method, params = {}) {
461
+ return await this.call(method, params);
462
+ }
463
+
379
464
  /**
380
465
  * Register callback for real-time broadcast events
381
466
  *
@@ -0,0 +1,396 @@
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
+ // Generate permission check logic
82
+ const checks = subscribePaths.map(path => {
83
+ const ast = this.parser.parse(path);
84
+ return this._generatePathCheck(ast, 'p_params', 'p_user_id');
85
+ });
86
+
87
+ const checkSQL = checks.join(' OR\n ');
88
+
89
+ return `CREATE OR REPLACE FUNCTION ${this.name}_can_subscribe(
90
+ p_user_id INT,
91
+ p_params JSONB
92
+ ) RETURNS BOOLEAN AS $$
93
+ BEGIN
94
+ RETURN (
95
+ ${checkSQL}
96
+ );
97
+ END;
98
+ $$ LANGUAGE plpgsql STABLE SECURITY DEFINER;`;
99
+ }
100
+
101
+ /**
102
+ * Generate path check SQL from AST
103
+ * @private
104
+ */
105
+ _generatePathCheck(ast, recordVar, userIdVar) {
106
+ // Handle direct field reference: @owner_id
107
+ if (ast.type === 'field_ref') {
108
+ return `(${recordVar}->>'${ast.field}')::int = ${userIdVar}`;
109
+ }
110
+
111
+ // Handle traversal with steps: @org_id->acts_for[org_id=$]{active}.user_id
112
+ if (ast.type === 'traversal' && ast.steps) {
113
+ const fieldRef = ast.steps[0]; // First step is the field reference
114
+ const tableRef = ast.steps[1]; // Second step is the table reference
115
+
116
+ if (!fieldRef || !tableRef || tableRef.type !== 'table_ref') {
117
+ return 'FALSE';
118
+ }
119
+
120
+ const startField = fieldRef.field;
121
+ const targetTable = tableRef.table;
122
+ const targetField = tableRef.targetField;
123
+
124
+ const startValue = `(${recordVar}->>'${startField}')::int`;
125
+
126
+ // Build WHERE clause
127
+ const whereClauses = [];
128
+
129
+ // Add filter conditions from the table_ref
130
+ if (tableRef.filter && tableRef.filter.length > 0) {
131
+ for (const filterCondition of tableRef.filter) {
132
+ const field = filterCondition.field;
133
+ if (filterCondition.value.type === 'param') {
134
+ // Parameter reference: org_id=$
135
+ whereClauses.push(`${targetTable}.${field} = ${startValue}`);
136
+ } else {
137
+ // Literal value
138
+ whereClauses.push(`${targetTable}.${field} = '${filterCondition.value}'`);
139
+ }
140
+ }
141
+ }
142
+
143
+ // Add temporal marker if present
144
+ if (tableRef.temporal) {
145
+ whereClauses.push(`${targetTable}.valid_to IS NULL`);
146
+ }
147
+
148
+ return `EXISTS (
149
+ SELECT 1 FROM ${targetTable}
150
+ WHERE ${whereClauses.join('\n AND ')}
151
+ AND ${targetTable}.${targetField} = ${userIdVar}
152
+ )`;
153
+ }
154
+
155
+ return 'FALSE';
156
+ }
157
+
158
+ /**
159
+ * Generate filter SQL
160
+ * @private
161
+ */
162
+ _generateFilterSQL(filter, tableAlias) {
163
+ const conditions = [];
164
+ for (const [key, value] of Object.entries(filter)) {
165
+ if (value === '$') {
166
+ // Placeholder - will be replaced with actual value
167
+ conditions.push(`${tableAlias}.${key} = ${tableAlias}.${key}`);
168
+ } else {
169
+ conditions.push(`${tableAlias}.${key} = '${value}'`);
170
+ }
171
+ }
172
+ return conditions.join(' AND ');
173
+ }
174
+
175
+ /**
176
+ * Generate query function that builds the document
177
+ * @private
178
+ */
179
+ _generateQueryFunction() {
180
+ const params = Object.keys(this.paramSchema);
181
+ const paramDeclarations = params.map(p => ` v_${p} ${this.paramSchema[p]};`).join('\n');
182
+ const paramExtractions = params.map(p =>
183
+ ` v_${p} := (p_params->>'${p}')::${this.paramSchema[p]};`
184
+ ).join('\n');
185
+
186
+ // Build root WHERE clause based on params
187
+ const rootFilter = this._generateRootFilter();
188
+
189
+ // Build relation subqueries
190
+ const relationSelects = this._generateRelationSelects();
191
+
192
+ return `CREATE OR REPLACE FUNCTION get_${this.name}(
193
+ p_params JSONB,
194
+ p_user_id INT
195
+ ) RETURNS JSONB AS $$
196
+ DECLARE
197
+ ${paramDeclarations}
198
+ v_result JSONB;
199
+ BEGIN
200
+ -- Extract parameters
201
+ ${paramExtractions}
202
+
203
+ -- Check access control
204
+ IF NOT ${this.name}_can_subscribe(p_user_id, p_params) THEN
205
+ RAISE EXCEPTION 'Permission denied';
206
+ END IF;
207
+
208
+ -- Build document with root and all relations
209
+ SELECT jsonb_build_object(
210
+ '${this.rootEntity}', row_to_json(root.*)${relationSelects}
211
+ )
212
+ INTO v_result
213
+ FROM ${this.rootEntity} root
214
+ WHERE ${rootFilter};
215
+
216
+ RETURN v_result;
217
+ END;
218
+ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
219
+ }
220
+
221
+ /**
222
+ * Generate root filter based on params
223
+ * @private
224
+ */
225
+ _generateRootFilter() {
226
+ const params = Object.keys(this.paramSchema);
227
+
228
+ // Assume first param is the root entity ID
229
+ // TODO: Make this more flexible based on param naming conventions
230
+ if (params.length > 0) {
231
+ const firstParam = params[0];
232
+ // Convention: venue_id -> id, org_id -> id, etc.
233
+ return `root.id = v_${firstParam}`;
234
+ }
235
+
236
+ return 'TRUE';
237
+ }
238
+
239
+ /**
240
+ * Generate relation subqueries
241
+ * @private
242
+ */
243
+ _generateRelationSelects() {
244
+ if (Object.keys(this.relations).length === 0) {
245
+ return '';
246
+ }
247
+
248
+ const selects = Object.entries(this.relations).map(([relName, relConfig]) => {
249
+ const relEntity = typeof relConfig === 'string' ? relConfig : relConfig.entity;
250
+ const relFilter = typeof relConfig === 'object' ? relConfig.filter : null;
251
+ const relIncludes = typeof relConfig === 'object' ? relConfig.include : null;
252
+
253
+ // Build filter condition
254
+ let filterSQL = this._generateRelationFilter(relFilter, relEntity);
255
+
256
+ // Build nested includes if any
257
+ let nestedSelect = 'row_to_json(rel.*)';
258
+ if (relIncludes) {
259
+ const nestedFields = Object.entries(relIncludes).map(([nestedName, nestedEntity]) => {
260
+ return `'${nestedName}', (
261
+ SELECT jsonb_agg(row_to_json(nested.*))
262
+ FROM ${nestedEntity} nested
263
+ WHERE nested.${relEntity}_id = rel.id
264
+ )`;
265
+ }).join(',\n ');
266
+
267
+ nestedSelect = `jsonb_build_object(
268
+ '${relEntity}', row_to_json(rel.*),
269
+ ${nestedFields}
270
+ )`;
271
+ }
272
+
273
+ return `,
274
+ '${relName}', (
275
+ SELECT jsonb_agg(${nestedSelect})
276
+ FROM ${relEntity} rel
277
+ WHERE ${filterSQL}
278
+ )`;
279
+ }).join('');
280
+
281
+ return selects;
282
+ }
283
+
284
+ /**
285
+ * Generate filter for relation subquery
286
+ * @private
287
+ */
288
+ _generateRelationFilter(filter, relEntity) {
289
+ if (!filter) {
290
+ // Default: foreign key to root
291
+ return `rel.${this.rootEntity}_id = root.id`;
292
+ }
293
+
294
+ // Parse filter expression like "venue_id=$venue_id"
295
+ // Replace $param with v_param variable
296
+ return filter.replace(/\$(\w+)/g, 'v_$1');
297
+ }
298
+
299
+ /**
300
+ * Generate affected documents function
301
+ * @private
302
+ */
303
+ _generateAffectedDocumentsFunction() {
304
+ const cases = [];
305
+
306
+ // Case 1: Root entity changed
307
+ cases.push(this._generateRootAffectedCase());
308
+
309
+ // Case 2: Related entities changed
310
+ for (const [relName, relConfig] of Object.entries(this.relations)) {
311
+ cases.push(this._generateRelationAffectedCase(relName, relConfig));
312
+ }
313
+
314
+ const casesSQL = cases.join('\n\n ');
315
+
316
+ return `CREATE OR REPLACE FUNCTION ${this.name}_affected_documents(
317
+ p_table_name TEXT,
318
+ p_op TEXT,
319
+ p_old JSONB,
320
+ p_new JSONB
321
+ ) RETURNS JSONB[] AS $$
322
+ DECLARE
323
+ v_affected JSONB[];
324
+ BEGIN
325
+ CASE p_table_name
326
+ ${casesSQL}
327
+
328
+ ELSE
329
+ v_affected := ARRAY[]::JSONB[];
330
+ END CASE;
331
+
332
+ RETURN v_affected;
333
+ END;
334
+ $$ LANGUAGE plpgsql IMMUTABLE;`;
335
+ }
336
+
337
+ /**
338
+ * Generate case for root entity changes
339
+ * @private
340
+ */
341
+ _generateRootAffectedCase() {
342
+ const params = Object.keys(this.paramSchema);
343
+ const firstParam = params[0] || 'id';
344
+
345
+ return `-- Root entity (${this.rootEntity}) changed
346
+ WHEN '${this.rootEntity}' THEN
347
+ v_affected := ARRAY[
348
+ jsonb_build_object('${firstParam}', COALESCE((p_new->>'id')::int, (p_old->>'id')::int))
349
+ ];`;
350
+ }
351
+
352
+ /**
353
+ * Generate case for related entity changes
354
+ * @private
355
+ */
356
+ _generateRelationAffectedCase(relName, relConfig) {
357
+ const relEntity = typeof relConfig === 'string' ? relConfig : relConfig.entity;
358
+ const relFK = typeof relConfig === 'object' && relConfig.foreignKey
359
+ ? relConfig.foreignKey
360
+ : `${this.rootEntity}_id`;
361
+
362
+ const params = Object.keys(this.paramSchema);
363
+ const firstParam = params[0] || 'id';
364
+
365
+ // Check if this is a nested relation (has parent FK)
366
+ const nestedIncludes = typeof relConfig === 'object' ? relConfig.include : null;
367
+
368
+ if (nestedIncludes) {
369
+ // Nested relation: need to traverse up to root
370
+ return `-- Nested relation (${relEntity}) changed
371
+ WHEN '${relEntity}' THEN
372
+ -- Find parent and then root
373
+ SELECT ARRAY_AGG(jsonb_build_object('${firstParam}', parent.${this.rootEntity}_id))
374
+ INTO v_affected
375
+ FROM ${relEntity} rel
376
+ JOIN ${Object.keys(nestedIncludes)[0]} parent ON parent.id = rel.${Object.keys(nestedIncludes)[0]}_id
377
+ WHERE rel.id = COALESCE((p_new->>'id')::int, (p_old->>'id')::int);`;
378
+ }
379
+
380
+ return `-- Related entity (${relEntity}) changed
381
+ WHEN '${relEntity}' THEN
382
+ v_affected := ARRAY[
383
+ jsonb_build_object('${firstParam}', COALESCE((p_new->>'${relFK}')::int, (p_old->>'${relFK}')::int))
384
+ ];`;
385
+ }
386
+ }
387
+
388
+ /**
389
+ * Generate subscribable functions from config
390
+ * @param {Object} subscribable - Subscribable configuration
391
+ * @returns {string} Generated SQL
392
+ */
393
+ export function generateSubscribable(subscribable) {
394
+ const codegen = new SubscribableCodegen(subscribable);
395
+ return codegen.generate();
396
+ }