turbine-orm 0.27.1 → 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.
Files changed (50) hide show
  1. package/README.md +17 -13
  2. package/dist/cjs/cli/config.js +20 -3
  3. package/dist/cjs/cli/index.js +273 -71
  4. package/dist/cjs/cli/mcp.js +788 -0
  5. package/dist/cjs/cli/migrate.js +95 -20
  6. package/dist/cjs/cli/studio.js +3 -2
  7. package/dist/cjs/client.js +267 -34
  8. package/dist/cjs/dialect.js +2 -0
  9. package/dist/cjs/generate.js +171 -7
  10. package/dist/cjs/index.js +4 -1
  11. package/dist/cjs/introspect.js +177 -4
  12. package/dist/cjs/query/batched-loader.js +148 -0
  13. package/dist/cjs/query/builder.js +714 -133
  14. package/dist/cjs/schema-builder.js +59 -4
  15. package/dist/cjs/schema-sql.js +315 -6
  16. package/dist/cjs/seed.js +66 -0
  17. package/dist/cli/config.d.ts +9 -2
  18. package/dist/cli/config.js +19 -3
  19. package/dist/cli/index.d.ts +52 -1
  20. package/dist/cli/index.js +272 -74
  21. package/dist/cli/mcp.d.ts +17 -0
  22. package/dist/cli/mcp.js +781 -0
  23. package/dist/cli/migrate.d.ts +37 -0
  24. package/dist/cli/migrate.js +92 -20
  25. package/dist/cli/studio.d.ts +3 -2
  26. package/dist/cli/studio.js +3 -2
  27. package/dist/client.d.ts +136 -1
  28. package/dist/client.js +267 -34
  29. package/dist/dialect.d.ts +17 -0
  30. package/dist/dialect.js +2 -0
  31. package/dist/generate.d.ts +17 -0
  32. package/dist/generate.js +171 -10
  33. package/dist/index.d.ts +4 -3
  34. package/dist/index.js +2 -0
  35. package/dist/introspect.d.ts +20 -1
  36. package/dist/introspect.js +175 -4
  37. package/dist/query/batched-loader.d.ts +29 -2
  38. package/dist/query/batched-loader.js +148 -1
  39. package/dist/query/builder.d.ts +156 -8
  40. package/dist/query/builder.js +715 -134
  41. package/dist/query/index.d.ts +1 -1
  42. package/dist/query/types.d.ts +113 -8
  43. package/dist/schema-builder.d.ts +73 -8
  44. package/dist/schema-builder.js +59 -4
  45. package/dist/schema-sql.d.ts +67 -0
  46. package/dist/schema-sql.js +310 -6
  47. package/dist/schema.d.ts +53 -0
  48. package/dist/seed.d.ts +4 -0
  49. package/dist/seed.js +63 -0
  50. package/package.json +2 -3
@@ -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
- const modified = mismatches.filter((m) => m.type === 'modified');
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
  *
@@ -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`, bound to 127.0.0.1 only.
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
- * • Bind 127.0.0.1 only (never 0.0.0.0 no LAN exposure)
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
@@ -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 we own the pg driver. External
246
- // pools (Neon HTTP, Vercel Postgres) may ship their own pg-types fork
247
- // and rely on their own parser configuration — don't mutate global state
248
- // we don't own.
249
- if (!config.pool && !TurbineClient.int8ParserRegistered) {
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 (!config.pool && config.utcTimestamps !== false && !TurbineClient.utcTimestampParserRegistered) {
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 cache so new QueryInterfaces pick up the middleware
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.queryOptions?.queryInterfaceFactory
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
- // $transaction Prisma-style typed transaction API
586
- // -------------------------------------------------------------------------
587
- /**
588
- * Execute a function within a database transaction with full typed table accessors.
589
- *
590
- * The `tx` object provides the same table accessor API as the main client.
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
  }
@@ -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) {