@everystack/cli 0.3.15 → 0.3.16
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/package.json
CHANGED
|
@@ -29,6 +29,7 @@ import { introspectContract, type QueryRunner } from '../authz-contract.js';
|
|
|
29
29
|
import { FUNCTIONS_SQL, catalogFunctionToDescriptor } 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
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';
|
|
@@ -141,9 +142,12 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
|
|
|
141
142
|
return;
|
|
142
143
|
}
|
|
143
144
|
|
|
145
|
+
const apply = flags.apply === 'true';
|
|
144
146
|
let models: ModelDescriptor[];
|
|
145
147
|
let current;
|
|
146
148
|
let liveAuthz;
|
|
149
|
+
let runner!: QueryRunner;
|
|
150
|
+
let end: (() => Promise<void>) | undefined;
|
|
147
151
|
try {
|
|
148
152
|
step(`Loading models from ${modelsPath}...`);
|
|
149
153
|
models = await loadModels(modelsPath);
|
|
@@ -154,8 +158,10 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
|
|
|
154
158
|
step(`Writing the drizzle schema → ${path.relative(process.cwd(), schemaOut)}...`);
|
|
155
159
|
await fs.writeFile(schemaOut, compileDrizzleSource(models), 'utf8');
|
|
156
160
|
const dbSource: DbSource = resolveDbSource(flags);
|
|
157
|
-
|
|
158
|
-
|
|
161
|
+
if (apply && dbSource.kind !== 'url') {
|
|
162
|
+
fail('--apply needs a direct connection (--database-url or DATABASE_URL) — the ops Lambda query path is read-only by design.');
|
|
163
|
+
process.exit(1);
|
|
164
|
+
}
|
|
159
165
|
if (dbSource.kind === 'url') {
|
|
160
166
|
step(dbSource.from === 'flag' ? 'Connecting via --database-url...' : 'Connecting via DATABASE_URL...');
|
|
161
167
|
({ runner, end } = await createUrlRunner(dbSource.url));
|
|
@@ -169,7 +175,7 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
|
|
|
169
175
|
current = await introspectSchema(runner);
|
|
170
176
|
step('Introspecting authorization (rls + grants + policies)...');
|
|
171
177
|
liveAuthz = await introspectContract(runner, mapFunctionRow, FUNCTIONS_SQL);
|
|
172
|
-
await end?.();
|
|
178
|
+
if (!apply) await end?.();
|
|
173
179
|
} catch (err: any) {
|
|
174
180
|
fail(err.message);
|
|
175
181
|
info('Ensure your IAM user/role has lambda:InvokeFunction and the stage is deployed.');
|
|
@@ -190,6 +196,45 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
|
|
|
190
196
|
process.exit(0);
|
|
191
197
|
}
|
|
192
198
|
|
|
199
|
+
// --apply: no tape. Print the plan, execute the executable subset as one
|
|
200
|
+
// transaction, record it in schema_log, then VERIFY by re-diffing — done
|
|
201
|
+
// means db:generate is a no-op again, not "a file was written".
|
|
202
|
+
if (apply) {
|
|
203
|
+
const classified = classifyGeneratedStatements(statements);
|
|
204
|
+
info(`Plan — ${statements.length} statement(s):`);
|
|
205
|
+
console.log('');
|
|
206
|
+
for (const s of statements) console.log(s.endsWith(';') ? s : `${s};`);
|
|
207
|
+
console.log('');
|
|
208
|
+
if (classified.heldDrops.length) warn(`${classified.heldDrops.length} destructive change(s) held back — NOT executed (re-run with --allow-drops to include them).`);
|
|
209
|
+
if (classified.notices.length) info(`${classified.notices.length} notice(s) — manual follow-ups, nothing executed for them.`);
|
|
210
|
+
if (classified.executable.length === 0) {
|
|
211
|
+
success('Nothing executable — only held/notice items differ.');
|
|
212
|
+
process.exit(0);
|
|
213
|
+
}
|
|
214
|
+
step(`Applying ${classified.executable.length} statement(s) as one transaction...`);
|
|
215
|
+
try {
|
|
216
|
+
await applyGeneratedStatements(runner, statements, { actor: process.env.USER ?? null, gitRef: currentGitRef() });
|
|
217
|
+
} catch (err: any) {
|
|
218
|
+
fail(`Apply failed and rolled back: ${err.message}`);
|
|
219
|
+
process.exit(1);
|
|
220
|
+
}
|
|
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
|
+
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);
|
|
231
|
+
process.exit(1);
|
|
232
|
+
}
|
|
233
|
+
const heldNote = classified.heldDrops.length ? ` (${classified.heldDrops.length} held drop(s) remain, by design)` : '';
|
|
234
|
+
success(`Applied and verified — db:generate is a no-op again${heldNote}. Recorded in everystack.schema_log.`);
|
|
235
|
+
process.exit(0);
|
|
236
|
+
}
|
|
237
|
+
|
|
193
238
|
const journalPath = path.join(migrationsDir, 'meta', '_journal.json');
|
|
194
239
|
let journal: Journal;
|
|
195
240
|
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';
|
package/src/cli/index.ts
CHANGED
|
@@ -295,7 +295,7 @@ Usage:
|
|
|
295
295
|
everystack db:backups [--stage <name>] List logical backups (id, size, created)
|
|
296
296
|
everystack db:restore --from <id> [--stage <name>] --confirm Restore a backup INTO the stage's DB (DESTRUCTIVE)
|
|
297
297
|
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 →
|
|
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)
|
|
299
299
|
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
300
|
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.
|
|
301
301
|
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.
|
|
@@ -0,0 +1,123 @@
|
|
|
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 { QueryRunner } from './authz-contract.js';
|
|
22
|
+
import { HELD_DROP_PREFIX } from './migration-generate.js';
|
|
23
|
+
import { ENSURE_RECONCILER_SQL, renderSchemaLogInsert } from './derived-apply.js';
|
|
24
|
+
|
|
25
|
+
const NOTICE_PREFIX = '-- NOTICE';
|
|
26
|
+
|
|
27
|
+
/** Every line is a comment (or blank) — nothing executable inside. */
|
|
28
|
+
function isPureComment(statement: string): boolean {
|
|
29
|
+
return statement
|
|
30
|
+
.split('\n')
|
|
31
|
+
.every((line) => line.trim() === '' || line.trim().startsWith('--'));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ClassifiedStatements {
|
|
35
|
+
/** Real DDL (possibly carrying leading -- WARNING lines) — what apply runs. */
|
|
36
|
+
executable: string[];
|
|
37
|
+
/** Destructive changes held back as comments (re-run with --allow-drops). */
|
|
38
|
+
heldDrops: string[];
|
|
39
|
+
/** Manual follow-ups / rename notices — comments, never executed. */
|
|
40
|
+
notices: string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function classifyGeneratedStatements(statements: string[]): ClassifiedStatements {
|
|
44
|
+
const executable: string[] = [];
|
|
45
|
+
const heldDrops: string[] = [];
|
|
46
|
+
const notices: string[] = [];
|
|
47
|
+
for (const statement of statements) {
|
|
48
|
+
if (statement.startsWith(HELD_DROP_PREFIX)) heldDrops.push(statement);
|
|
49
|
+
else if (statement.startsWith(NOTICE_PREFIX) || isPureComment(statement)) notices.push(statement);
|
|
50
|
+
else executable.push(statement);
|
|
51
|
+
}
|
|
52
|
+
return { executable, heldDrops, notices };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The current git HEAD, for schema_log's intent pointer. Null outside a repo. */
|
|
56
|
+
export function currentGitRef(): string | null {
|
|
57
|
+
try {
|
|
58
|
+
return execSync('git rev-parse HEAD', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface StateApplyOptions {
|
|
65
|
+
actor?: string | null;
|
|
66
|
+
gitRef?: string | null;
|
|
67
|
+
planRef?: string | null;
|
|
68
|
+
/** Injectable clock for tests. */
|
|
69
|
+
now?: () => number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface StateApplyResult {
|
|
73
|
+
/** True when DDL actually executed. */
|
|
74
|
+
applied: boolean;
|
|
75
|
+
classified: ClassifiedStatements;
|
|
76
|
+
/** The batch that was sent (empty when nothing was executable). */
|
|
77
|
+
batchSql: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Execute the executable subset of a generated diff as one batch and record
|
|
82
|
+
* it in schema_log. A failed batch rolls back (one implicit transaction),
|
|
83
|
+
* logs the failure best-effort, and rethrows.
|
|
84
|
+
*/
|
|
85
|
+
export async function applyGeneratedStatements(
|
|
86
|
+
runner: QueryRunner,
|
|
87
|
+
statements: string[],
|
|
88
|
+
options: StateApplyOptions = {},
|
|
89
|
+
): Promise<StateApplyResult> {
|
|
90
|
+
const classified = classifyGeneratedStatements(statements);
|
|
91
|
+
if (classified.executable.length === 0) {
|
|
92
|
+
return { applied: false, classified, batchSql: '' };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const now = options.now ?? Date.now;
|
|
96
|
+
await runner(ENSURE_RECONCILER_SQL.join(';\n'));
|
|
97
|
+
|
|
98
|
+
// Semicolon on its own line: a statement whose last line is a comment
|
|
99
|
+
// cannot swallow the separator.
|
|
100
|
+
const batchSql = classified.executable.join('\n;\n');
|
|
101
|
+
const started = now();
|
|
102
|
+
try {
|
|
103
|
+
await runner(batchSql);
|
|
104
|
+
} catch (err: any) {
|
|
105
|
+
try {
|
|
106
|
+
await runner(renderSchemaLogInsert({
|
|
107
|
+
kind: 'state apply', sql: batchSql,
|
|
108
|
+
outcome: `failed: ${String(err?.message ?? err).slice(0, 500)}`,
|
|
109
|
+
actor: options.actor, gitRef: options.gitRef, planRef: options.planRef,
|
|
110
|
+
durationMs: now() - started,
|
|
111
|
+
}));
|
|
112
|
+
} catch { /* the memoir is best-effort on failure */ }
|
|
113
|
+
throw err;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
await runner(renderSchemaLogInsert({
|
|
117
|
+
kind: 'state apply', sql: batchSql, outcome: 'applied',
|
|
118
|
+
actor: options.actor, gitRef: options.gitRef, planRef: options.planRef,
|
|
119
|
+
durationMs: now() - started,
|
|
120
|
+
}));
|
|
121
|
+
|
|
122
|
+
return { applied: true, classified, batchSql };
|
|
123
|
+
}
|