@everystack/model 0.4.17 → 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 +214 -0
- package/package.json +8 -2
- package/src/constraints.ts +33 -2
- package/src/field.ts +35 -2
- package/src/wire.ts +239 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `@everystack/model` are documented here.
|
|
4
|
+
|
|
5
|
+
Format based on [Keep a Changelog](https://keepachangelog.com/).
|
|
6
|
+
|
|
7
|
+
## [0.4.19] - 2026-09-16
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
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
|
|
13
|
+
|
|
14
|
+
## [0.4.18] - 2026-09-16
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- auto-generated changelogs from conventional commits
|
|
19
|
+
- version-guard the Model-derived wire payloads (provision, converge-ownership, reconcile)
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- the shape-pin also covers enum value domains, not just field names
|
|
24
|
+
- symmetric v-prefix in the mismatch message, restore db:provision's --direct detail
|
|
25
|
+
|
|
26
|
+
## [0.4.17] - 2026-08-15
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- compile owner-scoped `manage` to write policies + add `internal` flag for functions
|
|
31
|
+
|
|
32
|
+
### Documentation
|
|
33
|
+
|
|
34
|
+
- softDelete was documented nowhere a model author reads
|
|
35
|
+
|
|
36
|
+
## [0.4.16] - 2026-08-12
|
|
37
|
+
|
|
38
|
+
### Fixed
|
|
39
|
+
|
|
40
|
+
- the softDelete guard asked about visibility, not about DELETE
|
|
41
|
+
- close the gate findings — the rule was still one grant short
|
|
42
|
+
|
|
43
|
+
## [0.4.15] - 2026-08-10
|
|
44
|
+
|
|
45
|
+
### Added
|
|
46
|
+
|
|
47
|
+
- defineFunction takes an owner
|
|
48
|
+
- keep the index name the database already has
|
|
49
|
+
- keep the foreign key constraint name too
|
|
50
|
+
- capture GENERATED ALWAYS AS IDENTITY through the round trip
|
|
51
|
+
- a view's owner is a privilege boundary too — declare it, build it
|
|
52
|
+
|
|
53
|
+
### Fixed
|
|
54
|
+
|
|
55
|
+
- db:pull reads the search_path, it does not invent one
|
|
56
|
+
|
|
57
|
+
## [0.4.14] - 2026-08-08
|
|
58
|
+
|
|
59
|
+
### Added
|
|
60
|
+
|
|
61
|
+
- rls: false declares a grants-only table — row security as the database holds it
|
|
62
|
+
|
|
63
|
+
## [0.4.13] - 2026-08-06
|
|
64
|
+
|
|
65
|
+
### BREAKING CHANGES
|
|
66
|
+
|
|
67
|
+
- a grant with no policy is spellable, and no longer renders as an ability
|
|
68
|
+
|
|
69
|
+
### Added
|
|
70
|
+
|
|
71
|
+
- a live EXECUTE grant is a fact, so let a function record one
|
|
72
|
+
- db:pull renders trigger bindings, as standalone descriptors
|
|
73
|
+
- record a relation's inexpressible grants instead of skipping the object
|
|
74
|
+
|
|
75
|
+
## [0.4.12] - 2026-08-04
|
|
76
|
+
|
|
77
|
+
### Added
|
|
78
|
+
|
|
79
|
+
- writtenBy: 'functions' must have functions, checked at composition
|
|
80
|
+
|
|
81
|
+
## [0.4.11] - 2026-08-04
|
|
82
|
+
|
|
83
|
+
### Added
|
|
84
|
+
|
|
85
|
+
- policyRoles — the policy's audience, not its grantees
|
|
86
|
+
- field.pgType — a verbatim type is its own identity
|
|
87
|
+
- make column grants first-class
|
|
88
|
+
|
|
89
|
+
### Fixed
|
|
90
|
+
|
|
91
|
+
- one definition of a public read, and writtenBy stops guessing
|
|
92
|
+
|
|
93
|
+
## [0.4.10] - 2026-08-02
|
|
94
|
+
|
|
95
|
+
### Added
|
|
96
|
+
|
|
97
|
+
- the model can say REFERENCES, TRIGGER, TRUNCATE
|
|
98
|
+
|
|
99
|
+
## [0.4.9] - 2026-08-02
|
|
100
|
+
|
|
101
|
+
### Fixed
|
|
102
|
+
|
|
103
|
+
- db:pull dropped the owner branch of a `public OR mine` read
|
|
104
|
+
|
|
105
|
+
## [0.4.8] - 2026-08-01
|
|
106
|
+
|
|
107
|
+
### Added
|
|
108
|
+
|
|
109
|
+
- the authz vocabulary compiles what it declares
|
|
110
|
+
- a function is identified by its name AND its argument types
|
|
111
|
+
- a role the models cannot name keeps its grants, and is named on every run
|
|
112
|
+
- stripping an undeclared grantee is destructive, and db:check verifies the baseline artifact (cli 0.4.41, model 0.4.7)
|
|
113
|
+
|
|
114
|
+
### Fixed
|
|
115
|
+
|
|
116
|
+
- soft-delete visibility is declared, never inferred from a column name
|
|
117
|
+
|
|
118
|
+
## [0.4.5] - 2026-07-23
|
|
119
|
+
|
|
120
|
+
### Added
|
|
121
|
+
|
|
122
|
+
- db schema-move primitive — movedFrom marker → ALTER TABLE SET SCHEMA
|
|
123
|
+
|
|
124
|
+
## [0.4.4] - 2026-07-17
|
|
125
|
+
|
|
126
|
+
### Added
|
|
127
|
+
|
|
128
|
+
- field.json() — the Postgres json type, distinct from jsonb
|
|
129
|
+
|
|
130
|
+
## [0.4.3] - 2026-07-16
|
|
131
|
+
|
|
132
|
+
### Added
|
|
133
|
+
|
|
134
|
+
- canonical-sync artifact verbs (gap B)
|
|
135
|
+
|
|
136
|
+
## [0.4.2] - 2026-07-16
|
|
137
|
+
|
|
138
|
+
### Fixed
|
|
139
|
+
|
|
140
|
+
- array field .default() takes a JS array, not a PG literal
|
|
141
|
+
|
|
142
|
+
## [0.4.1] - 2026-07-10
|
|
143
|
+
|
|
144
|
+
### Added
|
|
145
|
+
|
|
146
|
+
- the loud undefined gate — a missing import is named at define time, never silently dropped
|
|
147
|
+
|
|
148
|
+
## [0.4.0] - 2026-07-10
|
|
149
|
+
|
|
150
|
+
### Added
|
|
151
|
+
|
|
152
|
+
- field.defaultSql() — the raw-default escape hatch (Brick C)
|
|
153
|
+
- the declarative derived layer — descriptor API + rejection rules (Brick B1a)
|
|
154
|
+
- index identity sees everything CREATE INDEX can say (Brick E, read-model-everywhere)
|
|
155
|
+
- typed reads — declared fields emit the derived layer into the generated schema (Brick B6a, read-model-everywhere)
|
|
156
|
+
- descriptors go live in the verbs — the B3 wiring (read-model-everywhere)
|
|
157
|
+
- the B2 gates — the read model is enforced, not just expressible (read-model-everywhere)
|
|
158
|
+
- live drift + doctor — what the content hashes can't see (Brick B4, read-model-everywhere)
|
|
159
|
+
- db:pull renders the derived layer — the consumer's migration tool (Brick B5, read-model-everywhere)
|
|
160
|
+
- retire db/sql — descriptors are the single home (Brick B7, read-model-everywhere)
|
|
161
|
+
- db:generate --dry-run + the journal remembers schema-out (Brick D, read-model-everywhere)
|
|
162
|
+
- db:reconcile --rebaseline — the defHash-verified migration for source re-renders (red-team blocker, read-model-everywhere)
|
|
163
|
+
|
|
164
|
+
### Fixed
|
|
165
|
+
|
|
166
|
+
- adoption-path fixes from the red team (read-model-everywhere)
|
|
167
|
+
- reconcile apply hardening — session guard, real CONCURRENTLY fallback (red team, read-model-everywhere)
|
|
168
|
+
- reachability gate mirrors the grant compiler — private models reach their granted roles (red team, read-model-everywhere)
|
|
169
|
+
|
|
170
|
+
## [0.3.6] - 2026-07-04
|
|
171
|
+
|
|
172
|
+
### Added
|
|
173
|
+
|
|
174
|
+
- losing data should be hard — classification, deprecated(), role-gated destructive (brick 9)
|
|
175
|
+
|
|
176
|
+
## [0.3.5] - 2026-07-04
|
|
177
|
+
|
|
178
|
+
### Added
|
|
179
|
+
|
|
180
|
+
- via: transitive-owner RLS — own a child through its parent
|
|
181
|
+
- toDrizzleTable — derive the runtime drizzle table from a Model
|
|
182
|
+
- writtenBy + the FORCE axiom — RLS posture is a model property
|
|
183
|
+
- per-model schema — a model declares the Postgres schema it lives in
|
|
184
|
+
- private — a model can be generated without being an API resource
|
|
185
|
+
- own dist.blobs as a v3 Model — schema + authz
|
|
186
|
+
- own the notification_* tables as Models + array vocabulary
|
|
187
|
+
- derive the FORCE-RLS carveouts from writtenBy — drop the hand-list
|
|
188
|
+
- column-scoped grants + cross-schema FK / index emission
|
|
189
|
+
- db:doctor functions teeth + derive every FORCE carveout
|
|
190
|
+
- defineModule — a package's full DB slice (models + extensions + functions)
|
|
191
|
+
- field.serial, field.real, field.doublePrecision; model 0.3.4
|
|
192
|
+
- table-level renamedFrom — rename intent through the diff (brick 6)
|
|
193
|
+
|
|
194
|
+
### Documentation
|
|
195
|
+
|
|
196
|
+
- v3 (0.3.0) documentation review — accurate READMEs + guides
|
|
197
|
+
- correct comments + README to the shipped 0.3.0 reality
|
|
198
|
+
|
|
199
|
+
## [0.3.0] - 2026-06-27
|
|
200
|
+
|
|
201
|
+
### Added
|
|
202
|
+
|
|
203
|
+
- defineModel + handler-config derivation (v3 Phase 0)
|
|
204
|
+
- renamedFrom — the opt-in column rename, so the diff preserves data
|
|
205
|
+
- P3 field vocabulary — numeric, varchar, date
|
|
206
|
+
- P3 enum — CREATE TYPE, pg_enum introspection, round-trip
|
|
207
|
+
- P4a FK referential actions + tougher constraint parser
|
|
208
|
+
- P2.1 table-level constraints — composite unique, multi-col check
|
|
209
|
+
- P2.2 indexes — index() builder, round-trip
|
|
210
|
+
- P4b foreignKey() table-level builder for composite FKs
|
|
211
|
+
|
|
212
|
+
### Documentation
|
|
213
|
+
|
|
214
|
+
- P7 add README — the v3 Model on-ramp
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/model",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.19",
|
|
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": {
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"src",
|
|
11
|
-
"README.md"
|
|
11
|
+
"README.md",
|
|
12
|
+
"CHANGELOG.md"
|
|
12
13
|
],
|
|
13
14
|
"exports": {
|
|
14
15
|
".": {
|
|
@@ -20,6 +21,11 @@
|
|
|
20
21
|
"source": "./src/drizzle.ts",
|
|
21
22
|
"types": "./src/drizzle.ts",
|
|
22
23
|
"default": "./src/drizzle.ts"
|
|
24
|
+
},
|
|
25
|
+
"./wire": {
|
|
26
|
+
"source": "./src/wire.ts",
|
|
27
|
+
"types": "./src/wire.ts",
|
|
28
|
+
"default": "./src/wire.ts"
|
|
23
29
|
}
|
|
24
30
|
},
|
|
25
31
|
"peerDependencies": {
|
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
|
|
package/src/wire.ts
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @everystack/model/wire — the Model-derived WIRE CONTRACT.
|
|
3
|
+
*
|
|
4
|
+
* Three privileged ops actions take a payload the CLI computes FROM the Models barrel and ships to
|
|
5
|
+
* a deployed stage's ops Lambda: `db:provision`, `db:converge-ownership` (owner assignments) and
|
|
6
|
+
* `db:reconcile` (derived-object descriptors). The CLI on an operator's machine and the
|
|
7
|
+
* `@everystack/cli` bundled inside that Lambda are SEPARATE installs on independent release
|
|
8
|
+
* trains, so their idea of those payload shapes can drift — a field renamed, reinterpreted, or
|
|
9
|
+
* added with new required meaning rides through silently and lands as wrong ownership or a wrong
|
|
10
|
+
* derived object on a real database.
|
|
11
|
+
*
|
|
12
|
+
* This module is the fix's two halves:
|
|
13
|
+
*
|
|
14
|
+
* 1. THE SHAPES, declared ONCE. `@everystack/model` is the neutral package both sides already
|
|
15
|
+
* reach (the CLI depends on it directly; the server reaches it through
|
|
16
|
+
* `@everystack/cli/wire`, which re-exports this file). Before this, the CLI built these
|
|
17
|
+
* payloads as inline object literals and the server declared its own matching interfaces —
|
|
18
|
+
* two independent types that agreed by convention, plus one (`db:reconcile`'s `declared`)
|
|
19
|
+
* that was `any[]` on the wire. Now there is one definition and both sides import it.
|
|
20
|
+
*
|
|
21
|
+
* 2. THE VERSION, `MODEL_WIRE_CONTRACT`. Types alone cannot police two independently installed
|
|
22
|
+
* packages, so every one of those payloads carries `modelContract`, the server refuses on
|
|
23
|
+
* anything but EXACT equality, and the CLI refuses before dispatch when a stage's `_health`
|
|
24
|
+
* does not advertise the same integer. See MODEL_WIRE_CONTRACT below for the bump rule.
|
|
25
|
+
*
|
|
26
|
+
* TYPES AND THE CONSTANT ONLY — no logic lives here. The refusals are enforced where they are
|
|
27
|
+
* decided: `packages/server/src/model-wire.ts` (the server gate) and
|
|
28
|
+
* `packages/cli/src/cli/model-wire-lane.ts` (the CLI preflight).
|
|
29
|
+
*
|
|
30
|
+
* This is deliberately SEPARATE from `APPLY_CONTRACT_VERSION` (`@everystack/cli/apply`), which
|
|
31
|
+
* versions db:apply's PERSISTED plan artifact — written by `db:plan --out <file>` and applied
|
|
32
|
+
* later, possibly much later. This versions an EPHEMERAL payload computed and consumed in one
|
|
33
|
+
* round trip. Different lifecycles, different numbers; neither may be folded into the other.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The Model-derived wire-contract version. Hand-bumped, checked for EXACT equality on both sides.
|
|
38
|
+
*
|
|
39
|
+
* BUMP IT whenever any type in this file changes shape — a field added, removed, renamed, retyped,
|
|
40
|
+
* or given a new required meaning. `__tests__/wire-shape.test.ts` pins every field of every type
|
|
41
|
+
* here and FAILS with that instruction if you change one without bumping, so the bump cannot be
|
|
42
|
+
* forgotten. That test is what makes exact-equality sustainable.
|
|
43
|
+
*
|
|
44
|
+
* Exact equality, never `>=`: a false refusal costs one redeploy (minutes, reversible), a false
|
|
45
|
+
* accept risks wrong ownership or a wrong derived object on a production database.
|
|
46
|
+
*
|
|
47
|
+
* v1 — the first version. Shapes as they stood when the guard was introduced.
|
|
48
|
+
*/
|
|
49
|
+
export const MODEL_WIRE_CONTRACT = 1;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Every Model-derived wire payload carries this. The server refuses a payload whose `modelContract`
|
|
53
|
+
* is missing or unequal BEFORE it opens a connection; the CLI refuses to dispatch to a stage whose
|
|
54
|
+
* `_health` does not advertise the same integer.
|
|
55
|
+
*
|
|
56
|
+
* Missing is a REFUSAL, never a default: a caller that does not send the field is by definition on
|
|
57
|
+
* a version that predates the field, which is exactly the skew being guarded.
|
|
58
|
+
*/
|
|
59
|
+
export interface ModelWireEnvelope {
|
|
60
|
+
/** This CLI's MODEL_WIRE_CONTRACT. Absent = a CLI older than the handshake. */
|
|
61
|
+
modelContract?: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// Ownership — the db:provision / db:converge-ownership payloads.
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* One (owner, schema) pair the Models declare for a function/view/matview — the ENACT unit.
|
|
70
|
+
* Provision grants the operator membership in `owner` and grants `owner` CREATE on `schema`.
|
|
71
|
+
*/
|
|
72
|
+
export interface DeclaredOwner {
|
|
73
|
+
owner: string;
|
|
74
|
+
schema: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The derived-object kinds ownership can be reassigned for. */
|
|
78
|
+
export type AdopteeKind = 'function' | 'view' | 'materialized view';
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* One declared object to ADOPT — a function/view/matview the Models declare that already exists
|
|
82
|
+
* live under the wrong owner. Reassigning a SECURITY DEFINER object changes its runs-as identity
|
|
83
|
+
* at call time, which is why `secdef` rides the wire and why adoption needs `confirm`.
|
|
84
|
+
*/
|
|
85
|
+
export interface Adoptee {
|
|
86
|
+
/** Reassign target: `api.foo(integer)` for a function, `api.v_report` for a view/matview. */
|
|
87
|
+
identity: string;
|
|
88
|
+
schema: string;
|
|
89
|
+
name: string;
|
|
90
|
+
kind: AdopteeKind;
|
|
91
|
+
/** SECURITY DEFINER — functions only; always false for a view/matview. */
|
|
92
|
+
secdef: boolean;
|
|
93
|
+
/** The live owner (e.g. `postgres`). */
|
|
94
|
+
currentOwner: string;
|
|
95
|
+
/** The owner the Models declare. */
|
|
96
|
+
declaredOwner: string;
|
|
97
|
+
body?: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The `db:provision` payload's Model-derived half (the credentials half is server-local). */
|
|
101
|
+
export interface ProvisionWirePayload extends ModelWireEnvelope {
|
|
102
|
+
/** The declared schema set, ordered and `public`-trailing — set as the ROLE default search_path. */
|
|
103
|
+
searchPath?: string[];
|
|
104
|
+
/** Accepted by the action's payload type; convergence itself runs as db:converge-ownership. */
|
|
105
|
+
declaredOwners?: DeclaredOwner[];
|
|
106
|
+
declaredObjects?: Adoptee[];
|
|
107
|
+
confirm?: boolean;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The `db:converge-ownership` payload — ENACT + ADOPT + the reader's read grants. */
|
|
111
|
+
export interface OwnershipWirePayload extends ModelWireEnvelope {
|
|
112
|
+
declaredOwners?: DeclaredOwner[];
|
|
113
|
+
declaredObjects?: Adoptee[];
|
|
114
|
+
confirm?: boolean;
|
|
115
|
+
adminRole?: string;
|
|
116
|
+
/** Present = also converge the reader's read grants over `searchPath`. */
|
|
117
|
+
readerRole?: string;
|
|
118
|
+
searchPath?: string[];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// Derived layer — the db:reconcile payload.
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
/** The declared derived kinds. `trigger` and `sql` carry the extra fields their edges need. */
|
|
126
|
+
export type DerivedKind = 'function' | 'view' | 'materialized view' | 'trigger' | 'sql';
|
|
127
|
+
|
|
128
|
+
/** A statement that belongs to a derived object: its index, comment, or grant. */
|
|
129
|
+
export interface DerivedAttachment {
|
|
130
|
+
kind: 'index' | 'comment' | 'grant';
|
|
131
|
+
sql: string;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* One compiled derived-object descriptor — the unit `db:reconcile` ships. The CLI's `SourceObject`
|
|
136
|
+
* IS this type (`packages/cli/src/cli/derived-source.ts` re-exports it), so there is one
|
|
137
|
+
* definition, not two that agree by convention. Field-by-field documentation lives with the
|
|
138
|
+
* compiler that produces them; what matters here is that the SHAPE is versioned by
|
|
139
|
+
* MODEL_WIRE_CONTRACT.
|
|
140
|
+
*/
|
|
141
|
+
export interface DerivedDescriptor {
|
|
142
|
+
kind: DerivedKind;
|
|
143
|
+
/** Schema name, defaulted to `public` when unqualified. */
|
|
144
|
+
schema: string;
|
|
145
|
+
name: string;
|
|
146
|
+
/** `schema.name` — the reconciler's join key against the live catalog.
|
|
147
|
+
* (Triggers: `schema.table.name` — compound identity, joined against pg_trigger.) */
|
|
148
|
+
identity: string;
|
|
149
|
+
/** The CREATE statement as authored (no trailing semicolon). */
|
|
150
|
+
sql: string;
|
|
151
|
+
/** Indexes / comments / grants targeting this object, in source order. */
|
|
152
|
+
attachments: DerivedAttachment[];
|
|
153
|
+
/** sha256 of the normalized CREATE + attachments — comment/whitespace edits do not change it. */
|
|
154
|
+
hash: string;
|
|
155
|
+
/**
|
|
156
|
+
* sha256 of the normalized CREATE + attachments EXCEPT plain (non-column-scoped) grants — the
|
|
157
|
+
* "would a rebuild be required" hash. Equal across an authz-only change (plain grants moved, body
|
|
158
|
+
* unchanged), so the reconciler can route that to a bare GRANT delta instead of a matview rebuild.
|
|
159
|
+
* Column-scoped grants stay IN (attacl not introspected → they must rebuild). `hash` stays the
|
|
160
|
+
* full/stable formula for provenance compat; this is the additional discriminator.
|
|
161
|
+
*/
|
|
162
|
+
bodyHash: string;
|
|
163
|
+
/** Source file this object came from (descriptors: the declared-models marker). */
|
|
164
|
+
file: string;
|
|
165
|
+
/** Position in the concatenated source — a valid dependency order by convention. */
|
|
166
|
+
seq: number;
|
|
167
|
+
/**
|
|
168
|
+
* kind 'function' only: the declared owning role.
|
|
169
|
+
*
|
|
170
|
+
* A SECURITY DEFINER function executes with its OWNER's rights, so this is a privilege
|
|
171
|
+
* boundary. The applier enacts it by running the CREATE under `SET ROLE <owner>` rather
|
|
172
|
+
* than following it with `ALTER … OWNER TO`: measured on PG16, once ownership has moved the
|
|
173
|
+
* builder gets "must be owner of function" on the next `CREATE OR REPLACE`, so the
|
|
174
|
+
* alter-after form works exactly once and breaks every re-run.
|
|
175
|
+
*/
|
|
176
|
+
owner?: string;
|
|
177
|
+
/**
|
|
178
|
+
* kind 'function' only: the declared `SET search_path`, or the sentinel `'unpinned'`.
|
|
179
|
+
*
|
|
180
|
+
* It is already inside `sql`, so this is NOT in the hash and changes no fingerprint. It rides
|
|
181
|
+
* structurally so the differ can compare the declared pin against the LIVE catalog directly
|
|
182
|
+
* rather than only against a recorded hash. Without that, `--baseline` records a declared pin
|
|
183
|
+
* the database does not have and every later check reports zero actions, forever: measured,
|
|
184
|
+
* and it is how a model came to claim `search_path=pg_catalog` about a database with none
|
|
185
|
+
* while every gate stayed green.
|
|
186
|
+
*
|
|
187
|
+
* `undefined` means NOT DECLARED — a parser-era db/sql object, or an invoker function with no
|
|
188
|
+
* pin. The comparison is skipped there; absent evidence is not evidence.
|
|
189
|
+
*/
|
|
190
|
+
searchPath?: string[] | 'unpinned';
|
|
191
|
+
/** kind 'sql' only: the declared PostgreSQL object kind (`'aggregate'`, …). */
|
|
192
|
+
objectKind?: string;
|
|
193
|
+
/** kind 'sql' only: the explicit DROP for kinds whose drop isn't derivable from the name. */
|
|
194
|
+
drop?: string;
|
|
195
|
+
/** kind 'trigger' only: the (possibly schema-qualified) table/view the trigger rides. */
|
|
196
|
+
table?: string;
|
|
197
|
+
/**
|
|
198
|
+
* Views/matviews: every declared `dependsOn` ref as an identity (models AND derived),
|
|
199
|
+
* sorted. B4's dependency drift verifies it against the live catalog's actual edges —
|
|
200
|
+
* "declared, then verified". Absent on kinds whose live edges pg_depend can't see.
|
|
201
|
+
*/
|
|
202
|
+
declaredDeps?: string[];
|
|
203
|
+
/**
|
|
204
|
+
* kind 'function' only: the relations this function's SIGNATURE names — a `setof(ref)`
|
|
205
|
+
* return, or an argument/return type spelled as a declared table, view or matview.
|
|
206
|
+
*
|
|
207
|
+
* Distinct from `dependsOn`, and the distinction is load-bearing for the from-scratch build.
|
|
208
|
+
* A body reference is a FORWARD reference the build can defer (`check_function_bodies = off`
|
|
209
|
+
* is exactly that escape), so a function whose body reads a not-yet-created table still
|
|
210
|
+
* creates. A SIGNATURE reference is not deferrable: PostgreSQL resolves the return and
|
|
211
|
+
* argument types when the function is created, so `RETURNS SETOF stats_view.x_row` needs
|
|
212
|
+
* that view to exist already. `db:build` runs functions ahead of state (an RLS policy's
|
|
213
|
+
* predicate must resolve the function it calls) and uses this to hold back the ones that
|
|
214
|
+
* cannot go first; the compiler also orders the function after them.
|
|
215
|
+
*
|
|
216
|
+
* RELATIONS only — tables, views, matviews. A signature naming a declared ENUM, domain or
|
|
217
|
+
* composite type has the same problem and is NOT covered here, so it still fails in the
|
|
218
|
+
* ahead-of-state pass. Absent/empty means "names no declared relation", never "is safe".
|
|
219
|
+
*
|
|
220
|
+
* Not in the hash — it is derived from `sql`, so carrying it moves no fingerprint.
|
|
221
|
+
*/
|
|
222
|
+
signatureDeps?: string[];
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** The `db:reconcile` payload — the declared derived layer plus the run's mode flags. */
|
|
226
|
+
export interface ReconcileWirePayload extends ModelWireEnvelope {
|
|
227
|
+
/** The declared derived objects. Was `any[]` on the wire before this contract existed. */
|
|
228
|
+
declared?: DerivedDescriptor[];
|
|
229
|
+
/** `{ [newName]: oldName }` — table renames the reconciler must follow. */
|
|
230
|
+
renamedTables?: Record<string, string>;
|
|
231
|
+
apply?: boolean;
|
|
232
|
+
baseline?: boolean;
|
|
233
|
+
rebaseline?: boolean;
|
|
234
|
+
rebuild?: boolean;
|
|
235
|
+
overwriteDrift?: boolean;
|
|
236
|
+
only?: string[];
|
|
237
|
+
actor?: string | null;
|
|
238
|
+
gitRef?: string | null;
|
|
239
|
+
}
|