@everystack/model 0.4.21 → 0.4.23
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 +48 -6
- package/README.md +207 -2
- package/package.json +1 -1
- package/src/constraints.ts +93 -9
- package/src/dependents.ts +168 -0
- package/src/derive.ts +46 -11
- package/src/derived.ts +1 -1
- package/src/drizzle.ts +4 -0
- package/src/external.ts +119 -0
- package/src/field.ts +27 -4
- package/src/index.ts +10 -6
- package/src/link.ts +40 -0
- package/src/model.ts +231 -6
- package/src/module.ts +8 -3
- package/src/relation-display.ts +48 -0
- package/src/relations.ts +207 -5
- package/src/wire.ts +4 -1
package/src/derive.ts
CHANGED
|
@@ -23,15 +23,24 @@ export interface RelationConfig {
|
|
|
23
23
|
from: string;
|
|
24
24
|
to: string;
|
|
25
25
|
many?: boolean;
|
|
26
|
+
/** To-one from the parent side: the first target row in `orderBy` order, or null. */
|
|
27
|
+
hasOne?: true;
|
|
28
|
+
orderBy?: Array<{ column: string; order: 'asc' | 'desc' }>;
|
|
29
|
+
limit?: number;
|
|
30
|
+
where?: Record<string, string | number | boolean | null>;
|
|
26
31
|
}
|
|
27
32
|
|
|
28
33
|
export interface DerivedHandlerConfig {
|
|
29
34
|
exposedTables: string[];
|
|
30
35
|
hiddenColumns: Record<string, string[]>;
|
|
36
|
+
/** Plain manage roles that may read lifecycle metadata; private fields stay hidden. */
|
|
37
|
+
hiddenColumnsExceptRoles?: Record<string, string[]>;
|
|
31
38
|
protectedFields: Record<string, string[]>;
|
|
32
39
|
/** `exceptRoles` is present only when a `can('manage', { role })` exists for the table. */
|
|
33
40
|
rowOwnership: Record<string, { column: string; userField: string; exceptRoles?: string[] }>;
|
|
34
|
-
|
|
41
|
+
/** `lifecycle` (present only when a lifecycle model exists) lists the `lifecycle: true` tables;
|
|
42
|
+
* each is also in `tables`, so the read filter applies to it. */
|
|
43
|
+
softDelete: { tables: string[]; lifecycle?: string[] };
|
|
35
44
|
relations: Record<string, Record<string, RelationConfig>>;
|
|
36
45
|
hooks: Record<string, ModelHooks>;
|
|
37
46
|
}
|
|
@@ -50,9 +59,11 @@ export function deriveHandlerConfig(models: ModelDescriptor[]): DerivedHandlerCo
|
|
|
50
59
|
linkModels(models);
|
|
51
60
|
const exposedTables: string[] = [];
|
|
52
61
|
const hiddenColumns: Record<string, string[]> = {};
|
|
62
|
+
const hiddenColumnsExceptRoles: Record<string, string[]> = {};
|
|
53
63
|
const protectedFields: Record<string, string[]> = {};
|
|
54
64
|
const rowOwnership: DerivedHandlerConfig['rowOwnership'] = {};
|
|
55
65
|
const softDeleteTables: string[] = [];
|
|
66
|
+
const lifecycleTables: string[] = [];
|
|
56
67
|
const relations: Record<string, Record<string, RelationConfig>> = {};
|
|
57
68
|
const hooks: Record<string, ModelHooks> = {};
|
|
58
69
|
|
|
@@ -79,6 +90,13 @@ export function deriveHandlerConfig(models: ModelDescriptor[]): DerivedHandlerCo
|
|
|
79
90
|
// The first owner-shaped ability → the handler's rowOwnership IDOR backstop.
|
|
80
91
|
// (RLS, compiled from the same ability in Phase 1, is the primary enforcement;
|
|
81
92
|
// this is the HTTP-path guard. A model's owner abilities share one column.)
|
|
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
|
+
];
|
|
82
100
|
const ownerAbility = model.abilities.find((a) => a.condition.owner);
|
|
83
101
|
if (ownerAbility) {
|
|
84
102
|
rowOwnership[table] = {
|
|
@@ -90,19 +108,20 @@ export function deriveHandlerConfig(models: ModelDescriptor[]): DerivedHandlerCo
|
|
|
90
108
|
// counts: a role-scoped update or delete alone does not lift it, and neither does a
|
|
91
109
|
// manage ability with any other condition, which narrows what RLS lets it write
|
|
92
110
|
// (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
111
|
if (exceptRoles.length) rowOwnership[table].exceptRoles = exceptRoles;
|
|
101
112
|
}
|
|
102
113
|
|
|
103
114
|
// Declared, not inferred — one `softDelete` decision drives both the API filter and the
|
|
104
115
|
// RLS guard, so the two can never disagree about what a soft-deleted row is.
|
|
105
116
|
if (model.softDelete) softDeleteTables.push(table);
|
|
117
|
+
// A lifecycle table soft-deletes in the database (its delete trigger); the handler reads it as
|
|
118
|
+
// a soft-delete table and drives that machinery instead of writing deleted_at itself.
|
|
119
|
+
if (model.lifecycle) {
|
|
120
|
+
softDeleteTables.push(table);
|
|
121
|
+
lifecycleTables.push(table);
|
|
122
|
+
hiddenColumns[table] = [...new Set([...(hiddenColumns[table] ?? []), 'deletedBy', 'deletedBatch', 'deletedXid'])];
|
|
123
|
+
hiddenColumnsExceptRoles[table] = exceptRoles;
|
|
124
|
+
}
|
|
106
125
|
|
|
107
126
|
if (model.hooks) hooks[table] = model.hooks;
|
|
108
127
|
|
|
@@ -112,7 +131,18 @@ export function deriveHandlerConfig(models: ModelDescriptor[]): DerivedHandlerCo
|
|
|
112
131
|
for (const name of relationNames) {
|
|
113
132
|
const rel = model.relations[name];
|
|
114
133
|
const target = rel.target();
|
|
115
|
-
if (rel.kind === '
|
|
134
|
+
if (rel.kind === 'hasOne') {
|
|
135
|
+
// this.<pk> → target.<column>, one row. linkModels normalized the orderBy: it always
|
|
136
|
+
// ends with the target's key, so the handler chooses the same row every time.
|
|
137
|
+
resolved[name] = {
|
|
138
|
+
table: target.table,
|
|
139
|
+
from: rel.references ?? primaryKeyOf(model),
|
|
140
|
+
to: rel.column,
|
|
141
|
+
hasOne: true,
|
|
142
|
+
orderBy: (rel.orderBy ?? []).map((t) => ({ column: t.field, order: t.order })),
|
|
143
|
+
};
|
|
144
|
+
if (rel.where) resolved[name].where = { ...rel.where };
|
|
145
|
+
} else if (rel.kind === 'belongsTo') {
|
|
116
146
|
// this.<column> → target.<pk>
|
|
117
147
|
resolved[name] = {
|
|
118
148
|
table: target.table,
|
|
@@ -127,18 +157,23 @@ export function deriveHandlerConfig(models: ModelDescriptor[]): DerivedHandlerCo
|
|
|
127
157
|
to: rel.column,
|
|
128
158
|
many: true,
|
|
129
159
|
};
|
|
160
|
+
// Model field names are the schema property names the handler resolves.
|
|
161
|
+
if (rel.orderBy) resolved[name].orderBy = rel.orderBy.map((t) => ({ column: t.field, order: t.order }));
|
|
162
|
+
if (rel.limit !== undefined) resolved[name].limit = rel.limit;
|
|
163
|
+
if (rel.where) resolved[name].where = { ...rel.where };
|
|
130
164
|
}
|
|
131
165
|
}
|
|
132
|
-
relations[table] = resolved;
|
|
166
|
+
if (Object.keys(resolved).length) relations[table] = resolved;
|
|
133
167
|
}
|
|
134
168
|
}
|
|
135
169
|
|
|
136
170
|
return {
|
|
137
171
|
exposedTables,
|
|
138
172
|
hiddenColumns,
|
|
173
|
+
...(lifecycleTables.length ? { hiddenColumnsExceptRoles } : {}),
|
|
139
174
|
protectedFields,
|
|
140
175
|
rowOwnership,
|
|
141
|
-
softDelete: { tables: softDeleteTables },
|
|
176
|
+
softDelete: { tables: softDeleteTables, ...(lifecycleTables.length ? { lifecycle: lifecycleTables } : {}) },
|
|
142
177
|
relations,
|
|
143
178
|
hooks,
|
|
144
179
|
};
|
package/src/derived.ts
CHANGED
|
@@ -88,7 +88,7 @@ function validateRelationFields(kind: string, name: string, fields: Record<strin
|
|
|
88
88
|
const s = builder.spec;
|
|
89
89
|
if (s.isPrimaryKey) throw new Error(`${at}: .primaryKey() is table structure — a relation column carries type, nullability, and array only.`);
|
|
90
90
|
if (s.isUnique) throw new Error(`${at}: .unique() is a table constraint — enforce it on the base table (or a matview index).`);
|
|
91
|
-
if (s.references) throw new Error(`${at}: .references() is a table FK — declare it on the base table; the body carries the join.`);
|
|
91
|
+
if (s.references || s.referencesExternal) throw new Error(`${at}: .references() is a table FK — declare it on the base table; the body carries the join.`);
|
|
92
92
|
if (s.defaultKind) throw new Error(`${at}: defaults are meaningless on a relation column — the body computes every value.`);
|
|
93
93
|
if (s.isDeprecated) throw new Error(`${at}: .deprecated() is the table contract phase — remove the column from the body instead.`);
|
|
94
94
|
if (s.isPrivate) throw new Error(`${at}: .private() hides a table column from the API — a relation exposes only what its body selects; drop the column from the body.`);
|
package/src/drizzle.ts
CHANGED
|
@@ -170,6 +170,10 @@ export function toDrizzleTable(model: ModelDescriptor): PgTableWithColumns<any>
|
|
|
170
170
|
|
|
171
171
|
if (spec.references) {
|
|
172
172
|
const target = spec.references as () => ModelDescriptor;
|
|
173
|
+
// The CHILD's declared actions only. A parent's hasMany `dependent` is a property of the
|
|
174
|
+
// model set (relationDeleteActions) and this table is built per model, so it is not here.
|
|
175
|
+
// The runtime table never emits DDL (the CLI does, from the set), so queries are unaffected.
|
|
176
|
+
// This is the one place it differs from the generated schema.generated.ts, which carries it.
|
|
173
177
|
const refOpts: { onDelete?: FieldSpec['onDelete']; onUpdate?: FieldSpec['onUpdate'] } = {};
|
|
174
178
|
if (spec.onDelete) refOpts.onDelete = spec.onDelete;
|
|
175
179
|
if (spec.onUpdate) refOpts.onUpdate = spec.onUpdate;
|
package/src/external.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An EXTERNAL foreign-key target: a table no model declares, named by its PostgreSQL identity.
|
|
3
|
+
*
|
|
4
|
+
* `field.uuid().references({ schema: 'auth', table: 'users', column: 'id' })` is how a model says
|
|
5
|
+
* "this column points into another system's table" (an auth provider's `auth.users`, a table a
|
|
6
|
+
* different service owns). Ratified rules:
|
|
7
|
+
*
|
|
8
|
+
* - schema, table and column are all REQUIRED and are three separate identifiers. Nothing is
|
|
9
|
+
* parsed for dots: an external FK crosses the governed boundary, so it says exactly where.
|
|
10
|
+
* - A string target stays a MODEL name. Only this object form names a table.
|
|
11
|
+
* - An object naming a table some model in the set declares is refused: use the model.
|
|
12
|
+
* - It is FK state only. It is never a relation (no belongsTo/hasMany, no embedding).
|
|
13
|
+
* - It compiles, diffs and fingerprints exactly like a model FK. Nothing downstream of the
|
|
14
|
+
* compiler can tell the two apart, so a live FK nothing declares is still visible drift.
|
|
15
|
+
*
|
|
16
|
+
* The names are PostgreSQL names, taken verbatim (no snake_casing: there is no model field to
|
|
17
|
+
* derive them from). They reach DDL quoted, so a name is refused when quoting cannot carry it.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { ModelDescriptor } from './model';
|
|
21
|
+
|
|
22
|
+
/** A table no model declares: the composite `foreignKey(cols, { schema, table }, refColumns)` target. */
|
|
23
|
+
export interface ExternalTableRef {
|
|
24
|
+
schema: string;
|
|
25
|
+
table: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A column of a table no model declares: the `.references({ schema, table, column })` target. */
|
|
29
|
+
export interface ExternalColumnRef extends ExternalTableRef {
|
|
30
|
+
column: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** NAMEDATALEN - 1 on a stock PostgreSQL build; a longer name is truncated with only a NOTICE. */
|
|
34
|
+
const MAX_IDENTIFIER_BYTES = 63;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Why a name cannot be an external identifier, or null when it can. The DDL quotes every part
|
|
38
|
+
* (`"auth"."users"`) without doubling quotes, and the FK IR carries `schema.table` as one dotted
|
|
39
|
+
* string, so a quote, a dot or a control character cannot be carried safely.
|
|
40
|
+
*/
|
|
41
|
+
export function externalIdentifierRefusal(name: unknown): string | null {
|
|
42
|
+
if (typeof name !== 'string') return `is ${name === null ? 'null' : typeof name}, not a string`;
|
|
43
|
+
if (name.length === 0) return 'is empty';
|
|
44
|
+
if (name.includes('"')) return 'holds a double quote';
|
|
45
|
+
// A line terminator or other control character: a name holding one could end a comment in
|
|
46
|
+
// generated source or forge a line in plan output and logs. No real table needs one.
|
|
47
|
+
if (/[\u0000-\u001f\u007f\u2028\u2029]/.test(name)) return 'holds a control character or line terminator';
|
|
48
|
+
if (name.includes('.')) return "holds a '.' (a name is one identifier; the schema is its own field)";
|
|
49
|
+
const bytes = new TextEncoder().encode(name).length;
|
|
50
|
+
if (bytes > MAX_IDENTIFIER_BYTES) return `is ${bytes} bytes (PostgreSQL identifiers are at most ${MAX_IDENTIFIER_BYTES})`;
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** `auth.users` — how every error and plan line names an external target. */
|
|
55
|
+
export function externalTargetName(ref: ExternalTableRef): string {
|
|
56
|
+
return `${String(ref.schema)}.${String(ref.table)}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function readRef<K extends string>(target: object, keys: readonly K[], call: string): Record<K, string> {
|
|
60
|
+
const shown = externalTargetName(target as ExternalTableRef);
|
|
61
|
+
for (const key of Object.keys(target)) {
|
|
62
|
+
if (!(keys as readonly string[]).includes(key)) {
|
|
63
|
+
throw new Error(`${call} target ${shown}: unknown key '${key}' (expected ${keys.join(', ')})`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const out = {} as Record<K, string>;
|
|
67
|
+
for (const key of keys) {
|
|
68
|
+
const value = (target as Record<string, unknown>)[key];
|
|
69
|
+
if (value === undefined) {
|
|
70
|
+
throw new Error(`${call} target ${shown}: ${key} is required (an external target names its ${keys.join(', ')}; there is no default)`);
|
|
71
|
+
}
|
|
72
|
+
const refusal = externalIdentifierRefusal(value);
|
|
73
|
+
if (refusal) throw new Error(`${call} target ${shown}: ${key} ${JSON.stringify(value)} ${refusal}`);
|
|
74
|
+
out[key] = value as string;
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Validate and copy a `.references({ schema, table, column })` target. */
|
|
80
|
+
export function externalColumnRef(target: object): ExternalColumnRef {
|
|
81
|
+
return Object.freeze(readRef(target, ['schema', 'table', 'column'] as const, 'references({ schema, table, column })'));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Validate and copy a composite `foreignKey(cols, { schema, table }, refColumns)` target and its columns. */
|
|
85
|
+
export function externalTableRef(target: object, refColumns: readonly string[], call: string): ExternalTableRef {
|
|
86
|
+
const ref = readRef(target, ['schema', 'table'] as const, call);
|
|
87
|
+
for (const c of refColumns) {
|
|
88
|
+
const refusal = externalIdentifierRefusal(c);
|
|
89
|
+
if (refusal) throw new Error(`${call} target ${externalTargetName(ref)}: reference column ${JSON.stringify(c)} ${refusal}`);
|
|
90
|
+
}
|
|
91
|
+
return Object.freeze(ref);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Refuse an external target that a model in this set declares: the author must use the model.
|
|
96
|
+
* Compared on RESOLVED identities (the model's schema, default `public`, and its table) across
|
|
97
|
+
* the whole set. Called by linkModels and by every compile entry point that holds the set.
|
|
98
|
+
*/
|
|
99
|
+
export function assertExternalTargets(models: readonly ModelDescriptor[]): void {
|
|
100
|
+
const declared = new Map<string, ModelDescriptor>();
|
|
101
|
+
for (const m of models) declared.set(`${m.schema ?? 'public'}\u0000${m.table}`, m);
|
|
102
|
+
const owner = (m: ModelDescriptor): string => `${m.schema ?? 'public'}.${m.table}`;
|
|
103
|
+
const check = (m: ModelDescriptor, ref: ExternalTableRef, what: string): void => {
|
|
104
|
+
const hit = declared.get(`${ref.schema}\u0000${ref.table}`);
|
|
105
|
+
if (!hit) return;
|
|
106
|
+
throw new Error(
|
|
107
|
+
`model '${owner(m)}': ${what} references ${externalTargetName(ref)}, which model '${owner(hit)}' declares: `
|
|
108
|
+
+ `use the model (.references(() => Model) or a model-name string), not the external { schema, table } form`,
|
|
109
|
+
);
|
|
110
|
+
};
|
|
111
|
+
for (const m of models) {
|
|
112
|
+
for (const [key, f] of Object.entries(m.fields)) {
|
|
113
|
+
if (f.spec.referencesExternal) check(m, f.spec.referencesExternal, `field '${key}'`);
|
|
114
|
+
}
|
|
115
|
+
for (const c of m.constraints) {
|
|
116
|
+
if (c.kind === 'foreignKey' && c.external) check(m, c.external, `foreignKey(${c.columns.join(', ')})`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
package/src/field.ts
CHANGED
|
@@ -15,6 +15,7 @@ import type { RawSql } from './raw-sql';
|
|
|
15
15
|
import { pgTypeRefusal } from './pg-type';
|
|
16
16
|
import { assertConstraintName, assertFkValidate } from './constraints';
|
|
17
17
|
import { referenceThunk } from './link';
|
|
18
|
+
import { externalColumnRef, type ExternalColumnRef } from './external';
|
|
18
19
|
|
|
19
20
|
export type FieldType =
|
|
20
21
|
| 'text'
|
|
@@ -80,6 +81,13 @@ export interface FieldSpec {
|
|
|
80
81
|
isDeprecated: boolean;
|
|
81
82
|
/** `.references(() => Other)` — FK target, a model thunk. Used by relation/ownership work. */
|
|
82
83
|
references?: () => unknown;
|
|
84
|
+
/**
|
|
85
|
+
* `.references({ schema, table, column })` — an FK to a table NO model declares (another
|
|
86
|
+
* system's `auth.users`). Set instead of {@link references}, never with it. FK state only: it is
|
|
87
|
+
* never a relation, so nothing that follows a model thunk ever sees it. It compiles, diffs and
|
|
88
|
+
* fingerprints exactly like a model FK. See external.ts.
|
|
89
|
+
*/
|
|
90
|
+
referencesExternal?: ExternalColumnRef;
|
|
83
91
|
/** `ON DELETE` action for the FK. Omitted (or `'no action'`) is the Postgres default. */
|
|
84
92
|
onDelete?: ReferentialAction;
|
|
85
93
|
/** `ON UPDATE` action for the FK. Omitted (or `'no action'`) is the Postgres default. */
|
|
@@ -231,8 +239,12 @@ export class FieldBuilder {
|
|
|
231
239
|
}
|
|
232
240
|
|
|
233
241
|
/**
|
|
234
|
-
* Declare a foreign key. The target is a
|
|
235
|
-
* by linkModels when the complete set is available
|
|
242
|
+
* Declare a foreign key. The target is a model thunk or a model NAME (a string is always a
|
|
243
|
+
* model name, resolved by linkModels when the complete set is available; one that names no
|
|
244
|
+
* model is an error, never a table). A table no model declares is named as an object:
|
|
245
|
+
* `.references({ schema: 'auth', table: 'users', column: 'id' })`, all three required, each a
|
|
246
|
+
* PostgreSQL name taken verbatim. That form is FK state only, never a relation, and is refused
|
|
247
|
+
* when a model in the set declares the table (use the model). Optional
|
|
236
248
|
* `onDelete`/`onUpdate` referential actions are column-scoped, so they live here, not on
|
|
237
249
|
* the table — `field.uuid().references(() => User, { onDelete: 'cascade' })`.
|
|
238
250
|
*
|
|
@@ -242,8 +254,19 @@ export class FieldBuilder {
|
|
|
242
254
|
* {@link FieldSpec.referencesValidate} for why a fifteen-year-old schema needs to be able
|
|
243
255
|
* to say it.
|
|
244
256
|
*/
|
|
245
|
-
references(target: (() => unknown) | string, opts?: { onDelete?: ReferentialAction; onUpdate?: ReferentialAction; name?: string; validate?: boolean }): this {
|
|
246
|
-
|
|
257
|
+
references(target: (() => unknown) | string | ExternalColumnRef, opts?: { onDelete?: ReferentialAction; onUpdate?: ReferentialAction; name?: string; validate?: boolean }): this {
|
|
258
|
+
// A second call REPLACES the target, as every other repeated modifier on this builder
|
|
259
|
+
// overwrites: each branch clears the other slot, so a field never holds both (the compiler
|
|
260
|
+
// would read one and relations or `via` the other).
|
|
261
|
+
if (typeof target === 'function' || typeof target === 'string') {
|
|
262
|
+
this.spec.references = referenceThunk(target);
|
|
263
|
+
delete this.spec.referencesExternal;
|
|
264
|
+
} else if (target !== null && typeof target === 'object') {
|
|
265
|
+
this.spec.referencesExternal = externalColumnRef(target);
|
|
266
|
+
delete this.spec.references;
|
|
267
|
+
} else {
|
|
268
|
+
throw new Error(`references(…): the target is ${target === null ? 'null' : typeof target}; pass a model thunk, a model name, or { schema, table, column }`);
|
|
269
|
+
}
|
|
247
270
|
if (opts?.onDelete) this.spec.onDelete = opts.onDelete;
|
|
248
271
|
if (opts?.onUpdate) this.spec.onUpdate = opts.onUpdate;
|
|
249
272
|
if (opts?.name !== undefined) this.spec.constraintName = assertConstraintName(opts.name, 'foreign key');
|
package/src/index.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* handler config. See docs/plans/v3-model-architecture.md.
|
|
4
4
|
*
|
|
5
5
|
* Ships the declaration surface (`defineModel`, `field`, `can`, `belongsTo`/
|
|
6
|
-
* `hasMany`); `@everystack/cli` compiles models to migrations, RLS + grants, and
|
|
6
|
+
* `hasMany`/`hasOne`); `@everystack/cli` compiles models to migrations, RLS + grants, and
|
|
7
7
|
* handler config (`deriveHandlerConfig`).
|
|
8
8
|
*/
|
|
9
9
|
|
|
@@ -14,8 +14,10 @@ export { canonicalColumnType, fieldFactoryCall, pgTypeRefusal } from './pg-type'
|
|
|
14
14
|
|
|
15
15
|
export { parseQualified } from './qualified';
|
|
16
16
|
export { linkModels, ModelLinkError } from './link';
|
|
17
|
+
export { assertExternalTargets, externalTargetName, externalIdentifierRefusal } from './external';
|
|
18
|
+
export type { ExternalTableRef, ExternalColumnRef } from './external';
|
|
17
19
|
|
|
18
|
-
export { unique, check, index, foreignKey, IndexBuilder } from './constraints';
|
|
20
|
+
export { unique, check, index, foreignKey, IndexBuilder, positionalCheckName } from './constraints';
|
|
19
21
|
export type { TableConstraint } from './constraints';
|
|
20
22
|
|
|
21
23
|
export { sql } from './raw-sql';
|
|
@@ -25,11 +27,13 @@ export { can, isColumnAbility, isPublicReadAbility, isDeleteAbility, needsSoftDe
|
|
|
25
27
|
export type { SoftDeleteFacts } from './can';
|
|
26
28
|
export type { Ability, AbilityCondition, Action } from './can';
|
|
27
29
|
|
|
28
|
-
export { belongsTo, hasMany } from './relations';
|
|
29
|
-
export type { Relation } from './relations';
|
|
30
|
+
export { belongsTo, hasMany, hasOne } from './relations';
|
|
31
|
+
export type { Relation, BelongsToOptions, HasManyOptions, HasOneOptions, HasManyDependent, RelationOrder, RelationWhere } from './relations';
|
|
32
|
+
export { relationDeleteActions } from './dependents';
|
|
33
|
+
export type { DependentActions } from './dependents';
|
|
30
34
|
|
|
31
|
-
export { defineModel, defineMaterializedTable, EXTRA_PRIVILEGES, DML_PRIVILEGES, TABLE_PRIVILEGES } from './model';
|
|
32
|
-
export type { ModelDescriptor, ModelDefinition, ModelHooks, WrittenBy, ExtraPrivilege, TablePrivilege, DmlPrivilege, MaterializedTableMarker, MaterializedTableDefinition } from './model';
|
|
35
|
+
export { defineModel, defineMaterializedTable, LIFECYCLE_COLUMNS, LIFECYCLE_OWNER_ROLE, EXTRA_PRIVILEGES, DML_PRIVILEGES, TABLE_PRIVILEGES } from './model';
|
|
36
|
+
export type { ModelDescriptor, ModelDefinition, ModelHooks, DestroyContext, DestroyHook, WrittenBy, ExtraPrivilege, TablePrivilege, DmlPrivilege, MaterializedTableMarker, MaterializedTableDefinition } from './model';
|
|
33
37
|
|
|
34
38
|
export { defineModule, moduleModels, moduleExtensions, moduleFunctionSql, moduleSequences, moduleDerived, moduleGovernedRoles } from './module';
|
|
35
39
|
export type { Module, ModuleDefinition } from './module';
|
package/src/link.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { ModelDescriptor } from './model';
|
|
2
2
|
import { parseQualified } from './qualified';
|
|
3
|
+
import { assertExternalTargets } from './external';
|
|
4
|
+
import { relationDeleteActions } from './dependents';
|
|
5
|
+
import { hasDisplayOptions, normalizeRelationDisplay } from './relation-display';
|
|
3
6
|
|
|
4
7
|
/** A failed or conflicting name binding must never become a usable model. */
|
|
5
8
|
export class ModelLinkError extends Error {
|
|
@@ -97,11 +100,14 @@ export function linkModels<T extends readonly ModelDescriptor[]>(models: T): T {
|
|
|
97
100
|
// Resolve everything before changing descriptors, so a typo can be corrected and retried
|
|
98
101
|
// without leaving earlier references bound to a different, incomplete set.
|
|
99
102
|
const replacements: Array<() => void> = [];
|
|
103
|
+
// What each name reference resolves to in THIS set, before anything is bound.
|
|
104
|
+
const resolvedNames = new Map<unknown, ModelDescriptor>();
|
|
100
105
|
for (const model of uniqueModels) {
|
|
101
106
|
for (const [key, field] of Object.entries(model.fields)) {
|
|
102
107
|
const target = field.spec.references;
|
|
103
108
|
if (target && isNameReference(target)) {
|
|
104
109
|
const resolved = resolve(target as NameReference<ModelDescriptor>, model, 'field', key);
|
|
110
|
+
resolvedNames.set(target, resolved);
|
|
105
111
|
if (target.boundTarget) continue;
|
|
106
112
|
const linked = Object.assign(() => resolved, {
|
|
107
113
|
refName: target.refName, refLocation: target.refLocation, boundTarget: resolved,
|
|
@@ -113,6 +119,7 @@ export function linkModels<T extends readonly ModelDescriptor[]>(models: T): T {
|
|
|
113
119
|
const target = relation.target;
|
|
114
120
|
if (isNameReference(target)) {
|
|
115
121
|
const resolved = resolve(target, model, 'relation', key);
|
|
122
|
+
resolvedNames.set(target, resolved);
|
|
116
123
|
if (target.boundTarget) continue;
|
|
117
124
|
const linked = Object.assign(() => resolved, {
|
|
118
125
|
refName: target.refName, refLocation: target.refLocation, boundTarget: resolved,
|
|
@@ -121,6 +128,39 @@ export function linkModels<T extends readonly ModelDescriptor[]>(models: T): T {
|
|
|
121
128
|
}
|
|
122
129
|
}
|
|
123
130
|
}
|
|
131
|
+
// An external `{ schema, table }` target naming a table this set models is refused: the
|
|
132
|
+
// author must use the model. Checked before anything is bound, like every error above.
|
|
133
|
+
try {
|
|
134
|
+
assertExternalTargets(uniqueModels);
|
|
135
|
+
} catch (err) {
|
|
136
|
+
throw new ModelLinkError(err instanceof Error ? err.message : String(err));
|
|
137
|
+
}
|
|
138
|
+
// hasMany `dependent` → the child FK's ON DELETE: checked here, before anything is bound, and
|
|
139
|
+
// never written into a spec (the compiler derives it per set). Only a relation that declares a
|
|
140
|
+
// dependent has its target (or its child's FK target) evaluated.
|
|
141
|
+
try {
|
|
142
|
+
relationDeleteActions(uniqueModels, (thunk) => resolvedNames.get(thunk) ?? thunk() as ModelDescriptor);
|
|
143
|
+
} catch (err) {
|
|
144
|
+
if (err instanceof ModelLinkError) throw err;
|
|
145
|
+
throw new ModelLinkError(err instanceof Error ? err.message : String(err));
|
|
146
|
+
}
|
|
147
|
+
// Display options (orderBy, where; every hasOne) name TARGET fields: checked here, before
|
|
148
|
+
// anything is bound, and the orderBy normalized once (the target's primary key appended).
|
|
149
|
+
// Only such a relation has its target evaluated. Display options never reach a dependent.
|
|
150
|
+
for (const model of uniqueModels) {
|
|
151
|
+
for (const [key, relation] of Object.entries(model.relations)) {
|
|
152
|
+
if (relation.kind === 'belongsTo' || !hasDisplayOptions(relation)) continue;
|
|
153
|
+
let orderBy: ReturnType<typeof normalizeRelationDisplay>;
|
|
154
|
+
try {
|
|
155
|
+
const target = resolvedNames.get(relation.target) ?? relation.target();
|
|
156
|
+
orderBy = normalizeRelationDisplay(modelName(model), key, relation, target);
|
|
157
|
+
} catch (err) {
|
|
158
|
+
if (err instanceof ModelLinkError) throw err;
|
|
159
|
+
throw new ModelLinkError(err instanceof Error ? err.message : String(err));
|
|
160
|
+
}
|
|
161
|
+
if (orderBy) replacements.push(() => { relation.orderBy = orderBy; });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
124
164
|
for (const replace of replacements) replace();
|
|
125
165
|
return models;
|
|
126
166
|
}
|