@everystack/server 0.4.13 → 0.4.14
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 -2
- package/package.json +9 -10
- package/src/backup-run.ts +138 -17
- package/src/backup.ts +146 -32
- package/src/db-doctor.ts +3 -46
- package/src/plugin.ts +97 -121
- package/src/ssr.ts +74 -2
- package/src/infra.ts +0 -96
package/README.md
CHANGED
|
@@ -149,8 +149,7 @@ Actions contributed (each is an `everystack db:*` command):
|
|
|
149
149
|
| `db:psql` / `db:query` | connection info / read-only SQL via Lambda |
|
|
150
150
|
| `db:doctor` | audit: is the API connection least-privilege + RLS-subject? Flags BYPASSRLS, un-FORCEd RLS, and **app-reachable tables without RLS** |
|
|
151
151
|
| `db:provision` | create the least-privilege role chain on an existing DB (idempotent; roles only) |
|
|
152
|
-
| `db:backup
|
|
153
|
-
| `db:backup` | `pg_dump -Fc` → gzip → S3 (streamed); awaits the child exit code, deletes the partial on failure |
|
|
152
|
+
| `db:backup` | dispatched to the Fargate Task lane: `pg_dump -Fc` → gzip → S3 (streamed), fingerprint-stamped; awaits the child exit code, deletes the partial on failure |
|
|
154
153
|
| `db:backups` | list a stage's backups |
|
|
155
154
|
| `db:restore` | S3 → gunzip → `pg_restore --clean --if-exists` (requires `confirm:true`) |
|
|
156
155
|
| `db:authz:probe` | run the CLI-built authorization red-team SQL in a rolled-back transaction |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/server",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.14",
|
|
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>",
|
|
@@ -85,9 +85,13 @@
|
|
|
85
85
|
"types": "./src/pipeline.ts",
|
|
86
86
|
"default": "./src/pipeline.ts"
|
|
87
87
|
},
|
|
88
|
-
"./
|
|
89
|
-
"types": "./src/
|
|
90
|
-
"default": "./src/
|
|
88
|
+
"./backup": {
|
|
89
|
+
"types": "./src/backup.ts",
|
|
90
|
+
"default": "./src/backup.ts"
|
|
91
|
+
},
|
|
92
|
+
"./backup-run": {
|
|
93
|
+
"types": "./src/backup-run.ts",
|
|
94
|
+
"default": "./src/backup-run.ts"
|
|
91
95
|
}
|
|
92
96
|
},
|
|
93
97
|
"peerDependencies": {
|
|
@@ -99,7 +103,6 @@
|
|
|
99
103
|
"@aws-sdk/signature-v4a": ">=3.1048.0",
|
|
100
104
|
"@everystack/auth": ">=0.1.0",
|
|
101
105
|
"@everystack/cli": ">=0.1.0",
|
|
102
|
-
"@everystack/pg-tools": ">=0.4.0",
|
|
103
106
|
"drizzle-orm": "0.41.0",
|
|
104
107
|
"heic-decode": "2.1.0",
|
|
105
108
|
"postgres": "3.4.9",
|
|
@@ -119,9 +122,6 @@
|
|
|
119
122
|
"sst": {
|
|
120
123
|
"optional": true
|
|
121
124
|
},
|
|
122
|
-
"@everystack/pg-tools": {
|
|
123
|
-
"optional": true
|
|
124
|
-
},
|
|
125
125
|
"@everystack/auth": {
|
|
126
126
|
"optional": true
|
|
127
127
|
},
|
|
@@ -160,8 +160,7 @@
|
|
|
160
160
|
"ts-jest": "29.4.9",
|
|
161
161
|
"typescript": "5.9.3",
|
|
162
162
|
"@everystack/auth": "0.4.0",
|
|
163
|
-
"@everystack/
|
|
164
|
-
"@everystack/cli": "0.4.29"
|
|
163
|
+
"@everystack/cli": "0.4.31"
|
|
165
164
|
},
|
|
166
165
|
"scripts": {
|
|
167
166
|
"test": "jest",
|
package/src/backup-run.ts
CHANGED
|
@@ -15,12 +15,12 @@
|
|
|
15
15
|
import { spawn } from 'node:child_process';
|
|
16
16
|
import { createGzip, createGunzip } from 'node:zlib';
|
|
17
17
|
import { pipeline } from 'node:stream/promises';
|
|
18
|
-
import { randomBytes } from 'node:crypto';
|
|
19
18
|
import type { Readable } from 'node:stream';
|
|
20
19
|
import {
|
|
21
|
-
backupPrefix,
|
|
22
|
-
artifactKey, artifactId, artifactPrefix, artifactIdForKey,
|
|
20
|
+
backupPrefix, metaKey, idForKey, keyForId,
|
|
23
21
|
pgEnvFromUrl, dbNameFromUrl, pgDumpArgs, pgRestoreArgs,
|
|
22
|
+
restoreIntentPrefix,
|
|
23
|
+
type BackupManifest, type RestoreIntent,
|
|
24
24
|
} from './backup';
|
|
25
25
|
|
|
26
26
|
/** Where backups live + the operator connection pg_dump/pg_restore use. */
|
|
@@ -36,6 +36,8 @@ export interface BackupSummary {
|
|
|
36
36
|
key: string;
|
|
37
37
|
bytes?: number;
|
|
38
38
|
lastModified?: string;
|
|
39
|
+
/** The live base-schema fingerprint stamped at dump time (present on B3+ backups). */
|
|
40
|
+
fingerprint?: string;
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
/**
|
|
@@ -77,19 +79,31 @@ async function getS3(region?: string): Promise<any> {
|
|
|
77
79
|
return new S3Client(region ? { region } : {});
|
|
78
80
|
}
|
|
79
81
|
|
|
82
|
+
/** A backup run's target — the id+key the DISPATCHER minted (mintBackupTarget) + its stage. */
|
|
83
|
+
export interface BackupTarget {
|
|
84
|
+
stage: string;
|
|
85
|
+
id: string;
|
|
86
|
+
key: string;
|
|
87
|
+
/**
|
|
88
|
+
* The live base-schema fingerprint at dump time (liveBaseFingerprint), stamped into the manifest so
|
|
89
|
+
* a destructive db:apply can prove `backup.fp == plan.from`. The Task computes it over the same
|
|
90
|
+
* connection it dumps. Absent → the manifest carries no fp and the safety gate refuses it (a
|
|
91
|
+
* pre-B3 or fp-less backup can't be a verified net).
|
|
92
|
+
*/
|
|
93
|
+
fingerprint?: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
80
96
|
/**
|
|
81
97
|
* pg_dump -Fc → gzip → S3 (multipart, streamed). Writes a `.meta.json` sidecar on success.
|
|
82
98
|
* Throws (after deleting the partial object) if pg_dump exits non-zero — the dump is never trusted
|
|
83
|
-
* on a successful upload alone.
|
|
99
|
+
* on a successful upload alone. The id+key are minted by the caller (the dispatcher) so the CLI
|
|
100
|
+
* knows the artifact at dispatch time; this IO never stamps its own clock into the id.
|
|
84
101
|
*/
|
|
85
|
-
export async function runBackup(deps: BackupRunDeps,
|
|
102
|
+
export async function runBackup(deps: BackupRunDeps, target: BackupTarget): Promise<BackupSummary> {
|
|
86
103
|
const { bucket, region, adminUrl } = deps;
|
|
104
|
+
const { stage, id, key, fingerprint } = target;
|
|
87
105
|
const env = { ...process.env, ...pgEnvFromUrl(adminUrl) };
|
|
88
|
-
const stamp = utcStamp(new Date());
|
|
89
|
-
const shortId = randomBytes(3).toString('hex');
|
|
90
|
-
const key = backupKey(stage, stamp, shortId);
|
|
91
106
|
const mkey = metaKey(key);
|
|
92
|
-
const id = backupId(stage, stamp, shortId);
|
|
93
107
|
|
|
94
108
|
const s3 = await getS3(region);
|
|
95
109
|
const { Upload } = await importOptionalAws(
|
|
@@ -139,13 +153,13 @@ export async function runBackup(deps: BackupRunDeps, stage: string): Promise<Bac
|
|
|
139
153
|
bytes = head.ContentLength;
|
|
140
154
|
} catch { /* size is best-effort */ }
|
|
141
155
|
|
|
142
|
-
const meta = { id, stage, createdAt: new Date().toISOString(), bytes, key };
|
|
156
|
+
const meta = { id, stage, createdAt: new Date().toISOString(), bytes, key, ...(fingerprint ? { fingerprint } : {}) };
|
|
143
157
|
await s3.send(new PutObjectCommand({
|
|
144
158
|
Bucket: bucket, Key: mkey, Body: JSON.stringify(meta),
|
|
145
159
|
ContentType: 'application/json', ServerSideEncryption: 'AES256',
|
|
146
160
|
}));
|
|
147
161
|
|
|
148
|
-
return { id, key, bytes };
|
|
162
|
+
return { id, key, bytes, ...(fingerprint ? { fingerprint } : {}) };
|
|
149
163
|
}
|
|
150
164
|
|
|
151
165
|
export interface ExportRunResult {
|
|
@@ -165,16 +179,12 @@ export interface ExportRunResult {
|
|
|
165
179
|
*/
|
|
166
180
|
export async function runExport(
|
|
167
181
|
deps: BackupRunDeps,
|
|
168
|
-
opts: { schema: string; stage: string; fingerprint: string },
|
|
182
|
+
opts: { schema: string; stage: string; fingerprint: string; id: string; key: string },
|
|
169
183
|
): Promise<ExportRunResult> {
|
|
170
184
|
const { bucket, region, adminUrl } = deps;
|
|
171
|
-
const { schema, stage, fingerprint } = opts;
|
|
185
|
+
const { schema, stage, fingerprint, id, key } = opts;
|
|
172
186
|
const env = { ...process.env, ...pgEnvFromUrl(adminUrl) };
|
|
173
|
-
const stamp = utcStamp(new Date());
|
|
174
|
-
const shortId = randomBytes(3).toString('hex');
|
|
175
|
-
const key = artifactKey(schema, stage, stamp, shortId);
|
|
176
187
|
const mkey = metaKey(key);
|
|
177
|
-
const id = artifactId(schema, stage, stamp, shortId);
|
|
178
188
|
|
|
179
189
|
const s3 = await getS3(region);
|
|
180
190
|
const { Upload } = await importOptionalAws(() => import('@aws-sdk/lib-storage'), '@aws-sdk/lib-storage', 'db:export');
|
|
@@ -219,6 +229,46 @@ export async function runExport(
|
|
|
219
229
|
return { id, key, bytes, fingerprint };
|
|
220
230
|
}
|
|
221
231
|
|
|
232
|
+
/**
|
|
233
|
+
* Fetch a backup's `.meta.json` manifest by id — the destructive-apply safety gate reads it to verify
|
|
234
|
+
* the ref. Returns null when the id is malformed or the manifest object is absent (a manifest exists
|
|
235
|
+
* ONLY on a successful dump, so absence == "no completed backup with that id"). The verify treats
|
|
236
|
+
* null as a refusal.
|
|
237
|
+
*/
|
|
238
|
+
export async function fetchBackupManifest(deps: BackupRunDeps, id: string): Promise<BackupManifest | null> {
|
|
239
|
+
const key = keyForId(id);
|
|
240
|
+
if (!key) return null;
|
|
241
|
+
const s3 = await getS3(deps.region);
|
|
242
|
+
const { GetObjectCommand } = await import('@aws-sdk/client-s3');
|
|
243
|
+
try {
|
|
244
|
+
const obj: any = await s3.send(new GetObjectCommand({ Bucket: deps.bucket, Key: metaKey(key) }));
|
|
245
|
+
if (!obj.Body) return null;
|
|
246
|
+
const text = await obj.Body.transformToString();
|
|
247
|
+
return JSON.parse(text) as BackupManifest;
|
|
248
|
+
} catch {
|
|
249
|
+
return null; // NoSuchKey / parse failure → no valid manifest
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** The stage's newest backup manifest (for the gate's auto-resolve when no --snapshot-ref is given). */
|
|
254
|
+
export async function latestBackupManifest(deps: BackupRunDeps, stage: string): Promise<BackupManifest | null> {
|
|
255
|
+
const backups = await listBackups(deps, stage); // newest first
|
|
256
|
+
if (backups.length === 0) return null;
|
|
257
|
+
return fetchBackupManifest(deps, backups[0].id);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Delete a backup artifact + its `.meta.json` — used to retract a dump whose fingerprint could not be
|
|
262
|
+
* PROVEN stable across the dump window (a concurrent DDL), so the safety gate never trusts a manifest
|
|
263
|
+
* whose fp might not match the schema the dump actually holds.
|
|
264
|
+
*/
|
|
265
|
+
export async function deleteBackup(deps: Pick<BackupRunDeps, 'bucket' | 'region'>, key: string): Promise<void> {
|
|
266
|
+
const s3 = await getS3(deps.region);
|
|
267
|
+
const { DeleteObjectCommand } = await import('@aws-sdk/client-s3');
|
|
268
|
+
await s3.send(new DeleteObjectCommand({ Bucket: deps.bucket, Key: key })).catch(() => {});
|
|
269
|
+
await s3.send(new DeleteObjectCommand({ Bucket: deps.bucket, Key: metaKey(key) })).catch(() => {});
|
|
270
|
+
}
|
|
271
|
+
|
|
222
272
|
/** List a stage's backups (newest first) from S3 object metadata — one list call, no per-item gets. */
|
|
223
273
|
export async function listBackups(deps: BackupRunDeps, stage: string): Promise<BackupSummary[]> {
|
|
224
274
|
const s3 = await getS3(deps.region);
|
|
@@ -279,6 +329,77 @@ export async function runRestore(deps: BackupRunDeps, id: string): Promise<{ id:
|
|
|
279
329
|
return { id, restored: true };
|
|
280
330
|
}
|
|
281
331
|
|
|
332
|
+
// --- Restore intent IO (M2 durable ledger — an S3 object OUTSIDE the DB being replaced) ----------
|
|
333
|
+
|
|
334
|
+
/** Write the intent object (dispatcher, BEFORE RunTask). Crash-truthful: the record exists before
|
|
335
|
+
* the restore can trash the DB. */
|
|
336
|
+
export async function putRestoreIntent(
|
|
337
|
+
deps: Pick<BackupRunDeps, 'bucket' | 'region'>,
|
|
338
|
+
key: string,
|
|
339
|
+
intent: RestoreIntent,
|
|
340
|
+
): Promise<void> {
|
|
341
|
+
const s3 = await getS3(deps.region);
|
|
342
|
+
const { PutObjectCommand } = await importOptionalAws(
|
|
343
|
+
() => import('@aws-sdk/client-s3'), '@aws-sdk/client-s3', 'db:restore',
|
|
344
|
+
);
|
|
345
|
+
await s3.send(new PutObjectCommand({
|
|
346
|
+
Bucket: deps.bucket, Key: key, Body: JSON.stringify(intent),
|
|
347
|
+
ContentType: 'application/json', ServerSideEncryption: 'AES256',
|
|
348
|
+
}));
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Read an intent object, or null when absent/unparseable. */
|
|
352
|
+
export async function getRestoreIntent(
|
|
353
|
+
deps: Pick<BackupRunDeps, 'bucket' | 'region'>,
|
|
354
|
+
key: string,
|
|
355
|
+
): Promise<RestoreIntent | null> {
|
|
356
|
+
const s3 = await getS3(deps.region);
|
|
357
|
+
const { GetObjectCommand } = await import('@aws-sdk/client-s3');
|
|
358
|
+
try {
|
|
359
|
+
const obj: any = await s3.send(new GetObjectCommand({ Bucket: deps.bucket, Key: key }));
|
|
360
|
+
if (!obj.Body) return null;
|
|
361
|
+
return JSON.parse(await obj.Body.transformToString()) as RestoreIntent;
|
|
362
|
+
} catch {
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Merge a patch into an intent object (stamp the ARN, or the terminal outcome). Best-effort. */
|
|
368
|
+
export async function stampRestoreIntent(
|
|
369
|
+
deps: Pick<BackupRunDeps, 'bucket' | 'region'>,
|
|
370
|
+
key: string,
|
|
371
|
+
patch: Partial<RestoreIntent>,
|
|
372
|
+
): Promise<void> {
|
|
373
|
+
const current = await getRestoreIntent(deps, key);
|
|
374
|
+
if (!current) return;
|
|
375
|
+
await putRestoreIntent(deps, key, { ...current, ...patch });
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** List a stage's restore intents (newest first) — the durable half of db:restores. */
|
|
379
|
+
export async function listRestoreIntents(
|
|
380
|
+
deps: Pick<BackupRunDeps, 'bucket' | 'region'>,
|
|
381
|
+
stage: string,
|
|
382
|
+
): Promise<RestoreIntent[]> {
|
|
383
|
+
const s3 = await getS3(deps.region);
|
|
384
|
+
const { ListObjectsV2Command, GetObjectCommand } = await import('@aws-sdk/client-s3');
|
|
385
|
+
const prefix = `${restoreIntentPrefix(stage)}/`;
|
|
386
|
+
const out: RestoreIntent[] = [];
|
|
387
|
+
let token: string | undefined;
|
|
388
|
+
do {
|
|
389
|
+
const res: any = await s3.send(new ListObjectsV2Command({ Bucket: deps.bucket, Prefix: prefix, ContinuationToken: token }));
|
|
390
|
+
for (const o of res.Contents ?? []) {
|
|
391
|
+
if (!o.Key?.endsWith('.json')) continue;
|
|
392
|
+
try {
|
|
393
|
+
const obj: any = await s3.send(new GetObjectCommand({ Bucket: deps.bucket, Key: o.Key }));
|
|
394
|
+
if (obj.Body) out.push(JSON.parse(await obj.Body.transformToString()) as RestoreIntent);
|
|
395
|
+
} catch { /* skip unreadable */ }
|
|
396
|
+
}
|
|
397
|
+
token = res.IsTruncated ? res.NextContinuationToken : undefined;
|
|
398
|
+
} while (token);
|
|
399
|
+
out.sort((a, b) => (b.startedAt ?? '').localeCompare(a.startedAt ?? ''));
|
|
400
|
+
return out;
|
|
401
|
+
}
|
|
402
|
+
|
|
282
403
|
/** The URL mode's precondition — a presigned http(s) download, never a path or bucket ref. */
|
|
283
404
|
export function assertRestoreUrl(url: string): void {
|
|
284
405
|
if (!/^https?:\/\//.test(url)) {
|
package/src/backup.ts
CHANGED
|
@@ -1,12 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* db:backup — logical pg_dump/pg_restore helpers (server side).
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* server's `server_version_num`. The parsing/compat logic is pure and unit-tested here; the actual
|
|
8
|
-
* spawn (which needs the pg_dump layer at runtime) is exercised by the deployed db:backup:probe.
|
|
9
|
-
* See docs/plans/db-backup-restore.md.
|
|
2
|
+
* db:backup — logical pg_dump/pg_restore helpers (server side). Pure helpers: the S3 key schemes,
|
|
3
|
+
* the PG* env decomposition, the pg_dump/pg_restore arg builders, the snapshot-ref safety gate, and
|
|
4
|
+
* the version-compat math. The pg binaries run in the ephemeral Fargate Task lane (backup-run.ts),
|
|
5
|
+
* not this Lambda; the version gate (pg_dump major >= server major — no bypass) is enforced by the
|
|
6
|
+
* Task image's handshake, verified on demand with `everystack task:probe`.
|
|
10
7
|
*/
|
|
11
8
|
|
|
12
9
|
/**
|
|
@@ -110,6 +107,135 @@ export function artifactIdForKey(dumpKey: string): string | null {
|
|
|
110
107
|
return m ? `${m[1]}/${m[2]}/${m[3]}` : null;
|
|
111
108
|
}
|
|
112
109
|
|
|
110
|
+
// --- Mint targets (dispatcher-side) -------------------------------------------------------------
|
|
111
|
+
// The ops Lambda mints the id+key from ITS clock + a random shortId and hands both to the ephemeral
|
|
112
|
+
// task (via the job) AND back to the CLI at dispatch time — so the CLI knows the artifact the moment
|
|
113
|
+
// dispatch returns, with no result-readback race. The task writes to exactly this key; nothing has
|
|
114
|
+
// to be discovered after the fact.
|
|
115
|
+
|
|
116
|
+
/** A minted artifact/backup target: the stage/schema-qualified id and the S3 dump key it maps to. */
|
|
117
|
+
export interface MintedTarget {
|
|
118
|
+
id: string;
|
|
119
|
+
key: string;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Mint a full-database backup target (db:backup). `now` is the dispatcher's clock. */
|
|
123
|
+
export function mintBackupTarget(stage: string, now: Date, shortId: string): MintedTarget {
|
|
124
|
+
const stamp = utcStamp(now);
|
|
125
|
+
return { id: backupId(stage, stamp, shortId), key: backupKey(stage, stamp, shortId) };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Mint a schema-scoped artifact target (db:export). `now` is the dispatcher's clock. */
|
|
129
|
+
export function mintExportTarget(schema: string, stage: string, now: Date, shortId: string): MintedTarget {
|
|
130
|
+
const stamp = utcStamp(now);
|
|
131
|
+
return { id: artifactId(schema, stage, stamp, shortId), key: artifactKey(schema, stage, stamp, shortId) };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// --- Snapshot-ref verification (the destructive-apply safety gate) ------------------------------
|
|
135
|
+
// A destructive db:apply must be covered by a COMPLETED, restorable, matching backup. The ops Lambda
|
|
136
|
+
// resolves the ref (an explicit --snapshot-ref id, else the stage's newest backup) and runs this pure
|
|
137
|
+
// verdict. The .meta.json is written ONLY on a successful dump (runBackup deletes the partial + never
|
|
138
|
+
// writes meta on failure), so a resolvable manifest IS the success proof. The fingerprint tie
|
|
139
|
+
// (manifest.fingerprint == plan.from) makes the guarantee provable, not merely temporal.
|
|
140
|
+
|
|
141
|
+
/** A backup's `.meta.json` — the manifest the safety gate reads. `fingerprint` added for the fp tie. */
|
|
142
|
+
export interface BackupManifest {
|
|
143
|
+
id: string;
|
|
144
|
+
stage: string;
|
|
145
|
+
/** ISO 8601. */
|
|
146
|
+
createdAt: string;
|
|
147
|
+
/** The base-schema fingerprint captured at dump time (absent on pre-B3 backups). */
|
|
148
|
+
fingerprint?: string;
|
|
149
|
+
bytes?: number;
|
|
150
|
+
key: string;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface SnapshotVerifyInput {
|
|
154
|
+
/** The resolved backup manifest, or null when none could be resolved. */
|
|
155
|
+
manifest: BackupManifest | null;
|
|
156
|
+
/** plan.fromFingerprint — the schema state the apply expects to change. */
|
|
157
|
+
planFrom: string;
|
|
158
|
+
/** The stage being changed — the backup must cover THIS database. */
|
|
159
|
+
targetStage: string;
|
|
160
|
+
now: Date;
|
|
161
|
+
/** How old a backup may be and still qualify (data-rollback bound; fp already proves schema). */
|
|
162
|
+
freshnessWindowMs: number;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export type SnapshotVerdict = { ok: true; id: string } | { ok: false; reason: string };
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Verify a resolved backup is a valid safety net for a destructive apply: it exists (⇒ succeeded),
|
|
169
|
+
* covers this stage, carries a fingerprint that equals plan.from (⇒ restores the exact state being
|
|
170
|
+
* changed), and is fresh. Every refusal names the fix (db:backup). Pure; the resolution IO lives in
|
|
171
|
+
* backup-run.ts and the enforcement in the ops db:apply action.
|
|
172
|
+
*/
|
|
173
|
+
export function verifySnapshotRef(input: SnapshotVerifyInput): SnapshotVerdict {
|
|
174
|
+
const { manifest, planFrom, targetStage, now, freshnessWindowMs } = input;
|
|
175
|
+
const takeFresh = `take a fresh backup: everystack db:backup --stage ${targetStage}, then re-run.`;
|
|
176
|
+
if (!manifest) {
|
|
177
|
+
return { ok: false, reason: `no backup found for stage ${targetStage} — ${takeFresh}` };
|
|
178
|
+
}
|
|
179
|
+
if (manifest.stage !== targetStage) {
|
|
180
|
+
return { ok: false, reason: `snapshot ${manifest.id} is for stage ${manifest.stage}, not ${targetStage} — a snapshot must cover the database being changed. ${takeFresh}` };
|
|
181
|
+
}
|
|
182
|
+
if (!manifest.fingerprint) {
|
|
183
|
+
return { ok: false, reason: `snapshot ${manifest.id} predates fingerprint stamping — ${takeFresh}` };
|
|
184
|
+
}
|
|
185
|
+
if (manifest.fingerprint !== planFrom) {
|
|
186
|
+
return { ok: false, reason: `snapshot ${manifest.id} captured schema ${manifest.fingerprint.slice(0, 12)}, but this plan starts from ${planFrom.slice(0, 12)} — it would not restore the state being changed. ${takeFresh}` };
|
|
187
|
+
}
|
|
188
|
+
const ageMs = now.getTime() - Date.parse(manifest.createdAt);
|
|
189
|
+
if (!(ageMs >= 0) || ageMs > freshnessWindowMs) {
|
|
190
|
+
const mins = Math.round(ageMs / 60_000);
|
|
191
|
+
return { ok: false, reason: `snapshot ${manifest.id} is ${mins} min old (freshness window ${Math.round(freshnessWindowMs / 60_000)} min) — ${takeFresh}` };
|
|
192
|
+
}
|
|
193
|
+
return { ok: true, id: manifest.id };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// --- Restore intent (M2: the durable ledger for a DB-REPLACING restore) -------------------------
|
|
197
|
+
// A restore pg_restores a full backup with --clean, drop+recreating the app schemas — so its own
|
|
198
|
+
// audit record is written OUTSIDE the DB, as a durable S3 object under `restores/<stage>/`, by the
|
|
199
|
+
// dispatcher BEFORE RunTask. That guarantees forensics regardless of how the restore leaves the DB
|
|
200
|
+
// (partial, corrupt, or a hard-killed task): "a restore of X was attempted on Y at Z" always
|
|
201
|
+
// survives. (The everystack ops schema itself is excluded from the dump, so its own ledgers persist
|
|
202
|
+
// across a restore — but the restore's record still belongs in S3, not in the DB it's rewriting.)
|
|
203
|
+
// db:restores reads these + the live ECS status.
|
|
204
|
+
|
|
205
|
+
/** The S3 prefix a stage's restore intents live under (admin-only, encrypted; OUTSIDE the app DB). */
|
|
206
|
+
export function restoreIntentPrefix(stage: string): string {
|
|
207
|
+
return `restores/${stage}`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** The S3 object key for a restore intent. `stamp` is a UTC `YYYYMMDDTHHMMSSZ` string. */
|
|
211
|
+
export function restoreIntentKey(stage: string, stamp: string, shortId: string): string {
|
|
212
|
+
return `${restoreIntentPrefix(stage)}/${stamp}-${shortId}.json`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** A restore intent's stage-qualified id (what db:restores displays): `dev/20260723T120000Z-ab12cd`. */
|
|
216
|
+
export function restoreIntentId(stage: string, stamp: string, shortId: string): string {
|
|
217
|
+
return `${stage}/${stamp}-${shortId}`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Mint a restore intent target (dispatcher-side): the S3 key + the display id. */
|
|
221
|
+
export function mintRestoreIntent(stage: string, now: Date, shortId: string): { intentKey: string; intentId: string } {
|
|
222
|
+
const stamp = utcStamp(now);
|
|
223
|
+
return { intentKey: restoreIntentKey(stage, stamp, shortId), intentId: restoreIntentId(stage, stamp, shortId) };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** The M2 intent record — the crash-truthful "what was attempted", plus the outcome once known. */
|
|
227
|
+
export interface RestoreIntent {
|
|
228
|
+
intentId: string;
|
|
229
|
+
stage: string;
|
|
230
|
+
/** The restore source: a backup id in-stage, or a presigned cross-stage URL (never both). */
|
|
231
|
+
source: { id?: string; url?: string };
|
|
232
|
+
actor?: string | null;
|
|
233
|
+
startedAt: string;
|
|
234
|
+
taskArn?: string;
|
|
235
|
+
status?: 'started' | 'running' | 'succeeded' | 'failed';
|
|
236
|
+
finishedAt?: string;
|
|
237
|
+
}
|
|
238
|
+
|
|
113
239
|
/** Format a Date as the UTC `YYYYMMDDTHHMMSSZ` stamp (caller's clock; pure). */
|
|
114
240
|
export function utcStamp(date: Date): string {
|
|
115
241
|
const p = (n: number): string => String(n).padStart(2, '0');
|
|
@@ -155,9 +281,18 @@ export function dbNameFromUrl(url: string): string {
|
|
|
155
281
|
*/
|
|
156
282
|
export function pgDumpArgs(opts: { schema?: string } = {}): string[] {
|
|
157
283
|
const args = ['-Fc', '--no-owner', '--no-privileges', '--no-comments'];
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
284
|
+
if (opts.schema) {
|
|
285
|
+
// A schema-scoped artifact dumps ONE schema (the canonical-sync export). pg_dump's --schema
|
|
286
|
+
// takes a pattern; a plain identifier scopes to exactly that schema and its objects.
|
|
287
|
+
args.push(`--schema=${opts.schema}`);
|
|
288
|
+
} else {
|
|
289
|
+
// A full backup EXCLUDES the framework ops schema: everystack.* holds the OPERATIONAL ledgers
|
|
290
|
+
// (task_log, schema_log, exec_log, backfill_log, derived_provenance) — the TARGET's audit and
|
|
291
|
+
// migration history, not app data. Restoring them would rewind that history AND makes
|
|
292
|
+
// pg_restore --clean fail (DROP SCHEMA everystack can't run: its ledger tables depend on it,
|
|
293
|
+
// non-CASCADE). Excluding it keeps a restore to app data and preserves the target's ops history.
|
|
294
|
+
args.push('--exclude-schema=everystack');
|
|
295
|
+
}
|
|
161
296
|
return args;
|
|
162
297
|
}
|
|
163
298
|
|
|
@@ -168,24 +303,3 @@ export function pgDumpArgs(opts: { schema?: string } = {}): string[] {
|
|
|
168
303
|
export function pgRestoreArgs(dbName: string): string[] {
|
|
169
304
|
return ['--clean', '--if-exists', '--no-owner', '--no-privileges', '--no-comments', '-d', dbName];
|
|
170
305
|
}
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* Spawn `<bin> --version` and return its trimmed stdout (e.g. "pg_dump (PostgreSQL) 16.9").
|
|
174
|
-
* Rejects when the binary is missing — which, in the ops Lambda, means the pg_dump layer is not
|
|
175
|
-
* attached. `bin` defaults to `pg_dump`, resolved on PATH (the layer lands at /opt/bin, on PATH).
|
|
176
|
-
*/
|
|
177
|
-
export async function pgClientVersion(bin = 'pg_dump'): Promise<string> {
|
|
178
|
-
const { spawn } = await import('node:child_process');
|
|
179
|
-
return await new Promise<string>((resolve, reject) => {
|
|
180
|
-
let out = '';
|
|
181
|
-
let err = '';
|
|
182
|
-
const child = spawn(bin, ['--version']);
|
|
183
|
-
child.stdout.on('data', (d) => { out += d.toString(); });
|
|
184
|
-
child.stderr.on('data', (d) => { err += d.toString(); });
|
|
185
|
-
child.on('error', (e) => reject(e));
|
|
186
|
-
child.on('close', (code) => {
|
|
187
|
-
if (code === 0) resolve(out.trim());
|
|
188
|
-
else reject(new Error(`${bin} --version exited ${code}: ${err.trim()}`));
|
|
189
|
-
});
|
|
190
|
-
});
|
|
191
|
-
}
|
package/src/db-doctor.ts
CHANGED
|
@@ -87,20 +87,6 @@ export interface DoctorProbe {
|
|
|
87
87
|
* an older probe (the check is then skipped). B4.
|
|
88
88
|
*/
|
|
89
89
|
darkRelations?: DarkRelation[];
|
|
90
|
-
/**
|
|
91
|
-
* pg_dump layer capability: whether @everystack/pg-tools is attached (pg_dump present) and its
|
|
92
|
-
* major can dump this server (client >= server). Drives the one-line "backups" lens — a missing
|
|
93
|
-
* layer blocks db:backup AND the snapshot-first destructive apply. Absent on an older probe (the
|
|
94
|
-
* check is then skipped). See docs/plans/pgdump-layer.md.
|
|
95
|
-
*/
|
|
96
|
-
backup?: {
|
|
97
|
-
/** `pg_dump --version` output, or null when the layer is not attached. */
|
|
98
|
-
pgDump: string | null;
|
|
99
|
-
pgDumpMajor: number | null;
|
|
100
|
-
serverMajor: number | null;
|
|
101
|
-
/** pgDumpMajor >= serverMajor — a client can dump an older-or-equal server, never a newer one. */
|
|
102
|
-
compatible: boolean;
|
|
103
|
-
};
|
|
104
90
|
}
|
|
105
91
|
|
|
106
92
|
/** A function PUBLIC can EXECUTE, and whether it runs as its owner (the dangerous half). */
|
|
@@ -558,32 +544,8 @@ export function buildDoctorReport(probe: DoctorProbe, options: DoctorReportOptio
|
|
|
558
544
|
}
|
|
559
545
|
}
|
|
560
546
|
|
|
561
|
-
//
|
|
562
|
-
//
|
|
563
|
-
if (probe.backup) {
|
|
564
|
-
const b = probe.backup;
|
|
565
|
-
if (b.pgDump && b.compatible) {
|
|
566
|
-
checks.push({
|
|
567
|
-
name: 'Logical backups available (pg_dump layer)',
|
|
568
|
-
status: 'pass',
|
|
569
|
-
detail: `pg_dump major ${b.pgDumpMajor} attached and compatible with server major ${b.serverMajor} — db:backup and the snapshot-first destructive apply are ready`,
|
|
570
|
-
});
|
|
571
|
-
} else if (b.pgDump && !b.compatible) {
|
|
572
|
-
checks.push({
|
|
573
|
-
name: 'Logical backups available (pg_dump layer)',
|
|
574
|
-
status: 'warn',
|
|
575
|
-
detail: `pg_dump major ${b.pgDumpMajor} is older than server major ${b.serverMajor} — it will refuse to dump (no bypass exists)`,
|
|
576
|
-
remediation: 'Upgrade @everystack/pg-tools to advance the client ceiling to >= the server major, then redeploy.',
|
|
577
|
-
});
|
|
578
|
-
} else {
|
|
579
|
-
checks.push({
|
|
580
|
-
name: 'Logical backups available (pg_dump layer)',
|
|
581
|
-
status: 'warn',
|
|
582
|
-
detail: 'pg_dump layer not attached — db:backup and the snapshot-first destructive apply are unavailable',
|
|
583
|
-
remediation: 'Install @everystack/pg-tools and redeploy (pgDumpLayer attaches it automatically), or pass a bring-your-own layerArn in sst.config.ts.',
|
|
584
|
-
});
|
|
585
|
-
}
|
|
586
|
-
}
|
|
547
|
+
// (No backups lens: pg-binary backup/restore run in the ephemeral Fargate Task lane, not the ops
|
|
548
|
+
// Lambda. Verify the Task image's pg_dump-vs-server compatibility with `everystack task:probe`.)
|
|
587
549
|
|
|
588
550
|
return {
|
|
589
551
|
api,
|
|
@@ -610,9 +572,6 @@ export interface DoctorProbeRunners {
|
|
|
610
572
|
export interface RunDoctorProbeOptions extends DoctorReportOptions {
|
|
611
573
|
/** Names the api credential source in the connect-failure message (e.g. 'DATABASE_URL'). */
|
|
612
574
|
apiSourceLabel?: string;
|
|
613
|
-
/** The backups lens (pg_dump layer) probe — Lambda-only. Omitted on the direct venue,
|
|
614
|
-
* where there is no layer concept, so the lens is simply not shown. */
|
|
615
|
-
backup?: () => Promise<DoctorProbe['backup']>;
|
|
616
575
|
}
|
|
617
576
|
|
|
618
577
|
const quoteDoctorIdent = (s: string): string => '"' + String(s).replace(/"/g, '""') + '"';
|
|
@@ -667,10 +626,8 @@ export async function runDoctorProbe(
|
|
|
667
626
|
}
|
|
668
627
|
}
|
|
669
628
|
|
|
670
|
-
const backup = options.backup ? await options.backup() : undefined;
|
|
671
|
-
|
|
672
629
|
return buildDoctorReport(
|
|
673
|
-
{ api, admin, forceRls, appGrantedRls, apiAmbientRead, secdefFunctions, publicExecutableFunctions, darkRelations
|
|
630
|
+
{ api, admin, forceRls, appGrantedRls, apiAmbientRead, secdefFunctions, publicExecutableFunctions, darkRelations },
|
|
674
631
|
{ forceRlsCarveouts: options.forceRlsCarveouts, functionSchemas: options.functionSchemas },
|
|
675
632
|
);
|
|
676
633
|
}
|
package/src/plugin.ts
CHANGED
|
@@ -729,34 +729,8 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
729
729
|
const apiRun = async (q: string): Promise<any[]> => rowsOf(await apiDb.execute(sql.raw(q)));
|
|
730
730
|
const opsRun = async (q: string): Promise<any[]> => rowsOf(await opsDb.execute(sql.raw(q)));
|
|
731
731
|
|
|
732
|
-
//
|
|
733
|
-
//
|
|
734
|
-
// CLI direct venue, where there is no layer.
|
|
735
|
-
const backup = async (): Promise<import('./db-doctor').DoctorProbe['backup']> => {
|
|
736
|
-
try {
|
|
737
|
-
const { pgClientVersion, parsePgDumpMajor, serverMajorFromNum, isDumpCompatible } = await import('./backup');
|
|
738
|
-
let pgDump: string | null = null;
|
|
739
|
-
let pgDumpMajor: number | null = null;
|
|
740
|
-
try {
|
|
741
|
-
pgDump = await pgClientVersion('pg_dump');
|
|
742
|
-
pgDumpMajor = parsePgDumpMajor(pgDump);
|
|
743
|
-
} catch {
|
|
744
|
-
// layer absent — pgDump stays null, the lens reports "not attached"
|
|
745
|
-
}
|
|
746
|
-
const svnRow = rowsOf(await opsDb.execute(sql.raw('SHOW server_version_num')))[0];
|
|
747
|
-
const svn = Number(svnRow?.server_version_num);
|
|
748
|
-
const serverMajor = Number.isFinite(svn) ? serverMajorFromNum(svn) : null;
|
|
749
|
-
return {
|
|
750
|
-
pgDump,
|
|
751
|
-
pgDumpMajor,
|
|
752
|
-
serverMajor,
|
|
753
|
-
compatible: pgDumpMajor != null && Number.isFinite(svn) ? isDumpCompatible(pgDumpMajor, svn) : false,
|
|
754
|
-
};
|
|
755
|
-
} catch {
|
|
756
|
-
return undefined; // leave backup out → the lens is skipped, like the other optional probes
|
|
757
|
-
}
|
|
758
|
-
};
|
|
759
|
-
|
|
732
|
+
// No backups lens: pg-binary work runs in the ephemeral Fargate Task lane, not the ops Lambda.
|
|
733
|
+
// Verify the Task image's pg_dump-vs-server compatibility with `everystack task:probe`.
|
|
760
734
|
try {
|
|
761
735
|
return await runDoctorProbe(
|
|
762
736
|
{ api: apiRun, ops: opsRun },
|
|
@@ -764,7 +738,6 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
764
738
|
apiSourceLabel: 'DATABASE_URL',
|
|
765
739
|
forceRlsCarveouts: options.forceRlsCarveouts,
|
|
766
740
|
functionSchemas: options.functionSchemas,
|
|
767
|
-
backup,
|
|
768
741
|
},
|
|
769
742
|
);
|
|
770
743
|
} catch (err: any) {
|
|
@@ -820,55 +793,13 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
820
793
|
);
|
|
821
794
|
};
|
|
822
795
|
|
|
823
|
-
// --- db:backup:probe — is the pg_dump layer attached, and does it match the server? ---
|
|
824
|
-
// The version gate for logical backups: pg_dump must be >= the server's MAJOR (no bypass flag
|
|
825
|
-
// exists). Returns the layer's pg_dump version + the live server_version_num so a deploy can
|
|
826
|
-
// confirm the layer is present AND version-compatible before db:backup is trusted. See backup.ts.
|
|
827
|
-
actions['db:backup:probe'] = async () => {
|
|
828
|
-
const { pgClientVersion, parsePgDumpMajor, serverMajorFromNum, isDumpCompatible } = await import('./backup');
|
|
829
|
-
const { sql } = await import('drizzle-orm');
|
|
830
|
-
|
|
831
|
-
let pgDump: string;
|
|
832
|
-
try {
|
|
833
|
-
pgDump = await pgClientVersion('pg_dump');
|
|
834
|
-
} catch (err: any) {
|
|
835
|
-
return { error: `pg_dump not available — the pg_dump layer is not attached. Install @everystack/pg-tools and redeploy (pgDumpLayer() attaches it automatically), or pass a bring-your-own layerArn in sst.config.ts. (${err?.message || String(err)})` };
|
|
836
|
-
}
|
|
837
|
-
const pgDumpMajor = parsePgDumpMajor(pgDump);
|
|
838
|
-
|
|
839
|
-
const rows: any = await opsDb.execute(sql.raw('SHOW server_version_num'));
|
|
840
|
-
const row = (Array.isArray(rows) ? rows : rows?.rows ?? [])[0];
|
|
841
|
-
const serverVersionNum = Number(row?.server_version_num);
|
|
842
|
-
const serverMajor = Number.isFinite(serverVersionNum) ? serverMajorFromNum(serverVersionNum) : null;
|
|
843
|
-
|
|
844
|
-
return {
|
|
845
|
-
pgDump,
|
|
846
|
-
pgDumpMajor,
|
|
847
|
-
serverVersionNum: Number.isFinite(serverVersionNum) ? serverVersionNum : null,
|
|
848
|
-
serverMajor,
|
|
849
|
-
compatible: pgDumpMajor != null && Number.isFinite(serverVersionNum)
|
|
850
|
-
? isDumpCompatible(pgDumpMajor, serverVersionNum)
|
|
851
|
-
: false,
|
|
852
|
-
};
|
|
853
|
-
};
|
|
854
796
|
|
|
855
|
-
// --- db:
|
|
856
|
-
//
|
|
857
|
-
//
|
|
858
|
-
//
|
|
797
|
+
// --- db:backups / db:restore — list + restore logical backups ---
|
|
798
|
+
// db:backup and db:export are pg_dump work and now run in the ephemeral Task lane
|
|
799
|
+
// (@everystack/jobs dbTaskPlugin) — the ops Lambda holds no pg binaries. db:backups is a plain
|
|
800
|
+
// S3 list; db:restore drives pg_restore in-Lambda until Phase 3b moves it to the Task. Restore is
|
|
801
|
+
// destructive and requires an explicit confirm in the payload (the CLI sets it). See backup-run.ts.
|
|
859
802
|
{
|
|
860
|
-
actions['db:backup'] = async (payload, ctx) => {
|
|
861
|
-
const r = await resolveBackupDeps();
|
|
862
|
-
if ('error' in r) return { error: r.error };
|
|
863
|
-
const stage = ((payload ?? {}) as { stage?: string }).stage ?? ctx.environment ?? 'default';
|
|
864
|
-
const { runBackup } = await import('./backup-run');
|
|
865
|
-
try {
|
|
866
|
-
return await runBackup(r.deps, stage);
|
|
867
|
-
} catch (err: any) {
|
|
868
|
-
return { error: err?.message || String(err) };
|
|
869
|
-
}
|
|
870
|
-
};
|
|
871
|
-
|
|
872
803
|
actions['db:backups'] = async (payload, ctx) => {
|
|
873
804
|
const r = await resolveBackupDeps();
|
|
874
805
|
if ('error' in r) return { error: r.error };
|
|
@@ -881,40 +812,9 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
881
812
|
}
|
|
882
813
|
};
|
|
883
814
|
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
if (id && url) return { error: 'db:restore takes { id } or { url }, not both' };
|
|
888
|
-
if (confirm !== true) {
|
|
889
|
-
return { error: 'db:restore is destructive (drops + replaces the target DB). Pass confirm:true to proceed.' };
|
|
890
|
-
}
|
|
891
|
-
const r = await resolveBackupDeps();
|
|
892
|
-
if ('error' in r) return { error: r.error };
|
|
893
|
-
const { runRestore, runRestoreFromUrl } = await import('./backup-run');
|
|
894
|
-
try {
|
|
895
|
-
return url ? await runRestoreFromUrl(r.deps, url) : await runRestore(r.deps, id!);
|
|
896
|
-
} catch (err: any) {
|
|
897
|
-
return { error: err?.message || String(err) };
|
|
898
|
-
}
|
|
899
|
-
};
|
|
900
|
-
|
|
901
|
-
// --- db:export — the schema-scoped, fingerprint-stamped artifact (canonical-sync gap B) ---
|
|
902
|
-
// pg_dump ONE schema → S3 artifact carrying the schema fingerprint the CLI computed from the
|
|
903
|
-
// same live schema (one formula both sides). db:swap gates on that fingerprint before it lands.
|
|
904
|
-
actions['db:export'] = async (payload, ctx) => {
|
|
905
|
-
const { schema, fingerprint } = (payload ?? {}) as { schema?: string; fingerprint?: string };
|
|
906
|
-
const stage = ((payload ?? {}) as { stage?: string }).stage ?? ctx.environment ?? 'default';
|
|
907
|
-
if (!schema) return { error: 'db:export requires { schema }' };
|
|
908
|
-
if (!fingerprint) return { error: 'db:export requires { fingerprint } — the CLI computes the scoped schema fingerprint so the artifact and the swap gate agree' };
|
|
909
|
-
const r = await resolveBackupDeps();
|
|
910
|
-
if ('error' in r) return { error: r.error };
|
|
911
|
-
const { runExport } = await import('./backup-run');
|
|
912
|
-
try {
|
|
913
|
-
return await runExport(r.deps, { schema, stage, fingerprint });
|
|
914
|
-
} catch (err: any) {
|
|
915
|
-
return { error: err?.message || String(err) };
|
|
916
|
-
}
|
|
917
|
-
};
|
|
815
|
+
// db:restore moved to the ephemeral Task lane (@everystack/jobs dbTaskPlugin) — it runs
|
|
816
|
+
// pg_restore, and the ops Lambda holds no pg binaries. Its intent is a durable S3 object (M2),
|
|
817
|
+
// since a restore replaces the DB task_log lives in.
|
|
918
818
|
}
|
|
919
819
|
|
|
920
820
|
// --- db:apply — the credential-free staged write (Phase 2) ---
|
|
@@ -928,7 +828,7 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
928
828
|
// direct --database-url path runs, only here the connection never leaves the
|
|
929
829
|
// Lambda. A destructive plan snapshots first (db:backup), in order.
|
|
930
830
|
actions['db:apply'] = async (payload, ctx) => {
|
|
931
|
-
const { plan, actor, gitRef, descentVerdict, authorityVerdict, stage } =
|
|
831
|
+
const { plan, actor, gitRef, descentVerdict, authorityVerdict, stage, snapshotRef } =
|
|
932
832
|
(payload ?? {}) as {
|
|
933
833
|
plan?: any;
|
|
934
834
|
actor?: string | null;
|
|
@@ -936,6 +836,8 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
936
836
|
descentVerdict?: { ok: boolean; reason?: string };
|
|
937
837
|
authorityVerdict?: { ok: boolean; reason?: string };
|
|
938
838
|
stage?: string;
|
|
839
|
+
/** An explicit backup id to verify; absent → the gate auto-resolves the stage's newest. */
|
|
840
|
+
snapshotRef?: string;
|
|
939
841
|
};
|
|
940
842
|
if (!plan || typeof plan !== 'object' || !Array.isArray(plan.statements)) {
|
|
941
843
|
return { error: 'db:apply requires { plan } — a reviewed edge minted by db:plan' };
|
|
@@ -952,6 +854,10 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
952
854
|
};
|
|
953
855
|
|
|
954
856
|
const destructive = Number(plan.destructive) > 0;
|
|
857
|
+
const snapStage = stage ?? ctx.environment ?? 'default';
|
|
858
|
+
// How old a backup may be and still auto-qualify as the safety net (the fp match already proves
|
|
859
|
+
// schema identity; this only bounds how much data a restore would roll back).
|
|
860
|
+
const SNAPSHOT_FRESHNESS_MS = 60 * 60_000;
|
|
955
861
|
let snapshotId: string | undefined;
|
|
956
862
|
try {
|
|
957
863
|
const result = await executeApplyPlan(runner, plan, {
|
|
@@ -965,19 +871,27 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
965
871
|
...(destructive && authorityVerdict
|
|
966
872
|
? { verifyAuthority: async () => (authorityVerdict.ok ? { ok: true as const } : { ok: false as const, reason: authorityVerdict.reason ?? 'authority refused' }) }
|
|
967
873
|
: {}),
|
|
968
|
-
//
|
|
969
|
-
//
|
|
970
|
-
//
|
|
874
|
+
// The B3 safety gate: a destructive apply must be covered by a VERIFIED backup — the apply
|
|
875
|
+
// no longer takes its own. Resolve the ref (explicit id, else the stage's newest) and verify
|
|
876
|
+
// it SERVER-SIDE (never a CLI-attested boolean): succeeded, this stage, fresh, and
|
|
877
|
+
// fingerprint == plan.from. A miss is recorded in schema_log by executeApplyPlan.
|
|
971
878
|
...(destructive
|
|
972
879
|
? {
|
|
973
|
-
|
|
880
|
+
verifySnapshot: async ({ planFrom }: { planFrom: string }) => {
|
|
974
881
|
const r = await resolveBackupDeps();
|
|
975
|
-
if ('error' in r) {
|
|
976
|
-
|
|
977
|
-
}
|
|
978
|
-
const
|
|
979
|
-
|
|
980
|
-
|
|
882
|
+
if ('error' in r) return { ok: false as const, reason: `snapshot gate: ${r.error}` };
|
|
883
|
+
const { fetchBackupManifest, latestBackupManifest } = await import('./backup-run');
|
|
884
|
+
const { verifySnapshotRef } = await import('./backup');
|
|
885
|
+
const manifest = snapshotRef
|
|
886
|
+
? await fetchBackupManifest(r.deps, snapshotRef)
|
|
887
|
+
: await latestBackupManifest(r.deps, snapStage);
|
|
888
|
+
const verdict = verifySnapshotRef({
|
|
889
|
+
manifest, planFrom, targetStage: snapStage,
|
|
890
|
+
now: new Date(), freshnessWindowMs: SNAPSHOT_FRESHNESS_MS,
|
|
891
|
+
});
|
|
892
|
+
if (!verdict.ok) return { ok: false as const, reason: verdict.reason };
|
|
893
|
+
snapshotId = verdict.id;
|
|
894
|
+
return { ok: true as const };
|
|
981
895
|
},
|
|
982
896
|
}
|
|
983
897
|
: {}),
|
|
@@ -1089,6 +1003,68 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
1089
1003
|
}
|
|
1090
1004
|
};
|
|
1091
1005
|
|
|
1006
|
+
// --- db:exec — credential-free DML-only write SQL, ONE transaction, catalog-digest guarded ---
|
|
1007
|
+
// The daily driver: `psql < file.sql --stage`, but credential-free. It runs on the SAME ops
|
|
1008
|
+
// connection db:seed uses (opsDb) — no new role, no BYPASSRLS, no superuser. The file runs in
|
|
1009
|
+
// one transaction; a catalog digest taken BEFORE vs AFTER (schema-relevant catalogs, app
|
|
1010
|
+
// schemas) rejects ANY schema change — however caused (a function, a DO block, dynamic SQL) —
|
|
1011
|
+
// by rolling the whole txn back. The ledger (everystack.exec_log) is crash-truthful: the
|
|
1012
|
+
// intent row commits on its own, BEFORE the run, so a mid-flight death still records what was
|
|
1013
|
+
// attempted; the outcome (status + per-statement rows-affected) is written after.
|
|
1014
|
+
actions['db:exec'] = async (payload, _ctx) => {
|
|
1015
|
+
const p = (payload ?? {}) as { sql?: string; actor?: string | null; stage?: string | null };
|
|
1016
|
+
if (!p.sql || typeof p.sql !== 'string') {
|
|
1017
|
+
return { error: 'db:exec needs a SQL file body (payload.sql).' };
|
|
1018
|
+
}
|
|
1019
|
+
const fileSql = p.sql;
|
|
1020
|
+
const schemas = options.schemas ?? ['public'];
|
|
1021
|
+
try {
|
|
1022
|
+
const {
|
|
1023
|
+
executeExec, runExecInTx,
|
|
1024
|
+
ENSURE_EXEC_LOG_SQL, renderExecIntentInsert, renderExecOutcomeUpdate,
|
|
1025
|
+
} = await import('@everystack/cli/exec');
|
|
1026
|
+
const { sql } = await import('drizzle-orm');
|
|
1027
|
+
|
|
1028
|
+
// The ledger writes on the ops connection but OUTSIDE the file's transaction (each
|
|
1029
|
+
// execute auto-commits), so the intent row survives a rollback or a mid-flight death.
|
|
1030
|
+
const audit = async (query: string): Promise<any[]> => {
|
|
1031
|
+
const result: any = await opsDb.execute(sql.raw(query));
|
|
1032
|
+
return Array.isArray(result) ? result : result?.rows ?? [];
|
|
1033
|
+
};
|
|
1034
|
+
|
|
1035
|
+
return await executeExec({
|
|
1036
|
+
sql: fileSql,
|
|
1037
|
+
actor: p.actor ?? 'unknown',
|
|
1038
|
+
stage: p.stage ?? 'unknown',
|
|
1039
|
+
writeIntent: async (intent) => {
|
|
1040
|
+
for (const ddl of ENSURE_EXEC_LOG_SQL) await audit(ddl);
|
|
1041
|
+
const rows = await audit(renderExecIntentInsert({ sha: intent.sha, actor: intent.actor, stage: intent.stage }));
|
|
1042
|
+
return String(rows[0].id);
|
|
1043
|
+
},
|
|
1044
|
+
run: () =>
|
|
1045
|
+
opsDb.transaction(async (tx: any) => {
|
|
1046
|
+
const txRun = async (stmt: string) => {
|
|
1047
|
+
const r: any = await tx.execute(sql.raw(stmt));
|
|
1048
|
+
const rows = Array.isArray(r) ? r : r?.rows ?? [];
|
|
1049
|
+
const count =
|
|
1050
|
+
typeof r?.count === 'number' ? r.count
|
|
1051
|
+
: typeof r?.rowCount === 'number' ? r.rowCount
|
|
1052
|
+
: rows.length;
|
|
1053
|
+
return { rows, count };
|
|
1054
|
+
};
|
|
1055
|
+
return runExecInTx(txRun, fileSql, { schemas });
|
|
1056
|
+
}),
|
|
1057
|
+
writeOutcome: async (id, outcome) => {
|
|
1058
|
+
await audit(renderExecOutcomeUpdate(id, outcome));
|
|
1059
|
+
},
|
|
1060
|
+
});
|
|
1061
|
+
} catch (err: any) {
|
|
1062
|
+
// A DML-only violation (SchemaChangedError) is a clean rejection — the txn already rolled
|
|
1063
|
+
// back and the outcome is recorded 'failed'. Surface the message, don't crash the plane.
|
|
1064
|
+
return { error: err?.message || String(err) };
|
|
1065
|
+
}
|
|
1066
|
+
};
|
|
1067
|
+
|
|
1092
1068
|
// --- db:refresh — the credential-free DATA lane's refresh half (the reconcile-lane twin) ---
|
|
1093
1069
|
// db:reconcile deploys the derived DEFINITION but no-ops on an unchanged def, so a pure
|
|
1094
1070
|
// DATA-changed matview refresh had no credential-free path. This runs the refresh HERE on
|
package/src/ssr.ts
CHANGED
|
@@ -69,6 +69,15 @@ export interface WebHandlerOptions {
|
|
|
69
69
|
/** Inject __EXPO_ROUTER_HYDRATE__ flag before </head>. Default: true. */
|
|
70
70
|
injectHydrateFlag?: boolean;
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Opaque the expo-router loader-data blob so search crawlers can't harvest
|
|
74
|
+
* URL-shaped strings (route keys like `/stories/index`, image/link values)
|
|
75
|
+
* out of the inline hydration script. Default: true. Runtime behaviour is
|
|
76
|
+
* unchanged — the decoded globalThis value is byte-identical. See
|
|
77
|
+
* deharvestLoaderData.
|
|
78
|
+
*/
|
|
79
|
+
deharvestLoaderData?: boolean;
|
|
80
|
+
|
|
72
81
|
/**
|
|
73
82
|
* App-specific HTML post-processing. Called after built-in processing
|
|
74
83
|
* (param stripping, hydrate flag). Receives the processed HTML and the
|
|
@@ -82,6 +91,59 @@ export interface WebHandlerOptions {
|
|
|
82
91
|
const DEFAULT_STRIP_PARAMS = ['_v', '_a'];
|
|
83
92
|
const HYDRATE_SCRIPT = '<script>globalThis.__EXPO_ROUTER_HYDRATE__=true;</script>\n';
|
|
84
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Matches expo-router's `<PreloadedDataScript />` output. The renderer emits
|
|
96
|
+
* exactly:
|
|
97
|
+
*
|
|
98
|
+
* <script id="expo-router-data" type="module">
|
|
99
|
+
* globalThis.__EXPO_ROUTER_LOADER_DATA__ = JSON.parse("<escaped-json>");
|
|
100
|
+
* </script>
|
|
101
|
+
*
|
|
102
|
+
* Group 1 = the opening tag, group 2 = the double-quoted JS string literal
|
|
103
|
+
* argument. The literal is HTML-safe (expo escapes <, >, & to \uXXXX), so a
|
|
104
|
+
* `</script>` terminator never appears inside it.
|
|
105
|
+
*/
|
|
106
|
+
const LOADER_DATA_SCRIPT_RE =
|
|
107
|
+
/(<script id="expo-router-data"[^>]*>globalThis\.__EXPO_ROUTER_LOADER_DATA__\s*=\s*JSON\.parse\()("(?:[^"\\]|\\.)*")(\);<\/script>)/;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Rewrite the expo-router loader-data script so its payload is opaque in the
|
|
111
|
+
* raw HTML while the executed value is byte-identical.
|
|
112
|
+
*
|
|
113
|
+
* expo-router keys the loader map by the route FILE path, so index routes get a
|
|
114
|
+
* `/index` suffix (`/stories/index`). Googlebot harvests URL-shaped strings
|
|
115
|
+
* from inline JS, resolves those same-origin, crawls `/stories/index`, and
|
|
116
|
+
* 404s. The key cannot be renamed: expo-router's client resolver
|
|
117
|
+
* (useLoaderData) looks the data up by the identical `/index` key, so any
|
|
118
|
+
* SSR-only rename mismatches on hydration (white flash).
|
|
119
|
+
*
|
|
120
|
+
* The one server-only fix that removes the harvestable text AND preserves the
|
|
121
|
+
* exact key: base64-encode the JSON payload. No URL-shaped substring survives
|
|
122
|
+
* in the source for the crawler, and at runtime `atob` + TextDecoder + JSON.parse
|
|
123
|
+
* reproduces the same object with the same keys — the client is untouched. This
|
|
124
|
+
* also opaques URL-shaped *values* (image/CDN links inside loader data), killing
|
|
125
|
+
* the whole harvest class, not just `/index` keys.
|
|
126
|
+
*
|
|
127
|
+
* Fail-safe: if the script is absent or doesn't match the expected shape (e.g.
|
|
128
|
+
* an expo-router format change), the HTML is returned unchanged. Idempotent —
|
|
129
|
+
* the rewritten script no longer matches the string-literal form.
|
|
130
|
+
*/
|
|
131
|
+
export function deharvestLoaderData(html: string): string {
|
|
132
|
+
return html.replace(LOADER_DATA_SCRIPT_RE, (full, open: string, literal: string, close: string) => {
|
|
133
|
+
let jsonText: string;
|
|
134
|
+
try {
|
|
135
|
+
// The literal is valid JSON string syntax; parsing yields the JSON text
|
|
136
|
+
// the client would otherwise receive verbatim.
|
|
137
|
+
jsonText = JSON.parse(literal) as string;
|
|
138
|
+
} catch {
|
|
139
|
+
return full; // unexpected shape — leave untouched
|
|
140
|
+
}
|
|
141
|
+
const b64 = Buffer.from(jsonText, 'utf8').toString('base64');
|
|
142
|
+
const decode = `new TextDecoder().decode(Uint8Array.from(atob(${JSON.stringify(b64)}),c=>c.charCodeAt(0)))`;
|
|
143
|
+
return `${open}${decode}${close}`;
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
85
147
|
/**
|
|
86
148
|
* Wraps an SSR request handler with framework-level fixes:
|
|
87
149
|
*
|
|
@@ -91,7 +153,10 @@ const HYDRATE_SCRIPT = '<script>globalThis.__EXPO_ROUTER_HYDRATE__=true;</script
|
|
|
91
153
|
* Native Web uses `hydrateRoot()` instead of `createRoot()`.
|
|
92
154
|
* (React 19 suppresses <script> tags from SSR output, so this must
|
|
93
155
|
* be done via string replacement after rendering.)
|
|
94
|
-
* 3.
|
|
156
|
+
* 3. Opaques the expo-router loader-data blob so crawlers can't harvest its
|
|
157
|
+
* URL-shaped route keys (`/{path}/index`) and 404 on them. See
|
|
158
|
+
* deharvestLoaderData.
|
|
159
|
+
* 4. Calls an optional `postProcessHtml` hook for app-specific transforms
|
|
95
160
|
* (e.g., JSON-LD injection, status code override from meta tags).
|
|
96
161
|
*
|
|
97
162
|
* @param innerHandler - The expo-server request handler
|
|
@@ -100,11 +165,12 @@ const HYDRATE_SCRIPT = '<script>globalThis.__EXPO_ROUTER_HYDRATE__=true;</script
|
|
|
100
165
|
*/
|
|
101
166
|
export function wrapSsrHandler(
|
|
102
167
|
innerHandler: (request: Request) => Promise<Response>,
|
|
103
|
-
options?: Pick<WebHandlerOptions, 'stripQueryParams' | 'injectHydrateFlag' | 'postProcessHtml'>,
|
|
168
|
+
options?: Pick<WebHandlerOptions, 'stripQueryParams' | 'injectHydrateFlag' | 'deharvestLoaderData' | 'postProcessHtml'>,
|
|
104
169
|
metadata?: ReleaseMetadata,
|
|
105
170
|
): (request: Request) => Promise<Response> {
|
|
106
171
|
const stripParams = options?.stripQueryParams ?? DEFAULT_STRIP_PARAMS;
|
|
107
172
|
const injectHydrate = options?.injectHydrateFlag !== false;
|
|
173
|
+
const deharvest = options?.deharvestLoaderData !== false;
|
|
108
174
|
const postProcess = options?.postProcessHtml;
|
|
109
175
|
|
|
110
176
|
return async (request: Request): Promise<Response> => {
|
|
@@ -149,6 +215,12 @@ export function wrapSsrHandler(
|
|
|
149
215
|
html = html.replace('</head>', `${HYDRATE_SCRIPT}</head>`);
|
|
150
216
|
}
|
|
151
217
|
|
|
218
|
+
// Opaque the loader-data blob so crawlers can't harvest its URL-shaped
|
|
219
|
+
// route keys (index routes leak `/{path}/index`). No-op if absent.
|
|
220
|
+
if (deharvest) {
|
|
221
|
+
html = deharvestLoaderData(html);
|
|
222
|
+
}
|
|
223
|
+
|
|
152
224
|
// 3. App-specific post-processing
|
|
153
225
|
if (postProcess) {
|
|
154
226
|
const result = await postProcess(html, request);
|
package/src/infra.ts
DELETED
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @everystack/server/infra — deploy-time helpers for a consumer's `sst.config.ts`.
|
|
3
|
-
*
|
|
4
|
-
* `pgDumpLayer()` attaches the PostgreSQL client-tools Lambda layer that `db:backup`/`db:restore`
|
|
5
|
-
* (and the snapshot-first apply ceremony) need. It replaces the old `PGDUMP_LAYER_ARN` env-var line
|
|
6
|
-
* with a zero-config, declarative resource: the framework ships the binaries (@everystack/pg-tools),
|
|
7
|
-
* and this publishes them as a content-addressed LayerVersion into the CONSUMER's own account.
|
|
8
|
-
*
|
|
9
|
-
* Declarative, not imperative: it returns a Pulumi-managed resource's arn (or an escape-hatch
|
|
10
|
-
* string), so the SST/Pulumi engine owns the layer's lifecycle — no SDK side effects at config-eval,
|
|
11
|
-
* no untracked state, clean teardown. Same artifact sha => same layer name => redeploys are no-ops.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import { manifest, layerZipPath } from '@everystack/pg-tools';
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* SST's ambient config globals, injected so this package typechecks standalone (it must not depend on
|
|
18
|
-
* SST's per-app generated types). In `sst.config.ts` you already have both — pass `{ aws, $util }`.
|
|
19
|
-
* Typed loosely on purpose; the shapes are Pulumi's and not worth vendoring here.
|
|
20
|
-
*/
|
|
21
|
-
export interface PgDumpLayerContext {
|
|
22
|
-
/** SST's global `aws` (@pulumi/aws) — for `aws.lambda.LayerVersion`. */
|
|
23
|
-
aws: any;
|
|
24
|
-
/** SST's global `$util` (@pulumi/pulumi) — for `$util.asset.FileArchive`. */
|
|
25
|
-
$util: any;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export interface PgDumpLayerOptions {
|
|
29
|
-
/**
|
|
30
|
-
* The stage's PostgreSQL server major. The shipped client must be >= this (pg_dump/pg_restore are
|
|
31
|
-
* backward compatible; a client older than the server is refused). Read it from the engine you declare.
|
|
32
|
-
*/
|
|
33
|
-
pgMajor: number;
|
|
34
|
-
/**
|
|
35
|
-
* Escape hatch: attach this exact layer ARN instead of the managed publish. For consumers who build
|
|
36
|
-
* their own (e.g. x86_64, an exotic major) via tools/pgdump-layer. Outranks everything else.
|
|
37
|
-
*/
|
|
38
|
-
layerArn?: string;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
let warnedEnv = false;
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Resolve and attach the pg_dump layer. Returns the ARN to spread into a Function's `layers: []`.
|
|
45
|
-
*
|
|
46
|
-
* Precedence: explicit `layerArn` > `PGDUMP_LAYER_ARN` (deprecated alias, warns once) > the managed
|
|
47
|
-
* content-addressed LayerVersion published into this account.
|
|
48
|
-
*
|
|
49
|
-
* @example
|
|
50
|
-
* import { pgDumpLayer } from '@everystack/server/infra';
|
|
51
|
-
* const ops = new sst.aws.Function('Ops', {
|
|
52
|
-
* layers: [pgDumpLayer({ aws, $util }, 'PgDump', { pgMajor: 16 })],
|
|
53
|
-
* // ...
|
|
54
|
-
* });
|
|
55
|
-
*/
|
|
56
|
-
export function pgDumpLayer(
|
|
57
|
-
ctx: PgDumpLayerContext,
|
|
58
|
-
name: string,
|
|
59
|
-
opts: PgDumpLayerOptions,
|
|
60
|
-
): any /* Input<string> — the layer arn; `any` at the Pulumi interop boundary so it spreads into layers:[] */ {
|
|
61
|
-
// 1) explicit escape hatch wins.
|
|
62
|
-
if (opts.layerArn) return opts.layerArn;
|
|
63
|
-
|
|
64
|
-
// 2) deprecated env alias — still honored for one minor line, but warned.
|
|
65
|
-
const envArn = process.env.PGDUMP_LAYER_ARN;
|
|
66
|
-
if (envArn) {
|
|
67
|
-
if (!warnedEnv) {
|
|
68
|
-
warnedEnv = true;
|
|
69
|
-
console.warn(
|
|
70
|
-
'[everystack] PGDUMP_LAYER_ARN is deprecated and will be removed in the next minor. ' +
|
|
71
|
-
'Install @everystack/pg-tools and drop the env var (the layer is published automatically), ' +
|
|
72
|
-
'or pass pgDumpLayer(ctx, name, { layerArn }) for a bring-your-own layer.',
|
|
73
|
-
);
|
|
74
|
-
}
|
|
75
|
-
return envArn;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// 3) the managed publish: a content-addressed LayerVersion in the consumer's own account.
|
|
79
|
-
if (manifest.major < opts.pgMajor) {
|
|
80
|
-
throw new Error(
|
|
81
|
-
`@everystack/pg-tools ships pg${manifest.major} client tools, but this stage's server is ` +
|
|
82
|
-
`pg${opts.pgMajor}. pg_dump/pg_restore must be >= the server. Upgrade @everystack/pg-tools ` +
|
|
83
|
-
`to advance the client ceiling, or pass a bring-your-own layerArn.`,
|
|
84
|
-
);
|
|
85
|
-
}
|
|
86
|
-
const sha8 = manifest.sha256.slice(0, 8);
|
|
87
|
-
const layer = new ctx.aws.lambda.LayerVersion(name, {
|
|
88
|
-
// content-addressed: same artifact => same name => Pulumi treats redeploys as no-ops.
|
|
89
|
-
layerName: `everystack-pgdump${manifest.major}-${sha8}`,
|
|
90
|
-
code: new ctx.$util.asset.FileArchive(layerZipPath()),
|
|
91
|
-
compatibleRuntimes: ['nodejs20.x'],
|
|
92
|
-
compatibleArchitectures: [manifest.arch],
|
|
93
|
-
description: `everystack pg_dump/pg_restore — pg${manifest.postgresql}, sha ${sha8}`,
|
|
94
|
-
});
|
|
95
|
-
return layer.arn;
|
|
96
|
-
}
|