Skip to content

Conversation

@hzrd149
Copy link
Collaborator

@hzrd149 hzrd149 commented Jan 27, 2026

This PR adds a new GroupHistoryStore interface and history field to the MarmotGroup class. the interface is a generic type on the class or that the app can override it with a custom class that handles MLS message history in a custom way (better loading for UI / live events)

I then added a MarmotGroupHistoryStore group history storage class that only saves the MIP-03 rumors from the MLS messages to a backend. this is intended to be used by most marmot apps since they only care about valid nostr rumor events and not other stuff that could be sent over MLS

The chat app them implements a idb based backend for storing group rumor messages and then provides that to the MarmotClient class so it can be pass down to the MarmotGroup class for group.history

The main chat view is now updated to use a useGroupMessages hook that only uses the group.history interface to listen for new messages and load old messages (via pagination)

Summary by CodeRabbit

Release Notes

  • New Features

    • Message history pagination: Load older messages from group conversations with improved history storage and retrieval.
    • Per-account message storage with real-time rumor synchronization and event-based updates.
  • Refactor

    • Migrated group and message management from store-based to direct client-driven architecture for improved performance and flexibility.
    • Replaced callback notifications with event-based system for store updates.
  • Chores

    • Updated dependencies: React, React Router, and added idb and eventemitter3 for enhanced data storage capabilities.

✏️ Tip: You can customize this high-level summary in your review settings.

- Introduced group history management with MarmotGroupHistoryStore for persistent message storage.
- Updated group subscription logic to utilize the new history store, ensuring messages are saved and retrieved efficiently.
- Improved error handling and type safety across group-related functionalities.
- Added new dependencies and updated existing ones for better compatibility and performance.
@hzrd149 hzrd149 requested a review from gzuuus January 27, 2026 19:13
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jan 27, 2026

Walkthrough

Introduces pluggable group history with IndexedDB-backed storage for rumors, refactors stores to use EventEmitter for notifications instead of callbacks, makes MarmotClient and MarmotGroup generic over history types, adds per-account database management, replaces direct store observables with derived event streams, and updates chat components to use the new history hooks.

Changes

Cohort / File(s) Summary
Dependency Updates
chat/package.json, package.json
Added idb dependency; bumped react, react-dom, @types/react; updated react-router, nostr-tools; added eventemitter3 for event-based APIs.
History & Database Infrastructure
chat/src/lib/account-database.ts, src/client/group/group-rumor-history.ts, src/client/group/index.ts
New IndexedDB-backed rumor history layer with per-account storage, paginated loader, and GroupRumorHistory class; MultiAccountDatabaseBroker manages per-account databases and storage interfaces.
Store EventEmitter Refactoring
src/store/group-store.ts, src/store/key-package-store.ts, examples/src/lib/group-store.ts, examples/src/lib/key-package-store.ts
Replaced callback-based notifications with EventEmitter; removed onUpdate callbacks and storeChanges$ subjects; stores now emit events (clientStateAdded, keyPackageRemoved, etc.).
Client Architecture & Generics
src/client/marmot-client.ts, src/client/group/marmot-group.ts
Made MarmotClient and MarmotGroup generic over history types; added caching layer for groups; introduced historyFactory for per-group history instantiation; added new lifecycle events and state management.
Chat Library Updates
chat/src/lib/lifecycle.ts, chat/src/lib/runtime.ts, chat/src/lib/group-subscription-manager.ts, chat/src/lib/invitation-inbox-manager.ts, chat/src/lib/marmot-client.ts
Refactored to use account-driven marmotClient$, added liveKeyPackages$ and liveGroups$ observables, updated manager initialization to use client directly, adjusted type signatures for MarmotGroup.
Removed Chat Store Observables
chat/src/lib/group-store.ts, chat/src/lib/key-package-store.ts
Removed entire modules exporting groupStore$, groupCount$, keyPackageStore$ observables and related mutation tracking; functionality migrated to per-account database broker.
New Chat Hook & Components
chat/src/hooks/use-group-messages.ts, chat/src/pages/groups/[id].tsx
Added useGroupMessages hook for paginated message loading and live rumor subscription; refactored group detail page with MessageForm and MessageList components using the new hook.
Chat UI Page Updates
chat/src/pages/groups.tsx, chat/src/pages/groups/create.tsx, chat/src/pages/key-packages.tsx, chat/src/pages/key-packages/[id].tsx, chat/src/pages/key-packages/create.tsx, chat/src/pages/contacts/[npub].tsx
Switched from direct store subscriptions to derived liveGroups$ and marmotClient$; updated group removal to use client.destroyGroup(); refactored key package pages to use client interfaces; replaced group-store with marmot-client data sources.
Examples & Tests Updates
examples/src/App.tsx, examples/src/components/error-boundary.tsx, examples/src/examples/group/create.tsx, src/__tests__/\\*
Updated error handling to safely stringify unknown errors; refactored group creation to work with returned group object instead of groupId; updated test imports and removed redundant imports; added type-specific imports.
Type Exports & Utilities
src/index.ts, src/core/group-message.ts
Exported GroupRumorHistory; added validation in deserializeApplicationRumor; updated InvitationInboxManagerOptions.signer type to use EventSigner.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.63% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Message history' clearly and concisely summarizes the main objective of the PR: adding message history functionality to the MarmotGroup class and supporting infrastructure.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
chat/package.json (1)

28-52: @types/react@19.2.10 does not exist; use 19.2.9 instead. Verify idb@8.0.3 breaking changes.

  • @types/react@19.2.10 is not a published version (latest available is 19.2.9 from Jan 20, 2026). Update the version specification.
  • idb@8.0.3 includes breaking changes: dropped support for legacy EdgeHTML-based Microsoft Edge and browsers without IDBCursor.request support; removed the separate async iterators build. Verify these changes are compatible with your target browser support.
  • react@19.2.4, react-dom@19.2.4, react-router@7.13.0 are routine security/patch updates with no breaking changes.
  • Ensure the lockfile is updated to reflect these changes.
🤖 Fix all issues with AI agents
In `@chat/src/hooks/use-group-messages.ts`:
- Around line 20-27: The hook useGroupMessages currently omits addNewMessages
from its returned object despite JSDoc/examples showing it; update the return
type and the actual returned object of useGroupMessages to include
addNewMessages (the function that performs optimistic insertion of Rumor
messages into the group state), export or inline the existing addNewMessages
implementation used elsewhere in the hook, and ensure the signature matches the
documented API so callers can call addNewMessages alongside messages,
loadMoreMessages, loadingMore, and loadingDone.
- Around line 51-58: The loadMoreMessages callback can leave loadingMore stuck
true if paginatedLoader.next() throws; wrap the await call and subsequent logic
in a try/finally so setLoadingMore(false) always runs. Specifically, inside
loadMoreMessages (which references paginatedLoader, addNewMessages,
setLoadingDone) set loadingMore to true before the try, execute const page =
await paginatedLoader.next() and calls to addNewMessages(page.value) /
setLoadingDone(page.done) inside the try, and call setLoadingMore(false) in the
finally block to ensure the UI resets even on errors.
- Around line 73-81: The live "rumor" listener in useEffect currently appends
directly via setMessages which can cause duplicates/order issues; replace the
inline setMessages call inside the listener to call the existing addNewMessages
function (passing the incoming Rumor or an array containing it) so the same
deduplication and sorting logic used by pagination is applied; keep the listener
registration and cleanup on group.history.addListener("rumor", listener) and
removeListener unchanged but update the listener implementation to invoke
addNewMessages instead of setMessages.

In `@chat/src/lib/group-store.ts`:
- Around line 43-52: Update the JSDoc comment above groupHistoryStore$ to
correct the typo: replace "Group a group history store" with "Create a group
history store" so the comment clearly describes the purpose of the exported
observable that builds the MarmotGroupHistoryStore/IdbGroupHistoryStore based on
accountManager.active$.

In `@chat/src/lib/idb-group-history.ts`:
- Around line 62-76: The IDBKeyRange queries currently include the `until`
timestamp (making it inclusive) which violates the documented exclusive
semantics; update the `IDBKeyRange` calls that set an upper bound so the upper
bound is open/exclusive: in the branch using IDBKeyRange.bound([...], [...],
false, false) change the final argument to true, and in the branch using
IDBKeyRange.upperBound([groupKey, until], false) change the second argument to
true; keep the `since` lower-bound behavior unchanged. Ensure you modify the
usages around the variables `range`, `since`, `until`, `groupKey` and the calls
to `IDBKeyRange.bound` and `IDBKeyRange.upperBound`.

In `@chat/src/lib/runtime.ts`:
- Around line 28-45: The subscriber passed to marmotClient$.subscribe must
serialize start/stop operations and catch exceptions: add a small per-subscriber
guard (e.g., an in-scope boolean "running" or an incrementing "opId"/"seq"
token) to ensure that concurrent emissions cannot run overlapping await
groupMgr.start()/inviteMgr.start() or groupMgr.stop()/inviteMgr.stop() calls,
and wrap the entire async callback body in try-catch to handle and log errors;
locate the subscriber where marmotClient$.subscribe is called and apply this
guard around the branches that construct/stop GroupSubscriptionManager and
InvitationInboxManager and around await
groupMgr.start()/inviteMgr.start()/groupMgr.stop()/inviteMgr.stop() so only the
latest emission performs actions and exceptions are caught instead of leaking as
unhandled rejections.

In `@src/client/marmot-client.ts`:
- Around line 50-51: Fix the JSDoc typo "instaces" -> "instances" for the
group-related comments: update the comment above the groupHistory property (the
JSDoc for groupHistory in marmot-client.ts referencing
MarmotGroupOptions<THistoryStore>["history"]) and the other JSDoc occurrence
around lines 78-79 that refers to group instances so both comments read
"instances".

In `@src/store/marmot-group-history-store.ts`:
- Around line 87-121: The final page is being returned instead of yielded in
createPaginatedLoader, so for-await consumers miss it; change the logic so that
when a non-empty page has rumors.length < limit you yield that page first and
then return/stop the generator (e.g., replace the if (rumors.length < limit) {
return rumors; } with if (rumors.length < limit) { yield rumors; return; }),
keeping the existing early return for the empty page case (rumors.length === 0)
unchanged.
🧹 Nitpick comments (8)
chat/src/lib/invitation-inbox-manager.ts (1)

2-6: Use import type for EventSigner.

EventSigner is used only as a type annotation on line 28, so it should be imported with the type keyword to align with TypeScript conventions and project guidelines.

♻️ Suggested change
-import { EventSigner, mapEventsToTimeline } from "applesauce-core";
+import { mapEventsToTimeline } from "applesauce-core";
+import type { EventSigner } from "applesauce-core";
chat/src/pages/groups/create.tsx (1)

1-32: Move the RxJS import into the external-import block.
Keep external libs together before alias imports to match the project convention.

♻️ Suggested import reordering
 import { bytesToHex } from "@noble/hashes/utils.js";
 import { use$ } from "applesauce-react/hooks";
 import { Loader2, XCircle } from "lucide-react";
 import {
   CompleteKeyPackage,
   createCredential,
   generateKeyPackage,
 } from "marmot-ts";
 import { useEffect, useMemo, useState } from "react";
 import { useNavigate } from "react-router";
+import { of, switchMap } from "rxjs";
 import type { CiphersuiteName, KeyPackage } from "ts-mls";
 import {
   defaultCryptoProvider,
   getCiphersuiteFromName,
   getCiphersuiteImpl,
 } from "ts-mls";
 
 import { PubkeyListCreator } from "@/components/form/pubkey-list-creator";
@@
 import { marmotClient$ } from "@/lib/marmot-client";
 import { extraRelays$ } from "@/lib/settings";
-import { of, switchMap } from "rxjs";

As per coding guidelines, external imports should appear before alias imports.

chat/src/lib/group-subscription-manager.ts (1)

3-11: Use type keyword for type-only imports.

Per coding guidelines, MarmotGroup and MarmotGroupHistoryStore are used only as type annotations in this file and should be imported with the type keyword.

♻️ Suggested fix
 import {
   deserializeApplicationRumor,
   getNostrGroupIdHex,
   GROUP_EVENT_KIND,
   MarmotClient,
-  MarmotGroup,
-  MarmotGroupHistoryStore,
 } from "marmot-ts";
+import type { MarmotGroup, MarmotGroupHistoryStore } from "marmot-ts";
 import { BehaviorSubject, Subscription } from "rxjs";
chat/src/lib/group-store.ts (1)

44-49: Redundant optional chaining.

On line 47, account?.pubkey uses optional chaining, but account is already guaranteed to be truthy by the account && guard on line 46. Consider using account.pubkey for consistency.

♻️ Suggested fix
 export const groupHistoryStore$ = accountManager.active$.pipe(
   map((account) =>
     account && MarmotGroupHistoryStore.makeGroupHistoryInterface(
-      new IdbGroupHistoryStore(`${account?.pubkey}-group-history`),
+      new IdbGroupHistoryStore(`${account.pubkey}-group-history`),
     )
   ),
src/store/marmot-group-history-store.ts (1)

47-55: Silent error swallowing may hide deserialization issues.

The catch block silently swallows errors without any logging. While it's reasonable to skip non-rumor messages, silently discarding errors could mask legitimate bugs or unexpected data formats during development.

♻️ Suggested fix
 async saveMessage(message: Uint8Array): Promise<void> {
   try {
     const rumor = deserializeApplicationRumor(message);
     await this.saveRumor(rumor);
   } catch (error) {
-    // Failed to read rumor, skip saving
+    // Failed to parse as MIP-03 rumor, skip saving
+    // This is expected for non-rumor application messages
   }
 }

The current behavior is acceptable if non-rumor messages are expected. If debugging is needed later, consider adding a debug-level log.

chat/src/pages/groups/[id].tsx (1)

115-123: Unused props in MessageListProps interface.

The props loadMoreMessages, loadingMore, and loadingDone are defined in MessageListProps but are not destructured or used in the MessageList component. The load-more button is rendered outside the component (lines 649-662). Consider removing these unused props from the interface.

♻️ Suggested fix
 interface MessageListProps {
   messages: Rumor[];
   currentUserPubkey: string | null;
-  loadMoreMessages?: () => Promise<void>;
-  loadingMore?: boolean;
-  loadingDone?: boolean;
 }

 function MessageList({ messages, currentUserPubkey }: MessageListProps) {
src/client/group/marmot-group.ts (2)

205-209: Consider adding braces for consistency.

The else branch lacks braces while the if branch has them. This is a minor style inconsistency.

♻️ Suggested fix
     // Create the history store
     if (typeof options.history === "function") {
       this.history = options.history(this.id);
-    } else this.history = options.history;
+    } else {
+      this.history = options.history;
+    }

746-752: History save failure could block message processing.

If this.history.saveMessage() throws an error, it will propagate up and potentially halt the entire ingest() operation. Consider wrapping in try-catch to ensure message processing continues even if history persistence fails.

♻️ Suggested fix
           // Save application message to history
           if (this.history) {
-            await this.history.saveMessage(this.id, result.message);
+            try {
+              await this.history.saveMessage(this.id, result.message);
+            } catch (historyError) {
+              console.warn(
+                `[MarmotGroup.ingest] Failed to save message to history:`,
+                historyError,
+              );
+            }
           }

Comment on lines +51 to +58
const loadMoreMessages = useCallback(async () => {
if (!paginatedLoader) return;
setLoadingMore(true);
const page = await paginatedLoader.next();
addNewMessages(page.value);
if (page.done) setLoadingDone(page.done);
setLoadingMore(false);
}, [paginatedLoader, addNewMessages]);
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Ensure loadingMore is reset if paging fails.

If paginatedLoader.next() throws, loadingMore stays true. Wrap in try/finally so the UI can recover. As per coding guidelines.

🛡️ Suggested fix
   const loadMoreMessages = useCallback(async () => {
     if (!paginatedLoader) return;
     setLoadingMore(true);
-    const page = await paginatedLoader.next();
-    addNewMessages(page.value);
-    if (page.done) setLoadingDone(page.done);
-    setLoadingMore(false);
+    try {
+      const page = await paginatedLoader.next();
+      addNewMessages(page.value);
+      if (page.done) setLoadingDone(true);
+    } finally {
+      setLoadingMore(false);
+    }
   }, [paginatedLoader, addNewMessages]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const loadMoreMessages = useCallback(async () => {
if (!paginatedLoader) return;
setLoadingMore(true);
const page = await paginatedLoader.next();
addNewMessages(page.value);
if (page.done) setLoadingDone(page.done);
setLoadingMore(false);
}, [paginatedLoader, addNewMessages]);
const loadMoreMessages = useCallback(async () => {
if (!paginatedLoader) return;
setLoadingMore(true);
try {
const page = await paginatedLoader.next();
addNewMessages(page.value);
if (page.done) setLoadingDone(true);
} finally {
setLoadingMore(false);
}
}, [paginatedLoader, addNewMessages]);
🤖 Prompt for AI Agents
In `@chat/src/hooks/use-group-messages.ts` around lines 51 - 58, The
loadMoreMessages callback can leave loadingMore stuck true if
paginatedLoader.next() throws; wrap the await call and subsequent logic in a
try/finally so setLoadingMore(false) always runs. Specifically, inside
loadMoreMessages (which references paginatedLoader, addNewMessages,
setLoadingDone) set loadingMore to true before the try, execute const page =
await paginatedLoader.next() and calls to addNewMessages(page.value) /
setLoadingDone(page.done) inside the try, and call setLoadingMore(false) in the
finally block to ensure the UI resets even on errors.

Comment on lines 43 to 52
/** Group a group history store based on the active account */
export const groupHistoryStore$ = accountManager.active$.pipe(
map((account) =>
account && MarmotGroupHistoryStore.makeGroupHistoryInterface(
new IdbGroupHistoryStore(`${account?.pubkey}-group-history`),
)
),
// Only create a single instance of the group history store
shareReplay(1),
);
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Fix typo in JSDoc comment.

The JSDoc comment has a typo: "Group a group history store" should be "Create a group history store".

📝 Suggested fix
-/** Group a group history store based on the active account */
+/** Create a group history store based on the active account */
 export const groupHistoryStore$ = accountManager.active$.pipe(
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/** Group a group history store based on the active account */
export const groupHistoryStore$ = accountManager.active$.pipe(
map((account) =>
account && MarmotGroupHistoryStore.makeGroupHistoryInterface(
new IdbGroupHistoryStore(`${account?.pubkey}-group-history`),
)
),
// Only create a single instance of the group history store
shareReplay(1),
);
/** Create a group history store based on the active account */
export const groupHistoryStore$ = accountManager.active$.pipe(
map((account) =>
account && MarmotGroupHistoryStore.makeGroupHistoryInterface(
new IdbGroupHistoryStore(`${account?.pubkey}-group-history`),
)
),
// Only create a single instance of the group history store
shareReplay(1),
);
🤖 Prompt for AI Agents
In `@chat/src/lib/group-store.ts` around lines 43 - 52, Update the JSDoc comment
above groupHistoryStore$ to correct the typo: replace "Group a group history
store" with "Create a group history store" so the comment clearly describes the
purpose of the exported observable that builds the
MarmotGroupHistoryStore/IdbGroupHistoryStore based on accountManager.active$.

Comment on lines 28 to 45
marmotClient$.subscribe(
async (client) => {
if (!client) {
// Client is not created, stop the managers
groupMgr?.stop();
inviteMgr?.stop();

groupMgr = null;
inviteMgr = null;
} else {
// Client is created, start the managers
groupMgr ??= new GroupSubscriptionManager(client);
inviteMgr ??= new InvitationInboxManager({ signer: client.signer });

await groupMgr.start();
await inviteMgr.start();
}
},
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

RxJS subscribe async callback behavior multiple emissions error handling

💡 Result:

1) subscribe(async value => …) does not “await” between emissions

subscribe treats your next handler as a normal callback; its return value is ignored (including a returned Promise). So if the source emits A, B, C quickly, RxJS will call your handler for A/B/C immediately; it will not wait for the await in the prior call to finish. This is why Subject.next() (and other sources) won’t “backpressure” on an async subscriber. [2]

Also, callbacks passed to subscribe are not guaranteed to be async; an Observable may call them synchronously. [1]

Implication: you can easily get overlapping in-flight async work and out-of-order completion.


2) Error handling: “Observable errors” vs “errors inside your async next

A) Errors emitted by the Observable

If the Observable sends an error notification, your subscribe({ error }) handler runs; if you don’t provide one, the error is effectively unhandled. [1]

To recover and keep a longer-lived stream alive, handle errors in the pipeline with catchError (e.g., return a fallback Observable/value). This is something the subscribe error callback can’t do because it’s already terminal. [3]

B) Errors thrown/rejected inside an async next handler

If you write:

source$.subscribe(async x => {
  await doWork(x);
  throw new Error('boom');
});

that throw becomes a rejected Promise. RxJS won’t automatically route that rejection into the Observable’s error channel, so you can get an unhandled promise rejection instead. (In Node.js, unhandled rejections are treated as a serious unhandled error and have had escalating severity over time.) [5]

So you must handle it yourself:

source$.subscribe(x => {
  void (async () => {
    try { await doWork(x); }
    catch (e) { /* handle/report */ }
  })();
});

3) The RxJS-native way: move async work into the stream

Instead of subscribe(async …), project each value to an Observable/Promise and control concurrency:

Sequential (wait for each):

source$.pipe(
  concatMap(x => from(doWork(x))),   // doWork returns Promise
  catchError(err => of(/* fallback */))
).subscribe();

Concurrent (allow overlap):

source$.pipe(
  mergeMap(x => from(doWork(x)))
).subscribe();

This way, errors propagate through RxJS and can be handled with catchError, and you get explicit control over ordering and concurrency. [3]


🏁 Script executed:

cat -n chat/src/lib/runtime.ts | head -60

Repository: marmot-protocol/marmot-ts

Length of output: 1830


Guard async start/stop against overlapping emissions and add error handling.

When marmotClient$ emits multiple times rapidly (e.g., during login/logout cycles), the async subscriber will invoke start() and stop() calls concurrently without waiting for prior operations to complete. Additionally, errors thrown inside the async callback become unhandled promise rejections instead of being caught by RxJS, which violates the coding guideline to use try-catch blocks for expected failures.

✅ One way to serialize and guard starts
 let groupMgr: GroupSubscriptionManager | null = null;
 let inviteMgr: InvitationInboxManager | null = null;
+let runId = 0;
 
 marmotClient$.subscribe(
-  async (client) => {
-    if (!client) {
-      // Client is not created, stop the managers
-      groupMgr?.stop();
-      inviteMgr?.stop();
-
-      groupMgr = null;
-      inviteMgr = null;
-    } else {
-      // Client is created, start the managers
-      groupMgr ??= new GroupSubscriptionManager(client);
-      inviteMgr ??= new InvitationInboxManager({ signer: client.signer });
-
-      await groupMgr.start();
-      await inviteMgr.start();
-    }
-  },
+  (client) => {
+    const token = ++runId;
+    (async () => {
+      try {
+        if (!client) {
+          groupMgr?.stop();
+          inviteMgr?.stop();
+          groupMgr = null;
+          inviteMgr = null;
+          return;
+        }
+
+        groupMgr ??= new GroupSubscriptionManager(client);
+        inviteMgr ??= new InvitationInboxManager({ signer: client.signer });
+
+        await groupMgr.start();
+        if (token !== runId) return;
+        await inviteMgr.start();
+      } catch (err) {
+        console.error("Failed to start runtime managers", err);
+      }
+    })();
+  },
 );
🤖 Prompt for AI Agents
In `@chat/src/lib/runtime.ts` around lines 28 - 45, The subscriber passed to
marmotClient$.subscribe must serialize start/stop operations and catch
exceptions: add a small per-subscriber guard (e.g., an in-scope boolean
"running" or an incrementing "opId"/"seq" token) to ensure that concurrent
emissions cannot run overlapping await groupMgr.start()/inviteMgr.start() or
groupMgr.stop()/inviteMgr.stop() calls, and wrap the entire async callback body
in try-catch to handle and log errors; locate the subscriber where
marmotClient$.subscribe is called and apply this guard around the branches that
construct/stop GroupSubscriptionManager and InvitationInboxManager and around
await groupMgr.start()/inviteMgr.start()/groupMgr.stop()/inviteMgr.stop() so
only the latest emission performs actions and exceptions are caught instead of
leaking as unhandled rejections.

Comment on lines 50 to 51
/** The group history interface to be passed to group instaces */
groupHistory: MarmotGroupOptions<THistoryStore>["history"];
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Fix typo in JSDoc comments.

"instaces" should be "instances" (appears on lines 51 and 78).

📝 Suggested fix
-  /** The group history interface to be passed to group instaces */
+  /** The group history interface to be passed to group instances */
   groupHistory: MarmotGroupOptions<THistoryStore>["history"];

And similarly for line 78-79:

-  /** Group history interface to be passed to group instaces */
+  /** Group history interface to be passed to group instances */
   private groupHistory: MarmotGroupOptions<THistoryStore>["history"];
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/** The group history interface to be passed to group instaces */
groupHistory: MarmotGroupOptions<THistoryStore>["history"];
/** The group history interface to be passed to group instances */
groupHistory: MarmotGroupOptions<THistoryStore>["history"];
🤖 Prompt for AI Agents
In `@src/client/marmot-client.ts` around lines 50 - 51, Fix the JSDoc typo
"instaces" -> "instances" for the group-related comments: update the comment
above the groupHistory property (the JSDoc for groupHistory in marmot-client.ts
referencing MarmotGroupOptions<THistoryStore>["history"]) and the other JSDoc
occurrence around lines 78-79 that refers to group instances so both comments
read "instances".

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
chat/src/lib/invitation-inbox-manager.ts (1)

2-29: Use type modifier for EventSigner import.

EventSigner is only used in a type position (InvitationInboxManagerOptions on line 29), so import it with the type keyword to align with the coding guideline for this file pattern.

♻️ Suggested change
-import { EventSigner, mapEventsToTimeline } from "applesauce-core";
+import { mapEventsToTimeline, type EventSigner } from "applesauce-core";
chat/src/pages/key-packages/[id].tsx (1)

383-397: Potential undefined publicPackage passed to button components.

If decodeKeyPackage returns null (line 297), publicPackage will be undefined. The type assertion as KeyPackage on lines 387 and 391 masks this, potentially passing undefined to PublishKeyPackageButton and BroadcastKeyPackageButton, which could cause runtime errors.

🐛 Suggested fix: guard against undefined publicPackage
           <CardFooter className="flex gap-2">
-            {isLocal ? (
+            {isLocal && publicPackage ? (
               <>
                 <PublishKeyPackageButton
                   event={event}
-                  keyPackage={publicPackage as KeyPackage}
+                  keyPackage={publicPackage}
                 />
                 <BroadcastKeyPackageButton
                   event={event}
-                  keyPackage={publicPackage as KeyPackage}
+                  keyPackage={publicPackage}
                 />
                 <DeleteKeyPackageButton
                   event={event}
                   keyPackageRef={keyPackage.keyPackageRef}
                 />
               </>
+            ) : isLocal ? (
+              <div className="text-sm text-muted-foreground">
+                Failed to decode key package data.
+              </div>
             ) : (
src/store/key-package-store.ts (1)

41-55: Update JSDoc example to reflect new keyPackageTls API.

The example in the class documentation (line 46) shows await store.add({ publicPackage, privatePackage }), but the API now expects { keyPackageTls, privatePackage }. This could confuse consumers.

📝 Suggested fix
  * `@example`
  * ```typescript
  * const store = new KeyPackageStore(backend);
  *
  * // Add a complete key package
- * await store.add({ publicPackage, privatePackage });
+ * await store.add({ keyPackageTls: encodeKeyPackage(publicPackage), privatePackage });
  * // List all key packages
  * const packages = await store.list();
src/client/group/marmot-group.ts (1)

853-858: Advance history cursor even when a commit is rejected.

Currently actionTaken === "reject" short-circuits without marking the outer event as processed, which can stall resume cursors and cause repeated reprocessing of a permanently invalid commit.

🛠️ Suggested fix
-          if (result.actionTaken === "reject") {
-            continue;
-          }
+          if (result.actionTaken === "reject") {
+            if (this.history) {
+              const outer: OuterCursor = {
+                created_at: event.created_at,
+                id: event.id,
+              };
+              try {
+                await this.history.markOuterEventProcessed(outer);
+              } catch {
+                // best-effort
+              }
+            }
+            continue;
+          }
🤖 Fix all issues with AI agents
In `@chat/src/lib/group-subscription-manager.ts`:
- Around line 239-260: The code assumes group.history exists before calling
group.history.getResumeCursor(); add a defensive null/undefined guard for
group.history (e.g., if (!group.history) { /* skip backfill: set resume
undefined or return/continue */ }) so you don't call getResumeCursor on
undefined; ensure downstream logic that uses resume (the filters.since
assignment and events filtering) works when resume is undefined by preserving
the existing behavior of treating resume as absent.

In `@chat/src/pages/groups/`[id].tsx:
- Around line 529-531: The Tailwind class string on the <pre> element containing
jsonStringifySafe(groupDetails.state) uses an invalid utility
"wrap-break-words"; replace that token with the standard Tailwind class
"break-all" in the className for the <pre> element so it becomes "text-xs
whitespace-pre-wrap break-all border rounded p-3 bg-muted/30".

In `@src/__tests__/ingest-watermark-mixed-messages.test.ts`:
- Around line 79-81: Update MemoryHistoryStore.queryRumors to match the
GroupHistoryStore signature by adding the required filter parameter and
appropriate type (e.g., change async queryRumors(): Promise<Rumor[]> to async
queryRumors(filter: RumorFilter): Promise<Rumor[]> or the exact filter type used
by GroupHistoryStore), and implement a minimal body (for tests you can ignore
the filter and return an empty Rumor[] or forward the filter to the real logic)
so the method conforms to the interface; reference
MemoryHistoryStore.queryRumors (and GroupHistoryStore.queryRumors) when making
the change.
🧹 Nitpick comments (11)
chat/src/lib/lifecycle.ts (2)

84-95: Consider logging parse failures for debugging.

The catch block silently swallows errors, which makes it difficult to diagnose issues with malformed key package events. While filtering out invalid events is correct behavior, a warning log would help identify problems during development.

🔧 Suggested improvement
     const parseEvent = async (event: NostrEvent) => {
       try {
         const keyPackage = getKeyPackage(event);
         const keyPackageRef = await calculateKeyPackageRef(
           keyPackage,
           client?.cryptoProvider,
         );
         return { event, keyPackage, keyPackageRef } as PublishedKeyPackage;
-      } catch {
+      } catch (error) {
+        console.warn(`Failed to parse key package event ${event.id}:`, error);
         return null;
       }
     };

106-111: Deduplication efficiency could be improved with a Map.

The current Array.some() lookup is O(n) per event. For small key package counts this is fine, but a Map-based approach would be O(1) and prevent subtle performance degradation as the array grows.

🔧 Optional optimization using Map
-    const published = merge(existing$, updates$).pipe(
-      scan((acc, curr) => {
-        const seen = acc.some((p) => p.event.id === curr.event.id);
-        return seen ? acc : [...acc, curr];
-      }, [] as PublishedKeyPackage[]),
-    );
+    const published = merge(existing$, updates$).pipe(
+      scan(
+        (acc, curr) => {
+          if (acc.seen.has(curr.event.id)) return acc;
+          acc.seen.add(curr.event.id);
+          return { seen: acc.seen, list: [...acc.list, curr] };
+        },
+        { seen: new Set<string>(), list: [] as PublishedKeyPackage[] },
+      ),
+      map((acc) => acc.list),
+    );
chat/src/pages/key-packages/[id].tsx (1)

33-35: Import ordering: external library imports should come before path alias imports.

The decodeKeyPackage import from ts-mls/keyPackage.js is an external library import and should be placed with the other external imports (lines 1-13), not after the @/ path alias imports.

♻️ Suggested fix

Move the import up to line 13 (after the other ts-mls import):

 import { KeyPackage } from "ts-mls";
+import { decodeKeyPackage } from "ts-mls/keyPackage.js";
 import accounts, { publish, user$ } from "../../lib/accounts";
-import { decodeKeyPackage } from "ts-mls/keyPackage.js";

As per coding guidelines: "Order imports as: external libraries first, then path alias imports (@/*), then relative imports"

chat/src/pages/key-packages.tsx (2)

4-6: Import ordering: group external library imports together.

The decodeKeyPackage import from ts-mls/keyPackage.js should be grouped with other external library imports, separate from React imports.

♻️ Suggested fix
 import { bytesToHex, relaySet, type NostrEvent } from "applesauce-core/helpers";
 import { use$ } from "applesauce-react/hooks";
 import { Plus } from "lucide-react";
 import { getKeyPackageClient, ListedKeyPackage } from "marmot-ts";
+import { decodeKeyPackage } from "ts-mls/keyPackage.js";
 import { useMemo } from "react";
-import { decodeKeyPackage } from "ts-mls/keyPackage.js";

As per coding guidelines: "Order imports as: external libraries first, then path alias imports (@/*), then relative imports"


72-78: Consider memoizing the decoded key package to avoid repeated parsing.

The IIFE decodes keyPackageTls on every render. While likely fast, this could be optimized by memoizing the result, especially since KeyPackageItem is rendered for each item in a list.

♻️ Suggested refactor
 function KeyPackageItem({
   keyPackage,
   event,
 }: {
   keyPackage: ListedKeyPackage & { isLocal?: boolean };
   event?: NostrEvent;
 }) {
   const location = useLocation();

   const linkTo = useMemo(
     () => `/key-packages/${bytesToHex(keyPackage.keyPackageRef)}`,
     [keyPackage.keyPackageRef],
   );
   const isActive = location.pathname === linkTo;

   const client = event ? getKeyPackageClient(event) : undefined;
   const timeAgo = event ? formatTimeAgo(event.created_at) : "Unpublished";

+  const cipherSuiteId = useMemo(() => {
+    const decoded = decodeKeyPackage(keyPackage.keyPackageTls, 0);
+    return decoded?.[0]?.cipherSuite;
+  }, [keyPackage.keyPackageTls]);

   return (
     <Link
       ...
       <div className="flex items-center gap-2">
-        {(() => {
-          const decoded = decodeKeyPackage(keyPackage.keyPackageTls, 0);
-          const [publicPackage] = decoded ?? [];
-          return publicPackage ? (
-            <CipherSuiteBadge cipherSuite={publicPackage.cipherSuite} />
-          ) : null;
-        })()}
+        {cipherSuiteId !== undefined && (
+          <CipherSuiteBadge cipherSuite={cipherSuiteId} />
+        )}
       </div>
src/store/marmot-group-history-store.ts (1)

115-128: Consider logging failed message parsing for debugging purposes.

The saveMessage method silently swallows parsing errors (line 125-126). While the intent to skip invalid messages is documented, completely suppressing errors can make debugging difficult. Consider logging at debug level.

♻️ Suggested enhancement
   async saveMessage(message: Uint8Array): Promise<void> {
     try {
       const rumor = deserializeApplicationRumor(message);
       // Best-effort placeholder: use rumor metadata as a stable, composite cursor.
       // This avoids timestamp-only pagination loss.
       const outer: OuterCursor = {
         created_at: rumor.created_at,
         id: rumor.id,
       };
       await this.addRumor({ rumor, outer });
-    } catch {
+    } catch (err) {
       // Failed to read rumor, skip saving
+      // Uncomment for debugging: console.debug("saveMessage: failed to parse rumor", err);
     }
   }
src/store/key-package-store.ts (1)

113-115: Consider extracting repeated decode-and-validate pattern to a private helper.

The same decode pattern appears in ensureKeyPackageRef (lines 113-115), add (lines 142-144), and getPublicKey (lines 181-183). Extracting to a helper would reduce duplication and centralize error handling.

♻️ Suggested refactor
// Add as a private method:
private decodePublicPackage(keyPackageTls: Uint8Array): KeyPackage {
  const decoded = decodeKeyPackage(keyPackageTls, 0);
  if (!decoded) {
    throw new Error("Failed to decode key package TLS bytes");
  }
  return decoded[0];
}

// Then simplify usages:
// In ensureKeyPackageRef:
const publicPackage = this.decodePublicPackage(keyPackage.keyPackageTls);

// In add:
const publicPackage = this.decodePublicPackage(keyPackage.keyPackageTls);

// In getPublicKey:
return this.decodePublicPackage(stored.keyPackageTls);

Also applies to: 142-144, 181-183

chat/src/pages/groups/create.tsx (1)

1-33: Reorder imports to keep external libs grouped.
rxjs should sit with other external imports before alias imports.

♻️ Proposed diff
 import { useEffect, useMemo, useState } from "react";
 import { useNavigate } from "react-router";
 import type { CiphersuiteName, KeyPackage } from "ts-mls";
 import {
   defaultCryptoProvider,
   getCiphersuiteFromName,
   getCiphersuiteImpl,
 } from "ts-mls";
 import { decodeKeyPackage } from "ts-mls/keyPackage.js";
+import { of, switchMap } from "rxjs";
 
 import { PubkeyListCreator } from "@/components/form/pubkey-list-creator";
 import { RelayListCreator } from "@/components/form/relay-list-creator";
@@
 import { marmotClient$ } from "@/lib/marmot-client";
 import { extraRelays$ } from "@/lib/settings";
-import { of, switchMap } from "rxjs";

As per coding guidelines, “Order imports as: external libraries first, then path alias imports (@/*), then relative imports”.

chat/src/pages/groups/[id].tsx (3)

115-123: Unused props in MessageListProps interface.

loadMoreMessages, loadingMore, and loadingDone are declared in the interface but not destructured or used in the MessageList component. The load-more button is rendered separately in the parent. Consider removing these props from the interface if they're not intended for use here.

♻️ Option 1: Remove unused props
 interface MessageListProps {
   messages: Rumor[];
   currentUserPubkey: string | null;
-  loadMoreMessages?: () => Promise<void>;
-  loadingMore?: boolean;
-  loadingDone?: boolean;
 }

287-293: Frequent markGroupSeen calls on every message.

The effect runs on every messages.length change, calling markGroupSeen(groupIdHex, unixNow()) each time. This may be intentional for keeping the group marked as seen while viewing, but consider if this is necessary for every single message arrival.


655-668: Consider error handling for loadMoreMessages.

If loadMoreMessages throws, the error is not caught or displayed. Consider wrapping in try-catch with user feedback.

🛡️ Suggested error handling
-              <Button onClick={loadMoreMessages} disabled={loadingMore}>
+              <Button 
+                onClick={async () => {
+                  try {
+                    await loadMoreMessages();
+                  } catch (err) {
+                    console.error('Failed to load older messages:', err);
+                  }
+                }} 
+                disabled={loadingMore}
+              >

Comment on lines 239 to 260
try {
const filters = {
const resume = await group.history.getResumeCursor();

const filters: Filter = {
kinds: [GROUP_EVENT_KIND],
"#h": [groupIdHex],
};

const events = await this.client.network.request(relays, filters);
// Best-effort incremental backfill: relays support `since` by timestamp only.
// We still apply composite `(created_at, id)` inclusivity locally.
if (resume) {
filters.since = resume.created_at;
}

const eventsAll = await this.client.network.request(relays, filters);
const events = resume
? eventsAll.filter(
(e) =>
e.created_at > resume.created_at ||
(e.created_at === resume.created_at && e.id > resume.id),
)
: eventsAll;
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Add null check for group.history before accessing.

group.history could be undefined. While the type constraint MarmotGroup<MarmotGroupHistoryStore> suggests it should exist, adding a guard improves robustness.

🛡️ Suggested defensive check
   try {
+     if (!group.history) {
+       console.warn(`[GroupSubscriptionManager] No history store for group ${groupIdHex}`);
+       return;
+     }
      const resume = await group.history.getResumeCursor();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try {
const filters = {
const resume = await group.history.getResumeCursor();
const filters: Filter = {
kinds: [GROUP_EVENT_KIND],
"#h": [groupIdHex],
};
const events = await this.client.network.request(relays, filters);
// Best-effort incremental backfill: relays support `since` by timestamp only.
// We still apply composite `(created_at, id)` inclusivity locally.
if (resume) {
filters.since = resume.created_at;
}
const eventsAll = await this.client.network.request(relays, filters);
const events = resume
? eventsAll.filter(
(e) =>
e.created_at > resume.created_at ||
(e.created_at === resume.created_at && e.id > resume.id),
)
: eventsAll;
try {
if (!group.history) {
throw new Error(`[GroupSubscriptionManager] No history store for group ${groupIdHex}`);
}
const resume = await group.history.getResumeCursor();
const filters: Filter = {
kinds: [GROUP_EVENT_KIND],
"#h": [groupIdHex],
};
// Best-effort incremental backfill: relays support `since` by timestamp only.
// We still apply composite `(created_at, id)` inclusivity locally.
if (resume) {
filters.since = resume.created_at;
}
const eventsAll = await this.client.network.request(relays, filters);
const events = resume
? eventsAll.filter(
(e) =>
e.created_at > resume.created_at ||
(e.created_at === resume.created_at && e.id > resume.id),
)
: eventsAll;
🤖 Prompt for AI Agents
In `@chat/src/lib/group-subscription-manager.ts` around lines 239 - 260, The code
assumes group.history exists before calling group.history.getResumeCursor(); add
a defensive null/undefined guard for group.history (e.g., if (!group.history) {
/* skip backfill: set resume undefined or return/continue */ }) so you don't
call getResumeCursor on undefined; ensure downstream logic that uses resume (the
filters.since assignment and events filtering) works when resume is undefined by
preserving the existing behavior of treating resume as absent.

Comment on lines +529 to 542
<pre className="text-xs whitespace-pre-wrap wrap-break-words border rounded p-3 bg-muted/30">
{jsonStringifySafe(groupDetails.state)}
</pre>
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for wrap-break-words class definition
rg "wrap-break-words" -t css -t scss -t ts -t tsx -t js -t jsx

Repository: marmot-protocol/marmot-ts

Length of output: 97


🏁 Script executed:

# Also check for Tailwind configuration and global styles
find . -type f \( -name "tailwind.config.*" -o -name "globals.css" -o -name "globals.scss" \) | head -20

Repository: marmot-protocol/marmot-ts

Length of output: 51


🏁 Script executed:

# Examine the specific file context around lines 529-531
sed -n '520,540p' chat/src/pages/groups/[id].tsx

Repository: marmot-protocol/marmot-ts

Length of output: 845


🏁 Script executed:

# Search for wrap-break-words in all files
rg "wrap-break-words"

Repository: marmot-protocol/marmot-ts

Length of output: 217


🏁 Script executed:

# Find Tailwind config and CSS files
find . -type f -name "tailwind.config.*" -o -name "*.css" -o -name "*.scss" | grep -v node_modules | head -30

Repository: marmot-protocol/marmot-ts

Length of output: 114


🏁 Script executed:

# Check for CSS/SCSS in src directory specifically
find ./chat -type f \( -name "*.css" -o -name "*.scss" \) 2>/dev/null

Repository: marmot-protocol/marmot-ts

Length of output: 89


🏁 Script executed:

# Check the CSS file content
cat ./chat/src/index.css

Repository: marmot-protocol/marmot-ts

Length of output: 4456


🏁 Script executed:

# Look for tailwind config file more thoroughly
find . -type f -name "tailwind.config*" 2>/dev/null | grep -v node_modules

Repository: marmot-protocol/marmot-ts

Length of output: 51


🏁 Script executed:

# Search for break-words usage in the project (standard Tailwind)
rg "break-words" --type ts --type tsx --type jsx --type js

Repository: marmot-protocol/marmot-ts

Length of output: 96


🏁 Script executed:

# Search for break-all usage
rg "break-all"

Repository: marmot-protocol/marmot-ts

Length of output: 5038


🏁 Script executed:

# Check for any other custom classes with similar patterns
rg "wrap-" --type css

Repository: marmot-protocol/marmot-ts

Length of output: 51


🏁 Script executed:

# Verify whitespace-pre-wrap is standard Tailwind
rg "whitespace-pre-wrap"

Repository: marmot-protocol/marmot-ts

Length of output: 427


Replace wrap-break-words with break-all.

wrap-break-words is not a valid Tailwind CSS class and is not defined as a custom utility in this project. Replace it with the standard Tailwind class break-all, which is used consistently throughout the codebase for breaking long words in similar contexts.

🤖 Prompt for AI Agents
In `@chat/src/pages/groups/`[id].tsx around lines 529 - 531, The Tailwind class
string on the <pre> element containing jsonStringifySafe(groupDetails.state)
uses an invalid utility "wrap-break-words"; replace that token with the standard
Tailwind class "break-all" in the className for the <pre> element so it becomes
"text-xs whitespace-pre-wrap break-all border rounded p-3 bg-muted/30".

Comment on lines 79 to 81
async queryRumors(): Promise<Rumor[]> {
throw new Error("not needed");
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Match GroupHistoryStore.queryRumors signature.

MemoryHistoryStore.queryRumors() currently has no parameters, but the interface requires a filter argument. This can fail TypeScript conformance checks.

🛠️ Suggested fix
-  async queryRumors(): Promise<Rumor[]> {
+  async queryRumors(
+    _filter: { until?: OuterCursor; limit?: number },
+  ): Promise<Rumor[]> {
     throw new Error("not needed");
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async queryRumors(): Promise<Rumor[]> {
throw new Error("not needed");
}
async queryRumors(
_filter: { until?: OuterCursor; limit?: number },
): Promise<Rumor[]> {
throw new Error("not needed");
}
🤖 Prompt for AI Agents
In `@src/__tests__/ingest-watermark-mixed-messages.test.ts` around lines 79 - 81,
Update MemoryHistoryStore.queryRumors to match the GroupHistoryStore signature
by adding the required filter parameter and appropriate type (e.g., change async
queryRumors(): Promise<Rumor[]> to async queryRumors(filter: RumorFilter):
Promise<Rumor[]> or the exact filter type used by GroupHistoryStore), and
implement a minimal body (for tests you can ignore the filter and return an
empty Rumor[] or forward the filter to the real logic) so the method conforms to
the interface; reference MemoryHistoryStore.queryRumors (and
GroupHistoryStore.queryRumors) when making the change.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
examples/src/components/error-boundary.tsx (1)

7-26: Use FallbackProps type from react-error-boundary instead of unknown.

The library guarantees error is of type Error, not unknown. Import and use FallbackProps to get proper typing and eliminate the need for casting:

♻️ Proposed fix
-import { ErrorBoundary as ReactErrorBoundary } from "react-error-boundary";
+import { ErrorBoundary as ReactErrorBoundary, type FallbackProps } from "react-error-boundary";

-function ErrorFallback({ error }: { error: unknown }) {
+function ErrorFallback({ error }: FallbackProps) {
   return (
     <div className="alert alert-error">
-        <div className="text-sm">{(error as Error).message}</div>
+        <div className="text-sm">{error.message}</div>
🤖 Fix all issues with AI agents
In `@examples/src/examples/key-package/create.tsx`:
- Around line 388-396: Replace the UTF-8 JSON encoding with the library TLS
encoder: import encodeKeyPackage from "ts-mls/keyPackage.js" and call
encodeKeyPackage(keyPackage.publicPackage) when building the object passed to
keyPackageStore.add (instead of TextEncoder + JSON.stringify); this will produce
the TLS wire-format bytes expected by decodeKeyPackage(keyPackage.keyPackageTls,
0).
🧹 Nitpick comments (1)
examples/src/App.tsx (1)

93-96: Remove redundant type cast in ErrorBoundary fallbackRender.

In react-error-boundary v6.0.2, the error parameter is already typed as Error, making the cast (error as Error) redundant. Simplify to {error.message} directly in both fallbackRender blocks.

♻️ Proposed fix
          <ErrorBoundary
            fallbackRender={({ error }) => (
-             <div className="text-red-500">{(error as Error).message}</div>
+             <div className="text-red-500">{error.message}</div>
            )}
          >

Also applies to: 101-104

Comment on lines 388 to 396
// Extract the parts needed for storage - encode public package to TLS bytes
const encoder = new TextEncoder();
// Create a minimal representation - the store will handle encoding
const key = await keyPackageStore.add({
keyPackageTls: encoder.encode(
JSON.stringify(keyPackage.publicPackage),
),
privatePackage: keyPackage.privatePackage,
});
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check how keyPackageStore handles keyPackageTls in the codebase
echo "=== Checking key-package-store implementation ==="
rg -n -A5 "keyPackageTls" --type ts

echo ""
echo "=== Looking for encodeKeyPackage usage ==="
rg -n "encodeKeyPackage" --type ts

echo ""
echo "=== Check ts-mls keyPackage exports ==="
ast-grep --pattern 'export { $$$encodeKeyPackage$$$ }'

Repository: marmot-protocol/marmot-ts

Length of output: 9255


Use encodeKeyPackage for TLS encoding instead of JSON.stringify.

The store's decodeKeyPackage(keyPackage.keyPackageTls, 0) expects TLS-encoded wire format bytes, but the code uses encoder.encode(JSON.stringify(keyPackage.publicPackage)), which produces UTF-8 JSON bytes. This mismatch will cause decoding to fail at runtime.

All other similar files in the codebase correctly use encodeKeyPackage(keyPackage.publicPackage) from "ts-mls/keyPackage.js". Import this function and use it here as well.

🤖 Prompt for AI Agents
In `@examples/src/examples/key-package/create.tsx` around lines 388 - 396, Replace
the UTF-8 JSON encoding with the library TLS encoder: import encodeKeyPackage
from "ts-mls/keyPackage.js" and call encodeKeyPackage(keyPackage.publicPackage)
when building the object passed to keyPackageStore.add (instead of TextEncoder +
JSON.stringify); this will produce the TLS wire-format bytes expected by
decodeKeyPackage(keyPackage.keyPackageTls, 0).

Fix client and group class generic types
Make all UI components rely on MarmotClient class
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 9

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
chat/src/lib/marmot-client.ts (1)

1-23: Convert type-only imports to import type.

These symbols are only used in type annotations: NostrEvent, GroupRumorHistory, MarmotGroup, NostrNetworkInterface, PublishResponse, and Observable. Separating them with import type keeps runtime imports clean.

Suggested update
-import { NostrEvent } from "applesauce-core/helpers/event";
+import type { NostrEvent } from "applesauce-core/helpers/event";
 import {
-  GroupRumorHistory,
-  MarmotClient,
-  MarmotGroup,
-  NostrNetworkInterface,
-  PublishResponse,
+  MarmotClient,
 } from "marmot-ts";
+import type {
+  GroupRumorHistory,
+  MarmotGroup,
+  NostrNetworkInterface,
+  PublishResponse,
+} from "marmot-ts";
 import {
   firstValueFrom,
   from,
   fromEvent,
   lastValueFrom,
   map,
   merge,
   of,
   shareReplay,
   startWith,
   switchMap,
 } from "rxjs";
+import type { Observable } from "rxjs";

Per coding guidelines for chat/**/*.{ts,tsx}.

🤖 Fix all issues with AI agents
In `@chat/src/lib/account-database.ts`:
- Around line 137-158: getCustomDatabaseForAccount currently caches the raw
promise from openDB so a failed open leaves a rejected promise in
customDatabases; update getCustomDatabaseForAccount to create the openDB
promise, immediately store that transient promise if you want deduplication,
then await it inside a try/catch: on success replace the cache entry with the
resolved IDBPDatabase and return it, on failure delete
customDatabases.delete(pubkey) and throw a descriptive Error (include pubkey and
original error) so callers can retry; reference the getCustomDatabaseForAccount
method, the customDatabases Map, and the openDB call when making this change.

In `@chat/src/lib/marmot-client.ts`:
- Around line 66-83: The pipe for marmotClient$ currently calls
databaseBroker.getStorageInterfacesForAccount without error handling, which can
cause the observable to error and stop; wrap the await
databaseBroker.getStorageInterfacesForAccount(account.pubkey) call in a
try-catch inside the switchMap(async (account) => { ... }) block, log the caught
error with context (e.g., include account.pubkey and that
getStorageInterfacesForAccount failed), and return undefined so the observable
stream (accounts.active$ -> marmotClient$) stays alive instead of terminating;
keep the rest of the function returning new MarmotClient({ signer:
account.signer, groupStore, keyPackageStore, network: networkInterface,
historyFactory }) when successful.

In `@chat/src/pages/groups.tsx`:
- Around line 3-4: The imports for MarmotGroup and GroupRumorHistory are only
used as types; change the import to a type-only import so they are not emitted
at runtime: replace the current import of MarmotGroup and GroupRumorHistory from
"marmot-ts" with a type import (reference MarmotGroup and GroupRumorHistory) to
ensure only type information is imported while leaving any runtime imports
(e.g., use$ from "applesauce-react/hooks") unchanged.

In `@chat/src/pages/groups/`[id].tsx:
- Around line 1-12: Change the runtime imports of the purely-typed symbols to
type-only imports: replace the current runtime import of Rumor from
"applesauce-common/helpers/gift-wrap" with an "import type { Rumor }" and
replace the runtime import of MarmotGroup from "marmot-ts" with "import type {
MarmotGroup }"; keep the other values from those modules (e.g.,
extractMarmotGroupData, getGroupMembers, getNostrGroupIdHex, unixNow) as normal
imports so only Rumor and MarmotGroup are moved to type-only imports.

In `@src/client/group/group-rumor-history.ts`:
- Around line 84-105: createPaginatedLoader currently calls
this.backend.queryRumors with only until and limit, dropping caller-provided
filters (since, authors, kinds, tags, etc.); update the call in
createPaginatedLoader so you merge the original filter into the query parameters
and only override limit and until (e.g., pass {...filter, until: cursor, limit})
when calling this.backend.queryRumors so pagination preserves all caller filters
while still controlling page size and cursor.

In `@src/client/group/marmot-group.ts`:
- Around line 54-58: The interface BaseGroupHistory declares a misspelled method
prugeMessages; rename it to purgeMessages in the interface and update all
implementations/usages (e.g., any classes implementing BaseGroupHistory and
calls to prugeMessages) to the correct name purgeMessages (also fix the other
occurrence mentioned). Ensure method signatures remain the same (Promise<void>)
and run the typechecker to catch any missed references.
- Around line 799-806: Wrap the call to this.history.saveMessage(result.message)
in its own try/catch so history persistence is treated as best-effort: call
await this.history.saveMessage(result.message) inside try, catch and log the
error (e.g. this.logger?.error(...) or this.emit("historyError", err)) but do
NOT rethrow or change control flow; continue to yield result and
emit("applicationMessage", result.message) as before so a save failure won't
mark the message unreadable or cause retries.

In `@src/client/marmot-client.ts`:
- Around line 220-238: The destroyGroup method currently calls group.destroy()
which already removes the group from the persistent store, and then calls
this.groupStore.remove(groupId) without awaiting it, causing redundant work and
potential unhandled rejections; remove the redundant this.groupStore.remove(...)
call and instead await the in-memory cache deletion and event emission after the
awaited group.destroy() completes (i.e., keep await group.destroy(), then
this.#groups.delete(id) and emit "groupDestroyed" and "groupsUpdated"), ensuring
you reference the same id variable derived from groupId and do not call
groupStore.remove anywhere in destroyGroup.

In `@src/store/group-store.ts`:
- Around line 92-100: The update() method currently emits the wrong event:
instead of emitting clientStateUpdated it emits clientStateAdded; locate the
update(clientState: ClientState) function and change the emitted event to
"clientStateUpdated" (or, if intent is upsert semantics, remove the
clientStateUpdated entry from GroupStoreEvents and keep emitting
clientStateAdded) so subscribers receive the correct notification; use the
existing symbols resolveStorageKey, serializeClientState, backend.setItem, and
this.emit(...) in your change to ensure consistency with GroupStoreEvents.
🧹 Nitpick comments (5)
src/store/key-package-store.ts (1)

155-157: Avoid emitting private key material to listeners.

keyPackageAdded currently emits a StoredKeyPackage (includes privatePackage). Consider emitting ListedKeyPackage or just the ref and let consumers fetch private data explicitly when needed.

♻️ Suggested adjustment to minimize exposure
 type KeyPackageStoreEvents = {
-  keyPackageAdded: (keyPackage: StoredKeyPackage) => any;
+  keyPackageAdded: (keyPackage: ListedKeyPackage) => any;
   /** Emitted when a key package is removed */
   keyPackageRemoved: (keyPackageRef: Uint8Array) => any;
 };

   const stored = await this.ensureKeyPackageRef(serialized);
-  this.emit("keyPackageAdded", stored);
+  this.emit("keyPackageAdded", {
+    keyPackageRef: stored.keyPackageRef,
+    publicPackage: stored.publicPackage,
+  });
chat/src/lib/lifecycle.ts (1)

106-111: Consider using a Set for deduplication if event volume grows.

The current linear scan (acc.some(...)) is O(n) per event, resulting in O(n²) overall for n events. For typical key package counts this is fine, but if volumes grow significantly, consider using a Map keyed by event.id for O(1) lookups.

♻️ Optional optimization using Map
-    const published = merge(existing$, updates$).pipe(
-      scan((acc, curr) => {
-        const seen = acc.some((p) => p.event.id === curr.event.id);
-        return seen ? acc : [...acc, curr];
-      }, [] as PublishedKeyPackage[]),
+    const published = merge(existing$, updates$).pipe(
+      scan(
+        (acc, curr) => {
+          if (acc.map.has(curr.event.id)) return acc;
+          acc.map.set(curr.event.id, curr);
+          return { map: acc.map, list: [...acc.list, curr] };
+        },
+        { map: new Map<string, PublishedKeyPackage>(), list: [] as PublishedKeyPackage[] },
+      ),
+      map((acc) => acc.list),
     );
examples/src/lib/key-package-store.ts (1)

16-16: storeChanges$ appears to be dead code.

The storeChanges$ BehaviorSubject is initialized but never updated anywhere in the file. With the new event-based pattern using fromEvent (lines 34-35), this subject seems to serve no purpose. The keyPackageCount$ observable still references it, but since it never emits new values, it effectively only triggers once on initial subscription.

Consider removing storeChanges$ and updating keyPackageCount$ to use the event-based pattern instead.

♻️ Proposed fix to remove dead code
-// Observable that triggers whenever the store changes
-const storeChanges$ = new BehaviorSubject<number>(0);
-
 // Create and export a shared KeyPackageStore instance
 export const keyPackageStore$ = accounts.active$.pipe(
   // ...existing code...
 );

 // Observable for the count of key packages in the store
 // This will automatically update when the store changes
 export const keyPackageCount$ = keyPackageStore$.pipe(
-  combineLatestWith(storeChanges$),
-  switchMap(([store, _]) => store.count()),
+  switchMap((store) => store.count()),
 );
src/store/group-store.ts (1)

73-81: Consider extracting shared logic between add() and update().

Both methods now have nearly identical implementations (serialize, store, emit). If the semantic difference between add/update is minimal, consider consolidating into a single internal method.

♻️ Optional consolidation
+  private async persist(clientState: ClientState, event: "clientStateAdded" | "clientStateUpdated"): Promise<string> {
+    const key = this.resolveStorageKey(clientState.groupContext.groupId);
+    const storedClientState = serializeClientState(clientState);
+    await this.backend.setItem(key, storedClientState);
+    this.emit(event, clientState);
+    return key;
+  }
+
   async add(clientState: ClientState): Promise<string> {
-    const key = this.resolveStorageKey(clientState.groupContext.groupId);
-    const storedClientState = serializeClientState(clientState);
-
-    await this.backend.setItem(key, storedClientState);
-    this.emit("clientStateAdded", clientState);
-
-    return key;
+    return this.persist(clientState, "clientStateAdded");
   }

   async update(clientState: ClientState): Promise<string> {
-    const key = this.resolveStorageKey(clientState.groupContext.groupId);
-    const storedClientState = serializeClientState(clientState);
-
-    await this.backend.setItem(key, storedClientState);
-    this.emit("clientStateAdded", clientState);
-
-    return key;
+    return this.persist(clientState, "clientStateUpdated");
   }

Also applies to: 92-100

chat/src/pages/groups.tsx (1)

101-106: Add JSDoc for the exported page component.

Suggested update
+/** Groups landing page. */
 export default function GroupsPage() {

As per coding guidelines, chat/**/*.{ts,tsx,js,jsx}: Use JSDoc documentation for exported functions and hooks.

Comment on lines +137 to +158
/** Get, open, or create a database for a given account */
private async getCustomDatabaseForAccount(
pubkey: string,
): Promise<IDBPDatabase<RumorDatabaseSchema>> {
const existing = this.customDatabases.get(pubkey);
if (existing) return existing;

// Create a new database for the account
const db = openDB<RumorDatabaseSchema>(pubkey, DB_VERSION, {
upgrade(db) {
const rumors = db.createObjectStore("rumors", {
keyPath: ["groupId", "id"],
});
rumors.createIndex("by_group_created_at", ["groupId", "created_at"]);
},
}).then((open) => {
this.customDatabases.set(pubkey, open);
return open;
});

this.customDatabases.set(pubkey, db);
return db;
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n chat/src/lib/account-database.ts | sed -n '130,165p'

Repository: marmot-protocol/marmot-ts

Length of output: 1513


🏁 Script executed:

head -n 30 chat/src/lib/account-database.ts

Repository: marmot-protocol/marmot-ts

Length of output: 690


🏁 Script executed:

rg "getCustomDatabaseForAccount" chat/src/lib/account-database.ts -B 2 -A 2

Repository: marmot-protocol/marmot-ts

Length of output: 463


🏁 Script executed:

rg "this\.customDatabases" chat/src/lib/account-database.ts -B 1 -A 1

Repository: marmot-protocol/marmot-ts

Length of output: 361


Clear failed DB promises to allow recovery on retry.

If openDB fails, the rejected promise is cached in customDatabases, so subsequent calls retrieve the same rejected promise and never recover. Remove the entry from the cache on failure and throw a descriptive error.

Suggested update
   private async getCustomDatabaseForAccount(
     pubkey: string,
   ): Promise<IDBPDatabase<RumorDatabaseSchema>> {
     const existing = this.customDatabases.get(pubkey);
     if (existing) return existing;
 
     // Create a new database for the account
-    const db = openDB<RumorDatabaseSchema>(pubkey, DB_VERSION, {
-      upgrade(db) {
-        const rumors = db.createObjectStore("rumors", {
-          keyPath: ["groupId", "id"],
-        });
-        rumors.createIndex("by_group_created_at", ["groupId", "created_at"]);
-      },
-    }).then((open) => {
-      this.customDatabases.set(pubkey, open);
-      return open;
-    });
-
-    this.customDatabases.set(pubkey, db);
-    return db;
+    const dbPromise = openDB<RumorDatabaseSchema>(pubkey, DB_VERSION, {
+      upgrade(db) {
+        const rumors = db.createObjectStore("rumors", {
+          keyPath: ["groupId", "id"],
+        });
+        rumors.createIndex("by_group_created_at", ["groupId", "created_at"]);
+      },
+    });
+
+    this.customDatabases.set(pubkey, dbPromise);
+
+    try {
+      const db = await dbPromise;
+      this.customDatabases.set(pubkey, db);
+      return db;
+    } catch (error) {
+      this.customDatabases.delete(pubkey);
+      throw new Error(
+        `Failed to open rumor database for account ${pubkey}: ${String(error)}`,
+      );
+    }
🤖 Prompt for AI Agents
In `@chat/src/lib/account-database.ts` around lines 137 - 158,
getCustomDatabaseForAccount currently caches the raw promise from openDB so a
failed open leaves a rejected promise in customDatabases; update
getCustomDatabaseForAccount to create the openDB promise, immediately store that
transient promise if you want deduplication, then await it inside a try/catch:
on success replace the cache entry with the resolved IDBPDatabase and return it,
on failure delete customDatabases.delete(pubkey) and throw a descriptive Error
(include pubkey and original error) so callers can retry; reference the
getCustomDatabaseForAccount method, the customDatabases Map, and the openDB call
when making this change.

Comment on lines +66 to +83
export const marmotClient$ = accounts.active$.pipe(
switchMap(async (account) => {
// Ensure all stores are created and setup
if (!account) return;

// Get storage interfaces for the account
const { groupStore, keyPackageStore, historyFactory } =
await databaseBroker.getStorageInterfacesForAccount(account.pubkey);

// Create a new marmot client for the active account
return new MarmotClient({
signer: account.signer,
groupStore,
keyPackageStore,
network: networkInterface,
historyFactory,
});
}),
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Check if the file exists and get line count
wc -l chat/src/lib/marmot-client.ts

Repository: marmot-protocol/marmot-ts

Length of output: 102


🏁 Script executed:

# Read the specific lines mentioned (66-83)
sed -n '60,90p' chat/src/lib/marmot-client.ts

Repository: marmot-protocol/marmot-ts

Length of output: 1135


🏁 Script executed:

# Search for databaseBroker.getStorageInterfacesForAccount to understand what it does
rg "getStorageInterfacesForAccount" chat/src/lib/marmot-client.ts -B 3 -A 3

Repository: marmot-protocol/marmot-ts

Length of output: 338


🏁 Script executed:

# Check if there's already try-catch around this area
sed -n '50,100p' chat/src/lib/marmot-client.ts | grep -E "(try|catch)"

Repository: marmot-protocol/marmot-ts

Length of output: 51


🏁 Script executed:

# Find databaseBroker definition and usage
rg "databaseBroker" chat/src/lib/marmot-client.ts -B 2 -A 2

Repository: marmot-protocol/marmot-ts

Length of output: 466


🏁 Script executed:

# Search for databaseBroker definition in the codebase
rg "const databaseBroker|export.*databaseBroker" chat/src --type ts --type tsx -A 3

Repository: marmot-protocol/marmot-ts

Length of output: 96


🏁 Script executed:

# Check if there's any error handling in the observable chain (catchError, onError, etc.)
sed -n '60,95p' chat/src/lib/marmot-client.ts

Repository: marmot-protocol/marmot-ts

Length of output: 1253


🏁 Script executed:

# Find and read account-database.ts to understand getStorageInterfacesForAccount
find chat/src -name "*account-database*" -type f

Repository: marmot-protocol/marmot-ts

Length of output: 101


🏁 Script executed:

# Read the account-database file to understand getStorageInterfacesForAccount
cat chat/src/lib/account-database.ts

Repository: marmot-protocol/marmot-ts

Length of output: 5942


🏁 Script executed:

# Check if databaseBroker is singleton/shared and how errors are handled
rg "getStorageInterfacesForAccount" chat/src -B 2 -A 5

Repository: marmot-protocol/marmot-ts

Length of output: 1191


🏁 Script executed:

# Verify file location and check imports to confirm it's a TypeScript file
head -20 chat/src/lib/marmot-client.ts

Repository: marmot-protocol/marmot-ts

Length of output: 495


🏁 Script executed:

# Check if there's any error handling at the observable level (catchError operator)
rg "catchError|onError|error =>" chat/src/lib/marmot-client.ts

Repository: marmot-protocol/marmot-ts

Length of output: 51


Wrap databaseBroker.getStorageInterfacesForAccount in try-catch to prevent observable errors.

If databaseBroker.getStorageInterfacesForAccount throws (IndexedDB/localForage blocked or initialization fails), the observable will error and stop emitting. Catch the error, log with context, and return undefined to keep the stream alive.

Suggested update
 export const marmotClient$ = accounts.active$.pipe(
   switchMap(async (account) => {
     // Ensure all stores are created and setup
     if (!account) return;
 
-    // Get storage interfaces for the account
-    const { groupStore, keyPackageStore, historyFactory } =
-      await databaseBroker.getStorageInterfacesForAccount(account.pubkey);
-
-    // Create a new marmot client for the active account
-    return new MarmotClient({
-      signer: account.signer,
-      groupStore,
-      keyPackageStore,
-      network: networkInterface,
-      historyFactory,
-    });
+    try {
+      // Get storage interfaces for the account
+      const { groupStore, keyPackageStore, historyFactory } =
+        await databaseBroker.getStorageInterfacesForAccount(account.pubkey);
+
+      // Create a new marmot client for the active account
+      return new MarmotClient({
+        signer: account.signer,
+        groupStore,
+        keyPackageStore,
+        network: networkInterface,
+        historyFactory,
+      });
+    } catch (error) {
+      console.error(
+        "Failed to initialize MarmotClient for account",
+        account.pubkey,
+        error,
+      );
+      return;
+    }
   }),
   startWith(undefined),
   shareReplay(1),
 );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const marmotClient$ = accounts.active$.pipe(
switchMap(async (account) => {
// Ensure all stores are created and setup
if (!account) return;
// Get storage interfaces for the account
const { groupStore, keyPackageStore, historyFactory } =
await databaseBroker.getStorageInterfacesForAccount(account.pubkey);
// Create a new marmot client for the active account
return new MarmotClient({
signer: account.signer,
groupStore,
keyPackageStore,
network: networkInterface,
historyFactory,
});
}),
export const marmotClient$ = accounts.active$.pipe(
switchMap(async (account) => {
// Ensure all stores are created and setup
if (!account) return;
try {
// Get storage interfaces for the account
const { groupStore, keyPackageStore, historyFactory } =
await databaseBroker.getStorageInterfacesForAccount(account.pubkey);
// Create a new marmot client for the active account
return new MarmotClient({
signer: account.signer,
groupStore,
keyPackageStore,
network: networkInterface,
historyFactory,
});
} catch (error) {
console.error(
"Failed to initialize MarmotClient for account",
account.pubkey,
error,
);
return;
}
}),
startWith(undefined),
shareReplay(1),
);
🤖 Prompt for AI Agents
In `@chat/src/lib/marmot-client.ts` around lines 66 - 83, The pipe for
marmotClient$ currently calls databaseBroker.getStorageInterfacesForAccount
without error handling, which can cause the observable to error and stop; wrap
the await databaseBroker.getStorageInterfacesForAccount(account.pubkey) call in
a try-catch inside the switchMap(async (account) => { ... }) block, log the
caught error with context (e.g., include account.pubkey and that
getStorageInterfacesForAccount failed), and return undefined so the observable
stream (accounts.active$ -> marmotClient$) stays alive instead of terminating;
keep the rest of the function returning new MarmotClient({ signer:
account.signer, groupStore, keyPackageStore, network: networkInterface,
historyFactory }) when successful.

Comment on lines 3 to +4
import { use$ } from "applesauce-react/hooks";
import { extractMarmotGroupData, getGroupIdHex } from "marmot-ts";
import { ClientState } from "ts-mls/clientState.js";
import { GroupRumorHistory, MarmotGroup } from "marmot-ts";
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cat -n chat/src/pages/groups.tsx | head -150

Repository: marmot-protocol/marmot-ts

Length of output: 5824


Use type-only imports for MarmotGroup and GroupRumorHistory.
Both are used only in type positions (line 29) and should be imported with the type keyword to avoid unnecessary runtime imports.

Suggested update
-import { GroupRumorHistory, MarmotGroup } from "marmot-ts";
+import type { GroupRumorHistory, MarmotGroup } from "marmot-ts";
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import { use$ } from "applesauce-react/hooks";
import { extractMarmotGroupData, getGroupIdHex } from "marmot-ts";
import { ClientState } from "ts-mls/clientState.js";
import { GroupRumorHistory, MarmotGroup } from "marmot-ts";
import { use$ } from "applesauce-react/hooks";
import type { GroupRumorHistory, MarmotGroup } from "marmot-ts";
🤖 Prompt for AI Agents
In `@chat/src/pages/groups.tsx` around lines 3 - 4, The imports for MarmotGroup
and GroupRumorHistory are only used as types; change the import to a type-only
import so they are not emitted at runtime: replace the current import of
MarmotGroup and GroupRumorHistory from "marmot-ts" with a type import (reference
MarmotGroup and GroupRumorHistory) to ensure only type information is imported
while leaving any runtime imports (e.g., use$ from "applesauce-react/hooks")
unchanged.

Comment on lines 1 to 12
import { Rumor } from "applesauce-common/helpers/gift-wrap";
import { use$ } from "applesauce-react/hooks";
import { Loader2, XCircle } from "lucide-react";
import { mapEventsToTimeline } from "applesauce-core";
import type { NostrEvent } from "applesauce-core/helpers";
import { use$ } from "applesauce-react/hooks";
import { Loader2, XCircle } from "lucide-react";
import {
extractMarmotGroupData,
getNostrGroupIdHex,
getGroupMembers,
getNostrGroupIdHex,
MarmotGroup,
unixNow,
} from "marmot-ts";
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

wc -l chat/src/pages/groups/[id].tsx

Repository: marmot-protocol/marmot-ts

Length of output: 103


🏁 Script executed:

cat -n chat/src/pages/groups/[id].tsx | head -100

Repository: marmot-protocol/marmot-ts

Length of output: 3684


🏁 Script executed:

# Search for uses of Rumor and MarmotGroup in the file
rg "Rumor|MarmotGroup" chat/src/pages/groups/[id].tsx -n

Repository: marmot-protocol/marmot-ts

Length of output: 759


Use import type for Rumor and MarmotGroup.
Both are used only in type annotations and don't require runtime values.

Suggested update
-import { Rumor } from "applesauce-common/helpers/gift-wrap";
+import type { Rumor } from "applesauce-common/helpers/gift-wrap";
 import {
   extractMarmotGroupData,
   getGroupMembers,
   getNostrGroupIdHex,
-  MarmotGroup,
   unixNow,
 } from "marmot-ts";
+import type { MarmotGroup } from "marmot-ts";
🤖 Prompt for AI Agents
In `@chat/src/pages/groups/`[id].tsx around lines 1 - 12, Change the runtime
imports of the purely-typed symbols to type-only imports: replace the current
runtime import of Rumor from "applesauce-common/helpers/gift-wrap" with an
"import type { Rumor }" and replace the runtime import of MarmotGroup from
"marmot-ts" with "import type { MarmotGroup }"; keep the other values from those
modules (e.g., extractMarmotGroupData, getGroupMembers, getNostrGroupIdHex,
unixNow) as normal imports so only Rumor and MarmotGroup are moved to type-only
imports.

Comment on lines +84 to +105
async *createPaginatedLoader(
filter?: Filter,
): AsyncGenerator<Rumor[], Rumor[]> {
const limit = filter?.limit ?? 50;
let cursor = filter?.until ?? undefined;

while (true) {
const rumors = await this.backend.queryRumors({
until: cursor,
limit,
});

// If no rumors returned, we've reached the end
if (rumors.length === 0) return rumors;

// Find the oldest timestamp in the current page
// and set it as the new `until` for the next page (going backwards)
const oldest = Math.min(...rumors.map((rumor) => rumor.created_at));

// Set the next `until` to be just before the oldest message
// This ensures we get the next older page without duplicates
if (!cursor || oldest < cursor) cursor = oldest - 1;
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Preserve caller filters in createPaginatedLoader.
The current call drops since/authors/kinds (and tags), so pagination ignores filters. Pass through the remaining filter fields while overriding until/limit.

Suggested update
-    const limit = filter?.limit ?? 50;
-    let cursor = filter?.until ?? undefined;
+    const { limit = 50, until, ...rest } = filter ?? {};
+    let cursor = until;
 
     while (true) {
       const rumors = await this.backend.queryRumors({
+        ...rest,
         until: cursor,
         limit,
       });
🤖 Prompt for AI Agents
In `@src/client/group/group-rumor-history.ts` around lines 84 - 105,
createPaginatedLoader currently calls this.backend.queryRumors with only until
and limit, dropping caller-provided filters (since, authors, kinds, tags, etc.);
update the call in createPaginatedLoader so you merge the original filter into
the query parameters and only override limit and until (e.g., pass {...filter,
until: cursor, limit}) when calling this.backend.queryRumors so pagination
preserves all caller filters while still controlling page size and cursor.

Comment on lines +54 to +58
export interface BaseGroupHistory {
/** Saves a new application message to the group history */
saveMessage(message: Uint8Array): Promise<void>;
/** Purge the group history, called when group is destroyed */
prugeMessages(): Promise<void>;
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Fix typo in history purge method name.

prugeMessages should be purgeMessages to avoid a misspelled public API.

Proposed fix
 export interface BaseGroupHistory {
   /** Saves a new application message to the group history */
   saveMessage(message: Uint8Array): Promise<void>;
   /** Purge the group history, called when group is destroyed */
-  prugeMessages(): Promise<void>;
+  purgeMessages(): Promise<void>;
 }
@@
   async destroy() {
-    if (this.history) await this.history.prugeMessages();
+    if (this.history) await this.history.purgeMessages();

Also applies to: 905-910

🤖 Prompt for AI Agents
In `@src/client/group/marmot-group.ts` around lines 54 - 58, The interface
BaseGroupHistory declares a misspelled method prugeMessages; rename it to
purgeMessages in the interface and update all implementations/usages (e.g., any
classes implementing BaseGroupHistory and calls to prugeMessages) to the correct
name purgeMessages (also fix the other occurrence mentioned). Ensure method
signatures remain the same (Promise<void>) and run the typechecker to catch any
missed references.

Comment on lines +799 to 806
// Save application message to history
if (this.history) {
await this.history.saveMessage(result.message);
}

yield result;
this.emit("applicationMessage", result.message);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Don’t let history persistence failures mark messages unreadable.

If history.saveMessage() throws, the outer catch treats the event as unreadable after state has already advanced, which can lead to retries or dropped messages. Treat history persistence as best‑effort and swallow/log errors.

Proposed fix
           // Save application message to history
           if (this.history) {
-            await this.history.saveMessage(result.message);
+            try {
+              await this.history.saveMessage(result.message);
+            } catch (err) {
+              console.warn(
+                "[MarmotGroup.ingest] Failed to persist history message",
+                err,
+              );
+            }
           }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Save application message to history
if (this.history) {
await this.history.saveMessage(result.message);
}
yield result;
this.emit("applicationMessage", result.message);
}
// Save application message to history
if (this.history) {
try {
await this.history.saveMessage(result.message);
} catch (err) {
console.warn(
"[MarmotGroup.ingest] Failed to persist history message",
err,
);
}
}
yield result;
this.emit("applicationMessage", result.message);
🤖 Prompt for AI Agents
In `@src/client/group/marmot-group.ts` around lines 799 - 806, Wrap the call to
this.history.saveMessage(result.message) in its own try/catch so history
persistence is treated as best-effort: call await
this.history.saveMessage(result.message) inside try, catch and log the error
(e.g. this.logger?.error(...) or this.emit("historyError", err)) but do NOT
rethrow or change control flow; continue to yield result and
emit("applicationMessage", result.message) as before so a save failure won't
mark the message unreadable or cause retries.

Comment on lines +220 to +238
/** Destroys a group and purges the group history */
async destroyGroup(groupId: Uint8Array | string): Promise<void> {
const id = typeof groupId === "string" ? groupId : bytesToHex(groupId);

// Get the existing instance or load a new one
const group = this.#groups.get(id) || (await this.loadGroup(groupId));

// Use the instance to destroy the group
await group.destroy();

// Remove the group from the cache without emitting an event
this.groupStore.remove(groupId);

// Remove the group from the cache
this.#groups.delete(id);

// Emit events
this.emit("groupDestroyed", group.id);
this.emit("groupsUpdated", this.groups);
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Avoid double removal and un-awaited store deletion in destroyGroup.

group.destroy() already removes the group from the store. The extra groupStore.remove() is redundant and currently not awaited, which risks unhandled rejections or errors if removal isn’t idempotent. Prefer a single removal path and await it.

Proposed fix
-    // Remove the group from the cache without emitting an event
-    this.groupStore.remove(groupId);
-
-    // Remove the group from the cache
+    // Remove the group from the cache
     this.#groups.delete(id);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/** Destroys a group and purges the group history */
async destroyGroup(groupId: Uint8Array | string): Promise<void> {
const id = typeof groupId === "string" ? groupId : bytesToHex(groupId);
// Get the existing instance or load a new one
const group = this.#groups.get(id) || (await this.loadGroup(groupId));
// Use the instance to destroy the group
await group.destroy();
// Remove the group from the cache without emitting an event
this.groupStore.remove(groupId);
// Remove the group from the cache
this.#groups.delete(id);
// Emit events
this.emit("groupDestroyed", group.id);
this.emit("groupsUpdated", this.groups);
/** Destroys a group and purges the group history */
async destroyGroup(groupId: Uint8Array | string): Promise<void> {
const id = typeof groupId === "string" ? groupId : bytesToHex(groupId);
// Get the existing instance or load a new one
const group = this.#groups.get(id) || (await this.loadGroup(groupId));
// Use the instance to destroy the group
await group.destroy();
// Remove the group from the cache
this.#groups.delete(id);
// Emit events
this.emit("groupDestroyed", group.id);
this.emit("groupsUpdated", this.groups);
🤖 Prompt for AI Agents
In `@src/client/marmot-client.ts` around lines 220 - 238, The destroyGroup method
currently calls group.destroy() which already removes the group from the
persistent store, and then calls this.groupStore.remove(groupId) without
awaiting it, causing redundant work and potential unhandled rejections; remove
the redundant this.groupStore.remove(...) call and instead await the in-memory
cache deletion and event emission after the awaited group.destroy() completes
(i.e., keep await group.destroy(), then this.#groups.delete(id) and emit
"groupDestroyed" and "groupsUpdated"), ensuring you reference the same id
variable derived from groupId and do not call groupStore.remove anywhere in
destroyGroup.

Comment on lines 92 to 100
async update(clientState: ClientState): Promise<string> {
return await this.add(clientState);
const key = this.resolveStorageKey(clientState.groupContext.groupId);
const storedClientState = serializeClientState(clientState);

await this.backend.setItem(key, storedClientState);
this.emit("clientStateAdded", clientState);

return key;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

update() emits clientStateAdded instead of clientStateUpdated.

The update() method emits clientStateAdded (line 97), but GroupStoreEvents defines a separate clientStateUpdated event (lines 23-24). This creates a semantic inconsistency: subscribers expecting update notifications would never receive them.

If updates should be treated identically to additions (upsert semantics), consider removing the clientStateUpdated event definition to avoid confusion. Otherwise, emit clientStateUpdated when updating an existing entry.

🔧 Option A: Emit the correct event for updates
   async update(clientState: ClientState): Promise<string> {
     const key = this.resolveStorageKey(clientState.groupContext.groupId);
     const storedClientState = serializeClientState(clientState);

     await this.backend.setItem(key, storedClientState);
-    this.emit("clientStateAdded", clientState);
+    this.emit("clientStateUpdated", clientState);

     return key;
   }
🔧 Option B: Remove unused event type if upsert semantics are intended
 type GroupStoreEvents = {
   /** Emitted when a client state is added */
   clientStateAdded: (clientState: ClientState) => any;
-  /** Emitted when a client state is updated */
-  clientStateUpdated: (clientState: ClientState) => any;
   /** Emitted when a client state is removed */
   clientStateRemoved: (groupId: Uint8Array) => any;
 };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async update(clientState: ClientState): Promise<string> {
return await this.add(clientState);
const key = this.resolveStorageKey(clientState.groupContext.groupId);
const storedClientState = serializeClientState(clientState);
await this.backend.setItem(key, storedClientState);
this.emit("clientStateAdded", clientState);
return key;
}
async update(clientState: ClientState): Promise<string> {
const key = this.resolveStorageKey(clientState.groupContext.groupId);
const storedClientState = serializeClientState(clientState);
await this.backend.setItem(key, storedClientState);
this.emit("clientStateUpdated", clientState);
return key;
}
🤖 Prompt for AI Agents
In `@src/store/group-store.ts` around lines 92 - 100, The update() method
currently emits the wrong event: instead of emitting clientStateUpdated it emits
clientStateAdded; locate the update(clientState: ClientState) function and
change the emitted event to "clientStateUpdated" (or, if intent is upsert
semantics, remove the clientStateUpdated entry from GroupStoreEvents and keep
emitting clientStateAdded) so subscribers receive the correct notification; use
the existing symbols resolveStorageKey, serializeClientState, backend.setItem,
and this.emit(...) in your change to ensure consistency with GroupStoreEvents.

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.

2 participants