seitu
Version:
257 lines (256 loc) • 9.38 kB
JavaScript
//#region src/web/indexed-db/index.ts
const MAX_UPGRADE_ATTEMPTS = 5;
const openLocks = /* @__PURE__ */ new Map();
function withOpenLock(name, run) {
const next = (openLocks.get(name) ?? Promise.resolve()).then(run, run);
openLocks.set(name, next);
const release = () => {
if (openLocks.get(name) === next) openLocks.delete(name);
};
next.then(release, release);
return next;
}
function normalizeIndex(definition) {
return typeof definition === "string" || Array.isArray(definition) ? { keyPath: definition } : definition;
}
function ensureStores(database, transaction, stores) {
for (const [storeName, definition] of Object.entries(stores)) {
let store;
if (database.objectStoreNames.contains(storeName)) store = transaction.objectStore(storeName);
else store = database.createObjectStore(storeName, {
keyPath: definition.keyPath,
autoIncrement: definition.autoIncrement
});
for (const [indexName, indexDefinition] of Object.entries(definition.indexes ?? {})) {
if (store.indexNames.contains(indexName)) continue;
const { keyPath, ...parameters } = normalizeIndex(indexDefinition);
store.createIndex(indexName, keyPath, parameters);
}
}
}
function findMissing(database, stores) {
for (const [storeName, definition] of Object.entries(stores)) {
if (!database.objectStoreNames.contains(storeName)) return storeName;
const indexNames = Object.keys(definition.indexes ?? {});
if (indexNames.length === 0) continue;
const store = database.transaction(storeName, "readonly").objectStore(storeName);
for (const indexName of indexNames) if (!store.indexNames.contains(indexName)) return `${storeName}.${indexName}`;
}
}
function migrateStore(transaction, storeName, rewrite, label) {
const request = transaction.objectStore(storeName).openCursor();
request.onerror = () => {
console.error(`[${label}] Could not read "${storeName}" to migrate it.`, request.error);
};
request.onsuccess = () => {
const cursor = request.result;
if (!cursor) return;
const next = rewrite(cursor.value, cursor.key);
if (next === null) cursor.delete();
else if (next !== void 0) cursor.update(next);
cursor.continue();
};
}
function openRequest(options, version, label) {
return new Promise((resolve, reject) => {
const request = indexedDB.open(options.name, version);
request.onupgradeneeded = (event) => {
const database = request.result;
const transaction = request.transaction;
ensureStores(database, transaction, options.stores);
options.onUpgrade?.({
database,
transaction,
oldVersion: event.oldVersion,
newVersion: event.newVersion,
migrate: (storeName, rewrite) => migrateStore(transaction, storeName, rewrite, label)
});
};
request.onblocked = () => {
console.warn(`[${label}] Upgrade is blocked by another open connection. Waiting for it to close.`);
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async function openDatabase(options, label) {
let version = options.version;
for (let attempt = 0; attempt < MAX_UPGRADE_ATTEMPTS; attempt++) {
let database;
try {
database = await openRequest(options, version, label);
} catch (error) {
if (version !== void 0 && error?.name === "VersionError") {
version = void 0;
continue;
}
throw error;
}
if (!findMissing(database, options.stores)) return database;
version = database.version + 1;
database.close();
}
throw new Error(`[${label}] Could not create the declared object stores after ${MAX_UPGRADE_ATTEMPTS} attempts.`);
}
/**
* Opens one IndexedDB connection and builds a handle for each store definition
* (`createIndexedDbStorage` or `createIndexedDbTable`). Missing stores/indexes
* are created automatically; concurrent opens of the same name are serialized.
*
* `version` is a minimum, not a pin: adding a store or an index bumps it on its
* own, so set it only when existing rows need migrating. Bump it, then rewrite
* the rows with `migrate` in `onUpgrade`, which runs inside the `versionchange`
* transaction after the declared stores and indexes are created. Everything
* there is synchronous — `migrate` queues a cursor walk instead of returning a
* promise, and `onUpgrade` cannot be `async`.
*
* `migrate` rewrites fields, not keys: changing the `keyPath` field of a row
* aborts the upgrade. Indexes are created and filled before `onUpgrade` runs,
* so a migration cannot clean up rows for a `unique` index added in the same
* version — add the index in a later version than the cleanup. A failed
* upgrade rolls back whole and is reported with `console.warn`: `ready` still
* resolves, but reads and writes then reject, because every access retries the
* same failing upgrade.
*
* Every write is one transaction, and only one. A single `put`, `delete`, `set`
* or `clear` call opens a `readwrite` transaction and queues all of its rows or
* keys on it, so a batch lands whole or not at all; two calls are two
* transactions, and none of them spans stores. Reads work the same way: each
* one is its own `readonly` snapshot.
*
* @example Vanilla
* ```ts twoslash title="db.ts"
* import { createIndexedDb, createIndexedDbStorage, createIndexedDbTable } from 'seitu/web'
* import * as z from 'zod'
*
* const db = createIndexedDb({
* name: 'app',
* stores: {
* settings: createIndexedDbStorage({
* schemas: { theme: z.enum(['light', 'dark']) },
* defaultValues: { theme: 'light' },
* }),
* todos: createIndexedDbTable({
* keyPath: 'id',
* indexes: { status: 'status' },
* schema: z.object({ id: z.string(), title: z.string(), status: z.enum(['open', 'done']) }),
* }),
* },
* })
*
* const { settings, todos } = db.stores
*
* settings.get() // { theme: 'light' } until hydrated
* await db.ready
* await todos.put({ id: '1', title: 'Write docs', status: 'open' })
* ```
*
* @example Migrations
* ```ts twoslash title="db.ts"
* import { createIndexedDb, createIndexedDbTable } from 'seitu/web'
* import * as z from 'zod'
*
* const db = createIndexedDb({
* name: 'app',
* version: 2, // v1 rows have no `priority`
* stores: {
* todos: createIndexedDbTable({
* keyPath: 'id',
* indexes: { priority: 'priority' },
* schema: z.object({ id: z.string(), title: z.string(), priority: z.number() }),
* }),
* },
* onUpgrade: ({ oldVersion, migrate }) => {
* if (oldVersion < 2) {
* migrate('todos', row => ({ ...row, priority: row.priority ?? 0 }))
* // Return `null` to drop a row, nothing to keep it as is.
* }
* },
* })
*
* await db.ready
* ```
*
* @example Transactions
* ```ts twoslash title="checkout.ts"
* import { createIndexedDb, createIndexedDbStorage, createIndexedDbTable } from 'seitu/web'
* import * as z from 'zod'
*
* const db = createIndexedDb({
* name: 'app',
* stores: {
* sync: createIndexedDbStorage({
* schemas: { lastSyncedAt: z.number(), pending: z.number() },
* defaultValues: { lastSyncedAt: 0, pending: 0 },
* }),
* todos: createIndexedDbTable({
* keyPath: 'id',
* schema: z.object({ id: z.string(), title: z.string(), status: z.enum(['open', 'done']) }),
* }),
* },
* })
* const { sync, todos } = db.stores
*
* // One `readwrite` transaction: both rows land, or neither does.
* await todos.put([
* { id: '1', title: 'Write docs', status: 'done' },
* { id: '2', title: 'Ship docs', status: 'open' },
* ])
*
* await todos.delete(['1', '2'])
* await sync.set({ lastSyncedAt: Date.now(), pending: 0 })
*
* await todos.put({ id: '3', title: 'Reconcile', status: 'open' })
* await sync.set({ pending: 1 })
* ```
*/
function createIndexedDb(options) {
const label = `createIndexedDb:${options.name}`;
const isSupported = typeof indexedDB !== "undefined";
const openOptions = {
name: options.name,
version: options.version,
onUpgrade: options.onUpgrade,
stores: Object.fromEntries(Object.entries(options.stores).map(([storeName, store]) => [storeName, store["~"].definition]))
};
let databasePromise;
const getDatabase = () => {
if (!isSupported) return Promise.reject(/* @__PURE__ */ new Error(`[${label}] IndexedDB is not available in this environment.`));
if (!databasePromise) {
const promise = withOpenLock(options.name, () => openDatabase(openOptions, label)).then((database) => {
database.onversionchange = () => {
database.close();
if (databasePromise === promise) databasePromise = void 0;
};
return database;
}).catch((error) => {
if (databasePromise === promise) databasePromise = void 0;
throw error;
});
databasePromise = promise;
}
return databasePromise;
};
const close = () => {
const promise = databasePromise;
if (!promise) return;
databasePromise = void 0;
promise.then((database) => database.close(), () => {});
};
const handles = {};
const db = {
ready: isSupported ? getDatabase().then(() => Promise.all(Object.values(handles).map((handle) => handle.hydrate?.()))).then(() => {}, (error) => {
console.warn(`[${label}] Failed to open IndexedDB.`, error);
}) : Promise.resolve(),
close,
stores: handles,
"~": {
name: options.name,
getDatabase
}
};
for (const [storeName, store] of Object.entries(options.stores)) handles[storeName] = store["~"].create(db, storeName);
return db;
}
//#endregion
export { createIndexedDb };