@everystack/cli 0.3.16 → 0.3.18

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/README.md CHANGED
@@ -217,6 +217,8 @@ everystack db:psql --stage dev [-c SQL] # interactive psql / one-off query via L
217
217
  everystack db:generate # diff Models vs the live DB → next migration
218
218
  everystack db:pull # introspect a live DB → field() Models (brownfield on-ramp)
219
219
  everystack db:reconcile # deploy the derived layer (functions/views/matviews) from db/sql — no migrations
220
+ everystack db:fingerprint # content-address the live base schema vs the Models — MATCH/MISMATCH
221
+ everystack db:sync # make the database match your checkout — state + compute, one verb (dev DBs)
220
222
 
221
223
  # Security
222
224
  everystack db:doctor # is the DB least-privilege + RLS-subject?
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.3.16",
3
+ "version": "0.3.18",
4
4
  "description": "CLI and OTA updates for Expo apps on everystack",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Scalable Technology, Inc. <licensing@scalable.technology>",
@@ -353,8 +353,11 @@ export function rlsRowToDescriptor(row: RlsRow): {
353
353
  * meaningful grants, and exists only when migrations ran through drizzle's migrator
354
354
  * (so it appears on a deployed stage but not on a raw-applied local DB — pure noise
355
355
  * that would read as local↔remote drift). Same intent as the pg_depend extension filter.
356
+ * `everystack` is the runner's own bookkeeping (`schema_log`, `derived_provenance`) —
357
+ * the memoir must not shift the schema's content address: a database the runner has
358
+ * written to and a fresh one declaring the same models are the SAME state.
356
359
  */
357
- export const IGNORED_SCHEMAS = new Set(['drizzle']);
360
+ export const IGNORED_SCHEMAS = new Set(['drizzle', 'everystack']);
358
361
 
359
362
  export interface ContractRows {
360
363
  rls: RlsRow[];
@@ -42,7 +42,7 @@ import {
42
42
  import { modelOwner } from '../authz-compile.js';
43
43
  import { writeContract, loadContract } from '../authz-contract-io.js';
44
44
  import { renderContractMarkdown } from '../authz-render.js';
45
- import { FUNCTIONS_SQL, catalogFunctionToDescriptor } from '../security-catalog.js';
45
+ import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
46
46
  import { resolveConfig, opsFunction } from '../config.js';
47
47
  import { resolveModelsPath } from '../models-path.js';
48
48
  import { invokeAction } from '../aws.js';
@@ -50,15 +50,6 @@ import { step, success, fail, info, warn } from '../output.js';
50
50
 
51
51
  const DEFAULT_DIR = 'authz';
52
52
 
53
- /** Map a FUNCTIONS_SQL row to the minimal shape the contract records. */
54
- const mapFunctionRow = (row: any) => {
55
- const d = catalogFunctionToDescriptor(row);
56
- return {
57
- schema: d.schema, name: d.name, securityDefiner: d.securityDefiner, hasSearchPath: d.hasSearchPath,
58
- owner: d.owner, ownerBypassesRls: d.ownerBypassesRls,
59
- };
60
- };
61
-
62
53
  /** A QueryRunner backed by the ops Lambda `db:query` action (read-only). */
63
54
  function lambdaRunner(region: string, fn: string): QueryRunner {
64
55
  return async (sql: string) => {
@@ -74,7 +65,7 @@ async function introspectStage(flags: Record<string, string>): Promise<AuthzCont
74
65
  info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
75
66
  step('Introspecting authorization (rls + grants + policies + secdef)...');
76
67
  const runner = lambdaRunner(config.region, opsFunction(config));
77
- return introspectContract(runner, mapFunctionRow, FUNCTIONS_SQL);
68
+ return introspectContract(runner, contractFunctionRow, FUNCTIONS_SQL);
78
69
  }
79
70
 
80
71
  export async function dbAuthzPullCommand(flags: Record<string, string>): Promise<void> {
@@ -0,0 +1,116 @@
1
+ /**
2
+ * `everystack db:fingerprint` — where is this database, and does it match the
3
+ * models? The seed of `db:status`.
4
+ *
5
+ * db:fingerprint [--stage <name> | --database-url <url>] [--models <barrel>] [--json]
6
+ *
7
+ * Introspects the live base schema (tables + constraints + authz) and prints
8
+ * its content-addressed fingerprint; loads the Models and prints the DECLARED
9
+ * fingerprint; says MATCH or MISMATCH (exit 1 — CI-able). A mismatch's next
10
+ * step is always `db:generate`, which shows the difference as SQL. The
11
+ * unfingerprinted report keeps partial coverage honest, and the derived layer
12
+ * is deliberately absent here — `db:reconcile --check` is its verifier.
13
+ */
14
+
15
+ import type { ModelDescriptor } from '@everystack/model';
16
+ import type { QueryRunner } from '../authz-contract.js';
17
+ import { introspectSchema } from '../schema-introspect.js';
18
+ import { introspectContract } from '../authz-contract.js';
19
+ import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
20
+ import {
21
+ fingerprintLive,
22
+ fingerprintModels,
23
+ mapUnfingerprintedRows,
24
+ UNFINGERPRINTED_SQL,
25
+ type UnfingerprintedObject,
26
+ } from '../schema-fingerprint.js';
27
+ import { resolveModelsPath } from '../models-path.js';
28
+ import { resolveDbSource, createUrlRunner, type DbSource } from '../db-source.js';
29
+ import { resolveConfig, opsFunction } from '../config.js';
30
+ import { invokeAction } from '../aws.js';
31
+ import { step, success, fail, info, warn } from '../output.js';
32
+ import { loadModels } from './db-generate.js';
33
+
34
+ export interface FingerprintStatus {
35
+ live: string;
36
+ declared: string | null;
37
+ match: boolean | null;
38
+ unfingerprinted: UnfingerprintedObject[];
39
+ }
40
+
41
+ /** The testable core: live fingerprint, declared fingerprint, verdict, honesty report. */
42
+ export async function computeFingerprintStatus(
43
+ runner: QueryRunner,
44
+ models: ModelDescriptor[] | null,
45
+ ): Promise<FingerprintStatus> {
46
+ const snapshot = await introspectSchema(runner);
47
+ const contract = await introspectContract(runner, contractFunctionRow, FUNCTIONS_SQL);
48
+ const live = fingerprintLive(snapshot, contract).hash;
49
+ const declared = models ? fingerprintModels(models).hash : null;
50
+ const unfingerprinted = mapUnfingerprintedRows((await runner(UNFINGERPRINTED_SQL)) as any[]);
51
+ return { live, declared, match: declared === null ? null : live === declared, unfingerprinted };
52
+ }
53
+
54
+ /** A QueryRunner backed by the ops Lambda `db:query` action (read-only). */
55
+ function lambdaRunner(region: string, fn: string): QueryRunner {
56
+ return async (sql: string) => {
57
+ const result: any = await invokeAction(region, fn, 'db:query', { sql });
58
+ if (result?.error) throw new Error(`Query failed: ${result.error}`);
59
+ return result?.rows ?? [];
60
+ };
61
+ }
62
+
63
+ export async function dbFingerprintCommand(flags: Record<string, string>): Promise<void> {
64
+ let dbSource: DbSource;
65
+ try {
66
+ dbSource = resolveDbSource(flags);
67
+ } catch (err: any) {
68
+ fail(err.message);
69
+ process.exit(1);
70
+ }
71
+
72
+ const modelsPath = resolveModelsPath(flags.models);
73
+ let models: ModelDescriptor[] | null = null;
74
+ try {
75
+ models = await loadModels(modelsPath);
76
+ } catch {
77
+ // Live-only mode: no models barrel — still useful (what fingerprint is this DB at?).
78
+ }
79
+
80
+ let runner: QueryRunner;
81
+ let end: (() => Promise<void>) | undefined;
82
+ if (dbSource.kind === 'url') {
83
+ step(dbSource.from === 'flag' ? 'Connecting via --database-url...' : 'Connecting via DATABASE_URL...');
84
+ ({ runner, end } = await createUrlRunner(dbSource.url));
85
+ } else {
86
+ step('Resolving deployed config...');
87
+ const config = await resolveConfig(flags.stage);
88
+ runner = lambdaRunner(config.region, opsFunction(config));
89
+ }
90
+
91
+ try {
92
+ const status = await computeFingerprintStatus(runner, models);
93
+
94
+ if (flags.json === 'true') {
95
+ console.log(JSON.stringify(status, null, 2));
96
+ } else {
97
+ info(`live: ${status.live}`);
98
+ if (status.declared === null) {
99
+ warn(`declared: (no models barrel at ${modelsPath} — live-only)`);
100
+ } else {
101
+ info(`declared: ${status.declared}`);
102
+ if (status.match) success('MATCH — the database is the state the models declare.');
103
+ else fail('MISMATCH — run `everystack db:generate` to see the difference as SQL.');
104
+ }
105
+ if (status.unfingerprinted.length > 0) {
106
+ warn(`unfingerprinted (${status.unfingerprinted.length}) — outside the base fingerprint and the derived manifest:`);
107
+ for (const u of status.unfingerprinted) info(` ${u.kind}: ${u.identity}`);
108
+ }
109
+ info('derived layer (functions/views/matviews): verified separately by `db:reconcile --check`.');
110
+ }
111
+
112
+ if (status.match === false) process.exit(1);
113
+ } finally {
114
+ await end?.();
115
+ }
116
+ }
@@ -26,10 +26,10 @@ import { compileDrizzleSource } from '../schema-source.js';
26
26
  import { compileModuleMigration } from '../migration-compile.js';
27
27
  import { generateMigrationSql, unmodeledTables, formatMigrationFile, planMigrationFile, HELD_DROP_PREFIX, type Journal } from '../migration-generate.js';
28
28
  import { introspectContract, type QueryRunner } from '../authz-contract.js';
29
- import { FUNCTIONS_SQL, catalogFunctionToDescriptor } from '../security-catalog.js';
29
+ import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
30
30
  import { resolveDbSource, createUrlRunner, type DbSource } from '../db-source.js';
31
31
  import { resolveModelsPath } from '../models-path.js';
32
- import { applyGeneratedStatements, classifyGeneratedStatements, currentGitRef } from '../state-apply.js';
32
+ import { applyStateAndVerify, classifyGeneratedStatements, currentGitRef, type StateSyncOutcome } from '../state-apply.js';
33
33
  import { resolveConfig, opsFunction } from '../config.js';
34
34
  import { invokeAction } from '../aws.js';
35
35
  import { step, success, fail, info, warn } from '../output.js';
@@ -46,14 +46,8 @@ function lambdaRunner(region: string, fn: string): QueryRunner {
46
46
  };
47
47
  }
48
48
 
49
- /** Map a FUNCTIONS_SQL row to the minimal shape the authz contract records. */
50
- const mapFunctionRow = (row: any) => {
51
- const d = catalogFunctionToDescriptor(row);
52
- return { schema: d.schema, name: d.name, securityDefiner: d.securityDefiner, hasSearchPath: d.hasSearchPath, owner: d.owner, ownerBypassesRls: d.ownerBypassesRls };
53
- };
54
-
55
49
  /** Import the app's Model barrel and return its `models` array (runs under tsx, so TS imports work). */
56
- async function loadModels(modelsPath: string): Promise<ModelDescriptor[]> {
50
+ export async function loadModels(modelsPath: string): Promise<ModelDescriptor[]> {
57
51
  const abs = path.resolve(modelsPath);
58
52
  let mod: any;
59
53
  try {
@@ -174,7 +168,7 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
174
168
  step('Introspecting live database (columns + constraints)...');
175
169
  current = await introspectSchema(runner);
176
170
  step('Introspecting authorization (rls + grants + policies)...');
177
- liveAuthz = await introspectContract(runner, mapFunctionRow, FUNCTIONS_SQL);
171
+ liveAuthz = await introspectContract(runner, contractFunctionRow, FUNCTIONS_SQL);
178
172
  if (!apply) await end?.();
179
173
  } catch (err: any) {
180
174
  fail(err.message);
@@ -211,27 +205,25 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
211
205
  success('Nothing executable — only held/notice items differ.');
212
206
  process.exit(0);
213
207
  }
214
- step(`Applying ${classified.executable.length} statement(s) as one transaction...`);
208
+ step(`Applying ${classified.executable.length} statement(s) as one transaction, then verifying by re-diff...`);
209
+ let outcome: StateSyncOutcome;
215
210
  try {
216
- await applyGeneratedStatements(runner, statements, { actor: process.env.USER ?? null, gitRef: currentGitRef() });
211
+ outcome = await applyStateAndVerify(runner, models, statements, current, liveAuthz, {
212
+ allowDrops, actor: process.env.USER ?? null, gitRef: currentGitRef(),
213
+ });
217
214
  } catch (err: any) {
218
215
  fail(`Apply failed and rolled back: ${err.message}`);
219
216
  process.exit(1);
220
217
  }
221
- step('Verifying — re-introspecting and re-diffing...');
222
- const after = await introspectSchema(runner);
223
- const afterAuthz = await introspectContract(runner, mapFunctionRow, FUNCTIONS_SQL);
224
- const remaining = classifyGeneratedStatements(
225
- generateMigrationSql(models, after, { allowDrops, liveAuthz: afterAuthz }),
226
- ).executable;
227
218
  await end?.();
228
- if (remaining.length > 0) {
229
- fail(`Verify FAILED — ${remaining.length} statement(s) still differ after apply:`);
230
- for (const s of remaining) console.log(s);
219
+ if (outcome.remaining.length > 0) {
220
+ fail(`Verify FAILED — ${outcome.remaining.length} statement(s) still differ after apply:`);
221
+ for (const s of outcome.remaining) console.log(s);
231
222
  process.exit(1);
232
223
  }
233
224
  const heldNote = classified.heldDrops.length ? ` (${classified.heldDrops.length} held drop(s) remain, by design)` : '';
234
225
  success(`Applied and verified — db:generate is a no-op again${heldNote}. Recorded in everystack.schema_log.`);
226
+ info(`fingerprint: ${outcome.fromFingerprint.slice(0, 12)} → ${outcome.toFingerprint.slice(0, 12)}`);
235
227
  process.exit(0);
236
228
  }
237
229
 
@@ -0,0 +1,332 @@
1
+ /**
2
+ * `everystack db:sync` — make the database match your checkout. One verb,
3
+ * both layers, for dev-stage databases (brick 5's core).
4
+ *
5
+ * db:sync [--database-url <url>] [--models <barrel>] [--sql-dir db/sql]
6
+ * [--allow-drops] [--overwrite-drift] [--baseline] [--json]
7
+ *
8
+ * The edit loop's verb: fingerprint the live base state, apply the state
9
+ * diff (tables + authz, one transaction, verified by re-diff), reconcile
10
+ * the derived layer (functions/views/matviews) against db/sql, and report
11
+ * where the database now is — "at fingerprint X, matching your checkout"
12
+ * when everything converged. Hot-reload for the database; no artifact
13
+ * produced, every act recorded in `everystack.schema_log`.
14
+ *
15
+ * The verdict is two-tier, honestly: CONVERGED means the state re-diff is
16
+ * empty and the compute layer is clean — that is the exit-0 bar. The
17
+ * fingerprint MATCH is the strong content-address claim on top, and every
18
+ * legitimate mismatch is explained by name: held drops (the checkout
19
+ * declares a removal `--allow-drops` hasn't confirmed) or unmodeled tables
20
+ * (live tables no model declares — untouched by design). Drift on derived
21
+ * objects refuses, exactly as `db:reconcile` does; nothing is ever
22
+ * overwritten silently.
23
+ *
24
+ * Sync applies, so it needs a direct connection (--database-url or
25
+ * DATABASE_URL) — the ops Lambda query path is read-only by design.
26
+ * Protected stages are the plan flow's territory (db:plan → db:apply,
27
+ * brick 7), not sync's.
28
+ */
29
+
30
+ import fs from 'node:fs/promises';
31
+ import path from 'node:path';
32
+ import type { ModelDescriptor } from '@everystack/model';
33
+ import { introspectContract, type QueryRunner } from '../authz-contract.js';
34
+ import { introspectSchema } from '../schema-introspect.js';
35
+ import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
36
+ import { generateMigrationSql, unmodeledTables } from '../migration-generate.js';
37
+ import {
38
+ applyStateAndVerify,
39
+ classifyGeneratedStatements,
40
+ currentGitRef,
41
+ type ClassifiedStatements,
42
+ type StateSyncOutcome,
43
+ } from '../state-apply.js';
44
+ import { fingerprintModels } from '../schema-fingerprint.js';
45
+ import { compileDrizzleSource } from '../schema-source.js';
46
+ import type { SourceFile } from '../derived-source.js';
47
+ import { resolveDbSource, createUrlRunner, type DbSource } from '../db-source.js';
48
+ import { resolveModelsPath } from '../models-path.js';
49
+ import { executeReconcile, buildReconcileReport, type ReconcileRun } from './db-reconcile.js';
50
+ import { loadModels } from './db-generate.js';
51
+ import { step, success, fail, info, warn } from '../output.js';
52
+
53
+ const DEFAULT_SQL_DIR = 'db/sql';
54
+ const DEFAULT_SCHEMA_OUT = 'db/schema.generated.ts';
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // The testable core.
58
+ // ---------------------------------------------------------------------------
59
+
60
+ export interface SyncOptions {
61
+ allowDrops?: boolean;
62
+ overwriteDrift?: boolean;
63
+ baseline?: boolean;
64
+ actor?: string | null;
65
+ gitRef?: string | null;
66
+ /** Injectable clock for tests. */
67
+ now?: () => number;
68
+ }
69
+
70
+ export interface SyncHooks {
71
+ /** Fires after the state diff is computed, BEFORE anything executes — the review seam. */
72
+ onStatePlan?: (statements: string[], classified: ClassifiedStatements) => void;
73
+ /** Fires after the state layer applied and verified, before the compute reconcile. */
74
+ onStateDone?: (state: StateSyncOutcome) => void;
75
+ }
76
+
77
+ export interface SyncRun {
78
+ state: StateSyncOutcome;
79
+ /** null = no db/sql sources — the compute layer is not declared, skipped. */
80
+ compute: ReconcileRun | null;
81
+ declaredFingerprint: string;
82
+ fingerprintMatch: boolean;
83
+ /** Live tables no model declares — untouched, and an honest MISMATCH cause. */
84
+ unmodeled: string[];
85
+ /** The state bar: the post-apply re-diff is empty. */
86
+ stateConverged: boolean;
87
+ /** The compute bar: applied (or nothing to do), no refusal, no pending findings. */
88
+ computeClean: boolean;
89
+ converged: boolean;
90
+ }
91
+
92
+ /**
93
+ * One sync: state first (derived objects may depend on new columns), then
94
+ * compute, then the verdict. Pure orchestration over an injected QueryRunner;
95
+ * the CLI shell below owns flags, files, and exits.
96
+ */
97
+ export async function executeSync(
98
+ runner: QueryRunner,
99
+ models: ModelDescriptor[],
100
+ sources: SourceFile[] | null,
101
+ options: SyncOptions = {},
102
+ hooks: SyncHooks = {},
103
+ ): Promise<SyncRun> {
104
+ const current = await introspectSchema(runner);
105
+ const liveAuthz = await introspectContract(runner, contractFunctionRow, FUNCTIONS_SQL);
106
+ const statements = generateMigrationSql(models, current, {
107
+ allowDrops: options.allowDrops, liveAuthz,
108
+ });
109
+ hooks.onStatePlan?.(statements, classifyGeneratedStatements(statements));
110
+
111
+ const state = await applyStateAndVerify(runner, models, statements, current, liveAuthz, {
112
+ allowDrops: options.allowDrops,
113
+ actor: options.actor, gitRef: options.gitRef, now: options.now,
114
+ });
115
+ hooks.onStateDone?.(state);
116
+
117
+ const compute = sources === null ? null : await executeReconcile(runner, sources, {
118
+ apply: true,
119
+ baseline: options.baseline,
120
+ overwriteDrift: options.overwriteDrift,
121
+ actor: options.actor, gitRef: options.gitRef, now: options.now,
122
+ });
123
+
124
+ const declaredFingerprint = fingerprintModels(models).hash;
125
+ const stateConverged = state.remaining.length === 0;
126
+ // The compute bar, judged AFTER the apply: no refusal (drift/blocked stop the
127
+ // whole batch) and nothing left unadopted (needsBaseline objects were not
128
+ // touched — --baseline records the trust). Executed actions are convergence,
129
+ // not pending work, so `checkFails` (the pre-apply CI lens) is wrong here.
130
+ const computeClean = compute === null
131
+ || (!compute.refusal && compute.plan.needsBaseline.length === 0);
132
+
133
+ return {
134
+ state,
135
+ compute,
136
+ declaredFingerprint,
137
+ fingerprintMatch: state.toFingerprint === declaredFingerprint,
138
+ unmodeled: unmodeledTables(models, state.after.snapshot),
139
+ stateConverged,
140
+ computeClean,
141
+ converged: stateConverged && computeClean,
142
+ };
143
+ }
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // Report rendering (pure).
147
+ // ---------------------------------------------------------------------------
148
+
149
+ const short = (hash: string): string => hash.slice(0, 12);
150
+
151
+ export function buildSyncReport(run: SyncRun): string[] {
152
+ const lines: string[] = [];
153
+ const { state, compute } = run;
154
+
155
+ // State.
156
+ if (state.remaining.length > 0) {
157
+ lines.push(`! state: ${state.remaining.length} statement(s) still differ after apply — investigate (db:generate shows them as SQL)`);
158
+ } else if (state.applied) {
159
+ lines.push(`~ state: applied ${state.classified.executable.length} statement(s) — verified, re-diff empty`);
160
+ } else {
161
+ lines.push('= state: in sync — no table/authz changes');
162
+ }
163
+ if (state.classified.heldDrops.length > 0) {
164
+ lines.push(`! ${state.classified.heldDrops.length} destructive change(s) held back — NOT executed (re-run with --allow-drops to include them)`);
165
+ }
166
+ if (state.classified.notices.length > 0) {
167
+ lines.push(`· ${state.classified.notices.length} notice(s) — manual follow-ups, nothing executed for them`);
168
+ }
169
+
170
+ // Compute.
171
+ if (compute === null) {
172
+ lines.push('· compute: no db/sql directory — derived layer not declared, skipped');
173
+ } else {
174
+ lines.push('compute:');
175
+ for (const line of buildReconcileReport(compute.plan)) lines.push(` ${line}`);
176
+ if (compute.refusal) lines.push(`! compute not applied: ${compute.refusal}`);
177
+ if (compute.plan.needsBaseline.length > 0) {
178
+ lines.push('! first contact with existing derived objects — re-run with --baseline to record trust, once');
179
+ }
180
+ }
181
+
182
+ // Fingerprint.
183
+ lines.push(
184
+ state.toFingerprint === state.fromFingerprint
185
+ ? `fingerprint: ${short(state.toFingerprint)} (unchanged)`
186
+ : `fingerprint: ${short(state.fromFingerprint)} → ${short(state.toFingerprint)}`,
187
+ );
188
+ if (run.fingerprintMatch) {
189
+ lines.push('MATCH — the database is the state your checkout declares');
190
+ } else if (run.stateConverged) {
191
+ const causes: string[] = [];
192
+ if (state.classified.heldDrops.length > 0) {
193
+ causes.push(`${state.classified.heldDrops.length} held drop(s) — the checkout declares their removal; --allow-drops completes it`);
194
+ }
195
+ if (run.unmodeled.length > 0) {
196
+ causes.push(`${run.unmodeled.length} unmodeled table(s): ${run.unmodeled.join(', ')} — undeclared, left untouched`);
197
+ }
198
+ lines.push(causes.length > 0
199
+ ? `MISMATCH vs declared ${short(run.declaredFingerprint)}, explained: ${causes.join('; ')}`
200
+ : `MISMATCH vs declared ${short(run.declaredFingerprint)} — run db:fingerprint for the honesty report, db:generate for the difference as SQL`);
201
+ }
202
+
203
+ return lines;
204
+ }
205
+
206
+ /** The exit-1 bar: sync failed iff it did not converge. */
207
+ export function syncFailed(run: SyncRun): boolean {
208
+ return !run.converged;
209
+ }
210
+
211
+ // ---------------------------------------------------------------------------
212
+ // The CLI shell.
213
+ // ---------------------------------------------------------------------------
214
+
215
+ async function readSqlDirIfPresent(dir: string): Promise<SourceFile[] | null> {
216
+ let entries: string[];
217
+ try {
218
+ entries = await fs.readdir(dir);
219
+ } catch {
220
+ return null;
221
+ }
222
+ const files = entries.filter((f) => f.endsWith('.sql')).sort();
223
+ return Promise.all(files.map(async (file) => ({
224
+ file: path.join(dir, file),
225
+ sql: await fs.readFile(path.join(dir, file), 'utf-8'),
226
+ })));
227
+ }
228
+
229
+ export async function dbSyncCommand(flags: Record<string, string>): Promise<void> {
230
+ let dbSource: DbSource;
231
+ try {
232
+ dbSource = resolveDbSource(flags);
233
+ } catch (err: any) {
234
+ fail(err.message);
235
+ process.exit(1);
236
+ }
237
+ if (dbSource.kind !== 'url') {
238
+ fail('db:sync applies — it needs a direct connection (--database-url or DATABASE_URL). The ops Lambda query path is read-only by design; protected stages take the plan flow, not sync.');
239
+ process.exit(1);
240
+ }
241
+
242
+ const modelsPath = resolveModelsPath(flags.models);
243
+ const schemaOut = path.resolve(flags['schema-out'] || DEFAULT_SCHEMA_OUT);
244
+ let models: ModelDescriptor[];
245
+ try {
246
+ step(`Loading models from ${modelsPath}...`);
247
+ models = await loadModels(modelsPath);
248
+ info(`${models.length} model(s).`);
249
+ // Same contract as db:generate: the drizzle runtime schema is a derived
250
+ // artifact, refreshed from the models on every run.
251
+ step(`Writing the drizzle schema → ${path.relative(process.cwd(), schemaOut)}...`);
252
+ await fs.writeFile(schemaOut, compileDrizzleSource(models), 'utf8');
253
+ } catch (err: any) {
254
+ fail(err.message);
255
+ process.exit(1);
256
+ }
257
+
258
+ const sqlDir = flags['sql-dir'] || DEFAULT_SQL_DIR;
259
+ const sources = await readSqlDirIfPresent(sqlDir);
260
+
261
+ step(dbSource.from === 'flag' ? 'Connecting via --database-url...' : 'Connecting via DATABASE_URL...');
262
+ const { runner, end } = await createUrlRunner(dbSource.url);
263
+
264
+ try {
265
+ const run = await executeSync(
266
+ runner,
267
+ models,
268
+ sources,
269
+ {
270
+ allowDrops: flags['allow-drops'] === 'true',
271
+ overwriteDrift: flags['overwrite-drift'] === 'true',
272
+ baseline: flags.baseline === 'true',
273
+ actor: process.env.USER ?? null,
274
+ gitRef: currentGitRef(),
275
+ },
276
+ {
277
+ onStatePlan: (statements, classified) => {
278
+ if (classified.executable.length > 0) {
279
+ info(`state plan — ${classified.executable.length} statement(s):`);
280
+ console.log('');
281
+ for (const s of statements) console.log(s.endsWith(';') ? s : `${s};`);
282
+ console.log('');
283
+ step('Applying state as one transaction, then verifying by re-diff...');
284
+ }
285
+ },
286
+ onStateDone: () => {
287
+ if (sources !== null) step(`Reconciling the derived layer against ${sqlDir}...`);
288
+ },
289
+ },
290
+ );
291
+
292
+ if (flags.json === 'true') {
293
+ console.log(JSON.stringify({
294
+ state: {
295
+ applied: run.state.applied,
296
+ executed: run.state.classified.executable.length,
297
+ heldDrops: run.state.classified.heldDrops.length,
298
+ notices: run.state.classified.notices.length,
299
+ remaining: run.state.remaining,
300
+ fromFingerprint: run.state.fromFingerprint,
301
+ toFingerprint: run.state.toFingerprint,
302
+ },
303
+ compute: run.compute === null ? null : {
304
+ ...run.compute.plan,
305
+ applied: run.compute.applied,
306
+ refusal: run.compute.refusal ?? null,
307
+ },
308
+ declaredFingerprint: run.declaredFingerprint,
309
+ fingerprintMatch: run.fingerprintMatch,
310
+ unmodeled: run.unmodeled,
311
+ converged: run.converged,
312
+ }, null, 2));
313
+ } else {
314
+ for (const line of buildSyncReport(run)) info(line);
315
+ console.log('');
316
+ if (!run.converged) {
317
+ fail('NOT converged — fix the findings above and re-run db:sync.');
318
+ } else if (run.fingerprintMatch) {
319
+ success(`In sync at fingerprint ${short(run.state.toFingerprint)} — the database matches your checkout.`);
320
+ } else {
321
+ success('Synced — state and compute converged (the content-address difference is explained above).');
322
+ }
323
+ }
324
+
325
+ if (syncFailed(run)) process.exit(1);
326
+ } catch (err: any) {
327
+ fail(`Sync failed and rolled back: ${err.message}`);
328
+ process.exit(1);
329
+ } finally {
330
+ await end?.();
331
+ }
332
+ }
@@ -153,10 +153,18 @@ export interface SchemaLogEntry {
153
153
  gitRef?: string | null;
154
154
  planRef?: string | null;
155
155
  durationMs?: number;
156
+ fromFingerprint?: string | null;
157
+ toFingerprint?: string | null;
156
158
  }
157
159
 
158
160
  export function renderSchemaLogInsert(entry: SchemaLogEntry): string {
159
161
  const duration = entry.durationMs === undefined ? 'NULL' : String(Math.round(entry.durationMs));
160
- return `INSERT INTO everystack.schema_log (kind, actor, git_ref, plan_ref, sql, duration_ms, outcome)
161
- VALUES (${escapeLiteral(entry.kind)}, ${nullable(entry.actor)}, ${nullable(entry.gitRef)}, ${nullable(entry.planRef)}, ${escapeLiteral(entry.sql)}, ${duration}, ${escapeLiteral(entry.outcome)})`;
162
+ return `INSERT INTO everystack.schema_log (kind, actor, git_ref, plan_ref, from_fingerprint, to_fingerprint, sql, duration_ms, outcome)
163
+ VALUES (${escapeLiteral(entry.kind)}, ${nullable(entry.actor)}, ${nullable(entry.gitRef)}, ${nullable(entry.planRef)}, ${nullable(entry.fromFingerprint)}, ${nullable(entry.toFingerprint)}, ${escapeLiteral(entry.sql)}, ${duration}, ${escapeLiteral(entry.outcome)})
164
+ RETURNING id`;
165
+ }
166
+
167
+ /** Fill in the to-side fingerprint once post-apply introspection knows it. */
168
+ export function renderSchemaLogFingerprintUpdate(id: number, toFingerprint: string): string {
169
+ return `UPDATE everystack.schema_log SET to_fingerprint = ${escapeLiteral(toFingerprint)} WHERE id = ${Math.floor(id)}`;
162
170
  }
package/src/cli/index.ts CHANGED
@@ -12,6 +12,8 @@ import { dbAuthzPullCommand, dbAuthzDiffCommand, dbAuthzTestCommand, dbAuthzOwne
12
12
  import { dbGenerateCommand } from './commands/db-generate.js';
13
13
  import { dbPullCommand } from './commands/db-pull.js';
14
14
  import { dbReconcileCommand } from './commands/db-reconcile.js';
15
+ import { dbFingerprintCommand } from './commands/db-fingerprint.js';
16
+ import { dbSyncCommand } from './commands/db-sync.js';
15
17
  import { dbSnapshotCommand, dbSnapshotsCommand } from './commands/db-snapshot.js';
16
18
  import { dbBackupProbeCommand, dbBackupCommand, dbBackupsCommand, dbRestoreCommand, dbBackupDownloadCommand } from './commands/db-backup.js';
17
19
  import { consoleCommand } from './commands/console.js';
@@ -176,6 +178,12 @@ async function main() {
176
178
  case 'db:reconcile':
177
179
  await dbReconcileCommand(flags);
178
180
  break;
181
+ case 'db:fingerprint':
182
+ await dbFingerprintCommand(flags);
183
+ break;
184
+ case 'db:sync':
185
+ await dbSyncCommand(flags);
186
+ break;
179
187
  case 'db:authz:pull':
180
188
  await dbAuthzPullCommand(flags);
181
189
  break;
@@ -295,10 +303,12 @@ Usage:
295
303
  everystack db:backups [--stage <name>] List logical backups (id, size, created)
296
304
  everystack db:restore --from <id> [--stage <name>] --confirm Restore a backup INTO the stage's DB (DESTRUCTIVE)
297
305
  everystack db:backup:download <id> [--stage <name>] Presigned URL to download a backup's dump (valid 1h)
298
- everystack db:generate [--stage <name> | --database-url <url>] [--name <label>] [--models db/models/index.ts] [--allow-drops] [--apply] 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)
306
+ everystack db:generate [--stage <name> | --database-url <url>] [--name <label>] [--models db/models/index.ts] [--schema-out db/schema.generated.ts] [--allow-drops] [--apply] 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)
299
307
  everystack db:pull [--stage <name> | --database-url <url>] [--schema public] [--out <dir | file.ts>] 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
300
308
  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.
309
+ 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
301
310
  everystack db:reconcile [--sql-dir db/sql] [--stage <name> | --database-url <url>] [--apply] [--check] [--baseline] [--overwrite-drift] [--json] Reconcile the derived layer (functions/views/matviews) against db/sql sources: plan with rebuild-cost estimates by default; --check is the CI gate; --apply executes (direct connection only) and records provenance + schema_log. Hand-edits are drift (never overwritten silently); first contact with existing objects wants --baseline, once.
311
+ everystack db:sync [--database-url <url>] [--models <barrel>] [--sql-dir db/sql] [--schema-out <file.ts>] [--allow-drops] [--overwrite-drift] [--baseline] [--json] Make the database match your checkout — one verb, both layers: apply the state diff (tables+authz, one transaction, verified by re-diff), reconcile the derived layer against db/sql, report the resulting fingerprint vs the models' declared one. Dev databases only (direct connection required); DROPs held back unless --allow-drops; derived drift refuses unless --overwrite-drift; exit 1 when not converged
302
312
  everystack db:authz:pull [--stage <name>] [--dir authz] Introspect live authz (rls/grants/policies/secdef) → reviewable contract files
303
313
  everystack db:authz:diff [--stage <name>] [--dir authz] Validate the live DB against the committed contract (non-zero exit on drift)
304
314
  everystack db:authz:test [--stage <name>] [--dir authz] Red-team enforcement: SET ROLE + attempt per role/table/command (non-zero exit on a hole)
@@ -0,0 +1,243 @@
1
+ /**
2
+ * schema-fingerprint — the content address of the base schema (brick 2 of the
3
+ * migration-authority plan).
4
+ *
5
+ * A schema STATE is a fingerprint: sha256 over the canonical form of the data
6
+ * layer (tables, columns with pg-precise types, PK, uniques, checks, FKs,
7
+ * indexes, enums) AND the authorization layer (RLS flags, grants, column
8
+ * grants, policies) — the security posture is part of the state. The prize is
9
+ * an identity: `fingerprintState(introspected) === fingerprintModels(models)`
10
+ * exactly when `db:generate` is a no-op. The database's actual schema — not
11
+ * any bookkeeping — is the authority; the fingerprint is how it testifies.
12
+ *
13
+ * Normalization decisions (the canonical form is versioned; changing any of
14
+ * these bumps FINGERPRINT_VERSION so hashes never lie across formats):
15
+ *
16
+ * - CONTENT, NOT NAMES, for constraints and indexes: pg's default names
17
+ * (`_fkey`, `_key`) and model-generated names describe the same state, and
18
+ * the diff already matches by content. Constraint/index names are excluded.
19
+ * Policy names ARE included — the authz compiler emits them
20
+ * deterministically and they are the policy's identity in the contract.
21
+ * - Column ORDER is ignored (sorted by name): a table built by CREATE and
22
+ * the same table built by ALTER ADD COLUMN are the same state. Enum VALUE
23
+ * order is kept — it is semantic. PK / unique / index column order is kept
24
+ * — it is semantic.
25
+ * - The handler-side `columns` exposure block (hidden/protected) is excluded
26
+ * — it is not a database fact.
27
+ * - FUNCTIONS are excluded — they belong to the derived layer, whose own
28
+ * content hashes (derived-introspect) cover them. Base fingerprint + derived
29
+ * manifest together are the full schema state.
30
+ * - Deparse stability caveat, stated once: policy/check predicates hash as
31
+ * Postgres deparses them; a major-version deparser change can shift
32
+ * fingerprints. The version prefix plus the re-diff path keep that honest.
33
+ *
34
+ * Coverage is partial and SAYS SO: `UNFINGERPRINTED_SQL` enumerates what the
35
+ * base fingerprint does not see (standalone sequences, triggers, domains,
36
+ * composite types, foreign/partitioned tables, extensions) so partial
37
+ * coverage reads as a report, never as "covered everything".
38
+ */
39
+
40
+ import { createHash } from 'node:crypto';
41
+ import type { ModelDescriptor } from '@everystack/model';
42
+ import type { SchemaSnapshot, TableSchema } from './schema-introspect.js';
43
+ import type { AuthzContract, TableContract } from './authz-contract.js';
44
+ import { compileTableSchema, compileEnums } from './schema-compile.js';
45
+ import { compileTableContract } from './authz-compile.js';
46
+
47
+ export const FINGERPRINT_VERSION = 1;
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Canonical form.
51
+ // ---------------------------------------------------------------------------
52
+
53
+ /** JSON.stringify with recursively sorted object keys — key order can never matter. */
54
+ export function stableStringify(value: unknown): string {
55
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
56
+ if (value !== null && typeof value === 'object') {
57
+ const entries = Object.entries(value as Record<string, unknown>)
58
+ .filter(([, v]) => v !== undefined)
59
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
60
+ .map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`);
61
+ return `{${entries.join(',')}}`;
62
+ }
63
+ return JSON.stringify(value);
64
+ }
65
+
66
+ const byKey = <T>(items: T[], key: (item: T) => string): T[] =>
67
+ [...items].sort((a, b) => (key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0));
68
+
69
+ function canonicalTable(table: TableSchema): Record<string, unknown> {
70
+ return {
71
+ table: table.table,
72
+ columns: byKey(
73
+ table.columns.map((c) => ({ name: c.name, type: c.type, notNull: c.notNull, default: c.default })),
74
+ (c) => c.name,
75
+ ),
76
+ primaryKey: table.primaryKey,
77
+ // Content, not names: a pg-default `_key` and a model-named unique are the same state.
78
+ uniques: byKey(table.uniques.map((u) => ({ columns: u.columns })), (u) => u.columns.join(',')),
79
+ checks: byKey(table.checks.map((c) => ({ expr: c.expr })), (c) => c.expr),
80
+ foreignKeys: byKey(
81
+ table.foreignKeys.map((f) => ({
82
+ columns: f.columns, refTable: f.refTable, refColumns: f.refColumns,
83
+ ...(f.onDelete ? { onDelete: f.onDelete } : {}),
84
+ ...(f.onUpdate ? { onUpdate: f.onUpdate } : {}),
85
+ })),
86
+ (f) => stableStringify(f),
87
+ ),
88
+ indexes: byKey(
89
+ table.indexes.map((i) => ({ columns: i.columns, unique: i.unique, ...(i.where ? { where: i.where } : {}) })),
90
+ (i) => stableStringify(i),
91
+ ),
92
+ };
93
+ }
94
+
95
+ function canonicalAuthzTable(contract: TableContract): Record<string, unknown> {
96
+ return {
97
+ table: contract.table,
98
+ rls: { enabled: contract.rls.enabled, forced: contract.rls.forced },
99
+ grants: Object.fromEntries(
100
+ Object.entries(contract.grants)
101
+ .map(([role, privs]) => [role, [...privs].sort()] as const)
102
+ .sort(([a], [b]) => (a < b ? -1 : 1)),
103
+ ),
104
+ ...(contract.columnGrants && Object.keys(contract.columnGrants).length > 0
105
+ ? {
106
+ columnGrants: Object.fromEntries(
107
+ Object.entries(contract.columnGrants)
108
+ .map(([role, byPriv]) => [
109
+ role,
110
+ Object.fromEntries(
111
+ Object.entries(byPriv)
112
+ .map(([priv, cols]) => [priv, [...cols].sort()] as const)
113
+ .sort(([a], [b]) => (a < b ? -1 : 1)),
114
+ ),
115
+ ] as const)
116
+ .sort(([a], [b]) => (a < b ? -1 : 1)),
117
+ ),
118
+ }
119
+ : {}),
120
+ policies: byKey(
121
+ contract.policies.map((p) => ({
122
+ name: p.name, command: p.command, roles: [...p.roles].sort(),
123
+ permissive: p.permissive, using: p.using, check: p.check,
124
+ })),
125
+ (p) => p.name,
126
+ ),
127
+ // The handler-side columns exposure block is NOT a database fact — excluded.
128
+ };
129
+ }
130
+
131
+ export interface CanonicalState {
132
+ v: number;
133
+ tables: Array<Record<string, unknown>>;
134
+ enums: Array<{ name: string; values: string[] }>;
135
+ authz: Array<Record<string, unknown>>;
136
+ }
137
+
138
+ export function canonicalizeState(
139
+ snapshot: SchemaSnapshot,
140
+ authzTables: TableContract[],
141
+ ): CanonicalState {
142
+ return {
143
+ v: FINGERPRINT_VERSION,
144
+ tables: byKey(snapshot.tables.map(canonicalTable), (t) => String(t.table)),
145
+ enums: byKey(
146
+ (snapshot.enums ?? []).map((e) => ({ name: e.name, values: e.values })),
147
+ (e) => e.name,
148
+ ),
149
+ authz: byKey(authzTables.map(canonicalAuthzTable), (t) => String(t.table)),
150
+ };
151
+ }
152
+
153
+ export interface Fingerprint {
154
+ /** sha256 hex over the canonical form. */
155
+ hash: string;
156
+ /** The canonical form itself — for diff display and debugging, never for storage. */
157
+ canonical: CanonicalState;
158
+ }
159
+
160
+ export function fingerprintState(
161
+ snapshot: SchemaSnapshot,
162
+ authzTables: TableContract[],
163
+ ): Fingerprint {
164
+ const canonical = canonicalizeState(snapshot, authzTables);
165
+ return { hash: createHash('sha256').update(stableStringify(canonical)).digest('hex'), canonical };
166
+ }
167
+
168
+ /** The DECLARED state: compile the models to the same canonical shape and hash it. */
169
+ export function fingerprintModels(
170
+ models: ModelDescriptor[],
171
+ opts: { schema?: string } = {},
172
+ ): Fingerprint {
173
+ const snapshot: SchemaSnapshot = {
174
+ tables: models.map((m) => compileTableSchema(m, opts)),
175
+ enums: compileEnums(models),
176
+ };
177
+ const authzTables = models.map((m) => compileTableContract(m, opts));
178
+ return fingerprintState(snapshot, authzTables);
179
+ }
180
+
181
+ /** Convenience: the live side, from the two existing introspections. */
182
+ export function fingerprintLive(snapshot: SchemaSnapshot, contract: AuthzContract): Fingerprint {
183
+ return fingerprintState(snapshot, contract.tables);
184
+ }
185
+
186
+ // ---------------------------------------------------------------------------
187
+ // The honesty report: what the base fingerprint does NOT see.
188
+ // ---------------------------------------------------------------------------
189
+
190
+ /**
191
+ * Objects outside both the base fingerprint (this module) and the derived
192
+ * manifest (derived-introspect): standalone sequences (serial-owned ones are
193
+ * column defaults, already fingerprinted), triggers, domains, composite
194
+ * types, foreign tables, partitioned tables, and installed extensions.
195
+ */
196
+ export const UNFINGERPRINTED_SQL = `
197
+ SELECT 'sequence' AS kind, n.nspname || '.' || c.relname AS identity
198
+ FROM pg_class c
199
+ JOIN pg_namespace n ON n.oid = c.relnamespace
200
+ WHERE c.relkind = 'S'
201
+ AND n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg_%'
202
+ AND NOT EXISTS (
203
+ SELECT 1 FROM pg_depend d
204
+ WHERE d.objid = c.oid AND d.deptype IN ('a', 'i') AND d.refobjsubid > 0
205
+ )
206
+ UNION ALL
207
+ SELECT 'trigger', n.nspname || '.' || c.relname || '.' || t.tgname
208
+ FROM pg_trigger t
209
+ JOIN pg_class c ON c.oid = t.tgrelid
210
+ JOIN pg_namespace n ON n.oid = c.relnamespace
211
+ WHERE NOT t.tgisinternal
212
+ AND n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg_%'
213
+ UNION ALL
214
+ SELECT CASE ty.typtype WHEN 'd' THEN 'domain' ELSE 'composite type' END,
215
+ n.nspname || '.' || ty.typname
216
+ FROM pg_type ty
217
+ JOIN pg_namespace n ON n.oid = ty.typnamespace
218
+ LEFT JOIN pg_class tc ON tc.oid = ty.typrelid
219
+ WHERE (ty.typtype = 'd' OR (ty.typtype = 'c' AND tc.relkind = 'c'))
220
+ AND n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg_%'
221
+ AND NOT EXISTS (SELECT 1 FROM pg_depend d WHERE d.objid = ty.oid AND d.deptype = 'e')
222
+ UNION ALL
223
+ SELECT CASE c.relkind WHEN 'f' THEN 'foreign table' ELSE 'partitioned table' END,
224
+ n.nspname || '.' || c.relname
225
+ FROM pg_class c
226
+ JOIN pg_namespace n ON n.oid = c.relnamespace
227
+ WHERE c.relkind IN ('f', 'p')
228
+ AND n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg_%'
229
+ UNION ALL
230
+ SELECT 'extension', e.extname
231
+ FROM pg_extension e
232
+ WHERE e.extname <> 'plpgsql'
233
+ ORDER BY 1, 2;
234
+ `.trim();
235
+
236
+ export interface UnfingerprintedObject {
237
+ kind: string;
238
+ identity: string;
239
+ }
240
+
241
+ export function mapUnfingerprintedRows(rows: Array<{ kind: unknown; identity: unknown }>): UnfingerprintedObject[] {
242
+ return rows.map((r) => ({ kind: String(r.kind), identity: String(r.identity) }));
243
+ }
@@ -150,6 +150,16 @@ export function catalogFunctionToDescriptor(row: FunctionRow): FunctionDescripto
150
150
  };
151
151
  }
152
152
 
153
+ /**
154
+ * The FUNCTIONS_SQL row → authz-contract function shape, as one shared map —
155
+ * every `introspectContract(runner, contractFunctionRow, FUNCTIONS_SQL)` call
156
+ * site uses this instead of keeping its own copy.
157
+ */
158
+ export const contractFunctionRow = (row: any): { schema: string; name: string; securityDefiner: boolean; hasSearchPath: boolean; owner?: string; ownerBypassesRls?: boolean } => {
159
+ const d = catalogFunctionToDescriptor(row);
160
+ return { schema: d.schema, name: d.name, securityDefiner: d.securityDefiner, hasSearchPath: d.hasSearchPath, owner: d.owner, ownerBypassesRls: d.ownerBypassesRls };
161
+ };
162
+
153
163
  interface RelationRow {
154
164
  schema: string;
155
165
  name: string;
@@ -18,9 +18,13 @@
18
18
  */
19
19
 
20
20
  import { execSync } from 'node:child_process';
21
- import type { QueryRunner } from './authz-contract.js';
22
- import { HELD_DROP_PREFIX } from './migration-generate.js';
23
- import { ENSURE_RECONCILER_SQL, renderSchemaLogInsert } from './derived-apply.js';
21
+ import type { ModelDescriptor } from '@everystack/model';
22
+ import { introspectContract, type AuthzContract, type QueryRunner } from './authz-contract.js';
23
+ import { introspectSchema, type SchemaSnapshot } from './schema-introspect.js';
24
+ import { FUNCTIONS_SQL, contractFunctionRow } from './security-catalog.js';
25
+ import { generateMigrationSql, HELD_DROP_PREFIX } from './migration-generate.js';
26
+ import { fingerprintLive } from './schema-fingerprint.js';
27
+ import { ENSURE_RECONCILER_SQL, renderSchemaLogInsert, renderSchemaLogFingerprintUpdate } from './derived-apply.js';
24
28
 
25
29
  const NOTICE_PREFIX = '-- NOTICE';
26
30
 
@@ -65,6 +69,8 @@ export interface StateApplyOptions {
65
69
  actor?: string | null;
66
70
  gitRef?: string | null;
67
71
  planRef?: string | null;
72
+ /** The live base fingerprint before this apply (schema_log's from-side). */
73
+ fromFingerprint?: string | null;
68
74
  /** Injectable clock for tests. */
69
75
  now?: () => number;
70
76
  }
@@ -75,6 +81,8 @@ export interface StateApplyResult {
75
81
  classified: ClassifiedStatements;
76
82
  /** The batch that was sent (empty when nothing was executable). */
77
83
  batchSql: string;
84
+ /** schema_log row id for this apply, when one was written. */
85
+ logId?: number;
78
86
  }
79
87
 
80
88
  /**
@@ -107,17 +115,96 @@ export async function applyGeneratedStatements(
107
115
  kind: 'state apply', sql: batchSql,
108
116
  outcome: `failed: ${String(err?.message ?? err).slice(0, 500)}`,
109
117
  actor: options.actor, gitRef: options.gitRef, planRef: options.planRef,
118
+ fromFingerprint: options.fromFingerprint,
110
119
  durationMs: now() - started,
111
120
  }));
112
121
  } catch { /* the memoir is best-effort on failure */ }
113
122
  throw err;
114
123
  }
115
124
 
116
- await runner(renderSchemaLogInsert({
125
+ const logRows = await runner(renderSchemaLogInsert({
117
126
  kind: 'state apply', sql: batchSql, outcome: 'applied',
118
127
  actor: options.actor, gitRef: options.gitRef, planRef: options.planRef,
128
+ fromFingerprint: options.fromFingerprint,
119
129
  durationMs: now() - started,
120
130
  }));
131
+ const logId = logRows?.[0]?.id !== undefined ? Number(logRows[0].id) : undefined;
121
132
 
122
- return { applied: true, classified, batchSql };
133
+ return { applied: true, classified, batchSql, ...(logId !== undefined ? { logId } : {}) };
134
+ }
135
+
136
+ // ---------------------------------------------------------------------------
137
+ // Apply-and-verify: the full state edge, fingerprinted at both ends.
138
+ // ---------------------------------------------------------------------------
139
+
140
+ export interface StateSyncOptions extends StateApplyOptions {
141
+ /** Passed through to the verifying re-diff so held-vs-real matches the plan. */
142
+ allowDrops?: boolean;
143
+ }
144
+
145
+ export interface StateSyncOutcome {
146
+ /** The generated diff, exactly as planned (executable + held + notices). */
147
+ statements: string[];
148
+ classified: ClassifiedStatements;
149
+ /** True when DDL actually executed. */
150
+ applied: boolean;
151
+ /** The live base fingerprint before the apply. */
152
+ fromFingerprint: string;
153
+ /** The live base fingerprint after (== from when nothing was executable). */
154
+ toFingerprint: string;
155
+ /** Executable statements still differing after apply — empty is the bar. */
156
+ remaining: string[];
157
+ /** schema_log row id, when DDL ran. */
158
+ logId?: number;
159
+ /** Fresh post-apply introspection (the pre-apply one when nothing ran). */
160
+ after: { snapshot: SchemaSnapshot; contract: AuthzContract };
161
+ }
162
+
163
+ /**
164
+ * The state layer's whole move, as one core shared by `db:generate --apply`
165
+ * and `db:sync`: fingerprint the live base state, execute the executable
166
+ * subset of an already-generated diff as one transaction, re-introspect,
167
+ * stamp the schema_log row with both fingerprint endpoints, and VERIFY by
168
+ * re-diff — done means db:generate is a no-op again, not "statements ran".
169
+ * With nothing executable it applies nothing and both fingerprints are the
170
+ * same hash. A failed batch rolls back, is logged best-effort, and rethrows.
171
+ */
172
+ export async function applyStateAndVerify(
173
+ runner: QueryRunner,
174
+ models: ModelDescriptor[],
175
+ statements: string[],
176
+ current: SchemaSnapshot,
177
+ liveAuthz: AuthzContract,
178
+ options: StateSyncOptions = {},
179
+ ): Promise<StateSyncOutcome> {
180
+ const classified = classifyGeneratedStatements(statements);
181
+ const fromFingerprint = fingerprintLive(current, liveAuthz).hash;
182
+
183
+ if (classified.executable.length === 0) {
184
+ return {
185
+ statements, classified, applied: false,
186
+ fromFingerprint, toFingerprint: fromFingerprint, remaining: [],
187
+ after: { snapshot: current, contract: liveAuthz },
188
+ };
189
+ }
190
+
191
+ const result = await applyGeneratedStatements(runner, statements, { ...options, fromFingerprint });
192
+
193
+ const snapshot = await introspectSchema(runner);
194
+ const contract = await introspectContract(runner, contractFunctionRow, FUNCTIONS_SQL);
195
+ const toFingerprint = fingerprintLive(snapshot, contract).hash;
196
+ if (result.logId !== undefined) {
197
+ await runner(renderSchemaLogFingerprintUpdate(result.logId, toFingerprint));
198
+ }
199
+
200
+ const remaining = classifyGeneratedStatements(
201
+ generateMigrationSql(models, snapshot, { allowDrops: options.allowDrops, liveAuthz: contract }),
202
+ ).executable;
203
+
204
+ return {
205
+ statements, classified, applied: true,
206
+ fromFingerprint, toFingerprint, remaining,
207
+ ...(result.logId !== undefined ? { logId: result.logId } : {}),
208
+ after: { snapshot, contract },
209
+ };
123
210
  }