@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.4.9",
3
+ "version": "0.4.11",
4
4
  "description": "CLI and OTA updates for Expo apps on everystack",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Scalable Technology, Inc. <licensing@scalable.technology>",
@@ -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
- const [rls, policies, grants, columnGrants, fnRows] = await Promise.all([
441
- run(RLS_SQL),
442
- run(POLICIES_SQL),
443
- run(GRANTS_SQL),
444
- run(COLUMN_GRANTS_SQL),
445
- run(functionsSql),
446
- ]);
447
- return assembleContract({
448
- rls: rls as RlsRow[],
449
- policies: policies as PolicyRow[],
450
- grants: grants as GrantRow[],
451
- columnGrants: columnGrants as ColumnGrantRow[],
452
- functions: (fnRows as any[]).map(mapFunctionRow),
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
- const [relations, functions, indexes, depends, triggers, grants] = await Promise.all([
533
- run(DERIVED_RELATIONS_SQL), run(DERIVED_FUNCTIONS_SQL), run(MATVIEW_INDEXES_SQL), run(DERIVED_DEPENDS_SQL),
534
- run(DERIVED_TRIGGERS_SQL), run(DERIVED_GRANTS_SQL),
535
- ]);
536
- let provenance: ProvenanceRawRow[] = [];
537
- try {
538
- provenance = (await run(PROVENANCE_SQL)) as ProvenanceRawRow[];
539
- } catch {
540
- // everystack.derived_provenance does not exist yet — nothing has been reconciled.
541
- }
542
- return assembleDerivedCatalog({
543
- relations: relations as RelationRow[],
544
- functions: functions as FunctionRow[],
545
- indexes: indexes as IndexDefRow[],
546
- depends: depends as DependsRow[],
547
- triggers: triggers as TriggerRow[],
548
- grants: grants as GrantAclRow[],
549
- provenance,
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
  }
@@ -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
- const [columns, constraints, enums, indexes, sequences] = await Promise.all([
583
- run(COLUMNS_SQL), run(CONSTRAINTS_SQL), run(ENUMS_SQL), run(INDEXES_SQL), run(SEQUENCES_SQL),
584
- ]);
585
- return assembleSchema({
586
- columns: columns as ColumnRow[],
587
- constraints: constraints as ConstraintRow[],
588
- enums: enums as EnumRow[],
589
- indexes: indexes as IndexRow[],
590
- sequences: sequences as SequenceRow[],
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
+ }