@everystack/cli 0.4.8 → 0.4.11
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/package.json +1 -1
- package/src/cli/authz-contract.ts +18 -13
- package/src/cli/commands/db-reconcile.ts +19 -3
- package/src/cli/derived-apply.ts +36 -2
- package/src/cli/derived-introspect.ts +25 -18
- package/src/cli/model-render.ts +72 -1
- package/src/cli/schema-introspect.ts +15 -9
- package/src/cli/search-path.ts +51 -0
package/package.json
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import { parsePgArray } from './security-catalog.js';
|
|
25
|
+
import { withCanonicalSearchPath } from './search-path.js';
|
|
25
26
|
|
|
26
27
|
// ---------------------------------------------------------------------------
|
|
27
28
|
// The contract format — the frozen, reviewable, version-controlled shape.
|
|
@@ -437,19 +438,23 @@ export async function introspectContract(
|
|
|
437
438
|
mapFunctionRow: (row: any) => { schema: string; name: string; securityDefiner: boolean; hasSearchPath: boolean },
|
|
438
439
|
functionsSql: string,
|
|
439
440
|
): Promise<AuthzContract> {
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
441
|
+
// Policy USING/WITH CHECK expressions deparse relative to the search_path — pin the
|
|
442
|
+
// canonical baseline so an ambient non-default path never reads as false authz drift.
|
|
443
|
+
return withCanonicalSearchPath(run, async () => {
|
|
444
|
+
const [rls, policies, grants, columnGrants, fnRows] = await Promise.all([
|
|
445
|
+
run(RLS_SQL),
|
|
446
|
+
run(POLICIES_SQL),
|
|
447
|
+
run(GRANTS_SQL),
|
|
448
|
+
run(COLUMN_GRANTS_SQL),
|
|
449
|
+
run(functionsSql),
|
|
450
|
+
]);
|
|
451
|
+
return assembleContract({
|
|
452
|
+
rls: rls as RlsRow[],
|
|
453
|
+
policies: policies as PolicyRow[],
|
|
454
|
+
grants: grants as GrantRow[],
|
|
455
|
+
columnGrants: columnGrants as ColumnGrantRow[],
|
|
456
|
+
functions: (fnRows as any[]).map(mapFunctionRow),
|
|
457
|
+
});
|
|
453
458
|
});
|
|
454
459
|
}
|
|
455
460
|
|
|
@@ -40,6 +40,8 @@ import {
|
|
|
40
40
|
triggerDropSql,
|
|
41
41
|
renderProvenanceDelete,
|
|
42
42
|
renderSchemaLogInsert,
|
|
43
|
+
derivedSearchPath,
|
|
44
|
+
renderSetSearchPath,
|
|
43
45
|
ENSURE_RECONCILER_SQL,
|
|
44
46
|
} from '../derived-apply.js';
|
|
45
47
|
import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
|
|
@@ -143,6 +145,12 @@ export async function executeReconcile(
|
|
|
143
145
|
// (CONCURRENTLY index builds, VACUUM); those self-commit, so we fall back to the unwrapped path.
|
|
144
146
|
const atomic = isTransactionalBatch(rendered.statements);
|
|
145
147
|
|
|
148
|
+
// Bare table refs in derived bodies (a db/pull deparse from the all-public era) resolve
|
|
149
|
+
// against the search_path at CREATE time — put every declared schema on it, or the build
|
|
150
|
+
// fails on the first non-public table. Null for an all-public app: the apply stays exactly
|
|
151
|
+
// as it was. See derivedSearchPath.
|
|
152
|
+
const setSearchPath = renderSetSearchPath(derivedSearchPath(parsed.objects), atomic);
|
|
153
|
+
|
|
146
154
|
if (atomic) await runner('BEGIN');
|
|
147
155
|
try {
|
|
148
156
|
// The session guard: postgres-js `max: 1` is a pool of one, not a session lease — a
|
|
@@ -156,19 +164,25 @@ export async function executeReconcile(
|
|
|
156
164
|
|
|
157
165
|
if (rendered.statements.length > 0) {
|
|
158
166
|
if (atomic) {
|
|
159
|
-
|
|
167
|
+
// The SET rides the same simple-query batch (and the same transaction) as the DDL,
|
|
168
|
+
// so bare body refs resolve to their real schema; SET LOCAL resets it at COMMIT.
|
|
169
|
+
await runner([setSearchPath, ...rendered.statements].filter(Boolean).join(';\n'));
|
|
160
170
|
} else {
|
|
161
171
|
// Self-committing statements (CONCURRENTLY, VACUUM) refuse ANY transaction block —
|
|
162
172
|
// including the implicit one a joined multi-statement simple query runs in, so the
|
|
163
173
|
// batch MUST go one statement per protocol message. A failure mid-way leaves the
|
|
164
174
|
// earlier statements committed: the documented cost of the unwrapped path; the next
|
|
165
|
-
// plan re-converges from what actually landed.
|
|
175
|
+
// plan re-converges from what actually landed. The session SET precedes them so every
|
|
176
|
+
// create resolves its bare refs.
|
|
177
|
+
if (setSearchPath) await runner(setSearchPath);
|
|
166
178
|
for (const statement of rendered.statements) await runner(statement);
|
|
167
179
|
}
|
|
168
180
|
}
|
|
169
181
|
|
|
170
182
|
// Re-read the catalog so provenance records the def hashes of what NOW exists, not what we hoped
|
|
171
183
|
// would exist. Inside the transaction this reads the batch's own not-yet-committed writes.
|
|
184
|
+
// introspectDerived pins the canonical search_path itself, so the defHash it records is stable
|
|
185
|
+
// regardless of the create-time wide path above (or any ambient override) — no manual reset here.
|
|
172
186
|
const after = await introspectDerived(runner);
|
|
173
187
|
const liveById = new Map(after.objects.map((o) => [o.identity, o]));
|
|
174
188
|
const srcById = new Map(parsed.objects.map((o) => [o.identity, o]));
|
|
@@ -196,7 +210,9 @@ export async function executeReconcile(
|
|
|
196
210
|
for (const identity of rendered.remove) bookkeeping.push(renderProvenanceDelete(identity));
|
|
197
211
|
bookkeeping.push(renderSchemaLogInsert({
|
|
198
212
|
kind: 'compute reconcile',
|
|
199
|
-
sql: rendered.statements.
|
|
213
|
+
sql: rendered.statements.length > 0
|
|
214
|
+
? [setSearchPath, ...rendered.statements].filter(Boolean).join(';\n')
|
|
215
|
+
: '-- provenance-only (baseline/rebaseline/prune)',
|
|
200
216
|
outcome: 'applied',
|
|
201
217
|
actor: options.actor, gitRef: options.gitRef, planRef: options.planRef,
|
|
202
218
|
durationMs: now() - started,
|
package/src/cli/derived-apply.ts
CHANGED
|
@@ -13,9 +13,9 @@
|
|
|
13
13
|
* the reconciler writes it and never reads it.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import { normalizeSql, type SourceObject } from './derived-source.js';
|
|
16
|
+
import { normalizeSql, parseQualified, type SourceObject } from './derived-source.js';
|
|
17
17
|
import type { ReconcilePlan } from './derived-plan.js';
|
|
18
|
-
import { quoteQualified } from './pg-ident.js';
|
|
18
|
+
import { quoteIdent, quoteQualified } from './pg-ident.js';
|
|
19
19
|
|
|
20
20
|
/** SQL string literal with '' doubling (standard_conforming_strings). */
|
|
21
21
|
export function escapeLiteral(value: string): string {
|
|
@@ -102,6 +102,40 @@ function objectSql(obj: SourceObject): string[] {
|
|
|
102
102
|
return statements;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
/**
|
|
106
|
+
* The schemas a from-scratch derived apply must put on the search_path before it
|
|
107
|
+
* creates anything. Derived bodies carry BARE table refs — db:pull deparsed them
|
|
108
|
+
* when every table lived in `public`, so a view over `stats.list_casts` still reads
|
|
109
|
+
* `FROM list_casts`. Postgres resolves and STORES those refs against the search_path
|
|
110
|
+
* AT CREATE TIME, so a build whose path is public-only cannot resolve them once the
|
|
111
|
+
* tables move to non-public schemas (the multi-schema split). The needed set is the
|
|
112
|
+
* union of every schema the declared objects and their declared dependencies live in
|
|
113
|
+
* — a deterministic function of the source, so the build stays hermetic. `public`
|
|
114
|
+
* always trails (tables/extensions still there; a db:pull-era bare ref was globally
|
|
115
|
+
* unique in public, so order never has to disambiguate).
|
|
116
|
+
*/
|
|
117
|
+
export function derivedSearchPath(objects: SourceObject[]): string[] {
|
|
118
|
+
const schemas = new Set<string>();
|
|
119
|
+
for (const o of objects) {
|
|
120
|
+
schemas.add(o.schema);
|
|
121
|
+
for (const dep of o.declaredDeps ?? []) schemas.add(parseQualified(dep).schema);
|
|
122
|
+
}
|
|
123
|
+
const nonPublic = [...schemas].filter((s) => s !== 'public').sort();
|
|
124
|
+
return [...nonPublic, 'public'];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The `SET search_path` that prefixes a derived apply — or `null` when every object
|
|
129
|
+
* and dependency lives in `public` (the default), so an all-public app's apply stays
|
|
130
|
+
* byte-identical and the override appears only when a schema split actually needs it.
|
|
131
|
+
* `LOCAL` scopes it to the apply transaction (the atomic batch); the non-atomic
|
|
132
|
+
* CONCURRENTLY path has no transaction, so it sets the session for the apply's life.
|
|
133
|
+
*/
|
|
134
|
+
export function renderSetSearchPath(schemas: string[], local: boolean): string | null {
|
|
135
|
+
if (schemas.length === 1 && schemas[0] === 'public') return null;
|
|
136
|
+
return `SET ${local ? 'LOCAL ' : ''}search_path = ${schemas.map(quoteIdent).join(', ')}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
105
139
|
export function renderReconcileSql(plan: ReconcilePlan, source: SourceObject[]): RenderedReconcile {
|
|
106
140
|
const srcById = new Map(source.map((o) => [o.identity, o]));
|
|
107
141
|
const statements: string[] = [];
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
import { createHash } from 'node:crypto';
|
|
21
21
|
import { IGNORED_SCHEMAS, type QueryRunner } from './authz-contract.js';
|
|
22
22
|
import { normalizeSql, type DerivedKind } from './derived-source.js';
|
|
23
|
+
import { withCanonicalSearchPath } from './search-path.js';
|
|
23
24
|
|
|
24
25
|
export interface LiveObject {
|
|
25
26
|
kind: DerivedKind;
|
|
@@ -529,23 +530,29 @@ export function assembleDerivedCatalog(rows: DerivedRows): DerivedCatalog {
|
|
|
529
530
|
* the reconciler has never touched) folds to an empty claims list.
|
|
530
531
|
*/
|
|
531
532
|
export async function introspectDerived(run: QueryRunner): Promise<DerivedCatalog> {
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
533
|
+
// View/matview/function bodies deparse relative to the search_path (`pg_get_viewdef`
|
|
534
|
+
// renders a ref bare while its schema is in scope, qualified once off). Pin the
|
|
535
|
+
// canonical baseline so the recorded def hash is stable across runs and paths — this
|
|
536
|
+
// also subsumes the reconcile apply's pre-introspection reset. See search-path.ts.
|
|
537
|
+
return withCanonicalSearchPath(run, async () => {
|
|
538
|
+
const [relations, functions, indexes, depends, triggers, grants] = await Promise.all([
|
|
539
|
+
run(DERIVED_RELATIONS_SQL), run(DERIVED_FUNCTIONS_SQL), run(MATVIEW_INDEXES_SQL), run(DERIVED_DEPENDS_SQL),
|
|
540
|
+
run(DERIVED_TRIGGERS_SQL), run(DERIVED_GRANTS_SQL),
|
|
541
|
+
]);
|
|
542
|
+
let provenance: ProvenanceRawRow[] = [];
|
|
543
|
+
try {
|
|
544
|
+
provenance = (await run(PROVENANCE_SQL)) as ProvenanceRawRow[];
|
|
545
|
+
} catch {
|
|
546
|
+
// everystack.derived_provenance does not exist yet — nothing has been reconciled.
|
|
547
|
+
}
|
|
548
|
+
return assembleDerivedCatalog({
|
|
549
|
+
relations: relations as RelationRow[],
|
|
550
|
+
functions: functions as FunctionRow[],
|
|
551
|
+
indexes: indexes as IndexDefRow[],
|
|
552
|
+
depends: depends as DependsRow[],
|
|
553
|
+
triggers: triggers as TriggerRow[],
|
|
554
|
+
grants: grants as GrantAclRow[],
|
|
555
|
+
provenance,
|
|
556
|
+
});
|
|
550
557
|
});
|
|
551
558
|
}
|
package/src/cli/model-render.ts
CHANGED
|
@@ -208,6 +208,77 @@ function renderDefault(expr: string): string | null {
|
|
|
208
208
|
return null;
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
+
const NUMERIC_ARRAY_BASE = /^(smallint|integer|bigint|numeric|decimal|real|double precision)\b/;
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* The `.default([...])` modifier for an ARRAY column — the spelling the array-default
|
|
215
|
+
* guard requires (a string default on an array field throws; `.default("{}")` was the
|
|
216
|
+
* old, now-rejected pull output). Parses the Postgres array literal back into the JS
|
|
217
|
+
* array the compiler round-trips from: `'{}'` → `[]`, `'{"a","b"}'` → `['a', 'b']`,
|
|
218
|
+
* `'{1,2}'` → `[1, 2]`. Returns null for anything it can't confidently invert (nested
|
|
219
|
+
* arrays, NULL elements, an `ARRAY[...]`/function expression) — the caller falls back to
|
|
220
|
+
* `.defaultSql()`, which round-trips via the raw SQL (the diff normalizes both sides).
|
|
221
|
+
*/
|
|
222
|
+
function renderArrayDefault(expr: string, arrayType: string): string | null {
|
|
223
|
+
const n = normalizeDefault(expr);
|
|
224
|
+
if (n == null) return null;
|
|
225
|
+
const lit = n.match(/^'(\{[\s\S]*\})'$/);
|
|
226
|
+
if (!lit) return null; // not a `'{…}'` literal (e.g. an ARRAY[…] expression) → defaultSql
|
|
227
|
+
const elems = parsePgArrayElements(lit[1]);
|
|
228
|
+
if (elems == null) return null;
|
|
229
|
+
const base = arrayType.slice(0, -2);
|
|
230
|
+
const numeric = NUMERIC_ARRAY_BASE.test(base);
|
|
231
|
+
const boolean = base === 'boolean';
|
|
232
|
+
const rendered = elems.map((e) => {
|
|
233
|
+
// A bare NULL is a real NULL element (a quoted "NULL" is the string) — we don't model it.
|
|
234
|
+
if (!e.quoted && e.raw.toUpperCase() === 'NULL') return null;
|
|
235
|
+
if (boolean) return e.raw === 'true' || e.raw === 'false' ? e.raw : null;
|
|
236
|
+
if (numeric) return /^-?\d+(\.\d+)?$/.test(e.raw) ? e.raw : null;
|
|
237
|
+
return tsLiteral(e.raw);
|
|
238
|
+
});
|
|
239
|
+
if (rendered.some((r) => r == null)) return null;
|
|
240
|
+
return `.default([${rendered.join(', ')}])`;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Split the body of a Postgres array literal (`{…}`, braces included) into its top-level
|
|
245
|
+
* elements, each tagged quoted-or-bare with backslash escapes resolved. Returns `[]` for
|
|
246
|
+
* `{}`, or null for a nested array or a malformed literal (the caller then falls back).
|
|
247
|
+
*/
|
|
248
|
+
function parsePgArrayElements(literal: string): Array<{ raw: string; quoted: boolean }> | null {
|
|
249
|
+
const body = literal.slice(1, -1);
|
|
250
|
+
if (body === '') return [];
|
|
251
|
+
const out: Array<{ raw: string; quoted: boolean }> = [];
|
|
252
|
+
let i = 0;
|
|
253
|
+
while (i < body.length) {
|
|
254
|
+
let raw = '';
|
|
255
|
+
let quoted = false;
|
|
256
|
+
if (body[i] === '{') return null; // nested array — not modeled
|
|
257
|
+
if (body[i] === '"') {
|
|
258
|
+
quoted = true;
|
|
259
|
+
i++;
|
|
260
|
+
while (i < body.length && body[i] !== '"') {
|
|
261
|
+
if (body[i] === '\\') { raw += body[i + 1] ?? ''; i += 2; continue; }
|
|
262
|
+
raw += body[i]; i++;
|
|
263
|
+
}
|
|
264
|
+
if (body[i] !== '"') return null; // unterminated quote
|
|
265
|
+
i++;
|
|
266
|
+
} else {
|
|
267
|
+
while (i < body.length && body[i] !== ',') {
|
|
268
|
+
if (body[i] === '{' || body[i] === '"') return null;
|
|
269
|
+
raw += body[i]; i++;
|
|
270
|
+
}
|
|
271
|
+
raw = raw.trim();
|
|
272
|
+
}
|
|
273
|
+
out.push({ raw, quoted });
|
|
274
|
+
if (i < body.length) {
|
|
275
|
+
if (body[i] !== ',') return null; // trailing junk after a quoted element
|
|
276
|
+
i++;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return out;
|
|
280
|
+
}
|
|
281
|
+
|
|
211
282
|
/** A single-quoted TS string literal (the model idiom), escaping embedded quotes. */
|
|
212
283
|
function tsLiteral(value: string): string {
|
|
213
284
|
return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
|
@@ -260,7 +331,7 @@ function renderField(table: TableSchema, col: ColumnSchema, known: Set<string>,
|
|
|
260
331
|
}
|
|
261
332
|
|
|
262
333
|
if (col.default != null && !serial) {
|
|
263
|
-
const d = renderDefault(col.default);
|
|
334
|
+
const d = col.type.endsWith('[]') ? renderArrayDefault(col.default, col.type) : renderDefault(col.default);
|
|
264
335
|
// No builder maps it → declare it verbatim with the escape hatch. RAW, not the
|
|
265
336
|
// normalized form — stripping a trailing cast here could change semantics
|
|
266
337
|
// (`'7 days'::interval`); the diff normalizes both sides for comparison instead.
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { IGNORED_SCHEMAS, coerceBool, type QueryRunner } from './authz-contract.js';
|
|
17
|
+
import { withCanonicalSearchPath } from './search-path.js';
|
|
17
18
|
|
|
18
19
|
// ---------------------------------------------------------------------------
|
|
19
20
|
// The data-layer snapshot — the structured shape both producers target.
|
|
@@ -579,14 +580,19 @@ export function assembleSchema(rows: SchemaRows): SchemaSnapshot {
|
|
|
579
580
|
* `assembleSchema`. This is the `current` side `db:generate` diffs the compiled Models against.
|
|
580
581
|
*/
|
|
581
582
|
export async function introspectSchema(run: QueryRunner): Promise<SchemaSnapshot> {
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
return
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
583
|
+
// Pin the canonical search_path: column defaults, CHECK constraints, and index
|
|
584
|
+
// expressions all deparse relative to it, so an ambient non-default path would
|
|
585
|
+
// read as false drift. See search-path.ts.
|
|
586
|
+
return withCanonicalSearchPath(run, async () => {
|
|
587
|
+
const [columns, constraints, enums, indexes, sequences] = await Promise.all([
|
|
588
|
+
run(COLUMNS_SQL), run(CONSTRAINTS_SQL), run(ENUMS_SQL), run(INDEXES_SQL), run(SEQUENCES_SQL),
|
|
589
|
+
]);
|
|
590
|
+
return assembleSchema({
|
|
591
|
+
columns: columns as ColumnRow[],
|
|
592
|
+
constraints: constraints as ConstraintRow[],
|
|
593
|
+
enums: enums as EnumRow[],
|
|
594
|
+
indexes: indexes as IndexRow[],
|
|
595
|
+
sequences: sequences as SequenceRow[],
|
|
596
|
+
});
|
|
591
597
|
});
|
|
592
598
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* search-path — the canonical introspection baseline.
|
|
3
|
+
*
|
|
4
|
+
* Every expression PostgreSQL deparses on introspection — column defaults
|
|
5
|
+
* (`pg_get_expr`), CHECK constraints, index expressions, generated columns,
|
|
6
|
+
* view/matview bodies (`pg_get_viewdef`), function bodies — renders its schema
|
|
7
|
+
* qualification RELATIVE to the session `search_path`. So the SAME schema reads
|
|
8
|
+
* differently depending on the ambient path: with a non-public schema on the
|
|
9
|
+
* path a reference to it renders BARE (`nextval('x_seq')`), off the path it
|
|
10
|
+
* renders QUALIFIED (`nextval('stats.x_seq')`). The declared state is always
|
|
11
|
+
* canonical (public bare, non-public qualified), so a non-default session or
|
|
12
|
+
* DB-level `search_path` makes every deparsed expression read as false drift.
|
|
13
|
+
*
|
|
14
|
+
* The fix is to CAPTURE under a fixed canonical path, once, at the introspection
|
|
15
|
+
* boundary — one guard for the whole class (defaults, checks, indexes, bodies),
|
|
16
|
+
* present and future. The baseline is `public`: it renders public-bare and
|
|
17
|
+
* non-public-qualified, matching the declared compile exactly (verified against
|
|
18
|
+
* PostgreSQL 16). It must be an EXPLICIT value — `RESET` / `SET … TO DEFAULT`
|
|
19
|
+
* inherit an `ALTER DATABASE/ROLE … SET search_path` override (the very thing
|
|
20
|
+
* that triggers the bug), so they are NOT canonical baselines.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { QueryRunner } from './authz-contract.js';
|
|
24
|
+
|
|
25
|
+
/** The canonical introspection search_path — explicit, override-proof, declared-matching. */
|
|
26
|
+
export const CANONICAL_SEARCH_PATH_SQL = 'SET search_path = public';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Run `fn` with the connection's search_path pinned to the canonical baseline, so every
|
|
30
|
+
* expression it deparses is captured in the declared-matching form regardless of the
|
|
31
|
+
* ambient path, then RESET to the connection's default afterward. RESET restores the
|
|
32
|
+
* database/role default — which for the case this fixes (an `ALTER DATABASE … SET
|
|
33
|
+
* search_path` override) is exactly the override, so a fresh connection sees no change.
|
|
34
|
+
* It deliberately does NOT preserve a caller's transient session/transaction-local SET:
|
|
35
|
+
* restoring a captured `SET LOCAL` value session-wide would leak it (e.g. the reconcile
|
|
36
|
+
* apply's create-time wide path), and no everystack caller relies on a hand-set path
|
|
37
|
+
* surviving introspection.
|
|
38
|
+
*
|
|
39
|
+
* Effective on a persistent single connection (the direct `createUrlRunner`, `max: 1`);
|
|
40
|
+
* over a per-call pooled runner the SET does not persist, but that path already renders
|
|
41
|
+
* canonically unless the database itself carries a search_path override.
|
|
42
|
+
*/
|
|
43
|
+
export async function withCanonicalSearchPath<T>(run: QueryRunner, fn: () => Promise<T>): Promise<T> {
|
|
44
|
+
await run(CANONICAL_SEARCH_PATH_SQL);
|
|
45
|
+
try {
|
|
46
|
+
return await fn();
|
|
47
|
+
} finally {
|
|
48
|
+
// Best-effort: a failure here must not mask fn's result.
|
|
49
|
+
try { await run('RESET search_path'); } catch { /* connection may be gone */ }
|
|
50
|
+
}
|
|
51
|
+
}
|