turbine-orm 0.27.0 → 0.28.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 +17 -13
- package/dist/cjs/cli/config.js +20 -3
- package/dist/cjs/cli/destructive.js +47 -31
- package/dist/cjs/cli/index.js +273 -71
- package/dist/cjs/cli/mcp.js +788 -0
- package/dist/cjs/cli/migrate.js +95 -20
- package/dist/cjs/cli/studio.js +3 -2
- package/dist/cjs/client.js +267 -34
- package/dist/cjs/dialect.js +2 -0
- package/dist/cjs/generate.js +171 -7
- package/dist/cjs/index.js +4 -1
- package/dist/cjs/introspect.js +177 -4
- package/dist/cjs/query/batched-loader.js +148 -0
- package/dist/cjs/query/builder.js +714 -133
- package/dist/cjs/schema-builder.js +59 -4
- package/dist/cjs/schema-sql.js +315 -6
- package/dist/cjs/seed.js +66 -0
- package/dist/cli/config.d.ts +9 -2
- package/dist/cli/config.js +19 -3
- package/dist/cli/destructive.js +47 -31
- package/dist/cli/index.d.ts +52 -1
- package/dist/cli/index.js +272 -74
- package/dist/cli/mcp.d.ts +17 -0
- package/dist/cli/mcp.js +781 -0
- package/dist/cli/migrate.d.ts +37 -0
- package/dist/cli/migrate.js +92 -20
- package/dist/cli/studio.d.ts +3 -2
- package/dist/cli/studio.js +3 -2
- package/dist/client.d.ts +136 -1
- package/dist/client.js +267 -34
- package/dist/dialect.d.ts +17 -0
- package/dist/dialect.js +2 -0
- package/dist/generate.d.ts +17 -0
- package/dist/generate.js +171 -10
- package/dist/index.d.ts +4 -3
- package/dist/index.js +2 -0
- package/dist/introspect.d.ts +20 -1
- package/dist/introspect.js +175 -4
- package/dist/query/batched-loader.d.ts +29 -2
- package/dist/query/batched-loader.js +148 -1
- package/dist/query/builder.d.ts +156 -8
- package/dist/query/builder.js +715 -134
- package/dist/query/index.d.ts +1 -1
- package/dist/query/types.d.ts +113 -8
- package/dist/schema-builder.d.ts +73 -8
- package/dist/schema-builder.js +59 -4
- package/dist/schema-sql.d.ts +67 -0
- package/dist/schema-sql.js +310 -6
- package/dist/schema.d.ts +53 -0
- package/dist/seed.d.ts +4 -0
- package/dist/seed.js +63 -0
- package/package.json +2 -3
package/dist/cjs/cli/migrate.js
CHANGED
|
@@ -25,7 +25,10 @@ exports.parseMigrationContent = parseMigrationContent;
|
|
|
25
25
|
exports.parseMigrationSQL = parseMigrationSQL;
|
|
26
26
|
exports.createMigration = createMigration;
|
|
27
27
|
exports.deriveLockId = deriveLockId;
|
|
28
|
+
exports.planMigrationDeploy = planMigrationDeploy;
|
|
29
|
+
exports.inspectMigrationDeploy = inspectMigrationDeploy;
|
|
28
30
|
exports.migrateUp = migrateUp;
|
|
31
|
+
exports.migrateDeploy = migrateDeploy;
|
|
29
32
|
exports.migrateDown = migrateDown;
|
|
30
33
|
exports.migrateStatus = migrateStatus;
|
|
31
34
|
const node_crypto_1 = require("node:crypto");
|
|
@@ -297,6 +300,85 @@ async function validateChecksums(client, migrationsDir, dialect = dialect_js_1.p
|
|
|
297
300
|
}
|
|
298
301
|
return mismatches;
|
|
299
302
|
}
|
|
303
|
+
function formatChecksumMismatchError(mismatches) {
|
|
304
|
+
const modified = mismatches.filter((m) => m.type === 'modified');
|
|
305
|
+
const missing = mismatches.filter((m) => m.type === 'missing');
|
|
306
|
+
const lines = [
|
|
307
|
+
'[turbine] Migration drift detected — refusing to apply pending migrations.',
|
|
308
|
+
'',
|
|
309
|
+
'Applied migrations should be immutable. The following files no longer match their applied state:',
|
|
310
|
+
'',
|
|
311
|
+
];
|
|
312
|
+
for (const m of modified) {
|
|
313
|
+
lines.push(` - ${m.name}.sql (modified on disk)`);
|
|
314
|
+
}
|
|
315
|
+
for (const m of missing) {
|
|
316
|
+
lines.push(` - ${m.name}.sql (deleted from disk)`);
|
|
317
|
+
}
|
|
318
|
+
lines.push('');
|
|
319
|
+
lines.push('Fix one of these:');
|
|
320
|
+
lines.push(' 1. Restore the file(s) to their original content, OR');
|
|
321
|
+
lines.push(' 2. Roll back the affected migrations with `npx turbine migrate down`, OR');
|
|
322
|
+
lines.push(' 3. Pass `--allow-drift` to bypass this check (advanced — make sure you know what you are doing).');
|
|
323
|
+
return lines.join('\n');
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Build a deploy plan from local migration files and applied migration rows.
|
|
327
|
+
* This is pure file-system planning; callers with a database connection should
|
|
328
|
+
* use `inspectMigrationDeploy()` to preserve legacy checksum upgrades.
|
|
329
|
+
*/
|
|
330
|
+
function planMigrationDeploy(migrationsDir, applied) {
|
|
331
|
+
const allFiles = listMigrationFiles(migrationsDir);
|
|
332
|
+
const fileMap = new Map(allFiles.map((f) => [f.name, f]));
|
|
333
|
+
const appliedNames = new Set(applied.map((m) => m.name));
|
|
334
|
+
const mismatches = [];
|
|
335
|
+
for (const migration of applied) {
|
|
336
|
+
const file = fileMap.get(migration.name);
|
|
337
|
+
if (!file) {
|
|
338
|
+
mismatches.push({
|
|
339
|
+
name: migration.name,
|
|
340
|
+
expected: migration.checksum,
|
|
341
|
+
actual: '',
|
|
342
|
+
type: 'missing',
|
|
343
|
+
});
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
const currentHash = checksum((0, node_fs_1.readFileSync)(file.path, 'utf-8'));
|
|
347
|
+
if (currentHash !== migration.checksum && !isLegacyChecksum(migration.checksum)) {
|
|
348
|
+
mismatches.push({
|
|
349
|
+
name: migration.name,
|
|
350
|
+
expected: migration.checksum,
|
|
351
|
+
actual: currentHash,
|
|
352
|
+
type: 'modified',
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return {
|
|
357
|
+
pending: allFiles.filter((f) => !appliedNames.has(f.name)),
|
|
358
|
+
mismatches,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Inspect deploy status without applying migrations.
|
|
363
|
+
*/
|
|
364
|
+
async function inspectMigrationDeploy(connectionString, migrationsDir, options) {
|
|
365
|
+
const client = new pg_1.default.Client({ connectionString });
|
|
366
|
+
await client.connect();
|
|
367
|
+
const dialect = options?.dialect ?? dialect_js_1.postgresDialect;
|
|
368
|
+
try {
|
|
369
|
+
await ensureTrackingTable(client, dialect);
|
|
370
|
+
const mismatches = await validateChecksums(client, migrationsDir, dialect);
|
|
371
|
+
const applied = await getAppliedMigrations(client, dialect);
|
|
372
|
+
const appliedNames = new Set(applied.map((m) => m.name));
|
|
373
|
+
return {
|
|
374
|
+
pending: listMigrationFiles(migrationsDir).filter((f) => !appliedNames.has(f.name)),
|
|
375
|
+
mismatches,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
finally {
|
|
379
|
+
await client.end();
|
|
380
|
+
}
|
|
381
|
+
}
|
|
300
382
|
/**
|
|
301
383
|
* Apply all pending migrations (UP).
|
|
302
384
|
*
|
|
@@ -340,26 +422,7 @@ async function migrateUp(connectionString, migrationsDir, options) {
|
|
|
340
422
|
if (!allowDrift) {
|
|
341
423
|
const mismatches = await validateChecksums(client, migrationsDir, dialect);
|
|
342
424
|
if (mismatches.length > 0) {
|
|
343
|
-
|
|
344
|
-
const missing = mismatches.filter((m) => m.type === 'missing');
|
|
345
|
-
const lines = [
|
|
346
|
-
'[turbine] Migration drift detected — refusing to apply pending migrations.',
|
|
347
|
-
'',
|
|
348
|
-
'Applied migrations should be immutable. The following files no longer match their applied state:',
|
|
349
|
-
'',
|
|
350
|
-
];
|
|
351
|
-
for (const m of modified) {
|
|
352
|
-
lines.push(` - ${m.name}.sql (modified on disk)`);
|
|
353
|
-
}
|
|
354
|
-
for (const m of missing) {
|
|
355
|
-
lines.push(` - ${m.name}.sql (deleted from disk)`);
|
|
356
|
-
}
|
|
357
|
-
lines.push('');
|
|
358
|
-
lines.push('Fix one of these:');
|
|
359
|
-
lines.push(' 1. Restore the file(s) to their original content, OR');
|
|
360
|
-
lines.push(' 2. Roll back the affected migrations with `npx turbine migrate down`, OR');
|
|
361
|
-
lines.push(' 3. Pass `--allow-drift` to bypass this check (advanced — make sure you know what you are doing).');
|
|
362
|
-
throw new errors_js_1.MigrationError(lines.join('\n'));
|
|
425
|
+
throw new errors_js_1.MigrationError(formatChecksumMismatchError(mismatches));
|
|
363
426
|
}
|
|
364
427
|
}
|
|
365
428
|
const applied = await getAppliedMigrations(client, dialect);
|
|
@@ -433,6 +496,18 @@ async function migrateUp(connectionString, migrationsDir, options) {
|
|
|
433
496
|
await client.end();
|
|
434
497
|
}
|
|
435
498
|
}
|
|
499
|
+
/**
|
|
500
|
+
* Production migration apply. This intentionally applies files as written and
|
|
501
|
+
* never performs interactive destructive confirmation.
|
|
502
|
+
*/
|
|
503
|
+
async function migrateDeploy(connectionString, migrationsDir, options) {
|
|
504
|
+
return migrateUp(connectionString, migrationsDir, {
|
|
505
|
+
allowDrift: false,
|
|
506
|
+
allowDestructive: true,
|
|
507
|
+
adapter: options?.adapter,
|
|
508
|
+
dialect: options?.dialect,
|
|
509
|
+
});
|
|
510
|
+
}
|
|
436
511
|
/**
|
|
437
512
|
* Rollback the last N migrations (DOWN).
|
|
438
513
|
*
|
package/dist/cjs/cli/studio.js
CHANGED
|
@@ -7,10 +7,11 @@
|
|
|
7
7
|
* raw-SQL input surface — the Query tab builds `findMany` args that are
|
|
8
8
|
* validated against introspected metadata and compiled by QueryInterface
|
|
9
9
|
* (`/api/builder`). Pure Node (built-in `http` module), no runtime
|
|
10
|
-
* dependencies beyond `pg
|
|
10
|
+
* dependencies beyond `pg`. CLI defaults to 127.0.0.1 and refuses non-loopback
|
|
11
|
+
* hosts unless `npx turbine studio --allow-remote` is set.
|
|
11
12
|
*
|
|
12
13
|
* Security model:
|
|
13
|
-
* •
|
|
14
|
+
* • Loopback by default; CLI refuses non-loopback without --allow-remote
|
|
14
15
|
* • Random auth token generated per process, required in Cookie header
|
|
15
16
|
* • No SQL input surface at all — every identifier in a builder request is
|
|
16
17
|
* validated against the introspected schema; all values are $N params
|
package/dist/cjs/client.js
CHANGED
|
@@ -75,6 +75,29 @@ const ISOLATION_LEVELS = {
|
|
|
75
75
|
* rejecting loudly before it reaches the database.
|
|
76
76
|
*/
|
|
77
77
|
const GUC_NAME_REGEX = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$/;
|
|
78
|
+
/**
|
|
79
|
+
* The read-only `QueryInterface` operations that a read-replica setup may route
|
|
80
|
+
* to a replica pool. Every other method (all writes, plus internals) stays on
|
|
81
|
+
* the primary. Kept as a Set so the routing proxy's `get` trap is O(1).
|
|
82
|
+
*/
|
|
83
|
+
const READ_OPERATIONS = new Set([
|
|
84
|
+
'findMany',
|
|
85
|
+
'findFirst',
|
|
86
|
+
'findUnique',
|
|
87
|
+
'findFirstOrThrow',
|
|
88
|
+
'findUniqueOrThrow',
|
|
89
|
+
'count',
|
|
90
|
+
'aggregate',
|
|
91
|
+
'groupBy',
|
|
92
|
+
'findManyStream',
|
|
93
|
+
]);
|
|
94
|
+
/**
|
|
95
|
+
* Internal marker on the config object that tells the `TurbineClient`
|
|
96
|
+
* constructor to build a lightweight "primary-only" view sharing an existing
|
|
97
|
+
* client's primary pool, dialect, query options, and middleware — instead of
|
|
98
|
+
* creating a fresh pool. Produced solely by `$primary()`; never public.
|
|
99
|
+
*/
|
|
100
|
+
const PRIMARY_VIEW = Symbol('turbine.primaryView');
|
|
78
101
|
// ---------------------------------------------------------------------------
|
|
79
102
|
// TransactionClient — provides typed table accessors within a transaction
|
|
80
103
|
// ---------------------------------------------------------------------------
|
|
@@ -218,7 +241,55 @@ class TurbineClient {
|
|
|
218
241
|
ownsPool = true;
|
|
219
242
|
/** Active LISTEN subscriptions — torn down on disconnect() so it never hangs */
|
|
220
243
|
activeSubscriptions = new Set();
|
|
244
|
+
/**
|
|
245
|
+
* Read-replica pools in round-robin order. Empty when no replicas are
|
|
246
|
+
* configured, in which case `table()` takes the original single-pool path.
|
|
247
|
+
*/
|
|
248
|
+
replicaPools;
|
|
249
|
+
/**
|
|
250
|
+
* The subset of {@link replicaPools} that Turbine created from connection
|
|
251
|
+
* strings and must close on `disconnect()`. External replica pools are not
|
|
252
|
+
* listed here (caller owns their lifecycle).
|
|
253
|
+
*/
|
|
254
|
+
ownedReplicaPools;
|
|
255
|
+
/** Rotating index for round-robin replica selection (advances per read op). */
|
|
256
|
+
replicaCursor = 0;
|
|
257
|
+
/** Per-replica `table → QueryInterface` caches, indexed like {@link replicaPools}. */
|
|
258
|
+
replicaTableCaches;
|
|
259
|
+
/** Cache of per-table routing proxies (only used when replicas are present). */
|
|
260
|
+
routingProxyCache = new Map();
|
|
261
|
+
/** Lazily-built, cached primary-only view returned by {@link $primary}. */
|
|
262
|
+
primaryView;
|
|
221
263
|
constructor(config = {}, schema) {
|
|
264
|
+
// Primary-only view: $primary() constructs this to share the parent's
|
|
265
|
+
// primary pool + derived state instead of creating a fresh pool. It owns
|
|
266
|
+
// no pool and no replicas, so every operation (reads included) runs on the
|
|
267
|
+
// primary and disconnect() is a no-op on the shared pool.
|
|
268
|
+
const seed = config[PRIMARY_VIEW];
|
|
269
|
+
if (seed) {
|
|
270
|
+
const parent = seed.parent;
|
|
271
|
+
this.schema = schema;
|
|
272
|
+
this.logging = parent.logging;
|
|
273
|
+
this.dialect = parent.dialect;
|
|
274
|
+
this.errorMessagesSafe = parent.errorMessagesSafe;
|
|
275
|
+
this.queryOptions = parent.queryOptions;
|
|
276
|
+
this.middlewares = parent.middlewares; // shared reference: $use on parent flows through
|
|
277
|
+
this.pool = parent.pool;
|
|
278
|
+
this.ownsPool = false;
|
|
279
|
+
this.replicaPools = [];
|
|
280
|
+
this.ownedReplicaPools = [];
|
|
281
|
+
this.replicaTableCaches = [];
|
|
282
|
+
for (const tableName of Object.keys(schema.tables)) {
|
|
283
|
+
const camelName = tableName.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
284
|
+
if (!(camelName in this)) {
|
|
285
|
+
Object.defineProperty(this, camelName, {
|
|
286
|
+
get: () => this.table(tableName),
|
|
287
|
+
enumerable: true,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
222
293
|
// Constructing without schema metadata previously crashed deep in the
|
|
223
294
|
// constructor with an opaque "Cannot read properties of undefined
|
|
224
295
|
// (reading 'tables')". Fail fast with an actionable message instead.
|
|
@@ -242,11 +313,16 @@ class TurbineClient {
|
|
|
242
313
|
* of returning a string is correct and matches the generated TypeScript type
|
|
243
314
|
* (numeric → string). Users who want number can cast explicitly in SQL.
|
|
244
315
|
*/
|
|
245
|
-
// Only register the int8 parser when
|
|
246
|
-
// pools (Neon HTTP, Vercel Postgres) may ship their own pg-types
|
|
247
|
-
// and rely on their own parser configuration —
|
|
248
|
-
//
|
|
249
|
-
|
|
316
|
+
// Only register the int8 parser when the PRIMARY pool is Turbine-owned.
|
|
317
|
+
// External pools (Neon HTTP, Vercel Postgres) may ship their own pg-types
|
|
318
|
+
// fork and rely on their own parser configuration — registration is
|
|
319
|
+
// process-global, so flipping it because a string replica exists alongside
|
|
320
|
+
// an external primary would silently change the external primary's parsing
|
|
321
|
+
// too. String replicas configured next to an external primary therefore
|
|
322
|
+
// inherit the caller's parser configuration (documented). Registration is
|
|
323
|
+
// constructor-gated by the static flags, so it happens at most once.
|
|
324
|
+
const ownsAnyPool = !config.pool;
|
|
325
|
+
if (ownsAnyPool && !TurbineClient.int8ParserRegistered) {
|
|
250
326
|
pg_1.default.types.setTypeParser(20, (val) => {
|
|
251
327
|
const n = Number(val);
|
|
252
328
|
return Number.isSafeInteger(n) ? n : val;
|
|
@@ -259,7 +335,7 @@ class TurbineClient {
|
|
|
259
335
|
// ORM convention (Prisma, Rails, Django) — and the only interpretation
|
|
260
336
|
// that round-trips what Postgres stores — is UTC. Same ownership rule as
|
|
261
337
|
// the int8 parser: never mutate parser state on external pools.
|
|
262
|
-
if (
|
|
338
|
+
if (ownsAnyPool && config.utcTimestamps !== false && !TurbineClient.utcTimestampParserRegistered) {
|
|
263
339
|
pg_1.default.types.setTypeParser(1114, (val) => new Date(`${val.replace(' ', 'T')}Z`));
|
|
264
340
|
TurbineClient.utcTimestampParserRegistered = true;
|
|
265
341
|
}
|
|
@@ -275,6 +351,7 @@ class TurbineClient {
|
|
|
275
351
|
utcTimestamps: config.utcTimestamps,
|
|
276
352
|
relationLoadStrategy: config.relationLoadStrategy,
|
|
277
353
|
jsonEncoding: config.jsonEncoding,
|
|
354
|
+
globalFilters: config.globalFilters,
|
|
278
355
|
preparedStatements: envDisablePrepared ? false : (config.preparedStatements ?? !config.pool),
|
|
279
356
|
sqlCache: config.sqlCache ?? true,
|
|
280
357
|
dialect: config.dialect,
|
|
@@ -339,6 +416,34 @@ class TurbineClient {
|
|
|
339
416
|
console.log(`[turbine] Pool created — max ${poolConfig.max} connections, ${Object.keys(schema.tables).length} tables`);
|
|
340
417
|
}
|
|
341
418
|
}
|
|
419
|
+
// Build read-replica pools (if any). String entries become owned pg.Pools
|
|
420
|
+
// sharing the primary's tuning knobs; PgCompatPool entries are external and
|
|
421
|
+
// used as-is. Replica selection is round-robin in this array order.
|
|
422
|
+
this.replicaPools = [];
|
|
423
|
+
this.ownedReplicaPools = [];
|
|
424
|
+
for (const replica of config.replicas ?? []) {
|
|
425
|
+
if (typeof replica === 'string') {
|
|
426
|
+
const replicaPool = new pg_1.default.Pool({
|
|
427
|
+
connectionString: replica,
|
|
428
|
+
max: config.poolSize ?? 10,
|
|
429
|
+
idleTimeoutMillis: config.idleTimeoutMs ?? 30_000,
|
|
430
|
+
connectionTimeoutMillis: config.connectionTimeoutMs ?? 5_000,
|
|
431
|
+
...(config.ssl !== undefined ? { ssl: config.ssl } : {}),
|
|
432
|
+
});
|
|
433
|
+
replicaPool.on('error', (err) => {
|
|
434
|
+
console.error('[turbine] Unexpected replica pool error:', err.message);
|
|
435
|
+
});
|
|
436
|
+
this.replicaPools.push(replicaPool);
|
|
437
|
+
this.ownedReplicaPools.push(replicaPool);
|
|
438
|
+
}
|
|
439
|
+
else {
|
|
440
|
+
this.replicaPools.push(replica);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
this.replicaTableCaches = this.replicaPools.map(() => new Map());
|
|
444
|
+
if (this.logging && this.replicaPools.length > 0) {
|
|
445
|
+
console.log(`[turbine] ${this.replicaPools.length} read replica(s) configured (${this.ownedReplicaPools.length} owned)`);
|
|
446
|
+
}
|
|
342
447
|
// Auto-create typed table accessors for all tables in the schema
|
|
343
448
|
for (const tableName of Object.keys(schema.tables)) {
|
|
344
449
|
const camelName = tableName.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
@@ -390,8 +495,14 @@ class TurbineClient {
|
|
|
390
495
|
*/
|
|
391
496
|
$use(middleware) {
|
|
392
497
|
this.middlewares.push(middleware);
|
|
393
|
-
// Clear table
|
|
498
|
+
// Clear table caches so new QueryInterfaces pick up the middleware. Covers
|
|
499
|
+
// the primary cache plus, when replicas are configured, the routing proxies
|
|
500
|
+
// and per-replica caches. The primary-view (if built) shares the middleware
|
|
501
|
+
// array by reference, so its QueryInterfaces observe the new middleware too.
|
|
394
502
|
this.tableCache.clear();
|
|
503
|
+
this.routingProxyCache.clear();
|
|
504
|
+
for (const cache of this.replicaTableCaches)
|
|
505
|
+
cache.clear();
|
|
395
506
|
}
|
|
396
507
|
// -------------------------------------------------------------------------
|
|
397
508
|
// Event emitter — subscribe to query lifecycle events
|
|
@@ -430,17 +541,110 @@ class TurbineClient {
|
|
|
430
541
|
/**
|
|
431
542
|
* Get a QueryInterface for a table.
|
|
432
543
|
* Results are cached — calling `table('users')` twice returns the same instance.
|
|
544
|
+
*
|
|
545
|
+
* When read replicas are configured, this returns a thin routing proxy: the
|
|
546
|
+
* read-only operations in {@link READ_OPERATIONS} are dispatched to a
|
|
547
|
+
* round-robin replica-bound QueryInterface (so an entire read — base rows and
|
|
548
|
+
* any batched sub-queries — runs against a single consistent replica), while
|
|
549
|
+
* writes and every other member fall through to the primary-bound instance.
|
|
550
|
+
* With no replicas the original single-pool instance is returned directly.
|
|
433
551
|
*/
|
|
434
552
|
table(name) {
|
|
553
|
+
if (this.replicaPools.length === 0) {
|
|
554
|
+
return this.primaryTableQI(name);
|
|
555
|
+
}
|
|
556
|
+
let proxy = this.routingProxyCache.get(name);
|
|
557
|
+
if (!proxy) {
|
|
558
|
+
proxy = this.createRoutingAccessor(name);
|
|
559
|
+
this.routingProxyCache.set(name, proxy);
|
|
560
|
+
}
|
|
561
|
+
return proxy;
|
|
562
|
+
}
|
|
563
|
+
/** Get (and cache) the primary-pool-bound QueryInterface for a table. */
|
|
564
|
+
primaryTableQI(name) {
|
|
435
565
|
let qi = this.tableCache.get(name);
|
|
436
566
|
if (!qi) {
|
|
437
|
-
qi = this.
|
|
438
|
-
? this.queryOptions.queryInterfaceFactory(this.pool, name, this.schema, this.middlewares, this.queryOptions)
|
|
439
|
-
: new index_js_1.QueryInterface(this.pool, name, this.schema, this.middlewares, this.queryOptions);
|
|
567
|
+
qi = this.buildTableQI(this.pool, name);
|
|
440
568
|
this.tableCache.set(name, qi);
|
|
441
569
|
}
|
|
442
570
|
return qi;
|
|
443
571
|
}
|
|
572
|
+
/**
|
|
573
|
+
* Advance the round-robin cursor and return the QueryInterface bound to the
|
|
574
|
+
* selected replica pool for `name` (cached per replica).
|
|
575
|
+
*/
|
|
576
|
+
nextReplicaTableQI(name) {
|
|
577
|
+
const index = this.replicaCursor % this.replicaPools.length;
|
|
578
|
+
// Reset before overflow so the cursor never grows unbounded.
|
|
579
|
+
this.replicaCursor = this.replicaCursor + 1 >= Number.MAX_SAFE_INTEGER ? 0 : this.replicaCursor + 1;
|
|
580
|
+
// index is always in-bounds (`% length`); the pools/cache entries exist.
|
|
581
|
+
const cache = this.replicaTableCaches[index];
|
|
582
|
+
const pool = this.replicaPools[index];
|
|
583
|
+
let qi = cache.get(name);
|
|
584
|
+
if (!qi) {
|
|
585
|
+
qi = this.buildTableQI(pool, name);
|
|
586
|
+
cache.set(name, qi);
|
|
587
|
+
}
|
|
588
|
+
return qi;
|
|
589
|
+
}
|
|
590
|
+
/** Construct a QueryInterface bound to `pool` (honoring any injected factory). */
|
|
591
|
+
buildTableQI(pool, name) {
|
|
592
|
+
const asPgPool = pool;
|
|
593
|
+
return this.queryOptions?.queryInterfaceFactory
|
|
594
|
+
? this.queryOptions.queryInterfaceFactory(asPgPool, name, this.schema, this.middlewares, this.queryOptions)
|
|
595
|
+
: new index_js_1.QueryInterface(asPgPool, name, this.schema, this.middlewares, this.queryOptions);
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* Build the read/write routing proxy for a table. The proxy targets the
|
|
599
|
+
* primary QueryInterface (so writes, `build*`, and every non-read member work
|
|
600
|
+
* unchanged); read operations are intercepted and dispatched to a replica.
|
|
601
|
+
*/
|
|
602
|
+
createRoutingAccessor(name) {
|
|
603
|
+
const primaryQI = this.primaryTableQI(name);
|
|
604
|
+
const client = this;
|
|
605
|
+
return new Proxy(primaryQI, {
|
|
606
|
+
get(target, prop, receiver) {
|
|
607
|
+
if (typeof prop === 'string' && READ_OPERATIONS.has(prop)) {
|
|
608
|
+
// Pick the replica at CALL time so round-robin advances per operation.
|
|
609
|
+
return (...args) => {
|
|
610
|
+
const replicaQI = client.nextReplicaTableQI(name);
|
|
611
|
+
const method = replicaQI[prop];
|
|
612
|
+
if (typeof method !== 'function') {
|
|
613
|
+
return Reflect.get(target, prop, receiver);
|
|
614
|
+
}
|
|
615
|
+
return method.apply(replicaQI, args);
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
return Reflect.get(target, prop, receiver);
|
|
619
|
+
},
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* Return a view of this client that pins EVERY operation — reads included —
|
|
624
|
+
* to the primary pool, bypassing replica routing. Use it to read your own
|
|
625
|
+
* write without replication lag, or for any read that must see the latest
|
|
626
|
+
* committed data.
|
|
627
|
+
*
|
|
628
|
+
* The view shares the primary pool, schema, dialect, query options, and
|
|
629
|
+
* middleware; it owns nothing, so its `disconnect()` is a no-op. When no
|
|
630
|
+
* replicas are configured this simply returns the client itself (already
|
|
631
|
+
* primary-only). The view is cached — repeated calls return the same instance.
|
|
632
|
+
*
|
|
633
|
+
* @example
|
|
634
|
+
* ```ts
|
|
635
|
+
* await db.users.create({ data: { email: 'a@b.com' } });
|
|
636
|
+
* // Read-after-write: guaranteed to see the row just inserted.
|
|
637
|
+
* const user = await db.$primary().users.findFirst({ where: { email: 'a@b.com' } });
|
|
638
|
+
* ```
|
|
639
|
+
*/
|
|
640
|
+
$primary() {
|
|
641
|
+
if (this.replicaPools.length === 0)
|
|
642
|
+
return this;
|
|
643
|
+
if (!this.primaryView) {
|
|
644
|
+
this.primaryView = new TurbineClient({ [PRIMARY_VIEW]: { parent: this } }, this.schema);
|
|
645
|
+
}
|
|
646
|
+
return this.primaryView;
|
|
647
|
+
}
|
|
444
648
|
// -------------------------------------------------------------------------
|
|
445
649
|
// Pipeline — batch multiple queries into one round-trip
|
|
446
650
|
// -------------------------------------------------------------------------
|
|
@@ -581,29 +785,13 @@ class TurbineClient {
|
|
|
581
785
|
client.release();
|
|
582
786
|
}
|
|
583
787
|
}
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
* Supports nested transactions via SAVEPOINTs, timeouts, and isolation levels.
|
|
592
|
-
*
|
|
593
|
-
* @example
|
|
594
|
-
* ```ts
|
|
595
|
-
* await db.$transaction(async (tx) => {
|
|
596
|
-
* const user = await tx.users.create({ data: { email: 'a@b.com' } });
|
|
597
|
-
* await tx.posts.create({ data: { userId: user.id, title: 'Hello' } });
|
|
598
|
-
* });
|
|
599
|
-
*
|
|
600
|
-
* // With options:
|
|
601
|
-
* await db.$transaction(async (tx) => {
|
|
602
|
-
* // ...
|
|
603
|
-
* }, { timeout: 5000, isolationLevel: 'Serializable' });
|
|
604
|
-
* ```
|
|
605
|
-
*/
|
|
606
|
-
async $transaction(fn, options) {
|
|
788
|
+
async $transaction(fnOrQueries, options) {
|
|
789
|
+
// Batch overload: an array of DeferredQuery objects runs atomically inside
|
|
790
|
+
// one BEGIN…COMMIT, reusing the raw transaction machinery below.
|
|
791
|
+
if (Array.isArray(fnOrQueries)) {
|
|
792
|
+
return this.transactionBatch(fnOrQueries);
|
|
793
|
+
}
|
|
794
|
+
const fn = fnOrQueries;
|
|
607
795
|
const client = await this.pool.connect();
|
|
608
796
|
const timeout = options?.timeout;
|
|
609
797
|
/**
|
|
@@ -719,6 +907,38 @@ class TurbineClient {
|
|
|
719
907
|
releaseOnce();
|
|
720
908
|
}
|
|
721
909
|
}
|
|
910
|
+
/**
|
|
911
|
+
* Execute a batch of {@link DeferredQuery} objects atomically inside one
|
|
912
|
+
* transaction. Backs the `$transaction([...])` array overload. Reuses the raw
|
|
913
|
+
* {@link transaction} machinery (BEGIN/COMMIT/ROLLBACK + connection release);
|
|
914
|
+
* queries run sequentially on the single transaction connection and each
|
|
915
|
+
* result is passed through its query's `transform`.
|
|
916
|
+
*/
|
|
917
|
+
async transactionBatch(queries) {
|
|
918
|
+
if (queries.length === 0) {
|
|
919
|
+
return [];
|
|
920
|
+
}
|
|
921
|
+
return this.transaction(async (client) => {
|
|
922
|
+
const results = [];
|
|
923
|
+
for (const dq of queries) {
|
|
924
|
+
let raw;
|
|
925
|
+
try {
|
|
926
|
+
// Non-RETURNING engines (resultStrategy 'reselect', e.g. MySQL)
|
|
927
|
+
// attach a reselect plan that runs the write plus a follow-up SELECT;
|
|
928
|
+
// running dq.sql alone would transform a row-less write result.
|
|
929
|
+
raw =
|
|
930
|
+
this.dialect.resultStrategy === 'reselect' && dq.reselect
|
|
931
|
+
? await dq.reselect((sql, params) => client.query(sql, params))
|
|
932
|
+
: await client.query(dq.sql, dq.params);
|
|
933
|
+
}
|
|
934
|
+
catch (err) {
|
|
935
|
+
throw (0, errors_js_1.wrapPgError)(err);
|
|
936
|
+
}
|
|
937
|
+
results.push(dq.transform(raw));
|
|
938
|
+
}
|
|
939
|
+
return results;
|
|
940
|
+
});
|
|
941
|
+
}
|
|
722
942
|
/**
|
|
723
943
|
* Convenience wrapper around `$transaction` for the multi-tenant / RLS case:
|
|
724
944
|
* runs `fn` inside a transaction with the given session GUCs applied via
|
|
@@ -875,9 +1095,22 @@ class TurbineClient {
|
|
|
875
1095
|
}
|
|
876
1096
|
this.activeSubscriptions.clear();
|
|
877
1097
|
}
|
|
1098
|
+
// Close owned (string-configured) replica pools regardless of whether the
|
|
1099
|
+
// primary is owned — external replica pools are left untouched (caller owns
|
|
1100
|
+
// their lifecycle), same contract as an external primary.
|
|
1101
|
+
for (const replicaPool of this.ownedReplicaPools) {
|
|
1102
|
+
try {
|
|
1103
|
+
await replicaPool.end();
|
|
1104
|
+
}
|
|
1105
|
+
catch (err) {
|
|
1106
|
+
if (this.logging) {
|
|
1107
|
+
console.error('[turbine] Error closing replica pool:', err.message);
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
878
1111
|
if (!this.ownsPool) {
|
|
879
1112
|
if (this.logging) {
|
|
880
|
-
console.log('[turbine] disconnect() skipped — external pool is not owned by Turbine');
|
|
1113
|
+
console.log('[turbine] disconnect() skipped — external primary pool is not owned by Turbine');
|
|
881
1114
|
}
|
|
882
1115
|
return;
|
|
883
1116
|
}
|
package/dist/cjs/dialect.js
CHANGED
|
@@ -48,6 +48,7 @@ exports.postgresDialect = {
|
|
|
48
48
|
name: 'postgresql',
|
|
49
49
|
resultStrategy: 'returning',
|
|
50
50
|
supportsReturning: true,
|
|
51
|
+
supportsUpsertUpdateWhere: true,
|
|
51
52
|
supportsILike: true,
|
|
52
53
|
jsonPathSupport: 'native',
|
|
53
54
|
emptyJsonArrayLiteral: "'[]'::json",
|
|
@@ -121,6 +122,7 @@ exports.postgresDialect = {
|
|
|
121
122
|
buildUpsertStatement(input) {
|
|
122
123
|
return (`INSERT INTO ${input.table} (${input.insertColumns.join(', ')}) VALUES (${input.valuePlaceholders.join(', ')})` +
|
|
123
124
|
` ON CONFLICT (${input.conflictColumns.join(', ')}) DO UPDATE SET ${input.updateSetClauses.join(', ')}` +
|
|
125
|
+
(input.updateWhere ? ` WHERE ${input.updateWhere}` : '') +
|
|
124
126
|
this.buildReturningClause(input.returning));
|
|
125
127
|
},
|
|
126
128
|
buildInsensitiveLike(column, paramRef) {
|