Skip to content

Conversation

@danisharora099
Copy link
Collaborator

@danisharora099 danisharora099 commented Nov 14, 2025

Problem / Description

Message history is currently stored only in memory, causing SDS to start recovery afresh every time.

Solution

This PR introduces persistent message history that survives application restarts using localStorage (browser), with an option to provide custom storage providers.

Changes

  • MemLocalHistory now optionally sets up Storage
  • Storage abstraction with automatic localStorage usage in browsers with serialisation support
    • Optionally can provide custom Storage providers
  • Backwards compatible MessageChannel with persistent storage support, by default
  • New tests

Notes

  • Persistent storage by default (if available)
    • Can opt out of persistent storage
    • Non-browser environments require custom storage provider, otherwise memory-only
  • Supports both browsers and NodeJS
  • Backwards compatibility maintained

Checklist

  • Code changes are covered by unit tests
  • Code changes are covered by e2e tests, if applicable
  • Dogfooding has been performed, if feasible
  • A test version has been published, if required
  • All CI checks pass successfully

*
* If no storage backend is available, this behaves like {@link MemLocalHistory}.
*/
export class PersistentHistory extends MemLocalHistory {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are you extending another class instead of the interface? Avoid abstractions as a rule of thumb

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extended so that we could get the implementations for the following functions without needing to reimplement:

 push(...items: ContentMessage[]): number;
  some(
    predicate: (
      value: ContentMessage,
      index: number,
      array: ContentMessage[]
    ) => unknown,
    thisArg?: any
  ): boolean;
  slice(start?: number, end?: number): ContentMessage[];
  find(
    predicate: (
      value: ContentMessage,
      index: number,
      obj: ContentMessage[]
    ) => unknown,
    thisArg?: any
  ): ContentMessage | undefined;
  findIndex(
    predicate: (
      value: ContentMessage,
      index: number,
      obj: ContentMessage[]
    ) => unknown,
    thisArg?: any
  ): number;

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes but you already know you want to get rid of some of that with the optimisation so I would avoid the abstraction usage.

You can also extract those logic if necessary. Saving code is not always the best way forward. See my new comments where naming is confusing.

this.restore();
}

public override push(...items: ContentMessage[]): number {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, don't use abstraction. Its not worth the indirection you are bringing in

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

switched to composition!

}

private persist(): void {
if (!this.storage) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hum, does it make sense for it to be constructed without storage?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we want the class to behave like MemLocalHistory if no storage is provided, right?

Copy link
Collaborator Author

@danisharora099 danisharora099 Nov 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or perhaps, that should be handled one level above where MessageChannel chooses between MemLocalHistory and PersistentHistory -- suggestions?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we want the class to behave like MemLocalHistory if no storage is provided, right?

So you are saying that if we instantiate a persistent local history, but there is no persistence to it, then we want it to behave like memory local history? Think about the footgun you are setting up for developers.

If there is no way to persist the history, then the class handling persisting history should not be instantiable.

Copy link
Collaborator Author

@danisharora099 danisharora099 Dec 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed:

if (storage instanceof PersistentStorage) {
      this.storage = storage;
      log.info("Using explicit persistent storage");
    } else if (typeof storage === "string") {
      this.storage = PersistentStorage.create(storage);
      log.info("Creating persistent storage for channel", storage);
    } else {
      this.storage = undefined;
      log.info("Using in-memory storage");
    }

@danisharora099 danisharora099 force-pushed the feat/persistent_history branch from d7aa504 to 92dd0ba Compare November 20, 2025 21:27
@github-actions
Copy link

github-actions bot commented Nov 20, 2025

size-limit report 📦

Path Size Loading time (3g) Running time (snapdragon) Total time
Waku node 96.31 KB (+0.08% 🔺) 2 s (+0.08% 🔺) 693 ms (+0.27% 🔺) 2.7 s
Waku Simple Light Node 147.63 KB (+0.03% 🔺) 3 s (+0.03% 🔺) 739 ms (+14.37% 🔺) 3.7 s
ECIES encryption 22.62 KB (0%) 453 ms (0%) 584 ms (+113.69% 🔺) 1.1 s
Symmetric encryption 22 KB (0%) 440 ms (0%) 457 ms (+171.08% 🔺) 897 ms
DNS discovery 52.17 KB (0%) 1.1 s (0%) 400 ms (+17.71% 🔺) 1.5 s
Peer Exchange discovery 52.91 KB (0%) 1.1 s (0%) 406 ms (+87.39% 🔺) 1.5 s
Peer Cache Discovery 46.64 KB (0%) 933 ms (0%) 398 ms (-8.35% 🔽) 1.4 s
Privacy preserving protocols 77.26 KB (-0.07% 🔽) 1.6 s (-0.07% 🔽) 338 ms (-46.59% 🔽) 1.9 s
Waku Filter 79.76 KB (-0.08% 🔽) 1.6 s (-0.08% 🔽) 662 ms (+27.57% 🔺) 2.3 s
Waku LightPush 78.06 KB (+0.11% 🔺) 1.6 s (+0.11% 🔺) 710 ms (+59.55% 🔺) 2.3 s
History retrieval protocols 83.73 KB (-0.02% 🔽) 1.7 s (-0.02% 🔽) 640 ms (-19.23% 🔽) 2.4 s
Deterministic Message Hashing 28.98 KB (0%) 580 ms (0%) 171 ms (-0.33% 🔽) 751 ms

@danisharora099
Copy link
Collaborator Author

danisharora099 commented Nov 27, 2025

Restructured the PR:
instead of keeping PersistentHistory as a module over MemLocalHistory, now setup PersistentStorage as a separate class that can be optionally used as a storage in MemLocalHistory -- perhaps a followup will be to update the name MemLocalHistory to just History

@danisharora099 danisharora099 changed the title feat: persistent history for SDS feat(sds): persistent storage for history Nov 27, 2025
@danisharora099 danisharora099 force-pushed the feat/persistent_history branch from df7d56b to 3e3c511 Compare November 27, 2025 03:40
@danisharora099 danisharora099 marked this pull request as ready for review November 27, 2025 04:07
@danisharora099 danisharora099 requested a review from a team as a code owner November 27, 2025 04:07
Comment on lines 22 to 50
export interface ILocalHistory {
length: number;
push(...items: ContentMessage[]): number;
some(
predicate: (
value: ContentMessage,
index: number,
array: ContentMessage[]
) => unknown,
thisArg?: any
): boolean;
slice(start?: number, end?: number): ContentMessage[];
find(
predicate: (
value: ContentMessage,
index: number,
obj: ContentMessage[]
) => unknown,
thisArg?: any
): ContentMessage | undefined;
findIndex(
predicate: (
value: ContentMessage,
index: number,
obj: ContentMessage[]
) => unknown,
thisArg?: any
): number;
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does not seem to be the right place to define this interface. Also, it is the same as before (Pick<Array...).

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tackled here: #2745

Copy link
Collaborator

@fryorcraken fryorcraken left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The naming is very confusing. It is unclear what is actually related to local history, vs the local storage interfaces.

Not sure how the message channel is supposed to use the persistent storage when there is no persistent storage implementing ILocalHistory.

* at next push.
*/
export class MemLocalHistory {
export interface ILocalHistory {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

define the interface alongside the class that needs this interface, aka, message channel.

It is odd to define the interface along side one of the implementations.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ILocalHistory is being implemented by LocalHistory (prev MemLocalHistory), so it needs it as well. Would you suggest moving it to MessageChannel?

Since the concept for LocalHistory belongs in this file, I believed it's good design to keep it close to the implementation. The interface exists because of the class

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Based on your observation that it's only being used once, removed it.

}

export type MemLocalHistoryOptions = {
storage?: ChannelId | PersistentStorage;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are you doing here? Why would you use persistent storage for the Memory implementation?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, no I got it, see my recent comment on the PR.

I would not have this kind of type switching here.

You can have a storagePrefix string that is applied in any case (whether you use browser localStorage or fs). It is relevant to both.

Then, for PersistentStorage, could we instead use the package.json browser feature and have 2 files:

browser-localStorage.ts
localStorage.ts

Both of them would expert a LocalStorage (what you did with PersistentStorage class, except that for the browser one, the LocalStorage class is just a thin wrap on the browser localStorage

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, interesting, that's probably a neater solution.

this.incomingBuffer = [];
this.localHistory = localHistory;
this.localHistory =
localHistory ?? new MemLocalHistory({ storage: channelId });
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Considering that this is not an API we actually want to expose, (ReliableChannel is), then it's fine to not have default.

Comment on lines 10 to 13
export interface HistoryStorage {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is that? it does not seem to be "History" right? It's supposed to be the interface for local storage, right? Call it ILocalStorage then

@fryorcraken
Copy link
Collaborator

After more review, I now better understand what is done here:

  ┌─────────────────────────────────────────────────────────────┐
  │                     MessageChannel                          │
  │  (defaults to MemLocalHistory with channelId for storage)   │
  └──────────────────────────┬──────────────────────────────────┘
                             │
                             ▼
  ┌─────────────────────────────────────────────────────────────┐
  │                    MemLocalHistory                          │
  │  implements ILocalHistory                                   │
  │  - Manages in-memory message array                          │
  │  - Delegates persistence to PersistentStorage               │
  └──────────────────────────┬──────────────────────────────────┘
                             │ optional
                             ▼
  ┌─────────────────────────────────────────────────────────────┐
  │                   PersistentStorage                         │
  │  - Serializes ContentMessage → JSON with hex encoding       │
  │  - Uses HistoryStorage interface (localStorage-compatible)  │
  └─────────────────────────────────────────────────────────────┘

This is not the architecture I originally had in mind, which is fine. It just means that I did some pre-optimisation that now needs to be trashed.

In this proposed architecture, there is only one implementation of ILocalHistory, hence, the interface can be trashed.

MemLocalHistory persist when possible -> so it's a not a Mem history anymore and can just be called LocalHistory.

Then the last part Uses HistoryStorage interface (localStorage-compatible) -> this is confusing. the interface should be called ILocalStorage and not have the history word in it, which is a term use in the SDS domain, and local storage is not SDS-aware.

@danisharora099 danisharora099 force-pushed the feat/persistent_history branch from 434728f to 96ec51f Compare December 9, 2025 22:00
@danisharora099
Copy link
Collaborator Author

danisharora099 commented Dec 9, 2025

After more review, I now better understand what is done here:

  ┌─────────────────────────────────────────────────────────────┐
  │                     MessageChannel                          │
  │  (defaults to MemLocalHistory with channelId for storage)   │
  └──────────────────────────┬──────────────────────────────────┘
                             │
                             ▼
  ┌─────────────────────────────────────────────────────────────┐
  │                    MemLocalHistory                          │
  │  implements ILocalHistory                                   │
  │  - Manages in-memory message array                          │
  │  - Delegates persistence to PersistentStorage               │
  └──────────────────────────┬──────────────────────────────────┘
                             │ optional
                             ▼
  ┌─────────────────────────────────────────────────────────────┐
  │                   PersistentStorage                         │
  │  - Serializes ContentMessage → JSON with hex encoding       │
  │  - Uses HistoryStorage interface (localStorage-compatible)  │
  └─────────────────────────────────────────────────────────────┘

Ah, I see where the confusion came from. Thanks for the diagram (note to self to include it wherever possible), I can see how it may have been confusing since the structure changed.

MemLocalHistory persist when possible -> so it's a not a Mem history anymore and can just be called LocalHistory.

Agreed, see my comment here: #2741 (comment)

Then the last part Uses HistoryStorage interface (localStorage-compatible) -> this is confusing. the interface should be called ILocalStorage and not have the history word in it, which is a term use in the SDS domain, and local storage is not SDS-aware.

Valid point, thanks! One caveat: the class is more than just "local storage" as it allows users to pass their custom storage providers, so IStorage, perhaps?

@danisharora099 danisharora099 force-pushed the feat/persistent_history branch from 75e8e12 to e396c3b Compare December 10, 2025 20:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants