@everystack/cli 0.4.85 → 0.4.87
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 +23 -0
- package/package.json +3 -3
- package/src/cli/apply-execute.ts +80 -19
- package/src/cli/apply-refusals.ts +47 -9
- package/src/cli/commands/audit.ts +2 -2
- package/src/cli/commands/bundle.ts +2 -2
- package/src/cli/commands/cache.ts +13 -6
- package/src/cli/commands/db-apply.ts +85 -4
- package/src/cli/commands/db-plan.ts +29 -0
- package/src/cli/commands/db-snapshot.ts +2 -0
- package/src/cli/commands/db.ts +22 -1
- package/src/cli/commands/diag.ts +4 -4
- package/src/cli/commands/lighthouse.ts +2 -2
- package/src/cli/commands/security-probe.ts +2 -2
- package/src/cli/commands/update.ts +17 -3
- package/src/cli/config.ts +30 -2
- package/src/cli/discover-distribution.ts +58 -22
- package/src/cli/discover.ts +48 -6
- package/src/cli/edge-plan.ts +55 -13
- package/src/cli/fk-orphan-advisory.ts +172 -0
- package/src/cli/fk-preflight.ts +126 -43
- package/src/cli/host-url.ts +127 -0
- package/src/cli/index.ts +1 -41
- package/src/cli/model-render.ts +10 -0
- package/src/cli/schema-compile.ts +14 -1
- package/src/cli/schema-diff.ts +47 -10
- package/src/cli/schema-fingerprint.ts +26 -0
- package/src/cli/schema-introspect.ts +20 -7
- package/src/cli/security-audit.ts +14 -2
- package/src/cli/state-apply.ts +84 -17
- package/src/cli/swap-snapshot.ts +2 -1
|
@@ -0,0 +1,172 @@
|
|
|
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, the same severity tier the ownership report's
|
|
120
|
+
* findings use — so the caller `warn()`s whatever comes back without having to classify it: at
|
|
121
|
+
* the default verbosity that is headlines only, and `verbose` adds the indented detail under
|
|
122
|
+
* each one. `warn()` (output.ts) already prepends the `!` mark, so these strings carry none of
|
|
123
|
+
* their own — a caller that printed one with `line()`/`info()` instead of `warn()` would be the
|
|
124
|
+
* bug, not this module doubling the mark on top of it. The headline states the CONSEQUENCE —
|
|
125
|
+
* enforced forward, N rows unchecked — because a bare count reads as an error nobody can size.
|
|
126
|
+
*
|
|
127
|
+
* Findings whose column also decides row access get their OWN headline. Same severity, same
|
|
128
|
+
* non-blocking behaviour: what changes is that the reader is told this is not only a data
|
|
129
|
+
* consistency question, because a row that points at a deleted parent through an ownership link
|
|
130
|
+
* answers "who owns me" with nothing.
|
|
131
|
+
*/
|
|
132
|
+
export function renderFkOrphanAdvisory(
|
|
133
|
+
findings: readonly FkOrphanFinding[],
|
|
134
|
+
ownership: ReadonlyMap<string, Set<string>> = new Map(),
|
|
135
|
+
opts: FkOrphanAdvisoryOptions = {},
|
|
136
|
+
): string[] {
|
|
137
|
+
if (findings.length === 0) return [];
|
|
138
|
+
const verbose = opts.verbose === true;
|
|
139
|
+
|
|
140
|
+
const gating = findings.filter((f) => gatingColumns(f, ownership).length > 0);
|
|
141
|
+
const plain = findings.filter((f) => gatingColumns(f, ownership).length === 0);
|
|
142
|
+
|
|
143
|
+
const lines: string[] = [];
|
|
144
|
+
const declareIt = 'declare `validate: false` on the reference if that is intentional';
|
|
145
|
+
|
|
146
|
+
if (plain.length > 0) {
|
|
147
|
+
const rows = plain.reduce((sum, f) => sum + f.orphans, 0);
|
|
148
|
+
lines.push(
|
|
149
|
+
`${plain.length} foreign ${plural(plain.length, 'key stays', 'keys stay')} NOT VALID: new writes enforced, `
|
|
150
|
+
+ `${count(rows)} existing ${plural(rows, 'row', 'rows')} unchecked `
|
|
151
|
+
+ `(--verbose for detail; ${declareIt}).`,
|
|
152
|
+
);
|
|
153
|
+
if (verbose) lines.push(...detailLines(plain));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (gating.length > 0) {
|
|
157
|
+
const rows = gating.reduce((sum, f) => sum + f.orphans, 0);
|
|
158
|
+
const where = gating
|
|
159
|
+
.map((f) => `${f.table}.${gatingColumns(f, ownership).join('/')}`)
|
|
160
|
+
.join(', ');
|
|
161
|
+
lines.push(
|
|
162
|
+
`ROW SECURITY — ${gating.length} unproven foreign ${plural(gating.length, 'key also decides', 'keys also decide')} `
|
|
163
|
+
+ `who may read or write the row (${where}): `
|
|
164
|
+
+ `${count(rows)} existing ${plural(rows, 'row', 'rows')} point at a parent that is gone, so the owner check `
|
|
165
|
+
+ `on ${plural(gating.length, 'it', 'them')} matches nobody `
|
|
166
|
+
+ `(--verbose for detail; ${declareIt}).`,
|
|
167
|
+
);
|
|
168
|
+
if (verbose) lines.push(...detailLines(gating));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return lines;
|
|
172
|
+
}
|
package/src/cli/fk-preflight.ts
CHANGED
|
@@ -1,20 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* fk-preflight — the orphan check that runs BEFORE a foreign key's DDL, not during it.
|
|
3
3
|
*
|
|
4
|
-
* A foreign key is only
|
|
4
|
+
* A foreign key is only VALIDATABLE if the rows already in the table satisfy it. PostgreSQL
|
|
5
5
|
* finds that out DURING `VALIDATE CONSTRAINT`, by raising on the first row that does not — one
|
|
6
6
|
* row, no count, no second offender, and (before the two-step split) after the apply had already
|
|
7
7
|
* taken an ACCESS EXCLUSIVE lock and scanned most of the table. The operator learned "there is a
|
|
8
8
|
* problem" at the worst possible moment and learned nothing else.
|
|
9
9
|
*
|
|
10
10
|
* So the apply asks the question first, the same way the declared-owner preflight in
|
|
11
|
-
* derived-apply does: ONE read that answers it for EVERY key the edge
|
|
12
|
-
* can name the COMPLETE list — how many rows, on which key, and what a few of them look
|
|
13
|
-
*
|
|
14
|
-
* exactly that), re-plan, apply.
|
|
11
|
+
* derived-apply does: ONE read that answers it for EVERY key the edge would validate, so the
|
|
12
|
+
* report can name the COMPLETE list — how many rows, on which key, and what a few of them look
|
|
13
|
+
* like — before anything runs.
|
|
15
14
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
15
|
+
* WHAT THE ANSWER IS FOR, and this is the part that changed. It used to refuse the WHOLE apply
|
|
16
|
+
* if any key had one orphan: unrelated grants, unrelated clean keys, the entire edge, held
|
|
17
|
+
* behind a data audit. On a long-lived database that is the wrong trade — ~11,500 legitimately
|
|
18
|
+
* orphaned rows across 12 of 23 keys is ordinary decay, not a broken deploy. Now the answer is
|
|
19
|
+
* PER KEY: the offending key's `VALIDATE` is withheld (the key stays installed and NOT VALID,
|
|
20
|
+
* enforced on every new write), it is recorded as a pending validation, and everything else in
|
|
21
|
+
* the edge commits. Fix the data when you choose (`db:exec` is the credential-free lane) and the
|
|
22
|
+
* next plan emits that one `VALIDATE` alone — or declare `validate: false` on the reference and
|
|
23
|
+
* the question is never asked about that key again.
|
|
24
|
+
*
|
|
25
|
+
* THE UNIT IS A `VALIDATE` STATEMENT, not a foreign key add. That is the whole rule, and it is
|
|
26
|
+
* why a key declared `validate: false` is never scanned: the differ emits no `VALIDATE` for it,
|
|
27
|
+
* so there is nothing here to ask about. An `ADD … NOT VALID` and the `VALIDATE` that follows it
|
|
28
|
+
* in the same edge are ONE question — the key is the unit, not the statement — and an
|
|
29
|
+
* `ADD … NOT VALID` with NO `VALIDATE` behind it is not a question at all.
|
|
30
|
+
*
|
|
31
|
+
* WHICH VALIDATED KEYS NEED THE QUESTION ASKED. Only the ones where an existing row could
|
|
32
|
+
* already violate the constraint, which is a property of the LIVE database, not of the statement:
|
|
18
33
|
*
|
|
19
34
|
* - a key on a table this edge CREATES → no. The table has no rows yet, and it does
|
|
20
35
|
* not exist to be queried before the edge runs.
|
|
@@ -31,13 +46,11 @@
|
|
|
31
46
|
* exactly what VALIDATE is about to scan.
|
|
32
47
|
* - a key on an existing table and columns → YES.
|
|
33
48
|
*
|
|
34
|
-
*
|
|
35
|
-
* two — the key is the unit, not the statement.
|
|
36
|
-
*
|
|
37
|
-
* Pure: the parsing, the SQL, and the refusal text. The caller runs the query.
|
|
49
|
+
* Pure: the parsing, the SQL, the findings, and the text. The caller runs the query.
|
|
38
50
|
*/
|
|
39
51
|
|
|
40
52
|
import type { SchemaSnapshot, TableSchema, ForeignKey } from './schema-introspect';
|
|
53
|
+
import type { PendingValidation } from './state-apply';
|
|
41
54
|
import { statementBody } from './sql-statement';
|
|
42
55
|
import { quoteIdent, quoteQualified } from './pg-ident';
|
|
43
56
|
import { escapeLiteral } from './derived-apply';
|
|
@@ -80,12 +93,18 @@ const liveTable = (live: SchemaSnapshot, table: string): TableSchema | undefined
|
|
|
80
93
|
live.tables.find((t) => t.table === table);
|
|
81
94
|
|
|
82
95
|
/**
|
|
83
|
-
* The foreign keys an edge is about to
|
|
84
|
-
*
|
|
96
|
+
* The foreign keys an edge is about to VALIDATE that could ALREADY be violated by rows in the
|
|
97
|
+
* live database — see the rules in the module docblock.
|
|
85
98
|
*
|
|
86
99
|
* `statements` is the edge exactly as it will execute (held drops and notices included; they are
|
|
87
100
|
* comments and match nothing). `live` is the target's state as the apply just read it — the same
|
|
88
101
|
* snapshot the concurrency lock was computed from, so the two cannot describe different databases.
|
|
102
|
+
*
|
|
103
|
+
* Driven by the `VALIDATE` statements, never by the `ADD`s. An `ADD … NOT VALID` used to imply
|
|
104
|
+
* the question because the emitter always paired the two; a key declared `validate: false` is
|
|
105
|
+
* emitted with no pair, and scanning for it would be scanning a table to answer a question
|
|
106
|
+
* nobody asked. The ADD is still read — it is where a NEW key's columns and target come from,
|
|
107
|
+
* which the live snapshot cannot supply.
|
|
89
108
|
*/
|
|
90
109
|
export function fkOrphanChecks(statements: readonly string[], live: SchemaSnapshot): FkOrphanCheck[] {
|
|
91
110
|
const bodies = statements.map(statementBody);
|
|
@@ -112,6 +131,14 @@ export function fkOrphanChecks(statements: readonly string[], live: SchemaSnapsh
|
|
|
112
131
|
if (m) renamedFrom.set(`${normalizeTableName(m[1])}::${unquote(m[3])}`, unquote(m[2]));
|
|
113
132
|
}
|
|
114
133
|
|
|
134
|
+
// The keys this edge actually asks to be PROVEN. Everything below is about them and only
|
|
135
|
+
// them: a key whose `ADD … NOT VALID` stands alone is the declared state, not a pending step.
|
|
136
|
+
const validated = new Set<string>();
|
|
137
|
+
for (const body of bodies) {
|
|
138
|
+
const m = VALIDATE_RE.exec(body);
|
|
139
|
+
if (m) validated.add(`${normalizeTableName(m[1])}::${unquote(m[2])}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
115
142
|
const checks: FkOrphanCheck[] = [];
|
|
116
143
|
const seen = new Set<string>();
|
|
117
144
|
|
|
@@ -126,6 +153,8 @@ export function fkOrphanChecks(statements: readonly string[], live: SchemaSnapsh
|
|
|
126
153
|
const add = ADD_FK_RE.exec(body);
|
|
127
154
|
if (add) {
|
|
128
155
|
const table = normalizeTableName(add[1]);
|
|
156
|
+
// No `VALIDATE` behind this ADD — `validate: false`. Nothing to prove, nothing to scan.
|
|
157
|
+
if (!validated.has(`${table}::${unquote(add[2])}`)) continue;
|
|
129
158
|
const columns = parseColumns(add[3]);
|
|
130
159
|
const target = liveTable(live, table);
|
|
131
160
|
// Not live yet, or one of its columns is not: nothing to scan, and nothing to scan it with.
|
|
@@ -245,9 +274,9 @@ export function fkOrphanSql(checks: readonly FkOrphanCheck[]): string {
|
|
|
245
274
|
* nobody can read, and an unreadable answer is not "no orphans": it is the gate not running.
|
|
246
275
|
*
|
|
247
276
|
* FAIL CLOSED. A driver that hands back `[]` (a shape quirk, a transport that dropped the rows,
|
|
248
|
-
* a future change to the read) would otherwise walk straight through
|
|
249
|
-
* reports
|
|
250
|
-
* owns the read's shape, so the assertion cannot drift from the SQL it is about.
|
|
277
|
+
* a future change to the read) would otherwise walk straight through {@link fkOrphanFindings},
|
|
278
|
+
* which reports an empty list — "every key is satisfied" — for an empty answer. Asserted here, in
|
|
279
|
+
* the module that owns the read's shape, so the assertion cannot drift from the SQL it is about.
|
|
251
280
|
*/
|
|
252
281
|
export function checkOrphanRowShape(rows: unknown, expected: number): asserts rows is FkOrphanRow[] {
|
|
253
282
|
const got = Array.isArray(rows) ? rows.length : null;
|
|
@@ -295,37 +324,91 @@ export function fkPreflightUnavailableRefusal(error: string): string {
|
|
|
295
324
|
}
|
|
296
325
|
|
|
297
326
|
/**
|
|
298
|
-
*
|
|
327
|
+
* One key the read found orphaned rows for.
|
|
299
328
|
*
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
|
|
329
|
+
* This, not the prose, is what the apply and the plan report from: the apply has to decide PER
|
|
330
|
+
* KEY which `VALIDATE` to withhold, and the plan has to cross-reference the column against the
|
|
331
|
+
* row-ownership declarations. Neither can be done by parsing a message.
|
|
332
|
+
*/
|
|
333
|
+
export interface FkOrphanFinding {
|
|
334
|
+
/** Schema-qualified child table (`public.posts`). */
|
|
335
|
+
table: string;
|
|
336
|
+
/** The constraint name the withheld `VALIDATE` would have named. */
|
|
337
|
+
constraint: string;
|
|
338
|
+
/** The key's own columns, in order. */
|
|
339
|
+
columns: string[];
|
|
340
|
+
/** Schema-qualified target table. */
|
|
341
|
+
refTable: string;
|
|
342
|
+
/** How many rows hold a value with no matching parent. Exact — the read counts, it does not sample. */
|
|
343
|
+
orphans: number;
|
|
344
|
+
/** At most three KEY VALUES, for recognising a row. Never row contents. */
|
|
345
|
+
samples: string[];
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* The keys with orphaned rows, as data. Empty when every key's rows already satisfy it.
|
|
304
350
|
*
|
|
305
|
-
* THROWS
|
|
306
|
-
* "asked and satisfied", so an unreadable
|
|
351
|
+
* THROWS on a count it cannot read, for exactly the reason {@link toCount} does: an empty
|
|
352
|
+
* findings list means "asked and satisfied", so an unreadable answer must never be able to
|
|
353
|
+
* produce one.
|
|
307
354
|
*/
|
|
308
|
-
export function
|
|
309
|
-
const
|
|
310
|
-
const
|
|
311
|
-
|
|
312
|
-
|
|
355
|
+
export function fkOrphanFindings(rows: readonly FkOrphanRow[]): FkOrphanFinding[] {
|
|
356
|
+
const out: FkOrphanFinding[] = [];
|
|
357
|
+
for (const r of rows) {
|
|
358
|
+
const table = String(r.table_name);
|
|
359
|
+
const constraint = String(r.constraint_name);
|
|
360
|
+
const orphans = toCount(r.orphans, `${table}.${constraint}`);
|
|
361
|
+
if (orphans <= 0) continue;
|
|
362
|
+
out.push({
|
|
363
|
+
table,
|
|
364
|
+
constraint,
|
|
365
|
+
columns: String(r.columns).split(', ').filter((c) => c.length > 0),
|
|
366
|
+
refTable: String(r.ref_table),
|
|
367
|
+
orphans,
|
|
368
|
+
samples: toSamples(r.samples),
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
return out;
|
|
372
|
+
}
|
|
313
373
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
374
|
+
/**
|
|
375
|
+
* Split an edge into the statements to RUN and the `VALIDATE`s to WITHHOLD, one per orphaned key.
|
|
376
|
+
*
|
|
377
|
+
* The withheld statement is not a failure and not a refusal — it is a check whose answer is
|
|
378
|
+
* already known, so running it would take a lock and a full scan to be told what the preflight
|
|
379
|
+
* just counted. It goes into the SAME `pendingValidations` record a check that ran and failed
|
|
380
|
+
* goes into: the operator's situation is identical (key installed, enforced forward, unproven
|
|
381
|
+
* against the old rows) and so is the next move.
|
|
382
|
+
*
|
|
383
|
+
* Matching is by table + constraint name, which is the name the `VALIDATE` will speak — a rename
|
|
384
|
+
* in the same edge lands first, and {@link fkOrphanChecks} already keys the check on the NEW
|
|
385
|
+
* name, so the two agree.
|
|
386
|
+
*/
|
|
387
|
+
export function withheldValidations(
|
|
388
|
+
statements: readonly string[],
|
|
389
|
+
findings: readonly FkOrphanFinding[],
|
|
390
|
+
): { statements: string[]; withheld: PendingValidation[] } {
|
|
391
|
+
if (findings.length === 0) return { statements: [...statements], withheld: [] };
|
|
392
|
+
const byKey = new Map(findings.map((f) => [`${f.table}::${f.constraint}`, f]));
|
|
393
|
+
const kept: string[] = [];
|
|
394
|
+
const withheld: PendingValidation[] = [];
|
|
395
|
+
for (const statement of statements) {
|
|
396
|
+
const m = VALIDATE_RE.exec(statementBody(statement));
|
|
397
|
+
const finding = m ? byKey.get(`${normalizeTableName(m[1])}::${unquote(m[2])}`) : undefined;
|
|
398
|
+
if (!finding) {
|
|
399
|
+
kept.push(statement);
|
|
400
|
+
continue;
|
|
325
401
|
}
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
402
|
+
withheld.push({
|
|
403
|
+
sql: statement,
|
|
404
|
+
// Phrased so it reads as the orphan-data cause it IS — `validationPendingReason` sends
|
|
405
|
+
// the operator to the data fix, which is the right move here.
|
|
406
|
+
error:
|
|
407
|
+
`not attempted: ${finding.orphans} row(s) already in ${finding.table} hold a value in `
|
|
408
|
+
+ `(${finding.columns.join(', ')}) with no matching row in ${finding.refTable}, counted by the foreign-key `
|
|
409
|
+
+ 'row check before any DDL ran. The key is installed and enforced on new writes; the rest of the edge applied.',
|
|
410
|
+
skipped: true,
|
|
411
|
+
});
|
|
329
412
|
}
|
|
330
|
-
return
|
|
413
|
+
return { statements: kept, withheld };
|
|
331
414
|
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-detect HOST_URL from SST outputs.
|
|
3
|
+
* SST writes .sst/outputs.json after every deploy with { routerUrl, apiUrl, ... }.
|
|
4
|
+
* If HOST_URL isn't already set, use the routerUrl from the last deploy.
|
|
5
|
+
*
|
|
6
|
+
* When a stage is specified, checks the stage-specific cached config first.
|
|
7
|
+
* .sst/outputs.json reflects the last deploy (any stage), so with --stage it is used only when
|
|
8
|
+
* its apiFunctionName says that deploy WAS this stage (outputsDescribeStage). Otherwise HOST_URL
|
|
9
|
+
* stays unset: a wrong-stage URL would be baked into the OTA bundle by `update`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import fs from 'node:fs/promises';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { createRequire } from 'node:module';
|
|
15
|
+
|
|
16
|
+
export async function loadSstOutputs(stage?: string): Promise<void> {
|
|
17
|
+
if (process.env.HOST_URL) return;
|
|
18
|
+
|
|
19
|
+
// When --stage is provided, try stage-specific cache first
|
|
20
|
+
if (stage) {
|
|
21
|
+
const stagePath = path.resolve('.sst', `outputs.${stage}.json`);
|
|
22
|
+
try {
|
|
23
|
+
const raw = await fs.readFile(stagePath, 'utf8');
|
|
24
|
+
const cached = JSON.parse(raw);
|
|
25
|
+
if (cached.routerUrl) {
|
|
26
|
+
process.env.HOST_URL = cached.routerUrl;
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
} catch {
|
|
30
|
+
// No cached config for this stage — fall through to default
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const outputsPath = path.resolve('.sst', 'outputs.json');
|
|
35
|
+
try {
|
|
36
|
+
const raw = await fs.readFile(outputsPath, 'utf8');
|
|
37
|
+
const outputs = JSON.parse(raw);
|
|
38
|
+
if (stage && !(await outputsBelongToStage(outputs, stage))) return;
|
|
39
|
+
if (outputs.routerUrl) {
|
|
40
|
+
process.env.HOST_URL = outputs.routerUrl;
|
|
41
|
+
}
|
|
42
|
+
} catch {
|
|
43
|
+
// No .sst/outputs.json — user must set HOST_URL manually or use app.config.js defaults
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Provenance gate for the --stage fall-through; an unreadable app name means unknown, so no. */
|
|
48
|
+
async function outputsBelongToStage(outputs: { apiFunctionName?: string }, stage: string): Promise<boolean> {
|
|
49
|
+
try {
|
|
50
|
+
const { parseAppName, outputsDescribeStage } = await import('./discover');
|
|
51
|
+
return outputsDescribeStage(outputs, await parseAppName(), stage);
|
|
52
|
+
} catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The @expo/env `expo export` will load, found the way it finds it: expo -> @expo/cli -> @expo/env
|
|
59
|
+
* from the project (a package manager that does not hoist still resolves it), else a direct
|
|
60
|
+
* @expo/env. Undefined when the project has no Expo.
|
|
61
|
+
*/
|
|
62
|
+
function resolveExpoEnv(from: string): string | undefined {
|
|
63
|
+
const at = (dir: string, id: string) => createRequire(path.join(dir, 'noop.js')).resolve(id);
|
|
64
|
+
try {
|
|
65
|
+
const cli = at(path.dirname(at(from, 'expo/package.json')), '@expo/cli/package.json');
|
|
66
|
+
return at(path.dirname(cli), '@expo/env');
|
|
67
|
+
} catch {
|
|
68
|
+
// fall through
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
return at(from, '@expo/env');
|
|
72
|
+
} catch {
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Does the dotenv loading `expo export` will run set a non-empty HOST_URL? It runs Expo's own
|
|
79
|
+
* loader (@expo/env parseProjectEnv), not a copy of it: file set, precedence,
|
|
80
|
+
* EXPO_NO_DOTENV and variable expansion are Expo's (the mode is fixed to production, as in export), so this sees what the bundle will bake.
|
|
81
|
+
* Non-mutating. Only a boolean escapes; no value is kept or printed. Fails closed: no Expo, or
|
|
82
|
+
* a loader that throws, means no.
|
|
83
|
+
*/
|
|
84
|
+
async function dotenvSetsHostUrl(
|
|
85
|
+
cwd: string,
|
|
86
|
+
env: Record<string, string | undefined>,
|
|
87
|
+
resolveFrom: string,
|
|
88
|
+
): Promise<boolean> {
|
|
89
|
+
const resolved = resolveExpoEnv(resolveFrom);
|
|
90
|
+
if (!resolved) return false;
|
|
91
|
+
try {
|
|
92
|
+
const expoEnv = createRequire(resolved)(resolved) as {
|
|
93
|
+
parseProjectEnv(
|
|
94
|
+
root: string,
|
|
95
|
+
options: { mode?: string; silent?: boolean; systemEnv?: Record<string, string | undefined> },
|
|
96
|
+
): { env: Record<string, string | undefined> };
|
|
97
|
+
};
|
|
98
|
+
// Upstream behaviour, re-verify on every Expo upgrade: @expo/cli build/src/export/exportApp.js
|
|
99
|
+
// exportAppAsync sets `process.env.NODE_ENV = dev ? 'development' : 'production'`
|
|
100
|
+
// unconditionally, and everystack never passes --dev. So the mode is production, not env.NODE_ENV.
|
|
101
|
+
const parsed = expoEnv.parseProjectEnv(cwd, { mode: 'production', silent: true, systemEnv: env });
|
|
102
|
+
return Boolean(parsed.env.HOST_URL);
|
|
103
|
+
} catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Why `update --stage` must stop: nothing supplied a HOST_URL, so the OTA bundle would bake the
|
|
110
|
+
* app's default (often localhost) as updates.url. Devices could then never fetch the recovery
|
|
111
|
+
* publish. Undefined when HOST_URL is set (env or dotenv) or no stage was given. The message
|
|
112
|
+
* names the ways out, including the stage's own site URL when discovery found one.
|
|
113
|
+
*/
|
|
114
|
+
export async function missingHostUrlError(
|
|
115
|
+
stage: string | undefined,
|
|
116
|
+
opts: { env?: Record<string, string | undefined>; cwd?: string; siteUrl?: string; resolveFrom?: string } = {},
|
|
117
|
+
): Promise<string | undefined> {
|
|
118
|
+
const { env = process.env, cwd = process.cwd(), siteUrl, resolveFrom = cwd } = opts;
|
|
119
|
+
if (!stage || env.HOST_URL || (await dotenvSetsHostUrl(cwd, env, resolveFrom))) return undefined;
|
|
120
|
+
const offer = siteUrl ? ` This stage's site URL is ${siteUrl}; export it as HOST_URL if that is its public URL.` : '';
|
|
121
|
+
return (
|
|
122
|
+
`No HOST_URL for stage "${stage}", so the bundle would ship the app's default URL as updates.url.${offer} ` +
|
|
123
|
+
'Otherwise export HOST_URL=<the stage\'s public URL>, or set CDN_URL on the API function and re-run with --refresh. ' +
|
|
124
|
+
'(A HOST_URL in a dotenv file counts only when the project\'s Expo can be found to read it the way `expo export` does.) ' +
|
|
125
|
+
`(.sst/outputs.json is used only when its apiFunctionName is "<app>-${stage}-ApiFunction-...".)`
|
|
126
|
+
);
|
|
127
|
+
}
|
package/src/cli/index.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env tsx
|
|
2
2
|
|
|
3
|
-
import
|
|
4
|
-
import path from 'node:path';
|
|
3
|
+
import { loadSstOutputs } from './host-url';
|
|
5
4
|
import { updateCommand } from './commands/update';
|
|
6
5
|
import { deployCommand } from './commands/deploy';
|
|
7
6
|
import { certsCommand } from './commands/certs';
|
|
@@ -51,45 +50,6 @@ import { parseFlags } from './parse-flags';
|
|
|
51
50
|
const args = process.argv.slice(2);
|
|
52
51
|
const command = args[0];
|
|
53
52
|
|
|
54
|
-
/**
|
|
55
|
-
* Auto-detect HOST_URL from SST outputs.
|
|
56
|
-
* SST writes .sst/outputs.json after every deploy with { routerUrl, apiUrl, ... }.
|
|
57
|
-
* If HOST_URL isn't already set, use the routerUrl from the last deploy.
|
|
58
|
-
*
|
|
59
|
-
* When a stage is specified, checks the stage-specific cached config first.
|
|
60
|
-
* .sst/outputs.json reflects the last deploy (any stage) and may be wrong
|
|
61
|
-
* when publishing to a different stage.
|
|
62
|
-
*/
|
|
63
|
-
async function loadSstOutputs(stage?: string): Promise<void> {
|
|
64
|
-
if (process.env.HOST_URL) return;
|
|
65
|
-
|
|
66
|
-
// When --stage is provided, try stage-specific cache first
|
|
67
|
-
if (stage) {
|
|
68
|
-
const stagePath = path.resolve('.sst', `outputs.${stage}.json`);
|
|
69
|
-
try {
|
|
70
|
-
const raw = await fs.readFile(stagePath, 'utf8');
|
|
71
|
-
const cached = JSON.parse(raw);
|
|
72
|
-
if (cached.routerUrl) {
|
|
73
|
-
process.env.HOST_URL = cached.routerUrl;
|
|
74
|
-
return;
|
|
75
|
-
}
|
|
76
|
-
} catch {
|
|
77
|
-
// No cached config for this stage — fall through to default
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const outputsPath = path.resolve('.sst', 'outputs.json');
|
|
82
|
-
try {
|
|
83
|
-
const raw = await fs.readFile(outputsPath, 'utf8');
|
|
84
|
-
const outputs = JSON.parse(raw);
|
|
85
|
-
if (outputs.routerUrl) {
|
|
86
|
-
process.env.HOST_URL = outputs.routerUrl;
|
|
87
|
-
}
|
|
88
|
-
} catch {
|
|
89
|
-
// No .sst/outputs.json — user must set HOST_URL manually or use app.config.js defaults
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
53
|
async function main() {
|
|
94
54
|
if (!command || command === '--help' || command === '-h') {
|
|
95
55
|
printHelp();
|
package/src/cli/model-render.ts
CHANGED
|
@@ -549,6 +549,14 @@ function renderField(table: TableSchema, col: ColumnSchema, known: Set<string>,
|
|
|
549
549
|
if (fk.name && fk.name !== generatedForeignKeyName(bareName(table.table), fk.columns, bareName(fk.refTable), fk.refColumns)) {
|
|
550
550
|
actions.push(`name: ${tsLiteral(fk.name)}`);
|
|
551
551
|
}
|
|
552
|
+
// The key is live and has NEVER been checked against the rows already in the table. Rendering
|
|
553
|
+
// it as an ordinary reference would declare a state this database is not in — and the FIRST
|
|
554
|
+
// plan after the pull would then emit a `VALIDATE` the legacy rows cannot pass. On the schema
|
|
555
|
+
// this was measured against that was 12 of 23 keys, every one of them deliberately NOT VALID
|
|
556
|
+
// in the app's own migration ("enforce forward, grandfather legacy"). So the pull SAYS what it
|
|
557
|
+
// found, the adoption plan comes out empty, and removing the flag later is how an operator
|
|
558
|
+
// opts into the scan. Same rule as the index and constraint names one line up: be faithful.
|
|
559
|
+
if (fk.notValid) actions.push('validate: false');
|
|
552
560
|
const opts = actions.length ? `, { ${actions.join(', ')} }` : '';
|
|
553
561
|
expr += `.references(() => ${modelVarName(fk.refTable)}${opts})`;
|
|
554
562
|
} else comment += ` // FK → ${fk.refTable} (not in the pulled set)`;
|
|
@@ -631,6 +639,8 @@ function renderConstraintsBlock(table: TableSchema, checks: CheckConstraint[], k
|
|
|
631
639
|
if (fk.name && fk.name !== generatedForeignKeyName(bareName(table.table), fk.columns, bareName(fk.refTable), fk.refColumns)) {
|
|
632
640
|
actions.push(`name: ${tsLiteral(fk.name)}`);
|
|
633
641
|
}
|
|
642
|
+
// Faithful about validity, exactly as the single-column case above is — see there for why.
|
|
643
|
+
if (fk.notValid) actions.push('validate: false');
|
|
634
644
|
const opts = actions.length ? `, { ${actions.join(', ')} }` : '';
|
|
635
645
|
items.push(`foreignKey(${cols}, () => ${modelVarName(fk.refTable)}, ${refCols}${opts})`);
|
|
636
646
|
}
|