@everystack/cli 0.4.9 → 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
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
|
|
|
@@ -179,15 +179,10 @@ export async function executeReconcile(
|
|
|
179
179
|
}
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
-
// Reset the search_path before introspecting. pg_get_viewdef renders a ref BARE while its
|
|
183
|
-
// schema is in scope and QUALIFIED once it is off — so the defHash we record must be read
|
|
184
|
-
// under the SAME (default) path every future introspection uses, or the qualified live
|
|
185
|
-
// deparse reads as drift against a bare recorded hash on the very next run. Canonical
|
|
186
|
-
// capture, always: SET LOCAL resets within the transaction; the session path otherwise.
|
|
187
|
-
if (setSearchPath) await runner(atomic ? 'SET LOCAL search_path TO DEFAULT' : 'RESET search_path');
|
|
188
|
-
|
|
189
182
|
// Re-read the catalog so provenance records the def hashes of what NOW exists, not what we hoped
|
|
190
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.
|
|
191
186
|
const after = await introspectDerived(runner);
|
|
192
187
|
const liveById = new Map(after.objects.map((o) => [o.identity, o]));
|
|
193
188
|
const srcById = new Map(parsed.objects.map((o) => [o.identity, o]));
|
|
@@ -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
|
+
}
|