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
|
@@ -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;
|
package/dist/cjs/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/cjs/cli/studio.js
CHANGED
|
@@ -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 ? `%${
|
|
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(`%${
|
|
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
|
// ---------------------------------------------------------------------------
|
package/dist/cjs/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/cjs/client.js
CHANGED
|
@@ -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
|
+
}
|