@parity/product-sdk-cloud-storage 0.5.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/src/index.ts ADDED
@@ -0,0 +1,107 @@
1
+ /**
2
+ * @parity/product-sdk-cloud-storage — Upload and retrieve content from the cloud
3
+ * (currently through the Polkadot Bulletin Chain).
4
+ *
5
+ * Wraps `@parity/bulletin-sdk` (chunking, DAG-PB manifests, CID calculation,
6
+ * progress events) and adds:
7
+ *
8
+ * - **Network presets** via {@link CloudStorageNetworks}
9
+ * - **Read helpers** ({@link CloudStorageClient.fetchBytes} /
10
+ * {@link CloudStorageClient.fetchJson})
11
+ * - **Authorization pre-flight** via {@link checkAuthorization}
12
+ *
13
+ * @packageDocumentation
14
+ */
15
+
16
+ export { CloudStorageClient } from "./client.js";
17
+ export type { CreateCloudStorageClientOptions } from "./client.js";
18
+ export { CloudStorageNetworks } from "./networks.js";
19
+ export type { CloudStorageEnvironment, CloudStorageNetwork } from "./networks.js";
20
+ export { authorizeAccount, checkAuthorization } from "./authorization.js";
21
+ export type { AuthorizeAccountOptions } from "./authorization.js";
22
+ export { createLazySigner } from "./lazy-signer.js";
23
+ export { executeQuery, queryBytes, queryJson } from "./query.js";
24
+ export { resolveQueryStrategy } from "./resolve-query.js";
25
+ export type { QueryStrategy } from "./resolve-query.js";
26
+ export { verifyStored } from "./verify.js";
27
+ export type { ChainStoredEntry, VerifyStoredOptions } from "./verify.js";
28
+ export {
29
+ cidToPreimageKey,
30
+ hashToCid,
31
+ HashAlgorithm,
32
+ CidCodec,
33
+ } from "./cid.js";
34
+
35
+ // Errors — abstracted CloudStorage* family plus the upstream `BulletinError` /
36
+ // `ErrorCode` re-exports for callers that need to catch upstream failures
37
+ // specifically.
38
+ export {
39
+ BulletinError,
40
+ ErrorCode,
41
+ ProductCloudStorageError,
42
+ CloudStorageAuthorizationError,
43
+ CloudStorageCidError,
44
+ CloudStorageHostUnavailableError,
45
+ CloudStorageLookupInterruptedError,
46
+ CloudStorageLookupTimeoutError,
47
+ } from "./errors.js";
48
+
49
+ // Types
50
+ export type {
51
+ AuthorizationStatus,
52
+ CloudStorageApi,
53
+ Environment,
54
+ QueryOptions,
55
+ } from "./types.js";
56
+
57
+ // Re-exports from upstream `@parity/bulletin-sdk` — surfaced for power users
58
+ // + so consumers don't need a separate `@parity/bulletin-sdk` import.
59
+ export {
60
+ AsyncBulletinClient,
61
+ BulletinPreparer,
62
+ AuthCallBuilder,
63
+ CallBuilder,
64
+ StoreBuilder,
65
+ MockBulletinClient,
66
+ AuthorizationScope,
67
+ ChunkStatus,
68
+ TxStatus,
69
+ WaitFor,
70
+ MAX_CHUNK_SIZE,
71
+ MAX_FILE_SIZE,
72
+ DEFAULT_CHUNKER_CONFIG,
73
+ DEFAULT_CLIENT_CONFIG,
74
+ DEFAULT_STORE_OPTIONS,
75
+ calculateCid,
76
+ cidFromBytes,
77
+ cidToBytes,
78
+ convertCid,
79
+ estimateAuthorization,
80
+ getContentHash,
81
+ parseCid,
82
+ reassembleChunks,
83
+ resolveClientConfig,
84
+ validateChunkSize,
85
+ CID,
86
+ } from "@parity/bulletin-sdk";
87
+
88
+ export type {
89
+ BulletinClientInterface,
90
+ BulletinTypedApi,
91
+ Chunk,
92
+ ChunkDetails,
93
+ ChunkProgressEvent,
94
+ ChunkedStoreResult,
95
+ ChunkerConfig,
96
+ ClientConfig,
97
+ DagManifest,
98
+ MockClientConfig,
99
+ MockOperation,
100
+ ProgressCallback,
101
+ ProgressEvent,
102
+ StoreOptions,
103
+ StoreResult,
104
+ SubmitFn,
105
+ TransactionReceipt,
106
+ TransactionStatusEvent,
107
+ } from "@parity/bulletin-sdk";
@@ -0,0 +1,113 @@
1
+ import type { PolkadotSigner } from "polkadot-api";
2
+
3
+ /**
4
+ * Build a `PolkadotSigner` whose underlying signer is resolved on every call.
5
+ *
6
+ * `AsyncBulletinClient` takes a fixed `PolkadotSigner` at construction, but
7
+ * apps often build the Cloud Storage client before any account is selected. This
8
+ * wrapper defers signer resolution: each call to `signTx` / `signBytes`
9
+ * invokes `getSigner()` and forwards to the result. If the getter returns
10
+ * `null`, calls throw with a clear message.
11
+ *
12
+ * The `publicKey` field is *also* resolved lazily — accessing it before a
13
+ * signer is available throws. This means callers that read `publicKey`
14
+ * eagerly will fail fast with the same error rather than seeing a stale
15
+ * key from a previously-selected account.
16
+ *
17
+ * Account changes between calls are picked up automatically: each sign
18
+ * resolves the current signer.
19
+ */
20
+ export function createLazySigner(
21
+ getSigner: () => PolkadotSigner | null,
22
+ onMissing = "No signer available — connect a wallet and select an account first.",
23
+ ): PolkadotSigner {
24
+ const resolve = (): PolkadotSigner => {
25
+ const inner = getSigner();
26
+ if (!inner) throw new Error(onMissing);
27
+ return inner;
28
+ };
29
+
30
+ // `async` on the methods is deliberate: it converts a "no signer" throw
31
+ // from `resolve()` into a rejected Promise. PolkadotSigner.signTx /
32
+ // signBytes are typed as returning Promises, and consumers expect a
33
+ // rejection rather than a synchronous escape on the failure path.
34
+ const lazy: PolkadotSigner = {
35
+ get publicKey() {
36
+ return resolve().publicKey;
37
+ },
38
+ signTx: async (...args: Parameters<PolkadotSigner["signTx"]>) => resolve().signTx(...args),
39
+ signBytes: async (...args: Parameters<PolkadotSigner["signBytes"]>) =>
40
+ resolve().signBytes(...args),
41
+ };
42
+ return lazy;
43
+ }
44
+
45
+ if (import.meta.vitest) {
46
+ const { describe, test, expect, vi } = import.meta.vitest;
47
+
48
+ function makeMockSigner(label: string): PolkadotSigner {
49
+ return {
50
+ publicKey: new TextEncoder().encode(label),
51
+ signTx: vi.fn().mockResolvedValue(new Uint8Array([1])),
52
+ signBytes: vi.fn().mockResolvedValue(new Uint8Array([2])),
53
+ };
54
+ }
55
+
56
+ describe("createLazySigner", () => {
57
+ test("publicKey throws when getter returns null", () => {
58
+ const lazy = createLazySigner(() => null);
59
+ expect(() => lazy.publicKey).toThrow("No signer available");
60
+ });
61
+
62
+ test("publicKey resolves through getter when signer is available", () => {
63
+ const inner = makeMockSigner("alice");
64
+ const lazy = createLazySigner(() => inner);
65
+ expect(lazy.publicKey).toBe(inner.publicKey);
66
+ });
67
+
68
+ test("signTx throws when getter returns null", async () => {
69
+ const lazy = createLazySigner(() => null);
70
+ await expect(lazy.signTx(new Uint8Array(), {}, new Uint8Array(), 0)).rejects.toThrow(
71
+ "No signer available",
72
+ );
73
+ });
74
+
75
+ test("signTx forwards to current signer", async () => {
76
+ const inner = makeMockSigner("alice");
77
+ const lazy = createLazySigner(() => inner);
78
+ const callData = new Uint8Array([0xaa, 0xbb]);
79
+ const signedExtensions = {};
80
+ const metadata = new Uint8Array([0xcc]);
81
+ const atBlock = 42;
82
+ const result = await lazy.signTx(callData, signedExtensions, metadata, atBlock);
83
+ expect(inner.signTx).toHaveBeenCalledWith(
84
+ callData,
85
+ signedExtensions,
86
+ metadata,
87
+ atBlock,
88
+ );
89
+ expect(result).toEqual(new Uint8Array([1]));
90
+ });
91
+
92
+ test("signBytes forwards to current signer", async () => {
93
+ const inner = makeMockSigner("bob");
94
+ const lazy = createLazySigner(() => inner);
95
+ const result = await lazy.signBytes(new Uint8Array([9]));
96
+ expect(inner.signBytes).toHaveBeenCalledWith(new Uint8Array([9]));
97
+ expect(result).toEqual(new Uint8Array([2]));
98
+ });
99
+
100
+ test("picks up account changes between calls", () => {
101
+ let active: PolkadotSigner | null = makeMockSigner("first");
102
+ const lazy = createLazySigner(() => active);
103
+ expect(lazy.publicKey).toEqual(new TextEncoder().encode("first"));
104
+ active = makeMockSigner("second");
105
+ expect(lazy.publicKey).toEqual(new TextEncoder().encode("second"));
106
+ });
107
+
108
+ test("custom error message", () => {
109
+ const lazy = createLazySigner(() => null, "select an account first");
110
+ expect(() => lazy.publicKey).toThrow("select an account first");
111
+ });
112
+ });
113
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Known Cloud Storage networks.
3
+ *
4
+ * Each environment pairs its genesis hash with a per-environment PAPI descriptor.
5
+ */
6
+ import { paseo_bulletin as paseoBulletinDescriptor } from "@parity/product-sdk-descriptors/paseo-bulletin";
7
+
8
+ export interface CloudStorageNetwork {
9
+ /** Genesis hash of the underlying chain on this environment. */
10
+ genesisHash: `0x${string}`;
11
+ /** PAPI descriptor for typed API access. */
12
+ descriptor: typeof paseoBulletinDescriptor;
13
+ }
14
+
15
+ /**
16
+ * Cloud Storage network presets.
17
+ *
18
+ * Use these with {@link CloudStorageClient.create} when you want to be explicit
19
+ * about the network rather than passing an environment string. Reads go
20
+ * through the host's preimage subscription (container-only); no gateway
21
+ * URL is configured per network.
22
+ */
23
+ export const CloudStorageNetworks = {
24
+ paseo: {
25
+ genesisHash: "0x8cfe6717dc4becfda2e13c488a1e2061ff2dfee96e7d031157f72d36716c0a22",
26
+ descriptor: paseoBulletinDescriptor,
27
+ },
28
+ } as const satisfies Record<string, CloudStorageNetwork>;
29
+
30
+ /** Network keys with built-in presets in {@link CloudStorageNetworks}. */
31
+ export type CloudStorageEnvironment = keyof typeof CloudStorageNetworks;
32
+
33
+ if (import.meta.vitest) {
34
+ const { describe, test, expect } = import.meta.vitest;
35
+
36
+ describe("CloudStorageNetworks", () => {
37
+ test("paseo has a valid genesis hash", () => {
38
+ expect(CloudStorageNetworks.paseo.genesisHash).toMatch(/^0x[a-f0-9]{64}$/);
39
+ });
40
+
41
+ test("paseo descriptor has matching genesis", () => {
42
+ expect(CloudStorageNetworks.paseo.descriptor.genesis).toBe(
43
+ CloudStorageNetworks.paseo.genesisHash,
44
+ );
45
+ });
46
+ });
47
+ }
package/src/query.ts ADDED
@@ -0,0 +1,216 @@
1
+ import { CidCodec, parseCid, UnixFsDagBuilder } from "@parity/bulletin-sdk";
2
+ import { createLogger } from "@parity/product-sdk-logger";
3
+
4
+ import type { QueryStrategy } from "./resolve-query.js";
5
+ import { resolveQueryStrategy } from "./resolve-query.js";
6
+ import type { QueryOptions } from "./types.js";
7
+
8
+ const log = createLogger("bulletin");
9
+
10
+ /**
11
+ * Fetch raw bytes for a CID via the host's preimage lookup.
12
+ *
13
+ * Container-only by design: the Cloud Storage SDK does not retrieve content
14
+ * through public IPFS gateways. Inside a Polkadot Browser / Desktop
15
+ * container, the host's `preimageManager` provides a cached, polling-
16
+ * managed lookup that returns bytes when the underlying IPFS network
17
+ * makes them available. Outside a container, this throws
18
+ * {@link CloudStorageHostUnavailableError}.
19
+ *
20
+ * The underlying chain stores transaction *metadata* on-chain
21
+ * (`chunk_root`, `content_hash`, `size`, `cid_codec`, `hashing`) — the
22
+ * content bytes themselves live in IPFS and are surfaced through the
23
+ * host's preimage subscription, never via direct gateway fetch.
24
+ *
25
+ * To prove that a CID was stored on-chain (without fetching the bytes),
26
+ * use `verifyStored` from `verify.ts`.
27
+ *
28
+ * @param cid - CIDv1 string to fetch.
29
+ * @param options - Query options (`lookupTimeoutMs` for host).
30
+ * @throws {CloudStorageHostUnavailableError} If running outside a container.
31
+ */
32
+ export async function queryBytes(cid: string, options?: QueryOptions): Promise<Uint8Array> {
33
+ const strategy = await resolveQueryStrategy();
34
+ return executeQuery(strategy, cid, options);
35
+ }
36
+
37
+ /**
38
+ * Fetch and parse JSON for a CID via the host's preimage lookup.
39
+ *
40
+ * Convenience wrapper over {@link queryBytes}.
41
+ */
42
+ export async function queryJson<T>(cid: string, options?: QueryOptions): Promise<T> {
43
+ const bytes = await queryBytes(cid, options);
44
+ return JSON.parse(new TextDecoder().decode(bytes)) as T;
45
+ }
46
+
47
+ /**
48
+ * Execute a query using a pre-resolved strategy.
49
+ *
50
+ * Exposed so `CloudStorageClient` can resolve the strategy once at
51
+ * construction time and reuse it across calls without re-detecting
52
+ * the host environment on every fetch.
53
+ *
54
+ * **Reassembly is automatic.** If `cid` carries the DAG-PB codec
55
+ * (`0x70`) — meaning the upload was chunked and a UnixFS manifest was
56
+ * created — this function recursively fetches each chunk via `strategy
57
+ * .lookup` and returns the concatenated bytes. Pass `noReassemble: true`
58
+ * to get the raw manifest bytes instead.
59
+ *
60
+ * For raw-codec CIDs (`0x55`, single-chunk content), the bytes returned
61
+ * by the host are returned directly — no parsing overhead.
62
+ */
63
+ export async function executeQuery(
64
+ strategy: QueryStrategy,
65
+ cid: string,
66
+ options?: QueryOptions,
67
+ ): Promise<Uint8Array> {
68
+ log.info("query: host preimage lookup", { cid });
69
+ const bytes = await strategy.lookup(cid, options?.lookupTimeoutMs);
70
+
71
+ // Skip reassembly when the caller explicitly asks for raw bytes, or
72
+ // when the CID's codec says this is a single-block payload (raw,
73
+ // 0x55) — most uploads land here, so the parseCid + Promise.all
74
+ // overhead is worth gating on codec rather than always paying it.
75
+ if (options?.noReassemble) return bytes;
76
+ const parsed = parseCid(cid);
77
+ if (parsed.code !== CidCodec.DagPb) return bytes;
78
+
79
+ log.info("query: reassembling DAG-PB manifest", { cid });
80
+ const builder = new UnixFsDagBuilder();
81
+ const { chunkCids } = await builder.parse(bytes);
82
+
83
+ // Fetch chunks in parallel — the host's preimageManager caches and
84
+ // dedupes lookups, and order is preserved by Promise.all's input
85
+ // ordering, which matches the DAG-PB Links order from parse().
86
+ const chunks = await Promise.all(
87
+ chunkCids.map((c) => strategy.lookup(c.toString(), options?.lookupTimeoutMs)),
88
+ );
89
+
90
+ let total = 0;
91
+ for (const chunk of chunks) total += chunk.length;
92
+ const out = new Uint8Array(total);
93
+ let offset = 0;
94
+ for (const chunk of chunks) {
95
+ out.set(chunk, offset);
96
+ offset += chunk.length;
97
+ }
98
+ return out;
99
+ }
100
+
101
+ if (import.meta.vitest) {
102
+ const { beforeAll, describe, test, expect, vi } = import.meta.vitest;
103
+ const { calculateCid } = await import("@parity/bulletin-sdk");
104
+
105
+ describe("executeQuery", () => {
106
+ const testData = new Uint8Array([1, 2, 3]);
107
+
108
+ test("delegates to the strategy's lookup function", async () => {
109
+ const lookup = vi.fn().mockResolvedValue(testData);
110
+ const strategy: QueryStrategy = { kind: "host-lookup", lookup };
111
+ // Use a real raw-codec CID so the codec check passes through
112
+ // without triggering reassembly.
113
+ const rawCid = (await calculateCid(testData)).toString();
114
+ const result = await executeQuery(strategy, rawCid);
115
+ expect(result).toBe(testData);
116
+ expect(lookup).toHaveBeenCalledWith(rawCid, undefined);
117
+ });
118
+
119
+ test("forwards lookupTimeoutMs to the strategy", async () => {
120
+ const lookup = vi.fn().mockResolvedValue(testData);
121
+ const strategy: QueryStrategy = { kind: "host-lookup", lookup };
122
+ const rawCid = (await calculateCid(testData)).toString();
123
+ await executeQuery(strategy, rawCid, { lookupTimeoutMs: 5000 });
124
+ expect(lookup).toHaveBeenCalledWith(rawCid, 5000);
125
+ });
126
+
127
+ test("returns raw bytes directly for raw-codec CIDs (no reassembly)", async () => {
128
+ const lookup = vi.fn().mockResolvedValue(testData);
129
+ const strategy: QueryStrategy = { kind: "host-lookup", lookup };
130
+ const rawCid = (await calculateCid(testData)).toString();
131
+ const result = await executeQuery(strategy, rawCid);
132
+ expect(result).toBe(testData);
133
+ // Single lookup, no recursion.
134
+ expect(lookup).toHaveBeenCalledTimes(1);
135
+ });
136
+
137
+ test("noReassemble: true short-circuits even for DAG-PB CIDs", async () => {
138
+ // Manufacture a DAG-PB CID; we don't need the bytes to actually
139
+ // be a valid manifest because we're skipping the parse step.
140
+ const fakeManifestBytes = new Uint8Array([10, 20, 30]);
141
+ const dagPbCid = (await calculateCid(fakeManifestBytes, /* dag-pb */ 0x70)).toString();
142
+ const lookup = vi.fn().mockResolvedValue(fakeManifestBytes);
143
+ const strategy: QueryStrategy = { kind: "host-lookup", lookup };
144
+ const result = await executeQuery(strategy, dagPbCid, { noReassemble: true });
145
+ expect(result).toBe(fakeManifestBytes);
146
+ expect(lookup).toHaveBeenCalledTimes(1);
147
+ });
148
+
149
+ describe("DAG-PB reassembly", () => {
150
+ // Build a real manifest: two raw chunks, dag-pb root.
151
+ const chunkA = new Uint8Array([0xaa, 0xaa, 0xaa]);
152
+ const chunkB = new Uint8Array([0xbb, 0xbb]);
153
+ let chunkACid: string;
154
+ let chunkBCid: string;
155
+ let manifestBytes: Uint8Array;
156
+ let manifestCid: string;
157
+
158
+ beforeAll(async () => {
159
+ const { UnixFsDagBuilder: Builder } = await import("@parity/bulletin-sdk");
160
+ chunkACid = (await calculateCid(chunkA)).toString();
161
+ chunkBCid = (await calculateCid(chunkB)).toString();
162
+ const cidA = await calculateCid(chunkA);
163
+ const cidB = await calculateCid(chunkB);
164
+ const manifest = await new Builder().build([
165
+ { data: chunkA, cid: cidA, index: 0, totalChunks: 2 },
166
+ { data: chunkB, cid: cidB, index: 1, totalChunks: 2 },
167
+ ]);
168
+ manifestBytes = manifest.dagBytes;
169
+ manifestCid = manifest.rootCid.toString();
170
+ });
171
+
172
+ test("recursively fetches chunks and concatenates", async () => {
173
+ const lookup = vi.fn(async (cid: string) => {
174
+ if (cid === manifestCid) return manifestBytes;
175
+ if (cid === chunkACid) return chunkA;
176
+ if (cid === chunkBCid) return chunkB;
177
+ throw new Error(`unexpected lookup: ${cid}`);
178
+ });
179
+ const strategy: QueryStrategy = { kind: "host-lookup", lookup };
180
+ const result = await executeQuery(strategy, manifestCid);
181
+ expect(result).toEqual(new Uint8Array([0xaa, 0xaa, 0xaa, 0xbb, 0xbb]));
182
+ // 1 manifest + 2 chunks = 3 lookups.
183
+ expect(lookup).toHaveBeenCalledTimes(3);
184
+ });
185
+
186
+ test("forwards lookupTimeoutMs to every chunk lookup", async () => {
187
+ const lookup = vi.fn(async (cid: string, _timeoutMs?: number) => {
188
+ if (cid === manifestCid) return manifestBytes;
189
+ if (cid === chunkACid) return chunkA;
190
+ if (cid === chunkBCid) return chunkB;
191
+ throw new Error("boom");
192
+ });
193
+ const strategy: QueryStrategy = { kind: "host-lookup", lookup };
194
+ await executeQuery(strategy, manifestCid, { lookupTimeoutMs: 7777 });
195
+ for (const call of lookup.mock.calls) {
196
+ expect(call[1]).toBe(7777);
197
+ }
198
+ });
199
+
200
+ test("preserves chunk order from the manifest", async () => {
201
+ const lookup = vi.fn(async (cid: string) => {
202
+ if (cid === manifestCid) return manifestBytes;
203
+ if (cid === chunkACid) return chunkA;
204
+ if (cid === chunkBCid) return chunkB;
205
+ throw new Error("boom");
206
+ });
207
+ const strategy: QueryStrategy = { kind: "host-lookup", lookup };
208
+ const result = await executeQuery(strategy, manifestCid);
209
+ // Order matters: chunkA bytes must come before chunkB bytes
210
+ // even though both are fetched in parallel.
211
+ expect(Array.from(result.slice(0, 3))).toEqual([0xaa, 0xaa, 0xaa]);
212
+ expect(Array.from(result.slice(3))).toEqual([0xbb, 0xbb]);
213
+ });
214
+ });
215
+ });
216
+ }
@@ -0,0 +1,198 @@
1
+ import { calculateCid } from "@parity/bulletin-sdk";
2
+ import { getPreimageManager, type PreimageManager } from "@parity/product-sdk-host";
3
+ import { createLogger } from "@parity/product-sdk-logger";
4
+
5
+ import { cidToPreimageKey } from "./cid.js";
6
+ import {
7
+ CloudStorageHostUnavailableError,
8
+ CloudStorageLookupInterruptedError,
9
+ CloudStorageLookupTimeoutError,
10
+ } from "./errors.js";
11
+
12
+ const log = createLogger("bulletin");
13
+
14
+ const DEFAULT_LOOKUP_TIMEOUT_MS = 30_000;
15
+
16
+ /**
17
+ * Query strategy for Cloud Storage retrieval.
18
+ *
19
+ * The host manages the lookup via its preimage subscription API,
20
+ * which includes local caching and managed IPFS polling.
21
+ */
22
+ export interface QueryStrategy {
23
+ kind: "host-lookup";
24
+ lookup: (cid: string, timeoutMs?: number) => Promise<Uint8Array>;
25
+ }
26
+
27
+ /**
28
+ * Determine the query strategy for Cloud Storage retrieval.
29
+ *
30
+ * Uses the host preimage lookup API which caches results and manages
31
+ * IPFS polling automatically.
32
+ *
33
+ * @returns The resolved query strategy.
34
+ * @throws {CloudStorageHostUnavailableError} If the host preimage manager is unavailable.
35
+ */
36
+ export async function resolveQueryStrategy(): Promise<QueryStrategy> {
37
+ const preimageManager = await getPreimageManager();
38
+ if (preimageManager) {
39
+ log.info("using host preimage lookup for Cloud Storage queries");
40
+ return {
41
+ kind: "host-lookup",
42
+ lookup: (cid, timeoutMs) => lookupViaHost(preimageManager, cid, timeoutMs),
43
+ };
44
+ }
45
+
46
+ throw new CloudStorageHostUnavailableError("query");
47
+ }
48
+
49
+ /**
50
+ * Wrap `preimageManager.lookup` (subscription-based) into a one-shot Promise.
51
+ *
52
+ * Converts the CID to a hex preimage key, subscribes, and resolves on the
53
+ * first non-null callback. Rejects on timeout or if the host interrupts the
54
+ * subscription (e.g. after repeated failures). Always unsubscribes on settlement.
55
+ *
56
+ * @param manager - The product-sdk preimage manager.
57
+ * @param cid - CIDv1 string to look up.
58
+ * @param timeoutMs - Maximum wait time. Default: 30_000ms.
59
+ * @returns The raw bytes of the preimage.
60
+ */
61
+ export function lookupViaHost(
62
+ manager: PreimageManager,
63
+ cid: string,
64
+ timeoutMs: number = DEFAULT_LOOKUP_TIMEOUT_MS,
65
+ ): Promise<Uint8Array> {
66
+ const key = cidToPreimageKey(cid);
67
+
68
+ return new Promise<Uint8Array>((resolve, reject) => {
69
+ const cleanup = () => {
70
+ cancelInterrupt();
71
+ sub.unsubscribe();
72
+ };
73
+
74
+ const settle = (fn: () => void) => {
75
+ if (timer === null) return;
76
+ clearTimeout(timer);
77
+ timer = null;
78
+ cleanup();
79
+ fn();
80
+ };
81
+
82
+ let timer: ReturnType<typeof setTimeout> | null = setTimeout(() => {
83
+ settle(() => {
84
+ reject(new CloudStorageLookupTimeoutError(cid, timeoutMs));
85
+ });
86
+ }, timeoutMs);
87
+
88
+ const sub = manager.lookup(key, (preimage) => {
89
+ if (preimage !== null) {
90
+ settle(() => resolve(preimage));
91
+ }
92
+ // null means "not found yet" — host will keep polling
93
+ });
94
+
95
+ const cancelInterrupt = sub.onInterrupt(() => {
96
+ settle(() => {
97
+ reject(new CloudStorageLookupInterruptedError(cid));
98
+ });
99
+ });
100
+ });
101
+ }
102
+
103
+ if (import.meta.vitest) {
104
+ const { beforeAll, describe, test, expect, vi } = import.meta.vitest;
105
+
106
+ // Note: resolveQueryStrategy tests require e2e testing as they
107
+ // depend on the host container environment.
108
+
109
+ describe("lookupViaHost", () => {
110
+ function createMockManager(
111
+ behavior: "resolve" | "null-then-resolve" | "hang" | "interrupt",
112
+ ) {
113
+ const unsubscribe = vi.fn();
114
+ const cancelInterrupt = vi.fn();
115
+ let interruptCb: VoidFunction | undefined;
116
+
117
+ const lookup = vi.fn((_key: string, callback: (p: Uint8Array | null) => void) => {
118
+ const data = new Uint8Array([10, 20, 30]);
119
+ queueMicrotask(() => {
120
+ if (behavior === "resolve") {
121
+ callback(data);
122
+ } else if (behavior === "null-then-resolve") {
123
+ callback(null);
124
+ queueMicrotask(() => callback(data));
125
+ } else if (behavior === "interrupt") {
126
+ interruptCb?.();
127
+ }
128
+ // "hang" does nothing
129
+ });
130
+ return {
131
+ unsubscribe,
132
+ onInterrupt: (cb: VoidFunction) => {
133
+ interruptCb = cb;
134
+ return cancelInterrupt;
135
+ },
136
+ };
137
+ });
138
+
139
+ return { lookup, unsubscribe, cancelInterrupt, submit: vi.fn() };
140
+ }
141
+
142
+ // calculateCid is async (Web Crypto), so populate lazily.
143
+ let testCid: string;
144
+ beforeAll(async () => {
145
+ const cid = await calculateCid(new TextEncoder().encode("test"));
146
+ testCid = cid.toString();
147
+ });
148
+
149
+ test("resolves on first non-null callback", async () => {
150
+ const manager = createMockManager("resolve");
151
+ const result = await lookupViaHost(manager, testCid);
152
+ expect(result).toEqual(new Uint8Array([10, 20, 30]));
153
+ });
154
+
155
+ test("ignores null callbacks and resolves on subsequent data", async () => {
156
+ const manager = createMockManager("null-then-resolve");
157
+ const result = await lookupViaHost(manager, testCid);
158
+ expect(result).toEqual(new Uint8Array([10, 20, 30]));
159
+ });
160
+
161
+ test("rejects with CloudStorageLookupTimeoutError on timeout", async () => {
162
+ const { CloudStorageLookupTimeoutError } = await import("./errors.js");
163
+ const manager = createMockManager("hang");
164
+ const err = await lookupViaHost(manager, testCid, 50).catch((e) => e);
165
+ expect(err).toBeInstanceOf(CloudStorageLookupTimeoutError);
166
+ expect(err.cid).toBe(testCid);
167
+ expect(err.timeoutMs).toBe(50);
168
+ });
169
+
170
+ test("rejects with CloudStorageLookupInterruptedError on interrupt", async () => {
171
+ const { CloudStorageLookupInterruptedError } = await import("./errors.js");
172
+ const manager = createMockManager("interrupt");
173
+ const err = await lookupViaHost(manager, testCid).catch((e) => e);
174
+ expect(err).toBeInstanceOf(CloudStorageLookupInterruptedError);
175
+ expect(err.cid).toBe(testCid);
176
+ });
177
+
178
+ test("calls unsubscribe and cancelInterrupt on resolution", async () => {
179
+ const manager = createMockManager("resolve");
180
+ await lookupViaHost(manager, testCid);
181
+ expect(manager.unsubscribe).toHaveBeenCalledOnce();
182
+ expect(manager.cancelInterrupt).toHaveBeenCalledOnce();
183
+ });
184
+
185
+ test("calls unsubscribe on interrupt", async () => {
186
+ const manager = createMockManager("interrupt");
187
+ await lookupViaHost(manager, testCid).catch(() => {});
188
+ expect(manager.unsubscribe).toHaveBeenCalledOnce();
189
+ });
190
+
191
+ test("passes correct hex key to manager", async () => {
192
+ const expectedKey = cidToPreimageKey(testCid);
193
+ const manager = createMockManager("resolve");
194
+ await lookupViaHost(manager, testCid);
195
+ expect(manager.lookup).toHaveBeenCalledWith(expectedKey, expect.any(Function));
196
+ });
197
+ });
198
+ }