@everystack/model 0.4.18 → 0.4.19
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 +2 -1
- package/package.json +1 -1
- package/src/constraints.ts +33 -2
- package/src/field.ts +35 -2
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
|
-
## [
|
|
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
package/src/constraints.ts
CHANGED
|
@@ -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
|
|