@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.
@@ -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 = 3;
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
+ }