@parity/product-sdk-bulletin 0.1.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/upload.ts ADDED
@@ -0,0 +1,344 @@
1
+ import { createLogger } from "@parity/product-sdk-logger";
2
+ import { submitAndWatch, withRetry } from "@parity/product-sdk-tx";
3
+ import type { PolkadotSigner } from "polkadot-api";
4
+ import { Binary } from "polkadot-api";
5
+
6
+ import { computeCid } from "./cid.js";
7
+ import { gatewayUrl } from "./gateway.js";
8
+ import { resolveUploadStrategy } from "./resolve-signer.js";
9
+ import type {
10
+ BatchUploadItem,
11
+ BatchUploadOptions,
12
+ BatchUploadResult,
13
+ BulletinApi,
14
+ UploadOptions,
15
+ UploadResult,
16
+ } from "./types.js";
17
+
18
+ const log = createLogger("bulletin");
19
+
20
+ /**
21
+ * Upload data to the Bulletin Chain.
22
+ *
23
+ * When a signer is provided, submits a `TransactionStorage.store` transaction
24
+ * directly. When omitted, uses the host preimage API — the host signs and
25
+ * submits automatically.
26
+ *
27
+ * Computes the CIDv1 (blake2b-256, raw codec) locally.
28
+ *
29
+ * @param api - Typed Bulletin Chain API.
30
+ * @param data - Raw bytes to store.
31
+ * @param signer - Optional signer. When omitted, uses host preimage API.
32
+ * @param options - Upload options (gateway, timeout, waitFor, status callback).
33
+ * @returns Upload result with CID and either blockHash or preimageKey.
34
+ */
35
+ export async function upload(
36
+ api: BulletinApi,
37
+ data: Uint8Array,
38
+ signer?: PolkadotSigner,
39
+ options?: UploadOptions,
40
+ ): Promise<UploadResult> {
41
+ const strategy = await resolveUploadStrategy(signer);
42
+ const cid = computeCid(data);
43
+
44
+ if (strategy.kind === "preimage") {
45
+ log.info("uploading via host preimage API", { cid, size: data.byteLength });
46
+ const preimageKey = await strategy.submit(data);
47
+ log.info("preimage submitted successfully", { cid, preimageKey });
48
+ return {
49
+ kind: "preimage",
50
+ cid,
51
+ preimageKey,
52
+ gatewayUrl: options?.gateway ? gatewayUrl(cid, options.gateway) : undefined,
53
+ };
54
+ }
55
+
56
+ log.info("uploading via TransactionStorage.store", { cid, size: data.byteLength });
57
+ const result = await withRetry(() => {
58
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
59
+ const tx = (api as any).tx.TransactionStorage.store({ data: Binary.fromBytes(data) });
60
+ return submitAndWatch(tx, strategy.signer, {
61
+ waitFor: options?.waitFor,
62
+ timeoutMs: options?.timeoutMs,
63
+ onStatus: options?.onStatus,
64
+ });
65
+ });
66
+
67
+ log.info("transaction included in block", { cid, blockHash: result.block.hash });
68
+ return {
69
+ kind: "transaction",
70
+ cid,
71
+ blockHash: result.block.hash,
72
+ gatewayUrl: options?.gateway ? gatewayUrl(cid, options.gateway) : undefined,
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Upload multiple items sequentially to the Bulletin Chain.
78
+ *
79
+ * Bulletin Chain requires sequential transaction submission (nonce ordering).
80
+ * Individual failures are captured in results — the batch does not abort.
81
+ *
82
+ * Signer resolution follows the same rules as {@link upload}: when omitted,
83
+ * the strategy is auto-resolved once and reused for all items.
84
+ *
85
+ * @param api - Typed Bulletin Chain API.
86
+ * @param items - Array of items to upload, each with data and a label.
87
+ * @param signer - Optional signer. When omitted, auto-resolved.
88
+ * @param options - Batch upload options (gateway, timeout, progress callback).
89
+ * @returns Array of results, one per item, preserving input order.
90
+ */
91
+ export async function batchUpload(
92
+ api: BulletinApi,
93
+ items: BatchUploadItem[],
94
+ signer?: PolkadotSigner,
95
+ options?: BatchUploadOptions,
96
+ ): Promise<BatchUploadResult[]> {
97
+ if (items.length === 0) return [];
98
+
99
+ const strategy = await resolveUploadStrategy(signer);
100
+ const results: BatchUploadResult[] = [];
101
+
102
+ for (let i = 0; i < items.length; i++) {
103
+ const item = items[i]!;
104
+ const cid = computeCid(item.data);
105
+
106
+ try {
107
+ if (strategy.kind === "preimage") {
108
+ log.info("batch: uploading item via preimage", {
109
+ label: item.label,
110
+ index: i,
111
+ total: items.length,
112
+ });
113
+ const preimageKey = await strategy.submit(item.data);
114
+
115
+ const entry: BatchUploadResult = {
116
+ kind: "preimage",
117
+ label: item.label,
118
+ cid,
119
+ success: true,
120
+ preimageKey,
121
+ gatewayUrl: options?.gateway ? gatewayUrl(cid, options.gateway) : undefined,
122
+ };
123
+ results.push(entry);
124
+ options?.onProgress?.(i + 1, items.length, entry);
125
+ } else {
126
+ log.info("batch: uploading item via transaction", {
127
+ label: item.label,
128
+ index: i,
129
+ total: items.length,
130
+ });
131
+ const result = await withRetry(() => {
132
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
133
+ const tx = (api as any).tx.TransactionStorage.store({
134
+ data: Binary.fromBytes(item.data),
135
+ });
136
+ return submitAndWatch(tx, strategy.signer, {
137
+ waitFor: options?.waitFor,
138
+ timeoutMs: options?.timeoutMs,
139
+ });
140
+ });
141
+
142
+ const entry: BatchUploadResult = {
143
+ kind: "transaction",
144
+ label: item.label,
145
+ cid,
146
+ success: true,
147
+ blockHash: result.block.hash,
148
+ gatewayUrl: options?.gateway ? gatewayUrl(cid, options.gateway) : undefined,
149
+ };
150
+ results.push(entry);
151
+ options?.onProgress?.(i + 1, items.length, entry);
152
+ }
153
+ } catch (err) {
154
+ log.error("batch: item upload failed", {
155
+ label: item.label,
156
+ index: i,
157
+ error: err instanceof Error ? err.message : String(err),
158
+ });
159
+ const entry: BatchUploadResult = {
160
+ kind: strategy.kind === "preimage" ? "preimage" : "transaction",
161
+ label: item.label,
162
+ cid,
163
+ success: false,
164
+ error: err instanceof Error ? err.message : String(err),
165
+ };
166
+ results.push(entry);
167
+ options?.onProgress?.(i + 1, items.length, entry);
168
+ }
169
+ }
170
+
171
+ return results;
172
+ }
173
+
174
+ if (import.meta.vitest) {
175
+ const { describe, test, expect, vi } = import.meta.vitest;
176
+
177
+ function createMockApi() {
178
+ return {
179
+ tx: {
180
+ TransactionStorage: {
181
+ store: vi.fn().mockReturnValue({
182
+ signSubmitAndWatch: () => ({
183
+ subscribe: (handlers: { next: (e: unknown) => void }) => {
184
+ queueMicrotask(() => {
185
+ handlers.next({ type: "signed", txHash: "0xtxhash" });
186
+ handlers.next({
187
+ type: "txBestBlocksState",
188
+ txHash: "0xtxhash",
189
+ found: true,
190
+ ok: true,
191
+ block: { hash: "0xblockhash", number: 1, index: 0 },
192
+ events: [],
193
+ });
194
+ });
195
+ return { unsubscribe: vi.fn() };
196
+ },
197
+ }),
198
+ }),
199
+ },
200
+ },
201
+ };
202
+ }
203
+
204
+ const mockSigner = {} as PolkadotSigner;
205
+
206
+ describe("upload", () => {
207
+ test("calls TransactionStorage.store and returns CID + blockHash with explicit signer", async () => {
208
+ const api = createMockApi();
209
+ const data = new TextEncoder().encode("test data");
210
+ const result = await upload(api as unknown as BulletinApi, data, mockSigner);
211
+
212
+ expect(api.tx.TransactionStorage.store).toHaveBeenCalledOnce();
213
+ expect(result.kind).toBe("transaction");
214
+ expect(result.cid).toBeTruthy();
215
+ if (result.kind === "transaction") {
216
+ expect(result.blockHash).toBe("0xblockhash");
217
+ }
218
+ });
219
+
220
+ test("includes gatewayUrl when gateway option provided", async () => {
221
+ const api = createMockApi();
222
+ const data = new TextEncoder().encode("test");
223
+ const result = await upload(api as unknown as BulletinApi, data, mockSigner, {
224
+ gateway: "https://gw/ipfs/",
225
+ });
226
+
227
+ expect(result.gatewayUrl).toBe(`https://gw/ipfs/${result.cid}`);
228
+ });
229
+
230
+ test("omits gatewayUrl when no gateway option", async () => {
231
+ const api = createMockApi();
232
+ const data = new TextEncoder().encode("test");
233
+ const result = await upload(api as unknown as BulletinApi, data, mockSigner);
234
+
235
+ expect(result.gatewayUrl).toBeUndefined();
236
+ });
237
+ });
238
+
239
+ describe("batchUpload", () => {
240
+ test("returns empty array for empty items", async () => {
241
+ const api = createMockApi();
242
+ const results = await batchUpload(api as unknown as BulletinApi, [], mockSigner);
243
+ expect(results).toEqual([]);
244
+ });
245
+
246
+ test("processes items sequentially with explicit signer", async () => {
247
+ const api = createMockApi();
248
+ const items: BatchUploadItem[] = [
249
+ { data: new TextEncoder().encode("a"), label: "file-a" },
250
+ { data: new TextEncoder().encode("b"), label: "file-b" },
251
+ ];
252
+ const results = await batchUpload(api as unknown as BulletinApi, items, mockSigner);
253
+
254
+ expect(results).toHaveLength(2);
255
+ expect(results[0]!.kind).toBe("transaction");
256
+ expect(results[0]!.label).toBe("file-a");
257
+ expect(results[0]!.success).toBe(true);
258
+ expect(results[1]!.kind).toBe("transaction");
259
+ expect(results[1]!.label).toBe("file-b");
260
+ expect(results[1]!.success).toBe(true);
261
+ });
262
+
263
+ test("captures individual failures without aborting batch", async () => {
264
+ const api = createMockApi();
265
+ // Make the second call fail with a dispatch error (non-retryable)
266
+ let callCount = 0;
267
+ api.tx.TransactionStorage.store.mockImplementation(() => {
268
+ callCount++;
269
+ if (callCount === 2) {
270
+ return {
271
+ signSubmitAndWatch: () => ({
272
+ subscribe: (handlers: { next: (e: unknown) => void }) => {
273
+ queueMicrotask(() => {
274
+ handlers.next({ type: "signed", txHash: "0x" });
275
+ handlers.next({
276
+ type: "txBestBlocksState",
277
+ txHash: "0x",
278
+ found: true,
279
+ ok: false,
280
+ block: { hash: "0xblock", number: 1, index: 0 },
281
+ events: [],
282
+ dispatchError: { type: "BadOrigin" },
283
+ });
284
+ });
285
+ return { unsubscribe: vi.fn() };
286
+ },
287
+ }),
288
+ };
289
+ }
290
+ return {
291
+ signSubmitAndWatch: () => ({
292
+ subscribe: (handlers: { next: (e: unknown) => void }) => {
293
+ queueMicrotask(() => {
294
+ handlers.next({ type: "signed", txHash: "0x" });
295
+ handlers.next({
296
+ type: "txBestBlocksState",
297
+ txHash: "0x",
298
+ found: true,
299
+ ok: true,
300
+ block: { hash: "0xblock", number: 1, index: 0 },
301
+ events: [],
302
+ });
303
+ });
304
+ return { unsubscribe: vi.fn() };
305
+ },
306
+ }),
307
+ };
308
+ });
309
+
310
+ const items: BatchUploadItem[] = [
311
+ { data: new TextEncoder().encode("a"), label: "ok" },
312
+ { data: new TextEncoder().encode("b"), label: "fail" },
313
+ { data: new TextEncoder().encode("c"), label: "ok2" },
314
+ ];
315
+ const results = await batchUpload(api as unknown as BulletinApi, items, mockSigner);
316
+
317
+ expect(results).toHaveLength(3);
318
+ expect(results[0]!.kind).toBe("transaction");
319
+ expect(results[0]!.success).toBe(true);
320
+ const f1 = results[1]!;
321
+ expect(f1.kind).toBe("transaction");
322
+ expect(f1.success).toBe(false);
323
+ if (!f1.success) expect(f1.error).toContain("BadOrigin");
324
+ expect(results[2]!.success).toBe(true);
325
+ });
326
+
327
+ test("calls onProgress for each item", async () => {
328
+ const api = createMockApi();
329
+ const items: BatchUploadItem[] = [
330
+ { data: new TextEncoder().encode("a"), label: "a" },
331
+ { data: new TextEncoder().encode("b"), label: "b" },
332
+ ];
333
+ const progress: Array<[number, number, string]> = [];
334
+ await batchUpload(api as unknown as BulletinApi, items, mockSigner, {
335
+ onProgress: (done, total, current) => progress.push([done, total, current.label]),
336
+ });
337
+
338
+ expect(progress).toEqual([
339
+ [1, 2, "a"],
340
+ [2, 2, "b"],
341
+ ]);
342
+ });
343
+ });
344
+ }