@everystack/model 0.4.10 → 0.4.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +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 -1
- package/src/model.ts +80 -4
- package/src/module.ts +72 -0
- package/src/pg-type.ts +97 -0
- package/src/qualified.ts +34 -0
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,13 +10,17 @@
|
|
|
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
|
+
|
|
15
|
+
export { parseQualified } from './qualified';
|
|
16
|
+
|
|
13
17
|
export { unique, check, index, foreignKey, IndexBuilder } from './constraints';
|
|
14
18
|
export type { TableConstraint } from './constraints';
|
|
15
19
|
|
|
16
20
|
export { sql } from './raw-sql';
|
|
17
21
|
export type { RawSql } from './raw-sql';
|
|
18
22
|
|
|
19
|
-
export { can } from './can';
|
|
23
|
+
export { can, isColumnAbility, isPublicReadAbility } from './can';
|
|
20
24
|
export type { Ability, AbilityCondition, Action } from './can';
|
|
21
25
|
|
|
22
26
|
export { belongsTo, hasMany } from './relations';
|
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';
|
|
@@ -207,6 +208,72 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
|
|
|
207
208
|
if (a.action === 'execute') {
|
|
208
209
|
throw new Error(`model '${table}': can('execute') is a function ability — declare it on the defineFunction it gates.`);
|
|
209
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
|
+
}
|
|
210
277
|
}
|
|
211
278
|
|
|
212
279
|
// An array field's value default must be a JS array — `.default([])`, not the
|
|
@@ -230,10 +297,19 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
|
|
|
230
297
|
// so there is no default: on the exact set of models where the answer changes behaviour (a
|
|
231
298
|
// `deletedAt` field plus a public read), it must be declared. `softDelete: true` is what the
|
|
232
299
|
// inference used to do.
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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) {
|
|
237
313
|
throw new Error(
|
|
238
314
|
`model '${table}': has a 'deletedAt' field and a public read, so it must declare softDelete. `
|
|
239
315
|
+ `softDelete: true excludes soft-deleted rows from the public read policies (what the compiler used to infer from the field's presence); `
|
package/src/module.ts
CHANGED
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
import type { ModelDescriptor } from './model';
|
|
23
23
|
import type { DerivedDescriptor, SequenceDescriptor } from './derived';
|
|
24
24
|
import { rejectHoles, functionArgSignature } from './derived';
|
|
25
|
+
import { deriveFunctionSchemas } from './derive';
|
|
26
|
+
import { parseQualified } from './qualified';
|
|
25
27
|
|
|
26
28
|
export interface ModuleDefinition {
|
|
27
29
|
/** The package's Models (schema + authz). */
|
|
@@ -114,6 +116,7 @@ export function defineModule(def: ModuleDefinition): Module {
|
|
|
114
116
|
}
|
|
115
117
|
seen.add(identity);
|
|
116
118
|
}
|
|
119
|
+
assertFunctionsAreDeclared(def);
|
|
117
120
|
return {
|
|
118
121
|
models: def.models ?? [],
|
|
119
122
|
extensions: def.extensions ?? [],
|
|
@@ -124,6 +127,75 @@ export function defineModule(def: ModuleDefinition): Module {
|
|
|
124
127
|
};
|
|
125
128
|
}
|
|
126
129
|
|
|
130
|
+
/**
|
|
131
|
+
* `writtenBy: 'functions'` is a PROMISE that functions do the writing. This is where the
|
|
132
|
+
* promise gets checked.
|
|
133
|
+
*
|
|
134
|
+
* The defect it closes, found 2026-08-04: `@everystack/auth` declared seven models
|
|
135
|
+
* `writtenBy: 'functions'` and shipped 21 SECURITY DEFINER functions — through the deprecated
|
|
136
|
+
* `functions` thunk, which exactly ONE command reads (`db:generate --init`). `db:reconcile`
|
|
137
|
+
* reads `derived`, and `derived` was empty. So a brownfield adopter installed the tables, got
|
|
138
|
+
* ENABLE-not-FORCE RLS on the strength of `writtenBy: 'functions'`, installed none of the
|
|
139
|
+
* functions that were supposed to be the only writers, and every gate reported clean. The
|
|
140
|
+
* declaration and the thing it depends on were checked by nobody.
|
|
141
|
+
*
|
|
142
|
+
* The check is per-MODULE, not across the composed set: a module that owns a
|
|
143
|
+
* functions-written model owns the functions that write it, and firing here can name the
|
|
144
|
+
* module at fault. Across modules the error arrives after everything is flattened and can
|
|
145
|
+
* only say that something, somewhere, is missing.
|
|
146
|
+
*
|
|
147
|
+
* A thunk downgrades the error to a warning rather than satisfying the check — the thunk lane
|
|
148
|
+
* does install, just on one command, so a consumer still on it is degraded and not broken.
|
|
149
|
+
*/
|
|
150
|
+
function assertFunctionsAreDeclared(def: ModuleDefinition): void {
|
|
151
|
+
const models = def.models ?? [];
|
|
152
|
+
const owed = deriveFunctionSchemas(models);
|
|
153
|
+
const declared = new Set(
|
|
154
|
+
(def.derived ?? [])
|
|
155
|
+
.filter((d) => (d.kind === 'sql' ? d.objectKind : d.kind) === 'function')
|
|
156
|
+
.map((d) => parseQualified(d.name).schema),
|
|
157
|
+
);
|
|
158
|
+
const missing = owed.filter((schema) => !declared.has(schema));
|
|
159
|
+
|
|
160
|
+
if (missing.length === 0) {
|
|
161
|
+
if (def.functions) {
|
|
162
|
+
console.warn(
|
|
163
|
+
'defineModule: the `functions` thunk is deprecated and only `db:generate --init` reads it — '
|
|
164
|
+
+ '`db:reconcile` reads `derived`, so anything declared there alone never reaches a brownfield '
|
|
165
|
+
+ 'database. Move it to `derived: [defineFunction(…)]`.',
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const blame = missing
|
|
172
|
+
.map((schema) => {
|
|
173
|
+
const tables = models
|
|
174
|
+
.filter((m) => m.writtenBy === 'functions' && m.schema === schema)
|
|
175
|
+
.map((m) => `${m.schema}.${m.table}`)
|
|
176
|
+
.sort();
|
|
177
|
+
return `schema '${schema}' (${tables.join(', ')})`;
|
|
178
|
+
})
|
|
179
|
+
.join('; ');
|
|
180
|
+
|
|
181
|
+
if (def.functions) {
|
|
182
|
+
console.warn(
|
|
183
|
+
`defineModule: ${blame} declares writtenBy: 'functions' but every function is in the deprecated `
|
|
184
|
+
+ '`functions` thunk, which only `db:generate --init` reads. `db:reconcile` reads `derived`, so a '
|
|
185
|
+
+ 'brownfield database gets the tables and none of their writers. Move them to '
|
|
186
|
+
+ '`derived: [defineFunction(…)]`.',
|
|
187
|
+
);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
throw new Error(
|
|
192
|
+
`defineModule: ${blame} declares writtenBy: 'functions' but the module declares no function in `
|
|
193
|
+
+ `that schema. writtenBy: 'functions' is what turns RLS FORCE off, so the tables ship writable `
|
|
194
|
+
+ `only by functions that do not exist. Declare them as \`derived: [defineFunction('${missing[0]}.…', …)]\`, `
|
|
195
|
+
+ `or change writtenBy if functions are not the writer.`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
127
199
|
/** The flattened model list across modules — for everything that still takes a model array
|
|
128
200
|
* (handler-config derivation, FORCE carveouts, the drift guard). */
|
|
129
201
|
export function moduleModels(modules: Module[]): ModelDescriptor[] {
|
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
|
+
}
|
package/src/qualified.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Splitting a declared name into `{ schema, name }`.
|
|
3
|
+
*
|
|
4
|
+
* This lives in `@everystack/model` because the names it parses are MODEL vocabulary: a
|
|
5
|
+
* descriptor declares `defineFunction('auth.sign_in_session', …)` and a model declares its
|
|
6
|
+
* `schema` separately, so anything that has to line those two up — the composition guard here,
|
|
7
|
+
* every emitter and the reconciler in `@everystack/cli` — must agree on where the dot is. The
|
|
8
|
+
* cli re-exports this from `derived-source.ts`, so there is ONE definition and its callers
|
|
9
|
+
* cannot drift apart. (Same reasoning as `fieldFactoryCall`: the parser belongs with the
|
|
10
|
+
* vocabulary it parses, not with the first consumer that needed it.)
|
|
11
|
+
*
|
|
12
|
+
* `public` is the unqualified default because that is what PostgreSQL resolves a bare name to
|
|
13
|
+
* under the default `search_path` — the parse must match the server, not a convention.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Strip quotes from an identifier and split an optionally schema-qualified name. */
|
|
17
|
+
export function parseQualified(raw: string): { schema: string; name: string } {
|
|
18
|
+
const parts: string[] = [];
|
|
19
|
+
let current = '';
|
|
20
|
+
let quoted = false;
|
|
21
|
+
for (let i = 0; i < raw.length; i++) {
|
|
22
|
+
const ch = raw[i];
|
|
23
|
+
if (ch === '"') {
|
|
24
|
+
if (quoted && raw[i + 1] === '"') { current += '"'; i++; continue; }
|
|
25
|
+
quoted = !quoted;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (ch === '.' && !quoted) { parts.push(current); current = ''; continue; }
|
|
29
|
+
current += ch;
|
|
30
|
+
}
|
|
31
|
+
parts.push(current);
|
|
32
|
+
if (parts.length >= 2) return { schema: parts[0], name: parts.slice(1).join('.') };
|
|
33
|
+
return { schema: 'public', name: parts[0] };
|
|
34
|
+
}
|