@everystack/model 0.4.20 → 0.4.22
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/CHANGELOG.md +36 -0
- package/README.md +2 -0
- package/package.json +1 -1
- package/src/constraints.ts +72 -6
- package/src/derive.ts +25 -2
- package/src/derived.ts +1 -1
- package/src/field.ts +5 -3
- package/src/index.ts +2 -1
- package/src/link.ts +126 -0
- package/src/model.ts +13 -46
- package/src/module.ts +2 -1
- package/src/privileges.ts +45 -0
- package/src/relations.ts +7 -6
- package/src/wire.ts +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,42 @@ All notable changes to `@everystack/model` are documented here.
|
|
|
4
4
|
|
|
5
5
|
Format based on [Keep a Changelog](https://keepachangelog.com/).
|
|
6
6
|
|
|
7
|
+
## [0.4.22] - 2026-10-16
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- check() takes an explicit constraint name; db:pull keeps a live name
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- db:apply runs a rename-only plan
|
|
16
|
+
|
|
17
|
+
## [0.4.21] - 2026-10-14
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- apply state prerequisites atomically
|
|
22
|
+
- link table name references through model thunks
|
|
23
|
+
- use table names in the example and gate import cycles
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- break the model and auth import cycles; gate new ones
|
|
28
|
+
- link cached placeholder references to their targets
|
|
29
|
+
- reject conflicting name bindings
|
|
30
|
+
- rowOwnership exceptRoles lifts the backstop for manage roles
|
|
31
|
+
- only a plain manage role lifts the rowOwnership backstop
|
|
32
|
+
|
|
33
|
+
## [0.4.20] - 2026-09-26
|
|
34
|
+
|
|
35
|
+
### BREAKING CHANGES
|
|
36
|
+
|
|
37
|
+
- defineModel refuses owner or via under a no-session audience
|
|
38
|
+
|
|
39
|
+
### Fixed
|
|
40
|
+
|
|
41
|
+
- refuse role: 'anon' combined with owner or via
|
|
42
|
+
|
|
7
43
|
## [0.4.19] - 2026-09-16
|
|
8
44
|
|
|
9
45
|
### Added
|
package/README.md
CHANGED
|
@@ -87,6 +87,8 @@ The table-level surface, composed in `constraints: [...]`, for what a column-sco
|
|
|
87
87
|
- `check(sql\`a >= b\`)` — a multi-column CHECK.
|
|
88
88
|
- `foreignKey(['a', 'b'], () => Other, ['x', 'y'], { onDelete })` — a composite foreign key.
|
|
89
89
|
|
|
90
|
+
Each can carry the name the database calls it: `index('slug').name('posts_slug_lookup')`, `foreignKey([...], () => Other, [...], { name: 'posts_owner_fk' })`, and `check(sql\`ends_at >= starts_at\`, { name: 'events_window_check' })`. Undeclared, the compiler generates the name. A check is then named by its position among the table's unnamed checks (`events_check_0`, `events_check_1`), so inserting or reordering one renames the others; an explicit name is used verbatim and is stable under reordering. A name must be a bare lowercase identifier of at most 63 bytes, and two checks in one table cannot share a name or take one an unnamed check gets by position. Declaring a name on an existing constraint plans a `RENAME CONSTRAINT` (or `ALTER INDEX … RENAME`), never a drop and re-add. `db:apply` applies a rename like any other change, also when it is the only change in the plan. `db:pull` declares a live name only when it is not the one the compiler would generate.
|
|
91
|
+
|
|
90
92
|
## Authorization
|
|
91
93
|
|
|
92
94
|
`can(action, condition)` declares one rule (default-deny: no matching `can()` means denied). Conditions:
|
package/package.json
CHANGED
package/src/constraints.ts
CHANGED
|
@@ -16,8 +16,11 @@ import type { ReferentialAction } from './field';
|
|
|
16
16
|
export type TableConstraint =
|
|
17
17
|
/** `UNIQUE (a, b)` over two or more field keys — the composite the field builder can't carry. */
|
|
18
18
|
| { kind: 'unique'; columns: string[] }
|
|
19
|
-
/**
|
|
20
|
-
|
|
19
|
+
/**
|
|
20
|
+
* `CHECK (<predicate>)` — a raw SQL predicate, typically spanning columns. `name` is present
|
|
21
|
+
* only when declared; absent, the compiler names it by position (see {@link positionalCheckName}).
|
|
22
|
+
*/
|
|
23
|
+
| { kind: 'check'; predicate: string; name?: string }
|
|
21
24
|
/** A standalone index — `CREATE [UNIQUE] INDEX [USING <method>] … (keys) [INCLUDE (cols)] [WHERE <predicate>]`. */
|
|
22
25
|
| { kind: 'index'; columns: string[]; isUnique: boolean; predicate?: string; method?: string; includeColumns?: string[]; rawPositions?: boolean[]; indexName?: string }
|
|
23
26
|
/**
|
|
@@ -35,9 +38,60 @@ export function unique(...columns: string[]): TableConstraint {
|
|
|
35
38
|
return { kind: 'unique', columns };
|
|
36
39
|
}
|
|
37
40
|
|
|
38
|
-
/**
|
|
39
|
-
|
|
40
|
-
|
|
41
|
+
/**
|
|
42
|
+
* A table CHECK from a raw predicate: `check(sql\`published_at >= created_at\`)`.
|
|
43
|
+
*
|
|
44
|
+
* `{ name }` declares the name the database calls it: `check(sql\`…\`, { name: 'contents_state_check' })`.
|
|
45
|
+
* Undeclared, the compiler names it by POSITION among the table's unnamed checks
|
|
46
|
+
* (`<table>_check_<n>`), which is fine for a greenfield model and fragile for everything that
|
|
47
|
+
* names a constraint: inserting or reordering an unnamed check renames its siblings. A declared
|
|
48
|
+
* name is used verbatim, does not take a position, and is stable under reordering. Same rule as
|
|
49
|
+
* `index().name()` and `foreignKey({ name })`: the generated name is the fallback.
|
|
50
|
+
*/
|
|
51
|
+
export function check(predicate: RawSql, opts?: { name?: string }): TableConstraint {
|
|
52
|
+
return {
|
|
53
|
+
kind: 'check',
|
|
54
|
+
predicate: predicate.sql,
|
|
55
|
+
...(opts?.name !== undefined ? { name: assertConstraintName(opts.name, `check(sql\`${predicate.sql}\`)`, MAX_IDENTIFIER_BYTES) } : {}),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The name an UNNAMED table check gets: `<table>_check_<position>`, where position counts only
|
|
61
|
+
* the unnamed checks before it — a named check never takes a position, so naming one check
|
|
62
|
+
* cannot rename the others. ONE implementation, shared by the compiler, `db:pull` (which declares
|
|
63
|
+
* a live name exactly when it is not this) and {@link assertCheckNames}.
|
|
64
|
+
*/
|
|
65
|
+
export function positionalCheckName(table: string, position: number): string {
|
|
66
|
+
return `${table}_check_${position}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Refuse declared check names that cannot coexist in one table: the same name twice, or a name
|
|
71
|
+
* an unnamed check in the same table gets by position. Run by `defineModel`, the first point
|
|
72
|
+
* that knows the table — the identifier itself was already validated at {@link check}.
|
|
73
|
+
*/
|
|
74
|
+
export function assertCheckNames(table: string, constraints: readonly TableConstraint[]): void {
|
|
75
|
+
const positional = new Set<string>();
|
|
76
|
+
let position = 0;
|
|
77
|
+
for (const c of constraints) {
|
|
78
|
+
if (c.kind === 'check' && c.name === undefined) positional.add(positionalCheckName(table, position++));
|
|
79
|
+
}
|
|
80
|
+
const declared = new Set<string>();
|
|
81
|
+
for (const c of constraints) {
|
|
82
|
+
if (c.kind !== 'check' || c.name === undefined) continue;
|
|
83
|
+
const label = `check(sql\`${c.predicate}\`)`;
|
|
84
|
+
if (declared.has(c.name)) {
|
|
85
|
+
throw new Error(`model '${table}': two checks are named '${c.name}' (the second is ${label}). A constraint name is unique within its table.`);
|
|
86
|
+
}
|
|
87
|
+
if (positional.has(c.name)) {
|
|
88
|
+
throw new Error(
|
|
89
|
+
`model '${table}': ${label} is named '${c.name}', which an unnamed check in this table gets by position. `
|
|
90
|
+
+ 'Name that check as well, or choose another name.',
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
declared.add(c.name);
|
|
94
|
+
}
|
|
41
95
|
}
|
|
42
96
|
|
|
43
97
|
/**
|
|
@@ -184,15 +238,27 @@ const INDEX_NAME = /^[a-z_][a-z0-9_$]*$/;
|
|
|
184
238
|
* surface that lets a model name a database object — an index, a foreign key constraint —
|
|
185
239
|
* because two copies of a validation rule is how one of them ends up laxer than the other.
|
|
186
240
|
*/
|
|
187
|
-
export function assertConstraintName(name: string, what = 'constraint'): string {
|
|
241
|
+
export function assertConstraintName(name: string, what = 'constraint', maxBytes?: number): string {
|
|
188
242
|
if (!INDEX_NAME.test(name)) {
|
|
189
243
|
throw new Error(
|
|
190
244
|
`${what} name '${name}' is not a bare lowercase identifier (${INDEX_NAME}). It is interpolated into DDL that takes no parameters, so it is refused here rather than built into SQL.`,
|
|
191
245
|
);
|
|
192
246
|
}
|
|
247
|
+
// PostgreSQL truncates a longer identifier with only a NOTICE, so the database would hold a
|
|
248
|
+
// name the model does not declare, and every later plan would see the difference.
|
|
249
|
+
// Opt-in: index and foreign key names loaded without this limit before check names had it.
|
|
250
|
+
const bytes = new TextEncoder().encode(name).length;
|
|
251
|
+
if (maxBytes !== undefined && bytes > maxBytes) {
|
|
252
|
+
throw new Error(
|
|
253
|
+
`${what} name '${name}' is ${bytes} bytes. PostgreSQL identifiers are at most ${maxBytes} bytes and a longer one is truncated silently, so it is refused here.`,
|
|
254
|
+
);
|
|
255
|
+
}
|
|
193
256
|
return name;
|
|
194
257
|
}
|
|
195
258
|
|
|
259
|
+
/** NAMEDATALEN - 1 on a stock PostgreSQL build. */
|
|
260
|
+
const MAX_IDENTIFIER_BYTES = 63;
|
|
261
|
+
|
|
196
262
|
export function index(...entries: Array<string | RawSql>): IndexBuilder {
|
|
197
263
|
return new IndexBuilder(entries);
|
|
198
264
|
}
|
package/src/derive.ts
CHANGED
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
|
|
15
15
|
import type { ModelDescriptor } from './model';
|
|
16
16
|
import type { ModelHooks } from './model';
|
|
17
|
+
import type { AbilityCondition } from './can';
|
|
18
|
+
import { linkModels } from './link';
|
|
17
19
|
|
|
18
20
|
/** The handler's RelationConfig shape, mirrored structurally (no @everystack/api dep). */
|
|
19
21
|
export interface RelationConfig {
|
|
@@ -27,7 +29,8 @@ export interface DerivedHandlerConfig {
|
|
|
27
29
|
exposedTables: string[];
|
|
28
30
|
hiddenColumns: Record<string, string[]>;
|
|
29
31
|
protectedFields: Record<string, string[]>;
|
|
30
|
-
|
|
32
|
+
/** `exceptRoles` is present only when a `can('manage', { role })` exists for the table. */
|
|
33
|
+
rowOwnership: Record<string, { column: string; userField: string; exceptRoles?: string[] }>;
|
|
31
34
|
softDelete: { tables: string[] };
|
|
32
35
|
relations: Record<string, Record<string, RelationConfig>>;
|
|
33
36
|
hooks: Record<string, ModelHooks>;
|
|
@@ -44,10 +47,11 @@ function primaryKeyOf(model: ModelDescriptor): string {
|
|
|
44
47
|
}
|
|
45
48
|
|
|
46
49
|
export function deriveHandlerConfig(models: ModelDescriptor[]): DerivedHandlerConfig {
|
|
50
|
+
linkModels(models);
|
|
47
51
|
const exposedTables: string[] = [];
|
|
48
52
|
const hiddenColumns: Record<string, string[]> = {};
|
|
49
53
|
const protectedFields: Record<string, string[]> = {};
|
|
50
|
-
const rowOwnership:
|
|
54
|
+
const rowOwnership: DerivedHandlerConfig['rowOwnership'] = {};
|
|
51
55
|
const softDeleteTables: string[] = [];
|
|
52
56
|
const relations: Record<string, Record<string, RelationConfig>> = {};
|
|
53
57
|
const hooks: Record<string, ModelHooks> = {};
|
|
@@ -81,6 +85,19 @@ export function deriveHandlerConfig(models: ModelDescriptor[]): DerivedHandlerCo
|
|
|
81
85
|
column: ownerAbility.condition.owner!,
|
|
82
86
|
userField: ownerAbility.condition.userField ?? DEFAULT_USER_FIELD,
|
|
83
87
|
};
|
|
88
|
+
// A role that may `manage` the table is exempt from the backstop, mirroring the
|
|
89
|
+
// RLS policy compiled from the same ability. Only a plain `can('manage', { role })`
|
|
90
|
+
// counts: a role-scoped update or delete alone does not lift it, and neither does a
|
|
91
|
+
// manage ability with any other condition, which narrows what RLS lets it write
|
|
92
|
+
// (fail closed: a whitelist of `role`, not a blacklist of the rest).
|
|
93
|
+
const exceptRoles = [
|
|
94
|
+
...new Set(
|
|
95
|
+
model.abilities
|
|
96
|
+
.filter((a) => a.action === 'manage' && isPlainRole(a.condition))
|
|
97
|
+
.map((a) => a.condition.role as string),
|
|
98
|
+
),
|
|
99
|
+
];
|
|
100
|
+
if (exceptRoles.length) rowOwnership[table].exceptRoles = exceptRoles;
|
|
84
101
|
}
|
|
85
102
|
|
|
86
103
|
// Declared, not inferred — one `softDelete` decision drives both the API filter and the
|
|
@@ -127,6 +144,12 @@ export function deriveHandlerConfig(models: ModelDescriptor[]): DerivedHandlerCo
|
|
|
127
144
|
};
|
|
128
145
|
}
|
|
129
146
|
|
|
147
|
+
/** True when the condition is exactly `{ role: <string> }`: every other key is absent or undefined. */
|
|
148
|
+
function isPlainRole(condition: AbilityCondition): boolean {
|
|
149
|
+
return typeof condition.role === 'string'
|
|
150
|
+
&& Object.entries(condition).every(([key, value]) => key === 'role' || value === undefined);
|
|
151
|
+
}
|
|
152
|
+
|
|
130
153
|
function fieldsWhere(
|
|
131
154
|
model: ModelDescriptor,
|
|
132
155
|
pred: (f: ModelDescriptor['fields'][string]) => boolean,
|
package/src/derived.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
import type { RawSql } from './raw-sql';
|
|
18
18
|
import type { Ability } from './can';
|
|
19
19
|
import type { ModelDescriptor } from './model';
|
|
20
|
-
import { TABLE_PRIVILEGES } from './
|
|
20
|
+
import { TABLE_PRIVILEGES } from './privileges';
|
|
21
21
|
import type { IndexBuilder } from './constraints';
|
|
22
22
|
import type { FieldBuilder } from './field';
|
|
23
23
|
|
package/src/field.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import type { RawSql } from './raw-sql';
|
|
15
15
|
import { pgTypeRefusal } from './pg-type';
|
|
16
16
|
import { assertConstraintName, assertFkValidate } from './constraints';
|
|
17
|
+
import { referenceThunk } from './link';
|
|
17
18
|
|
|
18
19
|
export type FieldType =
|
|
19
20
|
| 'text'
|
|
@@ -230,7 +231,8 @@ export class FieldBuilder {
|
|
|
230
231
|
}
|
|
231
232
|
|
|
232
233
|
/**
|
|
233
|
-
* Declare a foreign key. The target is a model thunk
|
|
234
|
+
* Declare a foreign key. The target is a table name or model thunk. Names are resolved
|
|
235
|
+
* by linkModels when the complete set is available. Optional
|
|
234
236
|
* `onDelete`/`onUpdate` referential actions are column-scoped, so they live here, not on
|
|
235
237
|
* the table — `field.uuid().references(() => User, { onDelete: 'cascade' })`.
|
|
236
238
|
*
|
|
@@ -240,8 +242,8 @@ export class FieldBuilder {
|
|
|
240
242
|
* {@link FieldSpec.referencesValidate} for why a fifteen-year-old schema needs to be able
|
|
241
243
|
* to say it.
|
|
242
244
|
*/
|
|
243
|
-
references(target: () => unknown, opts?: { onDelete?: ReferentialAction; onUpdate?: ReferentialAction; name?: string; validate?: boolean }): this {
|
|
244
|
-
this.spec.references = target;
|
|
245
|
+
references(target: (() => unknown) | string, opts?: { onDelete?: ReferentialAction; onUpdate?: ReferentialAction; name?: string; validate?: boolean }): this {
|
|
246
|
+
this.spec.references = referenceThunk(target);
|
|
245
247
|
if (opts?.onDelete) this.spec.onDelete = opts.onDelete;
|
|
246
248
|
if (opts?.onUpdate) this.spec.onUpdate = opts.onUpdate;
|
|
247
249
|
if (opts?.name !== undefined) this.spec.constraintName = assertConstraintName(opts.name, 'foreign key');
|
package/src/index.ts
CHANGED
|
@@ -13,8 +13,9 @@ export type { FieldSpec, FieldType, DefaultKind, ReferentialAction } from './fie
|
|
|
13
13
|
export { canonicalColumnType, fieldFactoryCall, pgTypeRefusal } from './pg-type';
|
|
14
14
|
|
|
15
15
|
export { parseQualified } from './qualified';
|
|
16
|
+
export { linkModels, ModelLinkError } from './link';
|
|
16
17
|
|
|
17
|
-
export { unique, check, index, foreignKey, IndexBuilder } from './constraints';
|
|
18
|
+
export { unique, check, index, foreignKey, IndexBuilder, positionalCheckName } from './constraints';
|
|
18
19
|
export type { TableConstraint } from './constraints';
|
|
19
20
|
|
|
20
21
|
export { sql } from './raw-sql';
|
package/src/link.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type { ModelDescriptor } from './model';
|
|
2
|
+
import { parseQualified } from './qualified';
|
|
3
|
+
|
|
4
|
+
/** A failed or conflicting name binding must never become a usable model. */
|
|
5
|
+
export class ModelLinkError extends Error {
|
|
6
|
+
constructor(message: string) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = 'ModelLinkError';
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type NameReference<T> = (() => T) & {
|
|
13
|
+
refName: string;
|
|
14
|
+
refLocation?: string;
|
|
15
|
+
boundTarget?: T;
|
|
16
|
+
linkedTarget?: () => T;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function isNameReference<T>(target: () => T): target is NameReference<T> {
|
|
20
|
+
return 'refName' in target && typeof target.refName === 'string';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Keep descriptors callable until the complete model set is available. */
|
|
24
|
+
export function referenceThunk<T>(target: (() => T) | string): () => T {
|
|
25
|
+
if (typeof target !== 'string') return target;
|
|
26
|
+
const placeholder: NameReference<T> = Object.assign((): T => {
|
|
27
|
+
// Lazy consumers can capture this function before linking (Drizzle caches tables).
|
|
28
|
+
// They must recover after the caller follows the error's linking instruction.
|
|
29
|
+
if (placeholder.linkedTarget) return placeholder.linkedTarget();
|
|
30
|
+
throw new ModelLinkError(
|
|
31
|
+
`reference '${placeholder.refName}' on '${placeholder.refLocation ?? 'unknown'}' is not linked: export your models through linkModels()`,
|
|
32
|
+
);
|
|
33
|
+
}, { refName: target });
|
|
34
|
+
return placeholder;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Called at declaration, where the owning table and field/relation name are known. */
|
|
38
|
+
export function referenceLocation(target: (() => unknown) | undefined, location: string): void {
|
|
39
|
+
if (target && isNameReference(target)) target.refLocation = location;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Resolve name references against this set only, then replace their placeholders with
|
|
44
|
+
* bound model thunks that retain their names. Returns the same array; repeated calls preserve thunk identities.
|
|
45
|
+
* Existing model thunks are never evaluated or replaced.
|
|
46
|
+
*/
|
|
47
|
+
export function linkModels<T extends readonly ModelDescriptor[]>(models: T): T {
|
|
48
|
+
const uniqueModels = [...new Set(models)];
|
|
49
|
+
const byName = new Map<string, ModelDescriptor[]>();
|
|
50
|
+
for (const model of uniqueModels) {
|
|
51
|
+
const candidates = byName.get(model.table) ?? [];
|
|
52
|
+
candidates.push(model);
|
|
53
|
+
byName.set(model.table, candidates);
|
|
54
|
+
}
|
|
55
|
+
const modelName = (model: ModelDescriptor): string =>
|
|
56
|
+
model.schema === 'public' ? model.table : `${model.schema}.${model.table}`;
|
|
57
|
+
|
|
58
|
+
// One resolver for fields and relations. Prefixing a schema through the same parser
|
|
59
|
+
// distinguishes bare names from explicit public names, including quoted dots.
|
|
60
|
+
function resolve(target: NameReference<ModelDescriptor>, model: ModelDescriptor, kind: string, key: string): ModelDescriptor {
|
|
61
|
+
const name = target.refName;
|
|
62
|
+
let parsed: ReturnType<typeof parseQualified>;
|
|
63
|
+
let qualified: boolean;
|
|
64
|
+
try {
|
|
65
|
+
parsed = parseQualified(name);
|
|
66
|
+
qualified = parseQualified(`public.${name}`).name !== parsed.name;
|
|
67
|
+
} catch (err) {
|
|
68
|
+
throw new ModelLinkError(`reference '${name}' on '${target.refLocation ?? 'unknown'}': ${err instanceof Error ? err.message : String(err)}`);
|
|
69
|
+
}
|
|
70
|
+
const candidates = (byName.get(parsed.name) ?? [])
|
|
71
|
+
.filter(candidate => !qualified || candidate.schema === parsed.schema);
|
|
72
|
+
const context = `model '${modelName(model)}': ${kind} '${key}'`;
|
|
73
|
+
if (!candidates.length) {
|
|
74
|
+
if (target.boundTarget) return target.boundTarget;
|
|
75
|
+
const defined = uniqueModels.map(modelName).sort().join(', ');
|
|
76
|
+
throw new ModelLinkError(`${context} targets '${name}', which is not a defined model (defined: ${defined})`);
|
|
77
|
+
}
|
|
78
|
+
if (candidates.length > 1) {
|
|
79
|
+
const names = candidates.map(candidate => `${candidate.schema}.${candidate.table}`).sort().join(', ');
|
|
80
|
+
throw new ModelLinkError(`${context} targets ambiguous model '${name}' (candidates: ${names})`);
|
|
81
|
+
}
|
|
82
|
+
const resolved = candidates[0];
|
|
83
|
+
if (target.boundTarget && target.boundTarget !== resolved) {
|
|
84
|
+
const previous = target.boundTarget;
|
|
85
|
+
const oldName = `${previous.schema}.${previous.table}`;
|
|
86
|
+
const newName = `${resolved.schema}.${resolved.table}`;
|
|
87
|
+
const difference = oldName === newName
|
|
88
|
+
? `a different object for the same table '${oldName}' (models loaded twice)`
|
|
89
|
+
: `a different table ('${oldName}' instead of '${newName}')`;
|
|
90
|
+
throw new ModelLinkError(
|
|
91
|
+
`reference '${name}' on '${target.refLocation ?? 'unknown'}' is already linked to ${difference}; models are shared objects, link them from one set`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
return resolved;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Resolve everything before changing descriptors, so a typo can be corrected and retried
|
|
98
|
+
// without leaving earlier references bound to a different, incomplete set.
|
|
99
|
+
const replacements: Array<() => void> = [];
|
|
100
|
+
for (const model of uniqueModels) {
|
|
101
|
+
for (const [key, field] of Object.entries(model.fields)) {
|
|
102
|
+
const target = field.spec.references;
|
|
103
|
+
if (target && isNameReference(target)) {
|
|
104
|
+
const resolved = resolve(target as NameReference<ModelDescriptor>, model, 'field', key);
|
|
105
|
+
if (target.boundTarget) continue;
|
|
106
|
+
const linked = Object.assign(() => resolved, {
|
|
107
|
+
refName: target.refName, refLocation: target.refLocation, boundTarget: resolved,
|
|
108
|
+
});
|
|
109
|
+
replacements.push(() => { target.boundTarget = resolved; target.linkedTarget = linked; field.spec.references = linked; });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
for (const [key, relation] of Object.entries(model.relations)) {
|
|
113
|
+
const target = relation.target;
|
|
114
|
+
if (isNameReference(target)) {
|
|
115
|
+
const resolved = resolve(target, model, 'relation', key);
|
|
116
|
+
if (target.boundTarget) continue;
|
|
117
|
+
const linked = Object.assign(() => resolved, {
|
|
118
|
+
refName: target.refName, refLocation: target.refLocation, boundTarget: resolved,
|
|
119
|
+
});
|
|
120
|
+
replacements.push(() => { target.boundTarget = resolved; target.linkedTarget = linked; relation.target = linked; });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
for (const replace of replacements) replace();
|
|
125
|
+
return models;
|
|
126
|
+
}
|
package/src/model.ts
CHANGED
|
@@ -12,12 +12,14 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import type { FieldBuilder } from './field';
|
|
15
|
+
import { referenceLocation } from './link';
|
|
15
16
|
import type { Ability } from './can';
|
|
16
17
|
import { isPublicReadAbility, isDeleteAbility, needsSoftDeleteDeclaration, privilegesGrantDelete, sessionlessAudience } from './can';
|
|
17
18
|
import type { Relation } from './relations';
|
|
18
|
-
import type
|
|
19
|
+
import { assertCheckNames, type TableConstraint } from './constraints';
|
|
19
20
|
import type { RawSql } from './raw-sql';
|
|
20
21
|
import { validateTriggersFor, type TriggerSpec, type DependsOnRef } from './derived';
|
|
22
|
+
import { TABLE_PRIVILEGES, type TablePrivilege } from './privileges';
|
|
21
23
|
|
|
22
24
|
/**
|
|
23
25
|
* Per-table mutation hooks. Imperative function bodies the framework cannot
|
|
@@ -51,51 +53,8 @@ export interface ModelHooks {
|
|
|
51
53
|
*/
|
|
52
54
|
export type WrittenBy = 'app' | 'worker' | 'functions';
|
|
53
55
|
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
71
|
-
/** The four CRUD privileges. Normally they arrive via `can()`, which decides the policy too. */
|
|
72
|
-
export const DML_PRIVILEGES = ['SELECT', 'INSERT', 'UPDATE', 'DELETE'] as const;
|
|
73
|
-
export type DmlPrivilege = (typeof DML_PRIVILEGES)[number];
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Every table privilege this language can spell.
|
|
77
|
-
*
|
|
78
|
-
* The DML four used to be REFUSED here, so that a grant could never be declared without the
|
|
79
|
-
* policy that gates it. That invariant was already vacuous for any database being adopted: a
|
|
80
|
-
* live table can hold `GRANT SELECT TO admin` with no policy covering admin, and refusing to
|
|
81
|
-
* say it did not make it not so — it only made the database unspellable.
|
|
82
|
-
*
|
|
83
|
-
* That matters because `db:fingerprint` compares the LIVE schema against the MODELS. A state
|
|
84
|
-
* the language cannot express is a permanent MISMATCH, so adopting such a database required
|
|
85
|
-
* mutating it first, which inverts who is the source of truth during adoption.
|
|
86
|
-
*
|
|
87
|
-
* The rule this settles (Fable, 2026-08-06): **expressibility is total over the fingerprint's
|
|
88
|
-
* domain; opinion lives in the linter, never in the parser.** Declaring a naked DML grant is
|
|
89
|
-
* still a mistake in hand-authored code — `db:check` says so, as an ERROR on a table without
|
|
90
|
-
* RLS (where it is an unrestricted table-wide privilege) and a WARNING on a table with RLS
|
|
91
|
-
* (where it is dead until someone adds a broad policy or disables RLS). It is no longer a
|
|
92
|
-
* parse error, because `db:pull` has to be able to render what is actually there.
|
|
93
|
-
*
|
|
94
|
-
* Policies still come ONLY from `can()`. This key can never mint row access by itself.
|
|
95
|
-
*/
|
|
96
|
-
export type TablePrivilege = ExtraPrivilege | DmlPrivilege;
|
|
97
|
-
|
|
98
|
-
export const TABLE_PRIVILEGES: readonly TablePrivilege[] = [...EXTRA_PRIVILEGES, ...DML_PRIVILEGES];
|
|
56
|
+
export { EXTRA_PRIVILEGES, DML_PRIVILEGES, TABLE_PRIVILEGES } from './privileges';
|
|
57
|
+
export type { ExtraPrivilege, DmlPrivilege, TablePrivilege } from './privileges';
|
|
99
58
|
|
|
100
59
|
export interface ModelDefinition {
|
|
101
60
|
fields: Record<string, FieldBuilder>;
|
|
@@ -269,6 +228,12 @@ export interface MaterializedTableMarker {
|
|
|
269
228
|
}
|
|
270
229
|
|
|
271
230
|
export function defineModel(table: string, def: ModelDefinition): ModelDescriptor {
|
|
231
|
+
for (const [name, field] of Object.entries(def.fields)) {
|
|
232
|
+
referenceLocation(field.spec.references, `${table}.${name}`);
|
|
233
|
+
}
|
|
234
|
+
for (const [name, relation] of Object.entries(def.relations ?? {})) {
|
|
235
|
+
referenceLocation(relation.target, `${table}.${name}`);
|
|
236
|
+
}
|
|
272
237
|
const primaryKey = Object.entries(def.fields)
|
|
273
238
|
.filter(([, f]) => f.spec.isPrimaryKey)
|
|
274
239
|
.map(([name]) => name);
|
|
@@ -485,6 +450,8 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
|
|
|
485
450
|
);
|
|
486
451
|
}
|
|
487
452
|
|
|
453
|
+
assertCheckNames(table, def.constraints ?? []);
|
|
454
|
+
|
|
488
455
|
return {
|
|
489
456
|
table,
|
|
490
457
|
fields: def.fields,
|
package/src/module.ts
CHANGED
|
@@ -24,6 +24,7 @@ import type { DerivedDescriptor, SequenceDescriptor } from './derived';
|
|
|
24
24
|
import { rejectHoles, functionArgSignature } from './derived';
|
|
25
25
|
import { deriveFunctionSchemas } from './derive';
|
|
26
26
|
import { parseQualified } from './qualified';
|
|
27
|
+
import { linkModels } from './link';
|
|
27
28
|
|
|
28
29
|
export interface ModuleDefinition {
|
|
29
30
|
/** The package's Models (schema + authz). */
|
|
@@ -199,7 +200,7 @@ function assertFunctionsAreDeclared(def: ModuleDefinition): void {
|
|
|
199
200
|
/** The flattened model list across modules — for everything that still takes a model array
|
|
200
201
|
* (handler-config derivation, FORCE carveouts, the drift guard). */
|
|
201
202
|
export function moduleModels(modules: Module[]): ModelDescriptor[] {
|
|
202
|
-
return modules.flatMap((m) => m.models);
|
|
203
|
+
return linkModels(modules.flatMap((m) => m.models));
|
|
203
204
|
}
|
|
204
205
|
|
|
205
206
|
/** The deduped, sorted extension list across modules — emitted as `CREATE EXTENSION` first. */
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A table privilege that is NOT one of the four CRUD verbs — the ones `can()` has no word for.
|
|
3
|
+
*
|
|
4
|
+
* `can('manage')` means exactly INSERT/SELECT/UPDATE/DELETE, and that is deliberate: a verb
|
|
5
|
+
* that quietly expanded to "every privilege" would hand out TRUNCATE, which is not a DELETE
|
|
6
|
+
* and is not filtered by a DELETE policy. So these three are named one at a time, by the
|
|
7
|
+
* author, in {@link ModelDefinition.privileges}.
|
|
8
|
+
*
|
|
9
|
+
* Why the vocabulary stops here: a real database routinely grants its admin role REFERENCES,
|
|
10
|
+
* TRIGGER and TRUNCATE, and everystack had no way to SAY that — so the first plan against an
|
|
11
|
+
* existing schema revoked all three on every table. That is our spelling, not their bug.
|
|
12
|
+
*/
|
|
13
|
+
export type ExtraPrivilege = 'REFERENCES' | 'TRIGGER' | 'TRUNCATE';
|
|
14
|
+
|
|
15
|
+
/** The declarable privilege set — the one definition; the CLI's classifier reads it too. */
|
|
16
|
+
export const EXTRA_PRIVILEGES: readonly ExtraPrivilege[] = ['REFERENCES', 'TRIGGER', 'TRUNCATE'];
|
|
17
|
+
|
|
18
|
+
/** The four CRUD privileges. Normally they arrive via `can()`, which decides the policy too. */
|
|
19
|
+
export const DML_PRIVILEGES = ['SELECT', 'INSERT', 'UPDATE', 'DELETE'] as const;
|
|
20
|
+
export type DmlPrivilege = (typeof DML_PRIVILEGES)[number];
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Every table privilege this language can spell.
|
|
24
|
+
*
|
|
25
|
+
* The DML four used to be REFUSED here, so that a grant could never be declared without the
|
|
26
|
+
* policy that gates it. That invariant was already vacuous for any database being adopted: a
|
|
27
|
+
* live table can hold `GRANT SELECT TO admin` with no policy covering admin, and refusing to
|
|
28
|
+
* say it did not make it not so — it only made the database unspellable.
|
|
29
|
+
*
|
|
30
|
+
* That matters because `db:fingerprint` compares the LIVE schema against the MODELS. A state
|
|
31
|
+
* the language cannot express is a permanent MISMATCH, so adopting such a database required
|
|
32
|
+
* mutating it first, which inverts who is the source of truth during adoption.
|
|
33
|
+
*
|
|
34
|
+
* The rule this settles (Fable, 2026-08-06): **expressibility is total over the fingerprint's
|
|
35
|
+
* domain; opinion lives in the linter, never in the parser.** Declaring a naked DML grant is
|
|
36
|
+
* still a mistake in hand-authored code — `db:check` says so, as an ERROR on a table without
|
|
37
|
+
* RLS (where it is an unrestricted table-wide privilege) and a WARNING on a table with RLS
|
|
38
|
+
* (where it is dead until someone adds a broad policy or disables RLS). It is no longer a
|
|
39
|
+
* parse error, because `db:pull` has to be able to render what is actually there.
|
|
40
|
+
*
|
|
41
|
+
* Policies still come ONLY from `can()`. This key can never mint row access by itself.
|
|
42
|
+
*/
|
|
43
|
+
export type TablePrivilege = ExtraPrivilege | DmlPrivilege;
|
|
44
|
+
|
|
45
|
+
export const TABLE_PRIVILEGES: readonly TablePrivilege[] = [...EXTRA_PRIVILEGES, ...DML_PRIVILEGES];
|
package/src/relations.ts
CHANGED
|
@@ -5,11 +5,12 @@
|
|
|
5
5
|
* belongsTo(() => Author, 'authorId') // this.authorId → Author.<pk>
|
|
6
6
|
* hasMany(() => Comment, 'postId') // this.<pk> → Comment.postId
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* A table-name target avoids sibling imports; linkModels resolves it against the
|
|
9
|
+
* complete set. Model thunks remain supported for forward references.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import type { ModelDescriptor } from './model';
|
|
13
|
+
import { referenceThunk } from './link';
|
|
13
14
|
|
|
14
15
|
export interface Relation {
|
|
15
16
|
kind: 'belongsTo' | 'hasMany';
|
|
@@ -31,18 +32,18 @@ export interface Relation {
|
|
|
31
32
|
|
|
32
33
|
/** Many-to-one: this row points at one target row via `column`. */
|
|
33
34
|
export function belongsTo(
|
|
34
|
-
target: () => ModelDescriptor,
|
|
35
|
+
target: (() => ModelDescriptor) | string,
|
|
35
36
|
column: string,
|
|
36
37
|
references?: string,
|
|
37
38
|
): Relation {
|
|
38
|
-
return { kind: 'belongsTo', target, column, references };
|
|
39
|
+
return { kind: 'belongsTo', target: referenceThunk(target), column, references };
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
/** One-to-many: target rows point back at this row via `column`. */
|
|
42
43
|
export function hasMany(
|
|
43
|
-
target: () => ModelDescriptor,
|
|
44
|
+
target: (() => ModelDescriptor) | string,
|
|
44
45
|
column: string,
|
|
45
46
|
references?: string,
|
|
46
47
|
): Relation {
|
|
47
|
-
return { kind: 'hasMany', target, column, references };
|
|
48
|
+
return { kind: 'hasMany', target: referenceThunk(target), column, references };
|
|
48
49
|
}
|
package/src/wire.ts
CHANGED
|
@@ -143,8 +143,8 @@ export interface DerivedDescriptor {
|
|
|
143
143
|
/** Schema name, defaulted to `public` when unqualified. */
|
|
144
144
|
schema: string;
|
|
145
145
|
name: string;
|
|
146
|
-
/** `schema.name` —
|
|
147
|
-
*
|
|
146
|
+
/** Functions use `schema.name(argtypes)` — their full PostgreSQL identity and reconciler join
|
|
147
|
+
* key. Other objects use `schema.name`; triggers use `schema.table.name` and join pg_trigger. */
|
|
148
148
|
identity: string;
|
|
149
149
|
/** The CREATE statement as authored (no trailing semicolon). */
|
|
150
150
|
sql: string;
|