1034 lines
31 KiB
TypeScript
1034 lines
31 KiB
TypeScript
import type {
|
|
IndexedDbMaintenanceDependencies,
|
|
IndexedDbRuntimeDependencies,
|
|
} from "../../src/adapters/storage/indexeddb/indexeddb-types.ts";
|
|
import {
|
|
expect,
|
|
test,
|
|
type Page,
|
|
} from "../support/browser/strict-browser-test.ts";
|
|
|
|
type BrowserItem = Readonly<{
|
|
label: string;
|
|
rank: number;
|
|
}>;
|
|
|
|
function databaseName(_prefix: string): string {
|
|
const token = crypto.randomUUID().replaceAll("-", "");
|
|
return `ca-idb-v1:a${token.slice(0, 31)}.n${token.slice(1)}.p${token.split("").reverse().join("").slice(0, 31)}`;
|
|
}
|
|
|
|
async function installLifecycleRuntime(
|
|
page: Page,
|
|
name: string,
|
|
targetVersion: 1 | 2,
|
|
blockedTimeoutMs = 1_000,
|
|
) {
|
|
return await page.evaluate(
|
|
async ({
|
|
currentDatabaseName,
|
|
currentTargetVersion,
|
|
currentBlockedTimeoutMs,
|
|
}) => {
|
|
const modulePath =
|
|
"/src/adapters/storage/indexeddb/indexeddb-runtime.ts";
|
|
const { createIndexedDbRuntime } = (await import(
|
|
/* @vite-ignore */ modulePath
|
|
)) as typeof import("../../src/adapters/storage/indexeddb/indexeddb-runtime.ts");
|
|
type LifecycleValue = Readonly<{ value: string }>;
|
|
const [authorityToken, namespaceToken, partitionToken] =
|
|
currentDatabaseName.slice("ca-idb-v1:".length).split(".");
|
|
const scope = {
|
|
authorityToken,
|
|
namespaceToken,
|
|
partitionToken,
|
|
accountScope: "ORIGIN_SHARED" as const,
|
|
};
|
|
const storagePolicy = {
|
|
owner: "platform-storage",
|
|
namespace: "indexeddb-browser-lifecycle",
|
|
classification: "INTERNAL" as const,
|
|
authority: "SERVER" as const,
|
|
accountScope: "ORIGIN_SHARED" as const,
|
|
retention: { kind: "EXPLICIT_DELETE" as const },
|
|
softBudgetBytes: 1_000_000,
|
|
hardBudgetBytes: 2_000_000,
|
|
evictionPriority: "SYNCED_COPY" as const,
|
|
logoutAction: "KEEP_ORIGIN_SHARED" as const,
|
|
accountDeletionAction: "KEEP_ORIGIN_SHARED" as const,
|
|
pressureAction: "RETAIN" as const,
|
|
unavailableFallback: "ONLINE_ONLY" as const,
|
|
};
|
|
const migrations: IndexedDbRuntimeDependencies<
|
|
LifecycleValue,
|
|
LifecycleValue,
|
|
null
|
|
>["migrations"] = [
|
|
{
|
|
id: "schema-v1",
|
|
fromVersion: 0,
|
|
toVersion: 1,
|
|
operations: [
|
|
{
|
|
kind: "CREATE_STORE",
|
|
name: "governance",
|
|
keyPath: "bindingKey",
|
|
},
|
|
{
|
|
kind: "CREATE_STORE",
|
|
name: "retention",
|
|
keyPath: "recordKey",
|
|
indexes: [
|
|
{
|
|
name: "by-eligibility",
|
|
keyPath: "eligibleAtEpochMs",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
kind: "CREATE_STORE",
|
|
name: "records",
|
|
keyPath: "key",
|
|
},
|
|
{
|
|
kind: "CREATE_STORE",
|
|
name: "receipts",
|
|
keyPath: "idempotencyKey",
|
|
indexes: [
|
|
{
|
|
name: "by-expiry",
|
|
keyPath: "expiresAtEpochMs",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
...(currentTargetVersion === 2
|
|
? [
|
|
{
|
|
id: "schema-v2",
|
|
fromVersion: 1,
|
|
toVersion: 2,
|
|
operations: [
|
|
{
|
|
kind: "CREATE_INDEX" as const,
|
|
store: "records",
|
|
index: {
|
|
name: "by-key",
|
|
keyPath: "key",
|
|
},
|
|
},
|
|
],
|
|
},
|
|
]
|
|
: []),
|
|
];
|
|
const config: IndexedDbRuntimeDependencies<
|
|
LifecycleValue,
|
|
LifecycleValue,
|
|
null
|
|
> = {
|
|
scope,
|
|
storagePolicy,
|
|
schemaVersion: currentTargetVersion,
|
|
recordStore: "records",
|
|
governanceStore: "governance",
|
|
retentionStore: "retention",
|
|
retentionEligibilityIndex: "by-eligibility",
|
|
lifecycleMetadataStores: [],
|
|
idempotencyStore: "receipts",
|
|
idempotencyExpiryIndex: "by-expiry",
|
|
receiptRetentionMs: 60_000,
|
|
maxIdempotencyReceipts: 1_000,
|
|
migrations,
|
|
codec: {
|
|
currentVersion: 1,
|
|
encode: (value) => ({ ok: true, value }),
|
|
measureStoredBytes: (value) =>
|
|
new TextEncoder().encode(JSON.stringify(value)).byteLength,
|
|
decode: (version, value) =>
|
|
version === 1 &&
|
|
value !== null &&
|
|
typeof value === "object" &&
|
|
typeof (
|
|
value as Readonly<{ value?: unknown }>
|
|
).value === "string"
|
|
? {
|
|
ok: true,
|
|
value: value as LifecycleValue,
|
|
}
|
|
: { ok: false },
|
|
fingerprint: () => "0".repeat(64),
|
|
},
|
|
queryPolicy: { plan: () => ({ limit: 10 }) },
|
|
blockedTimeoutMs: currentBlockedTimeoutMs,
|
|
authorizeLifecycle: () => ({
|
|
authorized: true,
|
|
proofToken: "browserauthorityproof_001",
|
|
}),
|
|
};
|
|
const runtime = createIndexedDbRuntime(config);
|
|
(
|
|
globalThis as unknown as {
|
|
__indexedDbLifecycleRuntime?: unknown;
|
|
}
|
|
).__indexedDbLifecycleRuntime = runtime;
|
|
const opened = await runtime.open();
|
|
return { opened, status: runtime.getStatus() };
|
|
},
|
|
{
|
|
currentDatabaseName: name,
|
|
currentTargetVersion: targetVersion,
|
|
currentBlockedTimeoutMs: blockedTimeoutMs,
|
|
},
|
|
);
|
|
}
|
|
|
|
async function lifecycleStatus(page: Page): Promise<unknown> {
|
|
return await page.evaluate(() => {
|
|
const runtime = (
|
|
globalThis as unknown as {
|
|
__indexedDbLifecycleRuntime?: {
|
|
getStatus(): unknown;
|
|
};
|
|
}
|
|
).__indexedDbLifecycleRuntime;
|
|
return runtime?.getStatus() ?? null;
|
|
});
|
|
}
|
|
|
|
async function closeLifecycleRuntime(page: Page): Promise<void> {
|
|
await page.evaluate(() => {
|
|
const runtime = (
|
|
globalThis as unknown as {
|
|
__indexedDbLifecycleRuntime?: {
|
|
close(): void;
|
|
};
|
|
}
|
|
).__indexedDbLifecycleRuntime;
|
|
runtime?.close();
|
|
});
|
|
}
|
|
|
|
test("runs open, CAS, read, cursor query, close, and deletion against native IndexedDB", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
const name = databaseName("runtime-browser");
|
|
const result = await page.evaluate(async (currentDatabaseName) => {
|
|
const modulePath =
|
|
"/src/adapters/storage/indexeddb/indexeddb-runtime.ts";
|
|
const { createIndexedDbRuntime } = (await import(
|
|
/* @vite-ignore */ modulePath
|
|
)) as typeof import("../../src/adapters/storage/indexeddb/indexeddb-runtime.ts");
|
|
const observations: unknown[] = [];
|
|
let epochMs = 1_000;
|
|
let lifecycleAuthorizations = 0;
|
|
const [authorityToken, namespaceToken, partitionToken] =
|
|
currentDatabaseName.slice("ca-idb-v1:".length).split(".");
|
|
const scope = {
|
|
authorityToken,
|
|
namespaceToken,
|
|
partitionToken,
|
|
accountScope: "ORIGIN_SHARED" as const,
|
|
};
|
|
const storagePolicy = {
|
|
owner: "platform-storage",
|
|
namespace: "indexeddb-browser-runtime",
|
|
classification: "INTERNAL" as const,
|
|
authority: "SERVER" as const,
|
|
accountScope: "ORIGIN_SHARED" as const,
|
|
retention: { kind: "TTL" as const, maxAgeMs: 10 },
|
|
softBudgetBytes: 1_000_000,
|
|
hardBudgetBytes: 2_000_000,
|
|
evictionPriority: "SYNCED_COPY" as const,
|
|
logoutAction: "KEEP_ORIGIN_SHARED" as const,
|
|
accountDeletionAction: "KEEP_ORIGIN_SHARED" as const,
|
|
pressureAction: "RETAIN" as const,
|
|
unavailableFallback: "ONLINE_ONLY" as const,
|
|
};
|
|
const config: IndexedDbRuntimeDependencies<
|
|
BrowserItem,
|
|
BrowserItem,
|
|
Readonly<{ limit: number }>
|
|
> = {
|
|
scope,
|
|
storagePolicy,
|
|
schemaVersion: 1,
|
|
recordStore: "records",
|
|
governanceStore: "governance",
|
|
retentionStore: "retention",
|
|
retentionEligibilityIndex: "by-eligibility",
|
|
lifecycleMetadataStores: [],
|
|
idempotencyStore: "receipts",
|
|
idempotencyExpiryIndex: "by-expiry",
|
|
receiptRetentionMs: 60_000,
|
|
maxIdempotencyReceipts: 1_000,
|
|
migrations: [
|
|
{
|
|
id: "schema-v1",
|
|
fromVersion: 0,
|
|
toVersion: 1,
|
|
operations: [
|
|
{
|
|
kind: "CREATE_STORE",
|
|
name: "governance",
|
|
keyPath: "bindingKey",
|
|
},
|
|
{
|
|
kind: "CREATE_STORE",
|
|
name: "retention",
|
|
keyPath: "recordKey",
|
|
indexes: [
|
|
{
|
|
name: "by-eligibility",
|
|
keyPath: "eligibleAtEpochMs",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
kind: "CREATE_STORE",
|
|
name: "records",
|
|
keyPath: "key",
|
|
indexes: [
|
|
{
|
|
name: "by-rank",
|
|
keyPath: "payload.rank",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
kind: "CREATE_STORE",
|
|
name: "receipts",
|
|
keyPath: "idempotencyKey",
|
|
indexes: [
|
|
{
|
|
name: "by-expiry",
|
|
keyPath: "expiresAtEpochMs",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
codec: {
|
|
currentVersion: 1,
|
|
encode: (value) => ({ ok: true, value }),
|
|
measureStoredBytes: (value) =>
|
|
new TextEncoder().encode(JSON.stringify(value)).byteLength,
|
|
decode: (version, value) =>
|
|
version === 1 &&
|
|
value !== null &&
|
|
typeof value === "object" &&
|
|
typeof (value as Partial<BrowserItem>).label === "string" &&
|
|
typeof (value as Partial<BrowserItem>).rank === "number"
|
|
? { ok: true, value: value as BrowserItem }
|
|
: { ok: false },
|
|
fingerprint: async (value) => {
|
|
const bytes = new TextEncoder().encode(
|
|
JSON.stringify([value.rank, value.label]),
|
|
);
|
|
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
|
return Array.from(
|
|
new Uint8Array(digest),
|
|
(byte) => byte.toString(16).padStart(2, "0"),
|
|
).join("");
|
|
},
|
|
},
|
|
queryPolicy: {
|
|
plan: (query) => ({
|
|
index: "by-rank",
|
|
direction: "next",
|
|
limit: query.limit,
|
|
}),
|
|
},
|
|
observe: (event) => observations.push(event),
|
|
nowEpochMilliseconds: () => epochMs,
|
|
nowMonotonicMilliseconds: () => 0,
|
|
authorizeLifecycle: () => {
|
|
lifecycleAuthorizations += 1;
|
|
return {
|
|
authorized: true,
|
|
proofToken: "browserauthorityproof_002",
|
|
};
|
|
},
|
|
};
|
|
const runtime = createIndexedDbRuntime(config);
|
|
const opened = await runtime.open();
|
|
const alpha = {
|
|
key: "alpha-key",
|
|
value: { label: "alpha", rank: 2 },
|
|
expectedRevision: null,
|
|
idempotencyKey: "alpha-create",
|
|
} as const;
|
|
const created = await runtime.compareAndSwap(alpha);
|
|
const replayed = await runtime.compareAndSwap(alpha);
|
|
const conflict = await runtime.compareAndSwap({
|
|
...alpha,
|
|
value: { label: "changed", rank: 2 },
|
|
});
|
|
const uncloneable = await runtime.compareAndSwap({
|
|
key: "uncloneable-key",
|
|
value: {
|
|
label: "uncloneable",
|
|
rank: 3,
|
|
callback: () => undefined,
|
|
} as unknown as BrowserItem,
|
|
expectedRevision: null,
|
|
idempotencyKey: "uncloneable-create",
|
|
});
|
|
const uncloneableRead = await runtime.read(
|
|
"uncloneable-key",
|
|
);
|
|
await runtime.compareAndSwap({
|
|
key: "beta-key",
|
|
value: { label: "beta", rank: 1 },
|
|
expectedRevision: null,
|
|
idempotencyKey: "beta-create",
|
|
});
|
|
const read = await runtime.read("alpha-key");
|
|
const firstPage = await runtime.query({ limit: 1 });
|
|
const secondPage = await runtime.query(
|
|
{ limit: 1 },
|
|
firstPage.ok ? firstPage.value.nextCursor : null,
|
|
);
|
|
epochMs = 1_011;
|
|
const expiredRead = await runtime.read("alpha-key");
|
|
const expiredPage = await runtime.query({ limit: 10 });
|
|
const lifecycle = await runtime.enforceLifecycleBatch({
|
|
action: "RETENTION_SWEEP",
|
|
maxRows: 10,
|
|
maxDurationMs: 1_000,
|
|
});
|
|
const auditConnection = await new Promise<IDBDatabase>(
|
|
(resolve, reject) => {
|
|
const request = indexedDB.open(currentDatabaseName, 1);
|
|
request.onsuccess = () => resolve(request.result);
|
|
request.onerror = () => reject(request.error);
|
|
},
|
|
);
|
|
const persistedAudit = await new Promise<string>(
|
|
(resolve, reject) => {
|
|
const transaction = auditConnection.transaction(
|
|
["governance", "retention", "receipts"],
|
|
"readonly",
|
|
);
|
|
const governance = transaction
|
|
.objectStore("governance")
|
|
.getAll();
|
|
const retention = transaction
|
|
.objectStore("retention")
|
|
.getAll();
|
|
const receipts = transaction
|
|
.objectStore("receipts")
|
|
.getAll();
|
|
transaction.oncomplete = () =>
|
|
resolve(
|
|
JSON.stringify([
|
|
governance.result,
|
|
retention.result,
|
|
receipts.result,
|
|
]),
|
|
);
|
|
transaction.onerror = () => reject(transaction.error);
|
|
transaction.onabort = () => reject(transaction.error);
|
|
},
|
|
);
|
|
auditConnection.close();
|
|
const beforeClose = runtime.getStatus();
|
|
runtime.close();
|
|
const afterClose = runtime.getStatus();
|
|
|
|
const deletion = await new Promise<string>((resolve) => {
|
|
const request = indexedDB.deleteDatabase(currentDatabaseName);
|
|
let blockedTimer: ReturnType<typeof setTimeout> | undefined;
|
|
const finish = (value: string) => {
|
|
if (blockedTimer) clearTimeout(blockedTimer);
|
|
resolve(value);
|
|
};
|
|
request.onsuccess = () => finish("DELETED");
|
|
request.onerror = () =>
|
|
finish(request.error?.name ?? "UNKNOWN_ERROR");
|
|
// `blocked` is a notification, not a terminal outcome: a connection
|
|
// that has already called close() may finish its last transaction and
|
|
// allow this same request to succeed.
|
|
request.onblocked = () => {
|
|
blockedTimer ??= setTimeout(() => finish("BLOCKED"), 2_000);
|
|
};
|
|
});
|
|
|
|
return {
|
|
opened,
|
|
created,
|
|
replayed,
|
|
conflict,
|
|
uncloneable,
|
|
uncloneableRead,
|
|
read,
|
|
firstPage,
|
|
secondPage,
|
|
expiredRead,
|
|
expiredPage,
|
|
lifecycle,
|
|
lifecycleAuthorizations,
|
|
persistedAudit,
|
|
beforeClose,
|
|
afterClose,
|
|
deletion,
|
|
observations,
|
|
};
|
|
}, name);
|
|
|
|
expect(result.opened).toMatchObject({ ok: true });
|
|
expect(result.created).toMatchObject({
|
|
ok: true,
|
|
value: { revision: 1, replayed: false },
|
|
});
|
|
expect(result.replayed).toMatchObject({
|
|
ok: true,
|
|
value: { revision: 1, replayed: true },
|
|
});
|
|
expect(result.conflict).toMatchObject({
|
|
ok: false,
|
|
error: { code: "CONFLICT" },
|
|
});
|
|
expect(result.uncloneable).toMatchObject({
|
|
ok: false,
|
|
error: { code: "CORRUPT_DATA" },
|
|
});
|
|
expect(result.uncloneableRead).toEqual({
|
|
ok: true,
|
|
value: null,
|
|
});
|
|
expect(result.read).toEqual({
|
|
ok: true,
|
|
value: {
|
|
value: { label: "alpha", rank: 2 },
|
|
revision: 1,
|
|
},
|
|
});
|
|
expect(result.firstPage).toMatchObject({
|
|
ok: true,
|
|
value: { items: [{ label: "beta", rank: 1 }] },
|
|
});
|
|
expect(result.secondPage).toEqual({
|
|
ok: true,
|
|
value: {
|
|
items: [{ label: "alpha", rank: 2 }],
|
|
nextCursor: null,
|
|
},
|
|
});
|
|
expect(result.expiredRead).toMatchObject({
|
|
ok: false,
|
|
error: { code: "EXPIRED_RESOURCE" },
|
|
});
|
|
expect(result.expiredPage).toEqual({
|
|
ok: true,
|
|
value: { items: [], nextCursor: null },
|
|
});
|
|
expect(result.lifecycle).toMatchObject({
|
|
ok: true,
|
|
value: { state: "COMPLETE", deletedRows: 2 },
|
|
});
|
|
expect(result.lifecycleAuthorizations).toBe(1);
|
|
expect(result.persistedAudit).not.toContain(
|
|
"browserauthorityproof_002",
|
|
);
|
|
expect(result.beforeClose).toEqual({
|
|
kind: "READY",
|
|
schemaVersion: 1,
|
|
});
|
|
expect(result.afterClose).toEqual({ kind: "DISPOSED" });
|
|
expect(result.deletion).toBe("DELETED");
|
|
expect(JSON.stringify(result.observations)).not.toMatch(
|
|
/alpha-key|beta-key|alpha-create/,
|
|
);
|
|
});
|
|
|
|
test("runs resumable codec maintenance against native IndexedDB", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
const name = databaseName("maintenance-browser");
|
|
const result = await page.evaluate(async (currentDatabaseName) => {
|
|
const runtimeModulePath =
|
|
"/src/adapters/storage/indexeddb/indexeddb-runtime.ts";
|
|
const maintenanceModulePath =
|
|
"/src/adapters/storage/indexeddb/indexeddb-maintenance.ts";
|
|
const { createIndexedDbRuntime } = (await import(
|
|
/* @vite-ignore */ runtimeModulePath
|
|
)) as typeof import("../../src/adapters/storage/indexeddb/indexeddb-runtime.ts");
|
|
const { createIndexedDbMaintenance } = (await import(
|
|
/* @vite-ignore */ maintenanceModulePath
|
|
)) as typeof import("../../src/adapters/storage/indexeddb/indexeddb-maintenance.ts");
|
|
const [authorityToken, namespaceToken, partitionToken] =
|
|
currentDatabaseName.slice("ca-idb-v1:".length).split(".");
|
|
const scope = {
|
|
authorityToken,
|
|
namespaceToken,
|
|
partitionToken,
|
|
accountScope: "ORIGIN_SHARED" as const,
|
|
};
|
|
const storagePolicy = {
|
|
owner: "platform-storage",
|
|
namespace: "indexeddb-browser-maintenance",
|
|
classification: "INTERNAL" as const,
|
|
authority: "SERVER" as const,
|
|
accountScope: "ORIGIN_SHARED" as const,
|
|
retention: { kind: "EXPLICIT_DELETE" as const },
|
|
softBudgetBytes: 1_000_000,
|
|
hardBudgetBytes: 2_000_000,
|
|
evictionPriority: "SYNCED_COPY" as const,
|
|
logoutAction: "KEEP_ORIGIN_SHARED" as const,
|
|
accountDeletionAction: "KEEP_ORIGIN_SHARED" as const,
|
|
pressureAction: "RETAIN" as const,
|
|
unavailableFallback: "ONLINE_ONLY" as const,
|
|
};
|
|
const runtimeConfig: IndexedDbRuntimeDependencies<
|
|
Readonly<{ label: string }>,
|
|
Readonly<{ label: string }>,
|
|
null
|
|
> = {
|
|
scope,
|
|
storagePolicy,
|
|
schemaVersion: 1,
|
|
recordStore: "records",
|
|
governanceStore: "governance",
|
|
retentionStore: "retention",
|
|
retentionEligibilityIndex: "by-eligibility",
|
|
lifecycleMetadataStores: ["maintenance"],
|
|
idempotencyStore: "receipts",
|
|
idempotencyExpiryIndex: "by-expiry",
|
|
receiptRetentionMs: 60_000,
|
|
maxIdempotencyReceipts: 1_000,
|
|
migrations: [
|
|
{
|
|
id: "schema-v1",
|
|
fromVersion: 0,
|
|
toVersion: 1,
|
|
operations: [
|
|
{
|
|
kind: "CREATE_STORE",
|
|
name: "governance",
|
|
keyPath: "bindingKey",
|
|
},
|
|
{
|
|
kind: "CREATE_STORE",
|
|
name: "retention",
|
|
keyPath: "recordKey",
|
|
indexes: [
|
|
{
|
|
name: "by-eligibility",
|
|
keyPath: "eligibleAtEpochMs",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
kind: "CREATE_STORE",
|
|
name: "records",
|
|
keyPath: "key",
|
|
},
|
|
{
|
|
kind: "CREATE_STORE",
|
|
name: "receipts",
|
|
keyPath: "idempotencyKey",
|
|
indexes: [
|
|
{
|
|
name: "by-expiry",
|
|
keyPath: "expiresAtEpochMs",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
kind: "CREATE_STORE",
|
|
name: "maintenance",
|
|
keyPath: "checkpointKey",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
codec: {
|
|
currentVersion: 2,
|
|
encode: (value) => ({ ok: true, value }),
|
|
measureStoredBytes: (value) =>
|
|
new TextEncoder().encode(JSON.stringify(value)).byteLength,
|
|
decode: (version, value) =>
|
|
version === 2 &&
|
|
value !== null &&
|
|
typeof value === "object" &&
|
|
typeof (
|
|
value as Readonly<{ label?: unknown }>
|
|
).label === "string"
|
|
? {
|
|
ok: true,
|
|
value: value as Readonly<{ label: string }>,
|
|
}
|
|
: { ok: false },
|
|
fingerprint: () => "0".repeat(64),
|
|
},
|
|
queryPolicy: { plan: () => ({ limit: 10 }) },
|
|
authorizeLifecycle: () => ({
|
|
authorized: true,
|
|
proofToken: "browserauthorityproof_003",
|
|
}),
|
|
};
|
|
const runtime = createIndexedDbRuntime(runtimeConfig);
|
|
const opened = await runtime.open();
|
|
runtime.close();
|
|
|
|
const seedConnection = await new Promise<IDBDatabase>(
|
|
(resolve, reject) => {
|
|
const request = indexedDB.open(currentDatabaseName, 1);
|
|
request.onsuccess = () => resolve(request.result);
|
|
request.onerror = () => reject(request.error);
|
|
},
|
|
);
|
|
await new Promise<void>((resolve, reject) => {
|
|
const transaction = seedConnection.transaction(
|
|
["records", "governance", "retention", "receipts"],
|
|
"readwrite",
|
|
);
|
|
const measuredA =
|
|
new TextEncoder().encode(
|
|
JSON.stringify({ legacyLabel: "alpha" }),
|
|
).byteLength +
|
|
512 +
|
|
"legacy-a".length * 2;
|
|
const measuredB =
|
|
new TextEncoder().encode(
|
|
JSON.stringify({ legacyLabel: "beta" }),
|
|
).byteLength +
|
|
512 +
|
|
"legacy-b".length * 2;
|
|
transaction.objectStore("records").put({
|
|
key: "legacy-a",
|
|
codecVersion: 1,
|
|
revision: 4,
|
|
payload: { legacyLabel: "alpha" },
|
|
});
|
|
transaction.objectStore("records").put({
|
|
key: "legacy-b",
|
|
codecVersion: 1,
|
|
revision: 8,
|
|
payload: { legacyLabel: "beta" },
|
|
});
|
|
transaction.objectStore("retention").put({
|
|
recordKey: "legacy-a",
|
|
writtenAtEpochMs: 1,
|
|
synchronization: "NONE",
|
|
measuredBytes: measuredA,
|
|
});
|
|
transaction.objectStore("retention").put({
|
|
recordKey: "legacy-b",
|
|
writtenAtEpochMs: 1,
|
|
synchronization: "NONE",
|
|
measuredBytes: measuredB,
|
|
});
|
|
transaction.objectStore("receipts").put({
|
|
idempotencyKey: "expired-browser-receipt",
|
|
operation: "PUT",
|
|
recordKey: "already-removed",
|
|
expectedRevision: null,
|
|
fingerprint: "a".repeat(64),
|
|
synchronization: "NONE",
|
|
revision: 1,
|
|
expiresAtEpochMs: 0,
|
|
});
|
|
transaction.objectStore("governance").put({
|
|
bindingKey: "dataset-budget",
|
|
budgetVersion: 1,
|
|
usedBytes: measuredA + measuredB,
|
|
receiptCount: 1,
|
|
});
|
|
transaction.oncomplete = () => resolve();
|
|
transaction.onerror = () => reject(transaction.error);
|
|
transaction.onabort = () => reject(transaction.error);
|
|
});
|
|
seedConnection.close();
|
|
|
|
const maintenanceConfig: IndexedDbMaintenanceDependencies<
|
|
Readonly<{ label: string }>
|
|
> = {
|
|
scope,
|
|
storagePolicy,
|
|
schemaVersion: 1,
|
|
recordStore: "records",
|
|
governanceStore: "governance",
|
|
retentionStore: "retention",
|
|
checkpointStore: "maintenance",
|
|
checkpointKey: "records-codec",
|
|
idempotencyStore: "receipts",
|
|
idempotencyExpiryIndex: "by-expiry",
|
|
migrationPolicy: {
|
|
migrationId: "records-to-v2",
|
|
targetCodecVersion: 2,
|
|
measureStoredBytes: (value) =>
|
|
new TextEncoder().encode(JSON.stringify(value)).byteLength,
|
|
isOldWriterDrainConfirmed: () => true,
|
|
migrate: async ({ payload }) => {
|
|
const legacyLabel =
|
|
payload &&
|
|
typeof payload === "object" &&
|
|
typeof (
|
|
payload as Readonly<{ legacyLabel?: unknown }>
|
|
).legacyLabel === "string"
|
|
? (
|
|
payload as Readonly<{ legacyLabel: string }>
|
|
).legacyLabel
|
|
: null;
|
|
return legacyLabel === null
|
|
? { ok: false }
|
|
: { ok: true, value: { label: legacyLabel } };
|
|
},
|
|
},
|
|
};
|
|
const maintenance = createIndexedDbMaintenance(
|
|
maintenanceConfig,
|
|
);
|
|
const firstBatch = await maintenance.migrateCodecBatch({
|
|
maxRows: 1,
|
|
maxDurationMs: 10_000,
|
|
});
|
|
const secondBatch = await maintenance.migrateCodecBatch({
|
|
maxRows: 1,
|
|
maxDurationMs: 10_000,
|
|
});
|
|
const pruned = await maintenance.pruneExpiredReceipts({
|
|
maxRows: 1,
|
|
maxDurationMs: 10_000,
|
|
});
|
|
|
|
const inspectConnection = await new Promise<IDBDatabase>(
|
|
(resolve, reject) => {
|
|
const request = indexedDB.open(currentDatabaseName, 1);
|
|
request.onsuccess = () => resolve(request.result);
|
|
request.onerror = () => reject(request.error);
|
|
},
|
|
);
|
|
const rawRecords = await new Promise<unknown[]>(
|
|
(resolve, reject) => {
|
|
const transaction = inspectConnection.transaction(
|
|
"records",
|
|
"readonly",
|
|
);
|
|
const request = transaction.objectStore("records").getAll();
|
|
request.onsuccess = () => resolve(request.result);
|
|
request.onerror = () => reject(request.error);
|
|
},
|
|
);
|
|
const receiptCount = await new Promise<number>(
|
|
(resolve, reject) => {
|
|
const transaction = inspectConnection.transaction(
|
|
"receipts",
|
|
"readonly",
|
|
);
|
|
const request = transaction.objectStore("receipts").count();
|
|
request.onsuccess = () => resolve(request.result);
|
|
request.onerror = () => reject(request.error);
|
|
},
|
|
);
|
|
inspectConnection.close();
|
|
const deletion = await new Promise<string>((resolve) => {
|
|
const request = indexedDB.deleteDatabase(currentDatabaseName);
|
|
let blockedTimer: ReturnType<typeof setTimeout> | undefined;
|
|
const finish = (value: string) => {
|
|
if (blockedTimer) clearTimeout(blockedTimer);
|
|
resolve(value);
|
|
};
|
|
request.onsuccess = () => finish("DELETED");
|
|
request.onerror = () =>
|
|
finish(request.error?.name ?? "UNKNOWN_ERROR");
|
|
request.onblocked = () => {
|
|
blockedTimer ??= setTimeout(() => finish("BLOCKED"), 2_000);
|
|
};
|
|
});
|
|
return {
|
|
opened,
|
|
firstBatch,
|
|
secondBatch,
|
|
pruned,
|
|
rawRecords,
|
|
receiptCount,
|
|
deletion,
|
|
};
|
|
}, name);
|
|
|
|
expect(result.opened).toMatchObject({ ok: true });
|
|
expect(result.firstBatch).toMatchObject({
|
|
ok: true,
|
|
value: { state: "MORE", migratedRows: 1 },
|
|
});
|
|
expect(result.secondBatch).toMatchObject({
|
|
ok: true,
|
|
value: { state: "COMPLETE", migratedRows: 1 },
|
|
});
|
|
expect(result.pruned).toMatchObject({
|
|
ok: true,
|
|
value: { state: "COMPLETE", deletedRows: 1 },
|
|
});
|
|
expect(result.receiptCount).toBe(0);
|
|
expect(result.rawRecords).toEqual([
|
|
{
|
|
key: "legacy-a",
|
|
codecVersion: 2,
|
|
revision: 4,
|
|
payload: { label: "alpha" },
|
|
},
|
|
{
|
|
key: "legacy-b",
|
|
codecVersion: 2,
|
|
revision: 8,
|
|
payload: { label: "beta" },
|
|
},
|
|
]);
|
|
expect(result.deletion).toBe("DELETED");
|
|
});
|
|
|
|
test("closes a v1 runtime on versionchange so another context can upgrade to v2", async ({
|
|
page,
|
|
}) => {
|
|
const secondPage = await page.context().newPage();
|
|
const name = databaseName("versionchange");
|
|
try {
|
|
await Promise.all([page.goto("/"), secondPage.goto("/")]);
|
|
expect(await installLifecycleRuntime(page, name, 1)).toEqual({
|
|
opened: { ok: true },
|
|
status: { kind: "READY", schemaVersion: 1 },
|
|
});
|
|
|
|
const upgrade = await secondPage.evaluate(
|
|
async (currentDatabaseName) =>
|
|
await new Promise<string>((resolve) => {
|
|
const request = indexedDB.open(currentDatabaseName, 2);
|
|
request.onsuccess = () => {
|
|
request.result.close();
|
|
resolve("UPGRADED");
|
|
};
|
|
request.onerror = () =>
|
|
resolve(request.error?.name ?? "UNKNOWN_ERROR");
|
|
request.onblocked = () => resolve("BLOCKED");
|
|
}),
|
|
name,
|
|
);
|
|
|
|
expect(upgrade).toBe("UPGRADED");
|
|
expect(await lifecycleStatus(page)).toEqual({
|
|
kind: "CLOSED",
|
|
reason: "VERSION_CHANGE",
|
|
});
|
|
await closeLifecycleRuntime(page);
|
|
const deletion = await secondPage.evaluate(
|
|
async (currentDatabaseName) =>
|
|
await new Promise<string>((resolve) => {
|
|
const request = indexedDB.deleteDatabase(
|
|
currentDatabaseName,
|
|
);
|
|
request.onsuccess = () => resolve("DELETED");
|
|
request.onerror = () =>
|
|
resolve(request.error?.name ?? "UNKNOWN_ERROR");
|
|
request.onblocked = () => resolve("BLOCKED");
|
|
}),
|
|
name,
|
|
);
|
|
expect(deletion).toBe("DELETED");
|
|
} finally {
|
|
await secondPage.close();
|
|
}
|
|
});
|
|
|
|
test("fails a blocked v2 upgrade closed, then closes its late connection after the blocker exits", async ({
|
|
page,
|
|
}) => {
|
|
const blockerPage = await page.context().newPage();
|
|
const name = databaseName("blocked");
|
|
try {
|
|
await Promise.all([page.goto("/"), blockerPage.goto("/")]);
|
|
const blocker = await blockerPage.evaluate(
|
|
async (currentDatabaseName) =>
|
|
await new Promise<string>((resolve) => {
|
|
const request = indexedDB.open(currentDatabaseName, 1);
|
|
request.onupgradeneeded = () => {
|
|
const records = request.result.createObjectStore(
|
|
"records",
|
|
{ keyPath: "key" },
|
|
);
|
|
void records;
|
|
const receipts = request.result.createObjectStore(
|
|
"receipts",
|
|
{ keyPath: "idempotencyKey" },
|
|
);
|
|
receipts.createIndex(
|
|
"by-expiry",
|
|
"expiresAtEpochMs",
|
|
);
|
|
};
|
|
request.onsuccess = () => {
|
|
(
|
|
globalThis as unknown as {
|
|
__indexedDbNativeBlocker?: IDBDatabase;
|
|
}
|
|
).__indexedDbNativeBlocker = request.result;
|
|
resolve("OPEN");
|
|
};
|
|
request.onerror = () =>
|
|
resolve(request.error?.name ?? "UNKNOWN_ERROR");
|
|
}),
|
|
name,
|
|
);
|
|
expect(blocker).toBe("OPEN");
|
|
|
|
const blocked = await installLifecycleRuntime(
|
|
page,
|
|
name,
|
|
2,
|
|
25,
|
|
);
|
|
expect(blocked).toMatchObject({
|
|
opened: {
|
|
ok: false,
|
|
error: {
|
|
code: "BLOCKED",
|
|
recovery: "RELOAD_OTHER_CONTEXTS",
|
|
},
|
|
},
|
|
status: {
|
|
kind: "BLOCKED",
|
|
currentVersion: 1,
|
|
targetVersion: 2,
|
|
},
|
|
});
|
|
|
|
await blockerPage.evaluate(() => {
|
|
const scope = globalThis as unknown as {
|
|
__indexedDbNativeBlocker?: IDBDatabase;
|
|
};
|
|
scope.__indexedDbNativeBlocker?.close();
|
|
scope.__indexedDbNativeBlocker = undefined;
|
|
});
|
|
await expect
|
|
.poll(async () => await lifecycleStatus(page))
|
|
.toEqual({
|
|
kind: "CLOSED",
|
|
reason: "NOT_OPENED",
|
|
});
|
|
|
|
await closeLifecycleRuntime(page);
|
|
const deletion = await blockerPage.evaluate(
|
|
async (currentDatabaseName) =>
|
|
await new Promise<string>((resolve) => {
|
|
const request = indexedDB.deleteDatabase(
|
|
currentDatabaseName,
|
|
);
|
|
request.onsuccess = () => resolve("DELETED");
|
|
request.onerror = () =>
|
|
resolve(request.error?.name ?? "UNKNOWN_ERROR");
|
|
request.onblocked = () => resolve("BLOCKED");
|
|
}),
|
|
name,
|
|
);
|
|
expect(deletion).toBe("DELETED");
|
|
} finally {
|
|
await blockerPage.close();
|
|
}
|
|
});
|