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/README.md +7 -2
- package/dist/cjs/cli/mcp.js +6 -0
- package/dist/cjs/cli/studio.js +10 -0
- package/dist/cjs/client.d.ts +182 -25
- package/dist/cjs/client.js +210 -29
- package/dist/cjs/dialect.d.ts +24 -0
- package/dist/cjs/dialect.js +1 -0
- package/dist/cjs/index.d.ts +1 -1
- package/dist/cjs/mssql.js +1 -0
- package/dist/cjs/mysql.js +1 -0
- package/dist/cjs/powdb.js +1 -0
- package/dist/cjs/query/builder.js +11 -2
- package/dist/cjs/query/utils.d.ts +85 -1
- package/dist/cjs/query/utils.js +151 -1
- package/dist/cjs/query/warn-registry.d.ts +6 -0
- package/dist/cjs/query/warn-registry.js +6 -0
- package/dist/cjs/sqlite.js +1 -0
- package/dist/cli/mcp.js +6 -0
- package/dist/cli/studio.js +11 -1
- package/dist/client.d.ts +182 -25
- package/dist/client.js +211 -30
- package/dist/dialect.d.ts +24 -0
- package/dist/dialect.js +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/mssql.js +1 -0
- package/dist/mysql.js +1 -0
- package/dist/powdb.js +1 -0
- package/dist/query/builder.js +11 -2
- package/dist/query/utils.d.ts +85 -1
- package/dist/query/utils.js +146 -1
- package/dist/query/warn-registry.d.ts +6 -0
- package/dist/query/warn-registry.js +6 -0
- package/dist/sqlite.js +1 -0
- package/package.json +1 -1
package/dist/cjs/client.js
CHANGED
|
@@ -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
|
|
435
|
-
*
|
|
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.
|
|
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`
|
|
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
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
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 =
|
|
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
|
-
*
|
|
740
|
-
*
|
|
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
|
|
746
|
-
* installs ONE parser per OID for the whole process,
|
|
747
|
-
* every raw query, and any other library using the same
|
|
748
|
-
* no per-pool parser hook to bind it to, and moving the
|
|
749
|
-
* `parseRow` instead would leave every non-ORM read (raw SQL,
|
|
750
|
-
* a caller's own `pool.query`) on the driver's value while
|
|
751
|
-
* default path's output type, so the read half stays
|
|
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
|
-
*
|
|
760
|
-
*
|
|
761
|
-
*
|
|
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
|
|
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 ' +
|
package/dist/cjs/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/cjs/dialect.js
CHANGED
package/dist/cjs/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/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
|
|
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 (
|
|
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)
|