@everystack/cli 0.4.7 → 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.7",
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>",
@@ -82,7 +82,7 @@
82
82
  "structured-headers": "1.0.1",
83
83
  "tsx": "4.21.0",
84
84
  "typescript": "5.9.3",
85
- "@everystack/model": "0.4.1"
85
+ "@everystack/model": "0.4.2"
86
86
  },
87
87
  "peerDependencies": {
88
88
  "@everystack/server": ">=0.1.0",
@@ -35,6 +35,7 @@ import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '
35
35
  import { resolveConfig, opsFunction } from '../config.js';
36
36
  import { invokeAction, lambdaQueryRunner } from '../aws.js';
37
37
  import { executeApplyPlan, type ApplyPlanResult } from '../apply-execute.js';
38
+ import { pgDumpPreflightError } from './db-backup.js';
38
39
  import { step, success, fail, info, warn } from '../output.js';
39
40
 
40
41
  // The apply core lives in apply-execute.ts (shared with the ops Lambda's
@@ -116,6 +117,19 @@ async function applyPlanViaStage(
116
117
 
117
118
  info(`plan ${planHash(plan).slice(0, 12)}: ${plan.fromFingerprint.slice(0, 12)} → ${plan.toFingerprint.slice(0, 12)} (${plan.executable} statement(s)${plan.destructive ? `, ${plan.destructive} destructive` : ''})`);
118
119
 
120
+ // A destructive staged apply snapshots via db:backup INSIDE the Lambda, before the DDL. Preflight
121
+ // that capability HERE so a missing/incompatible pg_dump layer fails fast with the install remedy —
122
+ // not deep in the ceremony after the authority check, the way it once surfaced mid-apply.
123
+ if (plan.destructive > 0) {
124
+ step('Preflighting the pg_dump layer (the destructive apply snapshots before the DDL)...');
125
+ const probe: any = await invokeAction(region, fn, 'db:backup:probe', {}).catch((err: any) => ({ error: err?.message ?? String(err) }));
126
+ const remedy = pgDumpPreflightError(probe);
127
+ if (remedy) {
128
+ fail(`Cannot take the pre-apply snapshot — the destructive plan was NOT applied.\n${remedy}`);
129
+ process.exit(1);
130
+ }
131
+ }
132
+
119
133
  try {
120
134
  // Descent — the fast-forward rule needs the git checkout, so the CLI
121
135
  // decides it (the Lambda has no checkout). Read the stage's state read-only
@@ -48,7 +48,7 @@ export async function dbBackupProbeCommand(flags: Record<string, string>): Promi
48
48
 
49
49
  if (result?.error) {
50
50
  fail(result.error);
51
- info('Build + publish the layer with scripts/build-pgdump-layer.sh, export PGDUMP_LAYER_ARN, then redeploy.');
51
+ info('Install @everystack/pg-tools and redeploy — the layer attaches automatically. For a bring-your-own layer, pass pgDumpLayer(..., { layerArn }) in sst.config.ts.');
52
52
  process.exit(1);
53
53
  }
54
54
 
@@ -71,7 +71,7 @@ export async function dbBackupProbeCommand(flags: Record<string, string>): Promi
71
71
  export function pgDumpPreflightError(probe: any): string | null {
72
72
  if (!probe || probe.error) {
73
73
  const detail = probe?.error ?? 'the pg_dump layer probe returned no result';
74
- return `${detail}\nBuild + publish the layer with scripts/build-pgdump-layer.sh, export PGDUMP_LAYER_ARN, then redeploy.`;
74
+ return `${detail}\nInstall @everystack/pg-tools and redeploy — the layer attaches automatically. For a bring-your-own layer, pass pgDumpLayer(..., { layerArn }) in sst.config.ts.`;
75
75
  }
76
76
  if (probe.compatible === false) {
77
77
  return `pg_dump ${probe.pgDump ?? '(unknown)'} (major ${probe.pgDumpMajor}) is older than the server (major ${probe.serverMajor}) — it will refuse to dump (no bypass exists). Rebuild the layer with pg_dump >= ${probe.serverMajor}.`;
@@ -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,
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import { spawn } from 'node:child_process';
14
- import { step, success, fail, info } from '../output.js';
14
+ import { step, success, fail, info, warn } from '../output.js';
15
15
  import { invalidateCachedConfig } from '../discover.js';
16
16
  import { resolveConfig } from '../config.js';
17
17
  import { probeDeployedFunctions } from '../deploy-probe.js';
@@ -23,11 +23,11 @@ export function buildDeployArgs(flags: Record<string, string>): string[] {
23
23
  }
24
24
 
25
25
  /**
26
- * The environment the sst child receives: the FULL ambient environment plus color.
27
- * Pinned as an explicit, tested contract because a consumer observed PGDUMP_LAYER_ARN
28
- * reaching sst when invoked directly but not through this wrapper sst.config.ts
29
- * conditionals like `process.env.PGDUMP_LAYER_ARN ? { layers: [...] } : {}` silently
30
- * collapse when a variable goes missing, and the deploy still exits green.
26
+ * The environment the sst child receives: the FULL ambient environment plus color. The wrapper
27
+ * forwards everything so a consumer's own sst.config.ts env reads behave the same run directly or
28
+ * through `everystack deploy`. (This contract was pinned when the pg_dump layer depended on
29
+ * PGDUMP_LAYER_ARN reaching sst now deprecated: the layer ships via @everystack/pg-tools +
30
+ * pgDumpLayer() and reads no ambient env at deploy.)
31
31
  */
32
32
  export function deployEnv(base: Record<string, string | undefined>): Record<string, string | undefined> {
33
33
  return { ...base, FORCE_COLOR: '1' };
@@ -38,11 +38,11 @@ export async function deployCommand(flags: Record<string, string>): Promise<void
38
38
  const args = buildDeployArgs(flags);
39
39
 
40
40
  step(`Deploying stage "${stage}"...`);
41
- // Say what the wrapper sees: an sst.config.ts conditional on an env var collapses
42
- // SILENTLY when the var is missing, and the deploy still exits green the operator
43
- // must be able to tell "the wrapper never saw it" from "sst ignored it" at a glance.
41
+ // PGDUMP_LAYER_ARN is deprecated: the layer now ships via @everystack/pg-tools and pgDumpLayer()
42
+ // needs no env var. Still forwarded to sst for one more minor (pgDumpLayer honors it as an alias),
43
+ // then removed surface it loudly so the operator migrates off it.
44
44
  if (process.env.PGDUMP_LAYER_ARN) {
45
- info(`pg_dump layer: PGDUMP_LAYER_ARN is set forwarded to sst (${process.env.PGDUMP_LAYER_ARN})`);
45
+ warn('PGDUMP_LAYER_ARN is DEPRECATED and will be removed next minor. The pg_dump layer now ships via @everystack/pg-tools + pgDumpLayer() with no env var. Drop it once your sst.config.ts uses pgDumpLayer().');
46
46
  }
47
47
  await new Promise<void>((resolve) => {
48
48
  // `npx` resolves the consumer's local sst (the same way the export step runs expo).
@@ -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[] = [];