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.
- package/README.md +13 -9
- package/dist/cjs/cli/config.d.ts +7 -1
- package/dist/cjs/cli/config.js +11 -2
- package/dist/cjs/cli/destructive.d.ts +1 -1
- package/dist/cjs/cli/destructive.js +307 -9
- package/dist/cjs/cli/index.js +252 -42
- package/dist/cjs/cli/mcp.d.ts +23 -0
- package/dist/cjs/cli/mcp.js +190 -152
- package/dist/cjs/cli/migrate.d.ts +243 -3
- package/dist/cjs/cli/migrate.js +432 -43
- package/dist/cjs/cli/sql-statements.js +27 -0
- package/dist/cjs/cli/studio.d.ts +0 -1
- package/dist/cjs/cli/studio.js +9 -7
- package/dist/cjs/client.d.ts +8 -1
- package/dist/cjs/client.js +7 -0
- package/dist/cjs/connection-url.d.ts +82 -0
- package/dist/cjs/connection-url.js +187 -1
- package/dist/cjs/errors.d.ts +112 -12
- package/dist/cjs/errors.js +558 -114
- package/dist/cjs/generate.js +47 -15
- package/dist/cjs/index.d.ts +1 -1
- package/dist/cjs/introspect.d.ts +33 -0
- package/dist/cjs/introspect.js +54 -1
- package/dist/cjs/mssql.js +21 -1
- package/dist/cjs/nested-write.js +85 -14
- package/dist/cjs/pipeline-submittable.d.ts +2 -0
- package/dist/cjs/pipeline-submittable.js +88 -3
- package/dist/cjs/pipeline.js +13 -1
- package/dist/cjs/powdb-introspect.d.ts +5 -1
- package/dist/cjs/powdb-introspect.js +5 -1
- package/dist/cjs/powql.d.ts +14 -0
- package/dist/cjs/powql.js +44 -4
- package/dist/cjs/prisma-compat.js +95 -8
- package/dist/cjs/query/aggregates.js +56 -6
- package/dist/cjs/query/builder.d.ts +76 -13
- package/dist/cjs/query/builder.js +188 -58
- package/dist/cjs/query/compound-unique.d.ts +76 -0
- package/dist/cjs/query/compound-unique.js +129 -0
- package/dist/cjs/query/index.d.ts +1 -1
- package/dist/cjs/query/types.d.ts +65 -11
- package/dist/cjs/query/where.d.ts +85 -19
- package/dist/cjs/query/where.js +262 -47
- package/dist/cjs/query/writes.d.ts +11 -2
- package/dist/cjs/query/writes.js +116 -21
- package/dist/cjs/seed.d.ts +16 -0
- package/dist/cjs/seed.js +16 -0
- package/dist/cli/config.d.ts +7 -1
- package/dist/cli/config.js +11 -2
- package/dist/cli/destructive.d.ts +1 -1
- package/dist/cli/destructive.js +307 -9
- package/dist/cli/index.js +254 -44
- package/dist/cli/mcp.d.ts +23 -0
- package/dist/cli/mcp.js +187 -150
- package/dist/cli/migrate.d.ts +243 -3
- package/dist/cli/migrate.js +423 -45
- package/dist/cli/sql-statements.js +27 -0
- package/dist/cli/studio.d.ts +0 -1
- package/dist/cli/studio.js +10 -7
- package/dist/client.d.ts +8 -1
- package/dist/client.js +7 -0
- package/dist/connection-url.d.ts +82 -0
- package/dist/connection-url.js +183 -0
- package/dist/errors.d.ts +112 -12
- package/dist/errors.js +558 -114
- package/dist/generate.js +47 -15
- package/dist/index.d.ts +1 -1
- package/dist/introspect.d.ts +33 -0
- package/dist/introspect.js +53 -1
- package/dist/mssql.js +21 -1
- package/dist/nested-write.js +85 -14
- package/dist/pipeline-submittable.d.ts +2 -0
- package/dist/pipeline-submittable.js +87 -3
- package/dist/pipeline.js +14 -2
- package/dist/powdb-introspect.d.ts +5 -1
- package/dist/powdb-introspect.js +5 -1
- package/dist/powql.d.ts +14 -0
- package/dist/powql.js +45 -5
- package/dist/prisma-compat.js +96 -9
- package/dist/query/aggregates.js +56 -6
- package/dist/query/builder.d.ts +76 -13
- package/dist/query/builder.js +188 -58
- package/dist/query/compound-unique.d.ts +76 -0
- package/dist/query/compound-unique.js +126 -1
- package/dist/query/index.d.ts +1 -1
- package/dist/query/types.d.ts +65 -11
- package/dist/query/where.d.ts +85 -19
- package/dist/query/where.js +260 -47
- package/dist/query/writes.d.ts +11 -2
- package/dist/query/writes.js +117 -22
- package/dist/seed.d.ts +16 -0
- package/dist/seed.js +16 -0
- package/package.json +3 -3
- 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;
|
package/dist/cli/studio.d.ts
CHANGED
|
@@ -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 {
|
package/dist/cli/studio.js
CHANGED
|
@@ -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 ? `%${
|
|
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(`%${
|
|
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():
|
|
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' } });
|
package/dist/connection-url.d.ts
CHANGED
|
@@ -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;
|
package/dist/connection-url.js
CHANGED
|
@@ -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
|
+
}
|