@everystack/cli 0.2.39 → 0.3.0

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.
@@ -0,0 +1,83 @@
1
+ /**
2
+ * `everystack db:pull` — generate `field()` Models from a live database (the brownfield on-ramp).
3
+ *
4
+ * db:pull --stage <name> [--schema public] [--out models/index.ts]
5
+ *
6
+ * The reverse of db:generate: that writes migrations FROM Models; this writes Models FROM the
7
+ * database. It introspects the deployed schema through the read-only ops Lambda `db:query`
8
+ * action and renders the TypeScript a Model is written in — a starting point you review and
9
+ * refine (it flags, as inline comments, anything the field builder can't yet express).
10
+ *
11
+ * The rendered source is the artifact: it goes to stdout (so `db:pull > models.ts` works) or
12
+ * to `--out`. Every progress/diagnostic line goes to stderr, so the stdout pipe stays pure
13
+ * source. The renderer (model-render.ts) is pure; this shell is the IO.
14
+ */
15
+
16
+ import fs from 'node:fs/promises';
17
+ import path from 'node:path';
18
+ import { introspectSchema } from '../schema-introspect.js';
19
+ import { renderModelSource } from '../model-render.js';
20
+ import type { QueryRunner } from '../authz-contract.js';
21
+ import { resolveConfig, opsFunction } from '../config.js';
22
+ import { invokeAction } from '../aws.js';
23
+ import { fail } from '../output.js';
24
+
25
+ // Progress → stderr, so stdout carries only the rendered source (this command is codegen,
26
+ // not a report — the shared output helpers write to stdout, which would corrupt a redirect).
27
+ const note = (m: string) => console.error(` > ${m}`);
28
+ const ok = (m: string) => console.error(` ✓ ${m}`);
29
+ const detail = (m: string) => console.error(` ${m}`);
30
+ const caution = (m: string) => console.error(` ! ${m}`);
31
+
32
+ /** A QueryRunner backed by the ops Lambda `db:query` action (read-only). */
33
+ function lambdaRunner(region: string, fn: string): QueryRunner {
34
+ return async (sql: string) => {
35
+ const result: any = await invokeAction(region, fn, 'db:query', { sql });
36
+ if (result?.error) throw new Error(`Introspection query failed: ${result.error}`);
37
+ return result?.rows ?? [];
38
+ };
39
+ }
40
+
41
+ export async function dbPullCommand(flags: Record<string, string>): Promise<void> {
42
+ const schema = flags.schema || 'public';
43
+
44
+ let current;
45
+ try {
46
+ note('Resolving deployed config...');
47
+ const config = await resolveConfig(flags.stage);
48
+ detail(`Region: ${config.region}, Function: ${opsFunction(config)}`);
49
+ note(`Introspecting live database (schema: ${schema})...`);
50
+ current = await introspectSchema(lambdaRunner(config.region, opsFunction(config)));
51
+ } catch (err: any) {
52
+ fail(err.message);
53
+ detail('Ensure your IAM user/role has lambda:InvokeFunction and the stage is deployed.');
54
+ process.exit(1);
55
+ }
56
+
57
+ const pulled = current.tables.filter((t) => t.table.startsWith(`${schema}.`));
58
+ if (pulled.length === 0) {
59
+ fail(`No tables found in schema "${schema}".`);
60
+ process.exit(1);
61
+ }
62
+
63
+ const source = renderModelSource(current, { schema });
64
+
65
+ if (flags.out) {
66
+ const outPath = path.resolve(flags.out);
67
+ try {
68
+ await fs.mkdir(path.dirname(outPath), { recursive: true });
69
+ await fs.writeFile(outPath, source, 'utf8');
70
+ } catch (err: any) {
71
+ fail(`Could not write ${flags.out}: ${err.message}`);
72
+ process.exit(1);
73
+ }
74
+ ok(`Wrote ${path.relative(process.cwd(), outPath)} — ${pulled.length} model(s).`);
75
+ } else {
76
+ process.stdout.write(source);
77
+ ok(`Rendered ${pulled.length} model(s) from schema "${schema}" (stdout — redirect or pass --out to save).`);
78
+ }
79
+
80
+ const flagged = (source.match(/\/\/ (FIXME|TODO|composite|CHECK|FK →)/g) ?? []).length;
81
+ if (flagged) caution(`${flagged} inline comment(s) flag things to review (unmapped types, checks, cross-schema FKs).`);
82
+ process.exit(0);
83
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * db:snapshot / db:snapshots — RDS *physical* snapshots, the thin `aws rds` control-plane wrapper.
3
+ *
4
+ * Unlike db:backup (logical pg_dump, runs in the ops Lambda), these are plain RDS management calls
5
+ * made directly from the CLI with the operator's IAM — no Lambda, no VPC, no pg_dump layer. They
6
+ * only apply when the DB is an RDS instance; for any other Postgres, use db:backup. See
7
+ * docs/plans/db-backup-restore.md.
8
+ */
9
+
10
+ import { resolveConfig, type CliConfig } from '../config.js';
11
+ import { createRdsSnapshot, describeRdsSnapshots, type RdsSnapshotSummary } from '../aws.js';
12
+ import { utcStamp } from '../backup.js';
13
+ import { rdsSnapshotIdentifier } from '../rds-snapshot.js';
14
+ import { step, success, fail, info } from '../output.js';
15
+
16
+ /**
17
+ * Resolve the RDS DB instance identifier: explicit `--instance` wins, else the deployed
18
+ * `databaseInstanceId` stack output. A missing or `placeholder` value means the DB isn't RDS (or
19
+ * the stage is in dev mode) — fail with a pointer to db:backup rather than calling AWS with junk.
20
+ */
21
+ function resolveInstanceId(config: CliConfig, flags: Record<string, string>): string {
22
+ const id = flags.instance ?? config.databaseInstanceId;
23
+ if (!id || id === 'placeholder') {
24
+ fail(
25
+ 'No RDS instance id available. db:snapshot needs an RDS instance.\n' +
26
+ ' - If your DB is RDS: redeploy so the `databaseInstanceId` output is present, or pass --instance <id>.\n' +
27
+ ' - If your DB is not RDS (Aurora, a container, self-hosted): use `everystack db:backup` for a portable logical backup.',
28
+ );
29
+ process.exit(1);
30
+ }
31
+ return id;
32
+ }
33
+
34
+ /** Only an authorization failure warrants the IAM hint; other RDS errors (state, quota) don't. */
35
+ function isAccessDenied(err: any): boolean {
36
+ const name = String(err?.name ?? '');
37
+ return /AccessDenied|Unauthorized|NotAuthorized/i.test(name);
38
+ }
39
+
40
+ function fmtSize(gib?: number): string {
41
+ return gib == null ? '-' : `${gib} GiB`;
42
+ }
43
+
44
+ function fmtDate(d?: Date): string {
45
+ return d ? d.toISOString().replace('.000Z', 'Z') : '(creating)';
46
+ }
47
+
48
+ export async function dbSnapshotCommand(flags: Record<string, string>): Promise<void> {
49
+ step('Resolving deployed config...');
50
+ let config: CliConfig;
51
+ try {
52
+ config = await resolveConfig(flags.stage);
53
+ } catch (err: any) {
54
+ fail(err.message);
55
+ process.exit(1);
56
+ }
57
+
58
+ const instanceId = resolveInstanceId(config, flags);
59
+ const snapshotId = rdsSnapshotIdentifier(flags.stage ?? 'manual', utcStamp(new Date()));
60
+
61
+ info(`Region: ${config.region}, Instance: ${instanceId}`);
62
+ step(`Creating RDS snapshot ${snapshotId}...`);
63
+
64
+ try {
65
+ const snap = await createRdsSnapshot(config.region, instanceId, snapshotId);
66
+ success(`Snapshot ${snap.identifier} (${snap.status}) — physical RDS image of ${instanceId}.`);
67
+ info('It becomes restorable once status is "available". Track it with `everystack db:snapshots`.');
68
+ } catch (err: any) {
69
+ fail(`Snapshot failed: ${err.message}`);
70
+ if (isAccessDenied(err)) {
71
+ info('Ensure your IAM identity has rds:CreateDBSnapshot on this instance.');
72
+ } else if (/not currently in the available state|InvalidDBInstanceState/i.test(String(err?.message ?? err?.name))) {
73
+ info('The instance is busy (another snapshot may be in progress). Wait for it to finish, then retry.');
74
+ }
75
+ process.exit(1);
76
+ }
77
+ }
78
+
79
+ export async function dbSnapshotsCommand(flags: Record<string, string>): Promise<void> {
80
+ step('Resolving deployed config...');
81
+ let config: CliConfig;
82
+ try {
83
+ config = await resolveConfig(flags.stage);
84
+ } catch (err: any) {
85
+ fail(err.message);
86
+ process.exit(1);
87
+ }
88
+
89
+ const instanceId = resolveInstanceId(config, flags);
90
+ step(`Listing manual RDS snapshots for ${instanceId}...`);
91
+
92
+ let snaps: RdsSnapshotSummary[];
93
+ try {
94
+ snaps = await describeRdsSnapshots(config.region, instanceId);
95
+ } catch (err: any) {
96
+ fail(`List failed: ${err.message}`);
97
+ if (isAccessDenied(err)) {
98
+ info('Ensure your IAM identity has rds:DescribeDBSnapshots.');
99
+ }
100
+ process.exit(1);
101
+ }
102
+
103
+ if (snaps.length === 0) {
104
+ info('No manual snapshots. Create one with `everystack db:snapshot`.');
105
+ return;
106
+ }
107
+
108
+ for (const s of snaps) {
109
+ const enc = s.encrypted ? ' [encrypted]' : '';
110
+ info(`${s.identifier} ${s.status.padEnd(10)} ${fmtSize(s.sizeGiB).padEnd(8)} pg${s.engineVersion ?? '?'} ${fmtDate(s.created)}${enc}`);
111
+ }
112
+ success(`${snaps.length} snapshot${snaps.length === 1 ? '' : 's'}.`);
113
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * `everystack deploy` — deploy the stack's infrastructure.
3
+ *
4
+ * A thin wrapper over `sst deploy` so a consumer never has to type or know about SST: the
5
+ * framework owns the infra tool, the consumer just runs `everystack deploy --stage <name>`.
6
+ * Infra only — migrations (`db:migrate`) and OTA (`update`) stay explicit, the same split
7
+ * `sst deploy` itself has.
8
+ *
9
+ * The arg build is pure (unit-tested); the command streams sst's output through and exits
10
+ * with its code so CI sees a failed deploy.
11
+ */
12
+
13
+ import { spawn } from 'node:child_process';
14
+ import { step, success, fail, info } from '../output.js';
15
+
16
+ /** The `sst` argv `everystack deploy` runs. Pure, so the mapping is unit-tested. */
17
+ export function buildDeployArgs(flags: Record<string, string>): string[] {
18
+ const stage = flags.stage || 'dev';
19
+ return ['sst', 'deploy', '--stage', stage];
20
+ }
21
+
22
+ export async function deployCommand(flags: Record<string, string>): Promise<void> {
23
+ const stage = flags.stage || 'dev';
24
+ const args = buildDeployArgs(flags);
25
+
26
+ step(`Deploying stage "${stage}"...`);
27
+ await new Promise<void>((resolve) => {
28
+ // `npx` resolves the consumer's local sst (the same way the export step runs expo).
29
+ const child = spawn('npx', args, { stdio: 'inherit', env: { ...process.env, FORCE_COLOR: '1' } });
30
+ child.on('close', (code) => {
31
+ if (code === 0) {
32
+ resolve();
33
+ } else {
34
+ fail(`Deploy failed (exit ${code}).`);
35
+ process.exit(code ?? 1);
36
+ }
37
+ });
38
+ child.on('error', (err) => {
39
+ fail(`Could not run sst: ${err.message}`);
40
+ info('Install it as a dev dependency: `pnpm add -D sst`.');
41
+ process.exit(1);
42
+ });
43
+ });
44
+ success(`Deployed stage "${stage}".`);
45
+ info('Next: `everystack db:migrate --stage ' + stage + '` to apply migrations, `everystack update` to publish the app bundle.');
46
+ }
package/src/cli/config.ts CHANGED
@@ -24,6 +24,10 @@ export interface CliConfig {
24
24
  mediaBucket?: string;
25
25
  kvsArn?: string;
26
26
  distributionId?: string;
27
+ /** RDS DB instance identifier (for db:snapshot). Absent when the DB isn't RDS or in dev mode. */
28
+ databaseInstanceId?: string;
29
+ /** Private S3 bucket holding logical backups (for db:backup:download presign). */
30
+ backupsBucket?: string;
27
31
  }
28
32
 
29
33
  /** Operator invoke target — dedicated ops function when deployed, else the API function. */
@@ -44,6 +48,8 @@ interface SstOutputs {
44
48
  mediaBucket?: string;
45
49
  kvsArn?: string;
46
50
  distributionId?: string;
51
+ databaseInstanceId?: string;
52
+ backupsBucket?: string;
47
53
  }
48
54
 
49
55
  export async function resolveConfig(stage?: string): Promise<CliConfig> {
@@ -115,5 +121,7 @@ export async function resolveConfig(stage?: string): Promise<CliConfig> {
115
121
  mediaBucket: outputs.mediaBucket,
116
122
  kvsArn: outputs.kvsArn,
117
123
  distributionId: outputs.distributionId,
124
+ databaseInstanceId: outputs.databaseInstanceId,
125
+ backupsBucket: outputs.backupsBucket,
118
126
  };
119
127
  }
package/src/cli/index.ts CHANGED
@@ -3,10 +3,16 @@
3
3
  import fs from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
  import { updateCommand } from './commands/update.js';
6
+ import { deployCommand } from './commands/deploy.js';
6
7
  import { certsCommand } from './commands/certs.js';
7
8
  import { channelsCommand } from './commands/channels.js';
8
9
  import { branchesCommand } from './commands/branches.js';
9
10
  import { dbMigrateCommand, dbSeedCommand, dbResetCommand, dbPsqlCommand, dbDoctorCommand, dbProvisionCommand } from './commands/db.js';
11
+ import { dbAuthzPullCommand, dbAuthzDiffCommand, dbAuthzTestCommand, dbAuthzOwnerCommand, dbAuthzReportCommand } from './commands/db-authz.js';
12
+ import { dbGenerateCommand } from './commands/db-generate.js';
13
+ import { dbPullCommand } from './commands/db-pull.js';
14
+ import { dbSnapshotCommand, dbSnapshotsCommand } from './commands/db-snapshot.js';
15
+ import { dbBackupProbeCommand, dbBackupCommand, dbBackupsCommand, dbRestoreCommand, dbBackupDownloadCommand } from './commands/db-backup.js';
10
16
  import { consoleCommand } from './commands/console.js';
11
17
  import { cachePurgeCommand } from './commands/cache.js';
12
18
  import { statusCommand } from './commands/status.js';
@@ -91,6 +97,9 @@ async function main() {
91
97
  case 'update':
92
98
  await updateCommand(flags);
93
99
  break;
100
+ case 'deploy':
101
+ await deployCommand(flags);
102
+ break;
94
103
  case 'certs:generate':
95
104
  await certsCommand('generate', flags);
96
105
  break;
@@ -121,6 +130,50 @@ async function main() {
121
130
  case 'db:provision':
122
131
  await dbProvisionCommand(flags);
123
132
  break;
133
+ case 'db:snapshot':
134
+ await dbSnapshotCommand(flags);
135
+ break;
136
+ case 'db:snapshots':
137
+ await dbSnapshotsCommand(flags);
138
+ break;
139
+ case 'db:backup:probe':
140
+ await dbBackupProbeCommand(flags);
141
+ break;
142
+ case 'db:backup':
143
+ await dbBackupCommand(flags);
144
+ break;
145
+ case 'db:backups':
146
+ await dbBackupsCommand(flags);
147
+ break;
148
+ case 'db:restore':
149
+ await dbRestoreCommand(flags);
150
+ break;
151
+ case 'db:backup:download': {
152
+ const backupId = args[1] && !args[1].startsWith('--') ? args[1] : undefined;
153
+ await dbBackupDownloadCommand(flags, backupId);
154
+ break;
155
+ }
156
+ case 'db:generate':
157
+ await dbGenerateCommand(flags);
158
+ break;
159
+ case 'db:pull':
160
+ await dbPullCommand(flags);
161
+ break;
162
+ case 'db:authz:pull':
163
+ await dbAuthzPullCommand(flags);
164
+ break;
165
+ case 'db:authz:diff':
166
+ await dbAuthzDiffCommand(flags);
167
+ break;
168
+ case 'db:authz:test':
169
+ await dbAuthzTestCommand(flags);
170
+ break;
171
+ case 'db:authz:owner':
172
+ await dbAuthzOwnerCommand(flags);
173
+ break;
174
+ case 'db:authz:report':
175
+ await dbAuthzReportCommand(flags);
176
+ break;
124
177
  case 'console':
125
178
  await consoleCommand(flags);
126
179
  break;
@@ -181,6 +234,7 @@ function printHelp() {
181
234
  everystack - CLI for Expo apps on everystack
182
235
 
183
236
  Usage:
237
+ everystack deploy [--stage <name>] Deploy infrastructure (wraps sst deploy — no need to know SST)
184
238
  everystack update --branch <name> --message <msg> [--platform ios|android|web|all] [--stage <name>] [--skip-export]
185
239
  everystack update --channel <name> --message <msg> [--platform ios|android|web|all] [--stage <name>] [--skip-export]
186
240
  everystack db:migrate [--stage <name>] Run database migrations on deployed Lambda
@@ -190,6 +244,20 @@ Usage:
190
244
  everystack db:psql [--stage <name>] -c <command> Run one query via Lambda (works for private RDS)
191
245
  everystack db:doctor [--stage <name>] Check the DB is least-privilege + RLS-subject (api vs operator connection)
192
246
  everystack db:provision [--stage <name>] Create the least-privilege role chain on an EXISTING database (idempotent; creates no DB)
247
+ everystack db:snapshot [--stage <name>] [--instance <id>] Take a physical RDS snapshot (instant DR point; RDS only — use db:backup for portable logical backups)
248
+ everystack db:snapshots [--stage <name>] [--instance <id>] List manual RDS snapshots for the instance
249
+ everystack db:backup:probe [--stage <name>] Verify the pg_dump layer is attached + version-compatible with the server
250
+ everystack db:backup [--stage <name>] Logical pg_dump of the DB → private S3 backups bucket (prints the backup id)
251
+ everystack db:backups [--stage <name>] List logical backups (id, size, created)
252
+ everystack db:restore --from <id> [--stage <name>] --confirm Restore a backup INTO the stage's DB (DESTRUCTIVE)
253
+ everystack db:backup:download <id> [--stage <name>] Presigned URL to download a backup's dump (valid 1h)
254
+ everystack db:generate [--stage <name>] [--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)
255
+ everystack db:pull [--stage <name>] [--schema public] [--out <file>] Introspect the live DB → render field() Models (the brownfield on-ramp; stdout unless --out)
256
+ everystack db:authz:pull [--stage <name>] [--dir authz] Introspect live authz (rls/grants/policies/secdef) → reviewable contract files
257
+ everystack db:authz:diff [--stage <name>] [--dir authz] Validate the live DB against the committed contract (non-zero exit on drift)
258
+ everystack db:authz:test [--stage <name>] [--dir authz] Red-team enforcement: SET ROLE + attempt per role/table/command (non-zero exit on a hole)
259
+ 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)
260
+ everystack db:authz:report [--dir authz] Render the committed contract as a human-readable authorization review (no DB)
193
261
  everystack console --stage <name> [--sandbox] Interactive REPL on deployed Lambda
194
262
  everystack status [--stage <name>] [--hours <n>] Platform health: CDN, Lambda, rollup summary
195
263
  everystack security:audit [--dir <path>] [--config <file>] Static scan of migration SQL (pre-merge gate)
@@ -0,0 +1,69 @@
1
+ /**
2
+ * The one generator — compose the Model into a single ordered migration.
3
+ *
4
+ * This is the unified-migration thesis made concrete: a set of Models compiles to
5
+ * ONE ordered list of SQL statements covering *both* layers, in dependency order:
6
+ *
7
+ * 1. CREATE TABLE … (data — fields → DDL + CHECK)
8
+ * 2. ALTER TABLE … FOREIGN KEY (data — references, after every table exists)
9
+ * 3. ENABLE/FORCE RLS · policies · grants (authz — abilities, after the
10
+ * columns they reference exist)
11
+ *
12
+ * It is not two pipelines: the authz statements reference columns the DDL just
13
+ * created, so they live in the same ordered migration. This composes the four
14
+ * compilers already built — compileCreateTable + foreignKeySql (data) and
15
+ * compileTableContract + emitReconcileSql (authz) — with no new SQL emission.
16
+ *
17
+ * This is the greenfield form (empty database → the whole schema). The brownfield
18
+ * form diffs against an introspected snapshot instead of the empty baseline; that
19
+ * lands with whole-DB introspection. The emission is identical either way.
20
+ */
21
+
22
+ import type { ModelDescriptor } from '@everystack/model';
23
+ import type { AuthzContract, TableContract } from './authz-contract.js';
24
+ import { compileCreateTable, compileEnums, foreignKeySql, modelConstraintSpecs, type CompileTableOptions } from './schema-compile.js';
25
+ import { compileTableContract } from './authz-compile.js';
26
+ import { emitReconcileSql } from './authz-reconcile.js';
27
+ import { emitSchemaSql, type SchemaChange } from './schema-diff.js';
28
+
29
+ /** The empty (not-yet-created) authz state for a table — the greenfield baseline. */
30
+ function emptyTable(table: string): TableContract {
31
+ return { table, rls: { enabled: false, forced: false }, grants: {}, policies: [] };
32
+ }
33
+
34
+ /**
35
+ * Compile a set of Models into one ordered migration (greenfield: empty DB → full
36
+ * schema). Tables, then foreign keys, then authz — the order a fresh database must
37
+ * apply them in.
38
+ */
39
+ export function compileMigration(models: ModelDescriptor[], opts: CompileTableOptions = {}): string[] {
40
+ const sql: string[] = [];
41
+
42
+ // 0. Enum types — `CREATE TYPE … AS ENUM`, before any table/column references them.
43
+ const enumCreates: SchemaChange[] = compileEnums(models).map((e) => ({ kind: 'createType', name: e.name, values: e.values }));
44
+ sql.push(...emitSchemaSql(enumCreates));
45
+
46
+ // 1. Tables (with columns, CHECKs, single/composite PK, uniques).
47
+ for (const model of models) sql.push(compileCreateTable(model, opts));
48
+
49
+ // 2. Foreign keys — after every table exists.
50
+ for (const model of models) sql.push(...foreignKeySql(model));
51
+
52
+ // 2b. Standalone indexes — after the columns they cover exist.
53
+ const indexCreates: SchemaChange[] = [];
54
+ for (const model of models) {
55
+ for (const ix of modelConstraintSpecs(model).indexes) {
56
+ indexCreates.push({ kind: 'createIndex', table: `"${model.table}"`, name: ix.name, columns: ix.columns, unique: ix.unique, ...(ix.where ? { where: ix.where } : {}) });
57
+ }
58
+ }
59
+ sql.push(...emitSchemaSql(indexCreates));
60
+
61
+ // 3. Authz — RLS + policies + grants, after the columns they reference. Reuse
62
+ // the reconcile emitter against the empty baseline: every policy/grant is a
63
+ // pure creation, in the same dependency-correct order reconcile already uses.
64
+ const desired: AuthzContract = { tables: models.map((m) => compileTableContract(m)), functions: [] };
65
+ const baseline: AuthzContract = { tables: models.map((m) => emptyTable(`public.${m.table}`)), functions: [] };
66
+ sql.push(...emitReconcileSql(desired, baseline));
67
+
68
+ return sql;
69
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * `db:generate`'s pure core — Models + live snapshot → a migration file.
3
+ *
4
+ * The brownfield generator, composed from the pieces already built and proven:
5
+ *
6
+ * compileTableSchema + compileRenames (the desired side, from the Models)
7
+ * → diffSchema (vs the introspected `current`)
8
+ * → emitSchemaSql (the ALTER/CREATE/RENAME DDL)
9
+ *
10
+ * A whole *new* table renders through the greenfield `compileCreateTable` (byte-identical
11
+ * to what the app ships) rather than the IR renderer; its foreign keys still arrive as the
12
+ * diff's `addConstraint` changes, so they land after every table exists.
13
+ *
14
+ * Everything here is pure and deterministic — the clock (`when`) is injected, never read —
15
+ * so the command shell (commands/db-generate.ts) is a thin layer of IO over it.
16
+ */
17
+
18
+ import type { ModelDescriptor } from '@everystack/model';
19
+ import type { SchemaSnapshot } from './schema-introspect.js';
20
+ import type { AuthzContract } from './authz-contract.js';
21
+ import { compileTableSchema, compileRenames, compileCreateTable, compileEnums } from './schema-compile.js';
22
+ import { compileTableContract } from './authz-compile.js';
23
+ import { emitReconcileSql } from './authz-reconcile.js';
24
+ import { diffSchema, emitSchemaSql, type SchemaChange } from './schema-diff.js';
25
+
26
+ export interface GenerateOptions {
27
+ /** The Postgres schema the Models live in. Default: `public`. */
28
+ schema?: string;
29
+ /**
30
+ * Emit real `DROP COLUMN`/`DROP CONSTRAINT`/`DROP TABLE` for things present in the
31
+ * database but absent from the Models. Default `false` — those are held back as
32
+ * commented notices (see {@link HELD_DROP_PREFIX}), so a partial or lossy Model set
33
+ * never silently loses data. Destruction needs intent, the same rule renames are built on.
34
+ */
35
+ allowDrops?: boolean;
36
+ /**
37
+ * The live authorization contract (`introspectContract`). When provided, the migration
38
+ * carries the authz layer too — the Models' `abilities` compiled to RLS/policies/grants,
39
+ * diffed against this live state, appended after the data phase as one ordered file (the
40
+ * unified-migration thesis: declare once, generate both layers). Omitted → data-only, the
41
+ * backward-compatible default. Authz drops (`DROP POLICY`/`REVOKE`) are data-safe, so they
42
+ * are emitted regardless of `allowDrops` — `allowDrops` gates only data destruction.
43
+ */
44
+ liveAuthz?: AuthzContract;
45
+ }
46
+
47
+ /** The comment prefix a held-back destructive change carries (the command greps it to count them). */
48
+ export const HELD_DROP_PREFIX = '-- DROP held back';
49
+
50
+ /**
51
+ * A destructive change that removes data or an integrity guard with no re-creation —
52
+ * the set held back unless `allowDrops`. A constraint *replace* (a `dropConstraint`
53
+ * paired with an `addConstraint` of the same name) is NOT a removal: it loses no data
54
+ * and its add depends on the drop, so it is kept intact.
55
+ */
56
+ function isRemoval(change: SchemaChange, replacedConstraints: Set<string>): boolean {
57
+ if (change.kind === 'dropColumn' || change.kind === 'dropTable' || change.kind === 'dropType' || change.kind === 'dropIndex') return true;
58
+ if (change.kind === 'dropConstraint') return !replacedConstraints.has(`${change.table}::${change.name}`);
59
+ return false;
60
+ }
61
+
62
+ /** Comment out a destructive statement, showing the exact DDL to run by hand or with `--allow-drops`. */
63
+ function holdDrop(sql: string): string {
64
+ return `${HELD_DROP_PREFIX} (destructive — re-run db:generate with --allow-drops to emit it, or run it by hand):\n-- ${sql}`;
65
+ }
66
+
67
+ /**
68
+ * Tables present in the live database that no model declares. `db:generate` leaves these
69
+ * untouched — they belong to another concern (auth, jobs, observability all ship their own
70
+ * migrations), and dropping a table must be deliberate, never a side effect of a missing
71
+ * model. The command reports the count so the omission is visible, not silent.
72
+ */
73
+ export function unmodeledTables(models: ModelDescriptor[], current: SchemaSnapshot, opts: GenerateOptions = {}): string[] {
74
+ const schema = opts.schema ?? 'public';
75
+ const declared = new Set(models.map((m) => `${schema}.${m.table}`));
76
+ return current.tables.map((t) => t.table).filter((t) => !declared.has(t)).sort();
77
+ }
78
+
79
+ /**
80
+ * Compile the Models into the ordered SQL that evolves `current` to match them. Empty when
81
+ * the database already matches (the no-op generate).
82
+ *
83
+ * Scoped to the tables the models DECLARE: the diff never sees a table no model owns, so it
84
+ * cannot propose dropping one. db:generate manages your declared schema; everything else in
85
+ * the database (auth, jobs, observability) is left to its own migrations.
86
+ */
87
+ export function generateMigrationSql(models: ModelDescriptor[], current: SchemaSnapshot, opts: GenerateOptions = {}): string[] {
88
+ const schema = opts.schema ?? 'public';
89
+ const desiredEnums = compileEnums(models);
90
+ const desired: SchemaSnapshot = { tables: models.map((m) => compileTableSchema(m, { schema })), enums: desiredEnums };
91
+ const declaredTables = new Set(desired.tables.map((t) => t.table));
92
+ const declaredEnums = new Set(desiredEnums.map((e) => e.name));
93
+ // Scope both tables and enums to what the Models declare — an undeclared table OR enum
94
+ // (auth, jobs, observability ship their own) is invisible to the diff, so it is never dropped.
95
+ const scopedCurrent: SchemaSnapshot = {
96
+ tables: current.tables.filter((t) => declaredTables.has(t.table)),
97
+ enums: (current.enums ?? []).filter((e) => declaredEnums.has(e.name)),
98
+ };
99
+ const renames = compileRenames(models, { schema });
100
+ const changes = diffSchema(desired, scopedCurrent, renames);
101
+
102
+ const modelByTable = new Map(models.map((m) => [`${schema}.${m.table}`, m]));
103
+ const emitted = emitSchemaSql(changes, {
104
+ createTable: (change) => {
105
+ const model = modelByTable.get(change.table);
106
+ if (!model) throw new Error(`db:generate: no model found for new table ${change.table}`);
107
+ return compileCreateTable(model);
108
+ },
109
+ });
110
+
111
+ // Non-destructive by default: a column/constraint/table in the database but absent
112
+ // from the Models would otherwise become a DROP — silent data loss for a partial or
113
+ // lossy Model set. Hold removals back as comments unless `allowDrops`. emitSchemaSql
114
+ // returns one statement per change, index-aligned, so the gate stays a pure
115
+ // post-pass — `diffSchema`/`emitSchemaSql` remain honest (they always emit the DROP).
116
+ let dataPhase = emitted;
117
+ if (!opts.allowDrops) {
118
+ const replacedConstraints = new Set(
119
+ changes
120
+ .filter((c): c is Extract<SchemaChange, { kind: 'addConstraint' }> => c.kind === 'addConstraint')
121
+ .map((c) => `${c.table}::${c.constraint.name}`),
122
+ );
123
+ dataPhase = emitted.map((sql, i) => (isRemoval(changes[i], replacedConstraints) ? holdDrop(sql) : sql));
124
+ }
125
+
126
+ // Authz phase — appended after the data phase so a policy/grant references columns the
127
+ // data DDL just created. Emitted only when the live contract is supplied; the Models'
128
+ // `abilities` compile to the same contract shape introspection reads, so the two diff.
129
+ // Authz changes are data-safe (no row touches), so they are never held by `allowDrops`.
130
+ const authzPhase = opts.liveAuthz
131
+ ? emitReconcileSql({ tables: models.map((m) => compileTableContract(m, { schema })), functions: [] }, opts.liveAuthz)
132
+ : [];
133
+
134
+ return [...dataPhase, ...authzPhase];
135
+ }
136
+
137
+ /** The marker drizzle migration files put between statements. */
138
+ const BREAKPOINT = '\n--> statement-breakpoint\n';
139
+
140
+ /** Join statements into a drizzle-compatible migration file body (trailing newline). */
141
+ export function formatMigrationFile(statements: string[]): string {
142
+ return statements.join(BREAKPOINT) + '\n';
143
+ }
144
+
145
+ export interface JournalEntry {
146
+ idx: number;
147
+ version: string;
148
+ when: number;
149
+ tag: string;
150
+ breakpoints: boolean;
151
+ }
152
+
153
+ export interface Journal {
154
+ version: string;
155
+ dialect: string;
156
+ entries: JournalEntry[];
157
+ }
158
+
159
+ /** Sanitize a migration name into the snake_case the `NNNN_<tag>.sql` convention uses. */
160
+ function sanitizeName(name: string): string {
161
+ return name.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'generated';
162
+ }
163
+
164
+ /**
165
+ * Plan the next migration file: its tag, filename, and the journal with the new entry
166
+ * appended. Pure — `when` is passed in (the command stamps it with the clock), so the same
167
+ * inputs always produce the same plan. The next index is one past the highest existing
168
+ * `idx`, matching how the runner and drizzle order migrations.
169
+ */
170
+ export function planMigrationFile(journal: Journal, name: string, when: number): { tag: string; filename: string; journal: Journal } {
171
+ const idx = journal.entries.reduce((max, e) => Math.max(max, e.idx + 1), 0);
172
+ const tag = `${String(idx).padStart(4, '0')}_${sanitizeName(name)}`;
173
+ const entry: JournalEntry = { idx, version: journal.version || '7', when, tag, breakpoints: true };
174
+ return { tag, filename: `${tag}.sql`, journal: { ...journal, entries: [...journal.entries, entry] } };
175
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * `@everystack/cli/model` — the programmatic surface of the v3 Model generator and red-team.
3
+ *
4
+ * Everything the CLI commands do (`db:generate`, `db:pull`, `db:authz:*`) is pure logic over a
5
+ * `QueryRunner`; this barrel exposes it so a consumer can generate and VERIFY their schema in
6
+ * their own tests/CI without shelling out to the CLI. The reference app dogfoods exactly this
7
+ * surface — its real Models are round-tripped through `compileMigration` / `generateMigrationSql`
8
+ * and red-teamed with the owner probe, against a real Postgres, proving the generator on a real app.
9
+ *
10
+ * The two entry shapes:
11
+ * - GENERATE: `compileMigration(models)` (greenfield) or `generateMigrationSql(models, snapshot,
12
+ * { liveAuthz })` (brownfield, both layers) → ordered SQL.
13
+ * - VERIFY: `introspectSchema(run)` / `introspectContract(run, …)` → diff against the Models, and
14
+ * `buildOwnerProbeSql` / `evaluateOwnerProbe` for behavioural owner-isolation checks.
15
+ */
16
+
17
+ // --- data layer: compile, introspect, diff, render -------------------------
18
+ export * from './schema-compile.js';
19
+ export * from './schema-introspect.js';
20
+ export * from './schema-diff.js';
21
+ export * from './model-render.js';
22
+ export * from './migration-compile.js';
23
+ export * from './migration-generate.js';
24
+
25
+ // --- authz layer: contract compile/introspect/diff/reconcile ---------------
26
+ export * from './authz-compile.js';
27
+ export * from './authz-contract.js';
28
+ export * from './authz-reconcile.js';
29
+
30
+ // --- red-team: grant enforcement + owner isolation -------------------------
31
+ export * from './authz-redteam.js';
32
+ export * from './authz-owner-probe.js';
33
+
34
+ // --- the SECDEF function catalog the contract introspection needs -----------
35
+ export { FUNCTIONS_SQL, catalogFunctionToDescriptor } from './security-catalog.js';