@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.3.15",
3
+ "version": "0.3.16",
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>",
@@ -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
- let runner: QueryRunner;
158
- let end: (() => Promise<void>) | undefined;
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 → write the next migration (never touches the DB; DROPs held back unless --allow-drops)
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
+ }