@everystack/cli 0.4.77 → 0.4.79
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 +31 -4
- package/package.json +1 -1
- package/src/cli/authz-adoption-class.ts +112 -22
- package/src/cli/commands/db-apply.ts +12 -3
- package/src/cli/commands/db-plan.ts +64 -8
- package/src/cli/commands/db-pull.ts +31 -0
- package/src/cli/edge-plan.ts +18 -3
- package/src/cli/git-descent.ts +290 -48
- package/src/cli/index.ts +1 -6
- package/src/cli/adopt.ts +0 -58
- package/src/cli/commands/db-adopt.ts +0 -243
- package/src/cli/live-models.ts +0 -65
package/src/cli/git-descent.ts
CHANGED
|
@@ -85,9 +85,13 @@ export interface ModelTreeCandidate {
|
|
|
85
85
|
|
|
86
86
|
const short = (ref: string): string => ref.slice(0, 12);
|
|
87
87
|
|
|
88
|
+
/** Every git command this module runs is bounded: in a partial clone even `cat-file` and
|
|
89
|
+
* `merge-base` can fetch lazily over the network, and a hang must become a null (refuse-closed). */
|
|
90
|
+
const GIT_TIMEOUT_MS = 60_000;
|
|
91
|
+
|
|
88
92
|
function git(args: string[], cwd: string): string | null {
|
|
89
93
|
try {
|
|
90
|
-
return execFileSync('git', args, { cwd, stdio: ['ignore', 'pipe', 'ignore'] }).toString();
|
|
94
|
+
return execFileSync('git', args, { cwd, stdio: ['ignore', 'pipe', 'ignore'], timeout: GIT_TIMEOUT_MS }).toString();
|
|
91
95
|
} catch {
|
|
92
96
|
return null;
|
|
93
97
|
}
|
|
@@ -100,6 +104,44 @@ const defaultLoader: ModelsLoader = async (barrel) => {
|
|
|
100
104
|
return models;
|
|
101
105
|
};
|
|
102
106
|
|
|
107
|
+
/**
|
|
108
|
+
* The SQUASHED SUCCESSOR of a witness commit HEAD does not descend from: an ancestor of HEAD that
|
|
109
|
+
* carries the witness's models tree UNCHANGED (same tree oid) and is not itself an ancestor of
|
|
110
|
+
* the witness. Deploy from a PR branch, squash-merge, deploy the next stage — the ledger names the
|
|
111
|
+
* PR commit, main carries its tree under a new sha, and every line of the declaring commit is
|
|
112
|
+
* inside HEAD. Refusing that as a revert is false (consumer cutover, cli 0.4.77, 2026-09-07).
|
|
113
|
+
*
|
|
114
|
+
* The second clause is the whole safety. In an S→T→S episode the pre-episode ancestor carries S's
|
|
115
|
+
* tree too — byte-identical — and a checkout branched there must still be refused: the witness
|
|
116
|
+
* (the re-declaration of S) DESCENDS from that carrier, so the carrier is the state the episode
|
|
117
|
+
* started from, not a successor. A squash commit is never an ancestor of the branch it squashed.
|
|
118
|
+
* Tree identity is exact and cheap (`rev-parse <sha>:<dir>`, no materialization); a state-level
|
|
119
|
+
* recompile is deliberately NOT used here, because it cannot tell those two shapes apart.
|
|
120
|
+
*
|
|
121
|
+
* One carrier that IS an ancestor of the witness still counts: the one with NO models change
|
|
122
|
+
* between it and the witness. That is the app-only PR — the deploy changed no models, the
|
|
123
|
+
* witness's tree is the base tree, and `rev-list --full-history -- <dir>` lists only the base as a
|
|
124
|
+
* carrier (closing-gate review, 2026-09-07). The pre-episode carrier never qualifies: the T
|
|
125
|
+
* episode sits between it and the witness, so `rev-list carrier..witness -- <dir>` is non-empty.
|
|
126
|
+
*/
|
|
127
|
+
function squashedSuccessor(witness: string, head: string, modelsDir: string, repoRoot: string): string | null {
|
|
128
|
+
const spec = modelsDir === '.' ? `${witness}^{tree}` : `${witness}:${modelsDir}`;
|
|
129
|
+
const tree = git(['rev-parse', '--verify', '--quiet', spec], repoRoot)?.trim();
|
|
130
|
+
if (!tree) return null;
|
|
131
|
+
const carriers = enumerateModelTrees(modelsDir, repoRoot, { ancestorsOf: head }).find((c) => c.tree === tree)?.commits ?? [];
|
|
132
|
+
const modelsUnchangedBetween = (carrier: string): boolean => {
|
|
133
|
+
const changes = git(['rev-list', `${carrier}..${witness}`, '--', modelsDir], repoRoot);
|
|
134
|
+
return changes !== null && changes.trim() === '';
|
|
135
|
+
};
|
|
136
|
+
// Refuse-closed on BOTH git questions: an ancestry git could not answer is not "not an
|
|
137
|
+
// ancestor" (that would accept the carrier), and a rev-list that failed is not "no changes".
|
|
138
|
+
return carriers.find((c) => {
|
|
139
|
+
const anc = ancestry(c, witness, repoRoot);
|
|
140
|
+
if (anc === null) return false;
|
|
141
|
+
return !anc || modelsUnchangedBetween(c);
|
|
142
|
+
}) ?? null;
|
|
143
|
+
}
|
|
144
|
+
|
|
103
145
|
/**
|
|
104
146
|
* Every historical state of the models directory across ALL refs, newest
|
|
105
147
|
* first, grouped by tree oid. `--full-history` keeps every !TREESAME commit
|
|
@@ -132,6 +174,7 @@ export function enumerateModelTrees(
|
|
|
132
174
|
cwd,
|
|
133
175
|
input: specs.join('\n') + '\n',
|
|
134
176
|
stdio: ['pipe', 'pipe', 'ignore'],
|
|
177
|
+
timeout: GIT_TIMEOUT_MS,
|
|
135
178
|
}).toString();
|
|
136
179
|
} catch {
|
|
137
180
|
return [];
|
|
@@ -195,8 +238,9 @@ export function materializeTree(tree: string, dest: string, cwd: string): void {
|
|
|
195
238
|
type TreeEval =
|
|
196
239
|
| { kind: 'declares' }
|
|
197
240
|
| { kind: 'no' }
|
|
198
|
-
|
|
199
|
-
| { kind: '
|
|
241
|
+
/** `detail` is the one-line record (`<tree> (at <commit>): <error>`); `error` is the bare cause. */
|
|
242
|
+
| { kind: 'compile-failed'; detail: string; error: string }
|
|
243
|
+
| { kind: 'unevaluated'; detail: string; error: string };
|
|
200
244
|
|
|
201
245
|
interface EvalContext {
|
|
202
246
|
repoRoot: string;
|
|
@@ -225,7 +269,8 @@ async function evaluateTree(candidate: ModelTreeCandidate, live: LiveState, ctx:
|
|
|
225
269
|
fs.rmSync(dest, { recursive: true, force: true });
|
|
226
270
|
ctx.materialize(candidate.tree, dest, ctx.repoRoot);
|
|
227
271
|
} catch (err: any) {
|
|
228
|
-
|
|
272
|
+
const error = `could not extract — ${firstLine(err.message)}`;
|
|
273
|
+
return { kind: 'unevaluated', detail: `${where}: ${error}`, error };
|
|
229
274
|
}
|
|
230
275
|
}
|
|
231
276
|
|
|
@@ -233,8 +278,9 @@ async function evaluateTree(candidate: ModelTreeCandidate, live: LiveState, ctx:
|
|
|
233
278
|
// not a failure to read. Every pre-Models repo has such ancestors: the models directory
|
|
234
279
|
// scaffolded (or present for another reason) before its barrel existed. Letting the loader
|
|
235
280
|
// throw on the absent file would classify it `compile-failed`, poisoning the verdict to
|
|
236
|
-
// UNVERIFIABLE — which sends a never-adopted database to "retry/upgrade" instead of to
|
|
237
|
-
// db:
|
|
281
|
+
// UNVERIFIABLE — which sends a never-adopted database to "retry/upgrade" instead of to the
|
|
282
|
+
// first-adoption lane (db:pull → commit → db:plan). So the missing barrel is decided here,
|
|
283
|
+
// before the loader is ever consulted.
|
|
238
284
|
const barrelPath = path.join(dest, ctx.barrel);
|
|
239
285
|
if (!fs.existsSync(barrelPath)) return { kind: 'no' };
|
|
240
286
|
|
|
@@ -259,8 +305,100 @@ async function evaluateTree(candidate: ModelTreeCandidate, live: LiveState, ctx:
|
|
|
259
305
|
}
|
|
260
306
|
return declares ? { kind: 'declares' } : { kind: 'no' };
|
|
261
307
|
} catch (err: any) {
|
|
262
|
-
|
|
308
|
+
const error = firstLine(err.message);
|
|
309
|
+
return { kind: 'compile-failed', detail: `${where}: ${error}`, error };
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** One tree the walk could not read, with where it sits in history — the refusal names it. */
|
|
314
|
+
interface UnreadableTree {
|
|
315
|
+
candidate: ModelTreeCandidate;
|
|
316
|
+
/** Carried by a commit HEAD descends from — no ref prune removes it. */
|
|
317
|
+
ancestor: boolean;
|
|
318
|
+
error: string;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** The git verb that removes one ref, or null for a ref shape we do not prescribe deleting. */
|
|
322
|
+
export function pruneCommand(ref: string): string | null {
|
|
323
|
+
const head = ref.match(/^refs\/heads\/(.+)$/);
|
|
324
|
+
if (head) return `git branch -D ${head[1]}`;
|
|
325
|
+
const remote = ref.match(/^refs\/remotes\/([^/]+)\/(.+)$/);
|
|
326
|
+
if (remote) return `git push ${remote[1]} --delete ${remote[2]}`;
|
|
327
|
+
const tag = ref.match(/^refs\/tags\/(.+)$/);
|
|
328
|
+
if (tag) return `git tag -d ${tag[1]}`;
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* The unreadable-tree refusal, built from what the walk KNOWS: each tree, the refs that carry it,
|
|
334
|
+
* whether HEAD descends from it, and what the target's ledger says about who deployed what.
|
|
335
|
+
*
|
|
336
|
+
* Consumer cutover, cli 0.4.77 (2026-09-07): four August trees carried only by one backup branch
|
|
337
|
+
* on origin refused a production apply, and the message led with "upgrade/pin the CLI that last
|
|
338
|
+
* deployed this state" — on a database no CLI had ever deployed (schema_log was empty). The
|
|
339
|
+
* operator reconstructed the carrying ref with `git rev-list --all -- db/models` and a per-tree
|
|
340
|
+
* grep. Every fact in that reconstruction was already in this function's hands.
|
|
341
|
+
*
|
|
342
|
+
* The verdict does not change here; only the account of it. A tree we cannot read may still
|
|
343
|
+
* describe live, so the walk stays refuse-closed; the ledger is a memoir and never decides
|
|
344
|
+
* (decision 7). What the ledger DOES decide is which fix comes first: with no recorded apply,
|
|
345
|
+
* no CLI deployed these trees and "pin the CLI" is false, so bundle + prune leads; with one, pin
|
|
346
|
+
* the CLI only when the recorded commit is itself among the unreadable trees.
|
|
347
|
+
*/
|
|
348
|
+
function unreadableRunbook(
|
|
349
|
+
unreadable: readonly UnreadableTree[],
|
|
350
|
+
lastApplied: DescentLedgerRow | undefined,
|
|
351
|
+
probe: GitLineageProbe,
|
|
352
|
+
): string {
|
|
353
|
+
// Bounded on both axes, because this reason is RECORDED in schema_log and each ref lookup is a
|
|
354
|
+
// full ref walk: at most MAX_TREES trees are named, and a tree's refs are the union over its
|
|
355
|
+
// first MAX_COMMITS carrying commits (a tree is often carried by several commits on several
|
|
356
|
+
// branches — naming one commit's refs under-named the prune; closing-gate review, 2026-09-07).
|
|
357
|
+
const MAX_TREES = 10;
|
|
358
|
+
const MAX_COMMITS = 5;
|
|
359
|
+
const listed = unreadable.slice(0, MAX_TREES);
|
|
360
|
+
const refsOf = new Map<string, string[]>();
|
|
361
|
+
for (const u of listed) {
|
|
362
|
+
const refs = new Set<string>();
|
|
363
|
+
for (const commit of u.candidate.commits.slice(0, MAX_COMMITS)) {
|
|
364
|
+
for (const ref of probe.refsContaining(commit)) if (ref !== 'refs/stash') refs.add(ref);
|
|
365
|
+
}
|
|
366
|
+
refsOf.set(u.candidate.tree, [...refs].sort());
|
|
367
|
+
}
|
|
368
|
+
const lines = listed.map((u) => {
|
|
369
|
+
const refs = refsOf.get(u.candidate.tree)!;
|
|
370
|
+
const carried = refs.length === 0
|
|
371
|
+
? 'carried by no ref'
|
|
372
|
+
: `carried by ${refs.slice(0, 3).join(', ')}${refs.length > 3 ? ` and ${refs.length - 3} more` : ''}`;
|
|
373
|
+
const where = u.ancestor ? "on your checkout's own history" : 'not an ancestor of your checkout';
|
|
374
|
+
return ` ${short(u.candidate.tree)} at ${short(u.candidate.commits[0])} — ${carried} (${where}) — ${scrubPaths(u.error)}`;
|
|
375
|
+
});
|
|
376
|
+
if (unreadable.length > listed.length) {
|
|
377
|
+
lines.push(` … and ${unreadable.length - listed.length} more unreadable tree(s) not listed — their refs are not in the prune list below; re-run after pruning to see them`);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const prunable = listed.filter((u) => !u.ancestor);
|
|
381
|
+
const ancestors = unreadable.filter((u) => u.ancestor);
|
|
382
|
+
const pruneRefs = [...new Set(prunable.flatMap((u) => refsOf.get(u.candidate.tree)!))];
|
|
383
|
+
const pruneCmds = pruneRefs.map(pruneCommand).filter((c): c is string => c !== null);
|
|
384
|
+
const prune = pruneRefs.length === 0
|
|
385
|
+
? ''
|
|
386
|
+
: `Keep them if you want (git bundle create unreadable-trees.bundle ${pruneRefs.join(' ')}), then prune: ${pruneCmds.join('; ')}; then re-run.`;
|
|
387
|
+
const rewrite = ancestors.length === 0
|
|
388
|
+
? ''
|
|
389
|
+
: `${ancestors.length === unreadable.length ? 'These trees are' : `${ancestors.length} of them ${ancestors.length === 1 ? 'is' : 'are'}`} on your checkout's own history, which no ref prune removes; the walk needs every ancestor tree readable. Rewriting that history is the git-native remedy — keep it first (git bundle create everything.bundle --all).`;
|
|
390
|
+
|
|
391
|
+
let lead: string;
|
|
392
|
+
if (!lastApplied) {
|
|
393
|
+
lead = 'No everystack apply is recorded on this target, so no CLI deployed these trees.';
|
|
394
|
+
} else {
|
|
395
|
+
const at = lastApplied.gitRef ? `at ${short(lastApplied.gitRef)} ` : '';
|
|
396
|
+
const among = lastApplied.gitRef !== null && unreadable.some((u) => u.candidate.commits.includes(lastApplied.gitRef!));
|
|
397
|
+
lead = `everystack last applied here ${at}on ${shortDate(lastApplied.appliedAt)}.`
|
|
398
|
+
+ (among ? ' That commit is among the unreadable trees — pin the CLI that applied it (the version in that commit\'s lockfile) and re-run.' : '');
|
|
399
|
+
if (among) return `${lines.join('\n')}\n${lead}`;
|
|
263
400
|
}
|
|
401
|
+
return `${lines.join('\n')}\n${[lead, prune, rewrite].filter(Boolean).join(' ')}`;
|
|
264
402
|
}
|
|
265
403
|
|
|
266
404
|
/**
|
|
@@ -296,7 +434,7 @@ const firstLine = summarizeCompileError;
|
|
|
296
434
|
* in a monorepo the app (`apps/expo/db/models`) has `apps/expo/node_modules`, and the root
|
|
297
435
|
* `node_modules` may not carry the package at all (closing-gate review round 4, reproduced). No
|
|
298
436
|
* `node_modules` anywhere up to the repo root → the repo root itself (a nested checkout still walks
|
|
299
|
-
* up to its host's). Never the OS temp dir — that resolves nothing (the
|
|
437
|
+
* up to its host's). Never the OS temp dir — that resolves nothing (the 0.4.74 sandbox-resolution bug).
|
|
300
438
|
*/
|
|
301
439
|
export function materializeBase(repoRoot: string, modelsAbs: string): string {
|
|
302
440
|
let dir = path.dirname(modelsAbs);
|
|
@@ -331,17 +469,32 @@ function resolveRepoContext(
|
|
|
331
469
|
/** `git cat-file -e <ref>^{commit}` — is the commit present in THIS clone (not a shallow gap)? */
|
|
332
470
|
function commitPresent(ref: string, cwd: string): boolean {
|
|
333
471
|
try {
|
|
334
|
-
execFileSync('git', ['cat-file', '-e', `${ref}^{commit}`], { cwd, stdio: 'ignore' });
|
|
472
|
+
execFileSync('git', ['cat-file', '-e', `${ref}^{commit}`], { cwd, stdio: 'ignore', timeout: GIT_TIMEOUT_MS });
|
|
335
473
|
return true;
|
|
336
474
|
} catch {
|
|
337
475
|
return false;
|
|
338
476
|
}
|
|
339
477
|
}
|
|
340
478
|
|
|
341
|
-
/**
|
|
479
|
+
/**
|
|
480
|
+
* `git merge-base --is-ancestor`, three-valued: true / false / null when git could not answer
|
|
481
|
+
* (a timeout, a missing object, a broken repo). Every caller whose SAFE answer is "not an
|
|
482
|
+
* ancestor" may use {@link isAncestor}; a caller whose safe answer is the OTHER way — the squash
|
|
483
|
+
* guard accepts a carrier that is NOT an ancestor of the witness — must read null as "refuse".
|
|
484
|
+
*/
|
|
485
|
+
function ancestry(ancestor: string, head: string, cwd: string): boolean | null {
|
|
486
|
+
try {
|
|
487
|
+
execFileSync('git', ['merge-base', '--is-ancestor', ancestor, head], { cwd, stdio: 'ignore', timeout: GIT_TIMEOUT_MS });
|
|
488
|
+
return true;
|
|
489
|
+
} catch (err: any) {
|
|
490
|
+
return err?.status === 1 ? false : null; // exit 1 = "not an ancestor"; anything else = could not tell
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** `git merge-base --is-ancestor <ancestor> <head>` — does HEAD descend from the commit? False when git cannot tell. */
|
|
342
495
|
function isAncestor(ancestor: string, head: string, cwd: string): boolean {
|
|
343
496
|
try {
|
|
344
|
-
execFileSync('git', ['merge-base', '--is-ancestor', ancestor, head], { cwd, stdio: 'ignore' });
|
|
497
|
+
execFileSync('git', ['merge-base', '--is-ancestor', ancestor, head], { cwd, stdio: 'ignore', timeout: GIT_TIMEOUT_MS });
|
|
345
498
|
return true;
|
|
346
499
|
} catch {
|
|
347
500
|
return false;
|
|
@@ -378,6 +531,11 @@ export interface GitLineageProbe {
|
|
|
378
531
|
remotes(): string[] | null;
|
|
379
532
|
/** `git ls-remote --heads --tags <remote>` advertised tip shas; null when the remote is unreachable. */
|
|
380
533
|
lsRemote(remote: string): string[] | null;
|
|
534
|
+
/**
|
|
535
|
+
* Why the last `lsRemote(remote)` returned null — git's own first stderr line, paths scrubbed —
|
|
536
|
+
* or null when nothing was captured. Optional: a probe without it still names the remote.
|
|
537
|
+
*/
|
|
538
|
+
lsRemoteError?(remote: string): string | null;
|
|
381
539
|
/**
|
|
382
540
|
* Does this object peel to a commit (`<sha>^{commit}`)? `null` when the object is absent from
|
|
383
541
|
* the clone. An advertised tip that is present but is a tag on a blob or tree can carry no
|
|
@@ -387,9 +545,41 @@ export interface GitLineageProbe {
|
|
|
387
545
|
peelsToCommit?(sha: string): boolean | null;
|
|
388
546
|
}
|
|
389
547
|
|
|
548
|
+
/**
|
|
549
|
+
* The runbook for a remote `git ls-remote` could not read. Three things it must do, each learned
|
|
550
|
+
* from a consumer's production cutover (cli 0.4.77, 2026-09-07) where the remote was named `db`
|
|
551
|
+
* and "could not read db" was read as the database being unreachable: name the remote AS a git
|
|
552
|
+
* remote, carry git's own error so the cause is on the surface, and name the verb that clears a
|
|
553
|
+
* dead one — `git remote remove` — next to the one that fixes a live one.
|
|
554
|
+
*/
|
|
555
|
+
function unreadableRemote(remote: string, git: GitLineageProbe): string {
|
|
556
|
+
const err = git.lsRemoteError?.(remote);
|
|
557
|
+
return `git remote '${remote}' could not be read: \`git ls-remote --heads --tags ${remote}\` failed${err ? ` — ${err}` : ''}. `
|
|
558
|
+
+ `Fix the remote or remove it (git remote remove ${remote}), then re-run.`;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* git's stderr, reduced to what a refusal may carry: the first non-empty line, absolute paths
|
|
563
|
+
* and file:// URLs replaced by `<path>` (the reason is recorded, and a home directory is not
|
|
564
|
+
* part of the diagnosis), capped so a chatty transport cannot flood the surface.
|
|
565
|
+
*/
|
|
566
|
+
export function firstErrorLine(stderr: string): string | null {
|
|
567
|
+
const line = stderr.split('\n').map((l) => l.trim()).find(Boolean);
|
|
568
|
+
if (!line) return null;
|
|
569
|
+
return scrubPaths(line).slice(0, 200);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/** Absolute paths and file:// URLs replaced by `<path>` — a recorded reason never carries a home directory. */
|
|
573
|
+
export function scrubPaths(s: string): string {
|
|
574
|
+
return s
|
|
575
|
+
.replace(/file:\/\/\S+/g, 'file://<path>')
|
|
576
|
+
.replace(/(^|[\s'"(])\/[^\s'"()]+/g, '$1<path>');
|
|
577
|
+
}
|
|
578
|
+
|
|
390
579
|
export type LineageProof =
|
|
391
580
|
| { proven: true; subcase: 'revert-unreachable' | 'unknown-commit-proven-absent' }
|
|
392
|
-
|
|
581
|
+
/** `ref` is set for `reachable`: the ref (or `HEAD`, or a remote name) that still holds the commit. */
|
|
582
|
+
| { proven: false; kind: 'shallow' | 'partial' | 'reachable' | 'behind-remote' | 'remote-unreachable' | 'remote-list-failed' | 'no-remotes'; reason: string; ref?: string };
|
|
393
583
|
|
|
394
584
|
/**
|
|
395
585
|
* Prove the declaring commit `sha` is reachable from NO ref, local or remote. Refuse-closed: every
|
|
@@ -410,10 +600,10 @@ export function proveLineageLost(sha: string, git: GitLineageProbe): LineageProo
|
|
|
410
600
|
if (present) {
|
|
411
601
|
const containing = git.refsContaining(sha);
|
|
412
602
|
if (containing.length > 0) {
|
|
413
|
-
return { proven: false, kind: 'reachable', reason: `commit ${short(sha)} is still reachable (${containing[0]}) — revert it in git and re-plan instead of attesting it lost.` };
|
|
603
|
+
return { proven: false, kind: 'reachable', ref: containing[0], reason: `commit ${short(sha)} is still reachable (${containing[0]}) — revert it in git and re-plan instead of attesting it lost.` };
|
|
414
604
|
}
|
|
415
605
|
if (git.isAncestorOf(sha, 'HEAD')) {
|
|
416
|
-
return { proven: false, kind: 'reachable', reason: `commit ${short(sha)} is still reachable (an ancestor of HEAD) — revert it in git and re-plan instead of attesting it lost.` };
|
|
606
|
+
return { proven: false, kind: 'reachable', ref: 'HEAD', reason: `commit ${short(sha)} is still reachable (an ancestor of HEAD) — revert it in git and re-plan instead of attesting it lost.` };
|
|
417
607
|
}
|
|
418
608
|
}
|
|
419
609
|
|
|
@@ -430,14 +620,14 @@ export function proveLineageLost(sha: string, git: GitLineageProbe): LineageProo
|
|
|
430
620
|
for (const remote of remotes) {
|
|
431
621
|
const tips = git.lsRemote(remote);
|
|
432
622
|
if (tips === null) {
|
|
433
|
-
return { proven: false, kind: 'remote-unreachable', reason: `
|
|
623
|
+
return { proven: false, kind: 'remote-unreachable', reason: `cannot confirm ${short(sha)} is not on ${remote}: ${unreadableRemote(remote, git)}` };
|
|
434
624
|
}
|
|
435
625
|
for (const tip of tips) {
|
|
436
626
|
if (!git.objectExists(tip)) {
|
|
437
627
|
return { proven: false, kind: 'behind-remote', reason: `this clone is behind ${remote} (it advertises ${short(tip)}, which you do not have) — fetch it first; the declaring commit may still be on the remote.` };
|
|
438
628
|
}
|
|
439
629
|
if (present && git.isAncestorOf(sha, tip)) {
|
|
440
|
-
return { proven: false, kind: 'reachable', reason: `commit ${short(sha)} is still reachable (on ${remote}) — revert it in git and re-plan instead of attesting it lost.` };
|
|
630
|
+
return { proven: false, kind: 'reachable', ref: remote, reason: `commit ${short(sha)} is still reachable (on ${remote}) — revert it in git and re-plan instead of attesting it lost.` };
|
|
441
631
|
}
|
|
442
632
|
}
|
|
443
633
|
}
|
|
@@ -464,9 +654,12 @@ export function proveLineageLost(sha: string, git: GitLineageProbe): LineageProo
|
|
|
464
654
|
|
|
465
655
|
/** The real probe: each method is one git command run in `cwd`. */
|
|
466
656
|
export function realLineageProbe(cwd: string): GitLineageProbe {
|
|
657
|
+
const lsRemoteErrors = new Map<string, string>();
|
|
658
|
+
// A hung remote must become a refusal, not a hang: every probe command is bounded. A timeout
|
|
659
|
+
// throws, so it reads as null — unreachable — and the proof refuses closed as it would offline.
|
|
467
660
|
const run = (args: string[]): string | null => {
|
|
468
661
|
try {
|
|
469
|
-
return execFileSync('git', args, { cwd, stdio: ['ignore', 'pipe', 'ignore'] }).toString();
|
|
662
|
+
return execFileSync('git', args, { cwd, stdio: ['ignore', 'pipe', 'ignore'], timeout: GIT_TIMEOUT_MS }).toString();
|
|
470
663
|
} catch {
|
|
471
664
|
return null;
|
|
472
665
|
}
|
|
@@ -489,10 +682,20 @@ export function realLineageProbe(cwd: string): GitLineageProbe {
|
|
|
489
682
|
return out === null ? null : out.split('\n').map((l) => l.trim()).filter(Boolean);
|
|
490
683
|
},
|
|
491
684
|
lsRemote: (remote) => {
|
|
492
|
-
|
|
493
|
-
|
|
685
|
+
// stderr is KEPT here, unlike `run`: the refusal this feeds must say why git could not read
|
|
686
|
+
// the remote, or "could not read db" is the database to whoever reads it.
|
|
687
|
+
let out: string;
|
|
688
|
+
try {
|
|
689
|
+
out = execFileSync('git', ['ls-remote', '--heads', '--tags', remote], { cwd, stdio: ['ignore', 'pipe', 'pipe'], timeout: GIT_TIMEOUT_MS }).toString();
|
|
690
|
+
} catch (e) {
|
|
691
|
+
const stderr = (e as { stderr?: Buffer | string }).stderr?.toString() ?? '';
|
|
692
|
+
lsRemoteErrors.set(remote, firstErrorLine(stderr) ?? firstErrorLine((e as Error).message ?? '') ?? 'no error output');
|
|
693
|
+
return null;
|
|
694
|
+
}
|
|
695
|
+
lsRemoteErrors.delete(remote);
|
|
494
696
|
return out.split('\n').map((l) => l.split('\t')[0]?.trim()).filter((s): s is string => !!s && s.length >= 7);
|
|
495
697
|
},
|
|
698
|
+
lsRemoteError: (remote) => lsRemoteErrors.get(remote) ?? null,
|
|
496
699
|
peelsToCommit: (sha) => {
|
|
497
700
|
if (!commitPresent(sha, cwd) && run(['cat-file', '-e', sha]) === null) return null; // absent
|
|
498
701
|
return run(['rev-parse', '--verify', '--quiet', `${sha}^{commit}`]) !== null;
|
|
@@ -550,8 +753,11 @@ export type ProveVerdict =
|
|
|
550
753
|
| { status: 'fresh-target' }
|
|
551
754
|
/** Not a git checkout — the rule cannot be verified here. */
|
|
552
755
|
| { status: 'no-git' }
|
|
553
|
-
/** Fast-forward proven — via a ledger witness or an ancestor recompile.
|
|
554
|
-
|
|
756
|
+
/** Fast-forward proven — via a ledger witness, its squashed successor, or an ancestor recompile.
|
|
757
|
+
* For `squash`, `commit` is the CARRIER (an ancestor of HEAD holding the deploying commit's
|
|
758
|
+
* models tree) and `witness` is the deploying commit the ledger names, which HEAD does not
|
|
759
|
+
* descend from — both are surfaced and the weaker proof is recorded in schema_log. */
|
|
760
|
+
| { status: 'proven'; via: 'ledger' | 'squash' | 'recompile'; commit: string; witness?: string }
|
|
555
761
|
/** The newest ledger witness is NOT an ancestor of HEAD — applying would revert merged work. */
|
|
556
762
|
| { status: 'revert'; commit: string; appliedAt: string; reason: string }
|
|
557
763
|
/** The witness names a commit this clone does not have (shallow) — fetch, do not guess. */
|
|
@@ -625,7 +831,7 @@ export function proveCloneCurrent(git: GitLineageProbe): { current: true } | { c
|
|
|
625
831
|
for (const remote of remotes) {
|
|
626
832
|
const tips = git.lsRemote(remote);
|
|
627
833
|
if (tips === null) {
|
|
628
|
-
return { current: false, reason: `
|
|
834
|
+
return { current: false, reason: `cannot confirm the clone is current: ${unreadableRemote(remote, git)}` };
|
|
629
835
|
}
|
|
630
836
|
for (const tip of tips) {
|
|
631
837
|
// refs/stash is excluded from the scan (enumerateModelTrees), so a tip contained ONLY by a
|
|
@@ -705,14 +911,17 @@ export async function proveDescent(
|
|
|
705
911
|
if (isAncestor(r.gitRef, head, repoRoot)) {
|
|
706
912
|
return { status: 'proven', via: 'ledger', commit: r.gitRef };
|
|
707
913
|
}
|
|
914
|
+
const squash = squashedSuccessor(r.gitRef, head, modelsDir, repoRoot);
|
|
915
|
+
if (squash) return { status: 'proven', via: 'squash', commit: squash, witness: r.gitRef };
|
|
708
916
|
return {
|
|
709
917
|
status: 'revert',
|
|
710
918
|
commit: r.gitRef,
|
|
711
919
|
appliedAt: r.appliedAt,
|
|
712
920
|
reason:
|
|
713
921
|
`your checkout is behind the branch that last deployed this state — commit ${short(r.gitRef)} ` +
|
|
714
|
-
`(applied ${shortDate(r.appliedAt)}) is NOT an ancestor of HEAD
|
|
715
|
-
'
|
|
922
|
+
`(applied ${shortDate(r.appliedAt)}) is NOT an ancestor of HEAD, and no ancestor of HEAD carries ` +
|
|
923
|
+
'its models tree as a squashed successor. Applying now would revert that merged work. ' +
|
|
924
|
+
'Pull the deploying branch, rebase onto it, and re-plan.',
|
|
716
925
|
};
|
|
717
926
|
}
|
|
718
927
|
|
|
@@ -751,37 +960,39 @@ export async function proveDescent(
|
|
|
751
960
|
|
|
752
961
|
const compileFailures: string[] = [];
|
|
753
962
|
const unevaluated: string[] = [];
|
|
963
|
+
const unreadable: UnreadableTree[] = [];
|
|
964
|
+
const probe = opts.probe ?? realLineageProbe(repoRoot);
|
|
965
|
+
const lastApplied = ordered.find((r) => isAppliedOutcome(r.outcome));
|
|
754
966
|
const unverifiable = (scope: string, scanned: number): ProveVerdict => {
|
|
755
967
|
const notes: string[] = [];
|
|
756
968
|
if (compileFailures.length > 0) notes.push(`${compileFailures.length} would not compile under this CLI`);
|
|
757
969
|
if (unevaluated.length > 0) notes.push(`${unevaluated.length} could not be extracted`);
|
|
758
|
-
//
|
|
759
|
-
//
|
|
760
|
-
//
|
|
761
|
-
|
|
970
|
+
// Every unreadable tree is NAMED — with the refs that carry it, its place in history, and its
|
|
971
|
+
// own error ("Cannot find module '@everystack/model'" is a resolution problem with a different
|
|
972
|
+
// fix than "an older tree that no longer builds") — and the runbook leads with the fix the
|
|
973
|
+
// ledger supports. See unreadableRunbook for the incident that shaped this.
|
|
762
974
|
return {
|
|
763
975
|
status: 'unverifiable',
|
|
764
976
|
reason:
|
|
765
977
|
`cannot verify the fast-forward rule — of ${scanned} ${scope} model state(s), ` +
|
|
766
978
|
`${notes.join(' and ')}, and none of the readable ones declares the live state. This is a ` +
|
|
767
979
|
'limit of THIS run (an older tree that no longer builds, or a failed extraction), not ' +
|
|
768
|
-
|
|
769
|
-
`this state, or prune the branch that carries the unreadable tree, and re-run.${first ? ` First failure: ${first}` : ''}`,
|
|
980
|
+
`evidence about the database — it is NOT drift. Unreadable:\n${unreadableRunbook(unreadable, lastApplied, probe)}`,
|
|
770
981
|
unevaluated,
|
|
771
982
|
compileFailures,
|
|
772
983
|
};
|
|
773
984
|
};
|
|
985
|
+
const record = (candidate: ModelTreeCandidate, result: Extract<TreeEval, { error: string }>, ancestor: boolean): void => {
|
|
986
|
+
(result.kind === 'unevaluated' ? unevaluated : compileFailures).push(result.detail);
|
|
987
|
+
unreadable.push({ candidate, ancestor, error: result.error });
|
|
988
|
+
};
|
|
774
989
|
|
|
775
990
|
let others: ModelTreeCandidate[] = [];
|
|
776
991
|
try {
|
|
777
992
|
for (const candidate of candidates) {
|
|
778
993
|
const result = await evaluateTree(candidate, live, evalCtx);
|
|
779
|
-
if (result.kind === 'unevaluated') {
|
|
780
|
-
|
|
781
|
-
continue;
|
|
782
|
-
}
|
|
783
|
-
if (result.kind === 'compile-failed') {
|
|
784
|
-
compileFailures.push(result.detail);
|
|
994
|
+
if (result.kind === 'unevaluated' || result.kind === 'compile-failed') {
|
|
995
|
+
record(candidate, result, true);
|
|
785
996
|
continue;
|
|
786
997
|
}
|
|
787
998
|
if (result.kind === 'no') continue;
|
|
@@ -824,7 +1035,7 @@ export async function proveDescent(
|
|
|
824
1035
|
}
|
|
825
1036
|
// Currency BEFORE the all-refs compile: a clone that is shallow, partial, or behind a remote
|
|
826
1037
|
// is told to fetch now, not after paying for every historical tree on every ref.
|
|
827
|
-
const currency = proveCloneCurrent(
|
|
1038
|
+
const currency = proveCloneCurrent(probe);
|
|
828
1039
|
if (!currency.current) {
|
|
829
1040
|
return {
|
|
830
1041
|
status: 'unverifiable',
|
|
@@ -838,12 +1049,8 @@ export async function proveDescent(
|
|
|
838
1049
|
others = all.filter((c) => !seen.has(c.tree));
|
|
839
1050
|
for (const candidate of others) {
|
|
840
1051
|
const result = await evaluateTree(candidate, live, evalCtx);
|
|
841
|
-
if (result.kind === 'unevaluated') {
|
|
842
|
-
|
|
843
|
-
continue;
|
|
844
|
-
}
|
|
845
|
-
if (result.kind === 'compile-failed') {
|
|
846
|
-
compileFailures.push(result.detail);
|
|
1052
|
+
if (result.kind === 'unevaluated' || result.kind === 'compile-failed') {
|
|
1053
|
+
record(candidate, result, false);
|
|
847
1054
|
continue;
|
|
848
1055
|
}
|
|
849
1056
|
if (result.kind === 'no') continue;
|
|
@@ -873,7 +1080,6 @@ export async function proveDescent(
|
|
|
873
1080
|
// this is the first adoption of a database everystack never wrote. A dirty-worktree apply is
|
|
874
1081
|
// still an apply — its git_ref declares nothing (Phase A skips it as a witness), so the state
|
|
875
1082
|
// it produced was never committed anywhere; the origin says so.
|
|
876
|
-
const lastApplied = ordered.find((r) => isAppliedOutcome(r.outcome));
|
|
877
1083
|
const origin: DriftOrigin = lastApplied
|
|
878
1084
|
? { kind: 'hand-edited', commit: lastApplied.gitRef, appliedAt: lastApplied.appliedAt, ...(lastApplied.dirty ? { dirty: true } : {}) }
|
|
879
1085
|
: { kind: 'never-declared' };
|
|
@@ -929,9 +1135,44 @@ export function attestationNote(a: { commit: string; subcase: string }): string
|
|
|
929
1135
|
return `descent-attested-lineage-lost (${short(a.commit)}, ${a.subcase})`;
|
|
930
1136
|
}
|
|
931
1137
|
|
|
1138
|
+
/** The recorded note for a squash proof — goes in the apply row's schema_log outcome. */
|
|
1139
|
+
export function squashNote(carrier: string, witness: string): string {
|
|
1140
|
+
return `descent: squash-successor (${short(carrier)} carries the models tree of ${short(witness)}, which HEAD does not descend from)`;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
/**
|
|
1144
|
+
* Does the operator's attestation name this commit? Exact, or a hex prefix of at least 7
|
|
1145
|
+
* characters — compared against the ONE commit the refusal named, so there is nothing for a
|
|
1146
|
+
* prefix to be ambiguous with. The refusal prints the 12-character form, and an operator who
|
|
1147
|
+
* pastes it back is naming that commit. A strict string compare refused
|
|
1148
|
+
* "42a3b97af246 does not match (42a3b97af246)" — two identical strings — because the ledger holds
|
|
1149
|
+
* 40 characters and the refusal printed 12 (consumer cutover, cli 0.4.77, 2026-09-07).
|
|
1150
|
+
*/
|
|
1151
|
+
export function attestsCommit(attestation: string, commit: string): boolean {
|
|
1152
|
+
const a = attestation.trim().toLowerCase();
|
|
1153
|
+
if (a === commit.toLowerCase()) return true;
|
|
1154
|
+
return a.length >= 7 && /^[0-9a-f]+$/.test(a) && commit.toLowerCase().startsWith(a);
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
/**
|
|
1158
|
+
* Resolve an attestation through git before it is compared: a prefix, a tag, a branch, or a
|
|
1159
|
+
* `HEAD~n` becomes the full sha. A value git cannot resolve (the commit is absent from this clone —
|
|
1160
|
+
* the `unknown-commit-proven-absent` subcase, or a typo) passes through unchanged so the prefix
|
|
1161
|
+
* compare above still gets its chance and a mismatch names what was given.
|
|
1162
|
+
*/
|
|
1163
|
+
export function resolveAttestation(attestation: string, cwd: string): string {
|
|
1164
|
+
const full = git(['rev-parse', '--verify', '--quiet', `${attestation.trim()}^{commit}`], cwd)?.trim();
|
|
1165
|
+
return full && /^[0-9a-f]{40,64}$/.test(full) ? full : attestation;
|
|
1166
|
+
}
|
|
1167
|
+
|
|
932
1168
|
export function decideDescentApply(verdict: ProveVerdict, ctx: DescentApplyContext): DescentApplyDecision {
|
|
933
1169
|
switch (verdict.status) {
|
|
934
1170
|
case 'proven':
|
|
1171
|
+
// A squash proof rests on models-tree identity, not ancestry of the deploying commit — the
|
|
1172
|
+
// record says so, exactly as an honoured attestation and a proven drift leave their trace.
|
|
1173
|
+
return verdict.via === 'squash' && verdict.witness
|
|
1174
|
+
? { ok: true, note: squashNote(verdict.commit, verdict.witness) }
|
|
1175
|
+
: { ok: true };
|
|
935
1176
|
case 'fresh-target':
|
|
936
1177
|
return { ok: true };
|
|
937
1178
|
|
|
@@ -969,8 +1210,8 @@ export function decideDescentApply(verdict: ProveVerdict, ctx: DescentApplyConte
|
|
|
969
1210
|
// construction, so the lineage-lost attestation can never be honoured here — the proof refuses
|
|
970
1211
|
// it as reachable. The escape is git-native: merge or rebase.
|
|
971
1212
|
if (!ctx.attestation) return { ok: false, reason: verdict.reason };
|
|
972
|
-
if (ctx.attestation
|
|
973
|
-
return { ok: false, reason: `--attest-lineage-lost ${
|
|
1213
|
+
if (!attestsCommit(ctx.attestation, verdict.commit)) {
|
|
1214
|
+
return { ok: false, reason: `--attest-lineage-lost ${ctx.attestation} does not match the commit that declares this state (${verdict.commit}) — drop the flag; that commit is reachable, so merge or rebase instead.` };
|
|
974
1215
|
}
|
|
975
1216
|
const proof = ctx.proveLineage(verdict.commit);
|
|
976
1217
|
return proof.proven
|
|
@@ -988,10 +1229,11 @@ export function decideDescentApply(verdict: ProveVerdict, ctx: DescentApplyConte
|
|
|
988
1229
|
reason: `${verdict.reason} If that commit is gone for good (its branch was rebased away or deleted), attest it: --attest-lineage-lost ${short(verdict.commit)}${ctx.destructive ? ' --confirm' : ''}.`,
|
|
989
1230
|
};
|
|
990
1231
|
}
|
|
991
|
-
if (ctx.attestation
|
|
1232
|
+
if (!attestsCommit(ctx.attestation, verdict.commit)) {
|
|
1233
|
+
// Both in FULL: a short form on both sides once printed two identical strings as a mismatch.
|
|
992
1234
|
return {
|
|
993
1235
|
ok: false,
|
|
994
|
-
reason: `--attest-lineage-lost ${
|
|
1236
|
+
reason: `--attest-lineage-lost ${ctx.attestation} does not match the commit that declared this state (${verdict.commit}) — attest the exact commit the refusal names, or drop the flag.`,
|
|
995
1237
|
};
|
|
996
1238
|
}
|
|
997
1239
|
const proof = ctx.proveLineage(verdict.commit);
|
package/src/cli/index.ts
CHANGED
|
@@ -11,7 +11,6 @@ import { dbMigrateCommand, dbSeedCommand, dbResetCommand, dbPsqlCommand, dbDocto
|
|
|
11
11
|
import { dbAuthzPullCommand, dbAuthzDiffCommand, dbAuthzTestCommand, dbAuthzOwnerCommand, dbAuthzReportCommand } from './commands/db-authz';
|
|
12
12
|
import { dbGenerateCommand } from './commands/db-generate';
|
|
13
13
|
import { dbPullCommand } from './commands/db-pull';
|
|
14
|
-
import { dbAdoptCommand } from './commands/db-adopt';
|
|
15
14
|
import { dbReconcileCommand } from './commands/db-reconcile';
|
|
16
15
|
import { dbRefreshCommand } from './commands/db-refresh';
|
|
17
16
|
import { dbFingerprintCommand } from './commands/db-fingerprint';
|
|
@@ -186,9 +185,6 @@ async function main() {
|
|
|
186
185
|
case 'db:pull':
|
|
187
186
|
await dbPullCommand(flags);
|
|
188
187
|
break;
|
|
189
|
-
case 'db:adopt':
|
|
190
|
-
await dbAdoptCommand(flags);
|
|
191
|
-
break;
|
|
192
188
|
case 'db:reconcile':
|
|
193
189
|
await dbReconcileCommand(flags);
|
|
194
190
|
break;
|
|
@@ -368,9 +364,8 @@ Usage:
|
|
|
368
364
|
everystack db:export --schema <name> [--stage <name> | --database-url <url> [--out <file.dump>]] [--models <barrel>] Schema-scoped pg_dump artifact, stamped with the DECLARED schema fingerprint (the canonical-sync export; db:swap gates on that stamp). --stage dumps the stage's private DB via the ops Lambda → S3; --database-url (explicit flag, never the env) dumps a reachable DB to a local .dump + .meta.json — the build-locally → publish → swap on-ramp
|
|
369
365
|
everystack db:swap --schema <name> --from <artifact.dump | artifact-id> [--stage <name> --direct | --database-url <url>] --confirm [--fingerprint <hash>] [--snapshot physical|logical|none] [--snapshot-ref <id>] [--rebuild-derived] [--dump-build <file.json>] Land a schema artifact atomically: fingerprint gate → pre-flight refusals → CONFIRMED snapshot → restore into <schema>_incoming (COPY-safe rewrite) → build the paired derived layer → one txn (drop+rename+recreate app→schema FKs, re-apply authz + schema USAGE) → assertions → drop retiring. Refresh-free; app.* untouched; the derived layer is never absent. DESTRUCTIVE. The venue is EXPLICIT — DATABASE_URL in the env is refused, and --stage requires --direct (a multi-GB restore exceeds the ops-Lambda 900s clock). The rollback point defaults to a PHYSICAL RDS snapshot on a stage that exposes databaseInstanceId (no locks, no pg_dump contending with the restore) and a WAITED logical db:backup otherwise; a bare --database-url refuses without --snapshot-ref <id> or --snapshot none. docs/schema-swap.md
|
|
370
366
|
everystack db:generate [--stage <name> | --database-url <url>] [--name <label>] [--models db/models/index.ts] [--schema-out db/schema.generated.ts] [--allow-drops] [--apply] [--dry-run] Diff models vs the live DB → next migration file, or with --apply execute it directly (one transaction, schema_log recorded, verified by re-diff — no drizzle folder needed; direct connection only; DROPs held back unless --allow-drops). --dry-run prints the edge and writes NOTHING (no migration, no journal entry, no schema refresh) — the preview verb; db:diff computes a models-vs-models edge with no database at all. The resolved --schema-out is recorded in the migration journal: later flag-less runs reuse it (flag > recorded > default), a differing flag updates the record and says so
|
|
371
|
-
everystack db:pull [--stage <name> | --database-url <url>] [--schema public] [--out <dir | file.ts>] [--derived-out <file.ts>] [--abilities live|public-read] Introspect the live DB → render field() Models
|
|
367
|
+
everystack db:pull [--stage <name> | --database-url <url>] [--schema public] [--out <dir | file.ts>] [--derived-out <file.ts>] [--abilities live|public-read] Introspect the live DB → render field() Models — THE on-ramp for an existing database: commit what it renders, then db:plan --stage <s>. EMPTY plan = adopted as it is (that commit declares the database; every later db:apply descends from it). NON-EMPTY plan = the hardening edge: review it, db:apply --plan <f> --stage <s> — no commit declares the database yet, so the reviewed plan is the ORIGIN. Render from the stage, never from a local restore of a dump. --out <dir> writes one file per model + index.ts (the default shape); --out <file.ts> writes a single module; stdout otherwise. --derived-out <file.ts> extracts the derived layer (descriptors + sequences) as its own self-contained module — alone it leaves the models untouched (the hand-maintained-barrel splice); with --out the models render omits the now-external derived layer. Every model scaffolds its authz decision as comments (db:check fails until authored). **--abilities live is the brownfield mode**: derive each model's authz from the grants and policies the database ALREADY has, and write the foreign-grantee baseline (db/authz-baseline.json) — this is what you want when adopting an existing schema. --abilities public-read stamps the common stanza (public read, admin write) uncommented — greenfield only, since on an existing database it declares public read of every table. Both are explicit generated code, never a runtime default. --govern-roles <r1,r2> (with --abilities live) transcribes the named FOREIGN roles' grants into privileges — the models then OWN them, so a fresh build recreates them; required for migration deletion when the migrations created grants to an ops/connection role. Explicit only (a re-pull must not silently govern), complete-or-refuse (a listed role holding a column-scoped grant fails the pull before anything is written). --matviews-as-tables renders every matview as defineMaterializedTable with INTROSPECTED fields (the canonical-sync flip: a pipeline-owned table everystack migrates but never refreshes) — names land in an exported materializedTables array to spread into your models; fields come back nullable/unkeyed (matviews carry no PK/NOT NULL) — tighten on review; add --suggest-keys to probe the LIVE rows for functionally-unique columns (one scan per matview) and surface each as a commented .primaryKey() suggestion. docs/derived-objects.md#flipping-a-matview-to-a-materialized-table---matviews-as-tables
|
|
372
368
|
Both introspect via the deployed ops Lambda by default; --database-url (or an inherited DATABASE_URL) connects directly — for a schema that exists only on a local Postgres.
|
|
373
|
-
everystack db:adopt [--stage <name> | --database-url <url>] [--schema public] [--out db/models] [--govern-roles r1,r2] First-time adoption of a pre-Models database AS IT IS, BY PROOF. A production DB that never went through Models has no committed ancestor declaring it; db:adopt is the on-ramp that makes one. It renders field() Models from the live DB (the same renderers db:pull uses, --abilities live), compiles them back, and finalizes the baseline ONLY when the compiled Models declare the live state EXACTLY (predicted == governed live — the same declares-test descent and db:check use). On success it writes --out (default db/models) and prints the \`chore(db): adopt live baseline <fp>\` commit — that commit legitimately declares the live state, so a later db:apply descends for real. PROVE not attest: there is NO bypass flag. When the round-trip is not exact, adoption REFUSES, names the surfaces that diverged, prints the edge, and writes NOTHING — that is the HARDENING case (the Models vocabulary cannot declare what is live, e.g. a sequence grant with no INSERT behind it), and it needs no adoption commit: commit the Models you intend, db:plan --stage, db:apply --stage. No commit declares the database yet, so db:apply PROVES that (proven drift) and treats the reviewed plan as the ORIGIN. Commit AND push the baseline before db:apply — an unpushed baseline is an unknown commit to teammates and the ops Lambda.
|
|
374
369
|
everystack db:fingerprint [--stage <name> | --database-url <url>] [--models <barrel>] [--json] Content-address the live base schema (tables+constraints+authz) and compare against the models — MATCH/MISMATCH (exit 1), plus the unfingerprinted-objects report
|
|
375
370
|
everystack db:reconcile [--stage <name> | --database-url <url>] [--apply] [--check] [--baseline] [--rebuild] [--overwrite-drift] [--only a,b] [--json] Reconcile the derived layer (functions/views/matviews/triggers) against the DECLARED descriptors (defineView/defineMaterializedView/defineFunction/defineSql/trigger() on models, from the barrel) — the single home (db/sql is retired; leftover .sql files fail with the migration path): plan with rebuild-cost estimates by default; --check is the CI gate; --apply executes (atomic — DDL + provenance in one transaction) and records provenance + schema_log; --apply --stage runs credential-free in the ops Lambda (no admin URL on the deployer, the db:apply twin), --apply --database-url runs direct. Hand-edits are drift (never overwritten silently). First contact with existing objects: --baseline TRUSTS live == source (records provenance, verifies nothing), --rebuild GUARANTEES it (drop+create from source). They are mutually exclusive. --only <schema.name,…> restricts the run to the named objects (surgical); with --rebuild it FORCES those to rebuild from source even when the hashes show no diff — the recovery exit when a mistaken --rebaseline left a self-consistent-but-wrong provenance row (the dependency cascade rebuilds their live dependents).
|
|
376
371
|
everystack db:refresh [--stage <name> | --database-url <url> | --direct] [--only a,b] [--verify-nonempty] [--list] Refresh the declared materialized views in dependency order, credential-free. --stage runs the whole refresh in the ops Lambda on the operator connection (no URL on the operator's machine — the data-lane twin of the reconcile lane); --database-url refreshes over a local connection (dev); --stage <name> --direct resolves the stage's operator connection from its ops Lambda (credential never on argv) and refreshes CLI-side on an UNBOUNDED clock, for a matview set that exceeds the ops-Lambda 900s limit; --only refreshes a named subset (full identity or bare name); --verify-nonempty gates on populated-but-zero-rows matviews (the dark-panel check — a scoped EXISTS on just what was refreshed, so a promotion script gets the gate with no read authority) and FAILS naming any empty; --list previews the order without connecting. Plain REFRESH (ACCESS EXCLUSIVE); fail-fast, idempotent to re-run.
|