dzql 0.6.14 → 0.6.16

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.6.14",
3
+ "version": "0.6.16",
4
4
  "description": "Database-first real-time framework with TypeScript support",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,6 +1,6 @@
1
1
  import { compilePermission } from "../compiler/permissions.js";
2
2
  import { compileGraphRules } from "../compiler/graph_rules.js";
3
- import type { EntityIR, ManyToManyIR } from "../../shared/ir.js";
3
+ import type { EntityIR, ManyToManyIR, IncludeIR } from "../../shared/ir.js";
4
4
 
5
5
  /** Column info from EntityIR */
6
6
  interface ColumnInfo {
@@ -313,6 +313,32 @@ export function generateSaveFunction(name: string, entityIR: EntityIR): string {
313
313
  return sql;
314
314
  }).join('\n');
315
315
 
316
+ // FK expansion (add related objects to output for real-time events)
317
+ // Only expand direct FKs (where key_id column exists), not reverse FKs (child arrays)
318
+ const includes: Record<string, IncludeIR> = entityIR.includes || {};
319
+ const includeKeys = Object.keys(includes);
320
+ const fkExpansion = includeKeys.map(key => {
321
+ const config: IncludeIR = includes[key];
322
+ const targetEntity = config.entity;
323
+ const fkField = `${key}_id`; // Convention: author -> author_id
324
+
325
+ // Only expand if this is a direct FK (key_id column exists)
326
+ const hasFkColumn = entityIR.columns.some((c: ColumnInfo) => c.name === fkField);
327
+
328
+ if (hasFkColumn) {
329
+ // Direct FK: single object expansion (e.g., author_id -> author object)
330
+ return `
331
+ -- FK: Add ${key} to output (from ${fkField})
332
+ IF (v_result->>'${fkField}') IS NOT NULL THEN
333
+ v_result := v_result || jsonb_build_object('${key}',
334
+ (SELECT to_jsonb(t.*) FROM ${targetEntity} t WHERE t.id = (v_result->>'${fkField}')::int));
335
+ END IF;`;
336
+ }
337
+ // Skip reverse FKs - they would require querying child tables which may not exist
338
+ // and are not needed for the primary use case of expanding the saved record
339
+ return '';
340
+ }).filter(s => s).join('\n');
341
+
316
342
  return `
317
343
  CREATE OR REPLACE FUNCTION dzql_v2.save_${name}(p_user_id int, p_data jsonb)
318
344
  RETURNS jsonb
@@ -365,6 +391,7 @@ ${m2mExtraction}
365
391
  END IF;
366
392
  ${m2mSync}
367
393
  ${m2mExpansion}
394
+ ${fkExpansion}
368
395
 
369
396
  -- Resolve notification recipients
370
397
  v_notify_users := dzql_v2.${name}_notify_users(p_user_id, v_result);
@@ -512,10 +512,18 @@ function generateNestedSelects(
512
512
  function generateAffectedKeysFunction(name: string, sub: SubscribableIR): string {
513
513
  const cases: string[] = [];
514
514
  const paramKey = sub.root.key;
515
+ const hasParams = Object.keys(sub.params).length > 0;
515
516
 
516
517
  // Root entity case
517
- cases.push(` WHEN '${sub.root.entity}' THEN
518
+ // For subscribables with no params (list feeds), just return the subscribable name
519
+ // For subscribables with params, include the root entity's id
520
+ if (hasParams) {
521
+ cases.push(` WHEN '${sub.root.entity}' THEN
518
522
  RETURN ARRAY['${name}:' || (p_data->>'id')];`);
523
+ } else {
524
+ cases.push(` WHEN '${sub.root.entity}' THEN
525
+ RETURN ARRAY['${name}'];`);
526
+ }
519
527
 
520
528
  // Get singular form of root entity for FK fields
521
529
  const singularRootEntity = singularize(sub.root.entity);
@@ -552,18 +560,30 @@ function generateAffectedKeysFunction(name: string, sub: SubscribableIR): string
552
560
  const parentRel = relationships[parentEntity]?.[relEntity];
553
561
  const fkField = parentRel?.fkOnParent || `${relName}_id`;
554
562
 
555
- cases.push(` WHEN '${relEntity}' THEN
563
+ if (hasParams) {
564
+ cases.push(` WHEN '${relEntity}' THEN
556
565
  -- Nested: traverse via ${parentEntity}.${fkField}
557
566
  SELECT ARRAY_AGG('${name}:' || parent.${singularRootEntity}_id)
558
567
  INTO v_keys
559
568
  FROM ${parentEntity} parent
560
569
  WHERE parent.${fkField} = (p_data->>'id')::int;
561
570
  RETURN COALESCE(v_keys, ARRAY[]::text[]);`);
571
+ } else {
572
+ // No params - just return the subscribable name
573
+ cases.push(` WHEN '${relEntity}' THEN
574
+ RETURN ARRAY['${name}'];`);
575
+ }
562
576
  } else {
563
577
  // Direct child of root - use the FK on child that points to root
564
578
  const keyField = fkOnChild || `${singularRootEntity}_id`;
565
- cases.push(` WHEN '${relEntity}' THEN
579
+ if (hasParams) {
580
+ cases.push(` WHEN '${relEntity}' THEN
566
581
  RETURN ARRAY['${name}:' || (p_data->>'${keyField}')];`);
582
+ } else {
583
+ // No params - just return the subscribable name
584
+ cases.push(` WHEN '${relEntity}' THEN
585
+ RETURN ARRAY['${name}'];`);
586
+ }
567
587
  }
568
588
 
569
589
  // Recurse for nested includes
@@ -175,6 +175,9 @@ export function generateIR(domain: DomainConfig): DomainIR {
175
175
  };
176
176
  }
177
177
 
178
+ // Parse includes (FK expansions)
179
+ const includes = parseIncludes(config.includes as Record<string, string | IncludeConfig> | undefined);
180
+
178
181
  entities[name] = {
179
182
  name,
180
183
  table: name,
@@ -187,6 +190,7 @@ export function generateIR(domain: DomainConfig): DomainIR {
187
190
  fieldDefaults: config.fieldDefaults || {},
188
191
  permissions,
189
192
  relationships: {},
193
+ includes,
190
194
  manyToMany,
191
195
  graphRules: {
192
196
  onCreate: onCreateRules,
@@ -93,7 +93,11 @@ function processEventNotifications(event: {
93
93
  for (const [subscribableName, subs] of subscriptionsByName.entries()) {
94
94
  for (const sub of subs) {
95
95
  const paramValues = Object.values(sub.params);
96
- const subKey = `${subscribableName}:${paramValues.join(':')}`;
96
+ // For subscribables with no params, the key is just the name
97
+ // For subscribables with params, the key is name:param1:param2:...
98
+ const subKey = paramValues.length > 0
99
+ ? `${subscribableName}:${paramValues.join(':')}`
100
+ : subscribableName;
97
101
 
98
102
  if (affected_keys.includes(subKey)) {
99
103
  // Send entity broadcast (not subscription:event) so global dispatcher can route it
package/src/shared/ir.ts CHANGED
@@ -130,6 +130,7 @@ export interface EntityIR {
130
130
  delete: string[];
131
131
  };
132
132
  relationships: Record<string, RelationshipIR>;
133
+ includes: Record<string, IncludeIR>; // FK expansions (e.g., author: users)
133
134
  manyToMany: Record<string, ManyToManyIR>;
134
135
  graphRules: {
135
136
  onCreate: GraphRuleIR[];