@everystack/model 0.4.9 → 0.4.11
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 +13 -1
- package/package.json +1 -1
- package/src/can.ts +58 -5
- package/src/drizzle.ts +8 -0
- package/src/field.ts +28 -2
- package/src/index.ts +5 -3
- package/src/model.ts +138 -4
- package/src/pg-type.ts +97 -0
package/README.md
CHANGED
|
@@ -96,7 +96,19 @@ The table-level surface, composed in `constraints: [...]`, for what a column-sco
|
|
|
96
96
|
- `{ where: 'deleted_at IS NULL' }` — a column/scalar predicate, compiled to the policy's `USING` clause.
|
|
97
97
|
- `{ sql: … }` — a verbatim RLS predicate escape hatch.
|
|
98
98
|
|
|
99
|
-
These compile to RLS policies and grants (via `@everystack/cli`'s `compileTableContract`, what `db:generate` emits). `can('manage', …)` covers all actions
|
|
99
|
+
These compile to RLS policies and grants (via `@everystack/cli`'s `compileTableContract`, what `db:generate` emits). `can('manage', …)` covers all four DML actions: `SELECT`, `INSERT`, `UPDATE`, `DELETE`.
|
|
100
|
+
|
|
101
|
+
Exactly those four, and no more. A verb that expanded to "every privilege" would hand out `TRUNCATE`, which no `DELETE` policy filters. The other table privileges are named one at a time:
|
|
102
|
+
|
|
103
|
+
```typescript
|
|
104
|
+
defineModel('posts', {
|
|
105
|
+
fields: { … },
|
|
106
|
+
abilities: [can('read'), can('manage', { role: 'admin' })],
|
|
107
|
+
privileges: { admin: ['REFERENCES', 'TRIGGER', 'TRUNCATE'] },
|
|
108
|
+
});
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`privileges` takes only those three, and it is additive — it widens what a role holds and can never narrow it. DML stays in `can()`, where the grant and the row policy are decided together. `db:pull --abilities live` writes this key from the live grants, so an existing database that grants its admin role all three keeps them.
|
|
100
112
|
|
|
101
113
|
## Relations
|
|
102
114
|
|
package/package.json
CHANGED
package/src/can.ts
CHANGED
|
@@ -15,6 +15,26 @@ export type Action = 'read' | 'create' | 'update' | 'delete' | 'manage' | 'execu
|
|
|
15
15
|
export interface AbilityCondition {
|
|
16
16
|
/** Role-gated: policy `TO <role>` + grant. */
|
|
17
17
|
role?: string;
|
|
18
|
+
/**
|
|
19
|
+
* The policy's AUDIENCE, decoupled from its grantees. Exactly one value: `'public'`.
|
|
20
|
+
*
|
|
21
|
+
* A bare `can('read')` means two things at once — *who holds the privilege* (a GRANT to
|
|
22
|
+
* anon and to authenticated) and *who the policy names* (`TO anon`, `TO authenticated`).
|
|
23
|
+
* One knob, two axes. A brownfield schema that wrote the ordinary Postgres idiom — a
|
|
24
|
+
* single policy `TO PUBLIC` — could not be expressed at all, so the plan proposed dropping
|
|
25
|
+
* it and creating two role-scoped ones. PUBLIC is an OPEN set and `{anon, authenticated}`
|
|
26
|
+
* is not, so that is a real narrowing: any other role loses the read.
|
|
27
|
+
*
|
|
28
|
+
* `policyRoles: 'public'` emits ONE policy `TO PUBLIC` and leaves the GRANTS exactly as
|
|
29
|
+
* they are derived today. **It can never add a grantee** — an open audience over a closed
|
|
30
|
+
* grant set widens nothing, because Postgres checks the GRANT first.
|
|
31
|
+
*
|
|
32
|
+
* Only `'public'` is admitted, deliberately. A free-form role list here would be a second
|
|
33
|
+
* way to spell the `role:` axis and could name a role no grant backs, which reads as access
|
|
34
|
+
* that does not exist. With the domain restricted to one open set, `policyRoles ⊇ grantees`
|
|
35
|
+
* holds structurally and there is no inverse case to guard.
|
|
36
|
+
*/
|
|
37
|
+
policyRoles?: 'public';
|
|
18
38
|
/** Row owner only: the column holding the owner id (`USING (<col> = auth.uid())`). */
|
|
19
39
|
owner?: string;
|
|
20
40
|
/**
|
|
@@ -49,11 +69,12 @@ export interface AbilityCondition {
|
|
|
49
69
|
*/
|
|
50
70
|
ownerExpr?: unknown;
|
|
51
71
|
/**
|
|
52
|
-
* Column-scoped exposure for a `read` ability. Names the field keys a role may
|
|
53
|
-
* (compiled to
|
|
54
|
-
* whole row — e.g. `can('read', { owner: 'id', columns: ['id', 'email', 'role'] })` on
|
|
55
|
-
* `auth.users` exposes id/email/role to the owner, never the password hash.
|
|
56
|
-
* `owner
|
|
72
|
+
* Column-scoped exposure for a `read` or `update` ability. Names the field keys a role may
|
|
73
|
+
* read or update (compiled to `GRANT SELECT|UPDATE (cols)`, never a table grant) instead of
|
|
74
|
+
* the whole row — e.g. `can('read', { owner: 'id', columns: ['id', 'email', 'role'] })` on
|
|
75
|
+
* `auth.users` exposes id/email/role to the owner, never the password hash. Every column
|
|
76
|
+
* ability must name a `role`, an `owner`, or both: the policy scopes rows and the columns
|
|
77
|
+
* scope fields.
|
|
57
78
|
*/
|
|
58
79
|
columns?: string[];
|
|
59
80
|
/** Scalar/column predicate — a sql-tagged expression compiled to an RLS USING clause. */
|
|
@@ -85,6 +106,38 @@ export interface Ability {
|
|
|
85
106
|
condition: AbilityCondition;
|
|
86
107
|
}
|
|
87
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Is this an ability whose privilege is scoped to listed columns rather than the
|
|
111
|
+
* whole table? `defineModel` rejects every malformed near-miss before a descriptor
|
|
112
|
+
* reaches a compiler, so consumers can use this as the one classification rule.
|
|
113
|
+
*/
|
|
114
|
+
export function isColumnAbility(ability: Ability): boolean {
|
|
115
|
+
return (ability.action === 'read' || ability.action === 'update')
|
|
116
|
+
&& Array.isArray(ability.condition.columns)
|
|
117
|
+
&& ability.condition.columns.length > 0
|
|
118
|
+
&& Boolean(ability.condition.role || ability.condition.owner);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Is this ability a PUBLIC read — a read path open to a caller with no session?
|
|
123
|
+
*
|
|
124
|
+
* The visibility question the soft-delete guard turns on: "what can someone with no session
|
|
125
|
+
* see?". Both a role-less read and `role: 'anon'` answer it the same way, because `anon` IS
|
|
126
|
+
* the anonymous role; an `owner` or `via` read answers it differently, because those scope
|
|
127
|
+
* rows to a session that exists.
|
|
128
|
+
*
|
|
129
|
+
* ONE definition, because there were two. `defineModel`'s refusal fired only on a role-less
|
|
130
|
+
* read while `db:pull`'s renderer already counted `role: 'anon'` — so a table whose
|
|
131
|
+
* authenticated read diverges (which pull renders as an anon-scoped read) slipped past the
|
|
132
|
+
* refusal while still being rendered as public. Two surfaces, two answers, one question.
|
|
133
|
+
*/
|
|
134
|
+
export function isPublicReadAbility(ability: Ability): boolean {
|
|
135
|
+
return ability.action === 'read'
|
|
136
|
+
&& !ability.condition.owner
|
|
137
|
+
&& !ability.condition.via
|
|
138
|
+
&& (!ability.condition.role || ability.condition.role === 'anon');
|
|
139
|
+
}
|
|
140
|
+
|
|
88
141
|
/** Declare one authorization rule. */
|
|
89
142
|
export function can(action: Action, condition: AbilityCondition = {}): Ability {
|
|
90
143
|
return { action, condition };
|
package/src/drizzle.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
pgTable,
|
|
19
19
|
pgSchema,
|
|
20
20
|
pgEnum,
|
|
21
|
+
customType,
|
|
21
22
|
text,
|
|
22
23
|
uuid,
|
|
23
24
|
integer,
|
|
@@ -106,6 +107,13 @@ function baseColumn(columnName: string, spec: FieldSpec): PgColumnBuilderBase {
|
|
|
106
107
|
return json(columnName);
|
|
107
108
|
case 'enum':
|
|
108
109
|
return getEnum(spec)(columnName);
|
|
110
|
+
case 'pgType': {
|
|
111
|
+
// A verbatim type: drizzle carries the exact string as the column's SQL
|
|
112
|
+
// type and the value stays `unknown` — no client-side typing is promised.
|
|
113
|
+
const typeName = spec.pgTypeName;
|
|
114
|
+
if (!typeName) throw new Error('pgType field is missing pgTypeName');
|
|
115
|
+
return customType<{ data: unknown }>({ dataType: () => typeName })(columnName);
|
|
116
|
+
}
|
|
109
117
|
default: {
|
|
110
118
|
const exhaustive: never = spec.type;
|
|
111
119
|
throw new Error(`Unsupported field type: ${String(exhaustive)}`);
|
package/src/field.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import type { RawSql } from './raw-sql';
|
|
15
|
+
import { pgTypeRefusal } from './pg-type';
|
|
15
16
|
|
|
16
17
|
export type FieldType =
|
|
17
18
|
| 'text'
|
|
@@ -30,7 +31,8 @@ export type FieldType =
|
|
|
30
31
|
| 'varchar'
|
|
31
32
|
| 'json'
|
|
32
33
|
| 'jsonb'
|
|
33
|
-
| 'enum'
|
|
34
|
+
| 'enum'
|
|
35
|
+
| 'pgType';
|
|
34
36
|
|
|
35
37
|
export type DefaultKind = 'value' | 'now' | 'random' | 'sql';
|
|
36
38
|
|
|
@@ -49,6 +51,14 @@ export interface FieldSpec {
|
|
|
49
51
|
scale?: number;
|
|
50
52
|
/** `varchar(length)` — maximum character length. */
|
|
51
53
|
length?: number;
|
|
54
|
+
/**
|
|
55
|
+
* The verbatim Postgres type of a `pgType` field — the exact canonical
|
|
56
|
+
* `format_type` spelling (`hstore`, `geometry(Geometry,4326)`, `tsvector`).
|
|
57
|
+
* The string IS the type's identity: introspection, the differ, and the
|
|
58
|
+
* fingerprint all compare it byte-for-byte, so a pulled column round-trips
|
|
59
|
+
* with no coercion and no drift.
|
|
60
|
+
*/
|
|
61
|
+
pgTypeName?: string;
|
|
52
62
|
isPrimaryKey: boolean;
|
|
53
63
|
isNotNull: boolean;
|
|
54
64
|
isUnique: boolean;
|
|
@@ -94,7 +104,7 @@ export interface FieldSpec {
|
|
|
94
104
|
export class FieldBuilder {
|
|
95
105
|
readonly spec: FieldSpec;
|
|
96
106
|
|
|
97
|
-
constructor(type: FieldType, extra?: Pick<FieldSpec, 'enumName' | 'enumValues' | 'precision' | 'scale' | 'length'>) {
|
|
107
|
+
constructor(type: FieldType, extra?: Pick<FieldSpec, 'enumName' | 'enumValues' | 'precision' | 'scale' | 'length' | 'pgTypeName'>) {
|
|
98
108
|
this.spec = {
|
|
99
109
|
type,
|
|
100
110
|
enumName: extra?.enumName,
|
|
@@ -102,6 +112,7 @@ export class FieldBuilder {
|
|
|
102
112
|
precision: extra?.precision,
|
|
103
113
|
scale: extra?.scale,
|
|
104
114
|
length: extra?.length,
|
|
115
|
+
pgTypeName: extra?.pgTypeName,
|
|
105
116
|
isPrimaryKey: false,
|
|
106
117
|
isNotNull: false,
|
|
107
118
|
isUnique: false,
|
|
@@ -252,4 +263,19 @@ export const field = {
|
|
|
252
263
|
json: (): FieldBuilder => new FieldBuilder('json'),
|
|
253
264
|
/** A Postgres enum. The `name` is the `CREATE TYPE` name and is always explicit, so `db:pull` round-trips it. */
|
|
254
265
|
enum: (name: string, values: readonly string[]): FieldBuilder => new FieldBuilder('enum', { enumName: name, enumValues: values }),
|
|
266
|
+
/**
|
|
267
|
+
* A verbatim Postgres type the vocabulary has no first-class field for —
|
|
268
|
+
* `field.pgType('hstore')`, `field.pgType('geometry(Geometry,4326)')`,
|
|
269
|
+
* `field.pgType('tsvector')`. The exact string is the column's type, end to
|
|
270
|
+
* end: DDL emits it verbatim, introspection reports it verbatim, and the
|
|
271
|
+
* differ/fingerprint compare it byte-for-byte — so an extension-typed column
|
|
272
|
+
* round-trips with no coercion. The spelling must be canonical and must not
|
|
273
|
+
* shadow a first-class field (one type, one spelling): non-canonical or
|
|
274
|
+
* first-class spellings are refused with the correct call named.
|
|
275
|
+
*/
|
|
276
|
+
pgType: (typeName: string): FieldBuilder => {
|
|
277
|
+
const refusal = pgTypeRefusal(typeName);
|
|
278
|
+
if (refusal) throw new Error(refusal);
|
|
279
|
+
return new FieldBuilder('pgType', { pgTypeName: typeName });
|
|
280
|
+
},
|
|
255
281
|
} as const;
|
package/src/index.ts
CHANGED
|
@@ -10,20 +10,22 @@
|
|
|
10
10
|
export { field, FieldBuilder } from './field';
|
|
11
11
|
export type { FieldSpec, FieldType, DefaultKind, ReferentialAction } from './field';
|
|
12
12
|
|
|
13
|
+
export { canonicalColumnType, fieldFactoryCall, pgTypeRefusal } from './pg-type';
|
|
14
|
+
|
|
13
15
|
export { unique, check, index, foreignKey, IndexBuilder } from './constraints';
|
|
14
16
|
export type { TableConstraint } from './constraints';
|
|
15
17
|
|
|
16
18
|
export { sql } from './raw-sql';
|
|
17
19
|
export type { RawSql } from './raw-sql';
|
|
18
20
|
|
|
19
|
-
export { can } from './can';
|
|
21
|
+
export { can, isColumnAbility, isPublicReadAbility } from './can';
|
|
20
22
|
export type { Ability, AbilityCondition, Action } from './can';
|
|
21
23
|
|
|
22
24
|
export { belongsTo, hasMany } from './relations';
|
|
23
25
|
export type { Relation } from './relations';
|
|
24
26
|
|
|
25
|
-
export { defineModel, defineMaterializedTable } from './model';
|
|
26
|
-
export type { ModelDescriptor, ModelDefinition, ModelHooks, WrittenBy, MaterializedTableMarker, MaterializedTableDefinition } from './model';
|
|
27
|
+
export { defineModel, defineMaterializedTable, EXTRA_PRIVILEGES } from './model';
|
|
28
|
+
export type { ModelDescriptor, ModelDefinition, ModelHooks, WrittenBy, ExtraPrivilege, MaterializedTableMarker, MaterializedTableDefinition } from './model';
|
|
27
29
|
|
|
28
30
|
export { defineModule, moduleModels, moduleExtensions, moduleFunctionSql, moduleSequences, moduleDerived, moduleGovernedRoles } from './module';
|
|
29
31
|
export type { Module, ModuleDefinition } from './module';
|
package/src/model.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import type { FieldBuilder } from './field';
|
|
15
15
|
import type { Ability } from './can';
|
|
16
|
+
import { isPublicReadAbility } from './can';
|
|
16
17
|
import type { Relation } from './relations';
|
|
17
18
|
import type { TableConstraint } from './constraints';
|
|
18
19
|
import type { RawSql } from './raw-sql';
|
|
@@ -50,6 +51,23 @@ export interface ModelHooks {
|
|
|
50
51
|
*/
|
|
51
52
|
export type WrittenBy = 'app' | 'worker' | 'functions';
|
|
52
53
|
|
|
54
|
+
/**
|
|
55
|
+
* A table privilege that is NOT one of the four CRUD verbs — the ones `can()` has no word for.
|
|
56
|
+
*
|
|
57
|
+
* `can('manage')` means exactly INSERT/SELECT/UPDATE/DELETE, and that is deliberate: a verb
|
|
58
|
+
* that quietly expanded to "every privilege" would hand out TRUNCATE, which is not a DELETE
|
|
59
|
+
* and is not filtered by a DELETE policy. So these three are named one at a time, by the
|
|
60
|
+
* author, in {@link ModelDefinition.privileges}.
|
|
61
|
+
*
|
|
62
|
+
* Why the vocabulary stops here: a real database routinely grants its admin role REFERENCES,
|
|
63
|
+
* TRIGGER and TRUNCATE, and everystack had no way to SAY that — so the first plan against an
|
|
64
|
+
* existing schema revoked all three on every table. That is our spelling, not their bug.
|
|
65
|
+
*/
|
|
66
|
+
export type ExtraPrivilege = 'REFERENCES' | 'TRIGGER' | 'TRUNCATE';
|
|
67
|
+
|
|
68
|
+
/** The declarable privilege set — the one definition; the CLI's classifier reads it too. */
|
|
69
|
+
export const EXTRA_PRIVILEGES: readonly ExtraPrivilege[] = ['REFERENCES', 'TRIGGER', 'TRUNCATE'];
|
|
70
|
+
|
|
53
71
|
export interface ModelDefinition {
|
|
54
72
|
fields: Record<string, FieldBuilder>;
|
|
55
73
|
abilities?: Ability[];
|
|
@@ -59,6 +77,21 @@ export interface ModelDefinition {
|
|
|
59
77
|
constraints?: TableConstraint[];
|
|
60
78
|
/** The write principal — drives RLS posture. Default `'app'`. See {@link WrittenBy}. */
|
|
61
79
|
writtenBy?: WrittenBy;
|
|
80
|
+
/**
|
|
81
|
+
* Privileges a role holds on this table BEYOND the CRUD an ability compiles to —
|
|
82
|
+
* `privileges: { admin: ['REFERENCES', 'TRIGGER', 'TRUNCATE'] }`.
|
|
83
|
+
*
|
|
84
|
+
* Only {@link ExtraPrivilege} values are accepted. The four DML privileges are NOT
|
|
85
|
+
* declarable here on purpose: `SELECT`/`INSERT`/`UPDATE`/`DELETE` come from `can()`, which
|
|
86
|
+
* decides the grant and the RLS policy together. A grant declared without its policy is a
|
|
87
|
+
* privilege with no row gate — the exact shape `db:pull` refuses to render.
|
|
88
|
+
*
|
|
89
|
+
* ADDITIVE, never subtractive: these are unioned with what the abilities grant, so this key
|
|
90
|
+
* can widen a role's privileges and can never narrow them. Naming a role here GOVERNS it —
|
|
91
|
+
* same rule as `can(…, { role })` — so a role that appears only in this map has its other
|
|
92
|
+
* privileges reconciled everywhere, not just on this table.
|
|
93
|
+
*/
|
|
94
|
+
privileges?: Record<string, ExtraPrivilege[]>;
|
|
62
95
|
/**
|
|
63
96
|
* `true` excludes soft-deleted rows (`deleted_at IS NULL`) from the public read policies
|
|
64
97
|
* and from the generic data API. **Required — not inferred — on any model that has a
|
|
@@ -132,6 +165,8 @@ export interface ModelDescriptor {
|
|
|
132
165
|
constraints: TableConstraint[];
|
|
133
166
|
/** The resolved write principal (default `'app'`) — drives RLS posture. See {@link WrittenBy}. */
|
|
134
167
|
writtenBy: WrittenBy;
|
|
168
|
+
/** Beyond-CRUD privileges per role, unioned with what the abilities grant. See {@link ModelDefinition.privileges}. */
|
|
169
|
+
privileges: Record<string, ExtraPrivilege[]>;
|
|
135
170
|
/** `true` excludes `deleted_at IS NULL` rows from public reads and the data API. Declared, never inferred. */
|
|
136
171
|
softDelete: boolean;
|
|
137
172
|
/** The resolved Postgres schema (default `'public'`). Package tables live in their own. */
|
|
@@ -173,6 +208,72 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
|
|
|
173
208
|
if (a.action === 'execute') {
|
|
174
209
|
throw new Error(`model '${table}': can('execute') is a function ability — declare it on the defineFunction it gates.`);
|
|
175
210
|
}
|
|
211
|
+
|
|
212
|
+
// A column list is authorization, not a selection hint. If it reaches a table-grant path
|
|
213
|
+
// unnoticed, Postgres receives a whole-table privilege. Admit only the shapes the column
|
|
214
|
+
// compiler understands, and reject an empty list because it is indistinguishable from no
|
|
215
|
+
// column scope at all.
|
|
216
|
+
if (a.condition.columns !== undefined) {
|
|
217
|
+
if (a.action !== 'read' && a.action !== 'update') {
|
|
218
|
+
throw new Error(
|
|
219
|
+
`model '${table}': columns apply only to can('read') or can('update'), not can('${a.action}').`,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
if (!Array.isArray(a.condition.columns) || a.condition.columns.length === 0) {
|
|
223
|
+
throw new Error(
|
|
224
|
+
`model '${table}': columns must be a non-empty array on can('${a.action}'); an empty list would erase the column scope.`,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
if (!a.condition.role && !a.condition.owner) {
|
|
228
|
+
throw new Error(
|
|
229
|
+
`model '${table}': columns require role and/or owner on can('${a.action}'); otherwise the intended column grant has no declared grantee or row gate.`,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
if (a.condition.policyRoles !== undefined) {
|
|
233
|
+
throw new Error(
|
|
234
|
+
`model '${table}': columns cannot use policyRoles. A column ability's policy is TO its declared role (or authenticated for owner-only), never an open audience.`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
if (a.condition.owner && !(a.condition.owner in def.fields)) {
|
|
238
|
+
throw new Error(
|
|
239
|
+
`model '${table}': columns names owner '${a.condition.owner}', but no such field exists.`,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
for (const column of a.condition.columns) {
|
|
243
|
+
if (!(column in def.fields)) {
|
|
244
|
+
throw new Error(
|
|
245
|
+
`model '${table}': columns names '${column}', but no such field exists. Column abilities use model field keys.`,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// `policyRoles` decouples the policy's AUDIENCE from its GRANTEES, and it is deliberately
|
|
252
|
+
// amputated to one open set. Anything else would be a second spelling of the `role:` axis
|
|
253
|
+
// and could name a role no grant backs — a policy that reads as access which does not
|
|
254
|
+
// exist. A key that cannot say NO is not a fix, so it says NO here, at authoring time.
|
|
255
|
+
if (a.condition.policyRoles !== undefined) {
|
|
256
|
+
if (a.condition.policyRoles !== 'public') {
|
|
257
|
+
throw new Error(
|
|
258
|
+
`model '${table}': policyRoles admits exactly one value, 'public' — got ` +
|
|
259
|
+
`${JSON.stringify(a.condition.policyRoles)}. It exists to express a single policy TO PUBLIC, ` +
|
|
260
|
+
`the open audience a brownfield schema already wrote. To gate on a named role use role: '<name>', ` +
|
|
261
|
+
`which sets the grant too.`,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
if (a.action !== 'read') {
|
|
265
|
+
throw new Error(
|
|
266
|
+
`model '${table}': policyRoles is a READ audience — can('${a.action}', { policyRoles: 'public' }) would ` +
|
|
267
|
+
`open a write policy to every role. Declare writes with role:/owner:.`,
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
if (a.condition.role) {
|
|
271
|
+
throw new Error(
|
|
272
|
+
`model '${table}': policyRoles: 'public' and role: '${a.condition.role}' both name the policy's audience. ` +
|
|
273
|
+
`Declare one. policyRoles states the audience and leaves the grants alone; role states both.`,
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
176
277
|
}
|
|
177
278
|
|
|
178
279
|
// An array field's value default must be a JS array — `.default([])`, not the
|
|
@@ -196,10 +297,19 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
|
|
|
196
297
|
// so there is no default: on the exact set of models where the answer changes behaviour (a
|
|
197
298
|
// `deletedAt` field plus a public read), it must be declared. `softDelete: true` is what the
|
|
198
299
|
// inference used to do.
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
300
|
+
// `isPublicReadAbility`, not a local copy: this refusal and db:pull's renderer were asking
|
|
301
|
+
// the same question — "can a caller with no session see these rows?" — and answering it
|
|
302
|
+
// differently. The renderer already counted `role: 'anon'`; this refusal did not, so a table
|
|
303
|
+
// whose authenticated read diverges (rendered as an anon-scoped read) slipped past the very
|
|
304
|
+
// decision the refusal exists to force. It now fires on the anonymous audience however it is
|
|
305
|
+
// spelled. Explicit `softDelete: false` remains the one-line answer, and pull writes that
|
|
306
|
+
// line into every model it renders, so a pulled checkout keeps loading.
|
|
307
|
+
const hasPublicRead = (def.abilities ?? []).some(isPublicReadAbility);
|
|
308
|
+
// Both spellings of the field key. The guard used to test `'deletedAt' in def.fields`
|
|
309
|
+
// literally, so a model keying the column `deleted_at` — the shape a snake_case brownfield
|
|
310
|
+
// schema pulls as — carried a soft-delete column past a guard that never looked at it.
|
|
311
|
+
const softDeleteField = 'deletedAt' in def.fields || 'deleted_at' in def.fields;
|
|
312
|
+
if (softDeleteField && hasPublicRead && def.softDelete === undefined) {
|
|
203
313
|
throw new Error(
|
|
204
314
|
`model '${table}': has a 'deletedAt' field and a public read, so it must declare softDelete. `
|
|
205
315
|
+ `softDelete: true excludes soft-deleted rows from the public read policies (what the compiler used to infer from the field's presence); `
|
|
@@ -207,6 +317,26 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
|
|
|
207
317
|
);
|
|
208
318
|
}
|
|
209
319
|
|
|
320
|
+
// A privilege outside the beyond-CRUD set is refused rather than passed through. The DML
|
|
321
|
+
// four are the dangerous ones: declared here they would compile to a GRANT with no policy
|
|
322
|
+
// beside it, which is a privilege nobody row-gated. They come from can(), where the grant
|
|
323
|
+
// and the policy are decided in one place.
|
|
324
|
+
for (const [role, privs] of Object.entries(def.privileges ?? {})) {
|
|
325
|
+
if (!Array.isArray(privs)) {
|
|
326
|
+
throw new Error(`model '${table}': privileges['${role}'] must be an array of ${EXTRA_PRIVILEGES.join(' | ')}.`);
|
|
327
|
+
}
|
|
328
|
+
for (const p of privs) {
|
|
329
|
+
if (EXTRA_PRIVILEGES.includes(p)) continue;
|
|
330
|
+
const dml = ['SELECT', 'INSERT', 'UPDATE', 'DELETE'].includes(String(p).toUpperCase());
|
|
331
|
+
throw new Error(
|
|
332
|
+
`model '${table}': privileges['${role}'] contains '${p}', which is not one of ${EXTRA_PRIVILEGES.join(', ')}. `
|
|
333
|
+
+ (dml
|
|
334
|
+
? `Declare ${String(p).toUpperCase()} with can() — that is what decides the row policy beside the grant. A grant declared here would have no policy.`
|
|
335
|
+
: `This key names only the table privileges can() has no verb for.`),
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
210
340
|
validateTriggersFor('table', table, def.triggers ?? []);
|
|
211
341
|
|
|
212
342
|
// A rename and a move in one increment can't be composed from two independent markers
|
|
@@ -230,6 +360,7 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
|
|
|
230
360
|
primaryKey,
|
|
231
361
|
constraints: def.constraints ?? [],
|
|
232
362
|
writtenBy: def.writtenBy ?? 'app',
|
|
363
|
+
privileges: def.privileges ?? {},
|
|
233
364
|
softDelete: def.softDelete ?? false,
|
|
234
365
|
schema: def.schema ?? 'public',
|
|
235
366
|
private: def.private ?? false,
|
|
@@ -266,6 +397,8 @@ export interface MaterializedTableDefinition {
|
|
|
266
397
|
writtenBy?: WrittenBy;
|
|
267
398
|
private?: boolean;
|
|
268
399
|
renamedFrom?: string;
|
|
400
|
+
/** Beyond-CRUD privileges per role. See {@link ModelDefinition.privileges}. */
|
|
401
|
+
privileges?: Record<string, ExtraPrivilege[]>;
|
|
269
402
|
}
|
|
270
403
|
|
|
271
404
|
export function defineMaterializedTable(table: string, def: MaterializedTableDefinition): ModelDescriptor {
|
|
@@ -282,6 +415,7 @@ export function defineMaterializedTable(table: string, def: MaterializedTableDef
|
|
|
282
415
|
constraints: def.constraints,
|
|
283
416
|
writtenBy: def.writtenBy ?? 'worker',
|
|
284
417
|
private: def.private,
|
|
418
|
+
...(def.privileges ? { privileges: def.privileges } : {}),
|
|
285
419
|
...(def.renamedFrom ? { renamedFrom: def.renamedFrom } : {}),
|
|
286
420
|
});
|
|
287
421
|
model.materialized = {
|
package/src/pg-type.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pg-type — the ONE definition of a column type's spelling.
|
|
3
|
+
*
|
|
4
|
+
* Three questions, answered here and only here, so every surface (the field
|
|
5
|
+
* factory, db:pull's renderer, introspection, the differ, the fingerprint)
|
|
6
|
+
* agrees by construction:
|
|
7
|
+
*
|
|
8
|
+
* 1. What is the canonical spelling of a `format_type` string?
|
|
9
|
+
* (`canonicalColumnType` — the v5 rule: a redundant default precision is
|
|
10
|
+
* not part of a type's identity.)
|
|
11
|
+
* 2. Which spellings are already owned by a first-class `field.*()` factory?
|
|
12
|
+
* (`fieldFactoryCall` — the inverse map db:pull renders with, and the map
|
|
13
|
+
* `field.pgType` refuses against, so one type always has one spelling.)
|
|
14
|
+
* 3. Everything else is carried VERBATIM by `field.pgType(...)` — the type
|
|
15
|
+
* string itself is the identity, end to end.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Canonicalize a `format_type` spelling. Only the redundant default precision,
|
|
20
|
+
* only the timestamp/time family: `timestamp(6) without time zone` is the same
|
|
21
|
+
* type as `timestamp without time zone` (6 is the family default), so both
|
|
22
|
+
* spell the canonical, shorter form. `timestamp(3)` is a real difference and
|
|
23
|
+
* passes through untouched.
|
|
24
|
+
*/
|
|
25
|
+
export function canonicalColumnType(type: string): string {
|
|
26
|
+
return type.replace(/^(timestamp|timestamptz|time|timetz)\(6\)(\s|$)/, '$1$2');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** `format_type` → the `field.*()` factory that produces it (the non-parameterized types). */
|
|
30
|
+
const FIELD_FACTORY: Record<string, string> = {
|
|
31
|
+
uuid: 'uuid',
|
|
32
|
+
text: 'text',
|
|
33
|
+
integer: 'integer',
|
|
34
|
+
bigint: 'bigint',
|
|
35
|
+
real: 'real',
|
|
36
|
+
'double precision': 'doublePrecision',
|
|
37
|
+
boolean: 'boolean',
|
|
38
|
+
json: 'json',
|
|
39
|
+
jsonb: 'jsonb',
|
|
40
|
+
date: 'date',
|
|
41
|
+
'timestamp with time zone': 'timestamptz',
|
|
42
|
+
'timestamp without time zone': 'timestamp',
|
|
43
|
+
timestamp: 'timestamp',
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The full `field.*()` call for a `format_type` string, or null when no
|
|
48
|
+
* first-class field maps it. Handles the parameterized types `format_type`
|
|
49
|
+
* spells out — `numeric(p,s)` and `character varying(n)` — so a pulled column
|
|
50
|
+
* round-trips back to the same DDL. A scale-0 numeric renders as the cleaner
|
|
51
|
+
* `field.numeric(p)` (identical to `(p, 0)`).
|
|
52
|
+
*/
|
|
53
|
+
export function fieldFactoryCall(type: string): string | null {
|
|
54
|
+
// An array is its base type + `.array()` — exactly how the compiler emits it
|
|
55
|
+
// (`format_type` spells arrays `text[]`). An array of an unmapped base stays null.
|
|
56
|
+
if (type.endsWith('[]')) {
|
|
57
|
+
const base = fieldFactoryCall(type.slice(0, -2));
|
|
58
|
+
return base ? `${base}.array()` : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const direct = FIELD_FACTORY[type];
|
|
62
|
+
if (direct) return `field.${direct}()`;
|
|
63
|
+
|
|
64
|
+
const num = type.match(/^numeric(?:\((\d+),(\d+)\))?$/);
|
|
65
|
+
if (num) {
|
|
66
|
+
if (num[1] == null) return 'field.numeric()';
|
|
67
|
+
return num[2] === '0' ? `field.numeric(${num[1]})` : `field.numeric(${num[1]}, ${num[2]})`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const vc = type.match(/^character varying(?:\((\d+)\))?$/);
|
|
71
|
+
if (vc) return vc[1] != null ? `field.varchar(${vc[1]})` : 'field.varchar()';
|
|
72
|
+
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Validate the argument of `field.pgType(...)`. Returns the reason the spelling
|
|
78
|
+
* is refused, or null when it may be carried verbatim. Refusals exist so a type
|
|
79
|
+
* never has two spellings in the vocabulary — the split-identity family of bugs.
|
|
80
|
+
*/
|
|
81
|
+
export function pgTypeRefusal(typeName: string): string | null {
|
|
82
|
+
const t = typeName.trim();
|
|
83
|
+
if (!t) return 'field.pgType() needs a Postgres type name';
|
|
84
|
+
if (t !== typeName) return `field.pgType('${typeName}'): no surrounding whitespace — write '${t}'`;
|
|
85
|
+
if (t.endsWith('[]')) {
|
|
86
|
+
return `field.pgType('${t}'): declare the scalar and add .array() — field.pgType('${t.slice(0, -2)}').array()`;
|
|
87
|
+
}
|
|
88
|
+
const canonical = canonicalColumnType(t);
|
|
89
|
+
if (canonical !== t) {
|
|
90
|
+
return `field.pgType('${t}'): the canonical spelling is '${canonical}' — a redundant default precision is not part of the type's identity`;
|
|
91
|
+
}
|
|
92
|
+
const firstClass = fieldFactoryCall(t);
|
|
93
|
+
if (firstClass) {
|
|
94
|
+
return `field.pgType('${t}'): this type is first-class — use ${firstClass}`;
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|