turbine-orm 0.60.1 → 0.62.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 +71 -27
- package/dist/cjs/cli/config.d.ts +40 -0
- package/dist/cjs/cli/config.js +74 -2
- package/dist/cjs/cli/index.d.ts +85 -1
- package/dist/cjs/cli/index.js +374 -24
- package/dist/cjs/cli/mcp.d.ts +8 -0
- package/dist/cjs/cli/mcp.js +448 -29
- package/dist/cjs/cli/pii-tags.d.ts +64 -9
- package/dist/cjs/cli/pii-tags.js +218 -39
- package/dist/cjs/cli/studio-ui.generated.js +1 -1
- package/dist/cjs/cli/studio.d.ts +23 -0
- package/dist/cjs/cli/studio.js +126 -53
- package/dist/cjs/cli/ui.d.ts +15 -1
- package/dist/cjs/cli/ui.js +19 -5
- package/dist/cjs/client.js +248 -11
- package/dist/cjs/errors.d.ts +38 -1
- package/dist/cjs/errors.js +235 -24
- package/dist/cjs/index.d.ts +2 -2
- package/dist/cjs/index.js +7 -2
- package/dist/cjs/pipeline-submittable.js +26 -3
- package/dist/cjs/pipeline.js +15 -2
- package/dist/cjs/powql.d.ts +12 -0
- package/dist/cjs/powql.js +46 -21
- package/dist/cjs/prisma-compat.d.ts +15 -5
- package/dist/cjs/prisma-compat.js +273 -78
- package/dist/cjs/query/aggregates.d.ts +1 -1
- package/dist/cjs/query/aggregates.js +24 -10
- package/dist/cjs/query/batched-loader.d.ts +9 -4
- package/dist/cjs/query/batched-loader.js +4 -1
- package/dist/cjs/query/builder.d.ts +47 -0
- package/dist/cjs/query/builder.js +149 -21
- package/dist/cjs/query/index.d.ts +3 -1
- package/dist/cjs/query/index.js +7 -1
- package/dist/cjs/query/option-surface.d.ts +11 -0
- package/dist/cjs/query/option-surface.js +13 -0
- package/dist/cjs/query/relations.d.ts +8 -0
- package/dist/cjs/query/relations.js +21 -1
- package/dist/cjs/query/types.d.ts +152 -18
- package/dist/cjs/query/types.js +212 -1
- package/dist/cjs/query/where.d.ts +3 -3
- package/dist/cjs/query/where.js +8 -2
- package/dist/cjs/query/writes.js +10 -9
- package/dist/cli/config.d.ts +40 -0
- package/dist/cli/config.js +73 -2
- package/dist/cli/index.d.ts +85 -1
- package/dist/cli/index.js +373 -27
- package/dist/cli/mcp.d.ts +8 -0
- package/dist/cli/mcp.js +448 -29
- package/dist/cli/pii-tags.d.ts +64 -9
- package/dist/cli/pii-tags.js +217 -39
- package/dist/cli/studio-ui.generated.js +1 -1
- package/dist/cli/studio.d.ts +23 -0
- package/dist/cli/studio.js +125 -53
- package/dist/cli/ui.d.ts +15 -1
- package/dist/cli/ui.js +18 -4
- package/dist/client.js +250 -13
- package/dist/errors.d.ts +38 -1
- package/dist/errors.js +234 -23
- package/dist/index.d.ts +2 -2
- package/dist/index.js +5 -2
- package/dist/pipeline-submittable.js +26 -3
- package/dist/pipeline.js +15 -2
- package/dist/powql.d.ts +12 -0
- package/dist/powql.js +46 -21
- package/dist/prisma-compat.d.ts +15 -5
- package/dist/prisma-compat.js +274 -79
- package/dist/query/aggregates.d.ts +1 -1
- package/dist/query/aggregates.js +24 -10
- package/dist/query/batched-loader.d.ts +9 -4
- package/dist/query/batched-loader.js +4 -1
- package/dist/query/builder.d.ts +47 -0
- package/dist/query/builder.js +148 -21
- package/dist/query/index.d.ts +3 -1
- package/dist/query/index.js +2 -0
- package/dist/query/option-surface.d.ts +11 -0
- package/dist/query/option-surface.js +13 -0
- package/dist/query/relations.d.ts +8 -0
- package/dist/query/relations.js +21 -1
- package/dist/query/types.d.ts +152 -18
- package/dist/query/types.js +207 -2
- package/dist/query/where.d.ts +3 -3
- package/dist/query/where.js +8 -2
- package/dist/query/writes.js +10 -9
- package/package.json +13 -3
package/dist/client.js
CHANGED
|
@@ -23,11 +23,11 @@
|
|
|
23
23
|
*/
|
|
24
24
|
import pg from 'pg';
|
|
25
25
|
import { postgresDialect } from './dialect.js';
|
|
26
|
-
import { setErrorMessageMode, TimeoutError, UnsupportedFeatureError, ValidationError, wrapPgError, } from './errors.js';
|
|
26
|
+
import { ConnectionError, setErrorMessageMode, TimeoutError, UnsupportedFeatureError, ValidationError, wrapPgError, } from './errors.js';
|
|
27
27
|
import { ObserveEngine } from './observe.js';
|
|
28
28
|
import { executePipeline, pipelineSupported } from './pipeline.js';
|
|
29
29
|
import { QueryInterface, } from './query/index.js';
|
|
30
|
-
import { markTurbineParser, quoteIdent, registerUtcTemporalParsers, suggestKey, warnParserOverwrite, } from './query/utils.js';
|
|
30
|
+
import { markTurbineParser, ownLookup, quoteIdent, registerUtcTemporalParsers, suggestKey, warnParserOverwrite, } from './query/utils.js';
|
|
31
31
|
import { shouldWarnOnce, WARN_NS } from './query/warn-registry.js';
|
|
32
32
|
import { createSubscription, validateChannel, } from './realtime.js';
|
|
33
33
|
import { buildTypedSql, TypedSqlQuery } from './typed-sql.js';
|
|
@@ -186,13 +186,215 @@ function warnUnknownConfigKeys(config) {
|
|
|
186
186
|
// must never be the reason a client fails to construct.
|
|
187
187
|
}
|
|
188
188
|
}
|
|
189
|
-
/**
|
|
190
|
-
|
|
189
|
+
/**
|
|
190
|
+
* Two bases that differ ONLY in host, used to detect whether the host pg ends
|
|
191
|
+
* up with came from the connection string or from pg's own placeholder base.
|
|
192
|
+
*
|
|
193
|
+
* pg-connection-string resolves a connection string as `new URL(str,
|
|
194
|
+
* 'postgres://base')`. A string with no scheme is therefore a RELATIVE url, and
|
|
195
|
+
* silently inherits the literal host `base`, which is where
|
|
196
|
+
* `getaddrinfo ENOTFOUND base` comes from. Parsing against two different bases
|
|
197
|
+
* and comparing the resulting hostnames identifies that case exactly, and
|
|
198
|
+
* cannot be fooled by a string that genuinely names a host called `base`.
|
|
199
|
+
*/
|
|
200
|
+
const CONNECTION_STRING_PROBE_BASES = ['postgres://turbine-probe-a', 'postgres://turbine-probe-b'];
|
|
201
|
+
/**
|
|
202
|
+
* Classify a connection string by replaying the parse pg itself performs.
|
|
203
|
+
*
|
|
204
|
+
* This is a deliberate mirror of `pg-connection-string`'s `parse()`, in the
|
|
205
|
+
* same order, because that function IS the oracle: anything it resolves to a
|
|
206
|
+
* real host/socket is a string pg will connect with, and a validator that
|
|
207
|
+
* refuses one of those breaks a working deployment. In particular all of these
|
|
208
|
+
* are usable and must pass:
|
|
209
|
+
* - `/var/run/postgresql app` and `/cloudsql/proj:region:instance`, the
|
|
210
|
+
* leading-slash unix-socket form (peer auth, Cloud SQL),
|
|
211
|
+
* - `socket:/var/run/postgresql?db=app`, the explicit socket scheme,
|
|
212
|
+
* - `postgresql:///db`, `postgres:/db`, `postgres://`, all empty-host forms
|
|
213
|
+
* that fall back to pg's localhost default,
|
|
214
|
+
* - `postgres://user:pw@/db` (the `@/` dummy-host retry) and
|
|
215
|
+
* `?host=/var/run/postgresql` (host as a query parameter),
|
|
216
|
+
* - anything else that parses as an absolute URL, `localhost:5432` included.
|
|
217
|
+
*
|
|
218
|
+
* Only two shapes are not usable: a string with no scheme (pg substitutes its
|
|
219
|
+
* placeholder host) and a string neither parse attempt can turn into a URL (pg
|
|
220
|
+
* throws `Invalid URL` from its own constructor).
|
|
221
|
+
*/
|
|
222
|
+
function classifyConnectionString(value) {
|
|
223
|
+
// Leading slash: pg short-circuits to `{ host: <path>, database: <word 2> }`
|
|
224
|
+
// before any URL parsing. Unix-domain socket directory or Cloud SQL path.
|
|
225
|
+
if (value.charAt(0) === '/')
|
|
226
|
+
return { kind: 'usable' };
|
|
227
|
+
// pg percent-encodes before parsing, so a string with spaces (or a stray
|
|
228
|
+
// half-written escape) reaches the URL constructor already encoded.
|
|
229
|
+
const encoded = / |%[^a-f0-9]|%[a-f0-9][^a-f0-9]/i.test(value)
|
|
230
|
+
? encodeURI(value).replace(/%25(\d\d)/g, '%$1')
|
|
231
|
+
: value;
|
|
232
|
+
const parseAgainst = (base) => {
|
|
233
|
+
try {
|
|
234
|
+
return { url: new URL(encoded, base), dummyHost: false };
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
// pg's own second attempt: `postgres://user:pw@/db` is not a valid URL,
|
|
238
|
+
// so it retries with a placeholder authority and treats the host as empty.
|
|
239
|
+
try {
|
|
240
|
+
return { url: new URL(encoded.replace('@/', '@___DUMMY___/'), base), dummyHost: true };
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
const a = parseAgainst(CONNECTION_STRING_PROBE_BASES[0]);
|
|
248
|
+
const b = parseAgainst(CONNECTION_STRING_PROBE_BASES[1]);
|
|
249
|
+
if (!a || !b)
|
|
250
|
+
return { kind: 'unparseable' };
|
|
251
|
+
// `socket:` is handled by its own branch in pg's parse: the pathname is the
|
|
252
|
+
// socket directory and the base never contributes.
|
|
253
|
+
if (a.url.protocol === 'socket:')
|
|
254
|
+
return { kind: 'usable' };
|
|
255
|
+
// The dummy-host retry only happens for an absolute URL, so the host is
|
|
256
|
+
// empty by construction and pg falls back to its localhost default.
|
|
257
|
+
if (a.dummyHost)
|
|
258
|
+
return { kind: 'usable' };
|
|
259
|
+
// A `host=` query parameter wins over the URL authority in pg's parse.
|
|
260
|
+
if (a.url.searchParams.get('host'))
|
|
261
|
+
return { kind: 'usable' };
|
|
262
|
+
return a.url.hostname === b.url.hostname ? { kind: 'usable' } : { kind: 'phantom-host' };
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Reject a connection string pg cannot connect with, at construction, naming
|
|
266
|
+
* the string as the problem.
|
|
267
|
+
*
|
|
268
|
+
* Without this, a typo'd or truncated string is handed to pg, which resolves it
|
|
269
|
+
* as a URL relative to its own `postgres://base`, so the string silently
|
|
270
|
+
* becomes a request for a host named `base`. The failure surfaces one query
|
|
271
|
+
* later as `[TURBINE_E004] Database connection error: getaddrinfo ENOTFOUND
|
|
272
|
+
* base`, and sends the reader looking for a DNS problem with a host they never
|
|
273
|
+
* configured.
|
|
274
|
+
*
|
|
275
|
+
* The oracle is pg itself (see {@link classifyConnectionString}), not a scheme
|
|
276
|
+
* pattern. An earlier scheme-matching version of this check refused
|
|
277
|
+
* `/var/run/postgresql app` and `/cloudsql/project:region:instance`, which are
|
|
278
|
+
* documented, working unix-socket connection strings, i.e. it broke peer-auth
|
|
279
|
+
* and Cloud SQL deployments at `new TurbineClient(...)`.
|
|
280
|
+
*
|
|
281
|
+
* The empty string never reaches here: the call sites skip a falsy value, which
|
|
282
|
+
* is exactly what the pool-building branch does with it, so
|
|
283
|
+
* `connectionString: process.env.DATABASE_URL ?? ''` keeps constructing.
|
|
284
|
+
*/
|
|
285
|
+
function assertUsableConnectionString(value, source) {
|
|
286
|
+
// Whitespace-only is not empty: it is truthy, so pg WOULD take it, encode it
|
|
287
|
+
// to `%20%20%20`, and resolve it against its placeholder host. Naming it as
|
|
288
|
+
// empty is more useful than reporting the phantom host.
|
|
289
|
+
if (value.trim().length === 0) {
|
|
290
|
+
throw new ConnectionError(`[turbine] The ${source} is empty. Expected a Postgres connection string such as ` +
|
|
291
|
+
'"postgresql://user:password@host:5432/database".');
|
|
292
|
+
}
|
|
293
|
+
const verdict = classifyConnectionString(value);
|
|
294
|
+
if (verdict.kind === 'usable')
|
|
295
|
+
return;
|
|
296
|
+
// Never echo the value: it may carry a password, and it is malformed, so no
|
|
297
|
+
// redaction written against the URL grammar can be trusted on it.
|
|
298
|
+
const tail = '(Check for a missing "//", a stray quote copied out of a .env file, or a shell-truncated value.) ' +
|
|
299
|
+
'The value is not included here because it may contain a password.';
|
|
300
|
+
if (verdict.kind === 'unparseable') {
|
|
301
|
+
throw new ConnectionError(`[turbine] The ${source} cannot be parsed as a connection string, pg rejects it with "Invalid URL" ` +
|
|
302
|
+
'(most often a non-numeric port). Expected something like ' +
|
|
303
|
+
`"postgresql://user:password@host:5432/database". ${tail}`);
|
|
304
|
+
}
|
|
305
|
+
// libpq keyword/value strings land here, and deserve their own sentence:
|
|
306
|
+
// node-postgres does NOT implement that form, it feeds the whole string to
|
|
307
|
+
// the URL parser, so `host=localhost dbname=app` becomes a request for the
|
|
308
|
+
// host `base` with the database `host=localhost dbname=app`.
|
|
309
|
+
const keywordForm = /^[A-Za-z_][A-Za-z0-9_]*\s*=/.test(value.trimStart())
|
|
310
|
+
? 'It looks like a libpq "host=… dbname=…" keyword/value string, which node-postgres does not support. '
|
|
311
|
+
: '';
|
|
312
|
+
throw new ConnectionError(`[turbine] The ${source} names no host: it has no "postgres://" or "postgresql://" scheme, so pg would ` +
|
|
313
|
+
`resolve it as a relative URL and try to connect to its internal placeholder host. ${keywordForm}` +
|
|
314
|
+
`Expected something like "postgresql://user:password@host:5432/database". ${tail}`);
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Maps isolation level names to SQL. Null-prototype and read through
|
|
318
|
+
* {@link resolveIsolationLevel}, never indexed directly: an inherited key
|
|
319
|
+
* (`constructor`, `toString`) would otherwise resolve to a function and be
|
|
320
|
+
* interpolated verbatim into `BEGIN ISOLATION LEVEL …`.
|
|
321
|
+
*/
|
|
322
|
+
const ISOLATION_LEVELS = Object.assign(Object.create(null), {
|
|
191
323
|
ReadUncommitted: 'READ UNCOMMITTED',
|
|
192
324
|
ReadCommitted: 'READ COMMITTED',
|
|
193
325
|
RepeatableRead: 'REPEATABLE READ',
|
|
194
326
|
Serializable: 'SERIALIZABLE',
|
|
195
|
-
};
|
|
327
|
+
});
|
|
328
|
+
/**
|
|
329
|
+
* Resolve a requested isolation level, REFUSING anything not in the map.
|
|
330
|
+
*
|
|
331
|
+
* A miss used to yield `undefined`, which `beginStatement` renders as a plain
|
|
332
|
+
* `BEGIN`. So `isolationLevel: 'serializable'` (wrong case) asked for
|
|
333
|
+
* SERIALIZABLE and silently got READ COMMITTED, which is the worst available
|
|
334
|
+
* outcome for this option: the caller believes it has a guarantee it does not
|
|
335
|
+
* have, and the workload that depended on it produces wrong data with no error.
|
|
336
|
+
*
|
|
337
|
+
* The TypeScript union on `TransactionOptions.isolationLevel` does not prevent
|
|
338
|
+
* this. It is not enforced for a JavaScript consumer of a published package, for
|
|
339
|
+
* a value read from config or an environment variable, or across any `as`.
|
|
340
|
+
* Throwing costs a caller who was already broken one clear error.
|
|
341
|
+
*/
|
|
342
|
+
/**
|
|
343
|
+
* Run a transaction-control statement (BEGIN / COMMIT / ROLLBACK) with the same
|
|
344
|
+
* error translation every other query boundary gets.
|
|
345
|
+
*
|
|
346
|
+
* These four statements were the only ones issued raw, and COMMIT is the one
|
|
347
|
+
* that matters: Postgres reports DEFERRABLE constraint violations and a good
|
|
348
|
+
* share of SERIALIZABLE conflicts at COMMIT rather than at the statement that
|
|
349
|
+
* caused them. Unwrapped, those surfaced as a raw `DatabaseError` carrying a
|
|
350
|
+
* SQLSTATE in `.code`, which is the SAME property Turbine puts `TURBINE_E0NN`
|
|
351
|
+
* in, so `err.code === 'TURBINE_E008'` silently missed them and a `switch` on
|
|
352
|
+
* `.code` received a value from a foreign namespace. Worse for the retry case:
|
|
353
|
+
* `SerializationFailureError.isRetryable` exists so callers can build retry
|
|
354
|
+
* loops, and a loop keyed on it would never fire for the commit-time conflicts
|
|
355
|
+
* that are the main reason to run SERIALIZABLE at all.
|
|
356
|
+
*/
|
|
357
|
+
/**
|
|
358
|
+
* Check out a pooled connection, translating a driver failure into a typed
|
|
359
|
+
* Turbine error.
|
|
360
|
+
*
|
|
361
|
+
* `pool.connect()` is where the first-run failures actually land: wrong
|
|
362
|
+
* password (SQLSTATE 28P01), no such database (3D000), nothing listening
|
|
363
|
+
* (ECONNREFUSED), an unverifiable TLS certificate. Unwrapped, every one of
|
|
364
|
+
* those left `$transaction`, `transaction()` and `connect()` as a raw pg
|
|
365
|
+
* `DatabaseError` carrying a SQLSTATE in `.code`, the same property Turbine
|
|
366
|
+
* puts `TURBINE_E0NN` in, so the single error a new user is most likely to see
|
|
367
|
+
* was the one error the typed-error contract did not cover.
|
|
368
|
+
*
|
|
369
|
+
* Query paths need no equivalent: `pool.query()` opens the connection itself
|
|
370
|
+
* and rejects with the connect error, which the query boundary already wraps.
|
|
371
|
+
*/
|
|
372
|
+
async function acquireConnection(pool) {
|
|
373
|
+
try {
|
|
374
|
+
return await pool.connect();
|
|
375
|
+
}
|
|
376
|
+
catch (err) {
|
|
377
|
+
throw wrapPgError(err);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
async function runTxControl(client, sql) {
|
|
381
|
+
try {
|
|
382
|
+
await client.query(sql);
|
|
383
|
+
}
|
|
384
|
+
catch (err) {
|
|
385
|
+
throw wrapPgError(err);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
function resolveIsolationLevel(level) {
|
|
389
|
+
if (level === undefined)
|
|
390
|
+
return undefined;
|
|
391
|
+
const sql = ownLookup(ISOLATION_LEVELS, level);
|
|
392
|
+
if (sql === undefined) {
|
|
393
|
+
throw new ValidationError(`[turbine] $transaction: unknown isolationLevel ${JSON.stringify(level)}. ` +
|
|
394
|
+
`Expected one of: ${Object.keys(ISOLATION_LEVELS).join(', ')} (case-sensitive).`);
|
|
395
|
+
}
|
|
396
|
+
return sql;
|
|
397
|
+
}
|
|
196
398
|
/**
|
|
197
399
|
* Strict GUC (session variable) name: an optionally namespaced identifier such
|
|
198
400
|
* as `app.current_tenant` or `search_path`. Even though the name is passed as a
|
|
@@ -531,6 +733,39 @@ export class TurbineClient {
|
|
|
531
733
|
// would poison the next, valid, TurbineClient with a phantom conflict.
|
|
532
734
|
const dialect = config.dialect ?? postgresDialect;
|
|
533
735
|
const planCacheMode = TurbineClient.resolvePlanCacheMode(config.planCacheMode, dialect);
|
|
736
|
+
// Refuse a connection string that cannot address a database, before it
|
|
737
|
+
// reaches pg's permissive parser and comes back one query later as a DNS
|
|
738
|
+
// error about a fragment of itself. Only the strings this client will
|
|
739
|
+
// actually USE are checked: with an external pool the connection target is
|
|
740
|
+
// the caller's, and an explicit host/port/user config makes DATABASE_URL
|
|
741
|
+
// irrelevant (the same precedence the pool-building branch below applies).
|
|
742
|
+
// The truthiness checks here mirror the pool-building branch below EXACTLY.
|
|
743
|
+
// An empty `connectionString` is ignored there (pg ignores it too), so
|
|
744
|
+
// `connectionString: process.env.DATABASE_URL ?? ''` must keep constructing,
|
|
745
|
+
// and it still suppresses the DATABASE_URL fallback because
|
|
746
|
+
// `hasExplicitConnection` tests `!= null`, not truthiness.
|
|
747
|
+
if (!config.pool) {
|
|
748
|
+
if (config.connectionString) {
|
|
749
|
+
assertUsableConnectionString(config.connectionString, 'connectionString');
|
|
750
|
+
}
|
|
751
|
+
else if (config.connectionString == null &&
|
|
752
|
+
config.host == null &&
|
|
753
|
+
config.port == null &&
|
|
754
|
+
config.database == null &&
|
|
755
|
+
config.user == null &&
|
|
756
|
+
config.password == null &&
|
|
757
|
+
process.env.DATABASE_URL) {
|
|
758
|
+
assertUsableConnectionString(process.env.DATABASE_URL, 'DATABASE_URL environment variable');
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
for (const [i, replica] of (config.replicas ?? []).entries()) {
|
|
762
|
+
// Only string replicas are ours to open; a PgCompatPool entry is already
|
|
763
|
+
// connected by its owner. An empty string is skipped for the same reason
|
|
764
|
+
// as above: pg ignores a falsy connectionString and uses its defaults.
|
|
765
|
+
if (typeof replica === 'string' && replica) {
|
|
766
|
+
assertUsableConnectionString(replica, `replicas[${i}] connection string`);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
534
769
|
/**
|
|
535
770
|
* Parse int8 (bigint, OID 20) as JavaScript number instead of string.
|
|
536
771
|
* Safe for values up to Number.MAX_SAFE_INTEGER (9,007,199,254,740,991).
|
|
@@ -1251,7 +1486,7 @@ export class TurbineClient {
|
|
|
1251
1486
|
* ```
|
|
1252
1487
|
*/
|
|
1253
1488
|
async transaction(fn) {
|
|
1254
|
-
const client = await this.pool
|
|
1489
|
+
const client = await acquireConnection(this.pool);
|
|
1255
1490
|
/**
|
|
1256
1491
|
* Only true once BEGIN has actually succeeded. If BEGIN itself throws
|
|
1257
1492
|
* (e.g. a single-writer engine's transaction gate times out or rejects a
|
|
@@ -1262,7 +1497,7 @@ export class TurbineClient {
|
|
|
1262
1497
|
*/
|
|
1263
1498
|
let began = false;
|
|
1264
1499
|
try {
|
|
1265
|
-
await client
|
|
1500
|
+
await runTxControl(client, this.dialect.beginStatement());
|
|
1266
1501
|
began = true;
|
|
1267
1502
|
// Engine seam: single-writer engines scope their transaction re-entrancy
|
|
1268
1503
|
// marker to the callback's async subtree (see
|
|
@@ -1270,7 +1505,7 @@ export class TurbineClient {
|
|
|
1270
1505
|
const wrap = client.wrapTransactionCallback;
|
|
1271
1506
|
// `.call` erases the generic, so the callback's Promise<T> is re-asserted.
|
|
1272
1507
|
const result = wrap ? (await wrap.call(client, () => fn(client))) : await fn(client);
|
|
1273
|
-
await client
|
|
1508
|
+
await runTxControl(client, this.dialect.commitStatement());
|
|
1274
1509
|
return result;
|
|
1275
1510
|
}
|
|
1276
1511
|
catch (err) {
|
|
@@ -1295,7 +1530,10 @@ export class TurbineClient {
|
|
|
1295
1530
|
return this.transactionBatch(fnOrQueries);
|
|
1296
1531
|
}
|
|
1297
1532
|
const fn = fnOrQueries;
|
|
1298
|
-
|
|
1533
|
+
// Resolve the isolation level BEFORE taking a pool slot: a bad argument is
|
|
1534
|
+
// the caller's bug and should not cost a connection to discover.
|
|
1535
|
+
const isolationSql = resolveIsolationLevel(options?.isolationLevel);
|
|
1536
|
+
const client = await acquireConnection(this.pool);
|
|
1299
1537
|
const timeout = options?.timeout;
|
|
1300
1538
|
/**
|
|
1301
1539
|
* Track whether the connection has already been released so the finally
|
|
@@ -1327,8 +1565,7 @@ export class TurbineClient {
|
|
|
1327
1565
|
try {
|
|
1328
1566
|
// BEGIN with optional isolation level, the dialect owns the keyword and
|
|
1329
1567
|
// BEGIN+isolation composition (Postgres appends ` ISOLATION LEVEL …`).
|
|
1330
|
-
|
|
1331
|
-
await client.query(this.dialect.beginStatement(isolationSql));
|
|
1568
|
+
await runTxControl(client, this.dialect.beginStatement(isolationSql));
|
|
1332
1569
|
began = true;
|
|
1333
1570
|
// Apply transaction-local session context (RLS / multi-tenant GUCs).
|
|
1334
1571
|
// Order matters: BEGIN -> isolation level (above) -> set_config loop ->
|
|
@@ -1400,7 +1637,7 @@ export class TurbineClient {
|
|
|
1400
1637
|
else {
|
|
1401
1638
|
result = await runCallback();
|
|
1402
1639
|
}
|
|
1403
|
-
await client
|
|
1640
|
+
await runTxControl(client, this.dialect.commitStatement());
|
|
1404
1641
|
if (this.logging) {
|
|
1405
1642
|
console.log('[turbine] Transaction committed');
|
|
1406
1643
|
}
|
|
@@ -1620,7 +1857,7 @@ export class TurbineClient {
|
|
|
1620
1857
|
* Throws if the connection fails.
|
|
1621
1858
|
*/
|
|
1622
1859
|
async connect() {
|
|
1623
|
-
const client = await this.pool
|
|
1860
|
+
const client = await acquireConnection(this.pool);
|
|
1624
1861
|
try {
|
|
1625
1862
|
await client.query('SELECT 1');
|
|
1626
1863
|
if (this.logging) {
|
package/dist/errors.d.ts
CHANGED
|
@@ -53,10 +53,29 @@ export type ErrorMessageMode = 'safe' | 'verbose';
|
|
|
53
53
|
* clause (e.g. `where: { id, email }`). Values are redacted.
|
|
54
54
|
* - `'verbose'`: the message includes the full JSON-serialized where
|
|
55
55
|
* clause (e.g. `where: {"id":1,"email":"alice@x.com"}`).
|
|
56
|
+
*
|
|
57
|
+
* SCOPE, stated precisely because the useful version of this contract is the
|
|
58
|
+
* one that is true. 'safe' mode redacts row values from the surfaces Turbine
|
|
59
|
+
* OWNS: its own error messages, and the `detail` field of a driver error it
|
|
60
|
+
* wraps and attaches as `.cause` (see redactCauseForMode).
|
|
61
|
+
*
|
|
62
|
+
* It is NOT a blanket guarantee that no row value can be reached from a thrown
|
|
63
|
+
* error. A driver error whose SQLSTATE {@link wrapPgError} does not classify is
|
|
64
|
+
* returned UNCHANGED, and some of those carry a value in the `message` field
|
|
65
|
+
* itself, where nothing can be removed without destroying the diagnosis:
|
|
66
|
+
* `22P02 invalid input syntax for type integer: "alice@example.com"` is the
|
|
67
|
+
* common one. Treat 'safe' mode as removing Turbine's own contribution to the
|
|
68
|
+
* leak, not as a log-scrubbing boundary.
|
|
56
69
|
*/
|
|
57
70
|
export declare function setErrorMessageMode(mode: ErrorMessageMode): void;
|
|
58
71
|
/** Returns the current NotFoundError message mode. Exported for tests. */
|
|
59
72
|
export declare function getErrorMessageMode(): ErrorMessageMode;
|
|
73
|
+
/**
|
|
74
|
+
* The marker left where a driver `detail` string was removed in 'safe' mode.
|
|
75
|
+
* Re-exported from the package root so tests and callers writing log
|
|
76
|
+
* assertions can match on it without hardcoding the wording.
|
|
77
|
+
*/
|
|
78
|
+
export declare const REDACTED_DETAIL = "[redacted by turbine errorMessages:\"safe\"]";
|
|
60
79
|
/**
|
|
61
80
|
* Render a user-supplied `where` / `connect` target for a "no row found" error
|
|
62
81
|
* message, honoring the global {@link ErrorMessageMode}. In 'safe' mode (the
|
|
@@ -122,13 +141,29 @@ export declare class ValidationError extends TurbineError {
|
|
|
122
141
|
}
|
|
123
142
|
/** Thrown when a database connection fails */
|
|
124
143
|
export declare class ConnectionError extends TurbineError {
|
|
144
|
+
/**
|
|
145
|
+
* The driver code that produced this error: a Postgres SQLSTATE (`28P01`
|
|
146
|
+
* wrong password, `3D000` no such database, `08006` connection failure, ...)
|
|
147
|
+
* or a Node socket/TLS code (`ECONNREFUSED`, `CERT_HAS_EXPIRED`, ...).
|
|
148
|
+
*
|
|
149
|
+
* Exposed because E004 covers causes with very different remedies, and the
|
|
150
|
+
* alternative for a caller who needs to tell "wrong password" from "server
|
|
151
|
+
* down" is matching on `.message` text or reaching into `.cause`, both of
|
|
152
|
+
* which are exactly the untyped handling this error class exists to remove.
|
|
153
|
+
* Undefined when Turbine raised the error itself rather than wrapping a
|
|
154
|
+
* driver error (a malformed connection string, a subscription on an HTTP
|
|
155
|
+
* pool).
|
|
156
|
+
*/
|
|
157
|
+
readonly sqlstate?: string;
|
|
125
158
|
/**
|
|
126
159
|
* @param message human-readable connection failure description.
|
|
127
160
|
* @param options optional pg/driver `cause` to preserve, used when wrapping a
|
|
128
|
-
* connection-class driver error via `wrapPgError
|
|
161
|
+
* connection-class driver error via `wrapPgError`, plus the driver `code`
|
|
162
|
+
* that classified it.
|
|
129
163
|
*/
|
|
130
164
|
constructor(message: string, options?: {
|
|
131
165
|
cause?: unknown;
|
|
166
|
+
sqlstate?: string;
|
|
132
167
|
});
|
|
133
168
|
}
|
|
134
169
|
/** Thrown when a relation reference is invalid */
|
|
@@ -363,6 +398,8 @@ export declare class ReadOnlyError extends TurbineError {
|
|
|
363
398
|
* 40P01 (deadlock_detected) -> DeadlockError (retryable)
|
|
364
399
|
* 40001 (serialization_failure) -> SerializationFailureError (retryable)
|
|
365
400
|
* 57014 (query_canceled) -> TimeoutError (server-side statement_timeout)
|
|
401
|
+
* 28P01 / 28000 (auth refused) -> ConnectionError, with a remediation hint
|
|
402
|
+
* 3D000 (no such database) -> ConnectionError, with a remediation hint
|
|
366
403
|
* connection-class codes -> ConnectionError (see CONNECTION_ERROR_CODES)
|
|
367
404
|
*
|
|
368
405
|
* The original pg error is preserved as `.cause` on the wrapped error.
|