@everystack/cli 0.4.8 → 0.4.9

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.9",
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>",
@@ -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,17 +164,28 @@ 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
 
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
+
170
189
  // Re-read the catalog so provenance records the def hashes of what NOW exists, not what we hoped
171
190
  // would exist. Inside the transaction this reads the batch's own not-yet-committed writes.
172
191
  const after = await introspectDerived(runner);
@@ -196,7 +215,9 @@ export async function executeReconcile(
196
215
  for (const identity of rendered.remove) bookkeeping.push(renderProvenanceDelete(identity));
197
216
  bookkeeping.push(renderSchemaLogInsert({
198
217
  kind: 'compute reconcile',
199
- sql: rendered.statements.join(';\n') || '-- provenance-only (baseline/rebaseline/prune)',
218
+ sql: rendered.statements.length > 0
219
+ ? [setSearchPath, ...rendered.statements].filter(Boolean).join(';\n')
220
+ : '-- provenance-only (baseline/rebaseline/prune)',
200
221
  outcome: 'applied',
201
222
  actor: options.actor, gitRef: options.gitRef, planRef: options.planRef,
202
223
  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[] = [];