turbine-orm 0.35.0 → 0.36.1

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 +4 -4
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # turbine-orm
2
2
 
3
- The Postgres ORM your DBA will sign off on. A read-only Studio that can't touch prod, errors that never leak PII, one dependency, and checksummed migrations.
3
+ The Postgres ORM your DBA will sign off on. A read-only-by-default Studio, PII-tagged columns that stay out of results until asked for, errors that never leak data, one dependency, and checksummed migrations.
4
4
 
5
5
  ```
6
6
  npm install turbine-orm
@@ -12,14 +12,14 @@ npm install turbine-orm
12
12
 
13
13
  Every TS ORM now resolves nested relations in a single `json_agg` query — Prisma 7 and Drizzle both ship it, and so does Turbine. That part is table stakes. The reason to reach for Turbine is the **safety bundle**: the boxes a DBA ticks before a query layer goes anywhere near production. It's the only TypeScript ORM that ships all six of these together:
14
14
 
15
- 1. **Read-only Studio your DBA will approve.** `npx turbine studio` spins up a loopback-bound web UI with 192-bit auth tokens, `BEGIN READ ONLY` transactions, and since v0.19 no raw-SQL surface at all: queries are composed in the ORM's own validated builder. The only TS ORM Studio that physically cannot mutate your database.
15
+ 1. **Read-only-by-default Studio your DBA will approve.** `npx turbine studio` spins up a loopback-bound web UI with 192-bit auth tokens, `BEGIN READ ONLY` transactions, and (since v0.19) no raw-SQL surface at all: queries are composed in the ORM's own validated builder. In the default mode the write endpoints do not exist and every transaction is read-only at the database level; single-row edits require an explicit `--write` opt-in per launch.
16
16
  2. **PII-safe error messages.** Turbine errors show WHERE keys, not values. A `UniqueConstraintError` says which column violated the constraint — never the actual user data. Safe to log, safe to surface to monitoring, no scrubbing needed.
17
- 3. **One runtime dependency (`pg`).** No engine binary, no WASM, no adapter packages to keep in lockstep. The main entry's **import graph** is ~42 kB brotli (edge ~33 kB) with `pg` external that is the client footprint your bundler sees, not the dual ESM+CJS install size on disk (~3 MB). Prisma 7 dropped its Rust query engine, but its client still ships a TypeScript/WASM query compiler a ~1.6 MB bundle, down from the ~14 MB Rust-era client.
17
+ 3. **One runtime dependency (`pg`).** No engine binary, no WASM, no adapter packages to keep in lockstep. The main entry's **import graph** is ~53 kB brotli (edge ~40 kB) with `pg` external (that is the client footprint your bundler sees, not the dual ESM+CJS install size on disk, ~3 MB). Prisma 7 dropped its Rust query engine, but its client still ships a TypeScript/WASM query compiler: a ~1.6 MB bundle, down from the ~14 MB Rust-era client.
18
18
  4. **SQL-first migrations with drift detection.** Write real SQL. SHA-256 checksums catch modified migration files. `pg_try_advisory_lock()` prevents concurrent runs. Each migration in its own transaction. No shadow database, no magic DSL.
19
19
  5. **Edge-native — one import swap.** `turbineHttp(pool, SCHEMA)` — same API on Neon, Vercel Postgres, Cloudflare Hyperdrive, Supabase. No WASM bundle, no adapter package, no separate serverless build.
20
20
  6. **Pipeline batching via wire protocol.** Real Parse/Bind/Execute pipeline — not queries wrapped in a transaction. N independent queries in one round-trip.
21
21
 
22
- See [How It Works](#how-it-works) for the `json_agg` query strategy itself but the query strategy isn't why you'd pick Turbine. The safety bundle above is: a Studio that can't mutate prod, errors that never leak PII, one dependency, and checksummed migrations.
22
+ See [How It Works](#how-it-works) for the `json_agg` query strategy itself, but the query strategy isn't why you'd pick Turbine. The safety bundle above is: a Studio that is read-only unless you explicitly opt in to writes, PII columns that stay out of results until asked for, errors that never leak data, one dependency, and checksummed migrations.
23
23
 
24
24
  **New in 0.28.0:** [global filters](https://turbineorm.dev/global-filters) for soft-delete and multi-tenancy · [read replicas](https://turbineorm.dev/read-replicas) with a `$primary()` escape hatch · a read-only [MCP server](https://turbineorm.dev/mcp) for AI agents · [seed-as-code](https://turbineorm.dev/seeding) and a non-interactive `migrate deploy` for CI · [Zod generation](https://turbineorm.dev/zod) · read-only [views & generated columns](https://turbineorm.dev/views) · `NULLS FIRST/LAST` ordering, relation `_count`, and ordering by a relation · schema referential actions, enums, array, `vector`, and check constraints.
25
25
 
@@ -46,7 +46,7 @@ Tested against **Prisma 7.6** (adapter-pg, relationJoins preview on) and **Drizz
46
46
  - **Drizzle leads nested reads (L2), streaming, and atomic increment.** Its relational query builder emits tighter SQL for the posts/comments joins, and its keyset pagination drains 50K rows fastest. Turbine's `json_agg` nesting is close behind and still 1.9x to 3.3x ahead of Prisma on the same L2/L3 shapes. L3 is a genuine Turbine/Drizzle near-tie that flips between runs.
47
47
  - **Prisma trails on every scenario here.** Its engine-less client's per-query work is no longer masked by network latency; on a pooled remote database (the regime we measured previously) these same deltas compress back into the noise floor.
48
48
 
49
- Net: on a local socket Turbine wins 6 of 10 scenarios, loses L2 / streaming / atomic increment to Drizzle, and trades the L3 lead run-to-run. It is competitive-to-ahead across the board rather than a clean sweep, and the honest takeaway is unchanged: performance is close enough that the real reasons to choose Turbine are elsewhere. **One dependency and no WASM** (vs Prisma 7's ~1.6 MB TypeScript/WASM query compiler), the **only read-only Studio** in the TS ORM ecosystem, **PII-safe error messages** that never leak user data, and **SQL-first migrations** with SHA-256 drift detection. Deep type inference through `with` clauses works end-to-end: write `db.users.findMany({ with: { posts: { with: { comments: true } } } })` and `users[0].posts[0].comments[0].body` autocompletes, with no manual assertion and no helper annotation.
49
+ Net: on a local socket Turbine wins 6 of 10 scenarios, loses L2 / streaming / atomic increment to Drizzle, and trades the L3 lead run-to-run. It is competitive-to-ahead across the board rather than a clean sweep, and the honest takeaway is unchanged: performance is close enough that the real reasons to choose Turbine are elsewhere. **One dependency and no WASM** (vs Prisma 7's ~1.6 MB TypeScript/WASM query compiler), the **only read-only-by-default Studio** in the TS ORM ecosystem, **PII-safe error messages** that never leak user data, and **SQL-first migrations** with SHA-256 drift detection. Deep type inference through `with` clauses works end-to-end: write `db.users.findMany({ with: { posts: { with: { comments: true } } } })` and `users[0].posts[0].comments[0].body` autocompletes, with no manual assertion and no helper annotation.
50
50
 
51
51
  > Full analysis with p50/p95/p99 and methodology notes: [`benchmarks/RESULTS.md`](./benchmarks/RESULTS.md).
52
52
  > Reproduce: `cd benchmarks && npm install && npx prisma generate && DATABASE_URL=... npx tsx bench.ts`
@@ -440,7 +440,7 @@ try {
440
440
  }
441
441
  ```
442
442
 
443
- Error codes: `TURBINE_E001` (NotFound), `TURBINE_E002` (Timeout), `TURBINE_E003` (Validation), `TURBINE_E004` (Connection), `TURBINE_E005` (Relation), `TURBINE_E006` (Migration), `TURBINE_E007` (CircularRelation), `TURBINE_E008` (UniqueConstraint), `TURBINE_E009` (ForeignKey), `TURBINE_E010` (NotNullViolation), `TURBINE_E011` (CheckConstraint), `TURBINE_E012` (Deadlock), `TURBINE_E013` (SerializationFailure), `TURBINE_E014` (Pipeline), `TURBINE_E015` (OptimisticLock), `TURBINE_E016` (ExclusionConstraint), `TURBINE_E017` (UnsupportedFeature a Postgres-only feature invoked on another engine).
443
+ Error codes: `TURBINE_E001` (NotFound), `TURBINE_E002` (Timeout), `TURBINE_E003` (Validation), `TURBINE_E004` (Connection), `TURBINE_E005` (Relation), `TURBINE_E006` (Migration), `TURBINE_E007` (CircularRelation), `TURBINE_E008` (UniqueConstraint), `TURBINE_E009` (ForeignKey), `TURBINE_E010` (NotNullViolation), `TURBINE_E011` (CheckConstraint), `TURBINE_E012` (Deadlock), `TURBINE_E013` (SerializationFailure), `TURBINE_E014` (Pipeline), `TURBINE_E015` (OptimisticLock), `TURBINE_E016` (ExclusionConstraint), `TURBINE_E017` (UnsupportedFeature: a Postgres-only feature invoked on another engine), `TURBINE_E018` (ReadOnly: a write refused on a read-only database, reason `'snapshot'` | `'rbac'`).
444
444
 
445
445
  Full reference with `wrapPgError()` translation, retry patterns for `DeadlockError` / `SerializationFailureError`, and safe vs verbose message modes: **[turbineorm.dev/errors](https://turbineorm.dev/errors)**.
446
446
 
@@ -614,7 +614,7 @@ Commands:
614
614
  seed Run seed file
615
615
  status Show database schema summary
616
616
  doctor Check relations for missing FK indexes (--fix emits migration)
617
- studio Launch local read-only Studio web UI
617
+ studio Launch local Studio web UI (read-only; --write opts in to single-row writes)
618
618
  mcp Start read-only MCP server over JSON-RPC stdio
619
619
  observe Launch local metrics dashboard (requires TURBINE_OBSERVE_URL)
620
620
 
@@ -680,7 +680,7 @@ then `yes`; in CI you must pass `--allow-destructive`. A refused batch applies n
680
680
 
681
681
  ## Studio
682
682
 
683
- The only Postgres ORM with a Studio your DBA will approve. `turbine studio` launches a local, read-only web UI for exploring your database no mutations, no writes, and since v0.19 **no raw-SQL surface at all**: every query is composed visually in the ORM and compiled by the same validated query builder your application uses.
683
+ The only Postgres ORM with a Studio your DBA will approve. `turbine studio` launches a local web UI for exploring your database. It is **read-only by default** (no mutations, no writes, every transaction `BEGIN READ ONLY`) and since v0.19 has **no raw-SQL surface at all**: every query is composed visually in the ORM and compiled by the same validated query builder your application uses. Since v0.36, `--write` opts a launch in to single-row insert/update/delete through that same validated builder; without the flag the write endpoints do not exist.
684
684
 
685
685
  ```bash
686
686
  DATABASE_URL=postgres://user:pass@localhost:5432/mydb npx turbine studio
@@ -695,14 +695,16 @@ npx turbine studio --port 5173 --host 127.0.0.1 --no-open
695
695
  - **Saved queries.** Named builder queries persisted to `.turbine/studio-queries.json` — share them across runs without committing them.
696
696
  - **Cmd+K command palette.** Jump to any table, tab, or saved query in one keystroke.
697
697
  - **Full-text search across rows.** The Data tab supports substring search across every text column of the current table.
698
+ - **PII redaction.** Columns tagged `pii: true` in the schema render as a redaction placeholder in every tab. `--show-pii` reveals them, with a loud startup warning.
699
+ - **Opt-in write mode.** `--write` enables single-row insert/update/delete from the Data tab, gated per row by the full primary key, compiled by the same validated builders, and flagged with a persistent WRITE MODE banner. Read-only stays the default on every launch.
698
700
 
699
- **Security posture (read-only by design)**
701
+ **Security posture (read-only by default)**
700
702
 
701
- - **No SQL input surface.** There is nothing to inject into builder requests are validated identifier-by-identifier against the introspected schema, and every value is bound as a `$N` parameter.
703
+ - **No SQL input surface.** There is nothing to inject into: builder requests are validated identifier-by-identifier against the introspected schema, and every value is bound as a `$N` parameter.
702
704
  - **Loopback by default** (`127.0.0.1`). Non-loopback `--host` is **refused** unless you pass `--allow-remote` (loud warning when you opt in).
703
- - **Per-process auth token** 24 random bytes of hex, stored in a `SameSite=Strict` `HttpOnly` cookie.
704
- - **Every query runs inside `BEGIN READ ONLY`** with a 30s transaction-local statement timeout (parameterized `set_config`). Writes are physically impossible at the transaction level.
705
- - **Security headers on every response** CSP, `X-Content-Type-Options`, `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer` plus per-session rate limiting and cross-origin refusal.
705
+ - **Per-process auth token**: 24 random bytes of hex, stored in a `SameSite=Strict` `HttpOnly` cookie.
706
+ - **Every read query runs inside `BEGIN READ ONLY`** with a 30s transaction-local statement timeout (parameterized `set_config`). Without `--write`, the write endpoints do not exist (they 404) and writes are impossible at the transaction level; with it, each write runs in its own transaction with the same timeout and schema pinning, requires the row's full primary key, and rejects absent or mismatched `Origin` headers.
707
+ - **Security headers on every response**: nonce-based CSP, `X-Content-Type-Options`, `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer`, plus per-session rate limiting and cross-origin refusal.
706
708
 
707
709
  ## Observability
708
710
 
@@ -944,11 +946,11 @@ Turbine maps Postgres types to TypeScript:
944
946
  |---|---|---|---|---|
945
947
  | **Engine / runtime** | No engine binary (`pg` only) | Client + TS/WASM query compiler | No engine | No engine |
946
948
  | **Runtime deps** | 1 (`pg`) | `@prisma/client` + required driver adapter | 0 | 0 |
947
- | **Main bundle (brotli)** | ~42 kB | ~1.6 MB client (TS/WASM compiler) | ~7 KB core | small |
949
+ | **Main bundle (brotli)** | ~53 kB | ~1.6 MB client (TS/WASM compiler) | ~7 KB core | small |
948
950
  | **Studio** | Read-only, 192-bit auth | Full CRUD, cloud-hosted | Free; hosted Gateway paid | None |
949
951
  | **Error PII safety** | Keys only by default | Values in messages | Raw pg errors | Raw pg errors |
950
952
  | **Migrations** | SQL-first, SHA-256 checksums | DSL-generated, shadow DB | SQL or Drizzle Kit | None |
951
- | **Edge runtime** | One import swap, ~33 kB brotli | Driver adapter + WASM compiler | Native | Native |
953
+ | **Edge runtime** | One import swap, ~40 kB brotli | Driver adapter + WASM compiler | Native | Native |
952
954
  | **Pipeline batching** | Parse/Bind/Execute protocol | Sequential in txn | Sequential | Manual |
953
955
  | **Typed errors** | `isRetryable` discriminant | Error codes only | None | None |
954
956
  | **Nested relations** | 1 query, deep type inference | 1 query, shallow inference | 1 query, `relations()` re-declaration | Manual (`jsonArrayFrom`) |
@@ -957,7 +959,7 @@ Turbine maps Postgres types to TypeScript:
957
959
  | **LISTEN/NOTIFY** | `$listen` / `$notify` | None | None | None |
958
960
  | **Multi-DB** | Postgres-first (+ SQLite/MySQL/MSSQL engines) | PG, MySQL, SQLite, MSSQL | PG, MySQL, SQLite | PG, MySQL, SQLite |
959
961
 
960
- All three ORMs now do single-query nested loads — that's table stakes. Turbine's real differentiators: no engine binary or WASM — just one dependency (`pg`), vs Prisma 7's ~1.6 MB TypeScript/WASM query compiler and required driver adapter; the only read-only Studio in the ecosystem; error messages that never leak PII; and SQL-first migrations with SHA-256 drift detection. See [Benchmarks](#benchmarks) for performance numbers — most scenarios are within noise over a real pooled database.
962
+ All three ORMs now do single-query nested loads — that's table stakes. Turbine's real differentiators: no engine binary or WASM — just one dependency (`pg`), vs Prisma 7's ~1.6 MB TypeScript/WASM query compiler and required driver adapter; the only read-only-by-default Studio in the ecosystem; error messages that never leak PII; and SQL-first migrations with SHA-256 drift detection. See [Benchmarks](#benchmarks) for performance numbers — most scenarios are within noise over a real pooled database.
961
963
 
962
964
  **A note on Kysely.** Kysely's [`jsonArrayFrom` / `jsonObjectFrom`](https://kysely.dev/docs/recipes/relations) relations recipe builds nested results with the same correlated-subquery-plus-JSON approach Turbine uses — good evidence the pattern is the right one. The gap is in what the driver can no longer see once rows are aggregated into JSON: nested fields lose their column types, so a `Date` inside a `jsonArrayFrom` result is typed `Date` but arrives as a **string** at runtime ([kysely-org/kysely#482](https://github.com/kysely-org/kysely/issues/482)), and the nesting isn't type-checked at depth. Turbine's `WithResult` inference types the whole tree, and `parseNestedRow` re-applies date coercion (and snake→camel mapping) to every nested row — so `users[0].posts[0].createdAt` is an actual `Date`, at any depth, with no plugin to wire up.
963
965
 
@@ -6,8 +6,8 @@
6
6
  * Commands:
7
7
  * turbine init — Initialize a Turbine project
8
8
  * turbine generate | pull — Introspect database and generate TypeScript types
9
- * turbine push Apply schema-builder definitions to database
10
- * turbine migrate create <name> Create a new SQL migration file
9
+ * turbine push - Apply schema-builder definitions to database (destructive ops gated)
10
+ * turbine migrate create <name> - Create a new SQL migration file (--auto | --recipe <name>)
11
11
  * turbine migrate up — Apply pending migrations
12
12
  * turbine migrate deploy — Apply pending migrations without prompts
13
13
  * turbine migrate down — Rollback last migration
@@ -150,6 +150,14 @@ function parseArgs(argv = process.argv.slice(2)) {
150
150
  case '--allow-destructive':
151
151
  result.allowDestructive = true;
152
152
  break;
153
+ case '--recipe':
154
+ if (next === undefined || next.startsWith('-')) {
155
+ console.error('--recipe requires a name (e.g. --recipe backfill)');
156
+ process.exit(1);
157
+ }
158
+ result.recipe = next;
159
+ i++;
160
+ break;
153
161
  case '--force':
154
162
  case '-f':
155
163
  result.force = true;
@@ -176,6 +184,12 @@ function parseArgs(argv = process.argv.slice(2)) {
176
184
  case '--allow-remote':
177
185
  result.allowRemote = true;
178
186
  break;
187
+ case '--write':
188
+ result.write = true;
189
+ break;
190
+ case '--show-pii':
191
+ result.showPii = true;
192
+ break;
179
193
  default:
180
194
  if (!arg.startsWith('-')) {
181
195
  result.positional.push(arg);
@@ -773,14 +787,45 @@ async function cmdPush(args, config) {
773
787
  }
774
788
  (0, ui_js_1.newline)();
775
789
  }
790
+ // Surface any non-fatal diff warnings (e.g. undeclared DB indexes, enum
791
+ // removals) the diff refuses to apply automatically.
792
+ if (diff.warnings && diff.warnings.length > 0) {
793
+ for (const w of diff.warnings)
794
+ (0, ui_js_1.warn)(w);
795
+ (0, ui_js_1.newline)();
796
+ }
776
797
  if (args.dryRun) {
777
798
  (0, ui_js_1.info)('Dry run — no changes applied.');
778
799
  (0, ui_js_1.newline)();
779
800
  return;
780
801
  }
781
- // Execute
802
+ if (args.allowDestructive) {
803
+ (0, ui_js_1.warn)('--allow-destructive is set: data-destroying schema changes WILL run.');
804
+ (0, ui_js_1.newline)();
805
+ }
806
+ // Execute (gated): schemaPush throws on destructive statements unless allowed.
807
+ // Pass the diff computed above as `precomputedDiff` so schemaPush applies the
808
+ // EXACT statements just displayed and confirmed, with no re-diff between confirm
809
+ // and apply (the TOCTOU window where a concurrent schema change could alter
810
+ // the applied set). Both the initial attempt and the post-confirmation retry
811
+ // reuse `diff`, so the confirmed plan and the applied plan are identical.
782
812
  const pushSpinner = new ui_js_1.Spinner('Applying changes').start();
783
- const result = await (0, schema_sql_js_1.schemaPush)(schemaDef, url);
813
+ let result;
814
+ try {
815
+ result = await (0, schema_sql_js_1.schemaPush)(schemaDef, url, { allowDestructive: args.allowDestructive, precomputedDiff: diff });
816
+ }
817
+ catch (err) {
818
+ if (!(err instanceof schema_sql_js_1.DestructivePushRefusal))
819
+ throw err;
820
+ pushSpinner.stop();
821
+ if (!(await confirmDestructive(err.message))) {
822
+ (0, ui_js_1.error)('Aborted: no changes were applied and no data was touched.');
823
+ (0, ui_js_1.newline)();
824
+ process.exit(1);
825
+ }
826
+ pushSpinner.start();
827
+ result = await (0, schema_sql_js_1.schemaPush)(schemaDef, url, { allowDestructive: true, precomputedDiff: diff });
828
+ }
784
829
  pushSpinner.succeed(`Applied ${(0, ui_js_1.bold)(String(result.statementsExecuted))} statement(s)`);
785
830
  if (result.tablesCreated.length > 0) {
786
831
  (0, ui_js_1.success)(`Created: ${result.tablesCreated.join(', ')}`);
@@ -802,22 +847,30 @@ async function cmdMigrate(args, config) {
802
847
  console.log(` ${(0, ui_js_1.bold)('turbine migrate')} ${(0, ui_js_1.dim)('— SQL-first migration system')}`);
803
848
  (0, ui_js_1.newline)();
804
849
  console.log(` ${(0, ui_js_1.bold)('Commands:')}`);
805
- console.log(` ${(0, ui_js_1.cyan)('create <name>')} Create a new migration file`);
806
- console.log(` ${(0, ui_js_1.cyan)('create <name> --auto')} Auto-generate from schema diff`);
807
- console.log(` ${(0, ui_js_1.cyan)('up')} Apply pending migrations`);
808
- console.log(` ${(0, ui_js_1.cyan)('deploy')} Apply pending migrations without prompts`);
809
- console.log(` ${(0, ui_js_1.cyan)('down')} Rollback last migration`);
810
- console.log(` ${(0, ui_js_1.cyan)('status')} Show migration status`);
850
+ console.log(` ${(0, ui_js_1.cyan)('create <name>')} Create a new migration file`);
851
+ console.log(` ${(0, ui_js_1.cyan)('create <name> --auto')} Auto-generate from schema diff`);
852
+ console.log(` ${(0, ui_js_1.cyan)('create <name> --recipe')} Scaffold a named recipe (e.g. backfill)`);
853
+ console.log(` ${(0, ui_js_1.cyan)('up')} Apply pending migrations`);
854
+ console.log(` ${(0, ui_js_1.cyan)('deploy')} Apply pending migrations without prompts`);
855
+ console.log(` ${(0, ui_js_1.cyan)('down')} Rollback last migration`);
856
+ console.log(` ${(0, ui_js_1.cyan)('status')} Show migration status`);
811
857
  (0, ui_js_1.newline)();
812
858
  console.log(` ${(0, ui_js_1.bold)('Options:')}`);
813
- console.log(` ${(0, ui_js_1.cyan)('--auto')} Auto-generate UP/DOWN SQL from schema diff`);
814
- console.log(` ${(0, ui_js_1.cyan)('--step, -n')} Number of migrations to apply/rollback`);
815
- console.log(` ${(0, ui_js_1.cyan)('--dry-run')} Show SQL without executing`);
816
- console.log(` ${(0, ui_js_1.cyan)('--allow-drift')} Bypass checksum validation on ${(0, ui_js_1.cyan)('migrate up')} ${(0, ui_js_1.dim)('(advanced)')}`);
859
+ console.log(` ${(0, ui_js_1.cyan)('--auto')} Auto-generate UP/DOWN SQL from schema diff`);
860
+ console.log(` ${(0, ui_js_1.cyan)('--recipe <name>')} Scaffold a sanctioned migration pattern`);
861
+ console.log(` ${(0, ui_js_1.cyan)('--step, -n')} Number of migrations to apply/rollback`);
862
+ console.log(` ${(0, ui_js_1.cyan)('--dry-run')} Show SQL without executing`);
863
+ console.log(` ${(0, ui_js_1.cyan)('--allow-drift')} Bypass checksum validation on ${(0, ui_js_1.cyan)('migrate up')} ${(0, ui_js_1.dim)('(advanced)')}`);
864
+ (0, ui_js_1.newline)();
865
+ console.log(` ${(0, ui_js_1.bold)('Recipes')} ${(0, ui_js_1.dim)('(--recipe):')}`);
866
+ for (const [key, recipe] of Object.entries(migrate_js_1.MIGRATION_RECIPES)) {
867
+ console.log(` ${(0, ui_js_1.cyan)(key)} ${(0, ui_js_1.dim)(recipe.description)}`);
868
+ }
817
869
  (0, ui_js_1.newline)();
818
870
  console.log(` ${(0, ui_js_1.bold)('Examples:')}`);
819
871
  console.log(` ${(0, ui_js_1.dim)('npx turbine migrate create add_users_table')}`);
820
872
  console.log(` ${(0, ui_js_1.dim)('npx turbine migrate create add_email_index --auto')}`);
873
+ console.log(` ${(0, ui_js_1.dim)('npx turbine migrate create backfill_full_name --recipe backfill')}`);
821
874
  console.log(` ${(0, ui_js_1.dim)('npx turbine migrate up')}`);
822
875
  console.log(` ${(0, ui_js_1.dim)('npx turbine migrate deploy --dry-run')}`);
823
876
  console.log(` ${(0, ui_js_1.dim)('npx turbine migrate down --step 2')}`);
@@ -910,6 +963,28 @@ async function cmdMigrateCreate(args, config) {
910
963
  (0, ui_js_1.newline)();
911
964
  return;
912
965
  }
966
+ if (args.recipe) {
967
+ if (!migrate_js_1.MIGRATION_RECIPES[args.recipe]) {
968
+ (0, ui_js_1.error)(`Unknown migration recipe: ${args.recipe}`);
969
+ (0, ui_js_1.newline)();
970
+ console.log(` ${(0, ui_js_1.dim)('Available recipes:')}`);
971
+ for (const [key, recipe] of Object.entries(migrate_js_1.MIGRATION_RECIPES)) {
972
+ console.log(` ${(0, ui_js_1.cyan)(key)} ${(0, ui_js_1.dim)(recipe.description)}`);
973
+ }
974
+ (0, ui_js_1.newline)();
975
+ process.exit(1);
976
+ }
977
+ const file = (0, migrate_js_1.createMigration)(config.migrationsDir, name, undefined, { recipe: args.recipe });
978
+ const relPath = (0, node_path_1.relative)(process.cwd(), file.path);
979
+ (0, ui_js_1.success)(`Created ${args.recipe} migration: ${(0, ui_js_1.bold)(file.filename)}`);
980
+ (0, ui_js_1.newline)();
981
+ console.log(` ${(0, ui_js_1.dim)('File:')} ${(0, ui_js_1.cyan)(relPath)}`);
982
+ (0, ui_js_1.newline)();
983
+ console.log(` ${(0, ui_js_1.dim)('Fill in the commented placeholders, then run:')}`);
984
+ console.log(` ${(0, ui_js_1.cyan)('npx turbine migrate up')}`);
985
+ (0, ui_js_1.newline)();
986
+ return;
987
+ }
913
988
  const file = (0, migrate_js_1.createMigration)(config.migrationsDir, name);
914
989
  const relPath = (0, node_path_1.relative)(process.cwd(), file.path);
915
990
  (0, ui_js_1.success)(`Created migration: ${(0, ui_js_1.bold)(file.filename)}`);
@@ -1462,6 +1537,8 @@ async function cmdStudio(args, config) {
1462
1537
  openBrowser,
1463
1538
  include: config.include.length ? config.include : undefined,
1464
1539
  exclude: config.exclude.length ? config.exclude : undefined,
1540
+ write: args.write === true,
1541
+ showPii: args.showPii === true,
1465
1542
  });
1466
1543
  spinner.succeed(`Studio is running`);
1467
1544
  }
@@ -1469,13 +1546,24 @@ async function cmdStudio(args, config) {
1469
1546
  spinner.fail(`Failed to start Studio: ${err instanceof Error ? err.message : String(err)}`);
1470
1547
  process.exit(1);
1471
1548
  }
1549
+ // Loud startup warnings for the opt-in modes that widen Studio's surface.
1550
+ if (args.write) {
1551
+ (0, ui_js_1.newline)();
1552
+ console.log((0, ui_js_1.warn)('WRITE MODE is ON. Studio can update, insert, and delete single rows in ' +
1553
+ `${(0, ui_js_1.redactUrl)(url)}. Every change is committed directly to your database.`));
1554
+ }
1555
+ if (args.showPii) {
1556
+ (0, ui_js_1.newline)();
1557
+ console.log((0, ui_js_1.warn)('--show-pii is ON. PII-tagged column values are shown UNREDACTED in Studio.'));
1558
+ }
1472
1559
  (0, ui_js_1.newline)();
1473
1560
  console.log((0, ui_js_1.box)([
1474
- `${(0, ui_js_1.bold)('Turbine Studio')} ${(0, ui_js_1.dim)(' local read-only UI')}`,
1561
+ `${(0, ui_js_1.bold)('Turbine Studio')} ${(0, ui_js_1.dim)(args.write ? 'local UI (WRITE MODE)' : 'local read-only UI')}`,
1475
1562
  '',
1476
1563
  ` ${(0, ui_js_1.cyan)('URL:')} ${(0, ui_js_1.bold)(studio.url)}`,
1477
1564
  ` ${(0, ui_js_1.cyan)('Schema:')} ${config.schema}`,
1478
1565
  ` ${(0, ui_js_1.cyan)('DB:')} ${(0, ui_js_1.redactUrl)(url)}`,
1566
+ ` ${(0, ui_js_1.cyan)('Mode:')} ${args.write ? (0, ui_js_1.red)('read-write (single-row)') : 'read-only'}`,
1479
1567
  '',
1480
1568
  (0, ui_js_1.dim)('Open the URL above in your browser. It includes a one-time session'),
1481
1569
  (0, ui_js_1.dim)('token that gets set as an HttpOnly cookie on first load.'),
@@ -1655,6 +1743,7 @@ function showPushHelp() {
1655
1743
  console.log(` ${(0, ui_js_1.bold)('Options:')}`);
1656
1744
  console.log(` ${(0, ui_js_1.cyan)('--url, -u')} ${(0, ui_js_1.dim)('<url>')} Postgres connection string`);
1657
1745
  console.log(` ${(0, ui_js_1.cyan)('--dry-run')} Show SQL without executing`);
1746
+ console.log(` ${(0, ui_js_1.cyan)('--allow-destructive')} Skip the interactive confirmation for data-destroying statements ${(0, ui_js_1.dim)('(CI)')}`);
1658
1747
  console.log(` ${(0, ui_js_1.cyan)('--verbose, -v')} Show detailed output`);
1659
1748
  (0, ui_js_1.newline)();
1660
1749
  }
@@ -1675,6 +1764,7 @@ function showMigrateHelp() {
1675
1764
  console.log(` ${(0, ui_js_1.bold)('Options:')}`);
1676
1765
  console.log(` ${(0, ui_js_1.cyan)('--url, -u')} ${(0, ui_js_1.dim)('<url>')} Postgres connection string`);
1677
1766
  console.log(` ${(0, ui_js_1.cyan)('--auto')} Auto-generate UP/DOWN SQL from schema diff ${(0, ui_js_1.dim)('(create only)')}`);
1767
+ console.log(` ${(0, ui_js_1.cyan)('--recipe')} ${(0, ui_js_1.dim)('<name>')} Scaffold a sanctioned migration pattern ${(0, ui_js_1.dim)('(create only, e.g. backfill)')}`);
1678
1768
  console.log(` ${(0, ui_js_1.cyan)('--step, -n')} ${(0, ui_js_1.dim)('<N>')} Number of migrations to apply/rollback`);
1679
1769
  console.log(` ${(0, ui_js_1.cyan)('--dry-run')} Show SQL without executing`);
1680
1770
  console.log(` ${(0, ui_js_1.cyan)('--allow-drift')} Bypass checksum validation ${(0, ui_js_1.dim)('(migrate up only — advanced)')}`);
@@ -1684,6 +1774,7 @@ function showMigrateHelp() {
1684
1774
  console.log(` ${(0, ui_js_1.bold)('Examples:')}`);
1685
1775
  console.log(` ${(0, ui_js_1.dim)('$')} npx turbine migrate create add_users_table`);
1686
1776
  console.log(` ${(0, ui_js_1.dim)('$')} npx turbine migrate create add_email_index --auto`);
1777
+ console.log(` ${(0, ui_js_1.dim)('$')} npx turbine migrate create backfill_full_name --recipe backfill`);
1687
1778
  console.log(` ${(0, ui_js_1.dim)('$')} npx turbine migrate up`);
1688
1779
  console.log(` ${(0, ui_js_1.dim)('$')} npx turbine migrate deploy --dry-run`);
1689
1780
  console.log(` ${(0, ui_js_1.dim)('$')} npx turbine migrate down --step 2`);
@@ -1759,7 +1850,7 @@ function showHelp() {
1759
1850
  console.log(` ${(0, ui_js_1.cyan)('seed')} Run seed file`);
1760
1851
  console.log(` ${(0, ui_js_1.cyan)('status')} ${(0, ui_js_1.dim)('| info')} Show schema summary`);
1761
1852
  console.log(` ${(0, ui_js_1.cyan)('doctor')} Check relations for missing FK indexes ${(0, ui_js_1.dim)('(--fix emits migration)')}`);
1762
- console.log(` ${(0, ui_js_1.cyan)('studio')} Launch local read-only web UI`);
1853
+ console.log(` ${(0, ui_js_1.cyan)('studio')} Launch local read-only web UI ${(0, ui_js_1.dim)('(--write opts in to single-row writes)')}`);
1763
1854
  console.log(` ${(0, ui_js_1.cyan)('mcp')} Start read-only MCP server over stdio`);
1764
1855
  console.log(` ${(0, ui_js_1.cyan)('observe')} Launch metrics dashboard ${(0, ui_js_1.dim)('(requires TURBINE_OBSERVE_URL)')}`);
1765
1856
  (0, ui_js_1.newline)();
@@ -1783,6 +1874,8 @@ function showHelp() {
1783
1874
  console.log(` ${(0, ui_js_1.cyan)('--host')} ${(0, ui_js_1.dim)('<addr>')} Bind address ${(0, ui_js_1.dim)('(default: 127.0.0.1)')}`);
1784
1875
  console.log(` ${(0, ui_js_1.cyan)('--no-open')} Don't auto-open the browser`);
1785
1876
  console.log(` ${(0, ui_js_1.cyan)('--allow-remote')} Allow non-loopback --host ${(0, ui_js_1.dim)('(refused without this flag)')}`);
1877
+ console.log(` ${(0, ui_js_1.cyan)('--write')} Studio: enable single-row update/insert/delete ${(0, ui_js_1.dim)('(read-only by default)')}`);
1878
+ console.log(` ${(0, ui_js_1.cyan)('--show-pii')} Studio: show PII-tagged values unredacted ${(0, ui_js_1.dim)('(redacted by default)')}`);
1786
1879
  (0, ui_js_1.newline)();
1787
1880
  console.log(` ${(0, ui_js_1.bold)('Config file:')}`);
1788
1881
  console.log(` ${(0, ui_js_1.dim)('Create')} ${(0, ui_js_1.cyan)('turbine.config.ts')} ${(0, ui_js_1.dim)('with')} ${(0, ui_js_1.cyan)('npx turbine init')}`);
@@ -16,6 +16,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
16
16
  return (mod && mod.__esModule) ? mod : { "default": mod };
17
17
  };
18
18
  Object.defineProperty(exports, "__esModule", { value: true });
19
+ exports.MIGRATION_RECIPES = void 0;
19
20
  exports.parseMigrationFilename = parseMigrationFilename;
20
21
  exports.sanitizeName = sanitizeName;
21
22
  exports.formatTimestamp = formatTimestamp;
@@ -169,14 +170,70 @@ function checksum(content) {
169
170
  function isLegacyChecksum(hash) {
170
171
  return hash.length < 64;
171
172
  }
173
+ /** The sanctioned two-phase (add nullable, batched backfill, swap) recipe. */
174
+ function buildBackfillRecipe() {
175
+ const up = `-- Two-phase backfill scaffold. Every statement below is COMMENTED OUT: fill in
176
+ -- your table, the new column, the old column, and the transform, then uncomment
177
+ -- the phases you need and review before running \`npx turbine migrate up\`.
178
+ --
179
+ -- Phase 1: add the new column as NULLABLE. This is a fast, non-blocking change
180
+ -- (no table rewrite, no long lock), so it is safe to ship ahead of the backfill.
181
+ -- ALTER TABLE "my_table" ADD COLUMN "new_col" text;
182
+ --
183
+ -- Phase 2: backfill in bounded batches. Repeat this UPDATE until it reports
184
+ -- 0 rows affected. \`turbine migrate\` runs each file exactly once, so for large
185
+ -- tables drive the loop from psql or your app rather than inlining it here.
186
+ -- Tune the LIMIT (batch size) to your row width and lock tolerance.
187
+ -- UPDATE "my_table"
188
+ -- SET "new_col" = transform("old_col")
189
+ -- WHERE "new_col" IS NULL
190
+ -- AND "id" IN (
191
+ -- SELECT "id" FROM "my_table" WHERE "new_col" IS NULL LIMIT 5000
192
+ -- );
193
+ --
194
+ -- Phase 3: once every row is populated, enforce NOT NULL.
195
+ -- Note: SET NOT NULL takes an exclusive lock and scans the table. On huge
196
+ -- tables, first ADD CONSTRAINT ... CHECK ("new_col" IS NOT NULL) NOT VALID,
197
+ -- then VALIDATE CONSTRAINT (PG 12+ uses the validated check to skip the scan).
198
+ -- ALTER TABLE "my_table" ALTER COLUMN "new_col" SET NOT NULL;
199
+ --
200
+ -- Phase 4 (optional atomic swap): retire the old column and rename the new one
201
+ -- into its place, in one transaction so readers never see a missing column.
202
+ -- BEGIN;
203
+ -- ALTER TABLE "my_table" RENAME COLUMN "old_col" TO "old_col_retired";
204
+ -- ALTER TABLE "my_table" RENAME COLUMN "new_col" TO "old_col";
205
+ -- COMMIT;`;
206
+ const down = `-- Reverse the Phase 4 atomic swap (only if you ran it).
207
+ -- BEGIN;
208
+ -- ALTER TABLE "my_table" RENAME COLUMN "old_col" TO "new_col";
209
+ -- ALTER TABLE "my_table" RENAME COLUMN "old_col_retired" TO "old_col";
210
+ -- COMMIT;
211
+ --
212
+ -- If you stopped after phases 1 to 3, drop the added column instead:
213
+ -- ALTER TABLE "my_table" DROP COLUMN "new_col";`;
214
+ return { up, down };
215
+ }
216
+ /**
217
+ * Registry of migration recipes, keyed by `--recipe <name>`. New recipes slot
218
+ * in here without touching {@link createMigration} or the CLI handler.
219
+ */
220
+ exports.MIGRATION_RECIPES = {
221
+ backfill: {
222
+ description: 'Two-phase column backfill (add nullable, batched UPDATE, SET NOT NULL, rename swap)',
223
+ build: buildBackfillRecipe,
224
+ },
225
+ };
172
226
  // ---------------------------------------------------------------------------
173
227
  // Commands
174
228
  // ---------------------------------------------------------------------------
175
229
  /**
176
230
  * Create a new migration file.
177
- * If `autoContent` is provided, the UP/DOWN sections are pre-populated with the given SQL.
231
+ *
232
+ * - `autoContent`: pre-populate UP/DOWN from a schema diff.
233
+ * - `options.recipe`: scaffold a named recipe (see {@link MIGRATION_RECIPES}).
234
+ * Mutually exclusive with `autoContent`; an unknown recipe throws.
178
235
  */
179
- function createMigration(migrationsDir, name, autoContent) {
236
+ function createMigration(migrationsDir, name, autoContent, options) {
180
237
  (0, node_fs_1.mkdirSync)(migrationsDir, { recursive: true });
181
238
  const now = new Date();
182
239
  const ts = formatTimestamp(now);
@@ -184,7 +241,25 @@ function createMigration(migrationsDir, name, autoContent) {
184
241
  const filename = `${ts}_${safeName}.sql`;
185
242
  const filePath = (0, node_path_1.join)(migrationsDir, filename);
186
243
  let template;
187
- if (autoContent) {
244
+ if (options?.recipe) {
245
+ const recipe = exports.MIGRATION_RECIPES[options.recipe];
246
+ if (!recipe) {
247
+ const known = Object.keys(exports.MIGRATION_RECIPES).join(', ') || '(none)';
248
+ throw new errors_js_1.MigrationError(`[turbine] Unknown migration recipe "${options.recipe}". Available recipes: ${known}`);
249
+ }
250
+ const body = recipe.build(name);
251
+ template = `-- Migration: ${name} (${options.recipe} recipe scaffold)
252
+ -- Created: ${now.toISOString()}
253
+ -- Fill in the placeholders and review before running: npx turbine migrate up
254
+
255
+ -- UP
256
+ ${body.up}
257
+
258
+ -- DOWN
259
+ ${body.down}
260
+ `;
261
+ }
262
+ else if (autoContent) {
188
263
  template = `-- Migration: ${name} (auto-generated from schema diff)
189
264
  -- Created: ${now.toISOString()}
190
265
  -- Review this file before running: npx turbine migrate up