Files
kandev/apps/web/lib/logger/indexeddb-store.test.ts
T
2026-08-27 16:04:59 +00:00

382 lines
13 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;
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.toBeUndefined();
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 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"));
});
}