LibWeb: Implement IndexedDB request/transaction reverts

To allow these to be reverted, we store mutation logs per object store
in the scope of a readwrite transaction to track the modifications that
were made by it. If a revert is needed, the log is played in reverse to
bring us back to the original state.
This commit is contained in:
Zaggy1024 2026-03-05 16:08:56 -06:00 committed by Gregory Bertilson
parent 8b1b2ae568
commit 547d4eb1f5
25 changed files with 1145 additions and 22 deletions

View file

@ -722,6 +722,7 @@ set(SOURCES
IndexedDB/Internal/Database.cpp
IndexedDB/Internal/Index.cpp
IndexedDB/Internal/Key.cpp
IndexedDB/Internal/MutationLog.cpp
IndexedDB/Internal/ObjectStore.cpp
IndexedDB/Internal/RequestList.cpp
Infra/ByteSequences.cpp

View file

@ -146,6 +146,9 @@ WebIDL::ExceptionOr<GC::Ref<IDBObjectStore>> IDBDatabase::create_object_store(St
// AD-HOC: Add newly created object store to this's object store set.
add_to_object_store_set(object_store);
// AD-HOC: Set up a mutation log for this store and log its creation for potential revert on abort.
transaction->set_up_mutation_log_for_new_store(object_store);
// 10. Return a new object store handle associated with store and transaction.
transaction->add_to_scope(object_store);
return transaction->get_or_create_object_store_handle(object_store);
@ -201,6 +204,9 @@ WebIDL::ExceptionOr<void> IDBDatabase::delete_object_store(String const& name)
for (auto const& [_, index] : store->index_set())
index->set_deleted(true);
// AD-HOC: Log the deletion for potential revert on abort.
store->mutation_log()->note_object_store_deleted();
// 7. Destroy store.
database->remove_object_store(*store);
@ -341,8 +347,11 @@ void IDBDatabase::block_on_conflicting_transactions(GC::Ref<IDBTransaction> tran
blocking.append(other);
}
if (blocking.is_empty())
if (blocking.is_empty()) {
if (!transaction->is_readonly())
transaction->set_up_mutation_logs();
return;
}
transaction->request_list().block_execution();
wait_for_transactions_to_finish(blocking, GC::create_function(realm().heap(), [transaction] {
@ -362,6 +371,8 @@ void IDBDatabase::block_on_conflicting_transactions(GC::Ref<IDBTransaction> tran
commit_a_transaction(transaction->realm(), transaction);
return;
}
if (!transaction->is_readonly())
transaction->set_up_mutation_logs();
transaction->request_list().unblock_execution();
}));
}

View file

@ -28,7 +28,9 @@ IDBIndex::IDBIndex(JS::Realm& realm, GC::Ref<Index> index, GC::Ref<IDBObjectStor
GC::Ref<IDBIndex> IDBIndex::create(JS::Realm& realm, GC::Ref<Index> index, GC::Ref<IDBObjectStore> object_store)
{
return realm.create<IDBIndex>(realm, index, object_store);
auto handle = realm.create<IDBIndex>(realm, index, object_store);
object_store->transaction()->register_index_handle({}, handle);
return handle;
}
void IDBIndex::initialize(JS::Realm& realm)
@ -78,6 +80,9 @@ WebIDL::ExceptionOr<void> IDBIndex::set_name(String const& value)
if (index->object_store()->index_set().contains(name))
return WebIDL::ConstraintError::create(realm, "An index with the given name already exists"_utf16);
// AD-HOC: Log the rename for potential revert on abort.
m_object_store_handle->store()->mutation_log()->note_index_renamed(index, index->name());
// 9. Set indexs name to name.
index->set_name(name);

View file

@ -42,6 +42,8 @@ public:
GC::Ref<IDBTransaction> transaction() { return m_object_store_handle->transaction(); }
GC::Ref<Index> index() { return m_index; }
void update_name() { m_name = m_index->name(); }
protected:
explicit IDBIndex(JS::Realm&, GC::Ref<Index>, GC::Ref<IDBObjectStore>);
virtual void initialize(JS::Realm&) override;

View file

@ -91,6 +91,9 @@ WebIDL::ExceptionOr<void> IDBObjectStore::set_name(String const& value)
if (store->database()->object_store_with_name(name))
return WebIDL::ConstraintError::create(realm, "Object store with the given name already exists"_utf16);
// AD-HOC: Log the rename for potential revert on abort.
store->mutation_log()->note_object_store_renamed(store->name());
// 9. Set stores name to name.
store->set_name(name);
@ -191,6 +194,9 @@ WebIDL::ExceptionOr<GC::Ref<IDBIndex>> IDBObjectStore::create_index(String const
// 12. Add index to this's index set.
this->index_set().set(name, index);
// AD-HOC: Log the creation for potential revert on abort.
store->mutation_log()->note_index_created(index);
// 13. Return a new index handle associated with index and this.
return IDBIndex::create(realm, index, *this);
}
@ -255,6 +261,9 @@ WebIDL::ExceptionOr<void> IDBObjectStore::delete_index(String const& name)
// AD-HOC: Mark the index as deleted so that stale handles throw InvalidStateError.
index.value()->set_deleted(true);
// AD-HOC: Log the deletion for potential revert on abort.
store->mutation_log()->note_index_deleted(*index.value());
// 8. Destroy index.
store->index_set().remove(name);

View file

@ -66,6 +66,9 @@ public:
WebIDL::ExceptionOr<GC::Ref<IDBRequest>> add_or_put(GC::Ref<IDBObjectStore>, JS::Value, Optional<JS::Value> const&, bool);
GC::Ref<ObjectStore> store() const { return m_store; }
void update_name() { m_name = m_store->name(); }
void update_index_set() { m_indexes = m_store->index_set(); }
protected:
explicit IDBObjectStore(JS::Realm&, GC::Ref<ObjectStore>, GC::Ref<IDBTransaction>);
virtual void initialize(JS::Realm&) override;

View file

@ -8,6 +8,7 @@
#include <LibWeb/Bindings/Intrinsics.h>
#include <LibWeb/Crypto/Crypto.h>
#include <LibWeb/HTML/EventNames.h>
#include <LibWeb/IndexedDB/IDBIndex.h>
#include <LibWeb/IndexedDB/IDBObjectStore.h>
#include <LibWeb/IndexedDB/IDBTransaction.h>
#include <LibWeb/IndexedDB/Internal/Algorithms.h>
@ -48,8 +49,15 @@ void IDBTransaction::visit_edges(Visitor& visitor)
visitor.visit(m_associated_request);
visitor.visit(m_scope);
visitor.visit(m_cleanup_event_loop);
for (auto& [store, handle] : m_object_store_handles)
visitor.visit(handle);
visitor.visit(m_index_handles);
for (auto& entry : m_store_mutation_logs) {
visitor.visit(entry.store);
visitor.visit(entry.log);
}
}
void IDBTransaction::set_onabort(WebIDL::CallbackType* event_handler)
@ -177,9 +185,49 @@ GC::Ptr<IDBObjectStore> IDBTransaction::object_store_handle_for(GC::Ref<ObjectSt
void IDBTransaction::set_state(TransactionState state)
{
m_state = state;
}
if (m_state == TransactionState::Finished)
m_connection->check_pending_transaction_waits();
void IDBTransaction::register_index_handle(Badge<IDBIndex>, GC::Ref<IDBIndex> handle)
{
m_index_handles.append(handle);
}
void IDBTransaction::set_up_mutation_logs()
{
m_original_version = m_connection->associated_database()->version();
for (auto& store : m_scope) {
auto log = MutationLog::create(realm());
store->set_mutation_log(log);
m_store_mutation_logs.append({ store, log });
}
}
void IDBTransaction::set_up_mutation_log_for_new_store(GC::Ref<ObjectStore> store)
{
auto log = MutationLog::create(realm());
store->set_mutation_log(log);
m_store_mutation_logs.append({ store, log });
log->note_object_store_created();
}
void IDBTransaction::revert_all_mutations()
{
auto database = m_connection->associated_database();
for (size_t i = m_store_mutation_logs.size(); i > 0; --i) {
auto& entry = m_store_mutation_logs[i - 1];
entry.log->revert(*entry.store, database, m_connection);
}
database->set_version(m_original_version);
m_connection->set_version(m_original_version);
}
void IDBTransaction::discard_mutation_logs()
{
for (auto& entry : m_store_mutation_logs)
entry.store->set_mutation_log(nullptr);
m_store_mutation_logs.clear();
}
}

View file

@ -6,6 +6,7 @@
#pragma once
#include <AK/Badge.h>
#include <AK/HashMap.h>
#include <AK/Vector.h>
#include <LibGC/Ptr.h>
@ -16,11 +17,13 @@
#include <LibWeb/HTML/EventLoop/EventLoop.h>
#include <LibWeb/IndexedDB/IDBDatabase.h>
#include <LibWeb/IndexedDB/IDBRequest.h>
#include <LibWeb/IndexedDB/Internal/MutationLog.h>
#include <LibWeb/IndexedDB/Internal/ObjectStore.h>
#include <LibWeb/IndexedDB/Internal/RequestList.h>
namespace Web::IndexedDB {
class IDBIndex;
class IDBObjectStore;
// https://w3c.github.io/IndexedDB/#transaction
@ -69,10 +72,30 @@ public:
GC::Ptr<ObjectStore> object_store_named(String const& name) const;
void add_to_scope(GC::Ref<ObjectStore> object_store) { m_scope.append(object_store); }
void remove_from_scope(GC::Ref<ObjectStore> object_store) { m_scope.remove_first_matching([&](auto& other) { return object_store == other; }); }
void remove_from_scope(GC::Ref<ObjectStore> object_store)
{
m_scope.remove_first_matching([&](auto& other) { return object_store == other; });
}
void set_scope(Vector<GC::Ref<ObjectStore>> scope) { m_scope = move(scope); }
GC::Ref<IDBObjectStore> get_or_create_object_store_handle(GC::Ref<ObjectStore>);
GC::Ptr<IDBObjectStore> object_store_handle_for(GC::Ref<ObjectStore>);
void for_each_object_store_handle(CallableAs<IterationDecision, GC::Ref<IDBObjectStore>> auto callback)
{
for (auto const& [object_store, handle] : m_object_store_handles) {
if (callback(handle) == IterationDecision::Break)
break;
}
}
void register_index_handle(Badge<IDBIndex>, GC::Ref<IDBIndex>);
ReadonlySpan<GC::Ref<IDBIndex>> index_handles() const { return m_index_handles; }
// Mutation log management. Logs are per-store and track both data and schema mutations.
void set_up_mutation_logs();
void set_up_mutation_log_for_new_store(GC::Ref<ObjectStore>);
void revert_all_mutations();
void discard_mutation_logs();
WebIDL::ExceptionOr<void> abort();
WebIDL::ExceptionOr<void> commit();
@ -122,6 +145,15 @@ private:
GC::Ptr<HTML::EventLoop> m_cleanup_event_loop;
HashMap<GC::RawPtr<ObjectStore>, GC::Ref<IDBObjectStore>> m_object_store_handles;
Vector<GC::Ref<IDBIndex>> m_index_handles;
// AD-HOC: Per-store mutation logs for data and schema change reversion.
struct StoreMutationLog {
GC::Ref<ObjectStore> store;
GC::Ref<MutationLog> log;
};
Vector<StoreMutationLog> m_store_mutation_logs;
u64 m_original_version { 0 };
// NOTE: Used for debug purposes
String m_uuid;

View file

@ -422,6 +422,10 @@ void upgrade_a_database(JS::Realm& realm, GC::Ref<IDBDatabase> connection, u64 v
// 7. Let old version be dbs version.
auto old_version = db->version();
// AD-HOC: Set up per-store mutation logs. This also records the current database version
// so it can be restored if the transaction is aborted.
transaction->set_up_mutation_logs();
// 8. Set dbs version to version. This change is considered part of the transaction, and so if the transaction is aborted, this change is reverted.
db->set_version(version);
@ -571,6 +575,49 @@ void delete_a_database(JS::Realm& realm, StorageAPI::StorageKey storage_key, Str
}));
}
// https://w3c.github.io/IndexedDB/#abort-an-upgrade-transaction
static void abort_an_upgrade_transaction(GC::Ref<IDBTransaction> transaction)
{
// 1. Let connection be transactions connection.
auto connection = transaction->connection();
// 2. Let database be connections database.
auto database = connection->associated_database();
// 3. Set connections version to databases version if database previously existed, or 0 (zero) if database was
// newly created.
connection->set_version(database->version());
// 4. Set connections object store set to the set of object stores in database if database previously existed, or
// the empty set if database was newly created.
// NB: MutationLog reverts all changes to the connection's object store set alongside the deletion/creation of the
// underlying ObjectStore instances.
// However, the transaction scope will still be outdated here. Upgrade transactions are always scoped to the
// entire database, so this is easy.
transaction->set_scope(Vector(connection->object_store_set()));
// 5. For each object store handle handle associated with transaction, including those for object stores that were
// created or deleted during transaction:
transaction->for_each_object_store_handle([&](auto const& handle) {
// 1. If handles object store was not newly created during transaction, set handles name to its object
// stores name.
if (database->object_stores().contains_slow(handle->store()))
handle->update_name();
// 2. Set handles index set to the set of indexes that reference its object store.
handle->update_index_set();
return IterationDecision::Continue;
});
// 6. For each index handle handle associated with transaction, including those for indexes that were created or
// deleted during transaction:
for (auto const& handle : transaction->index_handles()) {
// 1. If handles index was not newly created during transaction, set handles name to its indexs name.
if (handle->index()->object_store()->index_set().contains(handle->index()->name()))
handle->update_name();
}
}
// https://w3c.github.io/IndexedDB/#abort-a-transaction
void abort_a_transaction(GC::Ref<IDBTransaction> transaction, GC::Ptr<WebIDL::DOMException> error)
{
@ -582,13 +629,15 @@ void abort_a_transaction(GC::Ref<IDBTransaction> transaction, GC::Ptr<WebIDL::DO
if (transaction->is_finished())
return;
// FIXME: 2. All the changes made to the database by the transaction are reverted.
// 2. All the changes made to the database by the transaction are reverted.
// For upgrade transactions this includes changes to the set of object stores and indexes, as well as the change to the version.
// Any object stores and indexes which were created during the transaction are now considered deleted for the purposes of other algorithms.
transaction->revert_all_mutations();
transaction->discard_mutation_logs();
// FIXME: 3. If transaction is an upgrade transaction, run the steps to abort an upgrade transaction with transaction.
// if (transaction.is_upgrade_transaction())
// abort_an_upgrade_transaction(transaction);
// 3. If transaction is an upgrade transaction, run the steps to abort an upgrade transaction with transaction.
if (transaction->is_upgrade_transaction())
abort_an_upgrade_transaction(transaction);
// 4. Set transactions state to finished.
transaction->set_state(IDBTransaction::TransactionState::Finished);
@ -832,6 +881,9 @@ void commit_a_transaction(JS::Realm& realm, GC::Ref<IDBTransaction> transaction)
if (transaction->is_upgrade_transaction())
transaction->connection()->associated_database()->set_upgrade_transaction(nullptr);
// AD-HOC: Discard mutation logs now that changes are permanent.
transaction->discard_mutation_logs();
// 2. Set transactions state to finished.
transaction->set_state(IDBTransaction::TransactionState::Finished);
@ -1206,6 +1258,22 @@ GC::Ref<IDBRequest> asynchronously_execute_a_request(JS::Realm& realm, IDBReques
return cursor->transaction();
});
// AD-HOC: Determine the object store being operated on so that we can get the exact mutation log this
// operation will be writing to.
auto store = source.visit(
[](Empty) -> GC::Ref<ObjectStore> {
VERIFY_NOT_REACHED();
},
[](GC::Ref<IDBObjectStore> object_store) -> GC::Ref<ObjectStore> {
return object_store->store();
},
[](GC::Ref<IDBIndex> index) -> GC::Ref<ObjectStore> {
return index->object_store()->store();
},
[](GC::Ref<IDBCursor> cursor) -> GC::Ref<ObjectStore> {
return cursor->effective_object_store();
});
// 2. Assert: transactions state is active.
VERIFY(transaction->state() == IDBTransaction::TransactionState::Active);
@ -1219,7 +1287,7 @@ GC::Ref<IDBRequest> asynchronously_execute_a_request(JS::Realm& realm, IDBReques
// 4. Add request to the end of transactions request list.
// 5. Run these steps in parallel:
// 1. Wait until request is the first item in transactions request list that is not processed.
transaction->request_list().enqueue(request, GC::create_function(realm.heap(), [&realm, transaction, operation, request]() {
transaction->request_list().enqueue(request, GC::create_function(realm.heap(), [&realm, transaction, store, operation, request]() {
if (request->aborted()) {
dbgln_if(IDB_DEBUG, "asynchronously_execute_a_request: executing request {} canceled due to abort", request->uuid());
return;
@ -1227,6 +1295,9 @@ GC::Ref<IDBRequest> asynchronously_execute_a_request(JS::Realm& realm, IDBReques
dbgln_if(IDB_DEBUG, "asynchronously_execute_a_request: step 5.1: performing operation for request {}", request->uuid());
// AD-HOC: Determine where the mutation logs for this operation begin in case we need to revert.
auto log_position = store->mutation_log_position();
// 2. Let result be the result of performing operation.
auto result = operation->function()();
@ -1237,7 +1308,9 @@ GC::Ref<IDBRequest> asynchronously_execute_a_request(JS::Realm& realm, IDBReques
return;
}
// FIXME: 4. If result is an error, then revert all changes made by operation.
// 4. If result is an error, then revert all changes made by operation.
if (result.is_error())
store->revert_mutations_from(log_position);
// 5. Set requests processed flag to true.
request->set_processed(true);

View file

@ -6,6 +6,7 @@
#include <AK/QuickSort.h>
#include <LibWeb/IndexedDB/Internal/Index.h>
#include <LibWeb/IndexedDB/Internal/MutationLog.h>
#include <LibWeb/IndexedDB/Internal/ObjectStore.h>
namespace Web::IndexedDB {
@ -74,7 +75,9 @@ HTML::SerializationRecord Index::referenced_value(IndexRecord const& index_recor
void Index::clear_records()
{
m_records.clear();
auto deleted = move(m_records);
if (auto log = m_object_store->mutation_log(); log && !deleted.is_empty())
log->note_index_records_deleted(*this, move(deleted));
}
Optional<IndexRecord&> Index::first_in_range(GC::Ref<IDBKeyRange> range)
@ -124,6 +127,9 @@ u64 Index::count_records_in_range(GC::Ref<IDBKeyRange> range)
void Index::store_a_record(IndexRecord const& record)
{
if (auto log = m_object_store->mutation_log())
log->note_index_record_stored(*this, record);
m_records.append(record);
// NOTE: The record is stored in indexs list of records such that the list is sorted primarily on the records keys, and secondarily on the records values, in ascending order.
@ -136,11 +142,29 @@ void Index::store_a_record(IndexRecord const& record)
});
}
void Index::remove_records_with_value_in_range(GC::Ref<IDBKeyRange> range)
void Index::remove_record(IndexRecord const& record)
{
m_records.remove_all_matching([&](auto const& record) {
return range->is_in_range(record.value);
m_records.remove_first_matching([&](auto const& existing) {
return Key::equals(existing.key, record.key) && Key::equals(existing.value, record.value);
});
}
void Index::remove_records_with_value_in_range(GC::Ref<IDBKeyRange> range)
{
auto log = m_object_store->mutation_log();
Vector<IndexRecord> removed_records;
for (size_t i = 0; i < m_records.size();) {
auto const& record = m_records[i];
if (range->is_in_range(record.value)) {
auto removed_record = m_records.take(i);
if (log)
removed_records.append(removed_record);
continue;
}
i++;
}
if (!removed_records.is_empty())
log->note_index_records_deleted(*this, move(removed_records));
}
}

View file

@ -44,6 +44,7 @@ public:
GC::ConservativeVector<IndexRecord> last_n_in_range(GC::Ref<IDBKeyRange> range, Optional<WebIDL::UnsignedLong> count);
u64 count_records_in_range(GC::Ref<IDBKeyRange> range);
void store_a_record(IndexRecord const& record);
void remove_record(IndexRecord const& record);
void remove_records_with_value_in_range(GC::Ref<IDBKeyRange> range);
HTML::SerializationRecord referenced_value(IndexRecord const& index_record) const;

View file

@ -0,0 +1,195 @@
/*
* Copyright (c) 2025, zaggy1024
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#include <LibWeb/IndexedDB/IDBDatabase.h>
#include <LibWeb/IndexedDB/Internal/Database.h>
#include <LibWeb/IndexedDB/Internal/Index.h>
#include <LibWeb/IndexedDB/Internal/MutationLog.h>
#include <LibWeb/IndexedDB/Internal/ObjectStore.h>
namespace Web::IndexedDB {
GC_DEFINE_ALLOCATOR(MutationLog);
MutationLog::MutationLog() = default;
GC::Ref<MutationLog> MutationLog::create(JS::Realm& realm)
{
return realm.create<MutationLog>();
}
void MutationLog::visit_edges(Visitor& visitor)
{
Base::visit_edges(visitor);
for (auto& entry : m_entries) {
entry.visit(
[&](ObjectStoreCreated&) {
},
[&](ObjectStoreDeleted&) {
},
[&](IndexCreated& e) {
visitor.visit(e.index);
},
[&](IndexDeleted& e) {
visitor.visit(e.index);
},
[&](ObjectStoreRenamed&) {
},
[&](IndexRenamed& e) {
visitor.visit(e.index);
},
[&](KeyGeneratorChanged&) {
},
[&](RecordsDeleted& e) {
for (auto& record : e.records)
visitor.visit(record.key);
},
[&](RecordStored& e) {
visitor.visit(e.key);
},
[&](IndexRecordsDeleted& e) {
visitor.visit(e.index);
for (auto& record : e.records) {
visitor.visit(record.key);
visitor.visit(record.value);
}
},
[&](IndexRecordStored& e) {
visitor.visit(e.index);
visitor.visit(e.record.key);
visitor.visit(e.record.value);
});
}
}
void MutationLog::note_object_store_created()
{
m_entries.append(ObjectStoreCreated {});
}
void MutationLog::note_object_store_deleted()
{
m_entries.append(ObjectStoreDeleted {});
}
void MutationLog::note_object_store_renamed(String old_name)
{
m_entries.append(ObjectStoreRenamed { move(old_name) });
}
void MutationLog::note_index_created(GC::Ref<Index> index)
{
m_entries.append(IndexCreated { index });
}
void MutationLog::note_index_deleted(GC::Ref<Index> index)
{
m_entries.append(IndexDeleted { index });
}
void MutationLog::note_index_renamed(GC::Ref<Index> index, String old_name)
{
m_entries.append(IndexRenamed { index, move(old_name) });
}
void MutationLog::note_key_generator_changed(u64 old_value)
{
m_entries.append(KeyGeneratorChanged { old_value });
}
void MutationLog::note_records_deleted(Vector<ObjectStoreRecord> records)
{
m_entries.append(RecordsDeleted { move(records) });
}
void MutationLog::note_record_stored(GC::Ref<Key> key)
{
m_entries.append(RecordStored { key });
}
void MutationLog::note_index_records_deleted(GC::Ref<Index> index, Vector<IndexRecord> records)
{
m_entries.append(IndexRecordsDeleted { index, move(records) });
}
void MutationLog::note_index_record_stored(GC::Ref<Index> index, IndexRecord record)
{
m_entries.append(IndexRecordStored { index, record });
}
void MutationLog::revert(ObjectStore& store, GC::Ref<Database> database, GC::Ref<IDBDatabase> connection)
{
revert_entries(store, 0, database, connection);
}
void MutationLog::revert_from(ObjectStore& store, size_t position)
{
revert_entries(store, position, nullptr, nullptr);
}
void MutationLog::revert_entries(ObjectStore& store, size_t from_position, GC::Ptr<Database> database, GC::Ptr<IDBDatabase> connection)
{
// Temporarily clear the store's mutation log pointer so that the operations below
// don't re-log into this log while we're reverting it.
auto saved_log = store.mutation_log();
store.set_mutation_log(nullptr);
for (size_t i = m_entries.size(); i-- > from_position;) {
m_entries[i].visit(
[&](ObjectStoreCreated&) {
VERIFY(database);
VERIFY(connection);
database->remove_object_store(store);
connection->remove_from_object_store_set(store);
},
[&](ObjectStoreDeleted&) {
VERIFY(database);
VERIFY(connection);
store.set_deleted(false);
for (auto const& [_, index] : store.index_set())
index->set_deleted(false);
database->add_object_store(store);
connection->add_to_object_store_set(store);
},
[&](IndexCreated& e) {
VERIFY(database);
e.index->object_store()->index_set().remove(e.index->name());
},
[&](IndexDeleted& e) {
VERIFY(database);
e.index->set_deleted(false);
e.index->object_store()->index_set().set(e.index->name(), e.index);
},
[&](ObjectStoreRenamed& e) {
store.set_name(e.old_name);
},
[&](IndexRenamed& e) {
e.index->set_name(move(e.old_name));
},
[&](KeyGeneratorChanged& e) {
store.key_generator().set(e.old_value);
},
[&](RecordsDeleted& e) {
for (auto& record : e.records)
store.store_a_record(record);
},
[&](RecordStored& e) {
store.remove_record_with_key(e.key);
},
[&](IndexRecordsDeleted& e) {
for (auto& record : e.records)
e.index->store_a_record(record);
},
[&](IndexRecordStored& e) {
e.index->remove_record(e.record);
});
}
m_entries.shrink(from_position);
store.set_mutation_log(saved_log);
}
}

View file

@ -0,0 +1,126 @@
/*
* Copyright (c) 2025, zaggy1024
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#pragma once
#include <AK/Variant.h>
#include <AK/Vector.h>
#include <LibGC/Ptr.h>
#include <LibJS/Heap/Cell.h>
#include <LibWeb/IndexedDB/IDBRecord.h>
#include <LibWeb/IndexedDB/Internal/KeyGenerator.h>
namespace Web::IndexedDB {
class Database;
class IDBDatabase;
class Index;
class ObjectStore;
// Tracks mutations to an object store and its indexes so they can be reverted on request failure
// (asynchronously execute a request step 5.4) or transaction abort (abort a transaction step 2).
//
// For upgrade transactions, this also tracks schema-level changes (store/index creation and deletion).
// Schema entries are always at the boundaries: ObjectStoreCreated is always first (if present),
// and ObjectStoreDeleted is always last (if present). Index schema entries appear in order between
// data mutations.
class MutationLog : public JS::Cell {
GC_CELL(MutationLog, JS::Cell);
GC_DECLARE_ALLOCATOR(MutationLog);
public:
[[nodiscard]] static GC::Ref<MutationLog> create(JS::Realm&);
// Schema-level entries (upgrade transactions only).
void note_object_store_created();
void note_object_store_deleted();
void note_object_store_renamed(String old_name);
void note_index_created(GC::Ref<Index>);
void note_index_deleted(GC::Ref<Index>);
void note_index_renamed(GC::Ref<Index>, String old_name);
// Record that the key generator value was changed, saving the old value for revert.
void note_key_generator_changed(u64 old_value);
// Record that records were deleted from the object store, saving them for re-insertion on revert.
void note_records_deleted(Vector<ObjectStoreRecord>);
// Record that a new record was stored in the object store, saving the key for deletion on revert.
void note_record_stored(GC::Ref<Key> key);
// Record that records were deleted from an index, saving them for re-insertion on revert.
void note_index_records_deleted(GC::Ref<Index>, Vector<IndexRecord>);
// Record that a new record was stored in an index, saving it for deletion on revert.
void note_index_record_stored(GC::Ref<Index>, IndexRecord);
// Undo all logged mutations in reverse order. Database and connection are required for schema entries.
void revert(ObjectStore&, GC::Ref<Database>, GC::Ref<IDBDatabase>);
// Undo logged mutations from the given position forward (used for per-request revert on failure).
// Asserts that no schema entries are encountered.
void revert_from(ObjectStore&, size_t position);
// Clear the log without reverting (used after successful transaction commit).
void clear() { m_entries.clear(); }
[[nodiscard]] size_t position() const { return m_entries.size(); }
protected:
explicit MutationLog();
virtual void visit_edges(Visitor&) override;
private:
void revert_entries(ObjectStore&, size_t from_position, GC::Ptr<Database>, GC::Ptr<IDBDatabase>);
struct ObjectStoreCreated { };
struct ObjectStoreDeleted { };
struct IndexCreated {
GC::Ref<Index> index;
};
struct IndexDeleted {
GC::Ref<Index> index;
};
struct ObjectStoreRenamed {
String old_name;
};
struct IndexRenamed {
GC::Ref<Index> index;
String old_name;
};
struct KeyGeneratorChanged {
u64 old_value;
};
struct RecordsDeleted {
Vector<ObjectStoreRecord> records;
};
struct RecordStored {
GC::Ref<Key> key;
};
struct IndexRecordsDeleted {
GC::Ref<Index> index;
Vector<IndexRecord> records;
};
struct IndexRecordStored {
GC::Ref<Index> index;
IndexRecord record;
};
using Entry = Variant<ObjectStoreCreated, ObjectStoreDeleted, ObjectStoreRenamed, IndexCreated, IndexDeleted, IndexRenamed, KeyGeneratorChanged, RecordsDeleted, RecordStored, IndexRecordsDeleted, IndexRecordStored>;
Vector<Entry> m_entries;
};
}

View file

@ -7,6 +7,7 @@
#include <AK/Math.h>
#include <AK/QuickSort.h>
#include <LibWeb/IndexedDB/IDBKeyRange.h>
#include <LibWeb/IndexedDB/Internal/MutationLog.h>
#include <LibWeb/IndexedDB/Internal/ObjectStore.h>
namespace Web::IndexedDB {
@ -20,6 +21,19 @@ GC::Ref<ObjectStore> ObjectStore::create(JS::Realm& realm, GC::Ref<Database> dat
return realm.create<ObjectStore>(database, name, auto_increment, key_path);
}
size_t ObjectStore::mutation_log_position() const
{
if (!m_mutation_log)
return 0;
return m_mutation_log->position();
}
void ObjectStore::revert_mutations_from(size_t position)
{
if (m_mutation_log)
m_mutation_log->revert_from(*this, position);
}
ObjectStore::ObjectStore(GC::Ref<Database> database, String name, bool auto_increment, Optional<KeyPath> const& key_path)
: m_database(database)
, m_name(move(name))
@ -36,6 +50,7 @@ void ObjectStore::visit_edges(Visitor& visitor)
Base::visit_edges(visitor);
visitor.visit(m_database);
visitor.visit(m_indexes);
visitor.visit(m_mutation_log);
for (auto& record : m_records) {
visitor.visit(record.key);
@ -44,8 +59,25 @@ void ObjectStore::visit_edges(Visitor& visitor)
void ObjectStore::remove_records_in_range(GC::Ref<IDBKeyRange> range)
{
m_records.remove_all_matching([&](auto const& record) {
return range->is_in_range(record.key);
Vector<ObjectStoreRecord> deleted;
for (size_t i = 0; i < m_records.size();) {
auto const& record = m_records[i];
if (range->is_in_range(record.key)) {
auto record = m_records.take(i);
if (m_mutation_log)
deleted.append(record);
continue;
}
i++;
}
if (!deleted.is_empty())
m_mutation_log->note_records_deleted(move(deleted));
}
void ObjectStore::remove_record_with_key(GC::Ref<Key> key)
{
m_records.remove_first_matching([&](auto const& record) {
return Key::equals(record.key, key);
});
}
@ -60,6 +92,9 @@ bool ObjectStore::has_record_with_key(GC::Ref<Key> key)
void ObjectStore::store_a_record(ObjectStoreRecord const& record)
{
if (m_mutation_log)
m_mutation_log->note_record_stored(record.key);
m_records.append(record);
// NOTE: The record is stored in the object stores list of records such that the list is sorted according to the key of the records in ascending order.
@ -87,7 +122,9 @@ Optional<ObjectStoreRecord&> ObjectStore::first_in_range(GC::Ref<IDBKeyRange> ra
void ObjectStore::clear_records()
{
m_records.clear();
auto deleted_records = move(m_records);
if (m_mutation_log && !deleted_records.is_empty())
m_mutation_log->note_records_deleted(deleted_records);
}
// https://w3c.github.io/IndexedDB/#generate-a-key
@ -104,6 +141,8 @@ ErrorOr<u64> ObjectStore::generate_a_key()
return Error::from_string_literal("Key is greater than 2^53 while trying to generate a key");
// 4. Increase generator's current number by 1.
if (m_mutation_log)
m_mutation_log->note_key_generator_changed(key);
generator.increment(1);
// 5. Return key.
@ -130,8 +169,11 @@ void ObjectStore::possibly_update_the_key_generator(GC::Ref<Key> key)
auto& generator = key_generator();
// 6. If value is greater than or equal to generator's current number, then set generator's current number to value + 1.
if (value >= static_cast<double>(generator.current_number()))
if (value >= static_cast<double>(generator.current_number())) {
if (m_mutation_log)
m_mutation_log->note_key_generator_changed(generator.current_number());
generator.set(static_cast<u64>(value + 1));
}
}
GC::ConservativeVector<ObjectStoreRecord> ObjectStore::first_n_in_range(GC::Ref<IDBKeyRange> range, Optional<WebIDL::UnsignedLong> count)

View file

@ -19,6 +19,7 @@
#include <LibWeb/IndexedDB/Internal/Database.h>
#include <LibWeb/IndexedDB/Internal/Index.h>
#include <LibWeb/IndexedDB/Internal/KeyGenerator.h>
#include <LibWeb/IndexedDB/Internal/MutationLog.h>
namespace Web::IndexedDB {
@ -51,6 +52,7 @@ public:
void remove_records_in_range(GC::Ref<IDBKeyRange> range);
bool has_record_with_key(GC::Ref<Key> key);
void store_a_record(ObjectStoreRecord const& record);
void remove_record_with_key(GC::Ref<Key> key);
u64 count_records_in_range(GC::Ref<IDBKeyRange> range);
Optional<ObjectStoreRecord&> first_in_range(GC::Ref<IDBKeyRange> range);
void clear_records();
@ -62,6 +64,12 @@ public:
// https://w3c.github.io/IndexedDB/#possibly-update-the-key-generator
void possibly_update_the_key_generator(GC::Ref<Key>);
// Mutation log access. The log is set externally by the readwrite transaction that owns it.
GC::Ptr<MutationLog> mutation_log() const { return m_mutation_log; }
void set_mutation_log(GC::Ptr<MutationLog> log) { m_mutation_log = log; }
[[nodiscard]] size_t mutation_log_position() const;
void revert_mutations_from(size_t position);
protected:
virtual void visit_edges(Visitor&) override;
@ -87,6 +95,9 @@ private:
Vector<ObjectStoreRecord> m_records;
bool m_deleted { false };
// AD-HOC: Tracks mutations for revert on request failure or transaction abort.
GC::Ptr<MutationLog> m_mutation_log;
};
}

View file

@ -0,0 +1,3 @@
before rename: store=original_store index=original_index
after rename: store=renamed_store index=renamed_index
after abort: store=original_store index=original_index

View file

@ -2,8 +2,7 @@ Harness status: OK
Found 29 tests
28 Pass
1 Fail
29 Pass
Pass IDBFactory.open() - request has no source
Pass IDBFactory.open() - database 'name' and 'version' are correctly set
Pass IDBFactory.open() - no version opens current database
@ -30,6 +29,6 @@ Pass Calling open() with version argument object (third) should throw TypeError.
Pass Calling open() with version argument 1.5 should not throw.
Pass Calling open() with version argument 9007199254740991 should not throw.
Pass Calling open() with version argument undefined should not throw.
Fail IDBFactory.open() - error in upgradeneeded resets db
Pass IDBFactory.open() - error in upgradeneeded resets db
Pass IDBFactory.open() - second open's transaction is available to get objectStores
Pass IDBFactory.open() - upgradeneeded gets VersionChangeEvent

View file

@ -0,0 +1,7 @@
Harness status: OK
Found 2 tests
2 Pass
Pass IndexedDB object store rename in aborted transaction
Pass IndexedDB object store creation and rename in an aborted transaction

View file

@ -0,0 +1,6 @@
Harness status: OK
Found 1 tests
1 Pass
Pass small values

View file

@ -0,0 +1,54 @@
<!DOCTYPE html>
<script src="include.js"></script>
<script>
asyncTest(done => {
setTimeout(() => {
spoofCurrentURL("https://example.com/indexeddb-abort-upgrade-reverts-handle-names.html");
// Steps 5-6 of "abort an upgrade transaction" require that object store
// and index handles revert their names when an upgrade is aborted, if the
// store/index existed before the transaction.
const dbName = "test-abort-revert-names-" + Date.now() + Math.random();
// Version 1: create a store with an index.
const req1 = indexedDB.open(dbName, 1);
req1.onupgradeneeded = (e) => {
const db = e.target.result;
const store = db.createObjectStore("original_store");
store.createIndex("original_index", "key");
};
req1.onsuccess = (e) => {
e.target.result.close();
// Version 2: rename store and index, then abort.
let storeHandle, indexHandle;
const req2 = indexedDB.open(dbName, 2);
req2.onupgradeneeded = (e) => {
const tx = e.target.transaction;
storeHandle = tx.objectStore("original_store");
indexHandle = storeHandle.index("original_index");
println("before rename: store=" + storeHandle.name + " index=" + indexHandle.name);
storeHandle.name = "renamed_store";
indexHandle.name = "renamed_index";
println("after rename: store=" + storeHandle.name + " index=" + indexHandle.name);
tx.abort();
};
req2.onerror = (e) => {
println("after abort: store=" + storeHandle.name + " index=" + indexHandle.name);
indexedDB.deleteDatabase(dbName);
done();
};
req2.onsuccess = () => {
println("FAIL: expected error, got success");
indexedDB.deleteDatabase(dbName);
done();
};
};
}, 0);
});
</script>

View file

@ -0,0 +1,15 @@
<!doctype html>
<meta charset=utf-8>
<title>IndexedDB: object store renaming support in aborted transactions</title>
<script>
self.GLOBAL = {
isWindow: function() { return true; },
isWorker: function() { return false; },
isShadowRealm: function() { return false; },
};
</script>
<script src="../resources/testharness.js"></script>
<script src="../resources/testharnessreport.js"></script>
<script src="resources/support-promises.js"></script>
<div id=log></div>
<script src="../IndexedDB/idbobjectstore-rename-abort.any.js"></script>

View file

@ -0,0 +1,135 @@
// META: global=window,worker
// META: title=IndexedDB: object store renaming support in aborted transactions
// META: script=resources/support-promises.js
// Spec: https://w3c.github.io/IndexedDB/#dom-idbobjectstore-name
'use strict';
promise_test(testCase => {
const dbName = databaseName(testCase);
let bookStore = null;
let bookStore2 = null;
return createDatabase(
testCase,
(database, transaction) => {
createBooksStore(testCase, database);
})
.then(database => {
database.close();
})
.then(
() => migrateDatabase(
testCase, 2,
(database, transaction) => {
bookStore = transaction.objectStore('books');
bookStore.name = 'renamed_books';
transaction.abort();
assert_equals(
bookStore.name, 'books',
'IDBObjectStore.name should not reflect the rename any more ' +
'immediately after transaction.abort() returns');
assert_array_equals(
database.objectStoreNames, ['books'],
'IDBDatabase.objectStoreNames should not reflect the rename ' +
'any more immediately after transaction.abort() returns');
assert_array_equals(
transaction.objectStoreNames, ['books'],
'IDBTransaction.objectStoreNames should not reflect the ' +
'rename any more immediately after transaction.abort() returns');
}))
.then(event => {
assert_equals(
bookStore.name, 'books',
'IDBObjectStore.name should not reflect the rename any more ' +
'after the versionchange transaction is aborted');
const request = indexedDB.open(dbName, 1);
return promiseForRequest(testCase, request);
})
.then(database => {
assert_array_equals(
database.objectStoreNames, ['books'],
'IDBDatabase.objectStoreNames should not reflect the rename ' +
'after the versionchange transaction is aborted');
const transaction = database.transaction('books', 'readonly');
bookStore2 = transaction.objectStore('books');
return checkStoreContents(
testCase, bookStore2,
'Aborting an object store rename transaction should not change ' +
'the store\'s records')
.then(() => database.close());
})
.then(() => {
assert_equals(
bookStore.name, 'books',
'IDBObjectStore used in aborted rename transaction should not ' +
'reflect the rename after the transaction is aborted');
assert_equals(
bookStore2.name, 'books',
'IDBObjectStore obtained after an aborted rename transaction ' +
'should not reflect the rename');
});
}, 'IndexedDB object store rename in aborted transaction');
promise_test(testCase => {
const dbName = databaseName(testCase);
let notBookStore = null;
return createDatabase(testCase, (database, transaction) => {})
.then(database => {
database.close();
})
.then(
() => migrateDatabase(
testCase, 2,
(database, transaction) => {
notBookStore = createNotBooksStore(testCase, database);
notBookStore.name = 'not_books_renamed';
notBookStore.name = 'not_books_renamed_again';
transaction.abort();
assert_equals(
notBookStore.name, 'not_books_renamed_again',
'IDBObjectStore.name should reflect the last rename ' +
'immediately after transaction.abort() returns');
assert_array_equals(
database.objectStoreNames, [],
'IDBDatabase.objectStoreNames should not reflect the creation ' +
'or the rename any more immediately after transaction.abort() ' +
'returns');
assert_array_equals(
transaction.objectStoreNames, [],
'IDBTransaction.objectStoreNames should not reflect the ' +
'creation or the rename any more immediately after ' +
'transaction.abort() returns');
assert_array_equals(
notBookStore.indexNames, [],
'IDBObjectStore.indexNames for the newly created store ' +
'should be empty immediately after transaction.abort() ' +
'returns');
}))
.then(event => {
assert_equals(
notBookStore.name, 'not_books_renamed_again',
'IDBObjectStore.name should reflect the last rename after the ' +
'versionchange transaction is aborted');
assert_array_equals(
notBookStore.indexNames, [],
'IDBObjectStore.indexNames for the newly created store ' +
'should be empty after the versionchange transaction is aborted ' +
'returns');
const request = indexedDB.open(dbName, 1);
return promiseForRequest(testCase, request);
})
.then(database => {
assert_array_equals(
database.objectStoreNames, [],
'IDBDatabase.objectStoreNames should not reflect the creation or ' +
'the rename after the versionchange transaction is aborted');
database.close();
});
}, 'IndexedDB object store creation and rename in an aborted transaction');

View file

@ -0,0 +1,18 @@
<!doctype html>
<meta charset=utf-8>
<title>IndexedDB: request result events are delivered in order</title>
<meta name="timeout" content="long">
<script>
self.GLOBAL = {
isWindow: function() { return true; },
isWorker: function() { return false; },
isShadowRealm: function() { return false; },
};
</script>
<script src="../resources/testharness.js"></script>
<script src="../resources/testharnessreport.js"></script>
<script src="resources/support-promises.js"></script>
<script src="resources/support.js"></script>
<script src="resources/request-event-ordering-common.js"></script>
<div id=log></div>
<script src="../IndexedDB/request-event-ordering-small-values.any.js"></script>

View file

@ -0,0 +1,20 @@
// META: title=IndexedDB: request result events are delivered in order
// META: global=window,worker
// META: script=resources/support-promises.js
// META: script=resources/support.js
// META: script=resources/request-event-ordering-common.js
// META: timeout=long
// Spec: https://w3c.github.io/IndexedDB/#abort-transaction
'use strict';
eventsTest('small values', [
['get', 2], ['count', 4], ['continue-empty', null],
['get-empty', 5], ['add', 5], ['open', 2],
['continue', 2], ['get', 4], ['get-empty', 6],
['count', 5], ['put-with-id', 5], ['put', 6],
['error', 3], ['continue', 4], ['count', 6],
['get-empty', 7], ['open', 4], ['open-empty', 7],
['add', 7],
]);

View file

@ -0,0 +1,283 @@
'use strict';
// Should be large enough to trigger large value handling in the IndexedDB
// engines that have special code paths for large values.
const wrapThreshold = 128 * 1024;
function populateStore(store) {
store.put({id: 1, key: 'k1', value: largeValue(wrapThreshold, 1)});
store.put({id: 2, key: 'k2', value: ['small-2']});
store.put({id: 3, key: 'k3', value: largeValue(wrapThreshold, 3)});
store.put({id: 4, key: 'k4', value: ['small-4']});
}
// Assigns cursor indexes for operations that require open cursors.
//
// Returns the number of open cursors required to perform all operations.
function assignCursors(operations) {
return cursorCount;
}
// Opens index cursors for operations that require open cursors.
//
// onsuccess is called if all cursors are opened successfully. Otherwise,
// onerror will be called at least once.
function openCursors(testCase, index, operations, onerror, onsuccess) {
let pendingCursors = 0;
for (let operation of operations) {
const opcode = operation[0];
const primaryKey = operation[1];
let request;
switch (opcode) {
case 'continue':
request =
index.openCursor(IDBKeyRange.lowerBound(`k${primaryKey - 1}`));
break;
case 'continue-empty':
// k4 is the last key in the data set, so calling continue() will get
// the cursor past the end of the store.
request = index.openCursor(IDBKeyRange.lowerBound('k4'));
break;
default:
continue;
}
operation[2] = request;
++pendingCursors;
request.onsuccess = testCase.step_func(() => {
--pendingCursors;
if (!pendingCursors)
onsuccess();
});
request.onerror = testCase.step_func(onerror);
}
if (!pendingCursors)
onsuccess();
}
function doOperation(testCase, store, index, operation, requestId, results) {
const opcode = operation[0];
const primaryKey = operation[1];
const cursor = operation[2];
return new Promise((resolve, reject) => {
let request;
switch (opcode) {
case 'add': // Tests returning a primary key.
request =
store.add({key: `k${primaryKey}`, value: [`small-${primaryKey}`]});
break;
case 'put': // Tests returning a primary key.
request =
store.put({key: `k${primaryKey}`, value: [`small-${primaryKey}`]});
break;
case 'put-with-id': // Tests returning success or a primary key.
request = store.put({
key: `k${primaryKey}`,
value: [`small-${primaryKey}`],
id: primaryKey
});
break;
case 'get': // Tests returning a value.
case 'get-empty': // Tests returning undefined.
request = store.get(primaryKey);
break;
case 'getall': // Tests returning an array of values.
request = store.getAll();
break;
case 'error': // Tests returning an error.
request =
store.put({key: `k${primaryKey}`, value: [`small-${primaryKey}`]});
request.onerror = testCase.step_func(event => {
event.preventDefault();
results.push([requestId, request.error]);
resolve();
});
request.onsuccess = testCase.step_func(() => {
reject(new Error('put with duplicate primary key succeded'));
});
break;
case 'continue': // Tests returning a key, primary key, and value.
request = cursor;
cursor.result.continue();
request.onsuccess = testCase.step_func(() => {
const result = request.result;
results.push(
[requestId, result.key, result.primaryKey, result.value]);
resolve();
});
request.onerror = null;
break;
case 'open': // Tests returning a cursor, key, primary key, and value.
request = index.openCursor(IDBKeyRange.lowerBound(`k${primaryKey}`));
request.onsuccess = testCase.step_func(() => {
const result = request.result;
results.push(
[requestId, result.key, result.primaryKey, result.value]);
resolve();
});
break;
case 'continue-empty': // Tests returning a null result.
request = cursor;
cursor.result.continue();
request.onsuccess = testCase.step_func(() => {
results.push([requestId, request.result]);
resolve();
});
request.onerror = null;
break;
case 'open-empty': // Tests returning a null cursor.
request = index.openCursor(IDBKeyRange.lowerBound(`k${primaryKey}`));
request.onsuccess = testCase.step_func(() => {
const result = request.result;
results.push([requestId, request.result]);
resolve();
});
break;
case 'count': // Tests returning a numeric result.
request = index.count();
request.onsuccess = testCase.step_func(() => {
results.push([requestId, request.result]);
resolve();
});
break;
};
if (!request.onsuccess) {
request.onsuccess = testCase.step_func(() => {
results.push([requestId, request.result]);
resolve();
});
}
if (!request.onerror)
request.onerror = testCase.step_func(event => {
event.preventDefault();
reject(request.error);
});
});
}
function checkOperationResult(operation, result, requestId) {
const opcode = operation[0];
const primaryKey = operation[1];
const expectedValue = (primaryKey == 1 || primaryKey == 3) ?
largeValue(wrapThreshold, primaryKey) :
[`small-${primaryKey}`];
const requestIndex = result[0];
assert_equals(
requestIndex, requestId, 'result event order should match request order');
switch (opcode) {
case 'put':
case 'put-with-id':
case 'add':
assert_equals(
result[1], primaryKey,
`${opcode} result should be the new object's primary key`);
break;
case 'get':
assert_equals(
result[1].id, primaryKey,
'get result should match put value (primary key)');
assert_equals(
result[1].key, `k${primaryKey}`,
'get result should match put value (key)');
assert_equals(
result[1].value.join(','), expectedValue.join(','),
'get result should match put value (nested value)');
break;
case 'getall':
assert_equals(
result[1].length, primaryKey,
'getAll should return all the objects in the store');
for (let i = 0; i < primaryKey; ++i) {
const object = result[1][i];
assert_equals(
object.id, i + 1,
`getAll result ${i + 1} should match put value (primary key)`);
assert_equals(
object.key, `k${i + 1}`,
`get result ${i + 1} should match put value (key)`);
const expectedValue = (i == 0 || i == 2) ?
largeValue(wrapThreshold, i + 1) :
[`small-${i + 1}`];
assert_equals(
object.value.join(','), object.value.join(','),
`get result ${i + 1} should match put value (nested value)`);
}
break;
case 'get-empty':
assert_equals(
result[1], undefined, 'get-empty result should be undefined');
break;
case 'error':
assert_equals(
result[1].name, 'ConstraintError',
'incorrect error from put with duplicate primary key');
break;
case 'continue':
case 'open':
assert_equals(
result[1], `k${primaryKey}`,
`${opcode} key should match the key in the put value`);
assert_equals(
result[2], primaryKey,
`${opcode} primary key should match the put value's primary key`);
assert_equals(
result[3].id, primaryKey,
`${opcode} value should match put value (primary key)`);
assert_equals(
result[3].key, `k${primaryKey}`,
`${opcode} value should match put value (key)`);
assert_equals(
result[3].value.join(','), expectedValue.join(','),
`${opcode} value should match put value (nested value)`);
break;
case 'continue-empty':
case 'open-empty':
assert_equals(result[1], null, `${opcode} result should be null`);
break;
}
}
function eventsTest(label, operations) {
promise_test(testCase => {
return createDatabase(
testCase,
(database, transaction) => {
const store = database.createObjectStore(
'test-store', {autoIncrement: true, keyPath: 'id'});
store.createIndex('test-index', 'key', {unique: true});
populateStore(store);
})
.then(database => {
const transaction = database.transaction(['test-store'], 'readwrite');
const store = transaction.objectStore('test-store');
const index = store.index('test-index');
return new Promise((resolve, reject) => {
openCursors(testCase, index, operations, reject, () => {
const results = [];
const promises = [];
for (let i = 0; i < operations.length; ++i) {
const promise = doOperation(
testCase, store, index, operations[i], i, results);
promises.push(promise);
};
resolve(Promise.all(promises).then(() => results));
});
});
})
.then(results => {
assert_equals(
results.length, operations.length,
'Promise.all should resolve after all sub-promises resolve');
for (let i = 0; i < operations.length; ++i)
checkOperationResult(operations[i], results[i], i);
});
}, label);
}