import { describe, expect, it, vi } from "vitest"; import { createLiveCollection } from "./live-collection"; import { createFakeCapabilities, TestWorkspaceState } from "../testing/fake-capabilities"; import type { ColineCapabilities } from "./types"; function setup(userId = "owner", state = new TestWorkspaceState()) { const coline = createFakeCapabilities(state, { workspaceId: "ws", workspaceSlug: "ws", workspaceName: "WS", appKey: "test", installId: "install", actor: { kind: "user", userId }, members: [], pickers: {}, onNetFetch: undefined, onAiGenerate: undefined, }); return { state, coline }; } const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); describe("live collections", () => { it("renders a local edit before a delayed network write and reconciles the acknowledgement", async () => { const { coline } = setup(); const records = coline.storage.collection<{ title: string }>("issues"); const row = await records.insert({ title: "Before" }); let release!: () => void; const delayed = { ...coline, storage: { ...coline.storage, collection: () => ({ ...records, update: async (...args: Parameters) => { await new Promise((resolve) => { release = resolve; }); return records.update(...args); }, }), }, } as ColineCapabilities; const live = createLiveCollection<{ title: string }>(delayed, "issues"); await live.start(); const start = performance.now(); const saved = live.update(row.id, { title: "After" }); expect(live.getSnapshot().records[0]?.data.title).toBe("After"); expect(performance.now() - start).toBeLessThan(50); expect(live.getSnapshot().pending).toBe(1); expect((await records.get(row.id))?.data.title).toBe("Before"); release(); await saved; expect(live.getSnapshot().pending).toBe(0); live.dispose(); }); it("synchronizes two clients and recovers changes missed while stopped", async () => { const { coline, state } = setup(); const other = setup("member", state).coline; const a = createLiveCollection<{ title: string }>(coline, "issues"), b = createLiveCollection<{ title: string }>(other, "issues"); await Promise.all([a.start(), b.start()]); const row = await a.insert({ title: "Shared" }); await tick(); await b.refresh(); expect(b.getSnapshot().records[0]?.id).toBe(row?.id); b.stop(); await a.update(row!.id, { title: "While disconnected" }); await b.start(); expect(b.getSnapshot().records[0]?.data.title).toBe("While disconnected"); a.dispose(); b.dispose(); }); it("rejects stale edits and rolls back instead of silently overwriting another person", async () => { const { coline } = setup(); const records = coline.storage.collection<{ title: string }>("issues"); const row = await records.insert({ title: "Original" }); const noHints = { ...coline, realtime: { ...coline.realtime, subscribe: async () => ({ unsubscribe() {} }) }, }; const live = createLiveCollection<{ title: string }>(noHints, "issues"); await live.start(); await records.update(row.id, { title: "Their edit" }, { expectedVersion: 1 }); await expect(live.update(row.id, { title: "My stale edit" })).rejects.toMatchObject({ code: "record_conflict", }); await tick(); expect(live.getSnapshot().records[0]?.data.title).toBe("Their edit"); expect(live.getSnapshot().pending).toBe(0); live.dispose(); }); it("serializes rapid local edits without losing either acknowledgement", async () => { const { coline } = setup(); const live = createLiveCollection<{ title: string; priority: number }>(coline, "issues"); await live.start(); const row = await live.insert({ title: "Initial", priority: 0 }); await Promise.all([ live.update(row!.id, { title: "Updated" }), live.update(row!.id, { priority: 1 }), ]); expect(live.getSnapshot().records[0]?.data).toEqual({ title: "Updated", priority: 1 }); expect(live.getSnapshot().records[0]?.version).toBe(3); live.dispose(); }); it("filters private records before pagination and evicts a record after access is revoked", async () => { const { coline, state } = setup(); const other = setup("member", state).coline; const records = coline.storage.collection<{ title: string }>("issues"); const privateRecord = await records.insert( { title: "Hidden" }, { access: { workspace: "none", readers: [], writers: [] } }, ); await expect( other.references.resolve({ kind: "app_record", id: privateRecord.id }), ).rejects.toThrow(); const row = await records.insert( { title: "Shared" }, { access: { workspace: "none", readers: ["member"], writers: [] } }, ); const live = createLiveCollection<{ title: string }>(other, "issues", { limit: 1 }); await live.start(); expect(live.getSnapshot().records.map((row) => row.data.title)).toEqual(["Shared"]); await expect( other.storage.collection("issues").update(row.id, { title: "Denied" }), ).rejects.toMatchObject({ code: "record_forbidden" }); await records.setAccess( row.id, { workspace: "none", readers: [], writers: [] }, { expectedVersion: 1 }, ); await live.refresh(); expect(live.getSnapshot().records).toEqual([]); live.dispose(); }); it("does not resurrect cached data when an old acknowledgement arrives after membership denial", async () => { const { coline } = setup(); const records = coline.storage.collection<{ title: string }>("issues"); const row = await records.insert({ title: "Private" }); let denied = false; let release!: () => void; const transport = { ...coline, storage: { ...coline.storage, collection: () => ({ ...records, sync: async (...args: Parameters) => { if (denied) throw Object.assign(new Error("Membership revoked"), { code: "actor_not_member" }); return records.sync(...args); }, update: async (...args: Parameters) => { const result = await records.update(...args); await new Promise((resolve) => { release = resolve; }); return result; }, }), }, } as ColineCapabilities; const live = createLiveCollection<{ title: string }>(transport, "issues"); await live.start(); const rejected = live.update(row.id, { title: "Sent" }).catch((error) => error); await tick(); denied = true; await live.refresh(); expect((await rejected).code).toBe("actor_not_member"); expect(live.getSnapshot().records).toEqual([]); release(); await tick(); expect(live.getSnapshot().records).toEqual([]); live.dispose(); }); it("stops timers and subscriptions, including a late subscription acknowledgement", async () => { vi.useFakeTimers(); const { coline } = setup(); let complete!: (value: { unsubscribe: () => void }) => void; const unsubscribe = vi.fn(); coline.realtime.subscribe = () => new Promise((resolve) => { complete = resolve; }); const live = createLiveCollection(coline, "issues"); await live.start(); live.dispose(); complete({ unsubscribe }); await Promise.resolve(); expect(unsubscribe).toHaveBeenCalledOnce(); expect(vi.getTimerCount()).toBe(0); vi.useRealTimers(); }); });