turbine-orm 0.8.0 → 0.9.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.
- package/README.md +31 -5
- package/dist/cjs/cli/index.js +102 -10
- package/dist/cjs/cli/migrate.js +50 -13
- package/dist/cjs/cli/studio-ui.generated.js +6 -0
- package/dist/cjs/cli/studio.js +641 -0
- package/dist/cjs/query.js +26 -12
- package/dist/cjs/schema-builder.js +23 -3
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/index.js +103 -11
- package/dist/cli/migrate.d.ts +16 -0
- package/dist/cli/migrate.js +49 -13
- package/dist/cli/studio-ui.generated.d.ts +2 -0
- package/dist/cli/studio-ui.generated.js +4 -0
- package/dist/cli/studio.d.ts +75 -0
- package/dist/cli/studio.js +627 -0
- package/dist/query.js +26 -12
- package/dist/schema-builder.js +23 -3
- package/package.json +8 -4
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ npm install turbine-orm
|
|
|
8
8
|
|
|
9
9
|
## Why Turbine?
|
|
10
10
|
|
|
11
|
-
Turbine is a PostgreSQL-native TypeScript ORM
|
|
11
|
+
Turbine is a PostgreSQL-native TypeScript ORM built around features no other ORM bundles together: a **read-only, DBA-approvable Studio web UI** (the only one in the TS ORM ecosystem — `BEGIN READ ONLY` + statement-stacking guard + loopback-only + 24-byte auth token), **cursor-based streaming** through nested relations, **typed error classes** with PostgreSQL constraint mapping, **pipeline batching** (N queries, 1 round-trip), **middleware**, and a driver-agnostic core that plugs into any pg-compatible pool so it runs on Vercel Edge, Cloudflare Workers, Deno Deploy, and similar environments. 1 runtime dependency (`pg`), ~110KB on npm.
|
|
12
12
|
|
|
13
13
|
**One round-trip for nested relations.** `db.users.findMany({ with: { posts: { with: { comments: true } } } })` resolves the entire object graph in a single database round-trip, regardless of nesting depth. Prisma 7+ and Drizzle v2 also do single-query nested loads — Turbine's advantage is architectural simplicity: 1 dependency, no code generation DSL, no query plan compiler.
|
|
14
14
|
|
|
@@ -34,7 +34,7 @@ Tested against **Prisma 7.6** (adapter-pg, relationJoins preview on) and **Drizz
|
|
|
34
34
|
- **Streaming 50K rows.** Turbine's optimized streaming (speculative first fetch + batch size 1000) matches Prisma at ~3.1–3.2 s. Drizzle's keyset pagination is 1.49× slower at 4.6 s. Turbine's cursor still gives you correctness on any `orderBy` and clean early-`break` semantics.
|
|
35
35
|
- **Pipeline batching** puts 5 independent queries through a single round-trip using the Postgres extended-query pipeline protocol — all three ORMs are tied here since each runs 5 queries sequentially in a transaction.
|
|
36
36
|
|
|
37
|
-
Beyond the numbers, Turbine's real strengths are: **one runtime dependency** (`pg`, ~110 KB), a **single import swap** for edge runtimes (`turbine-orm/serverless`), **typed Postgres errors** with a `readonly isRetryable` const for retry loops, and **
|
|
37
|
+
Beyond the numbers, Turbine's real strengths are: **one runtime dependency** (`pg`, ~110 KB), a **single import swap** for edge runtimes (`turbine-orm/serverless`), **typed Postgres errors** with a `readonly isRetryable` const for retry loops, and the **read-only Studio** web UI that ships in the CLI — the only one in the TS ORM ecosystem that physically cannot mutate your database. And 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 — no manual assertion, no `*With*` helper interfaces.
|
|
38
38
|
|
|
39
39
|
> Full analysis with p50/p95/p99 and methodology notes: [`benchmarks/RESULTS.md`](./benchmarks/RESULTS.md).
|
|
40
40
|
> Reproduce: `cd benchmarks && npm install && npx prisma generate && DATABASE_URL=... npx tsx bench.ts`
|
|
@@ -381,6 +381,7 @@ Commands:
|
|
|
381
381
|
migrate status Show applied/pending migrations
|
|
382
382
|
seed Run seed file
|
|
383
383
|
status Show database schema summary
|
|
384
|
+
studio Launch local read-only Studio web UI
|
|
384
385
|
|
|
385
386
|
Options:
|
|
386
387
|
--url, -u <url> Postgres connection string
|
|
@@ -436,6 +437,32 @@ npx turbine migrate down
|
|
|
436
437
|
npx turbine migrate status
|
|
437
438
|
```
|
|
438
439
|
|
|
440
|
+
## Studio
|
|
441
|
+
|
|
442
|
+
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, no way around the transaction guard.
|
|
443
|
+
|
|
444
|
+
```bash
|
|
445
|
+
DATABASE_URL=postgres://user:pass@localhost:5432/mydb npx turbine studio
|
|
446
|
+
# With flags
|
|
447
|
+
npx turbine studio --port 5173 --host 127.0.0.1 --no-open
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
**Features**
|
|
451
|
+
|
|
452
|
+
- **Data / Schema / SQL / Builder tabs.** Browse rows, inspect tables and relations, run ad-hoc `SELECT`s, or compose queries visually with a live TypeScript preview.
|
|
453
|
+
- **Saved queries.** Named SQL snippets persisted to `.turbine/studio-queries.json` — share them across runs without committing them.
|
|
454
|
+
- **Cmd+K command palette.** Jump to any table, tab, or saved query in one keystroke.
|
|
455
|
+
- **Full-text search across rows.** The Data tab supports substring search across every text column of the current table.
|
|
456
|
+
- **Visual query composer.** The Builder tab lets you click together `where` / `orderBy` / `with` / `limit` clauses and renders the matching `db.table.findMany(...)` TypeScript in real time — copy it into your codebase.
|
|
457
|
+
|
|
458
|
+
**Security posture (read-only by design)**
|
|
459
|
+
|
|
460
|
+
- **Loopback by default** (`127.0.0.1`) with a loud warning if you bind to a non-loopback address.
|
|
461
|
+
- **Per-process auth token** — 24 random bytes of hex, stored in a `SameSite=Strict` `HttpOnly` cookie.
|
|
462
|
+
- **Every query runs inside `BEGIN READ ONLY` + `SET LOCAL statement_timeout = '30s'`.** Writes are physically impossible at the transaction level.
|
|
463
|
+
- **SELECT/WITH-only SQL parser** strips comments and rejects non-trailing semicolons, blocking statement-stacking attacks.
|
|
464
|
+
- **Security headers on every response** — `X-Content-Type-Options`, `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer`.
|
|
465
|
+
|
|
439
466
|
## Serverless / Edge
|
|
440
467
|
|
|
441
468
|
Turbine's core is driver-agnostic: pass any pg-compatible pool to `TurbineConfig.pool` (or use the `turbineHttp()` factory) and Turbine runs on **Vercel Edge**, **Cloudflare Workers**, **Deno Deploy**, **Netlify Edge**, or any other environment where a direct TCP connection is unavailable. No new dependencies — install whichever driver you already use.
|
|
@@ -533,9 +560,9 @@ Priority order: CLI flags > environment variables (`DATABASE_URL`) > config file
|
|
|
533
560
|
|
|
534
561
|
## How It Works
|
|
535
562
|
|
|
536
|
-
Turbine resolves the entire object graph in a single database round-trip, regardless of nesting depth. The `with` clause is fully type-inferred
|
|
563
|
+
Turbine resolves the entire object graph in a single database round-trip, regardless of nesting depth. The runtime nests relations for you via `json_agg` — one round-trip, no N+1, no client-side stitching. And the `with` clause is fully type-inferred: the generator emits branded `*Relations` interfaces with `RelationDescriptor` phantom fields, and a recursive `WithResult<T, R, W>` conditional type walks an arbitrarily-deep `with` literal to produce the exact nested return shape. Write `db.users.findMany({ with: { posts: { with: { comments: { with: { author: true } } } } } })` and `users[0].posts[0].comments[0].author.name` autocompletes — no manual assertion, no `*With*` helper annotation.
|
|
537
564
|
|
|
538
|
-
Prisma 7+ and Drizzle v2 also do single-query nested loads. Turbine's advantage isn't query latency (see [Benchmarks](#benchmarks) — all three are within noise over a real pooled database); it's architectural simplicity. One runtime dependency (`pg`), no DSL compiler, no driver adapter shim for edge, and
|
|
565
|
+
Prisma 7+ and Drizzle v2 also do single-query nested loads. Turbine's advantage isn't query latency (see [Benchmarks](#benchmarks) — all three are within noise over a real pooled database); it's architectural simplicity plus the read-only Studio. One runtime dependency (`pg`), no DSL compiler, no driver adapter shim for edge, and the only TS ORM Studio your DBA will approve.
|
|
539
566
|
|
|
540
567
|
## Type Mapping
|
|
541
568
|
|
|
@@ -573,7 +600,6 @@ Turbine is focused and opinionated. Here's what it doesn't do:
|
|
|
573
600
|
- **PostgreSQL only.** No MySQL, SQLite, or MSSQL. By design — going deep on one database enables the performance advantage and the edge-runtime story.
|
|
574
601
|
- **No full-text search operators.** TSVECTOR/TSQUERY are not exposed in the query builder. Use `db.raw` for full-text queries.
|
|
575
602
|
- **Large nested result sets.** Nested results are materialized server-side in PostgreSQL memory. For relations with 10K+ rows, always use `limit` in your `with` clause — or stream the parents with `findManyStream` and resolve children per-row.
|
|
576
|
-
- **No admin UI.** Turbine Studio is planned but not yet available.
|
|
577
603
|
|
|
578
604
|
## Examples
|
|
579
605
|
|
package/dist/cjs/cli/index.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* turbine migrate status — Show migration status
|
|
14
14
|
* turbine seed — Run seed file
|
|
15
15
|
* turbine status — Show schema summary
|
|
16
|
-
* turbine studio — Launch web UI
|
|
16
|
+
* turbine studio — Launch local read-only web UI
|
|
17
17
|
*
|
|
18
18
|
* Usage:
|
|
19
19
|
* DATABASE_URL=postgres://... npx turbine generate
|
|
@@ -63,6 +63,7 @@ const schema_sql_js_1 = require("../schema-sql.js");
|
|
|
63
63
|
const config_js_1 = require("./config.js");
|
|
64
64
|
const loader_js_1 = require("./loader.js");
|
|
65
65
|
const migrate_js_1 = require("./migrate.js");
|
|
66
|
+
const studio_js_1 = require("./studio.js");
|
|
66
67
|
const ui_js_1 = require("./ui.js");
|
|
67
68
|
function parseArgs() {
|
|
68
69
|
const args = process.argv.slice(2);
|
|
@@ -129,6 +130,17 @@ function parseArgs() {
|
|
|
129
130
|
case '-h':
|
|
130
131
|
result.help = true;
|
|
131
132
|
break;
|
|
133
|
+
case '--port':
|
|
134
|
+
result.port = next ? Number.parseInt(next, 10) : undefined;
|
|
135
|
+
i++;
|
|
136
|
+
break;
|
|
137
|
+
case '--host':
|
|
138
|
+
result.host = next;
|
|
139
|
+
i++;
|
|
140
|
+
break;
|
|
141
|
+
case '--no-open':
|
|
142
|
+
result.noOpen = true;
|
|
143
|
+
break;
|
|
132
144
|
default:
|
|
133
145
|
if (!arg.startsWith('-')) {
|
|
134
146
|
result.positional.push(arg);
|
|
@@ -925,19 +937,71 @@ async function cmdStatus(_args, config) {
|
|
|
925
937
|
}
|
|
926
938
|
}
|
|
927
939
|
// ---------------------------------------------------------------------------
|
|
928
|
-
// Command: studio
|
|
940
|
+
// Command: studio — local read-only web UI
|
|
929
941
|
// ---------------------------------------------------------------------------
|
|
930
|
-
async function cmdStudio(
|
|
942
|
+
async function cmdStudio(args, config) {
|
|
931
943
|
(0, ui_js_1.banner)();
|
|
944
|
+
const url = requireUrl(config);
|
|
945
|
+
const port = args.port ?? 4983;
|
|
946
|
+
const host = args.host ?? '127.0.0.1';
|
|
947
|
+
const openBrowser = !args.noOpen;
|
|
948
|
+
if (!Number.isFinite(port) || port <= 0 || port > 65535) {
|
|
949
|
+
console.log((0, ui_js_1.red)(`✗ invalid port: ${args.port}`));
|
|
950
|
+
process.exit(1);
|
|
951
|
+
}
|
|
952
|
+
// Refuse to bind anything other than loopback unless explicitly overridden.
|
|
953
|
+
// This is deliberate: Studio has no real authentication beyond a random
|
|
954
|
+
// session token, so exposing it on a LAN interface is foot-gun territory.
|
|
955
|
+
if (host !== '127.0.0.1' && host !== 'localhost' && host !== '::1') {
|
|
956
|
+
console.log((0, ui_js_1.warn)(`Studio is binding to ${(0, ui_js_1.yellow)(host)} — this is NOT loopback. ` +
|
|
957
|
+
`Anyone on your network who can reach this port + guess the session token can read your database.`));
|
|
958
|
+
}
|
|
959
|
+
const spinner = new ui_js_1.Spinner('Introspecting database').start();
|
|
960
|
+
let studio;
|
|
961
|
+
try {
|
|
962
|
+
studio = await (0, studio_js_1.startStudio)({
|
|
963
|
+
url,
|
|
964
|
+
schema: config.schema,
|
|
965
|
+
port,
|
|
966
|
+
host,
|
|
967
|
+
openBrowser,
|
|
968
|
+
include: config.include.length ? config.include : undefined,
|
|
969
|
+
exclude: config.exclude.length ? config.exclude : undefined,
|
|
970
|
+
});
|
|
971
|
+
spinner.succeed(`Studio is running`);
|
|
972
|
+
}
|
|
973
|
+
catch (err) {
|
|
974
|
+
spinner.fail(`Failed to start Studio: ${err instanceof Error ? err.message : String(err)}`);
|
|
975
|
+
process.exit(1);
|
|
976
|
+
}
|
|
977
|
+
(0, ui_js_1.newline)();
|
|
932
978
|
console.log((0, ui_js_1.box)([
|
|
933
|
-
`${(0, ui_js_1.bold)('Turbine Studio')}
|
|
979
|
+
`${(0, ui_js_1.bold)('Turbine Studio')} ${(0, ui_js_1.dim)('— local read-only UI')}`,
|
|
934
980
|
'',
|
|
935
|
-
|
|
936
|
-
|
|
981
|
+
` ${(0, ui_js_1.cyan)('URL:')} ${(0, ui_js_1.bold)(studio.url)}`,
|
|
982
|
+
` ${(0, ui_js_1.cyan)('Schema:')} ${config.schema}`,
|
|
983
|
+
` ${(0, ui_js_1.cyan)('DB:')} ${(0, ui_js_1.redactUrl)(url)}`,
|
|
937
984
|
'',
|
|
938
|
-
|
|
939
|
-
|
|
985
|
+
(0, ui_js_1.dim)('Open the URL above in your browser. It includes a one-time session'),
|
|
986
|
+
(0, ui_js_1.dim)('token that gets set as an HttpOnly cookie on first load.'),
|
|
987
|
+
(0, ui_js_1.dim)('Press Ctrl+C to stop.'),
|
|
988
|
+
].join('\n'), { title: (0, ui_js_1.bold)((0, ui_js_1.cyan)('Studio')), padding: 1 }));
|
|
940
989
|
(0, ui_js_1.newline)();
|
|
990
|
+
// Wait forever until SIGINT/SIGTERM, then dispose cleanly.
|
|
991
|
+
await new Promise((resolve) => {
|
|
992
|
+
const shutdown = async () => {
|
|
993
|
+
console.log((0, ui_js_1.dim)('\n shutting down…'));
|
|
994
|
+
try {
|
|
995
|
+
await studio.dispose();
|
|
996
|
+
}
|
|
997
|
+
catch {
|
|
998
|
+
/* ignore */
|
|
999
|
+
}
|
|
1000
|
+
resolve();
|
|
1001
|
+
};
|
|
1002
|
+
process.once('SIGINT', shutdown);
|
|
1003
|
+
process.once('SIGTERM', shutdown);
|
|
1004
|
+
});
|
|
941
1005
|
}
|
|
942
1006
|
// ---------------------------------------------------------------------------
|
|
943
1007
|
// Subcommand help
|
|
@@ -1086,7 +1150,7 @@ function showHelp() {
|
|
|
1086
1150
|
console.log(` ${(0, ui_js_1.dim)('status')} Show applied/pending migrations`);
|
|
1087
1151
|
console.log(` ${(0, ui_js_1.cyan)('seed')} Run seed file`);
|
|
1088
1152
|
console.log(` ${(0, ui_js_1.cyan)('status')} ${(0, ui_js_1.dim)('| info')} Show schema summary`);
|
|
1089
|
-
console.log(` ${(0, ui_js_1.cyan)('studio')} Launch web UI
|
|
1153
|
+
console.log(` ${(0, ui_js_1.cyan)('studio')} Launch local read-only web UI`);
|
|
1090
1154
|
(0, ui_js_1.newline)();
|
|
1091
1155
|
console.log(` ${(0, ui_js_1.bold)('Options:')}`);
|
|
1092
1156
|
console.log(` ${(0, ui_js_1.cyan)('--url, -u')} ${(0, ui_js_1.dim)('<url>')} Postgres connection string`);
|
|
@@ -1098,6 +1162,11 @@ function showHelp() {
|
|
|
1098
1162
|
console.log(` ${(0, ui_js_1.cyan)('--verbose, -v')} Show detailed output`);
|
|
1099
1163
|
console.log(` ${(0, ui_js_1.cyan)('--force, -f')} Overwrite existing files`);
|
|
1100
1164
|
(0, ui_js_1.newline)();
|
|
1165
|
+
console.log(` ${(0, ui_js_1.bold)('Studio options:')}`);
|
|
1166
|
+
console.log(` ${(0, ui_js_1.cyan)('--port')} ${(0, ui_js_1.dim)('<n>')} HTTP port ${(0, ui_js_1.dim)('(default: 4983)')}`);
|
|
1167
|
+
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)')}`);
|
|
1168
|
+
console.log(` ${(0, ui_js_1.cyan)('--no-open')} Don't auto-open the browser`);
|
|
1169
|
+
(0, ui_js_1.newline)();
|
|
1101
1170
|
console.log(` ${(0, ui_js_1.bold)('Config file:')}`);
|
|
1102
1171
|
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')}`);
|
|
1103
1172
|
console.log(` ${(0, ui_js_1.dim)('CLI flags override config file values.')}`);
|
|
@@ -1114,7 +1183,30 @@ function showHelp() {
|
|
|
1114
1183
|
// Version
|
|
1115
1184
|
// ---------------------------------------------------------------------------
|
|
1116
1185
|
function showVersion() {
|
|
1117
|
-
|
|
1186
|
+
// Walk up from the running script to find the turbine-orm package.json.
|
|
1187
|
+
// Using process.argv[1] instead of import.meta.url so the same code compiles
|
|
1188
|
+
// cleanly for both the ESM and CJS builds.
|
|
1189
|
+
try {
|
|
1190
|
+
let dir = (0, node_path_1.dirname)(process.argv[1] ?? '');
|
|
1191
|
+
for (let i = 0; i < 6; i++) {
|
|
1192
|
+
const candidate = (0, node_path_1.resolve)(dir, 'package.json');
|
|
1193
|
+
if ((0, node_fs_1.existsSync)(candidate)) {
|
|
1194
|
+
const pkg = JSON.parse((0, node_fs_1.readFileSync)(candidate, 'utf8'));
|
|
1195
|
+
if (pkg.name === 'turbine-orm') {
|
|
1196
|
+
console.log(`turbine-orm v${pkg.version ?? '?'}`);
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
const parent = (0, node_path_1.dirname)(dir);
|
|
1201
|
+
if (parent === dir)
|
|
1202
|
+
break;
|
|
1203
|
+
dir = parent;
|
|
1204
|
+
}
|
|
1205
|
+
console.log(`turbine-orm`);
|
|
1206
|
+
}
|
|
1207
|
+
catch {
|
|
1208
|
+
console.log(`turbine-orm`);
|
|
1209
|
+
}
|
|
1118
1210
|
}
|
|
1119
1211
|
// ---------------------------------------------------------------------------
|
|
1120
1212
|
// Main
|
package/dist/cjs/cli/migrate.js
CHANGED
|
@@ -24,6 +24,7 @@ exports.listMigrationFiles = listMigrationFiles;
|
|
|
24
24
|
exports.parseMigrationContent = parseMigrationContent;
|
|
25
25
|
exports.parseMigrationSQL = parseMigrationSQL;
|
|
26
26
|
exports.createMigration = createMigration;
|
|
27
|
+
exports.deriveLockId = deriveLockId;
|
|
27
28
|
exports.migrateUp = migrateUp;
|
|
28
29
|
exports.migrateDown = migrateDown;
|
|
29
30
|
exports.migrateStatus = migrateStatus;
|
|
@@ -218,16 +219,44 @@ ${autoContent.down}
|
|
|
218
219
|
// ---------------------------------------------------------------------------
|
|
219
220
|
// Advisory lock for concurrent migration safety
|
|
220
221
|
// ---------------------------------------------------------------------------
|
|
221
|
-
/**
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
222
|
+
/**
|
|
223
|
+
* Derive a Postgres advisory lock ID (positive int4) from the database name.
|
|
224
|
+
*
|
|
225
|
+
* Uses FNV-1a 32-bit hash — a well-known, stable, non-cryptographic hash with
|
|
226
|
+
* excellent distribution over short strings (database names are typically <64
|
|
227
|
+
* chars). Chosen over alternatives because it's:
|
|
228
|
+
* - deterministic (same input → same output, across processes/machines)
|
|
229
|
+
* - tiny (two lines, no allocations, no imports)
|
|
230
|
+
* - well-distributed (low collision rate for typical DB-name distributions)
|
|
231
|
+
*
|
|
232
|
+
* The top bit is cleared so the result fits in a positive int4, which is the
|
|
233
|
+
* range `pg_advisory_lock` expects for the single-argument form. Two databases
|
|
234
|
+
* in the same Postgres cluster can now run `turbine migrate` concurrently
|
|
235
|
+
* without contending on a single hardcoded lock ID.
|
|
236
|
+
*/
|
|
237
|
+
function deriveLockId(databaseName) {
|
|
238
|
+
let hash = 0x811c9dc5;
|
|
239
|
+
for (let i = 0; i < databaseName.length; i++) {
|
|
240
|
+
hash ^= databaseName.charCodeAt(i);
|
|
241
|
+
hash = Math.imul(hash, 0x01000193);
|
|
242
|
+
}
|
|
243
|
+
return hash >>> 1; // positive int4 (top bit cleared)
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Fetch the current database name from the connected client. Used to derive
|
|
247
|
+
* the advisory lock ID so concurrent migrations in sibling databases do not
|
|
248
|
+
* contend on one another.
|
|
249
|
+
*/
|
|
250
|
+
async function getCurrentDatabaseName(client) {
|
|
251
|
+
const result = await client.query(`SELECT current_database()`);
|
|
252
|
+
return result.rows[0]?.current_database ?? '';
|
|
253
|
+
}
|
|
254
|
+
async function acquireLock(client, lockId) {
|
|
255
|
+
const result = await client.query(`SELECT pg_try_advisory_lock($1) AS locked`, [lockId]);
|
|
256
|
+
return result.rows[0]?.locked ?? false;
|
|
228
257
|
}
|
|
229
|
-
async function releaseLock(client) {
|
|
230
|
-
await client.query(`SELECT pg_advisory_unlock($1)`, [
|
|
258
|
+
async function releaseLock(client, lockId) {
|
|
259
|
+
await client.query(`SELECT pg_advisory_unlock($1)`, [lockId]);
|
|
231
260
|
}
|
|
232
261
|
/**
|
|
233
262
|
* Validate that applied migration files have not been modified or deleted since they were run.
|
|
@@ -290,8 +319,12 @@ async function migrateUp(connectionString, migrationsDir, options) {
|
|
|
290
319
|
// Treat `force` as an alias for `allowDrift` for backwards compatibility.
|
|
291
320
|
const allowDrift = options?.allowDrift === true || options?.force === true;
|
|
292
321
|
try {
|
|
322
|
+
// Derive an advisory lock ID per-database so concurrent migrations in
|
|
323
|
+
// sibling databases on the same Postgres cluster do not contend.
|
|
324
|
+
const dbName = await getCurrentDatabaseName(client);
|
|
325
|
+
const lockId = deriveLockId(dbName);
|
|
293
326
|
// Acquire advisory lock to prevent concurrent migrations
|
|
294
|
-
const gotLock = await acquireLock(client);
|
|
327
|
+
const gotLock = await acquireLock(client, lockId);
|
|
295
328
|
if (!gotLock) {
|
|
296
329
|
throw new errors_js_1.MigrationError('[turbine] Could not acquire migration lock — another migration is already running');
|
|
297
330
|
}
|
|
@@ -363,7 +396,7 @@ async function migrateUp(connectionString, migrationsDir, options) {
|
|
|
363
396
|
return { applied: results, errors };
|
|
364
397
|
}
|
|
365
398
|
finally {
|
|
366
|
-
await releaseLock(client);
|
|
399
|
+
await releaseLock(client, lockId);
|
|
367
400
|
}
|
|
368
401
|
}
|
|
369
402
|
finally {
|
|
@@ -382,7 +415,11 @@ async function migrateDown(connectionString, migrationsDir, options) {
|
|
|
382
415
|
const client = new pg_1.default.Client({ connectionString });
|
|
383
416
|
await client.connect();
|
|
384
417
|
try {
|
|
385
|
-
|
|
418
|
+
// Derive a per-database advisory lock ID so concurrent migrations in
|
|
419
|
+
// sibling databases on the same cluster do not contend.
|
|
420
|
+
const dbName = await getCurrentDatabaseName(client);
|
|
421
|
+
const lockId = deriveLockId(dbName);
|
|
422
|
+
const gotLock = await acquireLock(client, lockId);
|
|
386
423
|
if (!gotLock) {
|
|
387
424
|
throw new errors_js_1.MigrationError('[turbine] Could not acquire migration lock — another migration is already running');
|
|
388
425
|
}
|
|
@@ -429,7 +466,7 @@ async function migrateDown(connectionString, migrationsDir, options) {
|
|
|
429
466
|
return { rolledBack: results, errors };
|
|
430
467
|
}
|
|
431
468
|
finally {
|
|
432
|
-
await releaseLock(client);
|
|
469
|
+
await releaseLock(client, lockId);
|
|
433
470
|
}
|
|
434
471
|
}
|
|
435
472
|
finally {
|