backupdrill 1.0.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -7
- package/dist/backup.js +47 -0
- package/dist/drill.d.ts +8 -5
- package/dist/drill.js +27 -96
- package/dist/index.js +100 -20
- package/dist/lib.d.ts +1 -1
- package/dist/manifest.d.ts +32 -1
- package/dist/manifest.js +157 -3
- package/dist/recovery-doc.d.ts +11 -0
- package/dist/recovery-doc.js +123 -0
- package/dist/restore-engine.d.ts +80 -0
- package/dist/restore-engine.js +267 -0
- package/dist/restore.d.ts +65 -2
- package/dist/restore.js +515 -23
- package/dist/storage-catalog.d.ts +19 -0
- package/dist/storage-catalog.js +50 -0
- package/dist/storage-restore.d.ts +79 -0
- package/dist/storage-restore.js +634 -0
- package/dist/storage.d.ts +16 -1
- package/dist/storage.js +33 -2
- package/package.json +1 -1
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
// Storage 真实回传(恢复闭环 PRD §5.4):bucket 重建 → 文件上传 → 逐键对账 → 抽样校验和。
|
|
2
|
+
// 通道 = 目标项目 Storage HTTP API + service-role key(R0 spike 1 实证:bucket 三属性
|
|
3
|
+
// 与 contentType/cacheControl/x-metadata 全保真;InvalidKey 是单文件失败不中断)。
|
|
4
|
+
// 重试幂等 = 上传前预检目标对象:同 key 同尺寸跳过、尺寸不同拒绝覆盖——不用 x-upsert,
|
|
5
|
+
// 静默覆盖在任何路径都不可能发生(评审第 11 轮)。对账用 list API 递归遍历目标(不依赖目标 DB
|
|
6
|
+
// 凭据,storage-only 恢复也有完整对账),**逐 bucket/key 比对 + 尺寸核对**——聚合数的
|
|
7
|
+
// >= 比较会被多余对象掩蔽缺失(评审第 8 轮)。
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import { Readable } from "node:stream";
|
|
10
|
+
import { GetObjectCommand } from "@aws-sdk/client-s3";
|
|
11
|
+
import { sampleStorageFiles } from "./drill.js";
|
|
12
|
+
import { log } from "./log.js";
|
|
13
|
+
// PRD §5.4.2:默认小并发、有界超时与重试,均有安全上限
|
|
14
|
+
const UPLOAD_CONCURRENCY = 4;
|
|
15
|
+
const CHECKSUM_SAMPLE_CAP = 10;
|
|
16
|
+
const API_TIMEOUT_MS = 120_000;
|
|
17
|
+
const UPLOAD_TIMEOUT_MS = 10 * 60_000;
|
|
18
|
+
const MAX_ATTEMPTS = 3;
|
|
19
|
+
function storageHeaders(key) {
|
|
20
|
+
return { Authorization: `Bearer ${key}`, apikey: key };
|
|
21
|
+
}
|
|
22
|
+
function isRetryable(status) {
|
|
23
|
+
return status === 429 || status >= 500;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* 带超时与有界重试的 Storage API 调用。只用于幂等操作(upsert 上传、list、GET、
|
|
27
|
+
* 带 already-exists 处理的创建)——非幂等操作不得经过这里。
|
|
28
|
+
*/
|
|
29
|
+
async function storageApi(target, method, path, init = {}, counters) {
|
|
30
|
+
let lastError = null;
|
|
31
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
32
|
+
try {
|
|
33
|
+
const res = await fetch(`${target.supabaseUrl}/storage/v1${path}`, {
|
|
34
|
+
method,
|
|
35
|
+
headers: { ...storageHeaders(target.serviceRoleKey), ...init.headers },
|
|
36
|
+
body: typeof init.body === "function" ? init.body() : init.body,
|
|
37
|
+
signal: AbortSignal.timeout(init.timeoutMs ?? API_TIMEOUT_MS),
|
|
38
|
+
// @ts-expect-error Node fetch 流式请求体必须声明 half-duplex
|
|
39
|
+
duplex: init.duplex,
|
|
40
|
+
});
|
|
41
|
+
if (isRetryable(res.status) && attempt < MAX_ATTEMPTS) {
|
|
42
|
+
lastError = new Error(`HTTP ${res.status}`);
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
if (attempt > 1 && counters)
|
|
46
|
+
counters.retries += 1;
|
|
47
|
+
return res;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
// 超时/网络层错误:可重试
|
|
52
|
+
lastError = error;
|
|
53
|
+
if (attempt === MAX_ATTEMPTS)
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
await new Promise((r) => setTimeout(r, 1000 * attempt * attempt));
|
|
57
|
+
}
|
|
58
|
+
throw lastError ?? new Error("unreachable");
|
|
59
|
+
}
|
|
60
|
+
/** bucket 名单 = manifest.buckets(含空桶)∪ files 里出现过的桶(v1 快照没有 buckets 字段) */
|
|
61
|
+
export function bucketsToRestore(manifest) {
|
|
62
|
+
const storage = manifest.storage;
|
|
63
|
+
const fromAttrs = new Map((storage.buckets ?? []).map((b) => [b.name, true]));
|
|
64
|
+
for (const f of storage.files)
|
|
65
|
+
if (!fromAttrs.has(f.bucket))
|
|
66
|
+
fromAttrs.set(f.bucket, false);
|
|
67
|
+
return [...fromAttrs].map(([name, attrs]) => ({ name, attrs }));
|
|
68
|
+
}
|
|
69
|
+
/** 只读探测:桶存在返回属性;确证不存在返回 null;其余失败抛错(不可验证 = 不动)。 */
|
|
70
|
+
async function probeBucket(target, name, summary) {
|
|
71
|
+
const info = await storageApi(target, "GET", `/bucket/${encodeURIComponent(name)}`, {}, summary);
|
|
72
|
+
if (info.ok)
|
|
73
|
+
return (await info.json());
|
|
74
|
+
const text = await info.text();
|
|
75
|
+
if (info.status === 404 || /not found/i.test(text))
|
|
76
|
+
return null;
|
|
77
|
+
throw new Error(`cannot probe bucket "${name}" (HTTP ${info.status}) — refusing to touch an unverifiable target`);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* 校验桶计划(零写入):所有安全裁决在任何创建/上传之前完成(评审第 15 轮 SF1)。
|
|
81
|
+
* 返回是否需要创建。抛错的情形:无属性快照的桶在目标缺失(手动回退,PRD §10.4)、
|
|
82
|
+
* 无属性快照撞上公开既有桶(上传即公开暴露)、public 漂移、属性不可读。
|
|
83
|
+
*/
|
|
84
|
+
function validateBucketPlan(name, attrs, existing, summary) {
|
|
85
|
+
if (!attrs) {
|
|
86
|
+
summary.bucketsWithoutAttrs.push(name);
|
|
87
|
+
if (!existing) {
|
|
88
|
+
throw new Error(`bucket "${name}" has no recorded attributes in this snapshot (pre-2.0 backup) and does not ` +
|
|
89
|
+
`exist on the target — create it manually with the visibility/limits you know, then re-run.`);
|
|
90
|
+
}
|
|
91
|
+
summary.bucketsExisting.push(name);
|
|
92
|
+
if (existing.public) {
|
|
93
|
+
// 评审第 15 轮:无属性快照 + 公开既有桶 → 上传即把可能私有的备份数据公开,
|
|
94
|
+
// 必须在写第一个字节之前拒绝,而不是传完了在退出码里认错
|
|
95
|
+
throw new Error(`existing bucket "${name}" is PUBLIC and this snapshot has no recorded attributes — uploading ` +
|
|
96
|
+
`would make restored files publicly readable. Make the bucket private (or restore into a ` +
|
|
97
|
+
`fresh project), then re-run.`);
|
|
98
|
+
}
|
|
99
|
+
return { create: false };
|
|
100
|
+
}
|
|
101
|
+
if (!existing)
|
|
102
|
+
return { create: true };
|
|
103
|
+
summary.bucketsExisting.push(name);
|
|
104
|
+
// public 是安全属性:漂移 = 硬失败(私有文件进公开桶是数据暴露,不是"配置口味")
|
|
105
|
+
if (attrs.public !== null && attrs.public !== undefined && existing.public !== attrs.public) {
|
|
106
|
+
throw new Error(`existing bucket "${name}" is ${existing.public ? "PUBLIC" : "private"} but the snapshot ` +
|
|
107
|
+
`recorded it as ${attrs.public ? "public" : "PRIVATE"} — fix the bucket's visibility ` +
|
|
108
|
+
`(or restore into a fresh project) before uploading files into it`);
|
|
109
|
+
}
|
|
110
|
+
const drift = [];
|
|
111
|
+
if (attrs.fileSizeLimit !== null &&
|
|
112
|
+
attrs.fileSizeLimit !== undefined &&
|
|
113
|
+
Number(existing.file_size_limit) !== attrs.fileSizeLimit)
|
|
114
|
+
drift.push(`file_size_limit ${existing.file_size_limit} ≠ manifest ${attrs.fileSizeLimit}`);
|
|
115
|
+
if (attrs.allowedMimeTypes !== null &&
|
|
116
|
+
attrs.allowedMimeTypes !== undefined &&
|
|
117
|
+
JSON.stringify([...(existing.allowed_mime_types ?? [])].sort()) !==
|
|
118
|
+
JSON.stringify([...attrs.allowedMimeTypes].sort()))
|
|
119
|
+
drift.push(`allowed_mime_types ${JSON.stringify(existing.allowed_mime_types)} ≠ manifest ${JSON.stringify(attrs.allowedMimeTypes)}`);
|
|
120
|
+
if (drift.length)
|
|
121
|
+
summary.bucketAttrDrift.push(`${name}: ${drift.join("; ")}`);
|
|
122
|
+
return { create: false };
|
|
123
|
+
}
|
|
124
|
+
/** 创建缺失桶(净度门通过后才会走到)。计划后出现的 409 = 并发竞态,如实失败。 */
|
|
125
|
+
async function createBucket(target, attrs, name, summary) {
|
|
126
|
+
const payload = { name };
|
|
127
|
+
if (attrs.public !== null && attrs.public !== undefined)
|
|
128
|
+
payload.public = attrs.public;
|
|
129
|
+
if (attrs.fileSizeLimit !== null && attrs.fileSizeLimit !== undefined)
|
|
130
|
+
payload.file_size_limit = attrs.fileSizeLimit;
|
|
131
|
+
if (attrs.allowedMimeTypes !== null && attrs.allowedMimeTypes !== undefined)
|
|
132
|
+
payload.allowed_mime_types = attrs.allowedMimeTypes;
|
|
133
|
+
const res = await storageApi(target, "POST", "/bucket", { body: JSON.stringify(payload), headers: { "Content-Type": "application/json" } }, summary);
|
|
134
|
+
if (!res.ok) {
|
|
135
|
+
throw new Error(`create bucket "${name}" failed: HTTP ${res.status} ${(await res.text()).slice(0, 160)}` +
|
|
136
|
+
(res.status === 409 ? " (bucket appeared concurrently — re-run to re-validate)" : ""));
|
|
137
|
+
}
|
|
138
|
+
summary.bucketsCreated.push(name);
|
|
139
|
+
}
|
|
140
|
+
// Supabase 标准上传的官方上限;超过则走 TUS 断点续传(PRD §5.4.2 大文件要求)
|
|
141
|
+
export const STANDARD_UPLOAD_LIMIT_BYTES = 5 * 1024 * 1024 * 1024;
|
|
142
|
+
// Supabase TUS 约定块大小:除末块外必须恰为 6MB
|
|
143
|
+
const TUS_CHUNK_BYTES = 6 * 1024 * 1024;
|
|
144
|
+
/**
|
|
145
|
+
* TUS 启用阈值:默认 = 6MB(Supabase 官方对 resumable 的建议线)——大文件从零重传
|
|
146
|
+
* 的标准上传既脆弱又贵;env 可覆盖(验证时压低,特殊网络环境可调高)。
|
|
147
|
+
*/
|
|
148
|
+
export function tusThresholdBytes() {
|
|
149
|
+
const env = Number(process.env.BACKUPDRILL_TUS_THRESHOLD ?? "");
|
|
150
|
+
const chosen = Number.isFinite(env) && env > 0 ? env : TUS_CHUNK_BYTES;
|
|
151
|
+
// 钳制在标准上传硬上限内:env 调高也不能把 >5GB 文件送进必败的标准上传
|
|
152
|
+
return Math.min(chosen, STANDARD_UPLOAD_LIMIT_BYTES);
|
|
153
|
+
}
|
|
154
|
+
async function collectBody(body) {
|
|
155
|
+
const chunks = [];
|
|
156
|
+
for await (const chunk of body)
|
|
157
|
+
chunks.push(Buffer.from(chunk));
|
|
158
|
+
return Buffer.concat(chunks);
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* TUS 断点续传上传(Supabase resumable 端点):创建 → 按 6MB 块 PATCH,块数据经
|
|
162
|
+
* S3 Range 读取(可重取);offset 不符/瞬时失败 → HEAD 重新对准后续传。
|
|
163
|
+
* 每块独立有界重试,整文件不从零重来——这正是它存在的意义。
|
|
164
|
+
*/
|
|
165
|
+
async function uploadViaTus(source, target, file, summary) {
|
|
166
|
+
const b64 = (s) => Buffer.from(s).toString("base64");
|
|
167
|
+
// TUS 通道保真面比标准上传窄:cacheControl 只能传 max-age 秒数,contentEncoding
|
|
168
|
+
// 无对应槽位——降级必须如实上报,不静默(评审第 14 轮)
|
|
169
|
+
if (file.cacheControl && file.cacheControl.replace(/max-age=\d+/, "").replace(/[,\s]/g, "")) {
|
|
170
|
+
summary.metadataNotes.push(`${file.bucket}/${file.key}: Cache-Control directives beyond max-age not preserved over resumable upload ("${file.cacheControl}")`);
|
|
171
|
+
}
|
|
172
|
+
if (file.contentEncoding) {
|
|
173
|
+
summary.metadataNotes.push(`${file.bucket}/${file.key}: contentEncoding "${file.contentEncoding}" not preserved over resumable upload`);
|
|
174
|
+
}
|
|
175
|
+
const metaParts = [`bucketName ${b64(file.bucket)}`, `objectName ${b64(file.key)}`];
|
|
176
|
+
if (file.contentType)
|
|
177
|
+
metaParts.push(`contentType ${b64(file.contentType)}`);
|
|
178
|
+
if (file.cacheControl) {
|
|
179
|
+
// Supabase 的 tus 生命周期把 cacheControl 当秒数解析,非数字整体回落 no-cache
|
|
180
|
+
// (实测:max-age=600 经 TUS 落成 no-cache)——只送数字部分;解析不出则不送
|
|
181
|
+
const maxAge = file.cacheControl.match(/max-age=(\d+)/)?.[1];
|
|
182
|
+
if (maxAge)
|
|
183
|
+
metaParts.push(`cacheControl ${b64(maxAge)}`);
|
|
184
|
+
}
|
|
185
|
+
if (file.metadata)
|
|
186
|
+
metaParts.push(`metadata ${b64(JSON.stringify(file.metadata))}`);
|
|
187
|
+
let create = null;
|
|
188
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
189
|
+
try {
|
|
190
|
+
create = await fetch(`${target.supabaseUrl}/storage/v1/upload/resumable`, {
|
|
191
|
+
method: "POST",
|
|
192
|
+
headers: {
|
|
193
|
+
...storageHeaders(target.serviceRoleKey),
|
|
194
|
+
"Tus-Resumable": "1.0.0",
|
|
195
|
+
"Upload-Length": String(file.bytes),
|
|
196
|
+
"Upload-Metadata": metaParts.join(","),
|
|
197
|
+
},
|
|
198
|
+
signal: AbortSignal.timeout(API_TIMEOUT_MS),
|
|
199
|
+
});
|
|
200
|
+
if (!isRetryable(create.status) || attempt === MAX_ATTEMPTS)
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
catch (error) {
|
|
204
|
+
if (attempt === MAX_ATTEMPTS)
|
|
205
|
+
throw error;
|
|
206
|
+
create = null;
|
|
207
|
+
}
|
|
208
|
+
summary.retries += 1;
|
|
209
|
+
await new Promise((r) => setTimeout(r, 1000 * attempt * attempt));
|
|
210
|
+
}
|
|
211
|
+
if (!create || create.status !== 201) {
|
|
212
|
+
throw new Error(`TUS creation failed: HTTP ${create?.status ?? "network error"} ${create ? (await create.text()).slice(0, 160) : ""}`);
|
|
213
|
+
}
|
|
214
|
+
const location = create.headers.get("location");
|
|
215
|
+
if (!location)
|
|
216
|
+
throw new Error("TUS creation returned no Location header");
|
|
217
|
+
const uploadUrl = new URL(location, target.supabaseUrl).toString();
|
|
218
|
+
// Location 可能是任意绝对 URL,而后续 PATCH/HEAD 会带 service key——凭据只许发往
|
|
219
|
+
// 已验证的目标源(与 validateStorageTargetOrigin 同一约束)
|
|
220
|
+
if (new URL(uploadUrl).origin !== target.supabaseUrl) {
|
|
221
|
+
throw new Error(`TUS Location resolved outside the verified target origin (${new URL(uploadUrl).origin}) — refusing to send credentials there`);
|
|
222
|
+
}
|
|
223
|
+
let offset = 0;
|
|
224
|
+
let chunkAttempts = 0; // 每前进一步清零:重试预算属于"当前卡住的位置",不是整个文件
|
|
225
|
+
while (offset < file.bytes) {
|
|
226
|
+
const end = Math.min(offset + TUS_CHUNK_BYTES, file.bytes) - 1;
|
|
227
|
+
// 源侧 Range 读同样有界重试:一次 S3 瞬断不该报废一个大文件的续传
|
|
228
|
+
let chunk = null;
|
|
229
|
+
for (let readAttempt = 1; readAttempt <= MAX_ATTEMPTS; readAttempt++) {
|
|
230
|
+
try {
|
|
231
|
+
const got = await source.s3.send(new GetObjectCommand({
|
|
232
|
+
Bucket: source.bucket,
|
|
233
|
+
Key: `${source.base}/storage/${file.bucket}/${file.key}`,
|
|
234
|
+
Range: `bytes=${offset}-${end}`,
|
|
235
|
+
}));
|
|
236
|
+
chunk = await collectBody(got.Body);
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
catch (error) {
|
|
240
|
+
if (readAttempt === MAX_ATTEMPTS)
|
|
241
|
+
throw error;
|
|
242
|
+
summary.retries += 1;
|
|
243
|
+
await new Promise((r) => setTimeout(r, 1000 * readAttempt * readAttempt));
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (chunk === null)
|
|
247
|
+
throw new Error("unreachable: range read retry exhausted");
|
|
248
|
+
let res = null;
|
|
249
|
+
try {
|
|
250
|
+
res = await fetch(uploadUrl, {
|
|
251
|
+
method: "PATCH",
|
|
252
|
+
headers: {
|
|
253
|
+
...storageHeaders(target.serviceRoleKey),
|
|
254
|
+
"Tus-Resumable": "1.0.0",
|
|
255
|
+
"Upload-Offset": String(offset),
|
|
256
|
+
"Content-Type": "application/offset+octet-stream",
|
|
257
|
+
},
|
|
258
|
+
body: new Uint8Array(chunk),
|
|
259
|
+
signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
res = null; // 超时/网络错:统一走下方的重试/对准路径,只计一次失败
|
|
264
|
+
}
|
|
265
|
+
if (res && (res.status === 204 || res.status === 200)) {
|
|
266
|
+
const header = Number(res.headers.get("upload-offset") ?? Number.NaN);
|
|
267
|
+
const newOffset = Number.isFinite(header) ? header : offset + chunk.length;
|
|
268
|
+
if (newOffset > offset && newOffset <= file.bytes) {
|
|
269
|
+
offset = newOffset;
|
|
270
|
+
chunkAttempts = 0;
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
// 成功状态却没有推进(或越界的 offset):坏网关/代理形态——落进下方的
|
|
274
|
+
// 有界重试与 HEAD 对准,绝不原地死循环(评审第 15 轮)
|
|
275
|
+
}
|
|
276
|
+
if (res && !isRetryable(res.status) && res.status !== 409) {
|
|
277
|
+
throw new Error(`TUS chunk failed: HTTP ${res.status} ${(await res.text()).slice(0, 160)}`);
|
|
278
|
+
}
|
|
279
|
+
chunkAttempts += 1;
|
|
280
|
+
if (chunkAttempts >= MAX_ATTEMPTS) {
|
|
281
|
+
throw new Error(`TUS upload gave up after ${MAX_ATTEMPTS} attempts at offset ${offset}`);
|
|
282
|
+
}
|
|
283
|
+
// 断点续传的核心:HEAD 问服务端"你收到哪了",从那里继续,不从零重来。
|
|
284
|
+
// HEAD 自身的瞬断不额外惩罚:留在同一 offset,由 chunkAttempts 预算兜底
|
|
285
|
+
try {
|
|
286
|
+
const head = await fetch(uploadUrl, {
|
|
287
|
+
method: "HEAD",
|
|
288
|
+
headers: { ...storageHeaders(target.serviceRoleKey), "Tus-Resumable": "1.0.0" },
|
|
289
|
+
signal: AbortSignal.timeout(API_TIMEOUT_MS),
|
|
290
|
+
});
|
|
291
|
+
if (head.ok) {
|
|
292
|
+
const serverOffset = Number(head.headers.get("upload-offset") ?? Number.NaN);
|
|
293
|
+
if (Number.isFinite(serverOffset) && serverOffset > offset) {
|
|
294
|
+
offset = serverOffset;
|
|
295
|
+
chunkAttempts = 0; // 服务端有进展 = 没卡住
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
/* HEAD 瞬断:保持当前 offset,下轮重试 */
|
|
301
|
+
}
|
|
302
|
+
summary.retries += 1;
|
|
303
|
+
await new Promise((r) => setTimeout(r, 1000 * chunkAttempts * chunkAttempts));
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
async function uploadFile(source, target, file, summary) {
|
|
307
|
+
// 大文件走 TUS:标准上传 5GB 封顶且整文件重试;TUS 分块可续传(PRD §5.4.2)
|
|
308
|
+
if (file.bytes > tusThresholdBytes()) {
|
|
309
|
+
return uploadViaTus(source, target, file, summary);
|
|
310
|
+
}
|
|
311
|
+
// 不用 x-upsert(评审第 11 轮):目标既有对象的处置在上传前已裁决(同尺寸跳过/
|
|
312
|
+
// 冲突拒绝),此处冲突只剩并发竞态 → 409 如实失败,绝不静默覆盖
|
|
313
|
+
const headers = {
|
|
314
|
+
"Content-Length": String(file.bytes), // 流式体 + 显式长度,不整文件驻留内存
|
|
315
|
+
};
|
|
316
|
+
if (file.contentType)
|
|
317
|
+
headers["Content-Type"] = file.contentType;
|
|
318
|
+
if (file.cacheControl)
|
|
319
|
+
headers["Cache-Control"] = file.cacheControl;
|
|
320
|
+
if (file.contentEncoding) {
|
|
321
|
+
// 尽力转发,但当前 Supabase 不持久化该头(R0 补充实证)——降级如实上报,不静默
|
|
322
|
+
headers["Content-Encoding"] = file.contentEncoding;
|
|
323
|
+
summary.metadataNotes.push(`${file.bucket}/${file.key}: contentEncoding "${file.contentEncoding}" forwarded but not persisted ` +
|
|
324
|
+
`by current Supabase Storage — verify serving behavior manually`);
|
|
325
|
+
}
|
|
326
|
+
if (file.metadata) {
|
|
327
|
+
headers["x-metadata"] = Buffer.from(JSON.stringify(file.metadata)).toString("base64");
|
|
328
|
+
}
|
|
329
|
+
const encodedKey = file.key.split("/").map(encodeURIComponent).join("/");
|
|
330
|
+
// 流式上传自管重试(不走 storageApi 的通用重试):每次尝试都必须从源桶重新取流
|
|
331
|
+
// ——上一次的流已被消费。同样有界、同样计入 retries。
|
|
332
|
+
let lastError = null;
|
|
333
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
334
|
+
try {
|
|
335
|
+
const got = await source.s3.send(new GetObjectCommand({
|
|
336
|
+
Bucket: source.bucket,
|
|
337
|
+
Key: `${source.base}/storage/${file.bucket}/${file.key}`,
|
|
338
|
+
}));
|
|
339
|
+
const res = await fetch(`${target.supabaseUrl}/storage/v1/object/${encodeURIComponent(file.bucket)}/${encodedKey}`, {
|
|
340
|
+
method: "POST",
|
|
341
|
+
headers: { ...storageHeaders(target.serviceRoleKey), ...headers },
|
|
342
|
+
body: Readable.toWeb(got.Body),
|
|
343
|
+
signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
|
|
344
|
+
// @ts-expect-error half-duplex for streaming body
|
|
345
|
+
duplex: "half",
|
|
346
|
+
});
|
|
347
|
+
if (res.ok) {
|
|
348
|
+
if (attempt > 1)
|
|
349
|
+
summary.retries += 1;
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
const text = await res.text();
|
|
353
|
+
if (!isRetryable(res.status) || attempt === MAX_ATTEMPTS) {
|
|
354
|
+
throw new Error(`HTTP ${res.status} ${text.slice(0, 160)}`);
|
|
355
|
+
}
|
|
356
|
+
lastError = new Error(`HTTP ${res.status}`);
|
|
357
|
+
}
|
|
358
|
+
catch (error) {
|
|
359
|
+
if (error.message.startsWith("HTTP "))
|
|
360
|
+
throw error;
|
|
361
|
+
// 超时/网络层错误:可重试
|
|
362
|
+
lastError = error;
|
|
363
|
+
if (attempt === MAX_ATTEMPTS)
|
|
364
|
+
throw error;
|
|
365
|
+
}
|
|
366
|
+
await new Promise((r) => setTimeout(r, 1000 * attempt * attempt));
|
|
367
|
+
}
|
|
368
|
+
throw lastError ?? new Error("unreachable");
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* 递归遍历目标 bucket 的全部对象(list API 是目录级的:id === null 的条目是"目录")。
|
|
372
|
+
* 返回 key → size。不依赖目标 DB 凭据 → storage-only 恢复也有完整对账。
|
|
373
|
+
*/
|
|
374
|
+
export async function walkTargetObjects(target, bucket, counters) {
|
|
375
|
+
const out = new Map();
|
|
376
|
+
const walk = async (prefix) => {
|
|
377
|
+
let offset = 0;
|
|
378
|
+
for (;;) {
|
|
379
|
+
const res = await storageApi(target, "POST", `/object/list/${encodeURIComponent(bucket)}`, {
|
|
380
|
+
body: JSON.stringify({ prefix, limit: 1000, offset }),
|
|
381
|
+
headers: { "Content-Type": "application/json" },
|
|
382
|
+
}, counters);
|
|
383
|
+
if (!res.ok)
|
|
384
|
+
throw new Error(`list ${bucket}/${prefix} failed: HTTP ${res.status}`);
|
|
385
|
+
const items = (await res.json());
|
|
386
|
+
for (const item of items) {
|
|
387
|
+
const full = prefix ? `${prefix}/${item.name}` : item.name;
|
|
388
|
+
if (item.id === null)
|
|
389
|
+
await walk(full);
|
|
390
|
+
else
|
|
391
|
+
out.set(full, Number(item.metadata?.size ?? Number.NaN));
|
|
392
|
+
}
|
|
393
|
+
if (items.length < 1000)
|
|
394
|
+
break;
|
|
395
|
+
offset += items.length;
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
await walk("");
|
|
399
|
+
return out;
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* 写前净度检查(纯函数,可测):既有桶里只允许"本快照的同尺寸残留"。
|
|
403
|
+
* 任何外来对象或尺寸冲突都在**写第一个字节之前**整体拒绝——部分写入的脏目标
|
|
404
|
+
* 比干净失败难收拾得多(评审第 14 轮)。
|
|
405
|
+
*/
|
|
406
|
+
export function targetResidueViolations(files, preexisting) {
|
|
407
|
+
const violations = [];
|
|
408
|
+
// 一次建索引(bucket\n key 作键,\n 不会出现在桶名里):大残留集的重试不能是二次方
|
|
409
|
+
const index = new Map();
|
|
410
|
+
for (const f of files)
|
|
411
|
+
index.set(`${f.bucket}\n${f.key}`, f.bytes);
|
|
412
|
+
for (const [bucket, actual] of preexisting) {
|
|
413
|
+
for (const [key, size] of actual) {
|
|
414
|
+
const expected = index.get(`${bucket}\n${key}`);
|
|
415
|
+
if (expected === undefined)
|
|
416
|
+
violations.push(`${bucket}/${key} (not in this snapshot)`);
|
|
417
|
+
else if (expected !== size)
|
|
418
|
+
violations.push(`${bucket}/${key} (size ${size} ≠ manifest ${expected})`);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return violations;
|
|
422
|
+
}
|
|
423
|
+
/** 逐键对账(纯函数,可测):manifest 每个文件都必须在目标出现且尺寸一致;多余对象另计。 */
|
|
424
|
+
export function reconcileFiles(files, actualByBucket) {
|
|
425
|
+
const missing = [];
|
|
426
|
+
const sizeMismatched = [];
|
|
427
|
+
let verified = 0;
|
|
428
|
+
const matchedPerBucket = new Map();
|
|
429
|
+
for (const f of files) {
|
|
430
|
+
const actual = actualByBucket.get(f.bucket);
|
|
431
|
+
const size = actual?.get(f.key);
|
|
432
|
+
if (size === undefined) {
|
|
433
|
+
missing.push(`${f.bucket}/${f.key}`);
|
|
434
|
+
}
|
|
435
|
+
else if (size !== f.bytes) {
|
|
436
|
+
sizeMismatched.push(`${f.bucket}/${f.key} (target ${size} ≠ manifest ${f.bytes})`);
|
|
437
|
+
matchedPerBucket.set(f.bucket, (matchedPerBucket.get(f.bucket) ?? 0) + 1);
|
|
438
|
+
}
|
|
439
|
+
else {
|
|
440
|
+
verified += 1;
|
|
441
|
+
matchedPerBucket.set(f.bucket, (matchedPerBucket.get(f.bucket) ?? 0) + 1);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
let extras = 0;
|
|
445
|
+
for (const [bucket, actual] of actualByBucket) {
|
|
446
|
+
extras += actual.size - (matchedPerBucket.get(bucket) ?? 0);
|
|
447
|
+
}
|
|
448
|
+
return {
|
|
449
|
+
verified,
|
|
450
|
+
missing,
|
|
451
|
+
sizeMismatched,
|
|
452
|
+
extras,
|
|
453
|
+
// extras 也判不匹配(评审第 12 轮):恢复目标应当只含快照内容,混着外来对象
|
|
454
|
+
// 不是干净恢复——要共存请显式分桶,不给静默混装留门
|
|
455
|
+
matches: missing.length === 0 && sizeMismatched.length === 0 && extras === 0,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
/** 从目标下载单个文件并重算 sha256;读不到/不符时返回原因,匹配返回 null。 */
|
|
459
|
+
async function hashMismatchOnTarget(target, file, counters) {
|
|
460
|
+
const encodedKey = file.key.split("/").map(encodeURIComponent).join("/");
|
|
461
|
+
const res = await storageApi(target, "GET", `/object/${encodeURIComponent(file.bucket)}/${encodedKey}`, { timeoutMs: UPLOAD_TIMEOUT_MS }, counters);
|
|
462
|
+
if (!res.ok)
|
|
463
|
+
return `unreadable: HTTP ${res.status}`;
|
|
464
|
+
const hash = createHash("sha256");
|
|
465
|
+
for await (const chunk of res.body)
|
|
466
|
+
hash.update(chunk);
|
|
467
|
+
return hash.digest("hex") === file.sha256 ? null : "sha256 mismatch";
|
|
468
|
+
}
|
|
469
|
+
/** 从目标下载抽样文件并重算 sha256(证明目标上的字节 = manifest 承诺的字节)。 */
|
|
470
|
+
async function sampleChecksums(target, files, counters) {
|
|
471
|
+
const sample = sampleStorageFiles(files, CHECKSUM_SAMPLE_CAP);
|
|
472
|
+
const mismatched = [];
|
|
473
|
+
for (const file of sample) {
|
|
474
|
+
const mismatch = await hashMismatchOnTarget(target, file, counters);
|
|
475
|
+
if (mismatch)
|
|
476
|
+
mismatched.push(`${file.bucket}/${file.key} (${mismatch})`);
|
|
477
|
+
}
|
|
478
|
+
return { checked: sample.length, mismatched };
|
|
479
|
+
}
|
|
480
|
+
/** 简单并发池:PRD §5.4.2 小并发;单文件失败收集不中断(§5.4.4)。 */
|
|
481
|
+
async function uploadAll(source, target, files, verifiedResidue, summary) {
|
|
482
|
+
let next = 0;
|
|
483
|
+
const workers = Array.from({ length: Math.min(UPLOAD_CONCURRENCY, files.length) }, async () => {
|
|
484
|
+
while (next < files.length) {
|
|
485
|
+
const file = files[next++];
|
|
486
|
+
// 计划阶段已逐字节验证的残留:跳过(冲突形态在净度门就整体拒绝了,到不了这里)
|
|
487
|
+
if (verifiedResidue.has(`${file.bucket}\n${file.key}`))
|
|
488
|
+
continue;
|
|
489
|
+
try {
|
|
490
|
+
await uploadFile(source, target, file, summary);
|
|
491
|
+
summary.filesUploaded += 1;
|
|
492
|
+
summary.bytesUploaded += file.bytes;
|
|
493
|
+
}
|
|
494
|
+
catch (error) {
|
|
495
|
+
summary.filesFailed.push({
|
|
496
|
+
file: `${file.bucket}/${file.key}`,
|
|
497
|
+
reason: error.message,
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
});
|
|
502
|
+
await Promise.all(workers);
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Storage 回传主流程(PRD §5.4.1 顺序):bucket 清单 → 检查/创建 → 上传(含元数据)
|
|
506
|
+
* → 逐键对账 → 抽样校验和。bucket 创建失败抛出(没桶一切免谈),文件级失败收集
|
|
507
|
+
* 入摘要由调用方裁决。
|
|
508
|
+
*/
|
|
509
|
+
function emptySummary() {
|
|
510
|
+
return {
|
|
511
|
+
bucketsCreated: [],
|
|
512
|
+
bucketsExisting: [],
|
|
513
|
+
bucketsWithoutAttrs: [],
|
|
514
|
+
bucketAttrDrift: [],
|
|
515
|
+
filesUploaded: 0,
|
|
516
|
+
bytesUploaded: 0,
|
|
517
|
+
filesSkippedIdentical: 0,
|
|
518
|
+
filesFailed: [],
|
|
519
|
+
retries: 0,
|
|
520
|
+
reconcile: { verified: 0, missing: [], sizeMismatched: [], extras: 0, matches: false },
|
|
521
|
+
checksumSample: { checked: 0, mismatched: [] },
|
|
522
|
+
metadataNotes: [],
|
|
523
|
+
ownerNote: "File bytes and supported metadata were restored. Original Auth ownership was not restored or verified.",
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* 只读计划阶段(评审第 16 轮):探测/校验每个桶 → 残留净度 → 同尺寸残留逐个重哈希。
|
|
528
|
+
* 全部安全裁决在这里完成,零写入——供正式执行(数据库写入**之前**)与 dry-run 共用,
|
|
529
|
+
* 可预见的 Storage 冲突不得在库已恢复之后才发现。
|
|
530
|
+
*/
|
|
531
|
+
async function readOnlyStoragePlan(target, manifest, summary) {
|
|
532
|
+
const buckets = bucketsToRestore(manifest);
|
|
533
|
+
log.step(`Probing ${buckets.length} bucket(s) on target (read-only)…`);
|
|
534
|
+
const usableAttrs = (name) => {
|
|
535
|
+
const recorded = (manifest.storage.buckets ?? []).find((b) => b.name === name);
|
|
536
|
+
// "可用"必须含 public(安全属性):部分缺字段按未捕获处理,不猜
|
|
537
|
+
return recorded && typeof recorded.public === "boolean" ? recorded : undefined;
|
|
538
|
+
};
|
|
539
|
+
const plans = [];
|
|
540
|
+
for (const bucket of buckets) {
|
|
541
|
+
const existing = await probeBucket(target, bucket.name, summary);
|
|
542
|
+
plans.push({
|
|
543
|
+
name: bucket.name,
|
|
544
|
+
create: validateBucketPlan(bucket.name, usableAttrs(bucket.name), existing, summary).create,
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
const preexisting = new Map();
|
|
548
|
+
for (const plan of plans) {
|
|
549
|
+
preexisting.set(plan.name, plan.create ? new Map() : await walkTargetObjects(target, plan.name, summary));
|
|
550
|
+
}
|
|
551
|
+
const files = manifest.storage.files;
|
|
552
|
+
const violations = targetResidueViolations(files, preexisting);
|
|
553
|
+
if (violations.length) {
|
|
554
|
+
throw new Error(`target bucket(s) are not clean — refusing to write anything: ` +
|
|
555
|
+
`${violations.slice(0, 5).join(", ")}${violations.length > 5 ? ` …and ${violations.length - 5} more` : ""}. ` +
|
|
556
|
+
`A restore target may only contain residue from a previous run of this same snapshot.`);
|
|
557
|
+
}
|
|
558
|
+
// 同尺寸残留在**写第一个字节之前**逐个重哈希:损坏残留 = 目标不净,整体拒写
|
|
559
|
+
const verifiedResidue = new Set();
|
|
560
|
+
const candidates = files.filter((f) => preexisting.get(f.bucket)?.get(f.key) === f.bytes);
|
|
561
|
+
if (candidates.length) {
|
|
562
|
+
log.step(`Verifying ${candidates.length} pre-existing object(s) byte-for-byte (read-only)…`);
|
|
563
|
+
for (const file of candidates) {
|
|
564
|
+
const mismatch = await hashMismatchOnTarget(target, file, summary);
|
|
565
|
+
if (mismatch) {
|
|
566
|
+
throw new Error(`target residue ${file.bucket}/${file.key} differs from the snapshot (${mismatch}) — ` +
|
|
567
|
+
`the target is not clean; refusing to write anything`);
|
|
568
|
+
}
|
|
569
|
+
verifiedResidue.add(`${file.bucket}\n${file.key}`);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
return { plans, preexisting, verifiedResidue };
|
|
573
|
+
}
|
|
574
|
+
/** dry-run / 执行前预检的公开形态:一次性 scratch 摘要,只返回计划概要与警告。 */
|
|
575
|
+
export async function planStorageRestore(target, manifest) {
|
|
576
|
+
const scratch = emptySummary();
|
|
577
|
+
const plan = await readOnlyStoragePlan(target, manifest, scratch);
|
|
578
|
+
return {
|
|
579
|
+
bucketsToCreate: plan.plans.filter((p) => p.create).map((p) => p.name),
|
|
580
|
+
verifiedResidue: plan.verifiedResidue.size,
|
|
581
|
+
warnings: [...scratch.bucketAttrDrift, ...scratch.metadataNotes],
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
export async function restoreStorage(source, target, manifest) {
|
|
585
|
+
const summary = emptySummary();
|
|
586
|
+
const { plans, verifiedResidue } = await readOnlyStoragePlan(target, manifest, summary);
|
|
587
|
+
summary.filesSkippedIdentical = verifiedResidue.size;
|
|
588
|
+
const files = manifest.storage.files;
|
|
589
|
+
// 阶段三:全部只读检查通过,才开始写——先建缺失桶,再上传
|
|
590
|
+
const toCreate = plans.filter((p) => p.create);
|
|
591
|
+
if (toCreate.length) {
|
|
592
|
+
log.step(`Creating ${toCreate.length} bucket(s)…`);
|
|
593
|
+
for (const plan of toCreate) {
|
|
594
|
+
// 计划阶段已证明该桶有含 public 的可用属性记录(否则走了手动回退)
|
|
595
|
+
const attrs = (manifest.storage.buckets ?? []).find((b) => b.name === plan.name);
|
|
596
|
+
await createBucket(target, attrs, plan.name, summary);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
log.step(`Uploading ${files.length} file(s) → target Storage…`);
|
|
600
|
+
await uploadAll(source, target, files, verifiedResidue, summary);
|
|
601
|
+
log.step("Reconciling every file against the target listing…");
|
|
602
|
+
const actualByBucket = new Map();
|
|
603
|
+
for (const plan of plans) {
|
|
604
|
+
actualByBucket.set(plan.name, await walkTargetObjects(target, plan.name, summary));
|
|
605
|
+
}
|
|
606
|
+
// 超时误报重裁(评审第 10 轮):上传"失败"但目标上尺寸吻合的文件(响应超时时
|
|
607
|
+
// 服务端其实已成功)→ 重哈希裁决;字节确证一致才改判成功,幂等重试的契约成立
|
|
608
|
+
if (summary.filesFailed.length) {
|
|
609
|
+
const reclassified = [];
|
|
610
|
+
for (const failed of [...summary.filesFailed]) {
|
|
611
|
+
const file = files.find((f) => `${f.bucket}/${f.key}` === failed.file);
|
|
612
|
+
const sizeOnTarget = file && actualByBucket.get(file.bucket)?.get(file.key);
|
|
613
|
+
if (!file || sizeOnTarget !== file.bytes)
|
|
614
|
+
continue;
|
|
615
|
+
const mismatch = await hashMismatchOnTarget(target, file, summary);
|
|
616
|
+
if (mismatch === null) {
|
|
617
|
+
summary.filesFailed = summary.filesFailed.filter((x) => x !== failed);
|
|
618
|
+
summary.filesUploaded += 1;
|
|
619
|
+
summary.bytesUploaded += file.bytes;
|
|
620
|
+
reclassified.push(failed.file);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
if (reclassified.length) {
|
|
624
|
+
log.ok(`${reclassified.length} timed-out upload(s) verified byte-identical on target — reclassified as success`);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
summary.reconcile = reconcileFiles(files, actualByBucket);
|
|
628
|
+
if (files.length && summary.filesFailed.length < files.length) {
|
|
629
|
+
log.step("Sampling checksums from target…");
|
|
630
|
+
const uploadedFiles = files.filter((f) => !summary.filesFailed.some((x) => x.file === `${f.bucket}/${f.key}`));
|
|
631
|
+
summary.checksumSample = await sampleChecksums(target, uploadedFiles, summary);
|
|
632
|
+
}
|
|
633
|
+
return summary;
|
|
634
|
+
}
|
package/dist/storage.d.ts
CHANGED
|
@@ -1,6 +1,20 @@
|
|
|
1
|
-
import { S3Client } from "@aws-sdk/client-s3";
|
|
1
|
+
import { S3Client, type GetObjectCommandOutput } from "@aws-sdk/client-s3";
|
|
2
2
|
import type { SupabaseStorageConfig } from "./config.js";
|
|
3
3
|
import type { StorageFile } from "./manifest.js";
|
|
4
|
+
/**
|
|
5
|
+
* 从 GetObject 响应提取文件的可恢复元数据。Supabase 的 S3 handler 把这些头直接
|
|
6
|
+
* 从 storage.objects.metadata / user_metadata 取出(content-type、cache-control、
|
|
7
|
+
* x-amz-meta-*,源码实证 2026-07-24)——所以这份元数据与刚流过的字节**原子一致**,
|
|
8
|
+
* 不存在"拷完再查 DB"的并发漂移窗口,也不依赖 DB 上 storage schema 的读权限。
|
|
9
|
+
* 缺席的头不落字段(= 源端未设置/未提供),不猜测。
|
|
10
|
+
*/
|
|
11
|
+
export declare function fileMetadataFromS3(got: GetObjectCommandOutput): {
|
|
12
|
+
contentType?: string;
|
|
13
|
+
cacheControl?: string;
|
|
14
|
+
contentEncoding?: string;
|
|
15
|
+
metadata?: Record<string, string>;
|
|
16
|
+
lastModified?: string;
|
|
17
|
+
};
|
|
4
18
|
/**
|
|
5
19
|
* 仅列举、不下载 —— 用于接入时的容量/egress 估算,几乎不产生 egress。
|
|
6
20
|
*/
|
|
@@ -21,4 +35,5 @@ export declare function syncStorage(cfg: SupabaseStorageConfig, target: {
|
|
|
21
35
|
fileCount: number;
|
|
22
36
|
totalBytes: number;
|
|
23
37
|
files: StorageFile[];
|
|
38
|
+
buckets: string[];
|
|
24
39
|
}>;
|