Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 | import { openDB } from "idb";
/**
* Default {@link ModelStoreInterface} implementation backed by IndexedDB (via the small `idb` wrapper).
*
* Works in browsers and MV3 service workers. Data is normalized across three object stores so that a single credential
* can be written/removed without rewriting the whole vault: `vaultLists` (per connection), `vaults` (metadata only) and
* `credentials` (one record per credential, indexed by connection + vault). {@link getVault} reassembles the full
* {@link SerializableTransferFullVaultInterface} from the vault metadata plus its credential records.
*
* If the underlying database is deleted (e.g. via DevTools) or the connection closes unexpectedly, the store reopens
* a fresh database on the next operation so callers do not keep a dead connection for the process lifetime.
*/
export class IndexedDbModelStore {
static DEFAULT_DB_NAME = "passman-model-store";
static DB_VERSION = 1;
dbName;
onUnexpectedReconnect;
dbPromise;
/** Set when the open connection closes (unexpectedly or after versionchange); next op reopens. */
connectionClosed = false;
reopenInFlight = null;
/**
* @param dbName IndexedDB database name
* @param onUnexpectedReconnect Optional hook when the store reopens after a closed/missing DB (e.g. DevTools delete)
*/
constructor(dbName = IndexedDbModelStore.DEFAULT_DB_NAME, onUnexpectedReconnect) {
this.dbName = dbName;
this.onUnexpectedReconnect = onUnexpectedReconnect;
this.dbPromise = this.openDatabase();
}
openDatabase() {
return openDB(this.dbName, IndexedDbModelStore.DB_VERSION, {
upgrade(db) {
if (!db.objectStoreNames.contains("vaultLists")) {
db.createObjectStore("vaultLists", { keyPath: "connectionId" });
}
if (!db.objectStoreNames.contains("vaults")) {
const vaultStore = db.createObjectStore("vaults", { keyPath: "key" });
vaultStore.createIndex("byConnection", "connectionId");
}
if (!db.objectStoreNames.contains("credentials")) {
const credentialStore = db.createObjectStore("credentials", { keyPath: "key" });
credentialStore.createIndex("byVault", ["connectionId", "vaultGuid"]);
}
}
}).then((db) => this.attachConnectionLifecycle(db));
}
/**
* Close promptly on versionchange so deleteDatabase / upgrades are not blocked, and mark the connection
* so the next operation reopens (recreating an empty schema if the DB was removed).
*/
attachConnectionLifecycle(db) {
db.addEventListener("versionchange", () => {
db.close();
this.connectionClosed = true;
});
db.addEventListener("close", () => {
// Fired for abnormal closes (storage cleared / DB deleted). Explicit db.close() does not fire this.
this.connectionClosed = true;
console.warn(`[IndexedDbModelStore] Connection to "${this.dbName}" closed unexpectedly; will reopen on next use`);
});
return db;
}
async reopenDatabase(reason, notifyReconnect = true) {
if (this.reopenInFlight) {
return this.reopenInFlight;
}
console.warn(`[IndexedDbModelStore] Reopening "${this.dbName}" (${reason})`);
this.reopenInFlight = this.openDatabase()
.then((db) => {
this.connectionClosed = false;
this.dbPromise = Promise.resolve(db);
this.reopenInFlight = null;
if (notifyReconnect) {
try {
this.onUnexpectedReconnect?.(reason);
}
catch (hookError) {
console.warn(`[IndexedDbModelStore] onUnexpectedReconnect hook failed for "${this.dbName}"`, hookError);
}
}
return db;
})
.catch((error) => {
this.reopenInFlight = null;
throw error;
});
return this.reopenInFlight;
}
async getDb() {
if (this.connectionClosed) {
return this.reopenDatabase("previous connection closed");
}
return this.dbPromise;
}
static isRecoverableIdbError(error) {
if (!error || typeof error !== "object") {
return false;
}
const name = "name" in error ? String(error.name) : "";
const message = "message" in error ? String(error.message) : "";
return (name === "InvalidStateError" ||
name === "NotFoundError" ||
/connection is closing/i.test(message) ||
/database connection is closed/i.test(message));
}
/**
* Run an IDB operation, reopening once if the connection died or the database was removed underneath us.
*/
async withDb(operation) {
try {
return await operation(await this.getDb());
}
catch (error) {
if (!IndexedDbModelStore.isRecoverableIdbError(error)) {
throw error;
}
const db = await this.reopenDatabase(`recoverable IDB error: ${error instanceof Error ? error.message : String(error)}`);
return await operation(db);
}
}
static vaultKey(connectionId, guid) {
return `${connectionId}::${guid}`;
}
static credentialKey(connectionId, vaultGuid, credentialGuid) {
return `${connectionId}::${vaultGuid}::${credentialGuid}`;
}
async getVaultList(connectionId) {
return this.withDb(async (db) => {
const record = await db.get("vaultLists", connectionId);
return record?.vaultList;
});
}
async putVaultList(connectionId, vaultList) {
return this.withDb(async (db) => {
await db.put("vaultLists", { connectionId, vaultList });
});
}
async getVault(connectionId, guid) {
return this.withDb(async (db) => {
const vaultRecord = await db.get("vaults", IndexedDbModelStore.vaultKey(connectionId, guid));
if (!vaultRecord) {
return undefined;
}
const credentialRecords = await db.getAllFromIndex("credentials", "byVault", [connectionId, guid]);
return {
serializableSpecificVaultInformation: vaultRecord.serializableSpecificVaultInformation,
encryptedSerializableCredentials: credentialRecords.map(record => record.credential)
};
});
}
async putVault(connectionId, vault) {
const guid = vault.serializableSpecificVaultInformation.guid;
return this.withDb(async (db) => {
const tx = db.transaction(["vaults", "credentials"], "readwrite");
await tx.objectStore("vaults").put({
key: IndexedDbModelStore.vaultKey(connectionId, guid),
connectionId,
guid,
serializableSpecificVaultInformation: vault.serializableSpecificVaultInformation
});
// replace the vault's credentials so credentials removed server-side don't linger
const credentialStore = tx.objectStore("credentials");
const existingKeys = await credentialStore.index("byVault").getAllKeys([connectionId, guid]);
for (const existingKey of existingKeys) {
await credentialStore.delete(existingKey);
}
for (const credential of vault.encryptedSerializableCredentials) {
const credentialGuid = credential.encryptedData.guid;
await credentialStore.put({
key: IndexedDbModelStore.credentialKey(connectionId, guid, credentialGuid),
connectionId,
vaultGuid: guid,
credentialGuid,
credential
});
}
await tx.done;
});
}
async deleteVault(connectionId, guid) {
return this.withDb(async (db) => {
const tx = db.transaction(["vaults", "credentials"], "readwrite");
await tx.objectStore("vaults").delete(IndexedDbModelStore.vaultKey(connectionId, guid));
const credentialStore = tx.objectStore("credentials");
const existingKeys = await credentialStore.index("byVault").getAllKeys([connectionId, guid]);
for (const existingKey of existingKeys) {
await credentialStore.delete(existingKey);
}
await tx.done;
});
}
async getCredential(connectionId, vaultGuid, credentialGuid) {
return this.withDb(async (db) => {
const record = await db.get("credentials", IndexedDbModelStore.credentialKey(connectionId, vaultGuid, credentialGuid));
return record?.credential;
});
}
async putCredential(connectionId, vaultGuid, credential) {
const credentialGuid = credential.encryptedData.guid;
return this.withDb(async (db) => {
await db.put("credentials", {
key: IndexedDbModelStore.credentialKey(connectionId, vaultGuid, credentialGuid),
connectionId,
vaultGuid,
credentialGuid,
credential
});
});
}
async deleteCredential(connectionId, vaultGuid, credentialGuid) {
return this.withDb(async (db) => {
await db.delete("credentials", IndexedDbModelStore.credentialKey(connectionId, vaultGuid, credentialGuid));
});
}
/**
* Approximate on-disk payload size by JSON-serializing all stored records.
* Useful for settings UI; not an exact IndexedDB footprint (indexes/overhead excluded).
*/
async estimateSize() {
return this.withDb(async (db) => {
const vaultLists = await db.getAll("vaultLists");
const vaults = await db.getAll("vaults");
const credentials = await db.getAll("credentials");
let bytes = 0;
const measure = (value) => {
bytes += new Blob([JSON.stringify(value)]).size;
};
for (const record of vaultLists) {
measure(record);
}
for (const record of vaults) {
measure(record);
}
for (const record of credentials) {
measure(record);
}
return {
bytes,
vaultListCount: vaultLists.length,
vaultCount: vaults.length,
credentialCount: credentials.length
};
});
}
/**
* Deletes the IndexedDB database (offline DTO cache) and reopens an empty schema so later ops keep working.
* Closes this instance's connection first so deletion is not blocked.
*/
async clearDatabase() {
try {
const db = await this.dbPromise;
db.close();
}
catch {
// connection may already be dead
}
this.connectionClosed = true;
await new Promise((resolve, reject) => {
const request = indexedDB.deleteDatabase(this.dbName);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error ?? new Error(`Failed to delete IndexedDB "${this.dbName}"`));
request.onblocked = () => {
// Other contexts should close on versionchange; still treat prolonged block as failure
reject(new Error(`Delete of IndexedDB "${this.dbName}" blocked by an open connection`));
};
});
await this.reopenDatabase("database cleared", false);
}
}
|