@sproutboat/runtime 0.7.1 → 0.8.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/CHANGELOG.md +28 -0
- package/README.md +41 -0
- package/package.json +1 -1
- package/src/native-fetch-prelude.js +58 -0
- package/src/r2-object.test.js +28 -2
- package/src/transport-broker.js +21 -0
- package/src/transport-embedded.js +483 -22
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
# @sproutboat/runtime
|
|
2
2
|
|
|
3
|
+
## 0.8.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 493e4e3: Add short-lived R2 direct-transfer tickets for broker-backed sprouts. A
|
|
8
|
+
loopback HTTP listener streams PUT bodies to file-backed R2 storage, and serves
|
|
9
|
+
GET and HEAD requests with single-range support without placing object bytes
|
|
10
|
+
in a binding frame.
|
|
11
|
+
|
|
12
|
+
Tickets are random bearer capabilities with a byte limit, expiry, one-use
|
|
13
|
+
claim, and optional SHA-256 verification. Native standalone direct transfers
|
|
14
|
+
remain unavailable until the native HTTP ingress can stream request bodies.
|
|
15
|
+
|
|
16
|
+
For broker deployments, ticket issuance now reserves account-level capacity
|
|
17
|
+
across every R2 resource. Reservations survive redeploys, expired and orphaned
|
|
18
|
+
temporary uploads are reclaimed, and the broker retains a configurable free
|
|
19
|
+
disk floor with capacity and rejected-transfer counters for the dashboard.
|
|
20
|
+
- 5acb93c: Add Cloudflare-shaped resumable R2 multipart uploads. Parts are persisted
|
|
21
|
+
independently, and standalone completion assembles the final object through a
|
|
22
|
+
fixed 64 KiB native buffer to keep memory bounded by part size rather than total
|
|
23
|
+
object size.
|
|
24
|
+
|
|
25
|
+
Unfinished multipart uploads expire after seven days and their persisted part
|
|
26
|
+
files are removed automatically.
|
|
27
|
+
|
|
28
|
+
Store completed objects as immutable blob generations so a failed overwrite
|
|
29
|
+
cannot expose new bytes under the previous metadata.
|
|
30
|
+
|
|
3
31
|
## 0.7.1
|
|
4
32
|
|
|
5
33
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -31,3 +31,44 @@ The prelude shims the Web API surface handlers expect (URL, Response,
|
|
|
31
31
|
crypto random values, binding shims, trigger dispatch) and is identical
|
|
32
32
|
for both transports, which is what lets one conformance suite hold them
|
|
33
33
|
honest.
|
|
34
|
+
|
|
35
|
+
## R2 multipart uploads
|
|
36
|
+
|
|
37
|
+
R2 bindings implement the Cloudflare multipart method shape for objects that
|
|
38
|
+
are larger than one safe request body:
|
|
39
|
+
|
|
40
|
+
```js
|
|
41
|
+
const upload = env.FILES.createMultipartUpload("archive.tar", {
|
|
42
|
+
httpMetadata: { contentType: "application/x-tar" },
|
|
43
|
+
customMetadata: { source: "backup" },
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const parts = [];
|
|
47
|
+
parts.push(await upload.uploadPart(1, firstChunk));
|
|
48
|
+
|
|
49
|
+
const resumed = env.FILES.resumeMultipartUpload(upload.key, upload.uploadId);
|
|
50
|
+
parts.push(await resumed.uploadPart(2, finalChunk));
|
|
51
|
+
|
|
52
|
+
const object = await resumed.complete(parts);
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Part numbers must be between 1 and 10,000. Parts must be supplied to
|
|
56
|
+
`complete()` in ascending order. Every part except the last must have the same
|
|
57
|
+
size, and the last cannot be larger. Sproutboat permits parts smaller than
|
|
58
|
+
Cloudflare R2's 5 MiB minimum so the default 1 MiB request-body limit remains
|
|
59
|
+
useful. Keep each part below the configured request-body limit.
|
|
60
|
+
|
|
61
|
+
Each part is persisted independently. Standalone completion copies parts into
|
|
62
|
+
the finished object through a fixed 64 KiB native buffer, so final object size
|
|
63
|
+
does not determine peak Porffor heap use. A single `put()` still buffers its
|
|
64
|
+
whole value; use multipart for larger objects. Call `abort()` to remove an
|
|
65
|
+
unfinished upload and its parts.
|
|
66
|
+
|
|
67
|
+
Completed objects use immutable, etag-addressed blob generations. The metadata
|
|
68
|
+
row switches to a fully written generation before the previous file is
|
|
69
|
+
removed. If a metadata update fails, readers continue seeing the previous
|
|
70
|
+
generation with its matching size and etag.
|
|
71
|
+
|
|
72
|
+
Unfinished uploads expire after seven days. The broker removes expired parts
|
|
73
|
+
when another multipart upload starts; a standalone binary removes them when it
|
|
74
|
+
initializes its embedded store.
|
package/package.json
CHANGED
|
@@ -1136,6 +1136,26 @@ function __sbR2Object(meta, body) {
|
|
|
1136
1136
|
return obj;
|
|
1137
1137
|
}
|
|
1138
1138
|
|
|
1139
|
+
function __sbR2Multipart(bucket, key, uploadId) {
|
|
1140
|
+
return {
|
|
1141
|
+
key,
|
|
1142
|
+
uploadId,
|
|
1143
|
+
uploadPart(partNumber, value) {
|
|
1144
|
+
const number = Number(partNumber);
|
|
1145
|
+
if (!Number.isInteger(number) || number < 1 || number > 10000)
|
|
1146
|
+
throw new TypeError("partNumber must be an integer between 1 and 10000");
|
|
1147
|
+
return __sbR2MultipartPut(bucket, key, uploadId, number, value == null ? "" : String(value)).part;
|
|
1148
|
+
},
|
|
1149
|
+
complete(parts) {
|
|
1150
|
+
const r = __sbRpc("r2.multipart.complete", { bucket, key, uploadId, parts: parts || [] });
|
|
1151
|
+
return __sbR2Object(r.object, null);
|
|
1152
|
+
},
|
|
1153
|
+
abort() {
|
|
1154
|
+
__sbRpc("r2.multipart.abort", { bucket, key, uploadId });
|
|
1155
|
+
},
|
|
1156
|
+
};
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1139
1159
|
// Installed only when the project declares bindings. `env` is the module-scoped
|
|
1140
1160
|
// object from compile.ts (a `const`, but mutable); we add the binding accessors
|
|
1141
1161
|
// to it in place. compile.ts emits `__sbInstallBindings(env, {...})` right after
|
|
@@ -1197,6 +1217,44 @@ globalThis.__sbInstallBindings = function (target, bindings) {
|
|
|
1197
1217
|
o.customMetadata || {},
|
|
1198
1218
|
).object;
|
|
1199
1219
|
},
|
|
1220
|
+
createMultipartUpload(key, options) {
|
|
1221
|
+
const objectKey = String(key);
|
|
1222
|
+
const o = options || {};
|
|
1223
|
+
const r = __sbRpc("r2.multipart.create", {
|
|
1224
|
+
bucket: name,
|
|
1225
|
+
key: objectKey,
|
|
1226
|
+
httpMetadata: o.httpMetadata || {},
|
|
1227
|
+
customMetadata: o.customMetadata || {},
|
|
1228
|
+
});
|
|
1229
|
+
return __sbR2Multipart(name, objectKey, r.uploadId);
|
|
1230
|
+
},
|
|
1231
|
+
resumeMultipartUpload(key, uploadId) {
|
|
1232
|
+
return __sbR2Multipart(name, String(key), String(uploadId));
|
|
1233
|
+
},
|
|
1234
|
+
createUploadUrl(key, options) {
|
|
1235
|
+
const o = options || {};
|
|
1236
|
+
const r = __sbRpc("r2.transfer.create", {
|
|
1237
|
+
bucket: name,
|
|
1238
|
+
key: String(key),
|
|
1239
|
+
method: "upload",
|
|
1240
|
+
maxBytes: o.maxBytes,
|
|
1241
|
+
expiresIn: o.expiresIn,
|
|
1242
|
+
sha256: o.sha256,
|
|
1243
|
+
httpMetadata: o.httpMetadata || {},
|
|
1244
|
+
customMetadata: o.customMetadata || {},
|
|
1245
|
+
});
|
|
1246
|
+
return { url: r.url, expiresAt: r.expiresAt };
|
|
1247
|
+
},
|
|
1248
|
+
createDownloadUrl(key, options) {
|
|
1249
|
+
const o = options || {};
|
|
1250
|
+
const r = __sbRpc("r2.transfer.create", {
|
|
1251
|
+
bucket: name,
|
|
1252
|
+
key: String(key),
|
|
1253
|
+
method: "download",
|
|
1254
|
+
expiresIn: o.expiresIn,
|
|
1255
|
+
});
|
|
1256
|
+
return { url: r.url, expiresAt: r.expiresAt };
|
|
1257
|
+
},
|
|
1200
1258
|
get(key) {
|
|
1201
1259
|
// #56 — bytes come back out of band on a transport that supports it, so
|
|
1202
1260
|
// an object body is never JSON-escaped into a frame.
|
package/src/r2-object.test.js
CHANGED
|
@@ -24,9 +24,21 @@ if (r2Start === -1 || r2End === -1 || r2End < r2Start) throw new Error("__sbR2Ob
|
|
|
24
24
|
|
|
25
25
|
// oxlint-disable-next-line no-function-constructor -- lifting pure blocks out of the text-only prelude for test
|
|
26
26
|
const load = new Function(
|
|
27
|
-
|
|
27
|
+
"__sbRpc",
|
|
28
|
+
"__sbR2MultipartPut",
|
|
29
|
+
`${src.slice(utf8Start, utf8End)}\n${src.slice(rawStart, rawEnd)}\n${src.slice(r2Start, r2End)}\nreturn { __sbR2Object, __sbR2Multipart };`,
|
|
28
30
|
);
|
|
29
|
-
const
|
|
31
|
+
const calls = [];
|
|
32
|
+
const rpc = (op, value) => {
|
|
33
|
+
calls.push({ op, value });
|
|
34
|
+
if (op === "r2.multipart.complete") return { object: meta };
|
|
35
|
+
return { ok: true };
|
|
36
|
+
};
|
|
37
|
+
const putPart = (bucket, key, uploadId, partNumber, body) => {
|
|
38
|
+
calls.push({ op: "r2.multipart.put", value: { bucket, key, uploadId, partNumber, body } });
|
|
39
|
+
return { part: { partNumber, etag: `part-${partNumber}` } };
|
|
40
|
+
};
|
|
41
|
+
const { __sbR2Object, __sbR2Multipart } = load(rpc, putPart);
|
|
30
42
|
|
|
31
43
|
const meta = { key: "f.bin", size: 3, etag: "abc123", uploaded: "2026-09-14T00:00:00.000Z" };
|
|
32
44
|
|
|
@@ -63,3 +75,17 @@ test("toResponse() on an empty body still returns bytes, not the string \"\"", a
|
|
|
63
75
|
const buf = new Uint8Array(await resp.arrayBuffer());
|
|
64
76
|
expect(buf.length).toBe(0);
|
|
65
77
|
});
|
|
78
|
+
|
|
79
|
+
test("multipart uploads expose the Cloudflare-shaped uploadPart, complete, and abort methods", () => {
|
|
80
|
+
calls.length = 0;
|
|
81
|
+
const upload = __sbR2Multipart("FILES", "archive.bin", "upload-1");
|
|
82
|
+
expect(upload).toMatchObject({ key: "archive.bin", uploadId: "upload-1" });
|
|
83
|
+
expect(upload.uploadPart(1, "first")).toEqual({ partNumber: 1, etag: "part-1" });
|
|
84
|
+
expect(upload.complete([{ partNumber: 1, etag: "part-1" }])).toMatchObject(meta);
|
|
85
|
+
upload.abort();
|
|
86
|
+
expect(calls.map((call) => call.op)).toEqual([
|
|
87
|
+
"r2.multipart.put",
|
|
88
|
+
"r2.multipart.complete",
|
|
89
|
+
"r2.multipart.abort",
|
|
90
|
+
]);
|
|
91
|
+
});
|
package/src/transport-broker.js
CHANGED
|
@@ -303,6 +303,27 @@ globalThis.__sbR2Get = function (bucket, key) {
|
|
|
303
303
|
return { found: true, object: reply.object, body: __sbTakeBin() };
|
|
304
304
|
};
|
|
305
305
|
|
|
306
|
+
globalThis.__sbR2MultipartPut = function (bucket, key, uploadId, partNumber, body) {
|
|
307
|
+
const token = __sbEnv("SB_BROKER_TOKEN");
|
|
308
|
+
const reply = JSON.parse(
|
|
309
|
+
__sbCallBin(
|
|
310
|
+
JSON.stringify({
|
|
311
|
+
v: 1,
|
|
312
|
+
id: ++__sbReqId,
|
|
313
|
+
token,
|
|
314
|
+
op: "r2.multipart.put",
|
|
315
|
+
bucket,
|
|
316
|
+
key,
|
|
317
|
+
uploadId,
|
|
318
|
+
partNumber,
|
|
319
|
+
}),
|
|
320
|
+
body == null ? "" : String(body),
|
|
321
|
+
),
|
|
322
|
+
);
|
|
323
|
+
if (reply.ok === false) throw new Error("sproutboat r2.multipart.put: " + (reply.error || "failed"));
|
|
324
|
+
return reply;
|
|
325
|
+
};
|
|
326
|
+
|
|
306
327
|
/** Static asset metadata stays JSON; the response body uses the v1 byte tail. */
|
|
307
328
|
globalThis.__sbAssetsGet = function (path) {
|
|
308
329
|
const token = __sbEnv("SB_BROKER_TOKEN");
|
|
@@ -41,6 +41,8 @@ extern int sqlite3_column_type(sqlite3_stmt*, int);
|
|
|
41
41
|
extern const unsigned char* sqlite3_column_text(sqlite3_stmt*, int);
|
|
42
42
|
extern int sqlite3_column_bytes(sqlite3_stmt*, int);
|
|
43
43
|
extern double sqlite3_column_double(sqlite3_stmt*, int);
|
|
44
|
+
extern int sqlite3_column_int(sqlite3_stmt*, int);
|
|
45
|
+
extern int64_t sqlite3_column_int64(sqlite3_stmt*, int);
|
|
44
46
|
extern const char* sqlite3_column_name(sqlite3_stmt*, int);
|
|
45
47
|
extern int sqlite3_bind_null(sqlite3_stmt*, int);
|
|
46
48
|
extern int sqlite3_bind_double(sqlite3_stmt*, int, double);
|
|
@@ -281,7 +283,8 @@ static void sb_iso_now(char* out, size_t cap) {
|
|
|
281
283
|
|
|
282
284
|
// "<dir-of-store_path>/r2-blobs/<sha256(bucket 0x1e key)>.blob" — deterministic,
|
|
283
285
|
// so no extra column is needed to find an object's file back.
|
|
284
|
-
static void sb_r2_blob_path(char* out, size_t cap, const char* store_path, const char* bucket, const char* key
|
|
286
|
+
static void sb_r2_blob_path(char* out, size_t cap, const char* store_path, const char* bucket, const char* key,
|
|
287
|
+
const char* generation) {
|
|
285
288
|
const char* slash = strrchr(store_path, '/');
|
|
286
289
|
int dirlen = slash ? (int)(slash - store_path) : 0;
|
|
287
290
|
br_sha256_context ctx;
|
|
@@ -289,6 +292,10 @@ static void sb_r2_blob_path(char* out, size_t cap, const char* store_path, const
|
|
|
289
292
|
br_sha256_update(&ctx, bucket, strlen(bucket));
|
|
290
293
|
br_sha256_update(&ctx, "\x1e", 1); // separator: a bucket/key split never collides across it
|
|
291
294
|
br_sha256_update(&ctx, key, strlen(key));
|
|
295
|
+
if (generation && *generation) {
|
|
296
|
+
br_sha256_update(&ctx, "\x1e", 1);
|
|
297
|
+
br_sha256_update(&ctx, generation, strlen(generation));
|
|
298
|
+
}
|
|
292
299
|
unsigned char digest[32];
|
|
293
300
|
br_sha256_out(&ctx, digest);
|
|
294
301
|
char hex[65];
|
|
@@ -296,6 +303,149 @@ static void sb_r2_blob_path(char* out, size_t cap, const char* store_path, const
|
|
|
296
303
|
snprintf(out, cap, "%.*s/r2-blobs/%s.blob", dirlen, store_path, hex);
|
|
297
304
|
}
|
|
298
305
|
|
|
306
|
+
static void sb_r2_part_path(char* out, size_t cap, const char* store_path, const char* bucket, const char* key,
|
|
307
|
+
const char* upload_id, int part_number) {
|
|
308
|
+
const char* slash = strrchr(store_path, '/');
|
|
309
|
+
int dirlen = slash ? (int)(slash - store_path) : 0;
|
|
310
|
+
br_sha256_context ctx;
|
|
311
|
+
br_sha256_init(&ctx);
|
|
312
|
+
br_sha256_update(&ctx, bucket, strlen(bucket));
|
|
313
|
+
br_sha256_update(&ctx, "\x1e", 1);
|
|
314
|
+
br_sha256_update(&ctx, key, strlen(key));
|
|
315
|
+
br_sha256_update(&ctx, "\x1e", 1);
|
|
316
|
+
br_sha256_update(&ctx, upload_id, strlen(upload_id));
|
|
317
|
+
br_sha256_update(&ctx, "\x1e", 1);
|
|
318
|
+
char number[16];
|
|
319
|
+
int number_len = snprintf(number, sizeof(number), "%d", part_number);
|
|
320
|
+
br_sha256_update(&ctx, number, (size_t)number_len);
|
|
321
|
+
unsigned char digest[32];
|
|
322
|
+
br_sha256_out(&ctx, digest);
|
|
323
|
+
char hex[65];
|
|
324
|
+
sb_hex32(digest, hex);
|
|
325
|
+
snprintf(out, cap, "%.*s/r2-parts/%s.part", dirlen, store_path, hex);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
static char* sb_r2_part_put_c(const char* path, const char* bucket, const char* key, const char* upload_id,
|
|
329
|
+
int part_number, const char* body, size_t bodylen) {
|
|
330
|
+
sb_buf out = { 0, 0, 0 };
|
|
331
|
+
char partpath[1024];
|
|
332
|
+
sb_r2_part_path(partpath, sizeof(partpath), path, bucket, key, upload_id, part_number);
|
|
333
|
+
sb_mkdirs(partpath);
|
|
334
|
+
char temporary[1100];
|
|
335
|
+
snprintf(temporary, sizeof(temporary), "%s.tmp", partpath);
|
|
336
|
+
FILE* f = fopen(temporary, "wb");
|
|
337
|
+
if (!f) { sb_puts(&out, "{\"ok\":false,\"error\":\"cannot write multipart part\"}"); return out.p; }
|
|
338
|
+
size_t written = bodylen ? fwrite(body, 1, bodylen, f) : 0;
|
|
339
|
+
int close_rc = fclose(f);
|
|
340
|
+
if (written != bodylen || close_rc != 0 || rename(temporary, partpath) != 0) {
|
|
341
|
+
unlink(temporary);
|
|
342
|
+
sb_puts(&out, "{\"ok\":false,\"error\":\"multipart part write failed\"}");
|
|
343
|
+
return out.p;
|
|
344
|
+
}
|
|
345
|
+
char etag[65];
|
|
346
|
+
sb_sha256_hex(body, bodylen, etag);
|
|
347
|
+
char result[160];
|
|
348
|
+
int n = snprintf(result, sizeof(result), "{\"ok\":true,\"etag\":\"%s\",\"size\":%zu}", etag, bodylen);
|
|
349
|
+
sb_put(&out, result, (size_t)n);
|
|
350
|
+
return out.p;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
static char* sb_r2_multipart_complete_c(const char* path, const char* bucket, const char* key, const char* upload_id) {
|
|
354
|
+
sb_buf out = { 0, 0, 0 };
|
|
355
|
+
int idx = sb_db_for(path);
|
|
356
|
+
if (idx < 0) { sb_puts(&out, "{\"ok\":false,\"error\":\"cannot open database\"}"); return out.p; }
|
|
357
|
+
|
|
358
|
+
char blobpath[1024];
|
|
359
|
+
sb_r2_blob_path(blobpath, sizeof(blobpath), path, bucket, key, "");
|
|
360
|
+
sb_mkdirs(blobpath);
|
|
361
|
+
char temporary[1100];
|
|
362
|
+
snprintf(temporary, sizeof(temporary), "%s.%s.tmp", blobpath, upload_id);
|
|
363
|
+
FILE* destination = fopen(temporary, "wb");
|
|
364
|
+
if (!destination) { sb_puts(&out, "{\"ok\":false,\"error\":\"cannot write object\"}"); return out.p; }
|
|
365
|
+
|
|
366
|
+
sqlite3_stmt* st = 0;
|
|
367
|
+
const char* sql =
|
|
368
|
+
"SELECT part_number, size FROM r2_part WHERE bucket = ?1 AND key = ?2 AND upload_id = ?3 ORDER BY part_number";
|
|
369
|
+
if (sqlite3_prepare_v2(sb_dbs[idx], sql, -1, &st, 0) != 0 || !st) {
|
|
370
|
+
fclose(destination); unlink(temporary);
|
|
371
|
+
sb_puts(&out, "{\"ok\":false,\"error\":\"cannot read multipart parts\"}");
|
|
372
|
+
return out.p;
|
|
373
|
+
}
|
|
374
|
+
sqlite3_bind_text(st, 1, bucket, -1, SB_SQLITE_TRANSIENT);
|
|
375
|
+
sqlite3_bind_text(st, 2, key, -1, SB_SQLITE_TRANSIENT);
|
|
376
|
+
sqlite3_bind_text(st, 3, upload_id, -1, SB_SQLITE_TRANSIENT);
|
|
377
|
+
|
|
378
|
+
br_sha256_context hash;
|
|
379
|
+
br_sha256_init(&hash);
|
|
380
|
+
size_t total = 0;
|
|
381
|
+
int failed = 0;
|
|
382
|
+
unsigned char chunk[65536];
|
|
383
|
+
while (!failed && sqlite3_step(st) == SB_SQLITE_ROW) {
|
|
384
|
+
int part_number = sqlite3_column_int(st, 0);
|
|
385
|
+
size_t expected = (size_t)sqlite3_column_int64(st, 1);
|
|
386
|
+
char partpath[1024];
|
|
387
|
+
sb_r2_part_path(partpath, sizeof(partpath), path, bucket, key, upload_id, part_number);
|
|
388
|
+
FILE* part = fopen(partpath, "rb");
|
|
389
|
+
if (!part) { failed = 1; break; }
|
|
390
|
+
size_t part_size = 0;
|
|
391
|
+
for (;;) {
|
|
392
|
+
size_t read_count = fread(chunk, 1, sizeof(chunk), part);
|
|
393
|
+
if (read_count) {
|
|
394
|
+
if (fwrite(chunk, 1, read_count, destination) != read_count) { failed = 1; break; }
|
|
395
|
+
br_sha256_update(&hash, chunk, read_count);
|
|
396
|
+
part_size += read_count;
|
|
397
|
+
total += read_count;
|
|
398
|
+
}
|
|
399
|
+
if (read_count < sizeof(chunk)) {
|
|
400
|
+
if (ferror(part)) failed = 1;
|
|
401
|
+
break;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
fclose(part);
|
|
405
|
+
if (part_size != expected) failed = 1;
|
|
406
|
+
}
|
|
407
|
+
sqlite3_finalize(st);
|
|
408
|
+
if (fclose(destination) != 0) failed = 1;
|
|
409
|
+
if (failed) {
|
|
410
|
+
unlink(temporary);
|
|
411
|
+
sb_puts(&out, "{\"ok\":false,\"error\":\"multipart completion failed\"}");
|
|
412
|
+
return out.p;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
unsigned char digest[32];
|
|
416
|
+
br_sha256_out(&hash, digest);
|
|
417
|
+
char etag[65];
|
|
418
|
+
sb_hex32(digest, etag);
|
|
419
|
+
char generation_path[1024];
|
|
420
|
+
sb_r2_blob_path(generation_path, sizeof(generation_path), path, bucket, key, etag);
|
|
421
|
+
if (rename(temporary, generation_path) != 0) {
|
|
422
|
+
unlink(temporary);
|
|
423
|
+
sb_puts(&out, "{\"ok\":false,\"error\":\"multipart completion failed\"}");
|
|
424
|
+
return out.p;
|
|
425
|
+
}
|
|
426
|
+
char result[160];
|
|
427
|
+
int n = snprintf(result, sizeof(result), "{\"ok\":true,\"etag\":\"%s\",\"size\":%zu}", etag, total);
|
|
428
|
+
sb_put(&out, result, (size_t)n);
|
|
429
|
+
return out.p;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
static void sb_r2_multipart_delete_parts_c(const char* path, const char* bucket, const char* key, const char* upload_id) {
|
|
433
|
+
int idx = sb_db_for(path);
|
|
434
|
+
if (idx < 0) return;
|
|
435
|
+
sqlite3_stmt* st = 0;
|
|
436
|
+
const char* sql = "SELECT part_number FROM r2_part WHERE bucket = ?1 AND key = ?2 AND upload_id = ?3";
|
|
437
|
+
if (sqlite3_prepare_v2(sb_dbs[idx], sql, -1, &st, 0) != 0 || !st) return;
|
|
438
|
+
sqlite3_bind_text(st, 1, bucket, -1, SB_SQLITE_TRANSIENT);
|
|
439
|
+
sqlite3_bind_text(st, 2, key, -1, SB_SQLITE_TRANSIENT);
|
|
440
|
+
sqlite3_bind_text(st, 3, upload_id, -1, SB_SQLITE_TRANSIENT);
|
|
441
|
+
while (sqlite3_step(st) == SB_SQLITE_ROW) {
|
|
442
|
+
char partpath[1024];
|
|
443
|
+
sb_r2_part_path(partpath, sizeof(partpath), path, bucket, key, upload_id, sqlite3_column_int(st, 0));
|
|
444
|
+
unlink(partpath);
|
|
445
|
+
}
|
|
446
|
+
sqlite3_finalize(st);
|
|
447
|
+
}
|
|
448
|
+
|
|
299
449
|
// Returns malloc'd JSON metadata; the body itself is never serialised into it.
|
|
300
450
|
static char* sb_r2_put_c(const char* path, const char* bucket, const char* key, const char* body, size_t bodylen,
|
|
301
451
|
const char* http_json, const char* custom_json) {
|
|
@@ -309,20 +459,40 @@ static char* sb_r2_put_c(const char* path, const char* bucket, const char* key,
|
|
|
309
459
|
char uploaded[40];
|
|
310
460
|
sb_iso_now(uploaded, sizeof(uploaded));
|
|
311
461
|
|
|
462
|
+
char previous[65];
|
|
463
|
+
previous[0] = 0;
|
|
464
|
+
sqlite3_stmt* old = 0;
|
|
465
|
+
if (sqlite3_prepare_v2(db, "SELECT blob_id FROM r2 WHERE bucket = ?1 AND key = ?2", -1, &old, 0) == 0 && old) {
|
|
466
|
+
sqlite3_bind_text(old, 1, bucket, -1, SB_SQLITE_TRANSIENT);
|
|
467
|
+
sqlite3_bind_text(old, 2, key, -1, SB_SQLITE_TRANSIENT);
|
|
468
|
+
if (sqlite3_step(old) == SB_SQLITE_ROW) {
|
|
469
|
+
const unsigned char* value = sqlite3_column_text(old, 0);
|
|
470
|
+
if (value) snprintf(previous, sizeof(previous), "%s", (const char*)value);
|
|
471
|
+
}
|
|
472
|
+
sqlite3_finalize(old);
|
|
473
|
+
}
|
|
474
|
+
|
|
312
475
|
char blobpath[1024];
|
|
313
|
-
sb_r2_blob_path(blobpath, sizeof(blobpath), path, bucket, key);
|
|
476
|
+
sb_r2_blob_path(blobpath, sizeof(blobpath), path, bucket, key, etag);
|
|
314
477
|
sb_mkdirs(blobpath);
|
|
315
|
-
|
|
478
|
+
char temporary[1100];
|
|
479
|
+
snprintf(temporary, sizeof(temporary), "%s.tmp", blobpath);
|
|
480
|
+
FILE* f = fopen(temporary, "wb");
|
|
316
481
|
if (!f) { sb_puts(&out, "{\"ok\":false,\"error\":\"cannot write object\"}"); return out.p; }
|
|
317
482
|
size_t written = bodylen ? fwrite(body, 1, bodylen, f) : 0;
|
|
318
|
-
fclose(f);
|
|
319
|
-
if (written != bodylen
|
|
483
|
+
int close_rc = fclose(f);
|
|
484
|
+
if (written != bodylen || close_rc != 0 || rename(temporary, blobpath) != 0) {
|
|
485
|
+
unlink(temporary);
|
|
486
|
+
sb_puts(&out, "{\"ok\":false,\"error\":\"object write failed\"}");
|
|
487
|
+
return out.p;
|
|
488
|
+
}
|
|
320
489
|
|
|
321
490
|
sqlite3_stmt* st = 0;
|
|
322
491
|
const char* sql =
|
|
323
|
-
"INSERT INTO r2 (bucket, key, size, etag, uploaded, http_json, custom_json) VALUES (?1,?2,?3,?4,?5,?6,?7) "
|
|
324
|
-
"ON CONFLICT (bucket, key) DO UPDATE SET size=?3, etag=?4, uploaded=?5, http_json=?6, custom_json=?7";
|
|
492
|
+
"INSERT INTO r2 (bucket, key, size, etag, uploaded, http_json, custom_json, blob_id) VALUES (?1,?2,?3,?4,?5,?6,?7,?8) "
|
|
493
|
+
"ON CONFLICT (bucket, key) DO UPDATE SET size=?3, etag=?4, uploaded=?5, http_json=?6, custom_json=?7, blob_id=?8";
|
|
325
494
|
if (sqlite3_prepare_v2(db, sql, -1, &st, 0) != 0 || !st) {
|
|
495
|
+
if (strcmp(previous, etag) != 0) unlink(blobpath);
|
|
326
496
|
sb_puts(&out, "{\"ok\":false,\"error\":");
|
|
327
497
|
const char* m = sqlite3_errmsg(db);
|
|
328
498
|
sb_putjson(&out, m, strlen(m));
|
|
@@ -336,9 +506,23 @@ static char* sb_r2_put_c(const char* path, const char* bucket, const char* key,
|
|
|
336
506
|
sqlite3_bind_text(st, 5, uploaded, -1, SB_SQLITE_TRANSIENT);
|
|
337
507
|
sqlite3_bind_text(st, 6, http_json && *http_json ? http_json : "{}", -1, SB_SQLITE_TRANSIENT);
|
|
338
508
|
sqlite3_bind_text(st, 7, custom_json && *custom_json ? custom_json : "{}", -1, SB_SQLITE_TRANSIENT);
|
|
509
|
+
sqlite3_bind_text(st, 8, etag, -1, SB_SQLITE_TRANSIENT);
|
|
339
510
|
int rc = sqlite3_step(st);
|
|
340
511
|
sqlite3_finalize(st);
|
|
341
|
-
if (rc != SB_SQLITE_DONE) {
|
|
512
|
+
if (rc != SB_SQLITE_DONE) {
|
|
513
|
+
if (strcmp(previous, etag) != 0) unlink(blobpath);
|
|
514
|
+
sb_puts(&out, "{\"ok\":false,\"error\":\"r2 put failed\"}");
|
|
515
|
+
return out.p;
|
|
516
|
+
}
|
|
517
|
+
if (previous[0] && strcmp(previous, etag) != 0) {
|
|
518
|
+
char oldpath[1024];
|
|
519
|
+
sb_r2_blob_path(oldpath, sizeof(oldpath), path, bucket, key, previous);
|
|
520
|
+
unlink(oldpath);
|
|
521
|
+
} else if (!previous[0]) {
|
|
522
|
+
char legacy[1024];
|
|
523
|
+
sb_r2_blob_path(legacy, sizeof(legacy), path, bucket, key, "");
|
|
524
|
+
if (strcmp(legacy, blobpath) != 0) unlink(legacy);
|
|
525
|
+
}
|
|
342
526
|
|
|
343
527
|
char head[256];
|
|
344
528
|
int k = snprintf(head, sizeof(head), "{\"ok\":true,\"etag\":\"%s\",\"size\":%zu,\"uploaded\":\"%s\"}", etag, bodylen, uploaded);
|
|
@@ -350,7 +534,7 @@ static char* sb_r2_put_c(const char* path, const char* bucket, const char* key,
|
|
|
350
534
|
// read) plus the bytestring's own allocation — no SQLite page-cache copy on
|
|
351
535
|
// top, and no growing single database file holding every object ever put.
|
|
352
536
|
// Returns the bytestring pointer, or 0.
|
|
353
|
-
static u32 sb_r2_get_c(const char* path, const char* bucket, const char* key, int* found) {
|
|
537
|
+
static u32 sb_r2_get_c(const char* path, const char* bucket, const char* key, const char* generation, int* found) {
|
|
354
538
|
*found = 0;
|
|
355
539
|
int idx = sb_db_for(path);
|
|
356
540
|
if (idx < 0) return 0;
|
|
@@ -363,7 +547,7 @@ static u32 sb_r2_get_c(const char* path, const char* bucket, const char* key, in
|
|
|
363
547
|
if (!has_row) return 0;
|
|
364
548
|
|
|
365
549
|
char blobpath[1024];
|
|
366
|
-
sb_r2_blob_path(blobpath, sizeof(blobpath), path, bucket, key);
|
|
550
|
+
sb_r2_blob_path(blobpath, sizeof(blobpath), path, bucket, key, generation);
|
|
367
551
|
FILE* f = fopen(blobpath, "rb");
|
|
368
552
|
// A metadata row with no blob file (deleted out from under it, disk issue)
|
|
369
553
|
// reads as not-found rather than crashing.
|
|
@@ -384,9 +568,9 @@ static u32 sb_r2_get_c(const char* path, const char* bucket, const char* key, in
|
|
|
384
568
|
|
|
385
569
|
// Best-effort: a metadata row deleted with no blob file (or vice versa) is
|
|
386
570
|
// still a correct end state, so a missing file here is not an error.
|
|
387
|
-
static void sb_r2_delete_blob(const char* path, const char* bucket, const char* key) {
|
|
571
|
+
static void sb_r2_delete_blob(const char* path, const char* bucket, const char* key, const char* generation) {
|
|
388
572
|
char blobpath[1024];
|
|
389
|
-
sb_r2_blob_path(blobpath, sizeof(blobpath), path, bucket, key);
|
|
573
|
+
sb_r2_blob_path(blobpath, sizeof(blobpath), path, bucket, key, generation);
|
|
390
574
|
unlink(blobpath);
|
|
391
575
|
}
|
|
392
576
|
|
|
@@ -1008,9 +1192,114 @@ function __sbR2PutRaw(path, bucket, key, body, httpJson, customJson) {
|
|
|
1008
1192
|
return res;
|
|
1009
1193
|
}
|
|
1010
1194
|
|
|
1195
|
+
// Multipart parts are persisted individually. Completion streams them through
|
|
1196
|
+
// a fixed 64 KiB C buffer, so the standalone heap never holds the whole object.
|
|
1197
|
+
// oxlint-disable-next-line no-unused-vars -- read inside the RawC block below, not by JS.
|
|
1198
|
+
function __sbR2MultipartPutRaw(path, bucket, key, uploadId, partNumberText, body) {
|
|
1199
|
+
let res = "";
|
|
1200
|
+
// oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
|
|
1201
|
+
Porffor.c`
|
|
1202
|
+
const char* __p; size_t __pl; char* __po = 0;
|
|
1203
|
+
porf_native_fetch_read_value(path, &__p, &__pl, &__po);
|
|
1204
|
+
char* __path = (char*)malloc(__pl + 1); memcpy(__path, __p, __pl); __path[__pl] = 0;
|
|
1205
|
+
if (__po) free(__po);
|
|
1206
|
+
|
|
1207
|
+
const char* __bk; size_t __bkl; char* __bko = 0;
|
|
1208
|
+
porf_native_fetch_read_value(bucket, &__bk, &__bkl, &__bko);
|
|
1209
|
+
char* __bucket = (char*)malloc(__bkl + 1); memcpy(__bucket, __bk, __bkl); __bucket[__bkl] = 0;
|
|
1210
|
+
if (__bko) free(__bko);
|
|
1211
|
+
|
|
1212
|
+
const char* __k; size_t __kl; char* __ko = 0;
|
|
1213
|
+
porf_native_fetch_read_value(key, &__k, &__kl, &__ko);
|
|
1214
|
+
char* __key = (char*)malloc(__kl + 1); memcpy(__key, __k, __kl); __key[__kl] = 0;
|
|
1215
|
+
if (__ko) free(__ko);
|
|
1216
|
+
|
|
1217
|
+
const char* __u; size_t __ul; char* __uo = 0;
|
|
1218
|
+
porf_native_fetch_read_value(uploadId, &__u, &__ul, &__uo);
|
|
1219
|
+
char* __upload = (char*)malloc(__ul + 1); memcpy(__upload, __u, __ul); __upload[__ul] = 0;
|
|
1220
|
+
if (__uo) free(__uo);
|
|
1221
|
+
|
|
1222
|
+
const char* __pn; size_t __pnl; char* __pno = 0;
|
|
1223
|
+
porf_native_fetch_read_value(partNumberText, &__pn, &__pnl, &__pno);
|
|
1224
|
+
char __partbuf[16];
|
|
1225
|
+
size_t __partlen = __pnl < sizeof(__partbuf) - 1 ? __pnl : sizeof(__partbuf) - 1;
|
|
1226
|
+
memcpy(__partbuf, __pn, __partlen); __partbuf[__partlen] = 0;
|
|
1227
|
+
if (__pno) free(__pno);
|
|
1228
|
+
|
|
1229
|
+
const char* __b; size_t __bl; char* __bo = 0;
|
|
1230
|
+
if (porf_native_fetch_read_raw_bytes(body, &__b, &__bl) != 0) {
|
|
1231
|
+
porf_native_fetch_read_value(body, &__b, &__bl, &__bo);
|
|
1232
|
+
}
|
|
1233
|
+
char* __out = sb_r2_part_put_c(__path, __bucket, __key, __upload, atoi(__partbuf), __b, __bl);
|
|
1234
|
+
if (__bo) free(__bo);
|
|
1235
|
+
free(__path); free(__bucket); free(__key); free(__upload);
|
|
1236
|
+
if (__out) {
|
|
1237
|
+
res = porf_box((f64)porf_native_fetch_alloc_bytestring(__out, strlen(__out)), 195);
|
|
1238
|
+
free(__out);
|
|
1239
|
+
}
|
|
1240
|
+
`;
|
|
1241
|
+
return res;
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
// oxlint-disable-next-line no-unused-vars -- read inside the RawC block below, not by JS.
|
|
1245
|
+
function __sbR2MultipartCompleteRaw(path, bucket, key, uploadId) {
|
|
1246
|
+
let res = "";
|
|
1247
|
+
// oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
|
|
1248
|
+
Porffor.c`
|
|
1249
|
+
const char* __p; size_t __pl; char* __po = 0;
|
|
1250
|
+
porf_native_fetch_read_value(path, &__p, &__pl, &__po);
|
|
1251
|
+
char* __path = (char*)malloc(__pl + 1); memcpy(__path, __p, __pl); __path[__pl] = 0;
|
|
1252
|
+
if (__po) free(__po);
|
|
1253
|
+
const char* __bk; size_t __bkl; char* __bko = 0;
|
|
1254
|
+
porf_native_fetch_read_value(bucket, &__bk, &__bkl, &__bko);
|
|
1255
|
+
char* __bucket = (char*)malloc(__bkl + 1); memcpy(__bucket, __bk, __bkl); __bucket[__bkl] = 0;
|
|
1256
|
+
if (__bko) free(__bko);
|
|
1257
|
+
const char* __k; size_t __kl; char* __ko = 0;
|
|
1258
|
+
porf_native_fetch_read_value(key, &__k, &__kl, &__ko);
|
|
1259
|
+
char* __key = (char*)malloc(__kl + 1); memcpy(__key, __k, __kl); __key[__kl] = 0;
|
|
1260
|
+
if (__ko) free(__ko);
|
|
1261
|
+
const char* __u; size_t __ul; char* __uo = 0;
|
|
1262
|
+
porf_native_fetch_read_value(uploadId, &__u, &__ul, &__uo);
|
|
1263
|
+
char* __upload = (char*)malloc(__ul + 1); memcpy(__upload, __u, __ul); __upload[__ul] = 0;
|
|
1264
|
+
if (__uo) free(__uo);
|
|
1265
|
+
char* __out = sb_r2_multipart_complete_c(__path, __bucket, __key, __upload);
|
|
1266
|
+
free(__path); free(__bucket); free(__key); free(__upload);
|
|
1267
|
+
if (__out) {
|
|
1268
|
+
res = porf_box((f64)porf_native_fetch_alloc_bytestring(__out, strlen(__out)), 195);
|
|
1269
|
+
free(__out);
|
|
1270
|
+
}
|
|
1271
|
+
`;
|
|
1272
|
+
return res;
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
// oxlint-disable-next-line no-unused-vars -- read inside the RawC block below, not by JS.
|
|
1276
|
+
function __sbR2MultipartDeleteRaw(path, bucket, key, uploadId) {
|
|
1277
|
+
// oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
|
|
1278
|
+
Porffor.c`
|
|
1279
|
+
const char* __p; size_t __pl; char* __po = 0;
|
|
1280
|
+
porf_native_fetch_read_value(path, &__p, &__pl, &__po);
|
|
1281
|
+
char* __path = (char*)malloc(__pl + 1); memcpy(__path, __p, __pl); __path[__pl] = 0;
|
|
1282
|
+
if (__po) free(__po);
|
|
1283
|
+
const char* __bk; size_t __bkl; char* __bko = 0;
|
|
1284
|
+
porf_native_fetch_read_value(bucket, &__bk, &__bkl, &__bko);
|
|
1285
|
+
char* __bucket = (char*)malloc(__bkl + 1); memcpy(__bucket, __bk, __bkl); __bucket[__bkl] = 0;
|
|
1286
|
+
if (__bko) free(__bko);
|
|
1287
|
+
const char* __k; size_t __kl; char* __ko = 0;
|
|
1288
|
+
porf_native_fetch_read_value(key, &__k, &__kl, &__ko);
|
|
1289
|
+
char* __key = (char*)malloc(__kl + 1); memcpy(__key, __k, __kl); __key[__kl] = 0;
|
|
1290
|
+
if (__ko) free(__ko);
|
|
1291
|
+
const char* __u; size_t __ul; char* __uo = 0;
|
|
1292
|
+
porf_native_fetch_read_value(uploadId, &__u, &__ul, &__uo);
|
|
1293
|
+
char* __upload = (char*)malloc(__ul + 1); memcpy(__upload, __u, __ul); __upload[__ul] = 0;
|
|
1294
|
+
if (__uo) free(__uo);
|
|
1295
|
+
sb_r2_multipart_delete_parts_c(__path, __bucket, __key, __upload);
|
|
1296
|
+
free(__path); free(__bucket); free(__key); free(__upload);
|
|
1297
|
+
`;
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1011
1300
|
// R2 get: the bytes come back as a bytestring, not inside a reply frame.
|
|
1012
1301
|
// oxlint-disable-next-line no-unused-vars -- read inside the RawC block below, not by JS.
|
|
1013
|
-
function __sbR2GetRaw(path, bucket, key) {
|
|
1302
|
+
function __sbR2GetRaw(path, bucket, key, blobId) {
|
|
1014
1303
|
let res = "";
|
|
1015
1304
|
// oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
|
|
1016
1305
|
Porffor.c`
|
|
@@ -1029,9 +1318,14 @@ function __sbR2GetRaw(path, bucket, key) {
|
|
|
1029
1318
|
char* __key = (char*)malloc(__kl + 1); memcpy(__key, __k, __kl); __key[__kl] = 0;
|
|
1030
1319
|
if (__ko) free(__ko);
|
|
1031
1320
|
|
|
1321
|
+
const char* __g; size_t __gl; char* __go = 0;
|
|
1322
|
+
porf_native_fetch_read_value(blobId, &__g, &__gl, &__go);
|
|
1323
|
+
char* __generation = (char*)malloc(__gl + 1); memcpy(__generation, __g, __gl); __generation[__gl] = 0;
|
|
1324
|
+
if (__go) free(__go);
|
|
1325
|
+
|
|
1032
1326
|
int __found = 0;
|
|
1033
|
-
u32 __bs = sb_r2_get_c(__path, __bucket, __key, &__found);
|
|
1034
|
-
free(__path); free(__bucket); free(__key);
|
|
1327
|
+
u32 __bs = sb_r2_get_c(__path, __bucket, __key, __generation, &__found);
|
|
1328
|
+
free(__path); free(__bucket); free(__key); free(__generation);
|
|
1035
1329
|
if (__found) res = porf_box((f64)__bs, 195);
|
|
1036
1330
|
`;
|
|
1037
1331
|
return res;
|
|
@@ -1040,7 +1334,7 @@ function __sbR2GetRaw(path, bucket, key) {
|
|
|
1040
1334
|
// R2 delete: also removes the object's own file, not just the metadata row
|
|
1041
1335
|
// (#56 — sb_r2_delete_blob is best-effort, a missing file is not an error).
|
|
1042
1336
|
// oxlint-disable-next-line no-unused-vars -- read inside the RawC block below, not by JS.
|
|
1043
|
-
function __sbR2DeleteRaw(path, bucket, key) {
|
|
1337
|
+
function __sbR2DeleteRaw(path, bucket, key, blobId) {
|
|
1044
1338
|
// oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
|
|
1045
1339
|
Porffor.c`
|
|
1046
1340
|
const char* __p; size_t __pl; char* __po = 0;
|
|
@@ -1058,8 +1352,13 @@ function __sbR2DeleteRaw(path, bucket, key) {
|
|
|
1058
1352
|
char* __key = (char*)malloc(__kl + 1); memcpy(__key, __k, __kl); __key[__kl] = 0;
|
|
1059
1353
|
if (__ko) free(__ko);
|
|
1060
1354
|
|
|
1061
|
-
|
|
1062
|
-
|
|
1355
|
+
const char* __g; size_t __gl; char* __go = 0;
|
|
1356
|
+
porf_native_fetch_read_value(blobId, &__g, &__gl, &__go);
|
|
1357
|
+
char* __generation = (char*)malloc(__gl + 1); memcpy(__generation, __g, __gl); __generation[__gl] = 0;
|
|
1358
|
+
if (__go) free(__go);
|
|
1359
|
+
|
|
1360
|
+
sb_r2_delete_blob(__path, __bucket, __key, __generation);
|
|
1361
|
+
free(__path); free(__bucket); free(__key); free(__generation);
|
|
1063
1362
|
`;
|
|
1064
1363
|
}
|
|
1065
1364
|
|
|
@@ -1253,8 +1552,33 @@ function __sbEnsureSchema() {
|
|
|
1253
1552
|
s,
|
|
1254
1553
|
"CREATE TABLE IF NOT EXISTS r2 (bucket TEXT NOT NULL, key TEXT NOT NULL, size INTEGER NOT NULL, " +
|
|
1255
1554
|
"etag TEXT NOT NULL, uploaded TEXT NOT NULL, http_json TEXT NOT NULL DEFAULT '{}', custom_json TEXT NOT NULL DEFAULT '{}', " +
|
|
1555
|
+
"blob_id TEXT, " +
|
|
1256
1556
|
"PRIMARY KEY (bucket, key))",
|
|
1257
1557
|
);
|
|
1558
|
+
const r2Columns = __sbSql(s, "PRAGMA table_info(r2)", []).rows;
|
|
1559
|
+
let hasBlobId = false;
|
|
1560
|
+
for (let i = 0; i < r2Columns.length; i++) if (r2Columns[i][1] === "blob_id") hasBlobId = true;
|
|
1561
|
+
if (!hasBlobId) __sbSql(s, "ALTER TABLE r2 ADD COLUMN blob_id TEXT", []);
|
|
1562
|
+
__sbSql(
|
|
1563
|
+
s,
|
|
1564
|
+
"CREATE TABLE IF NOT EXISTS r2_upload (bucket TEXT NOT NULL, key TEXT NOT NULL, upload_id TEXT NOT NULL, " +
|
|
1565
|
+
"uploaded TEXT NOT NULL, http_json TEXT NOT NULL DEFAULT '{}', custom_json TEXT NOT NULL DEFAULT '{}', " +
|
|
1566
|
+
"PRIMARY KEY (bucket, key, upload_id))",
|
|
1567
|
+
);
|
|
1568
|
+
__sbSql(
|
|
1569
|
+
s,
|
|
1570
|
+
"CREATE TABLE IF NOT EXISTS r2_part (bucket TEXT NOT NULL, key TEXT NOT NULL, upload_id TEXT NOT NULL, " +
|
|
1571
|
+
"part_number INTEGER NOT NULL, size INTEGER NOT NULL, etag TEXT NOT NULL, " +
|
|
1572
|
+
"PRIMARY KEY (bucket, key, upload_id, part_number))",
|
|
1573
|
+
);
|
|
1574
|
+
const expired = __sbSql(s, "SELECT bucket, key, upload_id FROM r2_upload WHERE uploaded < ?", [
|
|
1575
|
+
new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
|
|
1576
|
+
]).rows;
|
|
1577
|
+
for (let i = 0; i < expired.length; i++) {
|
|
1578
|
+
__sbR2MultipartDeleteRaw(s, String(expired[i][0]), String(expired[i][1]), String(expired[i][2]));
|
|
1579
|
+
__sbSql(s, "DELETE FROM r2_part WHERE bucket = ? AND key = ? AND upload_id = ?", expired[i]);
|
|
1580
|
+
__sbSql(s, "DELETE FROM r2_upload WHERE bucket = ? AND key = ? AND upload_id = ?", expired[i]);
|
|
1581
|
+
}
|
|
1258
1582
|
__sbSql(
|
|
1259
1583
|
s,
|
|
1260
1584
|
"CREATE TABLE IF NOT EXISTS mq (queue TEXT NOT NULL, id TEXT PRIMARY KEY, body TEXT NOT NULL, " +
|
|
@@ -1461,13 +1785,121 @@ function __sbEmbeddedDispatch(msg) {
|
|
|
1461
1785
|
if (reply.ok === false) throw new Error("r2 put: " + reply.error);
|
|
1462
1786
|
return { ok: true, object: { key: msg.key, size: reply.size, etag: reply.etag, uploaded: reply.uploaded } };
|
|
1463
1787
|
}
|
|
1788
|
+
if (op === "r2.multipart.create") {
|
|
1789
|
+
const uploadId = String(msg.uploadId || __sbHex(16));
|
|
1790
|
+
__sbSql(
|
|
1791
|
+
store,
|
|
1792
|
+
"INSERT INTO r2_upload (bucket, key, upload_id, uploaded, http_json, custom_json) VALUES (?1,?2,?3,?4,?5,?6) " +
|
|
1793
|
+
"ON CONFLICT (bucket, key, upload_id) DO NOTHING",
|
|
1794
|
+
[
|
|
1795
|
+
msg.bucket,
|
|
1796
|
+
msg.key,
|
|
1797
|
+
uploadId,
|
|
1798
|
+
new Date().toISOString(),
|
|
1799
|
+
JSON.stringify(msg.httpMetadata || {}),
|
|
1800
|
+
JSON.stringify(msg.customMetadata || {}),
|
|
1801
|
+
],
|
|
1802
|
+
);
|
|
1803
|
+
return { ok: true, key: msg.key, uploadId };
|
|
1804
|
+
}
|
|
1805
|
+
if (op === "r2.multipart.complete") {
|
|
1806
|
+
const upload = __sbSql(
|
|
1807
|
+
store,
|
|
1808
|
+
"SELECT http_json, custom_json FROM r2_upload WHERE bucket = ? AND key = ? AND upload_id = ?",
|
|
1809
|
+
[msg.bucket, msg.key, msg.uploadId],
|
|
1810
|
+
);
|
|
1811
|
+
if (!upload.rows.length) throw new Error("multipart upload not found");
|
|
1812
|
+
const stored = __sbSql(
|
|
1813
|
+
store,
|
|
1814
|
+
"SELECT part_number, size, etag FROM r2_part WHERE bucket = ? AND key = ? AND upload_id = ? ORDER BY part_number",
|
|
1815
|
+
[msg.bucket, msg.key, msg.uploadId],
|
|
1816
|
+
).rows;
|
|
1817
|
+
const requested = msg.parts || [];
|
|
1818
|
+
if (!requested.length) throw new Error("complete requires at least one uploaded part");
|
|
1819
|
+
if (requested.length !== stored.length) throw new Error("uploaded parts do not match");
|
|
1820
|
+
for (let i = 0; i < stored.length; i++) {
|
|
1821
|
+
const part = requested[i] || {};
|
|
1822
|
+
const partNumber = Number(part.partNumber);
|
|
1823
|
+
if (partNumber !== Number(stored[i][0]) || String(part.etag || "") !== String(stored[i][2]))
|
|
1824
|
+
throw new Error("uploaded parts do not match");
|
|
1825
|
+
if (i > 0 && partNumber <= Number(requested[i - 1].partNumber))
|
|
1826
|
+
throw new Error("uploaded parts must be ordered by partNumber");
|
|
1827
|
+
if (i < stored.length - 1 && Number(stored[i][1]) !== Number(stored[0][1]))
|
|
1828
|
+
throw new Error("all multipart parts except the last must have the same size");
|
|
1829
|
+
if (i === stored.length - 1 && stored.length > 1 && Number(stored[i][1]) > Number(stored[0][1]))
|
|
1830
|
+
throw new Error("the last multipart part cannot be larger than the others");
|
|
1831
|
+
}
|
|
1832
|
+
const previous = __sbSql(store, "SELECT blob_id FROM r2 WHERE bucket = ? AND key = ?", [msg.bucket, msg.key]);
|
|
1833
|
+
const completed = JSON.parse(
|
|
1834
|
+
__sbR2MultipartCompleteRaw(store, String(msg.bucket), String(msg.key), String(msg.uploadId)),
|
|
1835
|
+
);
|
|
1836
|
+
if (completed.ok === false) throw new Error("r2 multipart complete: " + completed.error);
|
|
1837
|
+
const uploaded = new Date().toISOString();
|
|
1838
|
+
try {
|
|
1839
|
+
__sbSql(
|
|
1840
|
+
store,
|
|
1841
|
+
"INSERT INTO r2 (bucket, key, size, etag, uploaded, http_json, custom_json, blob_id) " +
|
|
1842
|
+
"VALUES (?1,?2,?3,?4,?5,?6,?7,?8) " +
|
|
1843
|
+
"ON CONFLICT (bucket, key) DO UPDATE SET size=?3, etag=?4, uploaded=?5, http_json=?6, custom_json=?7, blob_id=?8",
|
|
1844
|
+
[msg.bucket, msg.key, completed.size, completed.etag, uploaded, upload.rows[0][0], upload.rows[0][1], completed.etag],
|
|
1845
|
+
);
|
|
1846
|
+
} catch (error) {
|
|
1847
|
+
if (!previous.rows.length || String(previous.rows[0][0] || "") !== String(completed.etag))
|
|
1848
|
+
__sbR2DeleteRaw(store, String(msg.bucket), String(msg.key), String(completed.etag));
|
|
1849
|
+
throw error;
|
|
1850
|
+
}
|
|
1851
|
+
if (previous.rows.length && String(previous.rows[0][0] || "") !== String(completed.etag))
|
|
1852
|
+
__sbR2DeleteRaw(store, String(msg.bucket), String(msg.key), String(previous.rows[0][0] || ""));
|
|
1853
|
+
__sbR2MultipartDeleteRaw(store, String(msg.bucket), String(msg.key), String(msg.uploadId));
|
|
1854
|
+
__sbSql(store, "DELETE FROM r2_part WHERE bucket = ? AND key = ? AND upload_id = ?", [
|
|
1855
|
+
msg.bucket,
|
|
1856
|
+
msg.key,
|
|
1857
|
+
msg.uploadId,
|
|
1858
|
+
]);
|
|
1859
|
+
__sbSql(store, "DELETE FROM r2_upload WHERE bucket = ? AND key = ? AND upload_id = ?", [
|
|
1860
|
+
msg.bucket,
|
|
1861
|
+
msg.key,
|
|
1862
|
+
msg.uploadId,
|
|
1863
|
+
]);
|
|
1864
|
+
return {
|
|
1865
|
+
ok: true,
|
|
1866
|
+
object: {
|
|
1867
|
+
key: msg.key,
|
|
1868
|
+
size: completed.size,
|
|
1869
|
+
etag: completed.etag,
|
|
1870
|
+
uploaded,
|
|
1871
|
+
httpMetadata: JSON.parse(upload.rows[0][0] || "{}"),
|
|
1872
|
+
customMetadata: JSON.parse(upload.rows[0][1] || "{}"),
|
|
1873
|
+
},
|
|
1874
|
+
};
|
|
1875
|
+
}
|
|
1876
|
+
if (op === "r2.multipart.abort") {
|
|
1877
|
+
const upload = __sbSql(
|
|
1878
|
+
store,
|
|
1879
|
+
"SELECT 1 FROM r2_upload WHERE bucket = ? AND key = ? AND upload_id = ?",
|
|
1880
|
+
[msg.bucket, msg.key, msg.uploadId],
|
|
1881
|
+
);
|
|
1882
|
+
if (!upload.rows.length) throw new Error("multipart upload not found");
|
|
1883
|
+
__sbR2MultipartDeleteRaw(store, String(msg.bucket), String(msg.key), String(msg.uploadId));
|
|
1884
|
+
__sbSql(store, "DELETE FROM r2_part WHERE bucket = ? AND key = ? AND upload_id = ?", [
|
|
1885
|
+
msg.bucket,
|
|
1886
|
+
msg.key,
|
|
1887
|
+
msg.uploadId,
|
|
1888
|
+
]);
|
|
1889
|
+
__sbSql(store, "DELETE FROM r2_upload WHERE bucket = ? AND key = ? AND upload_id = ?", [
|
|
1890
|
+
msg.bucket,
|
|
1891
|
+
msg.key,
|
|
1892
|
+
msg.uploadId,
|
|
1893
|
+
]);
|
|
1894
|
+
return { ok: true };
|
|
1895
|
+
}
|
|
1464
1896
|
if (op === "r2.get" || op === "r2.head") {
|
|
1465
1897
|
// head must not read the blob file: reading an 8 MB object only to drop
|
|
1466
1898
|
// it costs the read and a full JSON escape for nothing.
|
|
1467
1899
|
const wantsBody = op === "r2.get";
|
|
1468
1900
|
const r = __sbSql(
|
|
1469
1901
|
store,
|
|
1470
|
-
"SELECT size, etag, uploaded, http_json, custom_json FROM r2 WHERE bucket = ? AND key = ?",
|
|
1902
|
+
"SELECT size, etag, uploaded, http_json, custom_json, blob_id FROM r2 WHERE bucket = ? AND key = ?",
|
|
1471
1903
|
[msg.bucket, msg.key],
|
|
1472
1904
|
);
|
|
1473
1905
|
if (!r.rows.length) return { ok: true, found: false };
|
|
@@ -1484,12 +1916,15 @@ function __sbEmbeddedDispatch(msg) {
|
|
|
1484
1916
|
httpMetadata: JSON.parse(row[3] || "{}"),
|
|
1485
1917
|
customMetadata: JSON.parse(row[4] || "{}"),
|
|
1486
1918
|
},
|
|
1487
|
-
|
|
1919
|
+
blobId: row[5] || "",
|
|
1920
|
+
body: wantsBody ? __sbR2GetRaw(store, String(msg.bucket), String(msg.key), String(row[5] || "")) : undefined,
|
|
1488
1921
|
};
|
|
1489
1922
|
}
|
|
1490
1923
|
if (op === "r2.delete") {
|
|
1924
|
+
const existing = __sbSql(store, "SELECT blob_id FROM r2 WHERE bucket = ? AND key = ?", [msg.bucket, msg.key]);
|
|
1491
1925
|
__sbSql(store, "DELETE FROM r2 WHERE bucket = ? AND key = ?", [msg.bucket, msg.key]);
|
|
1492
|
-
|
|
1926
|
+
if (existing.rows.length)
|
|
1927
|
+
__sbR2DeleteRaw(store, String(msg.bucket), String(msg.key), String(existing.rows[0][0] || ""));
|
|
1493
1928
|
return { ok: true };
|
|
1494
1929
|
}
|
|
1495
1930
|
if (op === "r2.list") {
|
|
@@ -1671,7 +2106,33 @@ globalThis.__sbR2Get = function (bucket, key) {
|
|
|
1671
2106
|
// Metadata through the normal path (small), bytes through their own.
|
|
1672
2107
|
const meta = __sbEmbeddedDispatch({ op: "r2.head", bucket, key });
|
|
1673
2108
|
if (!meta.found) return { found: false };
|
|
1674
|
-
return {
|
|
2109
|
+
return {
|
|
2110
|
+
found: true,
|
|
2111
|
+
object: meta.object,
|
|
2112
|
+
body: __sbR2GetRaw(__sbStore(), String(bucket), String(key), String(meta.blobId || "")),
|
|
2113
|
+
};
|
|
2114
|
+
};
|
|
2115
|
+
|
|
2116
|
+
globalThis.__sbR2MultipartPut = function (bucket, key, uploadId, partNumber, body) {
|
|
2117
|
+
__sbEnsureSchema();
|
|
2118
|
+
const store = __sbStore();
|
|
2119
|
+
const upload = __sbSql(
|
|
2120
|
+
store,
|
|
2121
|
+
"SELECT 1 FROM r2_upload WHERE bucket = ? AND key = ? AND upload_id = ?",
|
|
2122
|
+
[bucket, key, uploadId],
|
|
2123
|
+
);
|
|
2124
|
+
if (!upload.rows.length) throw new Error("sproutboat r2.multipart.put: multipart upload not found");
|
|
2125
|
+
const reply = JSON.parse(
|
|
2126
|
+
__sbR2MultipartPutRaw(store, String(bucket), String(key), String(uploadId), String(partNumber), body),
|
|
2127
|
+
);
|
|
2128
|
+
if (reply.ok === false) throw new Error("sproutboat r2.multipart.put: " + reply.error);
|
|
2129
|
+
__sbSql(
|
|
2130
|
+
store,
|
|
2131
|
+
"INSERT INTO r2_part (bucket, key, upload_id, part_number, size, etag) VALUES (?1,?2,?3,?4,?5,?6) " +
|
|
2132
|
+
"ON CONFLICT (bucket, key, upload_id, part_number) DO UPDATE SET size=?5, etag=?6",
|
|
2133
|
+
[bucket, key, uploadId, partNumber, reply.size, reply.etag],
|
|
2134
|
+
);
|
|
2135
|
+
return { ok: true, part: { partNumber, etag: reply.etag } };
|
|
1675
2136
|
};
|
|
1676
2137
|
|
|
1677
2138
|
globalThis.__sbAssetsGet = function (path) {
|