@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.
@@ -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
+ }
@@ -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 installable if the rows already in the table satisfy it. PostgreSQL
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 touches, so the refusal
12
- * can name the COMPLETE list — how many rows, on which key, and what a few of them look like —
13
- * and NOTHING has run when it is raised. Fix the data (`db:exec` is the credential-free lane for
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
- * WHICH KEYS NEED THE QUESTION ASKED. Only the ones where an existing row could already violate
17
- * the constraint, which is a property of the LIVE database, not of the statement:
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
- * An `ADD … NOT VALID` and the `VALIDATE` that follows it in the same edge are ONE question, not
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 install or validate that could ALREADY be violated by
84
- * rows in the live database — see the rules in the module docblock.
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 `fkOrphanRefusal`, which
249
- * reports null — "every key is satisfied" — for an empty list. Asserted here, in the module that
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
- * The refusal text, or null when every key's rows already satisfy it.
327
+ * One key the read found orphaned rows for.
299
328
  *
300
- * It names the COUNT and a SAMPLE per key, because those are the two things the operator needs
301
- * and neither is in PostgreSQL's own error: how much work the fix is, and what the broken rows
302
- * look like. The fix is data, so the escape is a data fix and a re-plan — never a flag. There is
303
- * deliberately no `--force`: an unsatisfiable foreign key is not an intent someone can attest to.
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 when a row's count cannot be read — see {@link toCount}. `null` returned from here means
306
- * "asked and satisfied", so an unreadable count must not be able to produce it.
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 fkOrphanRefusal(rows: readonly FkOrphanRow[]): string | null {
309
- const where = (r: FkOrphanRow): string => `${String(r.table_name)}.${String(r.constraint_name)}`;
310
- const counted = rows.map((r) => ({ row: r, count: toCount(r.orphans, where(r)) }));
311
- const offenders = counted.filter((c) => c.count > 0);
312
- if (offenders.length === 0) return null;
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
- const lines = [
315
- `${offenders.length} foreign key(s) cannot be validated — rows already in the table do not satisfy them. Refused before any DDL, so nothing is half-applied.`,
316
- `Fix the data first (everystack db:exec runs a DML-only file against the target, credential-free), then re-plan and apply.`,
317
- ];
318
- for (const { row, count } of offenders) {
319
- const samples = toSamples(row.samples);
320
- lines.push(
321
- ` ORPHANED ROWS ${String(row.table_name)}.${String(row.constraint_name)}: ${count} row(s) hold a value in (${String(row.columns)}) with no matching row in ${String(row.ref_table)}.`,
322
- );
323
- if (samples.length > 0) {
324
- lines.push(` sample value(s): ${samples.join(', ')}${count > samples.length ? ', …' : ''}`);
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
- lines.push(
327
- ` the rows: SELECT * FROM ${row.table_name} c WHERE NOT EXISTS (SELECT 1 FROM ${row.ref_table} p WHERE p.<key> = c.<fk>);`,
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 lines.join('\n');
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 fs from 'node:fs/promises';
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();
@@ -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
  }