@polkadot-apps/statement-store 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/dist/channels.d.ts +117 -0
- package/dist/channels.d.ts.map +1 -0
- package/dist/channels.js +422 -0
- package/dist/channels.js.map +1 -0
- package/dist/client.d.ts +133 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +688 -0
- package/dist/client.js.map +1 -0
- package/dist/codec.d.ts +74 -0
- package/dist/codec.d.ts.map +1 -0
- package/dist/codec.js +564 -0
- package/dist/codec.js.map +1 -0
- package/dist/errors.d.ts +60 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +152 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/topics.d.ts +60 -0
- package/dist/topics.d.ts.map +1 -0
- package/dist/topics.js +187 -0
- package/dist/topics.js.map +1 -0
- package/dist/transport.d.ts +94 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +590 -0
- package/dist/transport.js.map +1 -0
- package/dist/types.d.ts +276 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +12 -0
- package/dist/types.js.map +1 -0
- package/package.json +33 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,688 @@
|
|
|
1
|
+
import { createLogger } from "@polkadot-apps/logger";
|
|
2
|
+
import { decodeData, decodeStatement, createSignatureMaterial, encodeData, encodeStatement, toHex, } from "./codec.js";
|
|
3
|
+
import { StatementConnectionError } from "./errors.js";
|
|
4
|
+
import { createChannel, createTopic, topicToHex } from "./topics.js";
|
|
5
|
+
import { createTransport } from "./transport.js";
|
|
6
|
+
import { DEFAULT_POLL_INTERVAL_MS, DEFAULT_TTL_SECONDS } from "./types.js";
|
|
7
|
+
const log = createLogger("statement-store");
|
|
8
|
+
/**
|
|
9
|
+
* High-level client for the Polkadot Statement Store.
|
|
10
|
+
*
|
|
11
|
+
* Provides a simple publish/subscribe API over the ephemeral statement store,
|
|
12
|
+
* handling SCALE encoding, Sr25519 signing, topic management, and resilient
|
|
13
|
+
* delivery (subscription + polling fallback).
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```ts
|
|
17
|
+
* import { StatementStoreClient } from "@polkadot-apps/statement-store";
|
|
18
|
+
*
|
|
19
|
+
* const client = new StatementStoreClient({ appName: "my-app" });
|
|
20
|
+
* await client.connect(signer);
|
|
21
|
+
*
|
|
22
|
+
* // Publish
|
|
23
|
+
* await client.publish({ type: "presence", peerId: "abc" }, {
|
|
24
|
+
* channel: "presence/abc",
|
|
25
|
+
* topic2: "room-123",
|
|
26
|
+
* });
|
|
27
|
+
*
|
|
28
|
+
* // Subscribe
|
|
29
|
+
* client.subscribe<{ type: string }>(statement => {
|
|
30
|
+
* console.log(statement.data.type);
|
|
31
|
+
* });
|
|
32
|
+
*
|
|
33
|
+
* // Cleanup
|
|
34
|
+
* client.destroy();
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
export class StatementStoreClient {
|
|
38
|
+
config;
|
|
39
|
+
transport = null;
|
|
40
|
+
signer = null;
|
|
41
|
+
subscription = null;
|
|
42
|
+
pollTimer = null;
|
|
43
|
+
callbacks = [];
|
|
44
|
+
connected = false;
|
|
45
|
+
connectPromise = null;
|
|
46
|
+
/**
|
|
47
|
+
* Track seen statements by channel hex to avoid re-delivering the same statement.
|
|
48
|
+
* Maps channel hex (or statement data hash) to the expiry value.
|
|
49
|
+
*/
|
|
50
|
+
seen = new Map();
|
|
51
|
+
/** Monotonic counter to ensure unique sequence numbers even within the same millisecond. */
|
|
52
|
+
sequenceCounter = 0;
|
|
53
|
+
/** Cached blake2b hash of the appName, used as topic1. */
|
|
54
|
+
appTopic;
|
|
55
|
+
constructor(config) {
|
|
56
|
+
this.config = {
|
|
57
|
+
appName: config.appName,
|
|
58
|
+
endpoint: config.endpoint,
|
|
59
|
+
pollIntervalMs: config.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS,
|
|
60
|
+
defaultTtlSeconds: config.defaultTtlSeconds ?? DEFAULT_TTL_SECONDS,
|
|
61
|
+
enablePolling: config.enablePolling ?? true,
|
|
62
|
+
};
|
|
63
|
+
this.appTopic = createTopic(config.appName);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Connect to the statement store and start receiving statements.
|
|
67
|
+
*
|
|
68
|
+
* Establishes the transport connection, starts a real-time subscription
|
|
69
|
+
* on the application's topic, fetches existing statements, and begins
|
|
70
|
+
* the polling fallback (if enabled).
|
|
71
|
+
*
|
|
72
|
+
* @param signer - The Sr25519 signer used to sign published statements.
|
|
73
|
+
* @throws {StatementConnectionError} If the transport cannot be established.
|
|
74
|
+
*/
|
|
75
|
+
async connect(signer) {
|
|
76
|
+
if (this.connected) {
|
|
77
|
+
log.warn("Already connected, ignoring duplicate connect()");
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
// Deduplicate concurrent connect() calls — return the in-flight promise
|
|
81
|
+
if (this.connectPromise) {
|
|
82
|
+
return this.connectPromise;
|
|
83
|
+
}
|
|
84
|
+
this.connectPromise = this.doConnect(signer).finally(() => {
|
|
85
|
+
this.connectPromise = null;
|
|
86
|
+
});
|
|
87
|
+
return this.connectPromise;
|
|
88
|
+
}
|
|
89
|
+
/* @integration */
|
|
90
|
+
async doConnect(signer) {
|
|
91
|
+
this.signer = signer;
|
|
92
|
+
this.transport = await createTransport({ endpoint: this.config.endpoint });
|
|
93
|
+
try {
|
|
94
|
+
log.info("Connected", {
|
|
95
|
+
appName: this.config.appName,
|
|
96
|
+
publicKey: topicToHex(signer.publicKey),
|
|
97
|
+
});
|
|
98
|
+
// Start subscription for real-time updates
|
|
99
|
+
this.startSubscription();
|
|
100
|
+
// Fetch pre-existing statements (subscription only delivers new ones)
|
|
101
|
+
await this.poll();
|
|
102
|
+
// Start polling fallback
|
|
103
|
+
if (this.config.enablePolling && this.config.pollIntervalMs > 0) {
|
|
104
|
+
this.startPolling();
|
|
105
|
+
}
|
|
106
|
+
this.connected = true;
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
// Clean up partial state on failure so connect() can be retried
|
|
110
|
+
this.destroy();
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Publish typed data to the statement store.
|
|
116
|
+
*
|
|
117
|
+
* Encodes the data as JSON, builds a SCALE-encoded statement with the
|
|
118
|
+
* configured topics and TTL, signs it with Sr25519, and submits it
|
|
119
|
+
* to the network.
|
|
120
|
+
*
|
|
121
|
+
* @typeParam T - The type of data being published.
|
|
122
|
+
* @param data - The value to publish (must be JSON-serializable, max 512 bytes).
|
|
123
|
+
* @param options - Optional channel, topic2, TTL, and decryption key overrides.
|
|
124
|
+
* @returns `true` if accepted ("new" or "known"), `false` if rejected.
|
|
125
|
+
* @throws {StatementConnectionError} If not connected.
|
|
126
|
+
* @throws {StatementDataTooLargeError} If the encoded data exceeds 512 bytes.
|
|
127
|
+
*/
|
|
128
|
+
async publish(data, options) {
|
|
129
|
+
if (!this.transport || !this.signer) {
|
|
130
|
+
throw new StatementConnectionError("Not connected. Call connect() first.");
|
|
131
|
+
}
|
|
132
|
+
const dataBytes = encodeData(data);
|
|
133
|
+
const ttl = options?.ttlSeconds ?? this.config.defaultTtlSeconds;
|
|
134
|
+
const expirationTimestamp = Math.floor(Date.now() / 1000) + ttl;
|
|
135
|
+
const sequenceNumber = (Date.now() + this.sequenceCounter++) % 0xffffffff;
|
|
136
|
+
const fields = {
|
|
137
|
+
expirationTimestamp,
|
|
138
|
+
sequenceNumber,
|
|
139
|
+
topic1: this.appTopic,
|
|
140
|
+
topic2: options?.topic2 ? createTopic(options.topic2) : undefined,
|
|
141
|
+
channel: options?.channel ? createChannel(options.channel) : undefined,
|
|
142
|
+
decryptionKey: options?.decryptionKey,
|
|
143
|
+
data: dataBytes,
|
|
144
|
+
};
|
|
145
|
+
// Sign
|
|
146
|
+
const signatureMaterial = createSignatureMaterial(fields);
|
|
147
|
+
const signature = await Promise.resolve(this.signer.sign(signatureMaterial));
|
|
148
|
+
// Encode
|
|
149
|
+
const encoded = encodeStatement(fields, this.signer.publicKey, signature);
|
|
150
|
+
const hex = toHex(encoded);
|
|
151
|
+
// Submit
|
|
152
|
+
try {
|
|
153
|
+
const status = await this.transport.submit(hex);
|
|
154
|
+
if (status === "new" || status === "known") {
|
|
155
|
+
log.debug("Published", {
|
|
156
|
+
channel: options?.channel,
|
|
157
|
+
status,
|
|
158
|
+
});
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
log.warn("Publish rejected", { status });
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
log.error("Publish failed", {
|
|
166
|
+
error: error instanceof Error ? error.message : String(error),
|
|
167
|
+
});
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Subscribe to incoming statements on this application's topic.
|
|
173
|
+
*
|
|
174
|
+
* Receives both real-time subscription events and polling results.
|
|
175
|
+
* Statements are deduplicated by channel + expiry to prevent double delivery.
|
|
176
|
+
*
|
|
177
|
+
* @typeParam T - The expected data type (decoded from JSON).
|
|
178
|
+
* @param callback - Called for each new statement.
|
|
179
|
+
* @param options - Optional secondary topic filter.
|
|
180
|
+
* @returns A handle to unsubscribe.
|
|
181
|
+
*/
|
|
182
|
+
subscribe(callback, options) {
|
|
183
|
+
const topic2Hash = options?.topic2 ? createTopic(options.topic2) : undefined;
|
|
184
|
+
const topic2Hex = topic2Hash ? topicToHex(topic2Hash) : undefined;
|
|
185
|
+
const wrappedCallback = (statement) => {
|
|
186
|
+
// Filter by topic2 if specified
|
|
187
|
+
if (topic2Hex) {
|
|
188
|
+
if (!statement.topic2)
|
|
189
|
+
return;
|
|
190
|
+
const statementTopic2Hex = topicToHex(statement.topic2);
|
|
191
|
+
if (statementTopic2Hex !== topic2Hex)
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
callback(statement);
|
|
195
|
+
};
|
|
196
|
+
this.callbacks.push(wrappedCallback);
|
|
197
|
+
return {
|
|
198
|
+
unsubscribe: () => {
|
|
199
|
+
const index = this.callbacks.indexOf(wrappedCallback);
|
|
200
|
+
if (index >= 0) {
|
|
201
|
+
this.callbacks.splice(index, 1);
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Query existing statements from the store.
|
|
208
|
+
*
|
|
209
|
+
* Fetches statements that were published before the subscription started.
|
|
210
|
+
* Useful for catching up on state (e.g., existing presence announcements).
|
|
211
|
+
*
|
|
212
|
+
* @typeParam T - The expected data type.
|
|
213
|
+
* @param options - Optional secondary topic filter.
|
|
214
|
+
* @returns Array of received statements.
|
|
215
|
+
*/
|
|
216
|
+
async query(options) {
|
|
217
|
+
if (!this.transport) {
|
|
218
|
+
throw new StatementConnectionError("Not connected. Call connect() first.");
|
|
219
|
+
}
|
|
220
|
+
const topics = [this.appTopic];
|
|
221
|
+
if (options?.topic2) {
|
|
222
|
+
topics.push(createTopic(options.topic2));
|
|
223
|
+
}
|
|
224
|
+
// Use explicit decryptionKey if provided, else derive from topic2
|
|
225
|
+
const decryptionKey = options?.decryptionKey
|
|
226
|
+
? topicToHex(options.decryptionKey)
|
|
227
|
+
: options?.topic2
|
|
228
|
+
? topicToHex(createTopic(options.topic2))
|
|
229
|
+
: undefined;
|
|
230
|
+
const hexStatements = await this.transport.query(topics, decryptionKey);
|
|
231
|
+
const results = [];
|
|
232
|
+
for (const hex of hexStatements) {
|
|
233
|
+
const parsed = this.parseStatement(hex);
|
|
234
|
+
if (parsed) {
|
|
235
|
+
results.push(parsed);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return results;
|
|
239
|
+
}
|
|
240
|
+
/** Whether the client is connected and ready to publish/subscribe. */
|
|
241
|
+
isConnected() {
|
|
242
|
+
return this.connected;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Get the signer's public key as a hex string (with 0x prefix).
|
|
246
|
+
*
|
|
247
|
+
* @returns The hex-encoded public key, or empty string if not connected.
|
|
248
|
+
*/
|
|
249
|
+
getPublicKeyHex() {
|
|
250
|
+
return this.signer ? topicToHex(this.signer.publicKey) : "";
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Destroy the client, stopping polling, unsubscribing, and closing the transport.
|
|
254
|
+
*
|
|
255
|
+
* Safe to call multiple times. After destruction, the client cannot be reused.
|
|
256
|
+
*/
|
|
257
|
+
destroy() {
|
|
258
|
+
this.stopPolling();
|
|
259
|
+
if (this.subscription) {
|
|
260
|
+
this.subscription.unsubscribe();
|
|
261
|
+
this.subscription = null;
|
|
262
|
+
}
|
|
263
|
+
if (this.transport) {
|
|
264
|
+
this.transport.destroy();
|
|
265
|
+
this.transport = null;
|
|
266
|
+
}
|
|
267
|
+
this.signer = null;
|
|
268
|
+
this.connected = false;
|
|
269
|
+
this.connectPromise = null;
|
|
270
|
+
this.callbacks = [];
|
|
271
|
+
this.seen.clear();
|
|
272
|
+
log.info("Destroyed");
|
|
273
|
+
}
|
|
274
|
+
// ========================================================================
|
|
275
|
+
// Internal
|
|
276
|
+
// ========================================================================
|
|
277
|
+
/* @integration */
|
|
278
|
+
startSubscription() {
|
|
279
|
+
if (!this.transport)
|
|
280
|
+
return;
|
|
281
|
+
const filter = { matchAll: [this.appTopic] };
|
|
282
|
+
this.subscription = this.transport.subscribe(filter, (hex) => this.handleStatementReceived(hex), (error) => {
|
|
283
|
+
log.warn("Subscription unavailable, relying on polling", {
|
|
284
|
+
error: error.message,
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
/* @integration */
|
|
289
|
+
startPolling() {
|
|
290
|
+
this.pollTimer = setInterval(() => {
|
|
291
|
+
this.poll().catch((error) => {
|
|
292
|
+
log.warn("Poll failed", {
|
|
293
|
+
error: error instanceof Error ? error.message : String(error),
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
}, this.config.pollIntervalMs);
|
|
297
|
+
}
|
|
298
|
+
stopPolling() {
|
|
299
|
+
if (this.pollTimer) {
|
|
300
|
+
clearInterval(this.pollTimer);
|
|
301
|
+
this.pollTimer = null;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
/* @integration */
|
|
305
|
+
async poll() {
|
|
306
|
+
if (!this.transport)
|
|
307
|
+
return;
|
|
308
|
+
// Prune expired entries from the seen map to prevent unbounded growth
|
|
309
|
+
this.pruneSeenMap();
|
|
310
|
+
const hexStatements = await this.transport.query([this.appTopic]);
|
|
311
|
+
let newCount = 0;
|
|
312
|
+
for (const hex of hexStatements) {
|
|
313
|
+
if (this.handleStatementReceived(hex)) {
|
|
314
|
+
newCount++;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
if (newCount > 0) {
|
|
318
|
+
log.debug("Poll found new statements", {
|
|
319
|
+
total: hexStatements.length,
|
|
320
|
+
new: newCount,
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
/** Remove entries from the seen map whose expiry timestamp is in the past. */
|
|
325
|
+
pruneSeenMap() {
|
|
326
|
+
const nowSeconds = BigInt(Math.floor(Date.now() / 1000));
|
|
327
|
+
for (const [key, expiry] of this.seen) {
|
|
328
|
+
const expiryTimestamp = expiry >> 32n;
|
|
329
|
+
if (expiryTimestamp > 0n && expiryTimestamp < nowSeconds) {
|
|
330
|
+
this.seen.delete(key);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Process a received statement hex, dedup, parse, and deliver to callbacks.
|
|
336
|
+
* Returns true if the statement was new and delivered.
|
|
337
|
+
*/
|
|
338
|
+
handleStatementReceived(hex) {
|
|
339
|
+
const parsed = this.parseStatement(hex);
|
|
340
|
+
if (!parsed)
|
|
341
|
+
return false;
|
|
342
|
+
// Deduplication key: channel hex (if present) or data hash
|
|
343
|
+
const dedupeKey = parsed.channel ? topicToHex(parsed.channel) : hex.substring(0, 64);
|
|
344
|
+
const existingExpiry = this.seen.get(dedupeKey);
|
|
345
|
+
const newExpiry = parsed.expiry ?? 0n;
|
|
346
|
+
if (existingExpiry !== undefined && newExpiry <= existingExpiry) {
|
|
347
|
+
return false; // Already seen or older
|
|
348
|
+
}
|
|
349
|
+
this.seen.set(dedupeKey, newExpiry);
|
|
350
|
+
// Deliver to callbacks (snapshot to handle mid-iteration unsubscribes)
|
|
351
|
+
for (const callback of [...this.callbacks]) {
|
|
352
|
+
try {
|
|
353
|
+
callback(parsed);
|
|
354
|
+
}
|
|
355
|
+
catch (error) {
|
|
356
|
+
log.error("Callback error", {
|
|
357
|
+
error: error instanceof Error ? error.message : String(error),
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return true;
|
|
362
|
+
}
|
|
363
|
+
parseStatement(hex) {
|
|
364
|
+
try {
|
|
365
|
+
const decoded = decodeStatement(hex);
|
|
366
|
+
if (!decoded.data)
|
|
367
|
+
return null;
|
|
368
|
+
const data = decodeData(decoded.data);
|
|
369
|
+
return {
|
|
370
|
+
data,
|
|
371
|
+
signer: decoded.signer,
|
|
372
|
+
channel: decoded.channel,
|
|
373
|
+
topic1: decoded.topic1,
|
|
374
|
+
topic2: decoded.topic2,
|
|
375
|
+
expiry: decoded.expiry,
|
|
376
|
+
raw: decoded,
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
catch {
|
|
380
|
+
// Skip malformed statements
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
if (import.meta.vitest) {
|
|
386
|
+
const { describe, test, expect, vi, beforeEach } = import.meta.vitest;
|
|
387
|
+
const { configure } = await import("@polkadot-apps/logger");
|
|
388
|
+
const { encodeStatement: encodeStmt, toHex: bytesToHex } = await import("./codec.js");
|
|
389
|
+
const { createTopic: mkTopic } = await import("./topics.js");
|
|
390
|
+
beforeEach(() => {
|
|
391
|
+
configure({ handler: () => { } });
|
|
392
|
+
});
|
|
393
|
+
// Helper to create a hex-encoded test statement
|
|
394
|
+
function makeTestStatementHex(data, opts) {
|
|
395
|
+
const dataBytes = new TextEncoder().encode(JSON.stringify(data));
|
|
396
|
+
const fields = {
|
|
397
|
+
expirationTimestamp: opts?.expiry ?? 1700000030,
|
|
398
|
+
sequenceNumber: Date.now() % 0xffffffff,
|
|
399
|
+
topic1: opts?.topic1 ?? mkTopic("test-app"),
|
|
400
|
+
topic2: opts?.topic2,
|
|
401
|
+
channel: opts?.channel,
|
|
402
|
+
data: dataBytes,
|
|
403
|
+
};
|
|
404
|
+
const signer = new Uint8Array(32).fill(0xaa);
|
|
405
|
+
const signature = new Uint8Array(64).fill(0xbb);
|
|
406
|
+
const encoded = encodeStmt(fields, signer, signature);
|
|
407
|
+
return bytesToHex(encoded);
|
|
408
|
+
}
|
|
409
|
+
function createMockTransport() {
|
|
410
|
+
const mock = {
|
|
411
|
+
subscribeCalls: [],
|
|
412
|
+
submitCalls: [],
|
|
413
|
+
queryCalls: [],
|
|
414
|
+
subscribe: vi.fn((_filter, _onStatement, _onError) => {
|
|
415
|
+
mock.subscribeCalls.push({ _filter, _onStatement, _onError });
|
|
416
|
+
return { unsubscribe: () => { } };
|
|
417
|
+
}),
|
|
418
|
+
submit: vi.fn(async (hex) => {
|
|
419
|
+
mock.submitCalls.push(hex);
|
|
420
|
+
return "new";
|
|
421
|
+
}),
|
|
422
|
+
query: vi.fn(async () => {
|
|
423
|
+
return [];
|
|
424
|
+
}),
|
|
425
|
+
destroy: vi.fn(),
|
|
426
|
+
};
|
|
427
|
+
return mock;
|
|
428
|
+
}
|
|
429
|
+
// We need to mock createTransport to return our mock transport
|
|
430
|
+
describe("StatementStoreClient", () => {
|
|
431
|
+
test("constructor sets config defaults", () => {
|
|
432
|
+
const client = new StatementStoreClient({ appName: "test" });
|
|
433
|
+
expect(client.isConnected()).toBe(false);
|
|
434
|
+
expect(client.getPublicKeyHex()).toBe("");
|
|
435
|
+
});
|
|
436
|
+
test("destroy is safe to call when not connected", () => {
|
|
437
|
+
const client = new StatementStoreClient({ appName: "test" });
|
|
438
|
+
expect(() => client.destroy()).not.toThrow();
|
|
439
|
+
});
|
|
440
|
+
test("destroy clears state", () => {
|
|
441
|
+
const client = new StatementStoreClient({ appName: "test" });
|
|
442
|
+
client.destroy();
|
|
443
|
+
expect(client.isConnected()).toBe(false);
|
|
444
|
+
expect(client.getPublicKeyHex()).toBe("");
|
|
445
|
+
});
|
|
446
|
+
test("subscribe returns unsubscribable handle", () => {
|
|
447
|
+
const client = new StatementStoreClient({ appName: "test" });
|
|
448
|
+
const callback = vi.fn();
|
|
449
|
+
const sub = client.subscribe(callback);
|
|
450
|
+
expect(sub.unsubscribe).toBeTypeOf("function");
|
|
451
|
+
sub.unsubscribe();
|
|
452
|
+
});
|
|
453
|
+
test("multiple subscribes and unsubscribes work correctly", () => {
|
|
454
|
+
const client = new StatementStoreClient({ appName: "test" });
|
|
455
|
+
const cb1 = vi.fn();
|
|
456
|
+
const cb2 = vi.fn();
|
|
457
|
+
const sub1 = client.subscribe(cb1);
|
|
458
|
+
const sub2 = client.subscribe(cb2);
|
|
459
|
+
sub1.unsubscribe();
|
|
460
|
+
sub2.unsubscribe();
|
|
461
|
+
// Unsubscribe is idempotent
|
|
462
|
+
sub1.unsubscribe();
|
|
463
|
+
});
|
|
464
|
+
test("publish throws when not connected", async () => {
|
|
465
|
+
const client = new StatementStoreClient({ appName: "test" });
|
|
466
|
+
await expect(client.publish({ foo: "bar" })).rejects.toThrow(StatementConnectionError);
|
|
467
|
+
});
|
|
468
|
+
test("query throws when not connected", async () => {
|
|
469
|
+
const client = new StatementStoreClient({ appName: "test" });
|
|
470
|
+
await expect(client.query()).rejects.toThrow(StatementConnectionError);
|
|
471
|
+
});
|
|
472
|
+
test("handleStatementReceived deduplicates by channel", () => {
|
|
473
|
+
const client = new StatementStoreClient({ appName: "test" });
|
|
474
|
+
const callback = vi.fn();
|
|
475
|
+
client.subscribe(callback);
|
|
476
|
+
const channel = mkTopic("test-channel");
|
|
477
|
+
const hex = makeTestStatementHex({ type: "test" }, { channel, expiry: 1700000030 });
|
|
478
|
+
// Access private method via bracket notation for testing
|
|
479
|
+
const delivered1 = client.handleStatementReceived(hex);
|
|
480
|
+
const delivered2 = client.handleStatementReceived(hex);
|
|
481
|
+
expect(delivered1).toBe(true);
|
|
482
|
+
expect(delivered2).toBe(false); // Duplicate
|
|
483
|
+
expect(callback).toHaveBeenCalledOnce();
|
|
484
|
+
});
|
|
485
|
+
test("handleStatementReceived delivers newer statement for same channel", () => {
|
|
486
|
+
const client = new StatementStoreClient({ appName: "test" });
|
|
487
|
+
const callback = vi.fn();
|
|
488
|
+
client.subscribe(callback);
|
|
489
|
+
const channel = mkTopic("test-channel");
|
|
490
|
+
const hex1 = makeTestStatementHex({ v: 1 }, { channel, expiry: 1700000030 });
|
|
491
|
+
const hex2 = makeTestStatementHex({ v: 2 }, { channel, expiry: 1700000060 });
|
|
492
|
+
const handle = client;
|
|
493
|
+
handle.handleStatementReceived(hex1);
|
|
494
|
+
handle.handleStatementReceived(hex2);
|
|
495
|
+
expect(callback).toHaveBeenCalledTimes(2);
|
|
496
|
+
});
|
|
497
|
+
test("parseStatement returns null for no data", () => {
|
|
498
|
+
const client = new StatementStoreClient({ appName: "test" });
|
|
499
|
+
const parse = client.parseStatement;
|
|
500
|
+
// Minimal statement with only expiry, no data
|
|
501
|
+
const fields = {
|
|
502
|
+
expirationTimestamp: 100,
|
|
503
|
+
sequenceNumber: 1,
|
|
504
|
+
};
|
|
505
|
+
const encoded = encodeStmt(fields, new Uint8Array(32), new Uint8Array(64));
|
|
506
|
+
const hex = bytesToHex(encoded);
|
|
507
|
+
expect(parse.call(client, hex)).toBeNull();
|
|
508
|
+
});
|
|
509
|
+
test("parseStatement returns null for malformed hex", () => {
|
|
510
|
+
const client = new StatementStoreClient({ appName: "test" });
|
|
511
|
+
const parse = client.parseStatement;
|
|
512
|
+
expect(parse.call(client, "0xdeadbeef")).toBeNull();
|
|
513
|
+
});
|
|
514
|
+
test("callback errors are caught and logged", () => {
|
|
515
|
+
const client = new StatementStoreClient({ appName: "test" });
|
|
516
|
+
const badCallback = vi.fn(() => {
|
|
517
|
+
throw new Error("callback boom");
|
|
518
|
+
});
|
|
519
|
+
const goodCallback = vi.fn();
|
|
520
|
+
client.subscribe(badCallback);
|
|
521
|
+
client.subscribe(goodCallback);
|
|
522
|
+
const channel = mkTopic("ch");
|
|
523
|
+
const hex = makeTestStatementHex({ type: "test" }, { channel });
|
|
524
|
+
const handle = client;
|
|
525
|
+
handle.handleStatementReceived(hex);
|
|
526
|
+
// Bad callback threw but good callback still received the statement
|
|
527
|
+
expect(badCallback).toHaveBeenCalledOnce();
|
|
528
|
+
expect(goodCallback).toHaveBeenCalledOnce();
|
|
529
|
+
});
|
|
530
|
+
test("subscribe with topic2 filter only delivers matching statements", () => {
|
|
531
|
+
const client = new StatementStoreClient({ appName: "test" });
|
|
532
|
+
const callback = vi.fn();
|
|
533
|
+
client.subscribe(callback, { topic2: "room-1" });
|
|
534
|
+
const topic2Match = mkTopic("room-1");
|
|
535
|
+
const topic2Other = mkTopic("room-2");
|
|
536
|
+
const hexMatch = makeTestStatementHex({ v: 1 }, { topic2: topic2Match, channel: mkTopic("ch1") });
|
|
537
|
+
const hexOther = makeTestStatementHex({ v: 2 }, { topic2: topic2Other, channel: mkTopic("ch2") });
|
|
538
|
+
const handle = client;
|
|
539
|
+
handle.handleStatementReceived(hexMatch);
|
|
540
|
+
handle.handleStatementReceived(hexOther);
|
|
541
|
+
expect(callback).toHaveBeenCalledOnce();
|
|
542
|
+
expect(callback.mock.calls[0][0].data).toEqual({ v: 1 });
|
|
543
|
+
});
|
|
544
|
+
test("subscribe with topic2 filter rejects statements without topic2", () => {
|
|
545
|
+
const client = new StatementStoreClient({ appName: "test" });
|
|
546
|
+
const callback = vi.fn();
|
|
547
|
+
client.subscribe(callback, { topic2: "room-1" });
|
|
548
|
+
// Statement without topic2
|
|
549
|
+
const hex = makeTestStatementHex({ v: 1 }, { channel: mkTopic("ch") });
|
|
550
|
+
const handle = client;
|
|
551
|
+
handle.handleStatementReceived(hex);
|
|
552
|
+
expect(callback).not.toHaveBeenCalled();
|
|
553
|
+
});
|
|
554
|
+
// --- Tests using injected mock transport ---
|
|
555
|
+
function injectTransport(client, transport) {
|
|
556
|
+
const internal = client;
|
|
557
|
+
internal.transport = transport;
|
|
558
|
+
internal.signer = {
|
|
559
|
+
publicKey: new Uint8Array(32).fill(0xaa),
|
|
560
|
+
sign: () => new Uint8Array(64).fill(0xbb),
|
|
561
|
+
};
|
|
562
|
+
internal.connected = true;
|
|
563
|
+
}
|
|
564
|
+
test("publish encodes, signs, and submits via transport", async () => {
|
|
565
|
+
const client = new StatementStoreClient({ appName: "test-app" });
|
|
566
|
+
const transport = createMockTransport();
|
|
567
|
+
injectTransport(client, transport);
|
|
568
|
+
const result = await client.publish({ type: "presence", peerId: "abc" }, { channel: "ch1" });
|
|
569
|
+
expect(result).toBe(true);
|
|
570
|
+
expect(transport.submitCalls.length).toBe(1);
|
|
571
|
+
expect(transport.submitCalls[0]).toMatch(/^0x/);
|
|
572
|
+
});
|
|
573
|
+
test("publish returns false on rejection", async () => {
|
|
574
|
+
const client = new StatementStoreClient({ appName: "test-app" });
|
|
575
|
+
const transport = createMockTransport();
|
|
576
|
+
transport.submit = vi.fn(async () => "rejected");
|
|
577
|
+
injectTransport(client, transport);
|
|
578
|
+
const result = await client.publish({ type: "test" });
|
|
579
|
+
expect(result).toBe(false);
|
|
580
|
+
});
|
|
581
|
+
test("publish returns false on transport error", async () => {
|
|
582
|
+
const client = new StatementStoreClient({ appName: "test-app" });
|
|
583
|
+
const transport = createMockTransport();
|
|
584
|
+
transport.submit = vi.fn(async () => {
|
|
585
|
+
throw new Error("network down");
|
|
586
|
+
});
|
|
587
|
+
injectTransport(client, transport);
|
|
588
|
+
const result = await client.publish({ type: "test" });
|
|
589
|
+
expect(result).toBe(false);
|
|
590
|
+
});
|
|
591
|
+
test("query returns parsed statements from transport", async () => {
|
|
592
|
+
const client = new StatementStoreClient({ appName: "test-app" });
|
|
593
|
+
const transport = createMockTransport();
|
|
594
|
+
const testHex = makeTestStatementHex({ type: "found" }, { channel: mkTopic("ch") });
|
|
595
|
+
transport.query = vi.fn(async () => [testHex]);
|
|
596
|
+
injectTransport(client, transport);
|
|
597
|
+
const results = await client.query();
|
|
598
|
+
expect(results.length).toBe(1);
|
|
599
|
+
expect(results[0].data.type).toBe("found");
|
|
600
|
+
});
|
|
601
|
+
test("query forwards decryptionKey to transport", async () => {
|
|
602
|
+
const client = new StatementStoreClient({ appName: "test-app" });
|
|
603
|
+
const transport = createMockTransport();
|
|
604
|
+
const querySpy = vi.fn(async () => []);
|
|
605
|
+
transport.query = querySpy;
|
|
606
|
+
injectTransport(client, transport);
|
|
607
|
+
const dk = new Uint8Array(32).fill(0xff);
|
|
608
|
+
await client.query({ decryptionKey: dk });
|
|
609
|
+
const args = querySpy.mock.calls[0];
|
|
610
|
+
// Second arg is the hex-encoded decryptionKey
|
|
611
|
+
expect(args[1]).toMatch(/^0x/);
|
|
612
|
+
expect(args[1]).toContain("ff");
|
|
613
|
+
});
|
|
614
|
+
test("query derives decryptionKey from topic2 when not explicit", async () => {
|
|
615
|
+
const { topicToHex: thx, createTopic: ct } = await import("./topics.js");
|
|
616
|
+
const client = new StatementStoreClient({ appName: "test-app" });
|
|
617
|
+
const transport = createMockTransport();
|
|
618
|
+
const querySpy = vi.fn(async () => []);
|
|
619
|
+
transport.query = querySpy;
|
|
620
|
+
injectTransport(client, transport);
|
|
621
|
+
await client.query({ topic2: "room-1" });
|
|
622
|
+
const args = querySpy.mock.calls[0];
|
|
623
|
+
expect(args[1]).toBe(thx(ct("room-1")));
|
|
624
|
+
});
|
|
625
|
+
test("pruneSeenMap removes expired entries", () => {
|
|
626
|
+
const client = new StatementStoreClient({ appName: "test" });
|
|
627
|
+
const internal = client;
|
|
628
|
+
// Entry with expiry far in the past (timestamp=100, seq=0)
|
|
629
|
+
internal.seen.set("expired", (100n << 32n) | 0n);
|
|
630
|
+
// Entry with expiry far in the future (timestamp=9999999999, seq=0)
|
|
631
|
+
internal.seen.set("valid", (9999999999n << 32n) | 0n);
|
|
632
|
+
internal.pruneSeenMap();
|
|
633
|
+
expect(internal.seen.has("expired")).toBe(false);
|
|
634
|
+
expect(internal.seen.has("valid")).toBe(true);
|
|
635
|
+
});
|
|
636
|
+
test("destroy stops polling and cleans up transport", () => {
|
|
637
|
+
const client = new StatementStoreClient({ appName: "test" });
|
|
638
|
+
const transport = createMockTransport();
|
|
639
|
+
injectTransport(client, transport);
|
|
640
|
+
// Start a poll timer manually
|
|
641
|
+
const internal = client;
|
|
642
|
+
internal.pollTimer = setInterval(() => { }, 10000);
|
|
643
|
+
client.destroy();
|
|
644
|
+
expect(client.isConnected()).toBe(false);
|
|
645
|
+
expect(transport.destroy).toHaveBeenCalledOnce();
|
|
646
|
+
expect(internal.pollTimer).toBeNull();
|
|
647
|
+
});
|
|
648
|
+
test("getPublicKeyHex returns hex when connected", () => {
|
|
649
|
+
const client = new StatementStoreClient({ appName: "test" });
|
|
650
|
+
const transport = createMockTransport();
|
|
651
|
+
injectTransport(client, transport);
|
|
652
|
+
const hex = client.getPublicKeyHex();
|
|
653
|
+
expect(hex).toMatch(/^0x/);
|
|
654
|
+
expect(hex.length).toBe(66); // 0x + 64 hex chars
|
|
655
|
+
});
|
|
656
|
+
test("connect deduplicates concurrent calls", async () => {
|
|
657
|
+
const client = new StatementStoreClient({ appName: "test" });
|
|
658
|
+
const internal = client;
|
|
659
|
+
// Simulate an in-flight connect
|
|
660
|
+
let resolveConnect;
|
|
661
|
+
internal.connectPromise = new Promise((r) => {
|
|
662
|
+
resolveConnect = r;
|
|
663
|
+
});
|
|
664
|
+
// Second connect should return a promise (not start a new connection)
|
|
665
|
+
const signer = {
|
|
666
|
+
publicKey: new Uint8Array(32),
|
|
667
|
+
sign: () => new Uint8Array(64),
|
|
668
|
+
};
|
|
669
|
+
const promise = client.connect(signer);
|
|
670
|
+
// The promise is derived from connectPromise, so it's linked
|
|
671
|
+
expect(internal.connectPromise).not.toBeNull();
|
|
672
|
+
resolveConnect();
|
|
673
|
+
await promise; // Should resolve without error
|
|
674
|
+
});
|
|
675
|
+
test("connect returns immediately if already connected", async () => {
|
|
676
|
+
const client = new StatementStoreClient({ appName: "test" });
|
|
677
|
+
const internal = client;
|
|
678
|
+
internal.connected = true;
|
|
679
|
+
const signer = {
|
|
680
|
+
publicKey: new Uint8Array(32),
|
|
681
|
+
sign: () => new Uint8Array(64),
|
|
682
|
+
};
|
|
683
|
+
// Should not throw — just returns
|
|
684
|
+
await client.connect(signer);
|
|
685
|
+
});
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
//# sourceMappingURL=client.js.map
|