fix(indexeddb): abort invalid unique index builds in request order

Validate stored keys when creating a unique index and queue its failure as
an owned transaction operation. Preserve earlier request successes, abort
pending requests, and roll back upgrade metadata and data without throwing
from createIndex. Later mutations cannot erase the captured failure.

Deduplicate multiEntry keys within each record in shared key extraction so
index creation, writes, queries and cursors agree on uniqueness.

Cover Window, iframe and Worker execution, persisted upgrades, explicit
commit/abort, rollback, request event ordering and multiEntry duplicates.
The 102-case official WPT sample improves from 88 to 92 passes without
regressions; createIndex also gains two passing subtests per environment.
This commit is contained in:
ldm0
2026-09-23 00:14:08 +08:00
parent fa22bfea7c
commit 9089ced8ff
11 changed files with 386 additions and 13 deletions
@@ -33,6 +33,8 @@ IndexedDB/idbdatabase_transaction.any.js?moli-wpt-any=dedicatedworker
IndexedDB/idbdatabase_transaction.any.js?moli-wpt-any=window
IndexedDB/idbfactory-open-error-properties.any.js?moli-wpt-any=dedicatedworker
IndexedDB/idbfactory-open-error-properties.any.js?moli-wpt-any=window
IndexedDB/idbfactory-open-request-error.any.js?moli-wpt-any=dedicatedworker
IndexedDB/idbfactory-open-request-error.any.js?moli-wpt-any=window
IndexedDB/idbfactory-open-request-success.any.js?moli-wpt-any=dedicatedworker
IndexedDB/idbfactory-open-request-success.any.js?moli-wpt-any=window
IndexedDB/idbfactory-origin-isolation.html
@@ -46,6 +48,8 @@ IndexedDB/idbrequest_error.any.js?moli-wpt-any=dedicatedworker
IndexedDB/idbrequest_error.any.js?moli-wpt-any=window
IndexedDB/idbrequest_result.any.js?moli-wpt-any=dedicatedworker
IndexedDB/idbrequest_result.any.js?moli-wpt-any=window
IndexedDB/idbtransaction_abort.any.js?moli-wpt-any=dedicatedworker
IndexedDB/idbtransaction_abort.any.js?moli-wpt-any=window
IndexedDB/idbversionchangeevent.any.js?moli-wpt-any=dedicatedworker
IndexedDB/idbversionchangeevent.any.js?moli-wpt-any=window
IndexedDB/open-request-queue.any.js?moli-wpt-any=dedicatedworker
@@ -0,0 +1,238 @@
globalThis.indexBuildProbe = async function (name = "idb-index-build") {
const checks = globalThis.indexBuildChecks = [];
const check = (label, pass, actual) => checks.push({ label, pass, actual });
const same = (label, actual, expected) => check(label,
JSON.stringify(actual) === JSON.stringify(expected), actual);
const connections = [];
const open = (suffix, version, upgrade) => new Promise((resolve, reject) => {
const request = indexedDB.open(`${name}-${suffix}`, version);
request.onupgradeneeded = () => {
connections.push(request.result);
try { upgrade(request.result, request.transaction); }
catch (error) { request.transaction.abort(); reject(error); }
};
request.onerror = () => reject(request.error);
request.onsuccess = () => {
connections.push(request.result);
resolve(request.result);
};
});
const result = request => new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
const finished = tx => new Promise(resolve => {
tx.oncomplete = () => resolve("complete");
tx.onabort = () => resolve("abort");
});
const throws = (label, expected, callback) => {
try { callback(); check(label, false, "accepted"); }
catch (error) { same(label, error.name, expected); }
};
const failedBuild = async (label, { persisted = false, keyPath = "group",
multiEntry = false, records = [{ group: "same", rank: 1 }, { group: "same", rank: 1 }],
after = () => {}, explicitAbort = false } = {}) => {
if (persisted) {
const db = await open(label, 1, db => {
const store = db.createObjectStore("s");
store.createIndex("original", "rank");
records.forEach((value, i) => store.put(value, i + 1));
});
db.close();
}
const log = [];
const requests = [];
let tx, store, index, db;
const outcome = await new Promise((resolve, reject) => {
const request = indexedDB.open(`${name}-${label}`, persisted ? 2 : 1);
request.onupgradeneeded = () => {
try {
db = request.result;
connections.push(db);
tx = request.transaction;
tx.oncomplete = () => log.push("tx:complete");
tx.onabort = () => log.push(`tx:abort:${tx.error?.name || "null"}`);
db.onabort = () => log.push(`db:abort:${tx.error?.name || "null"}`);
tx.onerror = event => log.push(`tx:error:${event.target.error.name}`);
db.onerror = event => log.push(`db:error:${event.target.error.name}`);
store = persisted ? tx.objectStore("s") : db.createObjectStore("s");
const watch = (request, prefix, precedesIndex = false) => {
requests.push({ request, prefix, precedesIndex });
request.onsuccess = () => log.push(`${prefix}:success`);
request.onerror = event => {
log.push(`${prefix}:error:${request.error.name}`);
// Canceling pending request errors cannot rescue an aborted index build.
event.preventDefault();
};
return request;
};
if (!persisted) records.forEach((value, i) => watch(store.put(value, i + 1), `put${i}`, true));
index = store.createIndex("unique", keyPath, { unique: true, multiEntry });
check(`${label}:returns-index`, index instanceof IDBIndex && index.objectStore === store &&
index.unique && index.multiEntry === multiEntry, index.name);
check(`${label}:synchronous-metadata`, store.indexNames.contains("unique") && tx.error === null,
Array.from(store.indexNames));
after({ db, tx, store, index, watch });
log.push("upgrade:return");
} catch (error) { reject(error); }
};
request.onerror = event => {
event.preventDefault();
log.push(`open:error:${request.error.name}`);
check(`${label}:open-transaction-cleared`, request.transaction === null, request.transaction);
resolve("error");
};
request.onsuccess = () => {
connections.push(request.result);
log.push("open:success");
resolve("success");
};
});
same(`${label}:open-fails`, outcome, "error");
same(`${label}:transaction-error`, tx.error?.name || null, explicitAbort ? null : "ConstraintError");
check(`${label}:error-realm`, explicitAbort ? tx.error === null : tx.error instanceof DOMException,
tx.error?.constructor.name);
check(`${label}:abort-before-open-error`, log.at(-3) === `tx:abort:${explicitAbort ? "null" : "ConstraintError"}` &&
log.at(-2) === `db:abort:${explicitAbort ? "null" : "ConstraintError"}` &&
log.at(-1) === "open:error:AbortError", log);
for (const { request, prefix, precedesIndex } of requests) {
const success = precedesIndex && !explicitAbort;
check(`${label}:${prefix}:result`, request.readyState === "done" &&
(success ? request.error === null : request.error?.name === "AbortError"),
request.error?.name || "success");
}
same(`${label}:version-rollback`, db.version, persisted ? 1 : 0);
throws(`${label}:index-invalid-after-abort`, "InvalidStateError", () => index.get("same"));
if (label === "ordered") {
same("ordered:event-order", log, ["upgrade:return", "put0:success", "put1:success",
"late:error:AbortError", "tx:error:AbortError", "db:error:AbortError",
"tx:abort:ConstraintError", "db:abort:ConstraintError", "open:error:AbortError"]);
}
if (persisted) {
db.close();
const restored = await open(label, 1, () => check(`${label}:unexpected-upgrade`, false));
const read = restored.transaction("s");
const done = finished(read);
const store = read.objectStore("s");
same(`${label}:metadata-rollback`, Array.from(store.indexNames), ["original"]);
same(`${label}:data-rollback`, await result(store.getAll()), records);
same(`${label}:read-complete`, await done, "complete");
restored.close();
}
};
try {
await failedBuild("ordered", { after: ({ store, watch }) => watch(store.put({ group: "same" }, 3), "late") });
await failedBuild("clear", { after: ({ store, watch }) => watch(store.clear(), "clear") });
await failedBuild("delete-index", { after: ({ store, watch }) => {
store.deleteIndex("unique");
watch(store.put({ group: "different" }, 2), "replace");
store.createIndex("unique", "rank");
} });
await failedBuild("delete-store", { after: ({ db }) => db.deleteObjectStore("s") });
await failedBuild("multiple-failures", { after: ({ store }) => store.createIndex("other", "group", { unique: true }) });
await failedBuild("compound", { keyPath: ["group", "rank"] });
await failedBuild("multientry-conflict", { multiEntry: true,
records: [{ group: ["same", "same"] }, { group: ["same"] }] });
await failedBuild("explicit-abort", { explicitAbort: true, after: ({ tx }) => tx.abort() });
await failedBuild("persisted", { persisted: true });
await failedBuild("commit", { persisted: true, after: ({ tx }) => tx.commit() });
await failedBuild("persisted-clear", { persisted: true, after: ({ store, watch }) => watch(store.clear(), "clear") });
for (const unique of [false, true]) {
for (const before of [false, true]) {
const label = `multi-${unique}-${before}`;
const db = await open(label, 1, db => {
const store = db.createObjectStore("s", { keyPath: "id" });
const create = () => store.createIndex("tags", "tags", { multiEntry: true, unique });
if (!before) create();
store.put({ id: 1, tags: [1, 1, "a", "a", ["a", 2], ["a", 2], null, undefined, {}, NaN] });
store.put({ id: 2, tags: [3, 3, "b", "b"] });
store.put({ id: 3, tags: [] });
store.put({ id: 4 });
store.put({ id: 5, tags: "scalar" });
if (before) create();
});
const tx = db.transaction("s");
const done = finished(tx);
const index = tx.objectStore("s").index("tags");
const values = await Promise.all([
result(index.count()), result(index.count("a")), result(index.getAllKeys()),
result(index.getAll("a")), result(index.getAllKeys(["a", 2]))
]);
same(`${label}:count`, values[0], 6);
same(`${label}:one-entry-per-record`, values[1], 1);
same(`${label}:keys`, values[2], [1, 2, 1, 2, 5, 1]);
same(`${label}:get-all-once`, values[3].map(record => record.id), [1]);
same(`${label}:array-key-equality`, values[4], [1]);
same(`${label}:read-complete`, await done, "complete");
const cursorTx = db.transaction("s");
const cursorDone = finished(cursorTx);
const keys = await new Promise((resolve, reject) => {
const entries = [];
const request = cursorTx.objectStore("s").index("tags").openKeyCursor();
request.onerror = () => reject(request.error);
request.onsuccess = () => {
const cursor = request.result;
if (!cursor) { resolve(entries); return; }
entries.push([cursor.key, cursor.primaryKey]);
cursor.continue();
};
});
same(`${label}:cursor-no-duplicates`, keys, [[1, 1], [3, 2], ["a", 1], ["b", 2], ["scalar", 5], [["a", 2], 1]]);
same(`${label}:cursor-complete`, await cursorDone, "complete");
const write = db.transaction("s", "readwrite");
const written = finished(write);
const store = write.objectStore("s");
// Replacing the same primary key is allowed even in a unique index.
const saved = await Promise.all([
result(store.put({ id: 1, tags: ["a", "a", ["a", 2], ["a", 2]] })),
result(store.add({ id: 6, tags: unique ? ["c", "c"] : ["a", "a"] }))
]);
same(`${label}:write-results`, saved, [1, 6]);
same(`${label}:write-complete`, await written, "complete");
if (unique) {
const conflict = db.transaction("s", "readwrite");
const settled = finished(conflict);
const conflicting = conflict.objectStore("s").add({ id: 7, tags: ["a", "a"] });
const error = await new Promise(resolve => {
conflicting.onsuccess = () => resolve(null);
conflicting.onerror = event => { event.preventDefault(); resolve(conflicting.error.name); };
});
same(`${label}:cross-record-conflict`, error, "ConstraintError");
same(`${label}:canceled-write-error`, await settled, "complete");
}
const verify = db.transaction("s");
const verified = finished(verify);
const current = verify.objectStore("s");
const verifiedValues = await Promise.all([result(current.index("tags").getAllKeys("a")), result(current.get(7))]);
same(`${label}:updated-keys`, verifiedValues[0], unique ? [1] : [1, 6]);
check(`${label}:failed-write-absent`, verifiedValues[1] === undefined, verifiedValues[1]);
same(`${label}:verify-complete`, await verified, "complete");
db.close();
}
}
for (const unique of [false, true]) {
const db = await open(`missing-${unique}`, 1, db => {
const store = db.createObjectStore("s");
const records = [{}, {}, { group: null }, { group: null }, { group: {} }, { group: NaN },
{ group: 1 }, { group: "1" }, { group: [] }, { group: [1, 2] }];
if (!unique) records.push({ group: 1 });
records.forEach((value, i) => store.put(value, i));
store.createIndex("index", "group", { unique });
});
const tx = db.transaction("s");
const done = finished(tx);
const keys = await result(tx.objectStore("s").index("index").getAllKeys());
same(`missing-${unique}:only-valid-keys`, keys, unique ? [6, 7, 8, 9] : [6, 10, 7, 8, 9]);
same(`missing-${unique}:complete`, await done, "complete");
db.close();
}
return { state: checks.every(check => check.pass) ? "pass" : "fail", checks };
} finally {
for (const db of connections) db.close();
}
};
@@ -1,6 +1,27 @@
use super::event_dispatch::run_probe;
use super::*;
#[tokio::test(flavor = "multi_thread")]
async fn indexed_db_unique_index_builds_abort_in_order_and_deduplicate_multi_entry_keys()
-> Result<()> {
let server = FixtureServer::spawn().await?;
let browser = Browser::new(AppConfig::default())?;
let fixture = include_str!("fixtures/indexeddb-index-build.js");
for target in ["window", "child", "worker"] {
let source = format!(
"{fixture}\nindexBuildProbe('index-build-{target}').then(finish, error => finish({{state: 'error', error: String(error), checks: indexBuildChecks}}));"
);
let result = run_probe(&browser, &server, target, &source).await?;
assert_eq!(result["state"], "pass", "{target}: {result}");
assert!(
result["checks"].as_array().unwrap().len() >= 150,
"{target}: {result}"
);
}
server.shutdown().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn indexed_db_events_propagate_and_abort_only_active_transactions() -> Result<()> {
let server = FixtureServer::spawn().await?;
@@ -4,6 +4,8 @@ mod constraints;
mod index;
mod object_store;
pub(in crate::context_bootstrap::indexed_db) use self::constraints::enforce_object_store_unique_constraints;
pub(in crate::context_bootstrap::indexed_db) use self::constraints::{
enforce_object_store_unique_constraints, validate_existing_index_entries,
};
pub(in crate::context_bootstrap::indexed_db) use self::index::scan_index_entries;
pub(in crate::context_bootstrap::indexed_db) use self::object_store::scan_object_store_entries;
@@ -1,5 +1,29 @@
use super::*;
pub(in crate::context_bootstrap::indexed_db) fn validate_existing_index_entries(
scope: &mut v8::PinScope<'_, '_>,
handle: TransactionHandle,
store_name: &str,
index: &IndexInfo,
) -> Result<(), IndexedDbError> {
if !index.unique {
return Ok(());
}
let entries = scan_index_entries(scope, handle, store_name, index, None)?;
// The scan sorts by index key and deduplicates each record's multiEntry
// keys. Equal keys in adjacent records therefore violate uniqueness.
if entries
.windows(2)
.any(|pair| pair[0].index_key == pair[1].index_key)
{
return Err(IndexedDbError::Constraint(format!(
"unique index `{}` already contains key",
index.name
)));
}
Ok(())
}
pub(in crate::context_bootstrap::indexed_db) fn enforce_object_store_unique_constraints<'s>(
scope: &mut v8::PinScope<'s, '_>,
store: v8::Local<'s, v8::Object>,
@@ -22,15 +46,7 @@ pub(in crate::context_bootstrap::indexed_db) fn enforce_object_store_unique_cons
continue;
}
let mut seen = BTreeSet::new();
for key in &candidate_keys {
if !seen.insert(key.clone()) {
return Err(IndexedDbError::Constraint(format!(
"unique index `{}` already contains key",
index.name
)));
}
}
let seen: BTreeSet<_> = candidate_keys.into_iter().collect();
let existing = scan_index_entries(scope, handle, store_name, &index, None)?;
if existing
@@ -33,11 +33,14 @@ fn extract_keys_from_string_key_path<'s>(
};
if multi_entry && let Ok(array) = v8::Local::<v8::Array>::try_from(current) {
let mut keys = Vec::new();
let mut seen = BTreeSet::new();
for index in 0..array.length() {
let Some(entry) = array.get_index(scope, index) else {
continue;
};
if let Ok(Some(key)) = parse_idb_key(scope, entry) {
if let Ok(Some(key)) = parse_idb_key(scope, entry)
&& seen.insert(key.clone())
{
keys.push(key);
}
}
@@ -1,4 +1,7 @@
use super::*;
use crate::context_bootstrap::indexed_db::{
enqueue_transaction_operation_error, validate_existing_index_entries,
};
use crate::webidl;
use moli_indexeddb::{IndexOptionsValidationError, validate_index_options};
@@ -44,7 +47,7 @@ pub(in crate::context_bootstrap::indexed_db) fn idb_object_store_create_index_ca
return;
}
let store = args.this();
let Some((_transaction, database, handle, store_name)) =
let Some((transaction, database, handle, store_name)) =
object_store_versionchange_common(scope, store)
else {
let error = dom_exception_value(
@@ -69,6 +72,13 @@ pub(in crate::context_bootstrap::indexed_db) fn idb_object_store_create_index_ca
}) {
Ok(info) => {
let _ = set_database_index_metadata(scope, database, &store_name, &info);
// Existing operations execute eagerly, but their results are
// delivered in the database task queue. Capture this creation's
// constraint result now so later writes/deletes cannot change it,
// and deliver its failure in the same operation order.
if let Err(error) = validate_existing_index_entries(scope, handle, &store_name, &info) {
enqueue_transaction_operation_error(scope, transaction, error);
}
if let Some(index) = create_index_object(scope, store, &info) {
rv.set(index.into());
} else {
@@ -78,6 +78,9 @@ fn flush_indexed_db_task<'s>(
IndexedDbTaskKind::TransactionStart => flush_transaction_start_task(scope, task),
IndexedDbTaskKind::TransactionCommit => flush_transaction_commit_task(scope, task),
IndexedDbTaskKind::TransactionAbort => flush_transaction_abort_task(scope, task),
IndexedDbTaskKind::TransactionOperationError => {
flush_transaction_operation_error_task(scope, task)
}
}
if !indexed_db_runtime_array_contains_object(
scope,
@@ -5,3 +5,23 @@ mod commit;
pub(in crate::context_bootstrap::indexed_db) use self::abort::flush_transaction_abort_task;
pub(in crate::context_bootstrap::indexed_db) use self::commit::flush_transaction_commit_task;
pub(in crate::context_bootstrap::indexed_db) fn flush_transaction_operation_error_task<'s>(
scope: &mut v8::PinScope<'s, '_>,
task: v8::Local<'s, v8::Object>,
) {
let Some(transaction) = indexed_db_transaction_task_transaction(scope, task) else {
return;
};
let Some(error) =
crate::context_bootstrap::indexed_db::indexed_db_transaction_task_error(scope, task)
else {
return;
};
let error = request_error_object(scope, &error);
crate::context_bootstrap::indexed_db::abort_indexed_db_transaction_with_error(
scope,
transaction,
error,
);
}
@@ -9,6 +9,21 @@ pub(in crate::context_bootstrap::indexed_db) use self::abort::enqueue_transactio
pub(in crate::context_bootstrap::indexed_db) use self::commit::enqueue_transaction_commit_task;
pub(in crate::context_bootstrap::indexed_db) use self::successor::enqueue_next_readwrite_transaction_start;
pub(in crate::context_bootstrap::indexed_db) fn enqueue_transaction_operation_error<'s>(
scope: &mut v8::PinScope<'s, '_>,
transaction: v8::Local<'s, v8::Object>,
error: IndexedDbError,
) {
let task = v8::Object::new(scope);
crate::context_bootstrap::indexed_db::register_indexed_db_transaction_error_task(
scope,
task,
transaction,
error,
);
enqueue_indexed_db_task(scope, task);
}
fn create_transaction_task<'s>(
scope: &mut v8::PinScope<'s, '_>,
kind: &'static str,
@@ -50,6 +50,7 @@ pub(super) enum IndexedDbTaskKind {
TransactionStart,
TransactionCommit,
TransactionAbort,
TransactionOperationError,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -204,7 +205,8 @@ impl IndexedDbTaskState {
| IndexedDbTaskKind::DatabasesSettle
| IndexedDbTaskKind::TransactionStart
| IndexedDbTaskKind::TransactionCommit
| IndexedDbTaskKind::TransactionAbort => self.owner.dispatch_scope(),
| IndexedDbTaskKind::TransactionAbort
| IndexedDbTaskKind::TransactionOperationError => self.owner.dispatch_scope(),
}
}
}
@@ -336,6 +338,7 @@ struct IndexedDbVersionChangeTaskPayload {
struct IndexedDbTransactionTaskPayload {
transaction: v8::Global<v8::Value>,
operation_error: Option<IndexedDbError>,
}
impl IndexedDbTransactionTaskPayload {
@@ -343,6 +346,7 @@ impl IndexedDbTransactionTaskPayload {
let transaction: v8::Local<'_, v8::Value> = transaction.into();
Self {
transaction: v8::Global::new(scope, transaction),
operation_error: None,
}
}
}
@@ -1609,6 +1613,7 @@ pub(super) fn register_indexed_db_transaction_task<'s>(
IndexedDbTaskKind::TransactionStart
| IndexedDbTaskKind::TransactionCommit
| IndexedDbTaskKind::TransactionAbort
| IndexedDbTaskKind::TransactionOperationError
));
let owner = indexed_db_typed_execution_owner(scope, transaction)
.expect("IDB transaction task should have typed owner state");
@@ -1619,6 +1624,42 @@ pub(super) fn register_indexed_db_transaction_task<'s>(
table.borrow_mut().transaction_tasks.insert(id, payload);
}
pub(super) fn register_indexed_db_transaction_error_task<'s>(
scope: &mut v8::PinScope<'s, '_>,
task: v8::Local<'s, v8::Object>,
transaction: v8::Local<'s, v8::Object>,
error: IndexedDbError,
) {
register_indexed_db_transaction_task(
scope,
task,
IndexedDbTaskKind::TransactionOperationError,
transaction,
);
let id = indexed_db_typed_task_id(scope, task).expect("registered transaction error task");
let table = indexed_db_runtime_state_table_for_object(scope, task);
table
.borrow_mut()
.transaction_tasks
.get_mut(&id)
.expect("registered transaction error payload")
.operation_error = Some(error);
}
pub(super) fn indexed_db_transaction_task_error<'s>(
scope: &mut v8::PinScope<'s, '_>,
task: v8::Local<'s, v8::Object>,
) -> Option<IndexedDbError> {
let id = indexed_db_typed_task_id(scope, task)?;
let table = indexed_db_runtime_state_table_for_object(scope, task);
table
.borrow()
.transaction_tasks
.get(&id)?
.operation_error
.clone()
}
pub(super) fn indexed_db_transaction_task_transaction<'s>(
scope: &mut v8::PinScope<'s, '_>,
task: v8::Local<'s, v8::Object>,