@everystack/cli 0.3.28 → 0.4.0

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.
Files changed (47) hide show
  1. package/README.md +1 -1
  2. package/package.json +6 -2
  3. package/src/cli/apply-execute.ts +185 -0
  4. package/src/cli/authz-compile.ts +5 -2
  5. package/src/cli/authz-lint.ts +48 -0
  6. package/src/cli/aws.ts +102 -4
  7. package/src/cli/backfill.ts +1 -1
  8. package/src/cli/commands/db-apply.ts +159 -169
  9. package/src/cli/commands/db-backfill.ts +2 -2
  10. package/src/cli/commands/db-backup.ts +34 -0
  11. package/src/cli/commands/db-branch.ts +28 -4
  12. package/src/cli/commands/db-check.ts +97 -34
  13. package/src/cli/commands/db-fingerprint.ts +9 -4
  14. package/src/cli/commands/db-generate.ts +73 -22
  15. package/src/cli/commands/db-plan.ts +7 -13
  16. package/src/cli/commands/db-pull.ts +39 -7
  17. package/src/cli/commands/db-reconcile.ts +219 -66
  18. package/src/cli/commands/db-sync.ts +58 -21
  19. package/src/cli/commands/deploy.ts +4 -0
  20. package/src/cli/commands/pipeline-run.ts +269 -0
  21. package/src/cli/db-build.ts +9 -3
  22. package/src/cli/db-source.ts +83 -7
  23. package/src/cli/declared-derived.ts +136 -0
  24. package/src/cli/derived-apply.ts +40 -6
  25. package/src/cli/derived-compile.ts +327 -0
  26. package/src/cli/derived-grants.ts +96 -0
  27. package/src/cli/derived-introspect.ts +258 -21
  28. package/src/cli/derived-lint.ts +261 -0
  29. package/src/cli/derived-plan.ts +194 -12
  30. package/src/cli/derived-render.ts +320 -0
  31. package/src/cli/derived-source.ts +53 -125
  32. package/src/cli/discover.ts +16 -0
  33. package/src/cli/git-descent.ts +15 -2
  34. package/src/cli/index.ts +26 -6
  35. package/src/cli/migration-compile.ts +38 -7
  36. package/src/cli/migration-generate.ts +43 -4
  37. package/src/cli/model-render.ts +106 -13
  38. package/src/cli/models-path.ts +1 -1
  39. package/src/cli/ops-diagnostics.ts +71 -0
  40. package/src/cli/pipeline-loader.ts +68 -0
  41. package/src/cli/pipeline-path.ts +25 -0
  42. package/src/cli/schema-compile.ts +41 -10
  43. package/src/cli/schema-diff.ts +123 -17
  44. package/src/cli/schema-fingerprint.ts +28 -18
  45. package/src/cli/schema-introspect.ts +175 -8
  46. package/src/cli/schema-source.ts +75 -6
  47. package/src/cli/state-apply.ts +4 -1
package/README.md CHANGED
@@ -216,7 +216,7 @@ everystack db:psql --stage dev [-c SQL] # interactive psql / one-off query via L
216
216
  # Model-driven schema (@everystack/model)
217
217
  everystack db:generate # diff Models vs the live DB → next migration
218
218
  everystack db:pull # introspect a live DB → field() Models (brownfield on-ramp)
219
- everystack db:reconcile # deploy the derived layer (functions/views/matviews) from db/sql — no migrations
219
+ everystack db:reconcile # deploy the derived layer (functions/views/matviews/triggers) from the declared descriptors in db/models — no migrations
220
220
  everystack db:fingerprint # content-address the live base schema vs the Models — MATCH/MISMATCH
221
221
  everystack db:sync # make the database match your checkout — state + compute, one verb (dev DBs)
222
222
  everystack db:diff # the state edge between two declared states — no DB, CI-pure; reverse = computed rollback
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.3.28",
3
+ "version": "0.4.0",
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>",
@@ -33,6 +33,10 @@
33
33
  "types": "./src/cli/audit-api.ts",
34
34
  "default": "./src/cli/audit-api.ts"
35
35
  },
36
+ "./apply": {
37
+ "types": "./src/cli/apply-execute.ts",
38
+ "default": "./src/cli/apply-execute.ts"
39
+ },
36
40
  "./audit/source": {
37
41
  "types": "./src/cli/audit-source-api.ts",
38
42
  "default": "./src/cli/audit-source-api.ts"
@@ -78,7 +82,7 @@
78
82
  "structured-headers": "1.0.1",
79
83
  "tsx": "4.21.0",
80
84
  "typescript": "5.9.3",
81
- "@everystack/model": "0.3.6"
85
+ "@everystack/model": "0.4.0"
82
86
  },
83
87
  "peerDependencies": {
84
88
  "@everystack/server": ">=0.1.0",
@@ -0,0 +1,185 @@
1
+ /**
2
+ * apply-execute — the apply core: verify → apply → verify, as one function
3
+ * over an injected QueryRunner.
4
+ *
5
+ * This is the transactional heart of `db:apply`, lifted out of the command
6
+ * shell so it can run in either place the write must live:
7
+ * - the CLI, over a direct `--database-url` connection (local dev DBs), and
8
+ * - the ops Lambda, over its resource-linked operator connection (a deployed
9
+ * stage, reached credential-free via IAM — the operator never holds a URL).
10
+ *
11
+ * The three moves, in order, none skippable:
12
+ * 1. VERIFY the target is exactly where the plan started — live
13
+ * fingerprint == plan.from, or REFUSE. This is the concurrency lock:
14
+ * two applies racing the same stage serialize on it (the second's
15
+ * parent no longer matches; it re-mints against the new reality). A
16
+ * hand-edited target refuses the same way. A target already at
17
+ * plan.to is done — re-apply is idempotent, not an error.
18
+ * 2. APPLY the edge as one transaction, recorded in schema_log with the
19
+ * plan's content address (`plan_ref`) and both fingerprints.
20
+ * 3. VERIFY the target landed exactly on plan.to. The plan is
21
+ * self-contained — no models needed at apply time — and the endpoint
22
+ * is exact even on brownfield targets (mint predicted it with the
23
+ * unmodeled tables merged in). A mismatch here is a real fault, loud.
24
+ *
25
+ * Pure orchestration: the connection, the git-descent verdict, and the
26
+ * destructive ceremony (authority, snapshot) are all injected. The checkout
27
+ * and the caller's identity live outside the Lambda, so descent and authority
28
+ * are computed by the caller and passed in as verdicts; the write, the lock,
29
+ * and the memoir stay here, atomic with the connection.
30
+ */
31
+
32
+ import { introspectContract, type QueryRunner, type AuthzContract } from './authz-contract.js';
33
+ import { introspectSchema, type SchemaSnapshot } from './schema-introspect.js';
34
+ import { FUNCTIONS_SQL, contractFunctionRow } from './security-catalog.js';
35
+ import { fingerprintLive } from './schema-fingerprint.js';
36
+ import { applyGeneratedStatements } from './state-apply.js';
37
+ import { ENSURE_RECONCILER_SQL, renderSchemaLogInsert, renderSchemaLogFingerprintUpdate } from './derived-apply.js';
38
+ import { checkPlanPrecondition, planHash, type EdgePlan } from './edge-plan.js';
39
+
40
+ export interface ApplyPlanOptions {
41
+ actor?: string | null;
42
+ /** Defaults to the plan's own gitRef (the intent that minted it). */
43
+ gitRef?: string | null;
44
+ /**
45
+ * The fast-forward rule (brick 7): called with the target's live state
46
+ * after the concurrency lock passes, BEFORE anything executes. Return
47
+ * `ok: false` to refuse — the checkout does not descend from the commit
48
+ * declaring the target's state. Omitted = no descent enforcement (tests,
49
+ * or an explicit, confirmed, snapshotted force).
50
+ */
51
+ verifyDescent?: (live: {
52
+ snapshot: SchemaSnapshot;
53
+ contract: AuthzContract;
54
+ fingerprint: string;
55
+ }) => Promise<{ ok: true } | { ok: false; reason: string }>;
56
+ /**
57
+ * Destructive authority (brick 9, decisions 11+12): called only when the
58
+ * plan is destructive, after the lock and descent checks pass. Return
59
+ * `ok: false` to refuse — the caller is not in the stage's declared
60
+ * approver set. Omitted = no authority gate (non-staged targets keep the
61
+ * ceremony the command shell enforces).
62
+ */
63
+ verifyAuthority?: () => Promise<{ ok: true } | { ok: false; reason: string }>;
64
+ /**
65
+ * Runs after every verification passes and BEFORE the edge executes, only
66
+ * for destructive plans — the auto-snapshot seam ("losing data should be
67
+ * hard": confirmed, approved, snapshotted, in that order). A throw here
68
+ * aborts the apply with nothing executed.
69
+ */
70
+ beforeDestructive?: () => Promise<void>;
71
+ /** Injectable clock for tests. */
72
+ now?: () => number;
73
+ }
74
+
75
+ export interface ApplyPlanResult {
76
+ status: 'applied' | 'already-applied' | 'refused' | 'descent-refused' | 'authority-refused' | 'verify-failed';
77
+ liveFingerprint: string;
78
+ reason?: string;
79
+ logId?: number;
80
+ }
81
+
82
+ /**
83
+ * Decision 7, made whole: every database records every refusal, not just
84
+ * every apply. Best-effort by construction — a memoir INSERT that cannot be
85
+ * written must never mask the refusal itself (a target that rejects the
86
+ * bookkeeping write would have failed the apply's ENSURE identically).
87
+ */
88
+ async function recordRefusal(
89
+ runner: QueryRunner,
90
+ plan: EdgePlan,
91
+ liveFingerprint: string,
92
+ gate: string,
93
+ reason: string,
94
+ options: ApplyPlanOptions,
95
+ ): Promise<void> {
96
+ try {
97
+ await runner(ENSURE_RECONCILER_SQL.join(';\n'));
98
+ await runner(renderSchemaLogInsert({
99
+ kind: 'state refusal',
100
+ outcome: 'refused',
101
+ actor: options.actor ?? null,
102
+ gitRef: options.gitRef ?? plan.gitRef,
103
+ planRef: planHash(plan),
104
+ fromFingerprint: liveFingerprint,
105
+ toFingerprint: plan.toFingerprint,
106
+ sql: `-- REFUSED (${gate}): ${reason}\n${plan.statements.join('\n;\n')}`,
107
+ }));
108
+ } catch {
109
+ // Swallowed deliberately — see the docblock.
110
+ }
111
+ }
112
+
113
+ /** Verify → apply → verify. Pure orchestration over an injected QueryRunner. */
114
+ export async function executeApplyPlan(
115
+ runner: QueryRunner,
116
+ plan: EdgePlan,
117
+ options: ApplyPlanOptions = {},
118
+ ): Promise<ApplyPlanResult> {
119
+ const before = await introspectSchema(runner);
120
+ const beforeAuthz = await introspectContract(runner, contractFunctionRow, FUNCTIONS_SQL);
121
+ const live = fingerprintLive(before, beforeAuthz).hash;
122
+
123
+ if (live === plan.toFingerprint) {
124
+ return { status: 'already-applied', liveFingerprint: live };
125
+ }
126
+ const pre = checkPlanPrecondition(plan, live);
127
+ if (!pre.ok) {
128
+ await recordRefusal(runner, plan, live, 'concurrency lock', pre.reason, options);
129
+ return { status: 'refused', liveFingerprint: live, reason: pre.reason };
130
+ }
131
+
132
+ if (options.verifyDescent) {
133
+ const descent = await options.verifyDescent({ snapshot: before, contract: beforeAuthz, fingerprint: live });
134
+ if (!descent.ok) {
135
+ await recordRefusal(runner, plan, live, 'fast-forward rule', descent.reason, options);
136
+ return { status: 'descent-refused', liveFingerprint: live, reason: descent.reason };
137
+ }
138
+ }
139
+
140
+ if (plan.destructive > 0 && options.verifyAuthority) {
141
+ const authority = await options.verifyAuthority();
142
+ if (!authority.ok) {
143
+ await recordRefusal(runner, plan, live, 'destructive authority', authority.reason, options);
144
+ return { status: 'authority-refused', liveFingerprint: live, reason: authority.reason };
145
+ }
146
+ }
147
+
148
+ if (plan.destructive > 0 && options.beforeDestructive) {
149
+ await options.beforeDestructive();
150
+ }
151
+
152
+ const result = await applyGeneratedStatements(runner, plan.statements, {
153
+ actor: options.actor,
154
+ gitRef: options.gitRef ?? plan.gitRef,
155
+ planRef: planHash(plan),
156
+ fromFingerprint: live,
157
+ now: options.now,
158
+ });
159
+
160
+ const after = await introspectSchema(runner);
161
+ const afterAuthz = await introspectContract(runner, contractFunctionRow, FUNCTIONS_SQL);
162
+ const landed = fingerprintLive(after, afterAuthz).hash;
163
+ if (result.logId !== undefined) {
164
+ await runner(renderSchemaLogFingerprintUpdate(result.logId, landed));
165
+ }
166
+
167
+ if (landed !== plan.toFingerprint) {
168
+ return {
169
+ status: 'verify-failed',
170
+ liveFingerprint: landed,
171
+ reason: `applied, but the target landed on ${landed.slice(0, 12)} — the plan predicted ${plan.toFingerprint.slice(0, 12)}. Investigate before touching this database again (db:fingerprint, db:generate).`,
172
+ ...(result.logId !== undefined ? { logId: result.logId } : {}),
173
+ };
174
+ }
175
+ return {
176
+ status: 'applied',
177
+ liveFingerprint: landed,
178
+ ...(result.logId !== undefined ? { logId: result.logId } : {}),
179
+ };
180
+ }
181
+
182
+ // Re-exported so a single import (`@everystack/cli/apply`) gives a consumer —
183
+ // notably the ops Lambda's db:apply action — everything it needs to validate
184
+ // and run a plan without reaching into the CLI's internal module layout.
185
+ export { PLAN_VERSION, planHash, checkPlanPrecondition, type EdgePlan } from './edge-plan.js';
@@ -308,8 +308,10 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
308
308
  /** The four DML privileges, sorted — the expansion of a `manage` ability. */
309
309
  const CRUD = ['DELETE', 'INSERT', 'SELECT', 'UPDATE'];
310
310
 
311
- /** The SQL privilege a single CRUD verb grants. `manage` expands to all of CRUD. */
312
- const VERB: Record<Exclude<Ability['action'], 'manage'>, string> = {
311
+ /** The SQL privilege a single CRUD verb grants. `manage` expands to all of CRUD.
312
+ * `execute` is a FUNCTION ability — defineModel rejects it on tables, so it never
313
+ * reaches the table grant compiler (the throw below keeps that contract loud). */
314
+ const VERB: Record<Exclude<Ability['action'], 'manage' | 'execute'>, string> = {
313
315
  read: 'SELECT',
314
316
  create: 'INSERT',
315
317
  update: 'UPDATE',
@@ -318,6 +320,7 @@ const VERB: Record<Exclude<Ability['action'], 'manage'>, string> = {
318
320
 
319
321
  /** The privileges an ability's action grants (a single verb, or all of CRUD for `manage`). */
320
322
  function verbsFor(action: Ability['action']): string[] {
323
+ if (action === 'execute') throw new Error(`can('execute') is a function ability — it has no table privilege (defineModel rejects it; this is unreachable).`);
321
324
  return action === 'manage' ? [...CRUD] : [VERB[action]];
322
325
  }
323
326
 
@@ -0,0 +1,48 @@
1
+ /**
2
+ * authz-lint — the "force-RLS with no read authz" gate.
3
+ *
4
+ * Every modeled table gets RLS enabled (`compileTableContract` emits `rls.enabled: true`), and the
5
+ * model is default-deny: no matching `can()` means no policy. So an EXPOSED table that declares no
6
+ * read ability is a superuser-drop landmine — it reads fine while a bypassing role (a superuser
7
+ * api) is in front, then returns empty for every app role the instant that role becomes
8
+ * RLS-subject. This is exactly how ~200 tables shipped RLS with zero policies/grants and would have
9
+ * gone dark on the credential split. The gate catches it statically, over the models, at CI time —
10
+ * long before the RLS is applied and superuser is dropped.
11
+ */
12
+
13
+ import type { ModelDescriptor } from '@everystack/model';
14
+
15
+ export interface ReadAuthzGap {
16
+ schema: string;
17
+ table: string;
18
+ message: string;
19
+ }
20
+
21
+ /** A model has a read path if it declares any `read` ability (public/owner/role/column) or `manage`
22
+ * (which subsumes read, e.g. admin). Anything less means no app role can read the table. */
23
+ function hasReadPath(model: ModelDescriptor): boolean {
24
+ return model.abilities.some((a) => a.action === 'read' || a.action === 'manage');
25
+ }
26
+
27
+ /**
28
+ * Find the exposed, RLS-enabled tables that declare no read ability — the tables that will read
29
+ * empty for every app role once it is RLS-subject. Private (non-exposed) tables are skipped: they
30
+ * are operational, not part of the data API, and are meant to be unreachable by app roles.
31
+ */
32
+ export function findReadAuthzGaps(models: readonly ModelDescriptor[]): ReadAuthzGap[] {
33
+ const gaps: ReadAuthzGap[] = [];
34
+ for (const m of models) {
35
+ if (m.private) continue;
36
+ if (hasReadPath(m)) continue;
37
+ gaps.push({
38
+ schema: m.schema,
39
+ table: m.table,
40
+ message:
41
+ `${m.schema}.${m.table} is exposed and RLS-enabled but declares no read ability — every app role ` +
42
+ `reads empty once it is RLS-subject (e.g. after dropping a superuser api). Declare a read: ` +
43
+ `can('read') for public data, can('read', { owner: '<col>' }) for private, or mark the model ` +
44
+ `private() if it is not part of the data API.`,
45
+ });
46
+ }
47
+ return gaps;
48
+ }
package/src/cli/aws.ts CHANGED
@@ -5,6 +5,8 @@
5
5
  * Authentication uses the developer's IAM credentials (default credential chain).
6
6
  */
7
7
 
8
+ import { isOpsHandlerCrash, extractCrashDiagnosis, formatOpsCrashReport } from './ops-diagnostics.js';
9
+
8
10
  let s3Client: InstanceType<typeof import('@aws-sdk/client-s3').S3Client> | null = null;
9
11
  let lambdaClient: InstanceType<typeof import('@aws-sdk/client-lambda').LambdaClient> | null = null;
10
12
  let kvsClient: InstanceType<typeof import('@aws-sdk/client-cloudfront-keyvaluestore').CloudFrontKeyValueStoreClient> | null = null;
@@ -26,9 +28,43 @@ async function getLambda(region: string) {
26
28
  return lambdaClient;
27
29
  }
28
30
 
31
+ /**
32
+ * Lazily load an optional-peer AWS SDK client, turning a missing dependency into a one-line
33
+ * install remedy instead of a raw `ERR_MODULE_NOT_FOUND` at the call site. The `@aws-sdk/*`
34
+ * clients are optional peers of the CLI (not every consumer needs RDS, backup, …), so a consumer
35
+ * that never installed the one a given command needs gets guidance, not a cryptic crash. Takes a
36
+ * thunk so the `import()` stays a literal — statically analyzable and fully typed.
37
+ */
38
+ export async function importOptionalAws<T>(
39
+ load: () => Promise<T>,
40
+ pkg: string,
41
+ feature: string,
42
+ ): Promise<T> {
43
+ try {
44
+ return await load();
45
+ } catch (err: unknown) {
46
+ const code = (err as { code?: string })?.code;
47
+ const message = String((err as { message?: string })?.message ?? err);
48
+ if (
49
+ code === 'ERR_MODULE_NOT_FOUND' ||
50
+ code === 'MODULE_NOT_FOUND' ||
51
+ /Cannot find (package|module)/.test(message)
52
+ ) {
53
+ throw new Error(
54
+ `${feature} needs ${pkg}, which isn't installed (it's an optional peer dependency). Install it:\n pnpm add ${pkg}`,
55
+ );
56
+ }
57
+ throw err;
58
+ }
59
+ }
60
+
29
61
  async function getRds(region: string) {
30
62
  if (!rdsClient) {
31
- const { RDSClient } = await import('@aws-sdk/client-rds');
63
+ const { RDSClient } = await importOptionalAws(
64
+ () => import('@aws-sdk/client-rds'),
65
+ '@aws-sdk/client-rds',
66
+ 'db:snapshot',
67
+ );
32
68
  rdsClient = new RDSClient({ region });
33
69
  }
34
70
  return rdsClient;
@@ -192,6 +228,24 @@ export async function resolveFunctionName(
192
228
  return candidates[0].name;
193
229
  }
194
230
 
231
+ /**
232
+ * A read-only QueryRunner backed by the ops Lambda's `db:query` action — the
233
+ * credential-free introspection path. Every deployed-stage read verb
234
+ * (db:plan, db:apply --stage's descent probe, …) shares it: catalog SQL runs
235
+ * inside AWS on the operator connection, and the row set comes back over IAM.
236
+ * `db:query` refuses writes, so this can only read.
237
+ */
238
+ export function lambdaQueryRunner(
239
+ region: string,
240
+ functionName: string,
241
+ ): (sql: string) => Promise<any[]> {
242
+ return async (sql: string) => {
243
+ const result: any = await invokeAction(region, functionName, 'db:query', { sql });
244
+ if (result?.error) throw new Error(`Query failed: ${result.error}`);
245
+ return result?.rows ?? [];
246
+ };
247
+ }
248
+
195
249
  export async function invokeAction(
196
250
  region: string,
197
251
  functionName: string,
@@ -209,6 +263,7 @@ export async function invokeAction(
209
263
  client.send(new InvokeCommand({ FunctionName: fnName, Payload: encoded }));
210
264
 
211
265
  let response;
266
+ let invokedName = functionName;
212
267
  try {
213
268
  response = await invoke(functionName);
214
269
  } catch (err: unknown) {
@@ -224,6 +279,7 @@ export async function invokeAction(
224
279
  } catch {
225
280
  // Cache invalidation is best-effort
226
281
  }
282
+ invokedName = live;
227
283
  response = await invoke(live);
228
284
  } else {
229
285
  throw err;
@@ -234,13 +290,47 @@ export async function invokeAction(
234
290
  const errorBody = response.Payload
235
291
  ? JSON.parse(new TextDecoder().decode(response.Payload))
236
292
  : { errorMessage: 'Unknown Lambda error' };
237
- throw new Error(errorBody.errorMessage || response.FunctionError);
293
+ const base = String(errorBody.errorMessage || response.FunctionError);
294
+ // A FunctionError means the handler threw uncaught. When the message is the opaque
295
+ // runtime/init crash (a broken top-level import — every action dies the same way), surface the
296
+ // real cause from CloudWatch instead of re-throwing "exit status 1".
297
+ if (isOpsHandlerCrash(base)) {
298
+ throw new Error(await diagnoseOpsCrash(region, invokedName, base));
299
+ }
300
+ throw new Error(base);
238
301
  }
239
302
 
240
303
  if (!response.Payload) return null;
241
304
  return JSON.parse(new TextDecoder().decode(response.Payload));
242
305
  }
243
306
 
307
+ /** Recent CloudWatch log text for a function's log group (last `windowMs`, capped). */
308
+ async function fetchRecentLogText(region: string, logGroupName: string, windowMs = 5 * 60_000): Promise<string> {
309
+ const { CloudWatchLogsClient, FilterLogEventsCommand } = await import('@aws-sdk/client-cloudwatch-logs');
310
+ const client = new CloudWatchLogsClient({ region });
311
+ const res = await client.send(new FilterLogEventsCommand({
312
+ logGroupName,
313
+ startTime: Date.now() - windowMs,
314
+ limit: 200,
315
+ }));
316
+ return (res.events ?? []).map((e) => e.message ?? '').join('\n');
317
+ }
318
+
319
+ /**
320
+ * Turn an opaque ops-handler runtime crash into a report naming the real cause: resolve the ops
321
+ * function's log group, read the recent lines, and extract the failing import. Best-effort — any
322
+ * failure (no log access, no group) falls back to the where-to-look hint.
323
+ */
324
+ export async function diagnoseOpsCrash(region: string, functionName: string, baseMessage: string): Promise<string> {
325
+ try {
326
+ const logGroup = await resolveLogGroup(region, functionName);
327
+ const text = await fetchRecentLogText(region, logGroup);
328
+ return formatOpsCrashReport(baseMessage, logGroup, extractCrashDiagnosis(text));
329
+ } catch {
330
+ return formatOpsCrashReport(baseMessage);
331
+ }
332
+ }
333
+
244
334
  /**
245
335
  * Resolve the CloudWatch log group for a Lambda function.
246
336
  *
@@ -421,7 +511,11 @@ export async function createRdsSnapshot(
421
511
  snapshotId: string,
422
512
  ): Promise<RdsSnapshotSummary> {
423
513
  const client = await getRds(region);
424
- const { CreateDBSnapshotCommand } = await import('@aws-sdk/client-rds');
514
+ const { CreateDBSnapshotCommand } = await importOptionalAws(
515
+ () => import('@aws-sdk/client-rds'),
516
+ '@aws-sdk/client-rds',
517
+ 'db:snapshot',
518
+ );
425
519
  const res = await client.send(new CreateDBSnapshotCommand({
426
520
  DBInstanceIdentifier: dbInstanceId,
427
521
  DBSnapshotIdentifier: snapshotId,
@@ -447,7 +541,11 @@ export async function describeRdsSnapshots(
447
541
  dbInstanceId: string,
448
542
  ): Promise<RdsSnapshotSummary[]> {
449
543
  const client = await getRds(region);
450
- const { DescribeDBSnapshotsCommand } = await import('@aws-sdk/client-rds');
544
+ const { DescribeDBSnapshotsCommand } = await importOptionalAws(
545
+ () => import('@aws-sdk/client-rds'),
546
+ '@aws-sdk/client-rds',
547
+ 'db:snapshots',
548
+ );
451
549
  const summaries: RdsSnapshotSummary[] = [];
452
550
  let marker: string | undefined;
453
551
  do {
@@ -8,7 +8,7 @@
8
8
  * unlike `schema_log`, which stays a write-only memoir. The distrust that
9
9
  * killed the migration tape doesn't transfer: nothing is numbered, nothing
10
10
  * is watermarked, and identity is the NORMALIZED CONTENT HASH (the same
11
- * comment-insensitive, dollar-quote-aware lexing as db/sql) — renaming a
11
+ * comment-insensitive, dollar-quote-aware lexing as the reconciler) — renaming a
12
12
  * file or editing a comment is a no-op, never a re-run.
13
13
  *
14
14
  * One-shot jobs are IMMUTABLE. A file whose name already ran on this