@everystack/cli 0.3.31 → 0.4.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.
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `everystack db:pull` — generate `field()` Models from a live database (the brownfield on-ramp).
3
3
  *
4
- * db:pull [--stage <name> | --database-url <url>] [--schema public] [--out <dir | file.ts>]
4
+ * db:pull [--stage <name> | --database-url <url>] [--schema public] [--out <dir | file.ts>] [--abilities public-read]
5
5
  *
6
6
  * The reverse of db:generate: that writes migrations FROM Models; this writes Models FROM the
7
7
  * database. It introspects the deployed schema through the read-only ops Lambda `db:query`
@@ -23,7 +23,9 @@
23
23
  import fs from 'node:fs/promises';
24
24
  import path from 'node:path';
25
25
  import { introspectSchema } from '../schema-introspect.js';
26
- import { renderModelSource, renderModelFiles, pullableTables } from '../model-render.js';
26
+ import { introspectDerived, type DerivedCatalog } from '../derived-introspect.js';
27
+ import { renderDerivedSource, type DerivedRenderResult } from '../derived-render.js';
28
+ import { renderModelSource, renderModelFiles, pullableTables, modelVarName, ABILITY_PRESETS } from '../model-render.js';
27
29
  import type { QueryRunner } from '../authz-contract.js';
28
30
  import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
29
31
  import { resolveConfig, opsFunction } from '../config.js';
@@ -49,6 +51,14 @@ function lambdaRunner(region: string, fn: string): QueryRunner {
49
51
  export async function dbPullCommand(flags: Record<string, string>): Promise<void> {
50
52
  const schema = flags.schema || 'public';
51
53
 
54
+ // The read-model scaffold: default 'commented' surfaces the authz decision in every
55
+ // model; a preset stamps it. Validated up front so a typo fails before introspection.
56
+ const abilities = flags.abilities || 'commented';
57
+ if (abilities !== 'commented' && !ABILITY_PRESETS[abilities]) {
58
+ fail(`Unknown --abilities preset '${abilities}'. Known: ${Object.keys(ABILITY_PRESETS).join(', ')} (omit the flag to scaffold the decision as comments).`);
59
+ process.exit(1);
60
+ }
61
+
52
62
  let dbSource: DbSource;
53
63
  try {
54
64
  dbSource = resolveDbSource(flags);
@@ -58,6 +68,7 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
58
68
  }
59
69
 
60
70
  let current;
71
+ let derivedCatalog: DerivedCatalog | undefined;
61
72
  try {
62
73
  let runner: QueryRunner;
63
74
  let end: (() => Promise<void>) | undefined;
@@ -72,6 +83,9 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
72
83
  }
73
84
  note(`Introspecting live database (schema: ${schema})...`);
74
85
  current = await introspectSchema(runner);
86
+ // The derived layer rides the same pull (B5) — views/matviews/functions/sequences
87
+ // render as descriptors; adoption is pull → commit → --baseline → clean reconcile.
88
+ derivedCatalog = await introspectDerived(runner);
75
89
  await end?.();
76
90
  } catch (err: any) {
77
91
  fail(err.message);
@@ -90,11 +104,24 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
90
104
  process.exit(1);
91
105
  }
92
106
 
107
+ // The derived layer (B5): rendered from the live catalog with dependsOn from the real
108
+ // edge graph. Everything inexpressible is a stderr warning AND an inline FIXME.
109
+ const knownTables = new Map(pulled.map((t) => [t.table, modelVarName(t.table)]));
110
+ const derived: DerivedRenderResult = renderDerivedSource(derivedCatalog ?? { objects: [], edges: [], provenance: [] }, {
111
+ knownTables,
112
+ sequences: current.sequences,
113
+ });
114
+ for (const w of derived.warnings) caution(w);
115
+ const derivedCount = derived.names.length + derived.sequenceNames.length;
116
+ if (derivedCount > 0) {
117
+ detail(`Derived layer: ${derived.names.length} descriptor(s) + ${derived.sequenceNames.length} sequence(s) rendered (adopt with db:reconcile --baseline after committing).`);
118
+ }
119
+
93
120
  let source: string;
94
121
  if (flags.out && !flags.out.endsWith('.ts')) {
95
122
  // A directory: one file per model + index.ts — the default shape for a real app.
96
123
  const dir = path.resolve(flags.out);
97
- const files = renderModelFiles(current, { schema });
124
+ const files = renderModelFiles(current, { schema, abilities, derived });
98
125
  try {
99
126
  await fs.mkdir(dir, { recursive: true });
100
127
  const written = new Set(files.map((f) => f.file));
@@ -111,7 +138,7 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
111
138
  source = files.map((f) => f.source).join('\n');
112
139
  } else if (flags.out) {
113
140
  const outPath = path.resolve(flags.out);
114
- source = renderModelSource(current, { schema });
141
+ source = renderModelSource(current, { schema, abilities, derived });
115
142
  try {
116
143
  await fs.mkdir(path.dirname(outPath), { recursive: true });
117
144
  await fs.writeFile(outPath, source, 'utf8');
@@ -121,12 +148,17 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
121
148
  }
122
149
  ok(`Wrote ${path.relative(process.cwd(), outPath)} — ${pulled.length} model(s).`);
123
150
  } else {
124
- source = renderModelSource(current, { schema });
151
+ source = renderModelSource(current, { schema, abilities, derived });
125
152
  process.stdout.write(source);
126
153
  ok(`Rendered ${pulled.length} model(s) from schema "${schema}" (stdout — redirect or pass --out to save).`);
127
154
  }
128
155
 
129
156
  const flagged = (source.match(/\/\/ (FIXME|TODO|composite|CHECK|FK →)/g) ?? []).length;
130
157
  if (flagged) caution(`${flagged} inline comment(s) flag things to review (unmapped types, checks, cross-schema FKs).`);
158
+ if (abilities === 'commented') {
159
+ note(`Each model scaffolds its authz decision as comments — author them (db:check fails until every model declares), or stamp the common case: db:pull --abilities public-read.`);
160
+ } else {
161
+ note(`Stamped '${abilities}' abilities into every model — review the generated stanzas; they are code, not defaults.`);
162
+ }
131
163
  process.exit(0);
132
164
  }
@@ -1,9 +1,13 @@
1
1
  /**
2
2
  * `everystack db:reconcile` — deploy the derived layer (functions, views,
3
- * matviews) by reconciling the live database against the db/sql sources.
3
+ * matviews, triggers, escape-hatch objects) by reconciling the live database
4
+ * against the DECLARED descriptors (defineView / defineMaterializedView /
5
+ * defineFunction / defineSql / trigger() on the models). Descriptors are the
6
+ * single home — a checkout still carrying db/sql *.sql files fails loudly with
7
+ * the migration path (B7).
4
8
  *
5
- * db:reconcile [--sql-dir db/sql] [--stage <name> | --database-url <url>]
6
- * [--apply] [--check] [--baseline] [--overwrite-drift] [--json]
9
+ * db:reconcile [--stage <name> | --database-url <url>]
10
+ * [--apply] [--check] [--baseline] [--rebaseline] [--overwrite-drift] [--json]
7
11
  *
8
12
  * Derived objects do not migrate; they deploy. The plan is computed fresh
9
13
  * every run: source objects (hashed, comment/whitespace-insensitive) joined
@@ -16,33 +20,35 @@
16
20
  *
17
21
  * Hand-edited live objects are DRIFT — reported, applied over only with
18
22
  * `--overwrite-drift`. Never-reconciled matches need `--baseline` once (the
19
- * explicit trust that live == source, recorded). `--apply` needs a direct
23
+ * explicit trust that live == source, recorded). A source RE-RENDER over an
24
+ * untouched live object — the db/sql-era → descriptor migration — needs
25
+ * `--rebaseline` once: live is verified (defHash match), the new source hash
26
+ * is re-recorded, nothing rebuilds. `--apply` needs a direct
20
27
  * connection (`--database-url` / DATABASE_URL): the ops Lambda `db:query`
21
28
  * path is read-only by design, so plan/check work anywhere but writes go
22
29
  * through a credentialed connection you chose to hold.
23
30
  */
24
31
 
25
- import fs from 'node:fs/promises';
26
- import path from 'node:path';
27
32
  import type { QueryRunner } from '../authz-contract.js';
28
- import { parseSqlSources, type SourceFile } from '../derived-source.js';
33
+ import type { SourceObject } from '../derived-source.js';
29
34
  import { introspectDerived } from '../derived-introspect.js';
30
35
  import { planReconcile, type ReconcilePlan, type ReconcileOptions } from '../derived-plan.js';
31
36
  import {
32
37
  renderReconcileSql,
33
38
  renderProvenanceUpsert,
39
+ renderProvenanceMigrate,
40
+ triggerDropSql,
34
41
  renderProvenanceDelete,
35
42
  renderSchemaLogInsert,
36
43
  ENSURE_RECONCILER_SQL,
37
44
  } from '../derived-apply.js';
38
45
  import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
46
+ import { loadDeclaredDerived, retiredSqlDirAnywhere, retiredSqlDirFlagRefusal, type DeclaredDerived } from '../declared-derived.js';
39
47
  import { currentGitRef } from '../state-apply.js';
40
48
  import { resolveConfig, opsFunction } from '../config.js';
41
49
  import { invokeAction } from '../aws.js';
42
50
  import { step, success, fail, info, warn } from '../output.js';
43
51
 
44
- const DEFAULT_SQL_DIR = 'db/sql';
45
-
46
52
  // ---------------------------------------------------------------------------
47
53
  // The testable core.
48
54
  // ---------------------------------------------------------------------------
@@ -52,6 +58,8 @@ export interface ExecuteOptions extends ReconcileOptions {
52
58
  actor?: string | null;
53
59
  gitRef?: string | null;
54
60
  planRef?: string | null;
61
+ /** Descriptor-compiled objects (the declared derived layer) — the ONE source stream. */
62
+ declared?: SourceObject[];
55
63
  /** Injectable clock for tests. */
56
64
  now?: () => number;
57
65
  }
@@ -73,22 +81,35 @@ export interface ReconcileRun {
73
81
  /** Statements Postgres forbids inside a transaction block — they implicitly commit. */
74
82
  export const NON_TRANSACTIONAL_RE = /\bCONCURRENTLY\b|\bVACUUM\b/i;
75
83
 
84
+ /** Session identity — the guard against postgres-js's silent reconnect (see the apply path). */
85
+ export const BACKEND_PID_SQL = 'SELECT pg_backend_pid() AS pid';
86
+
87
+ // Dollar-quoted bodies and comments are CONTENT, not batch shape — a plpgsql helper whose
88
+ // body says `REFRESH MATERIALIZED VIEW CONCURRENTLY …` is perfectly transactional to CREATE.
89
+ // Stripping errs loud, never silent: over-stripping would wrongly WRAP a truly-concurrent
90
+ // statement, and PostgreSQL then refuses it with its own clear error at apply time.
91
+ const DOLLAR_BODY_RE = /\$([A-Za-z_][A-Za-z0-9_]*)?\$[\s\S]*?\$\1\$/g;
92
+ const LINE_COMMENT_RE = /--[^\n]*/g;
93
+ const BLOCK_COMMENT_RE = /\/\*[\s\S]*?\*\//g;
94
+
76
95
  /**
77
96
  * Whether the whole batch can run inside one transaction. Almost always yes — the escape hatch
78
97
  * is a consumer whose matview source carries a `CREATE INDEX CONCURRENTLY` (rendered verbatim) or
79
- * a `VACUUM`, which self-commit and cannot live in a transaction block.
98
+ * a `VACUUM`, which self-commit and cannot live in a transaction block. Only the statement's own
99
+ * shape decides: dollar-quoted bodies and comments are stripped before the test.
80
100
  */
81
101
  export function isTransactionalBatch(statements: string[]): boolean {
82
- return statements.every((s) => !NON_TRANSACTIONAL_RE.test(s));
102
+ return statements.every((s) => !NON_TRANSACTIONAL_RE.test(
103
+ s.replace(DOLLAR_BODY_RE, '').replace(BLOCK_COMMENT_RE, '').replace(LINE_COMMENT_RE, ''),
104
+ ));
83
105
  }
84
106
 
85
107
  export async function executeReconcile(
86
108
  runner: QueryRunner,
87
- sources: SourceFile[],
88
109
  options: ExecuteOptions = {},
89
110
  ): Promise<ReconcileRun> {
90
111
  const live = await introspectDerived(runner);
91
- const parsed = parseSqlSources(sources);
112
+ const parsed = { objects: options.declared ?? [], warnings: [] as string[] };
92
113
  const plan = planReconcile(parsed, live, options);
93
114
  const rendered = renderReconcileSql(plan, parsed.objects);
94
115
 
@@ -106,7 +127,7 @@ export async function executeReconcile(
106
127
  refusal: `drift on ${plan.drift.map((d) => d.identity).join(', ')} — inspect, then re-run with --overwrite-drift to rebuild from source`,
107
128
  };
108
129
  }
109
- if (rendered.statements.length === 0 && rendered.record.length === 0 && rendered.remove.length === 0) {
130
+ if (rendered.statements.length === 0 && rendered.record.length === 0 && rendered.remove.length === 0 && rendered.migrate.length === 0) {
110
131
  return { plan, applied: false, statements: [] };
111
132
  }
112
133
 
@@ -124,8 +145,26 @@ export async function executeReconcile(
124
145
 
125
146
  if (atomic) await runner('BEGIN');
126
147
  try {
148
+ // The session guard: postgres-js `max: 1` is a pool of one, not a session lease — a
149
+ // connection that dies between runner calls silently reconnects, the open transaction
150
+ // aborts server-side, and every later call runs in autocommit on a fresh session. The
151
+ // bookkeeping would then record a state the DDL never reached, and the object would read
152
+ // as converged forever (the red-team's false-convergence finding). Pin the backend pid
153
+ // at BEGIN; refuse the bookkeeping if it moved. createUrlRunner also disables the
154
+ // driver's max_lifetime recycling — the deterministic trigger during long applies.
155
+ const pidAtBegin = atomic ? Number((await runner(BACKEND_PID_SQL))[0]?.pid) : null;
156
+
127
157
  if (rendered.statements.length > 0) {
128
- await runner(rendered.statements.join(';\n'));
158
+ if (atomic) {
159
+ await runner(rendered.statements.join(';\n'));
160
+ } else {
161
+ // Self-committing statements (CONCURRENTLY, VACUUM) refuse ANY transaction block —
162
+ // including the implicit one a joined multi-statement simple query runs in, so the
163
+ // batch MUST go one statement per protocol message. A failure mid-way leaves the
164
+ // earlier statements committed: the documented cost of the unwrapped path; the next
165
+ // plan re-converges from what actually landed.
166
+ for (const statement of rendered.statements) await runner(statement);
167
+ }
129
168
  }
130
169
 
131
170
  // Re-read the catalog so provenance records the def hashes of what NOW exists, not what we hoped
@@ -137,20 +176,42 @@ export async function executeReconcile(
137
176
  const bookkeeping: string[] = [];
138
177
  for (const identity of rendered.record) {
139
178
  const src = srcById.get(identity);
179
+ if (!src) {
180
+ throw new Error(`reconcile applied but ${identity} has no source object — provenance not recorded, investigate`);
181
+ }
182
+ // 'sql'-kind objects are invisible to the catalog: provenance records the source
183
+ // hash on both sides plus HOW TO REMOVE it (the escape hatch's whole contract).
184
+ if (src.kind === 'sql') {
185
+ bookkeeping.push(renderProvenanceUpsert(identity, src.hash, src.hash, 'sql', src.drop));
186
+ continue;
187
+ }
140
188
  const liveObj = liveById.get(identity);
141
- if (!src || !liveObj) {
189
+ if (!liveObj) {
142
190
  throw new Error(`reconcile applied but ${identity} is not introspectable afterwards — provenance not recorded, investigate`);
143
191
  }
144
- bookkeeping.push(renderProvenanceUpsert(identity, src.hash, liveObj.defHash));
192
+ const dropSql = src.kind === 'trigger' && src.table ? triggerDropSql(src.name, src.table) : undefined;
193
+ bookkeeping.push(renderProvenanceUpsert(identity, src.hash, liveObj.defHash, src.kind, dropSql));
145
194
  }
195
+ for (const m of rendered.migrate) bookkeeping.push(renderProvenanceMigrate(m.from, m.to));
146
196
  for (const identity of rendered.remove) bookkeeping.push(renderProvenanceDelete(identity));
147
197
  bookkeeping.push(renderSchemaLogInsert({
148
198
  kind: 'compute reconcile',
149
- sql: rendered.statements.join(';\n') || '-- provenance-only (baseline/prune)',
199
+ sql: rendered.statements.join(';\n') || '-- provenance-only (baseline/rebaseline/prune)',
150
200
  outcome: 'applied',
151
201
  actor: options.actor, gitRef: options.gitRef, planRef: options.planRef,
152
202
  durationMs: now() - started,
153
203
  }));
204
+
205
+ if (atomic) {
206
+ const pidNow = Number((await runner(BACKEND_PID_SQL))[0]?.pid);
207
+ if (pidNow !== pidAtBegin) {
208
+ throw new Error(
209
+ `the session changed mid-transaction (backend pid ${pidAtBegin} → ${pidNow}) — the connection `
210
+ + `was swapped or recycled under the open transaction (pooler restart? server restart?), PostgreSQL `
211
+ + `aborted it, and NOTHING from this batch is applied. No bookkeeping was written; re-run the apply.`,
212
+ );
213
+ }
214
+ }
154
215
  await runner(bookkeeping.join(';\n'));
155
216
 
156
217
  if (atomic) await runner('COMMIT');
@@ -205,7 +266,7 @@ export function formatBytes(bytes: number): string {
205
266
  }
206
267
 
207
268
  const VERB_GLYPH: Record<string, string> = {
208
- create: '+', replace: '~', drop: '-', refresh: '↻', baseline: '◦', prune: '·',
269
+ create: '+', replace: '~', drop: '-', refresh: '↻', baseline: '◦', rebaseline: '≈', prune: '·',
209
270
  };
210
271
 
211
272
  export function buildReconcileReport(plan: ReconcilePlan): string[] {
@@ -220,6 +281,16 @@ export function buildReconcileReport(plan: ReconcilePlan): string[] {
220
281
  lines.push(`estimated rebuild: ${formatBytes(plan.totalRebuildBytes)} across ${rebuilt.length} matview(s)`);
221
282
  }
222
283
  for (const d of plan.drift) lines.push(`! drift ${d.identity} — ${d.detail}`);
284
+ for (const r of plan.regrants) {
285
+ lines.push(`≠ regrant ${r.identity} — live grants differ from source (${r.statements.join('; ')})`);
286
+ }
287
+ for (const d of plan.dependencyDrift) {
288
+ const parts = [
289
+ ...(d.missing.length ? [`reads ${d.missing.join(', ')} undeclared`] : []),
290
+ ...(d.stale.length ? [`declares ${d.stale.join(', ')} but never reads it`] : []),
291
+ ];
292
+ lines.push(`! depends ${d.identity} — ${parts.join('; ')} (fix dependsOn in the descriptor)`);
293
+ }
223
294
  if (plan.needsBaseline.length > 0) {
224
295
  lines.push(`! needs baseline (${plan.needsBaseline.length}): ${plan.needsBaseline.join(', ')} — run with --baseline to record trust, once`);
225
296
  }
@@ -232,12 +303,15 @@ export function buildReconcileReport(plan: ReconcilePlan): string[] {
232
303
  return lines;
233
304
  }
234
305
 
235
- /** Anything that makes `--check` fail: pending work or unresolved findings. */
306
+ /** Anything that makes `--check` fail: pending work or unresolved findings.
307
+ * Baseline and rebaseline are explicit adoption the operator asked for, not findings. */
236
308
  export function checkFails(plan: ReconcilePlan): boolean {
237
- return plan.actions.some((a) => a.action !== 'baseline')
309
+ return plan.actions.some((a) => a.action !== 'baseline' && a.action !== 'rebaseline')
238
310
  || plan.drift.length > 0
239
311
  || plan.needsBaseline.length > 0
240
- || plan.blocked.length > 0;
312
+ || plan.blocked.length > 0
313
+ || plan.regrants.length > 0
314
+ || plan.dependencyDrift.length > 0;
241
315
  }
242
316
 
243
317
  // ---------------------------------------------------------------------------
@@ -253,25 +327,27 @@ function lambdaRunner(region: string, fn: string): QueryRunner {
253
327
  };
254
328
  }
255
329
 
256
- async function readSqlDir(dir: string): Promise<SourceFile[]> {
257
- let entries: string[];
258
- try {
259
- entries = await fs.readdir(dir);
260
- } catch {
261
- throw new Error(`No SQL source directory at ${dir} (set --sql-dir).`);
262
- }
263
- const files = entries.filter((f) => f.endsWith('.sql')).sort();
264
- return Promise.all(files.map(async (file) => ({
265
- file: path.join(dir, file),
266
- sql: await fs.readFile(path.join(dir, file), 'utf-8'),
267
- })));
268
- }
269
-
270
330
  export async function dbReconcileCommand(flags: Record<string, string>): Promise<void> {
271
- const sqlDir = flags['sql-dir'] || DEFAULT_SQL_DIR;
272
331
  const apply = flags.apply === 'true';
273
332
  const check = flags.check === 'true';
274
333
 
334
+ // B7 tombstone — db/sql is retired and no longer read. Failing beats silently
335
+ // skipping: skipped files would leave live objects unmanaged.
336
+ try {
337
+ if (flags['sql-dir']) {
338
+ fail(await retiredSqlDirFlagRefusal(flags['sql-dir']));
339
+ process.exit(1);
340
+ }
341
+ const retired = await retiredSqlDirAnywhere(flags.models);
342
+ if (retired) {
343
+ fail(retired);
344
+ process.exit(1);
345
+ }
346
+ } catch (err: any) {
347
+ fail(err.message);
348
+ process.exit(1);
349
+ }
350
+
275
351
  let dbSource: DbSource;
276
352
  try {
277
353
  dbSource = resolveDbSource(flags);
@@ -292,6 +368,10 @@ export async function dbReconcileCommand(flags: Record<string, string>): Promise
292
368
  fail('--baseline only persists with --apply — on its own it records nothing (it silently no-op\'d before). Re-run with --apply, or drop --baseline to just see the plan (it already lists what needs baseline).');
293
369
  process.exit(1);
294
370
  }
371
+ if (flags.rebaseline === 'true' && !apply) {
372
+ fail('--rebaseline only persists with --apply — on its own it records nothing. Re-run with --apply, or drop --rebaseline to see the plan (source re-renders show as drop+create with reason "source changed").');
373
+ process.exit(1);
374
+ }
295
375
 
296
376
  // --rebuild and --baseline are opposite answers to the same "can't verify first contact" question.
297
377
  if (flags.rebuild === 'true' && flags.baseline === 'true') {
@@ -299,13 +379,18 @@ export async function dbReconcileCommand(flags: Record<string, string>): Promise
299
379
  process.exit(1);
300
380
  }
301
381
 
302
- let sources: SourceFile[];
382
+ // The DECLARED derived layer (descriptors from the models barrel) — the one source
383
+ // stream — plus the pending table renames, so trigger provenance migrates.
384
+ let declared: DeclaredDerived | null = null;
303
385
  try {
304
- sources = await readSqlDir(sqlDir);
386
+ declared = await loadDeclaredDerived(flags.models);
305
387
  } catch (err: any) {
306
388
  fail(err.message);
307
389
  process.exit(1);
308
390
  }
391
+ if (declared && declared.objects.length > 0) {
392
+ info(`${declared.objects.length} declared derived object(s) from the models barrel.`);
393
+ }
309
394
 
310
395
  let runner: QueryRunner;
311
396
  let end: (() => Promise<void>) | undefined;
@@ -319,9 +404,12 @@ export async function dbReconcileCommand(flags: Record<string, string>): Promise
319
404
  }
320
405
 
321
406
  try {
322
- const run = await executeReconcile(runner, sources, {
407
+ const run = await executeReconcile(runner, {
323
408
  apply,
409
+ declared: declared?.objects,
410
+ renamedTables: declared?.renamedTables,
324
411
  baseline: flags.baseline === 'true',
412
+ rebaseline: flags.rebaseline === 'true',
325
413
  rebuild: flags.rebuild === 'true',
326
414
  overwriteDrift: flags['overwrite-drift'] === 'true',
327
415
  actor: process.env.USER ?? null,
@@ -337,7 +425,10 @@ export async function dbReconcileCommand(flags: Record<string, string>): Promise
337
425
  } else if (run.applied) {
338
426
  success(`Applied ${run.statements.length} statement(s); provenance and schema_log recorded.`);
339
427
  if (run.plan.actions.some((a) => a.action === 'baseline')) {
340
- warn('baseline recorded trust WITHOUT verifying live matches source — on first contact it CANNOT compare a source hash to a live deparse, so it trusts your assertion, it does not check. If you need a guarantee that live == db/sql, drop the object and let reconcile recreate it (or --overwrite-drift when the plan reports drift).');
428
+ warn('baseline recorded trust WITHOUT verifying live matches source — on first contact it CANNOT compare a source hash to a live deparse, so it trusts your assertion, it does not check. If you need a guarantee that live == the declared source, drop the object and let reconcile recreate it (or --overwrite-drift when the plan reports drift).');
429
+ }
430
+ if (run.plan.actions.some((a) => a.action === 'rebaseline')) {
431
+ warn('rebaseline VERIFIED the live side (each object is byte-identical to what the reconciler last applied) and TRUSTED the source side (that your re-rendered source produces the same object). If a descriptor actually differs — a changed body, a renamed index — that difference is now recorded as applied and will NOT deploy until the source changes again. When unsure, rebuild instead: drop the flag and let the plan drop+create.');
341
432
  }
342
433
  } else if (apply) {
343
434
  success('Nothing to apply — derived layer matches source.');
@@ -2,15 +2,15 @@
2
2
  * `everystack db:sync` — make the database match your checkout. One verb,
3
3
  * both layers, for dev-stage databases (brick 5's core).
4
4
  *
5
- * db:sync [--database-url <url>] [--models <barrel>] [--sql-dir db/sql]
6
- * [--allow-drops] [--overwrite-drift] [--baseline] [--json]
5
+ * db:sync [--database-url <url>] [--models <barrel>]
6
+ * [--allow-drops] [--overwrite-drift] [--baseline] [--rebaseline] [--json]
7
7
  *
8
8
  * The edit loop's verb: fingerprint the live base state, apply the state
9
9
  * diff (tables + authz, one transaction, verified by re-diff), reconcile
10
- * the derived layer (functions/views/matviews) against db/sql, and report
11
- * where the database now is — "at fingerprint X, matching your checkout"
12
- * when everything converged. Hot-reload for the database; no artifact
13
- * produced, every act recorded in `everystack.schema_log`.
10
+ * the derived layer against the declared descriptors, and report where the
11
+ * database now is — "at fingerprint X, matching your checkout" when
12
+ * everything converged. Hot-reload for the database; no artifact produced,
13
+ * every act recorded in `everystack.schema_log`.
14
14
  *
15
15
  * The verdict is two-tier, honestly: CONVERGED means the state re-diff is
16
16
  * empty and the compute layer is clean — that is the exit-0 bar. The
@@ -34,6 +34,8 @@ import { introspectContract, type QueryRunner } from '../authz-contract.js';
34
34
  import { introspectSchema } from '../schema-introspect.js';
35
35
  import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
36
36
  import { generateMigrationSql, unmodeledTables } from '../migration-generate.js';
37
+ import type { SequenceDescriptor } from '@everystack/model';
38
+ import type { SourceObject } from '../derived-source.js';
37
39
  import {
38
40
  applyStateAndVerify,
39
41
  classifyGeneratedStatements,
@@ -50,10 +52,10 @@ import { planBackfills, readBackfillLog } from '../backfill.js';
50
52
  import { resolveModelsPath } from '../models-path.js';
51
53
  import { executeReconcile, buildReconcileReport, type ReconcileRun } from './db-reconcile.js';
52
54
  import { loadModels } from './db-generate.js';
55
+ import { loadDeclaredDerived, retiredSqlDirAnywhere, retiredSqlDirFlagRefusal, type DeclaredDerived } from '../declared-derived.js';
53
56
  import { reportPipelineLastRun } from './pipeline-run.js';
54
57
  import { step, success, fail, info, warn } from '../output.js';
55
58
 
56
- const DEFAULT_SQL_DIR = 'db/sql';
57
59
  const DEFAULT_SCHEMA_OUT = 'db/schema.generated.ts';
58
60
 
59
61
  // ---------------------------------------------------------------------------
@@ -64,8 +66,17 @@ export interface SyncOptions {
64
66
  allowDrops?: boolean;
65
67
  overwriteDrift?: boolean;
66
68
  baseline?: boolean;
69
+ /** Re-record provenance for source re-renders over verifiably-untouched live objects
70
+ * (the db/sql-era migration) — see ReconcileOptions.rebaseline. */
71
+ rebaseline?: boolean;
67
72
  actor?: string | null;
68
73
  gitRef?: string | null;
74
+ /** Descriptor-compiled derived objects (the declared layer) — the one compute stream. */
75
+ declared?: SourceObject[];
76
+ /** Standalone sequences the modules declare (state — created before tables, fingerprinted). */
77
+ sequences?: SequenceDescriptor[];
78
+ /** Pending table renames — trigger provenance migrates instead of drop+create. */
79
+ renamedTables?: Record<string, string>;
69
80
  /** Injectable clock for tests. */
70
81
  now?: () => number;
71
82
  }
@@ -79,7 +90,7 @@ export interface SyncHooks {
79
90
 
80
91
  export interface SyncRun {
81
92
  state: StateSyncOutcome;
82
- /** null = no db/sql sources — the compute layer is not declared, skipped. */
93
+ /** null = no declared derived layer — the compute step is skipped. */
83
94
  compute: ReconcileRun | null;
84
95
  declaredFingerprint: string;
85
96
  fingerprintMatch: boolean;
@@ -100,31 +111,34 @@ export interface SyncRun {
100
111
  export async function executeSync(
101
112
  runner: QueryRunner,
102
113
  models: ModelDescriptor[],
103
- sources: SourceFile[] | null,
104
114
  options: SyncOptions = {},
105
115
  hooks: SyncHooks = {},
106
116
  ): Promise<SyncRun> {
107
117
  const current = await introspectSchema(runner);
108
118
  const liveAuthz = await introspectContract(runner, contractFunctionRow, FUNCTIONS_SQL);
109
119
  const statements = generateMigrationSql(models, current, {
110
- allowDrops: options.allowDrops, liveAuthz,
120
+ allowDrops: options.allowDrops, liveAuthz, sequences: options.sequences,
111
121
  });
112
122
  hooks.onStatePlan?.(statements, classifyGeneratedStatements(statements));
113
123
 
114
124
  const state = await applyStateAndVerify(runner, models, statements, current, liveAuthz, {
115
- allowDrops: options.allowDrops,
125
+ allowDrops: options.allowDrops, sequences: options.sequences,
116
126
  actor: options.actor, gitRef: options.gitRef, now: options.now,
117
127
  });
118
128
  hooks.onStateDone?.(state);
119
129
 
120
- const compute = sources === null ? null : await executeReconcile(runner, sources, {
130
+ // The declared derived layer is the one compute stream; nothing declared = skipped.
131
+ const compute = !options.declared?.length ? null : await executeReconcile(runner, {
121
132
  apply: true,
122
133
  baseline: options.baseline,
134
+ rebaseline: options.rebaseline,
123
135
  overwriteDrift: options.overwriteDrift,
136
+ declared: options.declared,
137
+ renamedTables: options.renamedTables,
124
138
  actor: options.actor, gitRef: options.gitRef, now: options.now,
125
139
  });
126
140
 
127
- const declaredFingerprint = fingerprintModels(models).hash;
141
+ const declaredFingerprint = fingerprintModels(models, { sequences: options.sequences }).hash;
128
142
  const stateConverged = state.remaining.length === 0;
129
143
  // The compute bar, judged AFTER the apply: no refusal (drift/blocked stop the
130
144
  // whole batch) and nothing left unadopted (needsBaseline objects were not
@@ -182,7 +196,7 @@ export function buildSyncReport(run: SyncRun): string[] {
182
196
 
183
197
  // Compute.
184
198
  if (compute === null) {
185
- lines.push('· compute: no db/sql directoryderived layer not declared, skipped');
199
+ lines.push('· compute: no derived layer declared nothing to reconcile');
186
200
  } else {
187
201
  lines.push('compute:');
188
202
  for (const line of buildReconcileReport(compute.plan)) lines.push(` ${line}`);
@@ -250,21 +264,39 @@ export async function dbSyncCommand(flags: Record<string, string>): Promise<void
250
264
  const modelsPath = resolveModelsPath(flags.models);
251
265
  const schemaOut = path.resolve(flags['schema-out'] || DEFAULT_SCHEMA_OUT);
252
266
  let models: ModelDescriptor[];
267
+ // The declared derived layer + sequences + renames — the same stream db:reconcile
268
+ // consumes. Loaded before the schema write: typed derived relations emit into it.
269
+ let declaredDb: DeclaredDerived | null = null;
253
270
  try {
254
271
  step(`Loading models from ${modelsPath}...`);
255
272
  models = await loadModels(modelsPath);
273
+ declaredDb = await loadDeclaredDerived(flags.models);
256
274
  info(`${models.length} model(s).`);
257
275
  // Same contract as db:generate: the drizzle runtime schema is a derived
258
276
  // artifact, refreshed from the models on every run.
259
277
  step(`Writing the drizzle schema → ${path.relative(process.cwd(), schemaOut)}...`);
260
- await fs.writeFile(schemaOut, compileDrizzleSource(models), 'utf8');
278
+ await fs.writeFile(schemaOut, compileDrizzleSource(models, { derived: declaredDb?.derived }), 'utf8');
261
279
  } catch (err: any) {
262
280
  fail(err.message);
263
281
  process.exit(1);
264
282
  }
265
283
 
266
- const sqlDir = flags['sql-dir'] || DEFAULT_SQL_DIR;
267
- const sources = await readSqlDirIfPresent(sqlDir);
284
+ // B7 tombstone — db/sql is retired and no longer read; failing beats silently
285
+ // skipping (skipped files would leave live objects unmanaged).
286
+ try {
287
+ if (flags['sql-dir']) {
288
+ fail(await retiredSqlDirFlagRefusal(flags['sql-dir']));
289
+ process.exit(1);
290
+ }
291
+ const retired = await retiredSqlDirAnywhere(flags.models);
292
+ if (retired) {
293
+ fail(retired);
294
+ process.exit(1);
295
+ }
296
+ } catch (err: any) {
297
+ fail(err.message);
298
+ process.exit(1);
299
+ }
268
300
 
269
301
  step(connectingVia(dbSource));
270
302
  const { runner, end } = await createUrlRunner(dbSource.url);
@@ -273,11 +305,14 @@ export async function dbSyncCommand(flags: Record<string, string>): Promise<void
273
305
  const run = await executeSync(
274
306
  runner,
275
307
  models,
276
- sources,
277
308
  {
309
+ declared: declaredDb?.objects,
310
+ sequences: declaredDb?.sequences,
311
+ renamedTables: declaredDb?.renamedTables,
278
312
  allowDrops: flags['allow-drops'] === 'true',
279
313
  overwriteDrift: flags['overwrite-drift'] === 'true',
280
314
  baseline: flags.baseline === 'true',
315
+ rebaseline: flags.rebaseline === 'true',
281
316
  actor: process.env.USER ?? null,
282
317
  gitRef: currentGitRef(),
283
318
  },
@@ -296,7 +331,7 @@ export async function dbSyncCommand(flags: Record<string, string>): Promise<void
296
331
  }
297
332
  },
298
333
  onStateDone: () => {
299
- if (sources !== null) step(`Reconciling the derived layer against ${sqlDir}...`);
334
+ if (declaredDb?.objects.length) step('Reconciling the declared derived layer...');
300
335
  },
301
336
  },
302
337
  );
@@ -12,7 +12,8 @@
12
12
 
13
13
  import type { ModelDescriptor } from '@everystack/model';
14
14
  import type { TableContract, QueryRunner } from './authz-contract.js';
15
- import type { SourceFile } from './derived-source.js';
15
+ import type { SourceObject } from './derived-source.js';
16
+ import type { SequenceDescriptor } from '@everystack/model';
16
17
  import { compileDeclaredState } from './declared-diff.js';
17
18
  import { createUrlRunner } from './db-source.js';
18
19
  import { executeSync, buildSyncReport } from './commands/db-sync.js';
@@ -88,7 +89,10 @@ export interface BuildResult {
88
89
  }
89
90
 
90
91
  export interface BuildOptions {
91
- sources?: SourceFile[] | null;
92
+ /** Descriptor-compiled derived objects (the declared layer). */
93
+ declared?: SourceObject[];
94
+ /** Standalone sequences (state — created before tables, in the fingerprint bar). */
95
+ sequences?: SequenceDescriptor[];
92
96
  actor?: string | null;
93
97
  gitRef?: string | null;
94
98
  }
@@ -106,7 +110,9 @@ export async function buildIntoDatabase(
106
110
  const { runner, end } = await createUrlRunner(url);
107
111
  try {
108
112
  const createdRoles = await ensureContractRoles(runner, models);
109
- const run = await executeSync(runner, models, options.sources ?? null, {
113
+ const run = await executeSync(runner, models, {
114
+ declared: options.declared,
115
+ sequences: options.sequences,
110
116
  actor: options.actor ?? 'db-build',
111
117
  gitRef: options.gitRef ?? null,
112
118
  });