seitu
Version:
191 lines (190 loc) • 5.9 kB
JavaScript
import { createReadableSubscription, createSubscription } from "../../core/subscription/index.mjs";
import { n as validateSchema } from "../../schema-store-Dfbr678J.mjs";
import { n as requestToPromise, r as transactionDone, t as createBroadcaster } from "../../indexed-db-request-MnNCk_ou.mjs";
import { deepEqual } from "fast-equals";
//#region src/web/indexed-db-storage/index.ts
function createStorage(options, db, storeName) {
const defaultValues = { ...options.defaultValues };
const keys = Object.keys(options.defaultValues);
const isSupported = typeof indexedDB !== "undefined";
const databaseName = db["~"].name;
const label = `createIndexedDbStorage:${databaseName}/${storeName}`;
const { broadcast, listen } = createBroadcaster(`seitu:indexed-db:${databaseName}:${storeName}`);
const resolveKey = (key) => String(options.keyTransform ? options.keyTransform(key) : key);
let cache = { ...options.defaultValues };
let writeVersion = 0;
const keyWriteVersion = /* @__PURE__ */ new Map();
let hydrateSeq = 0;
const markWritten = (written) => {
writeVersion++;
for (const key of written) keyWriteVersion.set(key, writeVersion);
};
let triggerHydrate = () => {};
const { subscribe, notify } = createSubscription({ onFirstSubscribe: () => {
triggerHydrate();
return listen(() => triggerHydrate());
} });
const validateValue = (key, raw) => {
if (raw === void 0) return options.defaultValues[key];
return validateSchema(options.schemas[key], raw, {
defaultValue: options.defaultValues[key],
label: `createIndexedDbStorage:${String(key)}`,
key,
onError: options.onValidationError
});
};
const hydrate = async () => {
if (!isSupported) return cache;
const seq = ++hydrateSeq;
const startVersion = writeVersion;
const store = (await db["~"].getDatabase()).transaction(storeName, "readonly").objectStore(storeName);
const persisted = {};
await Promise.all(keys.map(async (key) => {
persisted[key] = validateValue(key, await requestToPromise(store.get(resolveKey(key))));
}));
if (seq !== hydrateSeq) return cache;
const next = {};
for (const key of keys) next[key] = (keyWriteVersion.get(key) ?? 0) > startVersion ? cache[key] : persisted[key];
if (!deepEqual(cache, next)) {
cache = next;
notify();
}
return cache;
};
const safeHydrate = () => hydrate().catch((error) => {
console.warn(`[${label}] Failed to read from IndexedDB, using cached values.`, error);
return cache;
});
let ready;
let inFlight;
const rehydrate = () => {
if (!inFlight) {
const run = safeHydrate().finally(() => {
if (inFlight === run) inFlight = void 0;
});
inFlight = run;
ready = run;
}
return inFlight;
};
const ensureReady = () => ready ?? rehydrate();
triggerHydrate = () => void rehydrate();
const get = () => {
ensureReady();
return cache;
};
const readable = createReadableSubscription(get, subscribe, notify, () => defaultValues);
const persist = async (mutate) => {
if (!isSupported) return;
try {
const transaction = (await db["~"].getDatabase()).transaction(storeName, "readwrite");
mutate(transaction.objectStore(storeName));
await transactionDone(transaction);
broadcast();
} catch (error) {
console.warn(`[${label}] Failed to write to IndexedDB.`, error);
}
};
return {
...readable,
set: (value) => {
ensureReady();
const resolved = typeof value === "function" ? value(cache) : value;
markWritten(Object.keys(resolved));
cache = {
...cache,
...resolved
};
notify();
return persist((store) => {
for (const [key, entry] of Object.entries(resolved)) store.put(entry, resolveKey(key));
});
},
clear: () => {
ensureReady();
markWritten(keys);
cache = { ...options.defaultValues };
notify();
return persist((store) => {
for (const key of keys) store.delete(resolveKey(key));
});
},
hydrate: rehydrate,
db,
storeName,
"~": {
...readable["~"],
getDefaultValue: (key) => defaultValues[key],
getSchema: (key) => options.schemas[key],
transformKey: resolveKey
}
};
}
/**
* Key/value store for `createIndexedDb({ stores })`. `get()` reads an
* in-memory cache hydrated from IndexedDB (`await db.ready`). `set`/`clear`
* update the cache now and persist later. SSR snapshot is `defaultValues`.
*
* @example Vanilla
* ```ts twoslash title="settings-storage.ts"
* import { createIndexedDb, createIndexedDbStorage } from 'seitu/web'
* import * as z from 'zod'
*
* const db = createIndexedDb({
* name: 'app',
* stores: {
* settings: createIndexedDbStorage({
* schemas: {
* token: z.string().nullable(),
* preferences: z.object({ theme: z.enum(['light', 'dark']) }),
* },
* defaultValues: { token: null, preferences: { theme: 'light' } },
* }),
* },
* })
* const { settings } = db.stores
*
* settings.get()
* await db.ready
* await settings.set({ token: 'abc' })
* settings.get() // { token: 'abc', preferences: { theme: 'light' } }
* settings.subscribe(console.log)
* ```
*
* @example React
* ```tsx twoslash title="page.tsx"
* 'use client'
*
* import { createIndexedDb, createIndexedDbStorage } from 'seitu/web'
* import { useSubscription } from 'seitu/react'
* import * as z from 'zod'
*
* const db = createIndexedDb({
* name: 'app',
* stores: {
* settings: createIndexedDbStorage({
* schemas: { count: z.number(), name: z.string() },
* defaultValues: { count: 0, name: '' },
* }),
* },
* })
*
* export default function Page() {
* const value = useSubscription(db.stores.settings)
* return (
* <div>
* <span>{value.count}</span>
* <span>{value.name}</span>
* </div>
* )
* }
* ```
*/
function createIndexedDbStorage(options) {
return { "~": {
definition: {},
create: (db, storeName) => createStorage(options, db, storeName)
} };
}
//#endregion
export { createIndexedDbStorage };