@everystack/cli 0.4.84 → 0.4.86
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 +18 -1
- package/package.json +3 -3
- package/src/cli/apply-execute.ts +123 -2
- package/src/cli/apply-refusals.ts +112 -0
- package/src/cli/commands/db-apply.ts +104 -7
- package/src/cli/commands/db-plan.ts +30 -0
- package/src/cli/derived-apply.ts +17 -0
- package/src/cli/edge-plan.ts +64 -13
- package/src/cli/fk-orphan-advisory.ts +170 -0
- package/src/cli/fk-preflight.ts +414 -0
- package/src/cli/migration-generate.ts +8 -7
- package/src/cli/model-render.ts +10 -0
- package/src/cli/plain-output.ts +10 -2
- package/src/cli/schema-compile.ts +14 -1
- package/src/cli/schema-diff.ts +137 -13
- package/src/cli/schema-fingerprint-v12.ts +239 -0
- package/src/cli/schema-fingerprint.ts +52 -3
- package/src/cli/schema-introspect.ts +36 -0
- package/src/cli/state-apply.ts +227 -13
package/src/cli/edge-plan.ts
CHANGED
|
@@ -26,9 +26,9 @@
|
|
|
26
26
|
|
|
27
27
|
import { createHash } from 'node:crypto';
|
|
28
28
|
import type { ModelDescriptor } from '@everystack/model';
|
|
29
|
-
import type { SchemaSnapshot } from './schema-introspect';
|
|
29
|
+
import type { SchemaSnapshot, TableSchema } from './schema-introspect';
|
|
30
30
|
import type { AuthzContract, SequenceGrantContract, DefaultAclScope } from './authz-contract';
|
|
31
|
-
import { ownSequenceName } from './schema-diff';
|
|
31
|
+
import { ownSequenceName, foreignKeyContentKey } from './schema-diff';
|
|
32
32
|
import { generateMigrationSql, unmodeledTables } from './migration-generate';
|
|
33
33
|
import { compileTableContract } from './authz-compile';
|
|
34
34
|
import { carryUngovernedAuthz, inheritedSequenceGrants, type GrantExemption } from './authz-reconcile';
|
|
@@ -64,8 +64,28 @@ export const PLAN_VERSION = 2;
|
|
|
64
64
|
* the gate — not a false pass of the apply (it degrades to the post-DDL verify failure), but a
|
|
65
65
|
* silently dropped gate the operator was told is in force. The BUMP RULE says a gate the server
|
|
66
66
|
* runs is part of the contract, so the peers refuse until the stage is redeployed.
|
|
67
|
+
*
|
|
68
|
+
* v4 (the two-step foreign key): the server runs a NEW gate — the foreign-key orphan preflight,
|
|
69
|
+
* which counts the rows that would fail each key the plan installs or validates and refuses
|
|
70
|
+
* BEFORE any DDL — and the write path changed shape: `VALIDATE CONSTRAINT` is held out of the
|
|
71
|
+
* edge's transaction and run after it commits, so a failed check is a PENDING constraint rather
|
|
72
|
+
* than a rolled-back edge (`validation-pending`, a status a v3 ops Lambda cannot return). A v3
|
|
73
|
+
* stage would skip the preflight and run the VALIDATE inside the edge — a silently dropped gate
|
|
74
|
+
* and a different failure mode, both of which the BUMP RULE covers. Peers refuse until the stage
|
|
75
|
+
* is redeployed.
|
|
76
|
+
*
|
|
77
|
+
* v5 (validation-pending stops refusing the whole edge): `ApplyPlanResult` gained `pendingIsSafe`
|
|
78
|
+
* and `unappliedStatements` — the CLIENT now decides, from `pendingIsSafe`, whether a
|
|
79
|
+
* `validation-pending` result exits 0 (a withheld or ordinary-orphan cause) or 1 (a cut-off or
|
|
80
|
+
* unrecognised cause, a real anomaly). A v4 ops Lambda's `executeApplyPlan` never sets
|
|
81
|
+
* `pendingIsSafe` at all, so a v5 client reading its response would see it absent, treat every
|
|
82
|
+
* pending result as UNSAFE by the same fail-closed rule that protects the in-version case, and
|
|
83
|
+
* exit 1 on a perfectly safe withheld key — the exact false CI failure this whole feature exists
|
|
84
|
+
* to remove, reintroduced silently across a version skew instead of refused cleanly. The BUMP
|
|
85
|
+
* RULE covers exactly this: a field the CLIENT now depends on to decide something is part of the
|
|
86
|
+
* contract. Peers refuse until the stage is redeployed.
|
|
67
87
|
*/
|
|
68
|
-
export const APPLY_CONTRACT_VERSION =
|
|
88
|
+
export const APPLY_CONTRACT_VERSION = 5;
|
|
69
89
|
|
|
70
90
|
/** Classification counts (brick 9, decision 11): destructive = drops + narrowings + strips. */
|
|
71
91
|
export interface PlanClassification {
|
|
@@ -216,6 +236,36 @@ export function predictLiveFingerprint(
|
|
|
216
236
|
return predictLiveState(models, snapshot, contract, opts).hash;
|
|
217
237
|
}
|
|
218
238
|
|
|
239
|
+
/**
|
|
240
|
+
* One declared table with its `validate: false` foreign keys' VALIDITY taken from live.
|
|
241
|
+
*
|
|
242
|
+
* A key the model declared `validate: false` compiles to `notValid: true`, and that is right for
|
|
243
|
+
* a from-scratch build: nothing validates it, so the built key really is NOT VALID. But the
|
|
244
|
+
* PREDICTED endpoint is not a build, it is "the live database after this edge" — and the edge
|
|
245
|
+
* emits nothing at all for such a key when the live one already content-matches (see
|
|
246
|
+
* `diffConstraints`: a live NOT VALID key needs no statement, and a live FULLY-VALID key is the
|
|
247
|
+
* accept-either case). Predicting NOT VALID over a key the database has already validated would
|
|
248
|
+
* make the plan's own verify-after fail on an edge that touched nothing, on every apply, forever.
|
|
249
|
+
*
|
|
250
|
+
* So the prediction copies what the key IS. Untouched for every other key: absent live table,
|
|
251
|
+
* absent live counterpart, or a declaration that never said `validate: false` — those predict
|
|
252
|
+
* exactly what they always did.
|
|
253
|
+
*/
|
|
254
|
+
function carryFkValidityThrough(declared: TableSchema, live: TableSchema | undefined): TableSchema {
|
|
255
|
+
if (!live || !declared.foreignKeys.some((fk) => fk.notValid)) return declared;
|
|
256
|
+
const liveByContent = new Map(live.foreignKeys.map((fk) => [foreignKeyContentKey(fk), fk]));
|
|
257
|
+
return {
|
|
258
|
+
...declared,
|
|
259
|
+
foreignKeys: declared.foreignKeys.map((fk) => {
|
|
260
|
+
if (!fk.notValid) return fk;
|
|
261
|
+
const counterpart = liveByContent.get(foreignKeyContentKey(fk));
|
|
262
|
+
if (!counterpart || counterpart.notValid) return fk;
|
|
263
|
+
const { notValid: _dropped, ...rest } = fk;
|
|
264
|
+
return rest;
|
|
265
|
+
}),
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
219
269
|
/**
|
|
220
270
|
* The predicted endpoint as a full {@link Fingerprint} (hash AND canonical form). The hash is
|
|
221
271
|
* the plan's `to` and the declares-test; the canonical form lets a MISMATCH report name WHICH
|
|
@@ -259,16 +309,6 @@ export function predictLiveState(
|
|
|
259
309
|
!declaredTables.has(table) && !pendingRenameSources.has(table) && !pendingMoveSources.has(table);
|
|
260
310
|
const declaredEnumList = declared.snapshot.enums ?? [];
|
|
261
311
|
const declaredEnums = new Set(declaredEnumList.map((e) => e.name));
|
|
262
|
-
const mergedSnapshot: SchemaSnapshot = {
|
|
263
|
-
tables: [
|
|
264
|
-
...declared.snapshot.tables,
|
|
265
|
-
...snapshot.tables.filter((t) => ridesThrough(t.table)),
|
|
266
|
-
],
|
|
267
|
-
enums: [
|
|
268
|
-
...declaredEnumList,
|
|
269
|
-
...(snapshot.enums ?? []).filter((e) => !declaredEnums.has(e.name)),
|
|
270
|
-
],
|
|
271
|
-
};
|
|
272
312
|
// The live counterpart of a declared table: its rename or move source when the edge brings it
|
|
273
313
|
// in under a new name, else its own name. Grants, policies and owned sequences survive RENAME
|
|
274
314
|
// TABLE and SET SCHEMA alike (they attach by OID), so the foreign authz carries across both.
|
|
@@ -281,6 +321,17 @@ export function predictLiveState(
|
|
|
281
321
|
!currentNames.has(declaredTable) && currentNames.has(priorNameOf[declaredTable] ?? '')
|
|
282
322
|
? priorNameOf[declaredTable]
|
|
283
323
|
: declaredTable;
|
|
324
|
+
const liveSnapshotByTable = new Map(snapshot.tables.map((t) => [t.table, t]));
|
|
325
|
+
const mergedSnapshot: SchemaSnapshot = {
|
|
326
|
+
tables: [
|
|
327
|
+
...declared.snapshot.tables.map((t) => carryFkValidityThrough(t, liveSnapshotByTable.get(liveNameFor(t.table)))),
|
|
328
|
+
...snapshot.tables.filter((t) => ridesThrough(t.table)),
|
|
329
|
+
],
|
|
330
|
+
enums: [
|
|
331
|
+
...declaredEnumList,
|
|
332
|
+
...(snapshot.enums ?? []).filter((e) => !declaredEnums.has(e.name)),
|
|
333
|
+
],
|
|
334
|
+
};
|
|
284
335
|
const liveByTable = new Map(contract.tables.map((t) => [t.table, t]));
|
|
285
336
|
const declaredColumnsByTable = new Map(
|
|
286
337
|
declared.snapshot.tables.map((t) => [t.table, new Set(t.columns.map((c) => c.name))]),
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fk-orphan-advisory — the RECURRING NOTICE about foreign keys that stay unproven.
|
|
3
|
+
*
|
|
4
|
+
* Its sibling, fk-preflight, owns the fail-closed REFUSAL (the row check could not run) and the
|
|
5
|
+
* data the read produces. This owns the other half: what `db:plan` and `db:apply` say, at the
|
|
6
|
+
* DEFAULT verbosity, about keys that are installed, enforced on every new write, and carrying
|
|
7
|
+
* rows nobody has ever checked.
|
|
8
|
+
*
|
|
9
|
+
* THREE RULES, and each one is a decision somebody could get wrong:
|
|
10
|
+
*
|
|
11
|
+
* IT NEVER BLOCKS. Not db:plan, not db:apply, not db:check. There is no refusal here and no
|
|
12
|
+
* flag to get past one. Unproven legacy rows are DEBT, and a gate that stops work over debt
|
|
13
|
+
* gets disabled — which is how the whole edge came to be refused for one orphaned key.
|
|
14
|
+
*
|
|
15
|
+
* IT NEVER PROPOSES A DELETE. It names rows; a human decides. No DML is emitted, suggested,
|
|
16
|
+
* or implied, ever. The samples are KEY VALUES only — three of them, enough to recognise a
|
|
17
|
+
* row, never its contents.
|
|
18
|
+
*
|
|
19
|
+
* IT RECURS. This prints on every run while the condition holds, because the condition is
|
|
20
|
+
* durable: the keys really are unproven, today and next month. A one-time alert on a
|
|
21
|
+
* permanent fact is a fact nobody ever sees again. The escape is not acknowledgement, it is
|
|
22
|
+
* one of the two real answers — fix the rows, or declare `validate: false` and mean it.
|
|
23
|
+
*
|
|
24
|
+
* WHAT IS ABSENT FROM IT, deliberately: any key the Model declared `validate: false`. That
|
|
25
|
+
* declaration IS the decision this notice is asking for, so continuing to nag about it would
|
|
26
|
+
* make the flag worthless. The mechanism is structural rather than a filter — the differ emits
|
|
27
|
+
* no `VALIDATE` for such a key, so fk-preflight never scans it and it can never reach here.
|
|
28
|
+
* What is left is exactly the UNDECIDED set: keys whose model has said nothing either way.
|
|
29
|
+
*
|
|
30
|
+
* NONE OF IT RIDES IN THE PLAN FILE. The plan is a deterministic DDL list under a content hash,
|
|
31
|
+
* and live row counts go stale between minting and applying. A file that asserted "9,412 rows"
|
|
32
|
+
* would be asserting something that need not still be true when someone reads it.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import type { ModelDescriptor } from '@everystack/model';
|
|
36
|
+
import type { FkOrphanFinding } from './fk-preflight';
|
|
37
|
+
import { toSnakeCase } from './schema-compile';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The columns a model reads to decide WHO MAY SEE OR WRITE A ROW, per schema-qualified table.
|
|
41
|
+
*
|
|
42
|
+
* Two shapes qualify, and they fail differently:
|
|
43
|
+
*
|
|
44
|
+
* `can(…, { owner: 'userId' })` — the column IS the row's owner. `rowOwnership` in the
|
|
45
|
+
* derived handler config is the same column (derive.ts takes
|
|
46
|
+
* the first owner-shaped ability), so covering the ability
|
|
47
|
+
* covers both surfaces.
|
|
48
|
+
* `can(…, { via: 'uploadId' })` — the column is the LINK to the owner: the policy compiles to
|
|
49
|
+
* `<col> IN (SELECT id FROM <parent> WHERE <owner> = <claim>)`.
|
|
50
|
+
* An orphan here is the sharper case — the parent row is
|
|
51
|
+
* gone, so the subquery matches nothing and the row answers
|
|
52
|
+
* "belongs to nobody".
|
|
53
|
+
*
|
|
54
|
+
* Column-scoped abilities (`columns: [...]`) are covered by the same `owner`/`via` read; the
|
|
55
|
+
* column list narrows what is exposed, not what decides access.
|
|
56
|
+
*/
|
|
57
|
+
export function ownershipColumns(models: readonly ModelDescriptor[]): Map<string, Set<string>> {
|
|
58
|
+
const out = new Map<string, Set<string>>();
|
|
59
|
+
for (const model of models) {
|
|
60
|
+
const table = `${model.schema ?? 'public'}.${model.table}`;
|
|
61
|
+
for (const ability of model.abilities) {
|
|
62
|
+
for (const key of [ability.condition.owner, ability.condition.via]) {
|
|
63
|
+
if (!key) continue;
|
|
64
|
+
// Field KEYS are camelCase in the model, columns are snake_case in the database —
|
|
65
|
+
// through the compiler's own conversion, so this and `.references()` can never disagree
|
|
66
|
+
// about which column an ability names.
|
|
67
|
+
const set = out.get(table) ?? out.set(table, new Set()).get(table)!;
|
|
68
|
+
set.add(toSnakeCase(key));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A thousands-separated count — `11500` is read wrong at a glance, `11,500` is not. */
|
|
76
|
+
const count = (n: number): string => n.toLocaleString('en-US');
|
|
77
|
+
|
|
78
|
+
const plural = (n: number, one: string, many: string): string => (n === 1 ? one : many);
|
|
79
|
+
|
|
80
|
+
/** Which of a finding's own columns decide access on its table. Empty for the ordinary case. */
|
|
81
|
+
function gatingColumns(finding: FkOrphanFinding, ownership: ReadonlyMap<string, Set<string>>): string[] {
|
|
82
|
+
const gated = ownership.get(finding.table);
|
|
83
|
+
return gated ? finding.columns.filter((c) => gated.has(c)) : [];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The per-key detail — counts, the columns, and KEY VALUES only. Behind --verbose. */
|
|
87
|
+
function detailLines(findings: readonly FkOrphanFinding[]): string[] {
|
|
88
|
+
const lines: string[] = [];
|
|
89
|
+
for (const f of findings) {
|
|
90
|
+
lines.push(
|
|
91
|
+
` ${f.table}.${f.constraint}: ${count(f.orphans)} ${plural(f.orphans, 'row', 'rows')} `
|
|
92
|
+
+ `hold a value in (${f.columns.join(', ')}) with no matching row in ${f.refTable}.`,
|
|
93
|
+
);
|
|
94
|
+
if (f.samples.length > 0) {
|
|
95
|
+
lines.push(` sample key value(s): ${f.samples.join(', ')}${f.orphans > f.samples.length ? ', …' : ''}`);
|
|
96
|
+
}
|
|
97
|
+
// A read, never a write. This module has no DELETE to offer and never will. The parent's
|
|
98
|
+
// key columns are a PLACEHOLDER — the read that produced these counts knows them, this
|
|
99
|
+
// report does not carry them, and inventing a name would hand over a query that errors.
|
|
100
|
+
const predicate = f.columns.length === 1
|
|
101
|
+
? `p.<key> = c.${f.columns[0]}`
|
|
102
|
+
: `(p.<keys>) = (${f.columns.map((c) => `c.${c}`).join(', ')})`;
|
|
103
|
+
lines.push(
|
|
104
|
+
` the rows: SELECT * FROM ${f.table} c WHERE NOT EXISTS `
|
|
105
|
+
+ `(SELECT 1 FROM ${f.refTable} p WHERE ${predicate});`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
return lines;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface FkOrphanAdvisoryOptions {
|
|
112
|
+
/** --verbose: add the per-key counts and key-value samples under each headline. */
|
|
113
|
+
verbose?: boolean;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The advisory, as lines. Empty when nothing is unproven.
|
|
118
|
+
*
|
|
119
|
+
* Every HEADLINE it returns is a problem line — it starts with `!`, the same mark and the same
|
|
120
|
+
* severity tier the ownership report's findings use — so the caller prints whatever comes back
|
|
121
|
+
* without having to classify it: at the default verbosity that is headlines only, and `verbose`
|
|
122
|
+
* adds the indented detail under each one. The headline states the CONSEQUENCE — enforced
|
|
123
|
+
* forward, N rows unchecked — because a bare count reads as an error nobody can size.
|
|
124
|
+
*
|
|
125
|
+
* Findings whose column also decides row access get their OWN headline. Same severity, same
|
|
126
|
+
* non-blocking behaviour: what changes is that the reader is told this is not only a data
|
|
127
|
+
* consistency question, because a row that points at a deleted parent through an ownership link
|
|
128
|
+
* answers "who owns me" with nothing.
|
|
129
|
+
*/
|
|
130
|
+
export function renderFkOrphanAdvisory(
|
|
131
|
+
findings: readonly FkOrphanFinding[],
|
|
132
|
+
ownership: ReadonlyMap<string, Set<string>> = new Map(),
|
|
133
|
+
opts: FkOrphanAdvisoryOptions = {},
|
|
134
|
+
): string[] {
|
|
135
|
+
if (findings.length === 0) return [];
|
|
136
|
+
const verbose = opts.verbose === true;
|
|
137
|
+
|
|
138
|
+
const gating = findings.filter((f) => gatingColumns(f, ownership).length > 0);
|
|
139
|
+
const plain = findings.filter((f) => gatingColumns(f, ownership).length === 0);
|
|
140
|
+
|
|
141
|
+
const lines: string[] = [];
|
|
142
|
+
const declareIt = 'declare `validate: false` on the reference if that is intentional';
|
|
143
|
+
|
|
144
|
+
if (plain.length > 0) {
|
|
145
|
+
const rows = plain.reduce((sum, f) => sum + f.orphans, 0);
|
|
146
|
+
lines.push(
|
|
147
|
+
`! ${plain.length} foreign ${plural(plain.length, 'key stays', 'keys stay')} NOT VALID: new writes enforced, `
|
|
148
|
+
+ `${count(rows)} existing ${plural(rows, 'row', 'rows')} unchecked `
|
|
149
|
+
+ `(--verbose for detail; ${declareIt}).`,
|
|
150
|
+
);
|
|
151
|
+
if (verbose) lines.push(...detailLines(plain));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (gating.length > 0) {
|
|
155
|
+
const rows = gating.reduce((sum, f) => sum + f.orphans, 0);
|
|
156
|
+
const where = gating
|
|
157
|
+
.map((f) => `${f.table}.${gatingColumns(f, ownership).join('/')}`)
|
|
158
|
+
.join(', ');
|
|
159
|
+
lines.push(
|
|
160
|
+
`! ROW SECURITY — ${gating.length} unproven foreign ${plural(gating.length, 'key also decides', 'keys also decide')} `
|
|
161
|
+
+ `who may read or write the row (${where}): `
|
|
162
|
+
+ `${count(rows)} existing ${plural(rows, 'row', 'rows')} point at a parent that is gone, so the owner check `
|
|
163
|
+
+ `on ${plural(gating.length, 'it', 'them')} matches nobody `
|
|
164
|
+
+ `(--verbose for detail; ${declareIt}).`,
|
|
165
|
+
);
|
|
166
|
+
if (verbose) lines.push(...detailLines(gating));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return lines;
|
|
170
|
+
}
|