Files
clean-architecture-frontend…/tests/browser-capabilities/indexeddb-runtime.spec.ts
T

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();
}
});