mirror of
https://github.com/lexmount/moli.git
synced 2026-11-14 16:07:36 +00:00
fix(indexeddb): preserve UTF-16 database name identity
Preserve database DOMString names as IndexedDbName code units across derived WebIDL conversion, native connection FIFO, SQL service identity, IDBDatabase.name and databases() results. Store database names as UTF-16BE BLOBs in SQLite schema v2. Migrate v1 TEXT metadata inside one SQL transaction after owner/name/generation validation, keeping records, stable ids, generators and logical quota accounting. Extend the existing generation-path hash with canonical WTF-8 so legacy Unicode directory paths remain unchanged. Retain the complete original Window/iframe/Worker conversion, independent-record/version, abort, deletion and cross-agent matrix. Add real disk-profile restart plus legacy migration/failure, exact queue identity, path compatibility and quota-rollback regressions. This database-name topic is independent of object-store/index rename and readonly schema attributes.
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
globalThis.databaseNameChecks = [];
|
||||
async function databaseNamesProbe(prefix = 'database-names') {
|
||||
const checks = databaseNameChecks;
|
||||
const check = (label, pass, actual = '') => checks.push({label, pass, actual: String(actual)});
|
||||
const units = name => Array.from({length: name.length}, (_, i) => name.charCodeAt(i)).join(',');
|
||||
const result = request => new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
const complete = tx => new Promise((resolve, reject) => {
|
||||
tx.oncomplete = resolve;
|
||||
tx.onabort = () => reject(tx.error || new Error('unexpected abort'));
|
||||
});
|
||||
const exercise = async (label, Realm, MethodRealm) => {
|
||||
const here = (name, pass, actual) => check(label + ': ' + name, pass, actual);
|
||||
const factory = Realm.indexedDB;
|
||||
const open = (...args) => MethodRealm.IDBFactory.prototype.open.apply(factory, args);
|
||||
const remove = (...args) => MethodRealm.IDBFactory.prototype.deleteDatabase.apply(factory, args);
|
||||
const list = () => MethodRealm.IDBFactory.prototype.databases.call(factory);
|
||||
const getName = Object.getOwnPropertyDescriptor(MethodRealm.IDBDatabase.prototype, 'name').get;
|
||||
const stem = prefix + '-' + label + '-';
|
||||
const names = ['', '\0', '\ud800', '\udfff', '\ufffd', '\ud801',
|
||||
'\udfff\ud800', '\ufffd\ufffd', '💾', 'd800', '\ud800\0'].map(s => stem + s);
|
||||
const retained = [];
|
||||
for (let i = 0; i < names.length; ++i) {
|
||||
const conversion = [];
|
||||
const name = {[Symbol.toPrimitive](hint) { conversion.push(hint); return names[i]; }};
|
||||
const version = {valueOf() { conversion.push('version'); return 1; }};
|
||||
const request = open(name, version);
|
||||
here('conversion order ' + i, conversion.join() === 'string,version', conversion);
|
||||
let oldVersion, upgradeName;
|
||||
request.onupgradeneeded = event => {
|
||||
oldVersion = event.oldVersion;
|
||||
upgradeName = getName.call(request.result);
|
||||
request.result.createObjectStore('records');
|
||||
};
|
||||
const db = await result(request);
|
||||
retained.push(db);
|
||||
here('upgrade name ' + i, upgradeName === names[i], upgradeName === undefined ? '' : units(upgradeName));
|
||||
here('independent creation ' + i, oldVersion === 0, oldVersion);
|
||||
here('open name ' + i, db.name === names[i], units(db.name));
|
||||
const tx = db.transaction('records', 'readwrite');
|
||||
const done = complete(tx);
|
||||
tx.objectStore('records').put(i, 'same-key');
|
||||
await done;
|
||||
db.close();
|
||||
here('closed name ' + i, getName.call(db) === names[i], units(getName.call(db)));
|
||||
}
|
||||
let listed = (await list()).filter(db => db.name.startsWith(stem));
|
||||
here('listing keeps every database', listed.length === names.length, listed.length);
|
||||
for (let i = 0; i < names.length; ++i) {
|
||||
here('listing units ' + i, listed.some(db => db.name === names[i] && db.version === 1));
|
||||
const db = await result(open(new Realm.String(names[i])));
|
||||
const tx = db.transaction('records');
|
||||
const done = complete(tx);
|
||||
const read = tx.objectStore('records').get('same-key');
|
||||
await done;
|
||||
here('independent records ' + i, read.result === i, read.result);
|
||||
db.close();
|
||||
}
|
||||
|
||||
const order = [];
|
||||
const marker = new Error('name conversion');
|
||||
let caught;
|
||||
try {
|
||||
open({toString() { order.push('name'); throw marker; }}, {valueOf() { order.push('version'); return 1; }});
|
||||
} catch (error) { caught = error; }
|
||||
here('name exception preserves identity and prevents version conversion', caught === marker && order.join() === 'name', order);
|
||||
for (const [method, callback] of [['open', open], ['deleteDatabase', remove]]) {
|
||||
let error;
|
||||
try { callback(Symbol('name')); } catch (caught) { error = caught; }
|
||||
here(method + ' rejects Symbol in callee realm', error !== undefined && Object.getPrototypeOf(error) === MethodRealm.TypeError.prototype, error?.name);
|
||||
}
|
||||
|
||||
const upgrading = open(names[2], 2);
|
||||
let upgradeOld;
|
||||
upgrading.onupgradeneeded = event => {
|
||||
upgradeOld = event.oldVersion;
|
||||
upgrading.transaction.objectStore('records').put('upgraded', 'same-key');
|
||||
};
|
||||
const upgraded = await result(upgrading);
|
||||
here('upgrade uses exact database', upgradeOld === 1 && upgraded.name === names[2], upgradeOld);
|
||||
upgraded.close();
|
||||
const aborted = open(names[3], 3);
|
||||
aborted.onupgradeneeded = () => {
|
||||
aborted.transaction.objectStore('records').put('aborted', 'same-key');
|
||||
aborted.transaction.abort();
|
||||
};
|
||||
let abortError;
|
||||
try { (await result(aborted)).close(); } catch (error) { abortError = error; }
|
||||
here('aborted upgrade reports AbortError', abortError?.name === 'AbortError', abortError?.name);
|
||||
listed = (await list()).filter(db => db.name.startsWith(stem));
|
||||
for (let i = 0; i < names.length; ++i) {
|
||||
here('versions remain independent ' + i, listed.some(db => db.name === names[i] && db.version === (i === 2 ? 2 : 1)));
|
||||
}
|
||||
const rollback = await result(open(names[3]));
|
||||
const readTx = rollback.transaction('records');
|
||||
const readDone = complete(readTx);
|
||||
const rolledBack = readTx.objectStore('records').get('same-key');
|
||||
await readDone;
|
||||
here('aborted data remains independent', rolledBack.result === 3, rolledBack.result);
|
||||
rollback.close();
|
||||
|
||||
// Keep a different surrogate name open. A mistaken identity match still
|
||||
// completes via onblocked so every engine reports the same assertions.
|
||||
const held = await result(open(names[3]));
|
||||
let notifications = 0;
|
||||
held.onversionchange = () => { ++notifications; };
|
||||
const deleting = remove({toString() { return names[2]; }});
|
||||
let blocked = false;
|
||||
deleting.onblocked = () => { blocked = true; held.close(); };
|
||||
await result(deleting);
|
||||
held.close();
|
||||
here('delete does not notify another database', notifications === 0, notifications);
|
||||
here('delete is not blocked by another database', !blocked, blocked);
|
||||
listed = (await list()).filter(db => db.name.startsWith(stem));
|
||||
here('delete removes only the exact database', !listed.some(db => db.name === names[2])
|
||||
&& listed.length === names.length - 1, listed.length);
|
||||
here('retained connection name survives deletion', getName.call(retained[2]) === names[2], units(getName.call(retained[2])));
|
||||
for (const name of names) await result(remove(name));
|
||||
here('cleanup removes all test databases', !(await list()).some(db => db.name.startsWith(stem)));
|
||||
};
|
||||
|
||||
await exercise('own', globalThis, globalThis);
|
||||
if (typeof document !== 'undefined') {
|
||||
const frame = document.createElement('iframe');
|
||||
const loaded = new Promise(resolve => { frame.onload = resolve; });
|
||||
frame.src = 'about:blank';
|
||||
document.documentElement.appendChild(frame);
|
||||
await loaded;
|
||||
try {
|
||||
await exercise('foreign-method', globalThis, frame.contentWindow);
|
||||
await exercise('foreign-factory', frame.contentWindow, globalThis);
|
||||
} finally { frame.remove(); }
|
||||
|
||||
const firstName = prefix + '-shared-\ud800';
|
||||
const secondName = prefix + '-shared-\udfff';
|
||||
for (const name of [firstName, secondName]) {
|
||||
const request = indexedDB.open(name, 1);
|
||||
request.onupgradeneeded = () => request.result.createObjectStore('records');
|
||||
(await result(request)).close();
|
||||
}
|
||||
const held = await result(indexedDB.open(firstName));
|
||||
let notices = 0;
|
||||
held.onversionchange = () => { ++notices; held.close(); };
|
||||
const source = `onmessage = async event => {
|
||||
const request = indexedDB.open(event.data, 2);
|
||||
let oldVersion;
|
||||
request.onupgradeneeded = event => { oldVersion = event.oldVersion; };
|
||||
request.onerror = () => postMessage({error: request.error.name});
|
||||
request.onsuccess = () => {
|
||||
const db = request.result;
|
||||
const name = db.name;
|
||||
db.close();
|
||||
postMessage({name, oldVersion});
|
||||
};
|
||||
};`;
|
||||
const url = URL.createObjectURL(new Blob([source], {type: 'text/javascript'}));
|
||||
const worker = new Worker(url);
|
||||
try {
|
||||
const reply = new Promise((resolve, reject) => {
|
||||
worker.onmessage = event => resolve(event.data);
|
||||
worker.onerror = event => reject(new Error(event.message));
|
||||
});
|
||||
worker.postMessage(secondName);
|
||||
const observed = await reply;
|
||||
check('worker upgrade preserves database name', observed.name === secondName && observed.oldVersion === 1, JSON.stringify(observed));
|
||||
check('worker upgrade does not notify a different Window connection', notices === 0, notices);
|
||||
held.close();
|
||||
const listed = await indexedDB.databases();
|
||||
check('Window sees independent versions after worker upgrade', listed.some(db => db.name === firstName && db.version === 1)
|
||||
&& listed.some(db => db.name === secondName && db.version === 2));
|
||||
} finally {
|
||||
held.close();
|
||||
worker.terminate();
|
||||
URL.revokeObjectURL(url);
|
||||
await result(indexedDB.deleteDatabase(firstName));
|
||||
await result(indexedDB.deleteDatabase(secondName));
|
||||
}
|
||||
}
|
||||
return {state: checks.every(check => check.pass) ? 'pass' : 'fail', checks};
|
||||
}
|
||||
@@ -500,3 +500,25 @@ async fn indexed_db_cursors_observe_writes_without_changing_pending_values() ->
|
||||
server.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn indexed_db_database_names_preserve_utf16_identity_across_realms_and_queues() -> Result<()>
|
||||
{
|
||||
let server = FixtureServer::spawn().await?;
|
||||
let browser = Browser::new(AppConfig::default())?;
|
||||
let fixture = include_str!("fixtures/indexeddb-database-names.js");
|
||||
for target in ["window", "child", "worker"] {
|
||||
let source = format!(
|
||||
"{fixture}\ndatabaseNamesProbe('database-names-{target}').then(finish, error => finish({{state: 'error', error: String(error), checks: databaseNameChecks}}));"
|
||||
);
|
||||
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" { 100 } else { 303 },
|
||||
"{target}: {result}"
|
||||
);
|
||||
}
|
||||
server.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ pub struct ConnectionRequestQueues {
|
||||
struct QueueState {
|
||||
closed: bool,
|
||||
next_id: u64,
|
||||
queues: BTreeMap<(String, String), VecDeque<QueuedRequest>>,
|
||||
queues: BTreeMap<(String, crate::IndexedDbName), VecDeque<QueuedRequest>>,
|
||||
}
|
||||
|
||||
struct QueuedRequest {
|
||||
@@ -31,7 +31,7 @@ struct QueuedRequest {
|
||||
#[derive(Clone)]
|
||||
pub struct ConnectionRequestHandle {
|
||||
queues: Weak<ConnectionRequestQueues>,
|
||||
key: (String, String),
|
||||
key: (String, crate::IndexedDbName),
|
||||
id: u64,
|
||||
}
|
||||
|
||||
@@ -65,6 +65,15 @@ impl ConnectionRequestQueues {
|
||||
storage_key: &str,
|
||||
name: &str,
|
||||
wake: ConnectionRequestWake,
|
||||
) -> Result<ConnectionRequestLease, crate::IndexedDbError> {
|
||||
self.try_enqueue_named(storage_key, &name.into(), wake)
|
||||
}
|
||||
|
||||
pub fn try_enqueue_named(
|
||||
self: &Arc<Self>,
|
||||
storage_key: &str,
|
||||
name: &crate::IndexedDbName,
|
||||
wake: ConnectionRequestWake,
|
||||
) -> Result<ConnectionRequestLease, crate::IndexedDbError> {
|
||||
let mut state = self.state.lock();
|
||||
if state.closed {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
CREATE TABLE database_metadata (
|
||||
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||
name BLOB NOT NULL CHECK (length(name) % 2 = 0),
|
||||
generation BLOB NOT NULL CHECK (length(generation) = 16),
|
||||
version INTEGER NOT NULL CHECK (version >= 0),
|
||||
state TEXT NOT NULL CHECK (state IN ('creating', 'live')),
|
||||
key_format INTEGER NOT NULL CHECK (key_format = 1),
|
||||
key_path_format INTEGER NOT NULL CHECK (key_path_format = 1),
|
||||
value_format INTEGER NOT NULL CHECK (value_format = 1),
|
||||
usage INTEGER NOT NULL CHECK (usage >= 0),
|
||||
next_store_id INTEGER NOT NULL DEFAULT 1 CHECK (next_store_id > 0),
|
||||
next_index_id INTEGER NOT NULL DEFAULT 1 CHECK (next_index_id > 0),
|
||||
next_record_id INTEGER NOT NULL DEFAULT 1 CHECK (next_record_id > 0),
|
||||
next_object_id INTEGER NOT NULL DEFAULT 1 CHECK (next_object_id > 0),
|
||||
CHECK (
|
||||
(state = 'creating' AND version = 0)
|
||||
OR (state = 'live' AND version > 0)
|
||||
)
|
||||
) STRICT, WITHOUT ROWID;
|
||||
@@ -5,6 +5,7 @@
|
||||
//! worker connection under object-store scope admission. No overlapping writer
|
||||
//! can modify a reader's stores. Records and rollback state stay in SQLite.
|
||||
|
||||
mod names;
|
||||
mod query;
|
||||
mod records;
|
||||
mod schema;
|
||||
@@ -27,7 +28,7 @@ use crate::{
|
||||
pub use query::{Query, QuerySource, Seek};
|
||||
pub use records::RecordWrite;
|
||||
|
||||
pub const SCHEMA_VERSION: u32 = 1;
|
||||
pub const SCHEMA_VERSION: u32 = 2;
|
||||
pub const CHUNK_BYTES: usize = 256 * 1024;
|
||||
const REQUEST_SAVEPOINT: &str = "SAVEPOINT idb_request";
|
||||
|
||||
@@ -60,7 +61,7 @@ impl QuotaCheck {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DatabaseIdentity {
|
||||
pub owner_key: Vec<u8>,
|
||||
pub name: String,
|
||||
pub name: crate::IndexedDbName,
|
||||
pub generation: [u8; 16],
|
||||
}
|
||||
|
||||
@@ -81,7 +82,7 @@ pub type StoreDefinitions = BTreeMap<String, StoreMetadata>;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct DatabaseMetadata {
|
||||
pub name: String,
|
||||
pub name: crate::IndexedDbName,
|
||||
pub version: u64,
|
||||
pub usage: u64,
|
||||
pub stores: Arc<StoreDefinitions>,
|
||||
@@ -169,7 +170,7 @@ impl Database {
|
||||
)?;
|
||||
transaction.execute(
|
||||
"INSERT INTO database_metadata(singleton, name, generation, version, state, key_format, key_path_format, value_format, usage) VALUES(1, ?1, ?2, 0, 'creating', 1, 1, 1, ?3)",
|
||||
rusqlite::params![identity.name, identity.generation.as_slice(), sql_integer(identity.name.len() as u64 + 8)?],
|
||||
rusqlite::params![names::encode(&identity.name)?, identity.generation.as_slice(), sql_integer(identity.name.usage_bytes() + 8)?],
|
||||
)?;
|
||||
transaction.commit()?;
|
||||
}
|
||||
@@ -185,25 +186,35 @@ impl Database {
|
||||
}
|
||||
|
||||
fn open_shared(
|
||||
connection: Connection,
|
||||
mut connection: Connection,
|
||||
identity: Arc<DatabaseIdentity>,
|
||||
) -> Result<Self, IndexedDbError> {
|
||||
moli_sqlite::require_schema_version(&connection, SCHEMA_VERSION)
|
||||
.map_err(|error| corruption(&error.to_string()))?;
|
||||
let version = names::schema_version(&connection)?;
|
||||
let saved: Option<bool> = connection.query_row(
|
||||
"SELECT owner_key=?1 FROM database_info WHERE singleton=1 AND backend='indexeddb' AND identity_format=1", [&identity.owner_key], |row| row.get(0),
|
||||
).optional()?;
|
||||
if saved != Some(true) {
|
||||
return Err(corruption("IndexedDB owner identity mismatch"));
|
||||
}
|
||||
let generation: bool = connection.query_row(
|
||||
"SELECT generation=?1 FROM database_metadata WHERE singleton=1 AND name=?2",
|
||||
rusqlite::params![identity.generation.as_slice(), identity.name],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if !generation {
|
||||
let mut statement = connection
|
||||
.prepare("SELECT name, generation FROM database_metadata WHERE singleton=1")?;
|
||||
let mut rows = statement.query([])?;
|
||||
let row = rows
|
||||
.next()?
|
||||
.ok_or_else(|| corruption("missing IndexedDB identity"))?;
|
||||
let name = names::read_name(row, 0, version)?;
|
||||
let generation = row
|
||||
.get_ref(1)?
|
||||
.as_blob()
|
||||
.map_err(|_| corruption("invalid IndexedDB generation"))?;
|
||||
if name != identity.name || generation != identity.generation.as_slice() {
|
||||
return Err(corruption("IndexedDB generation mismatch"));
|
||||
}
|
||||
drop(rows);
|
||||
drop(statement);
|
||||
if version == 1 {
|
||||
names::migrate_legacy(&mut connection, &identity.name)?;
|
||||
}
|
||||
let committed = schema::read_metadata(&connection)?;
|
||||
Ok(Self {
|
||||
connection,
|
||||
@@ -347,7 +358,7 @@ impl Database {
|
||||
}
|
||||
let usage: Option<i64> = self.connection.query_row(
|
||||
"SELECT usage FROM database_metadata WHERE singleton=1 AND name=?1 AND version=?2 AND state='live' AND key_format=1 AND key_path_format=1 AND value_format=1",
|
||||
rusqlite::params![self.committed.name, sql_integer(self.committed.version)?],
|
||||
rusqlite::params![names::encode(&self.committed.name)?, sql_integer(self.committed.version)?],
|
||||
|row| row.get(0),
|
||||
).optional()?;
|
||||
let usage = usage
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
//! Database names use UTF-16BE BLOBs in schema v2. Store/index names retain
|
||||
//! their schema-v1 representation until their independent name migrations.
|
||||
use super::*;
|
||||
use crate::IndexedDbName;
|
||||
|
||||
pub(super) fn schema_version(connection: &Connection) -> Result<u32, IndexedDbError> {
|
||||
let version: i64 = connection.pragma_query_value(None, "user_version", |row| row.get(0))?;
|
||||
match version {
|
||||
1 | 2 => Ok(version as u32),
|
||||
_ => Err(corruption("unsupported IndexedDB SQL schema version")),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn encode(name: &IndexedDbName) -> Result<Vec<u8>, IndexedDbError> {
|
||||
let len = name
|
||||
.as_utf16()
|
||||
.len()
|
||||
.checked_mul(2)
|
||||
.ok_or_else(|| IndexedDbError::Resource("IndexedDB name size overflow".into()))?;
|
||||
let mut bytes = Vec::new();
|
||||
bytes
|
||||
.try_reserve_exact(len)
|
||||
.map_err(|_| IndexedDbError::Resource("Unable to allocate IndexedDB name".into()))?;
|
||||
for unit in name.as_utf16() {
|
||||
bytes.extend_from_slice(&unit.to_be_bytes());
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
pub(super) fn decode(bytes: &[u8]) -> Result<IndexedDbName, IndexedDbError> {
|
||||
if !bytes.len().is_multiple_of(2) {
|
||||
return Err(corruption("invalid UTF-16 database name"));
|
||||
}
|
||||
let mut units = Vec::new();
|
||||
units
|
||||
.try_reserve_exact(bytes.len() / 2)
|
||||
.map_err(|_| IndexedDbError::Resource("Unable to allocate IndexedDB name".into()))?;
|
||||
for pair in bytes.chunks_exact(2) {
|
||||
units.push(u16::from_be_bytes([pair[0], pair[1]]));
|
||||
}
|
||||
Ok(IndexedDbName::from_utf16(units))
|
||||
}
|
||||
|
||||
pub(super) fn read_name(
|
||||
row: &rusqlite::Row<'_>,
|
||||
column: usize,
|
||||
version: u32,
|
||||
) -> Result<IndexedDbName, IndexedDbError> {
|
||||
match (version, row.get_ref(column)?) {
|
||||
(1, rusqlite::types::ValueRef::Text(bytes)) => {
|
||||
let text = std::str::from_utf8(bytes)
|
||||
.map_err(|_| corruption("invalid legacy database name"))?;
|
||||
let mut units = Vec::new();
|
||||
units.try_reserve_exact(text.len()).map_err(|_| {
|
||||
IndexedDbError::Resource("Unable to allocate IndexedDB name".into())
|
||||
})?;
|
||||
units.extend(text.encode_utf16());
|
||||
Ok(IndexedDbName::from_utf16(units))
|
||||
}
|
||||
(2, rusqlite::types::ValueRef::Blob(bytes)) => decode(bytes),
|
||||
_ => Err(corruption("invalid IndexedDB database name column")),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn copy(name: &IndexedDbName) -> Result<IndexedDbName, IndexedDbError> {
|
||||
let mut units = Vec::new();
|
||||
units
|
||||
.try_reserve_exact(name.as_utf16().len())
|
||||
.map_err(|_| IndexedDbError::Resource("Unable to allocate IndexedDB name".into()))?;
|
||||
units.extend_from_slice(name.as_utf16());
|
||||
Ok(IndexedDbName::from_utf16(units))
|
||||
}
|
||||
|
||||
/// Called only after the legacy owner/name/generation have been validated.
|
||||
/// Table replacement, name conversion and format publication share one SQL
|
||||
/// transaction. Records and all stable ids are left in their existing tables.
|
||||
pub(super) fn migrate_legacy(
|
||||
connection: &mut Connection,
|
||||
name: &IndexedDbName,
|
||||
) -> Result<(), IndexedDbError> {
|
||||
let name = encode(name)?;
|
||||
let transaction =
|
||||
connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
|
||||
transaction.execute_batch("ALTER TABLE database_metadata RENAME TO database_metadata_v1")?;
|
||||
transaction.execute_batch(include_str!("migrations/v2_metadata.sql"))?;
|
||||
transaction.execute(
|
||||
"INSERT INTO database_metadata SELECT singleton, ?1, generation, version, state, key_format, key_path_format, value_format, usage, next_store_id, next_index_id, next_record_id, next_object_id FROM database_metadata_v1",
|
||||
[name],
|
||||
)?;
|
||||
transaction.execute_batch("DROP TABLE database_metadata_v1; PRAGMA user_version=2")?;
|
||||
transaction.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -19,7 +19,7 @@ pub(super) fn read_metadata(
|
||||
if version < 0 || usage < 0 || !matches!((state, version), ("creating", 0) | ("live", 1..)) {
|
||||
return Err(corruption("invalid IndexedDB database metadata"));
|
||||
}
|
||||
let name = owned_text(text(row, 0)?)?;
|
||||
let name = names::read_name(row, 0, SCHEMA_VERSION)?;
|
||||
drop(rows);
|
||||
drop(statement);
|
||||
let mut stores = BTreeMap::new();
|
||||
@@ -94,7 +94,7 @@ pub(super) fn read_metadata(
|
||||
|
||||
impl DatabaseMetadata {
|
||||
pub(super) fn with_usage(&self, usage: u64) -> Result<Arc<Self>, IndexedDbError> {
|
||||
let name = owned_text(&self.name)?;
|
||||
let name = names::copy(&self.name)?;
|
||||
Ok(Arc::new(Self {
|
||||
name,
|
||||
usage,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
PRAGMA user_version = 1;
|
||||
PRAGMA user_version = 2;
|
||||
|
||||
CREATE TABLE database_info (
|
||||
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||
@@ -9,7 +9,7 @@ CREATE TABLE database_info (
|
||||
|
||||
CREATE TABLE database_metadata (
|
||||
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||
name TEXT NOT NULL,
|
||||
name BLOB NOT NULL CHECK (length(name) % 2 = 0),
|
||||
generation BLOB NOT NULL CHECK (length(generation) = 16),
|
||||
version INTEGER NOT NULL CHECK (version >= 0),
|
||||
state TEXT NOT NULL CHECK (state IN ('creating', 'live')),
|
||||
|
||||
@@ -200,7 +200,7 @@ struct Lane {
|
||||
struct Registry {
|
||||
closed: bool,
|
||||
next_id: u64,
|
||||
entries: BTreeMap<(Vec<u8>, String), DatabaseClient>,
|
||||
entries: BTreeMap<(Vec<u8>, crate::IndexedDbName), DatabaseClient>,
|
||||
clearing: BTreeMap<clear::ClearScope, clear::ClearingOwner>,
|
||||
}
|
||||
|
||||
@@ -304,7 +304,7 @@ impl ServiceClient {
|
||||
fn register_client(
|
||||
&self,
|
||||
registry: &mut Registry,
|
||||
key: (Vec<u8>, String),
|
||||
key: (Vec<u8>, crate::IndexedDbName),
|
||||
generation: [u8; 16],
|
||||
location: Location,
|
||||
catalog: Option<Arc<catalog::DiskCatalog>>,
|
||||
|
||||
@@ -80,6 +80,15 @@ impl ServiceClient {
|
||||
owner_key: Vec<u8>,
|
||||
name: String,
|
||||
completion: impl FnOnce(Completion) + Send + 'static,
|
||||
) -> Result<DatabaseClient, DispatchError> {
|
||||
self.resolve_named(owner_key, name.into(), completion)
|
||||
}
|
||||
|
||||
pub fn resolve_named(
|
||||
&self,
|
||||
owner_key: Vec<u8>,
|
||||
name: crate::IndexedDbName,
|
||||
completion: impl FnOnce(Completion) + Send + 'static,
|
||||
) -> Result<DatabaseClient, DispatchError> {
|
||||
if owner_key.is_empty() {
|
||||
return Err(DispatchError::Storage(IndexedDbError::Data(
|
||||
@@ -166,9 +175,7 @@ impl ServiceClient {
|
||||
};
|
||||
databases.push(metadata);
|
||||
}
|
||||
databases.sort_unstable_by(|left, right| {
|
||||
left.name.encode_utf16().cmp(right.name.encode_utf16())
|
||||
});
|
||||
databases.sort_unstable_by(|left, right| left.name.cmp(&right.name));
|
||||
Ok(databases)
|
||||
}
|
||||
|
||||
@@ -176,6 +183,14 @@ impl ServiceClient {
|
||||
&self,
|
||||
owner_key: &[u8],
|
||||
name: &str,
|
||||
) -> Result<Option<u64>, IndexedDbError> {
|
||||
self.database_version_named(owner_key, &name.into())
|
||||
}
|
||||
|
||||
pub fn database_version_named(
|
||||
&self,
|
||||
owner_key: &[u8],
|
||||
name: &crate::IndexedDbName,
|
||||
) -> Result<Option<u64>, IndexedDbError> {
|
||||
let registry = self.registry.lock();
|
||||
if registry.closed {
|
||||
@@ -335,8 +350,7 @@ fn inspect_header(
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
moli_sqlite::require_schema_version(&connection, SCHEMA_VERSION)
|
||||
.map_err(|error| corruption(&error.to_string()))?;
|
||||
let version = names::schema_version(&connection)?;
|
||||
let mut statement = connection.prepare("SELECT i.owner_key, m.name, m.generation FROM database_info i JOIN database_metadata m ON m.singleton=i.singleton WHERE i.singleton=1 AND i.backend='indexeddb' AND i.identity_format=1")?;
|
||||
let mut rows = statement.query([])?;
|
||||
let row = rows
|
||||
@@ -346,10 +360,7 @@ fn inspect_header(
|
||||
.get_ref(0)?
|
||||
.as_blob()
|
||||
.map_err(|_| corruption("invalid IndexedDB owner"))?;
|
||||
let name = row
|
||||
.get_ref(1)?
|
||||
.as_str()
|
||||
.map_err(|_| corruption("invalid IndexedDB name"))?;
|
||||
let name = names::read_name(row, 1, version)?;
|
||||
let generation: [u8; 16] = row
|
||||
.get_ref(2)?
|
||||
.as_blob()
|
||||
@@ -358,7 +369,7 @@ fn inspect_header(
|
||||
.map_err(|_| corruption("invalid IndexedDB generation length"))?;
|
||||
let identity = DatabaseIdentity {
|
||||
owner_key: owner.to_vec(),
|
||||
name: name.to_owned(),
|
||||
name,
|
||||
generation,
|
||||
};
|
||||
drop(rows);
|
||||
|
||||
@@ -112,11 +112,16 @@ impl DiskCatalog {
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn path(&self, owner: &[u8], name: &str, generation: [u8; 16]) -> PathBuf {
|
||||
pub(super) fn path(
|
||||
&self,
|
||||
owner: &[u8],
|
||||
name: &crate::IndexedDbName,
|
||||
generation: [u8; 16],
|
||||
) -> PathBuf {
|
||||
self.root
|
||||
.join("live")
|
||||
.join(hash(1, owner))
|
||||
.join(hash(2, name.as_bytes()))
|
||||
.join(hash_database_name(name))
|
||||
.join(hex(&generation))
|
||||
.join("db.sqlite")
|
||||
}
|
||||
@@ -451,3 +456,62 @@ pub(super) fn hex(bytes: &[u8]) -> String {
|
||||
pub(super) fn io_error(action: &str, path: &Path, error: std::io::Error) -> IndexedDbError {
|
||||
IndexedDbError::Io(format!("IndexedDB {action} `{}`: {error}", path.display()))
|
||||
}
|
||||
|
||||
/// Keep every schema-v1 Unicode name in its original generation directory.
|
||||
/// Canonical WTF-8 extends the same hash domain to unpaired surrogates without
|
||||
/// projecting distinct DOMStrings onto the same replacement character.
|
||||
fn hash_database_name(name: &crate::IndexedDbName) -> String {
|
||||
let mut hash = moli_crypto::Sha256Context::new();
|
||||
hash.update(b"moli-indexeddb-path-v1\0");
|
||||
hash.update([2]);
|
||||
hash.update(name.usage_bytes().to_be_bytes());
|
||||
for character in char::decode_utf16(name.as_utf16().iter().copied()) {
|
||||
match character {
|
||||
Ok(character) => {
|
||||
let mut bytes = [0; 4];
|
||||
hash.update(character.encode_utf8(&mut bytes).as_bytes());
|
||||
}
|
||||
Err(error) => {
|
||||
let surrogate = error.unpaired_surrogate();
|
||||
hash.update([
|
||||
0xe0 | (surrogate >> 12) as u8,
|
||||
0x80 | ((surrogate >> 6) & 0x3f) as u8,
|
||||
0x80 | (surrogate & 0x3f) as u8,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
hex(&hash.finish())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod database_name_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn database_name_paths_keep_legacy_unicode_hashes_and_distinguish_surrogates() {
|
||||
for text in [
|
||||
"",
|
||||
"\0",
|
||||
"ordinary",
|
||||
"\u{1f4be}",
|
||||
"\u{e000}",
|
||||
"legacy-\0-数据库-\u{1f4be}",
|
||||
] {
|
||||
assert_eq!(hash_database_name(&text.into()), hash(2, text.as_bytes()));
|
||||
}
|
||||
let names = [
|
||||
vec![0xd800],
|
||||
vec![0xd801],
|
||||
vec![0xfffd],
|
||||
vec![0x64, 0x38, 0x30, 0x30],
|
||||
vec![0xdfff, 0xd800],
|
||||
vec![0xfffd, 0xfffd],
|
||||
];
|
||||
let hashes = names
|
||||
.into_iter()
|
||||
.map(|units| hash_database_name(&crate::IndexedDbName::from_utf16(units)))
|
||||
.collect::<std::collections::BTreeSet<_>>();
|
||||
assert_eq!(hashes.len(), 6);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +109,15 @@ impl ServiceClient {
|
||||
owner_key: &[u8],
|
||||
name: &str,
|
||||
wake: crate::ConnectionRequestWake,
|
||||
) -> Result<crate::ConnectionRequestLease, IndexedDbError> {
|
||||
self.queue_connection_request_named(owner_key, &name.into(), wake)
|
||||
}
|
||||
|
||||
pub fn queue_connection_request_named(
|
||||
&self,
|
||||
owner_key: &[u8],
|
||||
name: &crate::IndexedDbName,
|
||||
wake: crate::ConnectionRequestWake,
|
||||
) -> Result<crate::ConnectionRequestLease, IndexedDbError> {
|
||||
let registry = self.registry.lock();
|
||||
if registry.closed {
|
||||
@@ -116,7 +125,8 @@ impl ServiceClient {
|
||||
}
|
||||
registry.check_owner(owner_key)?;
|
||||
let owner = request_owner_key(owner_key)?;
|
||||
self.connection_requests.try_enqueue(&owner, name, wake)
|
||||
self.connection_requests
|
||||
.try_enqueue_named(&owner, name, wake)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ fn config() -> ServiceConfig {
|
||||
}
|
||||
}
|
||||
|
||||
fn identity(name: &str) -> DatabaseIdentity {
|
||||
fn identity(name: impl Into<crate::IndexedDbName>) -> DatabaseIdentity {
|
||||
DatabaseIdentity {
|
||||
owner_key: b"partition/default/storage-key/top-site/bucket-17".to_vec(),
|
||||
name: name.into(),
|
||||
@@ -36,7 +36,11 @@ fn receive(receiver: &mpsc::Receiver<Completion>) -> Completion {
|
||||
.expect("SQL worker must complete without waiting for the JS owner")
|
||||
}
|
||||
|
||||
fn register(service: &Service, name: &str, location: Location) -> (DatabaseClient, Completion) {
|
||||
fn register(
|
||||
service: &Service,
|
||||
name: impl Into<crate::IndexedDbName>,
|
||||
location: Location,
|
||||
) -> (DatabaseClient, Completion) {
|
||||
let (sender, receiver) = mpsc::channel();
|
||||
let client = service
|
||||
.open(identity(name), location, move |reply| {
|
||||
@@ -46,7 +50,11 @@ fn register(service: &Service, name: &str, location: Location) -> (DatabaseClien
|
||||
(client, receive(&receiver))
|
||||
}
|
||||
|
||||
fn open(service: &Service, name: &str, location: Location) -> DatabaseClient {
|
||||
fn open(
|
||||
service: &Service,
|
||||
name: impl Into<crate::IndexedDbName>,
|
||||
location: Location,
|
||||
) -> DatabaseClient {
|
||||
let (client, reply) = register(service, name, location);
|
||||
assert!(matches!(reply.result.unwrap(), CommandResult::Metadata(_)));
|
||||
client
|
||||
@@ -56,7 +64,7 @@ fn open(service: &Service, name: &str, location: Location) -> DatabaseClient {
|
||||
fn registration_accepts_more_than_1024_databases_and_reuses_each_owner() {
|
||||
let service = Service::new(config()).unwrap();
|
||||
let clients = (0..1030)
|
||||
.map(|id| open(&service, &format!("registration-{id}"), Location::Memory))
|
||||
.map(|id| open(&service, format!("registration-{id}"), Location::Memory))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(service.registry.lock().entries.len(), clients.len());
|
||||
let first = open(&service, "registration-0", Location::Memory);
|
||||
@@ -567,7 +575,12 @@ fn private_memory_databases_survive_more_than_eight_connections_on_one_worker()
|
||||
assert_eq!(records.len(), 1);
|
||||
assert_eq!(
|
||||
records[0].value,
|
||||
Some(client.identity().name.as_bytes().to_vec().into())
|
||||
Some(
|
||||
String::from_utf16(client.identity().name.as_utf16())
|
||||
.unwrap()
|
||||
.into_bytes()
|
||||
.into()
|
||||
)
|
||||
);
|
||||
execute(
|
||||
&reopened,
|
||||
@@ -662,7 +675,12 @@ fn active_disk_transactions_allow_more_than_eight_databases_on_one_worker() {
|
||||
if let Some(record) = records.first() {
|
||||
assert_eq!(
|
||||
record.value,
|
||||
Some(client.identity().name.as_bytes().to_vec().into())
|
||||
Some(
|
||||
String::from_utf16(client.identity().name.as_utf16())
|
||||
.unwrap()
|
||||
.into_bytes()
|
||||
.into()
|
||||
)
|
||||
);
|
||||
}
|
||||
execute(
|
||||
|
||||
@@ -156,7 +156,7 @@ fn catalog_lists_committed_metadata_and_snapshots_survive_upgrade_and_shutdown()
|
||||
assert_eq!(
|
||||
snapshot
|
||||
.iter()
|
||||
.map(|metadata| metadata.name.as_str())
|
||||
.map(|metadata| String::from_utf16(metadata.name.as_utf16()).unwrap())
|
||||
.collect::<Vec<_>>(),
|
||||
["\u{10000}", "\u{e000}"]
|
||||
);
|
||||
@@ -505,7 +505,9 @@ fn catalog_refuses_a_second_owner_and_unsupported_sql_schema_without_erasing_dat
|
||||
let file = path(&client);
|
||||
let connection =
|
||||
moli_sqlite::open_disk(&file, OpenMode::Existing, config().connection).unwrap();
|
||||
connection.pragma_update(None, "user_version", 2).unwrap();
|
||||
connection
|
||||
.pragma_update(None, "user_version", SCHEMA_VERSION + 1)
|
||||
.unwrap();
|
||||
connection.close().unwrap();
|
||||
assert!(Service::on_disk(root.clone(), config()).is_err());
|
||||
let connection =
|
||||
@@ -514,9 +516,11 @@ fn catalog_refuses_a_second_owner_and_unsupported_sql_schema_without_erasing_dat
|
||||
connection
|
||||
.pragma_query_value(None, "user_version", |row| row.get::<_, i64>(0))
|
||||
.unwrap(),
|
||||
2
|
||||
i64::from(SCHEMA_VERSION + 1)
|
||||
);
|
||||
connection.pragma_update(None, "user_version", 1).unwrap();
|
||||
connection
|
||||
.pragma_update(None, "user_version", SCHEMA_VERSION)
|
||||
.unwrap();
|
||||
connection.close().unwrap();
|
||||
let recovered = Service::on_disk(root, config()).unwrap();
|
||||
verify_record(&resolve(&recovered, OWNER, "protected"));
|
||||
@@ -633,3 +637,269 @@ fn catalog_root_initialization_is_retryable_but_never_fills_holes_in_published_o
|
||||
b"legacy or foreign"
|
||||
);
|
||||
}
|
||||
|
||||
fn resolve_dom_name(service: &Service, name: crate::IndexedDbName) -> DatabaseClient {
|
||||
let (sender, receiver) = mpsc::channel();
|
||||
let client = service
|
||||
.resolve_named(OWNER.to_vec(), name, move |reply| {
|
||||
let _ = sender.send(reply);
|
||||
})
|
||||
.unwrap();
|
||||
receive(&receiver).result.unwrap();
|
||||
client
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_utf16_database_names_survive_recovery_without_path_or_identity_collisions() {
|
||||
let directory = Directory::new();
|
||||
let root = directory.0.join("idb-lite");
|
||||
let mut names = [
|
||||
vec![],
|
||||
vec![0],
|
||||
vec![0xd800],
|
||||
vec![0xd801],
|
||||
vec![0xfffd],
|
||||
vec![0xd800, 0xdc00],
|
||||
vec![0xdc00, 0xd800],
|
||||
vec![0xd800, 0],
|
||||
]
|
||||
.into_iter()
|
||||
.map(crate::IndexedDbName::from_utf16)
|
||||
.collect::<Vec<_>>();
|
||||
names.sort();
|
||||
let mut first_paths = None;
|
||||
for write in [true, false] {
|
||||
let mut service = Service::on_disk(root.clone(), config()).unwrap();
|
||||
let mut paths = Vec::new();
|
||||
for name in &names {
|
||||
let client = resolve_dom_name(&service, name.clone());
|
||||
if write {
|
||||
seed(&client);
|
||||
}
|
||||
verify_record(&client);
|
||||
assert_eq!(&client.identity().name, name);
|
||||
assert_eq!(
|
||||
service.database_version_named(OWNER, name).unwrap(),
|
||||
Some(1)
|
||||
);
|
||||
paths.push(path(&client));
|
||||
}
|
||||
assert_eq!(
|
||||
service
|
||||
.databases(OWNER)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|metadata| metadata.name.clone())
|
||||
.collect::<Vec<_>>(),
|
||||
names
|
||||
);
|
||||
assert_eq!(
|
||||
paths
|
||||
.iter()
|
||||
.collect::<std::collections::BTreeSet<_>>()
|
||||
.len(),
|
||||
names.len()
|
||||
);
|
||||
if let Some(first) = &first_paths {
|
||||
assert_eq!(&paths, first);
|
||||
} else {
|
||||
first_paths = Some(paths);
|
||||
}
|
||||
service.shutdown().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf16_connection_requests_share_only_the_exact_code_unit_name() {
|
||||
let service = Service::new(config()).unwrap();
|
||||
let surrogate = crate::IndexedDbName::from_utf16(vec![0xd800]);
|
||||
let replacement = crate::IndexedDbName::from("\u{fffd}");
|
||||
let first = service
|
||||
.queue_connection_request_named(OWNER, &surrogate, Arc::new(|| {}))
|
||||
.unwrap();
|
||||
let second = service
|
||||
.queue_connection_request_named(OWNER, &surrogate, Arc::new(|| {}))
|
||||
.unwrap();
|
||||
let independent = service
|
||||
.queue_connection_request_named(OWNER, &replacement, Arc::new(|| {}))
|
||||
.unwrap();
|
||||
assert!(first.handle().is_head());
|
||||
assert!(!second.handle().is_head());
|
||||
assert!(independent.handle().is_head());
|
||||
drop(first);
|
||||
assert!(second.handle().is_head());
|
||||
assert!(independent.handle().is_head());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf16_database_names_keep_transaction_admission_separate() {
|
||||
let service = Service::new(config()).unwrap();
|
||||
let clients = [0xd800, 0xdfff, 0xfffd].map(|unit| {
|
||||
let client = resolve_dom_name(&service, crate::IndexedDbName::from_utf16(vec![unit]));
|
||||
initialize(&client);
|
||||
client
|
||||
});
|
||||
let leases = clients
|
||||
.each_ref()
|
||||
.map(|client| queue(client, "a", TransactionMode::ReadWrite));
|
||||
let same = queue(&clients[0], "a", TransactionMode::ReadWrite);
|
||||
assert!(leases.iter().all(|lease| lease.handle().is_ready()));
|
||||
assert!(!same.handle().is_ready());
|
||||
for (index, (client, lease)) in clients.iter().zip(leases).enumerate() {
|
||||
start(client, "a", TransactionMode::ReadWrite, &lease);
|
||||
write(client, &[index as u8]);
|
||||
execute(
|
||||
client,
|
||||
Command::Commit {
|
||||
transaction: TX,
|
||||
quota: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
assert!(same.handle().is_ready());
|
||||
start(&clients[0], "a", TransactionMode::ReadWrite, &same);
|
||||
assert_eq!(query(&clients[0])[0].value, Some(vec![0].into()));
|
||||
execute(
|
||||
&clients[0],
|
||||
Command::Commit {
|
||||
transaction: TX,
|
||||
quota: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf16_database_name_quota_rollback_preserves_other_database_versions_and_usage() {
|
||||
let service = Service::new(config()).unwrap();
|
||||
let clients = [0xd800, 0xdfff, 0xfffd].map(|unit| {
|
||||
let client = resolve_dom_name(&service, crate::IndexedDbName::from_utf16(vec![unit]));
|
||||
execute(
|
||||
&client,
|
||||
Command::Begin {
|
||||
transaction: TX,
|
||||
mode: TransactionMode::VersionChange,
|
||||
stores: BTreeSet::new(),
|
||||
version: Some(1),
|
||||
admission: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
execute(
|
||||
&client,
|
||||
Command::Commit {
|
||||
transaction: TX,
|
||||
quota: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
client
|
||||
});
|
||||
assert_eq!(service.owner_usage_bytes(OWNER).unwrap(), 33);
|
||||
let client = &clients[0];
|
||||
execute(
|
||||
client,
|
||||
Command::Begin {
|
||||
transaction: TX,
|
||||
mode: TransactionMode::VersionChange,
|
||||
stores: BTreeSet::new(),
|
||||
version: Some(2),
|
||||
admission: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
execute(
|
||||
client,
|
||||
Command::CreateStore {
|
||||
transaction: TX,
|
||||
name: "new".into(),
|
||||
options: Default::default(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
execute(
|
||||
client,
|
||||
Command::Commit {
|
||||
transaction: TX,
|
||||
quota: Some(QuotaCheck {
|
||||
quota: 33,
|
||||
other_usage: 22
|
||||
})
|
||||
}
|
||||
),
|
||||
Err(IndexedDbError::QuotaExceeded { .. })
|
||||
));
|
||||
assert_eq!(service.owner_usage_bytes(OWNER).unwrap(), 33);
|
||||
assert!(
|
||||
service
|
||||
.databases(OWNER)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|metadata| metadata.version == 1)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_recovers_legacy_text_names_in_the_existing_generation_directory() {
|
||||
let directory = Directory::new();
|
||||
let root = directory.0.join("idb-lite");
|
||||
let legacy_name = "legacy-\0-数据库-\u{1f4be}";
|
||||
let mut service = Service::on_disk(root.clone(), config()).unwrap();
|
||||
let client = resolve(&service, OWNER, legacy_name);
|
||||
seed(&client);
|
||||
let file = path(&client);
|
||||
let generation = client.identity().generation;
|
||||
service.shutdown().unwrap();
|
||||
// Recreate the exact v1 metadata representation while keeping this actual
|
||||
// generation's records, store ids and allocator counters untouched.
|
||||
let mut connection = Connection::open(&file).unwrap();
|
||||
let transaction = connection.transaction().unwrap();
|
||||
transaction
|
||||
.execute_batch("ALTER TABLE database_metadata RENAME TO database_metadata_v2")
|
||||
.unwrap();
|
||||
let schema = include_str!("../../tests/fixtures/schema-v1.sql");
|
||||
let metadata = schema
|
||||
.split_once("CREATE TABLE database_metadata")
|
||||
.unwrap()
|
||||
.1
|
||||
.split_once("CREATE TABLE object_stores")
|
||||
.unwrap()
|
||||
.0;
|
||||
transaction
|
||||
.execute_batch(&format!("CREATE TABLE database_metadata{metadata}"))
|
||||
.unwrap();
|
||||
transaction.execute("INSERT INTO database_metadata SELECT singleton,?1,generation,version,state,key_format,key_path_format,value_format,usage,next_store_id,next_index_id,next_record_id,next_object_id FROM database_metadata_v2",[legacy_name]).unwrap();
|
||||
transaction
|
||||
.execute_batch("DROP TABLE database_metadata_v2; PRAGMA user_version=1")
|
||||
.unwrap();
|
||||
transaction.commit().unwrap();
|
||||
drop(connection);
|
||||
let mut service = Service::on_disk(root, config()).unwrap();
|
||||
let client = resolve(&service, OWNER, legacy_name);
|
||||
assert_eq!(
|
||||
client.identity().name,
|
||||
crate::IndexedDbName::from(legacy_name)
|
||||
);
|
||||
assert_eq!(client.identity().generation, generation);
|
||||
assert_eq!(path(&client), file);
|
||||
verify_record(&client);
|
||||
service.shutdown().unwrap();
|
||||
let connection = Connection::open(&file).unwrap();
|
||||
assert_eq!(
|
||||
connection
|
||||
.pragma_query_value(None, "user_version", |row| row.get::<_, u32>(0))
|
||||
.unwrap(),
|
||||
2
|
||||
);
|
||||
assert_eq!(
|
||||
connection
|
||||
.query_row("SELECT typeof(name) FROM database_metadata", [], |row| {
|
||||
row.get::<_, String>(0)
|
||||
})
|
||||
.unwrap(),
|
||||
"blob"
|
||||
);
|
||||
drop(connection);
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ fn registration_identity_is_released_independently_of_retained_schema() {
|
||||
assert_eq!(service.registry.lock().entries.len(), 1);
|
||||
drop(client);
|
||||
assert!(identity.upgrade().is_none());
|
||||
assert_eq!(held_snapshot.name, "old");
|
||||
assert_eq!(held_snapshot.name, crate::IndexedDbName::from("old"));
|
||||
drop(held_snapshot);
|
||||
drop(other);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
mod database_names;
|
||||
mod schema_rename;
|
||||
mod storage_contracts;
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
use super::*;
|
||||
use crate::IndexedDbName;
|
||||
|
||||
struct Directory(std::path::PathBuf);
|
||||
impl Directory {
|
||||
fn new() -> Self {
|
||||
let mut token = [0; 16];
|
||||
moli_crypto::fill_secure_random(&mut token).unwrap();
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"moli-idb-name-migration-{}",
|
||||
moli_crypto::sha256_hex(token)
|
||||
));
|
||||
std::fs::create_dir(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
fn path(&self) -> std::path::PathBuf {
|
||||
self.0.join("database.sqlite")
|
||||
}
|
||||
}
|
||||
impl Drop for Directory {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn legacy_database(directory: &Directory) -> DatabaseIdentity {
|
||||
let mut identity = identity();
|
||||
identity.name = "legacy-\0-数据库-\u{1f4be}".into();
|
||||
let mut connection = moli_sqlite::open_disk(
|
||||
&directory.path(),
|
||||
moli_sqlite::OpenMode::Create,
|
||||
Default::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let transaction = connection.transaction().unwrap();
|
||||
transaction
|
||||
.execute_batch(include_str!("fixtures/schema-v1.sql"))
|
||||
.unwrap();
|
||||
transaction
|
||||
.execute(
|
||||
"INSERT INTO database_info VALUES(1, 'indexeddb', ?1, 1)",
|
||||
[&identity.owner_key],
|
||||
)
|
||||
.unwrap();
|
||||
let options = ObjectStoreOptions {
|
||||
auto_increment: true,
|
||||
..Default::default()
|
||||
};
|
||||
let usage = identity.name.usage_bytes() + 8 + schema::store_usage("a", &options) + 12;
|
||||
transaction.execute(
|
||||
"INSERT INTO database_metadata(singleton,name,generation,version,state,key_format,key_path_format,value_format,usage,next_store_id,next_record_id) VALUES(1,?1,?2,1,'live',1,1,1,?3,4,8)",
|
||||
rusqlite::params![String::from_utf16(identity.name.as_utf16()).unwrap(), identity.generation.as_slice(), usage as i64],
|
||||
).unwrap();
|
||||
transaction
|
||||
.execute("INSERT INTO object_stores VALUES(3,'a',NULL,1,7)", [])
|
||||
.unwrap();
|
||||
let key = Key::from(5);
|
||||
transaction
|
||||
.execute(
|
||||
"INSERT INTO records VALUES(7,3,?1,?2,4,12)",
|
||||
rusqlite::params![key.encode_ordered().unwrap(), key.encode_value().unwrap()],
|
||||
)
|
||||
.unwrap();
|
||||
transaction
|
||||
.execute(
|
||||
"INSERT INTO record_chunks VALUES(7,0,?1)",
|
||||
[b"keep".as_slice()],
|
||||
)
|
||||
.unwrap();
|
||||
transaction.commit().unwrap();
|
||||
identity
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn database_name_codec_preserves_code_units_and_rejects_malformed_payloads() {
|
||||
for units in [
|
||||
vec![],
|
||||
vec![0],
|
||||
vec![0xd800],
|
||||
vec![0xd801],
|
||||
vec![0xfffd],
|
||||
vec![0xd800, 0xdc00],
|
||||
vec![0xdc00, 0xd800],
|
||||
vec![0xd800, 0],
|
||||
] {
|
||||
let name = IndexedDbName::from_utf16(units.clone());
|
||||
let bytes = names::encode(&name).unwrap();
|
||||
assert_eq!(names::decode(&bytes).unwrap().as_utf16(), units);
|
||||
let mut database = Database::create(
|
||||
moli_sqlite::open_private_memory(Default::default()).unwrap(),
|
||||
DatabaseIdentity {
|
||||
name: name.clone(),
|
||||
..identity()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
database
|
||||
.begin(TX, TransactionMode::VersionChange, BTreeSet::new(), Some(1))
|
||||
.unwrap();
|
||||
database.commit(TX).unwrap();
|
||||
assert_eq!(database.committed().name, name);
|
||||
let stored: Vec<u8> = database
|
||||
.connection
|
||||
.query_row("SELECT name FROM database_metadata", [], |row| row.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(stored, bytes);
|
||||
}
|
||||
assert!(names::decode(&[0xd8]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_name_migration_preserves_records_ids_generator_and_following_commits() {
|
||||
let directory = Directory::new();
|
||||
let identity = legacy_database(&directory);
|
||||
let connection = moli_sqlite::open_disk(
|
||||
&directory.path(),
|
||||
moli_sqlite::OpenMode::Existing,
|
||||
Default::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut database = Database::open(connection, identity.clone()).unwrap();
|
||||
assert_eq!(database.committed().name, identity.name);
|
||||
assert_eq!(names::schema_version(&database.connection).unwrap(), 2);
|
||||
assert_eq!(database.committed().stores["a"].id, 3);
|
||||
begin(&mut database, TransactionMode::ReadWrite);
|
||||
assert_eq!(
|
||||
database
|
||||
.get(TX, "a", &5.into())
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.wire_bytes(),
|
||||
b"keep"
|
||||
);
|
||||
assert_eq!(
|
||||
put(&mut database, None, b"new", vec![], None).unwrap(),
|
||||
8.into()
|
||||
);
|
||||
database.commit(TX).unwrap();
|
||||
database.close().unwrap();
|
||||
let connection = moli_sqlite::open_disk(
|
||||
&directory.path(),
|
||||
moli_sqlite::OpenMode::Existing,
|
||||
Default::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut database = Database::open(connection, identity).unwrap();
|
||||
begin(&mut database, TransactionMode::ReadOnly);
|
||||
assert_eq!(database.count(TX, "a").unwrap(), 2);
|
||||
assert_eq!(
|
||||
database
|
||||
.get(TX, "a", &8.into())
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.wire_bytes(),
|
||||
b"new"
|
||||
);
|
||||
database.commit(TX).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_name_migration_keeps_legacy_format_and_data_available_for_reopen() {
|
||||
let directory = Directory::new();
|
||||
let identity = legacy_database(&directory);
|
||||
let connection =
|
||||
Connection::open_with_flags(directory.path(), rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)
|
||||
.unwrap();
|
||||
assert!(Database::open(connection, identity.clone()).is_err());
|
||||
let connection = Connection::open(directory.path()).unwrap();
|
||||
assert_eq!(names::schema_version(&connection).unwrap(), 1);
|
||||
assert_eq!(
|
||||
connection
|
||||
.query_row("SELECT typeof(name) FROM database_metadata", [], |row| {
|
||||
row.get::<_, String>(0)
|
||||
})
|
||||
.unwrap(),
|
||||
"text"
|
||||
);
|
||||
assert_eq!(
|
||||
connection
|
||||
.query_row(
|
||||
"SELECT count(*) FROM sqlite_schema WHERE name='database_metadata_v1'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
connection
|
||||
.query_row(
|
||||
"SELECT bytes FROM record_chunks WHERE record_id=7",
|
||||
[],
|
||||
|row| row.get::<_, Vec<u8>>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
b"keep"
|
||||
);
|
||||
drop(connection);
|
||||
let connection = moli_sqlite::open_disk(
|
||||
&directory.path(),
|
||||
moli_sqlite::OpenMode::Existing,
|
||||
Default::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
Database::open(connection, identity)
|
||||
.unwrap()
|
||||
.committed()
|
||||
.version,
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foreign_legacy_identity_is_rejected_before_migration() {
|
||||
let directory = Directory::new();
|
||||
let mut identity = legacy_database(&directory);
|
||||
identity.owner_key.push(0);
|
||||
let connection = Connection::open(directory.path()).unwrap();
|
||||
assert!(Database::open(connection, identity).is_err());
|
||||
let connection = Connection::open(directory.path()).unwrap();
|
||||
assert_eq!(names::schema_version(&connection).unwrap(), 1);
|
||||
assert_eq!(
|
||||
connection
|
||||
.query_row("SELECT typeof(name) FROM database_metadata", [], |row| {
|
||||
row.get::<_, String>(0)
|
||||
})
|
||||
.unwrap(),
|
||||
"text"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
PRAGMA user_version = 1;
|
||||
|
||||
CREATE TABLE database_info (
|
||||
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||
backend TEXT NOT NULL CHECK (backend = 'indexeddb'),
|
||||
owner_key BLOB NOT NULL CHECK (length(owner_key) > 0),
|
||||
identity_format INTEGER NOT NULL CHECK (identity_format = 1)
|
||||
) STRICT, WITHOUT ROWID;
|
||||
|
||||
CREATE TABLE database_metadata (
|
||||
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||
name TEXT NOT NULL,
|
||||
generation BLOB NOT NULL CHECK (length(generation) = 16),
|
||||
version INTEGER NOT NULL CHECK (version >= 0),
|
||||
state TEXT NOT NULL CHECK (state IN ('creating', 'live')),
|
||||
key_format INTEGER NOT NULL CHECK (key_format = 1),
|
||||
key_path_format INTEGER NOT NULL CHECK (key_path_format = 1),
|
||||
value_format INTEGER NOT NULL CHECK (value_format = 1),
|
||||
usage INTEGER NOT NULL CHECK (usage >= 0),
|
||||
next_store_id INTEGER NOT NULL DEFAULT 1 CHECK (next_store_id > 0),
|
||||
next_index_id INTEGER NOT NULL DEFAULT 1 CHECK (next_index_id > 0),
|
||||
next_record_id INTEGER NOT NULL DEFAULT 1 CHECK (next_record_id > 0),
|
||||
next_object_id INTEGER NOT NULL DEFAULT 1 CHECK (next_object_id > 0),
|
||||
CHECK (
|
||||
(state = 'creating' AND version = 0)
|
||||
OR (state = 'live' AND version > 0)
|
||||
)
|
||||
) STRICT, WITHOUT ROWID;
|
||||
|
||||
CREATE TABLE object_stores (
|
||||
store_id INTEGER PRIMARY KEY CHECK (store_id > 0),
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
key_path BLOB,
|
||||
auto_increment INTEGER NOT NULL CHECK (auto_increment IN (0, 1)),
|
||||
generator INTEGER NOT NULL CHECK (generator BETWEEN 0 AND 9007199254740992)
|
||||
) STRICT, WITHOUT ROWID;
|
||||
|
||||
CREATE TABLE indexes (
|
||||
index_id INTEGER PRIMARY KEY CHECK (index_id > 0),
|
||||
store_id INTEGER NOT NULL CHECK (store_id > 0),
|
||||
name TEXT NOT NULL,
|
||||
key_path BLOB NOT NULL,
|
||||
is_unique INTEGER NOT NULL CHECK (is_unique IN (0, 1)),
|
||||
multi_entry INTEGER NOT NULL CHECK (multi_entry IN (0, 1)),
|
||||
UNIQUE (store_id, name)
|
||||
) STRICT, WITHOUT ROWID;
|
||||
|
||||
CREATE TABLE records (
|
||||
record_id INTEGER PRIMARY KEY CHECK (record_id > 0),
|
||||
store_id INTEGER NOT NULL CHECK (store_id > 0),
|
||||
key_sort BLOB NOT NULL CHECK (length(key_sort) > 0),
|
||||
key_value BLOB NOT NULL CHECK (length(key_value) > 0),
|
||||
wire_size INTEGER NOT NULL CHECK (wire_size >= 0),
|
||||
usage INTEGER NOT NULL CHECK (usage >= 0),
|
||||
UNIQUE (store_id, key_sort)
|
||||
) STRICT, WITHOUT ROWID;
|
||||
|
||||
CREATE TABLE record_chunks (
|
||||
record_id INTEGER NOT NULL CHECK (record_id > 0),
|
||||
chunk_no INTEGER NOT NULL CHECK (chunk_no >= 0),
|
||||
bytes BLOB NOT NULL CHECK (length(bytes) BETWEEN 1 AND 262144),
|
||||
PRIMARY KEY (record_id, chunk_no)
|
||||
) STRICT, WITHOUT ROWID;
|
||||
|
||||
CREATE TABLE index_entries (
|
||||
index_id INTEGER NOT NULL CHECK (index_id > 0),
|
||||
index_key_sort BLOB NOT NULL CHECK (length(index_key_sort) > 0),
|
||||
index_key_value BLOB NOT NULL CHECK (length(index_key_value) > 0),
|
||||
primary_key_sort BLOB NOT NULL CHECK (length(primary_key_sort) > 0),
|
||||
record_id INTEGER NOT NULL CHECK (record_id > 0),
|
||||
is_unique INTEGER NOT NULL CHECK (is_unique IN (0, 1)),
|
||||
PRIMARY KEY (index_id, index_key_sort, primary_key_sort)
|
||||
) STRICT, WITHOUT ROWID;
|
||||
|
||||
CREATE UNIQUE INDEX index_entries_unique
|
||||
ON index_entries(index_id, index_key_sort) WHERE is_unique = 1;
|
||||
CREATE INDEX index_entries_record ON index_entries(record_id);
|
||||
|
||||
CREATE TABLE external_objects (
|
||||
object_id INTEGER PRIMARY KEY CHECK (object_id > 0),
|
||||
kind TEXT NOT NULL CHECK (kind IN ('blob', 'file', 'file-system-handle')),
|
||||
size INTEGER NOT NULL CHECK (size >= 0),
|
||||
mime_type TEXT,
|
||||
file_name TEXT,
|
||||
last_modified_f64 BLOB CHECK (
|
||||
last_modified_f64 IS NULL OR length(last_modified_f64) = 8
|
||||
),
|
||||
handle_kind TEXT CHECK (handle_kind IN ('file', 'directory')),
|
||||
handle_bucket_kind TEXT CHECK (handle_bucket_kind IN ('default', 'named')),
|
||||
handle_bucket_id INTEGER CHECK (handle_bucket_id IS NULL OR handle_bucket_id > 0),
|
||||
CHECK (
|
||||
(kind = 'blob' AND mime_type IS NOT NULL
|
||||
AND file_name IS NULL AND last_modified_f64 IS NULL
|
||||
AND handle_kind IS NULL AND handle_bucket_kind IS NULL AND handle_bucket_id IS NULL)
|
||||
OR
|
||||
(kind = 'file' AND mime_type IS NOT NULL
|
||||
AND file_name IS NOT NULL AND last_modified_f64 IS NOT NULL
|
||||
AND handle_kind IS NULL AND handle_bucket_kind IS NULL AND handle_bucket_id IS NULL)
|
||||
OR
|
||||
(kind = 'file-system-handle' AND mime_type IS NULL
|
||||
AND file_name IS NULL AND last_modified_f64 IS NULL AND size = 0
|
||||
AND handle_kind IS NOT NULL AND handle_bucket_kind IS NOT NULL
|
||||
AND ((handle_bucket_kind = 'default' AND handle_bucket_id IS NULL)
|
||||
OR (handle_bucket_kind = 'named' AND handle_bucket_id IS NOT NULL)))
|
||||
)
|
||||
) STRICT, WITHOUT ROWID;
|
||||
|
||||
CREATE TABLE record_objects (
|
||||
record_id INTEGER NOT NULL CHECK (record_id > 0),
|
||||
ordinal INTEGER NOT NULL CHECK (ordinal >= 0),
|
||||
object_id INTEGER NOT NULL CHECK (object_id > 0),
|
||||
PRIMARY KEY (record_id, ordinal)
|
||||
) STRICT, WITHOUT ROWID;
|
||||
|
||||
CREATE INDEX record_objects_object ON record_objects(object_id);
|
||||
|
||||
CREATE TABLE blob_chunks (
|
||||
object_id INTEGER NOT NULL CHECK (object_id > 0),
|
||||
chunk_no INTEGER NOT NULL CHECK (chunk_no >= 0),
|
||||
bytes BLOB NOT NULL CHECK (length(bytes) BETWEEN 1 AND 262144),
|
||||
PRIMARY KEY (object_id, chunk_no)
|
||||
) STRICT, WITHOUT ROWID;
|
||||
|
||||
CREATE TABLE handle_components (
|
||||
object_id INTEGER NOT NULL CHECK (object_id > 0),
|
||||
ordinal INTEGER NOT NULL CHECK (ordinal >= 0),
|
||||
name TEXT NOT NULL,
|
||||
PRIMARY KEY (object_id, ordinal)
|
||||
) STRICT, WITHOUT ROWID;
|
||||
@@ -17,8 +17,11 @@ fn database() -> Connection {
|
||||
"INSERT INTO database_metadata
|
||||
(singleton, name, generation, version, state,
|
||||
key_format, key_path_format, value_format, usage)
|
||||
VALUES (1, 'database', zeroblob(16), 0, 'creating', 1, 1, 1, 0)",
|
||||
[],
|
||||
VALUES (1, ?1, zeroblob(16), 0, 'creating', 1, 1, 1, 0)",
|
||||
["database"
|
||||
.encode_utf16()
|
||||
.flat_map(u16::to_be_bytes)
|
||||
.collect::<Vec<_>>()],
|
||||
)
|
||||
.unwrap();
|
||||
transaction.commit().unwrap();
|
||||
@@ -54,7 +57,7 @@ fn schema_uses_eleven_strict_without_rowid_tables_and_explicit_ids() {
|
||||
connection
|
||||
.pragma_query_value(None, "user_version", |row| row.get::<_, i64>(0))
|
||||
.unwrap(),
|
||||
1
|
||||
i64::from(crate::sqlite::SCHEMA_VERSION)
|
||||
);
|
||||
for statement in [
|
||||
"INSERT INTO object_stores(name, auto_increment, generator) VALUES ('store', 0, 0)",
|
||||
@@ -239,3 +242,28 @@ fn schema_rejects_invalid_external_objects_and_unbounded_chunks() {
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_database_names_are_even_utf16_blobs_and_preserve_lone_surrogates() {
|
||||
let connection = database();
|
||||
assert!(
|
||||
connection
|
||||
.execute("UPDATE database_metadata SET name='text'", [])
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
connection
|
||||
.execute("UPDATE database_metadata SET name=X'D8'", [])
|
||||
.is_err()
|
||||
);
|
||||
connection
|
||||
.execute("UPDATE database_metadata SET name=X'D800'", [])
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
connection
|
||||
.query_row("SELECT name FROM database_metadata", [], |row| row
|
||||
.get::<_, Vec<u8>>(0))
|
||||
.unwrap(),
|
||||
vec![0xd8, 0]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ use crate::context_bootstrap::{
|
||||
simple_event_target_handler_setter_callback,
|
||||
};
|
||||
use crate::web_api_interfaces;
|
||||
use moli_indexeddb::IndexedDbName;
|
||||
use moli_webapi_declare::WebApiObject;
|
||||
|
||||
#[derive(WebApiObject)]
|
||||
@@ -13,12 +14,12 @@ use moli_webapi_declare::WebApiObject;
|
||||
data_properties,
|
||||
enumerable
|
||||
)]
|
||||
struct IdbDatabaseObjectDeclaration {
|
||||
struct IdbDatabaseObjectDeclaration<'scope> {
|
||||
#[webapi(slot = SIMPLE_EVENT_TARGET_LISTENERS_SLOT, init = "null_object")]
|
||||
event_listeners: (),
|
||||
|
||||
#[webapi(slot = crate::context_bootstrap::indexed_db::DATABASE_NAME_SLOT)]
|
||||
name: String,
|
||||
name: v8::Local<'scope, v8::String>,
|
||||
#[webapi(slot = crate::context_bootstrap::indexed_db::DATABASE_VERSION_SLOT)]
|
||||
version: f64,
|
||||
#[webapi(
|
||||
@@ -68,11 +69,13 @@ pub(in crate::context_bootstrap::indexed_db) fn create_database_object<'s>(
|
||||
storage_scope: IndexedDbStorageScope,
|
||||
owner: IndexedDbExecutionOwner,
|
||||
handle: DatabaseHandle,
|
||||
name: &str,
|
||||
name: &IndexedDbName,
|
||||
version: u64,
|
||||
client: moli_indexeddb::sqlite::service::DatabaseClient,
|
||||
) -> Result<v8::Local<'s, v8::Object>, IndexedDbError> {
|
||||
let database = IdbDatabaseObjectDeclaration::new(name.to_owned(), version as f64)
|
||||
let name = v8::String::new_from_two_byte(scope, name.as_utf16(), v8::NewStringType::Normal)
|
||||
.ok_or_else(|| IndexedDbError::Resource("Unable to allocate IndexedDB name".into()))?;
|
||||
let database = IdbDatabaseObjectDeclaration::new(name, version as f64)
|
||||
.bind(scope)
|
||||
.map_err(|error| IndexedDbError::InvalidState(error.to_string()))?;
|
||||
register_indexed_db_wrapper_with_owner(
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
use super::{
|
||||
INDEXED_DB_DATABASE_CLOSED_SLOT, INDEXED_DB_DATABASE_UPGRADE_TRANSACTION_SLOT,
|
||||
INDEXED_DB_TRANSACTION_ABORTED_SLOT, INDEXED_DB_TRANSACTION_ACTIVE_SLOT,
|
||||
INDEXED_DB_TRANSACTION_FINISHED_SLOT, IndexedDbExecutionOwner, IndexedDbStorageScope,
|
||||
IndexedDbWrapperKind, KeyPath, ObjectStoreInfo, ObjectStoreOptions, TransactionMode,
|
||||
abort_queued_transaction_requests, compare_idb_keys, context_host_ptr_from_global_bridge,
|
||||
create_object_store_object, create_open_request_object, create_transaction_object,
|
||||
current_storage_scope, database_handle_from_value, dom_exception_value,
|
||||
enqueue_indexed_db_task, enqueue_ready_transaction_starts, enqueue_transaction_abort_task,
|
||||
enqueue_transaction_commit_task, indexed_db_databases_settle_task_payload,
|
||||
indexed_db_factory_storage_scope, indexed_db_runtime_factory, indexed_db_typed_execution_owner,
|
||||
indexed_db_typed_wrapper_is, object_bool_property, object_property_as_object,
|
||||
object_store_info_from_database_metadata, origin_allows_indexed_db,
|
||||
parse_optional_idb_key_path_member, register_indexed_db_databases_settle_task,
|
||||
register_regular_transaction, remove_database_store_metadata, request_error_object,
|
||||
require_idb_key, set_database_store_metadata, set_indexed_db_slot_value,
|
||||
INDEXED_DB_TRANSACTION_FINISHED_SLOT, IndexedDbExecutionOwner, IndexedDbName,
|
||||
IndexedDbStorageScope, IndexedDbWrapperKind, KeyPath, ObjectStoreInfo, ObjectStoreOptions,
|
||||
TransactionMode, abort_queued_transaction_requests, compare_idb_keys,
|
||||
context_host_ptr_from_global_bridge, create_object_store_object, create_open_request_object,
|
||||
create_transaction_object, current_storage_scope, database_handle_from_value,
|
||||
dom_exception_value, enqueue_indexed_db_task, enqueue_ready_transaction_starts,
|
||||
enqueue_transaction_abort_task, enqueue_transaction_commit_task,
|
||||
indexed_db_databases_settle_task_payload, indexed_db_factory_storage_scope,
|
||||
indexed_db_runtime_factory, indexed_db_typed_execution_owner, indexed_db_typed_wrapper_is,
|
||||
object_bool_property, object_property_as_object, object_store_info_from_database_metadata,
|
||||
origin_allows_indexed_db, parse_optional_idb_key_path_member,
|
||||
register_indexed_db_databases_settle_task, register_regular_transaction,
|
||||
remove_database_store_metadata, request_error_object, require_idb_key,
|
||||
set_database_store_metadata, set_indexed_db_slot_value,
|
||||
storage_scope_for_window_execution_context, store_request_error,
|
||||
sync_transaction_object_store_names_from_database, throw_type_error,
|
||||
transaction_handle_from_value, unregister_regular_transaction, v8_string,
|
||||
validate_storage_bucket_scope, with_indexed_db_manager,
|
||||
transaction_handle_from_value, unregister_regular_transaction, validate_storage_bucket_scope,
|
||||
with_indexed_db_manager,
|
||||
};
|
||||
|
||||
mod attributes;
|
||||
|
||||
@@ -72,7 +72,7 @@ fn dispatch_factory_request<'s>(
|
||||
scope: &mut v8::PinScope<'s, '_>,
|
||||
request: v8::Local<'s, v8::Object>,
|
||||
storage_scope: IndexedDbStorageScope,
|
||||
name: String,
|
||||
name: IndexedDbName,
|
||||
operation: FactoryOperation,
|
||||
) {
|
||||
let result = factory_sql_service(scope).and_then(|service| {
|
||||
|
||||
@@ -120,20 +120,20 @@ fn database_info_array<'s>(
|
||||
let snapshot = service.databases(storage_scope.storage_key().as_bytes())?;
|
||||
Ok(info_array(
|
||||
scope,
|
||||
snapshot
|
||||
.iter()
|
||||
.map(|info| (info.name.as_str(), info.version)),
|
||||
snapshot.iter().map(|info| (&info.name, info.version)),
|
||||
))
|
||||
}
|
||||
|
||||
fn info_array<'s, 'a>(
|
||||
scope: &mut v8::PinScope<'s, '_>,
|
||||
infos: impl ExactSizeIterator<Item = (&'a str, u64)>,
|
||||
infos: impl ExactSizeIterator<Item = (&'a IndexedDbName, u64)>,
|
||||
) -> v8::Local<'s, v8::Array> {
|
||||
let array = v8::Array::new(scope, infos.len() as i32);
|
||||
for (index, (name, version)) in infos.enumerate() {
|
||||
let object = ObjectLiteralDeclaration::bind(scope);
|
||||
if let Some(name) = v8_string(scope, name) {
|
||||
if let Some(name) =
|
||||
v8::String::new_from_two_byte(scope, name.as_utf16(), v8::NewStringType::Normal)
|
||||
{
|
||||
object.set_string_property(scope, "name", name.into());
|
||||
}
|
||||
let version = v8::Number::new(scope, version as f64);
|
||||
|
||||
+3
-3
@@ -5,8 +5,8 @@ use crate::webidl;
|
||||
#[derive(webidl::WebIdlArgs)]
|
||||
#[webidl(prefix = "IDBFactory.deleteDatabase")]
|
||||
struct IdbFactoryDeleteDatabaseArgs {
|
||||
#[webidl(required)]
|
||||
name: String,
|
||||
#[webidl(required, converter = "raw")]
|
||||
name: webidl::DomString16,
|
||||
}
|
||||
|
||||
pub(in crate::context_bootstrap::indexed_db) fn idb_factory_delete_database_callback<'s>(
|
||||
@@ -17,7 +17,7 @@ pub(in crate::context_bootstrap::indexed_db) fn idb_factory_delete_database_call
|
||||
let Some(parsed) = webidl::parse_args::<IdbFactoryDeleteDatabaseArgs>(scope, &args) else {
|
||||
return;
|
||||
};
|
||||
let name = parsed.name;
|
||||
let name = IndexedDbName::from_utf16(parsed.name.0);
|
||||
let Some(owner) = idb_factory_effective_execution_owner(scope, args.this()) else {
|
||||
let exception = dom_exception_value(
|
||||
scope,
|
||||
|
||||
@@ -5,8 +5,8 @@ use crate::webidl;
|
||||
#[derive(webidl::WebIdlArgs)]
|
||||
#[webidl(prefix = "IDBFactory.open")]
|
||||
struct IdbFactoryOpenArgs {
|
||||
#[webidl(required)]
|
||||
name: String,
|
||||
#[webidl(required, converter = "raw")]
|
||||
name: webidl::DomString16,
|
||||
#[webidl(converter = "enforce_range_unsigned_long_long")]
|
||||
version: Option<u64>,
|
||||
}
|
||||
@@ -19,7 +19,7 @@ pub(in crate::context_bootstrap::indexed_db) fn idb_factory_open_callback<'s>(
|
||||
let Some(parsed) = webidl::parse_args::<IdbFactoryOpenArgs>(scope, &args) else {
|
||||
return;
|
||||
};
|
||||
let name = parsed.name;
|
||||
let name = IndexedDbName::from_utf16(parsed.name.0);
|
||||
let version = parsed.version;
|
||||
if version == Some(0) {
|
||||
throw_type_error(
|
||||
|
||||
@@ -164,7 +164,7 @@ pub use self::state::{
|
||||
new_indexed_db_manager, new_sqlite_indexed_db_manager,
|
||||
};
|
||||
pub(in crate::context_bootstrap) use self::storage_bucket::scoped_storage_bucket_indexed_db_factory;
|
||||
pub use moli_indexeddb::{Key, ObjectStoreOptions, TransactionMode};
|
||||
pub use moli_indexeddb::{IndexedDbName, Key, ObjectStoreOptions, TransactionMode};
|
||||
|
||||
fn is_indexed_db_private_slot(slot: &str) -> bool {
|
||||
slot.starts_with("moli.IndexedDb.")
|
||||
|
||||
@@ -36,7 +36,7 @@ pub(super) struct FactoryRequest {
|
||||
request: RealmValueHandle,
|
||||
service: ServiceClient,
|
||||
storage_scope: IndexedDbStorageScope,
|
||||
name: String,
|
||||
name: IndexedDbName,
|
||||
operation: FactoryOperation,
|
||||
client: Option<DatabaseClient>,
|
||||
stage: Stage,
|
||||
@@ -69,7 +69,7 @@ pub(in crate::context_bootstrap::indexed_db) fn enqueue_sql_factory_request<'s>(
|
||||
request: v8::Local<'s, v8::Object>,
|
||||
service: ServiceClient,
|
||||
storage_scope: IndexedDbStorageScope,
|
||||
name: String,
|
||||
name: IndexedDbName,
|
||||
operation: FactoryOperation,
|
||||
) -> Result<(), IndexedDbError> {
|
||||
let owner = indexed_db_typed_execution_owner(scope, request)
|
||||
@@ -84,7 +84,7 @@ pub(in crate::context_bootstrap::indexed_db) fn enqueue_sql_factory_request<'s>(
|
||||
);
|
||||
let wake = super::super::runtime::indexed_db_sql_completion_wake(scope, owner, id);
|
||||
let queued_wake = wake.clone();
|
||||
let lease = match service.queue_connection_request(
|
||||
let lease = match service.queue_connection_request_named(
|
||||
storage_scope.storage_key().as_bytes(),
|
||||
&name,
|
||||
Arc::new(move || {
|
||||
@@ -312,7 +312,7 @@ fn resolve<'s>(
|
||||
scope,
|
||||
request,
|
||||
move |completion| {
|
||||
let client = service.resolve(owner, name, completion)?;
|
||||
let client = service.resolve_named(owner, name, completion)?;
|
||||
if let Some(payload) = target.upgrade() {
|
||||
payload.borrow_mut().client = Some(client.clone());
|
||||
}
|
||||
|
||||
@@ -6777,3 +6777,73 @@ fn indexed_db_roundtrips_image_data_and_geometry_objects() {
|
||||
r#"{"image":[true,true,true,2,1,"display-p3","1,2,3,4,5,6,7,8",true],"pointReadOnly":true,"point":true,"rectReadOnly":true,"rect":true,"quad":true,"matrixReadOnly":true,"matrix":true,"expandosExcluded":true}"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_db_utf16_database_names_survive_sqlite_profile_restart() {
|
||||
let root = indexed_db_test_root("database-names-profile-restart");
|
||||
let units: Vec<Vec<u16>> = vec![
|
||||
vec![],
|
||||
vec![0],
|
||||
vec![0xd800],
|
||||
vec![0xd801],
|
||||
vec![0xdfff],
|
||||
vec![0xfffd],
|
||||
vec![0xd800, 0xdc00],
|
||||
vec![0xdc00, 0xd800],
|
||||
vec![0xd800, 0],
|
||||
vec![0x64, 0x38, 0x30, 0x30],
|
||||
];
|
||||
for write in [true, false] {
|
||||
let manager = crate::new_indexed_db_manager(Some(root.clone())).unwrap();
|
||||
let mut vm = new_storage_page_task_executor_test_vm(
|
||||
"https://indexeddb-database-names-profile.test/",
|
||||
);
|
||||
vm.set_indexed_db_manager(Some(crate::downgrade_indexed_db_manager(&manager)));
|
||||
vm.set_storage_bucket_store(
|
||||
crate::new_shared_storage_bucket_store_with_indexed_db_manager(&manager),
|
||||
);
|
||||
vm.eval(&format!(
|
||||
"globalThis.writeNames={write}; globalThis.nameUnits={};",
|
||||
serde_json::to_string(&units).unwrap()
|
||||
))
|
||||
.unwrap();
|
||||
vm.eval(r#"
|
||||
globalThis.databaseNameProfile = 'pending';
|
||||
(async () => {
|
||||
const result = request => new Promise((resolve,reject) => {
|
||||
request.onsuccess=()=>resolve(request.result); request.onerror=()=>reject(request.error);
|
||||
});
|
||||
const complete = transaction => new Promise((resolve,reject) => {
|
||||
transaction.oncomplete=resolve; transaction.onabort=()=>reject(transaction.error);
|
||||
});
|
||||
const records=[];
|
||||
for (let i=0;i<nameUnits.length;i++) {
|
||||
const name=String.fromCharCode(...nameUnits[i]);
|
||||
const open=indexedDB.open(name,1);
|
||||
open.onupgradeneeded=()=>open.result.createObjectStore('s');
|
||||
const db=await result(open);
|
||||
if(db.name!==name) throw Error('database name identity');
|
||||
if(writeNames) {
|
||||
const transaction=db.transaction('s','readwrite');const done=complete(transaction);
|
||||
transaction.objectStore('s').put(i,'key');await done;
|
||||
}
|
||||
const transaction=db.transaction('s');const done=complete(transaction);
|
||||
records.push(await result(transaction.objectStore('s').get('key')));await done;db.close();
|
||||
}
|
||||
const listed=(await indexedDB.databases()).map(info=>Array.from({length:info.name.length},(_,i)=>info.name.charCodeAt(i)));
|
||||
return {records,listed};
|
||||
})().then(result=>databaseNameProfile=JSON.stringify(result),error=>databaseNameProfile=String(error));
|
||||
"#).unwrap();
|
||||
let actual = vm
|
||||
.eval_after_selected_page_tasks("databaseNameProfile")
|
||||
.unwrap();
|
||||
let mut ordered = units.clone();
|
||||
ordered.sort();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&actual).unwrap(),
|
||||
serde_json::json!({"records":(0..units.len()).collect::<Vec<_>>(),"listed":ordered}),
|
||||
"profile write: {write}"
|
||||
);
|
||||
}
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user