@everystack/server 0.3.3 → 0.3.6
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 +7 -3
- package/src/backup-run.ts +45 -3
- package/src/cdn/index.ts +1 -0
- package/src/cdn/transforms.ts +57 -0
- package/src/db-doctor.ts +172 -0
- package/src/pipeline.ts +423 -0
- package/src/plugin.ts +170 -15
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/server",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.6",
|
|
4
4
|
"description": "Server runtime primitives for Lambda — event adapters, routing, SSR, image processing",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"author": "Scalable Technology, Inc. <licensing@scalable.technology>",
|
|
@@ -72,6 +72,10 @@
|
|
|
72
72
|
"./media": {
|
|
73
73
|
"types": "./src/media.ts",
|
|
74
74
|
"default": "./src/media.ts"
|
|
75
|
+
},
|
|
76
|
+
"./pipeline": {
|
|
77
|
+
"types": "./src/pipeline.ts",
|
|
78
|
+
"default": "./src/pipeline.ts"
|
|
75
79
|
}
|
|
76
80
|
},
|
|
77
81
|
"peerDependencies": {
|
|
@@ -139,8 +143,8 @@
|
|
|
139
143
|
"sst": "4.13.1",
|
|
140
144
|
"ts-jest": "29.4.9",
|
|
141
145
|
"typescript": "5.9.3",
|
|
142
|
-
"@everystack/auth": "0.
|
|
143
|
-
"@everystack/cli": "0.3
|
|
146
|
+
"@everystack/auth": "0.4.0",
|
|
147
|
+
"@everystack/cli": "0.4.3"
|
|
144
148
|
},
|
|
145
149
|
"scripts": {
|
|
146
150
|
"test": "jest",
|
package/src/backup-run.ts
CHANGED
|
@@ -37,8 +37,42 @@ export interface BackupSummary {
|
|
|
37
37
|
lastModified?: string;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Lazily load an optional-peer AWS SDK client, turning a missing dependency into a one-line
|
|
42
|
+
* install remedy instead of a raw `ERR_MODULE_NOT_FOUND` deep in the ops Lambda. The `@aws-sdk/*`
|
|
43
|
+
* clients are optional peers of @everystack/server (a V1 app needs none of them), so a consumer
|
|
44
|
+
* whose ops function runs db:backup must install them — this names which, not "Cannot find
|
|
45
|
+
* package". Takes a thunk so the `import()` stays a literal — statically analyzable and typed.
|
|
46
|
+
*/
|
|
47
|
+
export async function importOptionalAws<T>(
|
|
48
|
+
load: () => Promise<T>,
|
|
49
|
+
pkg: string,
|
|
50
|
+
feature: string,
|
|
51
|
+
): Promise<T> {
|
|
52
|
+
try {
|
|
53
|
+
return await load();
|
|
54
|
+
} catch (err: unknown) {
|
|
55
|
+
const code = (err as { code?: string })?.code;
|
|
56
|
+
const message = String((err as { message?: string })?.message ?? err);
|
|
57
|
+
if (
|
|
58
|
+
code === 'ERR_MODULE_NOT_FOUND' ||
|
|
59
|
+
code === 'MODULE_NOT_FOUND' ||
|
|
60
|
+
/Cannot find (package|module)/.test(message)
|
|
61
|
+
) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`${feature} needs ${pkg}, which isn't installed (it's an optional peer dependency of @everystack/server). Install it:\n pnpm add ${pkg}`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
throw err;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
40
70
|
async function getS3(region?: string): Promise<any> {
|
|
41
|
-
const { S3Client } = await
|
|
71
|
+
const { S3Client } = await importOptionalAws(
|
|
72
|
+
() => import('@aws-sdk/client-s3'),
|
|
73
|
+
'@aws-sdk/client-s3',
|
|
74
|
+
'db:backup',
|
|
75
|
+
);
|
|
42
76
|
return new S3Client(region ? { region } : {});
|
|
43
77
|
}
|
|
44
78
|
|
|
@@ -57,8 +91,16 @@ export async function runBackup(deps: BackupRunDeps, stage: string): Promise<Bac
|
|
|
57
91
|
const id = backupId(stage, stamp, shortId);
|
|
58
92
|
|
|
59
93
|
const s3 = await getS3(region);
|
|
60
|
-
const { Upload } = await
|
|
61
|
-
|
|
94
|
+
const { Upload } = await importOptionalAws(
|
|
95
|
+
() => import('@aws-sdk/lib-storage'),
|
|
96
|
+
'@aws-sdk/lib-storage',
|
|
97
|
+
'db:backup',
|
|
98
|
+
);
|
|
99
|
+
const { DeleteObjectCommand, PutObjectCommand, HeadObjectCommand } = await importOptionalAws(
|
|
100
|
+
() => import('@aws-sdk/client-s3'),
|
|
101
|
+
'@aws-sdk/client-s3',
|
|
102
|
+
'db:backup',
|
|
103
|
+
);
|
|
62
104
|
|
|
63
105
|
const child = spawn('pg_dump', pgDumpArgs(), { env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
64
106
|
let stderr = '';
|
package/src/cdn/index.ts
CHANGED
package/src/cdn/transforms.ts
CHANGED
|
@@ -155,3 +155,60 @@ export function applyWafExclusions(
|
|
|
155
155
|
return rule;
|
|
156
156
|
});
|
|
157
157
|
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Rewrite rate-based-rule block actions to answer 429 + Retry-After instead
|
|
161
|
+
* of WAF's default 403.
|
|
162
|
+
*
|
|
163
|
+
* Google is explicit that the status code matters for SEO ("Don't use 403s
|
|
164
|
+
* or 404s for rate limiting"): every 4xx except 429 signals the content no
|
|
165
|
+
* longer exists and deindexes the URL over time, while 429/503 mean "back
|
|
166
|
+
* off and retry" — crawling slows almost immediately and Google retries for
|
|
167
|
+
* ~2 days before dropping anything. A per-IP rate rule clears within
|
|
168
|
+
* minutes of the burst ending, so a legitimate crawler never sees multi-day
|
|
169
|
+
* 429s. Getting this wrong turns a load shield into a deindexing engine.
|
|
170
|
+
*
|
|
171
|
+
* Only rules with a rateBasedStatement AND a block action are touched;
|
|
172
|
+
* managed rule groups (overrideAction) and count-mode rules pass through.
|
|
173
|
+
*
|
|
174
|
+
* @param rules Resolved WAF rules array from Pulumi.
|
|
175
|
+
* @param retryAfterSeconds Retry-After header value. Default 120.
|
|
176
|
+
*
|
|
177
|
+
* @example
|
|
178
|
+
* ```ts
|
|
179
|
+
* // In sst.config.ts — composes with applyWafExclusions in one transform.
|
|
180
|
+
* // MUST be registered BEFORE `new sst.aws.Router` (a $transform only
|
|
181
|
+
* // applies to resources created after it, and the Router creates the WebAcl).
|
|
182
|
+
* import { applyWafExclusions, applyWafRateLimit429 } from '@everystack/server/cdn';
|
|
183
|
+
*
|
|
184
|
+
* $transform(aws.wafv2.WebAcl, (args: any) => {
|
|
185
|
+
* if (!args.rules) return;
|
|
186
|
+
* args.rules = $resolve(args.rules).apply(
|
|
187
|
+
* (rules) => applyWafRateLimit429(applyWafExclusions(rules)),
|
|
188
|
+
* );
|
|
189
|
+
* });
|
|
190
|
+
* ```
|
|
191
|
+
*/
|
|
192
|
+
export function applyWafRateLimit429(
|
|
193
|
+
rules: any[],
|
|
194
|
+
retryAfterSeconds = 120,
|
|
195
|
+
): any[] {
|
|
196
|
+
return rules.map((rule: any) => {
|
|
197
|
+
if (!rule.statement?.rateBasedStatement || !rule.action?.block) return rule;
|
|
198
|
+
return {
|
|
199
|
+
...rule,
|
|
200
|
+
action: {
|
|
201
|
+
...rule.action,
|
|
202
|
+
block: {
|
|
203
|
+
...rule.action.block,
|
|
204
|
+
customResponse: {
|
|
205
|
+
responseCode: 429,
|
|
206
|
+
responseHeaders: [
|
|
207
|
+
{ name: 'Retry-After', value: String(retryAfterSeconds) },
|
|
208
|
+
],
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
});
|
|
214
|
+
}
|
package/src/db-doctor.ts
CHANGED
|
@@ -67,8 +67,40 @@ export interface DoctorProbe {
|
|
|
67
67
|
* `functions` teeth read this: for each schema that owns a `writtenBy: 'functions'` table
|
|
68
68
|
* (passed as `functionSchemas`), the package's SECDEF functions must exist and each must
|
|
69
69
|
* pin its search_path. Absent on an older probe (the check is then skipped).
|
|
70
|
+
*
|
|
71
|
+
* B4 also reads it globally: an unpinned SECDEF ANYWHERE is the runtime echo of the
|
|
72
|
+
* compile gate (defineFunction refuses to compile one) — warn-level outside the
|
|
73
|
+
* functions-teeth schemas, which keep their fail.
|
|
70
74
|
*/
|
|
71
75
|
secdefFunctions?: SecdefFn[];
|
|
76
|
+
/**
|
|
77
|
+
* Functions PUBLIC can EXECUTE — PostgreSQL's silent-open default (a NULL proacl MEANS
|
|
78
|
+
* EXECUTE to PUBLIC; the query expands acldefault to see it). The declared derived layer
|
|
79
|
+
* revokes it unconditionally; anything here was created outside it or hand-granted back.
|
|
80
|
+
* Absent on an older probe (the check is then skipped). B4.
|
|
81
|
+
*/
|
|
82
|
+
publicExecutableFunctions?: PublicExecutableFn[];
|
|
83
|
+
/**
|
|
84
|
+
* Views/matviews NO app role can read (no non-owner ACL entries) — superuser-only
|
|
85
|
+
* relations, the runtime echo of the derived read-gap gate. Declared-dark descriptors
|
|
86
|
+
* (`private: true`) also land here by design; the warn names both resolutions. Absent on
|
|
87
|
+
* an older probe (the check is then skipped). B4.
|
|
88
|
+
*/
|
|
89
|
+
darkRelations?: DarkRelation[];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A function PUBLIC can EXECUTE, and whether it runs as its owner (the dangerous half). */
|
|
93
|
+
export interface PublicExecutableFn {
|
|
94
|
+
schema: string;
|
|
95
|
+
name: string;
|
|
96
|
+
secdef: boolean;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** A view ('v') or matview ('m') with no grants to any non-owner role. */
|
|
100
|
+
export interface DarkRelation {
|
|
101
|
+
schema: string;
|
|
102
|
+
name: string;
|
|
103
|
+
kind: string;
|
|
72
104
|
}
|
|
73
105
|
|
|
74
106
|
/** A SECURITY DEFINER function and whether its `search_path` is pinned. */
|
|
@@ -143,6 +175,59 @@ WHERE p.prosecdef
|
|
|
143
175
|
ORDER BY n.nspname, p.proname
|
|
144
176
|
`.trim();
|
|
145
177
|
|
|
178
|
+
/**
|
|
179
|
+
* Functions PUBLIC can EXECUTE. The trap this exists for: a NULL `proacl` is not "no
|
|
180
|
+
* grants" — it is PostgreSQL's BUILT-IN DEFAULT, which includes EXECUTE to PUBLIC, so the
|
|
181
|
+
* query expands `acldefault('f', proowner)` before exploding. Trigger-returning functions
|
|
182
|
+
* are excluded (not directly callable — PUBLIC EXECUTE on them is inert), as are
|
|
183
|
+
* extension-owned functions (not ours to judge) and system schemas. `secdef` marks the
|
|
184
|
+
* dangerous half: PUBLIC-callable code that runs as its OWNER.
|
|
185
|
+
*/
|
|
186
|
+
export const PUBLIC_EXECUTABLE_FUNCTIONS_SQL = `
|
|
187
|
+
SELECT
|
|
188
|
+
n.nspname AS schema,
|
|
189
|
+
p.proname AS name,
|
|
190
|
+
p.prosecdef AS secdef
|
|
191
|
+
FROM pg_proc p
|
|
192
|
+
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
193
|
+
CROSS JOIN LATERAL aclexplode(COALESCE(p.proacl, acldefault('f', p.proowner))) a
|
|
194
|
+
WHERE p.prokind IN ('f', 'p')
|
|
195
|
+
AND a.grantee = 0
|
|
196
|
+
AND a.privilege_type = 'EXECUTE'
|
|
197
|
+
AND p.prorettype <> 'trigger'::regtype
|
|
198
|
+
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
199
|
+
AND n.nspname NOT LIKE 'pg_%'
|
|
200
|
+
AND NOT EXISTS (
|
|
201
|
+
SELECT 1 FROM pg_depend dep WHERE dep.objid = p.oid AND dep.deptype = 'e'
|
|
202
|
+
)
|
|
203
|
+
GROUP BY n.nspname, p.proname, p.prosecdef
|
|
204
|
+
ORDER BY n.nspname, p.proname
|
|
205
|
+
`.trim();
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Views/matviews with no ACL entry for any non-owner role — readable by the superuser
|
|
209
|
+
* and nobody else. The runtime echo of the derived read-gap gate: on a modeled project
|
|
210
|
+
* this is either an object created outside the read model or a declared-dark descriptor.
|
|
211
|
+
*/
|
|
212
|
+
export const DARK_RELATIONS_SQL = `
|
|
213
|
+
SELECT
|
|
214
|
+
n.nspname AS schema,
|
|
215
|
+
c.relname AS name,
|
|
216
|
+
c.relkind AS kind
|
|
217
|
+
FROM pg_class c
|
|
218
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
219
|
+
WHERE c.relkind IN ('v', 'm')
|
|
220
|
+
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
221
|
+
AND n.nspname NOT LIKE 'pg_%'
|
|
222
|
+
AND NOT EXISTS (
|
|
223
|
+
SELECT 1 FROM aclexplode(c.relacl) a WHERE a.grantee <> c.relowner
|
|
224
|
+
)
|
|
225
|
+
AND NOT EXISTS (
|
|
226
|
+
SELECT 1 FROM pg_depend dep WHERE dep.objid = c.oid AND dep.deptype = 'e'
|
|
227
|
+
)
|
|
228
|
+
ORDER BY n.nspname, c.relname
|
|
229
|
+
`.trim();
|
|
230
|
+
|
|
146
231
|
/** Coerce a Postgres boolean (true | 't' | 'true' | 1) to a JS boolean. */
|
|
147
232
|
export function pgBool(v: unknown): boolean {
|
|
148
233
|
return v === true || v === 't' || v === 'true' || v === 1 || v === '1';
|
|
@@ -385,6 +470,75 @@ export function buildDoctorReport(probe: DoctorProbe, options: DoctorReportOptio
|
|
|
385
470
|
}
|
|
386
471
|
}
|
|
387
472
|
|
|
473
|
+
// B4 — the derived layer's runtime echoes (read-model-everywhere). Each check echoes a
|
|
474
|
+
// compile/check gate on the LIVE database: the gates cover what the models declare;
|
|
475
|
+
// these cover what actually exists, however it got there.
|
|
476
|
+
|
|
477
|
+
// PUBLIC-executable functions: fail when any runs as its owner (SECDEF — PUBLIC-callable
|
|
478
|
+
// privileged code), warn otherwise (invoker functions still run with the caller's
|
|
479
|
+
// rights, but PUBLIC EXECUTE is never a decision someone reviewed).
|
|
480
|
+
if (probe.publicExecutableFunctions) {
|
|
481
|
+
const offenders = probe.publicExecutableFunctions;
|
|
482
|
+
if (offenders.length > 0) {
|
|
483
|
+
const label = (f: PublicExecutableFn): string => `${f.schema}.${f.name}${f.secdef ? ' (SECURITY DEFINER)' : ''}`;
|
|
484
|
+
checks.push({
|
|
485
|
+
name: 'Functions are not PUBLIC-executable',
|
|
486
|
+
status: offenders.some((f) => f.secdef) ? 'fail' : 'warn',
|
|
487
|
+
detail: `PUBLIC can EXECUTE (PostgreSQL's silent-open default — a NULL proacl grants it): ${offenders.map(label).join(', ')}`,
|
|
488
|
+
remediation: offenders.map((f) => `REVOKE ALL ON FUNCTION ${f.schema}.${f.name} FROM PUBLIC;`).join(' ')
|
|
489
|
+
+ ` Then grant the intended callers — or declare the function with defineFunction + can('execute', { role }) so the compiler emits both.`,
|
|
490
|
+
});
|
|
491
|
+
} else {
|
|
492
|
+
checks.push({
|
|
493
|
+
name: 'Functions are not PUBLIC-executable',
|
|
494
|
+
status: 'pass',
|
|
495
|
+
detail: 'no function is executable by PUBLIC',
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Dark relations: readable by the superuser and nobody else — either created outside
|
|
501
|
+
// the read model, or declared dark on purpose. Warn, because only the author knows.
|
|
502
|
+
if (probe.darkRelations) {
|
|
503
|
+
if (probe.darkRelations.length > 0) {
|
|
504
|
+
checks.push({
|
|
505
|
+
name: 'Derived relations are readable or declared dark',
|
|
506
|
+
status: 'warn',
|
|
507
|
+
detail: `no app role can read: ${probe.darkRelations.map((r) => `${r.schema}.${r.name}`).join(', ')} — superuser-only relation(s)`,
|
|
508
|
+
remediation: `Declare can('read') on the descriptor (the compiler emits the GRANT), or private: true if it is intentionally dark — either way the decision lands in source.`,
|
|
509
|
+
});
|
|
510
|
+
} else {
|
|
511
|
+
checks.push({
|
|
512
|
+
name: 'Derived relations are readable or declared dark',
|
|
513
|
+
status: 'pass',
|
|
514
|
+
detail: 'every view/matview is granted to at least one role',
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// The global SECDEF-pin echo: the compile gate refuses to DECLARE an unpinned SECDEF;
|
|
520
|
+
// this catches the ones that exist anyway (hand-created, pre-descriptor). The
|
|
521
|
+
// functions-teeth schemas keep their fail above — excluded here, never double-reported.
|
|
522
|
+
if (probe.secdefFunctions) {
|
|
523
|
+
const teeth = new Set(options.functionSchemas ?? []);
|
|
524
|
+
const unpinned = probe.secdefFunctions.filter((f) => !f.hasSearchPath && !teeth.has(f.schema));
|
|
525
|
+
if (unpinned.length > 0) {
|
|
526
|
+
checks.push({
|
|
527
|
+
name: 'Every SECURITY DEFINER function pins search_path',
|
|
528
|
+
status: 'warn',
|
|
529
|
+
detail: `unpinned SECDEF function(s) — owner-privilege hijack vector: ${unpinned.map((f) => `${f.schema}.${f.name}`).join(', ')}`,
|
|
530
|
+
remediation: unpinned.map((f) => `ALTER FUNCTION ${f.schema}.${f.name} SET search_path = pg_catalog;`).join(' ')
|
|
531
|
+
+ ` Better: declare it with defineFunction({ security: 'definer', searchPath: [...] }) — the compiler refuses an unpinned one.`,
|
|
532
|
+
});
|
|
533
|
+
} else {
|
|
534
|
+
checks.push({
|
|
535
|
+
name: 'Every SECURITY DEFINER function pins search_path',
|
|
536
|
+
status: 'pass',
|
|
537
|
+
detail: `all ${probe.secdefFunctions.length} SECURITY DEFINER function(s) pin their search_path`,
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
388
542
|
return {
|
|
389
543
|
api,
|
|
390
544
|
admin,
|
|
@@ -393,6 +547,24 @@ export function buildDoctorReport(probe: DoctorProbe, options: DoctorReportOptio
|
|
|
393
547
|
};
|
|
394
548
|
}
|
|
395
549
|
|
|
550
|
+
/** Map a PUBLIC_EXECUTABLE_FUNCTIONS_SQL row to PublicExecutableFn. */
|
|
551
|
+
export function rowToPublicExecutableFn(row: any): PublicExecutableFn {
|
|
552
|
+
return {
|
|
553
|
+
schema: String(row.schema),
|
|
554
|
+
name: String(row.name),
|
|
555
|
+
secdef: pgBool(row.secdef),
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** Map a DARK_RELATIONS_SQL row to DarkRelation. */
|
|
560
|
+
export function rowToDarkRelation(row: any): DarkRelation {
|
|
561
|
+
return {
|
|
562
|
+
schema: String(row.schema),
|
|
563
|
+
name: String(row.name),
|
|
564
|
+
kind: String(row.kind),
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
|
|
396
568
|
/** Map a SECDEF_FUNCTIONS_SQL row to SecdefFn. */
|
|
397
569
|
export function rowToSecdefFn(row: any): SecdefFn {
|
|
398
570
|
return {
|
package/src/pipeline.ts
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @everystack/server/pipeline — the ingest pipeline framework (Phase 3).
|
|
3
|
+
*
|
|
4
|
+
* A pipeline reproduces data from committed source: an ordered set of STAGES,
|
|
5
|
+
* each a function that reads source and writes rows idempotently. You never
|
|
6
|
+
* copy data between environments — you re-run the same committed source + the
|
|
7
|
+
* same stage code and get the same rows ("sync by reproduction").
|
|
8
|
+
*
|
|
9
|
+
* everystack owns the HARNESS — the stage contract, the topological order, the
|
|
10
|
+
* provenance-lane wall (automated/curated/frozen), resume, and the memoir
|
|
11
|
+
* (everystack.pipeline_log). The consumer owns the STAGES: their
|
|
12
|
+
* fetch/parse/transform/load code, their source files, their table and column
|
|
13
|
+
* names, their provenance values. Ingest logic never enters the framework,
|
|
14
|
+
* exactly as schema and migrations never do.
|
|
15
|
+
*
|
|
16
|
+
* A stage is a FUNCTION, not a spawned script, so the same stage runs unchanged
|
|
17
|
+
* in three places: a local process (`pipeline:run --database-url`), the ops
|
|
18
|
+
* Lambda (`pipeline:run --stage`, credential-free), or — later — a worker.
|
|
19
|
+
*
|
|
20
|
+
* See docs/plans/ingest-pipeline-framework.md.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** Read/write SQL against the target, inside whatever transaction the caller holds. */
|
|
24
|
+
export type PipelineQuery = (sql: string) => Promise<any[]>;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The connection the orchestrator drives. `transaction` runs a stage's writes
|
|
28
|
+
* and its memoir row as one atomic unit; `query` is for the pre-flight ensure
|
|
29
|
+
* and the best-effort failure record (which must land OUTSIDE the rolled-back
|
|
30
|
+
* stage transaction).
|
|
31
|
+
*/
|
|
32
|
+
export interface PipelineRunner {
|
|
33
|
+
query: PipelineQuery;
|
|
34
|
+
transaction: <T>(fn: (tx: PipelineQuery) => Promise<T>) => Promise<T>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The provenance lane — the wall between the two reproduction audiences. */
|
|
38
|
+
export type StageLane = 'automated' | 'curated' | 'frozen';
|
|
39
|
+
|
|
40
|
+
/** What a stage reports back for the memoir (both optional — a stage may write nothing). */
|
|
41
|
+
export interface StageResult {
|
|
42
|
+
/** Rows this stage wrote, for the memoir. */
|
|
43
|
+
rows?: number;
|
|
44
|
+
/** Content hash of the source this stage reproduced from — Phase 4's verify reads it. */
|
|
45
|
+
sourceHash?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** What a stage receives. The framework builds it; the consumer's run() uses it. */
|
|
49
|
+
export interface StageContext {
|
|
50
|
+
/** The transactional runner for this stage's writes. */
|
|
51
|
+
runner: PipelineQuery;
|
|
52
|
+
/** This stage's provenance tag (`provenance` override, else `${lane}:${id}`). */
|
|
53
|
+
provenance: string;
|
|
54
|
+
/** True → compute/preview, write nothing. */
|
|
55
|
+
dryRun: boolean;
|
|
56
|
+
/** Emit a progress line. */
|
|
57
|
+
log: (message: string) => void;
|
|
58
|
+
/**
|
|
59
|
+
* The idempotency helper: delete the rows this stage owns before re-inserting.
|
|
60
|
+
* `DELETE FROM <table> WHERE <column> = <this stage's provenance>`. The stage
|
|
61
|
+
* owns the actual insert; the framework owns only the provenance-replace shape.
|
|
62
|
+
*/
|
|
63
|
+
replaceProvenance: (table: string, column: string) => Promise<void>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface Stage {
|
|
67
|
+
id: string;
|
|
68
|
+
lane: StageLane;
|
|
69
|
+
/** Data/topo edges — a dependency runs before its dependent. */
|
|
70
|
+
dependsOn?: string[];
|
|
71
|
+
/** Ordering-only edges (run after, but no data dependency). */
|
|
72
|
+
after?: string[];
|
|
73
|
+
/** Refuses the ops-Lambda executor — this stage is too big, run it local-direct. */
|
|
74
|
+
heavy?: boolean;
|
|
75
|
+
/** Provenance tag for the memoir + the replace helper. Default `${lane}:${id}`. */
|
|
76
|
+
provenance?: string;
|
|
77
|
+
run: (ctx: StageContext) => Promise<StageResult | void>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface Pipeline {
|
|
81
|
+
stages: Stage[];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Identity for defineStage — a stage is a plain descriptor. */
|
|
85
|
+
export function defineStage(stage: Stage): Stage {
|
|
86
|
+
return stage;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Compose stages into a pipeline, validating unique ids and resolvable edges. */
|
|
90
|
+
export function definePipeline(stages: Stage[]): Pipeline {
|
|
91
|
+
const ids = new Set<string>();
|
|
92
|
+
for (const s of stages) {
|
|
93
|
+
if (ids.has(s.id)) throw new Error(`pipeline has a duplicate stage id: "${s.id}"`);
|
|
94
|
+
ids.add(s.id);
|
|
95
|
+
}
|
|
96
|
+
for (const s of stages) {
|
|
97
|
+
for (const dep of [...(s.dependsOn ?? []), ...(s.after ?? [])]) {
|
|
98
|
+
if (!ids.has(dep)) {
|
|
99
|
+
throw new Error(`stage "${s.id}" depends on "${dep}", which is not a stage in this pipeline`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return { stages };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The provenance tag a stage writes under. */
|
|
107
|
+
export function stageProvenance(stage: Stage): string {
|
|
108
|
+
return stage.provenance ?? `${stage.lane}:${stage.id}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Deterministic topological sort over dependsOn ∪ after edges. Stable: with no
|
|
113
|
+
* edge between two stages, declared order wins (so a re-run is byte-stable).
|
|
114
|
+
* Throws on an unknown edge or a cycle (naming a stage on the cycle).
|
|
115
|
+
*/
|
|
116
|
+
export function topoSortStages(stages: Stage[]): Stage[] {
|
|
117
|
+
const byId = new Map(stages.map((s) => [s.id, s]));
|
|
118
|
+
const indegree = new Map<string, number>(stages.map((s) => [s.id, 0]));
|
|
119
|
+
const dependents = new Map<string, string[]>(stages.map((s) => [s.id, []]));
|
|
120
|
+
|
|
121
|
+
for (const s of stages) {
|
|
122
|
+
for (const dep of [...(s.dependsOn ?? []), ...(s.after ?? [])]) {
|
|
123
|
+
if (!byId.has(dep)) {
|
|
124
|
+
throw new Error(`stage "${s.id}" depends on unknown stage "${dep}"`);
|
|
125
|
+
}
|
|
126
|
+
indegree.set(s.id, (indegree.get(s.id) ?? 0) + 1);
|
|
127
|
+
dependents.get(dep)!.push(s.id);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Kahn's algorithm; the ready queue is kept in declared order for stability.
|
|
132
|
+
const declaredIndex = new Map(stages.map((s, i) => [s.id, i]));
|
|
133
|
+
const ready = stages.filter((s) => (indegree.get(s.id) ?? 0) === 0).map((s) => s.id);
|
|
134
|
+
const sorted: Stage[] = [];
|
|
135
|
+
while (ready.length > 0) {
|
|
136
|
+
ready.sort((a, b) => declaredIndex.get(a)! - declaredIndex.get(b)!);
|
|
137
|
+
const id = ready.shift()!;
|
|
138
|
+
sorted.push(byId.get(id)!);
|
|
139
|
+
for (const next of dependents.get(id)!) {
|
|
140
|
+
indegree.set(next, (indegree.get(next) ?? 0) - 1);
|
|
141
|
+
if (indegree.get(next) === 0) ready.push(next);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (sorted.length !== stages.length) {
|
|
146
|
+
const remaining = stages.filter((s) => !sorted.includes(s)).map((s) => s.id);
|
|
147
|
+
throw new Error(`pipeline has a dependency cycle involving: ${remaining.join(', ')}`);
|
|
148
|
+
}
|
|
149
|
+
return sorted;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export interface SelectOptions {
|
|
153
|
+
/** rebuild → automated + frozen; curate → curated; else all lanes. */
|
|
154
|
+
lane?: 'rebuild' | 'curate' | 'all';
|
|
155
|
+
/** Keep stages whose id contains this substring. */
|
|
156
|
+
only?: string;
|
|
157
|
+
/** Slice the ordered list from this stage id (inclusive). */
|
|
158
|
+
from?: string;
|
|
159
|
+
/** Slice the ordered list to this stage id (inclusive). */
|
|
160
|
+
to?: string;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const laneKeeps = (lane: SelectOptions['lane'], stageLane: StageLane): boolean => {
|
|
164
|
+
if (lane === 'rebuild') return stageLane === 'automated' || stageLane === 'frozen';
|
|
165
|
+
if (lane === 'curate') return stageLane === 'curated';
|
|
166
|
+
return true;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/** Filter an already-ordered stage list by lane, --only, and --from/--to. */
|
|
170
|
+
export function selectStages(ordered: Stage[], opts: SelectOptions): Stage[] {
|
|
171
|
+
let selected = ordered;
|
|
172
|
+
if (opts.from || opts.to) {
|
|
173
|
+
const start = opts.from ? selected.findIndex((s) => s.id === opts.from) : 0;
|
|
174
|
+
const endIdx = opts.to ? selected.findIndex((s) => s.id === opts.to) : selected.length - 1;
|
|
175
|
+
if (start === -1) throw new Error(`--from names no stage: "${opts.from}"`);
|
|
176
|
+
if (endIdx === -1) throw new Error(`--to names no stage: "${opts.to}"`);
|
|
177
|
+
selected = selected.slice(start, endIdx + 1);
|
|
178
|
+
}
|
|
179
|
+
selected = selected.filter((s) => laneKeeps(opts.lane, s.lane));
|
|
180
|
+
if (opts.only) selected = selected.filter((s) => s.id.includes(opts.only!));
|
|
181
|
+
return selected;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
// The memoir — everystack.pipeline_log (append-only, sibling to backfill_log).
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
export const ENSURE_PIPELINE_SQL: string[] = [
|
|
189
|
+
'CREATE SCHEMA IF NOT EXISTS everystack',
|
|
190
|
+
`CREATE TABLE IF NOT EXISTS everystack.pipeline_log (
|
|
191
|
+
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
192
|
+
applied_at timestamptz NOT NULL DEFAULT now(),
|
|
193
|
+
run_id text NOT NULL,
|
|
194
|
+
stage_id text NOT NULL,
|
|
195
|
+
lane text NOT NULL,
|
|
196
|
+
source_hash text,
|
|
197
|
+
rows integer,
|
|
198
|
+
actor text,
|
|
199
|
+
git_ref text,
|
|
200
|
+
duration_ms integer,
|
|
201
|
+
outcome text NOT NULL
|
|
202
|
+
)`,
|
|
203
|
+
];
|
|
204
|
+
|
|
205
|
+
const sqlStr = (v: string | null | undefined): string =>
|
|
206
|
+
v === null || v === undefined ? 'NULL' : `'${String(v).replace(/'/g, "''")}'`;
|
|
207
|
+
const sqlNum = (v: number | null | undefined): string =>
|
|
208
|
+
v === null || v === undefined || !Number.isFinite(v) ? 'NULL' : String(Math.trunc(v));
|
|
209
|
+
|
|
210
|
+
export interface PipelineLogEntry {
|
|
211
|
+
runId: string;
|
|
212
|
+
stageId: string;
|
|
213
|
+
lane: StageLane;
|
|
214
|
+
sourceHash?: string | null;
|
|
215
|
+
rows?: number | null;
|
|
216
|
+
actor?: string | null;
|
|
217
|
+
gitRef?: string | null;
|
|
218
|
+
durationMs?: number | null;
|
|
219
|
+
outcome: 'applied' | 'failed';
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function renderPipelineLogInsert(e: PipelineLogEntry): string {
|
|
223
|
+
return `INSERT INTO everystack.pipeline_log (run_id, stage_id, lane, source_hash, rows, actor, git_ref, duration_ms, outcome)
|
|
224
|
+
VALUES (${sqlStr(e.runId)}, ${sqlStr(e.stageId)}, ${sqlStr(e.lane)}, ${sqlStr(e.sourceHash)}, ${sqlNum(e.rows)}, ${sqlStr(e.actor)}, ${sqlStr(e.gitRef)}, ${sqlNum(e.durationMs)}, ${sqlStr(e.outcome)})`;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The most recently touched run_id in the memoir — what `--resume` picks up
|
|
229
|
+
* with no explicit run id. Null when the pipeline has never run here.
|
|
230
|
+
*/
|
|
231
|
+
export async function latestRunId(query: PipelineQuery): Promise<string | null> {
|
|
232
|
+
try {
|
|
233
|
+
const rows = await query('SELECT run_id FROM everystack.pipeline_log ORDER BY id DESC LIMIT 1');
|
|
234
|
+
return rows.length > 0 ? String(rows[0].run_id) : null;
|
|
235
|
+
} catch {
|
|
236
|
+
// No memoir yet (table absent) — nothing to resume.
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** A one-line summary of a pipeline's most recent run — the visibility surface. */
|
|
242
|
+
export interface PipelineRunSummary {
|
|
243
|
+
runId: string;
|
|
244
|
+
/** applied_at of the run's last row (a string as the driver returns it). */
|
|
245
|
+
at: string | null;
|
|
246
|
+
/** Stages whose LATEST attempt under this run succeeded. */
|
|
247
|
+
applied: number;
|
|
248
|
+
/** Stages whose LATEST attempt failed (an incomplete run — resume). */
|
|
249
|
+
failed: number;
|
|
250
|
+
failedStages: string[];
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Summarize the most recent run for db:sync / db:plan to surface — the analog
|
|
255
|
+
* of the pending-backfill nudge. Read-only; null when the pipeline has never
|
|
256
|
+
* run here (or the memoir table is absent). Counts the LATEST attempt per stage
|
|
257
|
+
* so a resumed-and-fixed stage reads as applied, not failed.
|
|
258
|
+
*/
|
|
259
|
+
export async function readLastRun(query: PipelineQuery): Promise<PipelineRunSummary | null> {
|
|
260
|
+
try {
|
|
261
|
+
const latest = await query('SELECT run_id FROM everystack.pipeline_log ORDER BY id DESC LIMIT 1');
|
|
262
|
+
if (latest.length === 0) return null;
|
|
263
|
+
const runId = String(latest[0].run_id);
|
|
264
|
+
const rows = await query(
|
|
265
|
+
`SELECT stage_id, outcome, applied_at FROM everystack.pipeline_log WHERE run_id = ${sqlStr(runId)} ORDER BY id`,
|
|
266
|
+
);
|
|
267
|
+
const latestByStage = new Map<string, string>();
|
|
268
|
+
let at: string | null = null;
|
|
269
|
+
for (const r of rows) {
|
|
270
|
+
latestByStage.set(String(r.stage_id), String(r.outcome));
|
|
271
|
+
if (r.applied_at != null) at = String(r.applied_at);
|
|
272
|
+
}
|
|
273
|
+
const failedStages = [...latestByStage].filter(([, o]) => o === 'failed').map(([s]) => s);
|
|
274
|
+
const applied = [...latestByStage.values()].filter((o) => o === 'applied').length;
|
|
275
|
+
return { runId, at, applied, failed: failedStages.length, failedStages };
|
|
276
|
+
} catch {
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** The stage ids that already succeeded under a given run — the resume set. */
|
|
282
|
+
export async function readAppliedStages(query: PipelineQuery, runId: string): Promise<Set<string>> {
|
|
283
|
+
const rows = await query(
|
|
284
|
+
`SELECT stage_id FROM everystack.pipeline_log WHERE run_id = ${sqlStr(runId)} AND outcome = 'applied'`,
|
|
285
|
+
);
|
|
286
|
+
return new Set(rows.map((r: any) => String(r.stage_id)));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
// The orchestrator — pure over an injected runner, identical local and ops-routed.
|
|
291
|
+
// ---------------------------------------------------------------------------
|
|
292
|
+
|
|
293
|
+
export interface RunPipelineOptions extends SelectOptions {
|
|
294
|
+
/** Groups a multi-stage run for resume; caller supplies it (a uuid). */
|
|
295
|
+
runId: string;
|
|
296
|
+
/** Skip stages already applied under runId. */
|
|
297
|
+
resume?: boolean;
|
|
298
|
+
/** Print the plan, run nothing. */
|
|
299
|
+
dryRun?: boolean;
|
|
300
|
+
/** Keep going past a failed stage (default: fail-fast). */
|
|
301
|
+
continueOnError?: boolean;
|
|
302
|
+
actor?: string | null;
|
|
303
|
+
gitRef?: string | null;
|
|
304
|
+
/** Injectable clock for tests. */
|
|
305
|
+
now?: () => number;
|
|
306
|
+
/** Emit a progress line. */
|
|
307
|
+
log?: (message: string) => void;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export type StageOutcome = 'applied' | 'skipped' | 'failed' | 'dry-run';
|
|
311
|
+
|
|
312
|
+
export interface StageRunResult {
|
|
313
|
+
stageId: string;
|
|
314
|
+
outcome: StageOutcome;
|
|
315
|
+
rows?: number;
|
|
316
|
+
durationMs?: number;
|
|
317
|
+
error?: string;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export interface PipelineRunOutcome {
|
|
321
|
+
runId: string;
|
|
322
|
+
/** The ordered, selected stage ids — the plan. */
|
|
323
|
+
plan: string[];
|
|
324
|
+
results: StageRunResult[];
|
|
325
|
+
failed: boolean;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Reproduce the pipeline against the target: topo-order, filter to the selected
|
|
330
|
+
* lane/range, then run each stage inside its own transaction — the stage's
|
|
331
|
+
* writes AND its memoir row commit together. A throw rolls the stage back,
|
|
332
|
+
* records a best-effort `failed` row outside the transaction, and (unless
|
|
333
|
+
* continueOnError) stops, marking the rest skipped. dry-run returns the plan and
|
|
334
|
+
* runs nothing.
|
|
335
|
+
*/
|
|
336
|
+
export async function runPipeline(
|
|
337
|
+
runner: PipelineRunner,
|
|
338
|
+
pipeline: Pipeline,
|
|
339
|
+
options: RunPipelineOptions,
|
|
340
|
+
): Promise<PipelineRunOutcome> {
|
|
341
|
+
const now = options.now ?? Date.now;
|
|
342
|
+
const log = options.log ?? (() => {});
|
|
343
|
+
const ordered = topoSortStages(pipeline.stages);
|
|
344
|
+
const selected = selectStages(ordered, options);
|
|
345
|
+
const plan = selected.map((s) => s.id);
|
|
346
|
+
|
|
347
|
+
if (options.dryRun) {
|
|
348
|
+
return {
|
|
349
|
+
runId: options.runId,
|
|
350
|
+
plan,
|
|
351
|
+
results: selected.map((s) => ({ stageId: s.id, outcome: 'dry-run' as const })),
|
|
352
|
+
failed: false,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
await runner.query(ENSURE_PIPELINE_SQL.join(';\n'));
|
|
357
|
+
const alreadyApplied = options.resume
|
|
358
|
+
? await readAppliedStages(runner.query, options.runId)
|
|
359
|
+
: new Set<string>();
|
|
360
|
+
|
|
361
|
+
const results: StageRunResult[] = [];
|
|
362
|
+
let stopped = false;
|
|
363
|
+
|
|
364
|
+
for (const stage of selected) {
|
|
365
|
+
if (stopped) {
|
|
366
|
+
results.push({ stageId: stage.id, outcome: 'skipped' });
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
if (options.resume && alreadyApplied.has(stage.id)) {
|
|
370
|
+
results.push({ stageId: stage.id, outcome: 'skipped' });
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const provenance = stageProvenance(stage);
|
|
375
|
+
const started = now();
|
|
376
|
+
try {
|
|
377
|
+
const result = await runner.transaction(async (tx) => {
|
|
378
|
+
const ctx: StageContext = {
|
|
379
|
+
runner: tx,
|
|
380
|
+
provenance,
|
|
381
|
+
dryRun: false,
|
|
382
|
+
log,
|
|
383
|
+
replaceProvenance: async (table, column) => {
|
|
384
|
+
await tx(`DELETE FROM ${table} WHERE ${column} = ${sqlStr(provenance)}`);
|
|
385
|
+
},
|
|
386
|
+
};
|
|
387
|
+
const r = (await stage.run(ctx)) ?? {};
|
|
388
|
+
await tx(renderPipelineLogInsert({
|
|
389
|
+
runId: options.runId,
|
|
390
|
+
stageId: stage.id,
|
|
391
|
+
lane: stage.lane,
|
|
392
|
+
sourceHash: r.sourceHash ?? null,
|
|
393
|
+
rows: r.rows ?? null,
|
|
394
|
+
actor: options.actor ?? null,
|
|
395
|
+
gitRef: options.gitRef ?? null,
|
|
396
|
+
durationMs: now() - started,
|
|
397
|
+
outcome: 'applied',
|
|
398
|
+
}));
|
|
399
|
+
return r;
|
|
400
|
+
});
|
|
401
|
+
results.push({ stageId: stage.id, outcome: 'applied', rows: result.rows, durationMs: now() - started });
|
|
402
|
+
} catch (err: any) {
|
|
403
|
+
const message = String(err?.message ?? err);
|
|
404
|
+
// Best-effort failure memoir, OUTSIDE the rolled-back stage transaction.
|
|
405
|
+
try {
|
|
406
|
+
await runner.query(ENSURE_PIPELINE_SQL.join(';\n'));
|
|
407
|
+
await runner.query(renderPipelineLogInsert({
|
|
408
|
+
runId: options.runId,
|
|
409
|
+
stageId: stage.id,
|
|
410
|
+
lane: stage.lane,
|
|
411
|
+
durationMs: now() - started,
|
|
412
|
+
outcome: 'failed',
|
|
413
|
+
}));
|
|
414
|
+
} catch {
|
|
415
|
+
// the memoir is best-effort on failure
|
|
416
|
+
}
|
|
417
|
+
results.push({ stageId: stage.id, outcome: 'failed', durationMs: now() - started, error: message });
|
|
418
|
+
if (!options.continueOnError) stopped = true;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return { runId: options.runId, plan, results, failed: results.some((r) => r.outcome === 'failed') };
|
|
423
|
+
}
|
package/src/plugin.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { Handler, Route, LogSink, ServerCacheConfig } from './index';
|
|
10
|
+
import type { Pipeline } from './pipeline';
|
|
10
11
|
import type {
|
|
11
12
|
APIGatewayProxyEventV2,
|
|
12
13
|
APIGatewayProxyStructuredResultV2,
|
|
@@ -185,6 +186,18 @@ export function createPluginLambdaHandler(
|
|
|
185
186
|
): Promise<APIGatewayProxyStructuredResultV2 | unknown> => {
|
|
186
187
|
// Direct CLI invoke via Lambda Invoke — IAM is the auth layer.
|
|
187
188
|
if ('_action' in event && typeof event._action === 'string') {
|
|
189
|
+
// Built-in health probe — `everystack deploy` invokes it right after a deploy so a bundle
|
|
190
|
+
// that can't initialize is caught at deploy time, not at the next db verb. Handled before
|
|
191
|
+
// the action lookup (it must answer even when initialization itself is the failure) and
|
|
192
|
+
// reserved: an app action named `_health` is never reached.
|
|
193
|
+
if (event._action === '_health') {
|
|
194
|
+
try {
|
|
195
|
+
const { actions } = await ensureInitialized();
|
|
196
|
+
return { ok: true, mode: httpEnabled ? 'http' : 'ops', actions: Object.keys(actions).sort() };
|
|
197
|
+
} catch (error) {
|
|
198
|
+
return { ok: false, error: String(error) };
|
|
199
|
+
}
|
|
200
|
+
}
|
|
188
201
|
const { ctx, actions } = await ensureInitialized();
|
|
189
202
|
const actionName = event._action;
|
|
190
203
|
const actionHandler = actions[actionName];
|
|
@@ -490,6 +503,13 @@ export interface DbPluginOptions {
|
|
|
490
503
|
backupBucket?: string;
|
|
491
504
|
/** AWS region for the backups bucket (defaults to the Lambda's AWS_REGION). */
|
|
492
505
|
backupRegion?: string;
|
|
506
|
+
/**
|
|
507
|
+
* The app's ingest pipeline (built with definePipeline). When provided, registers the
|
|
508
|
+
* credential-free `pipeline:run` action: the orchestrator runs IN this Lambda over the
|
|
509
|
+
* operator connection, so a deployed stage's small curated overlays reproduce via IAM,
|
|
510
|
+
* never a held database URL. Stages marked `heavy` are refused here (run them local-direct).
|
|
511
|
+
*/
|
|
512
|
+
pipeline?: Pipeline;
|
|
493
513
|
}
|
|
494
514
|
|
|
495
515
|
/**
|
|
@@ -497,7 +517,14 @@ export interface DbPluginOptions {
|
|
|
497
517
|
* Contributes: migrate, seed, db:reset, db:psql, db:query, console.
|
|
498
518
|
*
|
|
499
519
|
* Usage:
|
|
500
|
-
* dbPlugin({
|
|
520
|
+
* dbPlugin({
|
|
521
|
+
* migrationsFolder: join(__dirname, '..', 'drizzle'),
|
|
522
|
+
* // Load the seed module LAZILY. Seed scripts are often also runnable directly
|
|
523
|
+
* // (`node db/seed.ts`) and may connect or process.exit at import time — a static
|
|
524
|
+
* // `import { runSeed }` runs that at Lambda INIT and crashes the ops plane before
|
|
525
|
+
* // any handler exists. The dynamic import defers it to the moment db:seed runs.
|
|
526
|
+
* seed: async (db, schema) => (await import('../db/seed.js')).runSeed(db, schema),
|
|
527
|
+
* })
|
|
501
528
|
*/
|
|
502
529
|
export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
503
530
|
return async (ctx: PluginContext): Promise<PluginContribution> => {
|
|
@@ -508,6 +535,24 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
508
535
|
// connection can be least-privilege and RLS-gated.
|
|
509
536
|
const opsDb = ctx.adminDb ?? ctx.db;
|
|
510
537
|
|
|
538
|
+
// Resolve the logical-backup dependencies (bucket + operator URL) once —
|
|
539
|
+
// shared by db:backup/db:backups/db:restore and by db:apply's auto-snapshot
|
|
540
|
+
// before a destructive staged apply. Returns `{ error }` when unconfigured.
|
|
541
|
+
const resolveBackupDeps = async (): Promise<
|
|
542
|
+
{ deps: { bucket: string; region?: string; adminUrl: string } } | { error: string }
|
|
543
|
+
> => {
|
|
544
|
+
const bucket = options.backupBucket;
|
|
545
|
+
if (!bucket) {
|
|
546
|
+
return { error: 'backup storage not configured — pass backupBucket to dbPlugin (e.g. Resource.Backups.name)' };
|
|
547
|
+
}
|
|
548
|
+
const { getAdminDatabaseUrl, getMasterDatabaseUrl } = await import('./db');
|
|
549
|
+
const adminUrl = getAdminDatabaseUrl() ?? getMasterDatabaseUrl();
|
|
550
|
+
if (!adminUrl) {
|
|
551
|
+
return { error: 'no operator connection available (ADMIN_DATABASE_URL / Resource.Database)' };
|
|
552
|
+
}
|
|
553
|
+
return { deps: { bucket, region: options.backupRegion ?? process.env.AWS_REGION, adminUrl } };
|
|
554
|
+
};
|
|
555
|
+
|
|
511
556
|
// --- migrate ---
|
|
512
557
|
actions.migrate = async (_payload, _ctx) => {
|
|
513
558
|
const { runMigrations } = await import('./migrate');
|
|
@@ -636,7 +681,9 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
636
681
|
const { sql } = await import('drizzle-orm');
|
|
637
682
|
const {
|
|
638
683
|
buildDoctorReport, rowToConnInfo, rowToForceRls, rowToAppGranted, rowToSecdefFn,
|
|
684
|
+
rowToPublicExecutableFn, rowToDarkRelation,
|
|
639
685
|
CONN_SQL, FORCE_RLS_SQL, appGrantsRlsSql, SECDEF_FUNCTIONS_SQL,
|
|
686
|
+
PUBLIC_EXECUTABLE_FUNCTIONS_SQL, DARK_RELATIONS_SQL,
|
|
640
687
|
} = await import('./db-doctor');
|
|
641
688
|
const rowsOf = (result: any): any[] => (Array.isArray(result) ? result : result?.rows || []);
|
|
642
689
|
|
|
@@ -660,6 +707,10 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
660
707
|
// SECDEF functions (schema, name, search_path pinned?) — the `functions` teeth read these
|
|
661
708
|
// to assert every SECDEF function in a functions-written schema pins its search_path.
|
|
662
709
|
const secdefFunctions = rowsOf(await opsDb.execute(sql.raw(SECDEF_FUNCTIONS_SQL))).map(rowToSecdefFn);
|
|
710
|
+
// B4 — the derived layer's runtime echoes: PUBLIC-executable functions (NULL proacl
|
|
711
|
+
// included via acldefault) and superuser-only views/matviews.
|
|
712
|
+
const publicExecutableFunctions = rowsOf(await opsDb.execute(sql.raw(PUBLIC_EXECUTABLE_FUNCTIONS_SQL))).map(rowToPublicExecutableFn);
|
|
713
|
+
const darkRelations = rowsOf(await opsDb.execute(sql.raw(DARK_RELATIONS_SQL))).map(rowToDarkRelation);
|
|
663
714
|
|
|
664
715
|
// Ambient-access probe: can the api connection read an RLS table WITHOUT SET ROLE?
|
|
665
716
|
let apiAmbientRead: { table: string; accessible: boolean } | undefined;
|
|
@@ -675,7 +726,7 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
675
726
|
}
|
|
676
727
|
|
|
677
728
|
return buildDoctorReport(
|
|
678
|
-
{ api, admin, forceRls, appGrantedRls, apiAmbientRead, secdefFunctions },
|
|
729
|
+
{ api, admin, forceRls, appGrantedRls, apiAmbientRead, secdefFunctions, publicExecutableFunctions, darkRelations },
|
|
679
730
|
{ forceRlsCarveouts: options.forceRlsCarveouts, functionSchemas: options.functionSchemas },
|
|
680
731
|
);
|
|
681
732
|
};
|
|
@@ -759,19 +810,6 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
759
810
|
// dedicated private backups bucket. Restore is destructive and requires an explicit confirm in
|
|
760
811
|
// the payload (the CLI sets it after the user confirms). See backup-run.ts.
|
|
761
812
|
{
|
|
762
|
-
const resolveBackupDeps = async () => {
|
|
763
|
-
const bucket = options.backupBucket;
|
|
764
|
-
if (!bucket) {
|
|
765
|
-
return { error: 'backup storage not configured — pass backupBucket to dbPlugin (e.g. Resource.Backups.name)' as string };
|
|
766
|
-
}
|
|
767
|
-
const { getAdminDatabaseUrl, getMasterDatabaseUrl } = await import('./db');
|
|
768
|
-
const adminUrl = getAdminDatabaseUrl() ?? getMasterDatabaseUrl();
|
|
769
|
-
if (!adminUrl) {
|
|
770
|
-
return { error: 'no operator connection available (ADMIN_DATABASE_URL / Resource.Database)' as string };
|
|
771
|
-
}
|
|
772
|
-
return { deps: { bucket, region: options.backupRegion ?? process.env.AWS_REGION, adminUrl } };
|
|
773
|
-
};
|
|
774
|
-
|
|
775
813
|
actions['db:backup'] = async (payload, ctx) => {
|
|
776
814
|
const r = await resolveBackupDeps();
|
|
777
815
|
if ('error' in r) return { error: r.error };
|
|
@@ -814,6 +852,123 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
814
852
|
};
|
|
815
853
|
}
|
|
816
854
|
|
|
855
|
+
// --- db:apply — the credential-free staged write (Phase 2) ---
|
|
856
|
+
// The staged half of db:apply: the CLI computes the plan and the
|
|
857
|
+
// checkout-bound verdicts (the fast-forward/descent rule, the destructive
|
|
858
|
+
// approver check — neither can be recomputed here: one needs the git
|
|
859
|
+
// checkout, the other the caller's real IAM identity); THIS action does the
|
|
860
|
+
// write against the operator connection. Verify the concurrency lock, apply
|
|
861
|
+
// the DDL in one transaction, stamp everystack.schema_log with the plan_ref,
|
|
862
|
+
// and verify the target landed on plan.to — the same executeApplyPlan the
|
|
863
|
+
// direct --database-url path runs, only here the connection never leaves the
|
|
864
|
+
// Lambda. A destructive plan snapshots first (db:backup), in order.
|
|
865
|
+
actions['db:apply'] = async (payload, ctx) => {
|
|
866
|
+
const { plan, actor, gitRef, descentVerdict, authorityVerdict, stage } =
|
|
867
|
+
(payload ?? {}) as {
|
|
868
|
+
plan?: any;
|
|
869
|
+
actor?: string | null;
|
|
870
|
+
gitRef?: string | null;
|
|
871
|
+
descentVerdict?: { ok: boolean; reason?: string };
|
|
872
|
+
authorityVerdict?: { ok: boolean; reason?: string };
|
|
873
|
+
stage?: string;
|
|
874
|
+
};
|
|
875
|
+
if (!plan || typeof plan !== 'object' || !Array.isArray(plan.statements)) {
|
|
876
|
+
return { error: 'db:apply requires { plan } — a reviewed edge minted by db:plan' };
|
|
877
|
+
}
|
|
878
|
+
const { executeApplyPlan, PLAN_VERSION } = await import('@everystack/cli/apply');
|
|
879
|
+
if (plan.v !== PLAN_VERSION) {
|
|
880
|
+
return { error: `plan version ${plan.v} is not this server's ${PLAN_VERSION} — re-mint with a matching db:plan` };
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
const { sql } = await import('drizzle-orm');
|
|
884
|
+
const runner = async (query: string): Promise<any[]> => {
|
|
885
|
+
const result: any = await opsDb.execute(sql.raw(query));
|
|
886
|
+
return Array.isArray(result) ? result : result?.rows ?? [];
|
|
887
|
+
};
|
|
888
|
+
|
|
889
|
+
const destructive = Number(plan.destructive) > 0;
|
|
890
|
+
let snapshotId: string | undefined;
|
|
891
|
+
try {
|
|
892
|
+
const result = await executeApplyPlan(runner, plan, {
|
|
893
|
+
actor: actor ?? null,
|
|
894
|
+
gitRef: gitRef ?? null,
|
|
895
|
+
// Descent/authority are decided by the CLI (checkout + caller identity)
|
|
896
|
+
// and enforced + recorded here, in order, atomic with the lock.
|
|
897
|
+
...(descentVerdict
|
|
898
|
+
? { verifyDescent: async () => (descentVerdict.ok ? { ok: true as const } : { ok: false as const, reason: descentVerdict.reason ?? 'descent refused' }) }
|
|
899
|
+
: {}),
|
|
900
|
+
...(destructive && authorityVerdict
|
|
901
|
+
? { verifyAuthority: async () => (authorityVerdict.ok ? { ok: true as const } : { ok: false as const, reason: authorityVerdict.reason ?? 'authority refused' }) }
|
|
902
|
+
: {}),
|
|
903
|
+
// Losing data is hard: the snapshot runs HERE, after the lock/descent/
|
|
904
|
+
// authority gates pass and before the DDL. It uses the same logical
|
|
905
|
+
// backup as db:backup, on the operator connection.
|
|
906
|
+
...(destructive
|
|
907
|
+
? {
|
|
908
|
+
beforeDestructive: async () => {
|
|
909
|
+
const r = await resolveBackupDeps();
|
|
910
|
+
if ('error' in r) {
|
|
911
|
+
throw new Error(`snapshot before a destructive apply is impossible — ${r.error}`);
|
|
912
|
+
}
|
|
913
|
+
const { runBackup } = await import('./backup-run');
|
|
914
|
+
const backup: any = await runBackup(r.deps, stage ?? ctx.environment ?? 'default');
|
|
915
|
+
snapshotId = backup?.id;
|
|
916
|
+
},
|
|
917
|
+
}
|
|
918
|
+
: {}),
|
|
919
|
+
});
|
|
920
|
+
return snapshotId ? { ...result, snapshotId } : result;
|
|
921
|
+
} catch (err: any) {
|
|
922
|
+
return { error: err?.message || String(err) };
|
|
923
|
+
}
|
|
924
|
+
};
|
|
925
|
+
|
|
926
|
+
// --- pipeline:run — the credential-free ingest reproduction (Phase 3, Brick 5) ---
|
|
927
|
+
// The orchestrator runs HERE, in the ops Lambda, over the operator connection: a
|
|
928
|
+
// deployed stage's small curated overlays reproduce via IAM, never a held URL. The
|
|
929
|
+
// pipeline definition is the one the app bundled into this Lambda (like `seed`); the
|
|
930
|
+
// CLI passes only the run options. A `heavy` stage is refused — it belongs local-direct.
|
|
931
|
+
if (options.pipeline) {
|
|
932
|
+
const pipeline = options.pipeline;
|
|
933
|
+
actions['pipeline:run'] = async (payload, _ctx) => {
|
|
934
|
+
const runOpts: any = { ...((payload ?? {}) as Record<string, unknown>) };
|
|
935
|
+
const { runPipeline, topoSortStages, selectStages, latestRunId } = await import('./pipeline');
|
|
936
|
+
|
|
937
|
+
let selected;
|
|
938
|
+
try {
|
|
939
|
+
selected = selectStages(topoSortStages(pipeline.stages), runOpts);
|
|
940
|
+
} catch (err: any) {
|
|
941
|
+
return { error: err?.message || String(err) };
|
|
942
|
+
}
|
|
943
|
+
const heavy = selected.filter((s) => s.heavy).map((s) => s.id);
|
|
944
|
+
if (heavy.length > 0) {
|
|
945
|
+
return { error: `stage(s) ${heavy.join(', ')} are marked heavy — too big for the ops Lambda. Run them local-direct: pipeline:run --database-url <url>.` };
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
const { sql } = await import('drizzle-orm');
|
|
949
|
+
const asRows = (r: any): any[] => (Array.isArray(r) ? r : r?.rows ?? []);
|
|
950
|
+
const runner = {
|
|
951
|
+
query: async (q: string) => asRows(await opsDb.execute(sql.raw(q))),
|
|
952
|
+
transaction: async <T>(fn: (tx: (q: string) => Promise<any[]>) => Promise<T>): Promise<T> =>
|
|
953
|
+
opsDb.transaction(async (tx: any) => fn(async (q: string) => asRows(await tx.execute(sql.raw(q))))),
|
|
954
|
+
};
|
|
955
|
+
|
|
956
|
+
// Resume with no explicit run id picks up the latest run on THIS stage.
|
|
957
|
+
if (runOpts.resume && !runOpts.runId) {
|
|
958
|
+
const latest = await latestRunId(runner.query);
|
|
959
|
+
if (!latest) return { error: 'nothing to resume — this pipeline has not run on this stage yet.' };
|
|
960
|
+
runOpts.runId = latest;
|
|
961
|
+
}
|
|
962
|
+
if (!runOpts.runId) return { error: 'pipeline:run requires a runId' };
|
|
963
|
+
|
|
964
|
+
try {
|
|
965
|
+
return await runPipeline(runner, pipeline, runOpts);
|
|
966
|
+
} catch (err: any) {
|
|
967
|
+
return { error: err?.message || String(err) };
|
|
968
|
+
}
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
|
|
817
972
|
// --- console ---
|
|
818
973
|
if (options.allowConsole !== false) {
|
|
819
974
|
actions['console:meta'] = async (_payload, ctx) => {
|