@everystack/cli 0.3.15 → 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 +2 -0
- package/package.json +1 -1
- package/src/cli/authz-contract.ts +4 -1
- package/src/cli/commands/db-authz.ts +2 -11
- package/src/cli/commands/db-fingerprint.ts +116 -0
- package/src/cli/commands/db-generate.ts +49 -12
- package/src/cli/commands/db-reconcile.ts +1 -9
- package/src/cli/commands/db-sync.ts +332 -0
- package/src/cli/derived-apply.ts +10 -2
- package/src/cli/index.ts +11 -1
- package/src/cli/schema-fingerprint.ts +243 -0
- package/src/cli/security-catalog.ts +10 -0
- package/src/cli/state-apply.ts +210 -0
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
|
@@ -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,
|
|
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,
|
|
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,9 +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,
|
|
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 { applyStateAndVerify, classifyGeneratedStatements, currentGitRef, type StateSyncOutcome } from '../state-apply.js';
|
|
32
33
|
import { resolveConfig, opsFunction } from '../config.js';
|
|
33
34
|
import { invokeAction } from '../aws.js';
|
|
34
35
|
import { step, success, fail, info, warn } from '../output.js';
|
|
@@ -45,14 +46,8 @@ function lambdaRunner(region: string, fn: string): QueryRunner {
|
|
|
45
46
|
};
|
|
46
47
|
}
|
|
47
48
|
|
|
48
|
-
/** Map a FUNCTIONS_SQL row to the minimal shape the authz contract records. */
|
|
49
|
-
const mapFunctionRow = (row: any) => {
|
|
50
|
-
const d = catalogFunctionToDescriptor(row);
|
|
51
|
-
return { schema: d.schema, name: d.name, securityDefiner: d.securityDefiner, hasSearchPath: d.hasSearchPath, owner: d.owner, ownerBypassesRls: d.ownerBypassesRls };
|
|
52
|
-
};
|
|
53
|
-
|
|
54
49
|
/** Import the app's Model barrel and return its `models` array (runs under tsx, so TS imports work). */
|
|
55
|
-
async function loadModels(modelsPath: string): Promise<ModelDescriptor[]> {
|
|
50
|
+
export async function loadModels(modelsPath: string): Promise<ModelDescriptor[]> {
|
|
56
51
|
const abs = path.resolve(modelsPath);
|
|
57
52
|
let mod: any;
|
|
58
53
|
try {
|
|
@@ -141,9 +136,12 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
|
|
|
141
136
|
return;
|
|
142
137
|
}
|
|
143
138
|
|
|
139
|
+
const apply = flags.apply === 'true';
|
|
144
140
|
let models: ModelDescriptor[];
|
|
145
141
|
let current;
|
|
146
142
|
let liveAuthz;
|
|
143
|
+
let runner!: QueryRunner;
|
|
144
|
+
let end: (() => Promise<void>) | undefined;
|
|
147
145
|
try {
|
|
148
146
|
step(`Loading models from ${modelsPath}...`);
|
|
149
147
|
models = await loadModels(modelsPath);
|
|
@@ -154,8 +152,10 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
|
|
|
154
152
|
step(`Writing the drizzle schema → ${path.relative(process.cwd(), schemaOut)}...`);
|
|
155
153
|
await fs.writeFile(schemaOut, compileDrizzleSource(models), 'utf8');
|
|
156
154
|
const dbSource: DbSource = resolveDbSource(flags);
|
|
157
|
-
|
|
158
|
-
|
|
155
|
+
if (apply && dbSource.kind !== 'url') {
|
|
156
|
+
fail('--apply needs a direct connection (--database-url or DATABASE_URL) — the ops Lambda query path is read-only by design.');
|
|
157
|
+
process.exit(1);
|
|
158
|
+
}
|
|
159
159
|
if (dbSource.kind === 'url') {
|
|
160
160
|
step(dbSource.from === 'flag' ? 'Connecting via --database-url...' : 'Connecting via DATABASE_URL...');
|
|
161
161
|
({ runner, end } = await createUrlRunner(dbSource.url));
|
|
@@ -168,8 +168,8 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
|
|
|
168
168
|
step('Introspecting live database (columns + constraints)...');
|
|
169
169
|
current = await introspectSchema(runner);
|
|
170
170
|
step('Introspecting authorization (rls + grants + policies)...');
|
|
171
|
-
liveAuthz = await introspectContract(runner,
|
|
172
|
-
await end?.();
|
|
171
|
+
liveAuthz = await introspectContract(runner, contractFunctionRow, FUNCTIONS_SQL);
|
|
172
|
+
if (!apply) await end?.();
|
|
173
173
|
} catch (err: any) {
|
|
174
174
|
fail(err.message);
|
|
175
175
|
info('Ensure your IAM user/role has lambda:InvokeFunction and the stage is deployed.');
|
|
@@ -190,6 +190,43 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
|
|
|
190
190
|
process.exit(0);
|
|
191
191
|
}
|
|
192
192
|
|
|
193
|
+
// --apply: no tape. Print the plan, execute the executable subset as one
|
|
194
|
+
// transaction, record it in schema_log, then VERIFY by re-diffing — done
|
|
195
|
+
// means db:generate is a no-op again, not "a file was written".
|
|
196
|
+
if (apply) {
|
|
197
|
+
const classified = classifyGeneratedStatements(statements);
|
|
198
|
+
info(`Plan — ${statements.length} statement(s):`);
|
|
199
|
+
console.log('');
|
|
200
|
+
for (const s of statements) console.log(s.endsWith(';') ? s : `${s};`);
|
|
201
|
+
console.log('');
|
|
202
|
+
if (classified.heldDrops.length) warn(`${classified.heldDrops.length} destructive change(s) held back — NOT executed (re-run with --allow-drops to include them).`);
|
|
203
|
+
if (classified.notices.length) info(`${classified.notices.length} notice(s) — manual follow-ups, nothing executed for them.`);
|
|
204
|
+
if (classified.executable.length === 0) {
|
|
205
|
+
success('Nothing executable — only held/notice items differ.');
|
|
206
|
+
process.exit(0);
|
|
207
|
+
}
|
|
208
|
+
step(`Applying ${classified.executable.length} statement(s) as one transaction, then verifying by re-diff...`);
|
|
209
|
+
let outcome: StateSyncOutcome;
|
|
210
|
+
try {
|
|
211
|
+
outcome = await applyStateAndVerify(runner, models, statements, current, liveAuthz, {
|
|
212
|
+
allowDrops, actor: process.env.USER ?? null, gitRef: currentGitRef(),
|
|
213
|
+
});
|
|
214
|
+
} catch (err: any) {
|
|
215
|
+
fail(`Apply failed and rolled back: ${err.message}`);
|
|
216
|
+
process.exit(1);
|
|
217
|
+
}
|
|
218
|
+
await end?.();
|
|
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);
|
|
222
|
+
process.exit(1);
|
|
223
|
+
}
|
|
224
|
+
const heldNote = classified.heldDrops.length ? ` (${classified.heldDrops.length} held drop(s) remain, by design)` : '';
|
|
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)}`);
|
|
227
|
+
process.exit(0);
|
|
228
|
+
}
|
|
229
|
+
|
|
193
230
|
const journalPath = path.join(migrationsDir, 'meta', '_journal.json');
|
|
194
231
|
let journal: Journal;
|
|
195
232
|
try {
|
|
@@ -24,7 +24,6 @@
|
|
|
24
24
|
|
|
25
25
|
import fs from 'node:fs/promises';
|
|
26
26
|
import path from 'node:path';
|
|
27
|
-
import { execSync } from 'node:child_process';
|
|
28
27
|
import type { QueryRunner } from '../authz-contract.js';
|
|
29
28
|
import { parseSqlSources, type SourceFile } from '../derived-source.js';
|
|
30
29
|
import { introspectDerived } from '../derived-introspect.js';
|
|
@@ -37,6 +36,7 @@ import {
|
|
|
37
36
|
ENSURE_RECONCILER_SQL,
|
|
38
37
|
} from '../derived-apply.js';
|
|
39
38
|
import { resolveDbSource, createUrlRunner, type DbSource } from '../db-source.js';
|
|
39
|
+
import { currentGitRef } from '../state-apply.js';
|
|
40
40
|
import { resolveConfig, opsFunction } from '../config.js';
|
|
41
41
|
import { invokeAction } from '../aws.js';
|
|
42
42
|
import { step, success, fail, info, warn } from '../output.js';
|
|
@@ -223,14 +223,6 @@ async function readSqlDir(dir: string): Promise<SourceFile[]> {
|
|
|
223
223
|
})));
|
|
224
224
|
}
|
|
225
225
|
|
|
226
|
-
function currentGitRef(): string | null {
|
|
227
|
-
try {
|
|
228
|
-
return execSync('git rev-parse HEAD', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
|
|
229
|
-
} catch {
|
|
230
|
-
return null;
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
|
|
234
226
|
export async function dbReconcileCommand(flags: Record<string, string>): Promise<void> {
|
|
235
227
|
const sqlDir = flags['sql-dir'] || DEFAULT_SQL_DIR;
|
|
236
228
|
const apply = flags.apply === 'true';
|
|
@@ -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
|
+
}
|
package/src/cli/derived-apply.ts
CHANGED
|
@@ -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] Diff models vs the live DB →
|
|
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;
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* state-apply — apply db:generate's live-computed diff directly, no tape.
|
|
3
|
+
*
|
|
4
|
+
* The state layer's analog of the derived reconciler's apply path: the diff
|
|
5
|
+
* is computed fresh against the live catalog (it cannot be stale, cannot be
|
|
6
|
+
* skipped, and has nothing numbered to collide), executed as ONE batch (one
|
|
7
|
+
* implicit transaction), and recorded in `everystack.schema_log` with the
|
|
8
|
+
* full SQL text. Held drops and notices are comments by construction and are
|
|
9
|
+
* never executed — the --allow-drops posture decides what is real DDL long
|
|
10
|
+
* before this module sees it.
|
|
11
|
+
*
|
|
12
|
+
* This retires the drizzle folder as the transport for table changes. The
|
|
13
|
+
* review discipline the tape file used to provide moves to the printed plan:
|
|
14
|
+
* the command shows every statement (including what it is NOT running)
|
|
15
|
+
* before anything executes. Fingerprint pinning (the edge runner) later
|
|
16
|
+
* upgrades "computed against the database just now" to "verified against a
|
|
17
|
+
* named state at both ends" — but nothing here needs a folder.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { execSync } from 'node:child_process';
|
|
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';
|
|
28
|
+
|
|
29
|
+
const NOTICE_PREFIX = '-- NOTICE';
|
|
30
|
+
|
|
31
|
+
/** Every line is a comment (or blank) — nothing executable inside. */
|
|
32
|
+
function isPureComment(statement: string): boolean {
|
|
33
|
+
return statement
|
|
34
|
+
.split('\n')
|
|
35
|
+
.every((line) => line.trim() === '' || line.trim().startsWith('--'));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ClassifiedStatements {
|
|
39
|
+
/** Real DDL (possibly carrying leading -- WARNING lines) — what apply runs. */
|
|
40
|
+
executable: string[];
|
|
41
|
+
/** Destructive changes held back as comments (re-run with --allow-drops). */
|
|
42
|
+
heldDrops: string[];
|
|
43
|
+
/** Manual follow-ups / rename notices — comments, never executed. */
|
|
44
|
+
notices: string[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function classifyGeneratedStatements(statements: string[]): ClassifiedStatements {
|
|
48
|
+
const executable: string[] = [];
|
|
49
|
+
const heldDrops: string[] = [];
|
|
50
|
+
const notices: string[] = [];
|
|
51
|
+
for (const statement of statements) {
|
|
52
|
+
if (statement.startsWith(HELD_DROP_PREFIX)) heldDrops.push(statement);
|
|
53
|
+
else if (statement.startsWith(NOTICE_PREFIX) || isPureComment(statement)) notices.push(statement);
|
|
54
|
+
else executable.push(statement);
|
|
55
|
+
}
|
|
56
|
+
return { executable, heldDrops, notices };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The current git HEAD, for schema_log's intent pointer. Null outside a repo. */
|
|
60
|
+
export function currentGitRef(): string | null {
|
|
61
|
+
try {
|
|
62
|
+
return execSync('git rev-parse HEAD', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface StateApplyOptions {
|
|
69
|
+
actor?: string | null;
|
|
70
|
+
gitRef?: string | null;
|
|
71
|
+
planRef?: string | null;
|
|
72
|
+
/** The live base fingerprint before this apply (schema_log's from-side). */
|
|
73
|
+
fromFingerprint?: string | null;
|
|
74
|
+
/** Injectable clock for tests. */
|
|
75
|
+
now?: () => number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface StateApplyResult {
|
|
79
|
+
/** True when DDL actually executed. */
|
|
80
|
+
applied: boolean;
|
|
81
|
+
classified: ClassifiedStatements;
|
|
82
|
+
/** The batch that was sent (empty when nothing was executable). */
|
|
83
|
+
batchSql: string;
|
|
84
|
+
/** schema_log row id for this apply, when one was written. */
|
|
85
|
+
logId?: number;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Execute the executable subset of a generated diff as one batch and record
|
|
90
|
+
* it in schema_log. A failed batch rolls back (one implicit transaction),
|
|
91
|
+
* logs the failure best-effort, and rethrows.
|
|
92
|
+
*/
|
|
93
|
+
export async function applyGeneratedStatements(
|
|
94
|
+
runner: QueryRunner,
|
|
95
|
+
statements: string[],
|
|
96
|
+
options: StateApplyOptions = {},
|
|
97
|
+
): Promise<StateApplyResult> {
|
|
98
|
+
const classified = classifyGeneratedStatements(statements);
|
|
99
|
+
if (classified.executable.length === 0) {
|
|
100
|
+
return { applied: false, classified, batchSql: '' };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const now = options.now ?? Date.now;
|
|
104
|
+
await runner(ENSURE_RECONCILER_SQL.join(';\n'));
|
|
105
|
+
|
|
106
|
+
// Semicolon on its own line: a statement whose last line is a comment
|
|
107
|
+
// cannot swallow the separator.
|
|
108
|
+
const batchSql = classified.executable.join('\n;\n');
|
|
109
|
+
const started = now();
|
|
110
|
+
try {
|
|
111
|
+
await runner(batchSql);
|
|
112
|
+
} catch (err: any) {
|
|
113
|
+
try {
|
|
114
|
+
await runner(renderSchemaLogInsert({
|
|
115
|
+
kind: 'state apply', sql: batchSql,
|
|
116
|
+
outcome: `failed: ${String(err?.message ?? err).slice(0, 500)}`,
|
|
117
|
+
actor: options.actor, gitRef: options.gitRef, planRef: options.planRef,
|
|
118
|
+
fromFingerprint: options.fromFingerprint,
|
|
119
|
+
durationMs: now() - started,
|
|
120
|
+
}));
|
|
121
|
+
} catch { /* the memoir is best-effort on failure */ }
|
|
122
|
+
throw err;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const logRows = await runner(renderSchemaLogInsert({
|
|
126
|
+
kind: 'state apply', sql: batchSql, outcome: 'applied',
|
|
127
|
+
actor: options.actor, gitRef: options.gitRef, planRef: options.planRef,
|
|
128
|
+
fromFingerprint: options.fromFingerprint,
|
|
129
|
+
durationMs: now() - started,
|
|
130
|
+
}));
|
|
131
|
+
const logId = logRows?.[0]?.id !== undefined ? Number(logRows[0].id) : undefined;
|
|
132
|
+
|
|
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
|
+
};
|
|
210
|
+
}
|