@everystack/cli 0.3.14 → 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.14",
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>",
@@ -44,6 +44,7 @@ import { writeContract, loadContract } from '../authz-contract-io.js';
44
44
  import { renderContractMarkdown } from '../authz-render.js';
45
45
  import { FUNCTIONS_SQL, catalogFunctionToDescriptor } from '../security-catalog.js';
46
46
  import { resolveConfig, opsFunction } from '../config.js';
47
+ import { resolveModelsPath } from '../models-path.js';
47
48
  import { invokeAction } from '../aws.js';
48
49
  import { step, success, fail, info, warn } from '../output.js';
49
50
 
@@ -239,7 +240,7 @@ async function loadModels(modelsPath: string): Promise<ModelDescriptor[]> {
239
240
  * exit on any leak or vacuous policy; an `unprobed` table (one identity) is a warning, not a fail.
240
241
  */
241
242
  export async function dbAuthzOwnerCommand(flags: Record<string, string>): Promise<void> {
242
- const modelsPath = flags.models || 'models/index.ts';
243
+ const modelsPath = resolveModelsPath(flags.models);
243
244
  const schema = flags.schema || 'public';
244
245
 
245
246
  let probes: OwnerProbe[];
@@ -28,11 +28,12 @@ import { generateMigrationSql, unmodeledTables, formatMigrationFile, planMigrati
28
28
  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
+ import { resolveModelsPath } from '../models-path.js';
32
+ import { applyGeneratedStatements, classifyGeneratedStatements, currentGitRef } from '../state-apply.js';
31
33
  import { resolveConfig, opsFunction } from '../config.js';
32
34
  import { invokeAction } from '../aws.js';
33
35
  import { step, success, fail, info, warn } from '../output.js';
34
36
 
35
- const DEFAULT_MODELS = 'models/index.ts';
36
37
  const DEFAULT_MIGRATIONS = 'drizzle';
37
38
  const DEFAULT_SCHEMA_OUT = 'db/schema.generated.ts';
38
39
 
@@ -123,7 +124,7 @@ async function dbGenerateInit(modelsPath: string, migrationsDir: string, schemaO
123
124
  }
124
125
 
125
126
  export async function dbGenerateCommand(flags: Record<string, string>): Promise<void> {
126
- const modelsPath = flags.models || DEFAULT_MODELS;
127
+ const modelsPath = resolveModelsPath(flags.models);
127
128
  const migrationsDir = path.resolve(flags.dir || DEFAULT_MIGRATIONS);
128
129
  const schemaOut = path.resolve(flags['schema-out'] || DEFAULT_SCHEMA_OUT);
129
130
  const name = flags.name || 'generated';
@@ -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';
@@ -13,7 +13,7 @@
13
13
  * the reconciler writes it and never reads it.
14
14
  */
15
15
 
16
- import type { SourceObject } from './derived-source.js';
16
+ import { normalizeSql, type SourceObject } from './derived-source.js';
17
17
  import type { ReconcilePlan } from './derived-plan.js';
18
18
  import { quoteQualified } from './pg-ident.js';
19
19
 
@@ -69,9 +69,22 @@ export function ensureOrReplace(sql: string): string {
69
69
  return sql.replace(/^(\s*)CREATE\s+FUNCTION/i, '$1CREATE OR REPLACE FUNCTION');
70
70
  }
71
71
 
72
- /** An object's full creation SQL: the CREATE statement plus its attachments, in source order. */
72
+ const WITH_NO_DATA_RE = /WITH\s+NO\s+DATA\s*$/i;
73
+
74
+ /**
75
+ * An object's full creation SQL: the CREATE statement plus its attachments, in
76
+ * source order. A matview whose source says `WITH NO DATA` gets a trailing
77
+ * REFRESH: the reconciler dropped a populated matview to rebuild it, and
78
+ * executing the source verbatim would leave it unpopulated — matching the
79
+ * text but regressing the database. (Found by a consumer whose extracted
80
+ * sources carry pg_dump's WITH NO DATA ordering.)
81
+ */
73
82
  function objectSql(obj: SourceObject): string[] {
74
- return [obj.sql, ...obj.attachments.map((a) => a.sql)];
83
+ const statements = [obj.sql, ...obj.attachments.map((a) => a.sql)];
84
+ if (obj.kind === 'materialized view' && WITH_NO_DATA_RE.test(normalizeSql(obj.sql))) {
85
+ statements.push(`REFRESH MATERIALIZED VIEW ${quoteQualified(obj.identity)}`);
86
+ }
87
+ return statements;
75
88
  }
76
89
 
77
90
  export function renderReconcileSql(plan: ReconcilePlan, source: SourceObject[]): RenderedReconcile {
package/src/cli/index.ts CHANGED
@@ -295,14 +295,14 @@ 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 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.
302
302
  everystack db:authz:pull [--stage <name>] [--dir authz] Introspect live authz (rls/grants/policies/secdef) → reviewable contract files
303
303
  everystack db:authz:diff [--stage <name>] [--dir authz] Validate the live DB against the committed contract (non-zero exit on drift)
304
304
  everystack db:authz:test [--stage <name>] [--dir authz] Red-team enforcement: SET ROLE + attempt per role/table/command (non-zero exit on a hole)
305
- everystack db:authz:owner [--stage <name>] [--models models/index.ts] Red-team owner isolation: two JWT identities per owner-scoped table — catches IDOR (non-zero exit on a leak)
305
+ everystack db:authz:owner [--stage <name>] [--models db/models/index.ts] Red-team owner isolation: two JWT identities per owner-scoped table — catches IDOR (non-zero exit on a leak)
306
306
  everystack db:authz:report [--dir authz] Render the committed contract as a human-readable authorization review (no DB)
307
307
  everystack console --stage <name> [--sandbox] Interactive REPL on deployed Lambda
308
308
  everystack status [--stage <name>] [--hours <n>] Platform health: CDN, Lambda, rollup summary
@@ -0,0 +1,25 @@
1
+ /**
2
+ * models-path — where the Models barrel lives.
3
+ *
4
+ * One home for schema: `db/models/` (state) beside `db/sql/` (compute). The
5
+ * CLI looks there first, falling back to the legacy top-level `models/` so
6
+ * existing consumers keep working without a flag. An explicit `--models`
7
+ * always wins. When neither home exists, the NEW home is returned so error
8
+ * messages point people at the current convention, not the legacy one.
9
+ */
10
+
11
+ import { existsSync } from 'node:fs';
12
+
13
+ /** Candidate barrels, in preference order. */
14
+ export const MODELS_HOMES = ['db/models/index.ts', 'models/index.ts'] as const;
15
+
16
+ export function resolveModelsPath(
17
+ flag?: string,
18
+ exists: (p: string) => boolean = existsSync,
19
+ ): string {
20
+ if (flag) return flag;
21
+ for (const home of MODELS_HOMES) {
22
+ if (exists(home)) return home;
23
+ }
24
+ return MODELS_HOMES[0];
25
+ }
@@ -102,10 +102,12 @@ export function detectProjectReality(root: string): ProjectReality {
102
102
  ? 'V2'
103
103
  : 'V1';
104
104
 
105
- const modelsDir = path.join(abs, 'models');
105
+ // One home for schema: db/models is the current convention, top-level models the legacy one.
106
+ const modelsHome = isDir(path.join(abs, 'db', 'models')) ? 'db/models' : 'models';
107
+ const modelsDir = path.join(abs, modelsHome);
106
108
  const modelFiles = listTs(modelsDir).filter((f) => f !== 'index.ts');
107
109
  const models = {
108
- dir: isDir(modelsDir) ? 'models' : null,
110
+ dir: isDir(modelsDir) ? modelsHome : null,
109
111
  files: modelFiles,
110
112
  hasIndex: isFile(path.join(modelsDir, 'index.ts')),
111
113
  };
@@ -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
+ }