@everystack/cli 0.4.88 → 0.4.90

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.88",
3
+ "version": "0.4.90",
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>",
@@ -266,7 +266,22 @@ async function runCatchUp(
266
266
  onStart: () => step('Catching the functions, views and triggers up to the checkout...'),
267
267
  onRefusal,
268
268
  });
269
- if (verbose && outcome.run) for (const text of buildReconcileReport(outcome.run.plan)) info(text);
269
+ if (outcome.run) {
270
+ if (verbose) {
271
+ for (const text of buildReconcileReport(outcome.run.plan)) info(text);
272
+ } else if (outcome.run.plan.needsBaseline.length > 0) {
273
+ // NOT gated behind --verbose. A first-contact match (an object that predates Models
274
+ // tracking) is silently SKIPPED here — the reconciler will not guess whether live
275
+ // matches source — so a catch-up that only ever prints its full report under --verbose
276
+ // reads as "nothing to do" while real brownfield objects are left permanently
277
+ // unverified. This is the exact silence a consumer's derived-layer incident traced back
278
+ // to: db:apply reported success on every deploy while these objects sat unreconciled.
279
+ warn(
280
+ `${outcome.run.plan.needsBaseline.length} derived object(s) skipped (never reconciled, so this catch-up cannot tell whether live matches source): ${outcome.run.plan.needsBaseline.join(', ')} — ` +
281
+ 'run `everystack db:reconcile --baseline` to record trust once you have verified it matches, or `--rebuild` to force it from source. Does not fail this apply.',
282
+ );
283
+ }
284
+ }
270
285
  return { ok: true, statements: outcome.statements };
271
286
  } catch (err: any) {
272
287
  const cause = String(err?.message ?? err);
@@ -64,7 +64,10 @@ import { resolveConfig, opsFunction } from '../config';
64
64
  import { resolveOperatorUrlViaStage } from '../direct-venue';
65
65
  import { lambdaQueryRunner, lambdaSessionRunner } from '../aws';
66
66
  import { loadModels } from './db-generate';
67
- import { loadDeclaredDerived } from '../declared-derived';
67
+ import { loadDeclaredDerived, type DeclaredDerived } from '../declared-derived';
68
+ import { introspectDerived } from '../derived-introspect';
69
+ import { planReconcile } from '../derived-plan';
70
+ import { checkFails, buildReconcileReport } from './db-reconcile';
68
71
  import { governedRoleSet, ungovernedGrants } from '../authz-reconcile';
69
72
  import { readBaselineFile, checkAgainstBaseline, baselineRefusal } from '../authz-baseline';
70
73
  import { alterTypeStatementTargets, findAlterTypeDependents, renderAlterTypeRefusal } from '../alter-type-dependents';
@@ -269,10 +272,13 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
269
272
  // The modules' extensions ride the same read. Without them the plan omits every
270
273
  // CREATE EXTENSION the target lacks, and the apply fails on a type the plan promised.
271
274
  let declaredExtensions: string[] = [];
275
+ // Kept for the derived-layer visibility check below — this IS the declared derived layer
276
+ // (functions/views/matviews/triggers), read once and reused rather than loaded twice.
277
+ let declaredDerived: DeclaredDerived | null = null;
272
278
  try {
273
- const declared = await loadDeclaredDerived(flags.models);
274
- declaredGovernedRoles = declared?.governedRoles ?? [];
275
- declaredExtensions = declared?.extensions ?? [];
279
+ declaredDerived = await loadDeclaredDerived(flags.models);
280
+ declaredGovernedRoles = declaredDerived?.governedRoles ?? [];
281
+ declaredExtensions = declaredDerived?.extensions ?? [];
276
282
  } catch {
277
283
  // The barrel's own compose errors surface on the paths that need the derived layer;
278
284
  // a plan must not fail to mint because a module could not be read for this one field.
@@ -456,6 +462,33 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
456
462
  }
457
463
  }
458
464
 
465
+ // DERIVED-LAYER VISIBILITY — db:apply ALWAYS reconciles functions/views/matviews/triggers
466
+ // against the models, whether or not a --plan is passed; the edge this command mints never
467
+ // covers that layer. Without this, an empty table/column/grant edge reads as "the target
468
+ // matches the checkout" even when a function body changed underneath it — an operator who
469
+ // trusts an empty plan never learns db:apply still has work to do. Read-only: the same pure
470
+ // planReconcile() that backs `db:reconcile --check`, applied to nothing here — advisory
471
+ // only, like the FK-orphan and backfill checks above, so it can go stale between mint and
472
+ // apply without corrupting the plan file (see the file header's rationale for why nothing
473
+ // read-time-only belongs in the artifact).
474
+ if (declaredDerived) {
475
+ try {
476
+ const liveDerived = await introspectDerived(session);
477
+ const derivedPlan = planReconcile(
478
+ { objects: declaredDerived.objects, warnings: [] },
479
+ liveDerived,
480
+ { renamedTables: declaredDerived.renamedTables },
481
+ );
482
+ if (checkFails(derivedPlan)) {
483
+ warn('derived layer out of sync (functions/views/matviews/triggers) — this plan does not preview these, only tables/columns/grants:');
484
+ for (const line of buildReconcileReport(derivedPlan)) warn(` ${line}`);
485
+ warn('db:apply always catches up the derived layer, --plan or not — run it to reconcile the above.');
486
+ }
487
+ } catch (err: any) {
488
+ warn(`derived-layer check could not run against this target (${err?.message ?? err}) — db:plan cannot say whether functions/views/matviews/triggers are in sync.`);
489
+ }
490
+ }
491
+
459
492
  await reportPipelineLastRun(runner);
460
493
 
461
494
  // THE DEFAULT REPORT — the only thing a reader sees when nothing is wrong.
@@ -61,6 +61,7 @@ import { invokeAction, lambdaQueryRunner, lambdaSessionRunner } from '../aws';
61
61
  import { modelContractPreflight } from '../model-wire-lane';
62
62
  import { MODEL_WIRE_CONTRACT, type ReconcileWirePayload } from '../../wire';
63
63
  import { step, success, fail, info, warn } from '../output';
64
+ import { splitIdentityList } from '../utils/identity-list';
64
65
 
65
66
  // ---------------------------------------------------------------------------
66
67
  // The testable core.
@@ -622,7 +623,7 @@ export async function dbReconcileCommand(flags: Record<string, string>): Promise
622
623
 
623
624
  // --only <a,b>: restrict the reconcile to the named identities (surgical). With --rebuild it is
624
625
  // the recovery path for a self-consistent-but-wrong provenance row (a mistaken --rebaseline).
625
- const only = flags.only ? flags.only.split(',').map((s) => s.trim()).filter(Boolean) : undefined;
626
+ const only = flags.only ? splitIdentityList(flags.only) : undefined;
626
627
  // An empty/whitespace --only would silently degrade to a FULL reconcile — refuse it, since the
627
628
  // operator clearly meant to scope the run.
628
629
  if (flags.only !== undefined && !only?.length) {
@@ -30,6 +30,7 @@ import { resolveConfig, opsFunction } from '../config';
30
30
  import { resolveOperatorUrlViaStage } from '../direct-venue';
31
31
  import { invokeAction } from '../aws';
32
32
  import { step, success, fail, info } from '../output';
33
+ import { splitIdentityList } from '../utils/identity-list';
33
34
 
34
35
  export async function dbRefreshCommand(flags: Record<string, string>): Promise<void> {
35
36
  // The declared matviews, in dependency order (a dependent matview reads its parents' fresh rows).
@@ -40,7 +41,7 @@ export async function dbRefreshCommand(flags: Record<string, string>): Promise<v
40
41
  fail(err.message);
41
42
  process.exit(1);
42
43
  }
43
- const only = flags.only ? flags.only.split(',').map((s) => s.trim()).filter(Boolean) : undefined;
44
+ const only = flags.only ? splitIdentityList(flags.only) : undefined;
44
45
  // --verify-nonempty: after refreshing, gate on populated-but-zero-rows matviews (the dark-panel
45
46
  // check) — credential-free, a scoped EXISTS on only what was just refreshed.
46
47
  const verifyNonempty = flags['verify-nonempty'] === 'true';
@@ -5,7 +5,7 @@
5
5
  * No HTTP endpoints, no shared secrets — IAM is the authorization layer.
6
6
  */
7
7
 
8
- import { resolveConfig, opsFunction } from '../config';
8
+ import { resolveConfig, opsFunction, probeUrl, type CliConfig } from '../config';
9
9
  import { invokeAction } from '../aws';
10
10
  import { step, success, fail, info, warn } from '../output';
11
11
  import { opsAdviceLines, IAM_ADVICE } from '../ops-advice';
@@ -613,6 +613,29 @@ export function parseUrlConnection(url: string): { host: string; port: string; d
613
613
  };
614
614
  }
615
615
 
616
+ /**
617
+ * Rotation's non-blocking origin check. probeUrl falls back to siteUrl when there is no
618
+ * CDN_URL, so a stage without one still gets checked here instead of silently skipping the
619
+ * check (reading config.baseUrl directly did that). Exported for testing without invoking the
620
+ * whole rotation flow.
621
+ */
622
+ export async function probeRotationOrigin(
623
+ config: Pick<CliConfig, 'baseUrl' | 'siteUrl'>,
624
+ fetchImpl: typeof fetch = fetch,
625
+ ): Promise<{ ok: boolean; message: string } | undefined> {
626
+ const url = probeUrl(config);
627
+ if (!url) return undefined;
628
+ try {
629
+ const probe = `${url}?_probe=${Date.now()}`;
630
+ const res = await fetchImpl(probe, { redirect: 'follow' });
631
+ return res.ok
632
+ ? { ok: true, message: 'Origin responding.' }
633
+ : { ok: false, message: `Origin returned ${res.status}.` };
634
+ } catch (err: any) {
635
+ return { ok: false, message: `Origin probe failed: ${err.message}` };
636
+ }
637
+ }
638
+
616
639
  /**
617
640
  * db:provision --rotate — swap the active A/B role for one or both credential slots.
618
641
  *
@@ -812,15 +835,10 @@ async function rotateCredentials(flags: Record<string, string>): Promise<void> {
812
835
  }
813
836
 
814
837
  // Origin HTTP check (non-blocking — the login probe is the real gate).
815
- if (config.baseUrl) {
816
- try {
817
- const probe = `${config.baseUrl}?_probe=${Date.now()}`;
818
- const res = await fetch(probe, { redirect: 'follow' });
819
- if (res.ok) success('Origin responding.');
820
- else warn(`Origin returned ${res.status}.`);
821
- } catch (err: any) {
822
- warn(`Origin probe failed: ${err.message}`);
823
- }
838
+ const originResult = await probeRotationOrigin(config);
839
+ if (originResult) {
840
+ if (originResult.ok) success(originResult.message);
841
+ else warn(originResult.message);
824
842
  }
825
843
 
826
844
  // Retire old roles and drain sessions — using the roles recorded at
@@ -129,13 +129,16 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
129
129
  warn(`Could not load SST secrets: ${err.message}`);
130
130
  info('Secrets may be unavailable during export. Provide env vars from the caller if needed.');
131
131
  }
132
+ }
132
133
 
133
- // After the secrets step: a secret may supply HOST_URL.
134
- const noHostUrl = await missingHostUrlError(flags.stage, { siteUrl: resolvedConfig?.siteUrl });
135
- if (noHostUrl) {
136
- fail(noHostUrl);
137
- process.exit(1);
138
- }
134
+ // Runs whether or not --stage was given: the harm (baking the app's default URL into a
135
+ // published channel's bundle) is the same either way, and the documented day-to-day flow is
136
+ // `update --channel production` with no --stage at all. A secret loaded above may supply
137
+ // HOST_URL, which is why this runs after that step rather than before it.
138
+ const noHostUrl = await missingHostUrlError(flags.stage, { siteUrl: resolvedConfig?.siteUrl });
139
+ if (noHostUrl) {
140
+ fail(noHostUrl);
141
+ process.exit(1);
139
142
  }
140
143
 
141
144
  const branch = flags.branch;
@@ -11,6 +11,8 @@
11
11
  * 3. Comment contains the {app}-{stage}- prefix (legacy setups; a substring, so a last resort)
12
12
  */
13
13
 
14
+ import { warn } from './output';
15
+
14
16
  export interface DistributionHints {
15
17
  /** Host of the discovered router URL, e.g. d111abc.cloudfront.net or a custom domain. */
16
18
  routerHost?: string;
@@ -112,6 +114,7 @@ export async function discoverDistributionMatch(
112
114
  // Tag pass — authoritative, so it runs before the Comment guess (a Comment substring can name
113
115
  // another app's stage). One call per distribution; SST tags every distribution it makes.
114
116
  if (hints.appName && hints.stage) {
117
+ let warnedOnTagReadFailure = false;
115
118
  for (const dist of items) {
116
119
  if (!dist.ARN) continue;
117
120
  try {
@@ -123,7 +126,13 @@ export async function discoverDistributionMatch(
123
126
  return toMatch(dist, 'tag');
124
127
  }
125
128
  } catch {
126
- // Tag read denied or transient — keep scanning
129
+ // Tag read denied or transient — keep scanning, but say so once. Silent here means
130
+ // siteUrl quietly degrades to empty and distributionId falls to the Comment guess,
131
+ // with nothing in the output naming why.
132
+ if (!warnedOnTagReadFailure) {
133
+ warnedOnTagReadFailure = true;
134
+ warn('cloudfront:ListTagsForResource failed during distribution discovery — falling back to Comment matching, which is a substring guess.');
135
+ }
127
136
  }
128
137
  }
129
138
  }
@@ -79,11 +79,45 @@ export async function parseAppName(configPath?: string): Promise<string> {
79
79
  return match[1];
80
80
  }
81
81
 
82
+ /**
83
+ * The prefix SST's `prefixName` produces for a resource named `resourceName` (e.g.
84
+ * "ApiFunction"), given the budget `physicalName(maxLen, name)` leaves after its trailing
85
+ * "-<8-char hash>" (maxLen - 9). Ports the SST platform's naming.ts prefixName exactly
86
+ * (see apps/example/.sst/platform/src/components/naming.ts): the STAGE is never truncated,
87
+ * the APP segment is cut to fit once app+stage+name would overflow the budget, and dropped
88
+ * entirely if even stage+name does.
89
+ * A naive "<app>-<stage>-<resourceName>" false-negatives on long app/stage names because it
90
+ * skips this truncation.
91
+ *
92
+ * `maxLen` is the physicalName length ceiling for the AWS resource TYPE being matched — it is
93
+ * NOT the same for every resource. SST's own naming table (component.ts namingRules) pins:
94
+ * Lambda functions and CloudFront functions at 64 (`aws:lambda/function:Function`,
95
+ * `aws:cloudfront/function:Function`), S3 buckets at 63 (`aws:s3/bucket:Bucket`, one character
96
+ * tighter). Reusing the Lambda budget for a bucket needle silently mis-truncates the app segment
97
+ * by one character once truncation actually kicks in.
98
+ */
99
+ function sstPhysicalNamePrefix(
100
+ appName: string,
101
+ stage: string,
102
+ resourceName: string,
103
+ maxLen: number,
104
+ ): string {
105
+ const max = maxLen - 9;
106
+ const nameLen = resourceName.length;
107
+ const stageLen = stage.length;
108
+ if (nameLen + 1 >= max) return resourceName.substring(0, max);
109
+ if (nameLen + stageLen + 2 >= max) {
110
+ return `${stage.substring(0, max - nameLen - 1)}-${resourceName}`;
111
+ }
112
+ return `${appName.substring(0, max - stageLen - nameLen - 2)}-${stage}-${resourceName}`;
113
+ }
114
+
82
115
  /**
83
116
  * Does a deploy-written .sst/outputs.json describe this stage? The file is the last deploy on ANY
84
117
  * stage and has no stage field, but its apiFunctionName is `<app>-<stage>-ApiFunction-<hash>`
85
- * (the convention discovery matches on). `.sst/stage` is NOT evidence: it is SST's personal-stage
86
- * marker and can name a different stage than the last deploy. Unknown means no.
118
+ * (the convention discovery matches on) — with the app segment possibly truncated by SST's
119
+ * prefixName (see sstPhysicalNamePrefix). `.sst/stage` is NOT evidence: it is SST's
120
+ * personal-stage marker and can name a different stage than the last deploy. Unknown means no.
87
121
  */
88
122
  export function outputsDescribeStage(
89
123
  outputs: { apiFunctionName?: string } | null | undefined,
@@ -92,7 +126,8 @@ export function outputsDescribeStage(
92
126
  ): boolean {
93
127
  const fn = outputs?.apiFunctionName;
94
128
  if (!fn) return false;
95
- return fn.toLowerCase().startsWith(`${appName}-${stage}-ApiFunction-`.toLowerCase());
129
+ const prefix = sstPhysicalNamePrefix(appName, stage, 'ApiFunction', 64);
130
+ return fn.toLowerCase().startsWith(`${prefix}-`.toLowerCase());
96
131
  }
97
132
 
98
133
  // ---------------------------------------------------------------------------
@@ -110,7 +145,8 @@ interface CfFunctionResult {
110
145
  */
111
146
  export async function discoverCfFunction(
112
147
  region: string,
113
- prefix: string,
148
+ appName: string,
149
+ stage: string,
114
150
  ): Promise<CfFunctionResult> {
115
151
  const {
116
152
  CloudFrontClient,
@@ -119,7 +155,9 @@ export async function discoverCfFunction(
119
155
  } = await import('@aws-sdk/client-cloudfront');
120
156
 
121
157
  const client = new CloudFrontClient({ region });
122
- const needle = `${prefix}RouterCloudfrontFunctionRequest-`.toLowerCase();
158
+ // CloudFront functions share Lambda's 64-char physicalName ceiling (naming.ts namingRules).
159
+ const prefix = sstPhysicalNamePrefix(appName, stage, 'RouterCloudfrontFunctionRequest', 64);
160
+ const needle = `${prefix}-`.toLowerCase();
123
161
 
124
162
  let marker: string | undefined;
125
163
  let matchName: string | undefined;
@@ -142,7 +180,7 @@ export async function discoverCfFunction(
142
180
 
143
181
  if (!matchName) {
144
182
  throw new Error(
145
- `No CloudFront function found matching "${prefix}RouterCloudfrontFunctionRequest-*". ` +
183
+ `No CloudFront function found matching "${prefix}-*". ` +
146
184
  `Check that the stage has been deployed.`,
147
185
  );
148
186
  }
@@ -168,12 +206,14 @@ interface LambdaResult {
168
206
  }
169
207
 
170
208
  /**
171
- * Find a Lambda function by SST resource name prefix.
209
+ * Find a Lambda function by SST resource name, truncating the app segment the way SST's
210
+ * physicalName does (see sstPhysicalNamePrefix) so a long app name still matches.
172
211
  * Returns the function name if found, undefined if not.
173
212
  */
174
213
  export async function discoverFunction(
175
214
  region: string,
176
- prefix: string,
215
+ appName: string,
216
+ stage: string,
177
217
  resourceName: string,
178
218
  ): Promise<string | undefined> {
179
219
  const { LambdaClient, ListFunctionsCommand } = await importOptionalAws(
@@ -181,7 +221,7 @@ export async function discoverFunction(
181
221
  );
182
222
 
183
223
  const client = new LambdaClient({ region });
184
- const needle = `${prefix}${resourceName}`.toLowerCase();
224
+ const needle = sstPhysicalNamePrefix(appName, stage, resourceName, 64).toLowerCase();
185
225
 
186
226
  let marker: string | undefined;
187
227
 
@@ -207,13 +247,15 @@ export async function discoverFunction(
207
247
  */
208
248
  export async function discoverLambda(
209
249
  region: string,
210
- prefix: string,
250
+ appName: string,
251
+ stage: string,
211
252
  ): Promise<LambdaResult> {
212
- const matchName = await discoverFunction(region, prefix, 'ApiFunction');
253
+ const matchName = await discoverFunction(region, appName, stage, 'ApiFunction');
213
254
 
214
255
  if (!matchName) {
256
+ const prefix = sstPhysicalNamePrefix(appName, stage, 'ApiFunction', 64);
215
257
  throw new Error(
216
- `No API Lambda found for prefix "${prefix}ApiFunction-*". ` +
258
+ `No API Lambda found for prefix "${prefix}-*". ` +
217
259
  `Check that the stage has been deployed and you have lambda:ListFunctions permission.`,
218
260
  );
219
261
  }
@@ -251,7 +293,8 @@ interface BucketsResult {
251
293
  */
252
294
  export async function discoverBuckets(
253
295
  region: string,
254
- prefix: string,
296
+ appName: string,
297
+ stage: string,
255
298
  ): Promise<BucketsResult> {
256
299
  const { S3Client, ListBucketsCommand } = await importOptionalAws(
257
300
  () => import('@aws-sdk/client-s3'), '@aws-sdk/client-s3', 'resource discovery',
@@ -261,15 +304,20 @@ export async function discoverBuckets(
261
304
  const res = await client.send(new ListBucketsCommand({}));
262
305
  const buckets = res.Buckets || [];
263
306
 
264
- const updatesNeedle = `${prefix}updatesbucket-`.toLowerCase();
265
- const clientNeedle = `${prefix}clientbundlesbucket-`.toLowerCase();
307
+ // S3 buckets physicalName-cap at 63 chars (naming.ts namingRules: "aws:s3/bucket:Bucket"),
308
+ // one tighter than Lambda/CloudFront functions' 64 — reusing the function budget here would
309
+ // mis-truncate the app segment by a character once truncation actually kicks in.
310
+ const bucketPrefix = (resourceName: string) =>
311
+ sstPhysicalNamePrefix(appName, stage, resourceName, 63);
312
+ const updatesNeedle = `${bucketPrefix('UpdatesBucket')}-`.toLowerCase();
313
+ const clientNeedle = `${bucketPrefix('ClientBundlesBucket')}-`.toLowerCase();
266
314
  // Media bucket resource is named 'Media' (not 'MediaBucket'), so match both patterns
267
- const mediaNeedle = `${prefix}media-`.toLowerCase();
268
- const mediaBucketNeedle = `${prefix}mediabucket-`.toLowerCase();
315
+ const mediaNeedle = `${bucketPrefix('Media')}-`.toLowerCase();
316
+ const mediaBucketNeedle = `${bucketPrefix('MediaBucket')}-`.toLowerCase();
269
317
  // Backups bucket (db:backup/restore/backup:download). Discovered like every other bucket, so the
270
318
  // --stage path carries it — db:backup:download presigns CLIENT-side and needs the name locally
271
319
  // (db:backup/backups run server-side in the ops Lambda, which is why only download failed).
272
- const backupsNeedle = `${prefix}backupsbucket-`.toLowerCase();
320
+ const backupsNeedle = `${bucketPrefix('BackupsBucket')}-`.toLowerCase();
273
321
 
274
322
  let updatesBucket: string | undefined;
275
323
  let clientBundlesBucket: string | undefined;
@@ -301,17 +349,19 @@ export async function discoverConfig(
301
349
  stage: string,
302
350
  region: string,
303
351
  ): Promise<DiscoveredConfig> {
352
+ // Naive, untruncated — only for discoverDistributionMatch (Comment/tag matching, not the
353
+ // physicalName convention the other discover* calls below now truncate correctly for).
304
354
  const prefix = `${appName}-${stage}-`;
305
355
 
306
356
  // Run CF function + Lambda + S3 discovery in parallel
307
357
  const [cfResult, lambdaResult, bucketsResult, opsFn, imageFn, workerFn] = await Promise.all([
308
- discoverCfFunction(region, prefix),
309
- discoverLambda(region, prefix),
310
- discoverBuckets(region, prefix),
311
- discoverFunction(region, prefix, 'OpsFunction').catch(() => undefined),
312
- discoverFunction(region, prefix, 'ImageFunction').catch(() => undefined),
313
- discoverFunction(region, prefix, 'WorkerFunction')
314
- .then(fn => fn || discoverFunction(region, prefix, 'JobsSubscriber'))
358
+ discoverCfFunction(region, appName, stage),
359
+ discoverLambda(region, appName, stage),
360
+ discoverBuckets(region, appName, stage),
361
+ discoverFunction(region, appName, stage, 'OpsFunction').catch(() => undefined),
362
+ discoverFunction(region, appName, stage, 'ImageFunction').catch(() => undefined),
363
+ discoverFunction(region, appName, stage, 'WorkerFunction')
364
+ .then(fn => fn || discoverFunction(region, appName, stage, 'JobsSubscriber'))
315
365
  .catch(() => undefined),
316
366
  ]);
317
367
 
@@ -359,15 +409,17 @@ export async function discoverConfig(
359
409
  : '';
360
410
 
361
411
  if (!bucketsResult.updatesBucket) {
412
+ const bucketPrefix = sstPhysicalNamePrefix(appName, stage, 'UpdatesBucket', 63);
362
413
  throw new Error(
363
- `Could not find S3 bucket "${prefix}updatesbucket-*". ` +
414
+ `Could not find S3 bucket "${bucketPrefix}-*". ` +
364
415
  `Check that stage "${stage}" has been deployed and you have s3:ListAllMyBuckets permission.`,
365
416
  );
366
417
  }
367
418
 
368
419
  if (!bucketsResult.clientBundlesBucket) {
420
+ const bucketPrefix = sstPhysicalNamePrefix(appName, stage, 'ClientBundlesBucket', 63);
369
421
  throw new Error(
370
- `Could not find S3 bucket "${prefix}clientbundlesbucket-*". ` +
422
+ `Could not find S3 bucket "${bucketPrefix}-*". ` +
371
423
  `Check that stage "${stage}" has been deployed.`,
372
424
  );
373
425
  }
@@ -106,17 +106,30 @@ async function dotenvSetsHostUrl(
106
106
  }
107
107
 
108
108
  /**
109
- * Why `update --stage` must stop: nothing supplied a HOST_URL, so the OTA bundle would bake the
110
- * app's default (often localhost) as updates.url. Devices could then never fetch the recovery
111
- * publish. Undefined when HOST_URL is set (env or dotenv) or no stage was given. The message
112
- * names the ways out, including the stage's own site URL when discovery found one.
109
+ * Why `update` must stop: nothing supplied a HOST_URL, so the OTA bundle would bake the app's
110
+ * default (often localhost) as updates.url. Devices could then never fetch the recovery publish.
111
+ * Undefined when HOST_URL is set (env or dotenv). Applies with OR without --stage — the
112
+ * documented day-to-day flow (getting-started.md) is `update --channel production` with no
113
+ * --stage at all, so a missing HOST_URL there is the SAME harm, not a smaller one: whatever
114
+ * bakes into that channel's bundle ships to real devices just the same. Without a stage there is
115
+ * no auto-discovery to fall back on (no --stage means no CDN_URL lookup, no site URL to offer),
116
+ * so the message says so plainly instead of naming a stage that was never given.
113
117
  */
114
118
  export async function missingHostUrlError(
115
119
  stage: string | undefined,
116
120
  opts: { env?: Record<string, string | undefined>; cwd?: string; siteUrl?: string; resolveFrom?: string } = {},
117
121
  ): Promise<string | undefined> {
118
122
  const { env = process.env, cwd = process.cwd(), siteUrl, resolveFrom = cwd } = opts;
119
- if (!stage || env.HOST_URL || (await dotenvSetsHostUrl(cwd, env, resolveFrom))) return undefined;
123
+ if (env.HOST_URL || (await dotenvSetsHostUrl(cwd, env, resolveFrom))) return undefined;
124
+ if (!stage) {
125
+ return (
126
+ 'No HOST_URL is set (env, dotenv, or a stage\'s .sst/outputs.json), so the bundle would ship ' +
127
+ 'the app\'s default URL as updates.url. Export HOST_URL=<the public URL devices should fetch ' +
128
+ 'updates from>, or re-run with --stage <stage> to discover it from the deployed API function\'s ' +
129
+ 'CDN_URL. (A HOST_URL in a dotenv file counts only when the project\'s Expo can be found to ' +
130
+ 'read it the way `expo export` does.)'
131
+ );
132
+ }
120
133
  const offer = siteUrl ? ` This stage's site URL is ${siteUrl}; export it as HOST_URL if that is its public URL.` : '';
121
134
  return (
122
135
  `No HOST_URL for stage "${stage}", so the bundle would ship the app's default URL as updates.url.${offer} ` +
package/src/cli/index.ts CHANGED
@@ -331,8 +331,8 @@ Usage:
331
331
  everystack db:refresh [--stage <name> | --database-url <url> | --direct] [--only a,b] [--verify-nonempty] [--list] Refresh the declared materialized views in dependency order, credential-free. --stage runs the whole refresh in the ops Lambda on the operator connection (no URL on the operator's machine — the data-lane twin of the reconcile lane); --database-url refreshes over a local connection (dev); --stage <name> --direct resolves the stage's operator connection from its ops Lambda (credential never on argv) and refreshes CLI-side on an UNBOUNDED clock, for a matview set that exceeds the ops-Lambda 900s limit; --only refreshes a named subset (full identity or bare name); --verify-nonempty gates on populated-but-zero-rows matviews (the dark-panel check — a scoped EXISTS on just what was refreshed, so a promotion script gets the gate with no read authority) and FAILS naming any empty; --list previews the order without connecting. Plain REFRESH (ACCESS EXCLUSIVE); fail-fast, idempotent to re-run.
332
332
  everystack db:sync [--database-url <url>] [--models <barrel>] [--schema-out <file.ts>] [--allow-drops] [--overwrite-drift] [--baseline] [--json] Make the database match your checkout — one verb, both layers: apply the state diff (tables+authz, one transaction, verified by re-diff), reconcile the derived layer against the declared descriptors, report the resulting fingerprint vs the models' declared one. Dev databases only (direct connection required); DROPs held back unless --allow-drops; derived drift refuses unless --overwrite-drift; exit 1 when not converged
333
333
  everystack db:diff --from-models <barrel> [--to-models db/models/index.ts] [--allow-drops] [--check] [--json] The state edge between two declared states, NO database: the SQL db:generate would produce, computed purely — CI plan previews (--check exits 1 on a non-empty edge) and computed rollbacks (swap the flags)
334
- everystack db:plan [--stage <name> [--direct] | --database-url <url>] [--models <barrel>] [--allow-drops] [--out db.plan.json | --out -] [--verbose] Mint a verified edge against a target: asks the TARGET its fingerprint, diffs the models, writes ONE reviewable plan (edge + both endpoint fingerprints). Held drops refuse the mint (--allow-drops carries destruction explicitly). Read-only; plans are ephemeral, never committed. VENUES: --stage runs via the ops Lambda, which reads TWICE and refuses on disagreement — agreement there is a DETECTOR, not a verification. --stage --direct resolves the stage's operator connection from its IAM-gated ops Lambda, holds it in memory only, and reads ONCE over one session: the lane for a fingerprint you intend to trust, with the credential never on argv. --database-url is the local-dev venue (same read guarantee, but against a deployed stage it puts a privileged DSN on the command line) DEFAULT OUTPUT is the plain change list: '+' something is now true, '-' something is no longer true, and a '⚠ DESTRUCTIVE' line of its own when real data can be lost. Nothing else. --verbose restores the full internal report (edge summary, kind histogram, adoption and ownership reports, plan ref)
335
- everystack db:apply [--plan <file.plan.json>] [--database-url <url>] [--stage <name>] [--models <barrel>] [--confirm] [--state-only] [--snapshot-ref <ref>] [--attest-lineage-lost <sha>] [--verbose] THE deploy verb — it does the WHOLE job: the reviewed plan (tables, columns, grants), then ALWAYS the functions, views, matviews and triggers, against the same target, reported as one receipt. With NO --plan it does the catch-up alone (this is what replaces running db:reconcile --apply by hand). --state-only skips the catch-up for the narrow case that wants the tables alone. If the plan lands and the catch-up then fails, the exit is non-zero and the message says the table/column changes are committed and safe and gives the rerun, which retries only the catch-up. Run a reviewed plan: verify the target is EXACTLY where the plan started (live fingerprint == plan.from, else refuse — the concurrency lock), verify the checkout DESCENDS from the commit declaring the target's state (the fast-forward rule — it protects MERGED work: a commit on any ref that declares the live state and is not an ancestor of HEAD refuses by name, escaped git-natively by fetch + merge/rebase + re-plan; --attest-lineage-lost <sha> waives it ONLY for a declaring commit proven gone from every ref. When NO commit on ANY ref declares the live state — a database everystack never wrote, or one hand-edited since its last apply — the rule has nothing to protect: over a full, current clone that is PROVEN DRIFT, the reviewed plan is the ORIGIN, it applies with a loud warning, and the origin is recorded in schema_log so every later apply descends from it. A shallow, partial, or behind-remote clone, or a models tree that will not compile, is UNVERIFIABLE and refuses with a fetch/prune runbook), and for DESTRUCTIVE plans require --confirm always + a confirmed rollback point + the stage's approver set when declared (STS identity-verified). The STAGE lane (--stage without --direct) runs every catalog query in its own ops-Lambda invoke, so one read can be assembled from several containers: it reads the target TWICE and REFUSES when the two disagree (inconsistent containers), reports agreement as a NON-DETECTION (it cannot verify read consistency), and REFUSES a DESTRUCTIVE plan outright — destructive applies go over --database-url (direct) with the full ceremony. Every refusal that reaches the ops Lambda is recorded in schema_log. Apply as one transaction (plan_ref stamped), verify it landed exactly on plan.to; idempotent when already there DEFAULT OUTPUT is the receipt: the same change lines back, then 'Applied N changes.'. --verbose restores the endpoint and plan-ref line
334
+ everystack db:plan [--stage <name> [--direct] | --database-url <url>] [--models <barrel>] [--allow-drops] [--out db.plan.json | --out -] [--verbose] Mint a verified edge against a target: asks the TARGET its fingerprint, diffs the models, writes ONE reviewable plan (edge + both endpoint fingerprints). Held drops refuse the mint (--allow-drops carries destruction explicitly). Read-only; plans are ephemeral, never committed. VENUES: --stage runs via the ops Lambda, which reads TWICE and refuses on disagreement — agreement there is a DETECTOR, not a verification. --stage --direct resolves the stage's operator connection from its IAM-gated ops Lambda, holds it in memory only, and reads ONCE over one session: the lane for a fingerprint you intend to trust, with the credential never on argv. --database-url is the local-dev venue (same read guarantee, but against a deployed stage it puts a privileged DSN on the command line) DEFAULT OUTPUT is the plain change list: '+' something is now true, '-' something is no longer true, and a '⚠ DESTRUCTIVE' line of its own when real data can be lost. Nothing else. --verbose restores the full internal report (edge summary, kind histogram, adoption and ownership reports, plan ref). The edge covers tables/columns/grants ONLY — it also reads (never mints) whether the DERIVED layer (functions/views/matviews/triggers) is out of sync with the target and warns if so, since db:apply always reconciles that layer regardless of --plan: an empty edge here does not mean the target fully matches the checkout
335
+ everystack db:apply [--plan <file.plan.json>] [--database-url <url>] [--stage <name>] [--models <barrel>] [--confirm] [--state-only] [--snapshot-ref <ref>] [--attest-lineage-lost <sha>] [--verbose] THE deploy verb — it does the WHOLE job: the reviewed plan (tables, columns, grants), then ALWAYS the functions, views, matviews and triggers, against the same target, reported as one receipt. With NO --plan it does the catch-up alone (this is what replaces running db:reconcile --apply by hand). --state-only skips the catch-up for the narrow case that wants the tables alone. A brownfield derived object the reconciler has never touched before (no provenance row) is SKIPPED, never guessed at, and that skip prints by default (not only under --verbose): 'N derived object(s) skipped ... db:reconcile --baseline' — a plain apply that only showed this under --verbose read as full success while such objects sat permanently unreconciled. If the plan lands and the catch-up then fails, the exit is non-zero and the message says the table/column changes are committed and safe and gives the rerun, which retries only the catch-up. Run a reviewed plan: verify the target is EXACTLY where the plan started (live fingerprint == plan.from, else refuse — the concurrency lock), verify the checkout DESCENDS from the commit declaring the target's state (the fast-forward rule — it protects MERGED work: a commit on any ref that declares the live state and is not an ancestor of HEAD refuses by name, escaped git-natively by fetch + merge/rebase + re-plan; --attest-lineage-lost <sha> waives it ONLY for a declaring commit proven gone from every ref. When NO commit on ANY ref declares the live state — a database everystack never wrote, or one hand-edited since its last apply — the rule has nothing to protect: over a full, current clone that is PROVEN DRIFT, the reviewed plan is the ORIGIN, it applies with a loud warning, and the origin is recorded in schema_log so every later apply descends from it. A shallow, partial, or behind-remote clone, or a models tree that will not compile, is UNVERIFIABLE and refuses with a fetch/prune runbook), and for DESTRUCTIVE plans require --confirm always + a confirmed rollback point + the stage's approver set when declared (STS identity-verified). The STAGE lane (--stage without --direct) runs every catalog query in its own ops-Lambda invoke, so one read can be assembled from several containers: it reads the target TWICE and REFUSES when the two disagree (inconsistent containers), reports agreement as a NON-DETECTION (it cannot verify read consistency), and REFUSES a DESTRUCTIVE plan outright — destructive applies go over --database-url (direct) with the full ceremony. Every refusal that reaches the ops Lambda is recorded in schema_log. Apply as one transaction (plan_ref stamped), verify it landed exactly on plan.to; idempotent when already there DEFAULT OUTPUT is the receipt: the same change lines back, then 'Applied N changes.'. --verbose restores the endpoint and plan-ref line
336
336
  everystack db:build --database-url <url> [--models <barrel>] Build a database FROM the models and KEEP it — the fresh-database bootstrap a deleted migration folder is replaced BY. Runs the same compose db:check proves buildability with (extensions -> schemas -> contract roles -> declared functions -> state -> derived layer), against a real target, then reports MATCH. Venue is EXPLICIT: --database-url only, never the ambient environment, because this writes a whole schema. REFUSES a database that already holds objects — evolving an existing one is db:sync (dev) or db:plan -> db:apply (a stage). Contract roles are created NOLOGIN if absent, and roles are cluster-level
337
337
  everystack db:check [--models <barrel>] [--schema-out <file.ts>] [--database-url <url>] [--json] [--verbose] The CI gate, per PR: the merged declared state must COMPOSE (models load, no duplicate tables, descriptors compile), every exposed RLS-enabled table must declare a read path (no force-RLS-with-no-read landmine that goes dark on the superuser drop), and generated artifacts must MATCH regeneration byte-for-byte; with a scratch PostgreSQL it builds the state from scratch on an ephemeral database (created + dropped) and requires fingerprint MATCH. Exit 1 on any failure; never touches a real target DEFAULT OUTPUT is ONE line: 'Up to date.' or 'N changes pending. Run db:plan to review.'. --verbose restores every finding and the from-scratch build report
338
338
  everystack db:approvers --stage <name> [--set "cto,arn:..."] [--remove] Declare who can DESTROY: the stage's destructive-approver set (SSM parameter, admin-writable). Destructive db:apply runs are then identity-verified (STS) against it; --set '' disables destructive applies; --remove returns the stage to ceremony-only
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Splits a `--only a,b,c` flag value into identities.
3
+ *
4
+ * A naive `.split(',')` breaks a function identity like `auth.change_password(text,
5
+ * text)` into two garbage fragments — the argument list's own commas are not
6
+ * separators. This only treats a comma as a separator when it sits outside any
7
+ * parentheses, so `schema.fn(text, text)` survives as one identity.
8
+ */
9
+ export function splitIdentityList(input: string): string[] {
10
+ const identities: string[] = [];
11
+ let depth = 0;
12
+ let current = '';
13
+
14
+ for (const ch of input) {
15
+ if (ch === '(') depth++;
16
+ else if (ch === ')') depth = Math.max(0, depth - 1);
17
+
18
+ if (ch === ',' && depth === 0) {
19
+ identities.push(current.trim());
20
+ current = '';
21
+ continue;
22
+ }
23
+ current += ch;
24
+ }
25
+ identities.push(current.trim());
26
+
27
+ return identities.filter(Boolean);
28
+ }