Skip to content

Commit 8a749c4

Browse files
refactor: implement browser and Node.js storage solutions for message persistence, updating LocalHistory to utilize a unified Storage interface and enhancing tests for localStorage functionality
1 parent 721c494 commit 8a749c4

File tree

10 files changed

+192
-211
lines changed

10 files changed

+192
-211
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,5 @@ CLAUDE.md
2121
.env
2222
postgres-data/
2323
packages/rln/waku-rlnv2-contract/
24+
/packages/**/allure-results
25+
/packages/**/allure-results

packages/sds/karma.conf.cjs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,16 @@
1-
const config = require("../../karma.conf.cjs");
1+
import path from "path";
22

3-
module.exports = config;
3+
import baseConfig from "../../karma.conf.cjs";
4+
5+
export default function (config) {
6+
baseConfig(config);
7+
8+
const storageDir = path.resolve(__dirname, "src/message_channel/storage");
9+
10+
// Swap node storage for browser storage in webpack builds
11+
config.webpack.resolve.alias = {
12+
...config.webpack.resolve.alias,
13+
[path.join(storageDir, "node.ts")]: path.join(storageDir, "browser.ts"),
14+
[path.join(storageDir, "node.js")]: path.join(storageDir, "browser.ts")
15+
};
16+
}

packages/sds/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
"description": "Scalable Data Sync implementation for the browser. Based on https://github.com/vacp2p/rfc-index/blob/main/vac/raw/sds.md",
55
"types": "./dist/index.d.ts",
66
"module": "./dist/index.js",
7+
"browser": {
8+
"./dist/message_channel/storage/index.js": "./dist/message_channel/storage/browser.js"
9+
},
710
"exports": {
811
".": {
912
"types": "./dist/index.d.ts",

packages/sds/src/message_channel/local_history.ts

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { Logger } from "@waku/utils";
22
import _ from "lodash";
33

4-
import { type ChannelId, ContentMessage, isContentMessage } from "./message.js";
5-
import { PersistentStorage } from "./persistent_storage.js";
4+
import { ContentMessage, isContentMessage } from "./message.js";
5+
import { Storage } from "./storage/index.js";
66

77
export const DEFAULT_MAX_LENGTH = 10_000;
88

@@ -21,33 +21,35 @@ export const DEFAULT_MAX_LENGTH = 10_000;
2121
*/
2222

2323
export type LocalHistoryOptions = {
24-
storage?: ChannelId | PersistentStorage;
24+
storagePrefix?: string;
25+
storage?: Storage;
2526
maxSize?: number;
2627
};
2728

2829
const log = new Logger("sds:local-history");
2930

3031
export class LocalHistory {
3132
private items: ContentMessage[] = [];
32-
private readonly storage?: PersistentStorage;
33+
private readonly storage?: Storage;
3334
private readonly maxSize: number;
3435

3536
/**
3637
* Construct a new in-memory local history.
3738
*
3839
* @param opts Configuration object.
39-
* - storage: Optional persistent storage backend for message persistence or channelId to use with PersistentStorage.
40+
* - storagePrefix: Optional prefix for persistent storage (creates Storage if provided).
41+
* - storage: Optional explicit Storage instance.
4042
* - maxSize: The maximum number of messages to store. Optional, defaults to DEFAULT_MAX_LENGTH.
4143
*/
4244
public constructor(opts: LocalHistoryOptions = {}) {
43-
const { storage, maxSize } = opts;
45+
const { storagePrefix, storage, maxSize } = opts;
4446
this.maxSize = maxSize ?? DEFAULT_MAX_LENGTH;
45-
if (storage instanceof PersistentStorage) {
47+
if (storage) {
4648
this.storage = storage;
47-
log.info("Using explicit persistent storage");
48-
} else if (typeof storage === "string") {
49-
this.storage = PersistentStorage.create(storage);
50-
log.info("Creating persistent storage for channel", storage);
49+
log.info("Using explicit storage");
50+
} else if (storagePrefix) {
51+
this.storage = new Storage(storagePrefix);
52+
log.info("Creating storage for prefix", storagePrefix);
5153
} else {
5254
this.storage = undefined;
5355
log.info("Using in-memory storage");

packages/sds/src/message_channel/message_channel.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ export class MessageChannel extends TypedEventEmitter<MessageChannelEvents> {
113113
this.possibleAcks = new Map();
114114
this.incomingBuffer = [];
115115
this.localHistory =
116-
localHistory ?? new LocalHistory({ storage: channelId });
116+
localHistory ?? new LocalHistory({ storagePrefix: channelId });
117117
this.causalHistorySize =
118118
options.causalHistorySize ?? DEFAULT_CAUSAL_HISTORY_SIZE;
119119
// TODO: this should be determined based on the bloom filter parameters and number of hashes

packages/sds/src/message_channel/persistent_storage.spec.ts

Lines changed: 38 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,27 @@ import { expect } from "chai";
22

33
import { LocalHistory } from "./local_history.js";
44
import { ContentMessage } from "./message.js";
5-
import { IStorage, PersistentStorage } from "./persistent_storage.js";
65

76
const channelId = "channel-1";
87

9-
describe("PersistentStorage", () => {
10-
describe("Explicit storage", () => {
11-
it("persists and restores messages", () => {
12-
const storage = new MemoryStorage();
13-
const persistentStorage = PersistentStorage.create(channelId, storage);
8+
describe("Storage", () => {
9+
describe("Browser localStorage", () => {
10+
before(function () {
11+
if (typeof localStorage === "undefined") {
12+
this.skip();
13+
}
14+
});
1415

15-
expect(persistentStorage).to.not.be.undefined;
16+
afterEach(() => {
17+
localStorage.removeItem(`waku:sds:storage:${channelId}`);
18+
});
1619

17-
const history1 = new LocalHistory({ storage: persistentStorage });
20+
it("persists and restores messages", () => {
21+
const history1 = new LocalHistory({ storagePrefix: channelId });
1822
history1.push(createMessage("msg-1", 1));
1923
history1.push(createMessage("msg-2", 2));
2024

21-
const history2 = new LocalHistory({ storage: persistentStorage });
25+
const history2 = new LocalHistory({ storagePrefix: channelId });
2226

2327
expect(history2.length).to.equal(2);
2428
expect(history2.slice(0).map((msg) => msg.messageId)).to.deep.equal([
@@ -27,39 +31,18 @@ describe("PersistentStorage", () => {
2731
]);
2832
});
2933

30-
it("uses in-memory only when no storage is provided", () => {
31-
const history = new LocalHistory({ maxSize: 100 });
32-
history.push(createMessage("msg-3", 3));
33-
34-
expect(history.length).to.equal(1);
35-
expect(history.slice(0)[0].messageId).to.equal("msg-3");
36-
37-
const history2 = new LocalHistory({ maxSize: 100 });
38-
expect(history2.length).to.equal(0);
39-
});
40-
41-
it("handles corrupt data in storage gracefully", () => {
42-
const storage = new MemoryStorage();
43-
// Corrupt data
44-
storage.setItem("waku:sds:messages:channel-1", "{ invalid json }");
45-
46-
const persistentStorage = PersistentStorage.create(channelId, storage);
47-
const history = new LocalHistory({ storage: persistentStorage });
34+
it("handles corrupt data gracefully", () => {
35+
localStorage.setItem(`waku:sds:storage:${channelId}`, "{ invalid json }");
4836

37+
const history = new LocalHistory({ storagePrefix: channelId });
4938
expect(history.length).to.equal(0);
50-
51-
// Corrupt data is not saved
52-
expect(storage.getItem("waku:sds:messages:channel-1")).to.equal(null);
39+
// Corrupt data is removed
40+
expect(localStorage.getItem(`waku:sds:storage:${channelId}`)).to.be.null;
5341
});
5442

5543
it("isolates history by channel ID", () => {
56-
const storage = new MemoryStorage();
57-
58-
const storage1 = PersistentStorage.create("channel-1", storage);
59-
const storage2 = PersistentStorage.create("channel-2", storage);
60-
61-
const history1 = new LocalHistory({ storage: storage1 });
62-
const history2 = new LocalHistory({ storage: storage2 });
44+
const history1 = new LocalHistory({ storagePrefix: "channel-1" });
45+
const history2 = new LocalHistory({ storagePrefix: "channel-2" });
6346

6447
history1.push(createMessage("msg-1", 1));
6548
history2.push(createMessage("msg-2", 2));
@@ -70,37 +53,34 @@ describe("PersistentStorage", () => {
7053
expect(history2.length).to.equal(1);
7154
expect(history2.slice(0)[0].messageId).to.equal("msg-2");
7255

73-
expect(storage.getItem("waku:sds:messages:channel-1")).to.not.be.null;
74-
expect(storage.getItem("waku:sds:messages:channel-2")).to.not.be.null;
56+
localStorage.removeItem("waku:sds:storage:channel-2");
7557
});
7658

7759
it("saves messages after each push", () => {
78-
const storage = new MemoryStorage();
79-
const persistentStorage = PersistentStorage.create(channelId, storage);
80-
const history = new LocalHistory({ storage: persistentStorage });
60+
const history = new LocalHistory({ storagePrefix: channelId });
8161

82-
expect(storage.getItem("waku:sds:messages:channel-1")).to.be.null;
62+
expect(localStorage.getItem(`waku:sds:storage:${channelId}`)).to.be.null;
8363

8464
history.push(createMessage("msg-1", 1));
8565

86-
expect(storage.getItem("waku:sds:messages:channel-1")).to.not.be.null;
66+
expect(localStorage.getItem(`waku:sds:storage:${channelId}`)).to.not.be
67+
.null;
8768

88-
const saved = JSON.parse(storage.getItem("waku:sds:messages:channel-1")!);
69+
const saved = JSON.parse(
70+
localStorage.getItem(`waku:sds:storage:${channelId}`)!
71+
);
8972
expect(saved).to.have.lengthOf(1);
9073
expect(saved[0].messageId).to.equal("msg-1");
9174
});
9275

9376
it("loads messages on initialization", () => {
94-
const storage = new MemoryStorage();
95-
const persistentStorage1 = PersistentStorage.create(channelId, storage);
96-
const history1 = new LocalHistory({ storage: persistentStorage1 });
77+
const history1 = new LocalHistory({ storagePrefix: channelId });
9778

9879
history1.push(createMessage("msg-1", 1));
9980
history1.push(createMessage("msg-2", 2));
10081
history1.push(createMessage("msg-3", 3));
10182

102-
const persistentStorage2 = PersistentStorage.create(channelId, storage);
103-
const history2 = new LocalHistory({ storage: persistentStorage2 });
83+
const history2 = new LocalHistory({ storagePrefix: channelId });
10484

10585
expect(history2.length).to.equal(3);
10686
expect(history2.slice(0).map((m) => m.messageId)).to.deep.equal([
@@ -111,59 +91,16 @@ describe("PersistentStorage", () => {
11191
});
11292
});
11393

114-
describe("Node.js only (no localStorage)", () => {
115-
before(function () {
116-
if (typeof localStorage !== "undefined") {
117-
this.skip();
118-
}
119-
});
120-
121-
it("returns undefined when no storage is available", () => {
122-
const persistentStorage = PersistentStorage.create(channelId, undefined);
123-
124-
expect(persistentStorage).to.equal(undefined);
125-
});
126-
});
127-
128-
describe("Browser only (localStorage)", () => {
129-
before(function () {
130-
if (typeof localStorage === "undefined") {
131-
this.skip();
132-
}
133-
});
134-
135-
it("persists and restores messages with channelId", () => {
136-
const testChannelId = `test-${Date.now()}`;
137-
const history1 = new LocalHistory({ storage: testChannelId });
138-
history1.push(createMessage("msg-1", 1));
139-
history1.push(createMessage("msg-2", 2));
140-
141-
const history2 = new LocalHistory({ storage: testChannelId });
142-
143-
expect(history2.length).to.equal(2);
144-
expect(history2.slice(0).map((msg) => msg.messageId)).to.deep.equal([
145-
"msg-1",
146-
"msg-2"
147-
]);
148-
149-
localStorage.removeItem(`waku:sds:messages:${testChannelId}`);
150-
});
151-
152-
it("auto-uses localStorage when channelId is provided", () => {
153-
const testChannelId = `auto-storage-${Date.now()}`;
154-
155-
const history = new LocalHistory({ storage: testChannelId });
156-
history.push(createMessage("msg-auto-1", 1));
157-
history.push(createMessage("msg-auto-2", 2));
94+
describe("In-memory fallback", () => {
95+
it("uses in-memory only when no storage is provided", () => {
96+
const history = new LocalHistory({ maxSize: 100 });
97+
history.push(createMessage("msg-3", 3));
15898

159-
const history2 = new LocalHistory({ storage: testChannelId });
160-
expect(history2.length).to.equal(2);
161-
expect(history2.slice(0).map((m) => m.messageId)).to.deep.equal([
162-
"msg-auto-1",
163-
"msg-auto-2"
164-
]);
99+
expect(history.length).to.equal(1);
100+
expect(history.slice(0)[0].messageId).to.equal("msg-3");
165101

166-
localStorage.removeItem(`waku:sds:messages:${testChannelId}`);
102+
const history2 = new LocalHistory({ maxSize: 100 });
103+
expect(history2.length).to.equal(0);
167104
});
168105
});
169106
});
@@ -180,19 +117,3 @@ const createMessage = (id: string, timestamp: number): ContentMessage => {
180117
undefined
181118
);
182119
};
183-
184-
class MemoryStorage implements IStorage {
185-
private readonly store = new Map<string, string>();
186-
187-
public getItem(key: string): string | null {
188-
return this.store.get(key) ?? null;
189-
}
190-
191-
public setItem(key: string, value: string): void {
192-
this.store.set(key, value);
193-
}
194-
195-
public removeItem(key: string): void {
196-
this.store.delete(key);
197-
}
198-
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { Logger } from "@waku/utils";
2+
3+
import { ContentMessage } from "../message.js";
4+
5+
import {
6+
MessageSerializer,
7+
StoredContentMessage
8+
} from "./message_serializer.js";
9+
10+
const log = new Logger("sds:storage");
11+
12+
const STORAGE_PREFIX = "waku:sds:storage:";
13+
14+
/**
15+
* Browser localStorage wrapper for message persistence.
16+
*/
17+
export class Storage {
18+
private readonly storageKey: string;
19+
20+
public constructor(storagePrefix: string) {
21+
this.storageKey = `${STORAGE_PREFIX}${storagePrefix}`;
22+
}
23+
24+
public save(messages: ContentMessage[]): void {
25+
try {
26+
const payload = JSON.stringify(
27+
messages.map((msg) => MessageSerializer.serializeContentMessage(msg))
28+
);
29+
localStorage.setItem(this.storageKey, payload);
30+
} catch (error) {
31+
log.error("Failed to save messages to storage:", error);
32+
}
33+
}
34+
35+
public load(): ContentMessage[] {
36+
try {
37+
const raw = localStorage.getItem(this.storageKey);
38+
if (!raw) {
39+
return [];
40+
}
41+
42+
const stored = JSON.parse(raw) as StoredContentMessage[];
43+
return stored
44+
.map((record) => MessageSerializer.deserializeContentMessage(record))
45+
.filter((message): message is ContentMessage => Boolean(message));
46+
} catch (error) {
47+
log.error("Failed to load messages from storage:", error);
48+
localStorage.removeItem(this.storageKey);
49+
return [];
50+
}
51+
}
52+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
// Node.js implementation - swapped to browser.js via package.json browser field
2+
export { Storage } from "./node.js";

0 commit comments

Comments
 (0)