@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.
@@ -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;
@@ -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. '