@everystack/cli 0.4.78 → 0.4.80

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.
@@ -27,13 +27,16 @@
27
27
  import { createHash } from 'node:crypto';
28
28
  import type { ModelDescriptor } from '@everystack/model';
29
29
  import type { SchemaSnapshot } from './schema-introspect';
30
- import type { AuthzContract } from './authz-contract';
30
+ import type { AuthzContract, SequenceGrantContract, DefaultAclScope } from './authz-contract';
31
+ import { ownSequenceName } from './schema-diff';
31
32
  import { generateMigrationSql, unmodeledTables } from './migration-generate';
32
33
  import { compileTableContract } from './authz-compile';
34
+ import { carryUngovernedAuthz, inheritedSequenceGrants, type GrantExemption } from './authz-reconcile';
35
+ import { BASELINE_FILE } from './authz-baseline';
33
36
  import { classifyGeneratedStatements, classifyDestructive, partitionStatements, renderStatementHistogram } from './state-apply';
34
- import { fingerprintLive, fingerprintState, fingerprintModels, governedRolesForModels, stableStringify, compareStoredFingerprint, formatChangedMessage, FINGERPRINT_VERSION, type Fingerprint } from './schema-fingerprint';
37
+ import { fingerprintLive, fingerprintState, fingerprintModels, governedRolesForModels, stableStringify, compareStoredFingerprint, formatChangedMessage, schemaOf, FINGERPRINT_VERSION, type Fingerprint } from './schema-fingerprint';
35
38
  import { compileDeclaredState } from './declared-diff';
36
- import { compileTableRenames, compileTableMoves } from './schema-compile';
39
+ import { compileTableRenames, compileTableMoves, compileRenames } from './schema-compile';
37
40
 
38
41
  export const PLAN_VERSION = 2;
39
42
 
@@ -48,8 +51,20 @@ export const PLAN_VERSION = 2;
48
51
  * runs, or a gate's semantics. Both packages import THIS binding (the server from the cli it
49
52
  * bundles), so a version skew is a hard, exact-equality refusal in both directions — never a
50
53
  * silently dropped gate.
54
+ *
55
+ * v2 (milestone plan-endpoint-full): `plan.toFingerprint` became the FULL endpoint (carries the
56
+ * ungoverned live authz through) and the server verifies the raw landed hash against it. An
57
+ * un-redeployed v1 ops Lambda would verify a GOVERNED landed hash against the full `to` and report
58
+ * verify-failed on a SUCCESSFUL brownfield apply — so the meaning of a gate changed and the peers
59
+ * must not mix. The handshake now forces a stage redeploy before an apply crosses versions.
60
+ *
61
+ * v3 (milestone finish-0-4-x): the payload gained `plan.defaultAclHash` and the server runs a new
62
+ * gate on it (`default privileges`, pre-dispatch). A v2 ops Lambda would ignore the key and skip
63
+ * the gate — not a false pass of the apply (it degrades to the post-DDL verify failure), but a
64
+ * silently dropped gate the operator was told is in force. The BUMP RULE says a gate the server
65
+ * runs is part of the contract, so the peers refuse until the stage is redeployed.
51
66
  */
52
- export const APPLY_CONTRACT_VERSION = 1;
67
+ export const APPLY_CONTRACT_VERSION = 3;
53
68
 
54
69
  /** Classification counts (brick 9, decision 11): destructive = drops + narrowings + strips. */
55
70
  export interface PlanClassification {
@@ -82,13 +97,29 @@ export interface EdgePlan {
82
97
  */
83
98
  fpVersion?: number;
84
99
  /**
85
- * The predicted live fingerprint after apply — exact, unmodeled-aware, and
86
- * GOVERNED: hashed through the models' governed-role set (`governedRoles`
87
- * below), because it is a declared-vs-live claim. Compare it only against
88
- * `governedLiveFingerprint` computed with the same set — never against the
89
- * raw `from` flavor.
100
+ * The predicted live fingerprint after apply — exact and unmodeled-aware. Its FLAVOR is
101
+ * `toFlavor`: a `full` endpoint (every plan minted since N3) carries the ungoverned live authz
102
+ * through, so it equals the post-apply RAW live hash and db:apply compares `from`-flavor live
103
+ * against it directly. A `governed`/absent endpoint predates the full flavor and db:apply
104
+ * REFUSES it — comparing a full live hash against a governed `to` would false-verify-fail after
105
+ * the DDL already landed.
90
106
  */
91
107
  toFingerprint: string;
108
+ /**
109
+ * Which flavor `toFingerprint` is (milestone plan-endpoint-full). `full` carries the ungoverned
110
+ * live grants/policies/sequence ACLs through the prediction and is what db:apply verifies
111
+ * against. Absent means a pre-N3 plan whose `to` was governed — db:apply refuses it (re-mint).
112
+ */
113
+ toFlavor?: 'governed' | 'full';
114
+ /**
115
+ * The content address of the default-privilege posture (`pg_default_acl`, tables + sequences,
116
+ * every schema and grantee) the FULL prediction consumed at mint. `from` never hashed it —
117
+ * default ACLs are not object state — so an `ALTER DEFAULT PRIVILEGES` between mint and apply
118
+ * passed the concurrency lock, the DDL landed, and verify-after failed POST-DDL. db:apply
119
+ * compares this against the live posture BEFORE dispatch and refuses on a difference; absent
120
+ * means a plan minted before the lock existed, and db:apply refuses that too (re-mint).
121
+ */
122
+ defaultAclHash?: string;
92
123
  /**
93
124
  * The governed-role set `to` was hashed under, sorted — carried on the plan
94
125
  * so every apply venue verifies with the exact set the mint used. Absent on
@@ -162,7 +193,7 @@ export function predictLiveFingerprint(
162
193
  models: ModelDescriptor[],
163
194
  snapshot: SchemaSnapshot,
164
195
  contract: AuthzContract,
165
- opts: { schema?: string; governedRoles?: ReadonlySet<string> } = {},
196
+ opts: { schema?: string; governedRoles?: ReadonlySet<string>; carryUngoverned?: boolean } = {},
166
197
  ): string {
167
198
  return predictLiveState(models, snapshot, contract, opts).hash;
168
199
  }
@@ -176,14 +207,20 @@ export function predictLiveState(
176
207
  models: ModelDescriptor[],
177
208
  snapshot: SchemaSnapshot,
178
209
  contract: AuthzContract,
179
- opts: { schema?: string; governedRoles?: ReadonlySet<string> } = {},
210
+ opts: { schema?: string; governedRoles?: ReadonlySet<string>; carryUngoverned?: boolean } = {},
180
211
  ): Fingerprint {
181
- // The prediction is a DECLARED-vs-live comparison, so it must hash through the
182
- // models' governed set — the live side of any comparison against it must use
183
- // the same set (see governedLiveFingerprint). Unfiltered, every ride-through
184
- // table's foreign grants poison the endpoint and verify-after can never pass
185
- // on a brownfield target.
212
+ // Two FLAVORS (milestone plan-endpoint-full):
213
+ // - GOVERNED (default): hash through the models' governed set. A DECLARED-vs-live comparison —
214
+ // the descent declares-test and db:check — must ignore the foreign grants the reconciler
215
+ // leaves alone, or it reads "drifted" against a state the models can never reach.
216
+ // - FULL (`carryUngoverned`): the plan's `to` endpoint. The reconciler converges the governed
217
+ // roles and leaves the foreign ones, so the post-apply truth is the declared contract PLUS
218
+ // the live ungoverned authz. Carry it through and hash UNFILTERED, so `to` equals the
219
+ // post-apply full live hash and an empty plan means the two fingerprints are equal.
220
+ const carry = opts.carryUngoverned === true;
186
221
  const governedRoles = opts.governedRoles ?? governedRolesForModels(models);
222
+ const foreignGrantee = (role: string): boolean =>
223
+ !governedRoles.has(role) && !(role.toUpperCase() === 'PUBLIC' && governedRoles.has('PUBLIC'));
187
224
  const declared = compileDeclaredState(models, { schema: opts.schema });
188
225
  const declaredTables = new Set(declared.snapshot.tables.map((t) => t.table));
189
226
  const currentNames = new Set(snapshot.tables.map((t) => t.table));
@@ -214,16 +251,122 @@ export function predictLiveState(
214
251
  ...(snapshot.enums ?? []).filter((e) => !declaredEnums.has(e.name)),
215
252
  ],
216
253
  };
254
+ // The live counterpart of a declared table: its rename or move source when the edge brings it
255
+ // in under a new name, else its own name. Grants, policies and owned sequences survive RENAME
256
+ // TABLE and SET SCHEMA alike (they attach by OID), so the foreign authz carries across both.
257
+ // A model cannot declare both, so the two maps never collide.
258
+ const priorNameOf: Record<string, string> = {
259
+ ...compileTableMoves(models, { schema: opts.schema }),
260
+ ...compileTableRenames(models, { schema: opts.schema }),
261
+ };
262
+ const liveNameFor = (declaredTable: string): string =>
263
+ !currentNames.has(declaredTable) && currentNames.has(priorNameOf[declaredTable] ?? '')
264
+ ? priorNameOf[declaredTable]
265
+ : declaredTable;
266
+ const liveByTable = new Map(contract.tables.map((t) => [t.table, t]));
267
+ const declaredColumnsByTable = new Map(
268
+ declared.snapshot.tables.map((t) => [t.table, new Set(t.columns.map((c) => c.name))]),
269
+ );
270
+
271
+ // The UNGOVERNED default-privilege grants that fire for an object created in `schema` — the
272
+ // schema-specific rows unioned with the all-schemas ('') rows, foreign grantees only. Postgres
273
+ // applies these at CREATE, so a plan-created table/sequence lands with them; the reconciler
274
+ // never touches a foreign role, so they persist. Governed roles are excluded — the reconciler
275
+ // sets those explicitly and the declared contract already models them.
276
+ const defaultAclFor = (scope: DefaultAclScope | undefined, schema: string): Record<string, string[]> => {
277
+ if (!scope) return {};
278
+ const out: Record<string, string[]> = {};
279
+ for (const key of ['', schema]) {
280
+ for (const [grantee, privileges] of Object.entries(scope[key] ?? {})) {
281
+ if (foreignGrantee(grantee)) out[grantee] = [...new Set([...(out[grantee] ?? []), ...privileges])].sort();
282
+ }
283
+ }
284
+ return out;
285
+ };
286
+
287
+ // FULL flavor: each declared table's authz AFTER apply = declared contract + the live authz of
288
+ // the ungoverned roles, carried onto the columns the declared target keeps (carryUngovernedAuthz).
289
+ // A CREATED table (no live counterpart) has nothing to carry — but Postgres applies the schema's
290
+ // default privileges to it at creation, so fold the ungoverned default ACL in, or the endpoint
291
+ // omits a grant the apply lands and verify-after false-fails.
292
+ const declaredAuthz = carry
293
+ ? declared.contract.tables.map((d) => {
294
+ const liveTable = liveByTable.get(liveNameFor(d.table));
295
+ if (liveTable) return carryUngovernedAuthz(d, liveTable, governedRoles, declaredColumnsByTable.get(d.table) ?? new Set());
296
+ const defaultGrants = defaultAclFor(contract.defaultAcls?.tables, schemaOf(d.table));
297
+ return Object.keys(defaultGrants).length > 0 ? { ...d, grants: { ...d.grants, ...defaultGrants } } : d;
298
+ })
299
+ : declared.contract.tables;
217
300
  const mergedAuthz = [
218
- ...declared.contract.tables,
301
+ ...declaredAuthz,
219
302
  ...contract.tables.filter((t) => ridesThrough(t.table)),
220
303
  ];
304
+
221
305
  // Sequence ACLs (v11): declared tables DERIVE their reconciled (inherited) target, so pass
222
306
  // measured ACLs ONLY for rides-through tables — the ones reconciliation never touches, whose
223
307
  // live ACLs carry through unchanged. Passing all measured would make a declared table's
224
308
  // predicted endpoint read its pre-reconcile ACL and never converge with the plan it mints.
225
309
  const ridesThroughSeqGrants = (contract.sequenceGrants ?? []).filter((s) => ridesThrough(s.table));
226
- return fingerprintState(mergedSnapshot, mergedAuthz, { governedRoles, sequenceGrants: ridesThroughSeqGrants });
310
+ let sequenceGrants = ridesThroughSeqGrants;
311
+ if (carry) {
312
+ // FULL flavor, the sequence combine: a declared table's sequence lands with its GOVERNED
313
+ // grants DERIVED (the reconciler converges them onto the A6 inheritance) AND its FOREIGN
314
+ // grants MEASURED (the reconciler skips ungoverned roles, so their direct grants are
315
+ // unchanged). Deriving alone drops the foreign grant; passing the live measured grants reads
316
+ // a stale (pre-reconcile) governed shape. So synthesize one entry per live sequence whose
317
+ // grants = inherited-governed ∪ that-sequence's-foreign-live-grants, and hash UNFILTERED.
318
+ const liveSeqByTable = new Map<string, SequenceGrantContract[]>();
319
+ for (const s of contract.sequenceGrants ?? []) {
320
+ (liveSeqByTable.get(s.table) ?? liveSeqByTable.set(s.table, []).get(s.table)!).push(s);
321
+ }
322
+ const declaredColumnsFor = new Map(declared.snapshot.tables.map((t) => [t.table, t.columns]));
323
+ const liveColumnsFor = new Map(snapshot.tables.map((t) => [t.table, new Set(t.columns.map((c) => c.name))]));
324
+ const columnRenames = compileRenames(models, { schema: opts.schema });
325
+ const declaredSeqGrants: SequenceGrantContract[] = [];
326
+ for (const d of declaredAuthz) {
327
+ if (ridesThrough(d.table)) continue;
328
+ const inherited = inheritedSequenceGrants(d);
329
+ const liveName = liveNameFor(d.table);
330
+ for (const s of liveSeqByTable.get(liveName) ?? []) {
331
+ const foreign: Record<string, string[]> = {};
332
+ for (const [role, privileges] of Object.entries(s.grants)) {
333
+ if (foreignGrantee(role)) foreign[role] = privileges;
334
+ }
335
+ declaredSeqGrants.push({
336
+ sequence: s.sequence,
337
+ table: d.table,
338
+ grants: { ...inherited, ...foreign },
339
+ ...(s.identity !== undefined ? { identity: s.identity } : {}),
340
+ });
341
+ }
342
+ // The sequences THIS plan creates — every serial column of a created table, or a serial
343
+ // column ADDED to a live one. `plannedSerialSequences` grants by the diff's `createTable` /
344
+ // `addColumn` changes; this side has no diff in hand, so it reads the same facts off the
345
+ // live snapshot: a column is added when neither its own name nor its `renamedFrom` source
346
+ // exists live (a rename is a `renameColumn` change and creates nothing). Each lands with the
347
+ // governed inheritance the same plan grants (finish-0-4-x F1) PLUS the SEQUENCE default ACL
348
+ // Postgres applied at creation for the foreign roles. Identity columns own no nextval
349
+ // default and are excluded on both sides, so only serial columns count.
350
+ const liveColumns = liveColumnsFor.get(liveName);
351
+ const renamedFromOf = columnRenames[d.table] ?? {};
352
+ const existsLive = (column: string): boolean =>
353
+ liveColumns?.has(column) === true || (renamedFromOf[column] !== undefined && liveColumns?.has(renamedFromOf[column]) === true);
354
+ const seqDefaultGrants = defaultAclFor(contract.defaultAcls?.sequences, schemaOf(d.table));
355
+ (declaredColumnsFor.get(d.table) ?? [])
356
+ .filter((c) => ownSequenceName(d.table, c) != null && !existsLive(c.name))
357
+ .forEach((_, i) => declaredSeqGrants.push({
358
+ sequence: `${d.table}#created_seq_${i}`,
359
+ table: d.table,
360
+ grants: { ...seqDefaultGrants, ...inherited },
361
+ identity: false,
362
+ }));
363
+ }
364
+ sequenceGrants = [...ridesThroughSeqGrants, ...declaredSeqGrants];
365
+ }
366
+ return fingerprintState(mergedSnapshot, mergedAuthz, {
367
+ governedRoles: carry ? undefined : governedRoles,
368
+ sequenceGrants,
369
+ });
227
370
  }
228
371
 
229
372
  /**
@@ -283,19 +426,22 @@ export function mintEdgePlan(
283
426
  const breakdown = partitionStatements(classified.executable, { declaredGrantees });
284
427
  const destructive = breakdown.drops.length + breakdown.narrowings.length + breakdown.strips.length;
285
428
 
286
- // The plan's two endpoints are two different FLAVORS, deliberately:
287
- // - `from` is the RAW live hash (everything introspected) — the concurrency
288
- // lock and the backup-stamp chain (backup.fp == plan.from) compare
289
- // live-vs-live and must stay computable with no models in hand.
290
- // - `to` is the GOVERNED hash — a declared-vs-live claim, filtered through
291
- // the models' governed set, which rides ON the plan so every venue
292
- // (direct, ops Lambda) verifies with the set the mint used.
429
+ // The plan's two endpoints are both FULL live hashes, deliberately:
430
+ // - `from` is the RAW live hash (everything introspected) — the concurrency lock and the
431
+ // backup-stamp chain (backup.fp == plan.from) compare live-vs-live.
432
+ // - `to` is the FULL predicted endpoint: the declared state PLUS the ungoverned live authz the
433
+ // reconciler leaves alone (carryUngoverned). So `to` equals the post-apply raw live hash, an
434
+ // empty plan means `from == to`, and db:apply verifies the full landed hash against it.
435
+ // The governed set still rides on the plan — the descent declares-test and db:check use it — but
436
+ // the endpoint itself is full.
293
437
  const governedRoles = governedRolesForModels(models, opts.governedRoles);
294
438
  return {
295
439
  v: PLAN_VERSION,
296
440
  fromFingerprint: fingerprintLive(snapshot, contract).hash,
297
441
  fpVersion: FINGERPRINT_VERSION,
298
- toFingerprint: predictLiveFingerprint(models, snapshot, contract, { schema: opts.schema, governedRoles }),
442
+ toFingerprint: predictLiveFingerprint(models, snapshot, contract, { schema: opts.schema, governedRoles, carryUngoverned: true }),
443
+ toFlavor: 'full',
444
+ defaultAclHash: defaultAclHash(contract),
299
445
  declaredFingerprint: fingerprintModels(models, { schema: opts.schema }).hash,
300
446
  governedRoles: [...governedRoles].sort(),
301
447
  statements,
@@ -384,6 +530,24 @@ export function tablesLeftWithoutARead(statements: readonly string[], live?: Aut
384
530
  return dark.sort();
385
531
  }
386
532
 
533
+ /**
534
+ * The content address of a contract's default-privilege posture — what `plan.defaultAclHash`
535
+ * carries and what db:apply recomputes over the live contract before dispatch. The WHOLE
536
+ * posture, not just the foreign slice the prediction folds in: a governed default ACL changes
537
+ * what a created object lands with too, and re-minting is free. A contract that never read
538
+ * default ACLs (an older recorder, a hand-built fixture) hashes as "not measured" — equal to
539
+ * itself, never to a measured one.
540
+ *
541
+ * `current_user`-relative, like the read it hashes (DEFAULT_ACL_SQL keys on the role that will
542
+ * create the objects). Mint and apply as DIFFERENT database roles that own different default
543
+ * ACLs, and the hashes differ with no change in between — a false refusal whose escape is one
544
+ * re-mint. The stage lanes share one operator role, so this only bites a mint over a local DSN
545
+ * applied through `--stage`, or two local DSNs — and a plan should be minted where it is applied.
546
+ */
547
+ export function defaultAclHash(contract: Pick<AuthzContract, 'defaultAcls'>): string {
548
+ return createHash('sha256').update(stableStringify(contract.defaultAcls ?? null)).digest('hex');
549
+ }
550
+
387
551
  /** The plan's content address — recorded as `plan_ref` on the schema_log row. */
388
552
  export function planHash(plan: EdgePlan): string {
389
553
  return createHash('sha256').update(stableStringify(plan)).digest('hex');
@@ -420,15 +584,42 @@ const short = (hash: string): string => hash.slice(0, 12);
420
584
  * cutover, cli 0.4.77). The caller computes it at mint from the canonical forms; it is never
421
585
  * stored on the plan.
422
586
  */
587
+ /**
588
+ * What accounts for an EMPTY edge between two DIFFERENT fingerprints — the `carriedBy` the
589
+ * summary prints. The `from` half is the full live fingerprint (the concurrency lock), the `to`
590
+ * half the declared endpoint through the governed set, and on a brownfield adopter they differ by
591
+ * exactly the roles the models do not govern: the reconciler leaves those grants alone. So when
592
+ * any ungoverned grantee is present, it IS the difference — name the roles and the baseline that
593
+ * admitted them (`db/authz-baseline.json`). Naming "35 tables differ" instead sent a consumer
594
+ * hunting a schema change that was not there (0.4.78 re-plan). Fall back to the raw divergent
595
+ * surfaces only when NO ungoverned grantee explains it — a live object the vocabulary cannot
596
+ * declare, say a policy on a governed role. Interim: the full fix carries the ungoverned authz
597
+ * through so the edge is truly empty (milestone plan-endpoint-full, N3).
598
+ */
599
+ export function explainCarriedBy(
600
+ exemptions: readonly GrantExemption[],
601
+ divergent: readonly string[],
602
+ ): string[] {
603
+ const grantees = [...new Set(exemptions.map((e) => e.grantee))].sort();
604
+ if (grantees.length > 0) {
605
+ return [`grants to baseline-adopted role(s) ${grantees.join(', ')} (${BASELINE_FILE})`];
606
+ }
607
+ return [...divergent];
608
+ }
609
+
423
610
  export function buildPlanSummary(plan: EdgePlan, opts: { carriedBy?: string[] } = {}): string[] {
424
611
  const lines: string[] = [];
425
612
  lines.push(`edge: ${short(plan.fromFingerprint)} → ${short(plan.toFingerprint)}`);
426
613
  if (plan.statements.length === 0) {
614
+ const carried = opts.carriedBy?.length ? `; carried by: ${opts.carriedBy.join('; ')}` : '';
427
615
  if (plan.fromFingerprint === plan.toFingerprint) {
428
- lines.push('empty — the target already declares this state');
616
+ // Condition (c), milestone plan-endpoint-full: the full endpoint carries the foreign grants
617
+ // through, so a brownfield adopter reaches from == to — but the operator must still see WHICH
618
+ // ungoverned roles ride through untouched, or the baseline they were admitted under is invisible.
619
+ lines.push('empty — the target already declares this state' + carried);
429
620
  } else {
430
621
  lines.push('empty — no statement to emit. The fingerprints differ by what the plan leaves alone: `from` is the target\'s full fingerprint (the concurrency lock), `to` the declared endpoint through the governed set'
431
- + (opts.carriedBy?.length ? `; carried by: ${opts.carriedBy.join('; ')}` : '') + '.');
622
+ + carried + '.');
432
623
  }
433
624
  return lines;
434
625
  }
@@ -278,8 +278,9 @@ async function evaluateTree(candidate: ModelTreeCandidate, live: LiveState, ctx:
278
278
  // not a failure to read. Every pre-Models repo has such ancestors: the models directory
279
279
  // scaffolded (or present for another reason) before its barrel existed. Letting the loader
280
280
  // throw on the absent file would classify it `compile-failed`, poisoning the verdict to
281
- // UNVERIFIABLE — which sends a never-adopted database to "retry/upgrade" instead of to
282
- // db:adopt. So the missing barrel is decided here, before the loader is ever consulted.
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.
283
284
  const barrelPath = path.join(dest, ctx.barrel);
284
285
  if (!fs.existsSync(barrelPath)) return { kind: 'no' };
285
286
 
@@ -433,7 +434,7 @@ const firstLine = summarizeCompileError;
433
434
  * in a monorepo the app (`apps/expo/db/models`) has `apps/expo/node_modules`, and the root
434
435
  * `node_modules` may not carry the package at all (closing-gate review round 4, reproduced). No
435
436
  * `node_modules` anywhere up to the repo root → the repo root itself (a nested checkout still walks
436
- * up to its host's). Never the OS temp dir — that resolves nothing (the db:adopt 0.4.74 bug).
437
+ * up to its host's). Never the OS temp dir — that resolves nothing (the 0.4.74 sandbox-resolution bug).
437
438
  */
438
439
  export function materializeBase(repoRoot: string, modelsAbs: string): string {
439
440
  let dir = path.dirname(modelsAbs);
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 (the brownfield on-ramp). --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
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.
@@ -16,12 +16,13 @@
16
16
  */
17
17
 
18
18
  import type { ModelDescriptor, SequenceDescriptor } from '@everystack/model';
19
- import type { SchemaSnapshot } from './schema-introspect';
20
- import type { AuthzContract } from './authz-contract';
19
+ import type { SchemaSnapshot, ColumnSchema } from './schema-introspect';
20
+ import type { AuthzContract, SequenceGrantContract } from './authz-contract';
21
21
  import { compileTableSchema, compileRenames, compileTableRenames, compileTableMoves, compileCreateTable, compileEnums, compileSequences } from './schema-compile';
22
22
  import { compileTableContract } from './authz-compile';
23
23
  import { emitReconcileSql, governedRoleSet } from './authz-reconcile';
24
- import { diffSchema, emitSchemaSql, type SchemaChange } from './schema-diff';
24
+ import { diffSchema, emitSchemaSql, ownSequenceName, type SchemaChange } from './schema-diff';
25
+ import { schemaOf } from './schema-fingerprint';
25
26
 
26
27
  export interface GenerateOptions {
27
28
  /** The Postgres schema the Models live in. Default: `public`. */
@@ -233,12 +234,22 @@ export function generateMigrationSql(models: ModelDescriptor[], current: SchemaS
233
234
  .filter(([, from]) => pendingMoveSources.has(from))
234
235
  .map(([to, from]) => [from, to] as const),
235
236
  ]);
237
+ // The owned sequences ride a RENAME/SET SCHEMA with their table (the sequence keeps its
238
+ // own name), so their live ACLs are keyed under the new table name too — else the sequence
239
+ // reconcile finds nothing for a renamed table and its inheritance lags a plan behind.
236
240
  const liveAuthzRenamed = opts.liveAuthz && pendingByFrom.size > 0
237
241
  ? {
238
242
  ...opts.liveAuthz,
239
243
  tables: opts.liveAuthz.tables.map((t) =>
240
244
  pendingByFrom.has(t.table) ? { ...t, table: pendingByFrom.get(t.table)! } : t,
241
245
  ),
246
+ ...(opts.liveAuthz.sequenceGrants
247
+ ? {
248
+ sequenceGrants: opts.liveAuthz.sequenceGrants.map((s) =>
249
+ pendingByFrom.has(s.table) ? { ...s, table: pendingByFrom.get(s.table)! } : s,
250
+ ),
251
+ }
252
+ : {}),
242
253
  }
243
254
  : opts.liveAuthz;
244
255
  const desiredContracts = models.map((m) => compileTableContract(m, { schema }));
@@ -246,6 +257,7 @@ export function generateMigrationSql(models: ModelDescriptor[], current: SchemaS
246
257
  const authzPhase = liveAuthzRenamed
247
258
  ? emitReconcileSql(desiredAuthz, liveAuthzRenamed, {
248
259
  governedRoles: governedRoleSet(desiredAuthz, opts.governedRoles ?? []),
260
+ createdSequences: plannedSerialSequences(changes),
249
261
  })
250
262
  : [];
251
263
 
@@ -295,6 +307,28 @@ export function generateMigrationSql(models: ModelDescriptor[], current: SchemaS
295
307
  return [...extensionPhase, ...schemaPhase, ...usagePhase, ...dataPhase, ...authzPhase];
296
308
  }
297
309
 
310
+ /**
311
+ * The serial sequences a diff's data phase CREATES — one per serial column in a `createTable`
312
+ * or `addColumn` change whose default draws the column's OWN conventional `<table>_<col>_seq`
313
+ * (the `serial` shorthand is what creates it). A foreign `nextval` draws an existing sequence
314
+ * and creates none; an identity column has no nextval default; a rename/move creates nothing
315
+ * (the live sequence rides along under its old name). Returned as live-shaped entries with NO
316
+ * grants, for `emitReconcileSql`'s `createdSequences` — the reconcile then grants the same
317
+ * plan's INSERT-holders their inheritance right after the CREATE that makes the sequence.
318
+ */
319
+ export function plannedSerialSequences(changes: readonly SchemaChange[]): SequenceGrantContract[] {
320
+ const out: SequenceGrantContract[] = [];
321
+ const push = (table: string, c: ColumnSchema): void => {
322
+ const seq = ownSequenceName(table, c);
323
+ if (seq) out.push({ sequence: `${schemaOf(table)}.${seq}`, table, grants: {}, identity: false });
324
+ };
325
+ for (const change of changes) {
326
+ if (change.kind === 'createTable') for (const c of change.schema.columns) push(change.table, c);
327
+ else if (change.kind === 'addColumn') push(change.table, change.column);
328
+ }
329
+ return out;
330
+ }
331
+
298
332
  /** The marker drizzle migration files put between statements. */
299
333
  const BREAKPOINT = '\n--> statement-breakpoint\n';
300
334
 
@@ -208,10 +208,21 @@ export function sequenceCreateSql(seq: SequenceSchema): string {
208
208
  return `${s};`;
209
209
  }
210
210
 
211
+ /**
212
+ * The bare name of the sequence a column OWNS through the `serial` shorthand — its conventional
213
+ * `<table>_<col>_seq` — or null: a foreign `nextval` draws an existing sequence and owns none,
214
+ * and an identity column has no nextval default. This is the one place that spells the
215
+ * convention; the CREATE/ADD renderer, the created-sequence grant, and the endpoint prediction
216
+ * all read it here so they cannot disagree about which columns make a sequence.
217
+ */
218
+ export function ownSequenceName(table: string, col: ColumnSchema): string | null {
219
+ const seq = nextvalSequence(col.default);
220
+ return seq != null && seq === `${bareTable(table)}_${col.name}_seq` ? seq : null;
221
+ }
222
+
211
223
  /** True when a column's default draws a sequence that is NOT its own conventional one. */
212
224
  function isForeignSequenceDefault(table: string, col: ColumnSchema): boolean {
213
- const seq = nextvalSequence(col.default);
214
- return seq != null && seq !== `${bareTable(table)}_${col.name}_seq`;
225
+ return nextvalSequence(col.default) != null && ownSequenceName(table, col) == null;
215
226
  }
216
227
 
217
228
  // ---------------------------------------------------------------------------
@@ -888,8 +899,7 @@ function serialSpelling(col: ColumnSchema, table: string): string | null {
888
899
  // ONLY the column's own conventional sequence is a serial declaration — the shorthand
889
900
  // creates a sequence, so spelling it for a foreign `nextval` would mint a duplicate
890
901
  // and silently detach the column from the sequence it declared.
891
- const seq = nextvalSequence(col.default);
892
- if (seq == null || seq !== `${bareTable(table)}_${col.name}_seq`) return null;
902
+ if (ownSequenceName(table, col) == null) return null;
893
903
  if (col.type === 'integer') return 'serial';
894
904
  if (col.type === 'bigint') return 'bigserial';
895
905
  return null;
@@ -301,8 +301,10 @@ export interface CanonicalizeOptions {
301
301
  * The MEASURED serial-sequence ACLs (v11). A table present here is hashed from its live ACLs
302
302
  * (so a REVOKE moves the fingerprint); a table absent DERIVES its ACLs from the A6 inheritance
303
303
  * rule (the reconciled target). The live path passes every measured table; the declared path
304
- * passes nothing; the plan's predicted endpoint passes only rides-through tables. See
305
- * {@link canonicalSequenceAuthz}.
304
+ * passes nothing; the plan's predicted endpoint passes the rides-through tables measured — and
305
+ * in its FULL flavor (`carryUngoverned`) also one synthesized entry per declared table's
306
+ * sequence: its live sequences combined with the reconciled governed shape, plus the sequences
307
+ * the plan creates. See {@link canonicalSequenceAuthz}.
306
308
  */
307
309
  sequenceGrants?: SequenceGrantContract[];
308
310
  }
@@ -568,13 +570,14 @@ WHERE p.prosecdef
568
570
  AND n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg_%'
569
571
  AND NOT EXISTS (SELECT 1 FROM pg_depend d WHERE d.objid = p.oid AND d.deptype = 'e')
570
572
  UNION ALL
571
- -- A14, the interim before v11. A column's GENERATED … AS IDENTITY is now READ, DECLARED and
572
- -- BUILT, but it is not yet hashed: the fingerprint's coverage is a version contract, and a
573
- -- property entering the hash inside a version makes two operators on different CLI builds
574
- -- disagree about the same database. So it is named here until v11 carries it, batched with the
575
- -- sequence ACLs and the schema posture. The gap is real and narrow: an identity that is silently
576
- -- dropped or flipped ALWAYS to BY DEFAULT leaves every fingerprint gate green. db:generate sees
577
- -- it (the differ compares it), and the round-trip oracle's pg_dump comparison catches it.
573
+ -- A14. A column's GENERATED … AS IDENTITY is READ, DECLARED and BUILT, but it is NOT hashed:
574
+ -- the fingerprint's coverage is a version contract, and a property entering the hash inside a
575
+ -- version makes two operators on different CLI builds disagree about the same database. v11
576
+ -- took the sequence ACLs and deliberately left this and the schema posture out; both stay
577
+ -- named here until a canon bump (a v12 candidate) carries them together. The gap is real and
578
+ -- narrow: an identity that is silently dropped or flipped ALWAYS to BY DEFAULT leaves the MATCH
579
+ -- claim green. It is not silent on the write path — db:generate/db:plan diff it (the differ
580
+ -- compares identity) — and the round-trip oracle's pg_dump comparison catches it.
578
581
  SELECT 'identity column',
579
582
  n.nspname || '.' || c.relname || '.' || a.attname ||
580
583
  ' → ' || CASE a.attidentity WHEN 'a' THEN 'always' ELSE 'by default' END
package/src/cli/adopt.ts DELETED
@@ -1,58 +0,0 @@
1
- /**
2
- * adopt — the PROVE core of `db:adopt` (P2a). Pure: live introspection + compiled Models in, a
3
- * verdict out. The command shell (commands/db-adopt.ts) does the IO — introspect, render, load,
4
- * write files, print the commit.
5
- *
6
- * The doctrine (Fable-ruled 2026-09-05): PROVE, not attest. There is no bypass flag. db:adopt
7
- * renders Models from a live database and may finalize them ONLY when the compiled Models declare
8
- * that database EXACTLY — the same declares-test descent and db:check use (predicted == governed
9
- * live). A commit that carries such Models legitimately declares the live state, so a later
10
- * db:apply descends for real (proveDescent finds the adopt commit) instead of being forced past a
11
- * drift refusal. When the round-trip is not exact, adoption REFUSES and names what diverged —
12
- * because a baseline that silently papered over a difference is the bypass under another name.
13
- */
14
-
15
- import type { ModelDescriptor } from '@everystack/model';
16
- import type { SchemaSnapshot } from './schema-introspect';
17
- import type { AuthzContract } from './authz-contract';
18
- import { governedRolesForModels, fingerprintState, fingerprintLive, divergentSurfaces } from './schema-fingerprint';
19
- import { predictLiveState, governedLiveFingerprint } from './edge-plan';
20
-
21
- export interface AdoptionProof {
22
- /** True when the compiled Models declare the live state exactly — safe to finalize the baseline. */
23
- clean: boolean;
24
- /** The RAW live fingerprint — the baseline id that names the adopt commit (`adopt live baseline <fp>`). */
25
- fingerprint: string;
26
- /** When not clean, the surfaces that still diverge (base schema / authorization / sequence ACLs,
27
- * with the tables named), so the refusal says exactly what the render could not capture. Empty when clean. */
28
- divergentSurfaces: string[];
29
- }
30
-
31
- /**
32
- * Does this compiled Models set declare the live state exactly? The declares-test, governed on
33
- * both sides through the SAME set the Models imply — a governed prediction against a raw live
34
- * hash can never converge on a brownfield database with foreign roles. Reuses the very functions
35
- * descent (`evaluateTree`) and db:fingerprint compare with, so "adoption is clean" and "the plan
36
- * is empty" are one answer, not two that can disagree.
37
- */
38
- export function proveAdoption(
39
- models: ModelDescriptor[],
40
- snapshot: SchemaSnapshot,
41
- contract: AuthzContract,
42
- opts: { schema?: string } = {},
43
- ): AdoptionProof {
44
- const governedRoles = governedRolesForModels(models);
45
- const predicted = predictLiveState(models, snapshot, contract, { schema: opts.schema, governedRoles });
46
- const governedLiveHash = governedLiveFingerprint(snapshot, contract, governedRoles);
47
- const clean = predicted.hash === governedLiveHash;
48
- return {
49
- clean,
50
- fingerprint: fingerprintLive(snapshot, contract).hash,
51
- divergentSurfaces: clean
52
- ? []
53
- : divergentSurfaces(
54
- predicted.canonical,
55
- fingerprintState(snapshot, contract.tables, { governedRoles, sequenceGrants: contract.sequenceGrants }).canonical,
56
- ),
57
- };
58
- }