@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutboat/runtime",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {
@@ -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.
@@ -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
- `${src.slice(utf8Start, utf8End)}\n${src.slice(rawStart, rawEnd)}\n${src.slice(r2Start, r2End)}\nreturn { __sbR2Object };`,
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 { __sbR2Object } = load();
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
+ });
@@ -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
- FILE* f = fopen(blobpath, "wb");
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) { sb_puts(&out, "{\"ok\":false,\"error\":\"short write\"}"); return out.p; }
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) { sb_puts(&out, "{\"ok\":false,\"error\":\"r2 put failed\"}"); return out.p; }
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
- sb_r2_delete_blob(__path, __bucket, __key);
1062
- free(__path); free(__bucket); free(__key);
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
- body: wantsBody ? __sbR2GetRaw(store, String(msg.bucket), String(msg.key)) : undefined,
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
- __sbR2DeleteRaw(store, String(msg.bucket), String(msg.key));
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 { found: true, object: meta.object, body: __sbR2GetRaw(__sbStore(), String(bucket), String(key)) };
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) {