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
@@ -233,6 +233,32 @@ export function tokenizeSql(sql) {
233
233
  }
234
234
  // Single-quoted literal. `''` always escapes a quote; inside an E-string a
235
235
  // backslash escapes the next character too.
236
+ //
237
+ // The CONTENT is handed over as a block, exactly as a dollar-quoted body
238
+ // is. A routine body does not have to be dollar-quoted: `DO 'BEGIN DROP
239
+ // TABLE users; END'` and `CREATE FUNCTION f() ... AS 'DELETE FROM t'` are
240
+ // both ordinary PostgreSQL and both execute. This branch used to push
241
+ // NOTHING, so `SqlStatement.blocks` came back empty for them, and the
242
+ // destructive scanner iterates `procedural ? statement.blocks : []`: with
243
+ // no blocks, every procedural pass was skipped, INCLUDING the fail-closed
244
+ // "cannot classify" backstop. The statement was correctly recognized as
245
+ // procedural and then scanned against nothing, so the guard reported a
246
+ // clean inventory for a file that drops a table. Verified live before the
247
+ // fix on PostgreSQL 17: no prompt, no `--allow-destructive`, table gone.
248
+ //
249
+ // Handing over EVERY single-quoted literal, not only the ones in a
250
+ // procedural statement, is deliberate and costs nothing: this tokenizer
251
+ // does not know which statement it is in the middle of, the consumer
252
+ // already gates on `PROCEDURAL_STATEMENT`, and pushing more is the
253
+ // fail-CLOSED direction this file resolves ambiguity toward. `INSERT INTO
254
+ // log VALUES ('DROP TABLE x')` is unaffected because an INSERT is not
255
+ // procedural, which is the same reason the dollar-quoted branch above can
256
+ // push unconditionally.
257
+ //
258
+ // `''` is unescaped to `'` so the block lexes as the SQL it will BE when
259
+ // the server reads it: the payload of `DO 'BEGIN EXECUTE ''DROP TABLE
260
+ // t''; END'` is `EXECUTE 'DROP TABLE t'`, and a nested scan of the raw
261
+ // slice would see a doubled quote where a string opener belongs.
236
262
  if (ch === "'") {
237
263
  const escapes = isEscapeStringPrefix(sql, i);
238
264
  let j = i + 1;
@@ -250,6 +276,7 @@ export function tokenizeSql(sql) {
250
276
  j++;
251
277
  }
252
278
  const end = Math.min(j + 1, n);
279
+ blocks.push(sql.slice(i + 1, Math.min(j, n)).replace(/''/g, "'"));
253
280
  stripped += "''";
254
281
  code += sql.slice(i, end);
255
282
  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 {
@@ -52,7 +52,7 @@ import { QueryInterface, quoteIdent } from '../query/index.js';
52
52
  // what decides that `blog_posts` and `blogPosts` name the same relation, and a
53
53
  // redaction walk that answered that differently would redact a different query
54
54
  // than the one that ran.
55
- import { ownLookup, registerUtcTemporalParsers, resolveRelation } from '../query/utils.js';
55
+ import { escapeLike, ownLookup, registerUtcTemporalParsers, resolveRelation } from '../query/utils.js';
56
56
  import { assertNoPiiPredicates as assertNoPiiPredicatesShared } from './pii-predicate-guard.js';
57
57
  import { applyPiiTags, loadPiiTags } from './pii-tags.js';
58
58
  import { callerKey, checkRateLimit } from './rate-limit.js';
@@ -578,12 +578,19 @@ export async function apiTableRows(res, ctx, rawTableName, params) {
578
578
  // Full-text-ish search: ILIKE across text/varchar columns. The value is
579
579
  // parameterized so injection is impossible. Each query gets its own
580
580
  // WHERE clause with parameter indices matching that query's param array.
581
+ // LIKE operands go through the dialect's escaper, the same hook the query
582
+ // builder uses (`Dialect.escapeLikePattern`): which characters need escaping
583
+ // is a property of the engine's pattern grammar, not of this SQL text, and a
584
+ // private copy here is how Studio came to bypass the SQL Server rule for
585
+ // `[`. No hook (Postgres, and the demo's SQLite, which inherits none) means
586
+ // the shared default. Paired with the `ESCAPE '\'` clause `likeCond` emits.
587
+ const escapeLikeOperand = (v) => ctx.dialect?.escapeLikePattern?.(v) ?? escapeLike(v);
581
588
  const search = params.get('search')?.trim() ?? '';
582
589
  const textColumns = table.columns
583
590
  .filter((c) => isTextishType(c.pgType) && !redactedPii.has(c.name))
584
591
  .map((c) => c.name);
585
592
  const hasSearch = search.length > 0 && textColumns.length > 0;
586
- const pattern = hasSearch ? `%${escapeLikePattern(search)}%` : null;
593
+ const pattern = hasSearch ? `%${escapeLikeOperand(search)}%` : null;
587
594
  // Per-column filters: `filters` is a JSON array of { column, op, value }
588
595
  // composed by the Data tab's filter bar. Every column is validated against
589
596
  // the metadata, every op against a fixed whitelist, and every value is a
@@ -626,7 +633,7 @@ export async function apiTableRows(res, ctx, rawTableName, params) {
626
633
  conds.push(`${quoteIdent(f.column)} IS NOT NULL`);
627
634
  }
628
635
  else if (f.op === 'contains') {
629
- values.push(`%${escapeLikePattern(String(f.value))}%`);
636
+ values.push(`%${escapeLikeOperand(String(f.value))}%`);
630
637
  conds.push(likeCond(f.column, n++));
631
638
  }
632
639
  else {
@@ -709,10 +716,6 @@ export function isTextishType(pgType) {
709
716
  pgType === 'citext' ||
710
717
  pgType === 'uuid');
711
718
  }
712
- export function escapeLikePattern(s) {
713
- // Escape the LIKE wildcards so user input is treated literally.
714
- return s.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_');
715
- }
716
719
  // ---------------------------------------------------------------------------
717
720
  // Data-tab per-column filters
718
721
  // ---------------------------------------------------------------------------
package/dist/client.d.ts CHANGED
@@ -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
  *
package/dist/client.js CHANGED
@@ -1451,6 +1451,13 @@ export class TurbineClient {
1451
1451
  * replicas are configured this simply returns the client itself (already
1452
1452
  * primary-only). The view is cached, repeated calls return the same instance.
1453
1453
  *
1454
+ * Typed as `this` so a generated subclass keeps its declared table accessors
1455
+ * through the call (`db.$primary().users`, the form in the example below).
1456
+ * With the base class as the return type the accessors were gone and the only
1457
+ * typed route was `db.$primary().table('users')`. The view is a base-class
1458
+ * instance built from the same schema, so at runtime it carries every table
1459
+ * accessor the subclass `declare`s; the cast states exactly that.
1460
+ *
1454
1461
  * @example
1455
1462
  * ```ts
1456
1463
  * 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;
@@ -284,3 +284,186 @@ export function mergeConnectionStringOptions(connectionString, setting) {
284
284
  params.set('options', `${existing} ${setting}`);
285
285
  return connectionString.slice(0, q + 1) + params.toString();
286
286
  }
287
+ // ---------------------------------------------------------------------------
288
+ // Connection-time search_path
289
+ // ---------------------------------------------------------------------------
290
+ /**
291
+ * The schema names {@link withSearchPathOption} accepts: a plain SQL
292
+ * identifier, letters, digits, `_` and `$`, not starting with a digit.
293
+ *
294
+ * Deliberately narrower than what Postgres allows in a quoted identifier. A GUC
295
+ * value cannot be a bind parameter, so the emitted text necessarily contains
296
+ * the caller's string as a literal, and the libpq `options` parameter is split
297
+ * on WHITESPACE: a name that can carry a space can carry a second `-c` and set
298
+ * any backend GUC it likes. Refusing anything outside this alphabet is what
299
+ * makes the literal safe to emit, the same reasoning as the integer narrowing in
300
+ * {@link withStatementTimeoutOption}.
301
+ */
302
+ export const SEARCH_PATH_SCHEMA_PATTERN = /^[A-Za-z_][A-Za-z0-9_$]*$/;
303
+ /** Whether `schema` is a name {@link withSearchPathOption} will emit. */
304
+ export function isPlainSchemaIdentifier(schema) {
305
+ return SEARCH_PATH_SCHEMA_PATTERN.test(schema);
306
+ }
307
+ /**
308
+ * The one name outside {@link SEARCH_PATH_SCHEMA_PATTERN} that is still safe to
309
+ * emit into the `options=-c` literal, and worth keeping.
310
+ *
311
+ * `$user` is Postgres's own token for "the schema named after the current
312
+ * role", and it leads the default `search_path` on nearly every installation.
313
+ * The pattern refuses it because a name may not START with `$`, which is right
314
+ * for a CALLER-supplied schema; dropping it from an inherited path, however,
315
+ * silently changes name resolution. It carries no whitespace, no quote and no
316
+ * backslash, so it cannot smuggle a second `-c`, which is the only property the
317
+ * literal needs.
318
+ */
319
+ const SEARCH_PATH_USER_TOKEN = '$user';
320
+ /**
321
+ * The schema names in a `SHOW search_path` value, unquoted.
322
+ *
323
+ * Postgres renders the GUC as a comma-separated identifier list, quoting only
324
+ * the entries that need it, so this splits on commas OUTSIDE double quotes and
325
+ * un-doubles `""`. Anything the caller cannot safely re-emit is dropped by
326
+ * {@link searchPathListValue}, not here: this function's job is to say what the
327
+ * connection's path IS.
328
+ */
329
+ export function parseSearchPathValue(shown) {
330
+ const names = [];
331
+ let current = '';
332
+ let quoted = false;
333
+ let sawContent = false;
334
+ for (let i = 0; i < shown.length; i++) {
335
+ const ch = shown[i];
336
+ if (quoted) {
337
+ if (ch === '"') {
338
+ if (shown[i + 1] === '"') {
339
+ current += '"';
340
+ i++;
341
+ }
342
+ else {
343
+ quoted = false;
344
+ }
345
+ }
346
+ else {
347
+ current += ch;
348
+ }
349
+ continue;
350
+ }
351
+ if (ch === '"') {
352
+ quoted = true;
353
+ sawContent = true;
354
+ continue;
355
+ }
356
+ if (ch === ',') {
357
+ const name = current.trim();
358
+ if (name !== '' || sawContent)
359
+ names.push(name);
360
+ current = '';
361
+ sawContent = false;
362
+ continue;
363
+ }
364
+ current += ch;
365
+ }
366
+ const last = current.trim();
367
+ if (last !== '' || sawContent)
368
+ names.push(last);
369
+ return names.filter((n) => n !== '');
370
+ }
371
+ /**
372
+ * The `search_path` GUC value a pin emits: `schema` first, then each name in
373
+ * `alsoResolveIn` that survives the same validation `schema` did.
374
+ *
375
+ * Every entry is quoted (Postgres folds an unquoted one to lower case, and the
376
+ * rest of the CLI treats the configured name exactly), and the list is joined
377
+ * with a BARE comma: the libpq `options` parameter is split on WHITESPACE, so a
378
+ * space inside the value would start a second `-c`. A name that cannot be
379
+ * emitted safely is DROPPED rather than escaped, because the alternative is
380
+ * inventing an escaping rule for a literal whose only defence is its alphabet.
381
+ * Dropping is monotone against the previous behaviour, which dropped all of
382
+ * them.
383
+ */
384
+ function searchPathListValue(schema, alsoResolveIn) {
385
+ const seen = new Set([schema]);
386
+ const names = [schema];
387
+ for (const name of alsoResolveIn) {
388
+ if (seen.has(name))
389
+ continue;
390
+ if (!isPlainSchemaIdentifier(name) && name !== SEARCH_PATH_USER_TOKEN)
391
+ continue;
392
+ seen.add(name);
393
+ names.push(name);
394
+ }
395
+ return names.map((n) => `"${n}"`).join(',');
396
+ }
397
+ /**
398
+ * `connectionString` with `search_path` pinned to `schema` through the
399
+ * connection's **startup parameters** (`options=-c search_path="<schema>"`),
400
+ * never through a `SET`.
401
+ *
402
+ * This is the mechanism a tool that emits UNQUALIFIED identifiers uses to land
403
+ * them in the configured schema: the migration runner (`CREATE TABLE t1` from a
404
+ * migration file, and its own `_turbine_migrations` tracking table) and the seed
405
+ * runner both connect through it. The alternative, `SET search_path` after
406
+ * connecting, is exactly the session-state write a transaction pooler leaves
407
+ * behind on a shared backend, see the module comment. The startup parameter is
408
+ * in force for the connection's first statement and cannot outlive it.
409
+ *
410
+ * The schema is QUOTED inside the GUC value. Postgres parses `search_path` as
411
+ * an identifier list and folds an unquoted entry to lower case, so an unquoted
412
+ * `MySchema` would resolve to `myschema`, while every other place this CLI
413
+ * reads the configured name (introspection's `table_schema = $1`, the push
414
+ * pin) treats it exactly. Quoting keeps the three in agreement. The quotes are
415
+ * safe to emit because {@link SEARCH_PATH_SCHEMA_PATTERN} admits no `"` and no
416
+ * whitespace; anything else throws rather than reaching the wire.
417
+ *
418
+ * The value EXTENDS the caller's path rather than discarding it: `schema` goes
419
+ * FIRST (an unqualified CREATE uses the first entry, which is the whole point of
420
+ * the pin) and every name in `alsoResolveIn` follows. Pinning used to emit the
421
+ * target ALONE, which is a different behaviour from the push path's
422
+ * `pinSearchPath`, and the difference is not cosmetic: on the ordinary managed
423
+ * layout (`public, extensions`) a `citext` / `vector` / `hstore` / `postgis`
424
+ * column, an extension opclass in a CREATE INDEX, or a CHECK calling an
425
+ * extension function all failed under a pin that the same DDL did not need
426
+ * without one. Two authorities on one question, so there is now one.
427
+ *
428
+ * The inherited path cannot be read before the connection exists, so
429
+ * `alsoResolveIn` is the caller's job: connect once unpinned, `SHOW
430
+ * search_path`, hand the names here (see {@link parseSearchPathValue}). Nothing
431
+ * is guessed, and an empty list emits exactly what it always did.
432
+ *
433
+ * Merge rules match {@link withStatementTimeoutOption}: an existing
434
+ * `?options=` is appended to (the later `-c search_path` wins on the backend),
435
+ * an existing query string gains an `options` entry, and a bare URL gains
436
+ * `?options=`. In the two branches that CREATE the parameter, `PGOPTIONS` is
437
+ * folded in first: pg reads the environment variable only while the connection
438
+ * carries no `options` of its own, so adding one blind would silently drop a
439
+ * deployment's setting. Only a URL-shaped string is rewritten; a key/value DSN
440
+ * (which node-postgres does not parse anyway) yields `null` instead of being
441
+ * returned unchanged.
442
+ *
443
+ * Returns `null`, never a guess, when it cannot pin SAFELY: a schema outside
444
+ * {@link SEARCH_PATH_SCHEMA_PATTERN}, or a connection string that is not a
445
+ * URL. This module raises nothing because it imports nothing (`ValidationError`
446
+ * included, see the module comment on why it must stay a leaf), the same
447
+ * posture as {@link withStatementTimeoutOption} returning its input untouched
448
+ * on a bad timeout. The caller owns the refusal and its error code
449
+ * (`connectionStringForSchema` in cli/migrate.ts raises E003), and MUST treat
450
+ * `null` as a refusal: the unpinned string means "into whichever schema the
451
+ * role defaults to", the exact outcome this exists to prevent.
452
+ */
453
+ export function withSearchPathOption(connectionString, schema, alsoResolveIn = []) {
454
+ if (!isPlainSchemaIdentifier(schema))
455
+ return null;
456
+ const setting = `-c search_path=${searchPathListValue(schema, alsoResolveIn)}`;
457
+ const merged = mergeConnectionStringOptions(connectionString, setting);
458
+ if (merged !== null)
459
+ return merged;
460
+ if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(connectionString.trim()))
461
+ return null;
462
+ const pgOptions = typeof process !== 'undefined' ? process.env?.PGOPTIONS : undefined;
463
+ const value = pgOptions ? `${pgOptions} ${setting}` : setting;
464
+ const q = connectionString.indexOf('?');
465
+ const params = new URLSearchParams(q === -1 ? '' : connectionString.slice(q + 1));
466
+ params.set('options', value);
467
+ const head = q === -1 ? connectionString : connectionString.slice(0, q);
468
+ return `${head}?${params.toString()}`;
469
+ }