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.
- package/README.md +16 -0
- package/docs/README.md +1 -1
- package/docs/for_ai.md +39 -9
- package/package.json +12 -5
- package/src/cli/codegen/notification.ts +3 -3
- package/src/cli/codegen/sql/core.ts +21 -5
- package/src/cli/codegen/sql/delete.ts +5 -7
- package/src/cli/codegen/sql/get.ts +9 -11
- package/src/cli/codegen/sql/index.ts +7 -4
- package/src/cli/codegen/sql/lookup.ts +4 -5
- package/src/cli/codegen/sql/save.ts +58 -39
- package/src/cli/codegen/sql/search.ts +44 -10
- package/src/cli/codegen/sql/utils.ts +16 -1
- package/src/cli/codegen/subscribable_sql.ts +116 -124
- package/src/cli/compile.ts +263 -0
- package/src/cli/compiler/path-validator.ts +115 -83
- package/src/cli/compiler/paths.ts +299 -379
- package/src/cli/compiler/permissions.ts +26 -26
- package/src/cli/index.ts +17 -219
- package/src/client/ws.ts +20 -10
- package/src/postgres/auth-jwt-sql.ts +32 -0
- package/src/postgres/auth-jwt.ts +209 -0
- package/src/postgres/auth.ts +50 -0
- package/src/postgres/bootstrap.ts +55 -0
- package/src/postgres/carry.ts +40 -0
- package/src/postgres/codegen.ts +172 -0
- package/src/postgres/index.ts +58 -0
- package/src/postgres/listener.ts +706 -0
- package/src/postgres/migrate.ts +143 -0
- package/src/postgres/pglite.ts +142 -0
- package/src/postgres/registry.ts +253 -0
- package/src/postgres/schema.ts +171 -0
- package/src/postgres/sql.ts +42 -0
- package/src/runtime/auth.ts +33 -2
- package/src/runtime/index.ts +47 -6
- package/src/runtime/manifest_loader.ts +10 -0
- package/src/runtime/projection.ts +19 -0
- package/src/sql/001a-delta-meta.sql +204 -0
- package/src/sql/001b-delta-scope.sql +619 -0
- package/src/sql/001c-delta-read.sql +506 -0
- package/src/sql/001d-delta-write.sql +1088 -0
- package/src/sql/001e-delta-ops.sql +142 -0
- package/src/sql/001f-delta-as.sql +41 -0
- package/src/sql/001g-delta-ledger.sql +564 -0
- 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
package/docs/for_ai.md
CHANGED
|
@@ -44,12 +44,19 @@ temporal: {
|
|
|
44
44
|
|
|
45
45
|
PERMISSION DSL
|
|
46
46
|
==============
|
|
47
|
-
[]
|
|
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: [] //
|
|
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
|
|
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
|
-
//
|
|
325
|
-
//
|
|
326
|
-
|
|
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
|
|
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.
|
|
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
|
-
"
|
|
51
|
+
"@blueshed/delta": "^0.16.0",
|
|
52
|
+
"@electric-sql/pglite": "^0.5.4",
|
|
49
53
|
"dotenv": "^16.4.5",
|
|
50
|
-
"jose": "^
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
121
|
-
|
|
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 {
|
|
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
|
-
|
|
69
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
149
|
-
|
|
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 {
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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 {
|
|
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(
|
|
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
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
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
|
-
|
|
348
|
-
|
|
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
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
//
|
|
375
|
-
|
|
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} (${
|
|
444
|
+
INSERT INTO ${name} (${versionCols})
|
|
428
445
|
SELECT
|
|
429
|
-
|
|
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
|