import { getLogger } from "@logtape/logtape" import { EphemeralStore, type PeerID } from "loro-crdt" import { describe, expect, it, vi } from "vitest" import type { PeerIdentityDetails } from "../types.js" import { TimerlessEphemeralStore } from "../utils/timerless-ephemeral-store.js" import { EphemeralStoreManager } from "./ephemeral-store-manager.js" const testIdentity: PeerIdentityDetails = { peerId: "1" as PeerID, name: "Test Peer", type: "user", } const logger = getLogger(["test"]) describe("EphemeralStoreManager", () => { describe("getOrCreate", () => { it("should create a new store for a new namespace", () => { const onLocalChange = vi.fn() const manager = new EphemeralStoreManager( testIdentity, onLocalChange, logger, ) const store = manager.getOrCreate("doc-1", "presence") expect(store).toBeInstanceOf(TimerlessEphemeralStore) expect(manager.stores.get("doc-1")?.get("presence")).toBe(store) }) it("should return existing store for same namespace", () => { const onLocalChange = vi.fn() const manager = new EphemeralStoreManager( testIdentity, onLocalChange, logger, ) const store1 = manager.getOrCreate("doc-1", "presence") const store2 = manager.getOrCreate("doc-1", "presence") expect(store1).toBe(store2) }) it("should create separate stores for different namespaces", () => { const onLocalChange = vi.fn() const manager = new EphemeralStoreManager( testIdentity, onLocalChange, logger, ) const presenceStore = manager.getOrCreate("doc-1", "presence") const cursorStore = manager.getOrCreate("doc-1", "cursors") expect(presenceStore).not.toBe(cursorStore) }) it("should create separate stores for different documents", () => { const onLocalChange = vi.fn() const manager = new EphemeralStoreManager( testIdentity, onLocalChange, logger, ) const store1 = manager.getOrCreate("doc-1", "presence") const store2 = manager.getOrCreate("doc-2", "presence") expect(store1).not.toBe(store2) }) it("should call onLocalChange when store data changes locally", () => { const onLocalChange = vi.fn() const manager = new EphemeralStoreManager( testIdentity, onLocalChange, logger, ) const store = manager.getOrCreate("doc-1", "presence") store.set("cursor", { x: 10, y: 20 }) expect(onLocalChange).toHaveBeenCalledWith("doc-1", "presence") }) }) describe("registerExternal", () => { it("should register an external store", () => { const onLocalChange = vi.fn() const manager = new EphemeralStoreManager( testIdentity, onLocalChange, logger, ) const externalStore = new EphemeralStore(10000) manager.registerExternal("doc-1", "prosemirror", externalStore) expect(manager.get("doc-1", "prosemirror")).toBe(externalStore) }) it("should throw if namespace already exists", () => { const onLocalChange = vi.fn() const manager = new EphemeralStoreManager( testIdentity, onLocalChange, logger, ) manager.getOrCreate("doc-1", "presence") expect(() => { manager.registerExternal("doc-1", "presence", new EphemeralStore(10000)) }).toThrow('Ephemeral store "presence" already exists for doc "doc-1"') }) it("should call onLocalChange when external store changes locally", () => { const onLocalChange = vi.fn() const manager = new EphemeralStoreManager( testIdentity, onLocalChange, logger, ) const externalStore = new EphemeralStore(10000) manager.registerExternal("doc-1", "prosemirror", externalStore) externalStore.set("selection", { start: 0, end: 5 }) expect(onLocalChange).toHaveBeenCalledWith("doc-1", "prosemirror") }) }) describe("get", () => { it("should return undefined for non-existent store", () => { const onLocalChange = vi.fn() const manager = new EphemeralStoreManager( testIdentity, onLocalChange, logger, ) expect(manager.get("doc-1", "presence")).toBeUndefined() }) it("should return existing store", () => { const onLocalChange = vi.fn() const manager = new EphemeralStoreManager( testIdentity, onLocalChange, logger, ) const store = manager.getOrCreate("doc-1", "presence") expect(manager.get("doc-1", "presence")).toBe(store) }) }) describe("encodeAll", () => { it("should return empty array for non-existent document", () => { const onLocalChange = vi.fn() const manager = new EphemeralStoreManager( testIdentity, onLocalChange, logger, ) const encoded = manager.encodeAll("doc-1") expect(encoded).toEqual([]) }) it("should encode stores - even empty stores may have minimal data", () => { const onLocalChange = vi.fn() const manager = new EphemeralStoreManager( testIdentity, onLocalChange, logger, ) manager.getOrCreate("doc-1", "presence") const encoded = manager.encodeAll("doc-1") // EphemeralStore.encodeAll() may return minimal data even for "empty" stores // This is implementation-dependent behavior from loro-crdt expect(encoded).toHaveLength(1) expect(encoded[0].namespace).toBe("presence") }) it("should encode stores with data", () => { const onLocalChange = vi.fn() const manager = new EphemeralStoreManager( testIdentity, onLocalChange, logger, ) const store = manager.getOrCreate("doc-1", "presence") store.set("cursor", { x: 10, y: 20 }) const encoded = manager.encodeAll("doc-1") expect(encoded).toHaveLength(1) expect(encoded[0].peerId).toBe(testIdentity.peerId) expect(encoded[0].namespace).toBe("presence") expect(encoded[0].data).toBeInstanceOf(Uint8Array) expect(encoded[0].data.length).toBeGreaterThan(0) }) it("should encode multiple namespaces", () => { const onLocalChange = vi.fn() const manager = new EphemeralStoreManager( testIdentity, onLocalChange, logger, ) const presenceStore = manager.getOrCreate("doc-1", "presence") presenceStore.set("status", "online") const cursorStore = manager.getOrCreate("doc-1", "cursors") cursorStore.set("position", 42) const encoded = manager.encodeAll("doc-1") expect(encoded).toHaveLength(2) const namespaces = encoded.map(e => e.namespace) expect(namespaces).toContain("presence") expect(namespaces).toContain("cursors") }) }) describe("applyRemote", () => { it("should apply remote data to existing store", () => { const onLocalChange = vi.fn() const manager = new EphemeralStoreManager( testIdentity, onLocalChange, logger, ) // Create a store and set some data const sourceStore = new TimerlessEphemeralStore() sourceStore.set("cursor", { x: 100, y: 200 }) const encodedData = sourceStore.encodeAll() // Apply to manager manager.applyRemote("doc-1", { peerId: "2" as PeerID, data: encodedData, namespace: "presence", }) // Verify data was applied const store = manager.get("doc-1", "presence") expect(store).toBeDefined() const states = store?.getAllStates() expect(states?.["2"]).toBeUndefined() // Data is keyed by the source store's internal ID }) it("should create store if it does not exist", () => { const onLocalChange = vi.fn() const manager = new EphemeralStoreManager( testIdentity, onLocalChange, logger, ) const sourceStore = new TimerlessEphemeralStore() sourceStore.set("cursor", { x: 100, y: 200 }) const encodedData = sourceStore.encodeAll() manager.applyRemote("doc-1", { peerId: "2" as PeerID, data: encodedData, namespace: "presence", }) expect(manager.get("doc-1", "presence")).toBeDefined() }) it("should ignore empty data", () => { const onLocalChange = vi.fn() const manager = new EphemeralStoreManager( testIdentity, onLocalChange, logger, ) manager.applyRemote("doc-1", { peerId: "2" as PeerID, data: new Uint8Array(0), namespace: "presence", }) // Store should not be created for empty data expect(manager.get("doc-1", "presence")).toBeUndefined() }) it("should ignore data without namespace", () => { const onLocalChange = vi.fn() const manager = new EphemeralStoreManager( testIdentity, onLocalChange, logger, ) manager.applyRemote("doc-1", { peerId: "2" as PeerID, data: new Uint8Array([1, 2, 3]), namespace: undefined as unknown as string, }) // No stores should be created expect(manager.stores.get("doc-1")).toBeUndefined() }) }) describe("removePeer", () => { it("should remove peer data from all stores when peer ID matches", () => { const onLocalChange = vi.fn() const manager = new EphemeralStoreManager( testIdentity, onLocalChange, logger, ) // Create stores with data const store1 = manager.getOrCreate("doc-1", "presence") store1.set("cursor", { x: 10, y: 20 }) const store2 = manager.getOrCreate("doc-2", "presence") store2.set("cursor", { x: 30, y: 40 }) // EphemeralStore uses its own internal peer ID, not our identity's peerId // The getAllStates() returns data keyed by the store's internal peer ID // Let's check what peer IDs are actually in the stores const states1 = store1.getAllStates() const peerIds1 = Object.keys(states1) // If there's data, try to remove it if (peerIds1.length > 0) { const removed = manager.removePeer(peerIds1[0] as PeerID) // Should have removed from at least one store expect(removed.length).toBeGreaterThanOrEqual(1) } else { // No peer data to remove - this is also valid const removed = manager.removePeer(testIdentity.peerId) expect(removed).toEqual([]) } }) it("should return empty array if peer has no data", () => { const onLocalChange = vi.fn() const manager = new EphemeralStoreManager( testIdentity, onLocalChange, logger, ) manager.getOrCreate("doc-1", "presence") const removed = manager.removePeer("999" as PeerID) expect(removed).toEqual([]) }) }) describe("getNamespaces", () => { it("should return empty array for non-existent document", () => { const onLocalChange = vi.fn() const manager = new EphemeralStoreManager( testIdentity, onLocalChange, logger, ) expect(manager.getNamespaces("doc-1")).toEqual([]) }) it("should return all namespaces for a document", () => { const onLocalChange = vi.fn() const manager = new EphemeralStoreManager( testIdentity, onLocalChange, logger, ) manager.getOrCreate("doc-1", "presence") manager.getOrCreate("doc-1", "cursors") manager.getOrCreate("doc-1", "selections") const namespaces = manager.getNamespaces("doc-1") expect(namespaces).toHaveLength(3) expect(namespaces).toContain("presence") expect(namespaces).toContain("cursors") expect(namespaces).toContain("selections") }) }) describe("unsubscribeAll", () => { it("should unsubscribe from all stores", () => { const onLocalChange = vi.fn() const manager = new EphemeralStoreManager( testIdentity, onLocalChange, logger, ) const store = manager.getOrCreate("doc-1", "presence") // Verify subscription is active store.set("cursor", { x: 10, y: 20 }) expect(onLocalChange).toHaveBeenCalledTimes(1) // Unsubscribe manager.unsubscribeAll() // Verify subscription is inactive store.set("cursor", { x: 30, y: 40 }) expect(onLocalChange).toHaveBeenCalledTimes(1) // No additional calls }) }) })