feat(app): switch replica cache to row storage

Persist only changed replica entities so steady-state writes scale with the update rather than the complete cached world. Restore remains an all-or-nothing validated boundary, with whole-host byte-budget eviction.
This commit is contained in:
Mohamed Boudra
2026-08-25 23:38:38 +02:00
parent f59db03d67
commit 579c6a58f5
21 changed files with 1125 additions and 371 deletions
+8 -6
View File
@@ -104,7 +104,7 @@ Cross-platform React Native app that connects to one or more daemons.
- Expo Router navigation (`/h/[serverId]/workspace/[workspaceId]`, `/h/[serverId]/agent/[agentId]`, etc.). The `workspaceId` URL segment is an opaque workspace id, not a directly meaningful filesystem path.
- `HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state
- `runtime/replica-cache` keeps the complete project, workspace, and active-agent directory plus one short focused timeline tail in AsyncStorage. It restores before navigation becomes ready and leaves remote hydration flags false.
- `runtime/replica-cache` keeps the complete project, workspace, and active-agent directory plus one short focused timeline tail in a row store. It restores before navigation becomes ready and leaves remote hydration flags false.
- `runtime/directory-sync` owns directory reconciliation. On reconnect it passes the persisted per-entity cursor through `project.list`, `fetch_workspaces`, and `fetch_agents`; the daemon returns each entity's latest projection when its sequence is newer, plus tombstones.
- `workspace-labels` owns one sequenced catalog replica per connected host, the deterministic cross-host projection that surfaces spanning hosts use (the filter page, the manager), and the per-host resolution a workspace row's chips use. Two hosts may give one name different colors, so a row resolves against its own host's catalog and a merged answer would be wrong there. Catalogs never synchronize between hosts; assignment creates a missing definition only on the target host. On the daemon, catalog and assignment rewrites share a journaled commit boundary. Startup recovery completes that commit before workspace or catalog publication.
- `SessionContext` wraps the daemon client for the active session
@@ -115,12 +115,14 @@ Cross-platform React Native app that connects to one or more daemons.
The replica cache paints stale data immediately while the host connects. Directory cursors are
reconciliation checkpoints; cached entities remain non-authoritative until the daemon answers.
Pending permission requests are not restored from it. AsyncStorage is not encrypted, so the cached
Pending permission requests are not restored from it. The row store is not encrypted, so the cached
timeline tail may contain source code, prompts, and tool output; encrypted-at-rest storage is a
separate product/security decision. Its serialized payload has a 32 MiB byte budget and evicts whole
host snapshots in least-recently-written order; a single oversized host is omitted rather than
partially restored. Browser and Electron builds store it in IndexedDB. Native builds use
AsyncStorage, and Android reserves 64 MiB for that database.
separate product/security decision. Agents, workspaces, and projects have individual rows; the
focused timeline tail and directory checkpoint each have one row per host. Writes serialize only
rows whose in-memory identities changed. Restore validates every row before hydration; one invalid
row clears the cache and starts cold. The 32 MiB byte budget is tracked incrementally from row
payload lengths and evicts whole hosts in least-recently-written order. Browser and Electron builds
use IndexedDB. Native builds use expo-sqlite.
The three directory entity types have independent monotonic sequences and share one daemon
generation. The daemon retains only the latest projection per entity and bounded tombstones, not an
@@ -14,7 +14,7 @@ import {
const RUN_REPLICA_CACHE_PERF = process.env.PASEO_REPLICA_CACHE_PERF_E2E === "1";
const MAX_STREAM_WRITES = 12;
const MAX_SERIALIZED_CHARS = 190_000;
const MAX_SERIALIZED_CHARS = 45_000;
const test = base.extend<{ replicaCacheAgent: MockAgentWorkspace }>({
replicaCacheAgent: async ({ page: _page }, provide) => {
@@ -1,11 +1,12 @@
import type { CDPSession, Page } from "@playwright/test";
const REPLICA_CACHE_DATABASE = "paseo-replica-cache";
const REPLICA_CACHE_DATABASE = "paseo-replica-row-store";
const REPLICA_CACHE_ROWS_STORE = "rows";
const PERSIST_FRAME_NAMES = [
"captureSessions",
"captureHost",
"buildBoundedPayload",
"serialize",
"queueReplicaChanges",
"apply",
"safeParse",
"stringify",
"setItem",
@@ -14,8 +15,7 @@ const PERSIST_FRAME_NAMES = [
const PERSIST_STACK_ANCHORS = new Set([
"captureSessions",
"captureHost",
"buildBoundedPayload",
"serialize",
"queueReplicaChanges",
"persist",
"flushPending",
"measuredPut",
@@ -69,7 +69,7 @@ declare global {
export async function installReplicaCacheMeasurement(page: Page): Promise<void> {
await page.addInitScript(
({ databaseName }) => {
({ databaseName, storeName }) => {
const encoder = new TextEncoder();
const transactionWrites = new WeakMap<
IDBTransaction,
@@ -128,8 +128,12 @@ export async function installReplicaCacheMeasurement(page: Page): Promise<void>
IDBObjectStore.prototype.put = function measuredPut(value: unknown, key?: IDBValidKey) {
const request =
key === undefined ? originalPut.call(this, value) : originalPut.call(this, value, key);
if (this.transaction.db.name !== databaseName) return request;
const serialized = typeof value === "string" ? value : JSON.stringify(value);
if (this.transaction.db.name !== databaseName || this.name !== storeName) return request;
const rowPayload =
value && typeof value === "object" ? Reflect.get(value, "payload") : undefined;
let serialized = JSON.stringify(value);
if (typeof value === "string") serialized = value;
if (typeof rowPayload === "string") serialized = rowPayload;
const storageKey = JSON.stringify(resolveStorageKey(this, value, key));
observeTransaction(this.transaction).push({
type: "put",
@@ -143,7 +147,7 @@ export async function installReplicaCacheMeasurement(page: Page): Promise<void>
const originalDelete = IDBObjectStore.prototype.delete;
IDBObjectStore.prototype.delete = function measuredDelete(query: IDBValidKey | IDBKeyRange) {
const request = originalDelete.call(this, query);
if (this.transaction.db.name !== databaseName) return request;
if (this.transaction.db.name !== databaseName || this.name !== storeName) return request;
observeTransaction(this.transaction).push({
type: "delete",
key: `${this.name}:${JSON.stringify(query)}`,
@@ -156,6 +160,7 @@ export async function installReplicaCacheMeasurement(page: Page): Promise<void>
const markRestoreRead = (store: IDBObjectStore) => {
if (
store.transaction.db.name === databaseName &&
store.name === storeName &&
store.transaction.mode === "readonly" &&
window.__replicaCacheRestoreReadStartedAt === undefined
) {
@@ -197,7 +202,7 @@ export async function installReplicaCacheMeasurement(page: Page): Promise<void>
}
}
},
{ databaseName: REPLICA_CACHE_DATABASE },
{ databaseName: REPLICA_CACHE_DATABASE, storeName: REPLICA_CACHE_ROWS_STORE },
);
}
@@ -1,26 +1,49 @@
import type { Page } from "@playwright/test";
const DATABASE_NAME = "paseo-replica-cache";
const STORE_NAME = "key-value";
const STORAGE_KEY = "@paseo:replica-cache";
const DATABASE_NAME = "paseo-replica-row-store";
const STORE_NAME = "rows";
const SINGLETON_ID = "singleton";
type ReplicaRowKind = "agent" | "workspace" | "project" | "timeline" | "checkpoint";
interface ReplicaRowRecord {
serverId: string;
kind: ReplicaRowKind;
id: string;
payload: string;
}
interface ReplicaCacheHostRecord {
serverId: string;
agents: Array<Record<string, unknown>>;
workspaces: Array<Record<string, unknown>>;
projects: Array<Record<string, unknown>>;
timeline?: {
agentId?: string;
items?: Array<Record<string, unknown>>;
range?: {
endSeq?: number;
epoch?: string;
startSeq?: number;
} | null;
} | null;
directorySync?: unknown;
}
export interface ReplicaCacheRecord {
version?: number;
hosts?: Array<{
timeline?: {
agentId?: string;
items?: Array<Record<string, unknown>>;
range?: {
endSeq?: number;
epoch?: string;
startSeq?: number;
} | null;
} | null;
}>;
hosts?: ReplicaCacheHostRecord[];
}
interface ReplicaCacheWriteObserverState {
lastValue: string | null;
lastValues: Record<string, string>;
redundantWrites: number;
serializedChars: number;
storageDurationMs: number;
writes: number;
}
interface ReplicaCacheWriteObserverReport {
redundantWrites: number;
serializedChars: number;
storageDurationMs: number;
@@ -33,11 +56,10 @@ declare global {
}
}
async function readStoredValue(input: {
async function readRows(input: {
databaseName: string;
storeName: string;
storageKey: string;
}): Promise<string | null> {
}): Promise<ReplicaRowRecord[]> {
const database = await new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(input.databaseName, 1);
request.addEventListener("success", () => resolve(request.result));
@@ -47,26 +69,103 @@ async function readStoredValue(input: {
const request = database
.transaction(input.storeName, "readonly")
.objectStore(input.storeName)
.get(input.storageKey);
request.addEventListener("success", () =>
resolve(typeof request.result === "string" ? request.result : null),
);
.getAll();
request.addEventListener("success", () => resolve(request.result));
request.addEventListener("error", () => reject(request.error));
});
}
function assembleCache(rows: ReplicaRowRecord[]): ReplicaCacheRecord {
const hosts = new Map<string, ReplicaCacheHostRecord>();
for (const row of rows) {
const host = hosts.get(row.serverId) ?? {
serverId: row.serverId,
agents: [],
workspaces: [],
projects: [],
timeline: null,
};
const payload = JSON.parse(row.payload) as Record<string, unknown>;
switch (row.kind) {
case "agent":
host.agents.push(payload);
break;
case "workspace":
host.workspaces.push(payload);
break;
case "project":
host.projects.push(payload);
break;
case "timeline":
host.timeline = payload;
break;
case "checkpoint":
host.directorySync = payload;
break;
}
hosts.set(row.serverId, host);
}
return { version: 6, hosts: [...hosts.values()] };
}
export async function readReplicaCache(page: Page): Promise<ReplicaCacheRecord | null> {
const raw = await page.evaluate(readStoredValue, {
const rows = await page.evaluate(readRows, {
databaseName: DATABASE_NAME,
storeName: STORE_NAME,
storageKey: STORAGE_KEY,
});
return raw ? (JSON.parse(raw) as ReplicaCacheRecord) : null;
return rows.length > 0 ? assembleCache(rows) : null;
}
export async function writeReplicaCache(page: Page, value: ReplicaCacheRecord): Promise<void> {
const rows: ReplicaRowRecord[] = [];
for (const host of value.hosts ?? []) {
for (const agent of host.agents) {
const snapshot = agent.snapshot;
if (!snapshot || typeof snapshot !== "object") continue;
const id = Reflect.get(snapshot, "id");
if (typeof id === "string") {
rows.push({ serverId: host.serverId, kind: "agent", id, payload: JSON.stringify(agent) });
}
}
for (const workspace of host.workspaces) {
if (typeof workspace.id === "string") {
rows.push({
serverId: host.serverId,
kind: "workspace",
id: workspace.id,
payload: JSON.stringify(workspace),
});
}
}
for (const project of host.projects) {
if (typeof project.projectId === "string") {
rows.push({
serverId: host.serverId,
kind: "project",
id: project.projectId,
payload: JSON.stringify(project),
});
}
}
if (host.timeline) {
rows.push({
serverId: host.serverId,
kind: "timeline",
id: SINGLETON_ID,
payload: JSON.stringify(host.timeline),
});
}
if (host.directorySync !== undefined) {
rows.push({
serverId: host.serverId,
kind: "checkpoint",
id: SINGLETON_ID,
payload: JSON.stringify(host.directorySync),
});
}
}
await page.evaluate(
async ({ databaseName, storeName, storageKey, raw }) => {
async ({ databaseName, storeName, nextRows }) => {
const database = await new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(databaseName, 1);
request.addEventListener("success", () => resolve(request.result));
@@ -74,26 +173,23 @@ export async function writeReplicaCache(page: Page, value: ReplicaCacheRecord):
});
await new Promise<void>((resolve, reject) => {
const transaction = database.transaction(storeName, "readwrite");
transaction.objectStore(storeName).put(raw, storageKey);
const store = transaction.objectStore(storeName);
store.clear();
for (const row of nextRows) store.put(row);
transaction.addEventListener("complete", () => resolve());
transaction.addEventListener("error", () => reject(transaction.error));
transaction.addEventListener("abort", () => reject(transaction.error));
});
},
{
databaseName: DATABASE_NAME,
storeName: STORE_NAME,
storageKey: STORAGE_KEY,
raw: JSON.stringify(value),
},
{ databaseName: DATABASE_NAME, storeName: STORE_NAME, nextRows: rows },
);
}
export async function observeReplicaCacheStorageWrites(page: Page): Promise<void> {
await page.addInitScript(
({ databaseName, storeName, storageKey }) => {
({ databaseName, storeName }) => {
window.__replicaCacheWriteObserver = {
lastValue: null,
lastValues: {},
redundantWrites: 0,
serializedChars: 0,
storageDurationMs: 0,
@@ -106,51 +202,71 @@ export async function observeReplicaCacheStorageWrites(page: Page): Promise<void
if (
this.transaction.db.name !== databaseName ||
this.name !== storeName ||
key !== storageKey ||
typeof value !== "string"
!value ||
typeof value !== "object"
) {
return request;
}
const serverId = Reflect.get(value, "serverId");
const kind = Reflect.get(value, "kind");
const id = Reflect.get(value, "id");
const payload = Reflect.get(value, "payload");
if (
typeof serverId !== "string" ||
typeof kind !== "string" ||
typeof id !== "string" ||
typeof payload !== "string"
) {
return request;
}
const rowKey = `${serverId}:${kind}:${id}`;
const startedAt = performance.now();
request.addEventListener("success", () => {
const state = window.__replicaCacheWriteObserver;
if (!state) return;
if (state.lastValue === value) state.redundantWrites += 1;
state.lastValue = value;
if (state.lastValues[rowKey] === payload) state.redundantWrites += 1;
state.lastValues[rowKey] = payload;
state.writes += 1;
state.serializedChars += value.length;
state.serializedChars += payload.length;
state.storageDurationMs += performance.now() - startedAt;
});
return request;
};
},
{ databaseName: DATABASE_NAME, storeName: STORE_NAME, storageKey: STORAGE_KEY },
{ databaseName: DATABASE_NAME, storeName: STORE_NAME },
);
}
export async function resetReplicaCacheStorageWriteObserver(page: Page): Promise<void> {
const lastValue = await page.evaluate(readStoredValue, {
const rows = await page.evaluate(readRows, {
databaseName: DATABASE_NAME,
storeName: STORE_NAME,
storageKey: STORAGE_KEY,
});
await page.evaluate((value) => {
const lastValues = Object.fromEntries(
rows.map((row) => [`${row.serverId}:${row.kind}:${row.id}`, row.payload]),
);
await page.evaluate((values) => {
window.__replicaCacheWriteObserver = {
lastValue: value,
lastValues: values,
redundantWrites: 0,
serializedChars: 0,
storageDurationMs: 0,
writes: 0,
};
}, lastValue);
}, lastValues);
}
export async function readReplicaCacheStorageWriteObserver(
page: Page,
): Promise<ReplicaCacheWriteObserverState> {
): Promise<ReplicaCacheWriteObserverReport> {
return page.evaluate(() => {
const state = window.__replicaCacheWriteObserver;
if (!state) throw new Error("Replica cache write observer is not installed");
return state;
return {
redundantWrites: state.redundantWrites,
serializedChars: state.serializedChars,
storageDurationMs: state.storageDurationMs,
writes: state.writes,
};
});
}
@@ -0,0 +1,4 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import type { ProjectIconCacheStorage } from "./icon-cache";
export const projectIconCacheStorage: ProjectIconCacheStorage = AsyncStorage;
@@ -0,0 +1,46 @@
import type { ProjectIconCacheStorage } from "./icon-cache";
const DATABASE_NAME = "paseo-project-icon-cache";
const STORE_NAME = "key-value";
let databasePromise: Promise<IDBDatabase> | null = null;
function openDatabase(): Promise<IDBDatabase> {
databasePromise ??= new Promise((resolve, reject) => {
const request = indexedDB.open(DATABASE_NAME, 1);
request.addEventListener("upgradeneeded", () => {
if (!request.result.objectStoreNames.contains(STORE_NAME)) {
request.result.createObjectStore(STORE_NAME);
}
});
request.addEventListener("success", () => resolve(request.result));
request.addEventListener("error", () =>
reject(request.error ?? new Error("Failed to open project icon cache")),
);
});
return databasePromise;
}
async function getItem(key: string): Promise<string | null> {
const database = await openDatabase();
return new Promise((resolve, reject) => {
const request = database.transaction(STORE_NAME, "readonly").objectStore(STORE_NAME).get(key);
request.addEventListener("success", () =>
resolve(typeof request.result === "string" ? request.result : null),
);
request.addEventListener("error", () => reject(request.error));
});
}
async function setItem(key: string, value: string): Promise<void> {
const database = await openDatabase();
return new Promise((resolve, reject) => {
const transaction = database.transaction(STORE_NAME, "readwrite");
transaction.objectStore(STORE_NAME).put(value, key);
transaction.addEventListener("complete", () => resolve());
transaction.addEventListener("error", () => reject(transaction.error));
transaction.addEventListener("abort", () => reject(transaction.error));
});
}
export const projectIconCacheStorage: ProjectIconCacheStorage = { getItem, setItem };
+2 -2
View File
@@ -1,6 +1,6 @@
import type { ProjectIcon } from "@getpaseo/protocol/messages";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { replicaCacheStorage } from "@/runtime/replica-cache/storage";
import { projectIconCacheStorage } from "./icon-cache-storage";
import type { ProjectIconTarget } from "./icon-target";
const STORAGE_KEY = "@paseo:project-icon-cache";
@@ -197,7 +197,7 @@ export class ProjectIconCache {
}
}
export const projectIconCache = new ProjectIconCache(replicaCacheStorage);
export const projectIconCache = new ProjectIconCache(projectIconCacheStorage);
function resolveLookup(
target: Pick<ProjectIconTarget, "projectId" | "iconWorkingDir">,
+38 -1
View File
@@ -22,6 +22,7 @@ import {
type HostRuntimeStorage,
} from "./host-runtime";
import { ReplicaCache } from "./replica-cache";
import type { ReplicaRow, ReplicaRowStore } from "./replica-cache/row-store";
class FakeDaemonClient {
private state: ConnectionState = { status: "idle" };
@@ -451,6 +452,40 @@ function createMemoryHostRuntimeStorage(entries: Record<string, string> = {}): H
};
}
function createMemoryReplicaRowStore(): ReplicaRowStore {
const rows = new Map<string, ReplicaRow>();
const keyOf = (row: Pick<ReplicaRow, "serverId" | "kind" | "id">) =>
`${row.serverId}:${row.kind}:${row.id}`;
return {
open: async () => undefined,
readAll: async () => {
const hosts = new Map<string, ReplicaRow[]>();
for (const row of rows.values()) {
const hostRows = hosts.get(row.serverId) ?? [];
hostRows.push(row);
hosts.set(row.serverId, hostRows);
}
return [...hosts].map(([serverId, hostRows]) => ({ serverId, rows: hostRows }));
},
apply: async (changes) => {
for (const key of changes.deletes) rows.delete(keyOf(key));
for (const row of changes.upserts) rows.set(keyOf(row), row);
},
deleteHost: async (serverId) => {
for (const [key, row] of rows) if (row.serverId === serverId) rows.delete(key);
},
renameHost: async (oldServerId, newServerId) => {
for (const [key, row] of rows) {
if (row.serverId !== oldServerId) continue;
rows.delete(key);
const renamed = { ...row, serverId: newServerId };
rows.set(keyOf(renamed), renamed);
}
},
clear: async () => rows.clear(),
};
}
function createAppearanceStore(storage: HostRuntimeStorage): HostRuntimeStore {
return new HostRuntimeStore({
storage,
@@ -1438,6 +1473,7 @@ describe("HostRuntimeStore", () => {
it("restores the display replica before declaring the host registry loaded", async () => {
const host = makeHost();
const storage = createMemoryHostRuntimeStorage();
const replicaRowStore = createMemoryReplicaRowStore();
await storage.setItem("@paseo:daemon-registry", JSON.stringify([host]));
await storage.setItem("@paseo:e2e", "1");
@@ -1470,13 +1506,14 @@ describe("HostRuntimeStore", () => {
],
]),
);
const cache = new ReplicaCache(storage);
const cache = new ReplicaCache(replicaRowStore, { clearLegacyCache: async () => undefined });
cache.setHosts([host.serverId]);
await cache.flush();
session.clearSession(host.serverId);
const store = new HostRuntimeStore({
storage,
replicaRowStore,
deps: {
createClient: () => {
throw new Error("createClient should not be called");
+4 -2
View File
@@ -60,7 +60,8 @@ import { resolveComposerAttachmentSubmitFormat } from "@/composer/attachments/su
import { encodeImages } from "@/utils/encode-images";
import { DirectorySync, type RefreshAgentDirectoryResult } from "@/runtime/directory-sync";
import { ReplicaCache } from "@/runtime/replica-cache";
import { replicaCacheStorage } from "@/runtime/replica-cache/storage";
import type { ReplicaRowStore } from "@/runtime/replica-cache/row-store";
import { createReplicaRowStore } from "@/runtime/replica-cache/row-store-factory";
import { projectIconCache } from "@/projects/icon-cache";
import { nativePerformanceTrace } from "@/performance/native-trace";
import { revokePushNotifications } from "@/push-notifications";
@@ -1373,11 +1374,12 @@ export class HostRuntimeStore {
constructor(input?: {
deps?: HostRuntimeControllerDeps;
storage?: HostRuntimeStorage;
replicaRowStore?: ReplicaRowStore;
revokePushNotifications?: typeof revokePushNotifications;
}) {
this.deps = input?.deps ?? createDefaultDeps();
this.storage = input?.storage ?? AsyncStorage;
this.replicaCache = new ReplicaCache(input?.storage ?? replicaCacheStorage);
this.replicaCache = new ReplicaCache(input?.replicaRowStore ?? createReplicaRowStore());
this.revokePushNotifications = input?.revokePushNotifications ?? revokePushNotifications;
}
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import type { StreamItem } from "@/types/stream";
import { diffMap, diffReplicaInput, type ReplicaInput } from "./diff";
function input(overrides: Partial<ReplicaInput> = {}): ReplicaInput {
return {
agents: new Map(),
workspaces: new Map(),
projects: new Map(),
focusedAgentId: undefined,
timelineItems: undefined,
timelineRange: null,
timelineHasOlder: false,
...overrides,
};
}
describe("diffReplicaInput", () => {
it("upserts only values whose map identity changed and deletes missing ids", () => {
const retained = { value: "retained" };
const previousValue = { value: "previous" };
const changed = { value: "changed" };
const removed = { value: "removed" };
const previous = new Map([
["retained", retained],
["changed", previousValue],
["removed", removed],
]);
const next = new Map([
["retained", retained],
["changed", changed],
]);
expect(diffMap(previous, next)).toEqual({
upserts: [changed],
deletes: ["removed"],
});
});
it("does not rewrite the timeline when its inputs retain identity", () => {
const timelineItems: StreamItem[] = [];
const range = { epoch: "epoch", startSeq: 1, endSeq: 1 };
const previous = input({
focusedAgentId: "agent",
timelineItems,
timelineRange: range,
});
const next = input({
focusedAgentId: "agent",
timelineItems,
timelineRange: range,
});
expect(diffReplicaInput(previous, next).timelineChanged).toBe(false);
});
it("rewrites the timeline when one of its inputs changes identity", () => {
const previous = input({ focusedAgentId: "agent", timelineItems: [] });
const next = input({ focusedAgentId: "agent", timelineItems: [] });
expect(diffReplicaInput(previous, next).timelineChanged).toBe(true);
});
});
@@ -0,0 +1,77 @@
import type {
Agent,
ProjectDescriptor,
SessionReplicaTimeline,
WorkspaceDescriptor,
} from "@/stores/session-store";
import type { StreamItem } from "@/types/stream";
export interface ReplicaInput {
agents: ReadonlyMap<string, Agent>;
workspaces: ReadonlyMap<string, WorkspaceDescriptor>;
projects: ReadonlyMap<string, ProjectDescriptor>;
focusedAgentId: string | undefined;
timelineItems: StreamItem[] | undefined;
timelineRange: SessionReplicaTimeline["range"];
timelineHasOlder: boolean;
}
interface EntityChanges<Entity> {
upserts: Entity[];
deletes: string[];
}
export interface ReplicaInputChanges {
agents: EntityChanges<Agent>;
workspaces: EntityChanges<WorkspaceDescriptor>;
projects: EntityChanges<ProjectDescriptor>;
timelineChanged: boolean;
}
export function diffMap<Entity>(
previous: ReadonlyMap<string, Entity> | undefined,
next: ReadonlyMap<string, Entity>,
): EntityChanges<Entity> {
if (previous === next) return { upserts: [], deletes: [] };
const upserts: Entity[] = [];
const deletes: string[] = [];
for (const [id, value] of next) {
if (previous?.get(id) !== value) upserts.push(value);
}
if (previous) {
for (const id of previous.keys()) {
if (!next.has(id)) deletes.push(id);
}
}
return { upserts, deletes };
}
export function diffReplicaInput(
previous: ReplicaInput | undefined,
next: ReplicaInput,
): ReplicaInputChanges {
const timelineChanged =
previous === undefined ||
previous.focusedAgentId !== next.focusedAgentId ||
previous.timelineItems !== next.timelineItems ||
previous.timelineRange !== next.timelineRange ||
previous.timelineHasOlder !== next.timelineHasOlder;
return {
agents: diffMap(previous?.agents, next.agents),
workspaces: diffMap(previous?.workspaces, next.workspaces),
projects: diffMap(previous?.projects, next.projects),
timelineChanged,
};
}
export function hasReplicaInputChanges(changes: ReplicaInputChanges): boolean {
return (
changes.agents.upserts.length > 0 ||
changes.agents.deletes.length > 0 ||
changes.workspaces.upserts.length > 0 ||
changes.workspaces.deletes.length > 0 ||
changes.projects.upserts.length > 0 ||
changes.projects.deletes.length > 0 ||
changes.timelineChanged
);
}
@@ -8,27 +8,68 @@ import {
useSessionStore,
} from "@/stores/session-store";
import { createUserMessage, type StreamItem } from "@/types/stream";
import { ReplicaCache, type ReplicaCacheStorage } from ".";
import { ReplicaCache } from ".";
import type { ReplicaHostRows, ReplicaRow, ReplicaRowChanges, ReplicaRowStore } from "./row-store";
const SERVER_ID = "cached-host";
const LRU_SERVER_IDS = ["host-a", "host-b", "host-c"] as const;
class MemoryStorage implements ReplicaCacheStorage {
readonly values = new Map<string, string>();
class MemoryStorage implements ReplicaRowStore {
readonly rows = new Map<string, ReplicaRow>();
readonly changes: ReplicaRowChanges[] = [];
readonly deletedHosts: string[] = [];
readonly renamedHosts: Array<{ oldServerId: string; newServerId: string }> = [];
writes = 0;
clears = 0;
async getItem(key: string): Promise<string | null> {
return this.values.get(key) ?? null;
private key(row: Pick<ReplicaRow, "serverId" | "kind" | "id">): string {
return `${row.serverId}:${row.kind}:${row.id}`;
}
async setItem(key: string, value: string): Promise<void> {
async open(): Promise<void> {}
async readAll(): Promise<ReplicaHostRows[]> {
const hosts = new Map<string, ReplicaRow[]>();
for (const row of this.rows.values()) {
const rows = hosts.get(row.serverId) ?? [];
rows.push(row);
hosts.set(row.serverId, rows);
}
return Array.from(hosts, ([serverId, rows]) => ({ serverId, rows }));
}
async apply(changes: ReplicaRowChanges): Promise<void> {
this.writes += 1;
this.values.set(key, value);
this.changes.push(changes);
for (const key of changes.deletes) this.rows.delete(this.key(key));
for (const row of changes.upserts) this.rows.set(this.key(row), row);
}
async removeItem(key: string): Promise<void> {
this.values.delete(key);
async deleteHost(serverId: string): Promise<void> {
this.deletedHosts.push(serverId);
for (const [key, row] of this.rows) if (row.serverId === serverId) this.rows.delete(key);
}
async renameHost(oldServerId: string, newServerId: string): Promise<void> {
this.renamedHosts.push({ oldServerId, newServerId });
for (const [key, row] of this.rows) {
if (row.serverId !== oldServerId) continue;
this.rows.delete(key);
const renamed = { ...row, serverId: newServerId };
this.rows.set(this.key(renamed), renamed);
}
}
async clear(): Promise<void> {
this.clears += 1;
this.rows.clear();
}
}
const NO_LEGACY_CLEANUP = { clearLegacyCache: async () => undefined };
function cache(storage: MemoryStorage, options: { maxBytes?: number } = {}): ReplicaCache {
return new ReplicaCache(storage, { ...options, ...NO_LEGACY_CLEANUP });
}
function workspace(
@@ -139,7 +180,26 @@ function toolCall(): StreamItem {
function seedSession(): void {
const store = useSessionStore.getState();
store.initializeSession(SERVER_ID, null);
store.setAgents(SERVER_ID, new Map([["agent-1", agent("agent-1")]]));
store.setAgents(
SERVER_ID,
new Map([
[
"agent-1",
{
...agent("agent-1"),
pendingPermissions: [
{
id: "pending-permission",
provider: "codex",
name: "shell",
kind: "tool",
title: "Approve shell command",
},
],
},
],
]),
);
store.setWorkspaces(
SERVER_ID,
new Map([
@@ -212,18 +272,18 @@ describe("ReplicaCache", () => {
it("persists after user inactivity even while replica changes continue", async () => {
vi.useFakeTimers();
const storage = new MemoryStorage();
const cache = new ReplicaCache(storage);
cache.setHosts([SERVER_ID]);
const replicaCache = cache(storage);
replicaCache.setHosts([SERVER_ID]);
seedSession();
await cache.flush();
cache.start();
await replicaCache.flush();
replicaCache.start();
const writesBeforeChange = storage.writes;
useSessionStore
.getState()
.setAgentStreamTail(SERVER_ID, new Map([["agent-1", [message("first", "First")]]]));
await vi.advanceTimersByTimeAsync(4_000);
cache.recordUserActivity();
replicaCache.recordUserActivity();
await vi.advanceTimersByTimeAsync(1_000);
useSessionStore
.getState()
@@ -235,17 +295,17 @@ describe("ReplicaCache", () => {
await vi.advanceTimersByTimeAsync(1);
expect(storage.writes).toBe(writesBeforeChange + 1);
cache.setHosts([]);
replicaCache.setHosts([]);
});
it("persists focused replica changes without writing transient stream head updates", async () => {
vi.useFakeTimers();
const storage = new MemoryStorage();
const cache = new ReplicaCache(storage);
cache.setHosts([SERVER_ID]);
const replicaCache = cache(storage);
replicaCache.setHosts([SERVER_ID]);
seedSession();
await cache.flush();
cache.start();
await replicaCache.flush();
replicaCache.start();
const writesBeforeStream = storage.writes;
useSessionStore
@@ -261,19 +321,19 @@ describe("ReplicaCache", () => {
await vi.advanceTimersByTimeAsync(5_000);
expect(storage.writes).toBe(writesBeforeStream + 1);
cache.setHosts([]);
replicaCache.setHosts([]);
});
it("restores the exact persisted canonical timeline window", async () => {
const storage = new MemoryStorage();
const writer = new ReplicaCache(storage);
const writer = cache(storage);
writer.setHosts([SERVER_ID]);
seedSession();
await writer.flush();
useSessionStore.getState().clearSession(SERVER_ID);
const reader = new ReplicaCache(storage);
const reader = cache(storage);
reader.setHosts([SERVER_ID]);
await reader.restore();
@@ -289,6 +349,7 @@ describe("ReplicaCache", () => {
expect(Array.from(session.projects.keys())).toEqual(["project-1", "empty-project"]);
expect(session.agents.get("agent-1")?.updatedAt).toBeInstanceOf(Date);
expect(session.agents.get("agent-1")?.projectPlacement?.checkout.cwd).toBe("/repo/paseo");
expect(session.agents.get("agent-1")?.pendingPermissions).toEqual([]);
expect(session.workspaces.get("workspace-1")?.statusEnteredAt).toBeInstanceOf(Date);
expect(session.workspaces.get("workspace-1")?.worktreeSlug).toBe("owned-worktree");
// A restored row draws its label chips. The reconnect cursor is current, so nothing re-sends
@@ -313,7 +374,7 @@ describe("ReplicaCache", () => {
it("restores canonical turn membership without downgrading tagged rows", async () => {
const storage = new MemoryStorage();
const writer = new ReplicaCache(storage);
const writer = cache(storage);
writer.setHosts([SERVER_ID]);
seedSession();
const initial: StreamItem = {
@@ -340,7 +401,7 @@ describe("ReplicaCache", () => {
);
await writer.flush();
useSessionStore.getState().clearSession(SERVER_ID);
const reader = new ReplicaCache(storage);
const reader = cache(storage);
reader.setHosts([SERVER_ID]);
await reader.restore();
const tail =
@@ -350,14 +411,14 @@ describe("ReplicaCache", () => {
it("restores tool calls inside an authoritative cached window", async () => {
const storage = new MemoryStorage();
const writer = new ReplicaCache(storage);
const writer = cache(storage);
writer.setHosts([SERVER_ID]);
seedSession();
useSessionStore.getState().setAgentStreamTail(SERVER_ID, new Map([["agent-1", [toolCall()]]]));
await writer.flush();
useSessionStore.getState().clearSession(SERVER_ID);
const reader = new ReplicaCache(storage);
const reader = cache(storage);
reader.setHosts([SERVER_ID]);
await reader.restore();
@@ -372,7 +433,7 @@ describe("ReplicaCache", () => {
it("restores display-only when retained items do not reach the stored range end", async () => {
const storage = new MemoryStorage();
const writer = new ReplicaCache(storage);
const writer = cache(storage);
writer.setHosts([SERVER_ID]);
seedSession();
useSessionStore
@@ -384,7 +445,7 @@ describe("ReplicaCache", () => {
await writer.flush();
useSessionStore.getState().clearSession(SERVER_ID);
const reader = new ReplicaCache(storage);
const reader = cache(storage);
reader.setHosts([SERVER_ID]);
await reader.restore();
@@ -399,8 +460,8 @@ describe("ReplicaCache", () => {
it("persists the complete directory with only the focused timeline tail", async () => {
const storage = new MemoryStorage();
const cache = new ReplicaCache(storage);
cache.setHosts([SERVER_ID]);
const replicaCache = cache(storage);
replicaCache.setHosts([SERVER_ID]);
seedSession();
const store = useSessionStore.getState();
@@ -431,10 +492,10 @@ describe("ReplicaCache", () => {
store.setAgentTimelineHasOlder(SERVER_ID, new Map([["agent-2", true]]));
store.setAgentAuthoritativeHistoryApplied(SERVER_ID, "agent-2", true);
store.setFocusedAgentId(SERVER_ID, "agent-2");
await cache.flush();
await replicaCache.flush();
store.clearSession(SERVER_ID);
const reader = new ReplicaCache(storage);
const reader = cache(storage);
reader.setHosts([SERVER_ID]);
await reader.restore();
@@ -455,23 +516,17 @@ describe("ReplicaCache", () => {
items: secondTimeline.slice(-50),
});
const persisted = JSON.parse(storage.values.get("@paseo:replica-cache") ?? "null") as {
version: number;
hosts: Array<{ timeline: Record<string, unknown> | null }>;
};
expect(persisted.version).toBe(6);
expect(Object.keys(persisted.hosts[0]?.timeline ?? {}).sort()).toEqual([
"agentId",
"hasOlder",
"items",
"range",
]);
const timelineRow = [...storage.rows.values()].find((row) => row.kind === "timeline");
expect(timelineRow).toBeDefined();
if (!timelineRow) throw new Error("Expected persisted timeline row");
const persisted = JSON.parse(timelineRow.payload) as Record<string, unknown>;
expect(Object.keys(persisted).sort()).toEqual(["agentId", "hasOlder", "items", "range"]);
});
it("persists reconciled rows without caching unreconciled local presentations", async () => {
const storage = new MemoryStorage();
const cache = new ReplicaCache(storage);
cache.setHosts([SERVER_ID]);
const replicaCache = cache(storage);
replicaCache.setHosts([SERVER_ID]);
seedSession();
const unreconciled = createUserMessage({
clientMessageId: "client-pending",
@@ -489,9 +544,9 @@ describe("ReplicaCache", () => {
.getState()
.setAgentStreamTail(SERVER_ID, new Map([["agent-1", [unreconciled, reconciled]]]));
await cache.flush();
await replicaCache.flush();
useSessionStore.getState().clearSession(SERVER_ID);
await cache.restore();
await replicaCache.restore();
expect(useSessionStore.getState().sessions[SERVER_ID]?.agentStreamTail.get("agent-1")).toEqual([
reconciled,
@@ -500,12 +555,12 @@ describe("ReplicaCache", () => {
it("persists monotonic directory cursors with the complete host replica", async () => {
const storage = new MemoryStorage();
const cache = new ReplicaCache(storage);
cache.setHosts([SERVER_ID]);
const replicaCache = cache(storage);
replicaCache.setHosts([SERVER_ID]);
seedSession();
await cache.flush();
await replicaCache.flush();
cache.writeDirectoryCheckpoint(SERVER_ID, {
replicaCache.writeDirectoryCheckpoint(SERVER_ID, {
agents: { generation: "daemon-generation", afterSeq: 7 },
});
useSessionStore.getState().setAgents(SERVER_ID, (agents) => {
@@ -513,14 +568,75 @@ describe("ReplicaCache", () => {
if (!current) throw new Error("Expected seeded agent");
return new Map(agents).set("agent-1", { ...current, title: "Updated agent" });
});
await cache.flush();
await replicaCache.flush();
const reader = new ReplicaCache(storage);
const reader = cache(storage);
reader.setHosts([SERVER_ID]);
await reader.restore();
expect(reader.readDirectoryCheckpoint(SERVER_ID)).toEqual({
agents: { generation: "daemon-generation", afterSeq: 7 },
});
const finalWrite = storage.changes.at(-1);
expect(finalWrite?.upserts.map((row) => row.kind).sort()).toEqual(["agent", "checkpoint"]);
});
it("persists one agent update as exactly one row upsert", async () => {
const storage = new MemoryStorage();
const replicaCache = cache(storage);
replicaCache.setHosts([SERVER_ID]);
seedSession();
await replicaCache.flush();
storage.changes.length = 0;
useSessionStore.getState().setAgents(SERVER_ID, (agents) => {
const current = agents.get("agent-1");
if (!current) throw new Error("Expected seeded agent");
return new Map(agents).set(current.id, { ...current, title: "Updated" });
});
await replicaCache.flush();
expect(storage.changes).toHaveLength(1);
expect(storage.changes[0]).toMatchObject({
upserts: [{ serverId: SERVER_ID, kind: "agent", id: "agent-1" }],
deletes: [],
});
});
it("persists a checkpoint without serializing host entity rows", async () => {
const storage = new MemoryStorage();
const replicaCache = cache(storage);
replicaCache.setHosts([SERVER_ID]);
seedSession();
await replicaCache.flush();
storage.changes.length = 0;
replicaCache.writeDirectoryCheckpoint(SERVER_ID, {
agents: { generation: "generation", afterSeq: 4 },
});
await replicaCache.flush();
expect(storage.changes).toHaveLength(1);
expect(storage.changes[0]?.upserts).toEqual([
expect.objectContaining({ kind: "checkpoint", id: "singleton" }),
]);
});
it("maps host removal and reconciliation to row-store host operations", async () => {
const storage = new MemoryStorage();
const replicaCache = cache(storage);
replicaCache.setHosts([SERVER_ID]);
seedSession();
await replicaCache.flush();
replicaCache.reconcileServerId(SERVER_ID, "reconciled-host");
await replicaCache.flush();
replicaCache.setHosts([]);
await replicaCache.flush();
expect(storage.renamedHosts).toEqual([
{ oldServerId: SERVER_ID, newServerId: "reconciled-host" },
]);
expect(storage.deletedHosts).toContain("reconciled-host");
});
it("restores workspace change request checks beside the directory cursor", async () => {
@@ -546,7 +662,7 @@ describe("ReplicaCache", () => {
error: null,
};
const storage = new MemoryStorage();
const writer = new ReplicaCache(storage);
const writer = cache(storage);
writer.setHosts([SERVER_ID]);
seedSession();
useSessionStore.getState().setWorkspaces(
@@ -568,7 +684,7 @@ describe("ReplicaCache", () => {
await writer.flush();
useSessionStore.getState().clearSession(SERVER_ID);
const reader = new ReplicaCache(storage);
const reader = cache(storage);
reader.setHosts([SERVER_ID]);
await reader.restore();
@@ -582,13 +698,13 @@ describe("ReplicaCache", () => {
it("restores every registered host directory before any host reconnects", async () => {
const storage = new MemoryStorage();
const writer = new ReplicaCache(storage);
const writer = cache(storage);
writer.setHosts(LRU_SERVER_IDS);
for (const serverId of LRU_SERVER_IDS) seedTimeline(serverId, `cached-${serverId}`);
await writer.flush();
for (const serverId of LRU_SERVER_IDS) useSessionStore.getState().clearSession(serverId);
const reader = new ReplicaCache(storage);
const reader = cache(storage);
reader.setHosts(LRU_SERVER_IDS);
await reader.restore();
@@ -604,62 +720,83 @@ describe("ReplicaCache", () => {
it("evicts the least recently written host when the cache exceeds its byte budget", async () => {
const storage = new MemoryStorage();
const cache = new ReplicaCache(storage, { maxBytes: 7_000 });
cache.setHosts(LRU_SERVER_IDS.slice(0, 2));
const replicaCache = cache(storage, { maxBytes: 7_000 });
replicaCache.setHosts(LRU_SERVER_IDS.slice(0, 2));
seedTimeline("host-a", "A".repeat(1_200));
seedTimeline("host-b", "B".repeat(1_200));
await cache.flush();
await replicaCache.flush();
seedTimeline("host-a", "A".repeat(1_201));
await cache.flush();
await replicaCache.flush();
cache.setHosts(LRU_SERVER_IDS);
replicaCache.setHosts(LRU_SERVER_IDS);
seedTimeline("host-c", "C".repeat(1_200));
await cache.flush();
await replicaCache.flush();
for (const serverId of LRU_SERVER_IDS) {
useSessionStore.getState().clearSession(serverId);
}
const reader = new ReplicaCache(storage, { maxBytes: 7_000 });
const reader = cache(storage, { maxBytes: 7_000 });
reader.setHosts(LRU_SERVER_IDS);
await reader.restore();
expect(Object.keys(useSessionStore.getState().sessions).sort()).toEqual(["host-a", "host-c"]);
});
it("rejects and clears version 5 cache data before overwriting it on flush", async () => {
it("clears the whole cache when one row payload is corrupt", async () => {
const storage = new MemoryStorage();
storage.values.set(
"@paseo:replica-cache",
JSON.stringify({
version: 5,
hosts: [
{
serverId: SERVER_ID,
agents: [],
workspaces: [],
emptyProjects: [],
timeline: {
agentId: "agent-1",
items: [],
cursor: { epoch: "poisoned", startSeq: 1, endSeq: 100 },
hasOlder: false,
},
},
],
}),
);
const cache = new ReplicaCache(storage);
cache.setHosts([SERVER_ID]);
storage.rows.set(`${SERVER_ID}:agent:agent-1`, {
serverId: SERVER_ID,
kind: "agent",
id: "agent-1",
payload: "{not json",
});
storage.rows.set(`${SERVER_ID}:project:project-1`, {
serverId: SERVER_ID,
kind: "project",
id: "project-1",
payload: JSON.stringify({ projectId: "project-1" }),
});
const replicaCache = cache(storage);
replicaCache.setHosts([SERVER_ID]);
await cache.restore();
expect(storage.values.has("@paseo:replica-cache")).toBe(false);
await cache.flush();
await replicaCache.restore();
expect(useSessionStore.getState().sessions[SERVER_ID]).toBeUndefined();
expect(JSON.parse(storage.values.get("@paseo:replica-cache") ?? "null")).toEqual({
version: 6,
hosts: [],
expect(storage.rows.size).toBe(0);
expect(storage.clears).toBe(1);
});
it("clears the whole cache when a valid JSON row has an unknown shape", async () => {
const storage = new MemoryStorage();
storage.rows.set(`${SERVER_ID}:project:project-1`, {
serverId: SERVER_ID,
kind: "project",
id: "project-1",
payload: JSON.stringify({ projectId: "project-1", surprise: true }),
});
const replicaCache = cache(storage);
replicaCache.setHosts([SERVER_ID]);
await replicaCache.restore();
expect(storage.rows.size).toBe(0);
expect(storage.clears).toBe(1);
});
it("runs legacy blob cleanup when the row store is first prepared", async () => {
const storage = new MemoryStorage();
let cleanups = 0;
const replicaCache = new ReplicaCache(storage, {
clearLegacyCache: async () => {
cleanups += 1;
},
});
replicaCache.setHosts([SERVER_ID]);
await replicaCache.restore();
await replicaCache.flush();
expect(cleanups).toBe(1);
});
});
+415 -133
View File
@@ -14,7 +14,6 @@ import {
useSessionStore,
type Agent,
type SessionReplica,
type SessionReplicaTimeline,
type SessionState,
type ProjectDescriptor,
type WorkspaceDescriptor,
@@ -25,9 +24,23 @@ import {
type StreamItem,
} from "@/types/stream";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import {
diffReplicaInput,
hasReplicaInputChanges,
type ReplicaInput,
type ReplicaInputChanges,
} from "./diff";
import { clearLegacyReplicaCache } from "./legacy-cleanup";
import {
REPLICA_SINGLETON_ROW_ID,
type ReplicaHostRows,
type ReplicaRow,
type ReplicaRowChanges,
type ReplicaRowKey,
type ReplicaRowKind,
type ReplicaRowStore,
} from "./row-store";
const STORAGE_KEY = "@paseo:replica-cache";
const CACHE_VERSION = 6;
const PERSIST_AFTER_USER_INACTIVITY_MS = 5_000;
const MAX_TIMELINE_ITEMS = 50;
const MAX_CACHE_BYTES = 32 * 1024 * 1024;
@@ -289,36 +302,17 @@ const StoredHostSchema = z.strictObject({
directorySync: z.unknown().optional(),
});
const StoredCacheSchema = z.strictObject({
version: z.literal(CACHE_VERSION),
hosts: z.array(StoredHostSchema),
});
type StoredAgent = z.infer<typeof StoredAgentSchema>;
type StoredHost = z.infer<typeof StoredHostSchema>;
type StoredTimeline = z.infer<typeof StoredTimelineSchema>;
type StoredTimelineItem = z.infer<typeof StoredTimelineItemSchema>;
type StoredToolCall = Extract<StoredTimelineItem, { kind: "tool_call" }>["item"];
type StoredWorkspace = z.infer<typeof StoredWorkspaceSchema>;
type StoredProject = z.infer<typeof StoredProjectSchema>;
interface ReplicaInput {
agents: ReadonlyMap<string, Agent>;
workspaces: ReadonlyMap<string, WorkspaceDescriptor>;
projects: ReadonlyMap<string, ProjectDescriptor>;
focusedAgent: Agent | undefined;
timelineItems: StreamItem[] | undefined;
timelineRange: SessionReplicaTimeline["range"];
timelineHasOlder: boolean;
}
export interface ReplicaCacheStorage {
getItem: (key: string) => Promise<string | null>;
setItem: (key: string, value: string) => Promise<void>;
removeItem: (key: string) => Promise<void>;
}
interface ReplicaCacheOptions {
maxBytes?: number;
clearLegacyCache?: () => Promise<void>;
}
function deserializeTimeline(stored: StoredHost["timeline"]): SessionReplica["timeline"] {
@@ -628,18 +622,6 @@ function isTimelineItemStoredLosslessly(item: StreamItem): boolean {
}
}
function replicaInputsEqual(left: ReplicaInput, right: ReplicaInput): boolean {
return (
left.agents === right.agents &&
left.workspaces === right.workspaces &&
left.projects === right.projects &&
left.focusedAgent === right.focusedAgent &&
left.timelineItems === right.timelineItems &&
left.timelineRange === right.timelineRange &&
left.timelineHasOlder === right.timelineHasOlder
);
}
function selectReplicaInput(session: SessionState, agentId: string | null): ReplicaInput {
const agent = agentId ? session.agents.get(agentId) : undefined;
const timeline = agentId
@@ -649,14 +631,14 @@ function selectReplicaInput(session: SessionState, agentId: string | null): Repl
agents: session.agents,
workspaces: session.workspaces,
projects: session.projects,
focusedAgent: agent,
focusedAgentId: agent?.id,
timelineItems: timeline.status === "cold" ? undefined : timeline.items,
timelineRange: timeline.status === "synced" ? timeline.range : null,
timelineHasOlder: timeline.status === "synced" && timeline.older === "available",
};
}
function serializeHost(serverId: string, input: ReplicaInput, directorySync?: unknown): StoredHost {
function serializeTimeline(input: ReplicaInput): StoredTimeline | null {
const canonicalItems = input.timelineItems?.filter(
(item) => item.kind !== "user_message" || !isUnreconciledLocalUserMessage(item),
);
@@ -678,24 +660,14 @@ function serializeHost(serverId: string, input: ReplicaInput, directorySync?: un
item.timelineCursor.seq <= range.endSeq,
) &&
canonicalItems.some((item) => item.timelineCursor?.seq === range.endSeq);
if (!input.focusedAgentId || !items) return null;
return {
serverId,
agents: Array.from(input.agents.values(), serializeAgent),
workspaces: Array.from(input.workspaces.values(), serializeWorkspace),
projects: Array.from(input.projects.values(), serializeProject),
emptyProjects: [],
timeline:
input.focusedAgent && items
? {
agentId: input.focusedAgent.id,
items: items.slice(-MAX_TIMELINE_ITEMS),
range: canPersistCoverage
? { epoch: range.epoch, startSeq: range.startSeq, endSeq: range.endSeq }
: null,
hasOlder: canPersistCoverage ? input.timelineHasOlder : false,
}
: null,
...(directorySync ? { directorySync } : {}),
agentId: input.focusedAgentId,
items: items.slice(-MAX_TIMELINE_ITEMS),
range: canPersistCoverage
? { epoch: range.epoch, startSeq: range.startSeq, endSeq: range.endSeq }
: null,
hasOlder: canPersistCoverage ? input.timelineHasOlder : false,
};
}
@@ -729,38 +701,66 @@ function legacyProjectDescriptorFromWorkspace(workspace: WorkspaceDescriptor): P
};
}
function rowKey(key: Pick<ReplicaRowKey, "kind" | "id">): string {
return `${key.kind}\u0000${key.id}`;
}
function pendingRowKey(key: ReplicaRowKey): string {
return `${key.serverId}\u0000${rowKey(key)}`;
}
function payloadBytes(payload: string): number {
return Buffer.byteLength(payload, "utf8");
}
function parseJsonPayload(payload: string): unknown {
return JSON.parse(payload);
}
function parseStoredPayload<Value>(schema: z.ZodType<Value>, payload: string): Value {
const parsed = schema.safeParse(parseJsonPayload(payload));
if (!parsed.success) throw new Error("Invalid replica row payload");
return parsed.data;
}
export class ReplicaCache {
private readonly activeServerIds = new Set<string>();
private readonly storedHosts = new Map<string, StoredHost>();
private readonly storedRows = new Map<string, Map<string, ReplicaRow>>();
private readonly hostBytes = new Map<string, number>();
private readonly hostWriteOrder = new Map<string, true>();
private readonly evictedServerIds = new Set<string>();
private readonly lastFocusedAgentIds = new Map<string, string>();
private readonly capturedInputs = new Map<string, ReplicaInput>();
private readonly directoryCheckpoints = new Map<string, unknown>();
private readonly checkpointInputs = new Map<string, unknown>();
private pendingUpserts = new Map<string, ReplicaRow>();
private pendingDeletes = new Map<string, ReplicaRowKey>();
private readonly maxBytes: number;
private needsPersist = false;
private totalBytes = 0;
private unsubscribe: (() => void) | null = null;
private persistTimer: ReturnType<typeof setTimeout> | null = null;
private writeQueue: Promise<void> = Promise.resolve();
private preparePromise: Promise<void> | null = null;
constructor(
private readonly storage: ReplicaCacheStorage,
private readonly rowStore: ReplicaRowStore,
options: ReplicaCacheOptions = {},
) {
const emptyPayloadBytes = Buffer.byteLength(
JSON.stringify({ version: CACHE_VERSION, hosts: [] }),
"utf8",
);
this.maxBytes = Math.max(options.maxBytes ?? MAX_CACHE_BYTES, emptyPayloadBytes);
this.maxBytes = Math.max(options.maxBytes ?? MAX_CACHE_BYTES, 0);
this.clearLegacyCache = options.clearLegacyCache ?? clearLegacyReplicaCache;
}
private readonly clearLegacyCache: () => Promise<void>;
setHosts(serverIds: Iterable<string>): void {
const next = new Set(serverIds);
const removed = [...this.activeServerIds].filter((serverId) => !next.has(serverId));
this.activeServerIds.clear();
for (const serverId of next) this.activeServerIds.add(serverId);
let removedStoredHost = false;
for (const serverId of this.storedHosts.keys()) {
if (!next.has(serverId)) {
this.storedHosts.delete(serverId);
removedStoredHost = true;
}
for (const serverId of removed) {
this.removeStoredHost(serverId);
this.dropPendingHostChanges(serverId);
this.queueOperation(() => this.rowStore.deleteHost(serverId));
}
for (const serverId of this.lastFocusedAgentIds.keys()) {
if (!next.has(serverId)) this.lastFocusedAgentIds.delete(serverId);
@@ -768,41 +768,52 @@ export class ReplicaCache {
for (const serverId of this.capturedInputs.keys()) {
if (!next.has(serverId)) this.capturedInputs.delete(serverId);
}
if (removedStoredHost) this.needsPersist = true;
if (this.unsubscribe && this.needsPersist) this.schedulePersist();
for (const serverId of this.directoryCheckpoints.keys()) {
if (!next.has(serverId)) this.directoryCheckpoints.delete(serverId);
}
for (const serverId of this.checkpointInputs.keys()) {
if (!next.has(serverId)) this.checkpointInputs.delete(serverId);
}
for (const serverId of this.evictedServerIds) {
if (!next.has(serverId)) this.evictedServerIds.delete(serverId);
}
}
async restore(): Promise<void> {
let raw: string | null;
try {
raw = await this.storage.getItem(STORAGE_KEY);
await this.prepareStore();
} catch {
return;
}
if (!raw) return;
let parsed: unknown;
let hosts: ReplicaHostRows[];
try {
parsed = JSON.parse(raw);
hosts = await this.rowStore.readAll();
} catch {
await this.clearInvalidCache();
return;
}
const cache = StoredCacheSchema.safeParse(parsed);
if (!cache.success) {
await this.clearInvalidCache();
return;
}
for (const host of cache.data.hosts) {
if (!this.activeServerIds.has(host.serverId)) {
this.needsPersist = true;
continue;
this.storedRows.clear();
this.hostBytes.clear();
this.hostWriteOrder.clear();
this.directoryCheckpoints.clear();
this.checkpointInputs.clear();
this.totalBytes = 0;
const restoredHosts: StoredHost[] = [];
try {
for (const hostRows of hosts) {
if (!this.activeServerIds.has(hostRows.serverId)) {
await this.rowStore.deleteHost(hostRows.serverId);
continue;
}
restoredHosts.push(this.restoreHostRows(hostRows));
}
this.storedHosts.set(host.serverId, host);
if (host.timeline) this.lastFocusedAgentIds.set(host.serverId, host.timeline.agentId);
} catch {
await this.clearInvalidCache();
return;
}
const bounded = this.buildBoundedPayload();
if (bounded.evicted) this.needsPersist = true;
for (const host of this.storedHosts.values()) {
await this.evictOverBudget();
for (const host of restoredHosts) {
if (!this.storedRows.has(host.serverId)) continue;
if (host.timeline) this.lastFocusedAgentIds.set(host.serverId, host.timeline.agentId);
useSessionStore.getState().restoreSessionReplica(host.serverId, deserializeHost(host));
const session = useSessionStore.getState().sessions[host.serverId];
if (session) {
@@ -828,14 +839,24 @@ export class ReplicaCache {
if (this.activeServerIds.size === 0) return;
this.schedulePersist();
});
if (changedBeforeSubscription || this.needsPersist) this.schedulePersist();
if (changedBeforeSubscription || this.hasPendingChanges()) this.schedulePersist();
}
reconcileServerId(oldServerId: string, newServerId: string): void {
const stored = this.storedHosts.get(oldServerId);
if (stored) {
this.storedHosts.delete(oldServerId);
this.storedHosts.set(newServerId, { ...stored, serverId: newServerId });
const rows = this.storedRows.get(oldServerId);
if (rows) {
const newRows = this.storedRows.get(newServerId) ?? new Map<string, ReplicaRow>();
this.totalBytes -=
(this.hostBytes.get(oldServerId) ?? 0) + (this.hostBytes.get(newServerId) ?? 0);
this.storedRows.delete(oldServerId);
for (const [key, row] of rows) newRows.set(key, { ...row, serverId: newServerId });
this.storedRows.set(newServerId, newRows);
const bytes = [...newRows.values()].reduce((sum, row) => sum + payloadBytes(row.payload), 0);
this.hostBytes.delete(oldServerId);
this.hostBytes.set(newServerId, bytes);
this.totalBytes += bytes;
this.hostWriteOrder.delete(oldServerId);
this.touchHost(newServerId);
}
const focusedAgentId = this.lastFocusedAgentIds.get(oldServerId);
if (focusedAgentId) {
@@ -847,32 +868,56 @@ export class ReplicaCache {
this.capturedInputs.delete(oldServerId);
this.capturedInputs.set(newServerId, capturedInput);
}
if (this.directoryCheckpoints.has(oldServerId)) {
const checkpoint = this.directoryCheckpoints.get(oldServerId);
this.directoryCheckpoints.delete(oldServerId);
this.directoryCheckpoints.set(newServerId, checkpoint);
}
if (this.checkpointInputs.has(oldServerId)) {
const checkpoint = this.checkpointInputs.get(oldServerId);
this.checkpointInputs.delete(oldServerId);
this.checkpointInputs.set(newServerId, checkpoint);
}
if (this.evictedServerIds.delete(oldServerId)) this.evictedServerIds.add(newServerId);
this.renamePendingHostChanges(oldServerId, newServerId);
if (this.activeServerIds.delete(oldServerId)) this.activeServerIds.add(newServerId);
this.needsPersist = true;
this.schedulePersist();
this.queueOperation(() => this.rowStore.renameHost(oldServerId, newServerId));
}
readDirectoryCheckpoint(serverId: string): unknown {
return this.storedHosts.get(serverId)?.directorySync;
return this.directoryCheckpoints.get(serverId);
}
writeDirectoryCheckpoint(serverId: string, checkpoint: unknown): void {
let stored = this.storedHosts.get(serverId);
if (!stored) {
if (this.checkpointInputs.get(serverId) === checkpoint) return;
if (this.evictedServerIds.has(serverId)) {
const session = useSessionStore.getState().sessions[serverId];
if (!session) return;
const input = selectReplicaInput(session, this.lastFocusedAgentIds.get(serverId) ?? null);
this.capturedInputs.set(serverId, input);
stored = serializeHost(serverId, input);
this.captureHost(serverId, session);
}
this.checkpointInputs.set(serverId, checkpoint);
this.directoryCheckpoints.set(serverId, checkpoint);
const payload = JSON.stringify(checkpoint);
if (payload === undefined) {
this.queueDelete({
serverId,
kind: "checkpoint",
id: REPLICA_SINGLETON_ROW_ID,
});
} else {
this.queueUpsert({
serverId,
kind: "checkpoint",
id: REPLICA_SINGLETON_ROW_ID,
payload,
});
}
this.storedHosts.delete(serverId);
this.storedHosts.set(serverId, { ...stored, directorySync: checkpoint });
this.needsPersist = true;
this.schedulePersist();
}
async flush(): Promise<void> {
await this.persist(false);
await this.writeQueue.catch(() => undefined);
}
private async flushPending(): Promise<void> {
@@ -889,14 +934,24 @@ export class ReplicaCache {
this.persistTimer = null;
}
const changed = this.captureSessions();
if (skipUnchanged && !changed && !this.needsPersist) return;
const bounded = this.buildBoundedPayload();
this.needsPersist = false;
if (skipUnchanged && !changed && !this.hasPendingChanges()) return;
const changes = this.drainPendingChanges();
if (changes.upserts.length === 0 && changes.deletes.length === 0) return;
const write = this.writeQueue
.catch(() => undefined)
.then(() => this.storage.setItem(STORAGE_KEY, bounded.payload));
.then(async () => {
try {
await this.prepareStore();
await this.rowStore.apply(changes);
this.applyStoredChanges(changes);
await this.evictOverBudget();
} catch {
this.restorePendingChanges(changes);
}
return undefined;
});
this.writeQueue = write;
await write.catch(() => undefined);
await write;
}
private captureSessions(): boolean {
@@ -929,41 +984,268 @@ export class ReplicaCache {
} else if (hasTimelineHead) {
input = { ...selected, timelineRange: null, timelineHasOlder: false };
}
if (previous && replicaInputsEqual(previous, input)) return false;
const initialInput = this.evictedServerIds.has(serverId) ? undefined : previous;
const changes = diffReplicaInput(initialInput, input);
this.capturedInputs.set(serverId, input);
const directorySync = this.storedHosts.get(serverId)?.directorySync;
this.storedHosts.delete(serverId);
this.storedHosts.set(serverId, serializeHost(serverId, input, directorySync));
if (!hasReplicaInputChanges(changes)) return false;
this.queueReplicaChanges(serverId, input, changes);
return true;
}
private buildBoundedPayload(): { payload: string; evicted: boolean } {
let evicted = false;
let payload = this.serialize();
while (Buffer.byteLength(payload, "utf8") > this.maxBytes && this.storedHosts.size > 0) {
const oldestServerId = this.storedHosts.keys().next().value;
if (oldestServerId === undefined) break;
this.storedHosts.delete(oldestServerId);
evicted = true;
payload = this.serialize();
private queueReplicaChanges(
serverId: string,
input: ReplicaInput,
changes: ReplicaInputChanges,
): void {
for (const agent of changes.agents.upserts) {
this.queueEntityUpsert(serverId, "agent", agent.id, serializeAgent(agent));
}
for (const id of changes.agents.deletes) this.queueEntityDelete(serverId, "agent", id);
for (const workspace of changes.workspaces.upserts) {
this.queueEntityUpsert(serverId, "workspace", workspace.id, serializeWorkspace(workspace));
}
for (const id of changes.workspaces.deletes) this.queueEntityDelete(serverId, "workspace", id);
for (const project of changes.projects.upserts) {
this.queueEntityUpsert(serverId, "project", project.projectId, serializeProject(project));
}
for (const id of changes.projects.deletes) this.queueEntityDelete(serverId, "project", id);
if (!changes.timelineChanged) return;
const timeline = serializeTimeline(input);
if (timeline) {
this.queueEntityUpsert(serverId, "timeline", REPLICA_SINGLETON_ROW_ID, timeline);
} else {
this.queueEntityDelete(serverId, "timeline", REPLICA_SINGLETON_ROW_ID);
}
return { payload, evicted };
}
private serialize(): string {
return JSON.stringify({
version: CACHE_VERSION,
hosts: Array.from(this.storedHosts.values()),
});
private queueEntityUpsert(
serverId: string,
kind: ReplicaRowKind,
id: string,
value: unknown,
): void {
this.queueUpsert({ serverId, kind, id, payload: JSON.stringify(value) });
}
private async clearInvalidCache(): Promise<void> {
try {
await this.storage.removeItem(STORAGE_KEY);
await this.rowStore.clear();
} catch {
// A storage failure must not turn invalid cached data into application state.
}
this.storedRows.clear();
this.hostBytes.clear();
this.hostWriteOrder.clear();
this.evictedServerIds.clear();
this.directoryCheckpoints.clear();
this.checkpointInputs.clear();
this.capturedInputs.clear();
this.lastFocusedAgentIds.clear();
this.pendingUpserts.clear();
this.pendingDeletes.clear();
this.totalBytes = 0;
}
private restoreHostRows(hostRows: ReplicaHostRows): StoredHost {
const agents: StoredAgent[] = [];
const workspaces: StoredWorkspace[] = [];
const projects: StoredProject[] = [];
let timeline: StoredTimeline | null = null;
let checkpoint: unknown;
const rows = new Map<string, ReplicaRow>();
let bytes = 0;
for (const row of hostRows.rows) {
if (row.serverId !== hostRows.serverId) throw new Error("Replica row host key mismatch");
switch (row.kind) {
case "agent": {
const agent = parseStoredPayload(StoredAgentSchema, row.payload);
if (agent.snapshot.id !== row.id) throw new Error("Replica agent row id mismatch");
agents.push(agent);
break;
}
case "workspace": {
const workspace = parseStoredPayload(StoredWorkspaceSchema, row.payload);
if (workspace.id !== row.id) throw new Error("Replica workspace row id mismatch");
workspaces.push(workspace);
break;
}
case "project": {
const project = parseStoredPayload(StoredProjectSchema, row.payload);
if (project.projectId !== row.id) throw new Error("Replica project row id mismatch");
projects.push(project);
break;
}
case "timeline":
if (row.id !== REPLICA_SINGLETON_ROW_ID) {
throw new Error("Replica timeline row id mismatch");
}
timeline = parseStoredPayload(StoredTimelineSchema, row.payload);
break;
case "checkpoint":
if (row.id !== REPLICA_SINGLETON_ROW_ID) {
throw new Error("Replica checkpoint row id mismatch");
}
checkpoint = parseJsonPayload(row.payload);
break;
default:
throw new Error("Unknown replica row kind");
}
rows.set(rowKey(row), row);
bytes += payloadBytes(row.payload);
}
this.storedRows.set(hostRows.serverId, rows);
this.hostBytes.set(hostRows.serverId, bytes);
this.totalBytes += bytes;
this.touchHost(hostRows.serverId);
if (checkpoint !== undefined) {
this.directoryCheckpoints.set(hostRows.serverId, checkpoint);
this.checkpointInputs.set(hostRows.serverId, checkpoint);
}
return {
serverId: hostRows.serverId,
agents,
workspaces,
projects,
emptyProjects: [],
timeline,
...(checkpoint !== undefined ? { directorySync: checkpoint } : {}),
};
}
private queueEntityDelete(serverId: string, kind: ReplicaRowKind, id: string): void {
this.queueDelete({ serverId, kind, id });
}
private queueUpsert(row: ReplicaRow): void {
const key = pendingRowKey(row);
this.pendingDeletes.delete(key);
this.pendingUpserts.set(key, row);
}
private queueDelete(key: ReplicaRowKey): void {
const pendingKey = pendingRowKey(key);
this.pendingUpserts.delete(pendingKey);
this.pendingDeletes.set(pendingKey, key);
}
private hasPendingChanges(): boolean {
return this.pendingUpserts.size > 0 || this.pendingDeletes.size > 0;
}
private drainPendingChanges(): ReplicaRowChanges {
const changes = {
upserts: [...this.pendingUpserts.values()],
deletes: [...this.pendingDeletes.values()],
};
this.pendingUpserts = new Map();
this.pendingDeletes = new Map();
return changes;
}
private restorePendingChanges(changes: ReplicaRowChanges): void {
const newer = this.drainPendingChanges();
for (const key of changes.deletes) this.queueDelete(key);
for (const row of changes.upserts) this.queueUpsert(row);
for (const key of newer.deletes) this.queueDelete(key);
for (const row of newer.upserts) this.queueUpsert(row);
}
private applyStoredChanges(changes: ReplicaRowChanges): void {
const touchedServerIds = new Set<string>();
for (const key of changes.deletes) {
const rows = this.storedRows.get(key.serverId);
const previous = rows?.get(rowKey(key));
if (previous) {
rows?.delete(rowKey(key));
this.adjustHostBytes(key.serverId, -payloadBytes(previous.payload));
}
touchedServerIds.add(key.serverId);
}
for (const row of changes.upserts) {
const rows = this.storedRows.get(row.serverId) ?? new Map<string, ReplicaRow>();
const previous = rows.get(rowKey(row));
const previousBytes = previous ? payloadBytes(previous.payload) : 0;
rows.set(rowKey(row), row);
this.storedRows.set(row.serverId, rows);
this.adjustHostBytes(row.serverId, payloadBytes(row.payload) - previousBytes);
this.evictedServerIds.delete(row.serverId);
touchedServerIds.add(row.serverId);
}
for (const serverId of touchedServerIds) {
if ((this.storedRows.get(serverId)?.size ?? 0) === 0) {
this.removeStoredHost(serverId);
} else {
this.touchHost(serverId);
}
}
}
private adjustHostBytes(serverId: string, delta: number): void {
this.hostBytes.set(serverId, (this.hostBytes.get(serverId) ?? 0) + delta);
this.totalBytes += delta;
}
private touchHost(serverId: string): void {
this.hostWriteOrder.delete(serverId);
this.hostWriteOrder.set(serverId, true);
}
private async evictOverBudget(): Promise<void> {
while (this.totalBytes > this.maxBytes) {
const oldestServerId = this.hostWriteOrder.keys().next().value;
if (oldestServerId === undefined) return;
await this.rowStore.deleteHost(oldestServerId);
this.removeStoredHost(oldestServerId);
this.evictedServerIds.add(oldestServerId);
this.directoryCheckpoints.delete(oldestServerId);
this.checkpointInputs.delete(oldestServerId);
}
}
private removeStoredHost(serverId: string): void {
this.totalBytes -= this.hostBytes.get(serverId) ?? 0;
this.storedRows.delete(serverId);
this.hostBytes.delete(serverId);
this.hostWriteOrder.delete(serverId);
}
private dropPendingHostChanges(serverId: string): void {
for (const [key, row] of this.pendingUpserts) {
if (row.serverId === serverId) this.pendingUpserts.delete(key);
}
for (const [key, row] of this.pendingDeletes) {
if (row.serverId === serverId) this.pendingDeletes.delete(key);
}
}
private renamePendingHostChanges(oldServerId: string, newServerId: string): void {
const changes = this.drainPendingChanges();
for (const key of changes.deletes) {
this.queueDelete(key.serverId === oldServerId ? { ...key, serverId: newServerId } : key);
}
for (const row of changes.upserts) {
this.queueUpsert(row.serverId === oldServerId ? { ...row, serverId: newServerId } : row);
}
}
private prepareStore(): Promise<void> {
this.preparePromise ??= (async () => {
await this.rowStore.open();
// COMPAT(replica-blob-cache): remove after 2026-11
await this.clearLegacyCache().catch(() => undefined);
})();
return this.preparePromise;
}
private queueOperation(operation: () => Promise<void>): void {
this.writeQueue = this.writeQueue
.catch(() => undefined)
.then(async () => {
await this.prepareStore();
await operation();
return undefined;
})
.catch(() => undefined);
}
private schedulePersist(): void {
@@ -0,0 +1,7 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
const LEGACY_STORAGE_KEY = "@paseo:replica-cache";
export async function clearLegacyReplicaCache(): Promise<void> {
await AsyncStorage.removeItem(LEGACY_STORAGE_KEY);
}
@@ -0,0 +1,35 @@
import { indexedDB as fakeIndexedDb } from "fake-indexeddb";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { clearLegacyReplicaCache } from "./legacy-cleanup.web";
const LEGACY_DATABASE_NAME = "paseo-replica-cache";
beforeAll(() => vi.stubGlobal("indexedDB", fakeIndexedDb));
afterAll(() => vi.unstubAllGlobals());
function openLegacyDatabase(): Promise<{ database: IDBDatabase; oldVersion: number }> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(LEGACY_DATABASE_NAME, 1);
let oldVersion = 1;
request.addEventListener("upgradeneeded", (event) => {
oldVersion = event.oldVersion;
request.result.createObjectStore("key-value");
});
request.addEventListener("success", () => resolve({ database: request.result, oldVersion }));
request.addEventListener("error", () => reject(request.error));
});
}
describe("clearLegacyReplicaCache", () => {
it("deletes the old blob database", async () => {
const seeded = await openLegacyDatabase();
seeded.database.close();
await clearLegacyReplicaCache();
const reopened = await openLegacyDatabase();
expect(reopened.oldVersion).toBe(0);
reopened.database.close();
await clearLegacyReplicaCache();
});
});
@@ -0,0 +1,12 @@
const LEGACY_DATABASE_NAME = "paseo-replica-cache";
export function clearLegacyReplicaCache(): Promise<void> {
return new Promise((resolve, reject) => {
const request = indexedDB.deleteDatabase(LEGACY_DATABASE_NAME);
request.addEventListener("success", () => resolve());
request.addEventListener("error", () =>
reject(request.error ?? new Error("Failed to delete legacy replica cache")),
);
request.addEventListener("blocked", () => resolve());
});
}
@@ -0,0 +1 @@
export { createReplicaRowStore } from "./row-store.native";
@@ -0,0 +1 @@
export { createReplicaRowStore } from "./row-store.web";
@@ -0,0 +1 @@
export { createReplicaRowStore } from "./row-store.web";
@@ -1,4 +0,0 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import type { ReplicaCacheStorage } from "./index";
export const replicaCacheStorage: ReplicaCacheStorage = AsyncStorage;
@@ -1,70 +0,0 @@
import type { ReplicaCacheStorage } from "./index";
const DATABASE_NAME = "paseo-replica-cache";
const STORE_NAME = "key-value";
let databasePromise: Promise<IDBDatabase> | null = null;
function openDatabase(): Promise<IDBDatabase> {
databasePromise ??= new Promise((resolve, reject) => {
const request = indexedDB.open(DATABASE_NAME, 1);
request.addEventListener("upgradeneeded", () => {
if (!request.result.objectStoreNames.contains(STORE_NAME)) {
request.result.createObjectStore(STORE_NAME);
}
});
request.addEventListener("success", () => resolve(request.result));
request.addEventListener("error", () =>
reject(request.error ?? new Error("Failed to open replica cache")),
);
request.addEventListener("blocked", () =>
reject(new Error("Replica cache database upgrade was blocked")),
);
});
return databasePromise;
}
async function getItem(key: string): Promise<string | null> {
const database = await openDatabase();
return new Promise((resolve, reject) => {
const request = database.transaction(STORE_NAME, "readonly").objectStore(STORE_NAME).get(key);
request.addEventListener("success", () =>
resolve(typeof request.result === "string" ? request.result : null),
);
request.addEventListener("error", () =>
reject(request.error ?? new Error("Failed to read replica cache")),
);
});
}
async function setItem(key: string, value: string): Promise<void> {
const database = await openDatabase();
return new Promise((resolve, reject) => {
const transaction = database.transaction(STORE_NAME, "readwrite");
transaction.objectStore(STORE_NAME).put(value, key);
transaction.addEventListener("complete", () => resolve());
transaction.addEventListener("error", () =>
reject(transaction.error ?? new Error("Failed to write replica cache")),
);
transaction.addEventListener("abort", () =>
reject(transaction.error ?? new Error("Replica cache write was aborted")),
);
});
}
async function removeItem(key: string): Promise<void> {
const database = await openDatabase();
return new Promise((resolve, reject) => {
const transaction = database.transaction(STORE_NAME, "readwrite");
transaction.objectStore(STORE_NAME).delete(key);
transaction.addEventListener("complete", () => resolve());
transaction.addEventListener("error", () =>
reject(transaction.error ?? new Error("Failed to remove replica cache")),
);
transaction.addEventListener("abort", () =>
reject(transaction.error ?? new Error("Replica cache removal was aborted")),
);
});
}
export const replicaCacheStorage: ReplicaCacheStorage = { getItem, setItem, removeItem };