@everystack/cli 0.3.23 → 0.3.27

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,220 @@
1
+ /**
2
+ * `everystack db:template:refresh` + `everystack db:branch` — per-branch
3
+ * dev databases from a models-built template (brick 5-remainder,
4
+ * decision 14).
5
+ *
6
+ * db:template:refresh [--database-url <url>] [--models <barrel>]
7
+ * [--sql-dir db/sql] [--seed "<cmd>"] [--no-seed]
8
+ * db:branch # mint (or find) the current branch's DB
9
+ * db:branch --list # every branch DB, mapped to its branch
10
+ * db:branch --drop --confirm # drop the current branch's DB
11
+ * db:branch --prune --confirm # drop DBs whose git branch is gone
12
+ *
13
+ * The template is built FROM THE DECLARED STATE (both layers, fingerprint
14
+ * MATCH bar) plus seed-as-code — the app's own `db:seed` script, run as a
15
+ * subprocess with DATABASE_URL pointed at the fresh template. Never a data
16
+ * copy: deterministic, PII-free (real data enters branch testing through
17
+ * stage forks, brick 10). Branch databases mint via `CREATE DATABASE …
18
+ * TEMPLATE`, keyed on the git branch, and `db:sync` evolves them with the
19
+ * checkout — disposable by construction, never long-lived, never poisoned.
20
+ */
21
+
22
+ import fs from 'node:fs/promises';
23
+ import { spawnSync } from 'node:child_process';
24
+ import type { ModelDescriptor } from '@everystack/model';
25
+ import {
26
+ executeTemplateRefresh,
27
+ ensureBranchDatabase,
28
+ listBranchDatabases,
29
+ readTemplateProvenance,
30
+ prunableBranchDatabases,
31
+ dropBranchDatabase,
32
+ branchDatabaseName,
33
+ baseNameFromUrl,
34
+ templateName,
35
+ currentBranch,
36
+ localBranches,
37
+ } from '../branch-db.js';
38
+ import { currentGitRef } from '../state-apply.js';
39
+ import { fingerprintModels } from '../schema-fingerprint.js';
40
+ import { resolveDbSource, type DbSource } from '../db-source.js';
41
+ import { resolveModelsPath } from '../models-path.js';
42
+ import { readSqlDirIfPresent } from './db-sync.js';
43
+ import { loadModels } from './db-generate.js';
44
+ import { step, success, fail, info, warn } from '../output.js';
45
+
46
+ function requireUrl(flags: Record<string, string>, verb: string): string {
47
+ let dbSource: DbSource;
48
+ try {
49
+ dbSource = resolveDbSource(flags);
50
+ } catch (err: any) {
51
+ fail(err.message);
52
+ process.exit(1);
53
+ }
54
+ if (dbSource.kind !== 'url') {
55
+ fail(`${verb} manages local databases — it needs a direct connection (--database-url or DATABASE_URL).`);
56
+ process.exit(1);
57
+ }
58
+ return dbSource.url;
59
+ }
60
+
61
+ /** The seed convention: the app's own `db:seed` script from package.json. */
62
+ async function detectSeedCommand(): Promise<string | null> {
63
+ try {
64
+ const pkg = JSON.parse(await fs.readFile('package.json', 'utf8'));
65
+ return pkg?.scripts?.['db:seed'] ? 'npm run db:seed' : null;
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ function runSeedSubprocess(command: string, templateUrl: string): void {
72
+ const result = spawnSync(command, {
73
+ shell: true,
74
+ stdio: 'inherit',
75
+ env: { ...process.env, DATABASE_URL: templateUrl },
76
+ });
77
+ if (result.status !== 0) {
78
+ throw new Error(`seed command failed (exit ${result.status}): ${command}`);
79
+ }
80
+ }
81
+
82
+ export async function dbTemplateRefreshCommand(flags: Record<string, string>): Promise<void> {
83
+ const url = requireUrl(flags, 'db:template:refresh');
84
+
85
+ const modelsPath = resolveModelsPath(flags.models);
86
+ let models: ModelDescriptor[];
87
+ try {
88
+ step(`Loading models from ${modelsPath}...`);
89
+ models = await loadModels(modelsPath);
90
+ info(`${models.length} model(s).`);
91
+ } catch (err: any) {
92
+ fail(err.message);
93
+ process.exit(1);
94
+ }
95
+ const sources = await readSqlDirIfPresent(flags['sql-dir'] || 'db/sql');
96
+
97
+ let seedCommand: string | null = null;
98
+ if (flags['no-seed'] === 'true') {
99
+ info('Seeding skipped (--no-seed).');
100
+ } else if (flags.seed && flags.seed !== 'true') {
101
+ seedCommand = flags.seed;
102
+ } else {
103
+ seedCommand = await detectSeedCommand();
104
+ if (seedCommand) info(`Seed: package.json db:seed script (override with --seed "<cmd>" / --no-seed).`);
105
+ else warn('No db:seed script in package.json — the template builds UNSEEDED (declare one, or pass --seed "<cmd>").');
106
+ }
107
+
108
+ const template = templateName(baseNameFromUrl(url));
109
+ step(`Rebuilding ${template} from the declared state (drop → create → sync both layers → seed)...`);
110
+ try {
111
+ const result = await executeTemplateRefresh(url, models, {
112
+ sources,
113
+ actor: process.env.USER ?? null,
114
+ gitRef: currentGitRef(),
115
+ seed: seedCommand === null ? null : (templateUrl) => {
116
+ step(`Seeding: ${seedCommand} (DATABASE_URL → ${redactUrl(templateUrl)})...`);
117
+ runSeedSubprocess(seedCommand!, templateUrl);
118
+ return Promise.resolve();
119
+ },
120
+ });
121
+ for (const line of result.report) info(` ${line}`);
122
+ success(`Template ${result.database} is the declared state at ${result.fingerprint.slice(0, 12)}${seedCommand ? ', seeded' : ''}. Mint branch databases: everystack db:branch`);
123
+ } catch (err: any) {
124
+ fail(`Template refresh failed — no template was left behind (all or nothing): ${err.message}`);
125
+ process.exit(1);
126
+ }
127
+ }
128
+
129
+ /** Redact credentials when echoing a URL. */
130
+ function redactUrl(url: string): string {
131
+ return url.replace(/\/\/[^@/]+@/, '//***@');
132
+ }
133
+
134
+ export async function dbBranchCommand(flags: Record<string, string>): Promise<void> {
135
+ const url = requireUrl(flags, 'db:branch');
136
+
137
+ if (flags.list === 'true') {
138
+ const provenance = await readTemplateProvenance(url);
139
+ if (provenance) {
140
+ info(`template ${templateName(baseNameFromUrl(url))}: fingerprint ${provenance.fingerprint.slice(0, 12)}, built ${provenance.builtAt}${provenance.gitRef ? ` at ${provenance.gitRef.slice(0, 12)}` : ''}`);
141
+ } else {
142
+ warn(`no template — build it once: everystack db:template:refresh`);
143
+ }
144
+ const all = await listBranchDatabases(url);
145
+ if (all.length === 0) {
146
+ info('no branch databases.');
147
+ return;
148
+ }
149
+ const live = new Set(localBranches());
150
+ for (const d of all) {
151
+ const state = d.branch === null ? 'branch unknown (comment lost)' : live.has(d.branch) ? d.branch : `${d.branch} — branch GONE (db:branch --prune)`;
152
+ info(` ${d.database} ← ${state}`);
153
+ }
154
+ return;
155
+ }
156
+
157
+ if (flags.prune === 'true') {
158
+ const dead = prunableBranchDatabases(await listBranchDatabases(url), localBranches());
159
+ if (dead.length === 0) {
160
+ success('Nothing to prune — every branch database maps to a live branch.');
161
+ return;
162
+ }
163
+ for (const d of dead) info(` ${d.database} ← ${d.branch} (gone)`);
164
+ if (flags.confirm !== 'true') {
165
+ fail(`Would drop the ${dead.length} database(s) above — confirm with --confirm.`);
166
+ process.exit(1);
167
+ }
168
+ for (const d of dead) {
169
+ await dropBranchDatabase(url, d.database);
170
+ info(`dropped ${d.database}`);
171
+ }
172
+ success(`Pruned ${dead.length} branch database(s).`);
173
+ return;
174
+ }
175
+
176
+ const branch = currentBranch();
177
+ if (branch === null) {
178
+ fail('db:branch is keyed on the git branch — not a repository, or detached HEAD.');
179
+ process.exit(1);
180
+ }
181
+
182
+ if (flags.drop === 'true') {
183
+ const database = branchDatabaseName(baseNameFromUrl(url), branch);
184
+ if (flags.confirm !== 'true') {
185
+ fail(`Would drop ${database} (branch ${branch}) — confirm with --confirm.`);
186
+ process.exit(1);
187
+ }
188
+ await dropBranchDatabase(url, database);
189
+ success(`Dropped ${database}.`);
190
+ return;
191
+ }
192
+
193
+ const result = await ensureBranchDatabase(url, branch);
194
+ switch (result.status) {
195
+ case 'no-template':
196
+ fail(`No template ${result.template} — build it once: everystack db:template:refresh. Branch databases mint from it.`);
197
+ process.exit(1);
198
+ break;
199
+ case 'exists':
200
+ info(`${result.database} already exists for branch ${branch}.`);
201
+ break;
202
+ case 'created': {
203
+ success(`Minted ${result.database} from ${result.template} (branch ${branch}).`);
204
+ try {
205
+ const provenance = await readTemplateProvenance(url);
206
+ const declared = fingerprintModels(await loadModels(resolveModelsPath(flags.models))).hash;
207
+ if (provenance && provenance.fingerprint !== declared) {
208
+ info(`· your checkout declares ${declared.slice(0, 12)}; the template was built at ${provenance.fingerprint.slice(0, 12)} — db:sync below evolves it (refresh the template when it drifts far).`);
209
+ }
210
+ } catch {
211
+ // The staleness note is best-effort — minting already succeeded.
212
+ }
213
+ break;
214
+ }
215
+ }
216
+ console.log('');
217
+ console.log(` export DATABASE_URL="${result.url}"`);
218
+ console.log('');
219
+ info(`then: everystack db:sync # evolve it with your checkout as you edit`);
220
+ }
@@ -28,20 +28,22 @@
28
28
  */
29
29
 
30
30
  import fs from 'node:fs/promises';
31
- import postgres from 'postgres';
32
31
  import type { ModelDescriptor } from '@everystack/model';
33
- import type { TableContract } from '../authz-contract.js';
34
32
  import { compileDeclaredState } from '../declared-diff.js';
35
33
  import { compileMigration } from '../migration-compile.js';
36
34
  import { compileDrizzleSource } from '../schema-source.js';
37
35
  import { parseSqlSources, type SourceFile } from '../derived-source.js';
38
36
  import { currentGitRef } from '../state-apply.js';
39
- import { createUrlRunner } from '../db-source.js';
37
+ import { createDatabase, dropDatabase, buildIntoDatabase, withDatabase } from '../db-build.js';
40
38
  import { resolveModelsPath } from '../models-path.js';
41
- import { executeSync, buildSyncReport, readSqlDirIfPresent } from './db-sync.js';
39
+ import { readSqlDirIfPresent } from './db-sync.js';
42
40
  import { loadModels } from './db-generate.js';
43
41
  import { step, success, fail, info, warn } from '../output.js';
44
42
 
43
+ // The role derivation moved to the shared builder (db-build) with brick
44
+ // 5-remainder; re-exported here because db:check is where it grew up.
45
+ export { rolesFromContract } from '../db-build.js';
46
+
45
47
  const DEFAULT_SQL_DIR = 'db/sql';
46
48
  const DEFAULT_SCHEMA_OUT = 'db/schema.generated.ts';
47
49
 
@@ -145,19 +147,6 @@ export function checkFailed(findings: CheckFinding[]): boolean {
145
147
  // The ephemeral compose ring.
146
148
  // ---------------------------------------------------------------------------
147
149
 
148
- /** Every role the contract grants to or names in a policy — the compose DB needs them. */
149
- export function rolesFromContract(tables: TableContract[]): string[] {
150
- const roles = new Set<string>();
151
- for (const t of tables) {
152
- for (const grantee of Object.keys(t.grants)) roles.add(grantee);
153
- for (const grantee of Object.keys(t.columnGrants ?? {})) roles.add(grantee);
154
- for (const p of t.policies) for (const r of p.roles) roles.add(r);
155
- }
156
- roles.delete('PUBLIC');
157
- roles.delete('public');
158
- return [...roles].sort();
159
- }
160
-
161
150
  export interface EphemeralComposeResult {
162
151
  database: string;
163
152
  createdRoles: string[];
@@ -168,12 +157,10 @@ export interface EphemeralComposeResult {
168
157
  }
169
158
 
170
159
  /**
171
- * Build the declared state from scratch on an ephemeral database: CREATE
172
- * DATABASE, roles the contract references (NOLOGIN, cluster-level, created
173
- * only if absent), one `executeSync` for both layers, DROP DATABASE —
174
- * always, error or not. The bar is `converged && fingerprintMatch`: a
175
- * fresh database has nothing unmodeled and nothing to drop, so anything
176
- * short of MATCH is a real compose fault.
160
+ * Build the declared state from scratch on an ephemeral database (the
161
+ * shared db-build core), then DROP it always, error or not. The bar is
162
+ * `converged && fingerprintMatch`: a fresh database has nothing unmodeled
163
+ * and nothing to drop, so anything short of MATCH is a real compose fault.
177
164
  */
178
165
  export async function executeEphemeralCompose(
179
166
  adminUrl: string,
@@ -181,46 +168,17 @@ export async function executeEphemeralCompose(
181
168
  sources: SourceFile[] | null,
182
169
  opts: { actor?: string | null; gitRef?: string | null } = {},
183
170
  ): Promise<EphemeralComposeResult> {
184
- const dbName = `escheck_${process.pid}_${Date.now()}`;
185
- const admin = postgres(adminUrl, { max: 1 });
186
- try {
187
- await admin.unsafe(`CREATE DATABASE "${dbName}"`);
188
- } finally {
189
- await admin.end();
190
- }
191
-
192
- const { runner, end } = await createUrlRunner(adminUrl.replace(/\/[^/?]+(\?|$)/, `/${dbName}$1`));
171
+ const database = `escheck_${process.pid}_${Date.now()}`;
172
+ await createDatabase(adminUrl, database);
193
173
  try {
194
- const roles = rolesFromContract(compileDeclaredState(models).contract.tables);
195
- for (const role of roles) {
196
- if (!/^[a-z_][a-z0-9_$]*$/i.test(role)) {
197
- throw new Error(`contract references a role name that is not a plain identifier: ${JSON.stringify(role)}`);
198
- }
199
- await runner(
200
- `DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${role}') THEN CREATE ROLE "${role}" NOLOGIN; END IF; END $$`,
201
- );
202
- }
203
-
204
- const run = await executeSync(runner, models, sources, {
174
+ const built = await buildIntoDatabase(withDatabase(adminUrl, database), models, {
175
+ sources,
205
176
  actor: opts.actor ?? 'db:check',
206
177
  gitRef: opts.gitRef ?? null,
207
178
  });
208
- return {
209
- database: dbName,
210
- createdRoles: roles,
211
- converged: run.converged,
212
- fingerprintMatch: run.fingerprintMatch,
213
- fingerprint: run.state.toFingerprint,
214
- report: buildSyncReport(run),
215
- };
179
+ return { database, ...built };
216
180
  } finally {
217
- await end?.();
218
- const reaper = postgres(adminUrl, { max: 1 });
219
- try {
220
- await reaper.unsafe(`DROP DATABASE IF EXISTS "${dbName}" WITH (FORCE)`);
221
- } finally {
222
- await reaper.end();
223
- }
181
+ await dropDatabase(adminUrl, database);
224
182
  }
225
183
  }
226
184
 
@@ -0,0 +1,139 @@
1
+ /**
2
+ * `everystack db:fork` — fork one stage's database into another
3
+ * (branch-stages plan, brick 2; docs/plans/branch-stages.md).
4
+ *
5
+ * db:fork --from-stage staging --stage feat-foo --confirm [--backup <id>]
6
+ *
7
+ * Three moves, all existing surfaces: back up the source (or reuse a named
8
+ * backup), presign the dump with the OPERATOR's credentials — the presigned
9
+ * URL is the whole cross-stage hand-off, no bucket policies, no IAM
10
+ * coupling, expires in an hour — and invoke the target's `db:restore` with
11
+ * the URL. The target stage must already be deployed (`sst deploy --stage
12
+ * feat-foo`); the fork replaces its database with the source's data, and
13
+ * the branch's own schema changes then land through the front door
14
+ * (`db:plan` → `db:apply` — the forked state is one a main commit declares,
15
+ * so the fast-forward rule composes for free).
16
+ *
17
+ * Production is never a fork target, flat — that is disaster recovery
18
+ * (`db:restore`), not a fork. Forking FROM production carries the PII
19
+ * data-governance warning. Teardown is the stage's own: `sst remove`.
20
+ */
21
+
22
+ import { resolveConfig, opsFunction, type CliConfig } from '../config.js';
23
+ import { invokeAction, presignGet } from '../aws.js';
24
+ import { keyForId, parseBackupRef, isProductionTier, crossStageGuard } from '../backup.js';
25
+ import { step, success, fail, info, warn } from '../output.js';
26
+
27
+ export type ForkGuardVerdict =
28
+ | { ok: true; piiWarning: string | null }
29
+ | { ok: false; reason: string };
30
+
31
+ /** The pure gate — see the module docblock for the decisions it enforces. */
32
+ export function checkForkGuards(input: { from?: string; stage?: string; confirm: boolean }): ForkGuardVerdict {
33
+ const { from, stage, confirm } = input;
34
+ if (!from || from === 'true') {
35
+ return { ok: false, reason: 'db:fork needs the source: --from-stage <name> (the stage whose data you are forking).' };
36
+ }
37
+ if (!stage || stage === 'true') {
38
+ return { ok: false, reason: 'db:fork needs the target: --stage <name> (an already-deployed feature stage; `sst deploy --stage <name>` first).' };
39
+ }
40
+ if (from === stage) {
41
+ return { ok: false, reason: 'source and target are the same stage — restoring a stage onto itself is db:restore, not a fork.' };
42
+ }
43
+ if (isProductionTier(stage)) {
44
+ return { ok: false, reason: `"${stage}" is production-tier — a fork never overwrites production. Restoring production is disaster recovery: db:restore, with its own ceremony.` };
45
+ }
46
+ if (!confirm) {
47
+ return { ok: false, reason: `db:fork REPLACES ${stage}'s database with ${from}'s data — confirm with --confirm.` };
48
+ }
49
+ const pii = crossStageGuard(from, stage);
50
+ return { ok: true, piiWarning: pii.requiresConfirm ? (pii.warning ?? null) : null };
51
+ }
52
+
53
+ export async function dbForkCommand(flags: Record<string, string>): Promise<void> {
54
+ const verdict = checkForkGuards({
55
+ from: flags['from-stage'],
56
+ stage: flags.stage,
57
+ confirm: flags.confirm === 'true',
58
+ });
59
+ if (!verdict.ok) {
60
+ fail(verdict.reason);
61
+ process.exit(1);
62
+ }
63
+ if (verdict.piiWarning) warn(verdict.piiWarning.replace(/pass --confirm to proceed\.?$/i, 'confirmed.'));
64
+
65
+ const from = flags['from-stage'];
66
+ const target = flags.stage;
67
+
68
+ step(`Resolving source stage ${from}...`);
69
+ let source: CliConfig;
70
+ try {
71
+ source = await resolveConfig(from);
72
+ } catch (err: any) {
73
+ fail(err.message);
74
+ process.exit(1);
75
+ }
76
+ if (!source.backupsBucket) {
77
+ fail(`Stage ${from} has no backupsBucket in its deployed config — logical backups are the fork's transport (docs/backups.md).`);
78
+ process.exit(1);
79
+ }
80
+
81
+ // Move 1: a backup of the source — fresh by default, or a named one.
82
+ let backupId = flags.backup;
83
+ if (backupId && backupId !== 'true') {
84
+ const ref = parseBackupRef(backupId);
85
+ if (!ref || !keyForId(backupId)) {
86
+ fail(`Malformed backup id: ${backupId}`);
87
+ process.exit(1);
88
+ }
89
+ if (ref.stage !== from) {
90
+ fail(`Backup ${backupId} belongs to stage "${ref.stage}", not --from-stage ${from} — a fork's data names its source honestly.`);
91
+ process.exit(1);
92
+ }
93
+ info(`Reusing backup ${backupId}.`);
94
+ } else {
95
+ step(`Backing up ${from} (pg_dump → S3; may take a while for large databases)...`);
96
+ const result: any = await invokeAction(source.region, opsFunction(source), 'db:backup', { stage: from });
97
+ if (result?.error) {
98
+ fail(`Source backup failed: ${result.error}`);
99
+ process.exit(1);
100
+ }
101
+ backupId = result.id;
102
+ info(`Backup ${backupId}.`);
103
+ }
104
+
105
+ // Move 2: the presigned hand-off — the operator's credentials ARE the authorization.
106
+ step('Presigning the dump (1 hour)...');
107
+ let url: string;
108
+ try {
109
+ url = await presignGet(source.region, source.backupsBucket, keyForId(backupId)!, 3600);
110
+ } catch (err: any) {
111
+ fail(`Presign failed: ${err.message}`);
112
+ process.exit(1);
113
+ }
114
+
115
+ // Move 3: restore into the target over the URL.
116
+ step(`Resolving target stage ${target}...`);
117
+ let targetConfig: CliConfig;
118
+ try {
119
+ targetConfig = await resolveConfig(target);
120
+ } catch (err: any) {
121
+ fail(`${err.message}\nDeploy the feature stage first: sst deploy --stage ${target}`);
122
+ process.exit(1);
123
+ }
124
+ step(`Restoring into ${target} (streamed; replaces its database)...`);
125
+ const restored: any = await invokeAction(targetConfig.region, opsFunction(targetConfig), 'db:restore', {
126
+ url,
127
+ confirm: true,
128
+ });
129
+ if (restored?.error) {
130
+ fail(`Restore failed: ${restored.error}`);
131
+ process.exit(1);
132
+ }
133
+
134
+ success(`Forked ${from} → ${target} (backup ${backupId}).`);
135
+ info('Next:');
136
+ info(` everystack db:fingerprint --stage ${target} # where the fork is (a state some main commit declares)`);
137
+ info(` everystack db:plan --stage ${target} # your branch's edge on top — descent composes for free`);
138
+ info(` sst remove --stage ${target} # teardown takes the database with it`);
139
+ }
@@ -25,6 +25,8 @@ import { introspectSchema } from '../schema-introspect.js';
25
25
  import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
26
26
  import { mintEdgePlan, planHash, buildPlanSummary } from '../edge-plan.js';
27
27
  import { verifyDescent } from '../git-descent.js';
28
+ import { planBackfills, readBackfillLog } from '../backfill.js';
29
+ import { readSqlDirIfPresent } from './db-sync.js';
28
30
  import { currentGitRef } from '../state-apply.js';
29
31
  import { resolveDbSource, createUrlRunner, type DbSource } from '../db-source.js';
30
32
  import { resolveModelsPath } from '../models-path.js';
@@ -113,6 +115,25 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
113
115
  warn('db:apply from this checkout will refuse this plan — rebase first, then re-mint.');
114
116
  }
115
117
  }
118
+
119
+ // Visibility, never automation (brick 8): the review surface names the
120
+ // target's pending backfills — expand → backfill → contract stays a
121
+ // sequence of deliberate acts, and a contraction plan minted while its
122
+ // data move is unrun should be caught by the human reading this.
123
+ const backfillFiles = await readSqlDirIfPresent('db/backfills');
124
+ if (backfillFiles !== null) {
125
+ try {
126
+ const bf = planBackfills(backfillFiles, await readBackfillLog(runner));
127
+ if (bf.blocked.length > 0) {
128
+ warn(`${bf.blocked.length} backfill(s) BLOCKED on this target (edited after running) — everystack db:backfill names the fix.`);
129
+ }
130
+ if (bf.pending.length > 0) {
131
+ warn(`${bf.pending.length} backfill(s) pending on this target: ${bf.pending.map((p) => p.file).join(', ')} — run them deliberately (db:backfill --apply) before a contraction lands.`);
132
+ }
133
+ } catch (err: any) {
134
+ warn(`backfill lane: could not read everystack.backfill_log (${err.message}) — pending-backfill visibility unavailable.`);
135
+ }
136
+ }
116
137
  if (out !== '-') {
117
138
  success(`Wrote ${out} — review it, then \`everystack db:apply --plan ${out}\`.`);
118
139
  warn('Plans are ephemeral release artifacts — attach to the run, do NOT commit.');
@@ -46,6 +46,7 @@ import { fingerprintModels } from '../schema-fingerprint.js';
46
46
  import { compileDrizzleSource } from '../schema-source.js';
47
47
  import type { SourceFile } from '../derived-source.js';
48
48
  import { resolveDbSource, createUrlRunner, type DbSource } from '../db-source.js';
49
+ import { planBackfills, readBackfillLog } from '../backfill.js';
49
50
  import { resolveModelsPath } from '../models-path.js';
50
51
  import { executeReconcile, buildReconcileReport, type ReconcileRun } from './db-reconcile.js';
51
52
  import { loadModels } from './db-generate.js';
@@ -294,8 +295,22 @@ export async function dbSyncCommand(flags: Record<string, string>): Promise<void
294
295
  },
295
296
  );
296
297
 
298
+ // Visibility, never automation (brick 8): pending backfills surface
299
+ // here, but a schema verb never runs a data job.
300
+ let backfills: { pending: number; blocked: number } | null = null;
301
+ const backfillFiles = await readSqlDirIfPresent('db/backfills');
302
+ if (backfillFiles !== null) {
303
+ try {
304
+ const bf = planBackfills(backfillFiles, await readBackfillLog(runner));
305
+ backfills = { pending: bf.pending.length, blocked: bf.blocked.length };
306
+ } catch (err: any) {
307
+ warn(`backfill lane: could not read everystack.backfill_log (${err.message}) — pending-backfill visibility unavailable.`);
308
+ }
309
+ }
310
+
297
311
  if (flags.json === 'true') {
298
312
  console.log(JSON.stringify({
313
+ backfills,
299
314
  state: {
300
315
  applied: run.state.applied,
301
316
  executed: run.state.classified.executable.length,
@@ -317,6 +332,12 @@ export async function dbSyncCommand(flags: Record<string, string>): Promise<void
317
332
  }, null, 2));
318
333
  } else {
319
334
  for (const line of buildSyncReport(run)) info(line);
335
+ if (backfills && backfills.blocked > 0) {
336
+ warn(`${backfills.blocked} backfill(s) BLOCKED (edited after running) — everystack db:backfill names the fix.`);
337
+ }
338
+ if (backfills && backfills.pending > 0) {
339
+ info(`· ${backfills.pending} backfill(s) pending — data jobs run deliberately, never as a sync side effect: everystack db:backfill --apply`);
340
+ }
320
341
  console.log('');
321
342
  if (!run.converged) {
322
343
  fail('NOT converged — fix the findings above and re-run db:sync.');
@@ -36,9 +36,10 @@ function cliVersion(): string {
36
36
  }
37
37
 
38
38
  /** Import the app's Model barrel for the data-model chapter. Absent or unloadable → honest warning, no chapter. */
39
- async function loadDataModel(root: string, hasIndex: boolean): Promise<{ dataModel: string | null; warning: string | null }> {
40
- if (!hasIndex) return { dataModel: null, warning: null };
41
- const barrel = path.join(root, 'models', 'index.ts');
39
+ async function loadDataModel(root: string, models: { dir: string | null; hasIndex: boolean }): Promise<{ dataModel: string | null; warning: string | null }> {
40
+ if (!models.hasIndex || !models.dir) return { dataModel: null, warning: null };
41
+ // The detected home (db/models preferred, legacy models/ fallback) — never hardcoded.
42
+ const barrel = path.join(root, models.dir, 'index.ts');
42
43
  try {
43
44
  const mod = await import(pathToFileURL(barrel).href);
44
45
  const models = mod.models ?? mod.default;
@@ -64,7 +65,7 @@ export interface RunbookRun {
64
65
  /** Result-producing core, shared by the command, `--check`, and the `audit` capstone. */
65
66
  export async function runRunbook(root: string): Promise<RunbookRun> {
66
67
  const reality = detectProjectReality(root);
67
- const { dataModel, warning } = await loadDataModel(reality.root, reality.models.hasIndex);
68
+ const { dataModel, warning } = await loadDataModel(reality.root, reality.models);
68
69
  const outPath = path.join(reality.root, OUT_REL);
69
70
  const oldText = fs.existsSync(outPath) ? fs.readFileSync(outPath, 'utf8') : null;
70
71
  const result = generateRunbook(reality, { cliVersion: cliVersion(), dataModel }, oldText ?? undefined);