@sproutboat/runtime 0.7.1 → 0.9.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.
@@ -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,12 +568,184 @@ 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
 
577
+ // The native-fetch C++ shim calls this narrow ABI for a reserved R2 transfer
578
+ // path before it allocates a PendingRequest body. The context owns only a file
579
+ // descriptor and small metadata buffers; object bytes stay in the kernel page
580
+ // cache until the final fixed-size digest pass.
581
+ typedef struct sb_r2_transfer_ctx {
582
+ FILE* file;
583
+ sqlite3* db;
584
+ char store[1024], bucket[256], key[512], token[32], expected[65], http[1024], custom[1024], temporary[1200];
585
+ size_t size, max_bytes;
586
+ } sb_r2_transfer_ctx;
587
+
588
+ extern const char* sb_standalone_app_name(void);
589
+
590
+ static void sb_r2_transfer_store_path(char* out, size_t cap) {
591
+ const char* dir = getenv("SB_DATA_DIR");
592
+ if (!dir || !*dir) dir = getenv("SPROUTBOAT_DATA");
593
+ if (!dir || !*dir) {
594
+ const char* app = sb_standalone_app_name();
595
+ snprintf(out, cap, "%s.data/store.sqlite", app && *app ? app : "app");
596
+ return;
597
+ }
598
+ snprintf(out, cap, "%s/store.sqlite", dir);
599
+ }
600
+
601
+ // Return HTTP status, or 0 when a one-use upload ticket was claimed.
602
+ int sb_r2_transfer_open(const char* bucket, const char* token, size_t declared, sb_r2_transfer_ctx** out) {
603
+ *out = 0;
604
+ if (!bucket || !token || strlen(bucket) >= 256 || strlen(token) != 24) return 404;
605
+ sb_r2_transfer_ctx* ctx = (sb_r2_transfer_ctx*)calloc(1, sizeof(*ctx));
606
+ if (!ctx) return 503;
607
+ sb_r2_transfer_store_path(ctx->store, sizeof(ctx->store));
608
+ int index = sb_db_for(ctx->store);
609
+ if (index < 0) { free(ctx); return 503; }
610
+ ctx->db = sb_dbs[index];
611
+ sqlite3_stmt* st = 0;
612
+ const char* sql = "SELECT key, max_bytes, expected_sha256, http_json, custom_json FROM r2_transfer "
613
+ "WHERE token=?1 AND bucket=?2 AND method='upload' AND used=0 AND expires>=?3";
614
+ if (sqlite3_prepare_v2(ctx->db, sql, -1, &st, 0) != 0 || !st) { free(ctx); return 503; }
615
+ sqlite3_bind_text(st, 1, token, -1, SB_SQLITE_TRANSIENT);
616
+ sqlite3_bind_text(st, 2, bucket, -1, SB_SQLITE_TRANSIENT);
617
+ sqlite3_bind_int64(st, 3, (int64_t)time(0) * 1000);
618
+ if (sqlite3_step(st) != SB_SQLITE_ROW) { sqlite3_finalize(st); free(ctx); return 410; }
619
+ const char* key = (const char*)sqlite3_column_text(st, 0);
620
+ ctx->max_bytes = (size_t)sqlite3_column_int64(st, 1);
621
+ const char* expected = (const char*)sqlite3_column_text(st, 2);
622
+ const char* http = (const char*)sqlite3_column_text(st, 3);
623
+ const char* custom = (const char*)sqlite3_column_text(st, 4);
624
+ if ((key && strlen(key) >= sizeof(ctx->key)) ||
625
+ (expected && strlen(expected) >= sizeof(ctx->expected)) ||
626
+ (http && strlen(http) >= sizeof(ctx->http)) ||
627
+ (custom && strlen(custom) >= sizeof(ctx->custom))) {
628
+ sqlite3_finalize(st); free(ctx); return 413;
629
+ }
630
+ snprintf(ctx->bucket, sizeof(ctx->bucket), "%s", bucket);
631
+ snprintf(ctx->key, sizeof(ctx->key), "%s", key ? key : "");
632
+ snprintf(ctx->token, sizeof(ctx->token), "%s", token);
633
+ snprintf(ctx->expected, sizeof(ctx->expected), "%s", expected ? expected : "");
634
+ snprintf(ctx->http, sizeof(ctx->http), "%s", http ? http : "{}");
635
+ snprintf(ctx->custom, sizeof(ctx->custom), "%s", custom ? custom : "{}");
636
+ sqlite3_finalize(st);
637
+ if (declared > ctx->max_bytes) { free(ctx); return 413; }
638
+ st = 0;
639
+ if (sqlite3_prepare_v2(ctx->db, "UPDATE r2_transfer SET used=1 WHERE token=?1 AND used=0", -1, &st, 0) != 0 || !st) { free(ctx); return 503; }
640
+ sqlite3_bind_text(st, 1, token, -1, SB_SQLITE_TRANSIENT);
641
+ int claimed = sqlite3_step(st) == SB_SQLITE_DONE && sqlite3_changes(ctx->db) == 1;
642
+ sqlite3_finalize(st);
643
+ if (!claimed) { free(ctx); return 410; }
644
+ const char* slash = strrchr(ctx->store, '/');
645
+ if (!slash) { free(ctx); return 500; }
646
+ char blobdir[1024];
647
+ sb_r2_blob_path(blobdir, sizeof(blobdir), ctx->store, ctx->bucket, ctx->key, "");
648
+ sb_mkdirs(blobdir);
649
+ if (snprintf(ctx->temporary, sizeof(ctx->temporary), "%.*s/r2-blobs/%s.upload.tmp", (int)(slash - ctx->store), ctx->store, token) >= (int)sizeof(ctx->temporary)) { free(ctx); return 507; }
650
+ ctx->file = fopen(ctx->temporary, "wb");
651
+ if (!ctx->file) { free(ctx); return 507; }
652
+ *out = ctx;
653
+ return 0;
654
+ }
655
+
656
+ int sb_r2_transfer_write(sb_r2_transfer_ctx* ctx, const char* bytes, size_t length) {
657
+ if (!ctx || !ctx->file || length > ctx->max_bytes - ctx->size) return 413;
658
+ if (length && fwrite(bytes, 1, length, ctx->file) != length) return 507;
659
+ ctx->size += length;
660
+ return 0;
661
+ }
662
+
663
+ // Finish the native direct-upload path. The digest is deliberately computed
664
+ // from the temporary file in fixed-size chunks so neither validation nor the
665
+ // immutable-generation commit ever materialises the object in process memory.
666
+ int sb_r2_transfer_finish(sb_r2_transfer_ctx* ctx) {
667
+ if (!ctx || !ctx->file) return 500;
668
+ if (fclose(ctx->file) != 0) { ctx->file = 0; unlink(ctx->temporary); free(ctx); return 507; }
669
+ ctx->file = 0;
670
+
671
+ FILE* source = fopen(ctx->temporary, "rb");
672
+ if (!source) { unlink(ctx->temporary); free(ctx); return 500; }
673
+ br_sha256_context hash;
674
+ br_sha256_init(&hash);
675
+ unsigned char chunk[65536];
676
+ for (;;) {
677
+ size_t count = fread(chunk, 1, sizeof(chunk), source);
678
+ if (count) br_sha256_update(&hash, chunk, count);
679
+ if (count < sizeof(chunk)) {
680
+ if (ferror(source)) { fclose(source); unlink(ctx->temporary); free(ctx); return 500; }
681
+ break;
682
+ }
683
+ }
684
+ fclose(source);
685
+ unsigned char digest[32];
686
+ char etag[65];
687
+ br_sha256_out(&hash, digest);
688
+ sb_hex32(digest, etag);
689
+ if (ctx->expected[0] && strcmp(ctx->expected, etag) != 0) {
690
+ unlink(ctx->temporary); free(ctx); return 422;
691
+ }
692
+
693
+ char previous[65] = { 0 };
694
+ sqlite3_stmt* old = 0;
695
+ if (sqlite3_prepare_v2(ctx->db, "SELECT blob_id FROM r2 WHERE bucket=?1 AND key=?2", -1, &old, 0) == 0 && old) {
696
+ sqlite3_bind_text(old, 1, ctx->bucket, -1, SB_SQLITE_TRANSIENT);
697
+ sqlite3_bind_text(old, 2, ctx->key, -1, SB_SQLITE_TRANSIENT);
698
+ if (sqlite3_step(old) == SB_SQLITE_ROW) {
699
+ const unsigned char* value = sqlite3_column_text(old, 0);
700
+ if (value) snprintf(previous, sizeof(previous), "%s", (const char*)value);
701
+ }
702
+ sqlite3_finalize(old);
703
+ }
704
+ char blobpath[1024];
705
+ sb_r2_blob_path(blobpath, sizeof(blobpath), ctx->store, ctx->bucket, ctx->key, etag);
706
+ sb_mkdirs(blobpath);
707
+ if (rename(ctx->temporary, blobpath) != 0) { unlink(ctx->temporary); free(ctx); return 507; }
708
+
709
+ char uploaded[40];
710
+ sb_iso_now(uploaded, sizeof(uploaded));
711
+ sqlite3_stmt* st = 0;
712
+ const char* sql =
713
+ "INSERT INTO r2 (bucket,key,size,etag,uploaded,http_json,custom_json,blob_id) VALUES (?1,?2,?3,?4,?5,?6,?7,?8) "
714
+ "ON CONFLICT(bucket,key) DO UPDATE SET size=?3,etag=?4,uploaded=?5,http_json=?6,custom_json=?7,blob_id=?8";
715
+ if (sqlite3_prepare_v2(ctx->db, sql, -1, &st, 0) != 0 || !st) {
716
+ if (strcmp(previous, etag) != 0) unlink(blobpath);
717
+ free(ctx); return 500;
718
+ }
719
+ sqlite3_bind_text(st, 1, ctx->bucket, -1, SB_SQLITE_TRANSIENT);
720
+ sqlite3_bind_text(st, 2, ctx->key, -1, SB_SQLITE_TRANSIENT);
721
+ sqlite3_bind_int64(st, 3, (int64_t)ctx->size);
722
+ sqlite3_bind_text(st, 4, etag, -1, SB_SQLITE_TRANSIENT);
723
+ sqlite3_bind_text(st, 5, uploaded, -1, SB_SQLITE_TRANSIENT);
724
+ sqlite3_bind_text(st, 6, ctx->http[0] ? ctx->http : "{}", -1, SB_SQLITE_TRANSIENT);
725
+ sqlite3_bind_text(st, 7, ctx->custom[0] ? ctx->custom : "{}", -1, SB_SQLITE_TRANSIENT);
726
+ sqlite3_bind_text(st, 8, etag, -1, SB_SQLITE_TRANSIENT);
727
+ int rc = sqlite3_step(st);
728
+ sqlite3_finalize(st);
729
+ if (rc != SB_SQLITE_DONE) {
730
+ if (strcmp(previous, etag) != 0) unlink(blobpath);
731
+ free(ctx); return 500;
732
+ }
733
+ if (previous[0] && strcmp(previous, etag) != 0) {
734
+ char oldpath[1024];
735
+ sb_r2_blob_path(oldpath, sizeof(oldpath), ctx->store, ctx->bucket, ctx->key, previous);
736
+ unlink(oldpath);
737
+ }
738
+ free(ctx);
739
+ return 201;
740
+ }
741
+
742
+ void sb_r2_transfer_abort(sb_r2_transfer_ctx* ctx) {
743
+ if (!ctx) return;
744
+ if (ctx->file) fclose(ctx->file);
745
+ if (ctx->temporary[0]) unlink(ctx->temporary);
746
+ free(ctx);
747
+ }
748
+
393
749
  // Run a script: one or more statements separated by semicolons. sqlite3_exec
394
750
  // handles the whole string, which prepare/step does not — it compiles the first
395
751
  // statement and silently ignores the rest, so a schema built from one exec call
@@ -1008,9 +1364,114 @@ function __sbR2PutRaw(path, bucket, key, body, httpJson, customJson) {
1008
1364
  return res;
1009
1365
  }
1010
1366
 
1367
+ // Multipart parts are persisted individually. Completion streams them through
1368
+ // a fixed 64 KiB C buffer, so the standalone heap never holds the whole object.
1369
+ // oxlint-disable-next-line no-unused-vars -- read inside the RawC block below, not by JS.
1370
+ function __sbR2MultipartPutRaw(path, bucket, key, uploadId, partNumberText, body) {
1371
+ let res = "";
1372
+ // oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
1373
+ Porffor.c`
1374
+ const char* __p; size_t __pl; char* __po = 0;
1375
+ porf_native_fetch_read_value(path, &__p, &__pl, &__po);
1376
+ char* __path = (char*)malloc(__pl + 1); memcpy(__path, __p, __pl); __path[__pl] = 0;
1377
+ if (__po) free(__po);
1378
+
1379
+ const char* __bk; size_t __bkl; char* __bko = 0;
1380
+ porf_native_fetch_read_value(bucket, &__bk, &__bkl, &__bko);
1381
+ char* __bucket = (char*)malloc(__bkl + 1); memcpy(__bucket, __bk, __bkl); __bucket[__bkl] = 0;
1382
+ if (__bko) free(__bko);
1383
+
1384
+ const char* __k; size_t __kl; char* __ko = 0;
1385
+ porf_native_fetch_read_value(key, &__k, &__kl, &__ko);
1386
+ char* __key = (char*)malloc(__kl + 1); memcpy(__key, __k, __kl); __key[__kl] = 0;
1387
+ if (__ko) free(__ko);
1388
+
1389
+ const char* __u; size_t __ul; char* __uo = 0;
1390
+ porf_native_fetch_read_value(uploadId, &__u, &__ul, &__uo);
1391
+ char* __upload = (char*)malloc(__ul + 1); memcpy(__upload, __u, __ul); __upload[__ul] = 0;
1392
+ if (__uo) free(__uo);
1393
+
1394
+ const char* __pn; size_t __pnl; char* __pno = 0;
1395
+ porf_native_fetch_read_value(partNumberText, &__pn, &__pnl, &__pno);
1396
+ char __partbuf[16];
1397
+ size_t __partlen = __pnl < sizeof(__partbuf) - 1 ? __pnl : sizeof(__partbuf) - 1;
1398
+ memcpy(__partbuf, __pn, __partlen); __partbuf[__partlen] = 0;
1399
+ if (__pno) free(__pno);
1400
+
1401
+ const char* __b; size_t __bl; char* __bo = 0;
1402
+ if (porf_native_fetch_read_raw_bytes(body, &__b, &__bl) != 0) {
1403
+ porf_native_fetch_read_value(body, &__b, &__bl, &__bo);
1404
+ }
1405
+ char* __out = sb_r2_part_put_c(__path, __bucket, __key, __upload, atoi(__partbuf), __b, __bl);
1406
+ if (__bo) free(__bo);
1407
+ free(__path); free(__bucket); free(__key); free(__upload);
1408
+ if (__out) {
1409
+ res = porf_box((f64)porf_native_fetch_alloc_bytestring(__out, strlen(__out)), 195);
1410
+ free(__out);
1411
+ }
1412
+ `;
1413
+ return res;
1414
+ }
1415
+
1416
+ // oxlint-disable-next-line no-unused-vars -- read inside the RawC block below, not by JS.
1417
+ function __sbR2MultipartCompleteRaw(path, bucket, key, uploadId) {
1418
+ let res = "";
1419
+ // oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
1420
+ Porffor.c`
1421
+ const char* __p; size_t __pl; char* __po = 0;
1422
+ porf_native_fetch_read_value(path, &__p, &__pl, &__po);
1423
+ char* __path = (char*)malloc(__pl + 1); memcpy(__path, __p, __pl); __path[__pl] = 0;
1424
+ if (__po) free(__po);
1425
+ const char* __bk; size_t __bkl; char* __bko = 0;
1426
+ porf_native_fetch_read_value(bucket, &__bk, &__bkl, &__bko);
1427
+ char* __bucket = (char*)malloc(__bkl + 1); memcpy(__bucket, __bk, __bkl); __bucket[__bkl] = 0;
1428
+ if (__bko) free(__bko);
1429
+ const char* __k; size_t __kl; char* __ko = 0;
1430
+ porf_native_fetch_read_value(key, &__k, &__kl, &__ko);
1431
+ char* __key = (char*)malloc(__kl + 1); memcpy(__key, __k, __kl); __key[__kl] = 0;
1432
+ if (__ko) free(__ko);
1433
+ const char* __u; size_t __ul; char* __uo = 0;
1434
+ porf_native_fetch_read_value(uploadId, &__u, &__ul, &__uo);
1435
+ char* __upload = (char*)malloc(__ul + 1); memcpy(__upload, __u, __ul); __upload[__ul] = 0;
1436
+ if (__uo) free(__uo);
1437
+ char* __out = sb_r2_multipart_complete_c(__path, __bucket, __key, __upload);
1438
+ free(__path); free(__bucket); free(__key); free(__upload);
1439
+ if (__out) {
1440
+ res = porf_box((f64)porf_native_fetch_alloc_bytestring(__out, strlen(__out)), 195);
1441
+ free(__out);
1442
+ }
1443
+ `;
1444
+ return res;
1445
+ }
1446
+
1447
+ // oxlint-disable-next-line no-unused-vars -- read inside the RawC block below, not by JS.
1448
+ function __sbR2MultipartDeleteRaw(path, bucket, key, uploadId) {
1449
+ // oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
1450
+ Porffor.c`
1451
+ const char* __p; size_t __pl; char* __po = 0;
1452
+ porf_native_fetch_read_value(path, &__p, &__pl, &__po);
1453
+ char* __path = (char*)malloc(__pl + 1); memcpy(__path, __p, __pl); __path[__pl] = 0;
1454
+ if (__po) free(__po);
1455
+ const char* __bk; size_t __bkl; char* __bko = 0;
1456
+ porf_native_fetch_read_value(bucket, &__bk, &__bkl, &__bko);
1457
+ char* __bucket = (char*)malloc(__bkl + 1); memcpy(__bucket, __bk, __bkl); __bucket[__bkl] = 0;
1458
+ if (__bko) free(__bko);
1459
+ const char* __k; size_t __kl; char* __ko = 0;
1460
+ porf_native_fetch_read_value(key, &__k, &__kl, &__ko);
1461
+ char* __key = (char*)malloc(__kl + 1); memcpy(__key, __k, __kl); __key[__kl] = 0;
1462
+ if (__ko) free(__ko);
1463
+ const char* __u; size_t __ul; char* __uo = 0;
1464
+ porf_native_fetch_read_value(uploadId, &__u, &__ul, &__uo);
1465
+ char* __upload = (char*)malloc(__ul + 1); memcpy(__upload, __u, __ul); __upload[__ul] = 0;
1466
+ if (__uo) free(__uo);
1467
+ sb_r2_multipart_delete_parts_c(__path, __bucket, __key, __upload);
1468
+ free(__path); free(__bucket); free(__key); free(__upload);
1469
+ `;
1470
+ }
1471
+
1011
1472
  // R2 get: the bytes come back as a bytestring, not inside a reply frame.
1012
1473
  // oxlint-disable-next-line no-unused-vars -- read inside the RawC block below, not by JS.
1013
- function __sbR2GetRaw(path, bucket, key) {
1474
+ function __sbR2GetRaw(path, bucket, key, blobId) {
1014
1475
  let res = "";
1015
1476
  // oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
1016
1477
  Porffor.c`
@@ -1029,9 +1490,14 @@ function __sbR2GetRaw(path, bucket, key) {
1029
1490
  char* __key = (char*)malloc(__kl + 1); memcpy(__key, __k, __kl); __key[__kl] = 0;
1030
1491
  if (__ko) free(__ko);
1031
1492
 
1493
+ const char* __g; size_t __gl; char* __go = 0;
1494
+ porf_native_fetch_read_value(blobId, &__g, &__gl, &__go);
1495
+ char* __generation = (char*)malloc(__gl + 1); memcpy(__generation, __g, __gl); __generation[__gl] = 0;
1496
+ if (__go) free(__go);
1497
+
1032
1498
  int __found = 0;
1033
- u32 __bs = sb_r2_get_c(__path, __bucket, __key, &__found);
1034
- free(__path); free(__bucket); free(__key);
1499
+ u32 __bs = sb_r2_get_c(__path, __bucket, __key, __generation, &__found);
1500
+ free(__path); free(__bucket); free(__key); free(__generation);
1035
1501
  if (__found) res = porf_box((f64)__bs, 195);
1036
1502
  `;
1037
1503
  return res;
@@ -1040,7 +1506,7 @@ function __sbR2GetRaw(path, bucket, key) {
1040
1506
  // R2 delete: also removes the object's own file, not just the metadata row
1041
1507
  // (#56 — sb_r2_delete_blob is best-effort, a missing file is not an error).
1042
1508
  // oxlint-disable-next-line no-unused-vars -- read inside the RawC block below, not by JS.
1043
- function __sbR2DeleteRaw(path, bucket, key) {
1509
+ function __sbR2DeleteRaw(path, bucket, key, blobId) {
1044
1510
  // oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
1045
1511
  Porffor.c`
1046
1512
  const char* __p; size_t __pl; char* __po = 0;
@@ -1058,8 +1524,13 @@ function __sbR2DeleteRaw(path, bucket, key) {
1058
1524
  char* __key = (char*)malloc(__kl + 1); memcpy(__key, __k, __kl); __key[__kl] = 0;
1059
1525
  if (__ko) free(__ko);
1060
1526
 
1061
- sb_r2_delete_blob(__path, __bucket, __key);
1062
- free(__path); free(__bucket); free(__key);
1527
+ const char* __g; size_t __gl; char* __go = 0;
1528
+ porf_native_fetch_read_value(blobId, &__g, &__gl, &__go);
1529
+ char* __generation = (char*)malloc(__gl + 1); memcpy(__generation, __g, __gl); __generation[__gl] = 0;
1530
+ if (__go) free(__go);
1531
+
1532
+ sb_r2_delete_blob(__path, __bucket, __key, __generation);
1533
+ free(__path); free(__bucket); free(__key); free(__generation);
1063
1534
  `;
1064
1535
  }
1065
1536
 
@@ -1221,7 +1692,10 @@ function __sbDir() {
1221
1692
  // Porffor's runtime init calls porf_init(0, NULL). Resolved once — the answer
1222
1693
  // cannot change mid-run.
1223
1694
  if (!__sbDataDir) {
1224
- __sbDataDir = __sbEnv("SB_DATA_DIR") || __sbEnv("SPROUTBOAT_DATA") || (globalThis.__sbAppName || "app") + ".data";
1695
+ __sbDataDir =
1696
+ __sbEnv("SB_DATA_DIR") ||
1697
+ __sbEnv("SPROUTBOAT_DATA") ||
1698
+ (globalThis.__sbAppName || "app") + ".data";
1225
1699
  }
1226
1700
  return __sbDataDir;
1227
1701
  }
@@ -1253,8 +1727,44 @@ function __sbEnsureSchema() {
1253
1727
  s,
1254
1728
  "CREATE TABLE IF NOT EXISTS r2 (bucket TEXT NOT NULL, key TEXT NOT NULL, size INTEGER NOT NULL, " +
1255
1729
  "etag TEXT NOT NULL, uploaded TEXT NOT NULL, http_json TEXT NOT NULL DEFAULT '{}', custom_json TEXT NOT NULL DEFAULT '{}', " +
1730
+ "blob_id TEXT, " +
1256
1731
  "PRIMARY KEY (bucket, key))",
1257
1732
  );
1733
+ const r2Columns = __sbSql(s, "PRAGMA table_info(r2)", []).rows;
1734
+ let hasBlobId = false;
1735
+ for (let i = 0; i < r2Columns.length; i++) if (r2Columns[i][1] === "blob_id") hasBlobId = true;
1736
+ if (!hasBlobId) __sbSql(s, "ALTER TABLE r2 ADD COLUMN blob_id TEXT", []);
1737
+ __sbSql(
1738
+ s,
1739
+ "CREATE TABLE IF NOT EXISTS r2_upload (bucket TEXT NOT NULL, key TEXT NOT NULL, upload_id TEXT NOT NULL, " +
1740
+ "uploaded TEXT NOT NULL, http_json TEXT NOT NULL DEFAULT '{}', custom_json TEXT NOT NULL DEFAULT '{}', " +
1741
+ "PRIMARY KEY (bucket, key, upload_id))",
1742
+ );
1743
+ __sbSql(
1744
+ s,
1745
+ "CREATE TABLE IF NOT EXISTS r2_part (bucket TEXT NOT NULL, key TEXT NOT NULL, upload_id TEXT NOT NULL, " +
1746
+ "part_number INTEGER NOT NULL, size INTEGER NOT NULL, etag TEXT NOT NULL, " +
1747
+ "PRIMARY KEY (bucket, key, upload_id, part_number))",
1748
+ );
1749
+ __sbSql(
1750
+ s,
1751
+ "CREATE TABLE IF NOT EXISTS r2_transfer (token TEXT PRIMARY KEY, bucket TEXT NOT NULL, key TEXT NOT NULL, " +
1752
+ "method TEXT NOT NULL, expires INTEGER NOT NULL, max_bytes INTEGER NOT NULL, expected_sha256 TEXT NOT NULL DEFAULT '', " +
1753
+ "http_json TEXT NOT NULL DEFAULT '{}', custom_json TEXT NOT NULL DEFAULT '{}', used INTEGER NOT NULL DEFAULT 0)",
1754
+ );
1755
+ const expired = __sbSql(s, "SELECT bucket, key, upload_id FROM r2_upload WHERE uploaded < ?", [
1756
+ new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
1757
+ ]).rows;
1758
+ for (let i = 0; i < expired.length; i++) {
1759
+ __sbR2MultipartDeleteRaw(
1760
+ s,
1761
+ String(expired[i][0]),
1762
+ String(expired[i][1]),
1763
+ String(expired[i][2]),
1764
+ );
1765
+ __sbSql(s, "DELETE FROM r2_part WHERE bucket = ? AND key = ? AND upload_id = ?", expired[i]);
1766
+ __sbSql(s, "DELETE FROM r2_upload WHERE bucket = ? AND key = ? AND upload_id = ?", expired[i]);
1767
+ }
1258
1768
  __sbSql(
1259
1769
  s,
1260
1770
  "CREATE TABLE IF NOT EXISTS mq (queue TEXT NOT NULL, id TEXT PRIMARY KEY, body TEXT NOT NULL, " +
@@ -1300,14 +1810,16 @@ function __sbEmbeddedDispatch(msg) {
1300
1810
 
1301
1811
  if (op === "kv.get") {
1302
1812
  const r = __sbSql(store, "SELECT value FROM kv WHERE ns = ? AND key = ?", [msg.ns, msg.key]);
1303
- return r.rows.length ? { ok: true, found: true, value: r.rows[0][0] } : { ok: true, found: false, value: null };
1813
+ return r.rows.length
1814
+ ? { ok: true, found: true, value: r.rows[0][0] }
1815
+ : { ok: true, found: false, value: null };
1304
1816
  }
1305
1817
  if (op === "kv.put") {
1306
- __sbSql(store, "INSERT INTO kv (ns, key, value) VALUES (?1,?2,?3) ON CONFLICT (ns, key) DO UPDATE SET value = ?3", [
1307
- msg.ns,
1308
- msg.key,
1309
- msg.value,
1310
- ]);
1818
+ __sbSql(
1819
+ store,
1820
+ "INSERT INTO kv (ns, key, value) VALUES (?1,?2,?3) ON CONFLICT (ns, key) DO UPDATE SET value = ?3",
1821
+ [msg.ns, msg.key, msg.value],
1822
+ );
1311
1823
  return { ok: true };
1312
1824
  }
1313
1825
  if (op === "kv.delete") {
@@ -1350,12 +1862,26 @@ function __sbEmbeddedDispatch(msg) {
1350
1862
  }
1351
1863
  if (key != null) {
1352
1864
  const info = meta[key] || {};
1353
- return { ok: true, found: true, status: 200, type: info.type, hash: info.hash, body: files[key] };
1865
+ return {
1866
+ ok: true,
1867
+ found: true,
1868
+ status: 200,
1869
+ type: info.type,
1870
+ hash: info.hash,
1871
+ body: files[key],
1872
+ };
1354
1873
  }
1355
1874
  const nfh = (bundle.manifest && bundle.manifest.notFound) || "none";
1356
1875
  if (nfh === "single-page-application" && files["/index.html"] != null) {
1357
1876
  const info = meta["/index.html"] || {};
1358
- return { ok: true, found: true, status: 200, type: info.type, hash: info.hash, body: files["/index.html"] };
1877
+ return {
1878
+ ok: true,
1879
+ found: true,
1880
+ status: 200,
1881
+ type: info.type,
1882
+ hash: info.hash,
1883
+ body: files["/index.html"],
1884
+ };
1359
1885
  }
1360
1886
  if (nfh === "404-page" && files["/404.html"] != null) {
1361
1887
  const info = meta["/404.html"] || {};
@@ -1369,7 +1895,8 @@ function __sbEmbeddedDispatch(msg) {
1369
1895
  const tls = url.protocol === "https:";
1370
1896
  if (!tls && url.protocol !== "http:") throw new Error("unsupported protocol: " + url.protocol);
1371
1897
  const allow = bindingsOutbound();
1372
- if (allow.indexOf(url.host) === -1) throw new Error("host not in outbound allowlist: " + url.host);
1898
+ if (allow.indexOf(url.host) === -1)
1899
+ throw new Error("host not in outbound allowlist: " + url.host);
1373
1900
  let headerText = "";
1374
1901
  const pairs = msg.headers || [];
1375
1902
  for (let i = 0; i < pairs.length; i++) {
@@ -1420,7 +1947,8 @@ function __sbEmbeddedDispatch(msg) {
1420
1947
  if (op === "d1.batch") {
1421
1948
  const results = [];
1422
1949
  const list = msg.statements || [];
1423
- for (let i = 0; i < list.length; i++) results.push(__sbD1Run(path, list[i].sql, list[i].params));
1950
+ for (let i = 0; i < list.length; i++)
1951
+ results.push(__sbD1Run(path, list[i].sql, list[i].params));
1424
1952
  return { ok: true, results };
1425
1953
  }
1426
1954
  const one = __sbD1Run(path, msg.sql, msg.params);
@@ -1459,7 +1987,175 @@ function __sbEmbeddedDispatch(msg) {
1459
1987
  ),
1460
1988
  );
1461
1989
  if (reply.ok === false) throw new Error("r2 put: " + reply.error);
1462
- return { ok: true, object: { key: msg.key, size: reply.size, etag: reply.etag, uploaded: reply.uploaded } };
1990
+ return {
1991
+ ok: true,
1992
+ object: { key: msg.key, size: reply.size, etag: reply.etag, uploaded: reply.uploaded },
1993
+ };
1994
+ }
1995
+ if (op === "r2.transfer.create") {
1996
+ const method = msg.method === "upload" || msg.method === "download" ? msg.method : "";
1997
+ if (!method) throw new Error("transfer method must be upload or download");
1998
+ const expiresIn = Math.min(Math.max(Number(msg.expiresIn) || 900, 30), 3600);
1999
+ const maxBytes =
2000
+ method === "upload"
2001
+ ? Math.min(Math.max(Number(msg.maxBytes) || 100 * 1024 * 1024, 1), 5 * 1024 * 1024 * 1024)
2002
+ : 0;
2003
+ const expectedSha256 = String(msg.sha256 || "").toLowerCase();
2004
+ if (expectedSha256 && !/^[0-9a-f]{64}$/.test(expectedSha256))
2005
+ throw new Error("sha256 must be 64 hexadecimal characters");
2006
+ const expires = Date.now() + expiresIn * 1000;
2007
+ __sbSql(store, "DELETE FROM r2_transfer WHERE expires < ? OR used = 1", [Date.now()]);
2008
+ const token = __sbHex(12);
2009
+ __sbSql(
2010
+ store,
2011
+ "INSERT INTO r2_transfer (token, bucket, key, method, expires, max_bytes, expected_sha256, http_json, custom_json) " +
2012
+ "VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)",
2013
+ [
2014
+ token,
2015
+ msg.bucket,
2016
+ msg.key,
2017
+ method,
2018
+ expires,
2019
+ maxBytes,
2020
+ expectedSha256,
2021
+ JSON.stringify(msg.httpMetadata || {}),
2022
+ JSON.stringify(msg.customMetadata || {}),
2023
+ ],
2024
+ );
2025
+ return {
2026
+ ok: true,
2027
+ url: "/__sb/r2/transfer/" + encodeURIComponent(String(msg.bucket)) + "/" + token,
2028
+ expiresAt: new Date(expires).toISOString(),
2029
+ };
2030
+ }
2031
+ if (op === "r2.multipart.create") {
2032
+ const uploadId = String(msg.uploadId || __sbHex(16));
2033
+ __sbSql(
2034
+ store,
2035
+ "INSERT INTO r2_upload (bucket, key, upload_id, uploaded, http_json, custom_json) VALUES (?1,?2,?3,?4,?5,?6) " +
2036
+ "ON CONFLICT (bucket, key, upload_id) DO NOTHING",
2037
+ [
2038
+ msg.bucket,
2039
+ msg.key,
2040
+ uploadId,
2041
+ new Date().toISOString(),
2042
+ JSON.stringify(msg.httpMetadata || {}),
2043
+ JSON.stringify(msg.customMetadata || {}),
2044
+ ],
2045
+ );
2046
+ return { ok: true, key: msg.key, uploadId };
2047
+ }
2048
+ if (op === "r2.multipart.complete") {
2049
+ const upload = __sbSql(
2050
+ store,
2051
+ "SELECT http_json, custom_json FROM r2_upload WHERE bucket = ? AND key = ? AND upload_id = ?",
2052
+ [msg.bucket, msg.key, msg.uploadId],
2053
+ );
2054
+ if (!upload.rows.length) throw new Error("multipart upload not found");
2055
+ const stored = __sbSql(
2056
+ store,
2057
+ "SELECT part_number, size, etag FROM r2_part WHERE bucket = ? AND key = ? AND upload_id = ? ORDER BY part_number",
2058
+ [msg.bucket, msg.key, msg.uploadId],
2059
+ ).rows;
2060
+ const requested = msg.parts || [];
2061
+ if (!requested.length) throw new Error("complete requires at least one uploaded part");
2062
+ if (requested.length !== stored.length) throw new Error("uploaded parts do not match");
2063
+ for (let i = 0; i < stored.length; i++) {
2064
+ const part = requested[i] || {};
2065
+ const partNumber = Number(part.partNumber);
2066
+ if (partNumber !== Number(stored[i][0]) || String(part.etag || "") !== String(stored[i][2]))
2067
+ throw new Error("uploaded parts do not match");
2068
+ if (i > 0 && partNumber <= Number(requested[i - 1].partNumber))
2069
+ throw new Error("uploaded parts must be ordered by partNumber");
2070
+ if (i < stored.length - 1 && Number(stored[i][1]) !== Number(stored[0][1]))
2071
+ throw new Error("all multipart parts except the last must have the same size");
2072
+ if (
2073
+ i === stored.length - 1 &&
2074
+ stored.length > 1 &&
2075
+ Number(stored[i][1]) > Number(stored[0][1])
2076
+ )
2077
+ throw new Error("the last multipart part cannot be larger than the others");
2078
+ }
2079
+ const previous = __sbSql(store, "SELECT blob_id FROM r2 WHERE bucket = ? AND key = ?", [
2080
+ msg.bucket,
2081
+ msg.key,
2082
+ ]);
2083
+ const completed = JSON.parse(
2084
+ __sbR2MultipartCompleteRaw(store, String(msg.bucket), String(msg.key), String(msg.uploadId)),
2085
+ );
2086
+ if (completed.ok === false) throw new Error("r2 multipart complete: " + completed.error);
2087
+ const uploaded = new Date().toISOString();
2088
+ try {
2089
+ __sbSql(
2090
+ store,
2091
+ "INSERT INTO r2 (bucket, key, size, etag, uploaded, http_json, custom_json, blob_id) " +
2092
+ "VALUES (?1,?2,?3,?4,?5,?6,?7,?8) " +
2093
+ "ON CONFLICT (bucket, key) DO UPDATE SET size=?3, etag=?4, uploaded=?5, http_json=?6, custom_json=?7, blob_id=?8",
2094
+ [
2095
+ msg.bucket,
2096
+ msg.key,
2097
+ completed.size,
2098
+ completed.etag,
2099
+ uploaded,
2100
+ upload.rows[0][0],
2101
+ upload.rows[0][1],
2102
+ completed.etag,
2103
+ ],
2104
+ );
2105
+ } catch (error) {
2106
+ if (!previous.rows.length || String(previous.rows[0][0] || "") !== String(completed.etag))
2107
+ __sbR2DeleteRaw(store, String(msg.bucket), String(msg.key), String(completed.etag));
2108
+ throw error;
2109
+ }
2110
+ if (previous.rows.length && String(previous.rows[0][0] || "") !== String(completed.etag))
2111
+ __sbR2DeleteRaw(
2112
+ store,
2113
+ String(msg.bucket),
2114
+ String(msg.key),
2115
+ String(previous.rows[0][0] || ""),
2116
+ );
2117
+ __sbR2MultipartDeleteRaw(store, String(msg.bucket), String(msg.key), String(msg.uploadId));
2118
+ __sbSql(store, "DELETE FROM r2_part WHERE bucket = ? AND key = ? AND upload_id = ?", [
2119
+ msg.bucket,
2120
+ msg.key,
2121
+ msg.uploadId,
2122
+ ]);
2123
+ __sbSql(store, "DELETE FROM r2_upload WHERE bucket = ? AND key = ? AND upload_id = ?", [
2124
+ msg.bucket,
2125
+ msg.key,
2126
+ msg.uploadId,
2127
+ ]);
2128
+ return {
2129
+ ok: true,
2130
+ object: {
2131
+ key: msg.key,
2132
+ size: completed.size,
2133
+ etag: completed.etag,
2134
+ uploaded,
2135
+ httpMetadata: JSON.parse(upload.rows[0][0] || "{}"),
2136
+ customMetadata: JSON.parse(upload.rows[0][1] || "{}"),
2137
+ },
2138
+ };
2139
+ }
2140
+ if (op === "r2.multipart.abort") {
2141
+ const upload = __sbSql(
2142
+ store,
2143
+ "SELECT 1 FROM r2_upload WHERE bucket = ? AND key = ? AND upload_id = ?",
2144
+ [msg.bucket, msg.key, msg.uploadId],
2145
+ );
2146
+ if (!upload.rows.length) throw new Error("multipart upload not found");
2147
+ __sbR2MultipartDeleteRaw(store, String(msg.bucket), String(msg.key), String(msg.uploadId));
2148
+ __sbSql(store, "DELETE FROM r2_part WHERE bucket = ? AND key = ? AND upload_id = ?", [
2149
+ msg.bucket,
2150
+ msg.key,
2151
+ msg.uploadId,
2152
+ ]);
2153
+ __sbSql(store, "DELETE FROM r2_upload WHERE bucket = ? AND key = ? AND upload_id = ?", [
2154
+ msg.bucket,
2155
+ msg.key,
2156
+ msg.uploadId,
2157
+ ]);
2158
+ return { ok: true };
1463
2159
  }
1464
2160
  if (op === "r2.get" || op === "r2.head") {
1465
2161
  // head must not read the blob file: reading an 8 MB object only to drop
@@ -1467,7 +2163,7 @@ function __sbEmbeddedDispatch(msg) {
1467
2163
  const wantsBody = op === "r2.get";
1468
2164
  const r = __sbSql(
1469
2165
  store,
1470
- "SELECT size, etag, uploaded, http_json, custom_json FROM r2 WHERE bucket = ? AND key = ?",
2166
+ "SELECT size, etag, uploaded, http_json, custom_json, blob_id FROM r2 WHERE bucket = ? AND key = ?",
1471
2167
  [msg.bucket, msg.key],
1472
2168
  );
1473
2169
  if (!r.rows.length) return { ok: true, found: false };
@@ -1484,12 +2180,25 @@ function __sbEmbeddedDispatch(msg) {
1484
2180
  httpMetadata: JSON.parse(row[3] || "{}"),
1485
2181
  customMetadata: JSON.parse(row[4] || "{}"),
1486
2182
  },
1487
- body: wantsBody ? __sbR2GetRaw(store, String(msg.bucket), String(msg.key)) : undefined,
2183
+ blobId: row[5] || "",
2184
+ body: wantsBody
2185
+ ? __sbR2GetRaw(store, String(msg.bucket), String(msg.key), String(row[5] || ""))
2186
+ : undefined,
1488
2187
  };
1489
2188
  }
1490
2189
  if (op === "r2.delete") {
2190
+ const existing = __sbSql(store, "SELECT blob_id FROM r2 WHERE bucket = ? AND key = ?", [
2191
+ msg.bucket,
2192
+ msg.key,
2193
+ ]);
1491
2194
  __sbSql(store, "DELETE FROM r2 WHERE bucket = ? AND key = ?", [msg.bucket, msg.key]);
1492
- __sbR2DeleteRaw(store, String(msg.bucket), String(msg.key));
2195
+ if (existing.rows.length)
2196
+ __sbR2DeleteRaw(
2197
+ store,
2198
+ String(msg.bucket),
2199
+ String(msg.key),
2200
+ String(existing.rows[0][0] || ""),
2201
+ );
1493
2202
  return { ok: true };
1494
2203
  }
1495
2204
  if (op === "r2.list") {
@@ -1515,12 +2224,11 @@ function __sbEmbeddedDispatch(msg) {
1515
2224
  if (op === "queue.send" || op === "queue.send_batch") {
1516
2225
  const items = op === "queue.send" ? [msg.body] : msg.messages || [];
1517
2226
  for (let i = 0; i < items.length; i++) {
1518
- __sbSql(store, "INSERT INTO mq (queue, id, body, visible_at, attempts, dead) VALUES (?1,?2,?3,?4,0,0)", [
1519
- msg.queue,
1520
- __sbHex(12),
1521
- String(items[i]),
1522
- Date.now(),
1523
- ]);
2227
+ __sbSql(
2228
+ store,
2229
+ "INSERT INTO mq (queue, id, body, visible_at, attempts, dead) VALUES (?1,?2,?3,?4,0,0)",
2230
+ [msg.queue, __sbHex(12), String(items[i]), Date.now()],
2231
+ );
1524
2232
  }
1525
2233
  return { ok: true };
1526
2234
  }
@@ -1531,7 +2239,9 @@ function __sbEmbeddedDispatch(msg) {
1531
2239
  msg.id,
1532
2240
  msg.key,
1533
2241
  ]);
1534
- return r.rows.length ? { ok: true, found: true, value: r.rows[0][0] } : { ok: true, found: false };
2242
+ return r.rows.length
2243
+ ? { ok: true, found: true, value: r.rows[0][0] }
2244
+ : { ok: true, found: false };
1535
2245
  }
1536
2246
  if (op === "do.storage.put") {
1537
2247
  __sbSql(
@@ -1542,7 +2252,11 @@ function __sbEmbeddedDispatch(msg) {
1542
2252
  return { ok: true };
1543
2253
  }
1544
2254
  if (op === "do.storage.delete") {
1545
- const r = __sbSql(store, "DELETE FROM do_storage WHERE cls = ? AND id = ? AND key = ?", [msg.cls, msg.id, msg.key]);
2255
+ const r = __sbSql(store, "DELETE FROM do_storage WHERE cls = ? AND id = ? AND key = ?", [
2256
+ msg.cls,
2257
+ msg.id,
2258
+ msg.key,
2259
+ ]);
1546
2260
  return { ok: true, deleted: r.changes > 0 };
1547
2261
  }
1548
2262
  if (op === "do.storage.delete_all") {
@@ -1579,13 +2293,17 @@ function __sbEmbeddedDispatch(msg) {
1579
2293
  }
1580
2294
 
1581
2295
  if (op === "ae.write") {
1582
- __sbSql(store, "INSERT INTO ae (dataset, ts, indexes_json, blobs_json, doubles_json) VALUES (?1,?2,?3,?4,?5)", [
1583
- msg.dataset,
1584
- Date.now(),
1585
- JSON.stringify(msg.indexes || []),
1586
- JSON.stringify(msg.blobs || []),
1587
- JSON.stringify(msg.doubles || []),
1588
- ]);
2296
+ __sbSql(
2297
+ store,
2298
+ "INSERT INTO ae (dataset, ts, indexes_json, blobs_json, doubles_json) VALUES (?1,?2,?3,?4,?5)",
2299
+ [
2300
+ msg.dataset,
2301
+ Date.now(),
2302
+ JSON.stringify(msg.indexes || []),
2303
+ JSON.stringify(msg.blobs || []),
2304
+ JSON.stringify(msg.doubles || []),
2305
+ ],
2306
+ );
1589
2307
  return { ok: true };
1590
2308
  }
1591
2309
  if (op === "ae.query") {
@@ -1664,14 +2382,50 @@ globalThis.__sbR2Put = function (bucket, key, body, httpMetadata, customMetadata
1664
2382
  ),
1665
2383
  );
1666
2384
  if (reply.ok === false) throw new Error("sproutboat r2.put: " + reply.error);
1667
- return { object: { key: String(key), size: reply.size, etag: reply.etag, uploaded: reply.uploaded } };
2385
+ return {
2386
+ object: { key: String(key), size: reply.size, etag: reply.etag, uploaded: reply.uploaded },
2387
+ };
1668
2388
  };
1669
2389
 
1670
2390
  globalThis.__sbR2Get = function (bucket, key) {
1671
2391
  // Metadata through the normal path (small), bytes through their own.
1672
2392
  const meta = __sbEmbeddedDispatch({ op: "r2.head", bucket, key });
1673
2393
  if (!meta.found) return { found: false };
1674
- return { found: true, object: meta.object, body: __sbR2GetRaw(__sbStore(), String(bucket), String(key)) };
2394
+ return {
2395
+ found: true,
2396
+ object: meta.object,
2397
+ body: __sbR2GetRaw(__sbStore(), String(bucket), String(key), String(meta.blobId || "")),
2398
+ };
2399
+ };
2400
+
2401
+ globalThis.__sbR2MultipartPut = function (bucket, key, uploadId, partNumber, body) {
2402
+ __sbEnsureSchema();
2403
+ const store = __sbStore();
2404
+ const upload = __sbSql(
2405
+ store,
2406
+ "SELECT 1 FROM r2_upload WHERE bucket = ? AND key = ? AND upload_id = ?",
2407
+ [bucket, key, uploadId],
2408
+ );
2409
+ if (!upload.rows.length)
2410
+ throw new Error("sproutboat r2.multipart.put: multipart upload not found");
2411
+ const reply = JSON.parse(
2412
+ __sbR2MultipartPutRaw(
2413
+ store,
2414
+ String(bucket),
2415
+ String(key),
2416
+ String(uploadId),
2417
+ String(partNumber),
2418
+ body,
2419
+ ),
2420
+ );
2421
+ if (reply.ok === false) throw new Error("sproutboat r2.multipart.put: " + reply.error);
2422
+ __sbSql(
2423
+ store,
2424
+ "INSERT INTO r2_part (bucket, key, upload_id, part_number, size, etag) VALUES (?1,?2,?3,?4,?5,?6) " +
2425
+ "ON CONFLICT (bucket, key, upload_id, part_number) DO UPDATE SET size=?5, etag=?6",
2426
+ [bucket, key, uploadId, partNumber, reply.size, reply.etag],
2427
+ );
2428
+ return { ok: true, part: { partNumber, etag: reply.etag } };
1675
2429
  };
1676
2430
 
1677
2431
  globalThis.__sbAssetsGet = function (path) {
@@ -1779,7 +2533,10 @@ globalThis.__sbStartLocalTriggers = function (handlers, bindings) {
1779
2533
  if (p && __sbIsFn(p.then)) __sbTasks.push(p);
1780
2534
  },
1781
2535
  };
1782
- const res = handlers.scheduled({ cron: crons[i], scheduledTime: now.getTime(), noRetry() {} }, ctx);
2536
+ const res = handlers.scheduled(
2537
+ { cron: crons[i], scheduledTime: now.getTime(), noRetry() {} },
2538
+ ctx,
2539
+ );
1783
2540
  if (res && __sbIsFn(res.then)) await res;
1784
2541
  if (__sbTasks.length) await __sbDrainWaitUntil(__sbTasks);
1785
2542
  }
@@ -1837,9 +2594,11 @@ globalThis.__sbStartLocalTriggers = function (handlers, bindings) {
1837
2594
  setInterval(async function () {
1838
2595
  __sbEnsureSchema();
1839
2596
  const now = Date.now();
1840
- const due = __sbSql(store, "SELECT cls, id, at, attempts FROM do_alarm WHERE at <= ? ORDER BY at LIMIT 10", [
1841
- now,
1842
- ]);
2597
+ const due = __sbSql(
2598
+ store,
2599
+ "SELECT cls, id, at, attempts FROM do_alarm WHERE at <= ? ORDER BY at LIMIT 10",
2600
+ [now],
2601
+ );
1843
2602
  for (let i = 0; i < due.rows.length; i++) {
1844
2603
  const cls = due.rows[i][0];
1845
2604
  const id = due.rows[i][1];