@everystack/cli 0.4.68 → 0.4.70
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 +34 -0
- package/package.json +2 -2
- package/src/cli/apply-execute.ts +133 -9
- package/src/cli/apply-lane.ts +102 -0
- package/src/cli/apply-refusals.ts +83 -0
- package/src/cli/backup-resolve.ts +31 -0
- package/src/cli/backup.ts +104 -0
- package/src/cli/commands/db-apply.ts +318 -180
- package/src/cli/commands/db-plan.ts +21 -10
- package/src/cli/commands/db-swap.ts +27 -54
- package/src/cli/deparse-normal.ts +12 -0
- package/src/cli/derived-apply.ts +17 -2
- package/src/cli/edge-plan.ts +14 -0
- package/src/cli/git-descent.ts +526 -143
- package/src/cli/index.ts +1 -1
- package/src/cli/local-rollback.ts +146 -0
- package/src/cli/prompt.ts +18 -0
- package/src/cli/rollback-point.ts +141 -0
- package/src/cli/schema-diff.ts +68 -8
- package/src/cli/schema-fingerprint.ts +60 -0
- package/src/cli/stage-rollback-deps.ts +70 -0
- package/src/cli/state-apply.ts +41 -3
- package/src/cli/swap-snapshot.ts +18 -1
package/src/cli/state-apply.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* named state at both ends" — but nothing here needs a folder.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { execSync } from 'node:child_process';
|
|
20
|
+
import { execSync, execFileSync } from 'node:child_process';
|
|
21
21
|
import type { ModelDescriptor } from '@everystack/model';
|
|
22
22
|
import { introspectContract, type AuthzContract, type QueryRunner } from './authz-contract';
|
|
23
23
|
import type { SessionRunner } from './session';
|
|
@@ -25,7 +25,7 @@ import { introspectSchema, type SchemaSnapshot } from './schema-introspect';
|
|
|
25
25
|
import { FUNCTIONS_SQL, contractFunctionRow } from './security-catalog';
|
|
26
26
|
import { generateMigrationSql, HELD_DROP_PREFIX } from './migration-generate';
|
|
27
27
|
import type { SequenceDescriptor } from '@everystack/model';
|
|
28
|
-
import { fingerprintLive } from './schema-fingerprint';
|
|
28
|
+
import { fingerprintLive, FINGERPRINT_VERSION } from './schema-fingerprint';
|
|
29
29
|
import { ENSURE_RECONCILER_SQL, renderSchemaLogInsert, renderSchemaLogFingerprintUpdate } from './derived-apply';
|
|
30
30
|
|
|
31
31
|
const NOTICE_PREFIX = '-- NOTICE';
|
|
@@ -276,14 +276,49 @@ export function currentGitRef(): string | null {
|
|
|
276
276
|
}
|
|
277
277
|
}
|
|
278
278
|
|
|
279
|
+
/**
|
|
280
|
+
* True when the models the apply ran against differ from HEAD — a dirty mint.
|
|
281
|
+
*
|
|
282
|
+
* schema_log records HEAD (`currentGitRef`) as the commit that declares the applied state. That
|
|
283
|
+
* pointer is only honest if HEAD's committed models ARE what was applied. If the operator
|
|
284
|
+
* applied from a worktree with uncommitted model changes, HEAD does not declare plan.to — so
|
|
285
|
+
* the fast-forward proof must ignore the row (it treats a dirty row like a null git_ref).
|
|
286
|
+
* Scoped to the models path, so an unrelated dirty file (a README) does not disqualify the row.
|
|
287
|
+
* Not a git checkout → not dirty (git_ref is null there anyway).
|
|
288
|
+
*/
|
|
289
|
+
export function worktreeDirty(modelsPath: string, cwd: string = process.cwd()): boolean {
|
|
290
|
+
try {
|
|
291
|
+
const out = execFileSync('git', ['status', '--porcelain', '--', modelsPath], {
|
|
292
|
+
cwd,
|
|
293
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
294
|
+
}).toString();
|
|
295
|
+
return out.trim().length > 0;
|
|
296
|
+
} catch {
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
279
301
|
export interface StateApplyOptions {
|
|
280
302
|
actor?: string | null;
|
|
281
303
|
gitRef?: string | null;
|
|
282
304
|
planRef?: string | null;
|
|
283
305
|
/** The live base fingerprint before this apply (schema_log's from-side). */
|
|
284
306
|
fromFingerprint?: string | null;
|
|
307
|
+
/** The canon the fingerprints were computed under. Defaults to the current FINGERPRINT_VERSION
|
|
308
|
+
* — the hashes this writer records are always current-canon — so the descent witness carries
|
|
309
|
+
* the version needed to recompute across a later bump. */
|
|
310
|
+
fpVersion?: number | null;
|
|
311
|
+
/** The apply ran from a dirty worktree (models differed from HEAD). The recorded git_ref then
|
|
312
|
+
* does not declare what was applied, so descent ignores the row. */
|
|
313
|
+
dirty?: boolean;
|
|
285
314
|
/** Injectable clock for tests. */
|
|
286
315
|
now?: () => number;
|
|
316
|
+
/**
|
|
317
|
+
* When the fast-forward rule was waived by a `--attest-lineage-lost` attestation, a short note read
|
|
318
|
+
* at WRITE time (a getter, because the attestation is decided in the descent step that runs before
|
|
319
|
+
* this) and folded into the `applied` schema_log outcome — the durable audit of a deliberate waiver.
|
|
320
|
+
*/
|
|
321
|
+
descentNote?: () => string | undefined;
|
|
287
322
|
}
|
|
288
323
|
|
|
289
324
|
export interface StateApplyResult {
|
|
@@ -327,16 +362,19 @@ export async function applyGeneratedStatements(
|
|
|
327
362
|
outcome: `failed: ${String(err?.message ?? err).slice(0, 500)}`,
|
|
328
363
|
actor: options.actor, gitRef: options.gitRef, planRef: options.planRef,
|
|
329
364
|
fromFingerprint: options.fromFingerprint,
|
|
365
|
+
fpVersion: options.fpVersion ?? FINGERPRINT_VERSION, dirty: options.dirty ?? false,
|
|
330
366
|
durationMs: now() - started,
|
|
331
367
|
}));
|
|
332
368
|
} catch { /* the memoir is best-effort on failure */ }
|
|
333
369
|
throw err;
|
|
334
370
|
}
|
|
335
371
|
|
|
372
|
+
const note = options.descentNote?.();
|
|
336
373
|
const logRows = await runner(renderSchemaLogInsert({
|
|
337
|
-
kind: 'state apply', sql: batchSql, outcome: 'applied',
|
|
374
|
+
kind: 'state apply', sql: batchSql, outcome: note ? `applied — ${note}` : 'applied',
|
|
338
375
|
actor: options.actor, gitRef: options.gitRef, planRef: options.planRef,
|
|
339
376
|
fromFingerprint: options.fromFingerprint,
|
|
377
|
+
fpVersion: options.fpVersion ?? FINGERPRINT_VERSION, dirty: options.dirty ?? false,
|
|
340
378
|
durationMs: now() - started,
|
|
341
379
|
}));
|
|
342
380
|
const logId = logRows?.[0]?.id !== undefined ? Number(logRows[0].id) : undefined;
|
package/src/cli/swap-snapshot.ts
CHANGED
|
@@ -33,6 +33,8 @@ export type SnapshotDecision =
|
|
|
33
33
|
| { mode: 'logical' }
|
|
34
34
|
/** The operator already took one and named it — nothing to do but record it. */
|
|
35
35
|
| { mode: 'attested'; ref: string }
|
|
36
|
+
/** Take our OWN local `pg_dump -Fc` and verify it (bare --database-url, db:apply only). */
|
|
37
|
+
| { mode: 'local' }
|
|
36
38
|
/** The operator explicitly accepted no rollback point. */
|
|
37
39
|
| { mode: 'none' }
|
|
38
40
|
/** Nothing safe is available and no consent was given — refuse before anything moves. */
|
|
@@ -51,6 +53,13 @@ export interface SnapshotDecisionInput {
|
|
|
51
53
|
snapshotRef?: string;
|
|
52
54
|
/** `--snapshot <physical|logical|none>`. */
|
|
53
55
|
requested?: SnapshotModeRequest;
|
|
56
|
+
/**
|
|
57
|
+
* Bare-URL only: may the tool take its OWN local `pg_dump -Fc` as the rollback point? db:apply
|
|
58
|
+
* passes true (REVERSAL 1 — bare-URL apply takes+verifies a local dump); db:swap leaves it false, so
|
|
59
|
+
* a bare-URL swap still REFUSES without an attested ref (a multi-GB schema restore is not a local
|
|
60
|
+
* pg_dump's job). Default false keeps the shared decision unchanged for db:swap.
|
|
61
|
+
*/
|
|
62
|
+
localDump?: boolean;
|
|
54
63
|
}
|
|
55
64
|
|
|
56
65
|
/** Is this instance id something we can actually snapshot, or an absent/placeholder output? */
|
|
@@ -73,7 +82,7 @@ function usableInstanceId(id?: string): id is string {
|
|
|
73
82
|
* is optional in practice.
|
|
74
83
|
*/
|
|
75
84
|
export function decideSnapshotMode(input: SnapshotDecisionInput): SnapshotDecision {
|
|
76
|
-
const { venue, instanceId, snapshotRef, requested } = input;
|
|
85
|
+
const { venue, instanceId, snapshotRef, requested, localDump } = input;
|
|
77
86
|
|
|
78
87
|
if (requested !== undefined && !SNAPSHOT_MODES.includes(requested)) {
|
|
79
88
|
return {
|
|
@@ -113,6 +122,11 @@ export function decideSnapshotMode(input: SnapshotDecisionInput): SnapshotDecisi
|
|
|
113
122
|
|
|
114
123
|
if (requested === 'logical') {
|
|
115
124
|
if (venue === 'url') {
|
|
125
|
+
// On the venue that CAN take its own local dump (db:apply), an explicit `--snapshot logical`
|
|
126
|
+
// means exactly that — a pg_dump rollback point — so honour it as the local dump. Where the
|
|
127
|
+
// local dump is not offered (db:swap), the stage's Task lane is the only logical backup and a
|
|
128
|
+
// bare URL has no stage, so refuse.
|
|
129
|
+
if (localDump) return { mode: 'local' };
|
|
116
130
|
return {
|
|
117
131
|
mode: 'refuse',
|
|
118
132
|
reason: 'A logical snapshot runs in the stage\'s Task lane via the ops Lambda, and a bare --database-url has no stage behind it. '
|
|
@@ -127,6 +141,9 @@ export function decideSnapshotMode(input: SnapshotDecisionInput): SnapshotDecisi
|
|
|
127
141
|
if (venue === 'stage') {
|
|
128
142
|
return usableInstanceId(instanceId) ? { mode: 'physical', instanceId } : { mode: 'logical' };
|
|
129
143
|
}
|
|
144
|
+
// Bare --database-url. Where the tool takes its own local dump (db:apply), that is the default; where
|
|
145
|
+
// it does not (db:swap), a destructive run over a bare URL still refuses without an attested ref.
|
|
146
|
+
if (localDump) return { mode: 'local' };
|
|
130
147
|
return {
|
|
131
148
|
mode: 'refuse',
|
|
132
149
|
reason: 'db:swap over a bare --database-url does not take a snapshot for you, and it will not run destructively without one. '
|