dzql 0.6.37 → 0.8.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 (45) hide show
  1. package/README.md +16 -0
  2. package/docs/README.md +1 -1
  3. package/docs/for_ai.md +39 -9
  4. package/package.json +12 -5
  5. package/src/cli/codegen/notification.ts +3 -3
  6. package/src/cli/codegen/sql/core.ts +21 -5
  7. package/src/cli/codegen/sql/delete.ts +5 -7
  8. package/src/cli/codegen/sql/get.ts +9 -11
  9. package/src/cli/codegen/sql/index.ts +7 -4
  10. package/src/cli/codegen/sql/lookup.ts +4 -5
  11. package/src/cli/codegen/sql/save.ts +58 -39
  12. package/src/cli/codegen/sql/search.ts +44 -10
  13. package/src/cli/codegen/sql/utils.ts +16 -1
  14. package/src/cli/codegen/subscribable_sql.ts +116 -124
  15. package/src/cli/compile.ts +263 -0
  16. package/src/cli/compiler/path-validator.ts +115 -83
  17. package/src/cli/compiler/paths.ts +299 -379
  18. package/src/cli/compiler/permissions.ts +26 -26
  19. package/src/cli/index.ts +17 -219
  20. package/src/client/ws.ts +20 -10
  21. package/src/postgres/auth-jwt-sql.ts +32 -0
  22. package/src/postgres/auth-jwt.ts +209 -0
  23. package/src/postgres/auth.ts +50 -0
  24. package/src/postgres/bootstrap.ts +55 -0
  25. package/src/postgres/carry.ts +40 -0
  26. package/src/postgres/codegen.ts +172 -0
  27. package/src/postgres/index.ts +58 -0
  28. package/src/postgres/listener.ts +706 -0
  29. package/src/postgres/migrate.ts +143 -0
  30. package/src/postgres/pglite.ts +142 -0
  31. package/src/postgres/registry.ts +253 -0
  32. package/src/postgres/schema.ts +171 -0
  33. package/src/postgres/sql.ts +42 -0
  34. package/src/runtime/auth.ts +33 -2
  35. package/src/runtime/index.ts +47 -6
  36. package/src/runtime/manifest_loader.ts +10 -0
  37. package/src/runtime/projection.ts +19 -0
  38. package/src/sql/001a-delta-meta.sql +204 -0
  39. package/src/sql/001b-delta-scope.sql +619 -0
  40. package/src/sql/001c-delta-read.sql +506 -0
  41. package/src/sql/001d-delta-write.sql +1088 -0
  42. package/src/sql/001e-delta-ops.sql +142 -0
  43. package/src/sql/001f-delta-as.sql +41 -0
  44. package/src/sql/001g-delta-ledger.sql +564 -0
  45. package/src/sql/auth-jwt.sql +62 -0
package/README.md CHANGED
@@ -12,6 +12,22 @@ bun run db:rebuild
12
12
  bun run dev
13
13
  ```
14
14
 
15
+ ## delta's Postgres step
16
+
17
+ dzql is becoming the Postgres step for apps built on [@blueshed/delta](https://github.com/blueshed/delta):
18
+ a delta app grows from a JSON file to SQLite to Postgres, and on Postgres its documents are kept by
19
+ dzql. Since 0.8.0 dzql carries delta's Postgres backend, with the same names, so moving is an
20
+ import change:
21
+
22
+ ```ts
23
+ import { applyFramework, applySql, createDocListener, docTypeFromDef, generateSql } from "dzql/postgres";
24
+ import { openPglite } from "dzql/pglite"; // Postgres in this process
25
+ import { jwtAuth } from "dzql/auth-jwt";
26
+ ```
27
+
28
+ Its tests run delta's own cases (`@blueshed/delta/testing`) against it. Compiled SQL replaces the
29
+ framework SQL in later releases; the plan is in Workshop's `docs/postgres-split-plan.md`.
30
+
15
31
  ## Documentation
16
32
 
17
33
  See the [full documentation](./docs/README.md) for:
package/docs/README.md CHANGED
@@ -37,7 +37,7 @@ export const entities = {
37
37
  author_id: 'int REFERENCES users(id)'
38
38
  },
39
39
  permissions: {
40
- view: [], // Anyone can view
40
+ view: ['TRUE'], // Anyone can view ([] would deny everyone)
41
41
  update: ['@author_id'] // Only author can edit
42
42
  }
43
43
  }
package/docs/for_ai.md CHANGED
@@ -44,12 +44,19 @@ temporal: {
44
44
 
45
45
  PERMISSION DSL
46
46
  ==============
47
- [] = Deny all
47
+ [] or omitted = Deny all (nobody, signed in or not)
48
48
  ['TRUE'] = Public access
49
+ ['AUTHENTICATED'] = Any signed-in user
49
50
  ['@author_id'] = @user_id == @author_id
50
51
  ['@author_id == @user_id'] = Explicit equality
51
52
  ['@org_id->acts_for[org_id=$].user_id'] = Single-hop traversal
52
53
  ['@org_id->acts_for[org_id=$]{active}.user_id'] = With condition filter
54
+ ['@org_id->user_orgs[org_id=$,role=owner].user_id'] = Several filters (AND)
55
+ ['rule A', 'rule B'] = Either rule grants access (OR); every rule is enforced
56
+
57
+ A path that can't be compiled (bad syntax, unknown table or column, a start
58
+ field that isn't an integer key) is a build error. It never compiles to "deny"
59
+ or to "any signed-in user".
53
60
 
54
61
  CONDITION FILTERS
55
62
  =================
@@ -65,6 +72,11 @@ Single: @org_id->acts_for[org_id=$]{active}.user_id
65
72
  3-hop: @site_id->sites.venue_id->venues.org_id->acts_for[org_id=$]{active}.user_id
66
73
  N-hop: @pkg_id->packages.occasion_id->occasions.venue_id->venues.org_id->user_orgs[org_id=$].user_id
67
74
 
75
+ A hop written table.field joins on table.id. A hop written table[field=$].out
76
+ joins on that field instead, and may match many rows (each is followed):
77
+ Filtered: @occasion_id->occasion_promoters[occasion_id=$].promoter_id->user_orgs[org_id=$].user_id
78
+ "a member of any org promoting this occasion"
79
+
68
80
  TABLE-FIRST PATTERN
69
81
  ===================
70
82
  rights[package_id=@package_id]{active}.org_id->user_orgs[org_id=$].user_id
@@ -87,7 +99,8 @@ ROOT KEY RULES:
87
99
 
88
100
  SUBSCRIBABLE TYPES:
89
101
  1. Single Entity: has root.key, returns one document with includes
90
- 2. List Subscribable: no root.key, returns array of documents
102
+ 2. List Subscribable: no root.key, returns array of documents, only the
103
+ root rows the subscriber may view (the root entity's view rules apply)
91
104
  ```
92
105
 
93
106
  ## Entity Definition
@@ -155,6 +168,9 @@ Permissions compile to SQL `EXISTS` clauses. Rules are OR'd together.
155
168
  ```typescript
156
169
  // Owner only
157
170
  update: ['@author_id'] // Shorthand for @author_id == @user_id
171
+ // An update must pass the rule on the stored row AND on the row after the
172
+ // change, so an owner can't hand the row to someone else through save, and an
173
+ // org member can't move a row into an org they don't belong to.
158
174
 
159
175
  // Organization member via junction table
160
176
  view: ['@org_id->acts_for[org_id=$]{active}.user_id']
@@ -165,7 +181,7 @@ view: ['@id'] // For users table: @id == @user_id
165
181
 
166
182
  // Public read, authenticated write
167
183
  view: ['TRUE'],
168
- create: [] // Empty = logged-in users only
184
+ create: ['AUTHENTICATED'] // Any signed-in user. [] would deny everyone
169
185
 
170
186
  // Multiple paths (OR'd)
171
187
  view: ['@author_id', '@org_id->acts_for[org_id=$].user_id']
@@ -294,7 +310,12 @@ export const subscribables = {
294
310
  // ALL tables that can trigger updates
295
311
  scopeTables: ['venues', 'organisations', 'sites', 'allocations'],
296
312
 
297
- // Who can subscribe
313
+ // Who can subscribe: rules are checked against the root row, and a
314
+ // reference to the key param (venue_id) means the root's id.
315
+ // [] denies everyone; use ['TRUE'] or ['AUTHENTICATED'] to open it.
316
+ // canSubscribe is the whole gate: the root's view rules are NOT also
317
+ // applied. To give the document the same audience as get_venues, copy
318
+ // venues.permissions.view in here.
298
319
  canSubscribe: ['@venue_id->venues.org_id->acts_for[org_id=$]{active}.user_id']
299
320
  }
300
321
  };
@@ -306,7 +327,7 @@ Returns an array of entities. Omit `root.key` to create a list:
306
327
 
307
328
  ```typescript
308
329
  export const subscribables = {
309
- // Returns all venues the user can access
330
+ // Returns the venues the subscriber may view, to any signed-in user
310
331
  my_venues: {
311
332
  params: {}, // No params needed
312
333
 
@@ -321,9 +342,16 @@ export const subscribables = {
321
342
 
322
343
  scopeTables: ['venues', 'organisations'],
323
344
 
324
- // Empty = authenticated users only
325
- // Row-level filtering happens in the query based on user context
326
- canSubscribe: []
345
+ // A list has no root row for a path to start from, so only TRUE, FALSE
346
+ // or AUTHENTICATED are allowed here; [] denies everyone.
347
+ // Each subscriber gets only the root rows that match root.filter AND
348
+ // that venues.permissions.view lets them see (view: [] shows no rows).
349
+ // Includes are not filtered by their own entities' view rules.
350
+ // Realtime pushes follow the same rule: a change to a root row is pushed
351
+ // only to subscribers whose view rules allow it, and a change to an
352
+ // included row only to subscribers whose documents show it. A deleted
353
+ // included row is pushed as its key alone.
354
+ canSubscribe: ['AUTHENTICATED']
327
355
  }
328
356
  };
329
357
  ```
@@ -653,7 +681,9 @@ bunx dzql compile domain.ts # Explicit compile command
653
681
 
654
682
  ### Validate Paths
655
683
 
656
- Validate all permission and notification paths without running the full compiler:
684
+ Validate all permission, notification and canSubscribe paths against the domain
685
+ (syntax, tables, columns) without running the full compiler. The compiler runs
686
+ the same checks and refuses to build if any fail:
657
687
 
658
688
  ```bash
659
689
  bunx dzql validate-paths domain.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dzql",
3
- "version": "0.6.37",
3
+ "version": "0.8.0",
4
4
  "description": "Database-first real-time framework with TypeScript support",
5
5
  "repository": {
6
6
  "type": "git",
@@ -14,7 +14,10 @@
14
14
  ".": "./src/runtime/index.ts",
15
15
  "./client": "./src/client/index.ts",
16
16
  "./compiler": "./src/cli/index.ts",
17
- "./namespace": "./src/runtime/namespace.ts"
17
+ "./namespace": "./src/runtime/namespace.ts",
18
+ "./postgres": "./src/postgres/index.ts",
19
+ "./pglite": "./src/postgres/pglite.ts",
20
+ "./auth-jwt": "./src/postgres/auth-jwt.ts"
18
21
  },
19
22
  "files": [
20
23
  "src",
@@ -24,7 +27,7 @@
24
27
  "README.md"
25
28
  ],
26
29
  "scripts": {
27
- "test": "bun run test:setup && bun test; bun run test:teardown",
30
+ "test": "bun run test:setup && bun test; status=$?; bun run test:teardown; exit $status",
28
31
  "test:setup": "docker compose down -v && docker compose up -d --wait",
29
32
  "test:teardown": "docker compose down -v"
30
33
  },
@@ -45,12 +48,16 @@
45
48
  "author": "Peter Bunyan",
46
49
  "license": "MIT",
47
50
  "dependencies": {
48
- "postgres": "^3.4.3",
51
+ "@blueshed/delta": "^0.16.0",
52
+ "@electric-sql/pglite": "^0.5.4",
49
53
  "dotenv": "^16.4.5",
50
- "jose": "^5.2.3"
54
+ "jose": "^6.2.4",
55
+ "pg": "^8.22.0",
56
+ "postgres": "^3.4.3"
51
57
  },
52
58
  "devDependencies": {
53
59
  "@types/bun": "latest",
60
+ "@types/pg": "^8.20.0",
54
61
  "pinia": "^3.0.4",
55
62
  "typescript": "^5.0.0",
56
63
  "vue": "^3.5.26"
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import type { EntityIR } from "../../shared/ir.js";
9
- import { parsePath, compilePathToSelect } from "../compiler/paths.js";
9
+ import { parsePath, compilePathToSelect, PathCompileError } from "../compiler/paths.js";
10
10
 
11
11
  /**
12
12
  * Generate notification resolution function for an entity
@@ -44,9 +44,9 @@ $$;`;
44
44
  for (const path of paths) {
45
45
  const parsed = parsePath(path);
46
46
 
47
+ // A path that can't be compiled is a build error, never silently skipped.
47
48
  if (!parsed.isValid) {
48
- console.warn(`[Notification] Invalid path in ${name}.${pathName}: ${parsed.error}`);
49
- continue;
49
+ throw new PathCompileError(`${name}.notifications.${pathName}: ${parsed.error}`, path);
50
50
  }
51
51
 
52
52
  const userQuery = compilePathToSelect(parsed, 'p_data');
@@ -56,7 +56,25 @@ $$;
56
56
  * Generate auth SQL functions.
57
57
  * These depend on the 'users' table existing, so must be applied after schema.
58
58
  */
59
- export function generateAuthSQL() {
59
+ export function generateAuthSQL(registerableColumns: string[] = []) {
60
+ // Build a fail-closed guard for register_user's p_options. Only columns the
61
+ // domain explicitly marks registerable may be set at sign-up; otherwise a
62
+ // client could self-provision privileged columns (role, is_admin, org_id, ...)
63
+ // that happen to exist on the users table. Core fields are always rejected.
64
+ const optionsGuard = registerableColumns.length > 0
65
+ ? ` IF p_options IS NOT NULL AND p_options <> '{}'::jsonb THEN
66
+ IF EXISTS (
67
+ SELECT 1 FROM jsonb_object_keys(p_options) AS k
68
+ WHERE k NOT IN (${registerableColumns.map(c => `'${c}'`).join(', ')})
69
+ ) THEN
70
+ RAISE EXCEPTION 'validation_error: field not allowed at registration' USING ERRCODE = '22023';
71
+ END IF;
72
+ v_insert_data := (p_options - 'id' - 'email' - 'password_hash' - 'password') || v_insert_data;
73
+ END IF;`
74
+ : ` IF p_options IS NOT NULL AND p_options <> '{}'::jsonb THEN
75
+ RAISE EXCEPTION 'validation_error: registration does not accept additional fields' USING ERRCODE = '22023';
76
+ END IF;`;
77
+
60
78
  return `
61
79
  -- === AUTH SYSTEM ===
62
80
  -- These functions depend on a 'users' table being created by the domain schema.
@@ -102,11 +120,9 @@ BEGIN
102
120
  v_salt := gen_salt('bf', 10);
103
121
  v_hash := crypt(p_password, v_salt);
104
122
 
105
- -- Build insert data: options fields + email + password_hash (options cannot override core fields)
123
+ -- Build insert data: email + password_hash, plus any allow-listed option fields.
106
124
  v_insert_data := jsonb_build_object('email', p_email, 'password_hash', v_hash);
107
- IF p_options IS NOT NULL THEN
108
- v_insert_data := (p_options - 'id' - 'email' - 'password_hash' - 'password') || v_insert_data;
109
- END IF;
125
+ ${optionsGuard}
110
126
 
111
127
  -- Dynamic INSERT from JSONB (same pattern as compiled save functions)
112
128
  EXECUTE (
@@ -1,4 +1,4 @@
1
- import { compilePermission } from "../../compiler/permissions.js";
1
+ import { compileRuleList } from "../../compiler/permissions.js";
2
2
  import { compileGraphRules } from "../../compiler/graph_rules.js";
3
3
  import type { EntityIR, ColumnInfo } from "./types.js";
4
4
 
@@ -33,9 +33,8 @@ export function generateDeleteFunction(name: string, entityIR: EntityIR): string
33
33
  : `jsonb_build_object(${pkFields.map(f => `'${f}', v_old_data->'${f}'`).join(', ')})`;
34
34
 
35
35
  // Permissions
36
- const deletePerm = entityIR.permissions?.delete?.[0]
37
- ? compilePermission(name, entityIR.permissions.delete[0], null, 'v_old_data')
38
- : 'TRUE';
36
+ // Every rule in a list grants access (OR); an empty list denies everyone.
37
+ const deletePerm = compileRuleList(name, entityIR.permissions?.delete, 'v_old_data');
39
38
 
40
39
  const onDeleteRules = entityIR.graphRules?.onDelete
41
40
  ? compileGraphRules(name, 'delete', entityIR.graphRules.onDelete)
@@ -117,9 +116,8 @@ function generateTemporalDeleteFunction(name: string, entityIR: EntityIR): strin
117
116
 
118
117
  const pkJsonbExpr = `jsonb_build_object('${pk}', v_old_data->'${pk}')`;
119
118
 
120
- const deletePerm = entityIR.permissions?.delete?.[0]
121
- ? compilePermission(name, entityIR.permissions.delete[0], null, 'v_old_data')
122
- : 'TRUE';
119
+ // Every rule in a list grants access (OR); an empty list denies everyone.
120
+ const deletePerm = compileRuleList(name, entityIR.permissions?.delete, 'v_old_data');
123
121
 
124
122
  const onDeleteRules = entityIR.graphRules?.onDelete
125
123
  ? compileGraphRules(name, 'delete', entityIR.graphRules.onDelete)
@@ -1,9 +1,9 @@
1
- import { compilePermission } from "../../compiler/permissions.js";
1
+ import { compileRuleList } from "../../compiler/permissions.js";
2
2
  import type { EntityIR, ManyToManyIR, IncludeIR, ColumnInfo } from "./types.js";
3
- import { buildVisibleJsonb } from "./utils.js";
3
+ import { buildVisibleJsonb, buildVisibleJsonbForEntity } from "./utils.js";
4
4
 
5
5
  // === GET FUNCTION ===
6
- export function generateGetFunction(name: string, entityIR: EntityIR): string {
6
+ export function generateGetFunction(name: string, entityIR: EntityIR, entities: Record<string, EntityIR> = {}): string {
7
7
  const pkFields = entityIR.primaryKey.length > 0 ? entityIR.primaryKey : ['id'];
8
8
  const pk = pkFields[0];
9
9
  const hidden = entityIR.hidden || [];
@@ -65,9 +65,8 @@ export function generateGetFunction(name: string, entityIR: EntityIR): string {
65
65
  }).join(' AND ');
66
66
  }
67
67
 
68
- const viewPerm = entityIR.permissions?.view?.length > 0
69
- ? entityIR.permissions.view.map((rule: string) => compilePermission(name, rule, null, name)).join(' OR ')
70
- : 'TRUE';
68
+ // An empty view list denies everyone.
69
+ const viewPerm = compileRuleList(name, entityIR.permissions?.view, name);
71
70
 
72
71
  const selectExpr = buildVisibleJsonb(name, entityIR.columns, hidden);
73
72
 
@@ -83,7 +82,7 @@ export function generateGetFunction(name: string, entityIR: EntityIR): string {
83
82
  -- FK: Add ${key} to result (from ${fkField})
84
83
  IF (v_result->>'${fkField}') IS NOT NULL THEN
85
84
  v_result := v_result || jsonb_build_object('${key}',
86
- (SELECT to_jsonb(t.*) FROM ${targetEntity} t WHERE t.id = (v_result->>'${fkField}')::int));
85
+ (SELECT ${buildVisibleJsonbForEntity('t', entities[targetEntity])} FROM ${targetEntity} t WHERE t.id = (v_result->>'${fkField}')::int));
87
86
  END IF;`;
88
87
  }
89
88
  return '';
@@ -102,7 +101,7 @@ export function generateGetFunction(name: string, entityIR: EntityIR): string {
102
101
  sql += `
103
102
  -- M2M: Add expanded ${key} to result
104
103
  v_result := v_result || jsonb_build_object('${key}',
105
- (SELECT COALESCE(jsonb_agg(to_jsonb(t.*) ORDER BY t.id), '[]'::jsonb)
104
+ (SELECT COALESCE(jsonb_agg(${buildVisibleJsonbForEntity('t', entities[config.targetEntity])} ORDER BY t.id), '[]'::jsonb)
106
105
  FROM ${config.junctionTable} jt
107
106
  JOIN ${config.targetEntity} t ON t.id = jt.${config.foreignKey}
108
107
  WHERE jt.${config.localKey} = (v_result->>'${pk}')::int));`;
@@ -145,9 +144,8 @@ export function generateHistoryFunction(name: string, entityIR: EntityIR): strin
145
144
  const refField = temporal.refField;
146
145
  const validFrom = temporal.validFrom;
147
146
 
148
- const viewPerm = entityIR.permissions?.view?.length > 0
149
- ? entityIR.permissions.view.map((rule: string) => compilePermission(name, rule, null, name)).join(' OR ')
150
- : 'TRUE';
147
+ // An empty view list denies everyone.
148
+ const viewPerm = compileRuleList(name, entityIR.permissions?.view, name);
151
149
 
152
150
  const selectExpr = buildVisibleJsonb(name, entityIR.columns, hidden);
153
151
 
@@ -30,16 +30,19 @@ import { generateSearchFunction } from "./search.js";
30
30
  import { generateLookupFunction } from "./lookup.js";
31
31
 
32
32
  // === AGGREGATE GENERATOR ===
33
- export function generateEntitySQL(name: string, entityIR: EntityIR): string {
33
+ export function generateEntitySQL(name: string, entityIR: EntityIR, entities: Record<string, EntityIR> = {}): string {
34
34
  // Import here to avoid circular dependency
35
35
  const { generateNotificationFunction } = require('../notification.js');
36
36
 
37
+ // entities lets FK/M2M expansion project related rows through the TARGET
38
+ // entity's visible columns instead of to_jsonb(t.*), so a related object
39
+ // never leaks the target's hidden fields (e.g. author.password_hash).
37
40
  const functions = [
38
41
  generateNotificationFunction(name, entityIR),
39
- generateSaveFunction(name, entityIR),
42
+ generateSaveFunction(name, entityIR, entities),
40
43
  generateDeleteFunction(name, entityIR),
41
- generateGetFunction(name, entityIR),
42
- generateSearchFunction(name, entityIR),
44
+ generateGetFunction(name, entityIR, entities),
45
+ generateSearchFunction(name, entityIR, entities),
43
46
  generateLookupFunction(name, entityIR)
44
47
  ];
45
48
 
@@ -1,4 +1,4 @@
1
- import { compilePermission } from "../../compiler/permissions.js";
1
+ import { compileRuleList } from "../../compiler/permissions.js";
2
2
  import type { EntityIR } from "./types.js";
3
3
 
4
4
  /**
@@ -13,10 +13,9 @@ export function generateLookupFunction(name: string, entityIR: EntityIR): string
13
13
  const softDelete = entityIR.softDelete || false;
14
14
  const temporal = entityIR.temporal;
15
15
 
16
- const viewPermRaw = entityIR.permissions?.view?.length > 0
17
- ? entityIR.permissions.view.map((rule: string) => compilePermission(name, rule, null, name)).join(' OR ')
18
- : 'TRUE';
19
- const viewPerm = viewPermRaw.replace(/p_user_id/g, '$1');
16
+ // The query reads FROM ${name} t, so the rule is compiled against alias t.
17
+ // An empty view list denies everyone.
18
+ const viewPerm = compileRuleList(name, entityIR.permissions?.view, { kind: 'row', name: 't' });
20
19
 
21
20
  const softDeleteFilter = softDelete ? ' AND deleted_at IS NULL' : '';
22
21
  const temporalFilter = temporal ? ` AND ${temporal.validTo} IS NULL` : '';
@@ -1,7 +1,7 @@
1
- import { compilePermission } from "../../compiler/permissions.js";
1
+ import { compileRuleList } from "../../compiler/permissions.js";
2
2
  import { compileGraphRules } from "../../compiler/graph_rules.js";
3
3
  import type { EntityIR, ManyToManyIR, IncludeIR, ColumnInfo } from "./types.js";
4
- import { getCastForType } from "./utils.js";
4
+ import { getCastForType, buildVisibleJsonbForEntity } from "./utils.js";
5
5
 
6
6
  /**
7
7
  * Generate M2M variable declarations
@@ -52,7 +52,7 @@ function generateM2MSync(m2m: Record<string, ManyToManyIR>, pk: string): string
52
52
  /**
53
53
  * Generate M2M expansion code (add to output)
54
54
  */
55
- function generateM2MExpansion(m2m: Record<string, ManyToManyIR>, pk: string): string {
55
+ function generateM2MExpansion(m2m: Record<string, ManyToManyIR>, pk: string, entities: Record<string, EntityIR> = {}): string {
56
56
  return Object.entries(m2m).map(([key, config]) => {
57
57
  let sql = `
58
58
  -- M2M: Add ${config.idField} to output
@@ -62,9 +62,9 @@ function generateM2MExpansion(m2m: Record<string, ManyToManyIR>, pk: string): st
62
62
 
63
63
  if (config.expand) {
64
64
  sql += `
65
- -- M2M: Add expanded ${key} to output
65
+ -- M2M: Add expanded ${key} to output (projected to the target's visible columns)
66
66
  v_result := v_result || jsonb_build_object('${key}',
67
- (SELECT COALESCE(jsonb_agg(to_jsonb(t.*) ORDER BY t.id), '[]'::jsonb)
67
+ (SELECT COALESCE(jsonb_agg(${buildVisibleJsonbForEntity('t', entities[config.targetEntity])} ORDER BY t.id), '[]'::jsonb)
68
68
  FROM ${config.junctionTable} jt
69
69
  JOIN ${config.targetEntity} t ON t.id = jt.${config.foreignKey}
70
70
  WHERE jt.${config.localKey} = (v_result->>'${pk}')::int));`;
@@ -76,7 +76,7 @@ function generateM2MExpansion(m2m: Record<string, ManyToManyIR>, pk: string): st
76
76
  /**
77
77
  * Generate FK expansion code (add related objects to output)
78
78
  */
79
- function generateFKExpansion(includes: Record<string, IncludeIR>, columns: ColumnInfo[]): string {
79
+ function generateFKExpansion(includes: Record<string, IncludeIR>, columns: ColumnInfo[], entities: Record<string, EntityIR> = {}): string {
80
80
  return Object.entries(includes).map(([key, config]) => {
81
81
  const targetEntity = config.entity;
82
82
  const fkField = `${key}_id`;
@@ -84,10 +84,10 @@ function generateFKExpansion(includes: Record<string, IncludeIR>, columns: Colum
84
84
 
85
85
  if (hasFkColumn) {
86
86
  return `
87
- -- FK: Add ${key} to output (from ${fkField})
87
+ -- FK: Add ${key} to output (from ${fkField}, projected to the target's visible columns)
88
88
  IF (v_result->>'${fkField}') IS NOT NULL THEN
89
89
  v_result := v_result || jsonb_build_object('${key}',
90
- (SELECT to_jsonb(t.*) FROM ${targetEntity} t WHERE t.id = (v_result->>'${fkField}')::int));
90
+ (SELECT ${buildVisibleJsonbForEntity('t', entities[targetEntity])} FROM ${targetEntity} t WHERE t.id = (v_result->>'${fkField}')::int));
91
91
  END IF;`;
92
92
  }
93
93
  return '';
@@ -95,7 +95,7 @@ function generateFKExpansion(includes: Record<string, IncludeIR>, columns: Colum
95
95
  }
96
96
 
97
97
  // === SAVE FUNCTION (Upsert) ===
98
- export function generateSaveFunction(name: string, entityIR: EntityIR): string {
98
+ export function generateSaveFunction(name: string, entityIR: EntityIR, entities: Record<string, EntityIR> = {}): string {
99
99
  const pkFields = entityIR.primaryKey.length > 0 ? entityIR.primaryKey : ['id'];
100
100
  const pk = pkFields[0];
101
101
  const fieldDefaults = entityIR.fieldDefaults || {};
@@ -105,7 +105,7 @@ export function generateSaveFunction(name: string, entityIR: EntityIR): string {
105
105
  // For temporal entities WITH refField, generate versioned save function
106
106
  // Temporal entities without refField just use standard save with validity filtering
107
107
  if (temporal?.refField) {
108
- return generateTemporalSaveFunction(name, entityIR);
108
+ return generateTemporalSaveFunction(name, entityIR, entities);
109
109
  }
110
110
 
111
111
  // Build INSERT columns/values - exclude serial columns
@@ -169,13 +169,24 @@ export function generateSaveFunction(name: string, entityIR: EntityIR): string {
169
169
  const pkNullCheck = pkFields.map(f => `(p_data->>'${f}') IS NOT NULL`).join(' AND ');
170
170
 
171
171
  // Permissions & Graph Rules
172
- const createPerm = entityIR.permissions?.create?.[0]
173
- ? compilePermission(name, entityIR.permissions.create[0], null, 'p_data')
174
- : 'TRUE';
175
-
176
- const updatePerm = entityIR.permissions?.update?.[0]
177
- ? compilePermission(name, entityIR.permissions.update[0], null, 'p_data')
178
- : 'TRUE';
172
+ // Every rule in a list grants access (OR); an empty list denies everyone.
173
+ const createPerm = compileRuleList(name, entityIR.permissions?.create, 'p_data');
174
+
175
+ // Compile the update predicate against the STORED row (v_old_data), not the
176
+ // request body. Evaluating it against p_data lets a caller pass their own
177
+ // owner/tenant id in the body to satisfy an ownership rule and then overwrite
178
+ // someone else's row. delete.ts and the temporal save path already do this.
179
+ const updatePerm = compileRuleList(name, entityIR.permissions?.update, 'v_old_data');
180
+
181
+ // ...and again against the row as it is after the update (v_result), so an
182
+ // editor can't move a row to a parent they don't control (e.g. change its
183
+ // occasion_id to an occasion they don't promote). The update rule, not the
184
+ // create rule, guards the new state: create rules are often broader
185
+ // (AUTHENTICATED) or empty (an entity only seeded, never created by users),
186
+ // and either would make the check wrong for an update. Checking after the
187
+ // UPDATE sees the real stored row (casts, defaults, triggers, and the table
188
+ // itself for rules that read it); the RAISE rolls the UPDATE back.
189
+ const updatePermNew = compileRuleList(name, entityIR.permissions?.update, 'v_result');
179
190
 
180
191
  const onCreateRules = entityIR.graphRules?.onCreate
181
192
  ? compileGraphRules(name, 'create', entityIR.graphRules.onCreate)
@@ -190,11 +201,11 @@ export function generateSaveFunction(name: string, entityIR: EntityIR): string {
190
201
  const m2mVarDeclarations = generateM2MDeclarations(m2m);
191
202
  const m2mExtraction = generateM2MExtraction(m2m);
192
203
  const m2mSync = generateM2MSync(m2m, pk);
193
- const m2mExpansion = generateM2MExpansion(m2m, pk);
204
+ const m2mExpansion = generateM2MExpansion(m2m, pk, entities);
194
205
 
195
206
  // FK expansion
196
207
  const includes: Record<string, IncludeIR> = entityIR.includes || {};
197
- const fkExpansion = generateFKExpansion(includes, entityIR.columns);
208
+ const fkExpansion = generateFKExpansion(includes, entityIR.columns, entities);
198
209
 
199
210
  return `
200
211
  CREATE OR REPLACE FUNCTION dzql_v2.save_${name}(p_user_id int, p_data jsonb)
@@ -225,11 +236,16 @@ ${m2mExtraction}
225
236
  RAISE EXCEPTION 'permission_denied';
226
237
  END IF;
227
238
 
228
- -- Perform Partial Update
239
+ ${updateSetClause ? `-- Perform Partial Update
229
240
  UPDATE ${name} SET
230
241
  ${updateSetClause}
231
242
  WHERE ${pkWhereClause}
232
- RETURNING to_jsonb(${name}.*) INTO v_result;
243
+ RETURNING to_jsonb(${name}.*) INTO v_result;` : `-- No non-key columns: nothing to update
244
+ v_result := v_old_data;`}
245
+
246
+ IF NOT (${updatePermNew}) THEN
247
+ RAISE EXCEPTION 'permission_denied';
248
+ END IF;
233
249
 
234
250
  ${onUpdateRules}
235
251
 
@@ -240,8 +256,8 @@ ${m2mExtraction}
240
256
  END IF;
241
257
 
242
258
  -- Perform Insert
243
- INSERT INTO ${name} (${colList})
244
- VALUES (${valList})
259
+ INSERT INTO ${name} ${colList ? `(${colList})
260
+ VALUES (${valList})` : 'DEFAULT VALUES'}
245
261
  RETURNING to_jsonb(${name}.*) INTO v_result;
246
262
 
247
263
  ${onCreateRules}
@@ -278,7 +294,7 @@ $$;
278
294
  }
279
295
 
280
296
  // === TEMPORAL SAVE FUNCTION (Versioned Insert) ===
281
- function generateTemporalSaveFunction(name: string, entityIR: EntityIR): string {
297
+ function generateTemporalSaveFunction(name: string, entityIR: EntityIR, entities: Record<string, EntityIR> = {}): string {
282
298
  const pkFields = entityIR.primaryKey.length > 0 ? entityIR.primaryKey : ['id'];
283
299
  const pk = pkFields[0];
284
300
  const fieldDefaults = entityIR.fieldDefaults || {};
@@ -344,13 +360,12 @@ function generateTemporalSaveFunction(name: string, entityIR: EntityIR): string
344
360
  const pkJsonbExpr = `jsonb_build_object('${pk}', v_result->'${pk}')`;
345
361
 
346
362
  // Permissions
347
- const createPerm = entityIR.permissions?.create?.[0]
348
- ? compilePermission(name, entityIR.permissions.create[0], null, 'p_data')
349
- : 'TRUE';
363
+ // Every rule in a list grants access (OR); an empty list denies everyone.
364
+ const createPerm = compileRuleList(name, entityIR.permissions?.create, 'p_data');
350
365
 
351
- const updatePerm = entityIR.permissions?.update?.[0]
352
- ? compilePermission(name, entityIR.permissions.update[0], null, 'v_current')
353
- : 'TRUE';
366
+ const updatePerm = compileRuleList(name, entityIR.permissions?.update, 'v_current');
367
+ // The new version must pass the update rule too (see generateSaveFunction).
368
+ const updatePermNew = compileRuleList(name, entityIR.permissions?.update, 'v_result');
354
369
 
355
370
  const onCreateRules = entityIR.graphRules?.onCreate
356
371
  ? compileGraphRules(name, 'create', entityIR.graphRules.onCreate)
@@ -365,20 +380,22 @@ function generateTemporalSaveFunction(name: string, entityIR: EntityIR): string
365
380
  const m2mVarDeclarations = generateM2MDeclarations(m2m);
366
381
  const m2mExtraction = generateM2MExtraction(m2m);
367
382
  const m2mSync = generateM2MSync(m2m, pk);
368
- const m2mExpansion = generateM2MExpansion(m2m, pk);
383
+ const m2mExpansion = generateM2MExpansion(m2m, pk, entities);
369
384
 
370
385
  // FK expansion
371
386
  const includes: Record<string, IncludeIR> = entityIR.includes || {};
372
- const fkExpansion = generateFKExpansion(includes, entityIR.columns);
387
+ const fkExpansion = generateFKExpansion(includes, entityIR.columns, entities);
373
388
 
374
- // Non-temporal columns for INSERT
375
- const nonTemporalCols = entityIR.columns
389
+ // Columns and values for the new version's INSERT (copyForwardFields may be
390
+ // empty when the entity has no columns beyond its key and temporal fields)
391
+ const versionCols = [refField, validFrom, ...entityIR.columns
376
392
  .filter((c: ColumnInfo) => {
377
393
  const isSerial = c.type.toLowerCase().includes('serial');
378
394
  const isTemporal = c.name === validFrom || c.name === validTo;
379
395
  return !isSerial && !isTemporal && c.name !== refField;
380
396
  })
381
- .map(c => c.name).join(', ');
397
+ .map(c => c.name)].join(', ');
398
+ const versionValues = ['v_ref', 'now()', ...(copyForwardFields ? [copyForwardFields] : [])].join(',\n ');
382
399
 
383
400
  return `
384
401
  CREATE OR REPLACE FUNCTION dzql_v2.save_${name}(p_user_id int, p_data jsonb)
@@ -424,13 +441,15 @@ ${m2mExtraction}
424
441
  WHERE ${refField} = v_ref AND ${validTo} IS NULL;
425
442
 
426
443
  -- Insert new version with copy-forward of unchanged fields
427
- INSERT INTO ${name} (${refField}, ${validFrom}, ${nonTemporalCols})
444
+ INSERT INTO ${name} (${versionCols})
428
445
  SELECT
429
- v_ref,
430
- now(),
431
- ${copyForwardFields}
446
+ ${versionValues}
432
447
  RETURNING to_jsonb(${name}.*) INTO v_result;
433
448
 
449
+ IF NOT (${updatePermNew}) THEN
450
+ RAISE EXCEPTION 'permission_denied';
451
+ END IF;
452
+
434
453
  ${onUpdateRules}
435
454
 
436
455
  ELSE