@parity/product-sdk-statement-store 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/LICENSE +201 -0
- package/dist/index.d.ts +583 -0
- package/dist/index.js +1058 -0
- package/dist/index.js.map +1 -0
- package/package.json +48 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1058 @@
|
|
|
1
|
+
// src/client.ts
|
|
2
|
+
import { createLogger as createLogger2 } from "@parity/product-sdk-logger";
|
|
3
|
+
import { blake2b256 as blake2b2562 } from "@parity/product-sdk-utils";
|
|
4
|
+
|
|
5
|
+
// src/types.ts
|
|
6
|
+
var MAX_STATEMENT_SIZE = 512;
|
|
7
|
+
var MAX_USER_TOTAL = 1024;
|
|
8
|
+
var DEFAULT_TTL_SECONDS = 30;
|
|
9
|
+
|
|
10
|
+
// src/errors.ts
|
|
11
|
+
var StatementStoreError = class extends Error {
|
|
12
|
+
constructor(message, options) {
|
|
13
|
+
super(message, options);
|
|
14
|
+
this.name = "StatementStoreError";
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
var StatementEncodingError = class extends StatementStoreError {
|
|
18
|
+
constructor(message, options) {
|
|
19
|
+
super(`Encoding error: ${message}`, options);
|
|
20
|
+
this.name = "StatementEncodingError";
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
var StatementSubmitError = class extends StatementStoreError {
|
|
24
|
+
/** The raw response from the RPC call. */
|
|
25
|
+
detail;
|
|
26
|
+
constructor(detail) {
|
|
27
|
+
super(`Statement submission rejected: ${JSON.stringify(detail)}`);
|
|
28
|
+
this.name = "StatementSubmitError";
|
|
29
|
+
this.detail = detail;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
var StatementSubscriptionError = class extends StatementStoreError {
|
|
33
|
+
constructor(message, options) {
|
|
34
|
+
super(`Subscription error: ${message}`, options);
|
|
35
|
+
this.name = "StatementSubscriptionError";
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
var StatementConnectionError = class extends StatementStoreError {
|
|
39
|
+
constructor(message, options) {
|
|
40
|
+
super(`Connection error: ${message}`, options);
|
|
41
|
+
this.name = "StatementConnectionError";
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
var StatementDataTooLargeError = class extends StatementStoreError {
|
|
45
|
+
/** The actual size of the data in bytes. */
|
|
46
|
+
actualSize;
|
|
47
|
+
/** The maximum allowed size in bytes. */
|
|
48
|
+
maxSize;
|
|
49
|
+
constructor(actualSize, maxSize = MAX_STATEMENT_SIZE) {
|
|
50
|
+
super(`Statement data too large: ${actualSize} bytes exceeds maximum of ${maxSize} bytes`);
|
|
51
|
+
this.name = "StatementDataTooLargeError";
|
|
52
|
+
this.actualSize = actualSize;
|
|
53
|
+
this.maxSize = maxSize;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
if (void 0) {
|
|
57
|
+
const { describe, test, expect } = void 0;
|
|
58
|
+
describe("StatementStoreError hierarchy", () => {
|
|
59
|
+
test("StatementStoreError is instanceof Error", () => {
|
|
60
|
+
const err = new StatementStoreError("test");
|
|
61
|
+
expect(err).toBeInstanceOf(Error);
|
|
62
|
+
expect(err).toBeInstanceOf(StatementStoreError);
|
|
63
|
+
expect(err.name).toBe("StatementStoreError");
|
|
64
|
+
expect(err.message).toBe("test");
|
|
65
|
+
});
|
|
66
|
+
test("StatementEncodingError", () => {
|
|
67
|
+
const err = new StatementEncodingError("bad field tag");
|
|
68
|
+
expect(err).toBeInstanceOf(StatementStoreError);
|
|
69
|
+
expect(err).toBeInstanceOf(Error);
|
|
70
|
+
expect(err.name).toBe("StatementEncodingError");
|
|
71
|
+
expect(err.message).toContain("bad field tag");
|
|
72
|
+
});
|
|
73
|
+
test("StatementEncodingError preserves cause", () => {
|
|
74
|
+
const cause = new Error("original");
|
|
75
|
+
const err = new StatementEncodingError("wrap", { cause });
|
|
76
|
+
expect(err.cause).toBe(cause);
|
|
77
|
+
});
|
|
78
|
+
test("StatementSubmitError", () => {
|
|
79
|
+
const detail = { status: "rejected", reason: "bad proof" };
|
|
80
|
+
const err = new StatementSubmitError(detail);
|
|
81
|
+
expect(err).toBeInstanceOf(StatementStoreError);
|
|
82
|
+
expect(err.name).toBe("StatementSubmitError");
|
|
83
|
+
expect(err.detail).toBe(detail);
|
|
84
|
+
expect(err.message).toContain("rejected");
|
|
85
|
+
});
|
|
86
|
+
test("StatementSubscriptionError", () => {
|
|
87
|
+
const err = new StatementSubscriptionError("not supported");
|
|
88
|
+
expect(err).toBeInstanceOf(StatementStoreError);
|
|
89
|
+
expect(err.name).toBe("StatementSubscriptionError");
|
|
90
|
+
expect(err.message).toContain("not supported");
|
|
91
|
+
});
|
|
92
|
+
test("StatementConnectionError", () => {
|
|
93
|
+
const err = new StatementConnectionError("timeout");
|
|
94
|
+
expect(err).toBeInstanceOf(StatementStoreError);
|
|
95
|
+
expect(err.name).toBe("StatementConnectionError");
|
|
96
|
+
expect(err.message).toContain("timeout");
|
|
97
|
+
});
|
|
98
|
+
test("StatementDataTooLargeError", () => {
|
|
99
|
+
const err = new StatementDataTooLargeError(600);
|
|
100
|
+
expect(err).toBeInstanceOf(StatementStoreError);
|
|
101
|
+
expect(err.name).toBe("StatementDataTooLargeError");
|
|
102
|
+
expect(err.actualSize).toBe(600);
|
|
103
|
+
expect(err.maxSize).toBe(512);
|
|
104
|
+
expect(err.message).toContain("600");
|
|
105
|
+
expect(err.message).toContain("512");
|
|
106
|
+
});
|
|
107
|
+
test("StatementDataTooLargeError with custom max", () => {
|
|
108
|
+
const err = new StatementDataTooLargeError(2e3, 1024);
|
|
109
|
+
expect(err.actualSize).toBe(2e3);
|
|
110
|
+
expect(err.maxSize).toBe(1024);
|
|
111
|
+
});
|
|
112
|
+
test("all errors are catchable via base class", () => {
|
|
113
|
+
const errors = [
|
|
114
|
+
new StatementEncodingError("test"),
|
|
115
|
+
new StatementSubmitError("test"),
|
|
116
|
+
new StatementSubscriptionError("test"),
|
|
117
|
+
new StatementConnectionError("test"),
|
|
118
|
+
new StatementDataTooLargeError(600)
|
|
119
|
+
];
|
|
120
|
+
for (const err of errors) {
|
|
121
|
+
expect(err).toBeInstanceOf(StatementStoreError);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// src/data.ts
|
|
128
|
+
function fromHex(hex) {
|
|
129
|
+
const clean = hex.startsWith("0x") ? hex.slice(2) : hex;
|
|
130
|
+
const bytes = new Uint8Array(clean.length / 2);
|
|
131
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
132
|
+
bytes[i] = Number.parseInt(clean.substring(i * 2, i * 2 + 2), 16);
|
|
133
|
+
}
|
|
134
|
+
return bytes;
|
|
135
|
+
}
|
|
136
|
+
function toHex(bytes) {
|
|
137
|
+
let hex = "0x";
|
|
138
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
139
|
+
hex += bytes[i].toString(16).padStart(2, "0");
|
|
140
|
+
}
|
|
141
|
+
return hex;
|
|
142
|
+
}
|
|
143
|
+
function encodeData(value) {
|
|
144
|
+
const json = JSON.stringify(value);
|
|
145
|
+
const bytes = new TextEncoder().encode(json);
|
|
146
|
+
if (bytes.length > MAX_STATEMENT_SIZE) {
|
|
147
|
+
throw new StatementDataTooLargeError(bytes.length);
|
|
148
|
+
}
|
|
149
|
+
return bytes;
|
|
150
|
+
}
|
|
151
|
+
function decodeData(bytes) {
|
|
152
|
+
try {
|
|
153
|
+
const json = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
154
|
+
return JSON.parse(json);
|
|
155
|
+
} catch (error) {
|
|
156
|
+
throw new StatementEncodingError("Failed to decode JSON data", { cause: error });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (void 0) {
|
|
160
|
+
const { describe, test, expect } = void 0;
|
|
161
|
+
describe("encodeData / decodeData", () => {
|
|
162
|
+
test("round-trips JSON object", () => {
|
|
163
|
+
const original = { type: "presence", peerId: "abc123", timestamp: 1234 };
|
|
164
|
+
const encoded = encodeData(original);
|
|
165
|
+
const decoded = decodeData(encoded);
|
|
166
|
+
expect(decoded).toEqual(original);
|
|
167
|
+
});
|
|
168
|
+
test("round-trips string", () => {
|
|
169
|
+
const encoded = encodeData("hello");
|
|
170
|
+
expect(decodeData(encoded)).toBe("hello");
|
|
171
|
+
});
|
|
172
|
+
test("round-trips number", () => {
|
|
173
|
+
const encoded = encodeData(42);
|
|
174
|
+
expect(decodeData(encoded)).toBe(42);
|
|
175
|
+
});
|
|
176
|
+
test("throws StatementDataTooLargeError for oversized data", () => {
|
|
177
|
+
const large = { data: "x".repeat(600) };
|
|
178
|
+
expect(() => encodeData(large)).toThrow(StatementDataTooLargeError);
|
|
179
|
+
});
|
|
180
|
+
test("allows data at exactly MAX_STATEMENT_SIZE", () => {
|
|
181
|
+
const str = "a".repeat(510);
|
|
182
|
+
const encoded = encodeData(str);
|
|
183
|
+
expect(encoded.length).toBe(512);
|
|
184
|
+
});
|
|
185
|
+
test("throws StatementEncodingError for invalid JSON bytes", () => {
|
|
186
|
+
const invalid = new TextEncoder().encode("{not valid json");
|
|
187
|
+
expect(() => decodeData(invalid)).toThrow(StatementEncodingError);
|
|
188
|
+
});
|
|
189
|
+
test("throws StatementEncodingError for non-UTF-8 bytes", () => {
|
|
190
|
+
const invalid = new Uint8Array([255, 254, 253]);
|
|
191
|
+
expect(() => decodeData(invalid)).toThrow(StatementEncodingError);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
describe("toHex / fromHex", () => {
|
|
195
|
+
test("round-trips bytes", () => {
|
|
196
|
+
const bytes = new Uint8Array([222, 173, 190, 239]);
|
|
197
|
+
const hex = toHex(bytes);
|
|
198
|
+
expect(hex).toBe("0xdeadbeef");
|
|
199
|
+
expect(fromHex(hex)).toEqual(bytes);
|
|
200
|
+
});
|
|
201
|
+
test("handles hex without 0x prefix", () => {
|
|
202
|
+
const bytes = fromHex("cafebabe");
|
|
203
|
+
expect(bytes).toEqual(new Uint8Array([202, 254, 186, 190]));
|
|
204
|
+
});
|
|
205
|
+
test("handles empty input", () => {
|
|
206
|
+
expect(toHex(new Uint8Array(0))).toBe("0x");
|
|
207
|
+
expect(fromHex("0x")).toEqual(new Uint8Array(0));
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// src/topics.ts
|
|
213
|
+
import { blake2b256 } from "@parity/product-sdk-utils";
|
|
214
|
+
function createTopic(name) {
|
|
215
|
+
const bytes = new TextEncoder().encode(name);
|
|
216
|
+
return blake2b256(bytes);
|
|
217
|
+
}
|
|
218
|
+
function createChannel(name) {
|
|
219
|
+
const bytes = new TextEncoder().encode(name);
|
|
220
|
+
return blake2b256(bytes);
|
|
221
|
+
}
|
|
222
|
+
function topicToHex(hash) {
|
|
223
|
+
let hex = "0x";
|
|
224
|
+
for (let i = 0; i < hash.length; i++) {
|
|
225
|
+
hex += hash[i].toString(16).padStart(2, "0");
|
|
226
|
+
}
|
|
227
|
+
return hex;
|
|
228
|
+
}
|
|
229
|
+
function topicsEqual(a, b) {
|
|
230
|
+
if (a.length !== b.length) return false;
|
|
231
|
+
for (let i = 0; i < a.length; i++) {
|
|
232
|
+
if (a[i] !== b[i]) return false;
|
|
233
|
+
}
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
function serializeTopicFilter(filter) {
|
|
237
|
+
if (filter === "any") return "any";
|
|
238
|
+
if ("matchAll" in filter) {
|
|
239
|
+
return { matchAll: filter.matchAll.map(topicToHex) };
|
|
240
|
+
}
|
|
241
|
+
return { matchAny: filter.matchAny.map(topicToHex) };
|
|
242
|
+
}
|
|
243
|
+
if (void 0) {
|
|
244
|
+
const { describe, test, expect } = void 0;
|
|
245
|
+
describe("createTopic", () => {
|
|
246
|
+
test("produces a 32-byte hash", () => {
|
|
247
|
+
const topic = createTopic("test");
|
|
248
|
+
expect(topic).toBeInstanceOf(Uint8Array);
|
|
249
|
+
expect(topic.length).toBe(32);
|
|
250
|
+
});
|
|
251
|
+
test("is deterministic (same input = same hash)", () => {
|
|
252
|
+
const a = createTopic("ss-webrtc");
|
|
253
|
+
const b = createTopic("ss-webrtc");
|
|
254
|
+
expect(topicsEqual(a, b)).toBe(true);
|
|
255
|
+
});
|
|
256
|
+
test("different inputs produce different hashes", () => {
|
|
257
|
+
const a = createTopic("topic-a");
|
|
258
|
+
const b = createTopic("topic-b");
|
|
259
|
+
expect(topicsEqual(a, b)).toBe(false);
|
|
260
|
+
});
|
|
261
|
+
test("empty string produces a valid hash", () => {
|
|
262
|
+
const topic = createTopic("");
|
|
263
|
+
expect(topic.length).toBe(32);
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
describe("createChannel", () => {
|
|
267
|
+
test("produces a 32-byte hash", () => {
|
|
268
|
+
const channel = createChannel("presence/peer-abc");
|
|
269
|
+
expect(channel).toBeInstanceOf(Uint8Array);
|
|
270
|
+
expect(channel.length).toBe(32);
|
|
271
|
+
});
|
|
272
|
+
test("same input as createTopic produces same bytes", () => {
|
|
273
|
+
const topic = createTopic("test-name");
|
|
274
|
+
const channel = createChannel("test-name");
|
|
275
|
+
expect(topicsEqual(topic, channel)).toBe(true);
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
describe("topicToHex", () => {
|
|
279
|
+
test("converts to hex with 0x prefix", () => {
|
|
280
|
+
const hash = new Uint8Array(32);
|
|
281
|
+
hash[0] = 171;
|
|
282
|
+
hash[1] = 205;
|
|
283
|
+
const hex = topicToHex(hash);
|
|
284
|
+
expect(hex).toMatch(/^0x/);
|
|
285
|
+
expect(hex).toBe(`0xabcd${"00".repeat(30)}`);
|
|
286
|
+
});
|
|
287
|
+
test("round-trips through hex encoding", () => {
|
|
288
|
+
const topic = createTopic("round-trip-test");
|
|
289
|
+
const hex = topicToHex(topic);
|
|
290
|
+
expect(hex.length).toBe(2 + 64);
|
|
291
|
+
});
|
|
292
|
+
test("pads single-digit bytes with leading zero", () => {
|
|
293
|
+
const hash = new Uint8Array([10]);
|
|
294
|
+
expect(topicToHex(hash)).toBe("0x0a");
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
describe("topicsEqual", () => {
|
|
298
|
+
test("returns true for identical hashes", () => {
|
|
299
|
+
const a = createTopic("same");
|
|
300
|
+
const b = createTopic("same");
|
|
301
|
+
expect(topicsEqual(a, b)).toBe(true);
|
|
302
|
+
});
|
|
303
|
+
test("returns false for different hashes", () => {
|
|
304
|
+
const a = createTopic("alpha");
|
|
305
|
+
const b = createTopic("beta");
|
|
306
|
+
expect(topicsEqual(a, b)).toBe(false);
|
|
307
|
+
});
|
|
308
|
+
test("returns false for different lengths", () => {
|
|
309
|
+
const a = new Uint8Array(32);
|
|
310
|
+
const b = new Uint8Array(16);
|
|
311
|
+
expect(topicsEqual(a, b)).toBe(false);
|
|
312
|
+
});
|
|
313
|
+
test("returns true for empty arrays", () => {
|
|
314
|
+
expect(topicsEqual(new Uint8Array(0), new Uint8Array(0))).toBe(true);
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
describe("serializeTopicFilter", () => {
|
|
318
|
+
test("serializes 'any' as string", () => {
|
|
319
|
+
expect(serializeTopicFilter("any")).toBe("any");
|
|
320
|
+
});
|
|
321
|
+
test("serializes matchAll with hex topics", () => {
|
|
322
|
+
const topics = [createTopic("a"), createTopic("b")];
|
|
323
|
+
const result = serializeTopicFilter({ matchAll: topics });
|
|
324
|
+
expect(result.matchAll).toHaveLength(2);
|
|
325
|
+
expect(result.matchAll[0]).toMatch(/^0x[0-9a-f]{64}$/);
|
|
326
|
+
expect(result.matchAll[1]).toMatch(/^0x[0-9a-f]{64}$/);
|
|
327
|
+
});
|
|
328
|
+
test("serializes matchAny with hex topics", () => {
|
|
329
|
+
const topics = [createTopic("x")];
|
|
330
|
+
const result = serializeTopicFilter({ matchAny: topics });
|
|
331
|
+
expect(result.matchAny).toHaveLength(1);
|
|
332
|
+
expect(result.matchAny[0]).toMatch(/^0x[0-9a-f]{64}$/);
|
|
333
|
+
});
|
|
334
|
+
test("matchAll preserves order", () => {
|
|
335
|
+
const topicA = createTopic("first");
|
|
336
|
+
const topicB = createTopic("second");
|
|
337
|
+
const result = serializeTopicFilter({ matchAll: [topicA, topicB] });
|
|
338
|
+
expect(result.matchAll[0]).toBe(topicToHex(topicA));
|
|
339
|
+
expect(result.matchAll[1]).toBe(topicToHex(topicB));
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// src/transport.ts
|
|
345
|
+
import { createLogger } from "@parity/product-sdk-logger";
|
|
346
|
+
var log = createLogger("statement-store:transport");
|
|
347
|
+
var HostTransport = class {
|
|
348
|
+
store;
|
|
349
|
+
constructor(store) {
|
|
350
|
+
this.store = store;
|
|
351
|
+
}
|
|
352
|
+
subscribe(filter, onStatements, onError) {
|
|
353
|
+
const topics = extractTopicBytes(filter);
|
|
354
|
+
try {
|
|
355
|
+
const sub = this.store.subscribe(topics, (statements) => {
|
|
356
|
+
const converted = statements.map(
|
|
357
|
+
hostSignedStatementToSdk
|
|
358
|
+
);
|
|
359
|
+
onStatements(converted);
|
|
360
|
+
});
|
|
361
|
+
log.info("Host subscription active");
|
|
362
|
+
return {
|
|
363
|
+
unsubscribe: () => sub.unsubscribe()
|
|
364
|
+
};
|
|
365
|
+
} catch (error) {
|
|
366
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
367
|
+
log.warn("Host subscription failed", { error: msg });
|
|
368
|
+
onError(new StatementSubscriptionError(msg));
|
|
369
|
+
return { unsubscribe: () => {
|
|
370
|
+
} };
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
async signAndSubmit(statement, credentials) {
|
|
374
|
+
if (credentials.mode !== "host") {
|
|
375
|
+
throw new StatementConnectionError(
|
|
376
|
+
"HostTransport requires host credentials. Use { mode: 'host', accountId } to connect."
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
const hostStatement = sdkStatementToHost(statement);
|
|
380
|
+
const proof = await this.store.createProof(credentials.accountId, hostStatement);
|
|
381
|
+
const signedStatement = {
|
|
382
|
+
...hostStatement,
|
|
383
|
+
proof
|
|
384
|
+
};
|
|
385
|
+
await this.store.submit(signedStatement);
|
|
386
|
+
log.debug("Statement submitted via host");
|
|
387
|
+
}
|
|
388
|
+
destroy() {
|
|
389
|
+
}
|
|
390
|
+
};
|
|
391
|
+
async function createTransport() {
|
|
392
|
+
const { getStatementStore } = await import("@parity/product-sdk-host");
|
|
393
|
+
const store = await getStatementStore();
|
|
394
|
+
if (!store) {
|
|
395
|
+
throw new StatementConnectionError(
|
|
396
|
+
"Host statement store unavailable. Ensure you are running inside a host container (Polkadot Browser / Desktop)."
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
log.info("Using host API statement store transport");
|
|
400
|
+
return new HostTransport(store);
|
|
401
|
+
}
|
|
402
|
+
function hostSignedStatementToSdk(hostStmt) {
|
|
403
|
+
const result = {};
|
|
404
|
+
if (hostStmt.data) result.data = hostStmt.data;
|
|
405
|
+
if (hostStmt.expiry !== void 0) result.expiry = hostStmt.expiry;
|
|
406
|
+
if (hostStmt.topics) {
|
|
407
|
+
result.topics = hostStmt.topics.map(bytesToHex);
|
|
408
|
+
}
|
|
409
|
+
if (hostStmt.channel) {
|
|
410
|
+
result.channel = bytesToHex(hostStmt.channel);
|
|
411
|
+
}
|
|
412
|
+
if (hostStmt.decryptionKey) {
|
|
413
|
+
result.decryptionKey = bytesToHex(hostStmt.decryptionKey);
|
|
414
|
+
}
|
|
415
|
+
if (hostStmt.proof) {
|
|
416
|
+
const tag = hostStmt.proof.tag;
|
|
417
|
+
const value = hostStmt.proof.value;
|
|
418
|
+
const sdkType = tag.charAt(0).toLowerCase() + tag.slice(1);
|
|
419
|
+
if ("signature" in value && "signer" in value) {
|
|
420
|
+
result.proof = {
|
|
421
|
+
type: sdkType,
|
|
422
|
+
value: {
|
|
423
|
+
signature: bytesToHex(value.signature),
|
|
424
|
+
signer: bytesToHex(value.signer)
|
|
425
|
+
}
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
return result;
|
|
430
|
+
}
|
|
431
|
+
function sdkStatementToHost(stmt) {
|
|
432
|
+
const result = {};
|
|
433
|
+
if (stmt.data) result.data = stmt.data;
|
|
434
|
+
if (stmt.expiry !== void 0) result.expiry = stmt.expiry;
|
|
435
|
+
if (stmt.topics) {
|
|
436
|
+
result.topics = stmt.topics.map(hexToBytes);
|
|
437
|
+
}
|
|
438
|
+
if (stmt.channel) {
|
|
439
|
+
result.channel = hexToBytes(stmt.channel);
|
|
440
|
+
}
|
|
441
|
+
if (stmt.decryptionKey) {
|
|
442
|
+
result.decryptionKey = hexToBytes(stmt.decryptionKey);
|
|
443
|
+
}
|
|
444
|
+
return result;
|
|
445
|
+
}
|
|
446
|
+
function extractTopicBytes(filter) {
|
|
447
|
+
if (filter === "any") return [];
|
|
448
|
+
if ("matchAll" in filter) {
|
|
449
|
+
return filter.matchAll.map(hexToBytes);
|
|
450
|
+
}
|
|
451
|
+
if ("matchAny" in filter) {
|
|
452
|
+
return filter.matchAny.map(hexToBytes);
|
|
453
|
+
}
|
|
454
|
+
return [];
|
|
455
|
+
}
|
|
456
|
+
function hexToBytes(hex) {
|
|
457
|
+
const clean = hex.startsWith("0x") ? hex.slice(2) : hex;
|
|
458
|
+
const bytes = new Uint8Array(clean.length / 2);
|
|
459
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
460
|
+
bytes[i] = Number.parseInt(clean.substring(i * 2, i * 2 + 2), 16);
|
|
461
|
+
}
|
|
462
|
+
return bytes;
|
|
463
|
+
}
|
|
464
|
+
function bytesToHex(bytes) {
|
|
465
|
+
let hex = "0x";
|
|
466
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
467
|
+
hex += bytes[i].toString(16).padStart(2, "0");
|
|
468
|
+
}
|
|
469
|
+
return hex;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// src/client.ts
|
|
473
|
+
import { createExpiry } from "@novasamatech/sdk-statement";
|
|
474
|
+
var log2 = createLogger2("statement-store");
|
|
475
|
+
var StatementStoreClient = class {
|
|
476
|
+
config;
|
|
477
|
+
transport = null;
|
|
478
|
+
credentials = null;
|
|
479
|
+
subscription = null;
|
|
480
|
+
callbacks = [];
|
|
481
|
+
connected = false;
|
|
482
|
+
connectPromise = null;
|
|
483
|
+
/** Set by destroy() so doConnect() can abort cleanly if destroy races with an in-flight connect. */
|
|
484
|
+
destroyed = false;
|
|
485
|
+
/**
|
|
486
|
+
* Track seen statements by channel hex to avoid re-delivering the same statement.
|
|
487
|
+
* Maps channel hex (or data hash) to the expiry value.
|
|
488
|
+
*/
|
|
489
|
+
seen = /* @__PURE__ */ new Map();
|
|
490
|
+
/** Monotonic counter to ensure unique sequence numbers even within the same millisecond. */
|
|
491
|
+
sequenceCounter = 0;
|
|
492
|
+
/** Cached hex topic string for the app name, used as the primary subscription topic. */
|
|
493
|
+
appTopicHex;
|
|
494
|
+
constructor(config) {
|
|
495
|
+
this.config = {
|
|
496
|
+
appName: config.appName,
|
|
497
|
+
defaultTtlSeconds: config.defaultTtlSeconds ?? DEFAULT_TTL_SECONDS,
|
|
498
|
+
transport: config.transport
|
|
499
|
+
};
|
|
500
|
+
this.appTopicHex = topicToHex(createTopic(config.appName));
|
|
501
|
+
}
|
|
502
|
+
async connect(arg) {
|
|
503
|
+
if (this.destroyed) {
|
|
504
|
+
throw new StatementConnectionError(
|
|
505
|
+
"Cannot connect: client has been destroyed. Create a new instance."
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
if (this.connected) {
|
|
509
|
+
log2.warn("Already connected, ignoring duplicate connect()");
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
if (this.connectPromise) {
|
|
513
|
+
return this.connectPromise;
|
|
514
|
+
}
|
|
515
|
+
const credentials = "mode" in arg ? arg : { mode: "local", signer: arg };
|
|
516
|
+
this.connectPromise = this.doConnect(credentials).finally(() => {
|
|
517
|
+
this.connectPromise = null;
|
|
518
|
+
});
|
|
519
|
+
return this.connectPromise;
|
|
520
|
+
}
|
|
521
|
+
/* @integration */
|
|
522
|
+
async doConnect(credentials) {
|
|
523
|
+
this.credentials = credentials;
|
|
524
|
+
const transport = this.config.transport ?? await createTransport();
|
|
525
|
+
if (this.destroyed) {
|
|
526
|
+
if (transport !== this.config.transport) {
|
|
527
|
+
transport.destroy();
|
|
528
|
+
}
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
this.transport = transport;
|
|
532
|
+
try {
|
|
533
|
+
log2.info("Connected", { appName: this.config.appName });
|
|
534
|
+
this.startSubscription();
|
|
535
|
+
this.connected = true;
|
|
536
|
+
} catch (error) {
|
|
537
|
+
this.destroy();
|
|
538
|
+
throw error;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Publish typed data to the statement store.
|
|
543
|
+
*
|
|
544
|
+
* @typeParam T - The type of data being published.
|
|
545
|
+
* @param data - The value to publish (must be JSON-serializable, max 512 bytes).
|
|
546
|
+
* @param options - Optional channel, topic2, TTL, and decryption key overrides.
|
|
547
|
+
* @returns `true` if accepted, `false` if rejected or errored.
|
|
548
|
+
* @throws {StatementConnectionError} If not connected.
|
|
549
|
+
* @throws {StatementDataTooLargeError} If the encoded data exceeds 512 bytes.
|
|
550
|
+
*/
|
|
551
|
+
async publish(data, options) {
|
|
552
|
+
if (!this.transport || !this.credentials) {
|
|
553
|
+
throw new StatementConnectionError("Not connected. Call connect() first.");
|
|
554
|
+
}
|
|
555
|
+
const dataBytes = encodeData(data);
|
|
556
|
+
const ttl = options?.ttlSeconds ?? this.config.defaultTtlSeconds;
|
|
557
|
+
const expirationTimestamp = Math.floor(Date.now() / 1e3) + ttl;
|
|
558
|
+
const sequenceNumber = (Date.now() + this.sequenceCounter++) % 4294967295;
|
|
559
|
+
const expiry = createExpiry(expirationTimestamp, sequenceNumber);
|
|
560
|
+
const topics = [this.appTopicHex];
|
|
561
|
+
if (options?.topic2) {
|
|
562
|
+
topics.push(topicToHex(createTopic(options.topic2)));
|
|
563
|
+
}
|
|
564
|
+
const statement = {
|
|
565
|
+
expiry,
|
|
566
|
+
topics,
|
|
567
|
+
channel: options?.channel ? topicToHex(createChannel(options.channel)) : void 0,
|
|
568
|
+
decryptionKey: options?.decryptionKey ? topicToHex(options.decryptionKey) : void 0,
|
|
569
|
+
data: dataBytes
|
|
570
|
+
};
|
|
571
|
+
try {
|
|
572
|
+
await this.transport.signAndSubmit(statement, this.credentials);
|
|
573
|
+
log2.debug("Published", { channel: options?.channel });
|
|
574
|
+
return true;
|
|
575
|
+
} catch (error) {
|
|
576
|
+
log2.error("Publish failed", {
|
|
577
|
+
error: error instanceof Error ? error.message : String(error)
|
|
578
|
+
});
|
|
579
|
+
return false;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* Subscribe to incoming statements on this application's topic.
|
|
584
|
+
*
|
|
585
|
+
* @typeParam T - The expected data type (decoded from JSON).
|
|
586
|
+
* @param callback - Called for each new statement.
|
|
587
|
+
* @param options - Optional secondary topic filter.
|
|
588
|
+
* @returns A handle to unsubscribe.
|
|
589
|
+
*/
|
|
590
|
+
subscribe(callback, options) {
|
|
591
|
+
const topic2Hex = options?.topic2 ? topicToHex(createTopic(options.topic2)) : void 0;
|
|
592
|
+
const wrappedCallback = (statement) => {
|
|
593
|
+
if (topic2Hex) {
|
|
594
|
+
if (!statement.topics[1] || statement.topics[1] !== topic2Hex) return;
|
|
595
|
+
}
|
|
596
|
+
callback(statement);
|
|
597
|
+
};
|
|
598
|
+
this.callbacks.push(wrappedCallback);
|
|
599
|
+
return {
|
|
600
|
+
unsubscribe: () => {
|
|
601
|
+
const index = this.callbacks.indexOf(wrappedCallback);
|
|
602
|
+
if (index >= 0) {
|
|
603
|
+
this.callbacks.splice(index, 1);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
/** Whether the client is connected and ready to publish/subscribe. */
|
|
609
|
+
isConnected() {
|
|
610
|
+
return this.connected;
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* Get the signer's public key as a hex string (with 0x prefix).
|
|
614
|
+
*
|
|
615
|
+
* @returns The hex-encoded public key, or empty string if not connected or in host mode.
|
|
616
|
+
*/
|
|
617
|
+
getPublicKeyHex() {
|
|
618
|
+
if (this.credentials?.mode === "local") {
|
|
619
|
+
return topicToHex(this.credentials.signer.publicKey);
|
|
620
|
+
}
|
|
621
|
+
return "";
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* Destroy the client, unsubscribing and closing the transport.
|
|
625
|
+
*
|
|
626
|
+
* Safe to call multiple times. After destruction, the client cannot be reused.
|
|
627
|
+
*/
|
|
628
|
+
destroy() {
|
|
629
|
+
this.destroyed = true;
|
|
630
|
+
if (this.subscription) {
|
|
631
|
+
this.subscription.unsubscribe();
|
|
632
|
+
this.subscription = null;
|
|
633
|
+
}
|
|
634
|
+
if (this.transport) {
|
|
635
|
+
this.transport.destroy();
|
|
636
|
+
this.transport = null;
|
|
637
|
+
}
|
|
638
|
+
this.credentials = null;
|
|
639
|
+
this.connected = false;
|
|
640
|
+
this.connectPromise = null;
|
|
641
|
+
this.callbacks = [];
|
|
642
|
+
this.seen.clear();
|
|
643
|
+
log2.info("Destroyed");
|
|
644
|
+
}
|
|
645
|
+
// ========================================================================
|
|
646
|
+
// Internal
|
|
647
|
+
// ========================================================================
|
|
648
|
+
/* @integration */
|
|
649
|
+
startSubscription() {
|
|
650
|
+
if (!this.transport) return;
|
|
651
|
+
const filter = this.buildFilter();
|
|
652
|
+
this.subscription = this.transport.subscribe(
|
|
653
|
+
filter,
|
|
654
|
+
(statements) => {
|
|
655
|
+
for (const stmt of statements) {
|
|
656
|
+
this.handleStatementReceived(stmt);
|
|
657
|
+
}
|
|
658
|
+
},
|
|
659
|
+
(error) => {
|
|
660
|
+
log2.warn("Subscription error", {
|
|
661
|
+
error: error.message
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
/** Remove entries from the seen map whose expiry timestamp is in the past. */
|
|
667
|
+
pruneSeenMap() {
|
|
668
|
+
const nowSeconds = BigInt(Math.floor(Date.now() / 1e3));
|
|
669
|
+
for (const [key, expiry] of this.seen) {
|
|
670
|
+
const expiryTimestamp = expiry >> 32n;
|
|
671
|
+
if (expiryTimestamp > 0n && expiryTimestamp < nowSeconds) {
|
|
672
|
+
this.seen.delete(key);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* Process a received statement, dedup, parse, and deliver to callbacks.
|
|
678
|
+
* Returns true if the statement was new and delivered.
|
|
679
|
+
*/
|
|
680
|
+
handleStatementReceived(stmt) {
|
|
681
|
+
this.pruneSeenMap();
|
|
682
|
+
const parsed = this.parseStatement(stmt);
|
|
683
|
+
if (!parsed) return false;
|
|
684
|
+
const dedupeKey = parsed.channelHex ?? (parsed.raw.data ? toHex(blake2b2562(parsed.raw.data)) : "");
|
|
685
|
+
const existingExpiry = this.seen.get(dedupeKey);
|
|
686
|
+
const newExpiry = parsed.expiry ?? 0n;
|
|
687
|
+
if (existingExpiry !== void 0 && newExpiry <= existingExpiry) {
|
|
688
|
+
return false;
|
|
689
|
+
}
|
|
690
|
+
this.seen.set(dedupeKey, newExpiry);
|
|
691
|
+
for (const callback of [...this.callbacks]) {
|
|
692
|
+
try {
|
|
693
|
+
callback(parsed);
|
|
694
|
+
} catch (error) {
|
|
695
|
+
log2.error("Callback error", {
|
|
696
|
+
error: error instanceof Error ? error.message : String(error)
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
return true;
|
|
701
|
+
}
|
|
702
|
+
parseStatement(stmt) {
|
|
703
|
+
try {
|
|
704
|
+
if (!stmt.data) return null;
|
|
705
|
+
const data = decodeData(stmt.data);
|
|
706
|
+
let signerHex;
|
|
707
|
+
if (stmt.proof) {
|
|
708
|
+
const proofValue = stmt.proof.value;
|
|
709
|
+
if ("signer" in proofValue && typeof proofValue.signer === "string") {
|
|
710
|
+
signerHex = proofValue.signer;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
return {
|
|
714
|
+
data,
|
|
715
|
+
signerHex,
|
|
716
|
+
channelHex: stmt.channel,
|
|
717
|
+
topics: stmt.topics ?? [],
|
|
718
|
+
expiry: stmt.expiry,
|
|
719
|
+
raw: stmt
|
|
720
|
+
};
|
|
721
|
+
} catch {
|
|
722
|
+
return null;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
/** Build an SdkTopicFilter for the app's primary topic. */
|
|
726
|
+
buildFilter(topic2Name) {
|
|
727
|
+
const topics = [createTopic(this.config.appName)];
|
|
728
|
+
if (topic2Name) topics.push(createTopic(topic2Name));
|
|
729
|
+
return serializeTopicFilter({ matchAll: topics });
|
|
730
|
+
}
|
|
731
|
+
};
|
|
732
|
+
|
|
733
|
+
// src/channels.ts
|
|
734
|
+
import { createLogger as createLogger3 } from "@parity/product-sdk-logger";
|
|
735
|
+
var log3 = createLogger3("statement-store:channels");
|
|
736
|
+
var ChannelStore = class {
|
|
737
|
+
client;
|
|
738
|
+
topic2;
|
|
739
|
+
values = /* @__PURE__ */ new Map();
|
|
740
|
+
/** Maps human-readable channel names to their hex hash keys, for consistent lookup. */
|
|
741
|
+
nameToHash = /* @__PURE__ */ new Map();
|
|
742
|
+
changeCallbacks = [];
|
|
743
|
+
subscription = null;
|
|
744
|
+
/**
|
|
745
|
+
* @param client - The connected {@link StatementStoreClient} to use.
|
|
746
|
+
* @param options - Optional secondary topic for scoping channels.
|
|
747
|
+
*/
|
|
748
|
+
constructor(client, options) {
|
|
749
|
+
this.client = client;
|
|
750
|
+
this.topic2 = options?.topic2;
|
|
751
|
+
this.subscription = this.client.subscribe(
|
|
752
|
+
(statement) => this.handleStatement(statement),
|
|
753
|
+
{ topic2: this.topic2 }
|
|
754
|
+
);
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Write a value to a named channel.
|
|
758
|
+
*
|
|
759
|
+
* If the value doesn't include a `timestamp`, one is added automatically
|
|
760
|
+
* using `Date.now()`. The value is published to the statement store
|
|
761
|
+
* with the channel name as the statement channel.
|
|
762
|
+
*
|
|
763
|
+
* @param channelName - The channel name (e.g., "presence/peer-abc").
|
|
764
|
+
* @param value - The value to write.
|
|
765
|
+
* @returns `true` if the statement was accepted by the network.
|
|
766
|
+
*/
|
|
767
|
+
async write(channelName, value) {
|
|
768
|
+
const timestamped = value.timestamp != null ? value : { ...value, timestamp: Date.now() };
|
|
769
|
+
const options = {
|
|
770
|
+
channel: channelName,
|
|
771
|
+
topic2: this.topic2
|
|
772
|
+
};
|
|
773
|
+
const success = await this.client.publish(timestamped, options);
|
|
774
|
+
if (success) {
|
|
775
|
+
const hashKey = topicToHex(createChannel(channelName));
|
|
776
|
+
this.nameToHash.set(channelName, hashKey);
|
|
777
|
+
this.updateChannel(hashKey, timestamped);
|
|
778
|
+
}
|
|
779
|
+
return success;
|
|
780
|
+
}
|
|
781
|
+
/**
|
|
782
|
+
* Read the latest value for a channel by its human-readable name.
|
|
783
|
+
*
|
|
784
|
+
* Looks up the channel hash from the name, then retrieves the value.
|
|
785
|
+
* Also checks the hash directly for values received from the network
|
|
786
|
+
* before any local write established the name mapping.
|
|
787
|
+
*
|
|
788
|
+
* @param channelName - The channel name (e.g., "presence/peer-abc").
|
|
789
|
+
* @returns The latest value, or `undefined` if no value has been received.
|
|
790
|
+
*/
|
|
791
|
+
read(channelName) {
|
|
792
|
+
const hashKey = this.nameToHash.get(channelName) ?? topicToHex(createChannel(channelName));
|
|
793
|
+
return this.values.get(hashKey);
|
|
794
|
+
}
|
|
795
|
+
/**
|
|
796
|
+
* Read all channel values as a read-only map.
|
|
797
|
+
*
|
|
798
|
+
* Keys are hex-encoded channel hashes. Use {@link read} for
|
|
799
|
+
* lookup by human-readable name.
|
|
800
|
+
*
|
|
801
|
+
* @returns A map of channel hash to latest value.
|
|
802
|
+
*/
|
|
803
|
+
readAll() {
|
|
804
|
+
return this.values;
|
|
805
|
+
}
|
|
806
|
+
/**
|
|
807
|
+
* Get the number of channels currently tracked.
|
|
808
|
+
*/
|
|
809
|
+
get size() {
|
|
810
|
+
return this.values.size;
|
|
811
|
+
}
|
|
812
|
+
/**
|
|
813
|
+
* Subscribe to channel value changes.
|
|
814
|
+
*
|
|
815
|
+
* The callback is invoked whenever a channel value is updated
|
|
816
|
+
* (either from the network or from a local write).
|
|
817
|
+
*
|
|
818
|
+
* @param callback - Called with the channel key (hex hash for network-received, hex hash for local writes), new value, and previous value.
|
|
819
|
+
* @returns A handle to unsubscribe.
|
|
820
|
+
*/
|
|
821
|
+
onChange(callback) {
|
|
822
|
+
this.changeCallbacks.push(callback);
|
|
823
|
+
return {
|
|
824
|
+
unsubscribe: () => {
|
|
825
|
+
const index = this.changeCallbacks.indexOf(callback);
|
|
826
|
+
if (index >= 0) {
|
|
827
|
+
this.changeCallbacks.splice(index, 1);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
/**
|
|
833
|
+
* Destroy the channel store and clean up subscriptions.
|
|
834
|
+
*
|
|
835
|
+
* Does not destroy the underlying client.
|
|
836
|
+
*/
|
|
837
|
+
destroy() {
|
|
838
|
+
if (this.subscription) {
|
|
839
|
+
this.subscription.unsubscribe();
|
|
840
|
+
this.subscription = null;
|
|
841
|
+
}
|
|
842
|
+
this.values.clear();
|
|
843
|
+
this.nameToHash.clear();
|
|
844
|
+
this.changeCallbacks.length = 0;
|
|
845
|
+
log3.debug("ChannelStore destroyed");
|
|
846
|
+
}
|
|
847
|
+
// ========================================================================
|
|
848
|
+
// Internal
|
|
849
|
+
// ========================================================================
|
|
850
|
+
handleStatement(statement) {
|
|
851
|
+
if (!statement.channelHex) return;
|
|
852
|
+
this.updateChannel(statement.channelHex, statement.data);
|
|
853
|
+
}
|
|
854
|
+
updateChannel(channelName, value) {
|
|
855
|
+
const existing = this.values.get(channelName);
|
|
856
|
+
if (existing) {
|
|
857
|
+
const existingTs = existing.timestamp ?? 0;
|
|
858
|
+
const newTs = value.timestamp ?? 0;
|
|
859
|
+
if (newTs <= existingTs) {
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
const previous = existing;
|
|
864
|
+
this.values.set(channelName, value);
|
|
865
|
+
for (const callback of [...this.changeCallbacks]) {
|
|
866
|
+
try {
|
|
867
|
+
callback(channelName, value, previous);
|
|
868
|
+
} catch (error) {
|
|
869
|
+
log3.error("onChange callback error", {
|
|
870
|
+
error: error instanceof Error ? error.message : String(error)
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
};
|
|
876
|
+
if (void 0) {
|
|
877
|
+
let createMockClient = function() {
|
|
878
|
+
const subscribeCallbacks = [];
|
|
879
|
+
return {
|
|
880
|
+
subscribe: vi.fn(
|
|
881
|
+
(callback, _options) => {
|
|
882
|
+
subscribeCallbacks.push(callback);
|
|
883
|
+
return {
|
|
884
|
+
unsubscribe: () => {
|
|
885
|
+
const idx = subscribeCallbacks.indexOf(
|
|
886
|
+
callback
|
|
887
|
+
);
|
|
888
|
+
if (idx >= 0) subscribeCallbacks.splice(idx, 1);
|
|
889
|
+
}
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
),
|
|
893
|
+
publish: vi.fn(async () => true),
|
|
894
|
+
// Helper to simulate incoming statement
|
|
895
|
+
_simulateStatement(stmt) {
|
|
896
|
+
for (const cb of subscribeCallbacks) {
|
|
897
|
+
cb(stmt);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
};
|
|
901
|
+
};
|
|
902
|
+
createMockClient2 = createMockClient;
|
|
903
|
+
const { describe, test, expect, vi, beforeEach } = void 0;
|
|
904
|
+
const { configure } = await null;
|
|
905
|
+
beforeEach(() => {
|
|
906
|
+
configure({ handler: () => {
|
|
907
|
+
} });
|
|
908
|
+
});
|
|
909
|
+
describe("ChannelStore", () => {
|
|
910
|
+
test("write publishes and updates local state", async () => {
|
|
911
|
+
const mockClient = createMockClient();
|
|
912
|
+
const store = new ChannelStore(
|
|
913
|
+
mockClient
|
|
914
|
+
);
|
|
915
|
+
await store.write("presence/abc", { type: "presence", timestamp: 1e3 });
|
|
916
|
+
expect(mockClient.publish).toHaveBeenCalledOnce();
|
|
917
|
+
expect(store.read("presence/abc")).toEqual({ type: "presence", timestamp: 1e3 });
|
|
918
|
+
});
|
|
919
|
+
test("write adds timestamp if missing", async () => {
|
|
920
|
+
const mockClient = createMockClient();
|
|
921
|
+
const store = new ChannelStore(
|
|
922
|
+
mockClient
|
|
923
|
+
);
|
|
924
|
+
const before = Date.now();
|
|
925
|
+
await store.write("ch", { type: "test" });
|
|
926
|
+
const calls = mockClient.publish.mock.calls;
|
|
927
|
+
const published = calls[0][0];
|
|
928
|
+
expect(published.timestamp).toBeGreaterThanOrEqual(before);
|
|
929
|
+
});
|
|
930
|
+
test("write returns false on publish failure", async () => {
|
|
931
|
+
const mockClient = createMockClient();
|
|
932
|
+
mockClient.publish = vi.fn(async () => false);
|
|
933
|
+
const store = new ChannelStore(
|
|
934
|
+
mockClient
|
|
935
|
+
);
|
|
936
|
+
const result = await store.write("ch", { type: "test", timestamp: 1 });
|
|
937
|
+
expect(result).toBe(false);
|
|
938
|
+
expect(store.read("ch")).toBeUndefined();
|
|
939
|
+
});
|
|
940
|
+
test("read returns undefined for unknown channel", () => {
|
|
941
|
+
const mockClient = createMockClient();
|
|
942
|
+
const store = new ChannelStore(
|
|
943
|
+
mockClient
|
|
944
|
+
);
|
|
945
|
+
expect(store.read("unknown")).toBeUndefined();
|
|
946
|
+
});
|
|
947
|
+
test("readAll returns all values keyed by hash", async () => {
|
|
948
|
+
const mockClient = createMockClient();
|
|
949
|
+
const store = new ChannelStore(
|
|
950
|
+
mockClient
|
|
951
|
+
);
|
|
952
|
+
await store.write("a", { type: "a", timestamp: 1 });
|
|
953
|
+
await store.write("b", { type: "b", timestamp: 2 });
|
|
954
|
+
const all = store.readAll();
|
|
955
|
+
expect(all.size).toBe(2);
|
|
956
|
+
});
|
|
957
|
+
test("size returns channel count", async () => {
|
|
958
|
+
const mockClient = createMockClient();
|
|
959
|
+
const store = new ChannelStore(
|
|
960
|
+
mockClient
|
|
961
|
+
);
|
|
962
|
+
expect(store.size).toBe(0);
|
|
963
|
+
await store.write("a", { type: "a", timestamp: 1 });
|
|
964
|
+
expect(store.size).toBe(1);
|
|
965
|
+
await store.write("b", { type: "b", timestamp: 2 });
|
|
966
|
+
expect(store.size).toBe(2);
|
|
967
|
+
});
|
|
968
|
+
test("last-write-wins: newer timestamp replaces older", async () => {
|
|
969
|
+
const mockClient = createMockClient();
|
|
970
|
+
const store = new ChannelStore(
|
|
971
|
+
mockClient
|
|
972
|
+
);
|
|
973
|
+
await store.write("ch", { type: "old", timestamp: 100 });
|
|
974
|
+
await store.write("ch", { type: "new", timestamp: 200 });
|
|
975
|
+
expect(store.read("ch")?.type).toBe("new");
|
|
976
|
+
});
|
|
977
|
+
test("last-write-wins: older timestamp is ignored", async () => {
|
|
978
|
+
const mockClient = createMockClient();
|
|
979
|
+
const store = new ChannelStore(
|
|
980
|
+
mockClient
|
|
981
|
+
);
|
|
982
|
+
await store.write("ch", { type: "new", timestamp: 200 });
|
|
983
|
+
await store.write("ch", { type: "old", timestamp: 100 });
|
|
984
|
+
expect(store.read("ch")?.type).toBe("new");
|
|
985
|
+
});
|
|
986
|
+
test("onChange fires on update with previous value", async () => {
|
|
987
|
+
const mockClient = createMockClient();
|
|
988
|
+
const store = new ChannelStore(
|
|
989
|
+
mockClient
|
|
990
|
+
);
|
|
991
|
+
const onChange = vi.fn();
|
|
992
|
+
store.onChange(onChange);
|
|
993
|
+
await store.write("ch", { type: "first", timestamp: 1 });
|
|
994
|
+
expect(onChange).toHaveBeenCalledOnce();
|
|
995
|
+
expect(onChange.mock.calls[0][1]).toEqual({ type: "first", timestamp: 1 });
|
|
996
|
+
expect(onChange.mock.calls[0][2]).toBeUndefined();
|
|
997
|
+
await store.write("ch", { type: "second", timestamp: 2 });
|
|
998
|
+
expect(onChange).toHaveBeenCalledTimes(2);
|
|
999
|
+
expect(onChange.mock.calls[1][1]).toEqual({ type: "second", timestamp: 2 });
|
|
1000
|
+
expect(onChange.mock.calls[1][2]).toEqual({ type: "first", timestamp: 1 });
|
|
1001
|
+
});
|
|
1002
|
+
test("onChange unsubscribe stops notifications", async () => {
|
|
1003
|
+
const mockClient = createMockClient();
|
|
1004
|
+
const store = new ChannelStore(
|
|
1005
|
+
mockClient
|
|
1006
|
+
);
|
|
1007
|
+
const onChange = vi.fn();
|
|
1008
|
+
const sub = store.onChange(onChange);
|
|
1009
|
+
await store.write("ch", { type: "first", timestamp: 1 });
|
|
1010
|
+
expect(onChange).toHaveBeenCalledOnce();
|
|
1011
|
+
sub.unsubscribe();
|
|
1012
|
+
await store.write("ch", { type: "second", timestamp: 2 });
|
|
1013
|
+
expect(onChange).toHaveBeenCalledOnce();
|
|
1014
|
+
});
|
|
1015
|
+
test("destroy cleans up", async () => {
|
|
1016
|
+
const mockClient = createMockClient();
|
|
1017
|
+
const store = new ChannelStore(
|
|
1018
|
+
mockClient
|
|
1019
|
+
);
|
|
1020
|
+
await store.write("ch", { type: "test", timestamp: 1 });
|
|
1021
|
+
store.destroy();
|
|
1022
|
+
expect(store.readAll().size).toBe(0);
|
|
1023
|
+
});
|
|
1024
|
+
test("destroy is safe when subscription is already null", () => {
|
|
1025
|
+
const mockClient = createMockClient();
|
|
1026
|
+
const store = new ChannelStore(
|
|
1027
|
+
mockClient
|
|
1028
|
+
);
|
|
1029
|
+
store.destroy();
|
|
1030
|
+
expect(() => store.destroy()).not.toThrow();
|
|
1031
|
+
});
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
1034
|
+
var createMockClient2;
|
|
1035
|
+
export {
|
|
1036
|
+
ChannelStore,
|
|
1037
|
+
DEFAULT_TTL_SECONDS,
|
|
1038
|
+
MAX_STATEMENT_SIZE,
|
|
1039
|
+
MAX_USER_TOTAL,
|
|
1040
|
+
StatementConnectionError,
|
|
1041
|
+
StatementDataTooLargeError,
|
|
1042
|
+
StatementEncodingError,
|
|
1043
|
+
StatementStoreClient,
|
|
1044
|
+
StatementStoreError,
|
|
1045
|
+
StatementSubmitError,
|
|
1046
|
+
StatementSubscriptionError,
|
|
1047
|
+
createChannel,
|
|
1048
|
+
createTopic,
|
|
1049
|
+
createTransport,
|
|
1050
|
+
decodeData,
|
|
1051
|
+
encodeData,
|
|
1052
|
+
fromHex,
|
|
1053
|
+
serializeTopicFilter,
|
|
1054
|
+
toHex,
|
|
1055
|
+
topicToHex,
|
|
1056
|
+
topicsEqual
|
|
1057
|
+
};
|
|
1058
|
+
//# sourceMappingURL=index.js.map
|