turbine-orm 0.8.0 → 0.9.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 +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/dist/cjs/query.js
CHANGED
|
@@ -1145,7 +1145,7 @@ class QueryInterface {
|
|
|
1145
1145
|
for (const [field, enabled] of Object.entries(args._sum)) {
|
|
1146
1146
|
if (enabled) {
|
|
1147
1147
|
const col = this.toColumn(field);
|
|
1148
|
-
selectExprs.push(`SUM(${quoteIdent(col)}) AS ${quoteIdent(
|
|
1148
|
+
selectExprs.push(`SUM(${quoteIdent(col)}) AS ${quoteIdent(`_sum_${col}`)}`);
|
|
1149
1149
|
}
|
|
1150
1150
|
}
|
|
1151
1151
|
}
|
|
@@ -1154,7 +1154,7 @@ class QueryInterface {
|
|
|
1154
1154
|
for (const [field, enabled] of Object.entries(args._avg)) {
|
|
1155
1155
|
if (enabled) {
|
|
1156
1156
|
const col = this.toColumn(field);
|
|
1157
|
-
selectExprs.push(`AVG(${quoteIdent(col)})::float AS ${quoteIdent(
|
|
1157
|
+
selectExprs.push(`AVG(${quoteIdent(col)})::float AS ${quoteIdent(`_avg_${col}`)}`);
|
|
1158
1158
|
}
|
|
1159
1159
|
}
|
|
1160
1160
|
}
|
|
@@ -1163,7 +1163,7 @@ class QueryInterface {
|
|
|
1163
1163
|
for (const [field, enabled] of Object.entries(args._min)) {
|
|
1164
1164
|
if (enabled) {
|
|
1165
1165
|
const col = this.toColumn(field);
|
|
1166
|
-
selectExprs.push(`MIN(${quoteIdent(col)}) AS ${quoteIdent(
|
|
1166
|
+
selectExprs.push(`MIN(${quoteIdent(col)}) AS ${quoteIdent(`_min_${col}`)}`);
|
|
1167
1167
|
}
|
|
1168
1168
|
}
|
|
1169
1169
|
}
|
|
@@ -1172,7 +1172,7 @@ class QueryInterface {
|
|
|
1172
1172
|
for (const [field, enabled] of Object.entries(args._max)) {
|
|
1173
1173
|
if (enabled) {
|
|
1174
1174
|
const col = this.toColumn(field);
|
|
1175
|
-
selectExprs.push(`MAX(${quoteIdent(col)}) AS ${quoteIdent(
|
|
1175
|
+
selectExprs.push(`MAX(${quoteIdent(col)}) AS ${quoteIdent(`_max_${col}`)}`);
|
|
1176
1176
|
}
|
|
1177
1177
|
}
|
|
1178
1178
|
}
|
|
@@ -1284,7 +1284,7 @@ class QueryInterface {
|
|
|
1284
1284
|
for (const [field, enabled] of Object.entries(args._count)) {
|
|
1285
1285
|
if (enabled) {
|
|
1286
1286
|
const col = this.toColumn(field);
|
|
1287
|
-
selectExprs.push(`COUNT(${quoteIdent(col)})::int AS ${quoteIdent(
|
|
1287
|
+
selectExprs.push(`COUNT(${quoteIdent(col)})::int AS ${quoteIdent(`_count_${col}`)}`);
|
|
1288
1288
|
}
|
|
1289
1289
|
}
|
|
1290
1290
|
}
|
|
@@ -1293,7 +1293,7 @@ class QueryInterface {
|
|
|
1293
1293
|
for (const [field, enabled] of Object.entries(args._sum)) {
|
|
1294
1294
|
if (enabled) {
|
|
1295
1295
|
const col = this.toColumn(field);
|
|
1296
|
-
selectExprs.push(`SUM(${quoteIdent(col)}) AS ${quoteIdent(
|
|
1296
|
+
selectExprs.push(`SUM(${quoteIdent(col)}) AS ${quoteIdent(`_sum_${col}`)}`);
|
|
1297
1297
|
}
|
|
1298
1298
|
}
|
|
1299
1299
|
}
|
|
@@ -1302,7 +1302,7 @@ class QueryInterface {
|
|
|
1302
1302
|
for (const [field, enabled] of Object.entries(args._avg)) {
|
|
1303
1303
|
if (enabled) {
|
|
1304
1304
|
const col = this.toColumn(field);
|
|
1305
|
-
selectExprs.push(`AVG(${quoteIdent(col)})::float AS ${quoteIdent(
|
|
1305
|
+
selectExprs.push(`AVG(${quoteIdent(col)})::float AS ${quoteIdent(`_avg_${col}`)}`);
|
|
1306
1306
|
}
|
|
1307
1307
|
}
|
|
1308
1308
|
}
|
|
@@ -1311,7 +1311,7 @@ class QueryInterface {
|
|
|
1311
1311
|
for (const [field, enabled] of Object.entries(args._min)) {
|
|
1312
1312
|
if (enabled) {
|
|
1313
1313
|
const col = this.toColumn(field);
|
|
1314
|
-
selectExprs.push(`MIN(${quoteIdent(col)}) AS ${quoteIdent(
|
|
1314
|
+
selectExprs.push(`MIN(${quoteIdent(col)}) AS ${quoteIdent(`_min_${col}`)}`);
|
|
1315
1315
|
}
|
|
1316
1316
|
}
|
|
1317
1317
|
}
|
|
@@ -1320,7 +1320,7 @@ class QueryInterface {
|
|
|
1320
1320
|
for (const [field, enabled] of Object.entries(args._max)) {
|
|
1321
1321
|
if (enabled) {
|
|
1322
1322
|
const col = this.toColumn(field);
|
|
1323
|
-
selectExprs.push(`MAX(${quoteIdent(col)}) AS ${quoteIdent(
|
|
1323
|
+
selectExprs.push(`MAX(${quoteIdent(col)}) AS ${quoteIdent(`_max_${col}`)}`);
|
|
1324
1324
|
}
|
|
1325
1325
|
}
|
|
1326
1326
|
}
|
|
@@ -1426,7 +1426,21 @@ class QueryInterface {
|
|
|
1426
1426
|
const mapped = this.tableMeta.columnMap[field];
|
|
1427
1427
|
if (mapped)
|
|
1428
1428
|
return mapped;
|
|
1429
|
-
|
|
1429
|
+
// Fall back to camelToSnake ONLY if that snake_cased name also exists as a
|
|
1430
|
+
// real column on the table. This preserves the convenience of writing
|
|
1431
|
+
// `userId` when the schema exposes `user_id` under an unusual field name,
|
|
1432
|
+
// but rejects arbitrary strings — closing the defense-in-depth gap for
|
|
1433
|
+
// SQL injection and catching typos like `where: { emial: 'x' }` with a
|
|
1434
|
+
// clear error instead of a cryptic Postgres "column does not exist".
|
|
1435
|
+
const snake = (0, schema_js_1.camelToSnake)(field);
|
|
1436
|
+
if (this.tableMeta.reverseColumnMap?.[snake]) {
|
|
1437
|
+
return snake;
|
|
1438
|
+
}
|
|
1439
|
+
if (this.tableMeta.allColumns?.includes(snake)) {
|
|
1440
|
+
return snake;
|
|
1441
|
+
}
|
|
1442
|
+
throw new errors_js_1.ValidationError(`[turbine] Unknown field "${field}" on table "${this.table}". ` +
|
|
1443
|
+
`Known fields: ${Object.keys(this.tableMeta.columnMap).join(', ') || '(none)'}.`);
|
|
1430
1444
|
}
|
|
1431
1445
|
/** Convert camelCase field name to a double-quoted SQL identifier */
|
|
1432
1446
|
toSqlColumn(field) {
|
|
@@ -1586,7 +1600,7 @@ class QueryInterface {
|
|
|
1586
1600
|
/**
|
|
1587
1601
|
* Fingerprint a relation filter sub-where for some/every/none.
|
|
1588
1602
|
*/
|
|
1589
|
-
fingerprintRelFilter(
|
|
1603
|
+
fingerprintRelFilter(_targetTable, subWhere) {
|
|
1590
1604
|
const keys = Object.keys(subWhere)
|
|
1591
1605
|
.filter((k) => subWhere[k] !== undefined)
|
|
1592
1606
|
.sort();
|
|
@@ -1698,7 +1712,7 @@ class QueryInterface {
|
|
|
1698
1712
|
const meta = this.schema.tables[targetTable];
|
|
1699
1713
|
if (!meta)
|
|
1700
1714
|
return;
|
|
1701
|
-
for (const [
|
|
1715
|
+
for (const [_field, value] of Object.entries(subWhere)) {
|
|
1702
1716
|
if (value === undefined)
|
|
1703
1717
|
continue;
|
|
1704
1718
|
if (value === null)
|
|
@@ -261,15 +261,35 @@ class ColumnBuilder {
|
|
|
261
261
|
}
|
|
262
262
|
}
|
|
263
263
|
exports.ColumnBuilder = ColumnBuilder;
|
|
264
|
+
/** Type guard: is `prop` a known nullary ColumnBuilder type method? */
|
|
265
|
+
function isNullaryColumnType(prop) {
|
|
266
|
+
return (prop === 'serial' ||
|
|
267
|
+
prop === 'bigint' ||
|
|
268
|
+
prop === 'integer' ||
|
|
269
|
+
prop === 'smallint' ||
|
|
270
|
+
prop === 'text' ||
|
|
271
|
+
prop === 'boolean' ||
|
|
272
|
+
prop === 'timestamp' ||
|
|
273
|
+
prop === 'date' ||
|
|
274
|
+
prop === 'json' ||
|
|
275
|
+
prop === 'uuid' ||
|
|
276
|
+
prop === 'real' ||
|
|
277
|
+
prop === 'doublePrecision' ||
|
|
278
|
+
prop === 'numeric' ||
|
|
279
|
+
prop === 'bytea');
|
|
280
|
+
}
|
|
264
281
|
/** @deprecated Use defineSchema() with plain objects instead */
|
|
265
282
|
exports.column = new Proxy({}, {
|
|
266
283
|
get(_target, prop) {
|
|
267
284
|
if (prop === 'varchar')
|
|
268
285
|
return (length) => new ColumnBuilder().varchar(length);
|
|
286
|
+
if (isNullaryColumnType(prop)) {
|
|
287
|
+
return () => {
|
|
288
|
+
const builder = new ColumnBuilder();
|
|
289
|
+
return builder[prop]();
|
|
290
|
+
};
|
|
291
|
+
}
|
|
269
292
|
return () => {
|
|
270
|
-
const builder = new ColumnBuilder();
|
|
271
|
-
if (typeof builder[prop] === 'function')
|
|
272
|
-
return builder[prop].call(builder);
|
|
273
293
|
throw new Error(`Unknown column type: ${prop}`);
|
|
274
294
|
};
|
|
275
295
|
},
|
package/dist/cli/index.d.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* turbine migrate status — Show migration status
|
|
13
13
|
* turbine seed — Run seed file
|
|
14
14
|
* turbine status — Show schema summary
|
|
15
|
-
* turbine studio — Launch web UI
|
|
15
|
+
* turbine studio — Launch local read-only web UI
|
|
16
16
|
*
|
|
17
17
|
* Usage:
|
|
18
18
|
* DATABASE_URL=postgres://... npx turbine generate
|
package/dist/cli/index.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* turbine migrate status — Show migration status
|
|
13
13
|
* turbine seed — Run seed file
|
|
14
14
|
* turbine status — Show schema summary
|
|
15
|
-
* turbine studio — Launch web UI
|
|
15
|
+
* turbine studio — Launch local read-only web UI
|
|
16
16
|
*
|
|
17
17
|
* Usage:
|
|
18
18
|
* DATABASE_URL=postgres://... npx turbine generate
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* npx turbine migrate create add_users_table
|
|
21
21
|
*/
|
|
22
22
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
23
|
-
import { relative, resolve } from 'node:path';
|
|
23
|
+
import { dirname, relative, resolve } from 'node:path';
|
|
24
24
|
import { pathToFileURL } from 'node:url';
|
|
25
25
|
import { generate } from '../generate.js';
|
|
26
26
|
import { introspect } from '../introspect.js';
|
|
@@ -28,6 +28,7 @@ import { schemaDiff, schemaPush } from '../schema-sql.js';
|
|
|
28
28
|
import { configTemplate, findConfigFile, loadConfig, resolveConfig } from './config.js';
|
|
29
29
|
import { needsTsLoader, registerTsLoader } from './loader.js';
|
|
30
30
|
import { createMigration, listMigrationFiles, migrateDown, migrateStatus, migrateUp } from './migrate.js';
|
|
31
|
+
import { startStudio } from './studio.js';
|
|
31
32
|
import { banner, blue, bold, box, cyan, dim, divider, elapsed, error, table as formatTable, gray, green, header, info, label, magenta, newline, red, redactUrl, Spinner, success, symbols, warn, yellow, } from './ui.js';
|
|
32
33
|
function parseArgs() {
|
|
33
34
|
const args = process.argv.slice(2);
|
|
@@ -94,6 +95,17 @@ function parseArgs() {
|
|
|
94
95
|
case '-h':
|
|
95
96
|
result.help = true;
|
|
96
97
|
break;
|
|
98
|
+
case '--port':
|
|
99
|
+
result.port = next ? Number.parseInt(next, 10) : undefined;
|
|
100
|
+
i++;
|
|
101
|
+
break;
|
|
102
|
+
case '--host':
|
|
103
|
+
result.host = next;
|
|
104
|
+
i++;
|
|
105
|
+
break;
|
|
106
|
+
case '--no-open':
|
|
107
|
+
result.noOpen = true;
|
|
108
|
+
break;
|
|
97
109
|
default:
|
|
98
110
|
if (!arg.startsWith('-')) {
|
|
99
111
|
result.positional.push(arg);
|
|
@@ -890,19 +902,71 @@ async function cmdStatus(_args, config) {
|
|
|
890
902
|
}
|
|
891
903
|
}
|
|
892
904
|
// ---------------------------------------------------------------------------
|
|
893
|
-
// Command: studio
|
|
905
|
+
// Command: studio — local read-only web UI
|
|
894
906
|
// ---------------------------------------------------------------------------
|
|
895
|
-
async function cmdStudio(
|
|
907
|
+
async function cmdStudio(args, config) {
|
|
896
908
|
banner();
|
|
909
|
+
const url = requireUrl(config);
|
|
910
|
+
const port = args.port ?? 4983;
|
|
911
|
+
const host = args.host ?? '127.0.0.1';
|
|
912
|
+
const openBrowser = !args.noOpen;
|
|
913
|
+
if (!Number.isFinite(port) || port <= 0 || port > 65535) {
|
|
914
|
+
console.log(red(`✗ invalid port: ${args.port}`));
|
|
915
|
+
process.exit(1);
|
|
916
|
+
}
|
|
917
|
+
// Refuse to bind anything other than loopback unless explicitly overridden.
|
|
918
|
+
// This is deliberate: Studio has no real authentication beyond a random
|
|
919
|
+
// session token, so exposing it on a LAN interface is foot-gun territory.
|
|
920
|
+
if (host !== '127.0.0.1' && host !== 'localhost' && host !== '::1') {
|
|
921
|
+
console.log(warn(`Studio is binding to ${yellow(host)} — this is NOT loopback. ` +
|
|
922
|
+
`Anyone on your network who can reach this port + guess the session token can read your database.`));
|
|
923
|
+
}
|
|
924
|
+
const spinner = new Spinner('Introspecting database').start();
|
|
925
|
+
let studio;
|
|
926
|
+
try {
|
|
927
|
+
studio = await startStudio({
|
|
928
|
+
url,
|
|
929
|
+
schema: config.schema,
|
|
930
|
+
port,
|
|
931
|
+
host,
|
|
932
|
+
openBrowser,
|
|
933
|
+
include: config.include.length ? config.include : undefined,
|
|
934
|
+
exclude: config.exclude.length ? config.exclude : undefined,
|
|
935
|
+
});
|
|
936
|
+
spinner.succeed(`Studio is running`);
|
|
937
|
+
}
|
|
938
|
+
catch (err) {
|
|
939
|
+
spinner.fail(`Failed to start Studio: ${err instanceof Error ? err.message : String(err)}`);
|
|
940
|
+
process.exit(1);
|
|
941
|
+
}
|
|
942
|
+
newline();
|
|
897
943
|
console.log(box([
|
|
898
|
-
`${bold('Turbine Studio')}
|
|
944
|
+
`${bold('Turbine Studio')} ${dim('— local read-only UI')}`,
|
|
899
945
|
'',
|
|
900
|
-
'
|
|
901
|
-
'
|
|
946
|
+
` ${cyan('URL:')} ${bold(studio.url)}`,
|
|
947
|
+
` ${cyan('Schema:')} ${config.schema}`,
|
|
948
|
+
` ${cyan('DB:')} ${redactUrl(url)}`,
|
|
902
949
|
'',
|
|
903
|
-
|
|
904
|
-
|
|
950
|
+
dim('Open the URL above in your browser. It includes a one-time session'),
|
|
951
|
+
dim('token that gets set as an HttpOnly cookie on first load.'),
|
|
952
|
+
dim('Press Ctrl+C to stop.'),
|
|
953
|
+
].join('\n'), { title: bold(cyan('Studio')), padding: 1 }));
|
|
905
954
|
newline();
|
|
955
|
+
// Wait forever until SIGINT/SIGTERM, then dispose cleanly.
|
|
956
|
+
await new Promise((resolve) => {
|
|
957
|
+
const shutdown = async () => {
|
|
958
|
+
console.log(dim('\n shutting down…'));
|
|
959
|
+
try {
|
|
960
|
+
await studio.dispose();
|
|
961
|
+
}
|
|
962
|
+
catch {
|
|
963
|
+
/* ignore */
|
|
964
|
+
}
|
|
965
|
+
resolve();
|
|
966
|
+
};
|
|
967
|
+
process.once('SIGINT', shutdown);
|
|
968
|
+
process.once('SIGTERM', shutdown);
|
|
969
|
+
});
|
|
906
970
|
}
|
|
907
971
|
// ---------------------------------------------------------------------------
|
|
908
972
|
// Subcommand help
|
|
@@ -1051,7 +1115,7 @@ function showHelp() {
|
|
|
1051
1115
|
console.log(` ${dim('status')} Show applied/pending migrations`);
|
|
1052
1116
|
console.log(` ${cyan('seed')} Run seed file`);
|
|
1053
1117
|
console.log(` ${cyan('status')} ${dim('| info')} Show schema summary`);
|
|
1054
|
-
console.log(` ${cyan('studio')} Launch web UI
|
|
1118
|
+
console.log(` ${cyan('studio')} Launch local read-only web UI`);
|
|
1055
1119
|
newline();
|
|
1056
1120
|
console.log(` ${bold('Options:')}`);
|
|
1057
1121
|
console.log(` ${cyan('--url, -u')} ${dim('<url>')} Postgres connection string`);
|
|
@@ -1063,6 +1127,11 @@ function showHelp() {
|
|
|
1063
1127
|
console.log(` ${cyan('--verbose, -v')} Show detailed output`);
|
|
1064
1128
|
console.log(` ${cyan('--force, -f')} Overwrite existing files`);
|
|
1065
1129
|
newline();
|
|
1130
|
+
console.log(` ${bold('Studio options:')}`);
|
|
1131
|
+
console.log(` ${cyan('--port')} ${dim('<n>')} HTTP port ${dim('(default: 4983)')}`);
|
|
1132
|
+
console.log(` ${cyan('--host')} ${dim('<addr>')} Bind address ${dim('(default: 127.0.0.1)')}`);
|
|
1133
|
+
console.log(` ${cyan('--no-open')} Don't auto-open the browser`);
|
|
1134
|
+
newline();
|
|
1066
1135
|
console.log(` ${bold('Config file:')}`);
|
|
1067
1136
|
console.log(` ${dim('Create')} ${cyan('turbine.config.ts')} ${dim('with')} ${cyan('npx turbine init')}`);
|
|
1068
1137
|
console.log(` ${dim('CLI flags override config file values.')}`);
|
|
@@ -1079,7 +1148,30 @@ function showHelp() {
|
|
|
1079
1148
|
// Version
|
|
1080
1149
|
// ---------------------------------------------------------------------------
|
|
1081
1150
|
function showVersion() {
|
|
1082
|
-
|
|
1151
|
+
// Walk up from the running script to find the turbine-orm package.json.
|
|
1152
|
+
// Using process.argv[1] instead of import.meta.url so the same code compiles
|
|
1153
|
+
// cleanly for both the ESM and CJS builds.
|
|
1154
|
+
try {
|
|
1155
|
+
let dir = dirname(process.argv[1] ?? '');
|
|
1156
|
+
for (let i = 0; i < 6; i++) {
|
|
1157
|
+
const candidate = resolve(dir, 'package.json');
|
|
1158
|
+
if (existsSync(candidate)) {
|
|
1159
|
+
const pkg = JSON.parse(readFileSync(candidate, 'utf8'));
|
|
1160
|
+
if (pkg.name === 'turbine-orm') {
|
|
1161
|
+
console.log(`turbine-orm v${pkg.version ?? '?'}`);
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
const parent = dirname(dir);
|
|
1166
|
+
if (parent === dir)
|
|
1167
|
+
break;
|
|
1168
|
+
dir = parent;
|
|
1169
|
+
}
|
|
1170
|
+
console.log(`turbine-orm`);
|
|
1171
|
+
}
|
|
1172
|
+
catch {
|
|
1173
|
+
console.log(`turbine-orm`);
|
|
1174
|
+
}
|
|
1083
1175
|
}
|
|
1084
1176
|
// ---------------------------------------------------------------------------
|
|
1085
1177
|
// Main
|
package/dist/cli/migrate.d.ts
CHANGED
|
@@ -79,6 +79,22 @@ export declare function createMigration(migrationsDir: string, name: string, aut
|
|
|
79
79
|
up: string;
|
|
80
80
|
down: string;
|
|
81
81
|
}): MigrationFile;
|
|
82
|
+
/**
|
|
83
|
+
* Derive a Postgres advisory lock ID (positive int4) from the database name.
|
|
84
|
+
*
|
|
85
|
+
* Uses FNV-1a 32-bit hash — a well-known, stable, non-cryptographic hash with
|
|
86
|
+
* excellent distribution over short strings (database names are typically <64
|
|
87
|
+
* chars). Chosen over alternatives because it's:
|
|
88
|
+
* - deterministic (same input → same output, across processes/machines)
|
|
89
|
+
* - tiny (two lines, no allocations, no imports)
|
|
90
|
+
* - well-distributed (low collision rate for typical DB-name distributions)
|
|
91
|
+
*
|
|
92
|
+
* The top bit is cleared so the result fits in a positive int4, which is the
|
|
93
|
+
* range `pg_advisory_lock` expects for the single-argument form. Two databases
|
|
94
|
+
* in the same Postgres cluster can now run `turbine migrate` concurrently
|
|
95
|
+
* without contending on a single hardcoded lock ID.
|
|
96
|
+
*/
|
|
97
|
+
export declare function deriveLockId(databaseName: string): number;
|
|
82
98
|
/**
|
|
83
99
|
* Apply all pending migrations (UP).
|
|
84
100
|
*
|
package/dist/cli/migrate.js
CHANGED
|
@@ -202,16 +202,44 @@ ${autoContent.down}
|
|
|
202
202
|
// ---------------------------------------------------------------------------
|
|
203
203
|
// Advisory lock for concurrent migration safety
|
|
204
204
|
// ---------------------------------------------------------------------------
|
|
205
|
-
/**
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
205
|
+
/**
|
|
206
|
+
* Derive a Postgres advisory lock ID (positive int4) from the database name.
|
|
207
|
+
*
|
|
208
|
+
* Uses FNV-1a 32-bit hash — a well-known, stable, non-cryptographic hash with
|
|
209
|
+
* excellent distribution over short strings (database names are typically <64
|
|
210
|
+
* chars). Chosen over alternatives because it's:
|
|
211
|
+
* - deterministic (same input → same output, across processes/machines)
|
|
212
|
+
* - tiny (two lines, no allocations, no imports)
|
|
213
|
+
* - well-distributed (low collision rate for typical DB-name distributions)
|
|
214
|
+
*
|
|
215
|
+
* The top bit is cleared so the result fits in a positive int4, which is the
|
|
216
|
+
* range `pg_advisory_lock` expects for the single-argument form. Two databases
|
|
217
|
+
* in the same Postgres cluster can now run `turbine migrate` concurrently
|
|
218
|
+
* without contending on a single hardcoded lock ID.
|
|
219
|
+
*/
|
|
220
|
+
export function deriveLockId(databaseName) {
|
|
221
|
+
let hash = 0x811c9dc5;
|
|
222
|
+
for (let i = 0; i < databaseName.length; i++) {
|
|
223
|
+
hash ^= databaseName.charCodeAt(i);
|
|
224
|
+
hash = Math.imul(hash, 0x01000193);
|
|
225
|
+
}
|
|
226
|
+
return hash >>> 1; // positive int4 (top bit cleared)
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Fetch the current database name from the connected client. Used to derive
|
|
230
|
+
* the advisory lock ID so concurrent migrations in sibling databases do not
|
|
231
|
+
* contend on one another.
|
|
232
|
+
*/
|
|
233
|
+
async function getCurrentDatabaseName(client) {
|
|
234
|
+
const result = await client.query(`SELECT current_database()`);
|
|
235
|
+
return result.rows[0]?.current_database ?? '';
|
|
236
|
+
}
|
|
237
|
+
async function acquireLock(client, lockId) {
|
|
238
|
+
const result = await client.query(`SELECT pg_try_advisory_lock($1) AS locked`, [lockId]);
|
|
239
|
+
return result.rows[0]?.locked ?? false;
|
|
212
240
|
}
|
|
213
|
-
async function releaseLock(client) {
|
|
214
|
-
await client.query(`SELECT pg_advisory_unlock($1)`, [
|
|
241
|
+
async function releaseLock(client, lockId) {
|
|
242
|
+
await client.query(`SELECT pg_advisory_unlock($1)`, [lockId]);
|
|
215
243
|
}
|
|
216
244
|
/**
|
|
217
245
|
* Validate that applied migration files have not been modified or deleted since they were run.
|
|
@@ -274,8 +302,12 @@ export async function migrateUp(connectionString, migrationsDir, options) {
|
|
|
274
302
|
// Treat `force` as an alias for `allowDrift` for backwards compatibility.
|
|
275
303
|
const allowDrift = options?.allowDrift === true || options?.force === true;
|
|
276
304
|
try {
|
|
305
|
+
// Derive an advisory lock ID per-database so concurrent migrations in
|
|
306
|
+
// sibling databases on the same Postgres cluster do not contend.
|
|
307
|
+
const dbName = await getCurrentDatabaseName(client);
|
|
308
|
+
const lockId = deriveLockId(dbName);
|
|
277
309
|
// Acquire advisory lock to prevent concurrent migrations
|
|
278
|
-
const gotLock = await acquireLock(client);
|
|
310
|
+
const gotLock = await acquireLock(client, lockId);
|
|
279
311
|
if (!gotLock) {
|
|
280
312
|
throw new MigrationError('[turbine] Could not acquire migration lock — another migration is already running');
|
|
281
313
|
}
|
|
@@ -347,7 +379,7 @@ export async function migrateUp(connectionString, migrationsDir, options) {
|
|
|
347
379
|
return { applied: results, errors };
|
|
348
380
|
}
|
|
349
381
|
finally {
|
|
350
|
-
await releaseLock(client);
|
|
382
|
+
await releaseLock(client, lockId);
|
|
351
383
|
}
|
|
352
384
|
}
|
|
353
385
|
finally {
|
|
@@ -366,7 +398,11 @@ export async function migrateDown(connectionString, migrationsDir, options) {
|
|
|
366
398
|
const client = new pg.Client({ connectionString });
|
|
367
399
|
await client.connect();
|
|
368
400
|
try {
|
|
369
|
-
|
|
401
|
+
// Derive a per-database advisory lock ID so concurrent migrations in
|
|
402
|
+
// sibling databases on the same cluster do not contend.
|
|
403
|
+
const dbName = await getCurrentDatabaseName(client);
|
|
404
|
+
const lockId = deriveLockId(dbName);
|
|
405
|
+
const gotLock = await acquireLock(client, lockId);
|
|
370
406
|
if (!gotLock) {
|
|
371
407
|
throw new MigrationError('[turbine] Could not acquire migration lock — another migration is already running');
|
|
372
408
|
}
|
|
@@ -413,7 +449,7 @@ export async function migrateDown(connectionString, migrationsDir, options) {
|
|
|
413
449
|
return { rolledBack: results, errors };
|
|
414
450
|
}
|
|
415
451
|
finally {
|
|
416
|
-
await releaseLock(client);
|
|
452
|
+
await releaseLock(client, lockId);
|
|
417
453
|
}
|
|
418
454
|
}
|
|
419
455
|
finally {
|