Files
kandev/apps/web/lib/logger/indexeddb-store.test.ts
T
2026-09-09 11:46:14 +01:00

544 lines
19 KiB
TypeScript

import "fake-indexeddb/auto";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { prepareLogEntry, type LogEntry, type PreparedLogEntry } from "./buffer";
import { IndexedDBLogStore, retentionPlan } from "./indexeddb-store";
const DATABASE_NAME = "kandev-diagnostic-logs-v1";
const ENTRIES_STORE = "entries";
const METADATA_STORE = "metadata";
const RETENTION_KEY = "retention";
const MAX_ENTRIES = 10_000;
const MAX_BYTES = 20 * 1024 * 1024;
const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000;
const IDENTITY_A = "identity-a";
const IDENTITY_B = "identity-b";
const BEFORE_RECEIPT_MESSAGE = "before-receipt";
const RECEIPT_PREFIX_MESSAGE = "receipt-prefix";
type PersistedTestEntry = {
id?: number;
identity_scope: string;
timestamp_ms: number;
bytes: number;
entry: LogEntry;
};
beforeEach(async () => {
await deleteDatabase();
});
describe("IndexedDB log retention planning and schema", () => {
it("prunes age first and then oldest records until count and byte caps hold", () => {
const now = Date.UTC(2026, 6, 30);
const records = [
{
id: 1,
identity_scope: "a",
timestamp_ms: now - 4 * 86_400_000,
bytes: 1,
entry: {} as never,
},
...Array.from({ length: 10_001 }, (_, index) => ({
id: index + 2,
identity_scope: "a",
timestamp_ms: now - 1_000 + index,
bytes: 10,
entry: {} as never,
})),
];
const result = retentionPlan(records, now);
expect(result.removeIDs).toEqual([1, 2]);
expect(result.retainedCount).toBe(10_000);
});
it("initializes version two totals without changing the database name", async () => {
const store = new IndexedDBLogStore();
await expect(store.snapshot("a")).resolves.toEqual([]);
const database = await openDatabase();
expect(database.name).toBe(DATABASE_NAME);
expect(database.version).toBe(2);
expect([...database.objectStoreNames]).toEqual([ENTRIES_STORE, METADATA_STORE]);
database.close();
await expect(readMetadata()).resolves.toEqual({
key: RETENTION_KEY,
count: 0,
bytes: 0,
});
});
it("rebuilds totals once while upgrading an existing version one database", async () => {
const now = Date.now();
const first = logEntry("a", now - 2_000, "first");
const second = logEntry("b", now - 1_000, "second");
await createVersionOneDatabase([
persisted(first, now - 2_000, 11),
persisted(second, now - 1_000, 22),
]);
const store = new IndexedDBLogStore();
await expect(store.snapshot("a")).resolves.toEqual([first]);
await expect(readMetadata()).resolves.toEqual({
key: RETENTION_KEY,
count: 2,
bytes: 33,
});
});
it("rejects a blocked schema upgrade and retries after the older tab closes", async () => {
const blocker = await openVersionOneDatabase();
const store = new IndexedDBLogStore();
try {
await expect(
store.append(prepareEntries([logEntry("a", Date.now(), "blocked")])),
).rejects.toThrow("IndexedDB upgrade blocked");
} finally {
blocker.close();
}
await new Promise<void>((resolve) => setTimeout(resolve, 0));
await expect(
store.append(prepareEntries([logEntry("a", Date.now(), "retried")])),
).resolves.toEqual(expect.any(Array));
await expect(store.snapshot("a")).resolves.toEqual([
expect.objectContaining({ message: "retried" }),
]);
});
});
describe("IndexedDB log retention writes", () => {
it("persists the prepared entry byte count without re-encoding it", async () => {
const store = new IndexedDBLogStore();
const prepared = prepareLogEntry(logEntry("a", Date.now(), "prepared"));
await store.append([prepared]);
const records = await readEntries();
expect(records[0]?.bytes).toBe(prepared.bytes);
});
it("retains the newest entries and records exact transactional totals", async () => {
const now = Date.now();
const store = new IndexedDBLogStore();
const entries = [
logEntry("a", now - THREE_DAYS_MS - 1, "expired"),
...Array.from({ length: MAX_ENTRIES }, (_, index) =>
logEntry("a", now - 1_000 + index, `entry-${index}`),
),
];
await store.append(prepareEntries(entries));
const records = await readEntries();
expect(records).toHaveLength(MAX_ENTRIES);
expect(records[0].entry.message).toBe("entry-0");
expect(records.at(-1)?.entry.message).toBe(`entry-${MAX_ENTRIES - 1}`);
await expect(readMetadata()).resolves.toEqual({
key: RETENTION_KEY,
count: MAX_ENTRIES,
bytes: records.reduce((total, record) => total + record.bytes, 0),
});
});
it("evicts the oldest rows only until the byte cap holds", async () => {
const now = Date.now();
const store = new IndexedDBLogStore();
const entries = Array.from({ length: 4_300 }, (_, index) =>
logEntry("a", now - 4_300 + index, `${index}-${"x".repeat(5_000)}`),
);
await store.append(prepareEntries(entries));
const records = await readEntries();
const metadata = await readMetadata();
expect(metadata.count).toBe(records.length);
expect(metadata.bytes).toBe(records.reduce((total, record) => total + record.bytes, 0));
expect(metadata.count).toBeLessThan(MAX_ENTRIES);
expect(metadata.bytes).toBeLessThanOrEqual(MAX_BYTES);
expect(records.at(-1)?.entry.message).toContain("4299-");
expect(records[0].entry.message).not.toContain("0-");
});
it("keeps identity snapshots partitioned while sharing the global caps", async () => {
const store = new IndexedDBLogStore();
await store.append(
prepareEntries([
logEntry(IDENTITY_A, Date.now(), "a"),
logEntry(IDENTITY_B, Date.now() + 1, "b"),
]),
);
await expect(store.snapshot(IDENTITY_A)).resolves.toHaveLength(1);
await expect(store.snapshot(IDENTITY_B)).resolves.toHaveLength(1);
});
});
describe("IndexedDB log paging", () => {
it("reads identity snapshots through the chronological timestamp index", async () => {
const now = Date.now();
const store = new IndexedDBLogStore();
const first = logEntry(IDENTITY_A, now, "first");
const other = logEntry(IDENTITY_B, now, "other");
const second = logEntry(IDENTITY_A, now, "second");
await store.append(prepareEntries([first, other, second]));
const getAll = vi.spyOn(IDBIndex.prototype, "getAll");
const openCursor = vi.spyOn(IDBIndex.prototype, "openCursor");
try {
await expect(store.snapshot(IDENTITY_A)).resolves.toEqual([first, second]);
expect(getAll).not.toHaveBeenCalled();
expect(openCursor).toHaveBeenCalled();
} finally {
getAll.mockRestore();
openCursor.mockRestore();
}
});
it("continues pages by timestamp and primary key without gaps or duplicates", async () => {
const timestamp = Date.now();
const store = new IndexedDBLogStore();
const entries = [
logEntry(IDENTITY_A, timestamp, "first"),
logEntry(IDENTITY_B, timestamp, "other"),
logEntry(IDENTITY_A, timestamp, "second"),
logEntry(IDENTITY_A, timestamp, "third"),
];
await store.append(prepareEntries(entries));
const messages: string[] = [];
let cursor: import("./indexeddb-store").LogPageCursor | null = null;
let pageCount = 0;
let done = false;
while (!done) {
const page = await store.readPage(IDENTITY_A, 1, cursor);
messages.push(...page.entries.map(({ entry }) => entry.message));
cursor = page.nextCursor;
done = page.done;
pageCount += 1;
}
expect(messages).toEqual(["first", "second", "third"]);
expect(pageCount).toBe(3);
});
});
describe("IndexedDB cursor continuation bounds", () => {
it("keeps equal-timestamp cursor visits linear across pages", async () => {
const timestamp = Date.now();
const store = new IndexedDBLogStore();
const entries = Array.from({ length: 64 }, (_, index) =>
logEntry(IDENTITY_A, timestamp, `entry-${String(index).padStart(2, "0")}`),
);
await store.append(prepareEntries(entries));
const cursorContinue = vi.spyOn(IDBCursor.prototype, "continue");
const cursorContinuePrimaryKey = vi.spyOn(IDBCursor.prototype, "continuePrimaryKey");
try {
let cursor: import("./indexeddb-store").LogPageCursor | null = null;
let done = false;
let pageCount = 0;
while (!done) {
const page = await store.readPage(IDENTITY_A, prepareLogEntry(entries[0]).bytes, cursor);
cursor = page.nextCursor;
done = page.done;
pageCount += 1;
}
expect(pageCount).toBe(entries.length);
expect(cursorContinuePrimaryKey).toHaveBeenCalledTimes(entries.length - 2);
expect(cursorContinue.mock.calls.length).toBeLessThanOrEqual(entries.length * 3);
} finally {
cursorContinue.mockRestore();
cursorContinuePrimaryKey.mockRestore();
}
});
});
describe("IndexedDB capture boundaries", () => {
it("excludes records persisted after the capture upper bound", async () => {
const timestamp = Date.now();
const store = new IndexedDBLogStore();
const first = logEntry(IDENTITY_A, timestamp, "first");
const second = logEntry(IDENTITY_A, timestamp, "second");
await store.append(prepareEntries([first, second]));
const maxPrimaryKey = await store.beginCaptureBoundary();
const firstPage = await store.readPage(
IDENTITY_A,
prepareLogEntry(first).bytes,
null,
maxPrimaryKey,
);
expect(firstPage.entries.map(({ entry }) => entry.message)).toEqual(["first"]);
expect(firstPage.done).toBe(false);
await store.append(prepareEntries([logEntry(IDENTITY_A, timestamp, "after-receipt")]));
const secondPage = await store.readPage(
IDENTITY_A,
MAX_BYTES,
firstPage.nextCursor,
maxPrimaryKey,
);
expect(secondPage.entries.map(({ entry }) => entry.message)).toEqual(["second"]);
expect(secondPage.done).toBe(true);
});
it("freezes the boundary before a later tab commits a row", async () => {
const firstStore = new IndexedDBLogStore();
const secondStore = new IndexedDBLogStore();
const timestamp = Date.now();
await firstStore.append(
prepareEntries([logEntry(IDENTITY_A, timestamp, BEFORE_RECEIPT_MESSAGE)]),
);
await secondStore.readPage(IDENTITY_A, MAX_BYTES);
const boundaryPromise = firstStore.beginCaptureBoundary();
const laterAppend = secondStore.append(
prepareEntries([logEntry(IDENTITY_A, timestamp, "after-receipt")]),
);
const maxPrimaryKey = await boundaryPromise;
await laterAppend;
const page = await firstStore.readPage(IDENTITY_A, MAX_BYTES, null, maxPrimaryKey);
expect(page.entries.map(({ entry }) => entry.message)).toEqual([BEFORE_RECEIPT_MESSAGE]);
expect(page.done).toBe(true);
});
it("excludes another tab write interleaved before the receipt prefix", async () => {
const firstStore = new IndexedDBLogStore();
const secondStore = new IndexedDBLogStore();
const timestamp = Date.now();
await firstStore.append(
prepareEntries([logEntry(IDENTITY_A, timestamp, BEFORE_RECEIPT_MESSAGE)]),
);
await secondStore.readPage(IDENTITY_A, MAX_BYTES);
const boundary = await firstStore.beginCaptureBoundary();
await secondStore.append(
prepareEntries([logEntry(IDENTITY_A, timestamp, "interleaved-write")]),
);
const prefix = await firstStore.append(
prepareEntries([logEntry(IDENTITY_A, timestamp, RECEIPT_PREFIX_MESSAGE)]),
);
const page = await firstStore.readPage(IDENTITY_A, MAX_BYTES, null, boundary, new Set(prefix));
expect(page.entries.map(({ entry }) => entry.message)).toEqual([
BEFORE_RECEIPT_MESSAGE,
RECEIPT_PREFIX_MESSAGE,
]);
expect(page.done).toBe(true);
});
});
describe("IndexedDB log retention repair and concurrency", () => {
it("repairs missing or invalid totals during the next write", async () => {
const store = new IndexedDBLogStore();
await store.append(prepareEntries([logEntry("a", Date.now(), "before repair")]));
await overwriteMetadata({ key: RETENTION_KEY, count: -1, bytes: Number.NaN });
await store.append(prepareEntries([logEntry("a", Date.now() + 1, "after repair")]));
const records = await readEntries();
await expect(readMetadata()).resolves.toEqual({
key: RETENTION_KEY,
count: records.length,
bytes: records.reduce((total, record) => total + record.bytes, 0),
});
});
it("clears entries and resets totals in one transaction", async () => {
const store = new IndexedDBLogStore();
await store.append(prepareEntries([logEntry("a", Date.now(), "to clear")]));
await store.clear();
await expect(readEntries()).resolves.toEqual([]);
await expect(readMetadata()).resolves.toEqual({
key: RETENTION_KEY,
count: 0,
bytes: 0,
});
});
it("keeps concurrent store instances transactionally consistent", async () => {
const stores = [new IndexedDBLogStore(), new IndexedDBLogStore()];
const batches = Array.from({ length: 24 }, (_, batchIndex) =>
Array.from({ length: 8 }, (_, entryIndex) =>
logEntry(
"a",
Date.now() + batchIndex * 10 + entryIndex,
`batch-${batchIndex}-${entryIndex}`,
),
),
);
await Promise.all(
batches.map((batch, index) => stores[index % stores.length].append(prepareEntries(batch))),
);
const records = await readEntries();
const metadata = await readMetadata();
expect(records).toHaveLength(24 * 8);
expect(metadata).toEqual({
key: RETENTION_KEY,
count: records.length,
bytes: records.reduce((total, record) => total + record.bytes, 0),
});
});
it("uses only the bounded timestamp cursor for a within-limit append", async () => {
const store = new IndexedDBLogStore();
await store.append(prepareEntries([logEntry("a", Date.now(), "first")]));
const objectStoreCursor = vi.spyOn(IDBObjectStore.prototype, "openCursor");
const timestampCursor = vi.spyOn(IDBIndex.prototype, "openCursor");
try {
await store.append(prepareEntries([logEntry("a", Date.now() + 1, "second")]));
expect(objectStoreCursor).not.toHaveBeenCalled();
expect(timestampCursor).toHaveBeenCalledTimes(1);
expect(timestampCursor.mock.calls[0]?.[0]).toBeInstanceOf(IDBKeyRange);
expect(timestampCursor.mock.calls[0]?.[0]).toMatchObject({
upper: expect.any(Number),
});
} finally {
objectStoreCursor.mockRestore();
timestampCursor.mockRestore();
}
});
});
function logEntry(identityScope: string, timestamp: number, message: string): LogEntry {
return {
timestamp: new Date(timestamp).toISOString(),
level: "info",
source: "test",
message,
identity_scope: identityScope,
};
}
function prepareEntries(entries: LogEntry[]): PreparedLogEntry[] {
return entries.map(prepareLogEntry);
}
function persisted(entry: LogEntry, timestamp: number, bytes: number): PersistedTestEntry {
return {
identity_scope: entry.identity_scope ?? "",
timestamp_ms: timestamp,
bytes,
entry,
};
}
function deleteDatabase(): Promise<void> {
return new Promise((resolve, reject) => {
const request = indexedDB.deleteDatabase(DATABASE_NAME);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error ?? new Error("failed to delete test database"));
});
}
function openDatabase(version?: number): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request =
version === undefined
? indexedDB.open(DATABASE_NAME)
: indexedDB.open(DATABASE_NAME, version);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error ?? new Error("failed to open test database"));
});
}
function createVersionOneDatabase(records: PersistedTestEntry[]): Promise<void> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DATABASE_NAME, 1);
request.onupgradeneeded = () => {
const store = request.result.createObjectStore(ENTRIES_STORE, {
keyPath: "id",
autoIncrement: true,
});
store.createIndex("identity_scope", "identity_scope", { unique: false });
store.createIndex("timestamp_ms", "timestamp_ms", { unique: false });
};
request.onerror = () =>
reject(request.error ?? new Error("failed to create version one database"));
request.onsuccess = () => {
const database = request.result;
const transaction = database.transaction(ENTRIES_STORE, "readwrite");
for (const record of records) transaction.objectStore(ENTRIES_STORE).add(record);
transaction.oncomplete = () => {
database.close();
resolve();
};
transaction.onerror = () =>
reject(transaction.error ?? new Error("failed to seed version one database"));
};
});
}
function openVersionOneDatabase(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DATABASE_NAME, 1);
request.onupgradeneeded = () => {
const store = request.result.createObjectStore(ENTRIES_STORE, {
keyPath: "id",
autoIncrement: true,
});
store.createIndex("identity_scope", "identity_scope", { unique: false });
store.createIndex("timestamp_ms", "timestamp_ms", { unique: false });
};
request.onerror = () =>
reject(request.error ?? new Error("failed to open version one blocker"));
request.onsuccess = () => resolve(request.result);
});
}
async function readEntries(): Promise<PersistedTestEntry[]> {
const database = await openDatabase();
const transaction = database.transaction(ENTRIES_STORE, "readonly");
const request = transaction.objectStore(ENTRIES_STORE).getAll();
const records = await requestResult(request);
await transactionComplete(transaction);
database.close();
return records as PersistedTestEntry[];
}
async function readMetadata(): Promise<Record<string, unknown>> {
const database = await openDatabase();
const transaction = database.transaction(METADATA_STORE, "readonly");
const value = await requestResult(transaction.objectStore(METADATA_STORE).get(RETENTION_KEY));
await transactionComplete(transaction);
database.close();
return value as Record<string, unknown>;
}
async function overwriteMetadata(value: Record<string, unknown>): Promise<void> {
const database = await openDatabase();
const transaction = database.transaction(METADATA_STORE, "readwrite");
transaction.objectStore(METADATA_STORE).put(value);
await transactionComplete(transaction);
database.close();
}
function requestResult<T>(request: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error ?? new Error("test request failed"));
});
}
function transactionComplete(transaction: IDBTransaction): Promise<void> {
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error ?? new Error("test transaction failed"));
transaction.onabort = () => reject(transaction.error ?? new Error("test transaction aborted"));
});
}