@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.4.8",
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
 
@@ -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
- await runner(rendered.statements.join(';\n'));
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.join(';\n') || '-- provenance-only (baseline/rebaseline/prune)',
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,
@@ -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
- 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
+ }