@freqhole/reliquary 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (226) hide show
  1. package/dist/automerge/acl-filtering.d.ts +87 -0
  2. package/dist/automerge/acl-filtering.d.ts.map +1 -0
  3. package/dist/automerge/acl-filtering.js +138 -0
  4. package/dist/automerge/acl-filtering.test.d.ts +2 -0
  5. package/dist/automerge/acl-filtering.test.d.ts.map +1 -0
  6. package/dist/automerge/acl-filtering.test.js +302 -0
  7. package/dist/automerge/index.d.ts +5 -0
  8. package/dist/automerge/index.d.ts.map +1 -0
  9. package/dist/automerge/index.js +7 -0
  10. package/dist/automerge/iroh-network-adapter.d.ts +178 -0
  11. package/dist/automerge/iroh-network-adapter.d.ts.map +1 -0
  12. package/dist/automerge/iroh-network-adapter.js +691 -0
  13. package/dist/automerge/iroh-network-adapter.test.d.ts +2 -0
  14. package/dist/automerge/iroh-network-adapter.test.d.ts.map +1 -0
  15. package/dist/automerge/iroh-network-adapter.test.js +818 -0
  16. package/dist/automerge/types.d.ts +66 -0
  17. package/dist/automerge/types.d.ts.map +1 -0
  18. package/dist/automerge/types.js +10 -0
  19. package/dist/blobs/bytes-backend.d.ts +41 -0
  20. package/dist/blobs/bytes-backend.d.ts.map +1 -0
  21. package/dist/blobs/bytes-backend.js +252 -0
  22. package/dist/blobs/bytes-backend.test.d.ts +2 -0
  23. package/dist/blobs/bytes-backend.test.d.ts.map +1 -0
  24. package/dist/blobs/bytes-backend.test.js +241 -0
  25. package/dist/blobs/db.d.ts +8 -0
  26. package/dist/blobs/db.d.ts.map +1 -0
  27. package/dist/blobs/db.js +141 -0
  28. package/dist/blobs/db.test.d.ts +2 -0
  29. package/dist/blobs/db.test.d.ts.map +1 -0
  30. package/dist/blobs/db.test.js +120 -0
  31. package/dist/blobs/index.d.ts +5 -0
  32. package/dist/blobs/index.d.ts.map +1 -0
  33. package/dist/blobs/index.js +2 -0
  34. package/dist/blobs/store.d.ts +47 -0
  35. package/dist/blobs/store.d.ts.map +1 -0
  36. package/dist/blobs/store.js +263 -0
  37. package/dist/blobs/store.test.d.ts +2 -0
  38. package/dist/blobs/store.test.d.ts.map +1 -0
  39. package/dist/blobs/store.test.js +377 -0
  40. package/dist/blobs/types.d.ts +55 -0
  41. package/dist/blobs/types.d.ts.map +1 -0
  42. package/dist/blobs/types.js +8 -0
  43. package/dist/ensure/client.d.ts +19 -0
  44. package/dist/ensure/client.d.ts.map +1 -0
  45. package/dist/ensure/client.js +74 -0
  46. package/dist/ensure/client.test.d.ts +2 -0
  47. package/dist/ensure/client.test.d.ts.map +1 -0
  48. package/dist/ensure/client.test.js +133 -0
  49. package/dist/ensure/index.d.ts +4 -0
  50. package/dist/ensure/index.d.ts.map +1 -0
  51. package/dist/ensure/index.js +4 -0
  52. package/dist/ensure/responder.d.ts +20 -0
  53. package/dist/ensure/responder.d.ts.map +1 -0
  54. package/dist/ensure/responder.js +100 -0
  55. package/dist/ensure/responder.test.d.ts +2 -0
  56. package/dist/ensure/responder.test.d.ts.map +1 -0
  57. package/dist/ensure/responder.test.js +191 -0
  58. package/dist/ensure/types.d.ts +19 -0
  59. package/dist/ensure/types.d.ts.map +1 -0
  60. package/dist/ensure/types.js +4 -0
  61. package/dist/ensure/types.test.d.ts +2 -0
  62. package/dist/ensure/types.test.d.ts.map +1 -0
  63. package/dist/ensure/types.test.js +81 -0
  64. package/dist/index.d.ts +2 -0
  65. package/dist/index.d.ts.map +1 -0
  66. package/dist/index.js +3 -0
  67. package/dist/index.test.d.ts +2 -0
  68. package/dist/index.test.d.ts.map +1 -0
  69. package/dist/index.test.js +7 -0
  70. package/dist/solid/blob-url.d.ts +10 -0
  71. package/dist/solid/blob-url.d.ts.map +1 -0
  72. package/dist/solid/blob-url.js +34 -0
  73. package/dist/solid/blob-url.test.d.ts +2 -0
  74. package/dist/solid/blob-url.test.d.ts.map +1 -0
  75. package/dist/solid/blob-url.test.js +62 -0
  76. package/dist/solid/connection-summary.d.ts +25 -0
  77. package/dist/solid/connection-summary.d.ts.map +1 -0
  78. package/dist/solid/connection-summary.js +29 -0
  79. package/dist/solid/connection-summary.test.d.ts +2 -0
  80. package/dist/solid/connection-summary.test.d.ts.map +1 -0
  81. package/dist/solid/connection-summary.test.js +57 -0
  82. package/dist/solid/doc-store.d.ts +29 -0
  83. package/dist/solid/doc-store.d.ts.map +1 -0
  84. package/dist/solid/doc-store.js +79 -0
  85. package/dist/solid/doc-store.test.d.ts +2 -0
  86. package/dist/solid/doc-store.test.d.ts.map +1 -0
  87. package/dist/solid/doc-store.test.js +234 -0
  88. package/dist/solid/index.d.ts +4 -0
  89. package/dist/solid/index.d.ts.map +1 -0
  90. package/dist/solid/index.js +9 -0
  91. package/dist/solid/transfer-progress.d.ts +21 -0
  92. package/dist/solid/transfer-progress.d.ts.map +1 -0
  93. package/dist/solid/transfer-progress.js +32 -0
  94. package/dist/solid/transfer-progress.test.d.ts +2 -0
  95. package/dist/solid/transfer-progress.test.d.ts.map +1 -0
  96. package/dist/solid/transfer-progress.test.js +76 -0
  97. package/dist/testing/blob-fixtures.d.ts +19 -0
  98. package/dist/testing/blob-fixtures.d.ts.map +1 -0
  99. package/dist/testing/blob-fixtures.js +46 -0
  100. package/dist/testing/blob-fixtures.test.d.ts +2 -0
  101. package/dist/testing/blob-fixtures.test.d.ts.map +1 -0
  102. package/dist/testing/blob-fixtures.test.js +46 -0
  103. package/dist/testing/idb-harness.d.ts +10 -0
  104. package/dist/testing/idb-harness.d.ts.map +1 -0
  105. package/dist/testing/idb-harness.js +17 -0
  106. package/dist/testing/idb-harness.test.d.ts +2 -0
  107. package/dist/testing/idb-harness.test.d.ts.map +1 -0
  108. package/dist/testing/idb-harness.test.js +48 -0
  109. package/dist/testing/index.d.ts +7 -0
  110. package/dist/testing/index.d.ts.map +1 -0
  111. package/dist/testing/index.js +11 -0
  112. package/dist/testing/media.d.ts +15 -0
  113. package/dist/testing/media.d.ts.map +1 -0
  114. package/dist/testing/media.js +37 -0
  115. package/dist/testing/media.test.d.ts +2 -0
  116. package/dist/testing/media.test.d.ts.map +1 -0
  117. package/dist/testing/media.test.js +47 -0
  118. package/dist/testing/mock-blob-fetch.d.ts +33 -0
  119. package/dist/testing/mock-blob-fetch.d.ts.map +1 -0
  120. package/dist/testing/mock-blob-fetch.js +39 -0
  121. package/dist/testing/mock-blob-fetch.test.d.ts +2 -0
  122. package/dist/testing/mock-blob-fetch.test.d.ts.map +1 -0
  123. package/dist/testing/mock-blob-fetch.test.js +65 -0
  124. package/dist/testing/mock-node.d.ts +36 -0
  125. package/dist/testing/mock-node.d.ts.map +1 -0
  126. package/dist/testing/mock-node.js +97 -0
  127. package/dist/testing/mock-node.test.d.ts +2 -0
  128. package/dist/testing/mock-node.test.d.ts.map +1 -0
  129. package/dist/testing/mock-node.test.js +66 -0
  130. package/dist/testing/streams.d.ts +68 -0
  131. package/dist/testing/streams.d.ts.map +1 -0
  132. package/dist/testing/streams.js +99 -0
  133. package/dist/testing/streams.test.d.ts +2 -0
  134. package/dist/testing/streams.test.d.ts.map +1 -0
  135. package/dist/testing/streams.test.js +82 -0
  136. package/dist/transfer/cancellation.d.ts +8 -0
  137. package/dist/transfer/cancellation.d.ts.map +1 -0
  138. package/dist/transfer/cancellation.js +19 -0
  139. package/dist/transfer/index.d.ts +8 -0
  140. package/dist/transfer/index.d.ts.map +1 -0
  141. package/dist/transfer/index.js +4 -0
  142. package/dist/transfer/prefetch.d.ts +38 -0
  143. package/dist/transfer/prefetch.d.ts.map +1 -0
  144. package/dist/transfer/prefetch.js +61 -0
  145. package/dist/transfer/prefetch.test.d.ts +2 -0
  146. package/dist/transfer/prefetch.test.d.ts.map +1 -0
  147. package/dist/transfer/prefetch.test.js +136 -0
  148. package/dist/transfer/serve.d.ts +66 -0
  149. package/dist/transfer/serve.d.ts.map +1 -0
  150. package/dist/transfer/serve.js +126 -0
  151. package/dist/transfer/serve.test.d.ts +2 -0
  152. package/dist/transfer/serve.test.d.ts.map +1 -0
  153. package/dist/transfer/serve.test.js +101 -0
  154. package/dist/transfer/snatch.d.ts +39 -0
  155. package/dist/transfer/snatch.d.ts.map +1 -0
  156. package/dist/transfer/snatch.js +289 -0
  157. package/dist/transfer/snatch.test.d.ts +2 -0
  158. package/dist/transfer/snatch.test.d.ts.map +1 -0
  159. package/dist/transfer/snatch.test.js +337 -0
  160. package/dist/transfer/types.d.ts +85 -0
  161. package/dist/transfer/types.d.ts.map +1 -0
  162. package/dist/transfer/types.js +15 -0
  163. package/dist/utils/hash.d.ts +13 -0
  164. package/dist/utils/hash.d.ts.map +1 -0
  165. package/dist/utils/hash.js +31 -0
  166. package/dist/utils/hash.test.d.ts +2 -0
  167. package/dist/utils/hash.test.d.ts.map +1 -0
  168. package/dist/utils/hash.test.js +56 -0
  169. package/dist/utils/image-utils.d.ts +28 -0
  170. package/dist/utils/image-utils.d.ts.map +1 -0
  171. package/dist/utils/image-utils.js +113 -0
  172. package/dist/utils/image-utils.test.d.ts +2 -0
  173. package/dist/utils/image-utils.test.d.ts.map +1 -0
  174. package/dist/utils/image-utils.test.js +181 -0
  175. package/dist/utils/index.d.ts +4 -0
  176. package/dist/utils/index.d.ts.map +1 -0
  177. package/dist/utils/index.js +3 -0
  178. package/dist/utils/log.d.ts +22 -0
  179. package/dist/utils/log.d.ts.map +1 -0
  180. package/dist/utils/log.js +100 -0
  181. package/dist/utils/log.test.d.ts +2 -0
  182. package/dist/utils/log.test.d.ts.map +1 -0
  183. package/dist/utils/log.test.js +113 -0
  184. package/dist/worker/blob-worker-client.d.ts +118 -0
  185. package/dist/worker/blob-worker-client.d.ts.map +1 -0
  186. package/dist/worker/blob-worker-client.js +361 -0
  187. package/dist/worker/blob-worker-client.test.d.ts +2 -0
  188. package/dist/worker/blob-worker-client.test.d.ts.map +1 -0
  189. package/dist/worker/blob-worker-client.test.js +137 -0
  190. package/dist/worker/blob-worker-logic.d.ts +120 -0
  191. package/dist/worker/blob-worker-logic.d.ts.map +1 -0
  192. package/dist/worker/blob-worker-logic.js +368 -0
  193. package/dist/worker/blob-worker-logic.test.d.ts +2 -0
  194. package/dist/worker/blob-worker-logic.test.d.ts.map +1 -0
  195. package/dist/worker/blob-worker-logic.test.js +191 -0
  196. package/dist/worker/blob-worker.d.ts +21 -0
  197. package/dist/worker/blob-worker.d.ts.map +1 -0
  198. package/dist/worker/blob-worker.js +44 -0
  199. package/dist/worker/index.d.ts +12 -0
  200. package/dist/worker/index.d.ts.map +1 -0
  201. package/dist/worker/index.js +6 -0
  202. package/dist/worker/midden-blake3.d.ts +23 -0
  203. package/dist/worker/midden-blake3.d.ts.map +1 -0
  204. package/dist/worker/midden-blake3.js +47 -0
  205. package/dist/worker/midden-blake3.test.d.ts +2 -0
  206. package/dist/worker/midden-blake3.test.d.ts.map +1 -0
  207. package/dist/worker/midden-blake3.test.js +25 -0
  208. package/dist/worker/midden-not-bundled.stub.d.ts +1 -0
  209. package/dist/worker/midden-not-bundled.stub.d.ts.map +1 -0
  210. package/dist/worker/midden-not-bundled.stub.js +10 -0
  211. package/dist/worker/midden-stub.d.ts +10 -0
  212. package/dist/worker/midden-stub.d.ts.map +1 -0
  213. package/dist/worker/midden-stub.js +31 -0
  214. package/dist/worker/midden-stub.test.d.ts +2 -0
  215. package/dist/worker/midden-stub.test.d.ts.map +1 -0
  216. package/dist/worker/midden-stub.test.js +25 -0
  217. package/dist/worker/midden-worker-client.d.ts +103 -0
  218. package/dist/worker/midden-worker-client.d.ts.map +1 -0
  219. package/dist/worker/midden-worker-client.js +246 -0
  220. package/dist/worker/midden-worker-client.test.d.ts +2 -0
  221. package/dist/worker/midden-worker-client.test.d.ts.map +1 -0
  222. package/dist/worker/midden-worker-client.test.js +151 -0
  223. package/dist/worker/midden-worker-contract.d.ts +87 -0
  224. package/dist/worker/midden-worker-contract.d.ts.map +1 -0
  225. package/dist/worker/midden-worker-contract.js +30 -0
  226. package/package.json +90 -0
@@ -0,0 +1,377 @@
1
+ // @vitest-environment happy-dom
2
+ //
3
+ // integration tests for the unified blob store: ties the metadata layer
4
+ // (db.ts) and the bytes layer (bytes-backend.ts) together behind
5
+ // createBlobStore(). the worker (hashing + the OPFS write path) is mocked
6
+ // since no environment here has a real Worker or a bundled midden module -
7
+ // the mock computes a deterministic fake "blake3" from the sha256 digest
8
+ // (real crypto.subtle is available in happy-dom) and writes bytes through
9
+ // the exact same fake OPFS directory the direct-read path uses, so the
10
+ // round trip is meaningful.
11
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
12
+ import { fakeIdbHarness } from "../testing/index.js";
13
+ // ---- fake OPFS (same shape as blob-worker-logic.test.ts / bytes-backend.test.ts) ----
14
+ class FakeWritable {
15
+ file;
16
+ constructor(file) {
17
+ this.file = file;
18
+ }
19
+ async write(data) {
20
+ this.file.bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : new Uint8Array(data.buffer);
21
+ }
22
+ async close() { }
23
+ }
24
+ class FakeFileHandle {
25
+ bytes = new Uint8Array(0);
26
+ async getFile() {
27
+ const bytes = this.bytes;
28
+ return { arrayBuffer: async () => bytes.buffer };
29
+ }
30
+ async createWritable() {
31
+ return new FakeWritable(this);
32
+ }
33
+ }
34
+ class FakeDirHandle {
35
+ files = new Map();
36
+ async getFileHandle(name, opts) {
37
+ let handle = this.files.get(name);
38
+ if (!handle) {
39
+ if (!opts?.create)
40
+ throw new Error(`not found: ${name}`);
41
+ handle = new FakeFileHandle();
42
+ this.files.set(name, handle);
43
+ }
44
+ return handle;
45
+ }
46
+ async getDirectoryHandle(_name, opts) {
47
+ void opts;
48
+ return this;
49
+ }
50
+ async removeEntry(name) {
51
+ if (!this.files.delete(name))
52
+ throw new Error(`not found: ${name}`);
53
+ }
54
+ }
55
+ let opfsRoot;
56
+ function installFakeOpfs() {
57
+ opfsRoot = new FakeDirHandle();
58
+ vi.stubGlobal("navigator", {
59
+ storage: {
60
+ getDirectory: async () => opfsRoot,
61
+ },
62
+ });
63
+ }
64
+ async function fakeSha256Hex(data) {
65
+ const digest = await crypto.subtle.digest("SHA-256", data);
66
+ return Array.from(new Uint8Array(digest))
67
+ .map((b) => b.toString(16).padStart(2, "0"))
68
+ .join("");
69
+ }
70
+ async function writeToFakeOpfs(id, data) {
71
+ const root = await navigator.storage.getDirectory();
72
+ const dir = await root.getDirectoryHandle("reliquary-blobs", { create: true });
73
+ const file = await dir.getFileHandle(id, { create: true });
74
+ const writable = await file.createWritable();
75
+ await writable.write(data);
76
+ await writable.close();
77
+ }
78
+ vi.mock("../worker/index.js", () => ({
79
+ BLOB_OPFS_DIR: "reliquary-blobs",
80
+ writeBlobToOpfs: vi.fn(async (id, data) => {
81
+ await writeToFakeOpfs(id, data);
82
+ }),
83
+ // a real blake3 digest is unavailable in this test environment (no
84
+ // bundled midden module) - derive a distinct, deterministic stand-in
85
+ // from the sha256 digest so different content still gets different
86
+ // content addresses. neither hasher writes any bytes - matching the
87
+ // real worker client, which only hashes here.
88
+ hashBlake3: vi.fn(async (data) => {
89
+ const sha256 = await fakeSha256Hex(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength));
90
+ return `b3-${sha256}`;
91
+ }),
92
+ hashSha256: vi.fn(async (data) => fakeSha256Hex(data)),
93
+ streamFileToOpfs: vi.fn(async (file) => {
94
+ const buffer = await file.arrayBuffer();
95
+ const sha256 = await fakeSha256Hex(buffer);
96
+ const blake3 = `b3-${sha256}`;
97
+ await writeToFakeOpfs(blake3, buffer);
98
+ return { blake3, size: buffer.byteLength };
99
+ }),
100
+ }));
101
+ const { createBlobStore, DEFAULT_DB_NAME } = await import("./store.js");
102
+ const { putRecord } = await import("./db.js");
103
+ beforeEach(() => {
104
+ fakeIdbHarness();
105
+ installFakeOpfs();
106
+ vi.stubGlobal("URL", {
107
+ createObjectURL: vi.fn(() => `blob:mock-${Math.random()}`),
108
+ revokeObjectURL: vi.fn(),
109
+ });
110
+ });
111
+ afterEach(() => {
112
+ vi.unstubAllGlobals();
113
+ });
114
+ function textBuffer(text) {
115
+ return new TextEncoder().encode(text).buffer;
116
+ }
117
+ describe("storeBlob / getBlob / getBlobData", () => {
118
+ it("stores bytes and metadata, then reads them back", async () => {
119
+ const store = createBlobStore({ dbName: "store-test-1" });
120
+ const record = await store.storeBlob(textBuffer("hello world"), {
121
+ filename: "hello.txt",
122
+ mime: "text/plain",
123
+ });
124
+ expect(record.blob_id).toBe(record.blake3);
125
+ expect(record.filename).toBe("hello.txt");
126
+ expect(record.blob_type).toBe("original");
127
+ expect(record.storage_backend).toBe("opfs");
128
+ const data = await store.getBlobData(record.blob_id);
129
+ expect(new TextDecoder().decode(data)).toBe("hello world");
130
+ const blob = await store.getBlob(record.blob_id);
131
+ expect(blob?.type).toBe("text/plain");
132
+ expect(new TextDecoder().decode(await blob.arrayBuffer())).toBe("hello world");
133
+ });
134
+ it("dedups on content address - storing the same bytes twice returns the same record", async () => {
135
+ const store = createBlobStore({ dbName: "store-test-dedup" });
136
+ const first = await store.storeBlob(textBuffer("same content"), {
137
+ filename: "a.txt",
138
+ mime: "text/plain",
139
+ });
140
+ const second = await store.storeBlob(textBuffer("same content"), {
141
+ filename: "b.txt",
142
+ mime: "text/plain",
143
+ });
144
+ expect(second).toEqual(first);
145
+ expect(second.filename).toBe("a.txt");
146
+ });
147
+ it("keeps two store instances with different db names from seeing each other's records", async () => {
148
+ const storeA = createBlobStore({ dbName: "store-test-iso-a" });
149
+ const storeB = createBlobStore({ dbName: "store-test-iso-b" });
150
+ const record = await storeA.storeBlob(textBuffer("isolated"), {
151
+ filename: "iso.txt",
152
+ mime: "text/plain",
153
+ });
154
+ expect(await storeB.getBlobRecord(record.blob_id)).toBeNull();
155
+ expect(await storeA.getBlobRecord(record.blob_id)).not.toBeNull();
156
+ });
157
+ it("defaults to DEFAULT_DB_NAME when no dbName is given", () => {
158
+ // just confirms the constant is exported and stable, since two default
159
+ // stores would otherwise silently share state across tests.
160
+ expect(DEFAULT_DB_NAME).toBe("reliquary-blobs");
161
+ });
162
+ it("falls back to the cache-api backend when opfs is unavailable, without throwing", async () => {
163
+ vi.unstubAllGlobals();
164
+ vi.stubGlobal("URL", {
165
+ createObjectURL: vi.fn(() => `blob:mock-${Math.random()}`),
166
+ revokeObjectURL: vi.fn(),
167
+ });
168
+ const cacheStorage = new Map();
169
+ vi.stubGlobal("caches", {
170
+ async open(name) {
171
+ if (!cacheStorage.has(name))
172
+ cacheStorage.set(name, new Map());
173
+ const entries = cacheStorage.get(name);
174
+ return {
175
+ async put(url, response) {
176
+ entries.set(url, response);
177
+ },
178
+ async match(url) {
179
+ const found = entries.get(url);
180
+ return found ? found.clone() : undefined;
181
+ },
182
+ };
183
+ },
184
+ });
185
+ // no navigator.storage installed at all - opfs is unavailable
186
+ const store = createBlobStore({
187
+ dbName: "store-test-cache-fallback",
188
+ allowCacheFallback: true,
189
+ });
190
+ const record = await store.storeBlob(textBuffer("opfs-less content"), {
191
+ filename: "no-opfs.txt",
192
+ mime: "text/plain",
193
+ });
194
+ expect(record.storage_backend).toBe("cache");
195
+ const data = await store.getBlobData(record.blob_id);
196
+ expect(new TextDecoder().decode(data)).toBe("opfs-less content");
197
+ });
198
+ it("throws when neither opfs nor cache-api can accept the write", async () => {
199
+ vi.unstubAllGlobals();
200
+ // no navigator.storage, no caches - every backend in the chain refuses
201
+ const store = createBlobStore({ dbName: "store-test-no-backend", allowCacheFallback: true });
202
+ await expect(store.storeBlob(textBuffer("nowhere to go"), { filename: "x.txt", mime: "text/plain" })).rejects.toThrow(/no bytes backend accepted/);
203
+ });
204
+ it("does not fall back to cache-api by default, even when it's available", async () => {
205
+ vi.unstubAllGlobals();
206
+ vi.stubGlobal("URL", {
207
+ createObjectURL: vi.fn(() => `blob:mock-${Math.random()}`),
208
+ revokeObjectURL: vi.fn(),
209
+ });
210
+ const cacheStorage = new Map();
211
+ vi.stubGlobal("caches", {
212
+ async open(name) {
213
+ if (!cacheStorage.has(name))
214
+ cacheStorage.set(name, new Map());
215
+ const entries = cacheStorage.get(name);
216
+ return {
217
+ async put(url, response) {
218
+ entries.set(url, response);
219
+ },
220
+ async match(url) {
221
+ const found = entries.get(url);
222
+ return found ? found.clone() : undefined;
223
+ },
224
+ };
225
+ },
226
+ });
227
+ // no navigator.storage installed - opfs is unavailable, cache-api is
228
+ // available but must NOT be used since allowCacheFallback defaults to
229
+ // false (Q25: loam's crash-loud semantics are the default).
230
+ const store = createBlobStore({ dbName: "store-test-no-fallback-default" });
231
+ await expect(store.storeBlob(textBuffer("opfs-less content"), { filename: "no-opfs.txt", mime: "text/plain" })).rejects.toThrow(/no bytes backend accepted/);
232
+ });
233
+ });
234
+ describe("storeBlobFromFile", () => {
235
+ it("uses the buffered path for small files", async () => {
236
+ const store = createBlobStore({ dbName: "store-test-file-small" });
237
+ const file = new File([textBuffer("small file contents")], "small.txt", { type: "text/plain" });
238
+ const record = await store.storeBlobFromFile(file);
239
+ expect(record.filename).toBe("small.txt");
240
+ expect(record.sha256).toBeTruthy();
241
+ const data = await store.getBlobData(record.blob_id);
242
+ expect(new TextDecoder().decode(data)).toBe("small file contents");
243
+ });
244
+ it("streams large files and omits sha256 (legacy-only, never computed for streamed uploads)", async () => {
245
+ const store = createBlobStore({ dbName: "store-test-file-large" });
246
+ const bigBytes = new Uint8Array(8 * 1024 * 1024 + 10).fill(7);
247
+ const file = new File([bigBytes], "large.bin", { type: "application/octet-stream" });
248
+ const record = await store.storeBlobFromFile(file);
249
+ expect(record.size).toBe(bigBytes.byteLength);
250
+ expect(record.sha256).toBeUndefined();
251
+ const data = await store.getBlobData(record.blob_id);
252
+ expect(data?.byteLength).toBe(bigBytes.byteLength);
253
+ });
254
+ it("propagates an AbortError from a cancelled streamed upload without falling back", async () => {
255
+ const store = createBlobStore({ dbName: "store-test-file-abort" });
256
+ const worker = await import("../worker/index.js");
257
+ vi.mocked(worker.streamFileToOpfs).mockRejectedValueOnce(new DOMException("upload cancelled", "AbortError"));
258
+ const bigBytes = new Uint8Array(8 * 1024 * 1024 + 1);
259
+ const file = new File([bigBytes], "large.bin");
260
+ await expect(store.storeBlobFromFile(file)).rejects.toThrow(/upload cancelled/);
261
+ });
262
+ });
263
+ describe("resolveBlob", () => {
264
+ it("resolves a legacy sha256-primary-keyed record by its blake3 index", async () => {
265
+ await putRecord("store-test-resolve", {
266
+ blob_id: "legacy-sha256-key",
267
+ blake3: "blake3-known-later",
268
+ sha256: "legacy-sha256-key",
269
+ filename: "legacy.txt",
270
+ mime: "text/plain",
271
+ size: 4,
272
+ blob_type: "original",
273
+ parent_blob_id: null,
274
+ created_at: 1,
275
+ storage_backend: "opfs",
276
+ });
277
+ const store = createBlobStore({ dbName: "store-test-resolve" });
278
+ expect(await store.resolveBlob("blake3-known-later")).not.toBeNull();
279
+ expect(await store.resolveBlob("legacy-sha256-key")).not.toBeNull();
280
+ expect(await store.resolveBlob("anything", "blake3-known-later")).not.toBeNull();
281
+ expect(await store.resolveBlob("nothing-matches")).toBeNull();
282
+ });
283
+ it("getBlobMetadata is an alias for resolveBlob", async () => {
284
+ const store = createBlobStore({ dbName: "store-test-metadata-alias" });
285
+ const record = await store.storeBlob(textBuffer("meta"), { filename: "m.txt", mime: "text/plain" });
286
+ expect(await store.getBlobMetadata(record.blob_id)).toEqual(record);
287
+ });
288
+ });
289
+ describe("getBlobObjectURL", () => {
290
+ it("returns and caches an object url for a stored blob", async () => {
291
+ const store = createBlobStore({ dbName: "store-test-url" });
292
+ const record = await store.storeBlob(textBuffer("url content"), {
293
+ filename: "u.txt",
294
+ mime: "text/plain",
295
+ });
296
+ const url1 = await store.getBlobObjectURL(record.blob_id);
297
+ const url2 = await store.getBlobObjectURL(record.blob_id);
298
+ expect(url1).toBe(url2);
299
+ expect(vi.mocked(URL.createObjectURL)).toHaveBeenCalledTimes(1);
300
+ });
301
+ it("returns null for a blob that doesn't exist", async () => {
302
+ const store = createBlobStore({ dbName: "store-test-url-missing" });
303
+ expect(await store.getBlobObjectURL("nope")).toBeNull();
304
+ });
305
+ it("clearBlobUrlCache revokes every cached url", async () => {
306
+ const store = createBlobStore({ dbName: "store-test-url-clear" });
307
+ const record = await store.storeBlob(textBuffer("clear me"), {
308
+ filename: "c.txt",
309
+ mime: "text/plain",
310
+ });
311
+ await store.getBlobObjectURL(record.blob_id);
312
+ store.clearBlobUrlCache();
313
+ expect(vi.mocked(URL.revokeObjectURL)).toHaveBeenCalledTimes(1);
314
+ });
315
+ });
316
+ describe("hasBlobBytes / checkBlobLocality", () => {
317
+ it("reports local for a record with bytes present", async () => {
318
+ const store = createBlobStore({ dbName: "store-test-locality-local" });
319
+ const record = await store.storeBlob(textBuffer("local content"), {
320
+ filename: "l.txt",
321
+ mime: "text/plain",
322
+ });
323
+ expect(await store.hasBlobBytes(record.blob_id)).toBe(true);
324
+ const info = await store.checkBlobLocality(record.blob_id);
325
+ expect(info.locality).toBe("local");
326
+ expect(info.metadata?.id).toBe(record.blob_id);
327
+ });
328
+ it("reports remote for a blobId with no record at all", async () => {
329
+ const store = createBlobStore({ dbName: "store-test-locality-remote" });
330
+ expect(await store.hasBlobBytes("nothing")).toBe(false);
331
+ expect((await store.checkBlobLocality("nothing")).locality).toBe("remote");
332
+ });
333
+ it("reports remote for a stranded record whose bytes are missing (not stuck local forever)", async () => {
334
+ const store = createBlobStore({ dbName: "store-test-locality-stranded" });
335
+ // simulate a record whose write silently failed to persist bytes:
336
+ // metadata exists, but no backend actually has the bytes.
337
+ await putRecord("store-test-locality-stranded", {
338
+ blob_id: "stranded-blake3",
339
+ blake3: "stranded-blake3",
340
+ filename: "stranded.txt",
341
+ mime: "text/plain",
342
+ size: 10,
343
+ blob_type: "original",
344
+ parent_blob_id: null,
345
+ created_at: 1,
346
+ storage_backend: "opfs",
347
+ });
348
+ expect(await store.hasBlobBytes("stranded-blake3")).toBe(false);
349
+ const info = await store.checkBlobLocality("stranded-blake3");
350
+ expect(info.locality).toBe("remote");
351
+ });
352
+ it("checkBlobLocality on an empty blobId is unknown, not remote", async () => {
353
+ const store = createBlobStore({ dbName: "store-test-locality-empty" });
354
+ expect((await store.checkBlobLocality("")).locality).toBe("unknown");
355
+ });
356
+ });
357
+ describe("deleteBlob / clearAll", () => {
358
+ it("removes both the record and the bytes", async () => {
359
+ const store = createBlobStore({ dbName: "store-test-delete" });
360
+ const record = await store.storeBlob(textBuffer("delete me"), {
361
+ filename: "d.txt",
362
+ mime: "text/plain",
363
+ });
364
+ await store.deleteBlob(record.blob_id);
365
+ expect(await store.getBlobRecord(record.blob_id)).toBeNull();
366
+ expect(await store.hasBlobBytes(record.blob_id)).toBe(false);
367
+ });
368
+ it("clearAll wipes every record and every backend's bytes", async () => {
369
+ const store = createBlobStore({ dbName: "store-test-clear-all" });
370
+ const a = await store.storeBlob(textBuffer("clear-all-a"), { filename: "a.txt", mime: "text/plain" });
371
+ const b = await store.storeBlob(textBuffer("clear-all-b"), { filename: "b.txt", mime: "text/plain" });
372
+ await store.clearAll();
373
+ expect(await store.getBlobRecord(a.blob_id)).toBeNull();
374
+ expect(await store.getBlobRecord(b.blob_id)).toBeNull();
375
+ expect(await store.hasBlobBytes(a.blob_id)).toBe(false);
376
+ });
377
+ });
@@ -0,0 +1,55 @@
1
+ /** original | thumbnail | waveform | preview - matches the rust schema's enum. */
2
+ export type BlobType = "original" | "thumbnail" | "waveform" | "preview";
3
+ /** which bytes backend a record's payload was written through. absent on
4
+ * records written before this field existed - callers should treat a
5
+ * missing value as "opfs" (the only backend that existed at the time). */
6
+ export type BytesBackendName = "opfs" | "cache";
7
+ export interface BlobRecord {
8
+ /** canonical id - the blake3 hex digest. */
9
+ blob_id: string;
10
+ /** blake3 hex digest (same value as blob_id for every record created by
11
+ * this store; kept as its own field, and its own index, so a record
12
+ * legacy-keyed by sha256 can still be found by blake3 once known). */
13
+ blake3: string;
14
+ /** legacy sha256 hex digest, kept for records/references that predate
15
+ * blake3 becoming canonical. never computed for new streamed uploads. */
16
+ sha256?: string;
17
+ filename: string;
18
+ mime: string;
19
+ size: number;
20
+ blob_type: BlobType;
21
+ /** blake3 of a "parent" blob this one was derived from (e.g. a
22
+ * thumbnail's parent is the original image). */
23
+ parent_blob_id?: string | null;
24
+ metadata?: Record<string, unknown>;
25
+ created_at: number;
26
+ storage_backend?: BytesBackendName;
27
+ }
28
+ /** everything needed to store a new blob, other than the bytes and the
29
+ * fields this store computes/fills in itself (blob_id, blake3, sha256,
30
+ * size, created_at, storage_backend). */
31
+ export interface NewBlobMeta {
32
+ filename: string;
33
+ mime: string;
34
+ blob_type?: BlobType;
35
+ parent_blob_id?: string | null;
36
+ metadata?: Record<string, unknown>;
37
+ }
38
+ export interface BlobLocalityMetadata {
39
+ id: string;
40
+ mime?: string;
41
+ filename?: string;
42
+ size?: number;
43
+ blake3?: string;
44
+ }
45
+ export interface BlobLocalityInfo {
46
+ /**
47
+ * "remote" covers both "no record at all" and "a record exists but its
48
+ * bytes are missing" - a stranded record (e.g. a write whose bytes
49
+ * backend silently failed) must not read as permanently local with no
50
+ * way to repair it.
51
+ */
52
+ locality: "local" | "remote" | "unknown";
53
+ metadata?: BlobLocalityMetadata;
54
+ }
55
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/blobs/types.ts"],"names":[],"mappings":"AAQA,kFAAkF;AAClF,MAAM,MAAM,QAAQ,GAAG,UAAU,GAAG,WAAW,GAAG,UAAU,GAAG,SAAS,CAAC;AAEzE;;2EAE2E;AAC3E,MAAM,MAAM,gBAAgB,GAAG,MAAM,GAAG,OAAO,CAAC;AAEhD,MAAM,WAAW,UAAU;IACzB,4CAA4C;IAC5C,OAAO,EAAE,MAAM,CAAC;IAChB;;2EAEuE;IACvE,MAAM,EAAE,MAAM,CAAC;IACf;8EAC0E;IAC1E,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,QAAQ,CAAC;IACpB;qDACiD;IACjD,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACnC,UAAU,EAAE,MAAM,CAAC;IACnB,eAAe,CAAC,EAAE,gBAAgB,CAAC;CACpC;AAED;;0CAE0C;AAC1C,MAAM,WAAW,WAAW;IAC1B,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE,QAAQ,CAAC;IACrB,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACpC;AAED,MAAM,WAAW,oBAAoB;IACnC,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,gBAAgB;IAC/B;;;;;OAKG;IACH,QAAQ,EAAE,OAAO,GAAG,QAAQ,GAAG,SAAS,CAAC;IACzC,QAAQ,CAAC,EAAE,oBAAoB,CAAC;CACjC"}
@@ -0,0 +1,8 @@
1
+ // shared shapes for the blake3-canonical blob record store.
2
+ //
3
+ // core schema: blake3 primary key, sha256 legacy index, blob_type, parent
4
+ // linkage. this browser version omits native-only fields (on-disk path,
5
+ // external flag, soft-delete bookkeeping) and app-specific classification
6
+ // fields (e.g. domain tags) - callers that want classification can use
7
+ // the `metadata` field.
8
+ export {};
@@ -0,0 +1,19 @@
1
+ import type { BiStreamLike } from "../automerge/types.js";
2
+ /** minimal interface for a node capable of opening bidirectional streams. */
3
+ export interface EnsureCapableNode {
4
+ open_bi(peerAddr: string, alpn: string): Promise<BiStreamLike>;
5
+ }
6
+ /**
7
+ * sends an ensure-blob request to a peer and returns whether the peer
8
+ * reports the blob as available. throws on connection/protocol errors.
9
+ *
10
+ * @param node - the transport node (midden, tauri, or any node exposing `open_bi`)
11
+ * @param peerAddr - peer address (bare node id or full `EndpointAddr`)
12
+ * @param blake3Hash - blake3 hash of the blob to check for
13
+ * @param alpn - ALPN string to dial (defaults to `DEFAULT_ENSURE_ALPN`)
14
+ * @returns true if the peer has the blob, false otherwise
15
+ * @throws when the peer rejects the connection, the stream errors, or the
16
+ * response cannot be parsed
17
+ */
18
+ export declare function ensureBlobOverAlpn(node: EnsureCapableNode, peerAddr: string, blake3Hash: string, alpn?: string): Promise<boolean>;
19
+ //# sourceMappingURL=client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/ensure/client.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,uBAAuB,CAAC;AAK1D,6EAA6E;AAC7E,MAAM,WAAW,iBAAiB;IAChC,OAAO,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,CAAC;CAChE;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,kBAAkB,CACtC,IAAI,EAAE,iBAAiB,EACvB,QAAQ,EAAE,MAAM,EAChB,UAAU,EAAE,MAAM,EAClB,IAAI,CAAC,EAAE,MAAM,GACZ,OAAO,CAAC,OAAO,CAAC,CAuElB"}
@@ -0,0 +1,74 @@
1
+ // client side of the ensure-blob protocol: dials a peer, sends a request,
2
+ // reads the response.
3
+ import { log } from "../utils/log.js";
4
+ import { DEFAULT_ENSURE_ALPN } from "./types.js";
5
+ const TAG = "ensure.client";
6
+ /**
7
+ * sends an ensure-blob request to a peer and returns whether the peer
8
+ * reports the blob as available. throws on connection/protocol errors.
9
+ *
10
+ * @param node - the transport node (midden, tauri, or any node exposing `open_bi`)
11
+ * @param peerAddr - peer address (bare node id or full `EndpointAddr`)
12
+ * @param blake3Hash - blake3 hash of the blob to check for
13
+ * @param alpn - ALPN string to dial (defaults to `DEFAULT_ENSURE_ALPN`)
14
+ * @returns true if the peer has the blob, false otherwise
15
+ * @throws when the peer rejects the connection, the stream errors, or the
16
+ * response cannot be parsed
17
+ */
18
+ export async function ensureBlobOverAlpn(node, peerAddr, blake3Hash, alpn) {
19
+ const actualAlpn = alpn ?? DEFAULT_ENSURE_ALPN;
20
+ const peerShort = peerAddr.slice(0, 16);
21
+ log.debug(TAG, `dialing peer ${peerShort} on ALPN ${actualAlpn} for blake3=${blake3Hash.slice(0, 16)}`);
22
+ let stream;
23
+ try {
24
+ stream = await node.open_bi(peerAddr, actualAlpn);
25
+ }
26
+ catch (err) {
27
+ throw new Error(`failed to connect to peer ${peerShort}: ${err instanceof Error ? err.message : err}`);
28
+ }
29
+ const request = {
30
+ type: "ensure_blob_request",
31
+ id: 1,
32
+ blake3_hash: blake3Hash,
33
+ };
34
+ const requestBytes = new TextEncoder().encode(JSON.stringify(request));
35
+ try {
36
+ if (stream.write_raw_and_finish) {
37
+ await stream.write_raw_and_finish(requestBytes);
38
+ }
39
+ else {
40
+ log.warn(TAG, "stream does not support write_raw_and_finish, falling back to write_message");
41
+ await stream.write_message(requestBytes);
42
+ stream.close();
43
+ }
44
+ }
45
+ catch (err) {
46
+ throw new Error(`failed to write request to peer ${peerShort}: ${err instanceof Error ? err.message : err}`);
47
+ }
48
+ if (!stream.read_to_end) {
49
+ throw new Error("stream does not support read_to_end");
50
+ }
51
+ let responseBytes;
52
+ try {
53
+ responseBytes = await stream.read_to_end(64 * 1024);
54
+ }
55
+ catch (err) {
56
+ throw new Error(`failed to read response from peer ${peerShort}: ${err instanceof Error ? err.message : err}`);
57
+ }
58
+ let msg;
59
+ try {
60
+ msg = JSON.parse(new TextDecoder().decode(responseBytes));
61
+ }
62
+ catch (err) {
63
+ throw new Error(`failed to parse response from peer ${peerShort}: ${err instanceof Error ? err.message : err}`);
64
+ }
65
+ if (msg.type !== "ensure_blob_response") {
66
+ throw new Error(`unexpected response type from peer ${peerShort}: ${msg.type}`);
67
+ }
68
+ const response = msg;
69
+ if (response.error) {
70
+ log.debug(TAG, `peer ${peerShort} reported error: ${response.error}`);
71
+ return false;
72
+ }
73
+ return response.available;
74
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=client.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.test.d.ts","sourceRoot":"","sources":["../../src/ensure/client.test.ts"],"names":[],"mappings":""}