dzql 0.5.28 → 0.5.30

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dzql",
3
- "version": "0.5.28",
3
+ "version": "0.5.30",
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",
@@ -243,6 +243,7 @@ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
243
243
 
244
244
  /**
245
245
  * Generate SAVE function for composite primary keys
246
+ * Uses INSERT ... ON CONFLICT DO UPDATE (UPSERT) to avoid race conditions
246
247
  * @private
247
248
  */
248
249
  _generateCompositePKSaveFunction(helpers) {
@@ -259,22 +260,12 @@ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
259
260
 
260
261
  const pkDescription = this.primaryKey.join(', ');
261
262
 
262
- // Generate WHERE clause for composite PK lookup
263
+ // Generate WHERE clause for composite PK lookup (used for permission checks and v_before)
263
264
  // e.g., "entity_type = (p_data->>'entity_type') AND entity_id = (p_data->>'entity_id')::int"
264
265
  const whereClause = this.primaryKey.map(col => {
265
266
  return `${col} = (p_data->>'${col}')${this._getTypeCast(col)}`;
266
267
  }).join(' AND ');
267
268
 
268
- // For use inside format() string literal with %L placeholders
269
- // e.g., "entity_type = %L AND entity_id = %L"
270
- const whereClauseForFormat = this.primaryKey.map(col => `${col} = %L`).join(' AND ');
271
-
272
- // Format arguments for the WHERE clause (to be passed to format())
273
- // e.g., "(p_data->>'entity_type'), (p_data->>'entity_id')::int"
274
- const whereClauseFormatArgs = this.primaryKey.map(col => {
275
- return `(p_data->>'${col}')${this._getTypeCast(col)}`;
276
- }).join(', ');
277
-
278
269
  // Check if all PK fields are present in p_data
279
270
  const pkNullCheck = this.primaryKey.map(col => `p_data->>'${col}' IS NULL`).join(' OR ');
280
271
 
@@ -286,7 +277,12 @@ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
286
277
  const m2mExpansionCompositePK = this._generateM2MExpansionCompositePK();
287
278
  const m2mExpansionForBeforeCompositePK = this._generateM2MExpansionForBeforeCompositePK();
288
279
 
280
+ // Generate ON CONFLICT clause for composite PK
281
+ // e.g., "(entity_type, entity_id)"
282
+ const onConflictPK = `(${this.primaryKey.join(', ')})`;
283
+
289
284
  return `-- SAVE operation for ${this.tableName} (composite primary key: ${pkDescription})
285
+ -- Uses UPSERT (INSERT ... ON CONFLICT DO UPDATE) to prevent race conditions
290
286
  CREATE OR REPLACE FUNCTION save_${this.tableName}(
291
287
  p_user_id INT,
292
288
  p_data JSONB
@@ -298,68 +294,76 @@ DECLARE
298
294
  v_before JSONB;
299
295
  v_is_insert BOOLEAN := false;
300
296
  v_notify_users INT[];
297
+ v_columns TEXT;
298
+ v_values TEXT;
299
+ v_update_set TEXT;
301
300
  ${m2mVariables}
302
301
  BEGIN
303
302
  ${m2mExtraction}
304
- -- Determine if this is insert or update (composite PK: ${pkDescription})
305
- IF ${pkNullCheck} THEN
306
- -- Missing one or more PK fields - this is an insert
307
- v_is_insert := true;
308
- ELSE
309
- -- Try to fetch existing record by composite PK
303
+ -- Check if this might be an update (all PK fields present)
304
+ -- We still need to check for existing record for permission checks and v_before
305
+ IF NOT (${pkNullCheck}) THEN
310
306
  SELECT * INTO v_existing
311
307
  FROM ${this.tableName}
312
308
  WHERE ${whereClause};
313
309
 
314
- v_is_insert := NOT FOUND;
315
- END IF;
316
-
317
- -- Check permissions
318
- IF v_is_insert THEN
319
- IF NOT can_create_${this.tableName}(p_user_id, p_data) THEN
320
- RAISE EXCEPTION 'Permission denied: create on ${this.tableName}';
310
+ IF FOUND THEN
311
+ -- Check update permission on existing record
312
+ IF NOT can_update_${this.tableName}(p_user_id, to_jsonb(v_existing)) THEN
313
+ RAISE EXCEPTION 'Permission denied: update on ${this.tableName}';
314
+ END IF;
315
+ -- Store before state for M2M expansion
316
+ v_before := to_jsonb(v_existing);
317
+ ${m2mExpansionForBeforeCompositePK}
318
+ ELSE
319
+ -- Record doesn't exist yet, this will be an insert
320
+ IF NOT can_create_${this.tableName}(p_user_id, p_data) THEN
321
+ RAISE EXCEPTION 'Permission denied: create on ${this.tableName}';
322
+ END IF;
321
323
  END IF;
322
324
  ELSE
323
- IF NOT can_update_${this.tableName}(p_user_id, to_jsonb(v_existing)) THEN
324
- RAISE EXCEPTION 'Permission denied: update on ${this.tableName}';
325
+ -- Missing PK fields - must be a new insert
326
+ IF NOT can_create_${this.tableName}(p_user_id, p_data) THEN
327
+ RAISE EXCEPTION 'Permission denied: create on ${this.tableName}';
325
328
  END IF;
326
329
  END IF;
327
-
328
- -- Expand M2M for existing record (for UPDATE events "before" field)
329
- IF NOT v_is_insert THEN
330
- v_before := to_jsonb(v_existing);
331
- ${m2mExpansionForBeforeCompositePK}
332
- END IF;
333
330
  ${fieldDefaults}
334
- -- Perform UPSERT
335
- IF v_is_insert THEN
336
- -- Dynamic INSERT from JSONB
337
- EXECUTE (
338
- SELECT format(
339
- 'INSERT INTO ${this.tableName} (%s) VALUES (%s) RETURNING *',
340
- string_agg(quote_ident(key), ', '),
341
- string_agg(quote_nullable(value), ', ')
342
- )
343
- FROM jsonb_each_text(p_data) kv(key, value)
331
+ -- Build column and value lists for UPSERT
332
+ SELECT
333
+ string_agg(quote_ident(key), ', '),
334
+ string_agg(quote_nullable(value), ', ')
335
+ INTO v_columns, v_values
336
+ FROM jsonb_each_text(p_data) kv(key, value);
337
+
338
+ -- Build UPDATE SET clause (excluding PK columns)
339
+ SELECT string_agg(quote_ident(key) || ' = EXCLUDED.' || quote_ident(key), ', ')
340
+ INTO v_update_set
341
+ FROM jsonb_each_text(p_data) kv(key, value)
342
+ WHERE ${this.primaryKey.map(col => `key != '${col}'`).join(' AND ')};
343
+
344
+ -- Perform atomic UPSERT to avoid race conditions
345
+ IF v_update_set IS NOT NULL AND v_update_set != '' THEN
346
+ -- Has non-PK fields to update
347
+ EXECUTE format(
348
+ 'INSERT INTO ${this.tableName} (%s) VALUES (%s) ' ||
349
+ 'ON CONFLICT ${onConflictPK} DO UPDATE SET %s ' ||
350
+ 'RETURNING *',
351
+ v_columns, v_values, v_update_set
344
352
  ) INTO v_result;
345
353
  ELSE
346
- -- Dynamic UPDATE from JSONB (only if there are non-PK fields to update)
347
- IF (SELECT COUNT(*) FROM jsonb_object_keys(p_data) WHERE ${pkFieldsExclusion}) > 0 THEN
348
- EXECUTE (
349
- SELECT format(
350
- 'UPDATE ${this.tableName} SET %s WHERE ${whereClauseForFormat} RETURNING *',
351
- string_agg(quote_ident(key) || ' = ' || quote_nullable(value), ', '),
352
- ${whereClauseFormatArgs}
353
- )
354
- FROM jsonb_each_text(p_data) kv(key, value)
355
- WHERE ${this.primaryKey.map(col => `key != '${col}'`).join(' AND ')}
356
- ) INTO v_result;
357
- ELSE
358
- -- No fields to update (only M2M fields were provided), just fetch existing
359
- v_result := v_existing;
360
- END IF;
354
+ -- Only PK fields provided - insert or return existing
355
+ EXECUTE format(
356
+ 'INSERT INTO ${this.tableName} (%s) VALUES (%s) ' ||
357
+ 'ON CONFLICT ${onConflictPK} DO UPDATE SET ${this.primaryKey[0]} = EXCLUDED.${this.primaryKey[0]} ' ||
358
+ 'RETURNING *',
359
+ v_columns, v_values
360
+ ) INTO v_result;
361
361
  END IF;
362
362
 
363
+ -- Determine if this was an insert by checking if v_existing was found
364
+ -- (if v_existing was NOT FOUND, then this was an insert)
365
+ v_is_insert := v_existing.${this.primaryKey[0]} IS NULL;
366
+
363
367
  ${m2mSyncCompositePK}
364
368
 
365
369
  -- Prepare output with M2M fields (BEFORE event creation for real-time notifications!)
@@ -129,6 +129,8 @@ export class SubscribableParser {
129
129
  */
130
130
  _cleanString(str) {
131
131
  if (!str) return '';
132
+ // Handle SQL NULL keyword - return empty string for null values
133
+ if (str.trim().toUpperCase() === 'NULL') return '';
132
134
  // Remove outer quotes, SQL comments, then any remaining quotes and whitespace
133
135
  let cleaned = str.replace(/^['"]|['"]$/g, ''); // Remove outer quotes
134
136
  cleaned = cleaned.replace(/--[^\n]*/g, ''); // Remove SQL comments
@@ -11,7 +11,7 @@ CREATE TABLE IF NOT EXISTS dzql.subscribables (
11
11
  name TEXT PRIMARY KEY,
12
12
  permission_paths JSONB NOT NULL DEFAULT '{}'::jsonb,
13
13
  param_schema JSONB NOT NULL DEFAULT '{}'::jsonb,
14
- root_entity TEXT NOT NULL,
14
+ root_entity TEXT, -- NULL allowed for dashboard mode (pure collections)
15
15
  relations JSONB NOT NULL DEFAULT '{}'::jsonb,
16
16
  scope_tables TEXT[] NOT NULL DEFAULT '{}',
17
17
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
@@ -31,7 +31,7 @@ COMMENT ON COLUMN dzql.subscribables.param_schema IS
31
31
  'Parameter schema defining subscription key (e.g., {"venue_id": "int"})';
32
32
 
33
33
  COMMENT ON COLUMN dzql.subscribables.root_entity IS
34
- 'Root table for the subscribable document';
34
+ 'Root table for the subscribable document. NULL for dashboard mode (pure collections).';
35
35
 
36
36
  COMMENT ON COLUMN dzql.subscribables.relations IS
37
37
  'Related entities to include (e.g., {"org": "organisations", "sites": {"entity": "sites", "filter": "venue_id=$venue_id"}})';
@@ -16,8 +16,12 @@ DECLARE
16
16
  v_entity TEXT;
17
17
  v_nested JSONB;
18
18
  BEGIN
19
- -- Start with root entity
20
- v_tables := ARRAY[p_root_entity];
19
+ -- Start with root entity (may be NULL for dashboard mode)
20
+ IF p_root_entity IS NOT NULL AND p_root_entity != '' THEN
21
+ v_tables := ARRAY[p_root_entity];
22
+ ELSE
23
+ v_tables := ARRAY[]::TEXT[];
24
+ END IF;
21
25
 
22
26
  -- Return early if no relations
23
27
  IF p_relations IS NULL OR p_relations = '{}'::jsonb THEN
@@ -85,8 +89,11 @@ BEGIN
85
89
  RAISE EXCEPTION 'Subscribable name cannot be empty';
86
90
  END IF;
87
91
 
88
- IF p_root_entity IS NULL OR p_root_entity = '' THEN
89
- RAISE EXCEPTION 'Root entity cannot be empty';
92
+ -- NULL root_entity is allowed for dashboard mode (pure collections)
93
+ -- But if relations is also empty, that's an error
94
+ IF (p_root_entity IS NULL OR p_root_entity = '') AND
95
+ (p_relations IS NULL OR p_relations = '{}'::jsonb) THEN
96
+ RAISE EXCEPTION 'Subscribable must have either a root entity or relations';
90
97
  END IF;
91
98
 
92
99
  -- Extract scope tables from root entity and relations
@@ -106,7 +113,7 @@ BEGIN
106
113
  p_name,
107
114
  COALESCE(p_permission_paths, '{}'::jsonb),
108
115
  COALESCE(p_param_schema, '{}'::jsonb),
109
- p_root_entity,
116
+ NULLIF(p_root_entity, ''), -- Store empty string as NULL
110
117
  COALESCE(p_relations, '{}'::jsonb),
111
118
  v_scope_tables,
112
119
  NOW(),