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.
Files changed (84) hide show
  1. package/README.md +71 -27
  2. package/dist/cjs/cli/config.d.ts +40 -0
  3. package/dist/cjs/cli/config.js +74 -2
  4. package/dist/cjs/cli/index.d.ts +85 -1
  5. package/dist/cjs/cli/index.js +374 -24
  6. package/dist/cjs/cli/mcp.d.ts +8 -0
  7. package/dist/cjs/cli/mcp.js +448 -29
  8. package/dist/cjs/cli/pii-tags.d.ts +64 -9
  9. package/dist/cjs/cli/pii-tags.js +218 -39
  10. package/dist/cjs/cli/studio-ui.generated.js +1 -1
  11. package/dist/cjs/cli/studio.d.ts +23 -0
  12. package/dist/cjs/cli/studio.js +126 -53
  13. package/dist/cjs/cli/ui.d.ts +15 -1
  14. package/dist/cjs/cli/ui.js +19 -5
  15. package/dist/cjs/client.js +248 -11
  16. package/dist/cjs/errors.d.ts +38 -1
  17. package/dist/cjs/errors.js +235 -24
  18. package/dist/cjs/index.d.ts +2 -2
  19. package/dist/cjs/index.js +7 -2
  20. package/dist/cjs/pipeline-submittable.js +26 -3
  21. package/dist/cjs/pipeline.js +15 -2
  22. package/dist/cjs/powql.d.ts +12 -0
  23. package/dist/cjs/powql.js +46 -21
  24. package/dist/cjs/prisma-compat.d.ts +15 -5
  25. package/dist/cjs/prisma-compat.js +273 -78
  26. package/dist/cjs/query/aggregates.d.ts +1 -1
  27. package/dist/cjs/query/aggregates.js +24 -10
  28. package/dist/cjs/query/batched-loader.d.ts +9 -4
  29. package/dist/cjs/query/batched-loader.js +4 -1
  30. package/dist/cjs/query/builder.d.ts +47 -0
  31. package/dist/cjs/query/builder.js +149 -21
  32. package/dist/cjs/query/index.d.ts +3 -1
  33. package/dist/cjs/query/index.js +7 -1
  34. package/dist/cjs/query/option-surface.d.ts +11 -0
  35. package/dist/cjs/query/option-surface.js +13 -0
  36. package/dist/cjs/query/relations.d.ts +8 -0
  37. package/dist/cjs/query/relations.js +21 -1
  38. package/dist/cjs/query/types.d.ts +152 -18
  39. package/dist/cjs/query/types.js +212 -1
  40. package/dist/cjs/query/where.d.ts +3 -3
  41. package/dist/cjs/query/where.js +8 -2
  42. package/dist/cjs/query/writes.js +10 -9
  43. package/dist/cli/config.d.ts +40 -0
  44. package/dist/cli/config.js +73 -2
  45. package/dist/cli/index.d.ts +85 -1
  46. package/dist/cli/index.js +373 -27
  47. package/dist/cli/mcp.d.ts +8 -0
  48. package/dist/cli/mcp.js +448 -29
  49. package/dist/cli/pii-tags.d.ts +64 -9
  50. package/dist/cli/pii-tags.js +217 -39
  51. package/dist/cli/studio-ui.generated.js +1 -1
  52. package/dist/cli/studio.d.ts +23 -0
  53. package/dist/cli/studio.js +125 -53
  54. package/dist/cli/ui.d.ts +15 -1
  55. package/dist/cli/ui.js +18 -4
  56. package/dist/client.js +250 -13
  57. package/dist/errors.d.ts +38 -1
  58. package/dist/errors.js +234 -23
  59. package/dist/index.d.ts +2 -2
  60. package/dist/index.js +5 -2
  61. package/dist/pipeline-submittable.js +26 -3
  62. package/dist/pipeline.js +15 -2
  63. package/dist/powql.d.ts +12 -0
  64. package/dist/powql.js +46 -21
  65. package/dist/prisma-compat.d.ts +15 -5
  66. package/dist/prisma-compat.js +274 -79
  67. package/dist/query/aggregates.d.ts +1 -1
  68. package/dist/query/aggregates.js +24 -10
  69. package/dist/query/batched-loader.d.ts +9 -4
  70. package/dist/query/batched-loader.js +4 -1
  71. package/dist/query/builder.d.ts +47 -0
  72. package/dist/query/builder.js +148 -21
  73. package/dist/query/index.d.ts +3 -1
  74. package/dist/query/index.js +2 -0
  75. package/dist/query/option-surface.d.ts +11 -0
  76. package/dist/query/option-surface.js +13 -0
  77. package/dist/query/relations.d.ts +8 -0
  78. package/dist/query/relations.js +21 -1
  79. package/dist/query/types.d.ts +152 -18
  80. package/dist/query/types.js +207 -2
  81. package/dist/query/where.d.ts +3 -3
  82. package/dist/query/where.js +8 -2
  83. package/dist/query/writes.js +10 -9
  84. package/package.json +13 -3
@@ -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 {};
@@ -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
- // Postgres auto-parses json/jsonb relation columns into JS values via its
940
- // type parsers; the SQLite demo driver returns them as raw JSON strings. So
941
- // in demo mode, parse relation columns back into arrays/objects (walking the
942
- // `with` tree) to match the Postgres shape before redaction + serialization.
943
- const rawRows = ctx.demo
944
- ? parseDemoRelationRows(result.rows, tableName, args.with, ctx.metadata)
945
- : result.rows;
946
- const redactedRows = ctx.showPii ? rawRows : redactBuilderRows(rawRows, tableName, args.with, ctx.metadata);
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
- columns: resultColumns(result, result.rows),
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
- * Demo-only: parse relation columns that arrive as raw JSON strings from the
1390
- * SQLite driver back into arrays/objects, walking the `with` tree so nested
1391
- * relations are parsed at every level. This mirrors what Postgres' json/jsonb
1392
- * type parsers do automatically, so the builder response shape (and downstream
1393
- * redaction) is identical across engines. Rows without the named relation, or
1394
- * whose value is already a parsed object/array, pass through unchanged.
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 parseDemoRelationRows(rows, tableName, withClause, metadata) {
1397
- const table = metadata.tables[tableName];
1398
- if (!table)
1399
- return rows;
1400
- const relEntries = withClause && typeof withClause === 'object'
1401
- ? Object.entries(withClause).filter(([, v]) => v)
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
@@ -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;
@@ -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
- return (redactUserinfo(url)
233
- // Query-string password params: `password=`, `sslpassword=`, and similar,
234
- // case-insensitive. Value runs up to the next `&`, `#`, or end of string.
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