dzql 0.5.5 → 0.5.7

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.
@@ -13,6 +13,7 @@ CREATE TABLE IF NOT EXISTS dzql.subscribables (
13
13
  param_schema JSONB NOT NULL DEFAULT '{}'::jsonb,
14
14
  root_entity TEXT NOT NULL,
15
15
  relations JSONB NOT NULL DEFAULT '{}'::jsonb,
16
+ scope_tables TEXT[] NOT NULL DEFAULT '{}',
16
17
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
17
18
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
18
19
  );
@@ -35,6 +36,9 @@ COMMENT ON COLUMN dzql.subscribables.root_entity IS
35
36
  COMMENT ON COLUMN dzql.subscribables.relations IS
36
37
  'Related entities to include (e.g., {"org": "organisations", "sites": {"entity": "sites", "filter": "venue_id=$venue_id"}})';
37
38
 
39
+ COMMENT ON COLUMN dzql.subscribables.scope_tables IS
40
+ 'Array of table names that are in scope for this subscribable (root + all relations). Used for efficient event filtering.';
41
+
38
42
  -- Index for quick lookups
39
43
  CREATE INDEX IF NOT EXISTS idx_subscribables_root_entity
40
44
  ON dzql.subscribables(root_entity);
@@ -110,6 +114,7 @@ RETURNS TABLE (
110
114
  param_schema JSONB,
111
115
  root_entity TEXT,
112
116
  relations JSONB,
117
+ scope_tables TEXT[],
113
118
  created_at TIMESTAMPTZ,
114
119
  updated_at TIMESTAMPTZ
115
120
  ) AS $$
@@ -121,6 +126,7 @@ BEGIN
121
126
  s.param_schema,
122
127
  s.root_entity,
123
128
  s.relations,
129
+ s.scope_tables,
124
130
  s.created_at,
125
131
  s.updated_at
126
132
  FROM dzql.subscribables s
@@ -139,6 +145,7 @@ RETURNS TABLE (
139
145
  param_schema JSONB,
140
146
  root_entity TEXT,
141
147
  relations JSONB,
148
+ scope_tables TEXT[],
142
149
  created_at TIMESTAMPTZ,
143
150
  updated_at TIMESTAMPTZ
144
151
  ) AS $$
@@ -150,6 +157,7 @@ BEGIN
150
157
  s.param_schema,
151
158
  s.root_entity,
152
159
  s.relations,
160
+ s.scope_tables,
153
161
  s.created_at,
154
162
  s.updated_at
155
163
  FROM dzql.subscribables s
@@ -168,6 +176,7 @@ RETURNS TABLE (
168
176
  param_schema JSONB,
169
177
  root_entity TEXT,
170
178
  relations JSONB,
179
+ scope_tables TEXT[],
171
180
  created_at TIMESTAMPTZ,
172
181
  updated_at TIMESTAMPTZ
173
182
  ) AS $$
@@ -179,6 +188,7 @@ BEGIN
179
188
  s.param_schema,
180
189
  s.root_entity,
181
190
  s.relations,
191
+ s.scope_tables,
182
192
  s.created_at,
183
193
  s.updated_at
184
194
  FROM dzql.subscribables s
@@ -0,0 +1,150 @@
1
+ -- Migration 010: Atomic Updates for Subscribables
2
+ -- Adds extract_scope_tables function and updates register_subscribable to auto-populate scope_tables
3
+
4
+ -- ============================================================================
5
+ -- Helper function to extract scope tables from relations
6
+ -- ============================================================================
7
+
8
+ CREATE OR REPLACE FUNCTION dzql.extract_scope_tables(
9
+ p_root_entity TEXT,
10
+ p_relations JSONB
11
+ ) RETURNS TEXT[] AS $$
12
+ DECLARE
13
+ v_tables TEXT[];
14
+ v_key TEXT;
15
+ v_value JSONB;
16
+ v_entity TEXT;
17
+ v_nested JSONB;
18
+ BEGIN
19
+ -- Start with root entity
20
+ v_tables := ARRAY[p_root_entity];
21
+
22
+ -- Return early if no relations
23
+ IF p_relations IS NULL OR p_relations = '{}'::jsonb THEN
24
+ RETURN v_tables;
25
+ END IF;
26
+
27
+ -- Iterate through relations
28
+ FOR v_key, v_value IN SELECT * FROM jsonb_each(p_relations)
29
+ LOOP
30
+ -- Handle string relation (simple FK expansion): "org": "organisations"
31
+ IF jsonb_typeof(v_value) = 'string' THEN
32
+ v_entity := v_value #>> '{}';
33
+ IF v_entity IS NOT NULL AND v_entity != '' THEN
34
+ v_tables := array_append(v_tables, v_entity);
35
+ END IF;
36
+ -- Handle object relation: {"entity": "sites", "filter": "..."}
37
+ ELSIF jsonb_typeof(v_value) = 'object' THEN
38
+ v_entity := v_value ->> 'entity';
39
+ IF v_entity IS NOT NULL AND v_entity != '' THEN
40
+ v_tables := array_append(v_tables, v_entity);
41
+ END IF;
42
+
43
+ -- Recursively handle nested relations (include or relations)
44
+ v_nested := v_value -> 'include';
45
+ IF v_nested IS NOT NULL AND jsonb_typeof(v_nested) = 'object' THEN
46
+ v_tables := v_tables || dzql.extract_scope_tables(NULL, v_nested);
47
+ END IF;
48
+
49
+ v_nested := v_value -> 'relations';
50
+ IF v_nested IS NOT NULL AND jsonb_typeof(v_nested) = 'object' THEN
51
+ v_tables := v_tables || dzql.extract_scope_tables(NULL, v_nested);
52
+ END IF;
53
+ END IF;
54
+ END LOOP;
55
+
56
+ -- Remove duplicates and nulls
57
+ SELECT array_agg(DISTINCT t) INTO v_tables
58
+ FROM unnest(v_tables) t
59
+ WHERE t IS NOT NULL;
60
+
61
+ RETURN COALESCE(v_tables, ARRAY[]::TEXT[]);
62
+ END;
63
+ $$ LANGUAGE plpgsql IMMUTABLE;
64
+
65
+ COMMENT ON FUNCTION dzql.extract_scope_tables IS
66
+ 'Extract all table names from a subscribable definition (root entity + all relations recursively)';
67
+
68
+ -- ============================================================================
69
+ -- Update register_subscribable to auto-populate scope_tables
70
+ -- ============================================================================
71
+
72
+ CREATE OR REPLACE FUNCTION dzql.register_subscribable(
73
+ p_name TEXT,
74
+ p_permission_paths JSONB,
75
+ p_param_schema JSONB,
76
+ p_root_entity TEXT,
77
+ p_relations JSONB
78
+ ) RETURNS TEXT AS $$
79
+ DECLARE
80
+ v_result TEXT;
81
+ v_scope_tables TEXT[];
82
+ BEGIN
83
+ -- Validate inputs
84
+ IF p_name IS NULL OR p_name = '' THEN
85
+ RAISE EXCEPTION 'Subscribable name cannot be empty';
86
+ END IF;
87
+
88
+ IF p_root_entity IS NULL OR p_root_entity = '' THEN
89
+ RAISE EXCEPTION 'Root entity cannot be empty';
90
+ END IF;
91
+
92
+ -- Extract scope tables from root entity and relations
93
+ v_scope_tables := dzql.extract_scope_tables(p_root_entity, p_relations);
94
+
95
+ -- Insert or update subscribable
96
+ INSERT INTO dzql.subscribables (
97
+ name,
98
+ permission_paths,
99
+ param_schema,
100
+ root_entity,
101
+ relations,
102
+ scope_tables,
103
+ created_at,
104
+ updated_at
105
+ ) VALUES (
106
+ p_name,
107
+ COALESCE(p_permission_paths, '{}'::jsonb),
108
+ COALESCE(p_param_schema, '{}'::jsonb),
109
+ p_root_entity,
110
+ COALESCE(p_relations, '{}'::jsonb),
111
+ v_scope_tables,
112
+ NOW(),
113
+ NOW()
114
+ )
115
+ ON CONFLICT (name) DO UPDATE SET
116
+ permission_paths = EXCLUDED.permission_paths,
117
+ param_schema = EXCLUDED.param_schema,
118
+ root_entity = EXCLUDED.root_entity,
119
+ relations = EXCLUDED.relations,
120
+ scope_tables = EXCLUDED.scope_tables,
121
+ updated_at = NOW();
122
+
123
+ v_result := format('Subscribable "%s" registered successfully with scope tables: %s',
124
+ p_name, array_to_string(v_scope_tables, ', '));
125
+
126
+ RAISE NOTICE '%', v_result;
127
+
128
+ RETURN v_result;
129
+ END;
130
+ $$ LANGUAGE plpgsql;
131
+
132
+ -- ============================================================================
133
+ -- Backfill existing subscribables with scope_tables
134
+ -- ============================================================================
135
+
136
+ UPDATE dzql.subscribables s
137
+ SET scope_tables = dzql.extract_scope_tables(s.root_entity, s.relations)
138
+ WHERE scope_tables = '{}' OR scope_tables IS NULL;
139
+
140
+ -- ============================================================================
141
+ -- Verification
142
+ -- ============================================================================
143
+
144
+ DO $$
145
+ BEGIN
146
+ RAISE NOTICE 'Migration 010: Atomic Updates - Complete';
147
+ RAISE NOTICE 'Created dzql.extract_scope_tables() function';
148
+ RAISE NOTICE 'Updated dzql.register_subscribable() to auto-populate scope_tables';
149
+ RAISE NOTICE 'Backfilled existing subscribables';
150
+ END $$;
@@ -2,7 +2,7 @@ import { createWebSocketHandlers, verify_jwt_token } from "./ws.js";
2
2
  import { closeConnections, setupListeners, sql, db } from "./db.js";
3
3
  import * as defaultApi from "./api.js";
4
4
  import { serverLogger, notifyLogger } from "./logger.js";
5
- import { getSubscriptionsBySubscribable, paramsMatch } from "./subscriptions.js";
5
+ import { getSubscriptionsBySubscribable, paramsMatch, getSubscribableScopeTables } from "./subscriptions.js";
6
6
 
7
7
  // Re-export commonly used utilities
8
8
  export { sql, db } from "./db.js";
@@ -11,12 +11,12 @@ export { createMCPRoute } from "./mcp.js";
11
11
 
12
12
  /**
13
13
  * Process subscription updates when a database event occurs
14
- * Checks if any active subscriptions are affected and sends updates
14
+ * Forwards atomic events to affected subscriptions for client-side patching
15
15
  * @param {Object} event - Database event {table, op, pk, before, after}
16
16
  * @param {Function} broadcast - Broadcast function from WebSocket handlers
17
17
  */
18
18
  async function processSubscriptionUpdates(event, broadcast) {
19
- const { table, op, before, after } = event;
19
+ const { table, op, pk, before, after } = event;
20
20
 
21
21
  // Get all active subscriptions grouped by subscribable
22
22
  const subscriptionsByName = getSubscriptionsBySubscribable();
@@ -30,6 +30,14 @@ async function processSubscriptionUpdates(event, broadcast) {
30
30
  // For each unique subscribable, check if this event affects any subscriptions
31
31
  for (const [subscribableName, subs] of subscriptionsByName.entries()) {
32
32
  try {
33
+ // Check if this table is in scope for this subscribable
34
+ // This is an optimization to avoid calling _affected_documents for unrelated tables
35
+ const scopeTables = await getSubscribableScopeTables(subscribableName, sql);
36
+ if (scopeTables.length > 0 && !scopeTables.includes(table)) {
37
+ notifyLogger.debug(`Table ${table} not in scope for ${subscribableName}, skipping`);
38
+ continue;
39
+ }
40
+
33
41
  // Ask PostgreSQL which subscription instances are affected
34
42
  const result = await sql.unsafe(
35
43
  `SELECT ${subscribableName}_affected_documents($1, $2, $3, $4) as affected`,
@@ -42,7 +50,7 @@ async function processSubscriptionUpdates(event, broadcast) {
42
50
  continue; // This subscribable not affected
43
51
  }
44
52
 
45
- notifyLogger.debug(`${subscribableName}: ${affectedParamSets.length} param set(s) affected`);
53
+ notifyLogger.debug(`${subscribableName}: ${affectedParamSets.length} param set(s) affected by ${table}:${op}`);
46
54
 
47
55
  // Match affected params to active subscriptions
48
56
  for (const affectedParams of affectedParamSets) {
@@ -50,33 +58,32 @@ async function processSubscriptionUpdates(event, broadcast) {
50
58
  // Check if this subscription matches the affected params
51
59
  if (paramsMatch(sub.params, affectedParams)) {
52
60
  try {
53
- // Re-execute query to get updated data
54
- const updated = await sql.unsafe(
55
- `SELECT get_${subscribableName}($1, $2) as data`,
56
- [sub.params, sub.user_id]
57
- );
58
-
59
- const data = updated[0]?.data;
60
-
61
- // Send update to specific connection
61
+ // Forward atomic event instead of re-querying the full document
62
+ // Client will apply the patch to their local copy
62
63
  const message = JSON.stringify({
63
64
  jsonrpc: "2.0",
64
- method: "subscription:update",
65
+ method: "subscription:event",
65
66
  params: {
66
67
  subscription_id: sub.subscriptionId,
67
68
  subscribable: subscribableName,
68
- data
69
+ event: {
70
+ table,
71
+ op,
72
+ pk,
73
+ data: after,
74
+ before
75
+ }
69
76
  }
70
77
  });
71
78
 
72
79
  const sent = broadcast.toConnection(sub.connection_id, message);
73
80
  if (sent) {
74
- notifyLogger.debug(`Sent update to subscription ${sub.subscriptionId.slice(0, 8)}...`);
81
+ notifyLogger.debug(`Sent atomic event to subscription ${sub.subscriptionId.slice(0, 8)}... (${table}:${op})`);
75
82
  } else {
76
- notifyLogger.warn(`Failed to send update to connection ${sub.connection_id.slice(0, 8)}...`);
83
+ notifyLogger.warn(`Failed to send event to connection ${sub.connection_id.slice(0, 8)}...`);
77
84
  }
78
85
  } catch (error) {
79
- notifyLogger.error(`Failed to update subscription ${sub.subscriptionId}:`, error.message);
86
+ notifyLogger.error(`Failed to send event to subscription ${sub.subscriptionId}:`, error.message);
80
87
  }
81
88
  }
82
89
  }
@@ -18,6 +18,12 @@ const subscriptions = new Map();
18
18
  */
19
19
  const connectionSubscriptions = new Map();
20
20
 
21
+ /**
22
+ * Cache for subscribable metadata (scope tables, path mappings)
23
+ * Structure: subscribable_name -> { scopeTables, pathMapping, rootEntity, relations }
24
+ */
25
+ const subscribableMetadataCache = new Map();
26
+
21
27
  /**
22
28
  * Register a new subscription
23
29
  * @param {string} subscribableName - Name of the subscribable
@@ -207,3 +213,122 @@ export function getAllSubscriptions() {
207
213
  ...sub
208
214
  }));
209
215
  }
216
+
217
+ /**
218
+ * Get subscribable metadata (scope tables, path mapping) with caching
219
+ * @param {string} subscribableName - Name of the subscribable
220
+ * @param {function} sql - Database query function
221
+ * @returns {Promise<{scopeTables: string[], pathMapping: object, rootEntity: string, relations: object}>}
222
+ */
223
+ export async function getSubscribableMetadata(subscribableName, sql) {
224
+ // Check cache first
225
+ if (subscribableMetadataCache.has(subscribableName)) {
226
+ return subscribableMetadataCache.get(subscribableName);
227
+ }
228
+
229
+ // Fetch from database
230
+ const result = await sql`
231
+ SELECT scope_tables, root_entity, relations
232
+ FROM dzql.subscribables
233
+ WHERE name = ${subscribableName}
234
+ `;
235
+
236
+ if (!result || result.length === 0) {
237
+ // Return empty metadata if subscribable not found
238
+ const emptyMetadata = {
239
+ scopeTables: [],
240
+ pathMapping: {},
241
+ rootEntity: null,
242
+ relations: {}
243
+ };
244
+ return emptyMetadata;
245
+ }
246
+
247
+ const { scope_tables, root_entity, relations } = result[0];
248
+
249
+ // Build path mapping from relations
250
+ const pathMapping = buildPathMappingFromRelations(root_entity, relations);
251
+
252
+ const metadata = {
253
+ scopeTables: scope_tables || [],
254
+ pathMapping,
255
+ rootEntity: root_entity,
256
+ relations: relations || {}
257
+ };
258
+
259
+ // Cache for future use
260
+ subscribableMetadataCache.set(subscribableName, metadata);
261
+
262
+ wsLogger.debug(`Cached metadata for subscribable ${subscribableName}:`, {
263
+ scopeTables: metadata.scopeTables,
264
+ pathMapping: metadata.pathMapping
265
+ });
266
+
267
+ return metadata;
268
+ }
269
+
270
+ /**
271
+ * Build path mapping from relations configuration
272
+ * Maps table names to their document path for client-side patching
273
+ * @param {string} rootEntity - Root table name
274
+ * @param {object} relations - Relations configuration
275
+ * @returns {object} - Map of table name -> document path
276
+ */
277
+ function buildPathMappingFromRelations(rootEntity, relations) {
278
+ const paths = {};
279
+
280
+ // Root entity maps to top level
281
+ if (rootEntity) {
282
+ paths[rootEntity] = '.';
283
+ }
284
+
285
+ const buildPaths = (rels, parentPath = '') => {
286
+ for (const [relName, relConfig] of Object.entries(rels || {})) {
287
+ const entity = typeof relConfig === 'string' ? relConfig : relConfig?.entity;
288
+ const currentPath = parentPath ? `${parentPath}.${relName}` : relName;
289
+
290
+ if (entity) {
291
+ paths[entity] = currentPath;
292
+ }
293
+
294
+ // Handle nested relations
295
+ if (typeof relConfig === 'object' && relConfig !== null) {
296
+ if (relConfig.include) {
297
+ buildPaths(relConfig.include, currentPath);
298
+ }
299
+ if (relConfig.relations) {
300
+ buildPaths(relConfig.relations, currentPath);
301
+ }
302
+ }
303
+ }
304
+ };
305
+
306
+ buildPaths(relations);
307
+ return paths;
308
+ }
309
+
310
+ /**
311
+ * Clear the subscribable metadata cache
312
+ * Called when subscribables are reregistered or updated
313
+ * @param {string} [subscribableName] - Optional: clear specific subscribable, or all if not provided
314
+ */
315
+ export function clearSubscribableMetadataCache(subscribableName = null) {
316
+ if (subscribableName) {
317
+ subscribableMetadataCache.delete(subscribableName);
318
+ wsLogger.debug(`Cleared metadata cache for ${subscribableName}`);
319
+ } else {
320
+ subscribableMetadataCache.clear();
321
+ wsLogger.debug('Cleared all subscribable metadata cache');
322
+ }
323
+ }
324
+
325
+ /**
326
+ * Get scope tables for a subscribable (convenience function)
327
+ * @param {string} subscribableName - Name of the subscribable
328
+ * @param {function} sql - Database query function
329
+ * @returns {Promise<string[]>} - Array of table names in scope
330
+ */
331
+ export async function getSubscribableScopeTables(subscribableName, sql) {
332
+ const metadata = await getSubscribableMetadata(subscribableName, sql);
333
+ return metadata.scopeTables;
334
+ }
package/src/server/ws.js CHANGED
@@ -11,7 +11,8 @@ import {
11
11
  registerSubscription,
12
12
  unregisterSubscription,
13
13
  unregisterSubscriptionByParams,
14
- removeConnectionSubscriptions
14
+ removeConnectionSubscriptions,
15
+ getSubscribableMetadata
15
16
  } from "./subscriptions.js";
16
17
 
17
18
  // Environment configuration
@@ -327,6 +328,9 @@ export function createRPCHandler(customHandlers = {}) {
327
328
 
328
329
  const data = queryResult[0]?.data;
329
330
 
331
+ // Get subscribable metadata for schema (path mapping for atomic updates)
332
+ const metadata = await getSubscribableMetadata(subscribableName, sql);
333
+
330
334
  // Register subscription in memory
331
335
  const subscriptionId = registerSubscription(
332
336
  subscribableName,
@@ -335,9 +339,15 @@ export function createRPCHandler(customHandlers = {}) {
335
339
  params
336
340
  );
337
341
 
342
+ // Build result with schema for client-side patching
338
343
  const result = {
339
344
  subscription_id: subscriptionId,
340
- data
345
+ data,
346
+ // Include schema for atomic update support
347
+ schema: {
348
+ root: metadata.rootEntity,
349
+ paths: metadata.pathMapping
350
+ }
341
351
  };
342
352
 
343
353
  wsLogger.response(method, result, Date.now() - startTime);