UNPKG

seitu

Version:
257 lines (256 loc) • 9.38 kB
//#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 };