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/client.js
CHANGED
|
@@ -68,6 +68,29 @@ const ISOLATION_LEVELS = {
|
|
|
68
68
|
* rejecting loudly before it reaches the database.
|
|
69
69
|
*/
|
|
70
70
|
const GUC_NAME_REGEX = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$/;
|
|
71
|
+
/**
|
|
72
|
+
* The read-only `QueryInterface` operations that a read-replica setup may route
|
|
73
|
+
* to a replica pool. Every other method (all writes, plus internals) stays on
|
|
74
|
+
* the primary. Kept as a Set so the routing proxy's `get` trap is O(1).
|
|
75
|
+
*/
|
|
76
|
+
const READ_OPERATIONS = new Set([
|
|
77
|
+
'findMany',
|
|
78
|
+
'findFirst',
|
|
79
|
+
'findUnique',
|
|
80
|
+
'findFirstOrThrow',
|
|
81
|
+
'findUniqueOrThrow',
|
|
82
|
+
'count',
|
|
83
|
+
'aggregate',
|
|
84
|
+
'groupBy',
|
|
85
|
+
'findManyStream',
|
|
86
|
+
]);
|
|
87
|
+
/**
|
|
88
|
+
* Internal marker on the config object that tells the `TurbineClient`
|
|
89
|
+
* constructor to build a lightweight "primary-only" view sharing an existing
|
|
90
|
+
* client's primary pool, dialect, query options, and middleware — instead of
|
|
91
|
+
* creating a fresh pool. Produced solely by `$primary()`; never public.
|
|
92
|
+
*/
|
|
93
|
+
const PRIMARY_VIEW = Symbol('turbine.primaryView');
|
|
71
94
|
// ---------------------------------------------------------------------------
|
|
72
95
|
// TransactionClient — provides typed table accessors within a transaction
|
|
73
96
|
// ---------------------------------------------------------------------------
|
|
@@ -210,7 +233,55 @@ export class TurbineClient {
|
|
|
210
233
|
ownsPool = true;
|
|
211
234
|
/** Active LISTEN subscriptions — torn down on disconnect() so it never hangs */
|
|
212
235
|
activeSubscriptions = new Set();
|
|
236
|
+
/**
|
|
237
|
+
* Read-replica pools in round-robin order. Empty when no replicas are
|
|
238
|
+
* configured, in which case `table()` takes the original single-pool path.
|
|
239
|
+
*/
|
|
240
|
+
replicaPools;
|
|
241
|
+
/**
|
|
242
|
+
* The subset of {@link replicaPools} that Turbine created from connection
|
|
243
|
+
* strings and must close on `disconnect()`. External replica pools are not
|
|
244
|
+
* listed here (caller owns their lifecycle).
|
|
245
|
+
*/
|
|
246
|
+
ownedReplicaPools;
|
|
247
|
+
/** Rotating index for round-robin replica selection (advances per read op). */
|
|
248
|
+
replicaCursor = 0;
|
|
249
|
+
/** Per-replica `table → QueryInterface` caches, indexed like {@link replicaPools}. */
|
|
250
|
+
replicaTableCaches;
|
|
251
|
+
/** Cache of per-table routing proxies (only used when replicas are present). */
|
|
252
|
+
routingProxyCache = new Map();
|
|
253
|
+
/** Lazily-built, cached primary-only view returned by {@link $primary}. */
|
|
254
|
+
primaryView;
|
|
213
255
|
constructor(config = {}, schema) {
|
|
256
|
+
// Primary-only view: $primary() constructs this to share the parent's
|
|
257
|
+
// primary pool + derived state instead of creating a fresh pool. It owns
|
|
258
|
+
// no pool and no replicas, so every operation (reads included) runs on the
|
|
259
|
+
// primary and disconnect() is a no-op on the shared pool.
|
|
260
|
+
const seed = config[PRIMARY_VIEW];
|
|
261
|
+
if (seed) {
|
|
262
|
+
const parent = seed.parent;
|
|
263
|
+
this.schema = schema;
|
|
264
|
+
this.logging = parent.logging;
|
|
265
|
+
this.dialect = parent.dialect;
|
|
266
|
+
this.errorMessagesSafe = parent.errorMessagesSafe;
|
|
267
|
+
this.queryOptions = parent.queryOptions;
|
|
268
|
+
this.middlewares = parent.middlewares; // shared reference: $use on parent flows through
|
|
269
|
+
this.pool = parent.pool;
|
|
270
|
+
this.ownsPool = false;
|
|
271
|
+
this.replicaPools = [];
|
|
272
|
+
this.ownedReplicaPools = [];
|
|
273
|
+
this.replicaTableCaches = [];
|
|
274
|
+
for (const tableName of Object.keys(schema.tables)) {
|
|
275
|
+
const camelName = tableName.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
276
|
+
if (!(camelName in this)) {
|
|
277
|
+
Object.defineProperty(this, camelName, {
|
|
278
|
+
get: () => this.table(tableName),
|
|
279
|
+
enumerable: true,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
214
285
|
// Constructing without schema metadata previously crashed deep in the
|
|
215
286
|
// constructor with an opaque "Cannot read properties of undefined
|
|
216
287
|
// (reading 'tables')". Fail fast with an actionable message instead.
|
|
@@ -234,11 +305,16 @@ export class TurbineClient {
|
|
|
234
305
|
* of returning a string is correct and matches the generated TypeScript type
|
|
235
306
|
* (numeric → string). Users who want number can cast explicitly in SQL.
|
|
236
307
|
*/
|
|
237
|
-
// Only register the int8 parser when
|
|
238
|
-
// pools (Neon HTTP, Vercel Postgres) may ship their own pg-types
|
|
239
|
-
// and rely on their own parser configuration —
|
|
240
|
-
//
|
|
241
|
-
|
|
308
|
+
// Only register the int8 parser when the PRIMARY pool is Turbine-owned.
|
|
309
|
+
// External pools (Neon HTTP, Vercel Postgres) may ship their own pg-types
|
|
310
|
+
// fork and rely on their own parser configuration — registration is
|
|
311
|
+
// process-global, so flipping it because a string replica exists alongside
|
|
312
|
+
// an external primary would silently change the external primary's parsing
|
|
313
|
+
// too. String replicas configured next to an external primary therefore
|
|
314
|
+
// inherit the caller's parser configuration (documented). Registration is
|
|
315
|
+
// constructor-gated by the static flags, so it happens at most once.
|
|
316
|
+
const ownsAnyPool = !config.pool;
|
|
317
|
+
if (ownsAnyPool && !TurbineClient.int8ParserRegistered) {
|
|
242
318
|
pg.types.setTypeParser(20, (val) => {
|
|
243
319
|
const n = Number(val);
|
|
244
320
|
return Number.isSafeInteger(n) ? n : val;
|
|
@@ -251,7 +327,7 @@ export class TurbineClient {
|
|
|
251
327
|
// ORM convention (Prisma, Rails, Django) — and the only interpretation
|
|
252
328
|
// that round-trips what Postgres stores — is UTC. Same ownership rule as
|
|
253
329
|
// the int8 parser: never mutate parser state on external pools.
|
|
254
|
-
if (
|
|
330
|
+
if (ownsAnyPool && config.utcTimestamps !== false && !TurbineClient.utcTimestampParserRegistered) {
|
|
255
331
|
pg.types.setTypeParser(1114, (val) => new Date(`${val.replace(' ', 'T')}Z`));
|
|
256
332
|
TurbineClient.utcTimestampParserRegistered = true;
|
|
257
333
|
}
|
|
@@ -267,6 +343,7 @@ export class TurbineClient {
|
|
|
267
343
|
utcTimestamps: config.utcTimestamps,
|
|
268
344
|
relationLoadStrategy: config.relationLoadStrategy,
|
|
269
345
|
jsonEncoding: config.jsonEncoding,
|
|
346
|
+
globalFilters: config.globalFilters,
|
|
270
347
|
preparedStatements: envDisablePrepared ? false : (config.preparedStatements ?? !config.pool),
|
|
271
348
|
sqlCache: config.sqlCache ?? true,
|
|
272
349
|
dialect: config.dialect,
|
|
@@ -331,6 +408,34 @@ export class TurbineClient {
|
|
|
331
408
|
console.log(`[turbine] Pool created — max ${poolConfig.max} connections, ${Object.keys(schema.tables).length} tables`);
|
|
332
409
|
}
|
|
333
410
|
}
|
|
411
|
+
// Build read-replica pools (if any). String entries become owned pg.Pools
|
|
412
|
+
// sharing the primary's tuning knobs; PgCompatPool entries are external and
|
|
413
|
+
// used as-is. Replica selection is round-robin in this array order.
|
|
414
|
+
this.replicaPools = [];
|
|
415
|
+
this.ownedReplicaPools = [];
|
|
416
|
+
for (const replica of config.replicas ?? []) {
|
|
417
|
+
if (typeof replica === 'string') {
|
|
418
|
+
const replicaPool = new pg.Pool({
|
|
419
|
+
connectionString: replica,
|
|
420
|
+
max: config.poolSize ?? 10,
|
|
421
|
+
idleTimeoutMillis: config.idleTimeoutMs ?? 30_000,
|
|
422
|
+
connectionTimeoutMillis: config.connectionTimeoutMs ?? 5_000,
|
|
423
|
+
...(config.ssl !== undefined ? { ssl: config.ssl } : {}),
|
|
424
|
+
});
|
|
425
|
+
replicaPool.on('error', (err) => {
|
|
426
|
+
console.error('[turbine] Unexpected replica pool error:', err.message);
|
|
427
|
+
});
|
|
428
|
+
this.replicaPools.push(replicaPool);
|
|
429
|
+
this.ownedReplicaPools.push(replicaPool);
|
|
430
|
+
}
|
|
431
|
+
else {
|
|
432
|
+
this.replicaPools.push(replica);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
this.replicaTableCaches = this.replicaPools.map(() => new Map());
|
|
436
|
+
if (this.logging && this.replicaPools.length > 0) {
|
|
437
|
+
console.log(`[turbine] ${this.replicaPools.length} read replica(s) configured (${this.ownedReplicaPools.length} owned)`);
|
|
438
|
+
}
|
|
334
439
|
// Auto-create typed table accessors for all tables in the schema
|
|
335
440
|
for (const tableName of Object.keys(schema.tables)) {
|
|
336
441
|
const camelName = tableName.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
@@ -382,8 +487,14 @@ export class TurbineClient {
|
|
|
382
487
|
*/
|
|
383
488
|
$use(middleware) {
|
|
384
489
|
this.middlewares.push(middleware);
|
|
385
|
-
// Clear table
|
|
490
|
+
// Clear table caches so new QueryInterfaces pick up the middleware. Covers
|
|
491
|
+
// the primary cache plus, when replicas are configured, the routing proxies
|
|
492
|
+
// and per-replica caches. The primary-view (if built) shares the middleware
|
|
493
|
+
// array by reference, so its QueryInterfaces observe the new middleware too.
|
|
386
494
|
this.tableCache.clear();
|
|
495
|
+
this.routingProxyCache.clear();
|
|
496
|
+
for (const cache of this.replicaTableCaches)
|
|
497
|
+
cache.clear();
|
|
387
498
|
}
|
|
388
499
|
// -------------------------------------------------------------------------
|
|
389
500
|
// Event emitter — subscribe to query lifecycle events
|
|
@@ -422,17 +533,110 @@ export class TurbineClient {
|
|
|
422
533
|
/**
|
|
423
534
|
* Get a QueryInterface for a table.
|
|
424
535
|
* Results are cached — calling `table('users')` twice returns the same instance.
|
|
536
|
+
*
|
|
537
|
+
* When read replicas are configured, this returns a thin routing proxy: the
|
|
538
|
+
* read-only operations in {@link READ_OPERATIONS} are dispatched to a
|
|
539
|
+
* round-robin replica-bound QueryInterface (so an entire read — base rows and
|
|
540
|
+
* any batched sub-queries — runs against a single consistent replica), while
|
|
541
|
+
* writes and every other member fall through to the primary-bound instance.
|
|
542
|
+
* With no replicas the original single-pool instance is returned directly.
|
|
425
543
|
*/
|
|
426
544
|
table(name) {
|
|
545
|
+
if (this.replicaPools.length === 0) {
|
|
546
|
+
return this.primaryTableQI(name);
|
|
547
|
+
}
|
|
548
|
+
let proxy = this.routingProxyCache.get(name);
|
|
549
|
+
if (!proxy) {
|
|
550
|
+
proxy = this.createRoutingAccessor(name);
|
|
551
|
+
this.routingProxyCache.set(name, proxy);
|
|
552
|
+
}
|
|
553
|
+
return proxy;
|
|
554
|
+
}
|
|
555
|
+
/** Get (and cache) the primary-pool-bound QueryInterface for a table. */
|
|
556
|
+
primaryTableQI(name) {
|
|
427
557
|
let qi = this.tableCache.get(name);
|
|
428
558
|
if (!qi) {
|
|
429
|
-
qi = this.
|
|
430
|
-
? this.queryOptions.queryInterfaceFactory(this.pool, name, this.schema, this.middlewares, this.queryOptions)
|
|
431
|
-
: new QueryInterface(this.pool, name, this.schema, this.middlewares, this.queryOptions);
|
|
559
|
+
qi = this.buildTableQI(this.pool, name);
|
|
432
560
|
this.tableCache.set(name, qi);
|
|
433
561
|
}
|
|
434
562
|
return qi;
|
|
435
563
|
}
|
|
564
|
+
/**
|
|
565
|
+
* Advance the round-robin cursor and return the QueryInterface bound to the
|
|
566
|
+
* selected replica pool for `name` (cached per replica).
|
|
567
|
+
*/
|
|
568
|
+
nextReplicaTableQI(name) {
|
|
569
|
+
const index = this.replicaCursor % this.replicaPools.length;
|
|
570
|
+
// Reset before overflow so the cursor never grows unbounded.
|
|
571
|
+
this.replicaCursor = this.replicaCursor + 1 >= Number.MAX_SAFE_INTEGER ? 0 : this.replicaCursor + 1;
|
|
572
|
+
// index is always in-bounds (`% length`); the pools/cache entries exist.
|
|
573
|
+
const cache = this.replicaTableCaches[index];
|
|
574
|
+
const pool = this.replicaPools[index];
|
|
575
|
+
let qi = cache.get(name);
|
|
576
|
+
if (!qi) {
|
|
577
|
+
qi = this.buildTableQI(pool, name);
|
|
578
|
+
cache.set(name, qi);
|
|
579
|
+
}
|
|
580
|
+
return qi;
|
|
581
|
+
}
|
|
582
|
+
/** Construct a QueryInterface bound to `pool` (honoring any injected factory). */
|
|
583
|
+
buildTableQI(pool, name) {
|
|
584
|
+
const asPgPool = pool;
|
|
585
|
+
return this.queryOptions?.queryInterfaceFactory
|
|
586
|
+
? this.queryOptions.queryInterfaceFactory(asPgPool, name, this.schema, this.middlewares, this.queryOptions)
|
|
587
|
+
: new QueryInterface(asPgPool, name, this.schema, this.middlewares, this.queryOptions);
|
|
588
|
+
}
|
|
589
|
+
/**
|
|
590
|
+
* Build the read/write routing proxy for a table. The proxy targets the
|
|
591
|
+
* primary QueryInterface (so writes, `build*`, and every non-read member work
|
|
592
|
+
* unchanged); read operations are intercepted and dispatched to a replica.
|
|
593
|
+
*/
|
|
594
|
+
createRoutingAccessor(name) {
|
|
595
|
+
const primaryQI = this.primaryTableQI(name);
|
|
596
|
+
const client = this;
|
|
597
|
+
return new Proxy(primaryQI, {
|
|
598
|
+
get(target, prop, receiver) {
|
|
599
|
+
if (typeof prop === 'string' && READ_OPERATIONS.has(prop)) {
|
|
600
|
+
// Pick the replica at CALL time so round-robin advances per operation.
|
|
601
|
+
return (...args) => {
|
|
602
|
+
const replicaQI = client.nextReplicaTableQI(name);
|
|
603
|
+
const method = replicaQI[prop];
|
|
604
|
+
if (typeof method !== 'function') {
|
|
605
|
+
return Reflect.get(target, prop, receiver);
|
|
606
|
+
}
|
|
607
|
+
return method.apply(replicaQI, args);
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
return Reflect.get(target, prop, receiver);
|
|
611
|
+
},
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* Return a view of this client that pins EVERY operation — reads included —
|
|
616
|
+
* to the primary pool, bypassing replica routing. Use it to read your own
|
|
617
|
+
* write without replication lag, or for any read that must see the latest
|
|
618
|
+
* committed data.
|
|
619
|
+
*
|
|
620
|
+
* The view shares the primary pool, schema, dialect, query options, and
|
|
621
|
+
* middleware; it owns nothing, so its `disconnect()` is a no-op. When no
|
|
622
|
+
* replicas are configured this simply returns the client itself (already
|
|
623
|
+
* primary-only). The view is cached — repeated calls return the same instance.
|
|
624
|
+
*
|
|
625
|
+
* @example
|
|
626
|
+
* ```ts
|
|
627
|
+
* await db.users.create({ data: { email: 'a@b.com' } });
|
|
628
|
+
* // Read-after-write: guaranteed to see the row just inserted.
|
|
629
|
+
* const user = await db.$primary().users.findFirst({ where: { email: 'a@b.com' } });
|
|
630
|
+
* ```
|
|
631
|
+
*/
|
|
632
|
+
$primary() {
|
|
633
|
+
if (this.replicaPools.length === 0)
|
|
634
|
+
return this;
|
|
635
|
+
if (!this.primaryView) {
|
|
636
|
+
this.primaryView = new TurbineClient({ [PRIMARY_VIEW]: { parent: this } }, this.schema);
|
|
637
|
+
}
|
|
638
|
+
return this.primaryView;
|
|
639
|
+
}
|
|
436
640
|
// -------------------------------------------------------------------------
|
|
437
641
|
// Pipeline — batch multiple queries into one round-trip
|
|
438
642
|
// -------------------------------------------------------------------------
|
|
@@ -573,29 +777,13 @@ export class TurbineClient {
|
|
|
573
777
|
client.release();
|
|
574
778
|
}
|
|
575
779
|
}
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
* Supports nested transactions via SAVEPOINTs, timeouts, and isolation levels.
|
|
584
|
-
*
|
|
585
|
-
* @example
|
|
586
|
-
* ```ts
|
|
587
|
-
* await db.$transaction(async (tx) => {
|
|
588
|
-
* const user = await tx.users.create({ data: { email: 'a@b.com' } });
|
|
589
|
-
* await tx.posts.create({ data: { userId: user.id, title: 'Hello' } });
|
|
590
|
-
* });
|
|
591
|
-
*
|
|
592
|
-
* // With options:
|
|
593
|
-
* await db.$transaction(async (tx) => {
|
|
594
|
-
* // ...
|
|
595
|
-
* }, { timeout: 5000, isolationLevel: 'Serializable' });
|
|
596
|
-
* ```
|
|
597
|
-
*/
|
|
598
|
-
async $transaction(fn, options) {
|
|
780
|
+
async $transaction(fnOrQueries, options) {
|
|
781
|
+
// Batch overload: an array of DeferredQuery objects runs atomically inside
|
|
782
|
+
// one BEGIN…COMMIT, reusing the raw transaction machinery below.
|
|
783
|
+
if (Array.isArray(fnOrQueries)) {
|
|
784
|
+
return this.transactionBatch(fnOrQueries);
|
|
785
|
+
}
|
|
786
|
+
const fn = fnOrQueries;
|
|
599
787
|
const client = await this.pool.connect();
|
|
600
788
|
const timeout = options?.timeout;
|
|
601
789
|
/**
|
|
@@ -711,6 +899,38 @@ export class TurbineClient {
|
|
|
711
899
|
releaseOnce();
|
|
712
900
|
}
|
|
713
901
|
}
|
|
902
|
+
/**
|
|
903
|
+
* Execute a batch of {@link DeferredQuery} objects atomically inside one
|
|
904
|
+
* transaction. Backs the `$transaction([...])` array overload. Reuses the raw
|
|
905
|
+
* {@link transaction} machinery (BEGIN/COMMIT/ROLLBACK + connection release);
|
|
906
|
+
* queries run sequentially on the single transaction connection and each
|
|
907
|
+
* result is passed through its query's `transform`.
|
|
908
|
+
*/
|
|
909
|
+
async transactionBatch(queries) {
|
|
910
|
+
if (queries.length === 0) {
|
|
911
|
+
return [];
|
|
912
|
+
}
|
|
913
|
+
return this.transaction(async (client) => {
|
|
914
|
+
const results = [];
|
|
915
|
+
for (const dq of queries) {
|
|
916
|
+
let raw;
|
|
917
|
+
try {
|
|
918
|
+
// Non-RETURNING engines (resultStrategy 'reselect', e.g. MySQL)
|
|
919
|
+
// attach a reselect plan that runs the write plus a follow-up SELECT;
|
|
920
|
+
// running dq.sql alone would transform a row-less write result.
|
|
921
|
+
raw =
|
|
922
|
+
this.dialect.resultStrategy === 'reselect' && dq.reselect
|
|
923
|
+
? await dq.reselect((sql, params) => client.query(sql, params))
|
|
924
|
+
: await client.query(dq.sql, dq.params);
|
|
925
|
+
}
|
|
926
|
+
catch (err) {
|
|
927
|
+
throw wrapPgError(err);
|
|
928
|
+
}
|
|
929
|
+
results.push(dq.transform(raw));
|
|
930
|
+
}
|
|
931
|
+
return results;
|
|
932
|
+
});
|
|
933
|
+
}
|
|
714
934
|
/**
|
|
715
935
|
* Convenience wrapper around `$transaction` for the multi-tenant / RLS case:
|
|
716
936
|
* runs `fn` inside a transaction with the given session GUCs applied via
|
|
@@ -867,9 +1087,22 @@ export class TurbineClient {
|
|
|
867
1087
|
}
|
|
868
1088
|
this.activeSubscriptions.clear();
|
|
869
1089
|
}
|
|
1090
|
+
// Close owned (string-configured) replica pools regardless of whether the
|
|
1091
|
+
// primary is owned — external replica pools are left untouched (caller owns
|
|
1092
|
+
// their lifecycle), same contract as an external primary.
|
|
1093
|
+
for (const replicaPool of this.ownedReplicaPools) {
|
|
1094
|
+
try {
|
|
1095
|
+
await replicaPool.end();
|
|
1096
|
+
}
|
|
1097
|
+
catch (err) {
|
|
1098
|
+
if (this.logging) {
|
|
1099
|
+
console.error('[turbine] Error closing replica pool:', err.message);
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
870
1103
|
if (!this.ownsPool) {
|
|
871
1104
|
if (this.logging) {
|
|
872
|
-
console.log('[turbine] disconnect() skipped — external pool is not owned by Turbine');
|
|
1105
|
+
console.log('[turbine] disconnect() skipped — external primary pool is not owned by Turbine');
|
|
873
1106
|
}
|
|
874
1107
|
return;
|
|
875
1108
|
}
|
package/dist/dialect.d.ts
CHANGED
|
@@ -47,6 +47,14 @@ export interface UpsertStatementInput {
|
|
|
47
47
|
conflictColumns: string[];
|
|
48
48
|
/** SQL-ready update SET clauses. */
|
|
49
49
|
updateSetClauses: string[];
|
|
50
|
+
/**
|
|
51
|
+
* Optional SQL-ready predicate (no `WHERE` keyword) restricting the
|
|
52
|
+
* conflict-UPDATE to matching rows — used by global filters (soft-delete /
|
|
53
|
+
* multi-tenancy) so an upsert never resurrects/steals a row outside the
|
|
54
|
+
* filter. Only honored by dialects that set
|
|
55
|
+
* {@link Dialect.supportsUpsertUpdateWhere}; others must never receive it.
|
|
56
|
+
*/
|
|
57
|
+
updateWhere?: string;
|
|
50
58
|
/** Optional SQL-ready RETURNING selection. */
|
|
51
59
|
returning?: string;
|
|
52
60
|
}
|
|
@@ -270,6 +278,15 @@ export interface Dialect {
|
|
|
270
278
|
wrapJsonSubresult(subquery: string, fallback: string): string;
|
|
271
279
|
/** Whether INSERT/UPDATE/DELETE support RETURNING rows. */
|
|
272
280
|
readonly supportsReturning: boolean;
|
|
281
|
+
/**
|
|
282
|
+
* Whether {@link buildUpsertStatement} honors {@link UpsertStatementInput.updateWhere}
|
|
283
|
+
* (a predicate on the conflict-UPDATE, e.g. Postgres `ON CONFLICT … DO UPDATE
|
|
284
|
+
* SET … WHERE …`). Global filters only push a conflict-UPDATE predicate when
|
|
285
|
+
* this is true, so engines whose upsert cannot express one (MySQL
|
|
286
|
+
* `ON DUPLICATE KEY UPDATE`) never receive an orphaned parameter. Optional —
|
|
287
|
+
* absent is treated as `false`.
|
|
288
|
+
*/
|
|
289
|
+
readonly supportsUpsertUpdateWhere?: boolean;
|
|
273
290
|
/** Whether this dialect/engine supports pgvector distance ops (KNN / distance WHERE). */
|
|
274
291
|
readonly supportsVector: boolean;
|
|
275
292
|
/** Whether this dialect/engine supports LISTEN/NOTIFY realtime pub/sub. */
|
package/dist/dialect.js
CHANGED
|
@@ -12,6 +12,7 @@ export const postgresDialect = {
|
|
|
12
12
|
name: 'postgresql',
|
|
13
13
|
resultStrategy: 'returning',
|
|
14
14
|
supportsReturning: true,
|
|
15
|
+
supportsUpsertUpdateWhere: true,
|
|
15
16
|
supportsILike: true,
|
|
16
17
|
jsonPathSupport: 'native',
|
|
17
18
|
emptyJsonArrayLiteral: "'[]'::json",
|
|
@@ -85,6 +86,7 @@ export const postgresDialect = {
|
|
|
85
86
|
buildUpsertStatement(input) {
|
|
86
87
|
return (`INSERT INTO ${input.table} (${input.insertColumns.join(', ')}) VALUES (${input.valuePlaceholders.join(', ')})` +
|
|
87
88
|
` ON CONFLICT (${input.conflictColumns.join(', ')}) DO UPDATE SET ${input.updateSetClauses.join(', ')}` +
|
|
89
|
+
(input.updateWhere ? ` WHERE ${input.updateWhere}` : '') +
|
|
88
90
|
this.buildReturningClause(input.returning));
|
|
89
91
|
},
|
|
90
92
|
buildInsensitiveLike(column, paramRef) {
|
package/dist/generate.d.ts
CHANGED
|
@@ -16,6 +16,13 @@ export interface GenerateOptions {
|
|
|
16
16
|
outDir?: string;
|
|
17
17
|
/** Redact connection string from generated comments */
|
|
18
18
|
connectionString?: string;
|
|
19
|
+
/**
|
|
20
|
+
* Also emit `zod.ts` with per-table `XSchema` / `XCreateSchema` /
|
|
21
|
+
* `XUpdateSchema` Zod validators (H1). The file imports the user-side `zod`
|
|
22
|
+
* package — it is never imported by Turbine's runtime, so Zod stays out of the
|
|
23
|
+
* library's dependency graph. Default: `false`.
|
|
24
|
+
*/
|
|
25
|
+
zod?: boolean;
|
|
19
26
|
}
|
|
20
27
|
export declare function generate(options: GenerateOptions): {
|
|
21
28
|
outDir: string;
|
|
@@ -27,3 +34,13 @@ export declare function generate(options: GenerateOptions): {
|
|
|
27
34
|
* generator output without writing files to disk.
|
|
28
35
|
*/
|
|
29
36
|
export declare function generateTypes(schema: SchemaMetadata): string;
|
|
37
|
+
/**
|
|
38
|
+
* Generate the contents of `zod.ts`. Emits, per table, `XSchema` (the full
|
|
39
|
+
* row), `XCreateSchema` (PK/defaulted/nullable columns optional, STORED
|
|
40
|
+
* generated columns omitted), and `XUpdateSchema` (PK + STORED generated
|
|
41
|
+
* columns omitted, every remaining column optional). Exported so tests can pin
|
|
42
|
+
* the output without writing files.
|
|
43
|
+
*/
|
|
44
|
+
export declare function generateZod(schema: SchemaMetadata): string;
|
|
45
|
+
export declare function generateMetadata(schema: SchemaMetadata): string;
|
|
46
|
+
export declare function generateIndex(schema: SchemaMetadata): string;
|