fix(indexeddb): expose native version change event attributes

This commit is contained in:
ldm0
2026-09-30 03:06:55 +08:00
parent d92d4da858
commit e5bc46966b
5 changed files with 266 additions and 6 deletions
@@ -0,0 +1,166 @@
globalThis.versionChangeChecks = [];
async function versionChangeProbe(prefix = 'version-change') {
const checks = versionChangeChecks;
const check = (label, pass, actual = '') => checks.push({label, pass, actual: String(actual)});
const same = (label, actual, expected) => check(label, JSON.stringify(actual) === JSON.stringify(expected), JSON.stringify(actual));
const throws = (label, action, Realm) => {
let caught;
try { action(); } catch (error) { caught = error; }
check(label, caught !== undefined && Object.getPrototypeOf(caught) === Realm.TypeError.prototype, caught);
};
const exercise = async (label, Realm, GetterRealm) => {
const Ctor = Realm.IDBVersionChangeEvent;
const getters = {};
for (const name of ['oldVersion', 'newVersion']) {
const descriptor = Object.getOwnPropertyDescriptor(GetterRealm.IDBVersionChangeEvent.prototype, name);
getters[name] = descriptor?.get;
check(label + ': ' + name + ' descriptor', typeof descriptor?.get === 'function' &&
descriptor.set === undefined && descriptor.enumerable && descriptor.configurable);
check(label + ': ' + name + ' getter metadata', descriptor?.get?.name === 'get ' + name && descriptor.get.length === 0);
}
const get = (name, receiver) => typeof getters[name] === 'function' ? getters[name].call(receiver) : undefined;
check(label + ': constructor length', Ctor.length === 1, Ctor.length);
throws(label + ': new is required', () => Ctor('type'), Realm);
throws(label + ': type is required', () => new Ctor(), Realm);
throws(label + ': symbol type is rejected', () => new Ctor(Symbol()), Realm);
for (const invalid of [1, 'init', Symbol(), 1n]) {
throws(label + ': primitive dictionary is rejected', () => new Ctor('type', invalid), Realm);
}
for (const init of [undefined, null, {}]) {
const event = new Ctor('default', init);
same(label + ': default versions', [event.oldVersion, event.newVersion], [0, null]);
same(label + ': default flags', [event.bubbles, event.cancelable, event.composed, event.isTrusted], [false, false, false, false]);
}
const order = [];
const init = {};
for (const [name, value] of [['bubbles', true], ['cancelable', true], ['composed', true],
['newVersion', {valueOf() { order.push('convert newVersion'); return 9; }}],
['oldVersion', {valueOf() { order.push('convert oldVersion'); return 7; }}]]) {
Object.defineProperty(init, name, {get() { order.push(name); return value; }});
}
const event = new Ctor({toString() { order.push('type'); return 'flags'; }}, Object.create(init));
same(label + ': conversion order', order,
['type', 'bubbles', 'cancelable', 'composed', 'newVersion', 'convert newVersion', 'oldVersion', 'convert oldVersion']);
for (const name of ['bubbles', 'cancelable', 'composed']) {
check(label + ': EventInit ' + name, event[name] === true, event[name]);
}
const target = new EventTarget();
target.addEventListener('flags', value => value.preventDefault());
check(label + ': EventInit dispatch cancellation', target.dispatchEvent(event) === false);
check(label + ': EventInit defaultPrevented', event.defaultPrevented === true);
for (const [name, expected] of [['oldVersion', 7], ['newVersion', 9]]) {
check(label + ': ' + name + ' inherited', !Object.hasOwn(event, name));
check(label + ': ' + name + ' getter value', get(name, event) === expected, get(name, event));
check(label + ': ' + name + ' readonly', Reflect.set(event, name, 99) === false);
check(label + ': ' + name + ' unchanged', event[name] === expected, event[name]);
Object.defineProperty(event, name, {configurable: true, get() { throw new Error('author shadow'); }});
check(label + ': ' + name + ' native value under author shadow', get(name, event) === expected, get(name, event));
delete event[name];
check(label + ': ' + name + ' restored getter', event[name] === expected, event[name]);
}
const revoked = Proxy.revocable(event, {}); revoked.revoke();
let traps = 0;
const trapped = new Proxy(event, {get() { ++traps; throw new Error('trap'); }, getPrototypeOf() { ++traps; throw new Error('trap'); }});
for (const receiver of [null, undefined, 1, {}, Ctor.prototype, Object.create(Ctor.prototype),
Object.create(event), new Realm.Event('base'), new Proxy(event, {}), revoked.proxy, trapped]) {
for (const name of ['oldVersion', 'newVersion']) {
throws(label + ': ' + name + ' invalid receiver', () => get(name, receiver), GetterRealm);
}
}
check(label + ': no Proxy traps', traps === 0, traps);
Object.setPrototypeOf(event, null);
same(label + ': native identity survives prototype removal', [get('oldVersion', event), get('newVersion', event)], [7, 9]);
class Subclass extends Ctor {}
const sub = new Subclass('sub', {oldVersion: 3, newVersion: 4});
check(label + ': subclass prototype is preserved', Object.getPrototypeOf(sub) === Subclass.prototype);
same(label + ': subclass native identity', [get('oldVersion', sub), get('newVersion', sub)], [3, 4]);
const members = ['bubbles', 'cancelable', 'composed', 'newVersion', 'oldVersion'];
for (let stop = 0; stop < members.length; ++stop) {
const reads = [];
const marker = new Error('dictionary getter');
const options = new Proxy({}, {get(_target, key) {
reads.push(key);
if (key === members[stop]) throw marker;
return undefined;
}});
let caught;
try { new Ctor('throws', options); } catch (error) { caught = error; }
check(label + ': getter exception identity ' + members[stop], caught === marker, caught);
same(label + ': stop conversion at ' + members[stop], reads, members.slice(0, stop + 1));
}
const conversions = [[undefined, 0, null], [null, 0, null], [NaN, 0, 0], [Infinity, 0, 0],
[-Infinity, 0, 0], [1.9, 1, 1], [-1, 2 ** 64, 2 ** 64], [2 ** 64, 0, 0], ['12', 12, 12]];
for (const [value, oldVersion, newVersion] of conversions) {
const converted = new Ctor('number', {oldVersion: value, newVersion: value});
same(label + ': numeric conversion ' + String(value), [converted.oldVersion, converted.newVersion], [oldVersion, newVersion]);
}
for (const name of ['oldVersion', 'newVersion']) {
for (const value of [Symbol(), 1n]) {
throws(label + ': reject invalid ' + name, () => new Ctor('invalid', {[name]: value}), Realm);
}
}
const marker = new Error('type conversion');
let dictionaryReads = 0;
let caught;
try {
new Ctor({toString() { throw marker; }}, new Proxy({}, {get() { ++dictionaryReads; }}));
} catch (error) { caught = error; }
check(label + ': type exception identity', caught === marker);
check(label + ': type exception stops dictionary conversion', dictionaryReads === 0, dictionaryReads);
const nativeEvents = [];
const seen = [];
let inspectionError;
const inspectNative = (name, value, oldVersion, newVersion) => {
try {
seen.push(name);
same(label + ': native ' + name + ' versions', [value.oldVersion, value.newVersion], [oldVersion, newVersion]);
same(label + ': native ' + name + ' flags', [value.bubbles, value.cancelable, value.composed], [false, false, false]);
check(label + ': native ' + name + ' prototype', Object.getPrototypeOf(value) === Ctor.prototype);
check(label + ': native ' + name + ' inherited attributes', !Object.hasOwn(value, 'oldVersion') && !Object.hasOwn(value, 'newVersion'));
same(label + ': native ' + name + ' borrowed getters', [get('oldVersion', value), get('newVersion', value)], [oldVersion, newVersion]);
check(label + ': native ' + name + ' readonly oldVersion', Reflect.set(value, 'oldVersion', 99) === false);
check(label + ': native ' + name + ' readonly newVersion', Reflect.set(value, 'newVersion', 99) === false);
nativeEvents.push([name, value, oldVersion, newVersion]);
} catch (error) { inspectionError ??= error; }
};
const requestResult = request => new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
const factory = Realm.indexedDB;
const name = prefix + '-' + label;
const savedCtor = Realm.IDBVersionChangeEvent;
Realm.IDBVersionChangeEvent = function() { throw new Error('author constructor'); };
try {
const first = factory.open(name, 1);
first.onupgradeneeded = value => inspectNative('create', value, 0, 1);
const oldDb = await requestResult(first);
oldDb.onversionchange = value => inspectNative('versionchange', value, 1, 2);
const second = factory.open(name, 2);
second.onblocked = value => { inspectNative('blocked', value, 1, 2); oldDb.close(); };
second.onupgradeneeded = value => inspectNative('upgrade', value, 1, 2);
const newDb = await requestResult(second);
newDb.onversionchange = value => { inspectNative('delete notification', value, 2, null); newDb.close(); };
await requestResult(factory.deleteDatabase(name));
} finally { Realm.IDBVersionChangeEvent = savedCtor; }
if (inspectionError) throw inspectionError;
same(label + ': native event order', seen,
['create', 'versionchange', 'blocked', 'upgrade', 'delete notification']);
for (const [name, value, oldVersion, newVersion] of nativeEvents) {
same(label + ': retained native ' + name, [get('oldVersion', value), get('newVersion', value)], [oldVersion, newVersion]);
}
};
await exercise('local', globalThis, globalThis);
if (typeof document !== 'undefined') {
const frame = document.createElement('iframe');
const loaded = new Promise(resolve => { frame.onload = resolve; });
frame.srcdoc = '<!doctype html>'; document.documentElement.appendChild(frame); await loaded;
try {
await exercise('foreign-getter', globalThis, frame.contentWindow);
await exercise('foreign-event', frame.contentWindow, globalThis);
} finally { frame.remove(); }
}
return {state: checks.every(check => check.pass) ? 'pass' : 'fail', checks};
}
@@ -1,6 +1,28 @@
use super::pipe_disturbed::run_probe;
use super::*;
#[tokio::test(flavor = "multi_thread")]
async fn indexed_db_version_change_events_preserve_native_attributes_and_dictionary_semantics()
-> Result<()> {
let server = FixtureServer::spawn().await?;
let browser = Browser::new(AppConfig::default())?;
let fixture = include_str!("fixtures/indexeddb-version-change.js");
for target in ["window", "child", "worker"] {
let source = format!(
"{fixture}\nversionChangeProbe('version-change-{target}').then(finish, error => finish({{state: 'error', error: String(error), checks: versionChangeChecks}}));"
);
let result = run_probe(&browser, &server, target, &source).await?;
assert_eq!(result["state"], "pass", "{target}: {result}");
assert_eq!(
result["checks"].as_array().unwrap().len(),
if target == "worker" { 128 } else { 384 },
"{target}: {result}"
);
}
server.shutdown().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn indexed_db_factory_checks_native_receivers_and_rejects_promises_in_callee_realm()
-> Result<()> {
@@ -938,7 +938,7 @@ pub(in crate::context_bootstrap) fn build_constructor_template<'s>(
web_api_interfaces::IDBVersionChangeEvent,
crate::context_bootstrap::indexed_db::idb_version_change_event_constructor_callback
))
.length(2)
.length(1)
.build(scope)
}
};
@@ -1,15 +1,62 @@
use super::super::super::events;
use super::*;
use crate::web_api_interfaces;
use crate::webidl;
use moli_webapi_declare::WebApiObject;
use moli_webapi_declare::{WebApiFunctionTemplate, WebApiObject};
const OLD_VERSION_SLOT: &str = "__moli_idb_event_old_version";
const NEW_VERSION_SLOT: &str = "__moli_idb_event_new_version";
#[derive(WebApiObject)]
#[webapi(plain, data_properties, enumerable)]
#[webapi(fragment)]
struct IdbVersionChangeEventFieldsDeclaration<'scope> {
#[webapi(slot = OLD_VERSION_SLOT)]
old_version: u64,
#[webapi(slot = NEW_VERSION_SLOT)]
new_version: v8::Local<'scope, v8::Value>,
}
#[derive(WebApiFunctionTemplate)]
#[webapi(interface = web_api_interfaces::IDBVersionChangeEvent, enumerable, receiver)]
struct IdbVersionChangeEventPrototypeDeclaration {
#[webapi(accessor_property, getter = old_version_getter)]
old_version: (),
#[webapi(accessor_property, getter = new_version_getter)]
new_version: (),
}
pub(in crate::context_bootstrap::indexed_db) fn install_version_change_event_template_bindings<
's,
>(
scope: &mut v8::PinScope<'s, '_, ()>,
prototype: v8::Local<'s, v8::ObjectTemplate>,
name: &str,
) {
if name == "IDBVersionChangeEvent" {
IdbVersionChangeEventPrototypeDeclaration::initialize_prototype_template(scope, prototype);
}
}
fn old_version_getter<'s>(
scope: &mut v8::PinScope<'s, '_>,
args: v8::FunctionCallbackArguments<'s>,
mut rv: v8::ReturnValue<'s, v8::Value>,
) {
if let Some(value) = events::event_private_value(scope, args.this(), OLD_VERSION_SLOT) {
rv.set(value);
}
}
fn new_version_getter<'s>(
scope: &mut v8::PinScope<'s, '_>,
args: v8::FunctionCallbackArguments<'s>,
mut rv: v8::ReturnValue<'s, v8::Value>,
) {
if let Some(value) = events::event_private_value(scope, args.this(), NEW_VERSION_SLOT) {
rv.set(value);
}
}
#[derive(webidl::WebIdlArgs)]
#[webidl(prefix = "IDBVersionChangeEvent")]
struct IdbVersionChangeEventConstructorArgs {
@@ -22,10 +69,18 @@ struct IdbVersionChangeEventConstructorArgs {
#[derive(Default, webidl::WebIdlDictionary)]
#[webidl(prefix = "IDBVersionChangeEventInit")]
struct IdbVersionChangeEventInit {
#[webidl(default = 0)]
old_version: u64,
// WebIDL reads inherited EventInit members first, then this dictionary's
// members in lexicographic order. The derive preserves declaration order.
#[webidl(default = false)]
bubbles: bool,
#[webidl(default = false)]
cancelable: bool,
#[webidl(default = false)]
composed: bool,
#[webidl(nullable)]
new_version: Option<u64>,
#[webidl(default = 0)]
old_version: u64,
}
pub(in crate::context_bootstrap) fn idb_version_change_event_constructor_callback<'s>(
@@ -46,7 +101,19 @@ pub(in crate::context_bootstrap) fn idb_version_change_event_constructor_callbac
};
let wrapper = args.this();
let event = events::new_event_state(scope);
events::initialize_event_object(scope, event, &parsed.event_type, false, false);
events::initialize_event_object(
scope,
event,
&parsed.event_type,
parsed.init.bubbles,
parsed.init.cancelable,
);
events::define_event_property(
scope,
event,
"composed",
v8::Boolean::new(scope, parsed.init.composed).into(),
);
IdbVersionChangeEventFieldsDeclaration::new(
parsed.init.old_version,
version_change_nullable_version_value(scope, parsed.init.new_version),
@@ -11,6 +11,11 @@ pub(in crate::context_bootstrap) fn install_indexed_db_template_bindings<'s>(
interface_name: &str,
) {
let prototype = template.prototype_template(scope);
super::super::event_target::install_version_change_event_template_bindings(
scope,
prototype,
interface_name,
);
install_dom_string_list_template_bindings(scope, prototype, interface_name);
factory_request::install_factory_and_request_template_bindings(
scope,