turbine-orm 0.77.1 → 0.78.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 (93) hide show
  1. package/README.md +13 -9
  2. package/dist/cjs/cli/config.d.ts +7 -1
  3. package/dist/cjs/cli/config.js +11 -2
  4. package/dist/cjs/cli/destructive.d.ts +1 -1
  5. package/dist/cjs/cli/destructive.js +307 -9
  6. package/dist/cjs/cli/index.js +252 -42
  7. package/dist/cjs/cli/mcp.d.ts +23 -0
  8. package/dist/cjs/cli/mcp.js +190 -152
  9. package/dist/cjs/cli/migrate.d.ts +243 -3
  10. package/dist/cjs/cli/migrate.js +432 -43
  11. package/dist/cjs/cli/sql-statements.js +27 -0
  12. package/dist/cjs/cli/studio.d.ts +0 -1
  13. package/dist/cjs/cli/studio.js +9 -7
  14. package/dist/cjs/client.d.ts +8 -1
  15. package/dist/cjs/client.js +7 -0
  16. package/dist/cjs/connection-url.d.ts +82 -0
  17. package/dist/cjs/connection-url.js +187 -1
  18. package/dist/cjs/errors.d.ts +112 -12
  19. package/dist/cjs/errors.js +558 -114
  20. package/dist/cjs/generate.js +47 -15
  21. package/dist/cjs/index.d.ts +1 -1
  22. package/dist/cjs/introspect.d.ts +33 -0
  23. package/dist/cjs/introspect.js +54 -1
  24. package/dist/cjs/mssql.js +21 -1
  25. package/dist/cjs/nested-write.js +85 -14
  26. package/dist/cjs/pipeline-submittable.d.ts +2 -0
  27. package/dist/cjs/pipeline-submittable.js +88 -3
  28. package/dist/cjs/pipeline.js +13 -1
  29. package/dist/cjs/powdb-introspect.d.ts +5 -1
  30. package/dist/cjs/powdb-introspect.js +5 -1
  31. package/dist/cjs/powql.d.ts +14 -0
  32. package/dist/cjs/powql.js +44 -4
  33. package/dist/cjs/prisma-compat.js +95 -8
  34. package/dist/cjs/query/aggregates.js +56 -6
  35. package/dist/cjs/query/builder.d.ts +76 -13
  36. package/dist/cjs/query/builder.js +188 -58
  37. package/dist/cjs/query/compound-unique.d.ts +76 -0
  38. package/dist/cjs/query/compound-unique.js +129 -0
  39. package/dist/cjs/query/index.d.ts +1 -1
  40. package/dist/cjs/query/types.d.ts +65 -11
  41. package/dist/cjs/query/where.d.ts +85 -19
  42. package/dist/cjs/query/where.js +262 -47
  43. package/dist/cjs/query/writes.d.ts +11 -2
  44. package/dist/cjs/query/writes.js +116 -21
  45. package/dist/cjs/seed.d.ts +16 -0
  46. package/dist/cjs/seed.js +16 -0
  47. package/dist/cli/config.d.ts +7 -1
  48. package/dist/cli/config.js +11 -2
  49. package/dist/cli/destructive.d.ts +1 -1
  50. package/dist/cli/destructive.js +307 -9
  51. package/dist/cli/index.js +254 -44
  52. package/dist/cli/mcp.d.ts +23 -0
  53. package/dist/cli/mcp.js +187 -150
  54. package/dist/cli/migrate.d.ts +243 -3
  55. package/dist/cli/migrate.js +423 -45
  56. package/dist/cli/sql-statements.js +27 -0
  57. package/dist/cli/studio.d.ts +0 -1
  58. package/dist/cli/studio.js +10 -7
  59. package/dist/client.d.ts +8 -1
  60. package/dist/client.js +7 -0
  61. package/dist/connection-url.d.ts +82 -0
  62. package/dist/connection-url.js +183 -0
  63. package/dist/errors.d.ts +112 -12
  64. package/dist/errors.js +558 -114
  65. package/dist/generate.js +47 -15
  66. package/dist/index.d.ts +1 -1
  67. package/dist/introspect.d.ts +33 -0
  68. package/dist/introspect.js +53 -1
  69. package/dist/mssql.js +21 -1
  70. package/dist/nested-write.js +85 -14
  71. package/dist/pipeline-submittable.d.ts +2 -0
  72. package/dist/pipeline-submittable.js +87 -3
  73. package/dist/pipeline.js +14 -2
  74. package/dist/powdb-introspect.d.ts +5 -1
  75. package/dist/powdb-introspect.js +5 -1
  76. package/dist/powql.d.ts +14 -0
  77. package/dist/powql.js +45 -5
  78. package/dist/prisma-compat.js +96 -9
  79. package/dist/query/aggregates.js +56 -6
  80. package/dist/query/builder.d.ts +76 -13
  81. package/dist/query/builder.js +188 -58
  82. package/dist/query/compound-unique.d.ts +76 -0
  83. package/dist/query/compound-unique.js +126 -1
  84. package/dist/query/index.d.ts +1 -1
  85. package/dist/query/types.d.ts +65 -11
  86. package/dist/query/where.d.ts +85 -19
  87. package/dist/query/where.js +260 -47
  88. package/dist/query/writes.d.ts +11 -2
  89. package/dist/query/writes.js +117 -22
  90. package/dist/seed.d.ts +16 -0
  91. package/dist/seed.js +16 -0
  92. package/package.json +3 -3
  93. package/skills/turbine-orm/SKILL.md +37 -10
@@ -238,6 +238,32 @@ function tokenizeSql(sql) {
238
238
  }
239
239
  // Single-quoted literal. `''` always escapes a quote; inside an E-string a
240
240
  // backslash escapes the next character too.
241
+ //
242
+ // The CONTENT is handed over as a block, exactly as a dollar-quoted body
243
+ // is. A routine body does not have to be dollar-quoted: `DO 'BEGIN DROP
244
+ // TABLE users; END'` and `CREATE FUNCTION f() ... AS 'DELETE FROM t'` are
245
+ // both ordinary PostgreSQL and both execute. This branch used to push
246
+ // NOTHING, so `SqlStatement.blocks` came back empty for them, and the
247
+ // destructive scanner iterates `procedural ? statement.blocks : []`: with
248
+ // no blocks, every procedural pass was skipped, INCLUDING the fail-closed
249
+ // "cannot classify" backstop. The statement was correctly recognized as
250
+ // procedural and then scanned against nothing, so the guard reported a
251
+ // clean inventory for a file that drops a table. Verified live before the
252
+ // fix on PostgreSQL 17: no prompt, no `--allow-destructive`, table gone.
253
+ //
254
+ // Handing over EVERY single-quoted literal, not only the ones in a
255
+ // procedural statement, is deliberate and costs nothing: this tokenizer
256
+ // does not know which statement it is in the middle of, the consumer
257
+ // already gates on `PROCEDURAL_STATEMENT`, and pushing more is the
258
+ // fail-CLOSED direction this file resolves ambiguity toward. `INSERT INTO
259
+ // log VALUES ('DROP TABLE x')` is unaffected because an INSERT is not
260
+ // procedural, which is the same reason the dollar-quoted branch above can
261
+ // push unconditionally.
262
+ //
263
+ // `''` is unescaped to `'` so the block lexes as the SQL it will BE when
264
+ // the server reads it: the payload of `DO 'BEGIN EXECUTE ''DROP TABLE
265
+ // t''; END'` is `EXECUTE 'DROP TABLE t'`, and a nested scan of the raw
266
+ // slice would see a doubled quote where a string opener belongs.
241
267
  if (ch === "'") {
242
268
  const escapes = isEscapeStringPrefix(sql, i);
243
269
  let j = i + 1;
@@ -255,6 +281,7 @@ function tokenizeSql(sql) {
255
281
  j++;
256
282
  }
257
283
  const end = Math.min(j + 1, n);
284
+ blocks.push(sql.slice(i + 1, Math.min(j, n)).replace(/''/g, "'"));
258
285
  stripped += "''";
259
286
  code += sql.slice(i, end);
260
287
  i = end;
@@ -196,7 +196,6 @@ export declare function relationLinksForTable(table: TableMetadata, metadata: Sc
196
196
  export declare function apiTableRows(res: ServerResponse, ctx: StudioContext, rawTableName: string, params: URLSearchParams): Promise<void>;
197
197
  export declare function resolveColumnName(table: TableMetadata, nameOrField: string): string | null;
198
198
  export declare function isTextishType(pgType: string): boolean;
199
- export declare function escapeLikePattern(s: string): string;
200
199
  export declare function apiBuilder(req: IncomingMessage, res: ServerResponse, ctx: StudioContext): Promise<void>;
201
200
  export declare function apiRowWrite(req: IncomingMessage, res: ServerResponse, ctx: StudioContext, op: 'update' | 'insert' | 'delete'): Promise<void>;
202
201
  interface SavedQuery {
@@ -49,7 +49,6 @@ exports.relationLinksForTable = relationLinksForTable;
49
49
  exports.apiTableRows = apiTableRows;
50
50
  exports.resolveColumnName = resolveColumnName;
51
51
  exports.isTextishType = isTextishType;
52
- exports.escapeLikePattern = escapeLikePattern;
53
52
  exports.apiBuilder = apiBuilder;
54
53
  exports.apiRowWrite = apiRowWrite;
55
54
  exports.apiListSavedQueries = apiListSavedQueries;
@@ -598,12 +597,19 @@ async function apiTableRows(res, ctx, rawTableName, params) {
598
597
  // Full-text-ish search: ILIKE across text/varchar columns. The value is
599
598
  // parameterized so injection is impossible. Each query gets its own
600
599
  // WHERE clause with parameter indices matching that query's param array.
600
+ // LIKE operands go through the dialect's escaper, the same hook the query
601
+ // builder uses (`Dialect.escapeLikePattern`): which characters need escaping
602
+ // is a property of the engine's pattern grammar, not of this SQL text, and a
603
+ // private copy here is how Studio came to bypass the SQL Server rule for
604
+ // `[`. No hook (Postgres, and the demo's SQLite, which inherits none) means
605
+ // the shared default. Paired with the `ESCAPE '\'` clause `likeCond` emits.
606
+ const escapeLikeOperand = (v) => ctx.dialect?.escapeLikePattern?.(v) ?? (0, utils_js_1.escapeLike)(v);
601
607
  const search = params.get('search')?.trim() ?? '';
602
608
  const textColumns = table.columns
603
609
  .filter((c) => isTextishType(c.pgType) && !redactedPii.has(c.name))
604
610
  .map((c) => c.name);
605
611
  const hasSearch = search.length > 0 && textColumns.length > 0;
606
- const pattern = hasSearch ? `%${escapeLikePattern(search)}%` : null;
612
+ const pattern = hasSearch ? `%${escapeLikeOperand(search)}%` : null;
607
613
  // Per-column filters: `filters` is a JSON array of { column, op, value }
608
614
  // composed by the Data tab's filter bar. Every column is validated against
609
615
  // the metadata, every op against a fixed whitelist, and every value is a
@@ -646,7 +652,7 @@ async function apiTableRows(res, ctx, rawTableName, params) {
646
652
  conds.push(`${(0, index_js_1.quoteIdent)(f.column)} IS NOT NULL`);
647
653
  }
648
654
  else if (f.op === 'contains') {
649
- values.push(`%${escapeLikePattern(String(f.value))}%`);
655
+ values.push(`%${escapeLikeOperand(String(f.value))}%`);
650
656
  conds.push(likeCond(f.column, n++));
651
657
  }
652
658
  else {
@@ -729,10 +735,6 @@ function isTextishType(pgType) {
729
735
  pgType === 'citext' ||
730
736
  pgType === 'uuid');
731
737
  }
732
- function escapeLikePattern(s) {
733
- // Escape the LIKE wildcards so user input is treated literally.
734
- return s.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_');
735
- }
736
738
  // ---------------------------------------------------------------------------
737
739
  // Data-tab per-column filters
738
740
  // ---------------------------------------------------------------------------
@@ -959,6 +959,13 @@ export declare class TurbineClient {
959
959
  * replicas are configured this simply returns the client itself (already
960
960
  * primary-only). The view is cached, repeated calls return the same instance.
961
961
  *
962
+ * Typed as `this` so a generated subclass keeps its declared table accessors
963
+ * through the call (`db.$primary().users`, the form in the example below).
964
+ * With the base class as the return type the accessors were gone and the only
965
+ * typed route was `db.$primary().table('users')`. The view is a base-class
966
+ * instance built from the same schema, so at runtime it carries every table
967
+ * accessor the subclass `declare`s; the cast states exactly that.
968
+ *
962
969
  * @example
963
970
  * ```ts
964
971
  * await db.users.create({ data: { email: 'a@b.com' } });
@@ -966,7 +973,7 @@ export declare class TurbineClient {
966
973
  * const user = await db.$primary().users.findFirst({ where: { email: 'a@b.com' } });
967
974
  * ```
968
975
  */
969
- $primary(): TurbineClient;
976
+ $primary(): this;
970
977
  /**
971
978
  * Execute multiple queries in a single database round-trip.
972
979
  *
@@ -1459,6 +1459,13 @@ class TurbineClient {
1459
1459
  * replicas are configured this simply returns the client itself (already
1460
1460
  * primary-only). The view is cached, repeated calls return the same instance.
1461
1461
  *
1462
+ * Typed as `this` so a generated subclass keeps its declared table accessors
1463
+ * through the call (`db.$primary().users`, the form in the example below).
1464
+ * With the base class as the return type the accessors were gone and the only
1465
+ * typed route was `db.$primary().table('users')`. The view is a base-class
1466
+ * instance built from the same schema, so at runtime it carries every table
1467
+ * accessor the subclass `declare`s; the cast states exactly that.
1468
+ *
1462
1469
  * @example
1463
1470
  * ```ts
1464
1471
  * await db.users.create({ data: { email: 'a@b.com' } });
@@ -188,3 +188,85 @@ export declare function withStatementTimeoutOption(config: ConnectionOptionsConf
188
188
  * the cycle `scripts/check-import-cycles.mjs` exists to refuse.
189
189
  */
190
190
  export declare function mergeConnectionStringOptions(connectionString: string, setting: string): string | null;
191
+ /**
192
+ * The schema names {@link withSearchPathOption} accepts: a plain SQL
193
+ * identifier, letters, digits, `_` and `$`, not starting with a digit.
194
+ *
195
+ * Deliberately narrower than what Postgres allows in a quoted identifier. A GUC
196
+ * value cannot be a bind parameter, so the emitted text necessarily contains
197
+ * the caller's string as a literal, and the libpq `options` parameter is split
198
+ * on WHITESPACE: a name that can carry a space can carry a second `-c` and set
199
+ * any backend GUC it likes. Refusing anything outside this alphabet is what
200
+ * makes the literal safe to emit, the same reasoning as the integer narrowing in
201
+ * {@link withStatementTimeoutOption}.
202
+ */
203
+ export declare const SEARCH_PATH_SCHEMA_PATTERN: RegExp;
204
+ /** Whether `schema` is a name {@link withSearchPathOption} will emit. */
205
+ export declare function isPlainSchemaIdentifier(schema: string): boolean;
206
+ /**
207
+ * The schema names in a `SHOW search_path` value, unquoted.
208
+ *
209
+ * Postgres renders the GUC as a comma-separated identifier list, quoting only
210
+ * the entries that need it, so this splits on commas OUTSIDE double quotes and
211
+ * un-doubles `""`. Anything the caller cannot safely re-emit is dropped by
212
+ * {@link searchPathListValue}, not here: this function's job is to say what the
213
+ * connection's path IS.
214
+ */
215
+ export declare function parseSearchPathValue(shown: string): string[];
216
+ /**
217
+ * `connectionString` with `search_path` pinned to `schema` through the
218
+ * connection's **startup parameters** (`options=-c search_path="<schema>"`),
219
+ * never through a `SET`.
220
+ *
221
+ * This is the mechanism a tool that emits UNQUALIFIED identifiers uses to land
222
+ * them in the configured schema: the migration runner (`CREATE TABLE t1` from a
223
+ * migration file, and its own `_turbine_migrations` tracking table) and the seed
224
+ * runner both connect through it. The alternative, `SET search_path` after
225
+ * connecting, is exactly the session-state write a transaction pooler leaves
226
+ * behind on a shared backend, see the module comment. The startup parameter is
227
+ * in force for the connection's first statement and cannot outlive it.
228
+ *
229
+ * The schema is QUOTED inside the GUC value. Postgres parses `search_path` as
230
+ * an identifier list and folds an unquoted entry to lower case, so an unquoted
231
+ * `MySchema` would resolve to `myschema`, while every other place this CLI
232
+ * reads the configured name (introspection's `table_schema = $1`, the push
233
+ * pin) treats it exactly. Quoting keeps the three in agreement. The quotes are
234
+ * safe to emit because {@link SEARCH_PATH_SCHEMA_PATTERN} admits no `"` and no
235
+ * whitespace; anything else throws rather than reaching the wire.
236
+ *
237
+ * The value EXTENDS the caller's path rather than discarding it: `schema` goes
238
+ * FIRST (an unqualified CREATE uses the first entry, which is the whole point of
239
+ * the pin) and every name in `alsoResolveIn` follows. Pinning used to emit the
240
+ * target ALONE, which is a different behaviour from the push path's
241
+ * `pinSearchPath`, and the difference is not cosmetic: on the ordinary managed
242
+ * layout (`public, extensions`) a `citext` / `vector` / `hstore` / `postgis`
243
+ * column, an extension opclass in a CREATE INDEX, or a CHECK calling an
244
+ * extension function all failed under a pin that the same DDL did not need
245
+ * without one. Two authorities on one question, so there is now one.
246
+ *
247
+ * The inherited path cannot be read before the connection exists, so
248
+ * `alsoResolveIn` is the caller's job: connect once unpinned, `SHOW
249
+ * search_path`, hand the names here (see {@link parseSearchPathValue}). Nothing
250
+ * is guessed, and an empty list emits exactly what it always did.
251
+ *
252
+ * Merge rules match {@link withStatementTimeoutOption}: an existing
253
+ * `?options=` is appended to (the later `-c search_path` wins on the backend),
254
+ * an existing query string gains an `options` entry, and a bare URL gains
255
+ * `?options=`. In the two branches that CREATE the parameter, `PGOPTIONS` is
256
+ * folded in first: pg reads the environment variable only while the connection
257
+ * carries no `options` of its own, so adding one blind would silently drop a
258
+ * deployment's setting. Only a URL-shaped string is rewritten; a key/value DSN
259
+ * (which node-postgres does not parse anyway) yields `null` instead of being
260
+ * returned unchanged.
261
+ *
262
+ * Returns `null`, never a guess, when it cannot pin SAFELY: a schema outside
263
+ * {@link SEARCH_PATH_SCHEMA_PATTERN}, or a connection string that is not a
264
+ * URL. This module raises nothing because it imports nothing (`ValidationError`
265
+ * included, see the module comment on why it must stay a leaf), the same
266
+ * posture as {@link withStatementTimeoutOption} returning its input untouched
267
+ * on a bad timeout. The caller owns the refusal and its error code
268
+ * (`connectionStringForSchema` in cli/migrate.ts raises E003), and MUST treat
269
+ * `null` as a refusal: the unpinned string means "into whichever schema the
270
+ * role defaults to", the exact outcome this exists to prevent.
271
+ */
272
+ export declare function withSearchPathOption(connectionString: string, schema: string, alsoResolveIn?: readonly string[]): string | null;
@@ -34,12 +34,15 @@
34
34
  * command from reporting confidently about a connection it cannot reason about.
35
35
  */
36
36
  Object.defineProperty(exports, "__esModule", { value: true });
37
- exports.POOLER_PORTS = exports.POOLER_HOST_TOKENS = void 0;
37
+ exports.SEARCH_PATH_SCHEMA_PATTERN = exports.POOLER_PORTS = exports.POOLER_HOST_TOKENS = void 0;
38
38
  exports.parseConnectionTarget = parseConnectionTarget;
39
39
  exports.detectPooler = detectPooler;
40
40
  exports.poolerRefusalMessage = poolerRefusalMessage;
41
41
  exports.withStatementTimeoutOption = withStatementTimeoutOption;
42
42
  exports.mergeConnectionStringOptions = mergeConnectionStringOptions;
43
+ exports.isPlainSchemaIdentifier = isPlainSchemaIdentifier;
44
+ exports.parseSearchPathValue = parseSearchPathValue;
45
+ exports.withSearchPathOption = withSearchPathOption;
43
46
  /**
44
47
  * Host and port from a connection string, in either shape libpq accepts.
45
48
  *
@@ -292,3 +295,186 @@ function mergeConnectionStringOptions(connectionString, setting) {
292
295
  params.set('options', `${existing} ${setting}`);
293
296
  return connectionString.slice(0, q + 1) + params.toString();
294
297
  }
298
+ // ---------------------------------------------------------------------------
299
+ // Connection-time search_path
300
+ // ---------------------------------------------------------------------------
301
+ /**
302
+ * The schema names {@link withSearchPathOption} accepts: a plain SQL
303
+ * identifier, letters, digits, `_` and `$`, not starting with a digit.
304
+ *
305
+ * Deliberately narrower than what Postgres allows in a quoted identifier. A GUC
306
+ * value cannot be a bind parameter, so the emitted text necessarily contains
307
+ * the caller's string as a literal, and the libpq `options` parameter is split
308
+ * on WHITESPACE: a name that can carry a space can carry a second `-c` and set
309
+ * any backend GUC it likes. Refusing anything outside this alphabet is what
310
+ * makes the literal safe to emit, the same reasoning as the integer narrowing in
311
+ * {@link withStatementTimeoutOption}.
312
+ */
313
+ exports.SEARCH_PATH_SCHEMA_PATTERN = /^[A-Za-z_][A-Za-z0-9_$]*$/;
314
+ /** Whether `schema` is a name {@link withSearchPathOption} will emit. */
315
+ function isPlainSchemaIdentifier(schema) {
316
+ return exports.SEARCH_PATH_SCHEMA_PATTERN.test(schema);
317
+ }
318
+ /**
319
+ * The one name outside {@link SEARCH_PATH_SCHEMA_PATTERN} that is still safe to
320
+ * emit into the `options=-c` literal, and worth keeping.
321
+ *
322
+ * `$user` is Postgres's own token for "the schema named after the current
323
+ * role", and it leads the default `search_path` on nearly every installation.
324
+ * The pattern refuses it because a name may not START with `$`, which is right
325
+ * for a CALLER-supplied schema; dropping it from an inherited path, however,
326
+ * silently changes name resolution. It carries no whitespace, no quote and no
327
+ * backslash, so it cannot smuggle a second `-c`, which is the only property the
328
+ * literal needs.
329
+ */
330
+ const SEARCH_PATH_USER_TOKEN = '$user';
331
+ /**
332
+ * The schema names in a `SHOW search_path` value, unquoted.
333
+ *
334
+ * Postgres renders the GUC as a comma-separated identifier list, quoting only
335
+ * the entries that need it, so this splits on commas OUTSIDE double quotes and
336
+ * un-doubles `""`. Anything the caller cannot safely re-emit is dropped by
337
+ * {@link searchPathListValue}, not here: this function's job is to say what the
338
+ * connection's path IS.
339
+ */
340
+ function parseSearchPathValue(shown) {
341
+ const names = [];
342
+ let current = '';
343
+ let quoted = false;
344
+ let sawContent = false;
345
+ for (let i = 0; i < shown.length; i++) {
346
+ const ch = shown[i];
347
+ if (quoted) {
348
+ if (ch === '"') {
349
+ if (shown[i + 1] === '"') {
350
+ current += '"';
351
+ i++;
352
+ }
353
+ else {
354
+ quoted = false;
355
+ }
356
+ }
357
+ else {
358
+ current += ch;
359
+ }
360
+ continue;
361
+ }
362
+ if (ch === '"') {
363
+ quoted = true;
364
+ sawContent = true;
365
+ continue;
366
+ }
367
+ if (ch === ',') {
368
+ const name = current.trim();
369
+ if (name !== '' || sawContent)
370
+ names.push(name);
371
+ current = '';
372
+ sawContent = false;
373
+ continue;
374
+ }
375
+ current += ch;
376
+ }
377
+ const last = current.trim();
378
+ if (last !== '' || sawContent)
379
+ names.push(last);
380
+ return names.filter((n) => n !== '');
381
+ }
382
+ /**
383
+ * The `search_path` GUC value a pin emits: `schema` first, then each name in
384
+ * `alsoResolveIn` that survives the same validation `schema` did.
385
+ *
386
+ * Every entry is quoted (Postgres folds an unquoted one to lower case, and the
387
+ * rest of the CLI treats the configured name exactly), and the list is joined
388
+ * with a BARE comma: the libpq `options` parameter is split on WHITESPACE, so a
389
+ * space inside the value would start a second `-c`. A name that cannot be
390
+ * emitted safely is DROPPED rather than escaped, because the alternative is
391
+ * inventing an escaping rule for a literal whose only defence is its alphabet.
392
+ * Dropping is monotone against the previous behaviour, which dropped all of
393
+ * them.
394
+ */
395
+ function searchPathListValue(schema, alsoResolveIn) {
396
+ const seen = new Set([schema]);
397
+ const names = [schema];
398
+ for (const name of alsoResolveIn) {
399
+ if (seen.has(name))
400
+ continue;
401
+ if (!isPlainSchemaIdentifier(name) && name !== SEARCH_PATH_USER_TOKEN)
402
+ continue;
403
+ seen.add(name);
404
+ names.push(name);
405
+ }
406
+ return names.map((n) => `"${n}"`).join(',');
407
+ }
408
+ /**
409
+ * `connectionString` with `search_path` pinned to `schema` through the
410
+ * connection's **startup parameters** (`options=-c search_path="<schema>"`),
411
+ * never through a `SET`.
412
+ *
413
+ * This is the mechanism a tool that emits UNQUALIFIED identifiers uses to land
414
+ * them in the configured schema: the migration runner (`CREATE TABLE t1` from a
415
+ * migration file, and its own `_turbine_migrations` tracking table) and the seed
416
+ * runner both connect through it. The alternative, `SET search_path` after
417
+ * connecting, is exactly the session-state write a transaction pooler leaves
418
+ * behind on a shared backend, see the module comment. The startup parameter is
419
+ * in force for the connection's first statement and cannot outlive it.
420
+ *
421
+ * The schema is QUOTED inside the GUC value. Postgres parses `search_path` as
422
+ * an identifier list and folds an unquoted entry to lower case, so an unquoted
423
+ * `MySchema` would resolve to `myschema`, while every other place this CLI
424
+ * reads the configured name (introspection's `table_schema = $1`, the push
425
+ * pin) treats it exactly. Quoting keeps the three in agreement. The quotes are
426
+ * safe to emit because {@link SEARCH_PATH_SCHEMA_PATTERN} admits no `"` and no
427
+ * whitespace; anything else throws rather than reaching the wire.
428
+ *
429
+ * The value EXTENDS the caller's path rather than discarding it: `schema` goes
430
+ * FIRST (an unqualified CREATE uses the first entry, which is the whole point of
431
+ * the pin) and every name in `alsoResolveIn` follows. Pinning used to emit the
432
+ * target ALONE, which is a different behaviour from the push path's
433
+ * `pinSearchPath`, and the difference is not cosmetic: on the ordinary managed
434
+ * layout (`public, extensions`) a `citext` / `vector` / `hstore` / `postgis`
435
+ * column, an extension opclass in a CREATE INDEX, or a CHECK calling an
436
+ * extension function all failed under a pin that the same DDL did not need
437
+ * without one. Two authorities on one question, so there is now one.
438
+ *
439
+ * The inherited path cannot be read before the connection exists, so
440
+ * `alsoResolveIn` is the caller's job: connect once unpinned, `SHOW
441
+ * search_path`, hand the names here (see {@link parseSearchPathValue}). Nothing
442
+ * is guessed, and an empty list emits exactly what it always did.
443
+ *
444
+ * Merge rules match {@link withStatementTimeoutOption}: an existing
445
+ * `?options=` is appended to (the later `-c search_path` wins on the backend),
446
+ * an existing query string gains an `options` entry, and a bare URL gains
447
+ * `?options=`. In the two branches that CREATE the parameter, `PGOPTIONS` is
448
+ * folded in first: pg reads the environment variable only while the connection
449
+ * carries no `options` of its own, so adding one blind would silently drop a
450
+ * deployment's setting. Only a URL-shaped string is rewritten; a key/value DSN
451
+ * (which node-postgres does not parse anyway) yields `null` instead of being
452
+ * returned unchanged.
453
+ *
454
+ * Returns `null`, never a guess, when it cannot pin SAFELY: a schema outside
455
+ * {@link SEARCH_PATH_SCHEMA_PATTERN}, or a connection string that is not a
456
+ * URL. This module raises nothing because it imports nothing (`ValidationError`
457
+ * included, see the module comment on why it must stay a leaf), the same
458
+ * posture as {@link withStatementTimeoutOption} returning its input untouched
459
+ * on a bad timeout. The caller owns the refusal and its error code
460
+ * (`connectionStringForSchema` in cli/migrate.ts raises E003), and MUST treat
461
+ * `null` as a refusal: the unpinned string means "into whichever schema the
462
+ * role defaults to", the exact outcome this exists to prevent.
463
+ */
464
+ function withSearchPathOption(connectionString, schema, alsoResolveIn = []) {
465
+ if (!isPlainSchemaIdentifier(schema))
466
+ return null;
467
+ const setting = `-c search_path=${searchPathListValue(schema, alsoResolveIn)}`;
468
+ const merged = mergeConnectionStringOptions(connectionString, setting);
469
+ if (merged !== null)
470
+ return merged;
471
+ if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(connectionString.trim()))
472
+ return null;
473
+ const pgOptions = typeof process !== 'undefined' ? process.env?.PGOPTIONS : undefined;
474
+ const value = pgOptions ? `${pgOptions} ${setting}` : setting;
475
+ const q = connectionString.indexOf('?');
476
+ const params = new URLSearchParams(q === -1 ? '' : connectionString.slice(q + 1));
477
+ params.set('options', value);
478
+ const head = q === -1 ? connectionString : connectionString.slice(0, q);
479
+ return `${head}?${params.toString()}`;
480
+ }