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.
- package/README.md +18 -16
- package/dist/cjs/cli/index.js +109 -16
- package/dist/cjs/cli/migrate.js +78 -3
- package/dist/cjs/cli/studio-ui.generated.js +1 -1
- package/dist/cjs/cli/studio.js +333 -22
- package/dist/cjs/cli/ui.js +7 -1
- package/dist/cjs/dialect.js +1 -1
- package/dist/cjs/generate.js +23 -2
- package/dist/cjs/index.js +2 -1
- package/dist/cjs/mssql.js +22 -5
- package/dist/cjs/powdb.js +41 -1
- package/dist/cjs/powql.js +80 -25
- package/dist/cjs/query/aggregates.js +683 -0
- package/dist/cjs/query/batched-loader.js +2 -0
- package/dist/cjs/query/builder.js +297 -4504
- package/dist/cjs/query/filters.js +12 -0
- package/dist/cjs/query/relations.js +1698 -0
- package/dist/cjs/query/where-compile.js +180 -0
- package/dist/cjs/query/where.js +1491 -0
- package/dist/cjs/query/writes.js +680 -0
- package/dist/cjs/schema-builder.js +6 -0
- package/dist/cjs/schema-metadata.js +4 -0
- package/dist/cjs/schema-sql.js +265 -3
- package/dist/cjs/sqlite.js +1 -1
- package/dist/cli/index.d.ts +8 -2
- package/dist/cli/index.js +111 -18
- package/dist/cli/migrate.d.ts +24 -1
- package/dist/cli/migrate.js +77 -3
- package/dist/cli/studio-ui.generated.js +1 -1
- package/dist/cli/studio.d.ts +46 -13
- package/dist/cli/studio.js +331 -23
- package/dist/cli/ui.js +7 -1
- package/dist/dialect.d.ts +15 -6
- package/dist/dialect.js +1 -1
- package/dist/generate.js +23 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/mssql.js +22 -5
- package/dist/powdb.d.ts +20 -0
- package/dist/powdb.js +40 -0
- package/dist/powql.d.ts +33 -1
- package/dist/powql.js +80 -25
- package/dist/query/aggregates.d.ts +74 -0
- package/dist/query/aggregates.js +641 -0
- package/dist/query/batched-loader.d.ts +6 -0
- package/dist/query/batched-loader.js +2 -0
- package/dist/query/builder.d.ts +62 -829
- package/dist/query/builder.js +302 -4509
- package/dist/query/deferred.d.ts +7 -0
- package/dist/query/filters.d.ts +7 -0
- package/dist/query/filters.js +11 -0
- package/dist/query/relations.d.ts +441 -0
- package/dist/query/relations.js +1627 -0
- package/dist/query/types.d.ts +15 -0
- package/dist/query/where-compile.d.ts +139 -0
- package/dist/query/where-compile.js +175 -0
- package/dist/query/where.d.ts +494 -0
- package/dist/query/where.js +1431 -0
- package/dist/query/writes.d.ts +131 -0
- package/dist/query/writes.js +626 -0
- package/dist/schema-builder.d.ts +18 -3
- package/dist/schema-builder.js +6 -0
- package/dist/schema-metadata.js +4 -0
- package/dist/schema-sql.d.ts +60 -3
- package/dist/schema-sql.js +261 -4
- package/dist/schema.d.ts +10 -0
- package/dist/sqlite.js +1 -1
- package/package.json +2 -2
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
|
|
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
|
|
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 ~
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
701
|
+
**Security posture (read-only by default)**
|
|
700
702
|
|
|
701
|
-
- **No SQL input surface.** There is nothing to inject into
|
|
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
|
|
704
|
-
- **Every query runs inside `BEGIN READ ONLY`** with a 30s transaction-local statement timeout (parameterized `set_config`).
|
|
705
|
-
- **Security headers on every response
|
|
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)** | ~
|
|
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, ~
|
|
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
|
|
package/dist/cjs/cli/index.js
CHANGED
|
@@ -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
|
|
10
|
-
* turbine migrate create <name>
|
|
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
|
-
|
|
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
|
-
|
|
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>')}
|
|
806
|
-
console.log(` ${(0, ui_js_1.cyan)('create <name> --auto')}
|
|
807
|
-
console.log(` ${(0, ui_js_1.cyan)('
|
|
808
|
-
console.log(` ${(0, ui_js_1.cyan)('
|
|
809
|
-
console.log(` ${(0, ui_js_1.cyan)('
|
|
810
|
-
console.log(` ${(0, ui_js_1.cyan)('
|
|
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')}
|
|
814
|
-
console.log(` ${(0, ui_js_1.cyan)('--
|
|
815
|
-
console.log(` ${(0, ui_js_1.cyan)('--
|
|
816
|
-
console.log(` ${(0, ui_js_1.cyan)('--
|
|
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)('
|
|
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')}`);
|
package/dist/cjs/cli/migrate.js
CHANGED
|
@@ -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
|
-
*
|
|
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 (
|
|
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
|