seitu
Version:
187 lines (186 loc) • 6.25 kB
JavaScript
import { createReadableSubscription, createSubscription } from "../../core/subscription/index.mjs";
import { n as validateSchema, r as validateSync } 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-table/index.ts
var IndexedDbTableValidationError = class extends Error {
issues;
value;
constructor(label, issues, value) {
super(`[${label}] Row failed validation: ${issues.map((issue) => issue.message).join("; ")}`);
this.name = "IndexedDbTableValidationError";
this.issues = issues;
this.value = value;
}
};
function createTable(options, db, storeName) {
const isSupported = typeof indexedDB !== "undefined";
const databaseName = db["~"].name;
const label = `createIndexedDbTable:${databaseName}/${storeName}`;
const { broadcast, listen } = createBroadcaster(`seitu:indexed-db:${databaseName}:${storeName}`);
const { subscribe, notify } = createSubscription({ onFirstSubscribe: () => listen(() => notify()) });
const validateRow = (raw) => validateSchema(options.schema, raw, {
defaultValue: void 0,
label,
onError: options.onValidationError
});
const validateForWrite = (raw) => {
const result = validateSync(options.schema, raw, label);
if (result.issues) throw new IndexedDbTableValidationError(label, result.issues, raw);
return result.value;
};
const read = async (run) => {
const store = (await db["~"].getDatabase()).transaction(storeName, "readonly").objectStore(storeName);
return requestToPromise(run(store));
};
const write = async (run) => {
const transaction = (await db["~"].getDatabase()).transaction(storeName, "readwrite");
run(transaction.objectStore(storeName));
await transactionDone(transaction);
notify();
broadcast();
};
const createReader = (source) => ({
get: async (key) => validateRow(await read((store) => source(store).get(key))),
getAll: async (query, count) => (await read((store) => source(store).getAll(query, count))).map(validateRow).filter((row) => row !== void 0),
getAllKeys: (query, count) => read((store) => source(store).getAllKeys(query, count)),
count: (query) => read((store) => source(store).count(query ?? void 0))
});
const reader = {
...createReader((store) => store),
index: (name) => createReader((store) => store.index(name))
};
const query = (run, queryOptions) => {
const initial = queryOptions?.initial;
let cache = initial;
let seq = 0;
const querySubscription = createSubscription({ onFirstSubscribe: () => {
const unsubscribe = subscribe(() => void refresh());
refresh();
return unsubscribe;
} });
async function refresh() {
if (!isSupported) return cache;
const current = ++seq;
let next;
try {
next = await run(reader);
} catch (error) {
console.warn(`[${label}] Query failed, keeping the cached value.`, error);
return cache;
}
if (current !== seq) return cache;
if (!deepEqual(cache, next)) {
cache = next;
querySubscription.notify();
}
return cache;
}
return {
...createReadableSubscription(() => cache, querySubscription.subscribe, querySubscription.notify, () => initial),
ready: refresh(),
refresh
};
};
return {
...reader,
put: async (input, key) => {
if (Array.isArray(input) && key !== void 0) throw new TypeError(`[${label}] An explicit key can only be used with a single row.`);
const validated = (Array.isArray(input) ? input : [input]).map(validateForWrite);
await write((store) => {
for (const row of validated) store.put(row, key);
});
},
delete: (keys) => write((store) => {
for (const key of Array.isArray(keys) ? keys : [keys]) store.delete(key);
}),
clear: () => write((store) => store.clear()),
query,
db,
storeName,
"~": {
schema: options.schema,
subscribe,
notify
}
};
}
/**
* Row store for `createIndexedDb({ stores })`: keyed rows, indexes, range
* reads, validation. Use `query()` for a `Readable`/`Subscribable` that
* re-runs on table changes. On the server, queries stay on `initial`.
*
* Key paths must name a schema field that can hold a key, and reads are typed
* from that field. Compound (`['id', 'order']`) and nested (`'meta.slug'`)
* paths fall back to `IDBValidKey`.
*
* @example Vanilla
* ```ts twoslash title="todos.ts"
* import { createIndexedDb, createIndexedDbTable } from 'seitu/web'
* import * as z from 'zod'
*
* const db = createIndexedDb({
* name: 'app',
* stores: {
* todos: createIndexedDbTable({
* keyPath: 'id',
* indexes: { status: 'status' },
* schema: z.object({ id: z.string(), title: z.string(), status: z.enum(['open', 'done']) }),
* }),
* },
* })
* const { todos } = db.stores
*
* await todos.put({ id: '1', title: 'Write docs', status: 'open' })
* await todos.get('1')
* await todos.getAll()
* await todos.index('status').getAll('open') // index names and keys are typed
* await todos.delete('1')
*
* const open = todos.query(t => t.index('status').getAll('open'), { initial: [] })
* open.get()
* open.subscribe(rows => console.log(rows))
* ```
*
* @example React
* ```tsx twoslash title="page.tsx"
* 'use client'
*
* import { createIndexedDb, createIndexedDbTable } from 'seitu/web'
* import { useSubscription } from 'seitu/react'
* import * as z from 'zod'
*
* const db = createIndexedDb({
* name: 'app',
* stores: {
* todos: createIndexedDbTable({
* keyPath: 'id',
* schema: z.object({ id: z.string(), title: z.string(), status: z.enum(['open', 'done']) }),
* }),
* },
* })
*
* const allTodos = db.stores.todos.query(t => t.getAll(), { initial: [] })
*
* export default function Page() {
* const rows = useSubscription(allTodos)
* return (
* <ul>
* {rows.map(todo => <li key={todo.id}>{todo.title}</li>)}
* </ul>
* )
* }
* ```
*/
function createIndexedDbTable(options) {
return { "~": {
definition: {
keyPath: options.keyPath,
autoIncrement: options.autoIncrement,
indexes: options.indexes
},
create: (db, storeName) => createTable(options, db, storeName)
} };
}
//#endregion
export { IndexedDbTableValidationError, createIndexedDbTable };