@everystack/cli 0.3.28 → 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.
Files changed (47) hide show
  1. package/README.md +1 -1
  2. package/package.json +6 -2
  3. package/src/cli/apply-execute.ts +185 -0
  4. package/src/cli/authz-compile.ts +5 -2
  5. package/src/cli/authz-lint.ts +48 -0
  6. package/src/cli/aws.ts +102 -4
  7. package/src/cli/backfill.ts +1 -1
  8. package/src/cli/commands/db-apply.ts +159 -169
  9. package/src/cli/commands/db-backfill.ts +2 -2
  10. package/src/cli/commands/db-backup.ts +34 -0
  11. package/src/cli/commands/db-branch.ts +28 -4
  12. package/src/cli/commands/db-check.ts +97 -34
  13. package/src/cli/commands/db-fingerprint.ts +9 -4
  14. package/src/cli/commands/db-generate.ts +73 -22
  15. package/src/cli/commands/db-plan.ts +7 -13
  16. package/src/cli/commands/db-pull.ts +39 -7
  17. package/src/cli/commands/db-reconcile.ts +219 -66
  18. package/src/cli/commands/db-sync.ts +58 -21
  19. package/src/cli/commands/deploy.ts +4 -0
  20. package/src/cli/commands/pipeline-run.ts +269 -0
  21. package/src/cli/db-build.ts +9 -3
  22. package/src/cli/db-source.ts +83 -7
  23. package/src/cli/declared-derived.ts +136 -0
  24. package/src/cli/derived-apply.ts +40 -6
  25. package/src/cli/derived-compile.ts +327 -0
  26. package/src/cli/derived-grants.ts +96 -0
  27. package/src/cli/derived-introspect.ts +258 -21
  28. package/src/cli/derived-lint.ts +261 -0
  29. package/src/cli/derived-plan.ts +194 -12
  30. package/src/cli/derived-render.ts +320 -0
  31. package/src/cli/derived-source.ts +53 -125
  32. package/src/cli/discover.ts +16 -0
  33. package/src/cli/git-descent.ts +15 -2
  34. package/src/cli/index.ts +26 -6
  35. package/src/cli/migration-compile.ts +38 -7
  36. package/src/cli/migration-generate.ts +43 -4
  37. package/src/cli/model-render.ts +106 -13
  38. package/src/cli/models-path.ts +1 -1
  39. package/src/cli/ops-diagnostics.ts +71 -0
  40. package/src/cli/pipeline-loader.ts +68 -0
  41. package/src/cli/pipeline-path.ts +25 -0
  42. package/src/cli/schema-compile.ts +41 -10
  43. package/src/cli/schema-diff.ts +123 -17
  44. package/src/cli/schema-fingerprint.ts +28 -18
  45. package/src/cli/schema-introspect.ts +175 -8
  46. package/src/cli/schema-source.ts +75 -6
  47. package/src/cli/state-apply.ts +4 -1
@@ -16,6 +16,7 @@
16
16
  */
17
17
 
18
18
  import type { SchemaSnapshot, TableSchema, ColumnSchema, CheckConstraint } from './schema-introspect.js';
19
+ import type { DerivedRenderResult } from './derived-render.js';
19
20
  import { normalizeDefault, normalizeCheck } from './schema-diff.js';
20
21
 
21
22
  /**
@@ -59,6 +60,47 @@ export function checkToValidate(expr: string): { column: string; zod: string } |
59
60
  export interface RenderOptions {
60
61
  /** Only pull tables in this Postgres schema (others are framework-managed). Default: `public`. */
61
62
  schema?: string;
63
+ /**
64
+ * The read-model scaffold. Default `'commented'`: every model carries the authz decision
65
+ * as a commented stanza (same guidance as the db:check gate — the model fails the gate
66
+ * until it is authored). A preset name (`'public-read'`) stamps that stanza uncommented
67
+ * into every rendered model — an explicit, one-shot, operator-invoked choice that lands
68
+ * as reviewable code. Grants are authored, never inherited; there is no runtime default.
69
+ */
70
+ abilities?: string;
71
+ /** The rendered derived layer (B5) — rides the barrel: block after the models,
72
+ * sequences/derived arrays on the module wrapper, symbols on the import header. */
73
+ derived?: DerivedRenderResult;
74
+ }
75
+
76
+ /**
77
+ * The `--abilities` presets db:pull can stamp. Deliberately few: `public-read` is the
78
+ * dominant brownfield shape (public reference data, admin-managed). Anything else is a
79
+ * per-model edit — the decision belongs in the file, not in a flag grammar.
80
+ */
81
+ export const ABILITY_PRESETS: Record<string, string> = {
82
+ 'public-read': `abilities: [can('read'), can('manage', { role: 'admin' })],`,
83
+ };
84
+
85
+ /**
86
+ * The scaffold stanza for one model: the commented decision surface (guide text says the
87
+ * same thing the db:check gate failure says — one voice, two doorways), or a preset
88
+ * stamped uncommented. An unknown preset throws — grants are authored, never guessed.
89
+ */
90
+ function abilitiesStanza(mode: string): string {
91
+ if (mode === 'commented') {
92
+ return [
93
+ ' // Declare the read model — db:check fails this model until its authz is authored:',
94
+ ` // abilities: [can('read')], // public data`,
95
+ ` // abilities: [can('read', { owner: '<column>' })], // rows owned by a user`,
96
+ ' // private: true, // not part of the data API',
97
+ ].join('\n');
98
+ }
99
+ const preset = ABILITY_PRESETS[mode];
100
+ if (!preset) {
101
+ throw new Error(`Unknown --abilities preset '${mode}' — known: ${Object.keys(ABILITY_PRESETS).join(', ')} (or omit the flag for the commented scaffold).`);
102
+ }
103
+ return ` ${preset}`;
62
104
  }
63
105
 
64
106
  /** `format_type` → the `field.*()` factory that produces it (the non-parameterized types). */
@@ -210,8 +252,11 @@ function renderField(table: TableSchema, col: ColumnSchema, known: Set<string>,
210
252
 
211
253
  if (col.default != null && !serial) {
212
254
  const d = renderDefault(col.default);
213
- if (d) expr += d;
214
- else comment += ` // TODO: unmapped default ${col.default}`;
255
+ // No builder maps it → declare it verbatim with the escape hatch. RAW, not the
256
+ // normalized form stripping a trailing cast here could change semantics
257
+ // (`'7 days'::interval`); the diff normalizes both sides for comparison instead.
258
+ // Drift closed by construction: pull output converges instead of shedding a TODO.
259
+ expr += d ?? `.defaultSql(sql\`${col.default}\`)`;
215
260
  }
216
261
 
217
262
  const validate = validates.get(col.name);
@@ -236,8 +281,14 @@ function renderConstraintsBlock(table: TableSchema, checks: CheckConstraint[], k
236
281
  items.push(`check(sql\`${normalizeCheck(ck.expr)}\`)`);
237
282
  }
238
283
  for (const ix of table.indexes) {
239
- let s = `index(${ix.columns.map((c) => tsLiteral(toCamelCase(c))).join(', ')})`;
284
+ // Plain entries render as field keys; canonical-text entries (expressions, DESC,
285
+ // opclass) render as raw sql`` args — the same form the builder declares (Brick E).
286
+ const entry = (c: string): string =>
287
+ /^[a-z_][a-z0-9_$]*$/i.test(c) ? tsLiteral(toCamelCase(c)) : `sql\`${c}\``;
288
+ let s = `index(${ix.columns.map(entry).join(', ')})`;
289
+ if (ix.using) s += `.using(${tsLiteral(ix.using)})`;
240
290
  if (ix.unique) s += '.unique()';
291
+ if (ix.include?.length) s += `.include(${ix.include.map((c) => tsLiteral(toCamelCase(c))).join(', ')})`;
241
292
  if (ix.where) s += `.where(sql\`${normalizeCheck(ix.where)}\`)`;
242
293
  items.push(s);
243
294
  }
@@ -261,7 +312,7 @@ function renderConstraintsBlock(table: TableSchema, checks: CheckConstraint[], k
261
312
  }
262
313
 
263
314
  /** One `export const X = defineModel(...)` block for a table. */
264
- export function renderModelBlock(table: TableSchema, known: Set<string>, enums: Map<string, string[]> = new Map()): string {
315
+ export function renderModelBlock(table: TableSchema, known: Set<string>, enums: Map<string, string[]> = new Map(), abilities = 'commented'): string {
265
316
  // A CHECK that reverses to a single field's .validate() is rendered on the field (ergonomic);
266
317
  // the rest stay table-level check(). Both round-trip — this only chooses the nicer form.
267
318
  const validates = new Map<string, string>();
@@ -275,23 +326,52 @@ export function renderModelBlock(table: TableSchema, known: Set<string>, enums:
275
326
 
276
327
  const fields = table.columns.map((c) => renderField(table, c, known, enums, validates)).join('\n');
277
328
  const constraints = renderConstraintsBlock(table, tableChecks, known);
329
+ // The authz decision renders FIRST — before fields — because it is the first thing a
330
+ // reviewer must resolve about a model (and where the field-report consumer's codemod
331
+ // put it, proving the position is mechanical-edit-friendly).
332
+ const stanza = abilitiesStanza(abilities);
278
333
 
279
- return `export const ${modelVarName(table.table)} = defineModel('${bareName(table.table)}', {\n fields: {\n${fields}\n },${constraints}\n});`;
334
+ return `export const ${modelVarName(table.table)} = defineModel('${bareName(table.table)}', {\n${stanza}\n fields: {\n${fields}\n },${constraints}\n});`;
280
335
  }
281
336
 
282
337
  /** The `import` lines a rendered body needs — only what it actually uses, so the file reads clean. */
283
- function importHeader(body: string): string {
338
+ function importHeader(body: string, extra: string[] = []): string {
284
339
  const symbols = ['defineModel', 'field'];
340
+ // `can` only when a stanza is stamped UNCOMMENTED (line starts with spaces, not `//`) —
341
+ // the commented scaffold must not leave an unused import behind.
342
+ if (/^\s*abilities: \[can\(/m.test(body)) symbols.push('can');
285
343
  if (/\bunique\(/.test(body)) symbols.push('unique');
286
344
  if (/\bcheck\(/.test(body)) symbols.push('check');
287
345
  if (/\bindex\(/.test(body)) symbols.push('index');
288
346
  if (/\bforeignKey\(/.test(body)) symbols.push('foreignKey');
347
+ if (/\bdefineModule\(/.test(body)) symbols.push('defineModule');
289
348
  if (/\bsql`/.test(body)) symbols.push('sql');
349
+ for (const s of extra) if (!symbols.includes(s)) symbols.push(s);
290
350
  const imports = [`import { ${symbols.join(', ')} } from '@everystack/model';`];
291
351
  if (/\.validate\(/.test(body)) imports.push(`import { z } from 'zod';`);
292
352
  return imports.join('\n');
293
353
  }
294
354
 
355
+ /** The barrel's module wrapper: models always; sequences/derived when the pull found any (B5). */
356
+ function moduleFooter(modelNames: string[], derived?: DerivedRenderResult, multiline = false): string {
357
+ const models = multiline
358
+ ? `export const models = [\n${modelNames.map((n) => ` ${n},`).join('\n')}\n];`
359
+ : `export const models = [${modelNames.join(', ')}];`;
360
+ const parts = [models];
361
+ const keys = ['models'];
362
+ if (derived?.sequenceNames.length) {
363
+ parts.push(`export const sequences = [${derived.sequenceNames.join(', ')}];`);
364
+ keys.push('sequences');
365
+ }
366
+ if (derived?.names.length) {
367
+ parts.push(`export const derived = [${derived.names.join(', ')}];`);
368
+ keys.push('derived');
369
+ }
370
+ parts.push(`export const appModule = defineModule({ ${keys.join(', ')} });`);
371
+ parts.push(`export const modules = [appModule];`);
372
+ return parts.join('\n\n');
373
+ }
374
+
295
375
  /**
296
376
  * Render a whole snapshot as a single Models module: the import, one block per table (scoped
297
377
  * to `opts.schema`), and the `models` array the rest of the framework consumes. The tables
@@ -303,11 +383,16 @@ export function renderModelSource(snapshot: SchemaSnapshot, opts: RenderOptions
303
383
  const known = new Set(tables.map((t) => bareName(t.table)));
304
384
  const enums = new Map((snapshot.enums ?? []).map((e) => [e.name, e.values]));
305
385
 
306
- const blocks = tables.map((t) => renderModelBlock(t, known, enums));
307
- const body = blocks.join('\n\n');
386
+ const blocks = tables.map((t) => renderModelBlock(t, known, enums, opts.abilities ?? 'commented'));
387
+ // The derived layer (B5): sequences + views/matviews/functions, after the models they
388
+ // reference, before the module wrapper that composes all three.
389
+ if (opts.derived?.block) blocks.push(opts.derived.block);
390
+
391
+ // Module composition is the norm: the pulled barrel exports `modules` alongside `models`,
392
+ // so a fresh brownfield project lands on the same shape the framework composes.
393
+ const footer = moduleFooter(tables.map((t) => modelVarName(t.table)), opts.derived);
308
394
 
309
- const header = importHeader(body);
310
- const footer = `export const models = [${tables.map((t) => modelVarName(t.table)).join(', ')}];`;
395
+ const header = importHeader([...blocks, footer].join('\n\n'), opts.derived?.imports ?? []);
311
396
 
312
397
  return [header, ...blocks, footer].join('\n\n') + '\n';
313
398
  }
@@ -349,7 +434,7 @@ export function renderModelFiles(snapshot: SchemaSnapshot, opts: RenderOptions =
349
434
  const enums = new Map((snapshot.enums ?? []).map((e) => [e.name, e.values]));
350
435
 
351
436
  const files: RenderedModelFile[] = tables.map((t) => {
352
- const block = renderModelBlock(t, known, enums);
437
+ const block = renderModelBlock(t, known, enums, opts.abilities ?? 'commented');
353
438
  const crossImports = referencedTables(t, known).map(
354
439
  (target) => `import { ${modelVarName(target)} } from './${bareName(target).replace(/_/g, '-')}';`,
355
440
  );
@@ -357,11 +442,19 @@ export function renderModelFiles(snapshot: SchemaSnapshot, opts: RenderOptions =
357
442
  return { file: modelFileName(t.table), source: `${header}\n\n${block}\n` };
358
443
  });
359
444
 
445
+ // The index carries the module wrapper (composition is the norm) and the derived layer
446
+ // (B5 — it references the model variables the index imports); model files carry only
447
+ // their own model.
360
448
  const names = tables.map((t) => modelVarName(t.table));
449
+ const modelSymbols = ['defineModule', ...(opts.derived?.imports ?? [])];
361
450
  const index = [
362
- names.map((n, i) => `import { ${n} } from './${bareName(tables[i].table).replace(/_/g, '-')}';`).join('\n'),
451
+ [
452
+ `import { ${modelSymbols.join(', ')} } from '@everystack/model';`,
453
+ ...names.map((n, i) => `import { ${n} } from './${bareName(tables[i].table).replace(/_/g, '-')}';`),
454
+ ].join('\n'),
363
455
  `export {\n${names.map((n) => ` ${n},`).join('\n')}\n};`,
364
- `export const models = [\n${names.map((n) => ` ${n},`).join('\n')}\n];`,
456
+ ...(opts.derived?.block ? [opts.derived.block] : []),
457
+ moduleFooter(names, opts.derived, true),
365
458
  ].join('\n\n');
366
459
  files.push({ file: 'index.ts', source: index + '\n' });
367
460
 
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * models-path — where the Models barrel lives.
3
3
  *
4
- * One home for schema: `db/models/` (state) beside `db/sql/` (compute). The
4
+ * One home for schema: `db/models/` — state (tables) and compute (descriptors). The
5
5
  * CLI looks there first, falling back to the legacy top-level `models/` so
6
6
  * existing consumers keep working without a flag. An explicit `--models`
7
7
  * always wins. When neither home exists, the NEW home is returned so error
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Diagnostics for a crashed ops-Lambda invoke.
3
+ *
4
+ * A consumer's ops handler can fail to *boot* — a broken top-level import (a dependency missing
5
+ * from the deployed bundle, a version skew, a bad path). Lambda then returns a FunctionError with
6
+ * the opaque `Runtime exited with error: exit status 1` for EVERY action, giving the operator no
7
+ * signal that it's the handler failing to start (not their command) and no path to the cause. The
8
+ * CLI turns that into a report naming the real cause by reading the handler's CloudWatch logs; the
9
+ * pure detection/extraction/formatting lives here, the AWS fetch in aws.ts.
10
+ */
11
+
12
+ /**
13
+ * Whether a Lambda FunctionError message is the handler failing to boot / crashing the runtime — an
14
+ * uncaught init error worth chasing into CloudWatch — rather than an already-descriptive error. Only
15
+ * these opaque cases trigger the log fetch; a clear message passes through untouched.
16
+ */
17
+ export function isOpsHandlerCrash(message: string): boolean {
18
+ return /Runtime exited|exited with error|Runtime\.(ImportModuleError|UserCodeSyntaxError|HandlerNotFound)|Cannot find (module|package)|ERR_MODULE_NOT_FOUND|\bMODULE_NOT_FOUND\b/i.test(
19
+ message,
20
+ );
21
+ }
22
+
23
+ /** Log lines worth surfacing: the import error, a runtime error class, or a thrown Error/stack. */
24
+ const SIGNAL = /Cannot find (module|package)|ERR_MODULE_NOT_FOUND|\bMODULE_NOT_FOUND\b|Runtime\.\w+|(?:Reference|Type|Syntax|Range)?Error:/;
25
+
26
+ /**
27
+ * Pull the diagnostic lines from raw CloudWatch log text for a crashed ops invoke — the import
28
+ * error / stack frame that names the missing dependency — so an opaque "exit status 1" becomes the
29
+ * real cause. De-duplicates and caps output. Returns null when nothing diagnostic is present.
30
+ */
31
+ export function extractCrashDiagnosis(logText: string): string | null {
32
+ const seen = new Set<string>();
33
+ const out: string[] = [];
34
+ for (const raw of logText.split('\n')) {
35
+ const line = raw.trim();
36
+ if (!line || !SIGNAL.test(line) || seen.has(line)) continue;
37
+ seen.add(line);
38
+ out.push(line);
39
+ if (out.length >= 6) break;
40
+ }
41
+ return out.length ? out.join('\n') : null;
42
+ }
43
+
44
+ /**
45
+ * Assemble the operator-facing report for an ops-handler crash: the raw message, the "it's the
46
+ * handler, not your command" framing, and either the real cause from CloudWatch or a where-to-look
47
+ * hint when the logs couldn't be read.
48
+ */
49
+ export function formatOpsCrashReport(
50
+ baseMessage: string,
51
+ logGroup?: string,
52
+ diagnosis?: string | null,
53
+ ): string {
54
+ const lines = [
55
+ baseMessage,
56
+ '',
57
+ 'The ops handler crashed at startup — usually a missing dependency in the deployed bundle, not a problem with this command.',
58
+ ];
59
+ if (diagnosis) {
60
+ lines.push(
61
+ '',
62
+ `From CloudWatch${logGroup ? ` (${logGroup})` : ''}:`,
63
+ diagnosis,
64
+ '',
65
+ 'Fix the import/dependency and redeploy.',
66
+ );
67
+ } else {
68
+ lines.push('Check its CloudWatch logs for the failing top-level import, then redeploy.');
69
+ }
70
+ return lines.join('\n');
71
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * pipeline-loader — the CLI's window onto @everystack/server/pipeline.
3
+ *
4
+ * The CLI keeps NO compile-time dependency on @everystack/server (the two are
5
+ * mutual peers; hard-importing would be a project-reference cycle — the same
6
+ * reason plugin.ts inlines its types). So the orchestrator's types are mirrored
7
+ * structurally here and the runtime module is loaded lazily via a variable
8
+ * specifier. Every CLI verb that reaches the pipeline framework (pipeline:run,
9
+ * and the db:sync / db:plan visibility) goes through this one door.
10
+ */
11
+
12
+ export type PipelineLane = 'rebuild' | 'curate' | 'all';
13
+
14
+ export interface PipelineStage {
15
+ id: string;
16
+ lane: 'automated' | 'curated' | 'frozen';
17
+ dependsOn?: string[];
18
+ after?: string[];
19
+ heavy?: boolean;
20
+ }
21
+ export interface Pipeline {
22
+ stages: PipelineStage[];
23
+ }
24
+ export interface RunPipelineOptions {
25
+ runId: string;
26
+ lane?: PipelineLane;
27
+ only?: string;
28
+ from?: string;
29
+ to?: string;
30
+ resume?: boolean;
31
+ dryRun?: boolean;
32
+ continueOnError?: boolean;
33
+ actor?: string | null;
34
+ gitRef?: string | null;
35
+ }
36
+ export interface StageRunResult {
37
+ stageId: string;
38
+ outcome: 'applied' | 'skipped' | 'failed' | 'dry-run';
39
+ rows?: number;
40
+ error?: string;
41
+ }
42
+ export interface PipelineRunOutcome {
43
+ runId: string;
44
+ plan: string[];
45
+ results: StageRunResult[];
46
+ failed: boolean;
47
+ }
48
+ export interface PipelineRunSummary {
49
+ runId: string;
50
+ at: string | null;
51
+ applied: number;
52
+ failed: number;
53
+ failedStages: string[];
54
+ }
55
+
56
+ export interface ServerPipeline {
57
+ runPipeline(runner: unknown, pipeline: Pipeline, options: RunPipelineOptions): Promise<PipelineRunOutcome>;
58
+ latestRunId(query: (sql: string) => Promise<any[]>): Promise<string | null>;
59
+ readLastRun(query: (sql: string) => Promise<any[]>): Promise<PipelineRunSummary | null>;
60
+ topoSortStages(stages: PipelineStage[]): PipelineStage[];
61
+ selectStages(ordered: PipelineStage[], opts: Record<string, unknown>): PipelineStage[];
62
+ }
63
+
64
+ /** Lazily load the orchestrator from the @everystack/server peer (runtime only). */
65
+ export async function loadServerPipeline(): Promise<ServerPipeline> {
66
+ const spec = '@everystack/server/pipeline';
67
+ return (await import(spec)) as ServerPipeline;
68
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * pipeline-path — where the consumer's pipeline definition lives.
3
+ *
4
+ * Beside the schema homes (`db/models/`, `db/backfills/`), the
5
+ * pipeline is authored at `db/pipeline.ts` (or `db/pipeline/index.ts`),
6
+ * exporting a `pipeline` built with definePipeline. An explicit `--pipeline`
7
+ * always wins; when neither home exists the primary is returned so error
8
+ * messages point at the current convention.
9
+ */
10
+
11
+ import { existsSync } from 'node:fs';
12
+
13
+ /** Candidate pipeline entrypoints, in preference order. */
14
+ export const PIPELINE_HOMES = ['db/pipeline.ts', 'db/pipeline/index.ts'] as const;
15
+
16
+ export function resolvePipelinePath(
17
+ flag?: string,
18
+ exists: (p: string) => boolean = existsSync,
19
+ ): string {
20
+ if (flag) return flag;
21
+ for (const home of PIPELINE_HOMES) {
22
+ if (exists(home)) return home;
23
+ }
24
+ return PIPELINE_HOMES[0];
25
+ }
@@ -19,12 +19,12 @@
19
19
  * Input is a `@everystack/model` ModelDescriptor (type-only — no runtime dep).
20
20
  */
21
21
 
22
- import type { ModelDescriptor, FieldSpec } from '@everystack/model';
23
- import type { TableSchema, EnumType, UniqueConstraint, CheckConstraint, IndexSchema, ForeignKey } from './schema-introspect.js';
24
- import type { RenameMap } from './schema-diff.js';
22
+ import type { ModelDescriptor, FieldSpec, SequenceDescriptor } from '@everystack/model';
23
+ import type { TableSchema, EnumType, SequenceSchema, UniqueConstraint, CheckConstraint, IndexSchema, ForeignKey } from './schema-introspect.js';
24
+ import { nextvalSequence, type RenameMap } from './schema-diff.js';
25
25
 
26
26
  /** `authorId` -> `author_id`. SQL identifiers are snake_case. */
27
- function toSnakeCase(name: string): string {
27
+ export function toSnakeCase(name: string): string {
28
28
  return name.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
29
29
  }
30
30
 
@@ -48,20 +48,25 @@ export function modelConstraintSpecs(model: ModelDescriptor): { uniques: UniqueC
48
48
  } else if (con.kind === 'check') {
49
49
  checks.push({ name: `${model.table}_check_${checkIndex++}`, expr: con.predicate });
50
50
  } else if (con.kind === 'index') {
51
- const cols = con.columns.map(toSnakeCase);
51
+ // Plain entries are field keys → snake_cased; raw sql`` entries (expressions,
52
+ // DESC, opclass) pass verbatim — they are already SQL (Brick E).
53
+ const cols = con.columns.map((c, i) => (con.rawPositions?.[i] ? c : toSnakeCase(c)));
52
54
  // Disambiguate multiple indexes over the same columns — e.g. a full index plus a
53
55
  // partial one (`familyId` and `familyId WHERE status='active'`): the name must be
54
56
  // unique within the table for CREATE to succeed. The diff matches indexes by content
55
- // (columns + uniqueness + predicate), never by name, so the suffix never affects the
56
- // round-trip. Declaration order is deterministic, so the name is stable across calls.
57
- let name = `${model.table}_${cols.join('_')}_index`;
58
- for (let n = 2; usedIndexNames.has(name); n++) name = `${model.table}_${cols.join('_')}_index_${n}`;
57
+ // (entries + uniqueness + predicate + method + INCLUDE), never by name, so the suffix
58
+ // never affects the round-trip. Raw entries sanitize to identifier-safe name parts.
59
+ const nameParts = cols.map((c) => c.replace(/\W+/g, '_').replace(/^_+|_+$/g, ''));
60
+ let name = `${model.table}_${nameParts.join('_')}_index`;
61
+ for (let n = 2; usedIndexNames.has(name); n++) name = `${model.table}_${nameParts.join('_')}_index_${n}`;
59
62
  usedIndexNames.add(name);
60
63
  indexes.push({
61
64
  name,
62
65
  columns: cols,
63
66
  unique: con.isUnique,
64
67
  ...(con.predicate ? { where: con.predicate } : {}),
68
+ ...(con.method ? { using: con.method } : {}),
69
+ ...(con.includeColumns?.length ? { include: con.includeColumns.map(toSnakeCase) } : {}),
65
70
  });
66
71
  }
67
72
  }
@@ -209,6 +214,7 @@ function defaultExpr(spec: FieldSpec): string | null {
209
214
  switch (spec.defaultKind) {
210
215
  case 'now': return 'now()';
211
216
  case 'random': return 'gen_random_uuid()';
217
+ case 'sql': return String(spec.default); // .defaultSql() — the author's SQL, verbatim
212
218
  case 'value':
213
219
  return spec.isArray && Array.isArray(spec.default) ? arrayLiteral(spec.default) : literal(spec.default);
214
220
  default: return null;
@@ -251,7 +257,12 @@ export function fieldColumnSql(sqlName: string, spec: FieldSpec, inlinePrimaryKe
251
257
  let s = `"${sqlName}" ${serial ?? pgType(spec)}`;
252
258
  if (inlinePrimaryKey) s += ' PRIMARY KEY';
253
259
  const def = serial ? null : defaultExpr(spec);
254
- if (def) s += ` DEFAULT ${def}`;
260
+ // A `defaultSql` nextval draws from an EXISTING sequence, possibly owned by a table
261
+ // created later in the same migration — held out of the column line (compileMigration
262
+ // re-attaches it as a trailing SET DEFAULT). Never the serial shorthand: that would
263
+ // mint a NEW sequence and silently detach the column from the one it declared.
264
+ const heldSequenceDraw = spec.defaultKind === 'sql' && nextvalSequence(def) != null;
265
+ if (def && !heldSequenceDraw) s += ` DEFAULT ${def}`;
255
266
  if (spec.isNotNull) s += ' NOT NULL';
256
267
  return s;
257
268
  }
@@ -485,6 +496,26 @@ export function compileEnums(models: ModelDescriptor[]): EnumType[] {
485
496
  return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
486
497
  }
487
498
 
499
+ /**
500
+ * Standalone-sequence descriptors → the snapshot shape the diff and fingerprint consume.
501
+ * PostgreSQL's defaults (start/increment of 1) are omitted, so a declaration that omits
502
+ * them reaches the same canonical form a live default-configured sequence introspects to.
503
+ */
504
+ export function compileSequences(sequences: readonly SequenceDescriptor[]): SequenceSchema[] {
505
+ return sequences
506
+ .map((s) => ({
507
+ name: s.name,
508
+ as: s.as,
509
+ ...(s.start != null && s.start !== 1 ? { start: s.start } : {}),
510
+ ...(s.increment != null && s.increment !== 1 ? { increment: s.increment } : {}),
511
+ }))
512
+ .sort((a, b) => a.name.localeCompare(b.name));
513
+ }
514
+
515
+ // The DDL renderer lives in schema-diff (emitOne needs it; this file already imports
516
+ // schema-diff, so defining it there avoids a cycle) — re-exported here beside its compiler.
517
+ export { sequenceCreateSql } from './schema-diff.js';
518
+
488
519
  /**
489
520
  * Foreign-key constraints for a model — its `.references()` fields and its table-level
490
521
  * `foreignKey(...)` constraints, via `modelForeignKeys` — as `ALTER TABLE` statements. These