turbine-orm 0.81.0 → 0.90.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 +26 -25
- package/dist/checkout.d.ts +27 -2
- package/dist/checkout.js +75 -4
- package/dist/cjs/checkout.d.ts +27 -2
- package/dist/cjs/checkout.js +76 -3
- package/dist/cjs/cli/code-first-tags.d.ts +62 -0
- package/dist/cjs/cli/code-first-tags.js +124 -0
- package/dist/cjs/cli/compile-query.js +9 -0
- package/dist/cjs/cli/config.d.ts +35 -6
- package/dist/cjs/cli/config.js +17 -1
- package/dist/cjs/cli/destructive.d.ts +7 -4
- package/dist/cjs/cli/destructive.js +224 -20
- package/dist/cjs/cli/error-catalog.js +36 -0
- package/dist/cjs/cli/error-text.d.ts +15 -0
- package/dist/cjs/cli/error-text.js +40 -0
- package/dist/cjs/cli/index.d.ts +62 -4
- package/dist/cjs/cli/index.js +568 -108
- package/dist/cjs/cli/local-server-guard.d.ts +53 -0
- package/dist/cjs/cli/local-server-guard.js +119 -0
- package/dist/cjs/cli/local-server-output.d.ts +41 -0
- package/dist/cjs/cli/local-server-output.js +103 -0
- package/dist/cjs/cli/mcp.d.ts +8 -0
- package/dist/cjs/cli/mcp.js +121 -32
- package/dist/cjs/cli/migrate.d.ts +113 -5
- package/dist/cjs/cli/migrate.js +270 -21
- package/dist/cjs/cli/observe.d.ts +6 -1
- package/dist/cjs/cli/observe.js +85 -55
- package/dist/cjs/cli/pii-predicate-guard.d.ts +43 -23
- package/dist/cjs/cli/pii-predicate-guard.js +98 -37
- package/dist/cjs/cli/prisma-resolve.js +17 -13
- package/dist/cjs/cli/prisma-schema.js +12 -4
- package/dist/cjs/cli/studio-ui.generated.js +1 -1
- package/dist/cjs/cli/studio.d.ts +14 -2
- package/dist/cjs/cli/studio.js +244 -69
- package/dist/cjs/cli/ui.d.ts +1 -1
- package/dist/cjs/client.d.ts +558 -85
- package/dist/cjs/client.js +1270 -395
- package/dist/cjs/connection-guard.d.ts +13 -0
- package/dist/cjs/connection-guard.js +28 -0
- package/dist/cjs/connection-url.d.ts +51 -16
- package/dist/cjs/connection-url.js +67 -34
- package/dist/cjs/diagnostics.d.ts +81 -0
- package/dist/cjs/diagnostics.js +147 -0
- package/dist/cjs/dialect.d.ts +449 -20
- package/dist/cjs/dialect.js +117 -76
- package/dist/cjs/engine-config.d.ts +7 -6
- package/dist/cjs/errors.d.ts +126 -6
- package/dist/cjs/errors.js +256 -13
- package/dist/cjs/generate.d.ts +57 -7
- package/dist/cjs/generate.js +635 -279
- package/dist/cjs/global-filters-config.d.ts +59 -0
- package/dist/cjs/global-filters-config.js +367 -0
- package/dist/cjs/index.d.ts +9 -4
- package/dist/cjs/index.js +10 -2
- package/dist/cjs/introspect.d.ts +31 -6
- package/dist/cjs/introspect.js +108 -50
- package/dist/cjs/log-codes.d.ts +12 -0
- package/dist/cjs/log-codes.js +77 -0
- package/dist/cjs/logger.d.ts +87 -0
- package/dist/cjs/logger.js +98 -0
- package/dist/cjs/mssql.d.ts +8 -5
- package/dist/cjs/mssql.js +352 -65
- package/dist/cjs/mysql.d.ts +3 -5
- package/dist/cjs/mysql.js +420 -70
- package/dist/cjs/nested-write.d.ts +3 -0
- package/dist/cjs/nested-write.js +179 -114
- package/dist/cjs/pipeline-submittable.d.ts +12 -0
- package/dist/cjs/pipeline-submittable.js +118 -44
- package/dist/cjs/pipeline.js +21 -26
- package/dist/cjs/powdb-introspect.d.ts +5 -2
- package/dist/cjs/powdb-introspect.js +20 -15
- package/dist/cjs/powdb-shared.d.ts +36 -5
- package/dist/cjs/powdb-shared.js +67 -8
- package/dist/cjs/powdb.d.ts +4 -0
- package/dist/cjs/powdb.js +183 -51
- package/dist/cjs/powql.d.ts +394 -51
- package/dist/cjs/powql.js +1829 -284
- package/dist/cjs/prisma-compat.d.ts +23 -15
- package/dist/cjs/prisma-compat.js +507 -372
- package/dist/cjs/query/aggregates.d.ts +17 -1
- package/dist/cjs/query/aggregates.js +261 -29
- package/dist/cjs/query/batched-loader.d.ts +25 -6
- package/dist/cjs/query/batched-loader.js +103 -20
- package/dist/cjs/query/builder.d.ts +325 -184
- package/dist/cjs/query/builder.js +1452 -490
- package/dist/cjs/query/column-ref.d.ts +61 -0
- package/dist/cjs/query/column-ref.js +73 -0
- package/dist/cjs/query/compound-unique.d.ts +21 -0
- package/dist/cjs/query/compound-unique.js +98 -18
- package/dist/cjs/query/deferred.d.ts +53 -16
- package/dist/cjs/query/expr.d.ts +217 -0
- package/dist/cjs/query/expr.js +859 -0
- package/dist/cjs/query/filters.d.ts +155 -53
- package/dist/cjs/query/filters.js +613 -76
- package/dist/cjs/query/index.d.ts +2 -0
- package/dist/cjs/query/index.js +7 -1
- package/dist/cjs/query/json-value.d.ts +43 -0
- package/dist/cjs/query/json-value.js +59 -0
- package/dist/cjs/query/normalize-args.d.ts +87 -0
- package/dist/cjs/query/normalize-args.js +527 -0
- package/dist/cjs/query/option-surface.d.ts +16 -4
- package/dist/cjs/query/option-surface.js +57 -14
- package/dist/cjs/query/relation-names.d.ts +14 -0
- package/dist/cjs/query/relation-names.js +77 -18
- package/dist/cjs/query/relations.d.ts +30 -3
- package/dist/cjs/query/relations.js +219 -63
- package/dist/cjs/query/row-lock.d.ts +49 -0
- package/dist/cjs/query/row-lock.js +138 -0
- package/dist/cjs/query/sql-comment.d.ts +161 -0
- package/dist/cjs/query/sql-comment.js +381 -0
- package/dist/cjs/query/stream-idle.d.ts +132 -0
- package/dist/cjs/query/stream-idle.js +212 -0
- package/dist/cjs/query/table-client.d.ts +101 -0
- package/dist/cjs/query/table-client.js +74 -0
- package/dist/cjs/query/types.d.ts +677 -298
- package/dist/cjs/query/utils.d.ts +105 -6
- package/dist/cjs/query/utils.js +426 -19
- package/dist/cjs/query/warn-registry.d.ts +18 -0
- package/dist/cjs/query/warn-registry.js +18 -0
- package/dist/cjs/query/where-compile.d.ts +138 -14
- package/dist/cjs/query/where-compile.js +513 -29
- package/dist/cjs/query/where.d.ts +159 -59
- package/dist/cjs/query/where.js +716 -246
- package/dist/cjs/query/writes.d.ts +112 -50
- package/dist/cjs/query/writes.js +1498 -200
- package/dist/cjs/query-events.d.ts +4 -2
- package/dist/cjs/query-events.js +3 -1
- package/dist/cjs/realtime.d.ts +7 -3
- package/dist/cjs/realtime.js +13 -2
- package/dist/cjs/schema-builder.d.ts +55 -4
- package/dist/cjs/schema-builder.js +382 -18
- package/dist/cjs/schema-metadata.d.ts +21 -1
- package/dist/cjs/schema-metadata.js +29 -7
- package/dist/cjs/schema-sql.d.ts +75 -5
- package/dist/cjs/schema-sql.js +497 -45
- package/dist/cjs/schema.d.ts +26 -5
- package/dist/cjs/schema.js +110 -13
- package/dist/cjs/sql-placeholders.d.ts +56 -0
- package/dist/cjs/sql-placeholders.js +204 -0
- package/dist/cjs/sqlite.d.ts +75 -20
- package/dist/cjs/sqlite.js +593 -95
- package/dist/cjs/supplied-pool-types.d.ts +48 -0
- package/dist/cjs/supplied-pool-types.js +256 -0
- package/dist/cjs/transaction-control.d.ts +51 -0
- package/dist/cjs/transaction-control.js +316 -0
- package/dist/cjs/transaction-state.d.ts +342 -0
- package/dist/cjs/transaction-state.js +707 -0
- package/dist/cjs/typed-sql.d.ts +37 -3
- package/dist/cjs/typed-sql.js +102 -8
- package/dist/cli/code-first-tags.d.ts +62 -0
- package/dist/cli/code-first-tags.js +120 -0
- package/dist/cli/compile-query.js +9 -0
- package/dist/cli/config.d.ts +35 -6
- package/dist/cli/config.js +16 -1
- package/dist/cli/destructive.d.ts +7 -4
- package/dist/cli/destructive.js +224 -20
- package/dist/cli/error-catalog.js +36 -0
- package/dist/cli/error-text.d.ts +15 -0
- package/dist/cli/error-text.js +37 -0
- package/dist/cli/index.d.ts +62 -4
- package/dist/cli/index.js +569 -112
- package/dist/cli/local-server-guard.d.ts +53 -0
- package/dist/cli/local-server-guard.js +113 -0
- package/dist/cli/local-server-output.d.ts +41 -0
- package/dist/cli/local-server-output.js +98 -0
- package/dist/cli/mcp.d.ts +8 -0
- package/dist/cli/mcp.js +121 -34
- package/dist/cli/migrate.d.ts +113 -5
- package/dist/cli/migrate.js +261 -21
- package/dist/cli/observe.d.ts +6 -1
- package/dist/cli/observe.js +84 -54
- package/dist/cli/pii-predicate-guard.d.ts +43 -23
- package/dist/cli/pii-predicate-guard.js +100 -35
- package/dist/cli/prisma-resolve.js +15 -11
- package/dist/cli/prisma-schema.js +12 -4
- package/dist/cli/studio-ui.generated.js +1 -1
- package/dist/cli/studio.d.ts +14 -2
- package/dist/cli/studio.js +246 -71
- package/dist/cli/ui.d.ts +1 -1
- package/dist/client.d.ts +558 -85
- package/dist/client.js +1275 -400
- package/dist/connection-guard.d.ts +13 -0
- package/dist/connection-guard.js +27 -0
- package/dist/connection-url.d.ts +51 -16
- package/dist/connection-url.js +63 -34
- package/dist/diagnostics.d.ts +81 -0
- package/dist/diagnostics.js +144 -0
- package/dist/dialect.d.ts +449 -20
- package/dist/dialect.js +115 -43
- package/dist/engine-config.d.ts +7 -6
- package/dist/errors.d.ts +126 -6
- package/dist/errors.js +252 -12
- package/dist/generate.d.ts +57 -7
- package/dist/generate.js +635 -281
- package/dist/global-filters-config.d.ts +59 -0
- package/dist/global-filters-config.js +361 -0
- package/dist/index.d.ts +9 -4
- package/dist/index.js +4 -2
- package/dist/introspect.d.ts +31 -6
- package/dist/introspect.js +106 -50
- package/dist/log-codes.d.ts +12 -0
- package/dist/log-codes.js +74 -0
- package/dist/logger.d.ts +87 -0
- package/dist/logger.js +93 -0
- package/dist/mssql.d.ts +8 -5
- package/dist/mssql.js +354 -67
- package/dist/mysql.d.ts +3 -5
- package/dist/mysql.js +423 -73
- package/dist/nested-write.d.ts +3 -0
- package/dist/nested-write.js +177 -112
- package/dist/pipeline-submittable.d.ts +12 -0
- package/dist/pipeline-submittable.js +117 -44
- package/dist/pipeline.js +21 -26
- package/dist/powdb-introspect.d.ts +5 -2
- package/dist/powdb-introspect.js +21 -16
- package/dist/powdb-shared.d.ts +36 -5
- package/dist/powdb-shared.js +65 -8
- package/dist/powdb.d.ts +4 -0
- package/dist/powdb.js +183 -51
- package/dist/powql.d.ts +394 -51
- package/dist/powql.js +1836 -291
- package/dist/prisma-compat.d.ts +23 -15
- package/dist/prisma-compat.js +508 -373
- package/dist/query/aggregates.d.ts +17 -1
- package/dist/query/aggregates.js +263 -32
- package/dist/query/batched-loader.d.ts +25 -6
- package/dist/query/batched-loader.js +101 -21
- package/dist/query/builder.d.ts +325 -184
- package/dist/query/builder.js +1458 -495
- package/dist/query/column-ref.d.ts +61 -0
- package/dist/query/column-ref.js +66 -0
- package/dist/query/compound-unique.d.ts +21 -0
- package/dist/query/compound-unique.js +97 -19
- package/dist/query/deferred.d.ts +53 -16
- package/dist/query/expr.d.ts +217 -0
- package/dist/query/expr.js +848 -0
- package/dist/query/filters.d.ts +155 -53
- package/dist/query/filters.js +597 -72
- package/dist/query/index.d.ts +2 -0
- package/dist/query/index.js +4 -0
- package/dist/query/json-value.d.ts +43 -0
- package/dist/query/json-value.js +54 -0
- package/dist/query/normalize-args.d.ts +87 -0
- package/dist/query/normalize-args.js +517 -0
- package/dist/query/option-surface.d.ts +16 -4
- package/dist/query/option-surface.js +56 -13
- package/dist/query/relation-names.d.ts +14 -0
- package/dist/query/relation-names.js +77 -19
- package/dist/query/relations.d.ts +30 -3
- package/dist/query/relations.js +220 -67
- package/dist/query/row-lock.d.ts +49 -0
- package/dist/query/row-lock.js +131 -0
- package/dist/query/sql-comment.d.ts +161 -0
- package/dist/query/sql-comment.js +374 -0
- package/dist/query/stream-idle.d.ts +132 -0
- package/dist/query/stream-idle.js +203 -0
- package/dist/query/table-client.d.ts +101 -0
- package/dist/query/table-client.js +70 -0
- package/dist/query/types.d.ts +677 -298
- package/dist/query/utils.d.ts +105 -6
- package/dist/query/utils.js +414 -18
- package/dist/query/warn-registry.d.ts +18 -0
- package/dist/query/warn-registry.js +18 -0
- package/dist/query/where-compile.d.ts +138 -14
- package/dist/query/where-compile.js +508 -31
- package/dist/query/where.d.ts +159 -59
- package/dist/query/where.js +711 -248
- package/dist/query/writes.d.ts +112 -50
- package/dist/query/writes.js +1496 -202
- package/dist/query-events.d.ts +4 -2
- package/dist/query-events.js +3 -1
- package/dist/realtime.d.ts +7 -3
- package/dist/realtime.js +13 -2
- package/dist/schema-builder.d.ts +55 -4
- package/dist/schema-builder.js +381 -18
- package/dist/schema-metadata.d.ts +21 -1
- package/dist/schema-metadata.js +28 -7
- package/dist/schema-sql.d.ts +75 -5
- package/dist/schema-sql.js +494 -44
- package/dist/schema.d.ts +26 -5
- package/dist/schema.js +109 -13
- package/dist/sql-placeholders.d.ts +56 -0
- package/dist/sql-placeholders.js +200 -0
- package/dist/sqlite.d.ts +75 -20
- package/dist/sqlite.js +597 -99
- package/dist/supplied-pool-types.d.ts +48 -0
- package/dist/supplied-pool-types.js +247 -0
- package/dist/transaction-control.d.ts +51 -0
- package/dist/transaction-control.js +311 -0
- package/dist/transaction-state.d.ts +342 -0
- package/dist/transaction-state.js +696 -0
- package/dist/typed-sql.d.ts +37 -3
- package/dist/typed-sql.js +100 -8
- package/package.json +7 -4
- package/skills/turbine-orm/SKILL.md +56 -6
package/dist/client.js
CHANGED
|
@@ -22,19 +22,29 @@
|
|
|
22
22
|
* ```
|
|
23
23
|
*/
|
|
24
24
|
import pg from '#pg';
|
|
25
|
-
import { openCheckout } from './checkout.js';
|
|
26
|
-
import { absorbCheckedOutErrors, settleLongIdleCheckouts } from './connection-guard.js';
|
|
27
|
-
import {
|
|
25
|
+
import { openCheckout, typeCheckoutTimeouts } from './checkout.js';
|
|
26
|
+
import { absorbCheckedOutErrors, settleLongIdleCheckouts, watchCheckouts, } from './connection-guard.js';
|
|
27
|
+
import { detectPoolerTarget, fillTargetFromEnvironment, parseConnectionTarget, statementTimeoutSetting, withStartupSetting, } from './connection-url.js';
|
|
28
|
+
import { resolveDiagnostics } from './diagnostics.js';
|
|
28
29
|
import { postgresDialect } from './dialect.js';
|
|
29
30
|
import { ConnectionError, errorMessageModesDiverged, explainConnectionLoss, PipelineError, registerClientErrorMessageMode, runWithErrorMessageMode, setErrorMessageMode, TimeoutError, UnsupportedFeatureError, ValidationError, wrapPgError, } from './errors.js';
|
|
31
|
+
import { normalizeGlobalFilters, tableAccessorName } from './global-filters-config.js';
|
|
32
|
+
import { CLIENT_LOG_CONTEXT, logTo, resolveLogger } from './logger.js';
|
|
33
|
+
import { hasRelationFields } from './nested-write.js';
|
|
30
34
|
import { ObserveEngine } from './observe.js';
|
|
31
35
|
import { executePipeline, pipelineSupported } from './pipeline.js';
|
|
32
36
|
import { QueryInterface, } from './query/index.js';
|
|
33
|
-
import {
|
|
37
|
+
import { assertNoLockInAutocommitPipeline } from './query/row-lock.js';
|
|
38
|
+
import { commentDeferred, resolveSqlComment } from './query/sql-comment.js';
|
|
39
|
+
import { resolveStreamIdleTimeout } from './query/stream-idle.js';
|
|
40
|
+
import { asQueryInterface, TABLE_METHODS } from './query/table-client.js';
|
|
41
|
+
import { ownLookup, quoteIdent, registerUtcTemporalParsers, sendCancelRequest, suggestKey, warnParserOverwrite, } from './query/utils.js';
|
|
34
42
|
import { shouldWarnOnce, WARN_NS } from './query/warn-registry.js';
|
|
35
43
|
import { currentQueryTag, deferredIdentity, emitStatementEvent, rawIdentity, resultRowCount, runReportedStatement, runWithQueryTag, } from './query-events.js';
|
|
36
44
|
import { createSubscription, validateChannel, } from './realtime.js';
|
|
37
|
-
import {
|
|
45
|
+
import { parseInt8, rawClientOf, typedQueryPool } from './supplied-pool-types.js';
|
|
46
|
+
import { commitTransaction, rolledBackError, TRANSACTION_STATE, TransactionState, unawaitedWorkError, } from './transaction-state.js';
|
|
47
|
+
import { buildTypedSql, TypedSqlQuery, utcDateParams } from './typed-sql.js';
|
|
38
48
|
export async function withRetry(fn, options) {
|
|
39
49
|
const maxAttempts = options?.maxAttempts ?? 3;
|
|
40
50
|
const baseDelay = options?.baseDelay ?? 50;
|
|
@@ -103,11 +113,17 @@ const TURBINE_CONFIG_KEYS = {
|
|
|
103
113
|
idleTimeoutMillis: true,
|
|
104
114
|
connectionTimeoutMillis: true,
|
|
105
115
|
logging: true,
|
|
116
|
+
logger: true,
|
|
117
|
+
diagnostics: true,
|
|
106
118
|
defaultLimit: true,
|
|
107
119
|
warnOnUnlimited: true,
|
|
108
120
|
utcTimestamps: true,
|
|
109
121
|
temporalInfinity: true,
|
|
110
122
|
planCacheMode: true,
|
|
123
|
+
statementTimeoutMs: true,
|
|
124
|
+
maxLifetimeSeconds: true,
|
|
125
|
+
streamIdleTimeoutMs: true,
|
|
126
|
+
sqlComment: true,
|
|
111
127
|
scopedConnect: true,
|
|
112
128
|
relationLoadStrategy: true,
|
|
113
129
|
stableRelationOrder: true,
|
|
@@ -155,8 +171,7 @@ function suggestConfigKey(key) {
|
|
|
155
171
|
return suggestKey(key, CONFIG_KEY_SET);
|
|
156
172
|
}
|
|
157
173
|
/**
|
|
158
|
-
*
|
|
159
|
-
* surface.
|
|
174
|
+
* Notice for a key on the config object that is not part of the config surface.
|
|
160
175
|
*
|
|
161
176
|
* An unknown key is silently ignored (JavaScript objects have no schema), which
|
|
162
177
|
* makes a typo or a wrong guess indistinguishable from a broken feature: a
|
|
@@ -168,12 +183,10 @@ function suggestConfigKey(key) {
|
|
|
168
183
|
* turbine that passes a key this version has not heard of must keep running,
|
|
169
184
|
* and the whole check is wrapped so that a hostile / exotic config object
|
|
170
185
|
* (a Proxy whose `ownKeys` throws) cannot take down the constructor either.
|
|
171
|
-
*
|
|
172
|
-
*
|
|
186
|
+
* Always on, whatever `diagnostics` says: an ignored option is ignored in
|
|
187
|
+
* production too, and the line fires once per key per process.
|
|
173
188
|
*/
|
|
174
|
-
function warnUnknownConfigKeys(config) {
|
|
175
|
-
if (process.env.NODE_ENV === 'production')
|
|
176
|
-
return;
|
|
189
|
+
function warnUnknownConfigKeys(config, logger) {
|
|
177
190
|
try {
|
|
178
191
|
for (const key of Object.keys(config)) {
|
|
179
192
|
if (CONFIG_KEY_SET.has(key) || NON_CONFIG_KEYS.has(key))
|
|
@@ -181,8 +194,8 @@ function warnUnknownConfigKeys(config) {
|
|
|
181
194
|
if (!shouldWarnOnce(WARN_NS.unknownConfigKey, key))
|
|
182
195
|
continue;
|
|
183
196
|
const suggestion = suggestConfigKey(key);
|
|
184
|
-
|
|
185
|
-
(suggestion ? ` Did you mean "${suggestion}"?` : ''));
|
|
197
|
+
logTo(logger, 'warn', `Unknown option "${key}" in the config passed to TurbineClient, it is ignored.` +
|
|
198
|
+
(suggestion ? ` Did you mean "${suggestion}"?` : ''), { code: 'unknown-config-key', key, ...(suggestion ? { suggestion } : {}) });
|
|
186
199
|
}
|
|
187
200
|
}
|
|
188
201
|
catch {
|
|
@@ -329,20 +342,6 @@ const ISOLATION_LEVELS = Object.assign(Object.create(null), {
|
|
|
329
342
|
RepeatableRead: 'REPEATABLE READ',
|
|
330
343
|
Serializable: 'SERIALIZABLE',
|
|
331
344
|
});
|
|
332
|
-
/**
|
|
333
|
-
* Resolve a requested isolation level, REFUSING anything not in the map.
|
|
334
|
-
*
|
|
335
|
-
* A miss used to yield `undefined`, which `beginStatement` renders as a plain
|
|
336
|
-
* `BEGIN`. So `isolationLevel: 'serializable'` (wrong case) asked for
|
|
337
|
-
* SERIALIZABLE and silently got READ COMMITTED, which is the worst available
|
|
338
|
-
* outcome for this option: the caller believes it has a guarantee it does not
|
|
339
|
-
* have, and the workload that depended on it produces wrong data with no error.
|
|
340
|
-
*
|
|
341
|
-
* The TypeScript union on `TransactionOptions.isolationLevel` does not prevent
|
|
342
|
-
* this. It is not enforced for a JavaScript consumer of a published package, for
|
|
343
|
-
* a value read from config or an environment variable, or across any `as`.
|
|
344
|
-
* Throwing costs a caller who was already broken one clear error.
|
|
345
|
-
*/
|
|
346
345
|
/**
|
|
347
346
|
* Run a transaction-control statement (BEGIN / COMMIT / ROLLBACK) with the same
|
|
348
347
|
* error translation every other query boundary gets.
|
|
@@ -358,20 +357,101 @@ const ISOLATION_LEVELS = Object.assign(Object.create(null), {
|
|
|
358
357
|
* loops, and a loop keyed on it would never fire for the commit-time conflicts
|
|
359
358
|
* that are the main reason to run SERIALIZABLE at all.
|
|
360
359
|
*/
|
|
361
|
-
async function runTxControl(client, sql) {
|
|
360
|
+
async function runTxControl(client, sql, params) {
|
|
362
361
|
try {
|
|
363
|
-
await client.query(sql);
|
|
362
|
+
await client.query(sql, params);
|
|
364
363
|
}
|
|
365
364
|
catch (err) {
|
|
366
365
|
throw wrapPgError(err);
|
|
367
366
|
}
|
|
368
367
|
}
|
|
368
|
+
/**
|
|
369
|
+
* Refuse anything in a batch that is not a deferred query, before any of it
|
|
370
|
+
* runs.
|
|
371
|
+
*
|
|
372
|
+
* The array forms take the descriptors `build*()` returns. Prisma's array form
|
|
373
|
+
* takes promises instead, so code ported from it passes `db.users.create(...)`,
|
|
374
|
+
* whose query has ALREADY started on its own connection, outside any
|
|
375
|
+
* transaction, by the time the array exists. That cannot be undone here, so
|
|
376
|
+
* the error says so and names the builder to use instead. The promise's own
|
|
377
|
+
* rejection is observed, so this refusal is the error the caller sees rather
|
|
378
|
+
* than an unhandled rejection from a query nobody awaits.
|
|
379
|
+
*/
|
|
380
|
+
function assertDeferredQueries(queries, api, unit) {
|
|
381
|
+
queries.forEach((q, i) => {
|
|
382
|
+
if (q !== null &&
|
|
383
|
+
(typeof q === 'object' || typeof q === 'function') &&
|
|
384
|
+
typeof q.then === 'function') {
|
|
385
|
+
Promise.resolve(q).catch(() => { });
|
|
386
|
+
throw new ValidationError(`${api}: item ${i} is a Promise. Its query started running on its own the moment it was created, ` +
|
|
387
|
+
`outside this ${unit}, and may already have committed. Pass the deferred form instead, built with ` +
|
|
388
|
+
'`build*()`: `db.users.buildCreate(...)` rather than `db.users.create(...)` (likewise `buildUpdate`, ' +
|
|
389
|
+
'`buildDelete`, `buildUpsert`, `buildFindMany`).' +
|
|
390
|
+
(unit === 'transaction' ? ' Or use the callback form, `$transaction(async (tx) => { ... })`.' : ''));
|
|
391
|
+
}
|
|
392
|
+
const dq = q;
|
|
393
|
+
if (!dq || typeof dq !== 'object' || typeof dq.sql !== 'string' || typeof dq.transform !== 'function') {
|
|
394
|
+
throw new ValidationError(`${api}: item ${i} is not a deferred query. Build each item with a \`build*()\` method, ` +
|
|
395
|
+
'for example `db.users.buildCreate({ data })` or `db.posts.buildCount({ where })`.');
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
/** The PG* variables pg reads for a connection target when its config leaves the field unset. */
|
|
400
|
+
const PG_ENV_TARGETS = ['PGHOST', 'PGPORT', 'PGDATABASE', 'PGUSER'];
|
|
401
|
+
/** A non-empty environment variable, or undefined (also where `process` does not exist). */
|
|
402
|
+
function pgEnv(name) {
|
|
403
|
+
return (typeof process !== 'undefined' ? process.env?.[name] : undefined) || undefined;
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Whether an owned pool's config names a database at all: a connection
|
|
407
|
+
* string, any host setting, DATABASE_URL (unless an explicit, empty
|
|
408
|
+
* `connectionString` suppresses it, as the pool branch does), or a PG*
|
|
409
|
+
* variable pg will read.
|
|
410
|
+
*/
|
|
411
|
+
function hasDatabaseTarget(config) {
|
|
412
|
+
if (config.connectionString)
|
|
413
|
+
return true;
|
|
414
|
+
if (config.host != null ||
|
|
415
|
+
config.port != null ||
|
|
416
|
+
config.database != null ||
|
|
417
|
+
config.user != null ||
|
|
418
|
+
config.password != null) {
|
|
419
|
+
return true;
|
|
420
|
+
}
|
|
421
|
+
if (config.connectionString == null && pgEnv('DATABASE_URL'))
|
|
422
|
+
return true;
|
|
423
|
+
return PG_ENV_TARGETS.some((name) => pgEnv(name) !== undefined);
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* The pool of a client that was given no database: every query and checkout
|
|
427
|
+
* rejects with a {@link ConnectionError} naming the fix, and `disconnect()`
|
|
428
|
+
* succeeds. Building SQL never touches the pool, so it still works.
|
|
429
|
+
*/
|
|
430
|
+
function unconfiguredPool(emptyConnectionString) {
|
|
431
|
+
const fail = () => Promise.reject(new ConnectionError('No database configured: ' +
|
|
432
|
+
(emptyConnectionString
|
|
433
|
+
? "`connectionString` is an empty string, which names no database (often `process.env.DATABASE_URL ?? ''` " +
|
|
434
|
+
'evaluated before DATABASE_URL was loaded). '
|
|
435
|
+
: 'this client was given no `connectionString`, `pool` or host settings, and the DATABASE_URL environment ' +
|
|
436
|
+
'variable is not set. ') +
|
|
437
|
+
'turbine-orm does not read `.env` files: load yours before the app starts (`node --env-file=.env app.js`, ' +
|
|
438
|
+
'or `process.loadEnvFile()` on Node 20.12 and later), or pass `connectionString` explicitly.'));
|
|
439
|
+
return { query: fail, connect: fail, end: async () => { }, on: () => { } };
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Resolve a requested isolation level, REFUSING anything not in the map.
|
|
443
|
+
*
|
|
444
|
+
* A miss rendered as a plain `BEGIN` would be the worst outcome for this
|
|
445
|
+
* option: `isolationLevel: 'serializable'` (wrong case) would ask for
|
|
446
|
+
* SERIALIZABLE and silently get READ COMMITTED. The TypeScript union does not
|
|
447
|
+
* prevent it for a JavaScript caller, a value read from config, or any `as`.
|
|
448
|
+
*/
|
|
369
449
|
function resolveIsolationLevel(level) {
|
|
370
450
|
if (level === undefined)
|
|
371
451
|
return undefined;
|
|
372
452
|
const sql = ownLookup(ISOLATION_LEVELS, level);
|
|
373
453
|
if (sql === undefined) {
|
|
374
|
-
throw new ValidationError(`$transaction: unknown isolationLevel ${
|
|
454
|
+
throw new ValidationError(`$transaction: unknown isolationLevel ${describeValue(level)}. ` +
|
|
375
455
|
`Expected one of: ${Object.keys(ISOLATION_LEVELS).join(', ')} (case-sensitive).`);
|
|
376
456
|
}
|
|
377
457
|
return sql;
|
|
@@ -384,34 +464,274 @@ function resolveIsolationLevel(level) {
|
|
|
384
464
|
*/
|
|
385
465
|
const GUC_NAME_REGEX = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$/;
|
|
386
466
|
/**
|
|
387
|
-
*
|
|
388
|
-
*
|
|
389
|
-
*
|
|
467
|
+
* How long a transaction being abandoned (timed out, failed, or returned with
|
|
468
|
+
* work still running) gives its in-flight statement to answer a cancel
|
|
469
|
+
* request, and its ROLLBACK to answer, before the connection is destroyed
|
|
470
|
+
* instead of reused.
|
|
471
|
+
*/
|
|
472
|
+
const TX_ABORT_GRACE_MS = 2000;
|
|
473
|
+
/** Default {@link TurbineConfig.maxLifetimeSeconds} on pools Turbine opens. */
|
|
474
|
+
const DEFAULT_MAX_LIFETIME_SECONDS = 1800;
|
|
475
|
+
/**
|
|
476
|
+
* A value as an error message should quote it. `JSON.stringify` writes `NaN` and
|
|
477
|
+
* `Infinity` as `null`, throws on a bigint, and returns `undefined` for a symbol
|
|
478
|
+
* or a function, so a message built from it can name a value the caller never
|
|
479
|
+
* passed.
|
|
480
|
+
*/
|
|
481
|
+
function describeValue(value) {
|
|
482
|
+
switch (typeof value) {
|
|
483
|
+
case 'string':
|
|
484
|
+
return JSON.stringify(value);
|
|
485
|
+
case 'bigint':
|
|
486
|
+
return `${value}n`;
|
|
487
|
+
case 'symbol':
|
|
488
|
+
return value.toString();
|
|
489
|
+
case 'function':
|
|
490
|
+
return 'a function';
|
|
491
|
+
case 'object':
|
|
492
|
+
return value === null ? 'null' : Array.isArray(value) ? 'an array' : 'an object';
|
|
493
|
+
default:
|
|
494
|
+
return String(value);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* `allowBuiltinSettings` as a lower-cased set (PostgreSQL folds setting names).
|
|
499
|
+
* Each entry must be a plain un-dotted setting name; anything else is E003.
|
|
390
500
|
*/
|
|
501
|
+
function resolveBuiltinSettingsAllowlist(value) {
|
|
502
|
+
if (value === undefined)
|
|
503
|
+
return new Set();
|
|
504
|
+
if (!Array.isArray(value)) {
|
|
505
|
+
throw new ValidationError(`$transaction: allowBuiltinSettings must be an array of setting names, received ${typeof value}.`);
|
|
506
|
+
}
|
|
507
|
+
const names = new Set();
|
|
508
|
+
for (const name of value) {
|
|
509
|
+
if (typeof name !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
|
|
510
|
+
throw new ValidationError(`$transaction: allowBuiltinSettings entry ${describeValue(name)} is not a PostgreSQL setting name ` +
|
|
511
|
+
'(a plain identifier such as "statement_timeout"). Namespaced custom settings need no entry.');
|
|
512
|
+
}
|
|
513
|
+
names.add(name.toLowerCase());
|
|
514
|
+
}
|
|
515
|
+
return names;
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* SQLSTATEs with which the server refuses a `set_config`, and what each means
|
|
519
|
+
* for the caller. A function of the setting where the cause depends on it: the
|
|
520
|
+
* same SQLSTATE says "no such role" for `role` and "unreadable duration" for
|
|
521
|
+
* `statement_timeout`.
|
|
522
|
+
*/
|
|
523
|
+
const SESSION_SETTING_REFUSALS = Object.assign(Object.create(null), {
|
|
524
|
+
'22023': (setting) => setting === 'role' || setting === 'session_authorization'
|
|
525
|
+
? 'the value is not valid for this setting (the role must exist)'
|
|
526
|
+
: 'the value is not valid for this setting',
|
|
527
|
+
'42704': () => 'the server has no setting by that name',
|
|
528
|
+
'42501': () => 'the database role is not allowed to change it',
|
|
529
|
+
'55P02': () => 'it cannot be changed after the connection has started',
|
|
530
|
+
});
|
|
391
531
|
/**
|
|
392
|
-
*
|
|
532
|
+
* Apply one `sessionContext` entry. A refusal by the server names the setting
|
|
533
|
+
* that caused it rather than reading as a column or type error; the server's
|
|
534
|
+
* own message (which may contain the value) stays on `cause`.
|
|
535
|
+
*/
|
|
536
|
+
async function applySessionSetting(client, setting) {
|
|
537
|
+
try {
|
|
538
|
+
await client.query(setting.sql, setting.params);
|
|
539
|
+
}
|
|
540
|
+
catch (err) {
|
|
541
|
+
const code = err?.code;
|
|
542
|
+
const describe = typeof code === 'string' ? SESSION_SETTING_REFUSALS[code] : undefined;
|
|
543
|
+
if (describe === undefined)
|
|
544
|
+
throw wrapPgError(err);
|
|
545
|
+
throw new ValidationError(`sessionContext["${setting.name}"] was refused by the server: ${describe(setting.name.toLowerCase())} (SQLSTATE ${code}). ` +
|
|
546
|
+
'The transaction was rolled back before your callback ran.', { cause: err });
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
/** A non-negative safe integer config value, or `undefined` when unset. Anything else is E003. */
|
|
550
|
+
function resolveNonNegativeInteger(value, name, unit) {
|
|
551
|
+
if (value === undefined || value === null)
|
|
552
|
+
return undefined;
|
|
553
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {
|
|
554
|
+
throw new ValidationError(`Invalid ${name}: ${typeof value === 'string' ? JSON.stringify(value) : String(value)}. Expected a ` +
|
|
555
|
+
`non-negative whole number of ${unit}.`);
|
|
556
|
+
}
|
|
557
|
+
return value;
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* Refuse a dialect with no `capabilities` object (E003): every code path asks
|
|
561
|
+
* it what the engine can do, so a hand-built dialect without one would fail on
|
|
562
|
+
* its first query with a TypeError instead of here.
|
|
563
|
+
*/
|
|
564
|
+
function assertDialectCapabilities(dialect) {
|
|
565
|
+
const capabilities = dialect.capabilities;
|
|
566
|
+
if (typeof capabilities === 'object' && capabilities !== null)
|
|
567
|
+
return;
|
|
568
|
+
throw new ValidationError(`The dialect "${String(dialect.name)}" has no \`capabilities\` object, which says what the engine can do. ` +
|
|
569
|
+
'Build it from an engine dialect (`{ ...postgresDialect, name, capabilities: { ... } }`) and state every ' +
|
|
570
|
+
'EngineCapabilities key.');
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* The statement that opens a transaction, read-only when asked. PostgreSQL and
|
|
574
|
+
* MySQL both accept `READ ONLY` after their own BEGIN form (`BEGIN [ISOLATION
|
|
575
|
+
* LEVEL x] READ ONLY`, `[SET TRANSACTION ISOLATION LEVEL x; ]START TRANSACTION
|
|
576
|
+
* READ ONLY`); the other engines have no read-only transaction.
|
|
577
|
+
*/
|
|
578
|
+
function beginStatementFor(dialect, isolationSql, readOnly) {
|
|
579
|
+
const begin = dialect.beginStatement(isolationSql);
|
|
580
|
+
if (!readOnly)
|
|
581
|
+
return begin;
|
|
582
|
+
if (dialect.capabilities.readOnlyTransactions)
|
|
583
|
+
return `${begin} READ ONLY`;
|
|
584
|
+
throw new UnsupportedFeatureError('$transaction({ readOnly: true })', dialect.name, 'This engine has no read-only transaction mode. Drop the option, or enforce read-only access with a ' +
|
|
585
|
+
'database role that has no write privileges.');
|
|
586
|
+
}
|
|
587
|
+
/** `readOnly` as a boolean; anything but a boolean or `undefined` is E003. */
|
|
588
|
+
function resolveReadOnly(value) {
|
|
589
|
+
if (value === undefined || value === false)
|
|
590
|
+
return false;
|
|
591
|
+
if (value === true)
|
|
592
|
+
return true;
|
|
593
|
+
throw new ValidationError(`$transaction: readOnly must be true or false, received ${describeValue(value)}.`);
|
|
594
|
+
}
|
|
595
|
+
/** Whether `pending` settles (either way) within `ms`. */
|
|
596
|
+
async function settlesWithin(pending, ms) {
|
|
597
|
+
let timer;
|
|
598
|
+
const late = new Promise((resolve) => {
|
|
599
|
+
timer = setTimeout(() => resolve(false), ms);
|
|
600
|
+
});
|
|
601
|
+
try {
|
|
602
|
+
return await Promise.race([
|
|
603
|
+
pending.then(() => true, () => true),
|
|
604
|
+
late,
|
|
605
|
+
]);
|
|
606
|
+
}
|
|
607
|
+
finally {
|
|
608
|
+
clearTimeout(timer);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* Stop a transaction that will not commit, on the server and now: cancel the
|
|
613
|
+
* statement it is running (the protocol's CancelRequest, which also reaches the
|
|
614
|
+
* right backend through a pooler), wait for it to answer, then ROLLBACK, which
|
|
615
|
+
* releases every lock the transaction holds.
|
|
393
616
|
*
|
|
394
|
-
*
|
|
395
|
-
*
|
|
396
|
-
*
|
|
397
|
-
*
|
|
398
|
-
*
|
|
399
|
-
*
|
|
617
|
+
* Closing the socket alone does not do that. PostgreSQL notices a closed
|
|
618
|
+
* client only when it next writes to it, so a backend waiting on a lock keeps
|
|
619
|
+
* the transaction, and every lock it already took, until that lock clears.
|
|
620
|
+
*
|
|
621
|
+
* Resolves `true` when the connection is rolled back and reusable, `false` when
|
|
622
|
+
* it is not (the cancel could not be sent, the statement or the ROLLBACK did not
|
|
623
|
+
* answer within {@link TX_ABORT_GRACE_MS}, or the ROLLBACK failed); the caller
|
|
624
|
+
* then destroys it. Call after `state.end()`, so nothing new is sent meanwhile.
|
|
625
|
+
*/
|
|
626
|
+
async function rollbackOnServer(client, state, dialect) {
|
|
627
|
+
if (state.running) {
|
|
628
|
+
if (!(await sendCancelRequest(client, TX_ABORT_GRACE_MS)))
|
|
629
|
+
return false;
|
|
630
|
+
if (!(await state.whenIdle(TX_ABORT_GRACE_MS)))
|
|
631
|
+
return false;
|
|
632
|
+
}
|
|
633
|
+
let failed = false;
|
|
634
|
+
const rollback = client.query(dialect.rollbackStatement()).catch(() => {
|
|
635
|
+
failed = true;
|
|
636
|
+
});
|
|
637
|
+
return (await settlesWithin(rollback, TX_ABORT_GRACE_MS)) && !failed;
|
|
638
|
+
}
|
|
639
|
+
/** `rawUnsafe` takes statement text; a tagged-template call or a non-string is E003. */
|
|
640
|
+
function assertSqlText(text, api) {
|
|
641
|
+
if (typeof text === 'string' && text.trim() !== '')
|
|
642
|
+
return;
|
|
643
|
+
throw new ValidationError(Array.isArray(text)
|
|
644
|
+
? `${api} takes the statement as a string, not as a tagged template. Use raw\`...\` or sql\`...\` for a ` +
|
|
645
|
+
'template, whose values are bound for you.'
|
|
646
|
+
: `${api} takes the statement text as a non-empty string, received ${text === null ? 'null' : typeof text}.`);
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* The connection `transaction(fn)` and `tx.$connection()` hand out: the
|
|
650
|
+
* checked-out client, with every statement routed through the transaction's
|
|
651
|
+
* state, so one issued after the transaction settled is refused rather than
|
|
652
|
+
* run on a connection that is back in the pool, and transaction-control SQL
|
|
653
|
+
* (`COMMIT`, `BEGIN`, ...) is refused rather than ending the transaction behind
|
|
654
|
+
* the state that tracks it.
|
|
655
|
+
*
|
|
656
|
+
* What would take the connection away is refused too: `release` (the
|
|
657
|
+
* transaction returns it itself), `end` and `connect`, and the driver's
|
|
658
|
+
* `connection` (the wire-level object, whose writes bypass every check here) is
|
|
659
|
+
* hidden. Everything else is the driver client's own.
|
|
660
|
+
*/
|
|
661
|
+
function closableRawClient(client, state, scope = state.rootScope, api = 'transaction()') {
|
|
662
|
+
// The refusals name the API the caller used: a `tx.$connection()` caller
|
|
663
|
+
// never called `transaction()`.
|
|
664
|
+
const who = api === 'transaction()' ? 'transaction(): the callback' : '$connection(): the $transaction callback';
|
|
665
|
+
const target = client;
|
|
666
|
+
const send = target.query;
|
|
667
|
+
const query = (...args) => {
|
|
668
|
+
const first = args[0];
|
|
669
|
+
const text = typeof first === 'string' ? first : first?.text;
|
|
670
|
+
// Callback form and submittables (cursors, COPY streams) answer through
|
|
671
|
+
// their own channel, not a promise: checked, then passed straight through.
|
|
672
|
+
if (typeof args[args.length - 1] === 'function' ||
|
|
673
|
+
typeof first?.submit === 'function') {
|
|
674
|
+
state.assertOpen();
|
|
675
|
+
state.screenRaw(text);
|
|
676
|
+
return send.apply(client, args);
|
|
677
|
+
}
|
|
678
|
+
return state.runRaw(text, () => send.apply(client, args), scope);
|
|
679
|
+
};
|
|
680
|
+
const release = () => {
|
|
681
|
+
throw new ValidationError(`${who} must not release its client; the transaction releases it after COMMIT or ROLLBACK.`);
|
|
682
|
+
};
|
|
683
|
+
const refuse = (verb) => {
|
|
684
|
+
return () => {
|
|
685
|
+
throw new ValidationError(`${who} must not ${verb} its client; the transaction owns the connection and ` +
|
|
686
|
+
'returns it to the pool after COMMIT or ROLLBACK.');
|
|
687
|
+
};
|
|
688
|
+
};
|
|
689
|
+
const refused = new Map([
|
|
690
|
+
['end', refuse('end')],
|
|
691
|
+
['connect', refuse('connect')],
|
|
692
|
+
]);
|
|
693
|
+
const bound = new Map();
|
|
694
|
+
return new Proxy(target, {
|
|
695
|
+
has(t, prop) {
|
|
696
|
+
return prop === 'connection' ? false : Reflect.has(t, prop);
|
|
697
|
+
},
|
|
698
|
+
get(t, prop) {
|
|
699
|
+
if (prop === 'query')
|
|
700
|
+
return query;
|
|
701
|
+
if (prop === 'release')
|
|
702
|
+
return release;
|
|
703
|
+
if (prop === 'connection')
|
|
704
|
+
return undefined;
|
|
705
|
+
const refusal = refused.get(prop);
|
|
706
|
+
if (refusal !== undefined)
|
|
707
|
+
return refusal;
|
|
708
|
+
const value = Reflect.get(t, prop, t);
|
|
709
|
+
if (typeof value !== 'function')
|
|
710
|
+
return value;
|
|
711
|
+
let fn = bound.get(prop);
|
|
712
|
+
if (fn === undefined) {
|
|
713
|
+
fn = value.bind(t);
|
|
714
|
+
bound.set(prop, fn);
|
|
715
|
+
}
|
|
716
|
+
return fn;
|
|
717
|
+
},
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* Operations that may be served by a read replica: the `read` entries of
|
|
722
|
+
* {@link TABLE_METHODS}. Every other method (writes, `build*`, internals) stays
|
|
723
|
+
* on the primary. A Set so the routing proxy's `get` trap is O(1).
|
|
724
|
+
*
|
|
725
|
+
* Derived, not listed, because a hand-kept list drifts SILENTLY: a read method
|
|
726
|
+
* missing here keeps working on the primary, so no test fails. A new public
|
|
727
|
+
* QueryInterface method cannot compile until it is classified in TABLE_METHODS.
|
|
728
|
+
* `src/test/read-operations-drift.test.ts` is the run-time check.
|
|
400
729
|
*
|
|
401
730
|
* Exported for that test only.
|
|
402
731
|
*/
|
|
403
|
-
export const READ_OPERATIONS = new Set(
|
|
404
|
-
'
|
|
405
|
-
|
|
406
|
-
'findUnique',
|
|
407
|
-
'findFirstOrThrow',
|
|
408
|
-
'findUniqueOrThrow',
|
|
409
|
-
'count',
|
|
410
|
-
'aggregate',
|
|
411
|
-
'groupBy',
|
|
412
|
-
'findManyStream',
|
|
413
|
-
'findManyStreamBatches',
|
|
414
|
-
]);
|
|
732
|
+
export const READ_OPERATIONS = new Set(Object.entries(TABLE_METHODS)
|
|
733
|
+
.filter(([, kind]) => kind === 'read')
|
|
734
|
+
.map(([name]) => name));
|
|
415
735
|
/**
|
|
416
736
|
* Internal marker on the config object that tells the `TurbineClient`
|
|
417
737
|
* constructor to build a lightweight "primary-only" view sharing an existing
|
|
@@ -426,6 +746,10 @@ const PRIMARY_VIEW = Symbol('turbine.primaryView');
|
|
|
426
746
|
* A transaction-scoped client that provides the same table accessor API as TurbineClient.
|
|
427
747
|
* All queries run on a dedicated connection within a BEGIN/COMMIT block.
|
|
428
748
|
* Supports nested transactions via SAVEPOINTs.
|
|
749
|
+
*
|
|
750
|
+
* Closed when its transaction settles (commit, rollback, timeout, error): every
|
|
751
|
+
* later call through it throws `ValidationError` (E003) instead of running on a
|
|
752
|
+
* connection that is back in the pool and may belong to another request.
|
|
429
753
|
*/
|
|
430
754
|
export class TransactionClient {
|
|
431
755
|
client;
|
|
@@ -434,9 +758,19 @@ export class TransactionClient {
|
|
|
434
758
|
queryOptions;
|
|
435
759
|
sourcePool;
|
|
436
760
|
tableCache = new Map();
|
|
437
|
-
savepointCounter = 0;
|
|
438
761
|
/** Active SQL dialect, owns savepoint keywords and raw-SQL placeholders. */
|
|
439
762
|
dialect;
|
|
763
|
+
/** Open/closed state, first failure and savepoint blocks, shared by everything this client hands out. */
|
|
764
|
+
state;
|
|
765
|
+
/**
|
|
766
|
+
* Where in the transaction this handle sits: the transaction itself, or the
|
|
767
|
+
* nested block whose callback received it. Statements carry it, so one issued
|
|
768
|
+
* through an outer handle from inside a block can be told from one issued
|
|
769
|
+
* through the block's own.
|
|
770
|
+
*/
|
|
771
|
+
scope;
|
|
772
|
+
/** What {@link $connection} hands out, built on first use. */
|
|
773
|
+
connectionHandle;
|
|
440
774
|
constructor(client, schema, middlewares, queryOptions,
|
|
441
775
|
/**
|
|
442
776
|
* The parent pool this transaction runs on. Only its `readonly` and
|
|
@@ -447,16 +781,26 @@ export class TransactionClient {
|
|
|
447
781
|
* (emitting join PowQL a pre-0.13 engine rejects). Undefined / absent flags
|
|
448
782
|
* for a plain pg pool leave the proxy unchanged.
|
|
449
783
|
*/
|
|
450
|
-
sourcePool
|
|
784
|
+
sourcePool,
|
|
785
|
+
/**
|
|
786
|
+
* The state of the transaction this client belongs to. `$transaction`
|
|
787
|
+
* passes the one it opened, which also turns on per-write savepoints for
|
|
788
|
+
* multi-statement writes. Omitted for Turbine's own implicit transactions.
|
|
789
|
+
*/
|
|
790
|
+
state,
|
|
791
|
+
/** The nested block this handle belongs to; omitted for the transaction's own handle. */
|
|
792
|
+
scope) {
|
|
451
793
|
this.client = client;
|
|
452
794
|
this.schema = schema;
|
|
453
795
|
this.middlewares = middlewares;
|
|
454
796
|
this.queryOptions = queryOptions;
|
|
455
797
|
this.sourcePool = sourcePool;
|
|
456
798
|
this.dialect = queryOptions?.dialect ?? postgresDialect;
|
|
799
|
+
this.state = state ?? new TransactionState(client, this.dialect);
|
|
800
|
+
this.scope = scope ?? this.state.rootScope;
|
|
457
801
|
// Auto-create typed table accessors for all tables in the schema
|
|
458
802
|
for (const tableName of Object.keys(schema.tables)) {
|
|
459
|
-
const camelName = tableName
|
|
803
|
+
const camelName = tableAccessorName(tableName);
|
|
460
804
|
if (!(camelName in this)) {
|
|
461
805
|
Object.defineProperty(this, camelName, {
|
|
462
806
|
get: () => this.table(tableName),
|
|
@@ -477,43 +821,121 @@ export class TransactionClient {
|
|
|
477
821
|
const txPool = this.createTxPool();
|
|
478
822
|
const txOpts = { ...this.queryOptions, _txScoped: true };
|
|
479
823
|
qi = txOpts.queryInterfaceFactory
|
|
480
|
-
? txOpts.queryInterfaceFactory(txPool, name, this.schema, this.middlewares, txOpts)
|
|
824
|
+
? asQueryInterface(txOpts.queryInterfaceFactory(txPool, name, this.schema, this.middlewares, txOpts))
|
|
481
825
|
: new QueryInterface(txPool, name, this.schema, this.middlewares, txOpts);
|
|
826
|
+
if (this.state.savepointWrites)
|
|
827
|
+
savepointMultiStatementWrites(qi, this.schema.tables[name], this.state);
|
|
482
828
|
this.tableCache.set(name, qi);
|
|
483
829
|
}
|
|
484
830
|
return qi;
|
|
485
831
|
}
|
|
486
832
|
/**
|
|
487
|
-
*
|
|
488
|
-
*
|
|
833
|
+
* The driver connection this transaction runs on, for code that needs one:
|
|
834
|
+
* another library's query function, a COPY stream, a driver feature Turbine
|
|
835
|
+
* does not wrap. Statements sent on it run inside this transaction.
|
|
836
|
+
*
|
|
837
|
+
* - It closes with the transaction: a statement sent after COMMIT or
|
|
838
|
+
* ROLLBACK is refused with `ValidationError` (E003) instead of running on a
|
|
839
|
+
* connection that is back in the pool.
|
|
840
|
+
* - `release()` is refused. The transaction returns the connection.
|
|
841
|
+
* - COMMIT, ROLLBACK, BEGIN, SAVEPOINT, RELEASE and SET TRANSACTION are
|
|
842
|
+
* refused with `ValidationError` (E003): the transaction ends itself, and
|
|
843
|
+
* Turbine would then commit or roll back a transaction that is no longer
|
|
844
|
+
* open. Use `tx.$transaction` for a savepoint.
|
|
845
|
+
* - `end()` and `connect()` are refused like `release()`, and the driver's
|
|
846
|
+
* `connection` object is not reachable through it.
|
|
847
|
+
* - Its queries use the driver's own parsers. On a pool you supplied, that is
|
|
848
|
+
* your parser configuration, not Turbine's int8 and UTC readings.
|
|
849
|
+
*
|
|
850
|
+
* Typed as the driver-neutral client. For the pg-only surface, cast:
|
|
851
|
+
* `tx.$connection() as unknown as PoolClient`.
|
|
852
|
+
*
|
|
853
|
+
* Throws `UnsupportedFeatureError` (E017) where no driver connection backs
|
|
854
|
+
* the transaction: PowDB, and a stateless HTTP driver.
|
|
489
855
|
*/
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
856
|
+
$connection() {
|
|
857
|
+
this.state.assertOpen();
|
|
858
|
+
if (this.connectionHandle)
|
|
859
|
+
return this.connectionHandle;
|
|
860
|
+
const raw = rawClientOf(this.client);
|
|
861
|
+
const driver = this.dialect.capabilities.connectionDriver;
|
|
862
|
+
if (driver === 'none') {
|
|
863
|
+
throw new UnsupportedFeatureError('tx.$connection()', this.dialect.name, 'A PowDB transaction has no driver connection to hand out. Run PowQL inside it with `tx.rawUnsafe(text, ...values)`.');
|
|
497
864
|
}
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
865
|
+
if (driver === 'pg-compatible' && typeof raw.on !== 'function') {
|
|
866
|
+
throw new UnsupportedFeatureError('tx.$connection()', this.dialect.name, 'The pool returned a client with no event support, as a stateless HTTP driver does, so there is no ' +
|
|
867
|
+
'connection to hand out. Use `tx.raw` / `tx.rawUnsafe`, or a TCP pool.');
|
|
501
868
|
}
|
|
869
|
+
this.connectionHandle = closableRawClient(raw, this.state, this.scope, '$connection()');
|
|
870
|
+
return this.connectionHandle;
|
|
502
871
|
}
|
|
503
872
|
/**
|
|
504
|
-
* Execute a
|
|
873
|
+
* Execute a nested transaction via SAVEPOINT.
|
|
874
|
+
*
|
|
875
|
+
* If the inner function throws, only the savepoint is rolled back. Blocks
|
|
876
|
+
* started side by side (`Promise.all`) run one after another, because
|
|
877
|
+
* savepoints are a stack on the connection and a rollback in one would
|
|
878
|
+
* otherwise undo a sibling that already reported success.
|
|
879
|
+
*
|
|
880
|
+
* The callback receives a `tx` of its own, bound to the block. Use it for the
|
|
881
|
+
* work inside the block: a statement through the OUTER `tx` from inside the
|
|
882
|
+
* block would run in the block's savepoint and be undone with it, so it is
|
|
883
|
+
* refused with `ValidationError` (E003).
|
|
884
|
+
*/
|
|
885
|
+
async $transaction(fn) {
|
|
886
|
+
return this.state.withBlock((scope) => fn(this.inScope(scope)), this.scope);
|
|
887
|
+
}
|
|
888
|
+
/** A handle on the same transaction bound to nested block `scope`. */
|
|
889
|
+
inScope(scope) {
|
|
890
|
+
return new TransactionClient(this.client, this.schema, this.middlewares, this.queryOptions, this.sourcePool, this.state, scope);
|
|
891
|
+
}
|
|
892
|
+
/**
|
|
893
|
+
* Execute a raw SQL query within this transaction, as a tagged template:
|
|
894
|
+
* every `${value}` is sent as a bound parameter.
|
|
505
895
|
*/
|
|
506
896
|
async raw(strings, ...values) {
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
897
|
+
const { sql, params } = buildTypedSql(strings, values, this.dialect, 'raw');
|
|
898
|
+
return this.runRaw(sql, params, 'raw');
|
|
899
|
+
}
|
|
900
|
+
/**
|
|
901
|
+
* Execute SQL text built at runtime within this transaction. See
|
|
902
|
+
* {@link TurbineClient.rawUnsafe}: nothing in `text` is escaped, so it must
|
|
903
|
+
* never contain untrusted input. Values go in `values`, referenced as `$1`,
|
|
904
|
+
* `$2`, ... (the engine's own placeholders on other engines).
|
|
905
|
+
*/
|
|
906
|
+
async rawUnsafe(text, ...values) {
|
|
907
|
+
assertSqlText(text, 'rawUnsafe');
|
|
908
|
+
return this.runRaw(text, values, 'rawUnsafe');
|
|
909
|
+
}
|
|
910
|
+
/**
|
|
911
|
+
* Typed raw SQL on this transaction's connection, like
|
|
912
|
+
* {@link TurbineClient.sql}: await it for `T[]`, or call `.one()` /
|
|
913
|
+
* `.scalar()`. Refused, like every other `tx` call, once the transaction has
|
|
914
|
+
* settled.
|
|
915
|
+
*/
|
|
916
|
+
sql(strings, ...values) {
|
|
917
|
+
this.state.assertOpen();
|
|
918
|
+
const { sql, params: bound } = buildTypedSql(strings, values, this.dialect);
|
|
919
|
+
const params = this.rawParams(bound, 'sql');
|
|
920
|
+
const state = this.state;
|
|
921
|
+
const client = this.client;
|
|
922
|
+
const scope = this.scope;
|
|
923
|
+
const pool = {
|
|
924
|
+
query: (text, vals) => state.runRaw(text, () => client.query(text, vals), scope),
|
|
925
|
+
};
|
|
926
|
+
return new TypedSqlQuery(pool, sql, params, false, undefined, this.queryOptions?._onQuery);
|
|
927
|
+
}
|
|
928
|
+
async runRaw(sql, values, action) {
|
|
929
|
+
const params = this.rawParams(values, action);
|
|
930
|
+
const result = await this.state.runRaw(sql, () => runReportedStatement(this.queryOptions?._onQuery, rawIdentity(action), sql, params, () => this.client.query(sql, params), wrapPgError), this.scope);
|
|
515
931
|
return result.rows;
|
|
516
932
|
}
|
|
933
|
+
/** Bound Dates as UTC text on a PostgreSQL client reading temporal types as UTC. */
|
|
934
|
+
rawParams(params, api) {
|
|
935
|
+
return this.dialect.capabilities.utcTimestampRewrite && this.queryOptions?.utcTimestamps !== false
|
|
936
|
+
? utcDateParams(params, api)
|
|
937
|
+
: params;
|
|
938
|
+
}
|
|
517
939
|
/**
|
|
518
940
|
* @internal The `turbine-orm/prisma-compat` adapter's transaction seam. NOT
|
|
519
941
|
* application API: use {@link raw}, whose tagged template makes concatenating
|
|
@@ -541,7 +963,8 @@ export class TransactionClient {
|
|
|
541
963
|
* passes `'$queryRaw'` / `'$executeRaw'` ...; default `'rawQuery'`).
|
|
542
964
|
*/
|
|
543
965
|
async rawQuery(text, params = [], action = 'rawQuery') {
|
|
544
|
-
const
|
|
966
|
+
const bound = this.rawParams(params, action);
|
|
967
|
+
const result = await this.state.runRaw(text, () => runReportedStatement(this.queryOptions?._onQuery, rawIdentity(action), text, bound, () => this.client.query(text, bound), wrapPgError), this.scope);
|
|
545
968
|
return { rows: result.rows, rowCount: result.rowCount };
|
|
546
969
|
}
|
|
547
970
|
/**
|
|
@@ -551,28 +974,40 @@ export class TransactionClient {
|
|
|
551
974
|
*
|
|
552
975
|
* pg driver errors thrown by queries are translated into typed Turbine
|
|
553
976
|
* errors via wrapPgError so transaction-scoped queries surface the same
|
|
554
|
-
* typed errors as pool-scoped queries.
|
|
977
|
+
* typed errors as pool-scoped queries. Every statement passes through the
|
|
978
|
+
* transaction state, which refuses it once the transaction has settled.
|
|
555
979
|
*/
|
|
556
980
|
createTxPool() {
|
|
557
981
|
const client = this.client;
|
|
982
|
+
const state = this.state;
|
|
983
|
+
const scope = this.scope;
|
|
984
|
+
const query = (textOrConfig, values) => state.run(async () => {
|
|
985
|
+
try {
|
|
986
|
+
if (typeof textOrConfig === 'string') {
|
|
987
|
+
return await client.query(textOrConfig, values);
|
|
988
|
+
}
|
|
989
|
+
// Object form for prepared statements: { name, text, values }
|
|
990
|
+
// A driver client accepts it, but the two-argument `query` in the
|
|
991
|
+
// PgCompatPoolClient contract does not describe it, so we cast.
|
|
992
|
+
return await client.query(textOrConfig);
|
|
993
|
+
}
|
|
994
|
+
catch (err) {
|
|
995
|
+
throw wrapPgError(err);
|
|
996
|
+
}
|
|
997
|
+
}, scope);
|
|
558
998
|
// Return a minimal pool-compatible object that routes queries
|
|
559
999
|
// through the transaction client
|
|
560
1000
|
const txPool = {
|
|
561
|
-
query
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
return await client.query(textOrConfig);
|
|
570
|
-
}
|
|
571
|
-
catch (err) {
|
|
572
|
-
throw wrapPgError(err);
|
|
573
|
-
}
|
|
1001
|
+
query,
|
|
1002
|
+
// The transaction's connection is not this pool's to lend: a "checkout"
|
|
1003
|
+
// runs on it through the same guarded `query`, and releasing it is a
|
|
1004
|
+
// no-op, so nothing reached through the pool can hand the connection back
|
|
1005
|
+
// to the real pool while the transaction still holds it.
|
|
1006
|
+
connect: async () => {
|
|
1007
|
+
state.assertOpen();
|
|
1008
|
+
return { query, release: () => { } };
|
|
574
1009
|
},
|
|
575
|
-
|
|
1010
|
+
[TRANSACTION_STATE]: state,
|
|
576
1011
|
};
|
|
577
1012
|
// Carry the parent pool's PowDB-only flags through so a transaction-scoped
|
|
578
1013
|
// PowqlInterface reads the same read-only guard and capabilities it would
|
|
@@ -584,6 +1019,47 @@ export class TransactionClient {
|
|
|
584
1019
|
return txPool;
|
|
585
1020
|
}
|
|
586
1021
|
}
|
|
1022
|
+
/**
|
|
1023
|
+
* Give each multi-statement write on a transaction-scoped accessor its own
|
|
1024
|
+
* savepoint: `create` / `update` carrying nested relation writes, and an
|
|
1025
|
+
* `upsert` that looks the row up first.
|
|
1026
|
+
*
|
|
1027
|
+
* Joining the caller's transaction statement by statement, a failure raised
|
|
1028
|
+
* part way through (a nested `connect` that finds nothing, a validation error
|
|
1029
|
+
* found mid-walk) would leave the statements before it in place, and a caller
|
|
1030
|
+
* that caught the error and went on would commit half of the write. In its own
|
|
1031
|
+
* savepoint the write is all or nothing, and the caller's transaction
|
|
1032
|
+
* continues either way.
|
|
1033
|
+
*
|
|
1034
|
+
* Installed as own methods on the accessor this transaction created and owns,
|
|
1035
|
+
* so a single-statement write pays one property lookup and a shape check.
|
|
1036
|
+
*/
|
|
1037
|
+
function savepointMultiStatementWrites(qi, meta, state) {
|
|
1038
|
+
if (!meta)
|
|
1039
|
+
return;
|
|
1040
|
+
const target = qi;
|
|
1041
|
+
const nestedData = (args) => {
|
|
1042
|
+
const data = args?.data;
|
|
1043
|
+
return data !== null && typeof data === 'object' && hasRelationFields(data, meta);
|
|
1044
|
+
};
|
|
1045
|
+
const lookupUpsert = (args) => {
|
|
1046
|
+
const needsLookup = qi.upsertNeedsLookup;
|
|
1047
|
+
return typeof needsLookup === 'function' && args !== undefined && needsLookup.call(qi, args);
|
|
1048
|
+
};
|
|
1049
|
+
const wrap = (method, multiStatement) => {
|
|
1050
|
+
const original = target[method];
|
|
1051
|
+
if (typeof original !== 'function')
|
|
1052
|
+
return;
|
|
1053
|
+
Object.defineProperty(qi, method, {
|
|
1054
|
+
value: (args) => multiStatement(args) ? state.withSavepoint(() => original.call(qi, args)) : original.call(qi, args),
|
|
1055
|
+
configurable: true,
|
|
1056
|
+
writable: true,
|
|
1057
|
+
});
|
|
1058
|
+
};
|
|
1059
|
+
wrap('create', nestedData);
|
|
1060
|
+
wrap('update', nestedData);
|
|
1061
|
+
wrap('upsert', lookupUpsert);
|
|
1062
|
+
}
|
|
587
1063
|
// ---------------------------------------------------------------------------
|
|
588
1064
|
// TurbineClient
|
|
589
1065
|
// ---------------------------------------------------------------------------
|
|
@@ -601,6 +1077,13 @@ export class TurbineClient {
|
|
|
601
1077
|
* `db.pool as unknown as Pool`.
|
|
602
1078
|
*/
|
|
603
1079
|
pool;
|
|
1080
|
+
/**
|
|
1081
|
+
* The pool Turbine's own statements go through: {@link pool}, or for a
|
|
1082
|
+
* caller-supplied PostgreSQL pool a view of it whose queries carry Turbine's
|
|
1083
|
+
* int8 and temporal parsers (see supplied-pool-types.ts). `pool` stays the
|
|
1084
|
+
* caller's object, and queries the caller runs on it keep their parsers.
|
|
1085
|
+
*/
|
|
1086
|
+
queryPool;
|
|
604
1087
|
/** The schema metadata this client was built from */
|
|
605
1088
|
schema;
|
|
606
1089
|
static int8ParserRegistered = false;
|
|
@@ -628,6 +1111,10 @@ export class TurbineClient {
|
|
|
628
1111
|
*/
|
|
629
1112
|
static utcTimestampParsersRegistered = false;
|
|
630
1113
|
logging;
|
|
1114
|
+
/** Where this client's log lines go (the `logger` option, else console). */
|
|
1115
|
+
logger;
|
|
1116
|
+
/** The `diagnostics` option, resolved once at construction. */
|
|
1117
|
+
diagnostics;
|
|
631
1118
|
/** Active SQL dialect, owns transaction keywords, set_config, raw-SQL placeholders, capability flags. */
|
|
632
1119
|
dialect;
|
|
633
1120
|
/** Validated `plan_cache_mode` to pin on every owned connection, or undefined to issue nothing. */
|
|
@@ -636,6 +1123,10 @@ export class TurbineClient {
|
|
|
636
1123
|
middlewares = [];
|
|
637
1124
|
queryListeners = new Set();
|
|
638
1125
|
queryOptions;
|
|
1126
|
+
/** The resolved `sqlComment` every accessor shares, for the batch paths that bypass them. */
|
|
1127
|
+
get sqlCommenter() {
|
|
1128
|
+
return this.queryOptions._sqlCommenter;
|
|
1129
|
+
}
|
|
639
1130
|
errorMessagesSafe;
|
|
640
1131
|
/**
|
|
641
1132
|
* THIS client's error-message mode. Not the process default: see
|
|
@@ -652,6 +1143,10 @@ export class TurbineClient {
|
|
|
652
1143
|
ownsPool = true;
|
|
653
1144
|
/** Active LISTEN subscriptions, torn down on disconnect() so it never hangs */
|
|
654
1145
|
activeSubscriptions = new Set();
|
|
1146
|
+
/** `$on('poolError')` / `$on('connectionLost')` listeners. */
|
|
1147
|
+
poolListeners = { poolError: new Set(), connectionLost: new Set() };
|
|
1148
|
+
/** What each pool Turbine opened has checked out, for `disconnect({ timeoutMs })`. */
|
|
1149
|
+
checkoutWatches = [];
|
|
655
1150
|
/**
|
|
656
1151
|
* Read-replica pools in round-robin order. Empty when no replicas are
|
|
657
1152
|
* configured, in which case `table()` takes the original single-pool path.
|
|
@@ -681,19 +1176,23 @@ export class TurbineClient {
|
|
|
681
1176
|
const parent = seed.parent;
|
|
682
1177
|
this.schema = schema;
|
|
683
1178
|
this.logging = parent.logging;
|
|
1179
|
+
this.logger = parent.logger;
|
|
1180
|
+
this.diagnostics = parent.diagnostics;
|
|
684
1181
|
this.dialect = parent.dialect;
|
|
685
1182
|
this.errorMessagesSafe = parent.errorMessagesSafe;
|
|
686
1183
|
this.errorMessageMode = parent.errorMessageMode;
|
|
687
1184
|
this.queryParamsVisible = parent.queryParamsVisible;
|
|
688
1185
|
this.queryOptions = parent.queryOptions;
|
|
689
1186
|
this.middlewares = parent.middlewares; // shared reference: $use on parent flows through
|
|
1187
|
+
this.poolListeners = parent.poolListeners;
|
|
690
1188
|
this.pool = parent.pool;
|
|
1189
|
+
this.queryPool = parent.queryPool;
|
|
691
1190
|
this.ownsPool = false;
|
|
692
1191
|
this.replicaPools = [];
|
|
693
1192
|
this.ownedReplicaPools = [];
|
|
694
1193
|
this.replicaTableCaches = [];
|
|
695
1194
|
for (const tableName of Object.keys(schema.tables)) {
|
|
696
|
-
const camelName = tableName
|
|
1195
|
+
const camelName = tableAccessorName(tableName);
|
|
697
1196
|
if (!(camelName in this)) {
|
|
698
1197
|
Object.defineProperty(this, camelName, {
|
|
699
1198
|
get: () => this.table(tableName),
|
|
@@ -731,15 +1230,38 @@ export class TurbineClient {
|
|
|
731
1230
|
}
|
|
732
1231
|
break;
|
|
733
1232
|
}
|
|
1233
|
+
// The logger and the diagnostics mode are validated first, so every line
|
|
1234
|
+
// below, the construction warnings included, reaches the caller's logger.
|
|
1235
|
+
// NODE_ENV is read here and nowhere on a query path.
|
|
1236
|
+
const logger = resolveLogger(config.logger);
|
|
1237
|
+
const diagnostics = resolveDiagnostics(config.diagnostics);
|
|
734
1238
|
// Name any key on the config object that is not part of the config surface
|
|
735
|
-
// (
|
|
736
|
-
warnUnknownConfigKeys(config);
|
|
1239
|
+
// (once per key, never throws). See warnUnknownConfigKeys.
|
|
1240
|
+
warnUnknownConfigKeys(config, logger);
|
|
737
1241
|
// ALL config validation runs before ANY process-global side effect below.
|
|
738
1242
|
// A constructor that throws must leave the process exactly as it found it:
|
|
739
1243
|
// settling the process-global parser mode and then rejecting the config
|
|
740
1244
|
// would poison the next, valid, TurbineClient with a phantom conflict.
|
|
741
1245
|
const dialect = config.dialect ?? postgresDialect;
|
|
1246
|
+
assertDialectCapabilities(dialect);
|
|
742
1247
|
const planCacheMode = TurbineClient.resolvePlanCacheMode(config.planCacheMode, dialect);
|
|
1248
|
+
const statementTimeoutMs = TurbineClient.resolveStatementTimeout(config.statementTimeoutMs, dialect);
|
|
1249
|
+
const maxLifetimeSeconds = resolveNonNegativeInteger(config.maxLifetimeSeconds, 'maxLifetimeSeconds', 'seconds');
|
|
1250
|
+
const streamIdleTimeoutMs = resolveStreamIdleTimeout(config.streamIdleTimeoutMs, 'streamIdleTimeoutMs');
|
|
1251
|
+
// A backend that injects its own query interface (PowDB) builds no SQL,
|
|
1252
|
+
// so there is nothing for a comment to ride on: refused, not ignored.
|
|
1253
|
+
const sqlComment = resolveSqlComment(config.sqlComment, logger, {
|
|
1254
|
+
dialect: dialect.name,
|
|
1255
|
+
preparesEveryStatement: dialect.capabilities.preparesEveryStatement,
|
|
1256
|
+
buildsSql: config.queryInterfaceFactory === undefined,
|
|
1257
|
+
});
|
|
1258
|
+
// Keyed by table name, with every key checked against the schema: a
|
|
1259
|
+
// filter under a name that matches no table would never be applied.
|
|
1260
|
+
// A non-SQL backend (PowDB) injects its own query interface and checks
|
|
1261
|
+
// its own filters; every SQL engine compiles each static filter here once,
|
|
1262
|
+
// with this client's dialect, so a value the compiler refuses fails now.
|
|
1263
|
+
const factory = config.queryInterfaceFactory;
|
|
1264
|
+
const globalFilters = normalizeGlobalFilters(config.globalFilters, schema, factory === undefined ? { QueryInterface, dialect, utcTimestamps: config.utcTimestamps } : undefined);
|
|
743
1265
|
// Refuse a connection string that cannot address a database, before it
|
|
744
1266
|
// reaches pg's permissive parser and comes back one query later as a DNS
|
|
745
1267
|
// error about a fragment of itself. Only the strings this client will
|
|
@@ -793,16 +1315,14 @@ export class TurbineClient {
|
|
|
793
1315
|
// fork and rely on their own parser configuration, registration is
|
|
794
1316
|
// process-global, so flipping it because a string replica exists alongside
|
|
795
1317
|
// an external primary would silently change the external primary's parsing
|
|
796
|
-
// too.
|
|
797
|
-
//
|
|
798
|
-
// constructor-gated by the static flags, so it happens at
|
|
1318
|
+
// too. With an external primary, Turbine's own queries carry the parsers
|
|
1319
|
+
// per query instead, on the primary and on every replica (see typedPool).
|
|
1320
|
+
// Registration is constructor-gated by the static flags, so it happens at
|
|
1321
|
+
// most once.
|
|
799
1322
|
const ownsAnyPool = !config.pool;
|
|
800
1323
|
if (ownsAnyPool && !TurbineClient.int8ParserRegistered) {
|
|
801
|
-
warnParserOverwrite(20, 'int8');
|
|
802
|
-
pg.types.setTypeParser(20,
|
|
803
|
-
const n = Number(val);
|
|
804
|
-
return Number.isSafeInteger(n) ? n : val;
|
|
805
|
-
}));
|
|
1324
|
+
warnParserOverwrite(20, 'int8', logger);
|
|
1325
|
+
pg.types.setTypeParser(20, parseInt8);
|
|
806
1326
|
TurbineClient.int8ParserRegistered = true;
|
|
807
1327
|
}
|
|
808
1328
|
// Parse the zone-less temporal types (`timestamp` OID 1114, `date` OID
|
|
@@ -827,16 +1347,22 @@ export class TurbineClient {
|
|
|
827
1347
|
const wantUtcTimestamps = config.utcTimestamps !== false;
|
|
828
1348
|
TurbineClient.assertUtcTimestampsAgree(wantUtcTimestamps);
|
|
829
1349
|
if (ownsAnyPool && wantUtcTimestamps && !TurbineClient.utcTimestampParsersRegistered) {
|
|
830
|
-
registerUtcTemporalParsers();
|
|
1350
|
+
registerUtcTemporalParsers(logger);
|
|
831
1351
|
TurbineClient.utcTimestampParsersRegistered = true;
|
|
832
1352
|
}
|
|
833
1353
|
TurbineClient.utcTimestampParserMode = wantUtcTimestamps;
|
|
834
1354
|
this.logging = config.logging ?? false;
|
|
1355
|
+
this.logger = logger;
|
|
1356
|
+
this.diagnostics = diagnostics;
|
|
835
1357
|
this.dialect = dialect;
|
|
836
1358
|
this.planCacheMode = planCacheMode;
|
|
837
1359
|
this.schema = schema;
|
|
838
1360
|
// Respect env var kill switch
|
|
839
1361
|
const envDisablePrepared = typeof process !== 'undefined' && process.env?.TURBINE_DISABLE_PREPARED === '1';
|
|
1362
|
+
const pooler = config.pool ? undefined : TurbineClient.poolerEndpoint(config);
|
|
1363
|
+
if (config.preparedStatements === true && pooler !== undefined) {
|
|
1364
|
+
TurbineClient.warnPreparedOnPooler(pooler, logger);
|
|
1365
|
+
}
|
|
840
1366
|
this.errorMessageMode = config.errorMessages ?? 'safe';
|
|
841
1367
|
this.errorMessagesSafe = this.errorMessageMode === 'safe';
|
|
842
1368
|
// Query-event param visibility. One derived boolean, so the two config
|
|
@@ -855,15 +1381,19 @@ export class TurbineClient {
|
|
|
855
1381
|
autoToOneJoinMaxRows: config.autoToOneJoinMaxRows,
|
|
856
1382
|
autoRoundTripMs: config.autoRoundTripMs,
|
|
857
1383
|
jsonEncoding: config.jsonEncoding,
|
|
858
|
-
globalFilters
|
|
859
|
-
preparedStatements: envDisablePrepared ? false : (config.preparedStatements ?? !config.pool),
|
|
1384
|
+
globalFilters,
|
|
1385
|
+
preparedStatements: envDisablePrepared ? false : (config.preparedStatements ?? (!config.pool && !pooler)),
|
|
860
1386
|
// Forwarded so a per-query `forceCustomPlan` can refuse the combination
|
|
861
1387
|
// this client's own connections would silently defeat, see the
|
|
862
1388
|
// `planCacheMode` note on QueryInterfaceOptions.
|
|
863
1389
|
planCacheMode,
|
|
864
1390
|
sqlCache: config.sqlCache ?? true,
|
|
865
1391
|
sqlCacheSize: config.sqlCacheSize,
|
|
1392
|
+
streamIdleTimeoutMs,
|
|
1393
|
+
_sqlCommenter: sqlComment,
|
|
866
1394
|
dialect: config.dialect,
|
|
1395
|
+
diagnostics,
|
|
1396
|
+
logger,
|
|
867
1397
|
// Non-SQL backends (PowDB) inject a factory that builds their own query
|
|
868
1398
|
// interface (PowqlInterface) instead of the SQL QueryInterface. SQL engines
|
|
869
1399
|
// never set this, so `table()` keeps constructing `new QueryInterface`.
|
|
@@ -883,7 +1413,7 @@ export class TurbineClient {
|
|
|
883
1413
|
}
|
|
884
1414
|
catch (e) {
|
|
885
1415
|
if (this.logging)
|
|
886
|
-
|
|
1416
|
+
logTo(this.logger, 'error', 'Query listener error:', { code: 'query-listener-error', error: e });
|
|
887
1417
|
}
|
|
888
1418
|
}
|
|
889
1419
|
},
|
|
@@ -906,11 +1436,24 @@ export class TurbineClient {
|
|
|
906
1436
|
if (config.pool) {
|
|
907
1437
|
// External pool, use directly. Turbine doesn't manage its lifecycle.
|
|
908
1438
|
this.pool = config.pool;
|
|
1439
|
+
// Turbine registers no parsers for a pool it did not open, so its own
|
|
1440
|
+
// queries on this one carry them instead.
|
|
1441
|
+
this.queryPool = this.typedPool(config.pool);
|
|
909
1442
|
this.ownsPool = false;
|
|
910
1443
|
if (this.logging) {
|
|
911
|
-
|
|
1444
|
+
const tables = Object.keys(schema.tables).length;
|
|
1445
|
+
logTo(this.logger, 'debug', `Using external pool, ${tables} tables`, { code: 'external-pool', tables });
|
|
912
1446
|
}
|
|
913
1447
|
}
|
|
1448
|
+
else if (!hasDatabaseTarget(config)) {
|
|
1449
|
+
// Nothing names a database: no pool, connection string, host settings,
|
|
1450
|
+
// DATABASE_URL or PG* variable. The first query fails with the fix,
|
|
1451
|
+
// which beats connecting to a guessed localhost. Failing here instead
|
|
1452
|
+
// would break every build step that imports the client without a database.
|
|
1453
|
+
this.pool = unconfiguredPool(config.connectionString === '');
|
|
1454
|
+
this.queryPool = this.pool;
|
|
1455
|
+
this.ownsPool = true;
|
|
1456
|
+
}
|
|
914
1457
|
else {
|
|
915
1458
|
const poolConfig = {
|
|
916
1459
|
max: config.poolSize ?? config.max ?? 10,
|
|
@@ -937,10 +1480,12 @@ export class TurbineClient {
|
|
|
937
1480
|
poolConfig.connectionString = process.env.DATABASE_URL;
|
|
938
1481
|
}
|
|
939
1482
|
else {
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
poolConfig.
|
|
943
|
-
poolConfig.
|
|
1483
|
+
// A default filled in here would override the PG* variable pg reads
|
|
1484
|
+
// for the same field, so a field the environment sets is left to pg.
|
|
1485
|
+
poolConfig.host = config.host ?? (pgEnv('PGHOST') ? undefined : 'localhost');
|
|
1486
|
+
poolConfig.port = config.port ?? (pgEnv('PGPORT') ? undefined : 5432);
|
|
1487
|
+
poolConfig.database = config.database ?? (pgEnv('PGDATABASE') ? undefined : 'postgres');
|
|
1488
|
+
poolConfig.user = config.user ?? (pgEnv('PGUSER') ? undefined : 'postgres');
|
|
944
1489
|
poolConfig.password = config.password;
|
|
945
1490
|
}
|
|
946
1491
|
if (config.ssl !== undefined) {
|
|
@@ -949,19 +1494,25 @@ export class TurbineClient {
|
|
|
949
1494
|
// Held as a `pg.Pool` for the length of this block: `PgCompatPool.on` is
|
|
950
1495
|
// optional (HTTP drivers have no pool events), and here we know we own a
|
|
951
1496
|
// real pg pool that has one. Same shape as the replica loop below.
|
|
952
|
-
const ownPool = new pg.Pool(TurbineClient.
|
|
953
|
-
|
|
954
|
-
console.error('[turbine] Unexpected pool error:', err.message);
|
|
955
|
-
});
|
|
956
|
-
absorbCheckedOutErrors(ownPool);
|
|
1497
|
+
const ownPool = new pg.Pool(TurbineClient.ownedPoolConfig(poolConfig, planCacheMode, statementTimeoutMs, maxLifetimeSeconds));
|
|
1498
|
+
this.watchOwnedPool(ownPool, { pool: 'primary' });
|
|
957
1499
|
// After a freeze (a serverless function between invocations), wait one
|
|
958
1500
|
// loop turn before reusing a long-idle connection, so the pool has read
|
|
959
1501
|
// any close the server sent meanwhile. See connection-guard.ts.
|
|
960
1502
|
settleLongIdleCheckouts(ownPool);
|
|
1503
|
+
// An exhausted pool rejects a checkout with an untyped pg-pool error;
|
|
1504
|
+
// make it a ConnectionError on every path, `pool.query` included.
|
|
1505
|
+
typeCheckoutTimeouts(ownPool);
|
|
961
1506
|
this.pool = ownPool;
|
|
1507
|
+
this.queryPool = ownPool;
|
|
962
1508
|
this.ownsPool = true;
|
|
963
1509
|
if (this.logging) {
|
|
964
|
-
|
|
1510
|
+
const tables = Object.keys(schema.tables).length;
|
|
1511
|
+
logTo(this.logger, 'debug', `Pool created, max ${poolConfig.max} connections, ${tables} tables`, {
|
|
1512
|
+
code: 'pool-created',
|
|
1513
|
+
max: poolConfig.max,
|
|
1514
|
+
tables,
|
|
1515
|
+
});
|
|
965
1516
|
}
|
|
966
1517
|
}
|
|
967
1518
|
// Build read-replica pools (if any). String entries become owned pg.Pools
|
|
@@ -971,23 +1522,23 @@ export class TurbineClient {
|
|
|
971
1522
|
this.ownedReplicaPools = [];
|
|
972
1523
|
for (const replica of config.replicas ?? []) {
|
|
973
1524
|
if (typeof replica === 'string') {
|
|
974
|
-
const replicaPool = new pg.Pool(TurbineClient.
|
|
1525
|
+
const replicaPool = new pg.Pool(TurbineClient.ownedPoolConfig({
|
|
975
1526
|
connectionString: replica,
|
|
976
1527
|
max: config.poolSize ?? config.max ?? 10,
|
|
977
1528
|
idleTimeoutMillis: config.idleTimeoutMs ?? config.idleTimeoutMillis ?? 30_000,
|
|
978
1529
|
connectionTimeoutMillis: config.connectionTimeoutMs ?? config.connectionTimeoutMillis ?? 5_000,
|
|
979
1530
|
...(config.ssl !== undefined ? { ssl: config.ssl } : {}),
|
|
980
|
-
},
|
|
981
|
-
|
|
982
|
-
console.error('[turbine] Unexpected replica pool error:', err.message);
|
|
983
|
-
});
|
|
984
|
-
absorbCheckedOutErrors(replicaPool);
|
|
1531
|
+
}, planCacheMode, statementTimeoutMs, maxLifetimeSeconds));
|
|
1532
|
+
this.watchOwnedPool(replicaPool, { pool: 'replica', replica: this.replicaPools.length });
|
|
985
1533
|
settleLongIdleCheckouts(replicaPool);
|
|
986
|
-
|
|
1534
|
+
typeCheckoutTimeouts(replicaPool);
|
|
1535
|
+
// Next to a supplied primary no parsers were registered, so this
|
|
1536
|
+
// replica's reads carry them like the primary's do.
|
|
1537
|
+
this.replicaPools.push(this.ownsPool ? replicaPool : this.typedPool(replicaPool));
|
|
987
1538
|
this.ownedReplicaPools.push(replicaPool);
|
|
988
1539
|
}
|
|
989
1540
|
else {
|
|
990
|
-
this.replicaPools.push(replica);
|
|
1541
|
+
this.replicaPools.push(this.typedPool(replica));
|
|
991
1542
|
}
|
|
992
1543
|
}
|
|
993
1544
|
// `planCacheMode` reaches a pool only where Turbine opens the connections.
|
|
@@ -998,26 +1549,34 @@ export class TurbineClient {
|
|
|
998
1549
|
// Deliberate no-op rather than a throw: the option is a performance knob,
|
|
999
1550
|
// and an app that moves from an owned pool to a serverless driver should
|
|
1000
1551
|
// not stop booting over it. Same ownership rule as the type parsers, which
|
|
1001
|
-
// also skip external pools silently.
|
|
1002
|
-
|
|
1552
|
+
// also skip external pools silently. Always on, like every construction
|
|
1553
|
+
// warning: the option is just as ignored in production.
|
|
1554
|
+
if (this.planCacheMode !== undefined && !this.ownsPool) {
|
|
1003
1555
|
if (shouldWarnOnce(WARN_NS.planCacheModeIgnored, this.planCacheMode)) {
|
|
1004
1556
|
const replicaNote = this.ownedReplicaPools.length > 0
|
|
1005
1557
|
? ` It IS applied to the ${this.ownedReplicaPools.length} Turbine-owned read replica pool(s) on this ` +
|
|
1006
1558
|
'client, so reads and writes would run under different plan-cache policies until the primary is set too.'
|
|
1007
1559
|
: '';
|
|
1008
|
-
|
|
1560
|
+
logTo(this.logger, 'warn', `planCacheMode: '${this.planCacheMode}' was not applied to the primary: this client was given an ` +
|
|
1009
1561
|
'external `pool`, whose connection lifecycle the caller owns, so Turbine never opens its connections. Set ' +
|
|
1010
1562
|
`\`plan_cache_mode\` in the driver's own connection setup (or run \`SET plan_cache_mode = ${this.planCacheMode}\` ` +
|
|
1011
|
-
`on checkout) instead.${replicaNote}
|
|
1563
|
+
`on checkout) instead.${replicaNote}`, { code: 'plan-cache-mode-ignored', option: 'planCacheMode', ownedReplicas: this.ownedReplicaPools.length });
|
|
1012
1564
|
}
|
|
1013
1565
|
}
|
|
1566
|
+
this.warnExternalPoolOptions(statementTimeoutMs, maxLifetimeSeconds);
|
|
1014
1567
|
this.replicaTableCaches = this.replicaPools.map(() => new Map());
|
|
1015
1568
|
if (this.logging && this.replicaPools.length > 0) {
|
|
1016
|
-
|
|
1569
|
+
const replicas = this.replicaPools.length;
|
|
1570
|
+
const owned = this.ownedReplicaPools.length;
|
|
1571
|
+
logTo(this.logger, 'debug', `${replicas} read replica(s) configured (${owned} owned)`, {
|
|
1572
|
+
code: 'replicas-configured',
|
|
1573
|
+
replicas,
|
|
1574
|
+
owned,
|
|
1575
|
+
});
|
|
1017
1576
|
}
|
|
1018
1577
|
// Auto-create typed table accessors for all tables in the schema
|
|
1019
1578
|
for (const tableName of Object.keys(schema.tables)) {
|
|
1020
|
-
const camelName = tableName
|
|
1579
|
+
const camelName = tableAccessorName(tableName);
|
|
1021
1580
|
if (!(camelName in this)) {
|
|
1022
1581
|
Object.defineProperty(this, camelName, {
|
|
1023
1582
|
get: () => this.table(tableName),
|
|
@@ -1079,8 +1638,10 @@ export class TurbineClient {
|
|
|
1079
1638
|
return matched;
|
|
1080
1639
|
}
|
|
1081
1640
|
/**
|
|
1082
|
-
*
|
|
1083
|
-
*
|
|
1641
|
+
* The pg pool config for a pool Turbine opens: the `maxLifetimeSeconds`
|
|
1642
|
+
* recycling default, plus `plan_cache_mode` and `statement_timeout` pinned
|
|
1643
|
+
* on every connection through the pool's **connection parameters** rather
|
|
1644
|
+
* than a `SET`.
|
|
1084
1645
|
*
|
|
1085
1646
|
* PostgreSQL's `options` startup parameter (`-c plan_cache_mode=...`) is
|
|
1086
1647
|
* applied by the backend as it starts the session, so the setting is in force
|
|
@@ -1114,23 +1675,157 @@ export class TurbineClient {
|
|
|
1114
1675
|
* should set the GUC on the server or role instead
|
|
1115
1676
|
* (`ALTER ROLE ... SET plan_cache_mode = ...`).
|
|
1116
1677
|
*/
|
|
1117
|
-
static
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
//
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1678
|
+
static ownedPoolConfig(poolConfig, planCacheMode, statementTimeoutMs, maxLifetimeSeconds) {
|
|
1679
|
+
let config = {
|
|
1680
|
+
...poolConfig,
|
|
1681
|
+
maxLifetimeSeconds: maxLifetimeSeconds ?? DEFAULT_MAX_LIFETIME_SECONDS,
|
|
1682
|
+
};
|
|
1683
|
+
// `planCacheMode` is a member of PLAN_CACHE_MODES and `statementTimeoutMs`
|
|
1684
|
+
// a narrowed integer, never caller text, which is what makes these
|
|
1685
|
+
// literals safe: a GUC value cannot be a bind parameter.
|
|
1686
|
+
if (planCacheMode !== undefined)
|
|
1687
|
+
config = withStartupSetting(config, `-c plan_cache_mode=${planCacheMode}`);
|
|
1688
|
+
const timeout = statementTimeoutMs === undefined ? null : statementTimeoutSetting(statementTimeoutMs);
|
|
1689
|
+
if (timeout !== null)
|
|
1690
|
+
config = withStartupSetting(config, timeout);
|
|
1691
|
+
return config;
|
|
1692
|
+
}
|
|
1693
|
+
/**
|
|
1694
|
+
* Validate `statementTimeoutMs`: a non-negative integer (E003), on
|
|
1695
|
+
* PostgreSQL only (E017), since it is applied as a PostgreSQL connection
|
|
1696
|
+
* parameter.
|
|
1697
|
+
*/
|
|
1698
|
+
static resolveStatementTimeout(value, dialect) {
|
|
1699
|
+
const ms = resolveNonNegativeInteger(value, 'statementTimeoutMs', 'milliseconds');
|
|
1700
|
+
if (ms !== undefined && !dialect.capabilities.connectionParameters) {
|
|
1701
|
+
throw new UnsupportedFeatureError(`The statementTimeoutMs option (statement_timeout = ${ms})`, dialect.name, '`statement_timeout` is applied as a PostgreSQL connection parameter. Remove the option on this engine, ' +
|
|
1702
|
+
'and bound slow statements with the per-query `timeout` instead.');
|
|
1703
|
+
}
|
|
1704
|
+
return ms;
|
|
1705
|
+
}
|
|
1706
|
+
/**
|
|
1707
|
+
* The first connection target this client opens that names a connection
|
|
1708
|
+
* pooler (see `detectPooler`), as `host:port`, or `undefined`. Covers the
|
|
1709
|
+
* connection string, the DATABASE_URL fallback, host/port settings, and
|
|
1710
|
+
* string replicas, each read the way node-postgres reads it: a host or port
|
|
1711
|
+
* the target leaves unset is `PGHOST` / `PGPORT`.
|
|
1712
|
+
*/
|
|
1713
|
+
static poolerEndpoint(config) {
|
|
1714
|
+
const explicit = config.connectionString != null ||
|
|
1715
|
+
config.host != null ||
|
|
1716
|
+
config.port != null ||
|
|
1717
|
+
config.database != null ||
|
|
1718
|
+
config.user != null ||
|
|
1719
|
+
config.password != null;
|
|
1720
|
+
const targets = [];
|
|
1721
|
+
if (config.connectionString)
|
|
1722
|
+
targets.push(parseConnectionTarget(config.connectionString));
|
|
1723
|
+
else if (!explicit && process.env.DATABASE_URL)
|
|
1724
|
+
targets.push(parseConnectionTarget(process.env.DATABASE_URL));
|
|
1725
|
+
else {
|
|
1726
|
+
targets.push({
|
|
1727
|
+
host: config.host ? config.host.toLowerCase() : null,
|
|
1728
|
+
port: typeof config.port === 'number' ? config.port : null,
|
|
1729
|
+
});
|
|
1730
|
+
}
|
|
1731
|
+
for (const replica of config.replicas ?? []) {
|
|
1732
|
+
if (typeof replica === 'string' && replica)
|
|
1733
|
+
targets.push(parseConnectionTarget(replica));
|
|
1734
|
+
}
|
|
1735
|
+
const environment = { host: pgEnv('PGHOST'), port: pgEnv('PGPORT') };
|
|
1736
|
+
for (const target of targets) {
|
|
1737
|
+
const found = detectPoolerTarget(fillTargetFromEnvironment(target, environment));
|
|
1738
|
+
if (found.pooled)
|
|
1739
|
+
return `${found.host ?? 'localhost'}:${found.port ?? 5432}`;
|
|
1740
|
+
}
|
|
1741
|
+
return undefined;
|
|
1742
|
+
}
|
|
1743
|
+
/** Always on: named statements behind a transaction pooler fail in production first. */
|
|
1744
|
+
static warnPreparedOnPooler(endpoint, logger) {
|
|
1745
|
+
if (!shouldWarnOnce(WARN_NS.preparedStatementsOnPooler, endpoint))
|
|
1746
|
+
return;
|
|
1747
|
+
logTo(logger, 'warn', `preparedStatements: true on ${endpoint}, which looks like a connection pooler. In transaction ` +
|
|
1748
|
+
'mode a pooler runs each transaction on any server connection, so a statement named on one fails on ' +
|
|
1749
|
+
'the next ("prepared statement ... does not exist" or "already exists") unless the pooler tracks ' +
|
|
1750
|
+
'prepared statements: PgBouncer 1.21+ with max_prepared_statements above 0. If yours does not, leave ' +
|
|
1751
|
+
'preparedStatements unset; Turbine then sends unnamed statements to a pooler.', { code: 'prepared-statements-on-pooler', endpoint });
|
|
1752
|
+
}
|
|
1753
|
+
/**
|
|
1754
|
+
* `pool` as Turbine's own statements should reach it: with Turbine's int8
|
|
1755
|
+
* and temporal parsers on every query, for a PostgreSQL pool built on
|
|
1756
|
+
* node-postgres. Any other pool is returned unchanged.
|
|
1757
|
+
*/
|
|
1758
|
+
typedPool(pool) {
|
|
1759
|
+
if (this.dialect.capabilities.connectionDriver !== 'pg-compatible')
|
|
1760
|
+
return pool;
|
|
1761
|
+
return typedQueryPool(pool, this.queryOptions.utcTimestamps !== false) ?? pool;
|
|
1762
|
+
}
|
|
1763
|
+
/**
|
|
1764
|
+
* Listen to a pool this client opened: its idle-connection errors become
|
|
1765
|
+
* `poolError` events, a checked-out connection's loss a `connectionLost`
|
|
1766
|
+
* event, and its checkouts are tracked for `disconnect({ timeoutMs })`.
|
|
1767
|
+
* Also the connection guards every owned pool gets (connection-guard.ts):
|
|
1768
|
+
* no unheard `'error'`, a settle after a freeze, typed checkout timeouts.
|
|
1769
|
+
*/
|
|
1770
|
+
watchOwnedPool(pool, origin) {
|
|
1771
|
+
pool.on('error', (err) => this.emitPoolEvent('poolError', { error: err, ...origin }));
|
|
1772
|
+
absorbCheckedOutErrors(pool);
|
|
1773
|
+
this.checkoutWatches.push(watchCheckouts(pool, (err) => this.emitPoolEvent('connectionLost', { error: err, ...origin })));
|
|
1774
|
+
}
|
|
1775
|
+
/**
|
|
1776
|
+
* Hand a pool event to its listeners. A `poolError` with no listener is
|
|
1777
|
+
* logged at `error`, always; a listener that throws is ignored, like a query
|
|
1778
|
+
* listener.
|
|
1779
|
+
*/
|
|
1780
|
+
emitPoolEvent(kind, event) {
|
|
1781
|
+
const listeners = this.poolListeners[kind];
|
|
1782
|
+
if (listeners.size === 0) {
|
|
1783
|
+
if (kind === 'poolError') {
|
|
1784
|
+
logTo(this.logger, 'error', `Unexpected ${event.pool} pool error: ${event.error.message}`, {
|
|
1785
|
+
code: 'pool-error',
|
|
1786
|
+
pool: event.pool,
|
|
1787
|
+
...(event.replica !== undefined ? { replica: event.replica } : {}),
|
|
1788
|
+
error: event.error,
|
|
1789
|
+
});
|
|
1790
|
+
}
|
|
1791
|
+
return;
|
|
1792
|
+
}
|
|
1793
|
+
for (const listener of listeners) {
|
|
1794
|
+
try {
|
|
1795
|
+
listener(event);
|
|
1796
|
+
}
|
|
1797
|
+
catch (e) {
|
|
1798
|
+
if (this.logging) {
|
|
1799
|
+
logTo(this.logger, 'error', `${kind} listener error:`, {
|
|
1800
|
+
code: 'pool-listener-error',
|
|
1801
|
+
listener: kind,
|
|
1802
|
+
error: e,
|
|
1803
|
+
});
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
/**
|
|
1809
|
+
* `statementTimeoutMs` and an explicit `maxLifetimeSeconds` reach only the
|
|
1810
|
+
* pools Turbine opens. On an external primary they do nothing, which a
|
|
1811
|
+
* warning says once, naming where the setting belongs instead. Always on: an
|
|
1812
|
+
* ignored timeout means no timeout in production.
|
|
1813
|
+
*/
|
|
1814
|
+
warnExternalPoolOptions(statementTimeoutMs, maxLifetimeSeconds) {
|
|
1815
|
+
if (this.ownsPool)
|
|
1816
|
+
return;
|
|
1817
|
+
const ownedReplicas = this.ownedReplicaPools.length;
|
|
1818
|
+
if (statementTimeoutMs !== undefined && shouldWarnOnce(WARN_NS.connectionOptionIgnored, 'statementTimeoutMs')) {
|
|
1819
|
+
logTo(this.logger, 'warn', `statementTimeoutMs: ${statementTimeoutMs} was not applied to the primary: this client was given an ` +
|
|
1820
|
+
"external `pool`, whose connections Turbine never opens. Set `statement_timeout` in the driver's own " +
|
|
1821
|
+
"connection setup, or on the role (`ALTER ROLE app SET statement_timeout = '30s'`)." +
|
|
1822
|
+
(ownedReplicas > 0 ? ' It IS applied to the Turbine-owned read replica pools.' : ''), { code: 'connection-option-ignored', option: 'statementTimeoutMs', ownedReplicas });
|
|
1823
|
+
}
|
|
1824
|
+
if (maxLifetimeSeconds !== undefined && shouldWarnOnce(WARN_NS.connectionOptionIgnored, 'maxLifetimeSeconds')) {
|
|
1825
|
+
logTo(this.logger, 'warn', `maxLifetimeSeconds: ${maxLifetimeSeconds} was not applied to the primary: this client was given ` +
|
|
1826
|
+
"an external `pool`, whose connection lifecycle the caller owns. Set it on that pool's own options." +
|
|
1827
|
+
(ownedReplicas > 0 ? ' It IS applied to the Turbine-owned read replica pools.' : ''), { code: 'connection-option-ignored', option: 'maxLifetimeSeconds', ownedReplicas });
|
|
1828
|
+
}
|
|
1134
1829
|
}
|
|
1135
1830
|
/**
|
|
1136
1831
|
* Refuse a `utcTimestamps` value that contradicts the one an earlier client
|
|
@@ -1235,14 +1930,18 @@ export class TurbineClient {
|
|
|
1235
1930
|
// OUTERMOST, so leaving it out silently defeats all three above.
|
|
1236
1931
|
this.errorScopedTableCache.clear();
|
|
1237
1932
|
}
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
$
|
|
1242
|
-
this.
|
|
1933
|
+
$on(event, listener) {
|
|
1934
|
+
this.listenersFor(event, '$on').add(listener);
|
|
1935
|
+
}
|
|
1936
|
+
$off(event, listener) {
|
|
1937
|
+
this.listenersFor(event, '$off').delete(listener);
|
|
1243
1938
|
}
|
|
1244
|
-
|
|
1245
|
-
|
|
1939
|
+
listenersFor(event, api) {
|
|
1940
|
+
if (event === 'query')
|
|
1941
|
+
return this.queryListeners;
|
|
1942
|
+
if (event === 'poolError' || event === 'connectionLost')
|
|
1943
|
+
return this.poolListeners[event];
|
|
1944
|
+
throw new ValidationError(`${api}: unknown event ${JSON.stringify(event)}. Expected 'query', 'poolError' or 'connectionLost'.`);
|
|
1246
1945
|
}
|
|
1247
1946
|
// -------------------------------------------------------------------------
|
|
1248
1947
|
// Observability, automatic metrics collection
|
|
@@ -1332,21 +2031,23 @@ export class TurbineClient {
|
|
|
1332
2031
|
*/
|
|
1333
2032
|
createErrorModeAccessor(target) {
|
|
1334
2033
|
const mode = this.errorMessageMode;
|
|
2034
|
+
// Keyed by name AND the function found under it, so a method replaced
|
|
2035
|
+
// after first use (a spy, a mock, an APM patch) is the one that runs.
|
|
1335
2036
|
const wrapped = new Map();
|
|
1336
2037
|
return new Proxy(target, {
|
|
1337
2038
|
get(t, prop, receiver) {
|
|
1338
2039
|
const value = Reflect.get(t, prop, receiver);
|
|
1339
2040
|
if (typeof value !== 'function' || typeof prop !== 'string')
|
|
1340
2041
|
return value;
|
|
1341
|
-
|
|
1342
|
-
if (
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
}
|
|
1349
|
-
return
|
|
2042
|
+
const hit = wrapped.get(prop);
|
|
2043
|
+
if (hit !== undefined && hit.fn === value)
|
|
2044
|
+
return hit.wrapper;
|
|
2045
|
+
const wrapper = (...args) => {
|
|
2046
|
+
const call = () => value.apply(t, args);
|
|
2047
|
+
return errorMessageModesDiverged() ? runWithErrorMessageMode(mode, call) : call();
|
|
2048
|
+
};
|
|
2049
|
+
wrapped.set(prop, { fn: value, wrapper });
|
|
2050
|
+
return wrapper;
|
|
1350
2051
|
},
|
|
1351
2052
|
});
|
|
1352
2053
|
}
|
|
@@ -1369,7 +2070,7 @@ export class TurbineClient {
|
|
|
1369
2070
|
primaryTableQI(name) {
|
|
1370
2071
|
let qi = this.tableCache.get(name);
|
|
1371
2072
|
if (!qi) {
|
|
1372
|
-
qi = this.buildTableQI(this.
|
|
2073
|
+
qi = this.buildTableQI(this.queryPool, name);
|
|
1373
2074
|
this.tableCache.set(name, qi);
|
|
1374
2075
|
}
|
|
1375
2076
|
return qi;
|
|
@@ -1395,7 +2096,7 @@ export class TurbineClient {
|
|
|
1395
2096
|
/** Construct a QueryInterface bound to `pool` (honoring any injected factory). */
|
|
1396
2097
|
buildTableQI(pool, name) {
|
|
1397
2098
|
return this.queryOptions?.queryInterfaceFactory
|
|
1398
|
-
? this.queryOptions.queryInterfaceFactory(pool, name, this.schema, this.middlewares, this.queryOptions)
|
|
2099
|
+
? asQueryInterface(this.queryOptions.queryInterfaceFactory(pool, name, this.schema, this.middlewares, this.queryOptions))
|
|
1399
2100
|
: new QueryInterface(pool, name, this.schema, this.middlewares, this.queryOptions);
|
|
1400
2101
|
}
|
|
1401
2102
|
/**
|
|
@@ -1406,20 +2107,30 @@ export class TurbineClient {
|
|
|
1406
2107
|
createRoutingAccessor(name) {
|
|
1407
2108
|
const primaryQI = this.primaryTableQI(name);
|
|
1408
2109
|
const client = this;
|
|
2110
|
+
// One routing function per operation, so the accessor above can tell a
|
|
2111
|
+
// replaced method from this one by identity.
|
|
2112
|
+
const routed = new Map();
|
|
1409
2113
|
return new Proxy(primaryQI, {
|
|
1410
2114
|
get(target, prop, receiver) {
|
|
1411
|
-
|
|
2115
|
+
// A read replaced on the accessor (a spy, a mock) is an own property of
|
|
2116
|
+
// the primary instance, and runs as installed rather than routed past.
|
|
2117
|
+
if (typeof prop !== 'string' || !READ_OPERATIONS.has(prop) || Object.hasOwn(target, prop)) {
|
|
2118
|
+
return Reflect.get(target, prop, receiver);
|
|
2119
|
+
}
|
|
2120
|
+
let route = routed.get(prop);
|
|
2121
|
+
if (route === undefined) {
|
|
1412
2122
|
// Pick the replica at CALL time so round-robin advances per operation.
|
|
1413
|
-
|
|
2123
|
+
route = (...args) => {
|
|
1414
2124
|
const replicaQI = client.nextReplicaTableQI(name);
|
|
1415
2125
|
const method = replicaQI[prop];
|
|
1416
2126
|
if (typeof method !== 'function') {
|
|
1417
|
-
return Reflect.get(target, prop, receiver);
|
|
2127
|
+
return Reflect.get(target, prop, receiver).apply(target, args);
|
|
1418
2128
|
}
|
|
1419
2129
|
return method.apply(replicaQI, args);
|
|
1420
2130
|
};
|
|
2131
|
+
routed.set(prop, route);
|
|
1421
2132
|
}
|
|
1422
|
-
return
|
|
2133
|
+
return route;
|
|
1423
2134
|
},
|
|
1424
2135
|
});
|
|
1425
2136
|
}
|
|
@@ -1456,27 +2167,13 @@ export class TurbineClient {
|
|
|
1456
2167
|
}
|
|
1457
2168
|
return this.primaryView;
|
|
1458
2169
|
}
|
|
1459
|
-
// -------------------------------------------------------------------------
|
|
1460
|
-
// Pipeline, batch multiple queries into one round-trip
|
|
1461
|
-
// -------------------------------------------------------------------------
|
|
1462
|
-
/**
|
|
1463
|
-
* Execute multiple queries in a single database round-trip.
|
|
1464
|
-
*
|
|
1465
|
-
* Two call styles:
|
|
1466
|
-
* - `db.pipeline(q1, q2, q3)`, rest params (backward-compatible)
|
|
1467
|
-
* - `db.pipeline([q1, q2, q3], { transactional: false })`, array + options
|
|
1468
|
-
*
|
|
1469
|
-
* On pg.Pool-backed connections with TCP, this uses the real Postgres
|
|
1470
|
-
* extended-query pipeline protocol (one TCP flush, one round-trip).
|
|
1471
|
-
* On HTTP-based drivers it falls back to sequential execution.
|
|
1472
|
-
*/
|
|
1473
2170
|
async pipeline(...args) {
|
|
1474
2171
|
let queries;
|
|
1475
2172
|
let options;
|
|
1476
|
-
// Detect which overload was used
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
2173
|
+
// Detect which overload was used. A deferred query is never an array, so
|
|
2174
|
+
// an array first argument is the array form whatever it contains; its
|
|
2175
|
+
// items are checked below like the rest form's.
|
|
2176
|
+
if (args.length > 0 && Array.isArray(args[0])) {
|
|
1480
2177
|
// Array form: pipeline([q1, q2], opts?)
|
|
1481
2178
|
queries = args[0];
|
|
1482
2179
|
options = args[1];
|
|
@@ -1485,8 +2182,18 @@ export class TurbineClient {
|
|
|
1485
2182
|
// Rest-param form: pipeline(q1, q2, q3)
|
|
1486
2183
|
queries = args;
|
|
1487
2184
|
}
|
|
2185
|
+
assertDeferredQueries(queries, 'pipeline', 'pipeline');
|
|
2186
|
+
// A locking read outside a transaction would release its locks before
|
|
2187
|
+
// the caller saw the rows; a transactional pipeline keeps them for the batch.
|
|
2188
|
+
if (options?.transactional === false)
|
|
2189
|
+
assertNoLockInAutocommitPipeline(queries);
|
|
1488
2190
|
if (this.logging) {
|
|
1489
|
-
|
|
2191
|
+
const tags = queries.map((q) => q.tag);
|
|
2192
|
+
logTo(this.logger, 'debug', `Pipeline: ${queries.length} queries, ${tags.join(', ')}`, {
|
|
2193
|
+
code: 'pipeline',
|
|
2194
|
+
queries: queries.length,
|
|
2195
|
+
tags,
|
|
2196
|
+
});
|
|
1490
2197
|
}
|
|
1491
2198
|
// Scoped like every other entry point. A pipeline builds NotFoundErrors (a
|
|
1492
2199
|
// `buildFindUniqueOrThrow` slot) and wraps driver errors into
|
|
@@ -1495,10 +2202,27 @@ export class TurbineClient {
|
|
|
1495
2202
|
// where VALUES as soon as any verbose client existed in the process. The
|
|
1496
2203
|
// scope is established around the await, so every continuation of the batch
|
|
1497
2204
|
// resolves this client's mode.
|
|
2205
|
+
// Each statement carries the `sqlComment` its query interface would have
|
|
2206
|
+
// given it, computed here in the caller's async context.
|
|
2207
|
+
const comments = this.statementComments(queries);
|
|
2208
|
+
const sent = comments ? queries.map((q, i) => commentDeferred(q, comments[i])) : queries;
|
|
1498
2209
|
const sink = this.queryListeners.size > 0 ? this.queryOptions._onQuery : undefined;
|
|
1499
2210
|
if (!sink)
|
|
1500
|
-
return this.withErrorMode(() => executePipeline(this.
|
|
1501
|
-
return this.withErrorMode(() => this.reportedPipeline(sink, queries, options));
|
|
2211
|
+
return this.withErrorMode(() => executePipeline(this.queryPool, sent, options));
|
|
2212
|
+
return this.withErrorMode(() => this.reportedPipeline(sink, queries, options, sent, comments));
|
|
2213
|
+
}
|
|
2214
|
+
/**
|
|
2215
|
+
* The `sqlComment` of each deferred query, named by its own model and action
|
|
2216
|
+
* (a pipeline can mix tables), or `undefined` when this client adds none.
|
|
2217
|
+
*/
|
|
2218
|
+
statementComments(queries) {
|
|
2219
|
+
const commenter = this.sqlCommenter;
|
|
2220
|
+
if (commenter === undefined)
|
|
2221
|
+
return undefined;
|
|
2222
|
+
return queries.map((q) => {
|
|
2223
|
+
const { model, action } = deferredIdentity(q.tag);
|
|
2224
|
+
return commenter.commentFor(model, action)?.comment;
|
|
2225
|
+
});
|
|
1502
2226
|
}
|
|
1503
2227
|
/**
|
|
1504
2228
|
* {@link pipeline} with one `$on('query')` event per statement. Only taken
|
|
@@ -1509,9 +2233,9 @@ export class TurbineClient {
|
|
|
1509
2233
|
* time is split evenly across them and each event carries
|
|
1510
2234
|
* `batch: 'pipeline'`.
|
|
1511
2235
|
*/
|
|
1512
|
-
async reportedPipeline(sink, queries, options) {
|
|
2236
|
+
async reportedPipeline(sink, queries, options, sent, comments) {
|
|
1513
2237
|
const rowCounts = new Array(queries.length).fill(undefined);
|
|
1514
|
-
const observed =
|
|
2238
|
+
const observed = sent.map((q, i) => ({
|
|
1515
2239
|
...q,
|
|
1516
2240
|
transform: (raw) => {
|
|
1517
2241
|
rowCounts[i] = resultRowCount(raw);
|
|
@@ -1521,7 +2245,7 @@ export class TurbineClient {
|
|
|
1521
2245
|
const start = performance.now();
|
|
1522
2246
|
let failure;
|
|
1523
2247
|
try {
|
|
1524
|
-
return await executePipeline(this.
|
|
2248
|
+
return await executePipeline(this.queryPool, observed, options);
|
|
1525
2249
|
}
|
|
1526
2250
|
catch (err) {
|
|
1527
2251
|
failure = err;
|
|
@@ -1547,6 +2271,7 @@ export class TurbineClient {
|
|
|
1547
2271
|
...deferredIdentity(q.tag),
|
|
1548
2272
|
rows: rowCounts[i] ?? 0,
|
|
1549
2273
|
batch: 'pipeline',
|
|
2274
|
+
...(comments?.[i] === undefined ? {} : { comment: comments[i] }),
|
|
1550
2275
|
...(failed ? { error: failure instanceof Error ? failure : new Error(String(failure)) } : {}),
|
|
1551
2276
|
});
|
|
1552
2277
|
}
|
|
@@ -1559,13 +2284,17 @@ export class TurbineClient {
|
|
|
1559
2284
|
* drivers (Neon HTTP, Vercel Postgres, etc.) and mock pools.
|
|
1560
2285
|
*/
|
|
1561
2286
|
async pipelineSupported() {
|
|
1562
|
-
return pipelineSupported(this.
|
|
2287
|
+
return pipelineSupported(this.queryPool);
|
|
1563
2288
|
}
|
|
1564
2289
|
// -------------------------------------------------------------------------
|
|
1565
2290
|
// Raw SQL, tagged template literal escape hatch
|
|
1566
2291
|
// -------------------------------------------------------------------------
|
|
1567
2292
|
/**
|
|
1568
2293
|
* Execute a raw SQL query with parameter interpolation via tagged templates.
|
|
2294
|
+
* Every `${value}` is sent as a bound parameter. Only a real tagged-template
|
|
2295
|
+
* call is accepted: an array passed in its place throws `ValidationError`
|
|
2296
|
+
* (E003), since its text would reach the SQL unescaped. For statement text
|
|
2297
|
+
* built at runtime, see {@link rawUnsafe}.
|
|
1569
2298
|
*
|
|
1570
2299
|
* @example
|
|
1571
2300
|
* ```ts
|
|
@@ -1577,17 +2306,40 @@ export class TurbineClient {
|
|
|
1577
2306
|
* ```
|
|
1578
2307
|
*/
|
|
1579
2308
|
async raw(strings, ...values) {
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
2309
|
+
const { sql, params } = buildTypedSql(strings, values, this.dialect, 'raw');
|
|
2310
|
+
return this.runRaw(sql, params, 'raw');
|
|
2311
|
+
}
|
|
2312
|
+
/**
|
|
2313
|
+
* Execute SQL text built at runtime: DDL read from a file, a statement in
|
|
2314
|
+
* another engine's own language (PowQL), a migration step. Values go in
|
|
2315
|
+
* `values` and are referenced from the text with the engine's placeholders
|
|
2316
|
+
* (`$1`, `$2`, ... on PostgreSQL), so they are still bound.
|
|
2317
|
+
*
|
|
2318
|
+
* NOTHING IN `text` IS ESCAPED. It is the one raw entry point where
|
|
2319
|
+
* concatenating input into the statement is possible, which is why it is
|
|
2320
|
+
* separate from {@link raw} and named for it: never build `text` from
|
|
2321
|
+
* request data. Prefer {@link raw} or {@link sql} wherever the statement is
|
|
2322
|
+
* known when the code is written.
|
|
2323
|
+
*
|
|
2324
|
+
* @example
|
|
2325
|
+
* ```ts
|
|
2326
|
+
* for (const statement of ddlStatements) await db.rawUnsafe(statement);
|
|
2327
|
+
* await db.rawUnsafe('SELECT * FROM users WHERE id = $1', userId);
|
|
2328
|
+
* ```
|
|
2329
|
+
*/
|
|
2330
|
+
async rawUnsafe(text, ...values) {
|
|
2331
|
+
assertSqlText(text, 'rawUnsafe');
|
|
2332
|
+
return this.runRaw(text, values, 'rawUnsafe');
|
|
2333
|
+
}
|
|
2334
|
+
async runRaw(sql, values, action) {
|
|
2335
|
+
const params = this.rawParams(values, action);
|
|
1587
2336
|
if (this.logging) {
|
|
1588
|
-
|
|
2337
|
+
logTo(this.logger, 'debug', `Raw SQL: ${sql.trim().substring(0, 120)}...`, {
|
|
2338
|
+
code: 'raw-sql',
|
|
2339
|
+
operation: action,
|
|
2340
|
+
});
|
|
1589
2341
|
}
|
|
1590
|
-
const result = await runReportedStatement(this.queryOptions._onQuery, rawIdentity(
|
|
2342
|
+
const result = await runReportedStatement(this.queryOptions._onQuery, rawIdentity(action), sql, params, () => this.queryPool.query(sql, params), (err) => this.withErrorMode(() => wrapPgError(err)));
|
|
1591
2343
|
return result.rows;
|
|
1592
2344
|
}
|
|
1593
2345
|
/**
|
|
@@ -1600,9 +2352,20 @@ export class TurbineClient {
|
|
|
1600
2352
|
* the pool. Errors are translated by `wrapPgError`, like {@link raw}.
|
|
1601
2353
|
*/
|
|
1602
2354
|
async rawQuery(text, params = [], action = 'rawQuery') {
|
|
1603
|
-
const
|
|
2355
|
+
const bound = this.rawParams(params, action);
|
|
2356
|
+
const result = await runReportedStatement(this.queryOptions._onQuery, rawIdentity(action), text, bound, () => this.queryPool.query(text, bound), (err) => this.withErrorMode(() => wrapPgError(err)));
|
|
1604
2357
|
return { rows: result.rows, rowCount: result.rowCount };
|
|
1605
2358
|
}
|
|
2359
|
+
/**
|
|
2360
|
+
* Bound Dates as UTC text, on a PostgreSQL client reading zone-less temporal
|
|
2361
|
+
* types as UTC (see {@link utcDateParams}). Typed builder writes convert per
|
|
2362
|
+
* column instead and never come through here.
|
|
2363
|
+
*/
|
|
2364
|
+
rawParams(params, api) {
|
|
2365
|
+
return this.dialect.capabilities.utcTimestampRewrite && this.queryOptions.utcTimestamps !== false
|
|
2366
|
+
? utcDateParams(params, api)
|
|
2367
|
+
: params;
|
|
2368
|
+
}
|
|
1606
2369
|
/**
|
|
1607
2370
|
* Run `fn` with `tag` attached to every `$on('query')` event it causes, so a
|
|
1608
2371
|
* listener can attribute queries to the feature or code path that issued
|
|
@@ -1656,14 +2419,15 @@ export class TurbineClient {
|
|
|
1656
2419
|
* ```
|
|
1657
2420
|
*/
|
|
1658
2421
|
sql(strings, ...values) {
|
|
1659
|
-
const { sql, params } = buildTypedSql(strings, values, this.dialect);
|
|
2422
|
+
const { sql, params: bound } = buildTypedSql(strings, values, this.dialect);
|
|
2423
|
+
const params = this.rawParams(bound, 'sql');
|
|
1660
2424
|
// The scope has to be handed to the BUILDER, not wrapped around this call.
|
|
1661
2425
|
// `TypedSqlQuery` is lazy (it runs on `await` / `.one()` / `.scalar()`), so
|
|
1662
2426
|
// a scope opened here would close before any row was fetched. The
|
|
1663
2427
|
// `withErrorMode` docstring already claimed to cover the typed-SQL builder
|
|
1664
2428
|
// and did not, which left two raw-SQL entry points disagreeing about the
|
|
1665
2429
|
// same statement, since the adjacent `raw` tag WAS scoped.
|
|
1666
|
-
return new TypedSqlQuery(this.
|
|
2430
|
+
return new TypedSqlQuery(this.queryPool, sql, params, this.logging, (fn) => this.withErrorMode(fn), this.queryOptions._onQuery, this.logger);
|
|
1667
2431
|
}
|
|
1668
2432
|
// -------------------------------------------------------------------------
|
|
1669
2433
|
// Transaction support (raw, legacy)
|
|
@@ -1673,10 +2437,16 @@ export class TurbineClient {
|
|
|
1673
2437
|
* connection. For the typed API, use `$transaction()` instead.
|
|
1674
2438
|
*
|
|
1675
2439
|
* The client is typed as {@link PgCompatPoolClient}, the driver-neutral
|
|
1676
|
-
* contract: `query(text, values)
|
|
1677
|
-
*
|
|
1678
|
-
*
|
|
1679
|
-
*
|
|
2440
|
+
* contract: `query(text, values)`, which is all any supported driver
|
|
2441
|
+
* guarantees. For the pg-only surface (cursors, COPY streams, the
|
|
2442
|
+
* `QueryConfig` object form), import `PoolClient` from the pg package and
|
|
2443
|
+
* cast: `client as unknown as PoolClient`.
|
|
2444
|
+
*
|
|
2445
|
+
* Like `$transaction`'s `tx`, the client is closed when the transaction
|
|
2446
|
+
* settles: a query sent through it afterwards throws `ValidationError`
|
|
2447
|
+
* (E003). The callback must not `release()` it, and must await every query
|
|
2448
|
+
* it starts: one still running when the callback returns rolls the
|
|
2449
|
+
* transaction back with E003.
|
|
1680
2450
|
*
|
|
1681
2451
|
* @example
|
|
1682
2452
|
* ```ts
|
|
@@ -1694,28 +2464,32 @@ export class TurbineClient {
|
|
|
1694
2464
|
// driver with one shared engine handle (PowDB embedded) would roll back a
|
|
1695
2465
|
// DIFFERENT caller's open transaction.
|
|
1696
2466
|
const { client, checkout } = await openCheckout(this.pool, (c) => runTxControl(c, this.dialect.beginStatement()));
|
|
2467
|
+
const state = new TransactionState(client, this.dialect, { api: 'transaction' });
|
|
2468
|
+
const guarded = closableRawClient(client, state);
|
|
1697
2469
|
try {
|
|
1698
2470
|
// Engine seam: single-writer engines scope their transaction re-entrancy
|
|
1699
2471
|
// marker to the callback's async subtree (see
|
|
1700
2472
|
// PgCompatPoolClient.wrapTransactionCallback). Absent everywhere else.
|
|
1701
2473
|
const wrap = client.wrapTransactionCallback;
|
|
1702
2474
|
// `.call` erases the generic, so the callback's Promise<T> is re-asserted.
|
|
1703
|
-
const result = wrap ? (await wrap.call(client, () => fn(
|
|
1704
|
-
|
|
2475
|
+
const result = wrap ? (await wrap.call(client, () => fn(guarded))) : await fn(guarded);
|
|
2476
|
+
const unawaited = state.busy;
|
|
2477
|
+
state.end('has ended');
|
|
2478
|
+
if (unawaited)
|
|
2479
|
+
throw unawaitedWorkError('transaction');
|
|
2480
|
+
// A COMMIT answered with ROLLBACK (a statement failed and the callback
|
|
2481
|
+
// caught it) rejects, with the first failed statement as the cause.
|
|
2482
|
+
await commitTransaction(client, this.dialect.commitStatement(), state.firstFailure, 'transaction');
|
|
2483
|
+
state.end('committed');
|
|
2484
|
+
checkout.release();
|
|
1705
2485
|
return result;
|
|
1706
2486
|
}
|
|
1707
2487
|
catch (err) {
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
catch {
|
|
1712
|
-
// Best-effort rollback, the connection may have died mid-query.
|
|
1713
|
-
}
|
|
2488
|
+
state.end('was rolled back');
|
|
2489
|
+
const clean = await rollbackOnServer(client, state, this.dialect);
|
|
2490
|
+
checkout.release(clean ? undefined : new Error('[turbine] transaction connection discarded: rollback not confirmed'));
|
|
1714
2491
|
throw explainConnectionLoss(err, checkout.lostWith);
|
|
1715
2492
|
}
|
|
1716
|
-
finally {
|
|
1717
|
-
checkout.release();
|
|
1718
|
-
}
|
|
1719
2493
|
}
|
|
1720
2494
|
async $transaction(fnOrQueries, options) {
|
|
1721
2495
|
// Scope the WHOLE call, so the transaction-scoped QueryInterfaces built
|
|
@@ -1725,145 +2499,183 @@ export class TurbineClient {
|
|
|
1725
2499
|
}
|
|
1726
2500
|
async runTransaction(fnOrQueries, options) {
|
|
1727
2501
|
// Batch overload: an array of DeferredQuery objects runs atomically inside
|
|
1728
|
-
// one BEGIN…COMMIT,
|
|
2502
|
+
// one BEGIN…COMMIT, through the same lifecycle (and options) as a callback.
|
|
1729
2503
|
if (Array.isArray(fnOrQueries)) {
|
|
1730
|
-
|
|
2504
|
+
assertDeferredQueries(fnOrQueries, '$transaction([...])', 'transaction');
|
|
2505
|
+
if (fnOrQueries.length === 0) {
|
|
2506
|
+
this.resolveTransactionOptions(options);
|
|
2507
|
+
return [];
|
|
2508
|
+
}
|
|
2509
|
+
return this.runInTransaction(options, (client, state) => this.runBatch(client, state, fnOrQueries));
|
|
2510
|
+
}
|
|
2511
|
+
if (typeof fnOrQueries !== 'function') {
|
|
2512
|
+
throw new ValidationError('$transaction expects a callback, `async (tx) => { ... }`, or an array of deferred queries built with ' +
|
|
2513
|
+
`\`build*()\`, but received ${fnOrQueries === null ? 'null' : typeof fnOrQueries}.`);
|
|
1731
2514
|
}
|
|
1732
2515
|
const fn = fnOrQueries;
|
|
1733
|
-
//
|
|
1734
|
-
// the
|
|
2516
|
+
// Pass the parent pool so its read-only guard + PowDB capabilities flow
|
|
2517
|
+
// into the transaction-scoped proxy pool (see TransactionClient.createTxPool).
|
|
2518
|
+
const sourcePool = this.pool;
|
|
2519
|
+
return this.runInTransaction(options, (client, state) => fn(new TransactionClient(client, this.schema, this.middlewares, this.queryOptions, sourcePool, state)));
|
|
2520
|
+
}
|
|
2521
|
+
/**
|
|
2522
|
+
* Validate every transaction option and build the statements they need,
|
|
2523
|
+
* BEFORE a pool slot is taken: a bad argument is the caller's bug and should
|
|
2524
|
+
* not cost a connection to discover.
|
|
2525
|
+
*/
|
|
2526
|
+
resolveTransactionOptions(options) {
|
|
1735
2527
|
const isolationSql = resolveIsolationLevel(options?.isolationLevel);
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
// runs inside openCheckout, which sends it once more on a fresh connection
|
|
1739
|
-
// when the first one turns out to be dead, and which releases the
|
|
1740
|
-
// connection itself when BEGIN fails for good. So everything below runs in
|
|
1741
|
-
// a transaction that began.
|
|
1742
|
-
const { client, checkout } = await openCheckout(this.pool, (c) => runTxControl(c, this.dialect.beginStatement(isolationSql)));
|
|
2528
|
+
const begin = beginStatementFor(this.dialect, isolationSql, resolveReadOnly(options?.readOnly));
|
|
2529
|
+
const sessionConfig = this.resolveSessionContext(options?.sessionContext, options?.allowBuiltinSettings);
|
|
1743
2530
|
const timeout = options?.timeout;
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
2531
|
+
if (timeout !== undefined && timeout !== 0 && !(typeof timeout === 'number' && timeout > 0 && timeout < 2 ** 31)) {
|
|
2532
|
+
throw new ValidationError(`$transaction: timeout must be a positive number of milliseconds (or 0 for none), received ${describeValue(timeout)}.`);
|
|
2533
|
+
}
|
|
2534
|
+
return { begin, sessionConfig, timeout: timeout || undefined };
|
|
2535
|
+
}
|
|
2536
|
+
/**
|
|
2537
|
+
* The one transaction lifecycle both `$transaction` forms share, so an option
|
|
2538
|
+
* one form honours is honoured by the other: BEGIN (isolation level, read
|
|
2539
|
+
* only) -> `sessionContext` GUCs -> body, raced against `timeout` -> COMMIT,
|
|
2540
|
+
* and ROLLBACK on any failure.
|
|
2541
|
+
*
|
|
2542
|
+
* The transaction state is ended as soon as the body settles, BEFORE the
|
|
2543
|
+
* COMMIT, so a `tx` that leaked out of the callback can never slip a
|
|
2544
|
+
* statement in ahead of the COMMIT or onto the connection after it. Work the
|
|
2545
|
+
* body started and did not await is caught there too: the transaction rolls
|
|
2546
|
+
* back rather than committing whatever part of it had run.
|
|
2547
|
+
*
|
|
2548
|
+
* A transaction that will not commit (timed out, failed, unawaited work) is
|
|
2549
|
+
* stopped on the server before its connection is released: the running
|
|
2550
|
+
* statement is cancelled and ROLLBACK sent, so its locks are freed now (see
|
|
2551
|
+
* {@link rollbackOnServer}). A connection that cannot be confirmed clean is
|
|
2552
|
+
* destroyed instead of reused.
|
|
2553
|
+
*/
|
|
2554
|
+
async runInTransaction(options, body) {
|
|
2555
|
+
const { begin, sessionConfig, timeout } = this.resolveTransactionOptions(options);
|
|
2556
|
+
// openCheckout sends BEGIN once more on a fresh connection when the first
|
|
2557
|
+
// one turns out to be dead, and releases the connection itself when BEGIN
|
|
2558
|
+
// fails for good. So everything below runs in a transaction that began.
|
|
2559
|
+
const { client, checkout } = await openCheckout(this.queryPool, (c) => runTxControl(c, begin));
|
|
2560
|
+
const state = new TransactionState(client, this.dialect, { userTransaction: true });
|
|
1761
2561
|
let timedOut = false;
|
|
1762
2562
|
try {
|
|
1763
|
-
//
|
|
1764
|
-
//
|
|
1765
|
-
//
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
// SET LOCAL, so both name and value are BOUND params, never interpolated.
|
|
1769
|
-
if (options?.sessionContext) {
|
|
1770
|
-
if (!this.dialect.supportsRLS) {
|
|
1771
|
-
throw new UnsupportedFeatureError('sessionContext (RLS session GUCs)', this.dialect.name, 'set_config-based row-level-security context requires PostgreSQL.');
|
|
1772
|
-
}
|
|
1773
|
-
for (const [name, value] of Object.entries(options.sessionContext)) {
|
|
1774
|
-
if (!GUC_NAME_REGEX.test(name)) {
|
|
1775
|
-
throw new ValidationError(`Invalid session-context GUC name "${name}", must match ` +
|
|
1776
|
-
'/^[A-Za-z_][A-Za-z0-9_]*(\\.[A-Za-z_][A-Za-z0-9_]*)?$/ (optionally namespaced, e.g. "app.current_tenant")');
|
|
1777
|
-
}
|
|
1778
|
-
const cfg = this.dialect.buildSetSessionConfig(name, String(value));
|
|
1779
|
-
await client.query(cfg.sql, cfg.params);
|
|
1780
|
-
}
|
|
1781
|
-
}
|
|
1782
|
-
// Create the transaction client with typed table accessors. Pass the
|
|
1783
|
-
// parent pool so its read-only guard + PowDB capabilities flow into the
|
|
1784
|
-
// transaction-scoped proxy pool (see TransactionClient.createTxPool).
|
|
1785
|
-
const tx = new TransactionClient(client, this.schema, this.middlewares, this.queryOptions, this.pool);
|
|
1786
|
-
// Dynamically attach table accessors to tx
|
|
1787
|
-
for (const tableName of Object.keys(this.schema.tables)) {
|
|
1788
|
-
const camelName = tableName.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
1789
|
-
if (!(camelName in tx)) {
|
|
1790
|
-
Object.defineProperty(tx, camelName, {
|
|
1791
|
-
get: () => tx.table(tableName),
|
|
1792
|
-
enumerable: true,
|
|
1793
|
-
});
|
|
1794
|
-
}
|
|
1795
|
-
}
|
|
1796
|
-
let result;
|
|
2563
|
+
// Transaction-local session context (RLS / multi-tenant GUCs), applied
|
|
2564
|
+
// after BEGIN and before the body via set_config(name, value,
|
|
2565
|
+
// is_local=true), so both name and value are BOUND params.
|
|
2566
|
+
for (const cfg of sessionConfig)
|
|
2567
|
+
await applySessionSetting(client, cfg);
|
|
1797
2568
|
// Engine seam: when the checked-out connection exposes
|
|
1798
|
-
// wrapTransactionCallback (single-writer engines such as PowDB), run the
|
|
1799
|
-
//
|
|
1800
|
-
//
|
|
2569
|
+
// wrapTransactionCallback (single-writer engines such as PowDB), run the
|
|
2570
|
+
// body through it so the engine can scope its re-entrancy marker to the
|
|
2571
|
+
// body's async subtree. All other drivers: plain body().
|
|
1801
2572
|
const wrap = client.wrapTransactionCallback;
|
|
1802
|
-
const
|
|
2573
|
+
const runBody = () => state.enter(() => (wrap ? wrap.call(client, () => body(client, state)) : body(client, state)));
|
|
2574
|
+
let result;
|
|
1803
2575
|
if (timeout) {
|
|
1804
|
-
// Race between the function and a timeout. If the timeout fires we
|
|
1805
|
-
// need to actually abort the in-flight query, otherwise the backend
|
|
1806
|
-
// keeps running until pg's own timeout, holding a pool slot the whole
|
|
1807
|
-
// time. The simplest reliable cancellation is to destroy the
|
|
1808
|
-
// connection: passing a truthy argument to client.release() tells the
|
|
1809
|
-
// pg pool to discard the client (its socket is closed, which causes
|
|
1810
|
-
// Postgres to abort the active query and roll back the transaction).
|
|
1811
|
-
// The pool will spin up a fresh connection on the next checkout.
|
|
1812
2576
|
let timer;
|
|
1813
2577
|
const timeoutPromise = new Promise((_, reject) => {
|
|
1814
2578
|
timer = setTimeout(() => {
|
|
1815
2579
|
timedOut = true;
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
2580
|
+
state.end('timed out');
|
|
2581
|
+
reject(new TimeoutError(timeout, 'Transaction', {
|
|
2582
|
+
message: `Transaction timed out after ${timeout}ms and was rolled back, so none of its writes were ` +
|
|
2583
|
+
'saved. A statement it was running was cancelled on the server.',
|
|
2584
|
+
outcome: 'not-executed',
|
|
2585
|
+
}));
|
|
1821
2586
|
}, timeout);
|
|
1822
2587
|
});
|
|
1823
2588
|
try {
|
|
1824
|
-
result = await Promise.race([
|
|
2589
|
+
result = await Promise.race([runBody(), timeoutPromise]);
|
|
1825
2590
|
}
|
|
1826
2591
|
finally {
|
|
1827
2592
|
clearTimeout(timer);
|
|
1828
2593
|
}
|
|
1829
2594
|
}
|
|
1830
2595
|
else {
|
|
1831
|
-
result = await
|
|
2596
|
+
result = await runBody();
|
|
1832
2597
|
}
|
|
1833
|
-
|
|
2598
|
+
const unawaited = state.busy;
|
|
2599
|
+
state.end('has ended');
|
|
2600
|
+
if (unawaited)
|
|
2601
|
+
throw unawaitedWorkError('$transaction');
|
|
2602
|
+
// A statement inside was cut short by its timeout, and the callback
|
|
2603
|
+
// caught the error: whether it ran is not known, so nothing commits.
|
|
2604
|
+
if (state.abortedWith !== undefined)
|
|
2605
|
+
throw rolledBackError(state.abortedWith, 'transaction');
|
|
2606
|
+
await commitTransaction(client, this.dialect.commitStatement(), state.firstFailure);
|
|
2607
|
+
state.end('committed');
|
|
2608
|
+
checkout.release();
|
|
1834
2609
|
if (this.logging) {
|
|
1835
|
-
|
|
2610
|
+
logTo(this.logger, 'debug', 'Transaction committed', {
|
|
2611
|
+
code: 'transaction-committed',
|
|
2612
|
+
operation: '$transaction',
|
|
2613
|
+
});
|
|
1836
2614
|
}
|
|
1837
2615
|
return result;
|
|
1838
2616
|
}
|
|
1839
2617
|
catch (err) {
|
|
1840
|
-
//
|
|
1841
|
-
//
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
try {
|
|
1847
|
-
await client.query(this.dialect.rollbackStatement());
|
|
1848
|
-
}
|
|
1849
|
-
catch {
|
|
1850
|
-
// Best-effort rollback, the connection may have died mid-query.
|
|
1851
|
-
}
|
|
1852
|
-
}
|
|
2618
|
+
// A timed-out transaction keeps its 'timed out' wording for any late
|
|
2619
|
+
// statement the callback still tries to send.
|
|
2620
|
+
if (!timedOut)
|
|
2621
|
+
state.end('was rolled back');
|
|
2622
|
+
const clean = await rollbackOnServer(client, state, this.dialect);
|
|
2623
|
+
checkout.release(clean ? undefined : new Error('[turbine] transaction connection discarded: rollback not confirmed'));
|
|
1853
2624
|
if (this.logging) {
|
|
1854
|
-
|
|
2625
|
+
logTo(this.logger, 'debug', 'Transaction rolled back', {
|
|
2626
|
+
code: 'transaction-rolled-back',
|
|
2627
|
+
operation: '$transaction',
|
|
2628
|
+
});
|
|
1855
2629
|
}
|
|
1856
2630
|
throw explainConnectionLoss(err, checkout.lostWith);
|
|
1857
2631
|
}
|
|
1858
|
-
|
|
1859
|
-
|
|
2632
|
+
}
|
|
2633
|
+
/**
|
|
2634
|
+
* Validate `sessionContext` and build its `set_config` statements, before
|
|
2635
|
+
* any connection is taken.
|
|
2636
|
+
*
|
|
2637
|
+
* A name must be a namespaced custom setting (`app.tenant_id`) unless it is
|
|
2638
|
+
* listed in `allowBuiltinSettings`: an un-dotted name is one of PostgreSQL's
|
|
2639
|
+
* own settings (`role`, `search_path`, `statement_timeout`), and a context
|
|
2640
|
+
* built from request data must not be able to reach one. A value that is not
|
|
2641
|
+
* a string, number or boolean is refused, so a missing tenant id cannot reach
|
|
2642
|
+
* a row-level security policy as the text `'undefined'`.
|
|
2643
|
+
*/
|
|
2644
|
+
resolveSessionContext(context, allowBuiltinSettings) {
|
|
2645
|
+
const allowed = resolveBuiltinSettingsAllowlist(allowBuiltinSettings);
|
|
2646
|
+
if (!context)
|
|
2647
|
+
return [];
|
|
2648
|
+
if (!this.dialect.supportsRLS) {
|
|
2649
|
+
throw new UnsupportedFeatureError('sessionContext (RLS session GUCs)', this.dialect.name, 'set_config-based row-level-security context requires PostgreSQL.');
|
|
2650
|
+
}
|
|
2651
|
+
const statements = [];
|
|
2652
|
+
for (const [name, value] of Object.entries(context)) {
|
|
2653
|
+
if (!GUC_NAME_REGEX.test(name)) {
|
|
2654
|
+
throw new ValidationError(`Invalid session-context GUC name "${name}", must match ` +
|
|
2655
|
+
'/^[A-Za-z_][A-Za-z0-9_]*(\\.[A-Za-z_][A-Za-z0-9_]*)?$/ (optionally namespaced, e.g. "app.current_tenant")');
|
|
2656
|
+
}
|
|
2657
|
+
if (!name.includes('.') && !allowed.has(name.toLowerCase())) {
|
|
2658
|
+
throw new ValidationError(`sessionContext["${name}"] names a PostgreSQL setting, not a custom one. A session context may set ` +
|
|
2659
|
+
'namespaced custom settings only (`app.tenant_id`, with a dot), because a built-in setting such as ' +
|
|
2660
|
+
'`role`, `search_path` or `statement_timeout` changes who the transaction runs as or what limits it ' +
|
|
2661
|
+
`has. Rename it into a namespace, or, if your code sets it on purpose, pass \`allowBuiltinSettings: ['${name}']\`.`);
|
|
2662
|
+
}
|
|
2663
|
+
const kind = typeof value;
|
|
2664
|
+
if (kind !== 'string' && kind !== 'number' && kind !== 'boolean') {
|
|
2665
|
+
throw new ValidationError(`sessionContext["${name}"] is ${value === null ? 'null' : kind}. A session setting is text, and ` +
|
|
2666
|
+
`${value === undefined ? 'a missing value' : 'this value'} would reach your row-level security ` +
|
|
2667
|
+
`policies as the string "${String(value)}". Pass a string, number or boolean, and check the value ` +
|
|
2668
|
+
'your request context supplies.');
|
|
2669
|
+
}
|
|
2670
|
+
statements.push({ name, ...this.dialect.buildSetSessionConfig(name, String(value)) });
|
|
1860
2671
|
}
|
|
2672
|
+
return statements;
|
|
1861
2673
|
}
|
|
1862
2674
|
/**
|
|
1863
|
-
*
|
|
1864
|
-
*
|
|
1865
|
-
*
|
|
1866
|
-
*
|
|
2675
|
+
* Run a batch of {@link DeferredQuery} objects on a transaction's connection.
|
|
2676
|
+
* Backs the `$transaction([...])` array overload; the transaction itself
|
|
2677
|
+
* (BEGIN/COMMIT/ROLLBACK, options, release) is {@link runInTransaction}'s.
|
|
2678
|
+
* Each result is passed through its query's `transform`.
|
|
1867
2679
|
*
|
|
1868
2680
|
* Execution strategy on the single transaction connection:
|
|
1869
2681
|
* - **Sequential (default).** Await each statement's reply before sending
|
|
@@ -1884,44 +2696,48 @@ export class TurbineClient {
|
|
|
1884
2696
|
* rethrowing, which keeps the connection's request/reply pairing intact and
|
|
1885
2697
|
* means ROLLBACK is only issued once no statement is still in flight.
|
|
1886
2698
|
*/
|
|
1887
|
-
async
|
|
1888
|
-
if (queries.length === 0) {
|
|
1889
|
-
return [];
|
|
1890
|
-
}
|
|
2699
|
+
async runBatch(client, state, queries) {
|
|
1891
2700
|
// One `$on('query')` event per statement, `batch: 'transaction'`, only
|
|
1892
|
-
// when someone is listening; each statement is timed on its own.
|
|
2701
|
+
// when someone is listening; each statement is timed on its own. Every
|
|
2702
|
+
// statement goes through the transaction state, so a timeout can tell
|
|
2703
|
+
// what is still on the wire and stop it.
|
|
1893
2704
|
const sink = this.queryListeners.size > 0 ? this.queryOptions._onQuery : undefined;
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
results.push(queries[i].transform(outcome.value));
|
|
1910
|
-
}
|
|
1911
|
-
return results;
|
|
1912
|
-
}
|
|
2705
|
+
// Each statement is sent with its `sqlComment`; its event reports the
|
|
2706
|
+
// commentless text and the comment beside it, as a model call's does.
|
|
2707
|
+
const comments = this.statementComments(queries);
|
|
2708
|
+
const reported = (i, run) => {
|
|
2709
|
+
const dq = queries[i];
|
|
2710
|
+
const comment = comments?.[i];
|
|
2711
|
+
return state.run(() => runReportedStatement(sink, { ...deferredIdentity(dq.tag), batch: 'transaction', ...(comment === undefined ? {} : { comment }) }, dq.sql, dq.params, () => run(commentDeferred(dq, comment)), wrapPgError));
|
|
2712
|
+
};
|
|
2713
|
+
const pipelined = client.supportsPipelining === true &&
|
|
2714
|
+
this.dialect.resultStrategy !== 'reselect';
|
|
2715
|
+
if (pipelined) {
|
|
2716
|
+
// Dispatch every statement before awaiting any reply. The driver's
|
|
2717
|
+
// FIFO guarantee makes settled[i] the reply to queries[i]. Each
|
|
2718
|
+
// statement is timed from its dispatch to its own reply.
|
|
2719
|
+
const settled = await Promise.allSettled(queries.map((_, i) => reported(i, (dq) => client.query(dq.sql, dq.params))));
|
|
1913
2720
|
const results = [];
|
|
1914
|
-
for (
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
: client.query(dq.sql, dq.params));
|
|
1921
|
-
results.push(dq.transform(raw));
|
|
2721
|
+
for (let i = 0; i < settled.length; i++) {
|
|
2722
|
+
const outcome = settled[i];
|
|
2723
|
+
if (outcome.status === 'rejected') {
|
|
2724
|
+
throw wrapPgError(outcome.reason);
|
|
2725
|
+
}
|
|
2726
|
+
results.push(queries[i].transform(outcome.value));
|
|
1922
2727
|
}
|
|
1923
2728
|
return results;
|
|
1924
|
-
}
|
|
2729
|
+
}
|
|
2730
|
+
const results = [];
|
|
2731
|
+
for (let i = 0; i < queries.length; i++) {
|
|
2732
|
+
// Non-RETURNING engines (resultStrategy 'reselect', e.g. MySQL) attach a
|
|
2733
|
+
// reselect plan that runs the write plus a follow-up SELECT; running
|
|
2734
|
+
// dq.sql alone would transform a row-less write result.
|
|
2735
|
+
const raw = await reported(i, (dq) => this.dialect.resultStrategy === 'reselect' && dq.reselect
|
|
2736
|
+
? dq.reselect((sql, params) => client.query(sql, params))
|
|
2737
|
+
: client.query(dq.sql, dq.params));
|
|
2738
|
+
results.push(queries[i].transform(raw));
|
|
2739
|
+
}
|
|
2740
|
+
return results;
|
|
1925
2741
|
}
|
|
1926
2742
|
/**
|
|
1927
2743
|
* Convenience wrapper around `$transaction` for the multi-tenant / RLS case:
|
|
@@ -1970,7 +2786,8 @@ export class TurbineClient {
|
|
|
1970
2786
|
* ends it instead. Postgres does not hold notifications for a disconnected
|
|
1971
2787
|
* listener, so anything sent during the gap is lost: use
|
|
1972
2788
|
* `options.onReconnect` to resynchronise. `options.onError` receives the loss
|
|
1973
|
-
* and each failed attempt (default: one
|
|
2789
|
+
* and each failed attempt (default: one log line each through `logger`, at
|
|
2790
|
+
* `warn` while it reconnects and `error` once it stops).
|
|
1974
2791
|
*
|
|
1975
2792
|
* @example
|
|
1976
2793
|
* ```ts
|
|
@@ -1991,11 +2808,11 @@ export class TurbineClient {
|
|
|
1991
2808
|
validateChannel(channel);
|
|
1992
2809
|
const quoted = quoteIdent(channel);
|
|
1993
2810
|
if (this.logging) {
|
|
1994
|
-
|
|
2811
|
+
logTo(this.logger, 'debug', `LISTEN ${quoted}`, { code: 'listen', channel });
|
|
1995
2812
|
}
|
|
1996
2813
|
const sub = await createSubscription(this.pool, channel, quoted, handler, (closed) => {
|
|
1997
2814
|
this.activeSubscriptions.delete(closed);
|
|
1998
|
-
}, options);
|
|
2815
|
+
}, options, this.logger);
|
|
1999
2816
|
this.activeSubscriptions.add(sub);
|
|
2000
2817
|
return sub;
|
|
2001
2818
|
}
|
|
@@ -2018,7 +2835,7 @@ export class TurbineClient {
|
|
|
2018
2835
|
}
|
|
2019
2836
|
validateChannel(channel);
|
|
2020
2837
|
if (this.logging) {
|
|
2021
|
-
|
|
2838
|
+
logTo(this.logger, 'debug', `NOTIFY ${channel}`, { code: 'notify', channel });
|
|
2022
2839
|
}
|
|
2023
2840
|
try {
|
|
2024
2841
|
await this.pool.query('SELECT pg_notify($1, $2)', [channel, payload ?? '']);
|
|
@@ -2056,29 +2873,45 @@ export class TurbineClient {
|
|
|
2056
2873
|
* Throws if the connection fails.
|
|
2057
2874
|
*/
|
|
2058
2875
|
async connect() {
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2876
|
+
await this.roundTrip();
|
|
2877
|
+
if (this.logging) {
|
|
2878
|
+
logTo(this.logger, 'debug', 'Connection verified', { code: 'connection-verified' });
|
|
2879
|
+
}
|
|
2880
|
+
}
|
|
2881
|
+
/**
|
|
2882
|
+
* Run one cheap round trip (`SELECT 1`) on the primary and resolve with its
|
|
2883
|
+
* latency in milliseconds, for health checks and for setting
|
|
2884
|
+
* `autoRoundTripMs`. The time measures the statement on a checked-out
|
|
2885
|
+
* connection, not the wait for one: an exhausted pool rejects with
|
|
2886
|
+
* `ConnectionError` (E004) after `connectionTimeoutMs` instead. Rejects with
|
|
2887
|
+
* the typed connection error when the database cannot be reached.
|
|
2888
|
+
*/
|
|
2889
|
+
async $ping() {
|
|
2890
|
+
return this.roundTrip();
|
|
2891
|
+
}
|
|
2892
|
+
/**
|
|
2893
|
+
* `SELECT 1` on a checked-out primary connection, timed. A connection the
|
|
2894
|
+
* server closed while idle says nothing about whether the database is
|
|
2895
|
+
* reachable, so it runs on a fresh one when the first turns out to be dead
|
|
2896
|
+
* (see openCheckout).
|
|
2897
|
+
*/
|
|
2898
|
+
async roundTrip() {
|
|
2899
|
+
let latency = 0;
|
|
2062
2900
|
const { checkout } = await openCheckout(this.pool, async (client) => {
|
|
2901
|
+
const start = performance.now();
|
|
2063
2902
|
try {
|
|
2064
2903
|
await client.query('SELECT 1');
|
|
2065
2904
|
}
|
|
2066
2905
|
catch (err) {
|
|
2067
2906
|
throw wrapPgError(err);
|
|
2068
2907
|
}
|
|
2908
|
+
latency = performance.now() - start;
|
|
2069
2909
|
});
|
|
2070
2910
|
checkout.release();
|
|
2071
|
-
|
|
2072
|
-
console.log('[turbine] Connection verified');
|
|
2073
|
-
}
|
|
2911
|
+
return latency;
|
|
2074
2912
|
}
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
*
|
|
2078
|
-
* If Turbine was given an external pool via `TurbineConfig.pool`, this
|
|
2079
|
-
* method is a no-op, the caller is responsible for the pool's lifecycle.
|
|
2080
|
-
*/
|
|
2081
|
-
async disconnect() {
|
|
2913
|
+
async disconnect(options) {
|
|
2914
|
+
const timeoutMs = resolveNonNegativeInteger(options?.timeoutMs, 'disconnect timeoutMs', 'milliseconds');
|
|
2082
2915
|
// Tear down any live LISTEN subscriptions first. Each holds a dedicated
|
|
2083
2916
|
// pooled connection checked out; if we ended the pool (or returned for an
|
|
2084
2917
|
// external pool) without releasing them, pool.end() would wait forever for
|
|
@@ -2094,33 +2927,63 @@ export class TurbineClient {
|
|
|
2094
2927
|
}
|
|
2095
2928
|
this.activeSubscriptions.clear();
|
|
2096
2929
|
}
|
|
2097
|
-
//
|
|
2098
|
-
// primary is owned
|
|
2099
|
-
//
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
console.error('[turbine] Error closing replica pool:', err.message);
|
|
2107
|
-
}
|
|
2930
|
+
// Owned (string-configured) replica pools are closed whether or not the
|
|
2931
|
+
// primary is owned; external replica pools are the caller's, like an
|
|
2932
|
+
// external primary.
|
|
2933
|
+
const endReplicas = Promise.all(this.ownedReplicaPools.map((replicaPool) => replicaPool.end().catch((err) => {
|
|
2934
|
+
if (this.logging) {
|
|
2935
|
+
logTo(this.logger, 'error', `Error closing replica pool: ${err.message}`, {
|
|
2936
|
+
code: 'replica-close-error',
|
|
2937
|
+
error: err,
|
|
2938
|
+
});
|
|
2108
2939
|
}
|
|
2940
|
+
})));
|
|
2941
|
+
if (!this.ownsPool && this.logging) {
|
|
2942
|
+
logTo(this.logger, 'debug', 'disconnect() skipped, external primary pool is not owned by Turbine', {
|
|
2943
|
+
code: 'disconnect-skipped',
|
|
2944
|
+
});
|
|
2109
2945
|
}
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2946
|
+
const ending = Promise.all([endReplicas, this.ownsPool ? this.pool.end() : undefined]);
|
|
2947
|
+
if (timeoutMs === undefined || (await settlesWithin(ending, timeoutMs))) {
|
|
2948
|
+
await ending;
|
|
2949
|
+
if (this.logging && this.ownsPool) {
|
|
2950
|
+
logTo(this.logger, 'debug', 'Pool disconnected', { code: 'pool-disconnected', timedOut: false });
|
|
2113
2951
|
}
|
|
2114
|
-
return;
|
|
2952
|
+
return options === undefined ? undefined : { timedOut: false, forcedConnections: 0 };
|
|
2115
2953
|
}
|
|
2116
|
-
await this.
|
|
2954
|
+
const forcedConnections = await this.forceCloseCheckedOut();
|
|
2955
|
+
// Closed connections reach the pool as their holders' queries fail; give
|
|
2956
|
+
// that one grace period, then return either way rather than hang on a
|
|
2957
|
+
// holder that never releases.
|
|
2958
|
+
await settlesWithin(ending, TX_ABORT_GRACE_MS);
|
|
2117
2959
|
if (this.logging) {
|
|
2118
|
-
|
|
2960
|
+
logTo(this.logger, 'debug', `Pool disconnected after ${timeoutMs}ms, ${forcedConnections} connection(s) closed in use`, { code: 'pool-disconnected', timedOut: true, timeoutMs, forcedConnections });
|
|
2119
2961
|
}
|
|
2962
|
+
return { timedOut: true, forcedConnections };
|
|
2120
2963
|
}
|
|
2121
|
-
/**
|
|
2122
|
-
|
|
2123
|
-
|
|
2964
|
+
/**
|
|
2965
|
+
* Cancel the running statement of every connection still checked out of a
|
|
2966
|
+
* pool this client opened, then close the connection. Cancelling first is
|
|
2967
|
+
* what stops the work on the server: a closed socket alone is noticed only
|
|
2968
|
+
* when the backend next writes to it. Returns how many were closed.
|
|
2969
|
+
*/
|
|
2970
|
+
async forceCloseCheckedOut() {
|
|
2971
|
+
const clients = this.checkoutWatches.flatMap((watch) => [...watch.checkedOut]);
|
|
2972
|
+
await Promise.all(clients.map(async (client) => {
|
|
2973
|
+
await sendCancelRequest(client, TX_ABORT_GRACE_MS);
|
|
2974
|
+
const c = client;
|
|
2975
|
+
const closing = Promise.resolve()
|
|
2976
|
+
.then(() => c.end?.())
|
|
2977
|
+
.catch(() => { });
|
|
2978
|
+
// A backend that does not answer the terminate message within the
|
|
2979
|
+
// grace period has its socket destroyed instead.
|
|
2980
|
+
if (!(await settlesWithin(closing, TX_ABORT_GRACE_MS)))
|
|
2981
|
+
c.connection?.stream?.destroy?.();
|
|
2982
|
+
}));
|
|
2983
|
+
return clients.length;
|
|
2984
|
+
}
|
|
2985
|
+
async end(options) {
|
|
2986
|
+
return options === undefined ? this.disconnect() : this.disconnect(options);
|
|
2124
2987
|
}
|
|
2125
2988
|
/**
|
|
2126
2989
|
* Pool statistics for monitoring. Returns zeros for pools that don't
|
|
@@ -2134,3 +2997,15 @@ export class TurbineClient {
|
|
|
2134
2997
|
};
|
|
2135
2998
|
}
|
|
2136
2999
|
}
|
|
3000
|
+
// What prisma-compat (and anything else that wraps a client) logs through: the
|
|
3001
|
+
// client's logger and resolved diagnostics, under a `Symbol.for` key so a CJS
|
|
3002
|
+
// and an ESM copy agree. Defined outside the class body so it stays off the
|
|
3003
|
+
// published declaration of `TurbineClient`.
|
|
3004
|
+
Object.defineProperty(TurbineClient.prototype, CLIENT_LOG_CONTEXT, {
|
|
3005
|
+
get() {
|
|
3006
|
+
const self = this;
|
|
3007
|
+
return { logger: self.logger, diagnostics: self.diagnostics };
|
|
3008
|
+
},
|
|
3009
|
+
enumerable: false,
|
|
3010
|
+
configurable: false,
|
|
3011
|
+
});
|