@parity/product-sdk-bulletin 0.1.0 → 0.2.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/query.ts CHANGED
@@ -1,19 +1,33 @@
1
+ import { CidCodec, parseCid, UnixFsDagBuilder } from "@parity/bulletin-sdk";
1
2
  import { createLogger } from "@parity/product-sdk-logger";
2
3
 
3
- import { resolveQueryStrategy, type QueryStrategy } from "./resolve-query.js";
4
+ import type { QueryStrategy } from "./resolve-query.js";
5
+ import { resolveQueryStrategy } from "./resolve-query.js";
4
6
  import type { QueryOptions } from "./types.js";
5
7
 
6
8
  const log = createLogger("bulletin");
7
9
 
8
10
  /**
9
- * Fetch raw bytes for a CID using the host preimage lookup.
11
+ * Fetch raw bytes for a CID via the host's preimage lookup.
10
12
  *
11
- * Uses local cache + managed IPFS polling via the host container.
13
+ * Container-only by design: the bulletin 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 BulletinHostUnavailableError}.
19
+ *
20
+ * The bulletin 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 `verifyOnChain` from `verify.ts`.
12
27
  *
13
28
  * @param cid - CIDv1 string to fetch.
14
- * @param options - Query options (lookupTimeoutMs for host).
15
- * @returns Raw bytes of the content.
16
- * @throws {Error} If the host preimage API is unavailable.
29
+ * @param options - Query options (`lookupTimeoutMs` for host).
30
+ * @throws {BulletinHostUnavailableError} If running outside a container.
17
31
  */
18
32
  export async function queryBytes(cid: string, options?: QueryOptions): Promise<Uint8Array> {
19
33
  const strategy = await resolveQueryStrategy();
@@ -21,14 +35,9 @@ export async function queryBytes(cid: string, options?: QueryOptions): Promise<U
21
35
  }
22
36
 
23
37
  /**
24
- * Fetch and parse JSON for a CID, auto-resolving the query path.
25
- *
26
- * Delegates to {@link queryBytes} and parses the result as JSON.
38
+ * Fetch and parse JSON for a CID via the host's preimage lookup.
27
39
  *
28
- * @param cid - CIDv1 string to fetch.
29
- * @param options - Query options.
30
- * @returns Parsed JSON value.
31
- * @throws {Error} If the host preimage API is unavailable.
40
+ * Convenience wrapper over {@link queryBytes}.
32
41
  */
33
42
  export async function queryJson<T>(cid: string, options?: QueryOptions): Promise<T> {
34
43
  const bytes = await queryBytes(cid, options);
@@ -38,45 +47,170 @@ export async function queryJson<T>(cid: string, options?: QueryOptions): Promise
38
47
  /**
39
48
  * Execute a query using a pre-resolved strategy.
40
49
  *
41
- * Exposed so that {@link BulletinClient} can resolve the strategy once and
42
- * reuse it across multiple calls without re-detecting the environment.
50
+ * Exposed so `BulletinClient` 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.
43
59
  *
44
- * @param strategy - Pre-resolved query strategy.
45
- * @param cid - CIDv1 string to fetch.
46
- * @param options - Query options.
47
- * @returns Raw bytes of the content.
60
+ * For raw-codec CIDs (`0x55`, single-chunk content), the bytes returned
61
+ * by the host are returned directly — no parsing overhead.
48
62
  */
49
63
  export async function executeQuery(
50
64
  strategy: QueryStrategy,
51
65
  cid: string,
52
66
  options?: QueryOptions,
53
67
  ): Promise<Uint8Array> {
54
- log.info("querying via host preimage lookup", { cid });
55
- return strategy.lookup(cid, options?.lookupTimeoutMs);
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;
56
99
  }
57
100
 
58
101
  if (import.meta.vitest) {
59
- const { describe, test, expect, vi } = import.meta.vitest;
60
-
61
- // Note: queryBytes and queryJson tests require e2e testing as they
62
- // depend on the host container environment for strategy resolution.
102
+ const { beforeAll, describe, test, expect, vi } = import.meta.vitest;
103
+ const { calculateCid } = await import("@parity/bulletin-sdk");
63
104
 
64
105
  describe("executeQuery", () => {
65
106
  const testData = new Uint8Array([1, 2, 3]);
66
107
 
67
- test("executes host-lookup strategy", async () => {
108
+ test("delegates to the strategy's lookup function", async () => {
68
109
  const lookup = vi.fn().mockResolvedValue(testData);
69
110
  const strategy: QueryStrategy = { kind: "host-lookup", lookup };
70
- const result = await executeQuery(strategy, "bafytest");
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);
71
115
  expect(result).toBe(testData);
72
- expect(lookup).toHaveBeenCalledWith("bafytest", undefined);
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);
73
125
  });
74
126
 
75
- test("passes lookupTimeoutMs to host-lookup", async () => {
127
+ test("returns raw bytes directly for raw-codec CIDs (no reassembly)", async () => {
76
128
  const lookup = vi.fn().mockResolvedValue(testData);
77
129
  const strategy: QueryStrategy = { kind: "host-lookup", lookup };
78
- await executeQuery(strategy, "bafytest", { lookupTimeoutMs: 5000 });
79
- expect(lookup).toHaveBeenCalledWith("bafytest", 5000);
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
+ });
80
214
  });
81
215
  });
82
216
  }
@@ -1,7 +1,8 @@
1
+ import { calculateCid } from "@parity/bulletin-sdk";
1
2
  import { getPreimageManager, type PreimageManager } from "@parity/product-sdk-host";
2
3
  import { createLogger } from "@parity/product-sdk-logger";
3
4
 
4
- import { cidToPreimageKey, computeCid } from "./cid.js";
5
+ import { cidToPreimageKey } from "./cid.js";
5
6
  import {
6
7
  BulletinHostUnavailableError,
7
8
  BulletinLookupInterruptedError,
@@ -100,7 +101,7 @@ export function lookupViaHost(
100
101
  }
101
102
 
102
103
  if (import.meta.vitest) {
103
- const { describe, test, expect, vi } = import.meta.vitest;
104
+ const { beforeAll, describe, test, expect, vi } = import.meta.vitest;
104
105
 
105
106
  // Note: resolveQueryStrategy tests require e2e testing as they
106
107
  // depend on the host container environment.
@@ -138,7 +139,12 @@ if (import.meta.vitest) {
138
139
  return { lookup, unsubscribe, cancelInterrupt, submit: vi.fn() };
139
140
  }
140
141
 
141
- const testCid = computeCid(new TextEncoder().encode("test"));
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
+ });
142
148
 
143
149
  test("resolves on first non-null callback", async () => {
144
150
  const manager = createMockManager("resolve");
package/src/types.ts CHANGED
@@ -1,116 +1,17 @@
1
- import type { TypedApi } from "polkadot-api";
2
- import type { TxStatus, WaitFor } from "@parity/product-sdk-tx";
1
+ import type { BulletinTypedApi } from "@parity/bulletin-sdk";
3
2
 
4
- /** Typed API for the Bulletin Chain, derived from PAPI descriptors. */
5
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
6
- export type BulletinApi = TypedApi<any>;
3
+ /** Typed API for the Bulletin Chain (re-export from upstream). */
4
+ export type BulletinApi = BulletinTypedApi;
7
5
 
6
+ /** Re-exported environment string from chain-client. */
8
7
  export type { Environment } from "@parity/product-sdk-chain-client";
9
8
 
10
- /**
11
- * Options for {@link upload}.
12
- *
13
- * Note: `waitFor`, `timeoutMs`, and `onStatus` only apply to the **transaction**
14
- * upload path (when an explicit signer is used or the dev signer fallback is active).
15
- * The preimage path delegates to the host which controls its own submission
16
- * lifecycle — these options are ignored in that case.
17
- */
18
- export interface UploadOptions {
19
- /** IPFS gateway base URL (e.g., from `getGateway("paseo")`). If provided, result includes gatewayUrl. */
20
- gateway?: string;
21
- /** When to resolve: `"best-block"` (default) or `"finalized"`. Transaction path only. */
22
- waitFor?: WaitFor;
23
- /** Timeout in ms. Default: 300_000 (5 min). Transaction path only. */
24
- timeoutMs?: number;
25
- /** Lifecycle status callback for UI progress. Transaction path only. */
26
- onStatus?: (status: TxStatus) => void;
27
- }
28
-
29
- /** Fields common to all upload results. */
30
- interface UploadResultBase {
31
- /** CIDv1 string (blake2b-256, raw codec). */
32
- cid: string;
33
- /** Gateway URL. Present only if `gateway` was provided in options. */
34
- gatewayUrl?: string;
35
- }
36
-
37
- /**
38
- * Result of a successful upload to the Bulletin Chain.
39
- *
40
- * Discriminated on `kind`:
41
- * - `"transaction"` — uploaded via a signed `TransactionStorage.store` extrinsic.
42
- * - `"preimage"` — uploaded via the host preimage API (no user signing).
43
- *
44
- * Use `result.kind` to narrow the type and access path-specific fields.
45
- */
46
- export type UploadResult =
47
- | (UploadResultBase & {
48
- /** Upload was performed via a signed transaction. */
49
- kind: "transaction";
50
- /** Block hash where the store transaction was included. */
51
- blockHash: string;
52
- })
53
- | (UploadResultBase & {
54
- /** Upload was performed via the host preimage API. */
55
- kind: "preimage";
56
- /** Hex key returned by the host preimage API. */
57
- preimageKey: string;
58
- });
59
-
60
- /** A single item in a batch upload. */
61
- export interface BatchUploadItem {
62
- /** Raw bytes to upload. */
63
- data: Uint8Array;
64
- /** Label for progress tracking (e.g., filename). */
65
- label: string;
66
- }
67
-
68
- /** Fields common to all batch upload results. */
69
- interface BatchUploadResultBase {
70
- label: string;
71
- cid: string;
72
- gatewayUrl?: string;
73
- }
74
-
75
- /**
76
- * Result for one item in a batch upload.
77
- *
78
- * Discriminated on `kind` (upload path) and `success` (outcome).
79
- * Use `result.success` to check for errors, then `result.kind` to access
80
- * path-specific fields like `blockHash` or `preimageKey`.
81
- */
82
- export type BatchUploadResult =
83
- | (BatchUploadResultBase & {
84
- kind: "transaction";
85
- success: true;
86
- /** Block hash where the store transaction was included. */
87
- blockHash: string;
88
- })
89
- | (BatchUploadResultBase & {
90
- kind: "preimage";
91
- success: true;
92
- /** Hex key returned by the host preimage API. */
93
- preimageKey: string;
94
- })
95
- | (BatchUploadResultBase & {
96
- kind: "transaction" | "preimage";
97
- success: false;
98
- /** Error message describing the failure. */
99
- error: string;
100
- });
101
-
102
- /** Options for {@link batchUpload}. */
103
- export interface BatchUploadOptions extends UploadOptions {
104
- /** Called after each item completes (success or failure). */
105
- onProgress?: (completed: number, total: number, current: BatchUploadResult) => void;
106
- }
107
-
108
9
  /**
109
10
  * Authorization status for a Bulletin Chain account.
110
11
  *
111
- * Returned by {@link checkAuthorization} to enable pre-flight checks before
112
- * uploading. Consumers can use this to show "not authorized" or "insufficient
113
- * quota" messages instead of letting the transaction fail.
12
+ * Returned by {@link checkAuthorization} as a pre-flight check before storing
13
+ * data. Consumers can use this to show "not authorized" or "insufficient quota"
14
+ * messages instead of letting the transaction fail mid-execution.
114
15
  */
115
16
  export interface AuthorizationStatus {
116
17
  /** Whether an authorization entry exists for this account. */
@@ -123,18 +24,26 @@ export interface AuthorizationStatus {
123
24
  expiration: number;
124
25
  }
125
26
 
126
- /** Options for gateway fetch operations. */
127
- export interface FetchOptions {
128
- /** Timeout in ms. Default: 30_000. */
129
- timeoutMs?: number;
130
- }
131
-
132
- /** Options for query operations that support host lookup auto-resolution. */
133
- export interface QueryOptions extends FetchOptions {
27
+ /**
28
+ * Options for {@link BulletinClient.fetchBytes} / {@link BulletinClient.fetchJson}.
29
+ */
30
+ export interface QueryOptions {
134
31
  /**
135
- * Timeout for the host preimage lookup subscription in ms.
136
- * Only applies when the query resolves through the host path.
137
- * Default: 30_000.
32
+ * Timeout for the host preimage lookup subscription, in ms.
33
+ * Default: 30_000. Applied per lookup for chunked content (DAG-PB
34
+ * manifest CIDs), the manifest fetch and each child chunk fetch
35
+ * each get this budget.
138
36
  */
139
37
  lookupTimeoutMs?: number;
38
+ /**
39
+ * When `true`, return the raw bytes for the requested CID without
40
+ * parsing or recursing into a DAG-PB manifest. Default: `false` — the
41
+ * client transparently reassembles chunked content so callers don't
42
+ * need to know whether a CID points at a single chunk or a manifest.
43
+ *
44
+ * Set this if you want to inspect the manifest itself, e.g., to read
45
+ * `unixfs.fileSize()` ahead of fetching, or to drive your own chunk
46
+ * pipeline.
47
+ */
48
+ noReassemble?: boolean;
140
49
  }