@everystack/model 0.4.18 → 0.4.20

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 CHANGED
@@ -4,11 +4,12 @@ All notable changes to `@everystack/model` are documented here.
4
4
 
5
5
  Format based on [Keep a Changelog](https://keepachangelog.com/).
6
6
 
7
- ## [Unreleased]
7
+ ## [0.4.19] - 2026-09-16
8
8
 
9
9
  ### Added
10
10
 
11
11
  - automate the version-drift check — a manual note is not a gate
12
+ - validate: false on a foreign key, and a per-key apply preflight
12
13
 
13
14
  ## [0.4.18] - 2026-09-16
14
15
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/model",
3
- "version": "0.4.18",
3
+ "version": "0.4.20",
4
4
  "description": "Declare a table's schema, validation, and authorization once — compiled to migrations, RLS, and handler config",
5
5
  "license": "AGPL-3.0-only",
6
6
  "publishConfig": {
package/src/can.ts CHANGED
@@ -138,6 +138,23 @@ export function isPublicReadAbility(ability: Ability): boolean {
138
138
  && (!ability.condition.role || ability.condition.role === 'anon');
139
139
  }
140
140
 
141
+ /**
142
+ * The no-session audience a condition names, spelled as declared — or null.
143
+ *
144
+ * `role: 'anon'` IS the caller with no session; `policyRoles: 'public'` admits that caller. An
145
+ * `owner`/`via` scope compares the row to the caller's JWT claim, which neither can supply, so
146
+ * the pair never does what it says: under anon it matches no row, and under PUBLIC the compiler
147
+ * drops the open audience and emits the signed-in owner policy. ONE definition, because two
148
+ * surfaces enforce it: `defineModel` refuses the pair and `db:pull` never renders it.
149
+ */
150
+ export function sessionlessAudience(
151
+ condition: Pick<AbilityCondition, 'role' | 'policyRoles'>,
152
+ ): "role: 'anon'" | "policyRoles: 'public'" | null {
153
+ if (condition.role === 'anon') return "role: 'anon'";
154
+ if (condition.policyRoles === 'public') return "policyRoles: 'public'";
155
+ return null;
156
+ }
157
+
141
158
  /**
142
159
  * Does this ability hand out DELETE?
143
160
  *
@@ -25,7 +25,10 @@ export type TableConstraint =
25
25
  * `.references()` can't carry. Local field keys, a model thunk for the target, and the target's
26
26
  * field keys; optional `onDelete`/`onUpdate` referential actions.
27
27
  */
28
- | { kind: 'foreignKey'; columns: string[]; references: () => unknown; refColumns: string[]; onDelete?: ReferentialAction; onUpdate?: ReferentialAction; constraintName?: string };
28
+ | { kind: 'foreignKey'; columns: string[]; references: () => unknown; refColumns: string[]; onDelete?: ReferentialAction; onUpdate?: ReferentialAction; constraintName?: string;
29
+ /** `validate: false` — see {@link foreignKey}. Stored only when FALSE; the default
30
+ * (validate against the rows already there) is the absence of the field. */
31
+ validate?: boolean };
29
32
 
30
33
  /** A composite unique constraint over the given field keys: `unique('authorId', 'slug')`. */
31
34
  export function unique(...columns: string[]): TableConstraint {
@@ -42,12 +45,17 @@ export function check(predicate: RawSql): TableConstraint {
42
45
  * surface can express: `foreignKey(['orgId', 'teamId'], () => Team, ['orgId', 'teamId'])`. The
43
46
  * target columns are explicit (a composite FK references a composite key, not just the PK), and
44
47
  * the referential actions ride here the same way they ride on `.references()`.
48
+ *
49
+ * `validate: false` means the same thing it means on `.references()` — "enforce forward,
50
+ * grandfather legacy": the key is installed `NOT VALID` and is never asked to prove itself
51
+ * against the rows that were already there. See {@link FieldBuilder.references} for the whole
52
+ * rationale; it is one declared intent with two surfaces, not two features.
45
53
  */
46
54
  export function foreignKey(
47
55
  columns: string[],
48
56
  references: () => unknown,
49
57
  refColumns: string[],
50
- opts?: { onDelete?: ReferentialAction; onUpdate?: ReferentialAction; name?: string },
58
+ opts?: { onDelete?: ReferentialAction; onUpdate?: ReferentialAction; name?: string; validate?: boolean },
51
59
  ): TableConstraint {
52
60
  return {
53
61
  kind: 'foreignKey',
@@ -58,9 +66,32 @@ export function foreignKey(
58
66
  ...(opts?.onUpdate ? { onUpdate: opts.onUpdate } : {}),
59
67
  // The generated name is the FALLBACK; an adopted database keeps the name it arrived with.
60
68
  ...(opts?.name !== undefined ? { constraintName: assertConstraintName(opts.name, 'foreign key') } : {}),
69
+ // FALSE is the only value worth storing: `true` is the default, and storing it would make
70
+ // two declarations of the same intent compile to two different shapes.
71
+ ...(assertFkValidate(opts?.validate, `foreignKey(${columns.join(', ')})`) === false ? { validate: false } : {}),
61
72
  };
62
73
  }
63
74
 
75
+ /**
76
+ * Validate a `validate:` option, returning it. Declared at the DECLARATION, like
77
+ * {@link assertConstraintName}, because the value decides whether a `VALIDATE CONSTRAINT`
78
+ * is ever emitted — and a truthy string (`validate: 'no'`) would silently read as "yes,
79
+ * validate", which is the reverse of what the author wrote.
80
+ *
81
+ * `undefined` passes through (the option was not given). Anything that is not a boolean is
82
+ * refused by name: there is no third state for this flag, so a coercion could only guess.
83
+ */
84
+ export function assertFkValidate(value: unknown, where: string): boolean | undefined {
85
+ if (value === undefined) return undefined;
86
+ if (typeof value !== 'boolean') {
87
+ throw new Error(
88
+ `${where}: validate must be true or false, not ${typeof value === 'object' ? JSON.stringify(value) : JSON.stringify(String(value))}. `
89
+ + 'It decides whether the foreign key is ever checked against the rows already in the table, so it is refused here rather than coerced.',
90
+ );
91
+ }
92
+ return value;
93
+ }
94
+
64
95
  /**
65
96
  * A standalone index over the given keys, chainable: `index('authorId')`,
66
97
  * `index('authorId').unique()`, `index('slug').where(sql\`deleted_at IS NULL\`)` (partial),
package/src/field.ts CHANGED
@@ -13,7 +13,7 @@
13
13
 
14
14
  import type { RawSql } from './raw-sql';
15
15
  import { pgTypeRefusal } from './pg-type';
16
- import { assertConstraintName } from './constraints';
16
+ import { assertConstraintName, assertFkValidate } from './constraints';
17
17
 
18
18
  export type FieldType =
19
19
  | 'text'
@@ -93,6 +93,29 @@ export interface FieldSpec {
93
93
  * stayed green. Same defect as index names, one object over.
94
94
  */
95
95
  constraintName?: string;
96
+ /**
97
+ * `validate: false` — install the foreign key `NOT VALID` and never ask it to prove itself
98
+ * against the rows that were already there. Present (and FALSE) only when declared; the
99
+ * default is a key that must be fully valid, exactly as before.
100
+ *
101
+ * WHY IT EXISTS — "enforce forward, grandfather legacy", the pattern a long-lived database
102
+ * reaches for when referential integrity is adopted late. A `NOT VALID` foreign key is fully
103
+ * enforced on every NEW write; what it skips is the one-time scan of the existing rows. On a
104
+ * schema that ran for years without the constraint, some of those rows legitimately point at
105
+ * parents that were deleted — 11,500 of them across 12 keys on one adopted database, all of
106
+ * it ordinary decay. Validating is then not a migration, it is a data-archaeology project,
107
+ * and holding every unrelated change behind it is the wrong trade.
108
+ *
109
+ * So this is a FIRST-CLASS, PERMANENT declared state, not a temporary escape hatch: a live
110
+ * `NOT VALID` key that matches a `validate: false` declaration is a clean match forever, and
111
+ * stays one until someone edits the model to drop the flag. The compiler turns it into
112
+ * `ForeignKey.notValid`, the differ emits `ADD CONSTRAINT … NOT VALID` with no paired
113
+ * `VALIDATE`, and the apply's row preflight is never asked about the key at all.
114
+ *
115
+ * It is the opposite of `NOT ENFORCED`: new writes are still refused. Nothing here ever
116
+ * deletes or rewrites a row — the orphans stay, named for a human to decide about.
117
+ */
118
+ referencesValidate?: boolean;
96
119
  /**
97
120
  * `GENERATED ALWAYS AS IDENTITY` / `GENERATED BY DEFAULT AS IDENTITY`.
98
121
  *
@@ -210,12 +233,22 @@ export class FieldBuilder {
210
233
  * Declare a foreign key. The target is a model thunk (forward-reference safe). Optional
211
234
  * `onDelete`/`onUpdate` referential actions are column-scoped, so they live here, not on
212
235
  * the table — `field.uuid().references(() => User, { onDelete: 'cascade' })`.
236
+ *
237
+ * `validate: false` declares "enforce forward, grandfather legacy": the key is installed
238
+ * `NOT VALID`, so every new write is refused as normal while the rows that were already
239
+ * there are never scanned. It is a permanent declared state, not a migration step — see
240
+ * {@link FieldSpec.referencesValidate} for why a fifteen-year-old schema needs to be able
241
+ * to say it.
213
242
  */
214
- references(target: () => unknown, opts?: { onDelete?: ReferentialAction; onUpdate?: ReferentialAction; name?: string }): this {
243
+ references(target: () => unknown, opts?: { onDelete?: ReferentialAction; onUpdate?: ReferentialAction; name?: string; validate?: boolean }): this {
215
244
  this.spec.references = target;
216
245
  if (opts?.onDelete) this.spec.onDelete = opts.onDelete;
217
246
  if (opts?.onUpdate) this.spec.onUpdate = opts.onUpdate;
218
247
  if (opts?.name !== undefined) this.spec.constraintName = assertConstraintName(opts.name, 'foreign key');
248
+ // Only FALSE is recorded — `true` is the default, and storing it would give one intent two
249
+ // compiled shapes. A non-boolean is refused by name rather than coerced (a truthy string
250
+ // would read as the OPPOSITE of what the author wrote).
251
+ if (assertFkValidate(opts?.validate, 'references(…)') === false) this.spec.referencesValidate = false;
219
252
  return this;
220
253
  }
221
254
 
package/src/index.ts CHANGED
@@ -20,7 +20,7 @@ export type { TableConstraint } from './constraints';
20
20
  export { sql } from './raw-sql';
21
21
  export type { RawSql } from './raw-sql';
22
22
 
23
- export { can, isColumnAbility, isPublicReadAbility, isDeleteAbility, needsSoftDeleteDeclaration, privilegesGrantDelete } from './can';
23
+ export { can, isColumnAbility, isPublicReadAbility, isDeleteAbility, needsSoftDeleteDeclaration, privilegesGrantDelete, sessionlessAudience } from './can';
24
24
  export type { SoftDeleteFacts } from './can';
25
25
  export type { Ability, AbilityCondition, Action } from './can';
26
26
 
package/src/model.ts CHANGED
@@ -13,7 +13,7 @@
13
13
 
14
14
  import type { FieldBuilder } from './field';
15
15
  import type { Ability } from './can';
16
- import { isPublicReadAbility, isDeleteAbility, needsSoftDeleteDeclaration, privilegesGrantDelete } from './can';
16
+ import { isPublicReadAbility, isDeleteAbility, needsSoftDeleteDeclaration, privilegesGrantDelete, sessionlessAudience } from './can';
17
17
  import type { Relation } from './relations';
18
18
  import type { TableConstraint } from './constraints';
19
19
  import type { RawSql } from './raw-sql';
@@ -317,6 +317,22 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
317
317
  }
318
318
  }
319
319
 
320
+ // `owner`/`via` compare a row column to the caller's JWT claim; a no-session audience has
321
+ // none. Truthiness matches the compiler, which treats `owner: ''` as no row scope.
322
+ const rowScope = a.condition.owner ? 'owner' : a.condition.via ? 'via' : null;
323
+ const audience = sessionlessAudience(a.condition);
324
+ if (rowScope && audience) {
325
+ const outcome = audience === "role: 'anon'"
326
+ ? 'can never match a row: anon has no session'
327
+ : 'is narrower than declared: PUBLIC admits callers with no session, so the open audience is dropped';
328
+ throw new Error(
329
+ `model '${table}': can('${a.action}', { ${audience}, ${rowScope}: '${a.condition[rowScope]}' }) ${outcome}. ` +
330
+ `${rowScope} compares the row to the signed-in user's claim. ` +
331
+ `Either drop ${audience} (the ability then applies to signed-in callers, their own rows), ` +
332
+ `or drop ${rowScope} and scope the anonymous read with columns or where.`,
333
+ );
334
+ }
335
+
320
336
  // `policyRoles` decouples the policy's AUDIENCE from its GRANTEES, and it is deliberately
321
337
  // amputated to one open set. Anything else would be a second spelling of the `role:` axis
322
338
  // and could name a role no grant backs — a policy that reads as access which does not