@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.
- package/README.md +1 -1
- package/package.json +6 -2
- package/src/cli/apply-execute.ts +185 -0
- package/src/cli/authz-compile.ts +5 -2
- package/src/cli/authz-lint.ts +48 -0
- package/src/cli/aws.ts +102 -4
- package/src/cli/backfill.ts +1 -1
- package/src/cli/commands/db-apply.ts +159 -169
- package/src/cli/commands/db-backfill.ts +2 -2
- package/src/cli/commands/db-backup.ts +34 -0
- package/src/cli/commands/db-branch.ts +28 -4
- package/src/cli/commands/db-check.ts +97 -34
- package/src/cli/commands/db-fingerprint.ts +9 -4
- package/src/cli/commands/db-generate.ts +73 -22
- package/src/cli/commands/db-plan.ts +7 -13
- package/src/cli/commands/db-pull.ts +39 -7
- package/src/cli/commands/db-reconcile.ts +219 -66
- package/src/cli/commands/db-sync.ts +58 -21
- package/src/cli/commands/deploy.ts +4 -0
- package/src/cli/commands/pipeline-run.ts +269 -0
- package/src/cli/db-build.ts +9 -3
- package/src/cli/db-source.ts +83 -7
- package/src/cli/declared-derived.ts +136 -0
- package/src/cli/derived-apply.ts +40 -6
- package/src/cli/derived-compile.ts +327 -0
- package/src/cli/derived-grants.ts +96 -0
- package/src/cli/derived-introspect.ts +258 -21
- package/src/cli/derived-lint.ts +261 -0
- package/src/cli/derived-plan.ts +194 -12
- package/src/cli/derived-render.ts +320 -0
- package/src/cli/derived-source.ts +53 -125
- package/src/cli/discover.ts +16 -0
- package/src/cli/git-descent.ts +15 -2
- package/src/cli/index.ts +26 -6
- package/src/cli/migration-compile.ts +38 -7
- package/src/cli/migration-generate.ts +43 -4
- package/src/cli/model-render.ts +106 -13
- package/src/cli/models-path.ts +1 -1
- package/src/cli/ops-diagnostics.ts +71 -0
- package/src/cli/pipeline-loader.ts +68 -0
- package/src/cli/pipeline-path.ts +25 -0
- package/src/cli/schema-compile.ts +41 -10
- package/src/cli/schema-diff.ts +123 -17
- package/src/cli/schema-fingerprint.ts +28 -18
- package/src/cli/schema-introspect.ts +175 -8
- package/src/cli/schema-source.ts +75 -6
- 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/
|
|
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
|
+
"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.
|
|
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';
|
package/src/cli/authz-compile.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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 {
|
package/src/cli/backfill.ts
CHANGED
|
@@ -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
|
|
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
|