turbine-orm 0.53.0 → 0.54.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/dist/client.js CHANGED
@@ -27,7 +27,7 @@ import { setErrorMessageMode, TimeoutError, UnsupportedFeatureError, ValidationE
27
27
  import { ObserveEngine } from './observe.js';
28
28
  import { executePipeline, pipelineSupported } from './pipeline.js';
29
29
  import { QueryInterface, } from './query/index.js';
30
- import { closestName, quoteIdent } from './query/utils.js';
30
+ import { closestName, quoteIdent, registerUtcTemporalParsers } from './query/utils.js';
31
31
  import { shouldWarnOnce, WARN_NS } from './query/warn-registry.js';
32
32
  import { createSubscription, validateChannel, } from './realtime.js';
33
33
  import { buildTypedSql, TypedSqlQuery } from './typed-sql.js';
@@ -55,6 +55,23 @@ export async function withRetry(fn, options) {
55
55
  }
56
56
  throw lastError;
57
57
  }
58
+ /**
59
+ * The accepted `plan_cache_mode` values, as a runtime set.
60
+ *
61
+ * A GUC name/value pair cannot be a bind parameter (`SET plan_cache_mode = $1`
62
+ * is a syntax error), so the emitted statement necessarily contains a literal.
63
+ * This CLOSED SET is therefore the entire safety boundary: the statement is
64
+ * built from the matched MEMBER of this set, never from the caller's string,
65
+ * so no input a caller can supply reaches the SQL text even if it compares
66
+ * equal under some looser rule. Anything not in the set is refused at
67
+ * construction. Module-private and frozen, so the set itself is not a mutation
68
+ * target either.
69
+ */
70
+ const PLAN_CACHE_MODES = Object.freeze([
71
+ 'auto',
72
+ 'force_custom_plan',
73
+ 'force_generic_plan',
74
+ ]);
58
75
  // ---------------------------------------------------------------------------
59
76
  // Unknown-config-key diagnostics
60
77
  // ---------------------------------------------------------------------------
@@ -85,6 +102,7 @@ const TURBINE_CONFIG_KEYS = {
85
102
  defaultLimit: true,
86
103
  warnOnUnlimited: true,
87
104
  utcTimestamps: true,
105
+ planCacheMode: true,
88
106
  scopedConnect: true,
89
107
  relationLoadStrategy: true,
90
108
  stableRelationOrder: true,
@@ -423,21 +441,33 @@ export class TurbineClient {
423
441
  schema;
424
442
  static int8ParserRegistered = false;
425
443
  /**
426
- * The `utcTimestamps` value the FIRST Turbine-owned pool in this process
427
- * settled the OID 1114 read parser on, or `undefined` while no Turbine-owned
428
- * client has been constructed yet.
444
+ * The `utcTimestamps` value the FIRST TurbineClient in this process settled
445
+ * on, or `undefined` while none has been constructed yet.
429
446
  *
430
447
  * `pg.types.setTypeParser` is process-global by nature: there is one parser
431
448
  * per OID for the whole pg module, so the READ side of `utcTimestamps` cannot
432
449
  * be per client the way the WRITE side is. Recording the settled value (not
433
450
  * just "registered yes/no") is what lets the constructor detect a second
434
451
  * client asking for the opposite and refuse it, instead of handing back a
435
- * client whose reads and writes disagree. See {@link assertUtcTimestampsAgree}.
452
+ * client whose reads and writes disagree. Every client records it, including
453
+ * one on an external pool, which registers nothing but still READS through
454
+ * whatever an owned client in the same process installed. See
455
+ * {@link assertUtcTimestampsAgree}.
436
456
  */
437
457
  static utcTimestampParserMode;
458
+ /**
459
+ * Whether the zone-less temporal read parsers (OIDs 1114, 1082, 1115, 1182)
460
+ * have actually been installed. Separate from
461
+ * {@link utcTimestampParserMode}, which every client settles: only an OWNED
462
+ * pool registers, so a client on an external pool must not make a later
463
+ * owned client skip registration.
464
+ */
465
+ static utcTimestampParsersRegistered = false;
438
466
  logging;
439
467
  /** Active SQL dialect, owns transaction keywords, set_config, raw-SQL placeholders, capability flags. */
440
468
  dialect;
469
+ /** Validated `plan_cache_mode` to pin on every owned connection, or undefined to issue nothing. */
470
+ planCacheMode;
441
471
  tableCache = new Map();
442
472
  middlewares = [];
443
473
  queryListeners = new Set();
@@ -530,6 +560,12 @@ export class TurbineClient {
530
560
  // Name any key on the config object that is not part of the config surface
531
561
  // (dev only, once per key, never throws). See warnUnknownConfigKeys.
532
562
  warnUnknownConfigKeys(config);
563
+ // ALL config validation runs before ANY process-global side effect below.
564
+ // A constructor that throws must leave the process exactly as it found it:
565
+ // settling the process-global parser mode and then rejecting the config
566
+ // would poison the next, valid, TurbineClient with a phantom conflict.
567
+ const dialect = config.dialect ?? postgresDialect;
568
+ const planCacheMode = TurbineClient.resolvePlanCacheMode(config.planCacheMode, dialect);
533
569
  /**
534
570
  * Parse int8 (bigint, OID 20) as JavaScript number instead of string.
535
571
  * Safe for values up to Number.MAX_SAFE_INTEGER (9,007,199,254,740,991).
@@ -561,7 +597,8 @@ export class TurbineClient {
561
597
  });
562
598
  TurbineClient.int8ParserRegistered = true;
563
599
  }
564
- // Parse `timestamp` (OID 1114) as UTC instead of server-local time. The
600
+ // Parse the zone-less temporal types (`timestamp` OID 1114, `date` OID
601
+ // 1082, and their array forms 1115 / 1182) as UTC instead of local time. The
565
602
  // pg driver's default hands back a Date built in the process's local zone,
566
603
  // so the same row yields a different instant per deployment region. The
567
604
  // ORM convention (Prisma, Rails, Django), and the only interpretation
@@ -572,16 +609,23 @@ export class TurbineClient {
572
609
  // same flag is per client (query/writes.ts). Two clients disagreeing about
573
610
  // it therefore cannot both be served, so the disagreement is refused here
574
611
  // rather than resolved silently into a client that does not round-trip.
575
- if (ownsAnyPool) {
576
- const wantUtcTimestamps = config.utcTimestamps !== false;
577
- TurbineClient.assertUtcTimestampsAgree(wantUtcTimestamps);
578
- if (wantUtcTimestamps && TurbineClient.utcTimestampParserMode === undefined) {
579
- pg.types.setTypeParser(1114, (val) => new Date(`${val.replace(' ', 'T')}Z`));
580
- }
581
- TurbineClient.utcTimestampParserMode = wantUtcTimestamps;
612
+ //
613
+ // The AGREEMENT check runs for EVERY client, owned pool or not. Only an
614
+ // owned pool ever REGISTERS the parsers, but once any client has registered
615
+ // them every client in the process reads through them, including one on an
616
+ // external pool: it would then read UTC while its own per-client write half
617
+ // still rendered local literals, which on a `date` column walks the stored
618
+ // calendar day backwards one day per read-modify-write cycle.
619
+ const wantUtcTimestamps = config.utcTimestamps !== false;
620
+ TurbineClient.assertUtcTimestampsAgree(wantUtcTimestamps);
621
+ if (ownsAnyPool && wantUtcTimestamps && !TurbineClient.utcTimestampParsersRegistered) {
622
+ registerUtcTemporalParsers();
623
+ TurbineClient.utcTimestampParsersRegistered = true;
582
624
  }
625
+ TurbineClient.utcTimestampParserMode = wantUtcTimestamps;
583
626
  this.logging = config.logging ?? false;
584
- this.dialect = config.dialect ?? postgresDialect;
627
+ this.dialect = dialect;
628
+ this.planCacheMode = planCacheMode;
585
629
  this.schema = schema;
586
630
  // Respect env var kill switch
587
631
  const envDisablePrepared = typeof process !== 'undefined' && process.env?.TURBINE_DISABLE_PREPARED === '1';
@@ -674,7 +718,7 @@ export class TurbineClient {
674
718
  if (config.ssl !== undefined) {
675
719
  poolConfig.ssl = config.ssl;
676
720
  }
677
- this.pool = new pg.Pool(poolConfig);
721
+ this.pool = new pg.Pool(TurbineClient.withPlanCacheMode(poolConfig, this.planCacheMode));
678
722
  this.ownsPool = true;
679
723
  this.pool.on('error', (err) => {
680
724
  console.error('[turbine] Unexpected pool error:', err.message);
@@ -690,13 +734,13 @@ export class TurbineClient {
690
734
  this.ownedReplicaPools = [];
691
735
  for (const replica of config.replicas ?? []) {
692
736
  if (typeof replica === 'string') {
693
- const replicaPool = new pg.Pool({
737
+ const replicaPool = new pg.Pool(TurbineClient.withPlanCacheMode({
694
738
  connectionString: replica,
695
739
  max: config.poolSize ?? config.max ?? 10,
696
740
  idleTimeoutMillis: config.idleTimeoutMs ?? config.idleTimeoutMillis ?? 30_000,
697
741
  connectionTimeoutMillis: config.connectionTimeoutMs ?? config.connectionTimeoutMillis ?? 5_000,
698
742
  ...(config.ssl !== undefined ? { ssl: config.ssl } : {}),
699
- });
743
+ }, this.planCacheMode));
700
744
  replicaPool.on('error', (err) => {
701
745
  console.error('[turbine] Unexpected replica pool error:', err.message);
702
746
  });
@@ -707,6 +751,27 @@ export class TurbineClient {
707
751
  this.replicaPools.push(replica);
708
752
  }
709
753
  }
754
+ // `planCacheMode` reaches a pool only where Turbine opens the connections.
755
+ // Warned here rather than in the external-pool branch above because the
756
+ // owned string replicas are built after it: with an external primary and
757
+ // owned replicas the option is applied to the replicas and dropped on the
758
+ // primary, and a warning that said it was "ignored" would be false.
759
+ // Deliberate no-op rather than a throw: the option is a performance knob,
760
+ // and an app that moves from an owned pool to a serverless driver should
761
+ // not stop booting over it. Same ownership rule as the type parsers, which
762
+ // also skip external pools silently.
763
+ if (this.planCacheMode !== undefined && !this.ownsPool && process.env.NODE_ENV !== 'production') {
764
+ if (shouldWarnOnce(WARN_NS.planCacheModeIgnored, this.planCacheMode)) {
765
+ const replicaNote = this.ownedReplicaPools.length > 0
766
+ ? ` It IS applied to the ${this.ownedReplicaPools.length} Turbine-owned read replica pool(s) on this ` +
767
+ 'client, so reads and writes would run under different plan-cache policies until the primary is set too.'
768
+ : '';
769
+ console.warn(`[turbine] planCacheMode: '${this.planCacheMode}' was not applied to the primary: this client was given an ` +
770
+ 'external `pool`, whose connection lifecycle the caller owns, so Turbine never opens its connections. Set ' +
771
+ `\`plan_cache_mode\` in the driver's own connection setup (or run \`SET plan_cache_mode = ${this.planCacheMode}\` ` +
772
+ `on checkout) instead.${replicaNote}`);
773
+ }
774
+ }
710
775
  this.replicaTableCaches = this.replicaPools.map(() => new Map());
711
776
  if (this.logging && this.replicaPools.length > 0) {
712
777
  console.log(`[turbine] ${this.replicaPools.length} read replica(s) configured (${this.ownedReplicaPools.length} owned)`);
@@ -728,19 +793,128 @@ export class TurbineClient {
728
793
  }
729
794
  }
730
795
  /**
731
- * Refuse a `utcTimestamps` value that contradicts the one the process-global
732
- * OID 1114 read parser was already settled on.
796
+ * Validate a caller-supplied `planCacheMode` and refuse it on an engine that
797
+ * has no plan cache to pin.
798
+ *
799
+ * Two refusals, both at construction rather than at first query, so a
800
+ * misconfigured client never opens a connection:
801
+ *
802
+ * - a value outside {@link PLAN_CACHE_MODES} throws `ValidationError`
803
+ * (E003). This is the security boundary as well as the usability one: a
804
+ * GUC value cannot be a bind parameter in either place it can be set (a
805
+ * `SET` statement or the connection `options` string), so the returned
806
+ * value is a MEMBER OF THE FROZEN LIST, never the caller's string, and
807
+ * there is no path by which caller text reaches the connection or the SQL.
808
+ * - a dialect that does not report `supportsPlanCacheMode` throws
809
+ * `UnsupportedFeatureError` (E017), in the same style as the other
810
+ * capability refusals.
811
+ */
812
+ static resolvePlanCacheMode(mode, dialect) {
813
+ if (mode === undefined || mode === null)
814
+ return undefined;
815
+ const matched = PLAN_CACHE_MODES.find((m) => m === mode);
816
+ if (matched === undefined) {
817
+ throw new ValidationError(`[turbine] Invalid planCacheMode: ${JSON.stringify(mode)}. Expected one of ${PLAN_CACHE_MODES.map((m) => `'${m}'`).join(', ')}.`);
818
+ }
819
+ if (dialect.supportsPlanCacheMode !== true) {
820
+ throw new UnsupportedFeatureError(`The planCacheMode option (plan_cache_mode = ${matched})`, dialect.name, '`plan_cache_mode` is a PostgreSQL plan-cache setting with no equivalent on this engine. Remove the option, ' +
821
+ 'or set it only on the PostgreSQL client.');
822
+ }
823
+ return matched;
824
+ }
825
+ /**
826
+ * Pin `plan_cache_mode` on every connection an OWNED pool opens, by putting
827
+ * it in the pool's **connection parameters** rather than issuing a `SET`.
828
+ *
829
+ * PostgreSQL's `options` startup parameter (`-c plan_cache_mode=...`) is
830
+ * applied by the backend as it starts the session, so the setting is in force
831
+ * for the connection's very first statement and for its whole life: every
832
+ * pooled checkout, `$transaction`, stream and pipeline on it inherits it.
833
+ * There is no per-checkout reset, and none is wanted, that IS the intent.
834
+ *
835
+ * Why not `pool.on('connect', c => c.query('SET ...'))`, the obvious
836
+ * alternative: pg hands the fresh client to the waiting caller in the same
837
+ * tick it emits `connect`, so the caller's first query is issued while the
838
+ * `SET` is still the active query. That path works today only through pg's
839
+ * deprecated same-client query queueing (it logs a DeprecationWarning per new
840
+ * connection and is slated for removal in pg 9), and it costs an extra round
841
+ * trip on every connection. The startup parameter costs nothing and cannot
842
+ * race.
843
+ *
844
+ * Nothing the caller already set is discarded, in either of the two places
845
+ * pg reads `options` from. pg's `ConnectionParameters` lets a value parsed
846
+ * out of a `connectionString` OVERRIDE the explicit `options` field, so when
847
+ * the URL already carries `?options=...` the GUC is appended THERE; and the
848
+ * explicit field itself falls back to `process.env.PGOPTIONS` only while it
849
+ * is unset, so setting it blind would silently drop a deployment's
850
+ * `PGOPTIONS` (its `search_path` or `statement_timeout`, not merely a slower
851
+ * plan). Both are read first and the GUC is appended to whichever applies.
852
+ *
853
+ * One deployment caveat: an `options` startup parameter is a connection-time
854
+ * parameter, and a connection pooler in front of Postgres may reject
855
+ * parameters it is not configured to pass through (PgBouncer's
856
+ * `ignore_startup_parameters`). A `SET` on checkout would survive that, at
857
+ * the cost of the race and the round trip above. Callers behind such a pooler
858
+ * should set the GUC on the server or role instead
859
+ * (`ALTER ROLE ... SET plan_cache_mode = ...`).
860
+ */
861
+ static withPlanCacheMode(poolConfig, mode) {
862
+ if (mode === undefined)
863
+ return poolConfig;
864
+ // `mode` is a member of PLAN_CACHE_MODES, never caller text (see
865
+ // resolvePlanCacheMode), which is what makes this literal safe: a GUC value
866
+ // cannot be a bind parameter.
867
+ const setting = `-c plan_cache_mode=${mode}`;
868
+ const merged = poolConfig.connectionString
869
+ ? TurbineClient.mergeConnectionStringOptions(poolConfig.connectionString, setting)
870
+ : null;
871
+ if (merged)
872
+ return { ...poolConfig, connectionString: merged };
873
+ // pg reads `config.options` when truthy and `process.env.PGOPTIONS`
874
+ // otherwise, so an unmerged setting would replace the caller's PGOPTIONS
875
+ // rather than add to it.
876
+ const existing = poolConfig.options || (typeof process !== 'undefined' ? process.env?.PGOPTIONS : undefined);
877
+ return { ...poolConfig, options: existing ? `${existing} ${setting}` : setting };
878
+ }
879
+ /**
880
+ * `connectionString` with `setting` appended to its existing `options` query
881
+ * parameter, or `null` when it carries no `options` (in which case the caller
882
+ * should use the `options` pool field, which is not overridden).
883
+ *
884
+ * Only the query string is rewritten, never the userinfo or host, so a
885
+ * percent-encoded password cannot be mangled by a round trip through `URL`.
886
+ * The split is on the first `?`, which is also where pg's own parser puts the
887
+ * query-string boundary: a connection string with an unencoded `?` inside the
888
+ * password is not parseable by pg either, so there is no shape this handles
889
+ * differently from the driver.
890
+ */
891
+ static mergeConnectionStringOptions(connectionString, setting) {
892
+ const q = connectionString.indexOf('?');
893
+ if (q === -1)
894
+ return null;
895
+ const params = new URLSearchParams(connectionString.slice(q + 1));
896
+ const existing = params.get('options');
897
+ if (existing === null)
898
+ return null;
899
+ params.set('options', `${existing} ${setting}`);
900
+ return connectionString.slice(0, q + 1) + params.toString();
901
+ }
902
+ /**
903
+ * Refuse a `utcTimestamps` value that contradicts the one an earlier client
904
+ * in this process settled the zone-less temporal read parsers (OIDs 1114,
905
+ * 1082, 1115, 1182) on.
733
906
  *
734
907
  * The flag has two halves. The WRITE half is per client: a bound `Date` on a
735
908
  * zone-less `date` / `timestamp` column is rewritten to a UTC literal unless
736
909
  * the owning client opted out (`coerceWriteValue` in query/writes.ts). The
737
- * READ half is the pg type parser for OID 1114, and `pg.types.setTypeParser`
738
- * installs ONE parser per OID for the whole process, shared by every pool,
739
- * every raw query, and any other library using the same pg module. There is
740
- * no per-pool parser hook to bind it to, and moving the coercion into
741
- * `parseRow` instead would leave every non-ORM read (raw SQL, `client.sql`,
742
- * a caller's own `pool.query`) on the driver's value while changing the
743
- * default path's output type, so the read half stays process-wide.
910
+ * READ half is the pg type parsers for OIDs 1114 / 1082 / 1115 / 1182, and
911
+ * `pg.types.setTypeParser` installs ONE parser per OID for the whole process,
912
+ * shared by every pool, every raw query, and any other library using the same
913
+ * pg module. There is no per-pool parser hook to bind it to, and moving the
914
+ * coercion into `parseRow` instead would leave every non-ORM read (raw SQL,
915
+ * `client.sql`, a caller's own `pool.query`) on the driver's value while
916
+ * changing the default path's output type, so the read half stays
917
+ * process-wide.
744
918
  *
745
919
  * That makes the mixed shape unserveable rather than merely awkward: the
746
920
  * second client would write local calendar fields and read them back as UTC
@@ -748,16 +922,23 @@ export class TurbineClient {
748
922
  * A client that silently does not round-trip is the worst of the three
749
923
  * outcomes, so construction fails with the two ways out.
750
924
  *
751
- * Only Turbine-owned pools take part. An external pool (Neon, Vercel
752
- * Postgres, Hyperdrive) inherits the caller's parser configuration and
753
- * Turbine never registers on its behalf, so it has no read half to contradict.
925
+ * EVERY client takes part, not only the ones on a Turbine-owned pool. Only an
926
+ * owned pool REGISTERS the parsers, but registration is process-global, so a
927
+ * client on an external pool (Neon, Vercel Postgres, Hyperdrive) constructed
928
+ * alongside an owned one reads through them too. It is exactly the pairing
929
+ * that produced a silent read/write disagreement: an external-pool client
930
+ * with `utcTimestamps: false` writing local `date` literals while reading UTC
931
+ * ones, which walks the stored calendar day back a day per read-modify-write
932
+ * cycle. An external-pool client ALONE in a process is unaffected: it settles
933
+ * the value, registers nothing, and keeps the caller's parser configuration.
754
934
  */
755
935
  static assertUtcTimestampsAgree(want) {
756
936
  const settled = TurbineClient.utcTimestampParserMode;
757
937
  if (settled === undefined || settled === want)
758
938
  return;
759
939
  throw new ValidationError(`[turbine] utcTimestamps: ${want} conflicts with utcTimestamps: ${settled}, which an earlier TurbineClient ` +
760
- 'in this process already applied. The timestamp READ parser (pg OID 1114) is process-global, so it cannot ' +
940
+ 'in this process already applied. The zone-less temporal READ parsers (pg OIDs 1114, 1082, 1115, 1182) are ' +
941
+ 'process-global, so they cannot ' +
761
942
  'differ per client, while the WRITE side is per client. Serving both values would give this client a ' +
762
943
  `${want ? 'UTC write' : 'local write'} and a ${settled ? 'UTC read' : 'local read'}, so every zone-less ` +
763
944
  '`timestamp` it writes would read back shifted by the process offset. Give every TurbineClient in this ' +
package/dist/dialect.d.ts CHANGED
@@ -370,6 +370,30 @@ export interface Dialect {
370
370
  readonly supportsRLS: boolean;
371
371
  /** Whether this dialect/engine supports advisory-lock-style migration locking. */
372
372
  readonly supportsAdvisoryLock: boolean;
373
+ /**
374
+ * Whether this dialect/engine understands the `plan_cache_mode` session
375
+ * setting (`SET plan_cache_mode = auto | force_custom_plan |
376
+ * force_generic_plan`). Gates the opt-in `planCacheMode` client option.
377
+ *
378
+ * A capability flag rather than a `dialect.name === 'postgresql'` test, for
379
+ * the same reason every other refusal here is one: the setting is a property
380
+ * of the PostgreSQL PLAN CACHE, not of the SQL string, so a
381
+ * Postgres-compatible dialect that grows one can opt in without editing
382
+ * client.ts, and one that has the wire protocol but not the plan cache
383
+ * (which is why it is not simply inherited) can leave it off. Optional:
384
+ * absent is treated as `false`, so every engine that predates the flag keeps
385
+ * throwing {@link UnsupportedFeatureError} (E017).
386
+ *
387
+ * The flag speaks for the DIALECT, not for the server on the other end. A
388
+ * Postgres wire-compatible engine driven through `postgresDialect`
389
+ * (CockroachDB, YugabyteDB, or a pre-12 PostgreSQL) reports `true` here and
390
+ * will instead be refused by the server itself with `unrecognized
391
+ * configuration parameter` when the connection is opened. Turbine has no
392
+ * client-level seam that identifies those engines (`DatabaseAdapter` is a
393
+ * CLI/migration concern and defines no dialect), and the same is already true
394
+ * of the other Postgres-only capabilities on this dialect.
395
+ */
396
+ readonly supportsPlanCacheMode?: boolean;
373
397
  /**
374
398
  * Whether this dialect supports `LEFT JOIN LATERAL (...) ON true` in the FROM
375
399
  * clause. Gates the opt-in `plan: 'lateral'` pick-row ordering. Optional:
package/dist/dialect.js CHANGED
@@ -25,6 +25,7 @@ export const postgresDialect = {
25
25
  supportsListenNotify: true,
26
26
  supportsRLS: true,
27
27
  supportsAdvisoryLock: true,
28
+ supportsPlanCacheMode: true,
28
29
  supportsLateralJoin: true,
29
30
  explainQuery: { prefix: 'EXPLAIN' },
30
31
  paramPlaceholder(index) {
package/dist/index.d.ts CHANGED
@@ -34,7 +34,7 @@
34
34
  */
35
35
  export type { DatabaseAdapter, IntrospectionOverrides } from './adapters/index.js';
36
36
  export { alloydb, cockroachdb, postgresql, timescale, yugabytedb } from './adapters/index.js';
37
- export { type Middleware, type MiddlewareNext, type MiddlewareParams, type PgCompatPool, type PgCompatPoolClient, type PgCompatQueryResult, type RetryOptions, TransactionClient, type TransactionOptions, TurbineClient, type TurbineConfig, type TurbineDriver, withRetry, } from './client.js';
37
+ export { type Middleware, type MiddlewareNext, type MiddlewareParams, type PgCompatPool, type PgCompatPoolClient, type PgCompatQueryResult, type PlanCacheMode, type RetryOptions, TransactionClient, type TransactionOptions, TurbineClient, type TurbineConfig, type TurbineDriver, withRetry, } from './client.js';
38
38
  export type { BuiltStatement, BulkInsertStatementInput, ColumnDefinitionInput, ColumnTypeInput, CreateIndexStatementInput, CreateTableStatementInput, Dialect, DialectIntrospector, DialectMigrator, DialectName, InsertStatementInput, IntrospectOptions as DialectIntrospectOptions, ResultStrategy, StreamableConnection, UpsertStatementInput, } from './dialect.js';
39
39
  export { postgresDialect } from './dialect.js';
40
40
  export { CheckConstraintError, CircularRelationError, ConnectionError, DeadlockError, type ErrorMessageMode, ExclusionConstraintError, ForeignKeyError, getErrorMessageMode, MigrationError, NotFoundError, NotNullViolationError, OptimisticLockError, PipelineError, type PipelineResultSlot, ReadOnlyError, RelationError, SerializationFailureError, setErrorMessageMode, TimeoutError, TurbineError, TurbineErrorCode, UniqueConstraintError, UnsupportedFeatureError, ValidationError, wrapPgError, } from './errors.js';
package/dist/mssql.js CHANGED
@@ -498,6 +498,7 @@ export const mssqlDialect = {
498
498
  // SQL Server has OUTER APPLY, not FROM-clause LATERAL: the lateral pick plan
499
499
  // is Postgres-only (out of scope here).
500
500
  supportsLateralJoin: false,
501
+ supportsPlanCacheMode: false,
501
502
  // sp_getapplock / sp_releaseapplock exist (used by a future migrate adapter).
502
503
  supportsAdvisoryLock: true,
503
504
  // No in-band EXPLAIN: SQL Server's SHOWPLAN is a session toggle
package/dist/mysql.js CHANGED
@@ -380,6 +380,7 @@ export const mysqlDialect = {
380
380
  // MySQL 8.0.14+ supports LATERAL, but the opt-in lateral pick plan stays
381
381
  // Postgres-only in this release (flipping it on is a one-line change + tests).
382
382
  supportsLateralJoin: false,
383
+ supportsPlanCacheMode: false,
383
384
  // GET_LOCK / RELEASE_LOCK exist (used by a future migrate adapter).
384
385
  supportsAdvisoryLock: true,
385
386
  // Plain `EXPLAIN` (one row of tabular plan columns) works on every supported
package/dist/powdb.js CHANGED
@@ -100,6 +100,7 @@ export const powdbDialect = {
100
100
  // PowQL has no LATERAL construct; PowqlInterface refuses pick ordering
101
101
  // earlier, this override keeps the flag truthful if a future path consults it.
102
102
  supportsLateralJoin: false,
103
+ supportsPlanCacheMode: false,
103
104
  beginStatement: () => 'begin',
104
105
  commitStatement: () => 'commit',
105
106
  rollbackStatement: () => 'rollback',
@@ -576,7 +576,8 @@ export class QueryInterface {
576
576
  // the flag and rewrote binds the caller had opted out of.
577
577
  //
578
578
  // This half of the flag is PER CLIENT. The read half is not: it is the
579
- // pg OID 1114 type parser, which `pg.types.setTypeParser` installs once
579
+ // pg type parsers for OIDs 1114 / 1082 / 1115 / 1182, which
580
+ // `pg.types.setTypeParser` installs once
580
581
  // per process. Two clients in one process therefore cannot hold
581
582
  // different values, and TurbineClient refuses the second one rather than
582
583
  // building a client whose writes and reads disagree (see
@@ -3042,10 +3043,18 @@ export class QueryInterface {
3042
3043
  // single Invalid Date, the column was unreadable on every strategy.
3043
3044
  // The join strategy's string arrays are handled upstream instead, by
3044
3045
  // the JSON-wire decode in relations.ts.
3046
+ //
3047
+ // A NUMBER is excluded for the same reason: the driver returns the
3048
+ // JS numbers `Infinity` / `-Infinity` for the Postgres `infinity` /
3049
+ // `-infinity` timestamp values, and re-coercing one ran
3050
+ // `parseDbDate(String(Infinity))` = `parseDbDate('Infinity')`, which
3051
+ // is an Invalid Date that JSON-encodes as null. The array form escaped
3052
+ // this only because it took the branch above.
3045
3053
  if ((dateCols.has(col) || camelDateFields.has(field)) &&
3046
3054
  value !== null &&
3047
3055
  !(value instanceof Date) &&
3048
- !Array.isArray(value)) {
3056
+ !Array.isArray(value) &&
3057
+ typeof value !== 'number') {
3049
3058
  // Offset-less strings (Postgres `timestamp`, json_agg output) are
3050
3059
  // pinned to UTC so results don't depend on the server's time zone.
3051
3060
  parsed[field] = this.utcTimestamps ? parseDbDate(String(value)) : new Date(value);
@@ -176,6 +176,90 @@ export declare function coerceTemporalValue(dbType: string | undefined, value: u
176
176
  * as-is.
177
177
  */
178
178
  export declare function parseDbDate(value: string): Date;
179
+ /**
180
+ * Build the driver parser for Postgres `date` (OID 1082) that reads a
181
+ * zone-less calendar day as **UTC midnight**.
182
+ *
183
+ * The pg default builds the Date from the process's LOCAL zone, so the stored
184
+ * calendar day `2026-07-21` comes back as `2026-07-20T22:00:00Z` in
185
+ * `Europe/Berlin` and `2026-07-20T15:00:00Z` in `Asia/Tokyo`: the wrong
186
+ * calendar day everywhere east of UTC, and the wrong instant everywhere except
187
+ * UTC itself. It is also the exact mirror-image of the WRITE side, which
188
+ * already renders a bound `Date` from its UTC components
189
+ * ({@link toLocalDateTimeLiteral}), so today a read-modify-write cycle on a
190
+ * `date` column east of UTC walks the stored day one day earlier per cycle.
191
+ * This is the missing read half of `utcTimestamps`, matching what
192
+ * {@link parseDbDate} already does for the JSON path and what the OID 1114
193
+ * parser already does for `timestamp`.
194
+ *
195
+ * `fallback` is the parser this one REPLACES, and it must be captured with
196
+ * `pg.types.getTypeParser(1082, 'text')` BEFORE registration (reading it after
197
+ * would hand back this function and recurse forever). It keeps `infinity` /
198
+ * `-infinity` on the driver's `Infinity` / `-Infinity`.
199
+ *
200
+ * `setUTCFullYear` rather than the `Date` constructor, so a two-or-three-digit
201
+ * year is not silently mapped into the 1900s, and ` BC` maps to the
202
+ * astronomical year (`0044 BC` → -43) the way the driver's own parser does.
203
+ */
204
+ export declare function createUtcDateParser(fallback: (text: string) => unknown): (text: string) => unknown;
205
+ /**
206
+ * Build the driver parser for Postgres `timestamp` (OID 1114) that reads an
207
+ * offset-less date-time as UTC. Also lifted to the `_timestamp` array OID
208
+ * (1115), so the scalar and the array can never settle on different
209
+ * interpretations.
210
+ *
211
+ * `fallback` is the parser this one REPLACES and must be captured with
212
+ * `pg.types.getTypeParser(1114, 'text')` BEFORE registration (see
213
+ * {@link createUtcDateParser}). It is what keeps `infinity` / `-infinity` on
214
+ * the driver's `Infinity` / `-Infinity`: the earlier
215
+ * `new Date(text.replace(' ', 'T') + 'Z')` form turned `'infinity'` into
216
+ * `'infinityZ'` and so into an `Invalid Date` that flowed on silently.
217
+ *
218
+ * Component assembly rather than `Date` string parsing, for the same reason as
219
+ * the `date` parser: a year outside four digits and a ` BC` suffix are not
220
+ * parseable as ISO-8601 and would otherwise also become `Invalid Date`.
221
+ * Fractional seconds are truncated to milliseconds, which is what
222
+ * `Date`-string parsing did too.
223
+ */
224
+ export declare function createUtcTimestampParser(fallback: (text: string) => unknown): (text: string) => unknown;
225
+ /**
226
+ * The offset-less-timestamp-as-UTC reading, with no fallback: `text` must be a
227
+ * plain `YYYY-MM-DD HH:MM:SS[.ffffff]`. Used where the input shape is already
228
+ * known (tests, JSON-wire coercion); the DRIVER parser is
229
+ * {@link createUtcTimestampParser}, which delegates everything else.
230
+ */
231
+ export declare function parseUtcTimestampText(text: string): Date;
232
+ /**
233
+ * Lift an element parser to the matching Postgres array OID.
234
+ *
235
+ * Array OIDs do NOT inherit their element type's parser: registering a parser
236
+ * for `date` (1082) leaves `date[]` (1182) on the driver's default, so the same
237
+ * value read from a scalar column and from an array column would disagree by
238
+ * the process offset. Every scalar temporal parser Turbine registers is
239
+ * therefore registered in its array form too.
240
+ *
241
+ * `pg.types.arrayParser` is a public member of the `pg` module (it is what the
242
+ * driver's own `_text` / `_date` parsers are built from), so this adds no
243
+ * dependency. NULL elements stay `null` and are never handed to `element`.
244
+ */
245
+ export declare function createPgArrayParser(element: (text: string) => unknown): (text: string) => unknown[];
246
+ /**
247
+ * Register the UTC readings of the four zone-less temporal OIDs on the pg
248
+ * module: `timestamp` (1114), `date` (1082) and their array forms (1115, 1182).
249
+ *
250
+ * ONE place, because `pg.types.setTypeParser` is process-global and the pairing
251
+ * matters: registering a scalar without its array form, or a `date` without the
252
+ * `timestamp` beside it, produces two columns of the same row disagreeing about
253
+ * what the same wire text means. Both callers are processes Turbine owns the
254
+ * pg module in: `TurbineClient` on a pool it created (never on an external
255
+ * pool, whose parser configuration belongs to the caller), and `turbine studio`,
256
+ * which builds a raw pool of its own and must render what the application sees.
257
+ *
258
+ * Each fallback is read BEFORE its parser is installed, so an unrecognised wire
259
+ * value (`infinity`, and whatever a future server adds) still reaches the
260
+ * driver's own parser.
261
+ */
262
+ export declare function registerUtcTemporalParsers(): void;
179
263
  /**
180
264
  * Postgres type name → OID, for every type family whose `json_build_object`
181
265
  * rendering is NOT the value the pg driver produces for the same column.
@@ -196,7 +280,7 @@ export declare function parseDbDate(value: string): Date;
196
280
  * numeric '1000.50' (string) 1000.5 (number, LOSSY)
197
281
  * int8 '9007199254740993' 9007199254740992 (LOSSY)
198
282
  * bytea Buffer '\xdeadbeef' (string)
199
- * date Date (local midnight) Date (UTC midnight, off by tz)
283
+ * date Date (UTC midnight) Date (UTC midnight, by coincidence)
200
284
  * interval { days, hours, … } '1 day 02:03:04' (string)
201
285
  * point { x, y } '(1,2)' (string)
202
286
  * circle { x, y, radius } '<(1,2),3>' (string)