@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/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
- softDelete: { tables: string[] };
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 === 'belongsTo') {
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;
@@ -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 table name or model thunk. Names are resolved
235
- * by linkModels when the complete set is available. Optional
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
- this.spec.references = referenceThunk(target);
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
  }