refactor: move IndexedDB maintenance onto the kernel
1558 -> 1430줄. 손수 abort 리스너가 0이 되어 래칫을 23에서 22로 조인다. 보존한 동작 셋: - blockedTimeoutMs를 넘기지 않는다. blocked 이벤트는 유지보수에게 종단이다 — 배치는 opt-in 백그라운드 패스라 다른 컨텍스트가 사라지길 기대하며 데드라인만큼 매다는 비용이 BLOCKED를 보고하고 재시도시키는 것보다 크다 - upgrade 콜백을 생략한다. 커널은 생략을 "어떤 upgrade든 예상 밖"으로 읽고 거절하는데, 그게 유지보수가 늘 해온 동작이다. 빈 APPLIED를 넣으면 잘못된 스키마로 열린다 - onversionchange는 admit 안, 바인딩 검증 앞에 등록한다 계획이 blocked 회귀 테스트로 지목한 indexeddb-maintenance.test.ts:289-290은 실제로는 onblocked 경로가 아니라 drain 검사였다. MT의 진짜 blocked 경로에는 테스트가 없었으므로 새로 썼다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
bb6080bb1c
commit
3366a81f0f
@@ -198,7 +198,8 @@ async function main(): Promise<void> {
|
|||||||
//
|
//
|
||||||
// 24 → 23: `browser-transfer/resumable-upload/indexeddb-checkpoint-store.ts`가
|
// 24 → 23: `browser-transfer/resumable-upload/indexeddb-checkpoint-store.ts`가
|
||||||
// IndexedDB 커널로 옮겨가면서 자기 abort 리스너를 지웠다.
|
// IndexedDB 커널로 옮겨가면서 자기 abort 리스너를 지웠다.
|
||||||
const HAND_ROLLED_ABORT_CEILING = 23;
|
// 23 → 22: `storage/indexeddb/indexeddb-maintenance.ts`가 같은 이유로 지웠다.
|
||||||
|
const HAND_ROLLED_ABORT_CEILING = 22;
|
||||||
const handRolledScan = spawnSync(
|
const handRolledScan = spawnSync(
|
||||||
"git",
|
"git",
|
||||||
["grep", "-l", 'addEventListener("abort"', "--", "src/adapters"],
|
["grep", "-l", 'addEventListener("abort"', "--", "src/adapters"],
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
IndexedDbReceiptPruneBatchReceipt,
|
IndexedDbReceiptPruneBatchReceipt,
|
||||||
} from "../../../application/ports/browser-file-storage/indexeddb-port.ts";
|
} from "../../../application/ports/browser-file-storage/indexeddb-port.ts";
|
||||||
import type {
|
import type {
|
||||||
|
BrowserDataFailure,
|
||||||
BrowserDataResult,
|
BrowserDataResult,
|
||||||
} from "../../../application/ports/browser-file-storage/shared.ts";
|
} from "../../../application/ports/browser-file-storage/shared.ts";
|
||||||
import {
|
import {
|
||||||
@@ -12,6 +13,17 @@ import {
|
|||||||
browserDataFailure,
|
browserDataFailure,
|
||||||
browserDataSuccess,
|
browserDataSuccess,
|
||||||
} from "../../browser-file-storage/result.ts";
|
} from "../../browser-file-storage/result.ts";
|
||||||
|
import {
|
||||||
|
openIndexedDbDatabase,
|
||||||
|
type IndexedDbTranslate,
|
||||||
|
} from "../../platform/indexeddb-connection.ts";
|
||||||
|
import {
|
||||||
|
openIndexedDbTransaction,
|
||||||
|
runIndexedDbTransaction,
|
||||||
|
walkIndexedDbCursor,
|
||||||
|
type IndexedDbBudget,
|
||||||
|
type IndexedDbTransactionContext,
|
||||||
|
} from "../../platform/indexeddb-transaction.ts";
|
||||||
import { mapIndexedDbException } from "./indexeddb-failure.ts";
|
import { mapIndexedDbException } from "./indexeddb-failure.ts";
|
||||||
import {
|
import {
|
||||||
createIndexedDbDatasetBinding,
|
createIndexedDbDatasetBinding,
|
||||||
@@ -94,12 +106,6 @@ type PreparedRecord<WireValue> = Readonly<{
|
|||||||
measuredBytes: number | undefined;
|
measuredBytes: number | undefined;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
type TransactionContext<Value> = Readonly<{
|
|
||||||
succeed(value: Value): void;
|
|
||||||
fail(result: BrowserDataResult<never>): void;
|
|
||||||
requestFailed(error: unknown): void;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
const SAFE_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u;
|
const SAFE_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u;
|
||||||
const OPAQUE_SHA256_FINGERPRINT = /^[a-f0-9]{64}$/u;
|
const OPAQUE_SHA256_FINGERPRINT = /^[a-f0-9]{64}$/u;
|
||||||
const MAX_BATCH_ROWS = 500;
|
const MAX_BATCH_ROWS = 500;
|
||||||
@@ -261,6 +267,41 @@ function unavailable(): BrowserDataResult<never> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function unsupported(): BrowserDataResult<never> {
|
||||||
|
return browserDataFailure("UNSUPPORTED", "INDEXEDDB_MIGRATE", {
|
||||||
|
recovery: "ONLINE_ONLY",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `browserDataFailure` and `mapIndexedDbException` build a `Result`, while the
|
||||||
|
* kernel's `translate` and `context.fail` want the failure on its own. Both
|
||||||
|
* only ever build the failure arm, so the branch below is a narrowing rather
|
||||||
|
* than a claim.
|
||||||
|
*/
|
||||||
|
function failureOf(result: BrowserDataResult<never>): BrowserDataFailure {
|
||||||
|
if (result.ok) {
|
||||||
|
throw new TypeError("A browser data failure was expected.");
|
||||||
|
}
|
||||||
|
return result.error;
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidInputFailure(): BrowserDataFailure {
|
||||||
|
return failureOf(invalidInput());
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrationFailure(): BrowserDataFailure {
|
||||||
|
return failureOf(migrationFailed());
|
||||||
|
}
|
||||||
|
|
||||||
|
function unsupportedFailure(): BrowserDataFailure {
|
||||||
|
return failureOf(unsupported());
|
||||||
|
}
|
||||||
|
|
||||||
|
function mappedFailure(error: unknown): BrowserDataFailure {
|
||||||
|
return failureOf(mapIndexedDbException(error, "INDEXEDDB_MIGRATE"));
|
||||||
|
}
|
||||||
|
|
||||||
function defaultNow(): number {
|
function defaultNow(): number {
|
||||||
return typeof globalThis.performance === "undefined"
|
return typeof globalThis.performance === "undefined"
|
||||||
? Date.now()
|
? Date.now()
|
||||||
@@ -420,6 +461,59 @@ export function createIndexedDbMaintenance<WireValue>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* STO-06. Maintenance keeps `mapIndexedDbException`, which the runtime and
|
||||||
|
* the OPFS journal share, rather than the checkpoint store's own table: the
|
||||||
|
* same native error is a different answer in the two families, and unifying
|
||||||
|
* them is a separate change from moving the mechanics onto the kernel.
|
||||||
|
*
|
||||||
|
* One translator covers the whole adapter because the operation label is the
|
||||||
|
* constant `INDEXEDDB_MIGRATE` here — the runtime needs one per operation.
|
||||||
|
*/
|
||||||
|
const translate: IndexedDbTranslate<BrowserDataFailure> = (cause) => {
|
||||||
|
switch (cause.kind) {
|
||||||
|
case "NATIVE_EXCEPTION":
|
||||||
|
return mappedFailure(cause.error);
|
||||||
|
case "BLOCKED":
|
||||||
|
case "BLOCKED_DEADLINE":
|
||||||
|
return failureOf(
|
||||||
|
browserDataFailure("BLOCKED", "INDEXEDDB_MIGRATE", {
|
||||||
|
retryable: true,
|
||||||
|
recovery: "RELOAD_OTHER_CONTEXTS",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
case "CALLER_ABORT":
|
||||||
|
return failureOf(
|
||||||
|
browserDataFailure("ABORTED", "INDEXEDDB_MIGRATE"),
|
||||||
|
);
|
||||||
|
case "CLOSED":
|
||||||
|
case "NO_VALUE_PRODUCED":
|
||||||
|
// A transaction that committed without producing a value is retried,
|
||||||
|
// not reconciled. `CLOSED` cannot reach here — maintenance opens a
|
||||||
|
// connection per batch instead of holding a cached handle — and is
|
||||||
|
// mapped alongside it so the union stays total.
|
||||||
|
return failureOf(unavailable());
|
||||||
|
case "UPGRADE_REJECTED":
|
||||||
|
// Maintenance owns no schema. Any upgrade means the database is not
|
||||||
|
// the one this batch was configured against.
|
||||||
|
return migrationFailure();
|
||||||
|
case "ADMISSION_REJECTED":
|
||||||
|
return cause.detail === "POLICY"
|
||||||
|
? failureOf(
|
||||||
|
browserDataFailure("POLICY_REJECTED", "INDEXEDDB_MIGRATE", {
|
||||||
|
recovery: storagePolicySnapshot.unavailableFallback,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
: migrationFailure();
|
||||||
|
case "UNSUPPORTED":
|
||||||
|
return unsupportedFailure();
|
||||||
|
default: {
|
||||||
|
const exhaustive: never = cause;
|
||||||
|
return exhaustive;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
function validBatchInput(
|
function validBatchInput(
|
||||||
input: IndexedDbMaintenanceBatchInput,
|
input: IndexedDbMaintenanceBatchInput,
|
||||||
): boolean {
|
): boolean {
|
||||||
@@ -436,173 +530,76 @@ export function createIndexedDbMaintenance<WireValue>(
|
|||||||
): Promise<BrowserDataResult<IDBDatabase>> {
|
): Promise<BrowserDataResult<IDBDatabase>> {
|
||||||
const cancelled = abortedResult(signal, "INDEXEDDB_MIGRATE");
|
const cancelled = abortedResult(signal, "INDEXEDDB_MIGRATE");
|
||||||
if (cancelled) return Promise.resolve(cancelled);
|
if (cancelled) return Promise.resolve(cancelled);
|
||||||
if (!factory) {
|
if (!factory) return Promise.resolve(unsupported());
|
||||||
return Promise.resolve(
|
|
||||||
browserDataFailure("UNSUPPORTED", "INDEXEDDB_MIGRATE", {
|
|
||||||
recovery: "ONLINE_ONLY",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise<BrowserDataResult<IDBDatabase>>((resolve) => {
|
return openIndexedDbDatabase<BrowserDataFailure>({
|
||||||
let request: IDBOpenDBRequest;
|
factory,
|
||||||
try {
|
databaseName,
|
||||||
request = factory.open(
|
version: dependencies.schemaVersion,
|
||||||
databaseName,
|
translate,
|
||||||
dependencies.schemaVersion,
|
signal,
|
||||||
);
|
// No `blockedTimeoutMs`, and therefore no `timers`. The blocked event is
|
||||||
} catch (error) {
|
// terminal for maintenance: a batch is an opt-in background pass, so
|
||||||
resolve(mapIndexedDbException(error, "INDEXEDDB_MIGRATE"));
|
// hanging it for a deadline in the hope another context goes away costs
|
||||||
return;
|
// more than reporting BLOCKED and letting the caller retry.
|
||||||
}
|
//
|
||||||
|
// No `upgrade` either. The kernel reads an omitted callback as "any
|
||||||
let settled = false;
|
// upgrade is unexpected" and rejects it, which is what maintenance has
|
||||||
let unexpectedUpgrade = false;
|
// always done: it migrates records under a schema somebody else owns and
|
||||||
const finish = (result: BrowserDataResult<IDBDatabase>) => {
|
// must never create or change one.
|
||||||
if (settled) return;
|
admit: async (database) => {
|
||||||
settled = true;
|
for (const store of [
|
||||||
signal?.removeEventListener("abort", onAbort);
|
dependencies.recordStore,
|
||||||
resolve(result);
|
dependencies.governanceStore,
|
||||||
};
|
dependencies.retentionStore,
|
||||||
function onAbort(): void {
|
dependencies.checkpointStore,
|
||||||
try {
|
dependencies.idempotencyStore,
|
||||||
request.transaction?.abort();
|
]) {
|
||||||
} catch {
|
if (!database.objectStoreNames.contains(store)) {
|
||||||
// A pending non-upgrade open request cannot be cancelled.
|
return { kind: "REJECT", detail: "STORE" };
|
||||||
}
|
}
|
||||||
finish(browserDataFailure("ABORTED", "INDEXEDDB_MIGRATE"));
|
|
||||||
}
|
|
||||||
signal?.addEventListener("abort", onAbort, { once: true });
|
|
||||||
|
|
||||||
request.onupgradeneeded = () => {
|
|
||||||
unexpectedUpgrade = true;
|
|
||||||
try {
|
|
||||||
request.transaction?.abort();
|
|
||||||
} catch {
|
|
||||||
// The error handler below owns the closed failure result.
|
|
||||||
}
|
|
||||||
};
|
|
||||||
request.onblocked = () => {
|
|
||||||
finish(
|
|
||||||
browserDataFailure("BLOCKED", "INDEXEDDB_MIGRATE", {
|
|
||||||
retryable: true,
|
|
||||||
recovery: "RELOAD_OTHER_CONTEXTS",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
request.onerror = () => {
|
|
||||||
finish(
|
|
||||||
unexpectedUpgrade
|
|
||||||
? migrationFailed()
|
|
||||||
: mapIndexedDbException(
|
|
||||||
request.error,
|
|
||||||
"INDEXEDDB_MIGRATE",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
request.onsuccess = () => {
|
|
||||||
const database = request.result;
|
|
||||||
if (settled) {
|
|
||||||
database.close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
!database.objectStoreNames.contains(
|
|
||||||
dependencies.recordStore,
|
|
||||||
) ||
|
|
||||||
!database.objectStoreNames.contains(
|
|
||||||
dependencies.governanceStore,
|
|
||||||
) ||
|
|
||||||
!database.objectStoreNames.contains(
|
|
||||||
dependencies.retentionStore,
|
|
||||||
) ||
|
|
||||||
!database.objectStoreNames.contains(
|
|
||||||
dependencies.checkpointStore,
|
|
||||||
) ||
|
|
||||||
!database.objectStoreNames.contains(
|
|
||||||
dependencies.idempotencyStore,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
database.close();
|
|
||||||
finish(migrationFailed());
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const transaction = database.transaction(
|
openIndexedDbTransaction(
|
||||||
dependencies.idempotencyStore,
|
database,
|
||||||
|
[dependencies.idempotencyStore],
|
||||||
"readonly",
|
"readonly",
|
||||||
);
|
)
|
||||||
transaction
|
|
||||||
.objectStore(dependencies.idempotencyStore)
|
.objectStore(dependencies.idempotencyStore)
|
||||||
.index(dependencies.idempotencyExpiryIndex);
|
.index(dependencies.idempotencyExpiryIndex);
|
||||||
} catch {
|
} catch {
|
||||||
database.close();
|
return { kind: "REJECT", detail: "INDEX" };
|
||||||
finish(migrationFailed());
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
// Maintenance never holds the connection past a batch, so the listener
|
||||||
|
// belongs here rather than on a cached handle: `admit` runs on the
|
||||||
|
// successful open path only, which is exactly the window the batch
|
||||||
|
// owns the connection for.
|
||||||
database.onversionchange = () => database.close();
|
database.onversionchange = () => database.close();
|
||||||
void (async () => {
|
const binding = await verifyIndexedDbDatasetBinding(
|
||||||
const binding = await verifyIndexedDbDatasetBinding(
|
database,
|
||||||
database,
|
dependencies.governanceStore,
|
||||||
dependencies.governanceStore,
|
expectedBinding,
|
||||||
expectedBinding,
|
signal,
|
||||||
signal,
|
);
|
||||||
);
|
if (binding.ok) return { kind: "ADMIT" };
|
||||||
if (!binding.ok) {
|
return binding.reason === "ABORTED"
|
||||||
database.close();
|
? { kind: "FAIL", cause: { kind: "CALLER_ABORT" } }
|
||||||
if (!settled) {
|
: binding.reason === "NATIVE_ERROR"
|
||||||
finish(
|
? {
|
||||||
binding.reason === "ABORTED"
|
kind: "FAIL",
|
||||||
? browserDataFailure(
|
cause: { kind: "NATIVE_EXCEPTION", error: binding.error },
|
||||||
"ABORTED",
|
}
|
||||||
"INDEXEDDB_MIGRATE",
|
: { kind: "REJECT", detail: "POLICY" };
|
||||||
)
|
},
|
||||||
: binding.reason === "NATIVE_ERROR"
|
|
||||||
? mapIndexedDbException(
|
|
||||||
binding.error,
|
|
||||||
"INDEXEDDB_MIGRATE",
|
|
||||||
)
|
|
||||||
: browserDataFailure(
|
|
||||||
"POLICY_REJECTED",
|
|
||||||
"INDEXEDDB_MIGRATE",
|
|
||||||
{
|
|
||||||
recovery:
|
|
||||||
storagePolicySnapshot.unavailableFallback,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (settled) {
|
|
||||||
database.close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
finish(browserDataSuccess(database));
|
|
||||||
})();
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTransaction(
|
/**
|
||||||
database: IDBDatabase,
|
* Durability is named explicitly on both sides because maintenance rewrites
|
||||||
stores: readonly string[],
|
* records: a write that is only queued when the tab goes away would leave a
|
||||||
mode: "readonly" | "readwrite",
|
* checkpoint claiming rows that were never stored. The kernel falls back to
|
||||||
): IDBTransaction {
|
* the no-options form on an engine that rejects the bag.
|
||||||
const durability =
|
*/
|
||||||
mode === "readonly"
|
|
||||||
? dependencies.durability?.read ?? "default"
|
|
||||||
: dependencies.durability?.write ?? "strict";
|
|
||||||
try {
|
|
||||||
return database.transaction([...stores], mode, { durability });
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof TypeError) {
|
|
||||||
return database.transaction([...stores], mode);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function runTransaction<Value>(
|
function runTransaction<Value>(
|
||||||
database: IDBDatabase,
|
database: IDBDatabase,
|
||||||
stores: readonly string[],
|
stores: readonly string[],
|
||||||
@@ -610,97 +607,20 @@ export function createIndexedDbMaintenance<WireValue>(
|
|||||||
signal: AbortSignal | undefined,
|
signal: AbortSignal | undefined,
|
||||||
queue: (
|
queue: (
|
||||||
transaction: IDBTransaction,
|
transaction: IDBTransaction,
|
||||||
context: TransactionContext<Value>,
|
context: IndexedDbTransactionContext<Value, BrowserDataFailure>,
|
||||||
) => void,
|
) => void,
|
||||||
): Promise<BrowserDataResult<Value>> {
|
): Promise<BrowserDataResult<Value>> {
|
||||||
const cancelled = abortedResult(signal, "INDEXEDDB_MIGRATE");
|
return runIndexedDbTransaction<Value, BrowserDataFailure>({
|
||||||
if (cancelled) return Promise.resolve(cancelled);
|
database,
|
||||||
|
stores,
|
||||||
let transaction: IDBTransaction;
|
mode,
|
||||||
try {
|
translate,
|
||||||
transaction = createTransaction(database, stores, mode);
|
signal,
|
||||||
} catch (error) {
|
durability:
|
||||||
return Promise.resolve(
|
mode === "readonly"
|
||||||
mapIndexedDbException(error, "INDEXEDDB_MIGRATE"),
|
? dependencies.durability?.read ?? "default"
|
||||||
);
|
: dependencies.durability?.write ?? "strict",
|
||||||
}
|
queue,
|
||||||
|
|
||||||
return new Promise<BrowserDataResult<Value>>((resolve) => {
|
|
||||||
let candidate: BrowserDataResult<Value> | undefined;
|
|
||||||
let requestError: unknown;
|
|
||||||
let callerAborted = false;
|
|
||||||
let settled = false;
|
|
||||||
|
|
||||||
const finish = (result: BrowserDataResult<Value>) => {
|
|
||||||
if (settled) return;
|
|
||||||
settled = true;
|
|
||||||
signal?.removeEventListener("abort", onAbort);
|
|
||||||
resolve(result);
|
|
||||||
};
|
|
||||||
const abortTransaction = () => {
|
|
||||||
try {
|
|
||||||
transaction.abort();
|
|
||||||
} catch {
|
|
||||||
// Completion or a prior abort already owns the result.
|
|
||||||
}
|
|
||||||
};
|
|
||||||
function onAbort(): void {
|
|
||||||
callerAborted = true;
|
|
||||||
abortTransaction();
|
|
||||||
}
|
|
||||||
|
|
||||||
transaction.oncomplete = () => {
|
|
||||||
finish(candidate ?? unavailable());
|
|
||||||
};
|
|
||||||
transaction.onerror = () => {
|
|
||||||
requestError ??= transaction.error;
|
|
||||||
};
|
|
||||||
transaction.onabort = () => {
|
|
||||||
if (callerAborted) {
|
|
||||||
finish(
|
|
||||||
browserDataFailure("ABORTED", "INDEXEDDB_MIGRATE"),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (candidate && !candidate.ok) {
|
|
||||||
finish(candidate);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
finish(
|
|
||||||
mapIndexedDbException(
|
|
||||||
requestError ?? transaction.error,
|
|
||||||
"INDEXEDDB_MIGRATE",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
signal?.addEventListener("abort", onAbort, { once: true });
|
|
||||||
|
|
||||||
const context: TransactionContext<Value> = Object.freeze({
|
|
||||||
succeed(value) {
|
|
||||||
if (!candidate) candidate = browserDataSuccess(value);
|
|
||||||
},
|
|
||||||
fail(result) {
|
|
||||||
if (!candidate) candidate = result;
|
|
||||||
abortTransaction();
|
|
||||||
},
|
|
||||||
requestFailed(error) {
|
|
||||||
requestError ??= error;
|
|
||||||
if (!candidate) {
|
|
||||||
candidate = mapIndexedDbException(
|
|
||||||
error,
|
|
||||||
"INDEXEDDB_MIGRATE",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
queue(transaction, context);
|
|
||||||
} catch (error) {
|
|
||||||
context.fail(
|
|
||||||
mapIndexedDbException(error, "INDEXEDDB_MIGRATE"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -732,7 +652,7 @@ export function createIndexedDbMaintenance<WireValue>(
|
|||||||
dependencies.checkpointKey,
|
dependencies.checkpointKey,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
context.fail(migrationFailed());
|
context.fail(migrationFailure());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const persisted = request.result;
|
const persisted = request.result;
|
||||||
@@ -777,85 +697,69 @@ export function createIndexedDbMaintenance<WireValue>(
|
|||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
context.fail(invalidInput());
|
context.fail(invalidInputFailure());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (checkpoint.effective.lastKey !== null && !query) {
|
if (checkpoint.effective.lastKey !== null && !query) {
|
||||||
context.fail(
|
context.fail(unsupportedFailure());
|
||||||
browserDataFailure(
|
|
||||||
"UNSUPPORTED",
|
|
||||||
"INDEXEDDB_MIGRATE",
|
|
||||||
{ recovery: "ONLINE_ONLY" },
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows: ScannedRecord[] = [];
|
const rows: ScannedRecord[] = [];
|
||||||
const request = transaction
|
// The scan bounds on rows it read, so the counter the kernel offers is
|
||||||
.objectStore(dependencies.recordStore)
|
// the right one here. The time check comes first so a batch that ran
|
||||||
.openCursor(query);
|
// out of both answers `budgetExhausted` the way it does today.
|
||||||
request.onerror = () => context.requestFailed(request.error);
|
const budget: IndexedDbBudget<BrowserDataFailure> = {
|
||||||
request.onsuccess = () => {
|
admit: (scannedRows) => {
|
||||||
if (input.signal?.aborted) {
|
const currentTime = clock();
|
||||||
try {
|
if (!currentTime.ok) return currentTime;
|
||||||
transaction.abort();
|
if (currentTime.value >= deadline) {
|
||||||
} catch {
|
return browserDataSuccess("TIME_BUDGET" as const);
|
||||||
// The transaction event decides the abort/complete race.
|
|
||||||
}
|
}
|
||||||
return;
|
return browserDataSuccess(
|
||||||
}
|
scannedRows >= input.maxRows
|
||||||
const cursor = request.result;
|
? ("ROW_BUDGET" as const)
|
||||||
if (!cursor) {
|
: ("CONTINUE" as const),
|
||||||
context.succeed({
|
|
||||||
rows: Object.freeze(rows),
|
|
||||||
reachedEnd: true,
|
|
||||||
budgetExhausted: false,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const currentTime = clock();
|
|
||||||
if (!currentTime.ok) {
|
|
||||||
context.fail(currentTime);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
rows.length >= input.maxRows ||
|
|
||||||
currentTime.value >= deadline
|
|
||||||
) {
|
|
||||||
context.succeed({
|
|
||||||
rows: Object.freeze(rows),
|
|
||||||
reachedEnd: false,
|
|
||||||
budgetExhausted:
|
|
||||||
currentTime.value >= deadline,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!isStoredRecord(cursor.value)) {
|
|
||||||
context.fail(migrationFailed());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
cursor.value.codecVersion >
|
|
||||||
dependencies.migrationPolicy.targetCodecVersion
|
|
||||||
) {
|
|
||||||
context.fail(migrationFailed());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
rows.push({
|
|
||||||
key: cursor.value.key,
|
|
||||||
codecVersion: cursor.value.codecVersion,
|
|
||||||
revision: cursor.value.revision,
|
|
||||||
payload: cursor.value.payload,
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
cursor.continue();
|
|
||||||
} catch (error) {
|
|
||||||
context.fail(
|
|
||||||
mapIndexedDbException(error, "INDEXEDDB_MIGRATE"),
|
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
walkIndexedDbCursor<BrowserDataFailure>({
|
||||||
|
request: transaction
|
||||||
|
.objectStore(dependencies.recordStore)
|
||||||
|
.openCursor(query),
|
||||||
|
sink: context,
|
||||||
|
translate,
|
||||||
|
budget,
|
||||||
|
signal: input.signal,
|
||||||
|
visit: ({ cursor }) => {
|
||||||
|
if (
|
||||||
|
!isStoredRecord(cursor.value) ||
|
||||||
|
cursor.value.codecVersion >
|
||||||
|
dependencies.migrationPolicy.targetCodecVersion
|
||||||
|
) {
|
||||||
|
context.fail(migrationFailure());
|
||||||
|
// `fail` aborts, so the walk has nowhere to go. Suspending
|
||||||
|
// without ever resuming says that without asking the pump for
|
||||||
|
// another row or claiming a summary the scan never reached.
|
||||||
|
return { kind: "SUSPEND" };
|
||||||
|
}
|
||||||
|
rows.push({
|
||||||
|
key: cursor.value.key,
|
||||||
|
codecVersion: cursor.value.codecVersion,
|
||||||
|
revision: cursor.value.revision,
|
||||||
|
payload: cursor.value.payload,
|
||||||
|
});
|
||||||
|
return { kind: "CONTINUE" };
|
||||||
|
},
|
||||||
|
done: (summary) => {
|
||||||
|
if (summary.reason === "ABORTED") return;
|
||||||
|
context.succeed({
|
||||||
|
rows: Object.freeze(rows),
|
||||||
|
reachedEnd: summary.reason === "EXHAUSTED",
|
||||||
|
budgetExhausted: summary.reason === "TIME_BUDGET",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1023,9 +927,7 @@ export function createIndexedDbMaintenance<WireValue>(
|
|||||||
try {
|
try {
|
||||||
request = checkpoints.put(stored);
|
request = checkpoints.put(stored);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
context.fail(
|
context.fail(mappedFailure(error));
|
||||||
mapIndexedDbException(error, "INDEXEDDB_MIGRATE"),
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
request.onerror = () =>
|
request.onerror = () =>
|
||||||
@@ -1057,7 +959,7 @@ export function createIndexedDbMaintenance<WireValue>(
|
|||||||
const currentTime = clock();
|
const currentTime = clock();
|
||||||
if (!currentTime.ok) {
|
if (!currentTime.ok) {
|
||||||
// A broken clock aborts rather than committing an unbounded batch.
|
// A broken clock aborts rather than committing an unbounded batch.
|
||||||
context.fail(currentTime);
|
context.fail(currentTime.error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (currentTime.value >= deadline) {
|
if (currentTime.value >= deadline) {
|
||||||
@@ -1069,9 +971,7 @@ export function createIndexedDbMaintenance<WireValue>(
|
|||||||
try {
|
try {
|
||||||
request = records.get(preparedRecord.source.key);
|
request = records.get(preparedRecord.source.key);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
context.fail(
|
context.fail(mappedFailure(error));
|
||||||
mapIndexedDbException(error, "INDEXEDDB_MIGRATE"),
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
request.onerror = () =>
|
request.onerror = () =>
|
||||||
@@ -1091,7 +991,7 @@ export function createIndexedDbMaintenance<WireValue>(
|
|||||||
preparedRecord.source.key,
|
preparedRecord.source.key,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
context.fail(migrationFailed());
|
context.fail(migrationFailure());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@@ -1123,7 +1023,7 @@ export function createIndexedDbMaintenance<WireValue>(
|
|||||||
!preparedRecord.needsMigration ||
|
!preparedRecord.needsMigration ||
|
||||||
preparedRecord.measuredBytes === undefined
|
preparedRecord.measuredBytes === undefined
|
||||||
) {
|
) {
|
||||||
context.fail(migrationFailed());
|
context.fail(migrationFailure());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const measuredBytes = preparedRecord.measuredBytes;
|
const measuredBytes = preparedRecord.measuredBytes;
|
||||||
@@ -1137,7 +1037,7 @@ export function createIndexedDbMaintenance<WireValue>(
|
|||||||
live.key,
|
live.key,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
context.fail(migrationFailed());
|
context.fail(migrationFailure());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const previousSidecar = sidecarRequest.result;
|
const previousSidecar = sidecarRequest.result;
|
||||||
@@ -1152,7 +1052,7 @@ export function createIndexedDbMaintenance<WireValue>(
|
|||||||
budgetRequest.result.usedBytes <
|
budgetRequest.result.usedBytes <
|
||||||
previousSidecar.measuredBytes
|
previousSidecar.measuredBytes
|
||||||
) {
|
) {
|
||||||
context.fail(migrationFailed());
|
context.fail(migrationFailure());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const usedBytes =
|
const usedBytes =
|
||||||
@@ -1165,7 +1065,7 @@ export function createIndexedDbMaintenance<WireValue>(
|
|||||||
usedBytes >
|
usedBytes >
|
||||||
storagePolicySnapshot.hardBudgetBytes
|
storagePolicySnapshot.hardBudgetBytes
|
||||||
) {
|
) {
|
||||||
context.fail(migrationFailed());
|
context.fail(migrationFailure());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const writeRequest = records.put({
|
const writeRequest = records.put({
|
||||||
@@ -1221,7 +1121,7 @@ export function createIndexedDbMaintenance<WireValue>(
|
|||||||
dependencies.checkpointKey,
|
dependencies.checkpointKey,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
context.fail(migrationFailed());
|
context.fail(migrationFailure());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
liveCheckpoint = checkpointRequest.result;
|
liveCheckpoint = checkpointRequest.result;
|
||||||
@@ -1412,134 +1312,106 @@ export function createIndexedDbMaintenance<WireValue>(
|
|||||||
let range: IDBKeyRange;
|
let range: IDBKeyRange;
|
||||||
try {
|
try {
|
||||||
if (!keyRange) {
|
if (!keyRange) {
|
||||||
context.fail(
|
context.fail(unsupportedFailure());
|
||||||
browserDataFailure(
|
|
||||||
"UNSUPPORTED",
|
|
||||||
"INDEXEDDB_MIGRATE",
|
|
||||||
{ recovery: "ONLINE_ONLY" },
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
range = keyRange.upperBound(cutoff.value);
|
range = keyRange.upperBound(cutoff.value);
|
||||||
} catch {
|
} catch {
|
||||||
context.fail(invalidInput());
|
context.fail(invalidInputFailure());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const request = store
|
|
||||||
.index(dependencies.idempotencyExpiryIndex)
|
|
||||||
.openCursor(range);
|
|
||||||
let scannedRows = 0;
|
|
||||||
let deletedRows = 0;
|
let deletedRows = 0;
|
||||||
|
// The prune bounds on rows it deleted, not on rows it read, so the
|
||||||
const succeed = (
|
// counter stays here while the kernel counts scanned rows for the
|
||||||
state: "MORE" | "COMPLETE",
|
// receipt. A row past the cutoff is corrupt data, not a stop
|
||||||
budgetExhausted: boolean,
|
// condition, so the walk never reports STOPPED.
|
||||||
) => {
|
const budget: IndexedDbBudget<BrowserDataFailure> = {
|
||||||
context.succeed(
|
admit: () => {
|
||||||
Object.freeze({
|
const currentTime = clock();
|
||||||
state,
|
if (!currentTime.ok) return currentTime;
|
||||||
scannedRows,
|
if (currentTime.value >= deadline) {
|
||||||
deletedRows,
|
return browserDataSuccess("TIME_BUDGET" as const);
|
||||||
budgetExhausted,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
request.onerror = () =>
|
|
||||||
context.requestFailed(request.error);
|
|
||||||
request.onsuccess = () => {
|
|
||||||
if (input.signal?.aborted) {
|
|
||||||
try {
|
|
||||||
transaction.abort();
|
|
||||||
} catch {
|
|
||||||
// The transaction event owns the completion race.
|
|
||||||
}
|
}
|
||||||
return;
|
return browserDataSuccess(
|
||||||
}
|
deletedRows >= input.maxRows
|
||||||
const cursor = request.result;
|
? ("ROW_BUDGET" as const)
|
||||||
if (!cursor) {
|
: ("CONTINUE" as const),
|
||||||
succeed("COMPLETE", false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const currentTime = clock();
|
|
||||||
if (!currentTime.ok) {
|
|
||||||
context.fail(currentTime);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
deletedRows >= input.maxRows ||
|
|
||||||
currentTime.value >= deadline
|
|
||||||
) {
|
|
||||||
succeed(
|
|
||||||
"MORE",
|
|
||||||
currentTime.value >= deadline,
|
|
||||||
);
|
);
|
||||||
return;
|
},
|
||||||
}
|
};
|
||||||
if (
|
walkIndexedDbCursor<BrowserDataFailure>({
|
||||||
!isStoredReceipt(cursor.value) ||
|
request: store
|
||||||
cursor.value.idempotencyKey !==
|
.index(dependencies.idempotencyExpiryIndex)
|
||||||
String(cursor.primaryKey) ||
|
.openCursor(range),
|
||||||
cursor.value.expiresAtEpochMs > cutoff.value
|
sink: context,
|
||||||
) {
|
translate,
|
||||||
context.fail(migrationFailed());
|
budget,
|
||||||
return;
|
signal: input.signal,
|
||||||
}
|
visit: ({ cursor, resume }) => {
|
||||||
scannedRows += 1;
|
if (
|
||||||
let deleteRequest: IDBRequest<undefined>;
|
!isStoredReceipt(cursor.value) ||
|
||||||
try {
|
cursor.value.idempotencyKey !==
|
||||||
deleteRequest = store.delete(
|
String(cursor.primaryKey) ||
|
||||||
cursor.primaryKey,
|
cursor.value.expiresAtEpochMs > cutoff.value
|
||||||
);
|
) {
|
||||||
} catch (error) {
|
context.fail(migrationFailure());
|
||||||
context.fail(
|
return { kind: "SUSPEND" };
|
||||||
mapIndexedDbException(
|
}
|
||||||
error,
|
let deleteRequest: IDBRequest<undefined>;
|
||||||
"INDEXEDDB_MIGRATE",
|
try {
|
||||||
),
|
deleteRequest = store.delete(cursor.primaryKey);
|
||||||
);
|
} catch (error) {
|
||||||
return;
|
context.fail(mappedFailure(error));
|
||||||
}
|
return { kind: "SUSPEND" };
|
||||||
deleteRequest.onerror = () =>
|
}
|
||||||
context.requestFailed(deleteRequest.error);
|
deleteRequest.onerror = () =>
|
||||||
deleteRequest.onsuccess = () => {
|
context.requestFailed(deleteRequest.error);
|
||||||
const budgetRequest = governance.get(
|
deleteRequest.onsuccess = () => {
|
||||||
INDEXEDDB_DATASET_BUDGET_KEY,
|
const budgetRequest = governance.get(
|
||||||
);
|
INDEXEDDB_DATASET_BUDGET_KEY,
|
||||||
budgetRequest.onerror = () =>
|
);
|
||||||
context.requestFailed(budgetRequest.error);
|
budgetRequest.onerror = () =>
|
||||||
budgetRequest.onsuccess = () => {
|
context.requestFailed(budgetRequest.error);
|
||||||
if (
|
budgetRequest.onsuccess = () => {
|
||||||
!isStoredDatasetBudget(budgetRequest.result) ||
|
if (
|
||||||
budgetRequest.result.receiptCount < 1
|
!isStoredDatasetBudget(budgetRequest.result) ||
|
||||||
) {
|
budgetRequest.result.receiptCount < 1
|
||||||
context.fail(migrationFailed());
|
) {
|
||||||
return;
|
context.fail(migrationFailure());
|
||||||
}
|
return;
|
||||||
const budgetWrite = governance.put({
|
|
||||||
...budgetRequest.result,
|
|
||||||
receiptCount:
|
|
||||||
budgetRequest.result.receiptCount - 1,
|
|
||||||
} satisfies StoredDatasetBudget);
|
|
||||||
budgetWrite.onerror = () =>
|
|
||||||
context.requestFailed(budgetWrite.error);
|
|
||||||
budgetWrite.onsuccess = () => {
|
|
||||||
deletedRows += 1;
|
|
||||||
try {
|
|
||||||
cursor.continue();
|
|
||||||
} catch (error) {
|
|
||||||
context.fail(
|
|
||||||
mapIndexedDbException(
|
|
||||||
error,
|
|
||||||
"INDEXEDDB_MIGRATE",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
const budgetWrite = governance.put({
|
||||||
|
...budgetRequest.result,
|
||||||
|
receiptCount:
|
||||||
|
budgetRequest.result.receiptCount - 1,
|
||||||
|
} satisfies StoredDatasetBudget);
|
||||||
|
budgetWrite.onerror = () =>
|
||||||
|
context.requestFailed(budgetWrite.error);
|
||||||
|
budgetWrite.onsuccess = () => {
|
||||||
|
deletedRows += 1;
|
||||||
|
resume({ kind: "CONTINUE" });
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
// The receipt is only counted once its whole delete chain
|
||||||
};
|
// commits, so the cursor stays parked until `resume`.
|
||||||
|
return { kind: "SUSPEND" };
|
||||||
|
},
|
||||||
|
done: (summary) => {
|
||||||
|
if (summary.reason === "ABORTED") return;
|
||||||
|
context.succeed(
|
||||||
|
Object.freeze({
|
||||||
|
state:
|
||||||
|
summary.reason === "EXHAUSTED"
|
||||||
|
? ("COMPLETE" as const)
|
||||||
|
: ("MORE" as const),
|
||||||
|
scannedRows: summary.scannedRows,
|
||||||
|
deletedRows,
|
||||||
|
budgetExhausted: summary.reason === "TIME_BUDGET",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return observeResult(
|
return observeResult(
|
||||||
|
|||||||
@@ -205,6 +205,7 @@ function createMaintenance(
|
|||||||
now?: () => number;
|
now?: () => number;
|
||||||
nowEpochMilliseconds?: () => number;
|
nowEpochMilliseconds?: () => number;
|
||||||
observe?: (event: IndexedDbObservation) => void;
|
observe?: (event: IndexedDbObservation) => void;
|
||||||
|
factory?: IDBFactory;
|
||||||
}> = {},
|
}> = {},
|
||||||
) {
|
) {
|
||||||
return createIndexedDbMaintenance<CurrentPayload>({
|
return createIndexedDbMaintenance<CurrentPayload>({
|
||||||
@@ -225,6 +226,63 @@ function createMaintenance(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records every `transaction.abort()` the adapter itself calls, with the mode
|
||||||
|
* of the transaction it aborted.
|
||||||
|
*
|
||||||
|
* A failure code alone cannot tell `fail` (record and abort) from
|
||||||
|
* `requestFailed` (record only): the fake aborts a transaction on a request
|
||||||
|
* error by itself, so both end at the same code while only one of them stops
|
||||||
|
* the writes queued behind it. Swapping the two is exactly the mistake this
|
||||||
|
* counter makes visible.
|
||||||
|
*/
|
||||||
|
function trackAborts(memory: MemoryIndexedDbFactory): Readonly<{
|
||||||
|
factory: IDBFactory;
|
||||||
|
aborts: readonly string[];
|
||||||
|
}> {
|
||||||
|
const aborts: string[] = [];
|
||||||
|
const wrapped = new WeakSet<object>();
|
||||||
|
const wrapDatabase = (database: IDBDatabase): IDBDatabase => {
|
||||||
|
if (wrapped.has(database)) return database;
|
||||||
|
wrapped.add(database);
|
||||||
|
const openTransaction = database.transaction.bind(database);
|
||||||
|
Object.defineProperty(database, "transaction", {
|
||||||
|
configurable: true,
|
||||||
|
value: (...args: Parameters<IDBDatabase["transaction"]>) => {
|
||||||
|
const transaction = openTransaction(...args);
|
||||||
|
const abort = transaction.abort.bind(transaction);
|
||||||
|
Object.defineProperty(transaction, "abort", {
|
||||||
|
configurable: true,
|
||||||
|
value: () => {
|
||||||
|
aborts.push(transaction.mode);
|
||||||
|
abort();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return transaction;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return database;
|
||||||
|
};
|
||||||
|
const factory = {
|
||||||
|
cmp: (first: IDBValidKey, second: IDBValidKey) =>
|
||||||
|
memory.factory.cmp(first, second),
|
||||||
|
open: (name: string, version?: number) => {
|
||||||
|
const request = memory.factory.open(name, version);
|
||||||
|
let stored: IDBDatabase | undefined = request.result;
|
||||||
|
if (stored) wrapDatabase(stored);
|
||||||
|
Object.defineProperty(request, "result", {
|
||||||
|
configurable: true,
|
||||||
|
get: () => stored,
|
||||||
|
set: (value: IDBDatabase | undefined) => {
|
||||||
|
stored = value ? wrapDatabase(value) : value;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return request;
|
||||||
|
},
|
||||||
|
} as unknown as IDBFactory;
|
||||||
|
return Object.freeze({ factory, aborts });
|
||||||
|
}
|
||||||
|
|
||||||
function seedReceipt(
|
function seedReceipt(
|
||||||
memory: MemoryIndexedDbFactory,
|
memory: MemoryIndexedDbFactory,
|
||||||
idempotencyKey: string,
|
idempotencyKey: string,
|
||||||
@@ -299,6 +357,101 @@ describe("IndexedDB bounded codec maintenance", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reports a blocked open immediately instead of waiting out a deadline", async () => {
|
||||||
|
const memory = new MemoryIndexedDbFactory();
|
||||||
|
await prepareSchema(memory);
|
||||||
|
seedLegacy(memory, "blocked", "waiting");
|
||||||
|
const maintenance = createMaintenance(memory, defaultPolicy());
|
||||||
|
memory.blockNextOpen();
|
||||||
|
|
||||||
|
// MT-1. The blocked open is never released here. A blocked deadline could
|
||||||
|
// only settle from a timer, and no amount of microtask draining reaches a
|
||||||
|
// timer, so a batch that has already landed proves the blocked event
|
||||||
|
// itself is terminal. Give maintenance a blocked timeout and this test
|
||||||
|
// stops finishing instead of failing on a code.
|
||||||
|
let landed = false;
|
||||||
|
const pending = maintenance
|
||||||
|
.migrateCodecBatch({ maxRows: 10, maxDurationMs: 10_000 })
|
||||||
|
.then((result) => {
|
||||||
|
landed = true;
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
for (let tick = 0; tick < 50; tick += 1) await Promise.resolve();
|
||||||
|
expect(landed).toBe(true);
|
||||||
|
|
||||||
|
expect(await pending).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: "BLOCKED",
|
||||||
|
operation: "INDEXEDDB_MIGRATE",
|
||||||
|
retryable: true,
|
||||||
|
recovery: "RELOAD_OTHER_CONTEXTS",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// The connection the caller already gave up on is closed, not leaked.
|
||||||
|
memory.releaseBlockedOpen();
|
||||||
|
expect(memory.isConnectionClosed()).toBe(true);
|
||||||
|
expect(memory.readRaw("records", "blocked")).toMatchObject({
|
||||||
|
codecVersion: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unexpected schema upgrade without creating the schema", async () => {
|
||||||
|
const memory = new MemoryIndexedDbFactory();
|
||||||
|
// No prepareSchema: the database does not exist yet, so opening at the
|
||||||
|
// exact schema version is an upgrade.
|
||||||
|
const maintenance = createMaintenance(memory, defaultPolicy());
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await maintenance.migrateCodecBatch({
|
||||||
|
maxRows: 10,
|
||||||
|
maxDurationMs: 10_000,
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: "MIGRATION_FAILED",
|
||||||
|
operation: "INDEXEDDB_MIGRATE",
|
||||||
|
retryable: false,
|
||||||
|
recovery: "READ_ONLY",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// MT-2. Maintenance owns no schema, so a rejected upgrade must leave the
|
||||||
|
// database untouched rather than open it at the right version with the
|
||||||
|
// wrong contents. Applying the upgrade instead would fail with the same
|
||||||
|
// code here and only surface later, as a database nobody can use.
|
||||||
|
expect(memory.hasStore("records")).toBe(false);
|
||||||
|
expect(memory.hasStore("governance")).toBe(false);
|
||||||
|
await prepareSchema(memory);
|
||||||
|
expect(memory.hasStore("records")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes the maintenance connection when another context needs a version change", async () => {
|
||||||
|
const memory = new MemoryIndexedDbFactory();
|
||||||
|
await prepareSchema(memory);
|
||||||
|
seedReceipt(memory, "versionchange-receipt", 100);
|
||||||
|
const maintenance = createMaintenance(memory, defaultPolicy(), {
|
||||||
|
nowEpochMilliseconds: () => 200,
|
||||||
|
});
|
||||||
|
memory.clearLastTransaction();
|
||||||
|
memory.pauseTransactions();
|
||||||
|
|
||||||
|
const pending = maintenance.pruneExpiredReceipts({
|
||||||
|
maxRows: 10,
|
||||||
|
maxDurationMs: 10_000,
|
||||||
|
});
|
||||||
|
await waitForWriteTransaction(memory);
|
||||||
|
expect(memory.isConnectionClosed()).toBe(false);
|
||||||
|
|
||||||
|
// MT-3. The listener is registered on the successful open path only, so it
|
||||||
|
// covers exactly the window in which a batch holds the connection.
|
||||||
|
memory.triggerVersionChange(2);
|
||||||
|
expect(memory.isConnectionClosed()).toBe(true);
|
||||||
|
|
||||||
|
memory.resumeTransactions();
|
||||||
|
await pending;
|
||||||
|
});
|
||||||
|
|
||||||
it("resumes from a durable checkpoint and completes in bounded row batches", async () => {
|
it("resumes from a durable checkpoint and completes in bounded row batches", async () => {
|
||||||
const memory = new MemoryIndexedDbFactory();
|
const memory = new MemoryIndexedDbFactory();
|
||||||
await prepareSchema(memory);
|
await prepareSchema(memory);
|
||||||
@@ -667,7 +820,10 @@ describe("IndexedDB bounded codec maintenance", () => {
|
|||||||
...defaultPolicy(),
|
...defaultPolicy(),
|
||||||
measureStoredBytes: () => 2_000_000,
|
measureStoredBytes: () => 2_000_000,
|
||||||
};
|
};
|
||||||
const maintenance = createMaintenance(memory, policy);
|
const tracker = trackAborts(memory);
|
||||||
|
const maintenance = createMaintenance(memory, policy, {
|
||||||
|
factory: tracker.factory,
|
||||||
|
});
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
await maintenance.migrateCodecBatch({
|
await maintenance.migrateCodecBatch({
|
||||||
@@ -681,6 +837,10 @@ describe("IndexedDB bounded codec maintenance", () => {
|
|||||||
recovery: "READ_ONLY",
|
recovery: "READ_ONLY",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
// The budget verdict aborts the commit transaction rather than only
|
||||||
|
// recording a failure, so the writes already queued behind it cannot
|
||||||
|
// commit. Recording without aborting would end at the same failure code.
|
||||||
|
expect(tracker.aborts).toEqual(["readwrite"]);
|
||||||
expect(memory.readRaw("records", "oversized")).toMatchObject({
|
expect(memory.readRaw("records", "oversized")).toMatchObject({
|
||||||
codecVersion: 1,
|
codecVersion: 1,
|
||||||
});
|
});
|
||||||
@@ -749,10 +909,11 @@ describe("IndexedDB bounded codec maintenance", () => {
|
|||||||
const memory = new MemoryIndexedDbFactory();
|
const memory = new MemoryIndexedDbFactory();
|
||||||
await prepareSchema(memory);
|
await prepareSchema(memory);
|
||||||
seedReceipt(memory, "quota-receipt", 100);
|
seedReceipt(memory, "quota-receipt", 100);
|
||||||
|
const tracker = trackAborts(memory);
|
||||||
const maintenance = createMaintenance(
|
const maintenance = createMaintenance(
|
||||||
memory,
|
memory,
|
||||||
defaultPolicy(),
|
defaultPolicy(),
|
||||||
{ nowEpochMilliseconds: () => 200 },
|
{ nowEpochMilliseconds: () => 200, factory: tracker.factory },
|
||||||
);
|
);
|
||||||
memory.failNextWriteCommit(
|
memory.failNextWriteCommit(
|
||||||
new DOMException("private receipt", "QuotaExceededError"),
|
new DOMException("private receipt", "QuotaExceededError"),
|
||||||
@@ -767,6 +928,9 @@ describe("IndexedDB bounded codec maintenance", () => {
|
|||||||
ok: false,
|
ok: false,
|
||||||
error: { code: "QUOTA_EXCEEDED" },
|
error: { code: "QUOTA_EXCEEDED" },
|
||||||
});
|
});
|
||||||
|
// The commit is what failed, so the adapter never aborts anything itself;
|
||||||
|
// the quota code has to come from the transaction's own error.
|
||||||
|
expect(tracker.aborts).toEqual([]);
|
||||||
expect(
|
expect(
|
||||||
memory.readRaw("receipts", "quota-receipt"),
|
memory.readRaw("receipts", "quota-receipt"),
|
||||||
).toBeDefined();
|
).toBeDefined();
|
||||||
@@ -776,10 +940,11 @@ describe("IndexedDB bounded codec maintenance", () => {
|
|||||||
const memory = new MemoryIndexedDbFactory();
|
const memory = new MemoryIndexedDbFactory();
|
||||||
await prepareSchema(memory);
|
await prepareSchema(memory);
|
||||||
seedReceipt(memory, "abort-receipt", 100);
|
seedReceipt(memory, "abort-receipt", 100);
|
||||||
|
const tracker = trackAborts(memory);
|
||||||
const maintenance = createMaintenance(
|
const maintenance = createMaintenance(
|
||||||
memory,
|
memory,
|
||||||
defaultPolicy(),
|
defaultPolicy(),
|
||||||
{ nowEpochMilliseconds: () => 200 },
|
{ nowEpochMilliseconds: () => 200, factory: tracker.factory },
|
||||||
);
|
);
|
||||||
memory.clearLastTransaction();
|
memory.clearLastTransaction();
|
||||||
memory.pauseTransactions();
|
memory.pauseTransactions();
|
||||||
@@ -796,6 +961,9 @@ describe("IndexedDB bounded codec maintenance", () => {
|
|||||||
ok: false,
|
ok: false,
|
||||||
error: { code: "ABORTED" },
|
error: { code: "ABORTED" },
|
||||||
});
|
});
|
||||||
|
// Exactly one abort: the caller's signal. A second one would mean the
|
||||||
|
// cursor pump and the transaction both claim the abort.
|
||||||
|
expect(tracker.aborts).toEqual(["readwrite"]);
|
||||||
memory.resumeTransactions();
|
memory.resumeTransactions();
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
Reference in New Issue
Block a user