turbine-orm 0.61.0 → 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 +65 -21
- 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 +323 -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 +186 -3
- 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.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 +125 -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 +321 -26
- 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 +187 -4
- 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.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 +124 -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/cjs/cli/studio.d.ts
CHANGED
|
@@ -141,6 +141,19 @@ export interface StudioContext {
|
|
|
141
141
|
* (saved queries persist to `<stateDir>/studio-queries.json`).
|
|
142
142
|
*/
|
|
143
143
|
memorySavedQueries?: SavedQueriesFile;
|
|
144
|
+
/**
|
|
145
|
+
* Set when a generated metadata file was found but its PII tags could NOT be
|
|
146
|
+
* read (`PiiScan.ok === false`). Studio then fails CLOSED: every column of
|
|
147
|
+
* every table is marked `pii`, so the existing enforcement points redact
|
|
148
|
+
* everything, and this record drives the persistent UI banner explaining why.
|
|
149
|
+
* Absent when the scan succeeded, when there was no metadata file, or when
|
|
150
|
+
* `--show-pii` is on.
|
|
151
|
+
*/
|
|
152
|
+
piiScanFailed?: {
|
|
153
|
+
path: string;
|
|
154
|
+
reason: string;
|
|
155
|
+
columns: number;
|
|
156
|
+
};
|
|
144
157
|
}
|
|
145
158
|
/**
|
|
146
159
|
* Start the Studio server. Returns a handle with the session token, a pre-built
|
|
@@ -204,4 +217,14 @@ export declare function apiCreateSavedQuery(req: IncomingMessage, res: ServerRes
|
|
|
204
217
|
export declare function apiDeleteSavedQuery(res: ServerResponse, ctx: StudioContext, id: string): void;
|
|
205
218
|
/** The literal replacement value for a redacted PII cell. */
|
|
206
219
|
export declare const PII_REDACTED = "\u2022\u2022 redacted \u2022\u2022";
|
|
220
|
+
/**
|
|
221
|
+
* Mark every column of every table `pii`, and return how many were touched.
|
|
222
|
+
*
|
|
223
|
+
* The fail-closed posture for "the generated metadata exists but its tags are
|
|
224
|
+
* unreadable". Deliberately expressed as data (tags on the metadata) rather
|
|
225
|
+
* than as a new flag threaded through the redaction code: every enforcement
|
|
226
|
+
* point in this file already asks `col.pii === true`, so there is no path that
|
|
227
|
+
* can forget to consult a separate switch.
|
|
228
|
+
*/
|
|
229
|
+
export declare function forceTagAllColumnsPii(metadata: SchemaMetadata): number;
|
|
207
230
|
export {};
|
package/dist/cjs/cli/studio.js
CHANGED
|
@@ -55,6 +55,7 @@ exports.apiRowWrite = apiRowWrite;
|
|
|
55
55
|
exports.apiListSavedQueries = apiListSavedQueries;
|
|
56
56
|
exports.apiCreateSavedQuery = apiCreateSavedQuery;
|
|
57
57
|
exports.apiDeleteSavedQuery = apiDeleteSavedQuery;
|
|
58
|
+
exports.forceTagAllColumnsPii = forceTagAllColumnsPii;
|
|
58
59
|
const node_child_process_1 = require("node:child_process");
|
|
59
60
|
const node_crypto_1 = require("node:crypto");
|
|
60
61
|
const node_fs_1 = require("node:fs");
|
|
@@ -91,6 +92,7 @@ async function startStudio(options) {
|
|
|
91
92
|
let dialect;
|
|
92
93
|
let statementTimeout;
|
|
93
94
|
let piiTags = null;
|
|
95
|
+
let piiScanFailed;
|
|
94
96
|
if (demo) {
|
|
95
97
|
// Seeded in-memory SQLite store: no DATABASE_URL, no network. Each launch
|
|
96
98
|
// starts pristine and nothing is ever persisted.
|
|
@@ -139,8 +141,35 @@ async function startStudio(options) {
|
|
|
139
141
|
// what happened so the CLI can be explicit about it at startup.
|
|
140
142
|
if (options.metadataDir) {
|
|
141
143
|
const source = (0, pii_tags_js_1.loadPiiTags)(options.metadataDir);
|
|
142
|
-
if (source)
|
|
144
|
+
if (source && !source.scan.ok) {
|
|
145
|
+
// FAIL CLOSED. The file exists and is meant to carry the tags, but the
|
|
146
|
+
// scanner could not prove anything about it (`ok: false` is "we do not
|
|
147
|
+
// know", never "no tagged columns"). Redacting nothing here and then
|
|
148
|
+
// reporting a clean tag count is the exact silent-failure shape this
|
|
149
|
+
// module was rewritten to end, and Studio is also the tool with a
|
|
150
|
+
// `--write` mode. So every column of every table is marked PII, which
|
|
151
|
+
// routes through the enforcement points that already exist (row
|
|
152
|
+
// redaction, builder rows, the write echo, and the orderBy / search /
|
|
153
|
+
// filter refusals) with no second code path. `--show-pii` is the
|
|
154
|
+
// override, and it overrides forced tags exactly as it overrides real
|
|
155
|
+
// ones.
|
|
156
|
+
piiScanFailed = {
|
|
157
|
+
path: source.path,
|
|
158
|
+
reason: source.scan.reason ?? 'unrecognized shape',
|
|
159
|
+
columns: forceTagAllColumnsPii(metadata),
|
|
160
|
+
};
|
|
161
|
+
// `applied` is the count of columns being redacted, which is what it
|
|
162
|
+
// means to the reader of the startup line. The warning below is what
|
|
163
|
+
// says these are forced, not declared.
|
|
164
|
+
piiTags = { path: source.path, applied: piiScanFailed.columns };
|
|
165
|
+
console.warn(`[turbine studio] WARNING: ${source.path} exists but its PII tags could not be read ` +
|
|
166
|
+
`(${piiScanFailed.reason}). Failing closed: EVERY column of every table is redacted, sorting, ` +
|
|
167
|
+
'searching, and filtering on any column are refused, and the Query tab is disabled. Re-run ' +
|
|
168
|
+
'`turbine generate` to fix this, or pass --show-pii to run unredacted anyway.');
|
|
169
|
+
}
|
|
170
|
+
else if (source) {
|
|
143
171
|
piiTags = { path: source.path, applied: (0, pii_tags_js_1.applyPiiTags)(metadata, source.tags) };
|
|
172
|
+
}
|
|
144
173
|
}
|
|
145
174
|
statementTimeout = options.adapter?.statementTimeout?.(30) ?? {
|
|
146
175
|
// Postgres rejects parameters in `SET LOCAL` (`SET LOCAL ... = $1` is a
|
|
@@ -166,6 +195,7 @@ async function startStudio(options) {
|
|
|
166
195
|
// live. Non-demo honors the CLI flags.
|
|
167
196
|
writable: demo ? false : options.write === true,
|
|
168
197
|
showPii: demo ? false : options.showPii === true,
|
|
198
|
+
piiScanFailed,
|
|
169
199
|
demo,
|
|
170
200
|
dialect,
|
|
171
201
|
// Demo never touches disk: saved queries live (and die) with the process,
|
|
@@ -438,6 +468,12 @@ async function apiSchema(res, ctx) {
|
|
|
438
468
|
showPii: ctx.showPii === true,
|
|
439
469
|
// Demo flag drives the in-UI mode switcher + persistent demo banner.
|
|
440
470
|
demo: ctx.demo === true,
|
|
471
|
+
// Present only when the PII tag scan failed and Studio is redacting
|
|
472
|
+
// everything as a result. Drives a persistent banner: a screen of redaction
|
|
473
|
+
// markers with no explanation reads like a bug, not like a safety posture.
|
|
474
|
+
piiScanFailed: ctx.piiScanFailed
|
|
475
|
+
? { path: ctx.piiScanFailed.path, reason: ctx.piiScanFailed.reason, columns: ctx.piiScanFailed.columns }
|
|
476
|
+
: null,
|
|
441
477
|
});
|
|
442
478
|
}
|
|
443
479
|
/** True when `columnName` on `table` is PII-tagged and currently redacted. */
|
|
@@ -903,6 +939,35 @@ async function apiBuilder(req, res, ctx) {
|
|
|
903
939
|
sendJson(res, 400, { error: unknownTableMessage(tableName, ctx) });
|
|
904
940
|
return;
|
|
905
941
|
}
|
|
942
|
+
// Fail-closed posture: the generated metadata exists but its PII tags could
|
|
943
|
+
// not be read, so every column is treated as PII. The Query tab is then
|
|
944
|
+
// REFUSED outright rather than half-served. A default projection that
|
|
945
|
+
// excludes every column emits `SELECT FROM "t"`, which the database answers
|
|
946
|
+
// with a syntax error, and letting an explicit `select` through would be a
|
|
947
|
+
// way to name any column in a database whose sensitive columns are, by
|
|
948
|
+
// assumption, unknown. The Data tab still works and shows every cell
|
|
949
|
+
// redacted, so the shape of the database is still visible.
|
|
950
|
+
if (ctx.piiScanFailed && !ctx.showPii) {
|
|
951
|
+
sendJson(res, 400, {
|
|
952
|
+
error: `The Query tab is disabled: PII tags could not be read from ${ctx.piiScanFailed.path} ` +
|
|
953
|
+
`(${ctx.piiScanFailed.reason}), so Studio treats every column as PII. Re-run \`turbine generate\`, or ` +
|
|
954
|
+
'restart Studio with --show-pii to query unredacted.',
|
|
955
|
+
});
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
// The same empty-projection trap without the fail-closed posture: a schema
|
|
959
|
+
// that genuinely tags every column of a table. Explicit `select` is allowed
|
|
960
|
+
// here (the user named the columns, and the values are redacted on the way
|
|
961
|
+
// out); only the empty default projection is refused, with a reason instead
|
|
962
|
+
// of a syntax error.
|
|
963
|
+
const target = ctx.metadata.tables[tableName];
|
|
964
|
+
if (target && !ctx.showPii && args.select === undefined && target.columns.every((c) => c.pii === true)) {
|
|
965
|
+
sendJson(res, 400, {
|
|
966
|
+
error: `Every column of "${tableName}" is PII-tagged, so the default projection is empty. Name the columns you ` +
|
|
967
|
+
'want in `select`, or run Studio with --show-pii.',
|
|
968
|
+
});
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
906
971
|
let deferred;
|
|
907
972
|
try {
|
|
908
973
|
const qi = new index_js_1.QueryInterface(ctx.pool, tableName, ctx.metadata, [], {
|
|
@@ -936,17 +1001,30 @@ async function apiBuilder(req, res, ctx) {
|
|
|
936
1001
|
const elapsedMs = Date.now() - started;
|
|
937
1002
|
if (!ctx.demo)
|
|
938
1003
|
await client.query('COMMIT');
|
|
939
|
-
//
|
|
940
|
-
//
|
|
941
|
-
//
|
|
942
|
-
//
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
1004
|
+
// Run the driver rows through the QUERY'S OWN transform rather than reading
|
|
1005
|
+
// `result.rows`. The transform is what turns a raw result into the entity
|
|
1006
|
+
// shape the ORM contracts for: column names mapped to camelCase field names,
|
|
1007
|
+
// dates coerced, and relation columns re-nested under the relation name at
|
|
1008
|
+
// every depth (whatever encoding the plan chose, and whatever the driver
|
|
1009
|
+
// handed back, pg's parsed json objects or the SQLite demo driver's raw JSON
|
|
1010
|
+
// strings, which is why this also replaces the demo-only re-parse).
|
|
1011
|
+
//
|
|
1012
|
+
// SECURITY, not cosmetics: `relationLoadStrategy: 'flatten'` projects a
|
|
1013
|
+
// to-one relation's columns FLAT onto the parent row under generated
|
|
1014
|
+
// prefixed aliases (`f0__email`). `redactBuilderRows` redacts a relation's
|
|
1015
|
+
// PII by walking the `with` tree into the NESTED object, so on flat rows it
|
|
1016
|
+
// walked into nothing and every prefixed PII cell was served in the clear.
|
|
1017
|
+
// `assertNoPiiPredicates` deliberately permits `select` on a PII column
|
|
1018
|
+
// because "those values are redacted on the way out", and only the
|
|
1019
|
+
// transform makes that true. The flat shape must never reach redaction.
|
|
1020
|
+
const rows = deferred.transform(result);
|
|
1021
|
+
const redactedRows = ctx.showPii ? rows : redactBuilderRows(rows, tableName, args.with, ctx.metadata);
|
|
947
1022
|
sendJson(res, 200, {
|
|
948
1023
|
sql: deferred.sql,
|
|
949
|
-
|
|
1024
|
+
// Columns come from the TRANSFORMED rows: `result.fields` names raw SQL
|
|
1025
|
+
// output columns (`created_at`, `f0__email`), which are neither what the
|
|
1026
|
+
// rows are keyed by nor what a user would write in a query.
|
|
1027
|
+
columns: entityResultColumns(redactedRows),
|
|
950
1028
|
rows: redactedRows.map((r) => serializeRow(r)),
|
|
951
1029
|
rowCount: result.rowCount ?? result.rows.length,
|
|
952
1030
|
elapsedMs,
|
|
@@ -1310,6 +1388,27 @@ function apiDeleteSavedQuery(res, ctx, id) {
|
|
|
1310
1388
|
// ---------------------------------------------------------------------------
|
|
1311
1389
|
/** The literal replacement value for a redacted PII cell. */
|
|
1312
1390
|
exports.PII_REDACTED = '•• redacted ••';
|
|
1391
|
+
/**
|
|
1392
|
+
* Mark every column of every table `pii`, and return how many were touched.
|
|
1393
|
+
*
|
|
1394
|
+
* The fail-closed posture for "the generated metadata exists but its tags are
|
|
1395
|
+
* unreadable". Deliberately expressed as data (tags on the metadata) rather
|
|
1396
|
+
* than as a new flag threaded through the redaction code: every enforcement
|
|
1397
|
+
* point in this file already asks `col.pii === true`, so there is no path that
|
|
1398
|
+
* can forget to consult a separate switch.
|
|
1399
|
+
*/
|
|
1400
|
+
function forceTagAllColumnsPii(metadata) {
|
|
1401
|
+
let count = 0;
|
|
1402
|
+
for (const table of Object.values(metadata.tables)) {
|
|
1403
|
+
for (const col of table.columns) {
|
|
1404
|
+
if (col.pii !== true) {
|
|
1405
|
+
col.pii = true;
|
|
1406
|
+
count++;
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
return count;
|
|
1411
|
+
}
|
|
1313
1412
|
/** Shared empty key set for the `--show-pii` fast path (no redaction). */
|
|
1314
1413
|
const NO_PII_KEYS = new Set();
|
|
1315
1414
|
/**
|
|
@@ -1386,50 +1485,24 @@ function redactBuilderRows(rows, tableName, withClause, metadata) {
|
|
|
1386
1485
|
});
|
|
1387
1486
|
}
|
|
1388
1487
|
/**
|
|
1389
|
-
*
|
|
1390
|
-
*
|
|
1391
|
-
*
|
|
1392
|
-
*
|
|
1393
|
-
*
|
|
1394
|
-
*
|
|
1488
|
+
* Column descriptors for rows that have already been through a query's
|
|
1489
|
+
* `transform`, i.e. entity rows keyed by camelCase FIELD name with relations
|
|
1490
|
+
* nested under the relation name.
|
|
1491
|
+
*
|
|
1492
|
+
* Derived from the rows themselves rather than from `result.fields`, which
|
|
1493
|
+
* names raw SQL output columns (`created_at`, or a flatten plan's `f0__email`)
|
|
1494
|
+
* and so would label the grid with keys the rows do not have. Keys are unioned
|
|
1495
|
+
* across every row in first-seen order: a `select` is uniform, but a null
|
|
1496
|
+
* to-one relation and a JSON-shaped row can still differ, and a column missing
|
|
1497
|
+
* from row 0 must not disappear from the header.
|
|
1395
1498
|
*/
|
|
1396
|
-
function
|
|
1397
|
-
const
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
if (relEntries.length === 0)
|
|
1404
|
-
return rows;
|
|
1405
|
-
return rows.map((row) => {
|
|
1406
|
-
const out = { ...row };
|
|
1407
|
-
for (const [relName, relVal] of relEntries) {
|
|
1408
|
-
const rel = table.relations[relName];
|
|
1409
|
-
if (!rel)
|
|
1410
|
-
continue;
|
|
1411
|
-
let child = out[relName];
|
|
1412
|
-
if (typeof child === 'string') {
|
|
1413
|
-
try {
|
|
1414
|
-
child = JSON.parse(child);
|
|
1415
|
-
}
|
|
1416
|
-
catch {
|
|
1417
|
-
continue;
|
|
1418
|
-
}
|
|
1419
|
-
}
|
|
1420
|
-
const nestedWith = relVal && typeof relVal === 'object' ? relVal.with : undefined;
|
|
1421
|
-
if (Array.isArray(child)) {
|
|
1422
|
-
out[relName] = parseDemoRelationRows(child, rel.to, nestedWith, metadata);
|
|
1423
|
-
}
|
|
1424
|
-
else if (child && typeof child === 'object') {
|
|
1425
|
-
out[relName] = parseDemoRelationRows([child], rel.to, nestedWith, metadata)[0];
|
|
1426
|
-
}
|
|
1427
|
-
else {
|
|
1428
|
-
out[relName] = child;
|
|
1429
|
-
}
|
|
1430
|
-
}
|
|
1431
|
-
return out;
|
|
1432
|
-
});
|
|
1499
|
+
function entityResultColumns(rows) {
|
|
1500
|
+
const seen = new Set();
|
|
1501
|
+
for (const row of rows) {
|
|
1502
|
+
for (const key of Object.keys(row))
|
|
1503
|
+
seen.add(key);
|
|
1504
|
+
}
|
|
1505
|
+
return [...seen].map((name) => ({ name, dataTypeID: 0 }));
|
|
1433
1506
|
}
|
|
1434
1507
|
/**
|
|
1435
1508
|
* A fresh CSP nonce for one HTML response. Base64 of 16 random bytes; the value
|
package/dist/cjs/cli/ui.d.ts
CHANGED
|
@@ -33,7 +33,7 @@ export declare const symbols: {
|
|
|
33
33
|
readonly info: "i" | "ℹ";
|
|
34
34
|
readonly warning: "⚠" | "!";
|
|
35
35
|
readonly dot: "." | "∙";
|
|
36
|
-
readonly line: "
|
|
36
|
+
readonly line: "─" | "-";
|
|
37
37
|
readonly vertLine: "|" | "│";
|
|
38
38
|
readonly topLeft: "+" | "╭";
|
|
39
39
|
readonly topRight: "+" | "╮";
|
|
@@ -70,4 +70,18 @@ export declare function divider(): void;
|
|
|
70
70
|
export declare function banner(): void;
|
|
71
71
|
export declare function elapsed(startMs: number): string;
|
|
72
72
|
export declare function stripAnsi(s: string): string;
|
|
73
|
+
/**
|
|
74
|
+
* A query-string parameter whose name ENDS in `password`: libpq's `password`
|
|
75
|
+
* and `sslpassword`, plus any vendor spelling of the same idea. Group 1 is
|
|
76
|
+
* `?`/`&` + the key, group 2 is the value, which runs to the next `&`, `#`, or
|
|
77
|
+
* the end of the string, and may be empty.
|
|
78
|
+
*
|
|
79
|
+
* Exported because "does this string carry a secret" and "redact the secret in
|
|
80
|
+
* this string" must agree on ONE definition. They did not: this redactor knew
|
|
81
|
+
* about `?password=` while `connectionStringHasPassword` in config.ts did not,
|
|
82
|
+
* so `turbine init --url` committed a query-form password into
|
|
83
|
+
* `turbine.config.ts` while printing the redacted spelling of the same string
|
|
84
|
+
* one line above. config.ts now matches with this exact pattern.
|
|
85
|
+
*/
|
|
86
|
+
export declare const PASSWORD_QUERY_PARAM_PATTERN = "([?&][^=&#]*password)=([^&#]*)";
|
|
73
87
|
export declare function redactUrl(url: string): string;
|
package/dist/cjs/cli/ui.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Zero dependencies, raw escape codes only.
|
|
7
7
|
*/
|
|
8
8
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
-
exports.Spinner = exports.symbols = exports.bgCyan = exports.bgYellow = exports.bgRed = exports.bgGreen = exports.redBright = exports.yellowBright = exports.cyanBright = exports.greenBright = exports.gray = exports.white = exports.cyan = exports.magenta = exports.blue = exports.yellow = exports.green = exports.red = exports.underline = exports.italic = exports.dim = exports.bold = void 0;
|
|
9
|
+
exports.PASSWORD_QUERY_PARAM_PATTERN = exports.Spinner = exports.symbols = exports.bgCyan = exports.bgYellow = exports.bgRed = exports.bgGreen = exports.redBright = exports.yellowBright = exports.cyanBright = exports.greenBright = exports.gray = exports.white = exports.cyan = exports.magenta = exports.blue = exports.yellow = exports.green = exports.red = exports.underline = exports.italic = exports.dim = exports.bold = void 0;
|
|
10
10
|
exports.box = box;
|
|
11
11
|
exports.table = table;
|
|
12
12
|
exports.header = header;
|
|
@@ -228,11 +228,25 @@ function stripAnsi(s) {
|
|
|
228
228
|
// ---------------------------------------------------------------------------
|
|
229
229
|
// Redact password from connection URL
|
|
230
230
|
// ---------------------------------------------------------------------------
|
|
231
|
+
/**
|
|
232
|
+
* A query-string parameter whose name ENDS in `password`: libpq's `password`
|
|
233
|
+
* and `sslpassword`, plus any vendor spelling of the same idea. Group 1 is
|
|
234
|
+
* `?`/`&` + the key, group 2 is the value, which runs to the next `&`, `#`, or
|
|
235
|
+
* the end of the string, and may be empty.
|
|
236
|
+
*
|
|
237
|
+
* Exported because "does this string carry a secret" and "redact the secret in
|
|
238
|
+
* this string" must agree on ONE definition. They did not: this redactor knew
|
|
239
|
+
* about `?password=` while `connectionStringHasPassword` in config.ts did not,
|
|
240
|
+
* so `turbine init --url` committed a query-form password into
|
|
241
|
+
* `turbine.config.ts` while printing the redacted spelling of the same string
|
|
242
|
+
* one line above. config.ts now matches with this exact pattern.
|
|
243
|
+
*/
|
|
244
|
+
exports.PASSWORD_QUERY_PARAM_PATTERN = '([?&][^=&#]*password)=([^&#]*)';
|
|
245
|
+
const PASSWORD_QUERY_PARAM_RE = new RegExp(exports.PASSWORD_QUERY_PARAM_PATTERN, 'gi');
|
|
231
246
|
function redactUrl(url) {
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
.replace(/([?&][^=&#]*password)=([^&#]*)/gi, '$1=***'));
|
|
247
|
+
// `String.replace` with a /g regex resets `lastIndex`, so sharing the compiled
|
|
248
|
+
// instance across calls is safe.
|
|
249
|
+
return redactUserinfo(url).replace(PASSWORD_QUERY_PARAM_RE, '$1=***');
|
|
236
250
|
}
|
|
237
251
|
/**
|
|
238
252
|
* Replace `<scheme>://<user>:<password>@` with `<scheme>://<user>:***@`, for
|
package/dist/cjs/client.js
CHANGED
|
@@ -193,6 +193,133 @@ function warnUnknownConfigKeys(config) {
|
|
|
193
193
|
// must never be the reason a client fails to construct.
|
|
194
194
|
}
|
|
195
195
|
}
|
|
196
|
+
/**
|
|
197
|
+
* Two bases that differ ONLY in host, used to detect whether the host pg ends
|
|
198
|
+
* up with came from the connection string or from pg's own placeholder base.
|
|
199
|
+
*
|
|
200
|
+
* pg-connection-string resolves a connection string as `new URL(str,
|
|
201
|
+
* 'postgres://base')`. A string with no scheme is therefore a RELATIVE url, and
|
|
202
|
+
* silently inherits the literal host `base`, which is where
|
|
203
|
+
* `getaddrinfo ENOTFOUND base` comes from. Parsing against two different bases
|
|
204
|
+
* and comparing the resulting hostnames identifies that case exactly, and
|
|
205
|
+
* cannot be fooled by a string that genuinely names a host called `base`.
|
|
206
|
+
*/
|
|
207
|
+
const CONNECTION_STRING_PROBE_BASES = ['postgres://turbine-probe-a', 'postgres://turbine-probe-b'];
|
|
208
|
+
/**
|
|
209
|
+
* Classify a connection string by replaying the parse pg itself performs.
|
|
210
|
+
*
|
|
211
|
+
* This is a deliberate mirror of `pg-connection-string`'s `parse()`, in the
|
|
212
|
+
* same order, because that function IS the oracle: anything it resolves to a
|
|
213
|
+
* real host/socket is a string pg will connect with, and a validator that
|
|
214
|
+
* refuses one of those breaks a working deployment. In particular all of these
|
|
215
|
+
* are usable and must pass:
|
|
216
|
+
* - `/var/run/postgresql app` and `/cloudsql/proj:region:instance`, the
|
|
217
|
+
* leading-slash unix-socket form (peer auth, Cloud SQL),
|
|
218
|
+
* - `socket:/var/run/postgresql?db=app`, the explicit socket scheme,
|
|
219
|
+
* - `postgresql:///db`, `postgres:/db`, `postgres://`, all empty-host forms
|
|
220
|
+
* that fall back to pg's localhost default,
|
|
221
|
+
* - `postgres://user:pw@/db` (the `@/` dummy-host retry) and
|
|
222
|
+
* `?host=/var/run/postgresql` (host as a query parameter),
|
|
223
|
+
* - anything else that parses as an absolute URL, `localhost:5432` included.
|
|
224
|
+
*
|
|
225
|
+
* Only two shapes are not usable: a string with no scheme (pg substitutes its
|
|
226
|
+
* placeholder host) and a string neither parse attempt can turn into a URL (pg
|
|
227
|
+
* throws `Invalid URL` from its own constructor).
|
|
228
|
+
*/
|
|
229
|
+
function classifyConnectionString(value) {
|
|
230
|
+
// Leading slash: pg short-circuits to `{ host: <path>, database: <word 2> }`
|
|
231
|
+
// before any URL parsing. Unix-domain socket directory or Cloud SQL path.
|
|
232
|
+
if (value.charAt(0) === '/')
|
|
233
|
+
return { kind: 'usable' };
|
|
234
|
+
// pg percent-encodes before parsing, so a string with spaces (or a stray
|
|
235
|
+
// half-written escape) reaches the URL constructor already encoded.
|
|
236
|
+
const encoded = / |%[^a-f0-9]|%[a-f0-9][^a-f0-9]/i.test(value)
|
|
237
|
+
? encodeURI(value).replace(/%25(\d\d)/g, '%$1')
|
|
238
|
+
: value;
|
|
239
|
+
const parseAgainst = (base) => {
|
|
240
|
+
try {
|
|
241
|
+
return { url: new URL(encoded, base), dummyHost: false };
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
// pg's own second attempt: `postgres://user:pw@/db` is not a valid URL,
|
|
245
|
+
// so it retries with a placeholder authority and treats the host as empty.
|
|
246
|
+
try {
|
|
247
|
+
return { url: new URL(encoded.replace('@/', '@___DUMMY___/'), base), dummyHost: true };
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
const a = parseAgainst(CONNECTION_STRING_PROBE_BASES[0]);
|
|
255
|
+
const b = parseAgainst(CONNECTION_STRING_PROBE_BASES[1]);
|
|
256
|
+
if (!a || !b)
|
|
257
|
+
return { kind: 'unparseable' };
|
|
258
|
+
// `socket:` is handled by its own branch in pg's parse: the pathname is the
|
|
259
|
+
// socket directory and the base never contributes.
|
|
260
|
+
if (a.url.protocol === 'socket:')
|
|
261
|
+
return { kind: 'usable' };
|
|
262
|
+
// The dummy-host retry only happens for an absolute URL, so the host is
|
|
263
|
+
// empty by construction and pg falls back to its localhost default.
|
|
264
|
+
if (a.dummyHost)
|
|
265
|
+
return { kind: 'usable' };
|
|
266
|
+
// A `host=` query parameter wins over the URL authority in pg's parse.
|
|
267
|
+
if (a.url.searchParams.get('host'))
|
|
268
|
+
return { kind: 'usable' };
|
|
269
|
+
return a.url.hostname === b.url.hostname ? { kind: 'usable' } : { kind: 'phantom-host' };
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Reject a connection string pg cannot connect with, at construction, naming
|
|
273
|
+
* the string as the problem.
|
|
274
|
+
*
|
|
275
|
+
* Without this, a typo'd or truncated string is handed to pg, which resolves it
|
|
276
|
+
* as a URL relative to its own `postgres://base`, so the string silently
|
|
277
|
+
* becomes a request for a host named `base`. The failure surfaces one query
|
|
278
|
+
* later as `[TURBINE_E004] Database connection error: getaddrinfo ENOTFOUND
|
|
279
|
+
* base`, and sends the reader looking for a DNS problem with a host they never
|
|
280
|
+
* configured.
|
|
281
|
+
*
|
|
282
|
+
* The oracle is pg itself (see {@link classifyConnectionString}), not a scheme
|
|
283
|
+
* pattern. An earlier scheme-matching version of this check refused
|
|
284
|
+
* `/var/run/postgresql app` and `/cloudsql/project:region:instance`, which are
|
|
285
|
+
* documented, working unix-socket connection strings, i.e. it broke peer-auth
|
|
286
|
+
* and Cloud SQL deployments at `new TurbineClient(...)`.
|
|
287
|
+
*
|
|
288
|
+
* The empty string never reaches here: the call sites skip a falsy value, which
|
|
289
|
+
* is exactly what the pool-building branch does with it, so
|
|
290
|
+
* `connectionString: process.env.DATABASE_URL ?? ''` keeps constructing.
|
|
291
|
+
*/
|
|
292
|
+
function assertUsableConnectionString(value, source) {
|
|
293
|
+
// Whitespace-only is not empty: it is truthy, so pg WOULD take it, encode it
|
|
294
|
+
// to `%20%20%20`, and resolve it against its placeholder host. Naming it as
|
|
295
|
+
// empty is more useful than reporting the phantom host.
|
|
296
|
+
if (value.trim().length === 0) {
|
|
297
|
+
throw new errors_js_1.ConnectionError(`[turbine] The ${source} is empty. Expected a Postgres connection string such as ` +
|
|
298
|
+
'"postgresql://user:password@host:5432/database".');
|
|
299
|
+
}
|
|
300
|
+
const verdict = classifyConnectionString(value);
|
|
301
|
+
if (verdict.kind === 'usable')
|
|
302
|
+
return;
|
|
303
|
+
// Never echo the value: it may carry a password, and it is malformed, so no
|
|
304
|
+
// redaction written against the URL grammar can be trusted on it.
|
|
305
|
+
const tail = '(Check for a missing "//", a stray quote copied out of a .env file, or a shell-truncated value.) ' +
|
|
306
|
+
'The value is not included here because it may contain a password.';
|
|
307
|
+
if (verdict.kind === 'unparseable') {
|
|
308
|
+
throw new errors_js_1.ConnectionError(`[turbine] The ${source} cannot be parsed as a connection string, pg rejects it with "Invalid URL" ` +
|
|
309
|
+
'(most often a non-numeric port). Expected something like ' +
|
|
310
|
+
`"postgresql://user:password@host:5432/database". ${tail}`);
|
|
311
|
+
}
|
|
312
|
+
// libpq keyword/value strings land here, and deserve their own sentence:
|
|
313
|
+
// node-postgres does NOT implement that form, it feeds the whole string to
|
|
314
|
+
// the URL parser, so `host=localhost dbname=app` becomes a request for the
|
|
315
|
+
// host `base` with the database `host=localhost dbname=app`.
|
|
316
|
+
const keywordForm = /^[A-Za-z_][A-Za-z0-9_]*\s*=/.test(value.trimStart())
|
|
317
|
+
? 'It looks like a libpq "host=… dbname=…" keyword/value string, which node-postgres does not support. '
|
|
318
|
+
: '';
|
|
319
|
+
throw new errors_js_1.ConnectionError(`[turbine] The ${source} names no host: it has no "postgres://" or "postgresql://" scheme, so pg would ` +
|
|
320
|
+
`resolve it as a relative URL and try to connect to its internal placeholder host. ${keywordForm}` +
|
|
321
|
+
`Expected something like "postgresql://user:password@host:5432/database". ${tail}`);
|
|
322
|
+
}
|
|
196
323
|
/**
|
|
197
324
|
* Maps isolation level names to SQL. Null-prototype and read through
|
|
198
325
|
* {@link resolveIsolationLevel}, never indexed directly: an inherited key
|
|
@@ -234,6 +361,29 @@ const ISOLATION_LEVELS = Object.assign(Object.create(null), {
|
|
|
234
361
|
* loops, and a loop keyed on it would never fire for the commit-time conflicts
|
|
235
362
|
* that are the main reason to run SERIALIZABLE at all.
|
|
236
363
|
*/
|
|
364
|
+
/**
|
|
365
|
+
* Check out a pooled connection, translating a driver failure into a typed
|
|
366
|
+
* Turbine error.
|
|
367
|
+
*
|
|
368
|
+
* `pool.connect()` is where the first-run failures actually land: wrong
|
|
369
|
+
* password (SQLSTATE 28P01), no such database (3D000), nothing listening
|
|
370
|
+
* (ECONNREFUSED), an unverifiable TLS certificate. Unwrapped, every one of
|
|
371
|
+
* those left `$transaction`, `transaction()` and `connect()` as a raw pg
|
|
372
|
+
* `DatabaseError` carrying a SQLSTATE in `.code`, the same property Turbine
|
|
373
|
+
* puts `TURBINE_E0NN` in, so the single error a new user is most likely to see
|
|
374
|
+
* was the one error the typed-error contract did not cover.
|
|
375
|
+
*
|
|
376
|
+
* Query paths need no equivalent: `pool.query()` opens the connection itself
|
|
377
|
+
* and rejects with the connect error, which the query boundary already wraps.
|
|
378
|
+
*/
|
|
379
|
+
async function acquireConnection(pool) {
|
|
380
|
+
try {
|
|
381
|
+
return await pool.connect();
|
|
382
|
+
}
|
|
383
|
+
catch (err) {
|
|
384
|
+
throw (0, errors_js_1.wrapPgError)(err);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
237
387
|
async function runTxControl(client, sql) {
|
|
238
388
|
try {
|
|
239
389
|
await client.query(sql);
|
|
@@ -591,6 +741,39 @@ class TurbineClient {
|
|
|
591
741
|
// would poison the next, valid, TurbineClient with a phantom conflict.
|
|
592
742
|
const dialect = config.dialect ?? dialect_js_1.postgresDialect;
|
|
593
743
|
const planCacheMode = TurbineClient.resolvePlanCacheMode(config.planCacheMode, dialect);
|
|
744
|
+
// Refuse a connection string that cannot address a database, before it
|
|
745
|
+
// reaches pg's permissive parser and comes back one query later as a DNS
|
|
746
|
+
// error about a fragment of itself. Only the strings this client will
|
|
747
|
+
// actually USE are checked: with an external pool the connection target is
|
|
748
|
+
// the caller's, and an explicit host/port/user config makes DATABASE_URL
|
|
749
|
+
// irrelevant (the same precedence the pool-building branch below applies).
|
|
750
|
+
// The truthiness checks here mirror the pool-building branch below EXACTLY.
|
|
751
|
+
// An empty `connectionString` is ignored there (pg ignores it too), so
|
|
752
|
+
// `connectionString: process.env.DATABASE_URL ?? ''` must keep constructing,
|
|
753
|
+
// and it still suppresses the DATABASE_URL fallback because
|
|
754
|
+
// `hasExplicitConnection` tests `!= null`, not truthiness.
|
|
755
|
+
if (!config.pool) {
|
|
756
|
+
if (config.connectionString) {
|
|
757
|
+
assertUsableConnectionString(config.connectionString, 'connectionString');
|
|
758
|
+
}
|
|
759
|
+
else if (config.connectionString == null &&
|
|
760
|
+
config.host == null &&
|
|
761
|
+
config.port == null &&
|
|
762
|
+
config.database == null &&
|
|
763
|
+
config.user == null &&
|
|
764
|
+
config.password == null &&
|
|
765
|
+
process.env.DATABASE_URL) {
|
|
766
|
+
assertUsableConnectionString(process.env.DATABASE_URL, 'DATABASE_URL environment variable');
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
for (const [i, replica] of (config.replicas ?? []).entries()) {
|
|
770
|
+
// Only string replicas are ours to open; a PgCompatPool entry is already
|
|
771
|
+
// connected by its owner. An empty string is skipped for the same reason
|
|
772
|
+
// as above: pg ignores a falsy connectionString and uses its defaults.
|
|
773
|
+
if (typeof replica === 'string' && replica) {
|
|
774
|
+
assertUsableConnectionString(replica, `replicas[${i}] connection string`);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
594
777
|
/**
|
|
595
778
|
* Parse int8 (bigint, OID 20) as JavaScript number instead of string.
|
|
596
779
|
* Safe for values up to Number.MAX_SAFE_INTEGER (9,007,199,254,740,991).
|
|
@@ -1311,7 +1494,7 @@ class TurbineClient {
|
|
|
1311
1494
|
* ```
|
|
1312
1495
|
*/
|
|
1313
1496
|
async transaction(fn) {
|
|
1314
|
-
const client = await this.pool
|
|
1497
|
+
const client = await acquireConnection(this.pool);
|
|
1315
1498
|
/**
|
|
1316
1499
|
* Only true once BEGIN has actually succeeded. If BEGIN itself throws
|
|
1317
1500
|
* (e.g. a single-writer engine's transaction gate times out or rejects a
|
|
@@ -1358,7 +1541,7 @@ class TurbineClient {
|
|
|
1358
1541
|
// Resolve the isolation level BEFORE taking a pool slot: a bad argument is
|
|
1359
1542
|
// the caller's bug and should not cost a connection to discover.
|
|
1360
1543
|
const isolationSql = resolveIsolationLevel(options?.isolationLevel);
|
|
1361
|
-
const client = await this.pool
|
|
1544
|
+
const client = await acquireConnection(this.pool);
|
|
1362
1545
|
const timeout = options?.timeout;
|
|
1363
1546
|
/**
|
|
1364
1547
|
* Track whether the connection has already been released so the finally
|
|
@@ -1682,7 +1865,7 @@ class TurbineClient {
|
|
|
1682
1865
|
* Throws if the connection fails.
|
|
1683
1866
|
*/
|
|
1684
1867
|
async connect() {
|
|
1685
|
-
const client = await this.pool
|
|
1868
|
+
const client = await acquireConnection(this.pool);
|
|
1686
1869
|
try {
|
|
1687
1870
|
await client.query('SELECT 1');
|
|
1688
1871
|
if (this.logging) {
|
package/dist/cjs/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.
|