@everystack/model 0.4.22 → 0.4.24

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.
@@ -0,0 +1,236 @@
1
+ /**
2
+ * hasMany `dependent` → the ON DELETE of the CHILD's foreign key.
3
+ *
4
+ * `hasMany(() => Comment, 'postId', { dependent: 'nullify' })` on posts says: deleting a post sets
5
+ * comments.post_id to NULL. The foreign key that does it belongs to comments, so the compiler
6
+ * gives the child's FK on `postId` the action `set null`, and a plan shows it as an ordinary FK
7
+ * change.
8
+ *
9
+ * The result is a property of the MODEL SET, never of a field spec. `relationDeleteActions(models)`
10
+ * is a pure function: it returns a map keyed by (child model, field key) and writes nothing. Every
11
+ * FK reader that holds the set consults it. A field builder shared by two children, or a child
12
+ * reused in two sets with different parents, therefore compiles per (set, child), and compiling
13
+ * one set never changes what another compiles to.
14
+ *
15
+ * Ratified rules (F7):
16
+ * - 'restrict' → 'restrict', 'nullify' → 'set null', 'cascade' → 'cascade'.
17
+ * - Lifecycle (P1.2): a lifecycle parent's 'cascade' needs a lifecycle child and compiles to
18
+ * 'restrict' (the soft-delete cascade runs in triggers). Any other cascade into a lifecycle
19
+ * table is refused, and so is a lifecycle parent's 'cascade' or 'restrict' onto a plain child.
20
+ * - Neither side wins. Both may declare; agreeing is fine, disagreeing is an error naming both.
21
+ * - Matched on (target, column): a child's other FKs to the same parent are untouched.
22
+ * - 'nullify' on a NOT NULL child column is an error (the delete would fail).
23
+ * - The child must declare `.references()` to this model. A dependent never creates a foreign
24
+ * key: the child file must show its own constraint.
25
+ *
26
+ * linkModels runs the same checks at load. With no dependent in the set the map is empty and no
27
+ * thunk is called.
28
+ */
29
+
30
+ import type { ReferentialAction } from './field';
31
+ import type { ModelDescriptor } from './model';
32
+ import type { HasManyDependent, Relation } from './relations';
33
+
34
+ const ACTION: Record<HasManyDependent, ReferentialAction> = { restrict: 'restrict', nullify: 'set null', cascade: 'cascade' };
35
+
36
+ /** The derived ON DELETE per child model, per field key. Empty when the set declares no dependent. */
37
+ export type DependentActions = ReadonlyMap<ModelDescriptor, ReadonlyMap<string, ReferentialAction>>;
38
+
39
+ const nameOf = (m: ModelDescriptor): string => (!m.schema || m.schema === 'public' ? m.table : `${m.schema}.${m.table}`);
40
+
41
+ /**
42
+ * Why an ON DELETE CASCADE into a lifecycle table is refused, and the fix. One text for every
43
+ * declaration site (a parent's hasMany, the child's own onDelete), so the reason cannot drift.
44
+ */
45
+ export function lifecycleCascadeHarm(where: string): string {
46
+ return (
47
+ `ON DELETE CASCADE into a lifecycle table is refused. The cascaded delete is turned into a soft delete, `
48
+ + `so the parent row is removed and ${where} still points at it. `
49
+ + `Use 'restrict' or 'nullify' (onDelete 'restrict' or 'set null') instead; `
50
+ + `to soft-delete the children with their parent, make the parent lifecycle: true and declare hasMany { dependent: 'cascade' } on it`
51
+ );
52
+ }
53
+
54
+ /**
55
+ * A lifecycle parent's hasMany with both a `dependent` and `where`: `where` is display only and never
56
+ * narrows what a delete does, so the pair reads as a filtered cascade or restrict that does not
57
+ * exist. Refused on a lifecycle parent; a plain parent keeps accepting it (declarations predate this).
58
+ */
59
+ export function dependentWhereRefusal(parent: string, relation: string, rel: { dependent?: unknown; where?: unknown }): string | undefined {
60
+ if (rel.dependent === undefined || rel.where === undefined) return undefined;
61
+ return `model '${parent}': relation '${relation}' declares dependent '${String(rel.dependent)}' with where: where is display only and never narrows a dependent. `
62
+ + 'Use an unfiltered dependent and a separate display relation, or drop where.';
63
+ }
64
+
65
+ /**
66
+ * Validate every dependent in the set and return the actions it implies. Pure: nothing is written.
67
+ * `targetOf` resolves a thunk; linkModels passes one that knows the names it has resolved but not
68
+ * yet bound, so an error leaves nothing half-linked.
69
+ */
70
+ export function relationDeleteActions(
71
+ models: readonly ModelDescriptor[],
72
+ targetOf: (thunk: () => unknown) => ModelDescriptor = (thunk) => thunk() as ModelDescriptor,
73
+ ): DependentActions {
74
+ // Keyed by (child, field key); `site`/`dependent` name the relation for a later disagreement.
75
+ const planned = new Map<ModelDescriptor, Map<string, { onDelete: ReferentialAction; site: string; dependent: string }>>();
76
+ for (const parent of new Set(models)) {
77
+ for (const [name, rel] of Object.entries(parent.relations) as Array<[string, Relation]>) {
78
+ if (rel.dependent === undefined) continue;
79
+ const dependent = rel.dependent;
80
+ const site = `model '${nameOf(parent)}': relation '${name}'`;
81
+ const at = `${site} declares dependent '${dependent}'`;
82
+ if (rel.kind !== 'hasMany' || !Object.hasOwn(ACTION, dependent)) {
83
+ // defineModel refuses these shapes; a hand-edited descriptor still never compiles.
84
+ throw new Error(`${at}, which is not a hasMany 'restrict', 'nullify' or 'cascade'`);
85
+ }
86
+ const filtered = parent.lifecycle ? dependentWhereRefusal(nameOf(parent), name, rel) : undefined;
87
+ if (filtered) throw new Error(filtered);
88
+ const child = targetOf(rel.target);
89
+ const where = `${nameOf(child)}.${rel.column}`;
90
+ const f = child.fields[rel.column];
91
+ if (!f) throw new Error(`${at}, but model '${nameOf(child)}' has no field '${rel.column}'`);
92
+ const spec = f.spec;
93
+
94
+ if (!spec.references) {
95
+ if (spec.referencesExternal) {
96
+ const ext = spec.referencesExternal;
97
+ throw new Error(
98
+ `${at}, but ${where} references '${ext.schema}.${ext.table}', not '${nameOf(parent)}': `
99
+ + `declare .references() on ${where} to this model (dependent never creates a foreign key)`,
100
+ );
101
+ }
102
+ const covering = child.constraints.find((c) => c.kind === 'foreignKey' && c.columns.includes(rel.column));
103
+ if (covering && covering.kind === 'foreignKey') {
104
+ throw new Error(
105
+ `${at}, but ${where} is covered by a table-level foreignKey(${covering.columns.join(', ')}): `
106
+ + 'dependent applies only to a field .references(); declare onDelete on that foreignKey(...) instead',
107
+ );
108
+ }
109
+ throw new Error(
110
+ `${at}, but ${where} has no foreign key to '${nameOf(parent)}': `
111
+ + `declare .references() on ${where} (dependent never creates a foreign key)`,
112
+ );
113
+ }
114
+ const referenced = targetOf(spec.references);
115
+ if (referenced !== parent) {
116
+ throw new Error(
117
+ `${at}, but ${where} references '${nameOf(referenced)}', not '${nameOf(parent)}': `
118
+ + `declare .references() on ${where} to this model (dependent never creates a foreign key)`,
119
+ );
120
+ }
121
+ const pk = parent.primaryKey[0] ?? 'id';
122
+ if (rel.references !== undefined && rel.references !== pk) {
123
+ throw new Error(
124
+ `${at} with references '${rel.references}', but ${where}'s foreign key points at the primary key '${pk}': `
125
+ + 'dependent needs the relation to join on that key',
126
+ );
127
+ }
128
+ if (dependent === 'nullify' && spec.isNotNull) {
129
+ throw new Error(
130
+ `${at}, but ${where} is NOT NULL: ON DELETE SET NULL would fail on every delete; `
131
+ + "make the field nullable or use dependent 'restrict'",
132
+ );
133
+ }
134
+
135
+ if (dependent === 'cascade' && child.lifecycle && !parent.lifecycle) {
136
+ throw new Error(`${at}, but ${where} is in a lifecycle table: ${lifecycleCascadeHarm(where)}`);
137
+ }
138
+ if (dependent === 'cascade' && parent.lifecycle && !child.lifecycle) {
139
+ throw new Error(
140
+ `${at}, but ${where} is not in a lifecycle table. Deleting a lifecycle row is a soft delete, so the parent `
141
+ + `row stays and its foreign key never fires: ${where} would stay live under a deleted parent. `
142
+ + `Make '${nameOf(child)}' lifecycle: true (its rows are then soft-deleted with the parent), `
143
+ + `or use dependent 'restrict' or 'nullify'`,
144
+ );
145
+ }
146
+
147
+ if (dependent === 'restrict' && parent.lifecycle && !child.lifecycle) {
148
+ throw new Error(
149
+ `${at}, but ${where} is not in a lifecycle table. Deleting a lifecycle row is a soft delete, so the foreign key `
150
+ + `never fires; the restrict check runs in a trigger as the lifecycle role, which holds no rights on a plain table. `
151
+ + `Make '${nameOf(child)}' lifecycle: true, or use dependent 'nullify'`,
152
+ );
153
+ }
154
+
155
+ // A lifecycle parent soft-deletes its lifecycle 'cascade' children through triggers
156
+ // (lifecycle-derived.ts), never through the foreign key: its DELETE never removes the row,
157
+ // and an RI cascade into a lifecycle table would leave the child pointing at a removed
158
+ // parent once one is purged. So that foreign key is RESTRICT; the relation keeps 'cascade'.
159
+ const onDelete: ReferentialAction = dependent === 'cascade' && parent.lifecycle ? 'restrict' : ACTION[dependent];
160
+ const forChild = planned.get(child) ?? new Map();
161
+ const earlier = forChild.get(rel.column);
162
+ if (earlier && earlier.onDelete !== onDelete) {
163
+ throw new Error(
164
+ `${at} (ON DELETE ${onDelete.toUpperCase()}), but ${earlier.site} declares dependent '${earlier.dependent}' `
165
+ + `for the same foreign key ${where}: they disagree`,
166
+ );
167
+ }
168
+ if (spec.onDelete !== undefined && spec.onDelete !== onDelete) {
169
+ throw new Error(
170
+ `${at} (ON DELETE ${onDelete.toUpperCase()}), but model '${nameOf(child)}': field '${rel.column}' `
171
+ + `declares onDelete '${spec.onDelete}': they disagree; declare one, or make them agree`,
172
+ );
173
+ }
174
+ if (!earlier) forChild.set(rel.column, { onDelete, site, dependent });
175
+ planned.set(child, forChild);
176
+ }
177
+ }
178
+ const out = new Map<ModelDescriptor, ReadonlyMap<string, ReferentialAction>>();
179
+ for (const [child, fields] of planned) out.set(child, new Map([...fields].map(([k, v]) => [k, v.onDelete])));
180
+ return out;
181
+ }
182
+
183
+ /**
184
+ * The child FK columns a lifecycle parent's dependents need indexed, per child model. A property
185
+ * of the MODEL SET like {@link relationDeleteActions}: pure, nothing written, every reader that
186
+ * compiles indexes from the set consults it.
187
+ *
188
+ * Why: the cascade trigger on a middle table runs one UPDATE on the child per stamped row, the
189
+ * restrict check counts the child by the FK, and purge's ON DELETE SET NULL ('nullify') runs
190
+ * PostgreSQL's referential action by the FK, once per removed parent. Without an index each is a
191
+ * full scan of the child (measured: a 100k-child delete 15.5 s without, 1.4 s with; a 1,000-parent
192
+ * purge over 600k SET NULL children 21.4 s without, 0.6 s with).
193
+ *
194
+ * Rules: only a LIFECYCLE parent's hasMany with a dependent; field keys, one column (a dependent
195
+ * rides a field `.references()`, never a composite foreignKey); skipped when the child already has
196
+ * a btree index, unique or primary key whose LEADING column is that one (a partial, hash or
197
+ * expression index does not serve every row); one entry per column however many edges share it;
198
+ * column lists sorted for a stable result.
199
+ */
200
+ export type DependentIndexes = ReadonlyMap<ModelDescriptor, ReadonlyArray<readonly string[]>>;
201
+
202
+ const leads = (columns: readonly string[], fk: readonly string[]): boolean =>
203
+ fk.length > 0 && fk.every((c, i) => columns[i] === c);
204
+
205
+ function servedBy(child: ModelDescriptor, fk: readonly string[]): boolean {
206
+ if (leads(child.primaryKey, fk)) return true;
207
+ if (fk.length === 1 && child.fields[fk[0]]?.spec.isUnique) return true;
208
+ return child.constraints.some((c) => {
209
+ if (c.kind === 'unique') return leads(c.columns, fk);
210
+ if (c.kind !== 'index') return false;
211
+ if (c.predicate !== undefined || (c.method !== undefined && c.method !== 'btree')) return false;
212
+ if (fk.some((_, i) => c.rawPositions?.[i])) return false;
213
+ return leads(c.columns, fk);
214
+ });
215
+ }
216
+
217
+ export function lifecycleDependentIndexes(
218
+ models: readonly ModelDescriptor[],
219
+ targetOf: (thunk: () => unknown) => ModelDescriptor = (thunk) => thunk() as ModelDescriptor,
220
+ ): DependentIndexes {
221
+ const out = new Map<ModelDescriptor, string[][]>();
222
+ for (const parent of new Set(models)) {
223
+ if (parent.lifecycle !== true) continue;
224
+ for (const rel of Object.values(parent.relations) as Relation[]) {
225
+ if (rel.kind !== 'hasMany' || rel.dependent === undefined) continue;
226
+ const child = targetOf(rel.target);
227
+ const fk = [rel.column];
228
+ if (servedBy(child, fk)) continue;
229
+ const list = out.get(child) ?? [];
230
+ if (!list.some((l) => l.join('\u0000') === fk.join('\u0000'))) list.push(fk);
231
+ out.set(child, list);
232
+ }
233
+ }
234
+ for (const list of out.values()) list.sort((a, b) => a.join('\u0000').localeCompare(b.join('\u0000')));
235
+ return out;
236
+ }
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,6 +14,8 @@ 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
20
  export { unique, check, index, foreignKey, IndexBuilder, positionalCheckName } from './constraints';
19
21
  export type { TableConstraint } from './constraints';
@@ -25,11 +27,14 @@ 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, lifecycleDependentIndexes } from './dependents';
33
+ export type { DependentActions, DependentIndexes } 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 { veto, isVeto } from './lifecycle-hooks';
36
+ export { defineModel, defineMaterializedTable, LIFECYCLE_COLUMNS, LIFECYCLE_OWNER_ROLE, EXTRA_PRIVILEGES, DML_PRIVILEGES, TABLE_PRIVILEGES } from './model';
37
+ export type { ModelDescriptor, ModelDefinition, ModelHooks, LifecycleOptions, DestroyContext, DestroyHook, LifecycleContext, HookVeto, BeforeLifecycleHook, AfterLifecycleHook, WrittenBy, ExtraPrivilege, TablePrivilege, DmlPrivilege, MaterializedTableMarker, MaterializedTableDefinition } from './model';
33
38
 
34
39
  export { defineModule, moduleModels, moduleExtensions, moduleFunctionSql, moduleSequences, moduleDerived, moduleGovernedRoles } from './module';
35
40
  export type { Module, ModuleDefinition } from './module';
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The lifecycle hook contract, shared by the model, the API handler and purge.
3
+ * `@everystack/api` carries a structurally identical copy (it has no runtime dependencies);
4
+ * a veto made by either package is recognized by both.
5
+ */
6
+
7
+ const VETO_KIND = 'everystack.hookVeto';
8
+
9
+ /** What a before hook returns to refuse its operation. Make it with {@link veto}. */
10
+ export interface HookVeto {
11
+ readonly kind: typeof VETO_KIND;
12
+ readonly reason: string;
13
+ }
14
+
15
+ /** Refuse the operation a before hook guards. A throw is a failure; a veto is a decision. */
16
+ export function veto(reason: string): HookVeto {
17
+ if (typeof reason !== 'string' || reason.trim() === '') throw new TypeError('veto(reason) needs a non-empty reason string');
18
+ return Object.freeze({ kind: VETO_KIND, reason });
19
+ }
20
+
21
+ /** True only for a value {@link veto} made (or its twin in `@everystack/api`). */
22
+ export function isVeto(value: unknown): value is HookVeto {
23
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
24
+ && (value as { kind?: unknown }).kind === VETO_KIND && typeof (value as { reason?: unknown }).reason === 'string';
25
+ }
26
+
27
+ /** The one context every lifecycle hook receives. */
28
+ export interface LifecycleContext {
29
+ /** `stamp`: the row was marked deleted. `restore`: a batch came back. `destroy`: physical removal. */
30
+ phase: 'stamp' | 'restore' | 'destroy';
31
+ /** `api`: a request through createHandler. `purge`: purgeExpired. */
32
+ source: 'api' | 'purge';
33
+ /** The lifecycle batch, or null when there is none to report (before a stamp, a non-lifecycle table, a pre-batch row). Never invented. */
34
+ batch: string | null;
35
+ user: Record<string, unknown> | null;
36
+ client: Record<string, unknown> | null;
37
+ /** Restore only: what the restore function reported. */
38
+ counts?: { restored: number; skipped: number };
39
+ }
40
+
41
+ /** Context for a real removal. Hooks must be idempotent: retries can repeat external effects. */
42
+ export interface DestroyContext extends LifecycleContext {
43
+ phase: 'destroy';
44
+ }
45
+
46
+ /** beforeDestroy may return {@link veto}; afterDestroy's return value is ignored. */
47
+ export type DestroyHook = (row: Record<string, unknown>, ctx: DestroyContext) => void | HookVeto | Promise<void | HookVeto>;
48
+
49
+ /** A before hook of the lifecycle: called once per root row; returns nothing or a veto. */
50
+ export type BeforeLifecycleHook = (row: Record<string, unknown>, ctx: LifecycleContext) => void | HookVeto | Promise<void | HookVeto>;
51
+
52
+ /** An after hook of the lifecycle: called after commit with the root rows of one batch. Best-effort. */
53
+ export type AfterLifecycleHook = (rows: Record<string, unknown>[], ctx: LifecycleContext) => void | Promise<void>;