@le-space/orbitdb-storage-bridge 0.10.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.
@@ -0,0 +1,330 @@
1
+ /**
2
+ * @fileoverview Pinata storage backend for OrbitDB Storage Bridge
3
+ *
4
+ * Pinata is the only evaluated backend that can both take a CAR and pin a CID that is
5
+ * already on IPFS, and those are the two shapes this library wants. Pinning by CID is the
6
+ * better one: we already run Helia, so `pinCid()` moves no bytes through a vendor API and
7
+ * the hashes cannot drift, because nobody but us ever computed them.
8
+ *
9
+ * Both are paid-plan features, so both are opt-in (`pinByCid`, `carImport`). Measured on
10
+ * the free plan on 2026-09-17: uploads, listing and deletion work, a CAR goes up as a plain
11
+ * file and comes back byte for byte, and pin by CID answers 403 "not supported by the
12
+ * current plan type".
13
+ *
14
+ * No SDK dependency -- the v3 API is three documented endpoints and `fetch`, which keeps
15
+ * the package lean and works unchanged in the browser.
16
+ *
17
+ * Two modes, and the capability set says which one you got:
18
+ *
19
+ * - **with a JWT** — full backend: upload, list, delete, and pin by CID on a paid plan.
20
+ * The JWT is a bearer secret, so `browserSafeAuth` is false; this belongs on a relay or
21
+ * in Node.
22
+ * - **with `getUploadUrl`** — a presigned upload URL minted elsewhere. Nothing secret
23
+ * reaches the browser, so `browserSafeAuth` is true, but the operations that need the
24
+ * account key are gone and the driver declares them gone rather than failing later.
25
+ *
26
+ * @author @NiKrause
27
+ * @requires ./types.js - the backend contract
28
+ * @see {@link https://docs.pinata.cloud/files/uploading-files} for the CAR upload rules
29
+ */
30
+
31
+ /* global FormData, URLSearchParams */
32
+
33
+ import { defineBackend, handleId, BackendError } from "./types.js";
34
+
35
+ const DEFAULT_UPLOAD_URL = "https://uploads.pinata.cloud/v3/files";
36
+ const DEFAULT_API_URL = "https://api.pinata.cloud/v3";
37
+ const DEFAULT_GATEWAY = "https://gateway.pinata.cloud";
38
+
39
+ /** Pinata treats a CAR upload as a CAR only when told to. */
40
+ const CAR_MIME = "application/vnd.ipld.car";
41
+
42
+ /** How often a gateway 429 is waited out before it counts as a failure. */
43
+ const GATEWAY_RETRIES = 4;
44
+ /** The longest single wait, whatever Retry-After asks for. */
45
+ const MAX_RETRY_WAIT_MS = 30_000;
46
+
47
+ /** Pinata's words for a feature the account's plan does not include. */
48
+ const PLAN_REFUSAL = /not supported by the current plan/i;
49
+
50
+ /**
51
+ * What a refusal means to a caller: a plan without the feature is "cannot", a key Pinata
52
+ * rejects or has not scoped for the call is a misconfigured backend, and only a 404 is
53
+ * "not there".
54
+ */
55
+ const codeFor = (status, detail) => {
56
+ if (status === 404) return "NOT_FOUND";
57
+ if (status === 401 || (status === 403 && !PLAN_REFUSAL.test(detail))) {
58
+ return "INVALID_BACKEND";
59
+ }
60
+ return "UNSUPPORTED";
61
+ };
62
+
63
+ /** Retry-After when the gateway sends one, otherwise doubling from a second. */
64
+ const retryWait = (response, attempt) => {
65
+ const header = response.headers.get("retry-after");
66
+ const seconds = header === null ? NaN : Number(header);
67
+ const ms = Number.isFinite(seconds) && seconds >= 0 ? seconds * 1000 : 1000 * 2 ** attempt;
68
+ return Math.min(ms, MAX_RETRY_WAIT_MS);
69
+ };
70
+
71
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
72
+
73
+ /**
74
+ * A gateway as a URL. Pinata's dashboard lists gateways as bare domains
75
+ * (`<name>.mypinata.cloud`), and one pasted as shown would otherwise reach
76
+ * `fetch` without a scheme and fail every read with "Invalid URL".
77
+ */
78
+ const gatewayUrl = (value) => {
79
+ const trimmed = value.trim().replace(/\/+$/, "");
80
+ return /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
81
+ };
82
+
83
+ /**
84
+ * Create a Pinata backend.
85
+ *
86
+ * @param {object} options
87
+ * @param {string} [options.jwt] - Pinata JWT; falls back to PINATA_JWT
88
+ * @param {() => Promise<string>} [options.getUploadUrl] - mint a presigned upload URL
89
+ * instead of holding a JWT. Uploads only; see the note above.
90
+ * @param {string} [options.gateway] - retrieval gateway: `https://<name>.mypinata.cloud`, or
91
+ * the bare domain as Pinata's dashboard shows it. The account's dedicated gateway is
92
+ * strongly preferred: the shared one rate limits with 429s, which are waited out, but slowly
93
+ * @param {number} [options.gatewayRetries=4] - how often a gateway 429 is waited out
94
+ * @param {"public"|"private"} [options.network="public"] - CAR uploads are public only
95
+ * @param {boolean} [options.pinByCid=false] - offer `pinCid()`. Paid plans only; the free
96
+ * plan refuses it with 403, so it is declared only when asked for.
97
+ * @param {boolean} [options.carImport=false] - send CARs with `car=true`, so Pinata unpacks
98
+ * and indexes the blocks. Paid plans only. Without it a CAR goes up as one opaque file,
99
+ * exactly as on Aleph, and comes back byte for byte for us to unpack.
100
+ * @param {string} [options.uploadUrl]
101
+ * @param {string} [options.apiUrl]
102
+ * @returns {import("./types.js").StorageBackend}
103
+ */
104
+ export function createPinataBackend(options = {}) {
105
+ const jwt =
106
+ options.jwt ||
107
+ (typeof process !== "undefined" ? process.env?.PINATA_JWT : undefined);
108
+ const getUploadUrl = options.getUploadUrl;
109
+
110
+ if (!jwt && !getUploadUrl) {
111
+ throw new BackendError(
112
+ "INVALID_BACKEND",
113
+ "createPinataBackend needs either a jwt or a getUploadUrl function",
114
+ );
115
+ }
116
+
117
+ const network = options.network || "public";
118
+ const uploadUrl = options.uploadUrl || DEFAULT_UPLOAD_URL;
119
+ const apiUrl = (options.apiUrl || DEFAULT_API_URL).replace(/\/+$/, "");
120
+ const gateway = gatewayUrl(options.gateway || DEFAULT_GATEWAY);
121
+ const keyed = Boolean(jwt);
122
+ const carImport = options.carImport === true;
123
+ const pinByCid = keyed && options.pinByCid === true;
124
+ const gatewayRetries = options.gatewayRetries ?? GATEWAY_RETRIES;
125
+
126
+ /** Call the account API. Only available in JWT mode. */
127
+ const api = async (path, init = {}) => {
128
+ const response = await fetch(`${apiUrl}${path}`, {
129
+ ...init,
130
+ headers: {
131
+ Authorization: `Bearer ${jwt}`,
132
+ ...(init.headers || {}),
133
+ },
134
+ });
135
+ if (!response.ok) {
136
+ const detail = await response.text().catch(() => "");
137
+ throw new BackendError(
138
+ codeFor(response.status, detail),
139
+ `Pinata ${init.method || "GET"} ${path} failed: HTTP ${response.status} ${detail}`.trim() +
140
+ (PLAN_REFUSAL.test(detail) ? " (a paid-plan feature)" : ""),
141
+ );
142
+ }
143
+ return response.status === 204 ? null : response.json();
144
+ };
145
+
146
+ const backend = {
147
+ name: "pinata",
148
+
149
+ capabilities: {
150
+ // Paid plans only — the free plan answers 403 — so declared only when asked for.
151
+ pinByCid,
152
+ // A CAR uploaded with car=true is unpacked and its blocks indexed — paid plans only,
153
+ // so it is opt-in. The free plan stores the CAR as a file, which is all a backup needs.
154
+ carImport,
155
+ // A plain file push is hashed by Pinata with its own chunker, so per-block upload is
156
+ // not safe here -- and at 60 requests a minute on the free tier it would be unwise
157
+ // even if it were. Declaring false routes backups through a CAR, which is correct.
158
+ preservesInnerCids: false,
159
+ browserSafeAuth: !keyed,
160
+ delegation: true,
161
+ listing: keyed,
162
+ deletion: keyed,
163
+ minBlobSize: 0,
164
+ },
165
+
166
+ /** @type {import("./types.js").StorageBackend["putBlob"]} */
167
+ putBlob: async (bytes, meta = {}) => {
168
+ const name = meta.name || "blob";
169
+ const type = meta.type || "application/octet-stream";
170
+ const isCar = carImport && (meta.car ?? type === CAR_MIME);
171
+
172
+ const form = new FormData();
173
+ // The file's own name must not carry a path. backupDatabase names files
174
+ // `<space>/backup-…`, and Pinata turns a path into a folder and answers with
175
+ // the folder's CID, which a gateway serves as an HTML listing. The full name
176
+ // still goes up as the upload's `name`.
177
+ const fileName = name.split("/").filter(Boolean).pop() || "blob";
178
+ form.append("file", new File([bytes], fileName, { type }));
179
+ form.append("network", network);
180
+ form.append("name", name);
181
+ if (isCar) {
182
+ form.append("car", "true");
183
+ }
184
+
185
+ const target = getUploadUrl ? await getUploadUrl() : uploadUrl;
186
+ const response = await fetch(target, {
187
+ method: "POST",
188
+ headers:
189
+ keyed && !getUploadUrl ? { Authorization: `Bearer ${jwt}` } : {},
190
+ body: form,
191
+ });
192
+
193
+ if (!response.ok) {
194
+ const detail = await response.text().catch(() => "");
195
+ throw new BackendError(
196
+ codeFor(response.status, detail),
197
+ `Pinata upload failed: HTTP ${response.status} ${detail}`.trim() +
198
+ (isCar
199
+ ? " (CAR import needs a paid plan, the public network, and a single root CID)"
200
+ : ""),
201
+ );
202
+ }
203
+
204
+ const body = await response.json();
205
+ const data = body?.data || body;
206
+ return {
207
+ id: data.cid,
208
+ // Only an imported CAR comes back under a CID that is ours — its root. A file
209
+ // upload is hashed by Pinata's own chunker, so like Aleph's it names their
210
+ // encoding of our bytes, and the handle does not pretend otherwise.
211
+ ...(isCar ? { cid: data.cid } : {}),
212
+ backend: "pinata",
213
+ size: data.size ?? bytes.length,
214
+ // Deletion addresses Pinata's own file id, not the CID.
215
+ fileId: data.id,
216
+ raw: data,
217
+ };
218
+ },
219
+
220
+ /** @type {import("./types.js").StorageBackend["getBlob"]} */
221
+ getBlob: async (handle) => {
222
+ const cid = handleId(handle);
223
+ let response;
224
+ // A 429 says "not now", not "not there": wait as told, a few times.
225
+ for (let attempt = 0; ; attempt++) {
226
+ response = await fetch(`${gateway}/ipfs/${cid}`);
227
+ if (response.status !== 429 || attempt >= gatewayRetries) break;
228
+ await response.body?.cancel();
229
+ await sleep(retryWait(response, attempt));
230
+ }
231
+ if (!response.ok) {
232
+ // A gateway says why it refused ("… not pinned to their Pinata account.
233
+ // - ERR_ID:00006"); a status code alone does not.
234
+ const reason = (await response.text().catch(() => ""))
235
+ .replace(/\s+/g, " ")
236
+ .trim()
237
+ .slice(0, 240);
238
+ throw new BackendError(
239
+ "NOT_FOUND",
240
+ `Pinata gateway did not serve ${cid}: HTTP ${response.status}` +
241
+ (reason ? ` "${reason}"` : "") +
242
+ (response.status === 404
243
+ ? " (a CAR upload is processed asynchronously; it may not be indexed yet)"
244
+ : "") +
245
+ (response.status === 429
246
+ ? ` (still rate limited after ${gatewayRetries} retries` +
247
+ (gateway === DEFAULT_GATEWAY
248
+ ? "; the shared gateway is for testing, pass the account's dedicated gateway as `gateway`)"
249
+ : ")")
250
+ : ""),
251
+ );
252
+ }
253
+ return new Uint8Array(await response.arrayBuffer());
254
+ },
255
+ };
256
+
257
+ if (pinByCid) {
258
+ backend.pinCid = async (cid, meta = {}) => {
259
+ const body = await api("/files/public/pin_by_cid", {
260
+ method: "POST",
261
+ headers: { "Content-Type": "application/json" },
262
+ body: JSON.stringify({
263
+ cid: String(cid),
264
+ ...(meta.name ? { name: meta.name } : {}),
265
+ }),
266
+ });
267
+ const data = body?.data || body || {};
268
+ return {
269
+ id: String(cid),
270
+ cid: String(cid),
271
+ backend: "pinata",
272
+ // The id of the pin *request* in Pinata's queue. The file it becomes gets an id
273
+ // of its own once retrieved, so this is deliberately not `fileId`: remove() then
274
+ // resolves the file by CID instead of deleting by an id that names something else.
275
+ requestId: data.id,
276
+ // "prechecking" or "retrieving": Pinata has accepted the job, not finished it.
277
+ status: data.status,
278
+ raw: data,
279
+ };
280
+ };
281
+ }
282
+
283
+ if (keyed) {
284
+ backend.list = async (listOptions = {}) => {
285
+ const query = new URLSearchParams();
286
+ if (listOptions.limit) query.set("limit", String(listOptions.limit));
287
+ if (listOptions.cursor) query.set("pageToken", listOptions.cursor);
288
+ if (listOptions.cid) query.set("cid", String(listOptions.cid));
289
+
290
+ const body = await api(
291
+ `/files/${network}${query.size ? `?${query}` : ""}`,
292
+ );
293
+ const files = body?.data?.files || [];
294
+ return files.map((file) => ({
295
+ id: file.cid,
296
+ cid: file.cid,
297
+ backend: "pinata",
298
+ size: file.size,
299
+ fileId: file.id,
300
+ insertedAt: file.created_at,
301
+ raw: file,
302
+ }));
303
+ };
304
+
305
+ backend.remove = async (handle) => {
306
+ let fileId = typeof handle === "object" ? handle?.fileId : undefined;
307
+
308
+ if (!fileId) {
309
+ // Only a CID was given. Resolve it, and refuse unless the record we found is
310
+ // actually the one asked for -- deleting the wrong file is not recoverable.
311
+ const cid = handleId(handle);
312
+ const matches = await backend.list({ cid, limit: 2 });
313
+ const match = matches.find((entry) => entry.cid === cid);
314
+ if (!match?.fileId) {
315
+ throw new BackendError(
316
+ "NOT_FOUND",
317
+ `No Pinata file found for ${cid}; pass the handle from putBlob to delete it`,
318
+ );
319
+ }
320
+ fileId = match.fileId;
321
+ }
322
+
323
+ await api(`/files/${network}/${fileId}`, { method: "DELETE" });
324
+ };
325
+ }
326
+
327
+ return defineBackend(backend);
328
+ }
329
+
330
+ export default createPinataBackend;
@@ -0,0 +1,72 @@
1
+ /**
2
+ * One decision about where a backup goes: a ready backend is used as-is, and
3
+ * credentials or a UCAN client build a Storacha one.
4
+ *
5
+ * Its own module, and the Storacha backend is loaded only when it is actually
6
+ * built, because that module pulls `@storacha/client` — about 88 kB gzipped in
7
+ * a browser bundle. A page that backs up to Aleph should not carry a client
8
+ * for a service it never calls.
9
+ */
10
+
11
+ import { logger } from "../logger.js";
12
+
13
+ /**
14
+ * Resolve the storage backend for a call.
15
+ *
16
+ * Six copies of this decision used to sit inline in this file and in backup-car.js,
17
+ * which is how a vendor ends up welded to a library. Now there is one: pass a ready
18
+ * backend and it is used as-is, pass a UCAN client or credentials and a Storacha
19
+ * backend is built around them.
20
+ *
21
+ * @param {Object} [config] - call options
22
+ * @param {Object} [config.backend] - any driver implementing the backend contract
23
+ * @param {Object} [config.ucanClient] - a Storacha client authorised over UCAN
24
+ * @param {string} [config.spaceDID]
25
+ * @param {string} [config.storachaKey] - falls back to STORACHA_KEY
26
+ * @param {string} [config.storachaProof] - falls back to STORACHA_PROOF
27
+ * @param {Object} [config.serviceConf]
28
+ * @param {string|URL} [config.receiptsEndpoint]
29
+ * @param {string[]} [config.gateways] - retrieval gateways for the Storacha backend
30
+ * @returns {Promise<import("./backends/types.js").StorageBackend>}
31
+ * @see {@link ./backends/types.js} for the contract
32
+ */
33
+ export async function resolveBackend(config = {}) {
34
+ if (config.backend) {
35
+ return config.backend;
36
+ }
37
+
38
+ const gateways = config.gateways;
39
+
40
+ if (config.ucanClient) {
41
+ logger.info("🔐 Using UCAN authentication...");
42
+ const { createStorachaBackend } = await import("./storacha.js");
43
+ return createStorachaBackend({
44
+ client: config.ucanClient,
45
+ spaceDID: config.spaceDID,
46
+ ...(gateways ? { gateways } : {}),
47
+ });
48
+ }
49
+
50
+ const storachaKey =
51
+ config.storachaKey ||
52
+ (typeof process !== "undefined" ? process.env?.STORACHA_KEY : undefined);
53
+ const storachaProof =
54
+ config.storachaProof ||
55
+ (typeof process !== "undefined" ? process.env?.STORACHA_PROOF : undefined);
56
+
57
+ if (!storachaKey || !storachaProof) {
58
+ throw new Error(
59
+ "Storacha authentication required: pass storachaKey + storachaProof OR ucanClient in options",
60
+ );
61
+ }
62
+
63
+ logger.info("🔑 Using credential authentication...");
64
+ const { createStorachaBackend } = await import("./storacha.js");
65
+ return createStorachaBackend({
66
+ storachaKey,
67
+ storachaProof,
68
+ serviceConf: config.serviceConf,
69
+ receiptsEndpoint: config.receiptsEndpoint,
70
+ ...(gateways ? { gateways } : {}),
71
+ });
72
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * @fileoverview Storacha storage backend for OrbitDB Storage Bridge
3
+ *
4
+ * The original backend, behind the contract the others will implement. It is kept for
5
+ * three reasons even though the public service is gone: the in-memory upload-api in
6
+ * `test/helpers/in-memory-storacha.js` still speaks this protocol, anyone holding
7
+ * credentials against a self-hosted w3up deployment can still use it, and it is the
8
+ * reference for what a UCAN-delegated backend looked like when one existed.
9
+ *
10
+ * Retrieval no longer defaults to Storacha's own gateways: `storacha.link` and `w3s.link`
11
+ * answer 301 to `dweb.link` as of 2026-09-05, so the driver goes there directly and lets
12
+ * a caller pass its own list — which is what the in-memory service does.
13
+ *
14
+ * @author @NiKrause
15
+ * @requires ./types.js - the backend contract
16
+ * @see {@link ../../docs/STORAGE-BACKENDS.md} for what replaced it
17
+ */
18
+
19
+ import * as Client from "@storacha/client";
20
+ import { StoreMemory } from "@storacha/client/stores/memory";
21
+ import { Signer } from "@storacha/client/principal/ed25519";
22
+ import * as Proof from "@storacha/client/proof";
23
+ import { CID } from "multiformats/cid";
24
+ import { defineBackend, handleId, BackendError } from "./types.js";
25
+
26
+ /** Gateways tried in order by `getBlob()`, unless the caller supplies its own. */
27
+ export const DEFAULT_GATEWAYS = Object.freeze([
28
+ "https://dweb.link",
29
+ "https://ipfs.io",
30
+ ]);
31
+
32
+ /**
33
+ * Build a Storacha client from key and proof, or adopt one that already exists.
34
+ *
35
+ * @param {object} options
36
+ * @param {string} [options.storachaKey]
37
+ * @param {string} [options.storachaProof]
38
+ * @param {object} [options.client] - a pre-built client, e.g. one authorised over UCAN
39
+ * @param {string} [options.spaceDID] - space to select on a pre-built client
40
+ * @param {object} [options.serviceConf]
41
+ * @param {string|URL} [options.receiptsEndpoint]
42
+ * @returns {Promise<object>}
43
+ */
44
+ async function resolveClient(options) {
45
+ if (options.client) {
46
+ if (options.spaceDID) {
47
+ await options.client.setCurrentSpace(options.spaceDID);
48
+ }
49
+ return options.client;
50
+ }
51
+
52
+ if (!options.storachaKey || !options.storachaProof) {
53
+ throw new BackendError(
54
+ "INVALID_BACKEND",
55
+ "createStorachaBackend needs either a client or storachaKey + storachaProof",
56
+ );
57
+ }
58
+
59
+ const clientOptions = {
60
+ principal: Signer.parse(options.storachaKey),
61
+ store: new StoreMemory(),
62
+ };
63
+ if (options.serviceConf) {
64
+ clientOptions.serviceConf = options.serviceConf;
65
+ }
66
+ if (options.receiptsEndpoint) {
67
+ clientOptions.receiptsEndpoint = options.receiptsEndpoint;
68
+ }
69
+
70
+ const client = await Client.create(clientOptions);
71
+ const space = await client.addSpace(await Proof.parse(options.storachaProof));
72
+ await client.setCurrentSpace(space.did());
73
+ return client;
74
+ }
75
+
76
+ /**
77
+ * Create a Storacha backend.
78
+ *
79
+ * @param {object} options - see {@link resolveClient}, plus:
80
+ * @param {string[]} [options.gateways] - retrieval gateways, tried in order
81
+ * @returns {Promise<import("./types.js").StorageBackend>}
82
+ */
83
+ export async function createStorachaBackend(options = {}) {
84
+ const client = await resolveClient(options);
85
+ const gateways = (options.gateways || DEFAULT_GATEWAYS).map((gateway) =>
86
+ String(gateway).replace(/\/+$/, ""),
87
+ );
88
+
89
+ return defineBackend({
90
+ name: "storacha",
91
+
92
+ capabilities: {
93
+ pinByCid: false,
94
+ carImport: false,
95
+ // the client hashes locally and the service stores exactly those bytes —
96
+ // the property this library was built on
97
+ preservesInnerCids: true,
98
+ browserSafeAuth: true,
99
+ delegation: true,
100
+ listing: true,
101
+ deletion: true,
102
+ minBlobSize: 0,
103
+ },
104
+
105
+ /** @type {import("./types.js").StorageBackend["putBlob"]} */
106
+ putBlob: async (bytes, meta = {}) => {
107
+ const name = meta.name || "blob";
108
+ const file = new File([bytes], name, {
109
+ type: meta.type || "application/octet-stream",
110
+ });
111
+ const cid = (await client.uploadFile(file)).toString();
112
+ return {
113
+ id: cid,
114
+ cid,
115
+ backend: "storacha",
116
+ size: bytes.length,
117
+ ...(meta.name ? { name } : {}),
118
+ };
119
+ },
120
+
121
+ /** @type {import("./types.js").StorageBackend["getBlob"]} */
122
+ getBlob: async (handle) => {
123
+ const id = handleId(handle);
124
+ const failures = [];
125
+
126
+ for (const gateway of gateways) {
127
+ try {
128
+ const response = await fetch(`${gateway}/ipfs/${id}`);
129
+ if (!response.ok) {
130
+ failures.push(`${gateway}: HTTP ${response.status}`);
131
+ continue;
132
+ }
133
+ return new Uint8Array(await response.arrayBuffer());
134
+ } catch (error) {
135
+ failures.push(`${gateway}: ${error?.message ?? error}`);
136
+ }
137
+ }
138
+
139
+ throw new BackendError(
140
+ "NOT_FOUND",
141
+ `No gateway served ${id} (${failures.join("; ")})`,
142
+ );
143
+ },
144
+
145
+ /** @type {import("./types.js").StorageBackend["list"]} */
146
+ list: async (listOptions = {}) => {
147
+ const query = {};
148
+ if (listOptions.size) query.size = listOptions.size;
149
+ if (listOptions.cursor) query.cursor = listOptions.cursor;
150
+
151
+ const result = await client.capability.upload.list(query);
152
+ return result.results.map((upload) => ({
153
+ id: upload.root.toString(),
154
+ cid: upload.root.toString(),
155
+ backend: "storacha",
156
+ size:
157
+ upload.shards?.reduce(
158
+ (total, shard) => total + (shard.size || 0),
159
+ 0,
160
+ ) || undefined,
161
+ insertedAt: upload.insertedAt,
162
+ updatedAt: upload.updatedAt,
163
+ // Escape hatch: the vendor record, for callers that have not generalised yet.
164
+ raw: upload,
165
+ }));
166
+ },
167
+
168
+ /** @type {import("./types.js").StorageBackend["remove"]} */
169
+ remove: async (handle) => {
170
+ await client.capability.upload.remove(CID.parse(handleId(handle)));
171
+ },
172
+
173
+ /** The underlying client, for the space and UCAN handling that does not generalise. */
174
+ client,
175
+ });
176
+ }
177
+
178
+ export default createStorachaBackend;