Archived
feat(sessions): persist shared unread state
This commit is contained in:
@@ -0,0 +1,593 @@
|
||||
import { mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { SESSION_UNREAD_LIMIT, SESSION_UNREAD_SESSION_ID_MAX_LENGTH } from "../../shared/apiTypes.js";
|
||||
import {
|
||||
FileSessionUnreadPersistence,
|
||||
SessionUnreadStore,
|
||||
defaultSessionUnreadFilePath,
|
||||
type SessionUnreadPersistedState,
|
||||
type SessionUnreadPersistence,
|
||||
} from "./sessionUnreadStore.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("SessionUnreadStore", () => {
|
||||
it("marks only known active-to-idle transitions unread", () => {
|
||||
const store = storeAt("2026-07-20T00:00:00.000Z", "catalog-a");
|
||||
|
||||
expect(store.observeActivityState("session-1", "/repo", false)).toEqual([]);
|
||||
expect(store.observeActivityState("session-1", "/repo", true)).toEqual([]);
|
||||
expect(store.observeActivityState("session-1", "/repo", true)).toEqual([]);
|
||||
const completed = store.observeActivityState("session-1", "/repo", false);
|
||||
|
||||
expect(completed).toMatchObject([{
|
||||
event: {
|
||||
type: "sessions.unread",
|
||||
catalogId: "catalog-a",
|
||||
catalogRevision: 1,
|
||||
sessionId: "session-1",
|
||||
cwd: "/repo",
|
||||
unread: { completionOrder: 1, completedAt: "2026-07-20T00:00:00.000Z" },
|
||||
},
|
||||
}]);
|
||||
expect(store.catalogSnapshot()).toMatchObject({
|
||||
catalogId: "catalog-a",
|
||||
catalogRevision: 1,
|
||||
sessions: [{ sessionId: "session-1", cwd: "/repo", completionOrder: 1 }],
|
||||
});
|
||||
expect(store.observeActivityState("session-1", "/repo", false)).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses monotonic completion orders so stale acknowledgements cannot clear newer work", () => {
|
||||
const store = storeAt("2026-07-20T00:00:00.000Z", "catalog-a");
|
||||
complete(store, "session-1", "/repo");
|
||||
const firstOrder = currentOrder(store, "session-1", "/repo");
|
||||
|
||||
complete(store, "session-1", "/repo");
|
||||
const secondOrder = currentOrder(store, "session-1", "/repo");
|
||||
|
||||
expect(secondOrder).toBeGreaterThan(firstOrder);
|
||||
expect(store.acknowledge("session-1", {
|
||||
cwd: "/repo",
|
||||
catalogId: "catalog-a",
|
||||
throughCompletionOrder: firstOrder,
|
||||
}).mutations).toEqual([]);
|
||||
expect(currentOrder(store, "session-1", "/repo")).toBe(secondOrder);
|
||||
|
||||
const acknowledged = store.acknowledge("session-1", {
|
||||
cwd: "/repo",
|
||||
catalogId: "catalog-a",
|
||||
throughCompletionOrder: secondOrder,
|
||||
});
|
||||
expect(acknowledged.mutations).toMatchObject([{
|
||||
event: {
|
||||
type: "sessions.unread",
|
||||
catalogId: "catalog-a",
|
||||
catalogRevision: 3,
|
||||
sessionId: "session-1",
|
||||
unread: null,
|
||||
},
|
||||
}]);
|
||||
expect(store.catalogSnapshot().sessions).toEqual([]);
|
||||
expect(store.acknowledge("session-1", {
|
||||
cwd: "/repo",
|
||||
catalogId: "catalog-a",
|
||||
throughCompletionOrder: secondOrder,
|
||||
}).mutations).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects stale acknowledgements from a reset catalog epoch", () => {
|
||||
const store = storeAt("2026-07-20T00:00:00.000Z", "catalog-new");
|
||||
complete(store, "session-1", "/repo");
|
||||
const current = currentOrder(store, "session-1", "/repo");
|
||||
|
||||
const stale = store.acknowledge("session-1", {
|
||||
cwd: "/repo",
|
||||
catalogId: "catalog-old",
|
||||
throughCompletionOrder: Number.MAX_SAFE_INTEGER,
|
||||
});
|
||||
|
||||
expect(stale.mutations).toEqual([]);
|
||||
expect(currentOrder(store, "session-1", "/repo")).toBe(current);
|
||||
});
|
||||
|
||||
it("scopes lifecycle and acknowledgements to the canonical id and cwd pair", () => {
|
||||
const store = storeAt("2026-07-20T00:00:00.000Z", "catalog-a");
|
||||
complete(store, "session-1", "/repo-a");
|
||||
complete(store, "session-1", "/repo-b");
|
||||
|
||||
const repoBOrder = currentOrder(store, "session-1", "/repo-b");
|
||||
store.acknowledge("session-1", {
|
||||
cwd: "/repo-b",
|
||||
catalogId: "catalog-a",
|
||||
throughCompletionOrder: repoBOrder,
|
||||
});
|
||||
|
||||
expect(store.catalogSnapshot().sessions).toMatchObject([
|
||||
{ sessionId: "session-1", cwd: "/repo-a", completionOrder: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("forgets a closing runtime's active latch without manufacturing a completion", () => {
|
||||
const store = storeAt("2026-07-20T00:00:00.000Z", "catalog-a");
|
||||
store.observeActivityState("session-1", "/repo", true);
|
||||
|
||||
store.forgetActivity("session-1", "/repo");
|
||||
|
||||
expect(store.observeActivityState("session-1", "/repo", false)).toEqual([]);
|
||||
expect(store.catalogSnapshot().sessions).toEqual([]);
|
||||
});
|
||||
|
||||
it("excludes verified tracked sub-sessions and clears accidental lifecycle state", () => {
|
||||
const store = storeAt("2026-07-20T00:00:00.000Z", "catalog-a");
|
||||
store.observeActivityState("tracked", "/repo", true);
|
||||
|
||||
expect(store.excludeSession("tracked", "/repo")).toEqual([]);
|
||||
expect(store.observeActivityState("tracked", "/repo", false)).toEqual([]);
|
||||
expect(store.observeActivityState("tracked", "/repo", true)).toEqual([]);
|
||||
expect(store.observeActivityState("tracked", "/repo", false)).toEqual([]);
|
||||
|
||||
store.forgetSession("tracked", "/repo");
|
||||
complete(store, "tracked", "/repo");
|
||||
const removed = store.excludeSession("tracked", "/repo");
|
||||
|
||||
expect(removed).toMatchObject([{
|
||||
event: { catalogId: "catalog-a", sessionId: "tracked", cwd: "/repo", unread: null },
|
||||
}]);
|
||||
expect(store.catalogSnapshot().sessions).toEqual([]);
|
||||
});
|
||||
|
||||
it("removes durable and transient state when a cwd is reconciled", () => {
|
||||
const store = storeAt("2026-07-20T00:00:00.000Z", "catalog-a");
|
||||
complete(store, "keep", "/repo");
|
||||
complete(store, "remove", "/repo");
|
||||
store.observeActivityState("active-orphan", "/repo", true);
|
||||
store.excludeSession("excluded-orphan", "/repo");
|
||||
|
||||
const mutations = store.reconcileCwd("/repo", ["keep"]);
|
||||
|
||||
expect(mutations).toMatchObject([{ event: { sessionId: "remove", unread: null } }]);
|
||||
expect(store.catalogSnapshot().sessions.map((summary) => summary.sessionId)).toEqual(["keep"]);
|
||||
expect(store.observeActivityState("active-orphan", "/repo", false)).toEqual([]);
|
||||
complete(store, "excluded-orphan", "/repo");
|
||||
expect(currentOrder(store, "excluded-orphan", "/repo")).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("bounds the catalog and emits an authoritative removal when pruning", () => {
|
||||
const store = storeAt("2026-07-20T00:00:00.000Z", "catalog-a");
|
||||
let finalMutations = store.observeActivityState("baseline", "/repo", false);
|
||||
for (let index = 0; index <= SESSION_UNREAD_LIMIT; index += 1) {
|
||||
const sessionId = `session-${index.toString()}`;
|
||||
store.observeActivityState(sessionId, "/repo", true);
|
||||
finalMutations = store.observeActivityState(sessionId, "/repo", false);
|
||||
}
|
||||
|
||||
const snapshot = store.catalogSnapshot();
|
||||
expect(snapshot.sessions).toHaveLength(SESSION_UNREAD_LIMIT);
|
||||
expect(snapshot.sessions.some((summary) => summary.sessionId === "session-0")).toBe(false);
|
||||
expect(snapshot.sessions[0]).toMatchObject({ sessionId: `session-${SESSION_UNREAD_LIMIT.toString()}`, completionOrder: SESSION_UNREAD_LIMIT + 1 });
|
||||
expect(finalMutations).toMatchObject([
|
||||
{ event: { unread: { sessionId: `session-${SESSION_UNREAD_LIMIT.toString()}` } } },
|
||||
{ event: { sessionId: "session-0", unread: null } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("persists the catalog epoch, revisions, and completion order across store instances", async () => {
|
||||
const persistence = new MemoryPersistence(undefined);
|
||||
const first = persistedStore(persistence, "catalog-a", "2026-07-20T00:00:00.000Z");
|
||||
await Promise.all([first.load(), first.load()]);
|
||||
expect(persistence.loadCalls).toBe(1);
|
||||
complete(first, "session-1", "/repo");
|
||||
await first.flush();
|
||||
|
||||
const second = persistedStore(persistence, "unused-catalog-b", "2026-07-20T01:00:00.000Z");
|
||||
await second.load();
|
||||
expect(second.catalogSnapshot()).toEqual(first.catalogSnapshot());
|
||||
|
||||
const order = currentOrder(second, "session-1", "/repo");
|
||||
second.acknowledge("session-1", {
|
||||
cwd: "/repo",
|
||||
catalogId: "catalog-a",
|
||||
throughCompletionOrder: order,
|
||||
});
|
||||
await second.flush();
|
||||
|
||||
const third = persistedStore(persistence, "unused-catalog-c", "2026-07-20T02:00:00.000Z");
|
||||
await third.load();
|
||||
expect(third.catalogSnapshot()).toMatchObject({ catalogId: "catalog-a", catalogRevision: 2, sessions: [] });
|
||||
complete(third, "session-1", "/repo");
|
||||
expect(currentOrder(third, "session-1", "/repo")).toBe(2);
|
||||
});
|
||||
|
||||
it("repairs malformed persistence with a fresh epoch and keeps stale old-epoch acks harmless", async () => {
|
||||
const errors: { operation: "load" | "save"; error: unknown }[] = [];
|
||||
const persistence = new MemoryPersistence({ version: 999, catalogId: "catalog-old" });
|
||||
const store = new SessionUnreadStore({
|
||||
persistence,
|
||||
createCatalogId: () => "catalog-reset",
|
||||
onPersistenceError: (operation, error) => { errors.push({ operation, error }); },
|
||||
now: () => new Date("2026-07-20T00:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(() => store.catalogSnapshot()).toThrow("must be loaded");
|
||||
await store.load();
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]?.operation).toBe("load");
|
||||
expect(errors[0]?.error).toBeInstanceOf(Error);
|
||||
expect(store.catalogSnapshot()).toEqual({ catalogId: "catalog-reset", catalogRevision: 0, sessions: [] });
|
||||
expect(persistence.valueSnapshot()).toMatchObject({
|
||||
version: 1,
|
||||
catalogId: "catalog-reset",
|
||||
catalogRevision: 0,
|
||||
nextCompletionOrder: 0,
|
||||
sessions: [],
|
||||
});
|
||||
|
||||
complete(store, "session-1", "/repo");
|
||||
expect(store.acknowledge("session-1", {
|
||||
cwd: "/repo",
|
||||
catalogId: "catalog-old",
|
||||
throughCompletionOrder: Number.MAX_SAFE_INTEGER,
|
||||
}).mutations).toEqual([]);
|
||||
expect(store.catalogSnapshot().sessions).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("resets persisted protocol fields that exceed shared bounds and rejects oversized runtime identities", async () => {
|
||||
const persistence = new MemoryPersistence({
|
||||
version: 1,
|
||||
catalogId: "catalog-old",
|
||||
catalogRevision: 1,
|
||||
nextCompletionOrder: 1,
|
||||
sessions: [{
|
||||
sessionId: "x".repeat(SESSION_UNREAD_SESSION_ID_MAX_LENGTH + 1),
|
||||
cwd: "/repo",
|
||||
completionOrder: 1,
|
||||
completedAt: "2026-07-20T00:00:00.000Z",
|
||||
}],
|
||||
});
|
||||
const errors: unknown[] = [];
|
||||
const store = new SessionUnreadStore({
|
||||
persistence,
|
||||
createCatalogId: () => "catalog-reset",
|
||||
onPersistenceError: (_operation, error) => { errors.push(error); },
|
||||
});
|
||||
|
||||
await store.load();
|
||||
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(store.catalogSnapshot()).toEqual({ catalogId: "catalog-reset", catalogRevision: 0, sessions: [] });
|
||||
expect(() => store.observeActivityState(
|
||||
"x".repeat(SESSION_UNREAD_SESSION_ID_MAX_LENGTH + 1),
|
||||
"/repo",
|
||||
true,
|
||||
)).toThrow("sessionId exceeds its length limit");
|
||||
});
|
||||
|
||||
it("rejects an oversized persisted catalog instead of loading unbounded state", async () => {
|
||||
const sessions = Array.from({ length: SESSION_UNREAD_LIMIT + 1 }, (_, index) => ({
|
||||
sessionId: `session-${index.toString()}`,
|
||||
cwd: "/repo",
|
||||
completionOrder: index + 1,
|
||||
completedAt: "2026-07-20T00:00:00.000Z",
|
||||
}));
|
||||
const persistence = new MemoryPersistence({
|
||||
version: 1,
|
||||
catalogId: "catalog-old",
|
||||
catalogRevision: sessions.length,
|
||||
nextCompletionOrder: sessions.length,
|
||||
sessions,
|
||||
});
|
||||
const errors: unknown[] = [];
|
||||
const store = new SessionUnreadStore({
|
||||
persistence,
|
||||
createCatalogId: () => "catalog-reset",
|
||||
onPersistenceError: (_operation, error) => { errors.push(error); },
|
||||
});
|
||||
|
||||
await store.load();
|
||||
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(store.catalogSnapshot()).toEqual({ catalogId: "catalog-reset", catalogRevision: 0, sessions: [] });
|
||||
});
|
||||
|
||||
it("serializes writes and captures each mutation's state", async () => {
|
||||
const persistence = new BlockingPersistence(emptyPersistedState("catalog-a"));
|
||||
const store = persistedStore(persistence, "unused-catalog", "2026-07-20T00:00:00.000Z");
|
||||
await store.load();
|
||||
complete(store, "session-1", "/repo");
|
||||
const order = currentOrder(store, "session-1", "/repo");
|
||||
store.acknowledge("session-1", {
|
||||
cwd: "/repo",
|
||||
catalogId: "catalog-a",
|
||||
throughCompletionOrder: order,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => { expect(persistence.savedStates).toHaveLength(1); });
|
||||
expect(persistence.maximumConcurrentSaves).toBe(1);
|
||||
expect(persistence.savedStates[0]?.sessions).toHaveLength(1);
|
||||
|
||||
persistence.releaseNextSave();
|
||||
await vi.waitFor(() => { expect(persistence.savedStates).toHaveLength(2); });
|
||||
expect(persistence.maximumConcurrentSaves).toBe(1);
|
||||
expect(persistence.savedStates[1]?.sessions).toEqual([]);
|
||||
|
||||
const flushed = store.flush();
|
||||
persistence.releaseNextSave();
|
||||
await flushed;
|
||||
});
|
||||
|
||||
it("coalesces a mutation burst behind one in-flight persistence write", async () => {
|
||||
const persistence = new BlockingPersistence(emptyPersistedState("catalog-a"));
|
||||
const store = persistedStore(persistence, "unused-catalog", "2026-07-20T00:00:00.000Z");
|
||||
await store.load();
|
||||
|
||||
complete(store, "session-0", "/repo");
|
||||
for (let index = 1; index <= 200; index += 1) complete(store, `session-${index.toString()}`, "/repo");
|
||||
|
||||
await vi.waitFor(() => { expect(persistence.savedStates).toHaveLength(1); });
|
||||
expect(persistence.savedStates[0]).toMatchObject({ catalogRevision: 1, nextCompletionOrder: 1 });
|
||||
|
||||
persistence.releaseNextSave();
|
||||
await vi.waitFor(() => { expect(persistence.savedStates).toHaveLength(2); });
|
||||
expect(persistence.savedStates[1]).toMatchObject({ catalogRevision: 201, nextCompletionOrder: 201 });
|
||||
|
||||
persistence.releaseNextSave();
|
||||
await store.flush();
|
||||
expect(persistence.savedStates).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("holds one latest snapshot without retrying every mutation during a storage outage", async () => {
|
||||
const saveError = new Error("storage unavailable");
|
||||
const save = vi.fn<SessionUnreadPersistence["save"]>(() => Promise.reject(saveError));
|
||||
const store = persistedStore({
|
||||
load: () => Promise.resolve(emptyPersistedState("catalog-a")),
|
||||
save,
|
||||
}, "unused-catalog", "2026-07-20T00:00:00.000Z");
|
||||
await store.load();
|
||||
|
||||
complete(store, "session-0", "/repo");
|
||||
await vi.waitFor(() => { expect(save).toHaveBeenCalledOnce(); });
|
||||
for (let index = 1; index <= 100; index += 1) complete(store, `session-${index.toString()}`, "/repo");
|
||||
await Promise.resolve();
|
||||
|
||||
expect(save).toHaveBeenCalledOnce();
|
||||
await expect(store.flush()).rejects.toBe(saveError);
|
||||
expect(save).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("retries the latest snapshot before exposing state after a transient save failure", async () => {
|
||||
const persistence = new FailOncePersistence(emptyPersistedState("catalog-a"));
|
||||
const errors: unknown[] = [];
|
||||
const store = new SessionUnreadStore({
|
||||
persistence,
|
||||
createCatalogId: () => "unused-catalog",
|
||||
now: () => new Date("2026-07-20T00:00:00.000Z"),
|
||||
onPersistenceError: (_operation, error) => { errors.push(error); },
|
||||
});
|
||||
await store.load();
|
||||
complete(store, "session-1", "/repo");
|
||||
|
||||
const snapshot = await store.durableCatalogSnapshot();
|
||||
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(persistence.saveCalls).toBe(2);
|
||||
expect(persistence.valueSnapshot()).toMatchObject({
|
||||
catalogId: "catalog-a",
|
||||
catalogRevision: 1,
|
||||
nextCompletionOrder: 1,
|
||||
sessions: [{ sessionId: "session-1", completionOrder: 1 }],
|
||||
});
|
||||
expect(snapshot.sessions).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("rejects operational load failures without overwriting the unread file", async () => {
|
||||
const loadError = Object.assign(new Error("read failed"), { code: "EIO" });
|
||||
const save = vi.fn<SessionUnreadPersistence["save"]>(() => Promise.resolve());
|
||||
const errors: { operation: "load" | "save"; error: unknown }[] = [];
|
||||
const store = new SessionUnreadStore({
|
||||
persistence: { load: () => Promise.reject(loadError), save },
|
||||
createCatalogId: () => "catalog-reset",
|
||||
onPersistenceError: (operation, error) => { errors.push({ operation, error }); },
|
||||
});
|
||||
|
||||
await expect(store.load()).rejects.toBe(loadError);
|
||||
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
expect(errors).toEqual([{ operation: "load", error: loadError }]);
|
||||
expect(() => store.catalogSnapshot()).toThrow("must be loaded");
|
||||
});
|
||||
|
||||
it("rejects startup when a missing or corrupt catalog cannot persist its fresh epoch", async () => {
|
||||
const saveError = new Error("disk full");
|
||||
const errors: { operation: "load" | "save"; error: unknown }[] = [];
|
||||
const store = new SessionUnreadStore({
|
||||
persistence: {
|
||||
load: () => Promise.resolve(undefined),
|
||||
save: () => Promise.reject(saveError),
|
||||
},
|
||||
createCatalogId: () => "catalog-reset",
|
||||
onPersistenceError: (operation, error) => { errors.push({ operation, error }); },
|
||||
});
|
||||
|
||||
await expect(store.load()).rejects.toBe(saveError);
|
||||
|
||||
expect(errors).toEqual([{ operation: "save", error: saveError }]);
|
||||
expect(() => store.catalogSnapshot()).toThrow("must be loaded");
|
||||
});
|
||||
});
|
||||
|
||||
describe("FileSessionUnreadPersistence", () => {
|
||||
it("repairs malformed JSON with a fresh persisted catalog epoch", async () => {
|
||||
const root = await temporaryRoot();
|
||||
const filePath = join(root, "session-unread.json");
|
||||
await writeFile(filePath, "{not-json", "utf8");
|
||||
const errors: unknown[] = [];
|
||||
const store = new SessionUnreadStore({
|
||||
persistence: new FileSessionUnreadPersistence(filePath),
|
||||
createCatalogId: () => "catalog-reset",
|
||||
onPersistenceError: (_operation, error) => { errors.push(error); },
|
||||
});
|
||||
|
||||
await store.load();
|
||||
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(store.catalogSnapshot()).toEqual({ catalogId: "catalog-reset", catalogRevision: 0, sessions: [] });
|
||||
expect(JSON.parse(await readFile(filePath, "utf8"))).toMatchObject({ catalogId: "catalog-reset", sessions: [] });
|
||||
});
|
||||
|
||||
it("uses PI_WEB_DATA_DIR and atomically reloads a private state file", async () => {
|
||||
const root = await temporaryRoot();
|
||||
expect(defaultSessionUnreadFilePath({ PI_WEB_DATA_DIR: "state" }, root)).toBe(join(root, "state", "session-unread.json"));
|
||||
const filePath = join(root, "state", "custom-unread.json");
|
||||
const persistence = new FileSessionUnreadPersistence(filePath);
|
||||
const store = new SessionUnreadStore({
|
||||
persistence,
|
||||
createCatalogId: () => "catalog-a",
|
||||
now: () => new Date("2026-07-20T00:00:00.000Z"),
|
||||
});
|
||||
await store.load();
|
||||
complete(store, "session-1", "/repo");
|
||||
await store.flush();
|
||||
|
||||
const persisted: unknown = JSON.parse(await readFile(filePath, "utf8"));
|
||||
expect(persisted).toMatchObject({
|
||||
version: 1,
|
||||
catalogId: "catalog-a",
|
||||
catalogRevision: 1,
|
||||
nextCompletionOrder: 1,
|
||||
sessions: [{ sessionId: "session-1", cwd: "/repo", completionOrder: 1 }],
|
||||
});
|
||||
expect((await stat(filePath)).mode & 0o777).toBe(0o600);
|
||||
expect((await readdir(join(root, "state"))).filter((name) => name.endsWith(".tmp"))).toEqual([]);
|
||||
|
||||
const reloaded = new SessionUnreadStore({ persistence, createCatalogId: () => "unused-catalog" });
|
||||
await reloaded.load();
|
||||
expect(reloaded.catalogSnapshot()).toMatchObject({
|
||||
catalogId: "catalog-a",
|
||||
sessions: [{ sessionId: "session-1", completionOrder: 1 }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function storeAt(iso: string, catalogId: string): SessionUnreadStore {
|
||||
return new SessionUnreadStore({ now: () => new Date(iso), createCatalogId: () => catalogId });
|
||||
}
|
||||
|
||||
function persistedStore(
|
||||
persistence: SessionUnreadPersistence,
|
||||
catalogId: string,
|
||||
iso: string,
|
||||
): SessionUnreadStore {
|
||||
return new SessionUnreadStore({
|
||||
persistence,
|
||||
createCatalogId: () => catalogId,
|
||||
now: () => new Date(iso),
|
||||
});
|
||||
}
|
||||
|
||||
function complete(store: SessionUnreadStore, sessionId: string, cwd: string): void {
|
||||
store.observeActivityState(sessionId, cwd, true);
|
||||
store.observeActivityState(sessionId, cwd, false);
|
||||
}
|
||||
|
||||
function currentOrder(store: SessionUnreadStore, sessionId: string, cwd: string): number {
|
||||
return store.catalogSnapshot().sessions.find((summary) => summary.sessionId === sessionId && summary.cwd === cwd)?.completionOrder ?? 0;
|
||||
}
|
||||
|
||||
function emptyPersistedState(catalogId: string): SessionUnreadPersistedState {
|
||||
return {
|
||||
version: 1,
|
||||
catalogId,
|
||||
catalogRevision: 0,
|
||||
nextCompletionOrder: 0,
|
||||
sessions: [],
|
||||
};
|
||||
}
|
||||
|
||||
class MemoryPersistence implements SessionUnreadPersistence {
|
||||
loadCalls = 0;
|
||||
|
||||
constructor(private value: unknown) {}
|
||||
|
||||
load(): Promise<unknown> {
|
||||
this.loadCalls += 1;
|
||||
return Promise.resolve(structuredClone(this.value));
|
||||
}
|
||||
|
||||
save(state: SessionUnreadPersistedState): Promise<void> {
|
||||
this.value = structuredClone(state);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
valueSnapshot(): unknown {
|
||||
return structuredClone(this.value);
|
||||
}
|
||||
}
|
||||
|
||||
class FailOncePersistence implements SessionUnreadPersistence {
|
||||
saveCalls = 0;
|
||||
private value: SessionUnreadPersistedState;
|
||||
|
||||
constructor(initial: SessionUnreadPersistedState) {
|
||||
this.value = structuredClone(initial);
|
||||
}
|
||||
|
||||
load(): Promise<unknown> {
|
||||
return Promise.resolve(structuredClone(this.value));
|
||||
}
|
||||
|
||||
save(state: SessionUnreadPersistedState): Promise<void> {
|
||||
this.saveCalls += 1;
|
||||
if (this.saveCalls === 1) return Promise.reject(new Error("transient save failure"));
|
||||
this.value = structuredClone(state);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
valueSnapshot(): unknown {
|
||||
return structuredClone(this.value);
|
||||
}
|
||||
}
|
||||
|
||||
class BlockingPersistence implements SessionUnreadPersistence {
|
||||
readonly savedStates: SessionUnreadPersistedState[] = [];
|
||||
maximumConcurrentSaves = 0;
|
||||
private concurrentSaves = 0;
|
||||
private readonly releases: (() => void)[] = [];
|
||||
|
||||
constructor(private readonly initial: SessionUnreadPersistedState) {}
|
||||
|
||||
load(): Promise<unknown> {
|
||||
return Promise.resolve(structuredClone(this.initial));
|
||||
}
|
||||
|
||||
save(state: SessionUnreadPersistedState): Promise<void> {
|
||||
this.concurrentSaves += 1;
|
||||
this.maximumConcurrentSaves = Math.max(this.maximumConcurrentSaves, this.concurrentSaves);
|
||||
this.savedStates.push(structuredClone(state));
|
||||
return new Promise<void>((resolve) => {
|
||||
this.releases.push(() => {
|
||||
this.concurrentSaves -= 1;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
releaseNextSave(): void {
|
||||
const release = this.releases.shift();
|
||||
if (release === undefined) throw new Error("No blocked persistence save to release");
|
||||
release();
|
||||
}
|
||||
}
|
||||
|
||||
async function temporaryRoot(): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), "pi-web-unread-"));
|
||||
roots.push(root);
|
||||
return root;
|
||||
}
|
||||
Reference in New Issue
Block a user