@sproutboat/wire 0.5.0 → 0.6.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 +17 -0
- package/package.json +6 -1
- package/src/broker.test.ts +82 -1
- package/src/broker.ts +81 -43
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# @sproutboat/wire
|
|
2
2
|
|
|
3
|
+
## 0.6.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 6998a15: R2 object bytes no longer live inside SQLite, on either transport (baronunread/sproutboat#56). Every `put()` used to bind the whole object into a `body` column, and every `get()` read it back out — doubling the object through SQLite's own page cache/WAL machinery on top of the buffer it already arrived in, which was the real driver of #56's "large uploads make memory skyrocket" complaint (the JSON-escaping half of that was already fixed in #63).
|
|
8
|
+
|
|
9
|
+
Objects now live in their own file under `r2-blobs/`, named by a hash of bucket+key (so unicode keys and directory traversal are non-issues):
|
|
10
|
+
|
|
11
|
+
- **Embedded (standalone):** `<data-dir>/r2-blobs/` — `sb_r2_put_c`/`sb_r2_get_c` write/read the file directly via C `fopen`/`fwrite`/`fread`, no SQLite blob column at all. `sb_r2_delete_blob` removes the file on delete.
|
|
12
|
+
- **Broker:** colocated with whichever store file already holds that bucket's metadata — the main `db` for a bare-string binding, the resource's own file under `resourceDir` for an account-level one (#74) — so blobs persist across a redeploy exactly when that metadata does. `:memory:` stores (tests, local dev) fall back to an in-memory map.
|
|
13
|
+
|
|
14
|
+
No migration: this is a schema change for new writes going forward, not a converter for objects already stored inline from before.
|
|
15
|
+
|
|
16
|
+
Verified end-to-end against both transports (kitchen-sink's full conformance suite, 24/24 standalone + 27/27 broker) and with new unit tests for file-backed persistence, the `:memory:` fallback, and a real binary round-trip over the v1 wire protocol.
|
|
17
|
+
|
|
18
|
+
**Known limitation found while verifying this, not fixed here:** a binary `put()` body (bytes ≥ 0x80) is corrupted on the way *in*, before it ever reaches storage — `porf_native_fetch_read_value` (the function every inline-C string read in the runtime goes through) always UTF-8-encodes a Latin-1-range string on read, which is correct for genuine text but wrong for opaque bytes coming off a raw HTTP body. This predates this change (the old SQLite-blob path had the identical corruption) and is not something the storage backend can fix — it needs an input-side counterpart to `#176`'s `x-sb-raw-body` marker, or real `ArrayBuffer`/`Uint8Array` body support (the upstream ask already filed as Draft G in `patches/UPSTREAM.md`).
|
|
19
|
+
|
|
3
20
|
## 0.5.0
|
|
4
21
|
|
|
5
22
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sproutboat/wire",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/baronunread/sproutboat-packages.git",
|
|
9
|
+
"directory": "packages/wire"
|
|
10
|
+
},
|
|
6
11
|
"main": "./src/index.ts",
|
|
7
12
|
"types": "./src/index.ts",
|
|
8
13
|
"dependencies": {
|
package/src/broker.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { afterEach, expect, test } from "bun:test";
|
|
2
2
|
import { Database } from "bun:sqlite";
|
|
3
|
-
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import {
|
|
@@ -332,6 +332,87 @@ test("resource-backed KV persists across brokers and is shared by id", async ()
|
|
|
332
332
|
}
|
|
333
333
|
});
|
|
334
334
|
|
|
335
|
+
// #56 — object bytes live in their own file next to the store's own SQLite
|
|
336
|
+
// file, not inside it, so a resource-bound bucket's blobs persist across a
|
|
337
|
+
// redeploy exactly like its metadata does, and a bare-string binding's blobs
|
|
338
|
+
// live beside its per-deployment db.
|
|
339
|
+
test("R2 blobs are file-backed and persist across brokers for a resource-bound bucket", async () => {
|
|
340
|
+
const root = mkdtempSync(join(tmpdir(), "sb-broker-r2-res-"));
|
|
341
|
+
try {
|
|
342
|
+
const resourceDir = join(root, "resources");
|
|
343
|
+
const bucketId = "r2_0123456789abcdef01234567";
|
|
344
|
+
mkdirSync(join(root, "dep-a"), { recursive: true });
|
|
345
|
+
mkdirSync(join(root, "dep-b"), { recursive: true });
|
|
346
|
+
|
|
347
|
+
const first = createBroker({
|
|
348
|
+
db: join(root, "dep-a", "state.sqlite"),
|
|
349
|
+
resourceDir,
|
|
350
|
+
bindings: { r2: ["UPLOADS"], resources: { UPLOADS: { kind: "r2", id: bucketId } } },
|
|
351
|
+
});
|
|
352
|
+
await first.dispatch({ op: "r2.put", bucket: "UPLOADS", key: "a.txt", body: "hello" });
|
|
353
|
+
first.close();
|
|
354
|
+
// metadata in the per-resource file, bytes in their own file beside it —
|
|
355
|
+
// never inline in the sqlite row.
|
|
356
|
+
const dbFile = join(resourceDir, `${bucketId}.sqlite`);
|
|
357
|
+
expect(existsSync(dbFile)).toBe(true);
|
|
358
|
+
expect(readFileSync(dbFile, "utf8")).not.toContain("hello");
|
|
359
|
+
expect(existsSync(join(resourceDir, "r2-blobs"))).toBe(true);
|
|
360
|
+
|
|
361
|
+
const second = createBroker({
|
|
362
|
+
db: join(root, "dep-b", "state.sqlite"),
|
|
363
|
+
resourceDir,
|
|
364
|
+
bindings: { r2: ["FILES"], resources: { FILES: { kind: "r2", id: bucketId } } },
|
|
365
|
+
});
|
|
366
|
+
expect(await second.dispatch({ op: "r2.get", bucket: "FILES", key: "a.txt" })).toMatchObject({
|
|
367
|
+
ok: true,
|
|
368
|
+
found: true,
|
|
369
|
+
body: "hello",
|
|
370
|
+
});
|
|
371
|
+
second.close();
|
|
372
|
+
} finally {
|
|
373
|
+
rmSync(root, { recursive: true, force: true });
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test("a bare-string R2 binding stores blobs beside the per-deployment db, not resourceDir", async () => {
|
|
378
|
+
const root = mkdtempSync(join(tmpdir(), "sb-broker-r2-bare-"));
|
|
379
|
+
try {
|
|
380
|
+
const resourceDir = join(root, "resources");
|
|
381
|
+
mkdirSync(join(root, "dep"), { recursive: true });
|
|
382
|
+
const b = createBroker({ db: join(root, "dep", "state.sqlite"), resourceDir, bindings: { r2: ["UPLOADS"] } });
|
|
383
|
+
const put = await b.dispatch({ op: "r2.put", bucket: "UPLOADS", key: "k", body: "bytes" });
|
|
384
|
+
b.close();
|
|
385
|
+
expect(existsSync(resourceDir)).toBe(false);
|
|
386
|
+
expect(existsSync(join(root, "dep", "r2-blobs"))).toBe(true);
|
|
387
|
+
expect(obj(put.object)).toMatchObject({ size: 5 });
|
|
388
|
+
} finally {
|
|
389
|
+
rmSync(root, { recursive: true, force: true });
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
// Binary round trip against a real file-backed store (not `:memory:`), the
|
|
394
|
+
// v1 path a live sprout actually uses (#63).
|
|
395
|
+
test("R2 v1 binary put/get round-trips through a file-backed blob", async () => {
|
|
396
|
+
const root = mkdtempSync(join(tmpdir(), "sb-broker-r2-v1-"));
|
|
397
|
+
try {
|
|
398
|
+
const b = createBroker({ db: join(root, "state.sqlite"), bindings: { r2: ["UPLOADS"] }, token: "tok" });
|
|
399
|
+
const server = listen(b, "127.0.0.1", 0);
|
|
400
|
+
try {
|
|
401
|
+
const body = new Uint8Array([0, 1, 2, 0x80, 0xff, 254, 253]);
|
|
402
|
+
const put = await v1(server, { v: 1, token: "tok", op: "r2.put", bucket: "UPLOADS", key: "k" }, body);
|
|
403
|
+
expect(obj(put.json.object)).toMatchObject({ size: body.length });
|
|
404
|
+
expect(existsSync(join(root, "r2-blobs"))).toBe(true);
|
|
405
|
+
const got = await v1(server, { v: 1, token: "tok", op: "r2.get", bucket: "UPLOADS", key: "k" });
|
|
406
|
+
expect(Buffer.from(got.bytes).equals(Buffer.from(body))).toBe(true);
|
|
407
|
+
} finally {
|
|
408
|
+
server.stop();
|
|
409
|
+
b.close();
|
|
410
|
+
}
|
|
411
|
+
} finally {
|
|
412
|
+
rmSync(root, { recursive: true, force: true });
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
|
|
335
416
|
test("a bare-string binding still uses the per-broker db, not resourceDir", async () => {
|
|
336
417
|
const root = mkdtempSync(join(tmpdir(), "sb-broker-bare-"));
|
|
337
418
|
try {
|
package/src/broker.ts
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import { Database, type Statement } from "bun:sqlite";
|
|
21
21
|
import { createHash } from "node:crypto";
|
|
22
|
-
import { existsSync, mkdirSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
22
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
23
23
|
import { dirname, join, normalize, resolve } from "node:path";
|
|
24
24
|
import { parseArgs } from "node:util";
|
|
25
25
|
import { resolveAssetKey, type AssetManifest } from "@sproutboat/assets";
|
|
@@ -110,8 +110,6 @@ type D1Result = {
|
|
|
110
110
|
};
|
|
111
111
|
type R2Row = {
|
|
112
112
|
key: string;
|
|
113
|
-
/** TEXT from a v0 write, BLOB from a v1 one; sqlite hands each back as-is. */
|
|
114
|
-
body: string | Uint8Array;
|
|
115
113
|
size: number;
|
|
116
114
|
etag: string;
|
|
117
115
|
uploaded: string;
|
|
@@ -234,8 +232,14 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
234
232
|
conn.exec(
|
|
235
233
|
"CREATE TABLE IF NOT EXISTS kv (ns TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (ns, key))",
|
|
236
234
|
);
|
|
235
|
+
// #56 — no `body` column: an object's bytes live in their own file under
|
|
236
|
+
// `r2Dir` (or an in-memory map for `:memory:`), never in the SQLite row.
|
|
237
|
+
// Doubling every object through SQLite's own page cache on top of the
|
|
238
|
+
// buffer it already arrived in was the real cost driving #56's "large
|
|
239
|
+
// uploads make memory skyrocket" complaint, not just the JSON-escaping
|
|
240
|
+
// #63 already fixed.
|
|
237
241
|
conn.exec(
|
|
238
|
-
"CREATE TABLE IF NOT EXISTS r2 (bucket TEXT NOT NULL, key TEXT NOT NULL,
|
|
242
|
+
"CREATE TABLE IF NOT EXISTS r2 (bucket TEXT NOT NULL, key TEXT NOT NULL, size INTEGER NOT NULL, " +
|
|
239
243
|
"etag TEXT NOT NULL, uploaded TEXT NOT NULL, http_json TEXT NOT NULL DEFAULT '{}', custom_json TEXT NOT NULL DEFAULT '{}', " +
|
|
240
244
|
"PRIMARY KEY (bucket, key))",
|
|
241
245
|
);
|
|
@@ -414,15 +418,48 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
414
418
|
return { path: dest, bytes: statSync(dest).size };
|
|
415
419
|
};
|
|
416
420
|
|
|
417
|
-
/**
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
*
|
|
423
|
-
*
|
|
424
|
-
|
|
425
|
-
|
|
421
|
+
/**
|
|
422
|
+
* #56 — an object's bytes never enter SQLite; they live in their own file,
|
|
423
|
+
* named by a hash of bucket+key (so unicode keys and directory traversal
|
|
424
|
+
* are both non-issues), colocated with whichever store file holds that
|
|
425
|
+
* bucket's metadata — the main `db` for a bare-string binding, the
|
|
426
|
+
* resource's own file under `resourceDir` for an account-level one (#74) —
|
|
427
|
+
* so blobs persist across a redeploy exactly when that metadata does.
|
|
428
|
+
* `:memory:` stores (tests, `db: ":memory:"`) fall back to an in-memory map.
|
|
429
|
+
*/
|
|
430
|
+
const r2MemBlobs = new Map<string, Uint8Array>();
|
|
431
|
+
const r2BlobId = (bucketPart: string, key: string): string =>
|
|
432
|
+
createHash("sha256").update(`${bucketPart}\0${key}`).digest("hex");
|
|
433
|
+
const r2BlobDir = (store: Database): string | null => {
|
|
434
|
+
const file = store.filename;
|
|
435
|
+
return file && file !== ":memory:" ? join(dirname(resolve(file)), "r2-blobs") : null;
|
|
436
|
+
};
|
|
437
|
+
const r2BlobPath = (store: Database, bucketPart: string, key: string): string | null => {
|
|
438
|
+
const dir = r2BlobDir(store);
|
|
439
|
+
return dir ? join(dir, `${r2BlobId(bucketPart, key)}.blob`) : null;
|
|
440
|
+
};
|
|
441
|
+
const r2WriteBlob = (store: Database, bucketPart: string, key: string, bytes: Uint8Array): void => {
|
|
442
|
+
const path = r2BlobPath(store, bucketPart, key);
|
|
443
|
+
if (!path) {
|
|
444
|
+
r2MemBlobs.set(`${bucketPart}\0${key}`, bytes);
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
448
|
+
writeFileSync(path, bytes);
|
|
449
|
+
};
|
|
450
|
+
const r2ReadBlob = (store: Database, bucketPart: string, key: string): Uint8Array | null => {
|
|
451
|
+
const path = r2BlobPath(store, bucketPart, key);
|
|
452
|
+
if (!path) return r2MemBlobs.get(`${bucketPart}\0${key}`) ?? null;
|
|
453
|
+
return existsSync(path) ? readFileSync(path) : null;
|
|
454
|
+
};
|
|
455
|
+
const r2DeleteBlob = (store: Database, bucketPart: string, key: string): void => {
|
|
456
|
+
const path = r2BlobPath(store, bucketPart, key);
|
|
457
|
+
if (!path) {
|
|
458
|
+
r2MemBlobs.delete(`${bucketPart}\0${key}`);
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
if (existsSync(path)) rmSync(path);
|
|
462
|
+
};
|
|
426
463
|
|
|
427
464
|
const r2Row = (r: R2Row) => ({
|
|
428
465
|
key: r.key,
|
|
@@ -617,40 +654,42 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
617
654
|
|
|
618
655
|
case "r2.put": {
|
|
619
656
|
const { store, part: bucket } = storeFor("r2", requireR2(msg.bucket));
|
|
620
|
-
const
|
|
621
|
-
const
|
|
657
|
+
const key = str(msg.key);
|
|
658
|
+
const bytes = new TextEncoder().encode(str(msg.body));
|
|
659
|
+
const etag = createHash("sha256").update(bytes).digest("hex");
|
|
660
|
+
const uploaded = new Date().toISOString();
|
|
661
|
+
r2WriteBlob(store, bucket, key, bytes);
|
|
622
662
|
store
|
|
623
663
|
.query(
|
|
624
|
-
"INSERT INTO r2 (bucket, key,
|
|
625
|
-
"ON CONFLICT (bucket, key) DO UPDATE SET
|
|
664
|
+
"INSERT INTO r2 (bucket, key, size, etag, uploaded, http_json, custom_json) VALUES (?1,?2,?3,?4,?5,?6,?7) " +
|
|
665
|
+
"ON CONFLICT (bucket, key) DO UPDATE SET size=?3, etag=?4, uploaded=?5, http_json=?6, custom_json=?7",
|
|
626
666
|
)
|
|
627
667
|
.run(
|
|
628
668
|
bucket,
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
Buffer.byteLength(body),
|
|
669
|
+
key,
|
|
670
|
+
bytes.byteLength,
|
|
632
671
|
etag,
|
|
633
|
-
|
|
672
|
+
uploaded,
|
|
634
673
|
JSON.stringify(msg.httpMetadata ?? {}),
|
|
635
674
|
JSON.stringify(msg.customMetadata ?? {}),
|
|
636
675
|
);
|
|
637
|
-
return {
|
|
638
|
-
ok: true,
|
|
639
|
-
object: { key: str(msg.key), size: Buffer.byteLength(body), etag, uploaded: new Date().toISOString() },
|
|
640
|
-
};
|
|
676
|
+
return { ok: true, object: { key, size: bytes.byteLength, etag, uploaded } };
|
|
641
677
|
}
|
|
642
678
|
case "r2.get":
|
|
643
679
|
case "r2.head": {
|
|
644
680
|
const { store, part: bucket } = storeFor("r2", requireR2(msg.bucket));
|
|
645
|
-
const
|
|
646
|
-
|
|
647
|
-
.get(bucket, str(msg.key));
|
|
681
|
+
const key = str(msg.key);
|
|
682
|
+
const row = store.query<R2Row, [string, string]>("SELECT * FROM r2 WHERE bucket = ? AND key = ?").get(bucket, key);
|
|
648
683
|
if (!row) return { ok: true, found: false };
|
|
649
|
-
|
|
684
|
+
const body =
|
|
685
|
+
msg.op === "r2.get" ? new TextDecoder().decode(r2ReadBlob(store, bucket, key) ?? new Uint8Array(0)) : undefined;
|
|
686
|
+
return { ok: true, found: true, object: r2Row(row), body };
|
|
650
687
|
}
|
|
651
688
|
case "r2.delete": {
|
|
652
689
|
const { store, part: bucket } = storeFor("r2", requireR2(msg.bucket));
|
|
653
|
-
|
|
690
|
+
const key = str(msg.key);
|
|
691
|
+
store.query("DELETE FROM r2 WHERE bucket = ? AND key = ?").run(bucket, key);
|
|
692
|
+
r2DeleteBlob(store, bucket, key);
|
|
654
693
|
return { ok: true };
|
|
655
694
|
}
|
|
656
695
|
case "r2.list": {
|
|
@@ -832,38 +871,37 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
832
871
|
*/
|
|
833
872
|
async function dispatchBinary(msg: Frame, binary: Uint8Array): Promise<{ reply: Frame; bytes?: Uint8Array }> {
|
|
834
873
|
if (msg.op === "r2.put") {
|
|
835
|
-
// The body arrived as bytes;
|
|
836
|
-
//
|
|
874
|
+
// The body arrived as bytes; write it straight to its own file rather
|
|
875
|
+
// than into SQLite (#56) — no escape, no parse, no re-encode, and no
|
|
876
|
+
// second full-object copy through the database's own page cache.
|
|
837
877
|
const { store, part: bucket } = storeFor("r2", requireR2(msg.bucket));
|
|
878
|
+
const key = str(msg.key);
|
|
838
879
|
const etag = createHash("sha256").update(binary).digest("hex");
|
|
839
880
|
const uploaded = new Date().toISOString();
|
|
881
|
+
r2WriteBlob(store, bucket, key, binary);
|
|
840
882
|
store
|
|
841
883
|
.query(
|
|
842
|
-
"INSERT INTO r2 (bucket, key,
|
|
843
|
-
"ON CONFLICT (bucket, key) DO UPDATE SET
|
|
884
|
+
"INSERT INTO r2 (bucket, key, size, etag, uploaded, http_json, custom_json) VALUES (?1,?2,?3,?4,?5,?6,?7) " +
|
|
885
|
+
"ON CONFLICT (bucket, key) DO UPDATE SET size=?3, etag=?4, uploaded=?5, http_json=?6, custom_json=?7",
|
|
844
886
|
)
|
|
845
887
|
.run(
|
|
846
888
|
bucket,
|
|
847
|
-
|
|
848
|
-
binary,
|
|
889
|
+
key,
|
|
849
890
|
binary.byteLength,
|
|
850
891
|
etag,
|
|
851
892
|
uploaded,
|
|
852
893
|
JSON.stringify(msg.httpMetadata ?? {}),
|
|
853
894
|
JSON.stringify(msg.customMetadata ?? {}),
|
|
854
895
|
);
|
|
855
|
-
return {
|
|
856
|
-
reply: { ok: true, object: { key: str(msg.key), size: binary.byteLength, etag, uploaded } },
|
|
857
|
-
};
|
|
896
|
+
return { reply: { ok: true, object: { key, size: binary.byteLength, etag, uploaded } } };
|
|
858
897
|
}
|
|
859
898
|
|
|
860
899
|
if (msg.op === "r2.get") {
|
|
861
900
|
const { store, part: bucket } = storeFor("r2", requireR2(msg.bucket));
|
|
862
|
-
const
|
|
863
|
-
|
|
864
|
-
.get(bucket, str(msg.key));
|
|
901
|
+
const key = str(msg.key);
|
|
902
|
+
const row = store.query<R2Row, [string, string]>("SELECT * FROM r2 WHERE bucket = ? AND key = ?").get(bucket, key);
|
|
865
903
|
if (!row) return { reply: { ok: true, found: false } };
|
|
866
|
-
return { reply: { ok: true, found: true, object: r2Row(row) }, bytes:
|
|
904
|
+
return { reply: { ok: true, found: true, object: r2Row(row) }, bytes: r2ReadBlob(store, bucket, key) ?? new Uint8Array(0) };
|
|
867
905
|
}
|
|
868
906
|
|
|
869
907
|
if (msg.op === "assets.get") {
|