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.
@@ -62,6 +62,23 @@ async function withRetry(fn, options) {
62
62
  }
63
63
  throw lastError;
64
64
  }
65
+ /**
66
+ * The accepted `plan_cache_mode` values, as a runtime set.
67
+ *
68
+ * A GUC name/value pair cannot be a bind parameter (`SET plan_cache_mode = $1`
69
+ * is a syntax error), so the emitted statement necessarily contains a literal.
70
+ * This CLOSED SET is therefore the entire safety boundary: the statement is
71
+ * built from the matched MEMBER of this set, never from the caller's string,
72
+ * so no input a caller can supply reaches the SQL text even if it compares
73
+ * equal under some looser rule. Anything not in the set is refused at
74
+ * construction. Module-private and frozen, so the set itself is not a mutation
75
+ * target either.
76
+ */
77
+ const PLAN_CACHE_MODES = Object.freeze([
78
+ 'auto',
79
+ 'force_custom_plan',
80
+ 'force_generic_plan',
81
+ ]);
65
82
  // ---------------------------------------------------------------------------
66
83
  // Unknown-config-key diagnostics
67
84
  // ---------------------------------------------------------------------------
@@ -92,6 +109,7 @@ const TURBINE_CONFIG_KEYS = {
92
109
  defaultLimit: true,
93
110
  warnOnUnlimited: true,
94
111
  utcTimestamps: true,
112
+ planCacheMode: true,
95
113
  scopedConnect: true,
96
114
  relationLoadStrategy: true,
97
115
  stableRelationOrder: true,
@@ -431,21 +449,33 @@ class TurbineClient {
431
449
  schema;
432
450
  static int8ParserRegistered = false;
433
451
  /**
434
- * The `utcTimestamps` value the FIRST Turbine-owned pool in this process
435
- * settled the OID 1114 read parser on, or `undefined` while no Turbine-owned
436
- * client has been constructed yet.
452
+ * The `utcTimestamps` value the FIRST TurbineClient in this process settled
453
+ * on, or `undefined` while none has been constructed yet.
437
454
  *
438
455
  * `pg.types.setTypeParser` is process-global by nature: there is one parser
439
456
  * per OID for the whole pg module, so the READ side of `utcTimestamps` cannot
440
457
  * be per client the way the WRITE side is. Recording the settled value (not
441
458
  * just "registered yes/no") is what lets the constructor detect a second
442
459
  * client asking for the opposite and refuse it, instead of handing back a
443
- * client whose reads and writes disagree. See {@link assertUtcTimestampsAgree}.
460
+ * client whose reads and writes disagree. Every client records it, including
461
+ * one on an external pool, which registers nothing but still READS through
462
+ * whatever an owned client in the same process installed. See
463
+ * {@link assertUtcTimestampsAgree}.
444
464
  */
445
465
  static utcTimestampParserMode;
466
+ /**
467
+ * Whether the zone-less temporal read parsers (OIDs 1114, 1082, 1115, 1182)
468
+ * have actually been installed. Separate from
469
+ * {@link utcTimestampParserMode}, which every client settles: only an OWNED
470
+ * pool registers, so a client on an external pool must not make a later
471
+ * owned client skip registration.
472
+ */
473
+ static utcTimestampParsersRegistered = false;
446
474
  logging;
447
475
  /** Active SQL dialect, owns transaction keywords, set_config, raw-SQL placeholders, capability flags. */
448
476
  dialect;
477
+ /** Validated `plan_cache_mode` to pin on every owned connection, or undefined to issue nothing. */
478
+ planCacheMode;
449
479
  tableCache = new Map();
450
480
  middlewares = [];
451
481
  queryListeners = new Set();
@@ -538,6 +568,12 @@ class TurbineClient {
538
568
  // Name any key on the config object that is not part of the config surface
539
569
  // (dev only, once per key, never throws). See warnUnknownConfigKeys.
540
570
  warnUnknownConfigKeys(config);
571
+ // ALL config validation runs before ANY process-global side effect below.
572
+ // A constructor that throws must leave the process exactly as it found it:
573
+ // settling the process-global parser mode and then rejecting the config
574
+ // would poison the next, valid, TurbineClient with a phantom conflict.
575
+ const dialect = config.dialect ?? dialect_js_1.postgresDialect;
576
+ const planCacheMode = TurbineClient.resolvePlanCacheMode(config.planCacheMode, dialect);
541
577
  /**
542
578
  * Parse int8 (bigint, OID 20) as JavaScript number instead of string.
543
579
  * Safe for values up to Number.MAX_SAFE_INTEGER (9,007,199,254,740,991).
@@ -569,7 +605,8 @@ class TurbineClient {
569
605
  });
570
606
  TurbineClient.int8ParserRegistered = true;
571
607
  }
572
- // Parse `timestamp` (OID 1114) as UTC instead of server-local time. The
608
+ // Parse the zone-less temporal types (`timestamp` OID 1114, `date` OID
609
+ // 1082, and their array forms 1115 / 1182) as UTC instead of local time. The
573
610
  // pg driver's default hands back a Date built in the process's local zone,
574
611
  // so the same row yields a different instant per deployment region. The
575
612
  // ORM convention (Prisma, Rails, Django), and the only interpretation
@@ -580,16 +617,23 @@ class TurbineClient {
580
617
  // same flag is per client (query/writes.ts). Two clients disagreeing about
581
618
  // it therefore cannot both be served, so the disagreement is refused here
582
619
  // rather than resolved silently into a client that does not round-trip.
583
- if (ownsAnyPool) {
584
- const wantUtcTimestamps = config.utcTimestamps !== false;
585
- TurbineClient.assertUtcTimestampsAgree(wantUtcTimestamps);
586
- if (wantUtcTimestamps && TurbineClient.utcTimestampParserMode === undefined) {
587
- pg_1.default.types.setTypeParser(1114, (val) => new Date(`${val.replace(' ', 'T')}Z`));
588
- }
589
- TurbineClient.utcTimestampParserMode = wantUtcTimestamps;
620
+ //
621
+ // The AGREEMENT check runs for EVERY client, owned pool or not. Only an
622
+ // owned pool ever REGISTERS the parsers, but once any client has registered
623
+ // them every client in the process reads through them, including one on an
624
+ // external pool: it would then read UTC while its own per-client write half
625
+ // still rendered local literals, which on a `date` column walks the stored
626
+ // calendar day backwards one day per read-modify-write cycle.
627
+ const wantUtcTimestamps = config.utcTimestamps !== false;
628
+ TurbineClient.assertUtcTimestampsAgree(wantUtcTimestamps);
629
+ if (ownsAnyPool && wantUtcTimestamps && !TurbineClient.utcTimestampParsersRegistered) {
630
+ (0, utils_js_1.registerUtcTemporalParsers)();
631
+ TurbineClient.utcTimestampParsersRegistered = true;
590
632
  }
633
+ TurbineClient.utcTimestampParserMode = wantUtcTimestamps;
591
634
  this.logging = config.logging ?? false;
592
- this.dialect = config.dialect ?? dialect_js_1.postgresDialect;
635
+ this.dialect = dialect;
636
+ this.planCacheMode = planCacheMode;
593
637
  this.schema = schema;
594
638
  // Respect env var kill switch
595
639
  const envDisablePrepared = typeof process !== 'undefined' && process.env?.TURBINE_DISABLE_PREPARED === '1';
@@ -682,7 +726,7 @@ class TurbineClient {
682
726
  if (config.ssl !== undefined) {
683
727
  poolConfig.ssl = config.ssl;
684
728
  }
685
- this.pool = new pg_1.default.Pool(poolConfig);
729
+ this.pool = new pg_1.default.Pool(TurbineClient.withPlanCacheMode(poolConfig, this.planCacheMode));
686
730
  this.ownsPool = true;
687
731
  this.pool.on('error', (err) => {
688
732
  console.error('[turbine] Unexpected pool error:', err.message);
@@ -698,13 +742,13 @@ class TurbineClient {
698
742
  this.ownedReplicaPools = [];
699
743
  for (const replica of config.replicas ?? []) {
700
744
  if (typeof replica === 'string') {
701
- const replicaPool = new pg_1.default.Pool({
745
+ const replicaPool = new pg_1.default.Pool(TurbineClient.withPlanCacheMode({
702
746
  connectionString: replica,
703
747
  max: config.poolSize ?? config.max ?? 10,
704
748
  idleTimeoutMillis: config.idleTimeoutMs ?? config.idleTimeoutMillis ?? 30_000,
705
749
  connectionTimeoutMillis: config.connectionTimeoutMs ?? config.connectionTimeoutMillis ?? 5_000,
706
750
  ...(config.ssl !== undefined ? { ssl: config.ssl } : {}),
707
- });
751
+ }, this.planCacheMode));
708
752
  replicaPool.on('error', (err) => {
709
753
  console.error('[turbine] Unexpected replica pool error:', err.message);
710
754
  });
@@ -715,6 +759,27 @@ class TurbineClient {
715
759
  this.replicaPools.push(replica);
716
760
  }
717
761
  }
762
+ // `planCacheMode` reaches a pool only where Turbine opens the connections.
763
+ // Warned here rather than in the external-pool branch above because the
764
+ // owned string replicas are built after it: with an external primary and
765
+ // owned replicas the option is applied to the replicas and dropped on the
766
+ // primary, and a warning that said it was "ignored" would be false.
767
+ // Deliberate no-op rather than a throw: the option is a performance knob,
768
+ // and an app that moves from an owned pool to a serverless driver should
769
+ // not stop booting over it. Same ownership rule as the type parsers, which
770
+ // also skip external pools silently.
771
+ if (this.planCacheMode !== undefined && !this.ownsPool && process.env.NODE_ENV !== 'production') {
772
+ if ((0, warn_registry_js_1.shouldWarnOnce)(warn_registry_js_1.WARN_NS.planCacheModeIgnored, this.planCacheMode)) {
773
+ const replicaNote = this.ownedReplicaPools.length > 0
774
+ ? ` It IS applied to the ${this.ownedReplicaPools.length} Turbine-owned read replica pool(s) on this ` +
775
+ 'client, so reads and writes would run under different plan-cache policies until the primary is set too.'
776
+ : '';
777
+ console.warn(`[turbine] planCacheMode: '${this.planCacheMode}' was not applied to the primary: this client was given an ` +
778
+ 'external `pool`, whose connection lifecycle the caller owns, so Turbine never opens its connections. Set ' +
779
+ `\`plan_cache_mode\` in the driver's own connection setup (or run \`SET plan_cache_mode = ${this.planCacheMode}\` ` +
780
+ `on checkout) instead.${replicaNote}`);
781
+ }
782
+ }
718
783
  this.replicaTableCaches = this.replicaPools.map(() => new Map());
719
784
  if (this.logging && this.replicaPools.length > 0) {
720
785
  console.log(`[turbine] ${this.replicaPools.length} read replica(s) configured (${this.ownedReplicaPools.length} owned)`);
@@ -736,19 +801,128 @@ class TurbineClient {
736
801
  }
737
802
  }
738
803
  /**
739
- * Refuse a `utcTimestamps` value that contradicts the one the process-global
740
- * OID 1114 read parser was already settled on.
804
+ * Validate a caller-supplied `planCacheMode` and refuse it on an engine that
805
+ * has no plan cache to pin.
806
+ *
807
+ * Two refusals, both at construction rather than at first query, so a
808
+ * misconfigured client never opens a connection:
809
+ *
810
+ * - a value outside {@link PLAN_CACHE_MODES} throws `ValidationError`
811
+ * (E003). This is the security boundary as well as the usability one: a
812
+ * GUC value cannot be a bind parameter in either place it can be set (a
813
+ * `SET` statement or the connection `options` string), so the returned
814
+ * value is a MEMBER OF THE FROZEN LIST, never the caller's string, and
815
+ * there is no path by which caller text reaches the connection or the SQL.
816
+ * - a dialect that does not report `supportsPlanCacheMode` throws
817
+ * `UnsupportedFeatureError` (E017), in the same style as the other
818
+ * capability refusals.
819
+ */
820
+ static resolvePlanCacheMode(mode, dialect) {
821
+ if (mode === undefined || mode === null)
822
+ return undefined;
823
+ const matched = PLAN_CACHE_MODES.find((m) => m === mode);
824
+ if (matched === undefined) {
825
+ throw new errors_js_1.ValidationError(`[turbine] Invalid planCacheMode: ${JSON.stringify(mode)}. Expected one of ${PLAN_CACHE_MODES.map((m) => `'${m}'`).join(', ')}.`);
826
+ }
827
+ if (dialect.supportsPlanCacheMode !== true) {
828
+ throw new errors_js_1.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, ' +
829
+ 'or set it only on the PostgreSQL client.');
830
+ }
831
+ return matched;
832
+ }
833
+ /**
834
+ * Pin `plan_cache_mode` on every connection an OWNED pool opens, by putting
835
+ * it in the pool's **connection parameters** rather than issuing a `SET`.
836
+ *
837
+ * PostgreSQL's `options` startup parameter (`-c plan_cache_mode=...`) is
838
+ * applied by the backend as it starts the session, so the setting is in force
839
+ * for the connection's very first statement and for its whole life: every
840
+ * pooled checkout, `$transaction`, stream and pipeline on it inherits it.
841
+ * There is no per-checkout reset, and none is wanted, that IS the intent.
842
+ *
843
+ * Why not `pool.on('connect', c => c.query('SET ...'))`, the obvious
844
+ * alternative: pg hands the fresh client to the waiting caller in the same
845
+ * tick it emits `connect`, so the caller's first query is issued while the
846
+ * `SET` is still the active query. That path works today only through pg's
847
+ * deprecated same-client query queueing (it logs a DeprecationWarning per new
848
+ * connection and is slated for removal in pg 9), and it costs an extra round
849
+ * trip on every connection. The startup parameter costs nothing and cannot
850
+ * race.
851
+ *
852
+ * Nothing the caller already set is discarded, in either of the two places
853
+ * pg reads `options` from. pg's `ConnectionParameters` lets a value parsed
854
+ * out of a `connectionString` OVERRIDE the explicit `options` field, so when
855
+ * the URL already carries `?options=...` the GUC is appended THERE; and the
856
+ * explicit field itself falls back to `process.env.PGOPTIONS` only while it
857
+ * is unset, so setting it blind would silently drop a deployment's
858
+ * `PGOPTIONS` (its `search_path` or `statement_timeout`, not merely a slower
859
+ * plan). Both are read first and the GUC is appended to whichever applies.
860
+ *
861
+ * One deployment caveat: an `options` startup parameter is a connection-time
862
+ * parameter, and a connection pooler in front of Postgres may reject
863
+ * parameters it is not configured to pass through (PgBouncer's
864
+ * `ignore_startup_parameters`). A `SET` on checkout would survive that, at
865
+ * the cost of the race and the round trip above. Callers behind such a pooler
866
+ * should set the GUC on the server or role instead
867
+ * (`ALTER ROLE ... SET plan_cache_mode = ...`).
868
+ */
869
+ static withPlanCacheMode(poolConfig, mode) {
870
+ if (mode === undefined)
871
+ return poolConfig;
872
+ // `mode` is a member of PLAN_CACHE_MODES, never caller text (see
873
+ // resolvePlanCacheMode), which is what makes this literal safe: a GUC value
874
+ // cannot be a bind parameter.
875
+ const setting = `-c plan_cache_mode=${mode}`;
876
+ const merged = poolConfig.connectionString
877
+ ? TurbineClient.mergeConnectionStringOptions(poolConfig.connectionString, setting)
878
+ : null;
879
+ if (merged)
880
+ return { ...poolConfig, connectionString: merged };
881
+ // pg reads `config.options` when truthy and `process.env.PGOPTIONS`
882
+ // otherwise, so an unmerged setting would replace the caller's PGOPTIONS
883
+ // rather than add to it.
884
+ const existing = poolConfig.options || (typeof process !== 'undefined' ? process.env?.PGOPTIONS : undefined);
885
+ return { ...poolConfig, options: existing ? `${existing} ${setting}` : setting };
886
+ }
887
+ /**
888
+ * `connectionString` with `setting` appended to its existing `options` query
889
+ * parameter, or `null` when it carries no `options` (in which case the caller
890
+ * should use the `options` pool field, which is not overridden).
891
+ *
892
+ * Only the query string is rewritten, never the userinfo or host, so a
893
+ * percent-encoded password cannot be mangled by a round trip through `URL`.
894
+ * The split is on the first `?`, which is also where pg's own parser puts the
895
+ * query-string boundary: a connection string with an unencoded `?` inside the
896
+ * password is not parseable by pg either, so there is no shape this handles
897
+ * differently from the driver.
898
+ */
899
+ static mergeConnectionStringOptions(connectionString, setting) {
900
+ const q = connectionString.indexOf('?');
901
+ if (q === -1)
902
+ return null;
903
+ const params = new URLSearchParams(connectionString.slice(q + 1));
904
+ const existing = params.get('options');
905
+ if (existing === null)
906
+ return null;
907
+ params.set('options', `${existing} ${setting}`);
908
+ return connectionString.slice(0, q + 1) + params.toString();
909
+ }
910
+ /**
911
+ * Refuse a `utcTimestamps` value that contradicts the one an earlier client
912
+ * in this process settled the zone-less temporal read parsers (OIDs 1114,
913
+ * 1082, 1115, 1182) on.
741
914
  *
742
915
  * The flag has two halves. The WRITE half is per client: a bound `Date` on a
743
916
  * zone-less `date` / `timestamp` column is rewritten to a UTC literal unless
744
917
  * the owning client opted out (`coerceWriteValue` in query/writes.ts). The
745
- * READ half is the pg type parser for OID 1114, and `pg.types.setTypeParser`
746
- * installs ONE parser per OID for the whole process, shared by every pool,
747
- * every raw query, and any other library using the same pg module. There is
748
- * no per-pool parser hook to bind it to, and moving the coercion into
749
- * `parseRow` instead would leave every non-ORM read (raw SQL, `client.sql`,
750
- * a caller's own `pool.query`) on the driver's value while changing the
751
- * default path's output type, so the read half stays process-wide.
918
+ * READ half is the pg type parsers for OIDs 1114 / 1082 / 1115 / 1182, and
919
+ * `pg.types.setTypeParser` installs ONE parser per OID for the whole process,
920
+ * shared by every pool, every raw query, and any other library using the same
921
+ * pg module. There is no per-pool parser hook to bind it to, and moving the
922
+ * coercion into `parseRow` instead would leave every non-ORM read (raw SQL,
923
+ * `client.sql`, a caller's own `pool.query`) on the driver's value while
924
+ * changing the default path's output type, so the read half stays
925
+ * process-wide.
752
926
  *
753
927
  * That makes the mixed shape unserveable rather than merely awkward: the
754
928
  * second client would write local calendar fields and read them back as UTC
@@ -756,16 +930,23 @@ class TurbineClient {
756
930
  * A client that silently does not round-trip is the worst of the three
757
931
  * outcomes, so construction fails with the two ways out.
758
932
  *
759
- * Only Turbine-owned pools take part. An external pool (Neon, Vercel
760
- * Postgres, Hyperdrive) inherits the caller's parser configuration and
761
- * Turbine never registers on its behalf, so it has no read half to contradict.
933
+ * EVERY client takes part, not only the ones on a Turbine-owned pool. Only an
934
+ * owned pool REGISTERS the parsers, but registration is process-global, so a
935
+ * client on an external pool (Neon, Vercel Postgres, Hyperdrive) constructed
936
+ * alongside an owned one reads through them too. It is exactly the pairing
937
+ * that produced a silent read/write disagreement: an external-pool client
938
+ * with `utcTimestamps: false` writing local `date` literals while reading UTC
939
+ * ones, which walks the stored calendar day back a day per read-modify-write
940
+ * cycle. An external-pool client ALONE in a process is unaffected: it settles
941
+ * the value, registers nothing, and keeps the caller's parser configuration.
762
942
  */
763
943
  static assertUtcTimestampsAgree(want) {
764
944
  const settled = TurbineClient.utcTimestampParserMode;
765
945
  if (settled === undefined || settled === want)
766
946
  return;
767
947
  throw new errors_js_1.ValidationError(`[turbine] utcTimestamps: ${want} conflicts with utcTimestamps: ${settled}, which an earlier TurbineClient ` +
768
- 'in this process already applied. The timestamp READ parser (pg OID 1114) is process-global, so it cannot ' +
948
+ 'in this process already applied. The zone-less temporal READ parsers (pg OIDs 1114, 1082, 1115, 1182) are ' +
949
+ 'process-global, so they cannot ' +
769
950
  'differ per client, while the WRITE side is per client. Serving both values would give this client a ' +
770
951
  `${want ? 'UTC write' : 'local write'} and a ${settled ? 'UTC read' : 'local read'}, so every zone-less ` +
771
952
  '`timestamp` it writes would read back shifted by the process offset. Give every TurbineClient in this ' +
@@ -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:
@@ -61,6 +61,7 @@ exports.postgresDialect = {
61
61
  supportsListenNotify: true,
62
62
  supportsRLS: true,
63
63
  supportsAdvisoryLock: true,
64
+ supportsPlanCacheMode: true,
64
65
  supportsLateralJoin: true,
65
66
  explainQuery: { prefix: 'EXPLAIN' },
66
67
  paramPlaceholder(index) {
@@ -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/cjs/mssql.js CHANGED
@@ -509,6 +509,7 @@ exports.mssqlDialect = {
509
509
  // SQL Server has OUTER APPLY, not FROM-clause LATERAL: the lateral pick plan
510
510
  // is Postgres-only (out of scope here).
511
511
  supportsLateralJoin: false,
512
+ supportsPlanCacheMode: false,
512
513
  // sp_getapplock / sp_releaseapplock exist (used by a future migrate adapter).
513
514
  supportsAdvisoryLock: true,
514
515
  // No in-band EXPLAIN: SQL Server's SHOWPLAN is a session toggle
package/dist/cjs/mysql.js CHANGED
@@ -391,6 +391,7 @@ exports.mysqlDialect = {
391
391
  // MySQL 8.0.14+ supports LATERAL, but the opt-in lateral pick plan stays
392
392
  // Postgres-only in this release (flipping it on is a one-line change + tests).
393
393
  supportsLateralJoin: false,
394
+ supportsPlanCacheMode: false,
394
395
  // GET_LOCK / RELEASE_LOCK exist (used by a future migrate adapter).
395
396
  supportsAdvisoryLock: true,
396
397
  // Plain `EXPLAIN` (one row of tabular plan columns) works on every supported
package/dist/cjs/powdb.js CHANGED
@@ -159,6 +159,7 @@ exports.powdbDialect = {
159
159
  // PowQL has no LATERAL construct; PowqlInterface refuses pick ordering
160
160
  // earlier, this override keeps the flag truthful if a future path consults it.
161
161
  supportsLateralJoin: false,
162
+ supportsPlanCacheMode: false,
162
163
  beginStatement: () => 'begin',
163
164
  commitStatement: () => 'commit',
164
165
  rollbackStatement: () => 'rollback',
@@ -612,7 +612,8 @@ class QueryInterface {
612
612
  // the flag and rewrote binds the caller had opted out of.
613
613
  //
614
614
  // This half of the flag is PER CLIENT. The read half is not: it is the
615
- // pg OID 1114 type parser, which `pg.types.setTypeParser` installs once
615
+ // pg type parsers for OIDs 1114 / 1082 / 1115 / 1182, which
616
+ // `pg.types.setTypeParser` installs once
616
617
  // per process. Two clients in one process therefore cannot hold
617
618
  // different values, and TurbineClient refuses the second one rather than
618
619
  // building a client whose writes and reads disagree (see
@@ -3078,10 +3079,18 @@ class QueryInterface {
3078
3079
  // single Invalid Date, the column was unreadable on every strategy.
3079
3080
  // The join strategy's string arrays are handled upstream instead, by
3080
3081
  // the JSON-wire decode in relations.ts.
3082
+ //
3083
+ // A NUMBER is excluded for the same reason: the driver returns the
3084
+ // JS numbers `Infinity` / `-Infinity` for the Postgres `infinity` /
3085
+ // `-infinity` timestamp values, and re-coercing one ran
3086
+ // `parseDbDate(String(Infinity))` = `parseDbDate('Infinity')`, which
3087
+ // is an Invalid Date that JSON-encodes as null. The array form escaped
3088
+ // this only because it took the branch above.
3081
3089
  if ((dateCols.has(col) || camelDateFields.has(field)) &&
3082
3090
  value !== null &&
3083
3091
  !(value instanceof Date) &&
3084
- !Array.isArray(value)) {
3092
+ !Array.isArray(value) &&
3093
+ typeof value !== 'number') {
3085
3094
  // Offset-less strings (Postgres `timestamp`, json_agg output) are
3086
3095
  // pinned to UTC so results don't depend on the server's time zone.
3087
3096
  parsed[field] = this.utcTimestamps ? (0, utils_js_1.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)