turbine-orm 0.35.0 → 0.36.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 (68) hide show
  1. package/README.md +18 -16
  2. package/dist/cjs/cli/index.js +109 -16
  3. package/dist/cjs/cli/migrate.js +78 -3
  4. package/dist/cjs/cli/studio-ui.generated.js +1 -1
  5. package/dist/cjs/cli/studio.js +333 -22
  6. package/dist/cjs/cli/ui.js +7 -1
  7. package/dist/cjs/dialect.js +1 -1
  8. package/dist/cjs/generate.js +23 -2
  9. package/dist/cjs/index.js +2 -1
  10. package/dist/cjs/mssql.js +22 -5
  11. package/dist/cjs/powdb.js +41 -1
  12. package/dist/cjs/powql.js +80 -25
  13. package/dist/cjs/query/aggregates.js +683 -0
  14. package/dist/cjs/query/batched-loader.js +2 -0
  15. package/dist/cjs/query/builder.js +297 -4504
  16. package/dist/cjs/query/filters.js +12 -0
  17. package/dist/cjs/query/relations.js +1698 -0
  18. package/dist/cjs/query/where-compile.js +180 -0
  19. package/dist/cjs/query/where.js +1491 -0
  20. package/dist/cjs/query/writes.js +680 -0
  21. package/dist/cjs/schema-builder.js +6 -0
  22. package/dist/cjs/schema-metadata.js +4 -0
  23. package/dist/cjs/schema-sql.js +265 -3
  24. package/dist/cjs/sqlite.js +1 -1
  25. package/dist/cli/index.d.ts +8 -2
  26. package/dist/cli/index.js +111 -18
  27. package/dist/cli/migrate.d.ts +24 -1
  28. package/dist/cli/migrate.js +77 -3
  29. package/dist/cli/studio-ui.generated.js +1 -1
  30. package/dist/cli/studio.d.ts +46 -13
  31. package/dist/cli/studio.js +331 -23
  32. package/dist/cli/ui.js +7 -1
  33. package/dist/dialect.d.ts +15 -6
  34. package/dist/dialect.js +1 -1
  35. package/dist/generate.js +23 -2
  36. package/dist/index.d.ts +1 -1
  37. package/dist/index.js +1 -1
  38. package/dist/mssql.js +22 -5
  39. package/dist/powdb.d.ts +20 -0
  40. package/dist/powdb.js +40 -0
  41. package/dist/powql.d.ts +33 -1
  42. package/dist/powql.js +80 -25
  43. package/dist/query/aggregates.d.ts +74 -0
  44. package/dist/query/aggregates.js +641 -0
  45. package/dist/query/batched-loader.d.ts +6 -0
  46. package/dist/query/batched-loader.js +2 -0
  47. package/dist/query/builder.d.ts +62 -829
  48. package/dist/query/builder.js +302 -4509
  49. package/dist/query/deferred.d.ts +7 -0
  50. package/dist/query/filters.d.ts +7 -0
  51. package/dist/query/filters.js +11 -0
  52. package/dist/query/relations.d.ts +441 -0
  53. package/dist/query/relations.js +1627 -0
  54. package/dist/query/types.d.ts +15 -0
  55. package/dist/query/where-compile.d.ts +139 -0
  56. package/dist/query/where-compile.js +175 -0
  57. package/dist/query/where.d.ts +494 -0
  58. package/dist/query/where.js +1431 -0
  59. package/dist/query/writes.d.ts +131 -0
  60. package/dist/query/writes.js +626 -0
  61. package/dist/schema-builder.d.ts +18 -3
  62. package/dist/schema-builder.js +6 -0
  63. package/dist/schema-metadata.js +4 -0
  64. package/dist/schema-sql.d.ts +60 -3
  65. package/dist/schema-sql.js +261 -4
  66. package/dist/schema.d.ts +10 -0
  67. package/dist/sqlite.js +1 -1
  68. package/package.json +2 -2
package/dist/cli/index.js CHANGED
@@ -5,8 +5,8 @@
5
5
  * Commands:
6
6
  * turbine init — Initialize a Turbine project
7
7
  * turbine generate | pull — Introspect database and generate TypeScript types
8
- * turbine push Apply schema-builder definitions to database
9
- * turbine migrate create <name> Create a new SQL migration file
8
+ * turbine push - Apply schema-builder definitions to database (destructive ops gated)
9
+ * turbine migrate create <name> - Create a new SQL migration file (--auto | --recipe <name>)
10
10
  * turbine migrate up — Apply pending migrations
11
11
  * turbine migrate deploy — Apply pending migrations without prompts
12
12
  * turbine migrate down — Rollback last migration
@@ -29,11 +29,11 @@ import { pathToFileURL } from 'node:url';
29
29
  import { generate } from '../generate.js';
30
30
  import { findMissingRelationIndexes } from '../index-advisor.js';
31
31
  import { introspect } from '../introspect.js';
32
- import { schemaDiff, schemaPush } from '../schema-sql.js';
32
+ import { DestructivePushRefusal, schemaDiff, schemaPush } from '../schema-sql.js';
33
33
  import { configTemplate, findConfigFile, loadConfigResult, looksLikeSchemaFilePath, resolveConfig, resolveSeedFile, unwrapModuleDefault, } from './config.js';
34
34
  import { canResolveTsx, getTsLoaderError, needsTsLoader, registerTsLoader } from './loader.js';
35
35
  import { runMcpServer } from './mcp.js';
36
- import { createMigration, inspectMigrationDeploy, listMigrationFiles, migrateDeploy, migrateDown, migrateStatus, migrateUp, } from './migrate.js';
36
+ import { createMigration, inspectMigrationDeploy, listMigrationFiles, MIGRATION_RECIPES, migrateDeploy, migrateDown, migrateStatus, migrateUp, } from './migrate.js';
37
37
  import { startObserve } from './observe.js';
38
38
  import { startStudio } from './studio.js';
39
39
  import { banner, blue, bold, box, cyan, dim, divider, elapsed, error, table as formatTable, gray, green, header, info, label, magenta, newline, red, redactUrl, Spinner, success, symbols, warn, yellow, } from './ui.js';
@@ -108,6 +108,14 @@ export function parseArgs(argv = process.argv.slice(2)) {
108
108
  case '--allow-destructive':
109
109
  result.allowDestructive = true;
110
110
  break;
111
+ case '--recipe':
112
+ if (next === undefined || next.startsWith('-')) {
113
+ console.error('--recipe requires a name (e.g. --recipe backfill)');
114
+ process.exit(1);
115
+ }
116
+ result.recipe = next;
117
+ i++;
118
+ break;
111
119
  case '--force':
112
120
  case '-f':
113
121
  result.force = true;
@@ -134,6 +142,12 @@ export function parseArgs(argv = process.argv.slice(2)) {
134
142
  case '--allow-remote':
135
143
  result.allowRemote = true;
136
144
  break;
145
+ case '--write':
146
+ result.write = true;
147
+ break;
148
+ case '--show-pii':
149
+ result.showPii = true;
150
+ break;
137
151
  default:
138
152
  if (!arg.startsWith('-')) {
139
153
  result.positional.push(arg);
@@ -731,14 +745,45 @@ async function cmdPush(args, config) {
731
745
  }
732
746
  newline();
733
747
  }
748
+ // Surface any non-fatal diff warnings (e.g. undeclared DB indexes, enum
749
+ // removals) the diff refuses to apply automatically.
750
+ if (diff.warnings && diff.warnings.length > 0) {
751
+ for (const w of diff.warnings)
752
+ warn(w);
753
+ newline();
754
+ }
734
755
  if (args.dryRun) {
735
756
  info('Dry run — no changes applied.');
736
757
  newline();
737
758
  return;
738
759
  }
739
- // Execute
760
+ if (args.allowDestructive) {
761
+ warn('--allow-destructive is set: data-destroying schema changes WILL run.');
762
+ newline();
763
+ }
764
+ // Execute (gated): schemaPush throws on destructive statements unless allowed.
765
+ // Pass the diff computed above as `precomputedDiff` so schemaPush applies the
766
+ // EXACT statements just displayed and confirmed, with no re-diff between confirm
767
+ // and apply (the TOCTOU window where a concurrent schema change could alter
768
+ // the applied set). Both the initial attempt and the post-confirmation retry
769
+ // reuse `diff`, so the confirmed plan and the applied plan are identical.
740
770
  const pushSpinner = new Spinner('Applying changes').start();
741
- const result = await schemaPush(schemaDef, url);
771
+ let result;
772
+ try {
773
+ result = await schemaPush(schemaDef, url, { allowDestructive: args.allowDestructive, precomputedDiff: diff });
774
+ }
775
+ catch (err) {
776
+ if (!(err instanceof DestructivePushRefusal))
777
+ throw err;
778
+ pushSpinner.stop();
779
+ if (!(await confirmDestructive(err.message))) {
780
+ error('Aborted: no changes were applied and no data was touched.');
781
+ newline();
782
+ process.exit(1);
783
+ }
784
+ pushSpinner.start();
785
+ result = await schemaPush(schemaDef, url, { allowDestructive: true, precomputedDiff: diff });
786
+ }
742
787
  pushSpinner.succeed(`Applied ${bold(String(result.statementsExecuted))} statement(s)`);
743
788
  if (result.tablesCreated.length > 0) {
744
789
  success(`Created: ${result.tablesCreated.join(', ')}`);
@@ -760,22 +805,30 @@ async function cmdMigrate(args, config) {
760
805
  console.log(` ${bold('turbine migrate')} ${dim('— SQL-first migration system')}`);
761
806
  newline();
762
807
  console.log(` ${bold('Commands:')}`);
763
- console.log(` ${cyan('create <name>')} Create a new migration file`);
764
- console.log(` ${cyan('create <name> --auto')} Auto-generate from schema diff`);
765
- console.log(` ${cyan('up')} Apply pending migrations`);
766
- console.log(` ${cyan('deploy')} Apply pending migrations without prompts`);
767
- console.log(` ${cyan('down')} Rollback last migration`);
768
- console.log(` ${cyan('status')} Show migration status`);
808
+ console.log(` ${cyan('create <name>')} Create a new migration file`);
809
+ console.log(` ${cyan('create <name> --auto')} Auto-generate from schema diff`);
810
+ console.log(` ${cyan('create <name> --recipe')} Scaffold a named recipe (e.g. backfill)`);
811
+ console.log(` ${cyan('up')} Apply pending migrations`);
812
+ console.log(` ${cyan('deploy')} Apply pending migrations without prompts`);
813
+ console.log(` ${cyan('down')} Rollback last migration`);
814
+ console.log(` ${cyan('status')} Show migration status`);
769
815
  newline();
770
816
  console.log(` ${bold('Options:')}`);
771
- console.log(` ${cyan('--auto')} Auto-generate UP/DOWN SQL from schema diff`);
772
- console.log(` ${cyan('--step, -n')} Number of migrations to apply/rollback`);
773
- console.log(` ${cyan('--dry-run')} Show SQL without executing`);
774
- console.log(` ${cyan('--allow-drift')} Bypass checksum validation on ${cyan('migrate up')} ${dim('(advanced)')}`);
817
+ console.log(` ${cyan('--auto')} Auto-generate UP/DOWN SQL from schema diff`);
818
+ console.log(` ${cyan('--recipe <name>')} Scaffold a sanctioned migration pattern`);
819
+ console.log(` ${cyan('--step, -n')} Number of migrations to apply/rollback`);
820
+ console.log(` ${cyan('--dry-run')} Show SQL without executing`);
821
+ console.log(` ${cyan('--allow-drift')} Bypass checksum validation on ${cyan('migrate up')} ${dim('(advanced)')}`);
822
+ newline();
823
+ console.log(` ${bold('Recipes')} ${dim('(--recipe):')}`);
824
+ for (const [key, recipe] of Object.entries(MIGRATION_RECIPES)) {
825
+ console.log(` ${cyan(key)} ${dim(recipe.description)}`);
826
+ }
775
827
  newline();
776
828
  console.log(` ${bold('Examples:')}`);
777
829
  console.log(` ${dim('npx turbine migrate create add_users_table')}`);
778
830
  console.log(` ${dim('npx turbine migrate create add_email_index --auto')}`);
831
+ console.log(` ${dim('npx turbine migrate create backfill_full_name --recipe backfill')}`);
779
832
  console.log(` ${dim('npx turbine migrate up')}`);
780
833
  console.log(` ${dim('npx turbine migrate deploy --dry-run')}`);
781
834
  console.log(` ${dim('npx turbine migrate down --step 2')}`);
@@ -868,6 +921,28 @@ async function cmdMigrateCreate(args, config) {
868
921
  newline();
869
922
  return;
870
923
  }
924
+ if (args.recipe) {
925
+ if (!MIGRATION_RECIPES[args.recipe]) {
926
+ error(`Unknown migration recipe: ${args.recipe}`);
927
+ newline();
928
+ console.log(` ${dim('Available recipes:')}`);
929
+ for (const [key, recipe] of Object.entries(MIGRATION_RECIPES)) {
930
+ console.log(` ${cyan(key)} ${dim(recipe.description)}`);
931
+ }
932
+ newline();
933
+ process.exit(1);
934
+ }
935
+ const file = createMigration(config.migrationsDir, name, undefined, { recipe: args.recipe });
936
+ const relPath = relative(process.cwd(), file.path);
937
+ success(`Created ${args.recipe} migration: ${bold(file.filename)}`);
938
+ newline();
939
+ console.log(` ${dim('File:')} ${cyan(relPath)}`);
940
+ newline();
941
+ console.log(` ${dim('Fill in the commented placeholders, then run:')}`);
942
+ console.log(` ${cyan('npx turbine migrate up')}`);
943
+ newline();
944
+ return;
945
+ }
871
946
  const file = createMigration(config.migrationsDir, name);
872
947
  const relPath = relative(process.cwd(), file.path);
873
948
  success(`Created migration: ${bold(file.filename)}`);
@@ -1420,6 +1495,8 @@ async function cmdStudio(args, config) {
1420
1495
  openBrowser,
1421
1496
  include: config.include.length ? config.include : undefined,
1422
1497
  exclude: config.exclude.length ? config.exclude : undefined,
1498
+ write: args.write === true,
1499
+ showPii: args.showPii === true,
1423
1500
  });
1424
1501
  spinner.succeed(`Studio is running`);
1425
1502
  }
@@ -1427,13 +1504,24 @@ async function cmdStudio(args, config) {
1427
1504
  spinner.fail(`Failed to start Studio: ${err instanceof Error ? err.message : String(err)}`);
1428
1505
  process.exit(1);
1429
1506
  }
1507
+ // Loud startup warnings for the opt-in modes that widen Studio's surface.
1508
+ if (args.write) {
1509
+ newline();
1510
+ console.log(warn('WRITE MODE is ON. Studio can update, insert, and delete single rows in ' +
1511
+ `${redactUrl(url)}. Every change is committed directly to your database.`));
1512
+ }
1513
+ if (args.showPii) {
1514
+ newline();
1515
+ console.log(warn('--show-pii is ON. PII-tagged column values are shown UNREDACTED in Studio.'));
1516
+ }
1430
1517
  newline();
1431
1518
  console.log(box([
1432
- `${bold('Turbine Studio')} ${dim(' local read-only UI')}`,
1519
+ `${bold('Turbine Studio')} ${dim(args.write ? 'local UI (WRITE MODE)' : 'local read-only UI')}`,
1433
1520
  '',
1434
1521
  ` ${cyan('URL:')} ${bold(studio.url)}`,
1435
1522
  ` ${cyan('Schema:')} ${config.schema}`,
1436
1523
  ` ${cyan('DB:')} ${redactUrl(url)}`,
1524
+ ` ${cyan('Mode:')} ${args.write ? red('read-write (single-row)') : 'read-only'}`,
1437
1525
  '',
1438
1526
  dim('Open the URL above in your browser. It includes a one-time session'),
1439
1527
  dim('token that gets set as an HttpOnly cookie on first load.'),
@@ -1613,6 +1701,7 @@ function showPushHelp() {
1613
1701
  console.log(` ${bold('Options:')}`);
1614
1702
  console.log(` ${cyan('--url, -u')} ${dim('<url>')} Postgres connection string`);
1615
1703
  console.log(` ${cyan('--dry-run')} Show SQL without executing`);
1704
+ console.log(` ${cyan('--allow-destructive')} Skip the interactive confirmation for data-destroying statements ${dim('(CI)')}`);
1616
1705
  console.log(` ${cyan('--verbose, -v')} Show detailed output`);
1617
1706
  newline();
1618
1707
  }
@@ -1633,6 +1722,7 @@ function showMigrateHelp() {
1633
1722
  console.log(` ${bold('Options:')}`);
1634
1723
  console.log(` ${cyan('--url, -u')} ${dim('<url>')} Postgres connection string`);
1635
1724
  console.log(` ${cyan('--auto')} Auto-generate UP/DOWN SQL from schema diff ${dim('(create only)')}`);
1725
+ console.log(` ${cyan('--recipe')} ${dim('<name>')} Scaffold a sanctioned migration pattern ${dim('(create only, e.g. backfill)')}`);
1636
1726
  console.log(` ${cyan('--step, -n')} ${dim('<N>')} Number of migrations to apply/rollback`);
1637
1727
  console.log(` ${cyan('--dry-run')} Show SQL without executing`);
1638
1728
  console.log(` ${cyan('--allow-drift')} Bypass checksum validation ${dim('(migrate up only — advanced)')}`);
@@ -1642,6 +1732,7 @@ function showMigrateHelp() {
1642
1732
  console.log(` ${bold('Examples:')}`);
1643
1733
  console.log(` ${dim('$')} npx turbine migrate create add_users_table`);
1644
1734
  console.log(` ${dim('$')} npx turbine migrate create add_email_index --auto`);
1735
+ console.log(` ${dim('$')} npx turbine migrate create backfill_full_name --recipe backfill`);
1645
1736
  console.log(` ${dim('$')} npx turbine migrate up`);
1646
1737
  console.log(` ${dim('$')} npx turbine migrate deploy --dry-run`);
1647
1738
  console.log(` ${dim('$')} npx turbine migrate down --step 2`);
@@ -1717,7 +1808,7 @@ function showHelp() {
1717
1808
  console.log(` ${cyan('seed')} Run seed file`);
1718
1809
  console.log(` ${cyan('status')} ${dim('| info')} Show schema summary`);
1719
1810
  console.log(` ${cyan('doctor')} Check relations for missing FK indexes ${dim('(--fix emits migration)')}`);
1720
- console.log(` ${cyan('studio')} Launch local read-only web UI`);
1811
+ console.log(` ${cyan('studio')} Launch local read-only web UI ${dim('(--write opts in to single-row writes)')}`);
1721
1812
  console.log(` ${cyan('mcp')} Start read-only MCP server over stdio`);
1722
1813
  console.log(` ${cyan('observe')} Launch metrics dashboard ${dim('(requires TURBINE_OBSERVE_URL)')}`);
1723
1814
  newline();
@@ -1741,6 +1832,8 @@ function showHelp() {
1741
1832
  console.log(` ${cyan('--host')} ${dim('<addr>')} Bind address ${dim('(default: 127.0.0.1)')}`);
1742
1833
  console.log(` ${cyan('--no-open')} Don't auto-open the browser`);
1743
1834
  console.log(` ${cyan('--allow-remote')} Allow non-loopback --host ${dim('(refused without this flag)')}`);
1835
+ console.log(` ${cyan('--write')} Studio: enable single-row update/insert/delete ${dim('(read-only by default)')}`);
1836
+ console.log(` ${cyan('--show-pii')} Studio: show PII-tagged values unredacted ${dim('(redacted by default)')}`);
1744
1837
  newline();
1745
1838
  console.log(` ${bold('Config file:')}`);
1746
1839
  console.log(` ${dim('Create')} ${cyan('turbine.config.ts')} ${dim('with')} ${cyan('npx turbine init')}`);
@@ -73,13 +73,36 @@ export declare function parseMigrationSQL(filePath: string): {
73
73
  up: string;
74
74
  down: string;
75
75
  };
76
+ /**
77
+ * A named migration scaffold. `build()` returns the commented-SQL UP/DOWN body
78
+ * for the recipe; `createMigration({ recipe })` wraps it in the file header.
79
+ */
80
+ export interface MigrationRecipe {
81
+ /** One-line description shown in CLI help. */
82
+ description: string;
83
+ /** Build the UP/DOWN body (commented scaffold with placeholders). */
84
+ build(name: string): {
85
+ up: string;
86
+ down: string;
87
+ };
88
+ }
89
+ /**
90
+ * Registry of migration recipes, keyed by `--recipe <name>`. New recipes slot
91
+ * in here without touching {@link createMigration} or the CLI handler.
92
+ */
93
+ export declare const MIGRATION_RECIPES: Record<string, MigrationRecipe>;
76
94
  /**
77
95
  * Create a new migration file.
78
- * If `autoContent` is provided, the UP/DOWN sections are pre-populated with the given SQL.
96
+ *
97
+ * - `autoContent`: pre-populate UP/DOWN from a schema diff.
98
+ * - `options.recipe`: scaffold a named recipe (see {@link MIGRATION_RECIPES}).
99
+ * Mutually exclusive with `autoContent`; an unknown recipe throws.
79
100
  */
80
101
  export declare function createMigration(migrationsDir: string, name: string, autoContent?: {
81
102
  up: string;
82
103
  down: string;
104
+ }, options?: {
105
+ recipe?: string;
83
106
  }): MigrationFile;
84
107
  /**
85
108
  * Derive a Postgres advisory lock ID (positive int4) from the database name.
@@ -149,14 +149,70 @@ function checksum(content) {
149
149
  function isLegacyChecksum(hash) {
150
150
  return hash.length < 64;
151
151
  }
152
+ /** The sanctioned two-phase (add nullable, batched backfill, swap) recipe. */
153
+ function buildBackfillRecipe() {
154
+ const up = `-- Two-phase backfill scaffold. Every statement below is COMMENTED OUT: fill in
155
+ -- your table, the new column, the old column, and the transform, then uncomment
156
+ -- the phases you need and review before running \`npx turbine migrate up\`.
157
+ --
158
+ -- Phase 1: add the new column as NULLABLE. This is a fast, non-blocking change
159
+ -- (no table rewrite, no long lock), so it is safe to ship ahead of the backfill.
160
+ -- ALTER TABLE "my_table" ADD COLUMN "new_col" text;
161
+ --
162
+ -- Phase 2: backfill in bounded batches. Repeat this UPDATE until it reports
163
+ -- 0 rows affected. \`turbine migrate\` runs each file exactly once, so for large
164
+ -- tables drive the loop from psql or your app rather than inlining it here.
165
+ -- Tune the LIMIT (batch size) to your row width and lock tolerance.
166
+ -- UPDATE "my_table"
167
+ -- SET "new_col" = transform("old_col")
168
+ -- WHERE "new_col" IS NULL
169
+ -- AND "id" IN (
170
+ -- SELECT "id" FROM "my_table" WHERE "new_col" IS NULL LIMIT 5000
171
+ -- );
172
+ --
173
+ -- Phase 3: once every row is populated, enforce NOT NULL.
174
+ -- Note: SET NOT NULL takes an exclusive lock and scans the table. On huge
175
+ -- tables, first ADD CONSTRAINT ... CHECK ("new_col" IS NOT NULL) NOT VALID,
176
+ -- then VALIDATE CONSTRAINT (PG 12+ uses the validated check to skip the scan).
177
+ -- ALTER TABLE "my_table" ALTER COLUMN "new_col" SET NOT NULL;
178
+ --
179
+ -- Phase 4 (optional atomic swap): retire the old column and rename the new one
180
+ -- into its place, in one transaction so readers never see a missing column.
181
+ -- BEGIN;
182
+ -- ALTER TABLE "my_table" RENAME COLUMN "old_col" TO "old_col_retired";
183
+ -- ALTER TABLE "my_table" RENAME COLUMN "new_col" TO "old_col";
184
+ -- COMMIT;`;
185
+ const down = `-- Reverse the Phase 4 atomic swap (only if you ran it).
186
+ -- BEGIN;
187
+ -- ALTER TABLE "my_table" RENAME COLUMN "old_col" TO "new_col";
188
+ -- ALTER TABLE "my_table" RENAME COLUMN "old_col_retired" TO "old_col";
189
+ -- COMMIT;
190
+ --
191
+ -- If you stopped after phases 1 to 3, drop the added column instead:
192
+ -- ALTER TABLE "my_table" DROP COLUMN "new_col";`;
193
+ return { up, down };
194
+ }
195
+ /**
196
+ * Registry of migration recipes, keyed by `--recipe <name>`. New recipes slot
197
+ * in here without touching {@link createMigration} or the CLI handler.
198
+ */
199
+ export const MIGRATION_RECIPES = {
200
+ backfill: {
201
+ description: 'Two-phase column backfill (add nullable, batched UPDATE, SET NOT NULL, rename swap)',
202
+ build: buildBackfillRecipe,
203
+ },
204
+ };
152
205
  // ---------------------------------------------------------------------------
153
206
  // Commands
154
207
  // ---------------------------------------------------------------------------
155
208
  /**
156
209
  * Create a new migration file.
157
- * If `autoContent` is provided, the UP/DOWN sections are pre-populated with the given SQL.
210
+ *
211
+ * - `autoContent`: pre-populate UP/DOWN from a schema diff.
212
+ * - `options.recipe`: scaffold a named recipe (see {@link MIGRATION_RECIPES}).
213
+ * Mutually exclusive with `autoContent`; an unknown recipe throws.
158
214
  */
159
- export function createMigration(migrationsDir, name, autoContent) {
215
+ export function createMigration(migrationsDir, name, autoContent, options) {
160
216
  mkdirSync(migrationsDir, { recursive: true });
161
217
  const now = new Date();
162
218
  const ts = formatTimestamp(now);
@@ -164,7 +220,25 @@ export function createMigration(migrationsDir, name, autoContent) {
164
220
  const filename = `${ts}_${safeName}.sql`;
165
221
  const filePath = join(migrationsDir, filename);
166
222
  let template;
167
- if (autoContent) {
223
+ if (options?.recipe) {
224
+ const recipe = MIGRATION_RECIPES[options.recipe];
225
+ if (!recipe) {
226
+ const known = Object.keys(MIGRATION_RECIPES).join(', ') || '(none)';
227
+ throw new MigrationError(`[turbine] Unknown migration recipe "${options.recipe}". Available recipes: ${known}`);
228
+ }
229
+ const body = recipe.build(name);
230
+ template = `-- Migration: ${name} (${options.recipe} recipe scaffold)
231
+ -- Created: ${now.toISOString()}
232
+ -- Fill in the placeholders and review before running: npx turbine migrate up
233
+
234
+ -- UP
235
+ ${body.up}
236
+
237
+ -- DOWN
238
+ ${body.down}
239
+ `;
240
+ }
241
+ else if (autoContent) {
168
242
  template = `-- Migration: ${name} (auto-generated from schema diff)
169
243
  -- Created: ${now.toISOString()}
170
244
  -- Review this file before running: npx turbine migrate up