@everystack/model 0.4.20 → 0.4.21
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 +32 -0
- package/package.json +1 -1
- package/src/derive.ts +25 -2
- package/src/derived.ts +1 -1
- package/src/field.ts +5 -3
- package/src/index.ts +1 -0
- package/src/link.ts +126 -0
- package/src/model.ts +10 -45
- 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,38 @@ 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.21] - 2026-10-14
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- apply state prerequisites atomically
|
|
12
|
+
- link table name references through model thunks
|
|
13
|
+
- use table names in the example and gate import cycles
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- break the model and auth import cycles; gate new ones
|
|
18
|
+
- link cached placeholder references to their targets
|
|
19
|
+
- reject conflicting name bindings
|
|
20
|
+
- rowOwnership exceptRoles lifts the backstop for manage roles
|
|
21
|
+
- only a plain manage role lifts the rowOwnership backstop
|
|
22
|
+
|
|
23
|
+
## [Unreleased]
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
- apply state prerequisites atomically
|
|
28
|
+
|
|
29
|
+
## [0.4.20] - 2026-09-26
|
|
30
|
+
|
|
31
|
+
### BREAKING CHANGES
|
|
32
|
+
|
|
33
|
+
- defineModel refuses owner or via under a no-session audience
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
|
|
37
|
+
- refuse role: 'anon' combined with owner or via
|
|
38
|
+
|
|
7
39
|
## [0.4.19] - 2026-09-16
|
|
8
40
|
|
|
9
41
|
### Added
|
package/package.json
CHANGED
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,6 +13,7 @@ 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
18
|
export { unique, check, index, foreignKey, IndexBuilder } from './constraints';
|
|
18
19
|
export type { TableConstraint } from './constraints';
|
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
19
|
import 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);
|
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;
|