Skip to content

Instantly share code, notes, and snippets.

@robinsax
Created June 7, 2025 19:56
Show Gist options
  • Save robinsax/e42c89c7d6bd110eba8debe596e84811 to your computer and use it in GitHub Desktop.
Save robinsax/e42c89c7d6bd110eba8debe596e84811 to your computer and use it in GitHub Desktop.
import { config } from '../config';
import { makeLogger } from './logging';
const logger = makeLogger('databases');
export type Schema = {
[collection: string]: object
};
export type DatabaseSchemaInit<S extends Schema> = {
name: string;
collections: {
[K in keyof S]: {
key: string | null,
indexes?: string[]
}
}
};
export type LinearQueryable = number | Date;
export type QueryCondition<T, K extends keyof T> = (
{ eq: T[K] } |
{ in: T[K][] } |
{ or: QueryCondition<T, K>[] } |
{ and: QueryCondition<T, K>[] } |
(T[K] extends LinearQueryable ? (
{ gt: T[K] } |
{ lt: T[K] } |
{ gte: T[K] } |
{ lte: T[K] }
) : never)
);
export type Query<T> = Partial<{
[K in keyof T]: QueryCondition<T, K>
}>;
export interface Database<S extends Schema> {
upsert<K extends keyof S>(collection: K, values: S[K][]): Promise<void>;
query<K extends keyof S>(collection: K, query?: Query<S[K]> | null): Promise<S[K][]>;
delete<K extends keyof S>(collection: K, query?: Query<S[K]> | null): Promise<void>;
}
type FieldQueryResolverFn = (value: any) => boolean;
type FieldQueryResolver = IDBKeyRange | FieldQueryResolverFn;
type QueryPlan = {
index: IDBKeyRange | null;
memory: [string, FieldQueryResolverFn][];
};
const queryConditionResolver = (
condition: QueryCondition<any, any>, allowKeyRange: boolean = false
): FieldQueryResolver => {
const operator = Object.keys(condition)[0];
const operand = condition[operator as keyof QueryCondition<any, any>] as any;
switch (operator) {
case 'eq':
if (allowKeyRange) return IDBKeyRange.only(operand);
return (value: any) => value == operand;
case 'in':
return (value: any) => operand.includes(value);
case 'or': {
const resolvers: FieldQueryResolverFn[] = [];
for (const innerCondition of operand) {
resolvers.push(queryConditionResolver(innerCondition, allowKeyRange) as FieldQueryResolverFn);
}
return (value: any) => resolvers.some(resolver => resolver(value));
}
case 'and': {
const resolvers: FieldQueryResolverFn[] = [];
for (const innerCondition of operand) {
resolvers.push(queryConditionResolver(innerCondition, allowKeyRange) as FieldQueryResolverFn);
}
return (value: any) => resolvers.every(resolver => resolver(value));
}
case 'gt':
if (allowKeyRange) return IDBKeyRange.lowerBound(operand, true);
return (value: any) => value > operand;
case 'lt':
if (allowKeyRange) return IDBKeyRange.upperBound(operand, true);
return (value: any) => value < operand;
case 'gte':
if (allowKeyRange) return IDBKeyRange.lowerBound(operand, false);
return (value: any) => value >= operand;
case 'lte':
if (allowKeyRange) return IDBKeyRange.upperBound(operand, false);
return (value: any) => value <= operand;
default:
throw new Error('invalid query condition');
}
};
const queryPlan = <T>(query: Query<T> | null): QueryPlan => {
let index = null;
const memory: [string, FieldQueryResolverFn][] = [];
if (!query) return { index, memory };
for (const key in query) {
const condition = query[key] as unknown as QueryCondition<any, any>;
const resolver = queryConditionResolver(condition, !index);
if (resolver instanceof IDBKeyRange) {
index = resolver;
continue;
}
memory.push([key, resolver]);
}
return { index, memory };
}
const asyncRequest = <T>(request: IDBRequest): Promise<T> => {
return new Promise((resolve, reject) => {
request.addEventListener('success', () => {
resolve(request.result);
});
request.addEventListener('error', () => {
reject(request.error);
});
});
};
const asyncCursor = <T>(request: IDBRequest, iterator: (item: T, cursor: IDBCursor) => boolean): Promise<void> => {
return new Promise((resolve, reject) => {
request.addEventListener('success', (event) => {
const cursor = (event.target as IDBRequest).result;
if (!cursor) {
resolve();
return;
}
if (iterator(cursor.value, cursor)) cursor.continue();
else {
cursor.close();
resolve();
}
});
request.addEventListener('error', () => {
reject(request.error);
});
});
};
export class IndexedDBDatabase<S extends Schema> implements Database<S> {
private db: IDBDatabase;
constructor(db: IDBDatabase) {
this.db = db;
}
static async open<S extends Schema>(init: DatabaseSchemaInit<S>) {
const request = indexedDB.open(init.name, config.databases.globalVersion);
request.addEventListener('upgradeneeded', (event) => {
logger.info('upgrading', init.name);
// TODO: Indexes.
const db = (event.target as IDBRequest).result;
for (const collection of db.objectStoreNames) {
db.deleteObjectStore(collection);
}
for (const collection in init.collections) {
logger.info(init.name, 'make', collection);
const params: Record<string, any> = {};
const key = init.collections[collection].key;
if (!key) {
params.keyPath = '_autoKey';
params.autoIncrement = true;
}
else {
params.keyPath = key;
}
db.createObjectStore(collection, params);
}
});
const db = await asyncRequest<IDBDatabase>(request);
return new IndexedDBDatabase<S>(db);
}
async upsert<K extends keyof S>(collection: K, values: S[K][]) {
const store = this.db.transaction(collection as unknown as string, 'readwrite')
.objectStore(collection as unknown as string);
for (const value of values) {
try {
await asyncRequest(store.put(value));
}
catch (err) {
logger.error('upsert failed:', err, value);
}
}
}
async query<K extends keyof S>(collection: K, query: Query<S[K]> | null = null) {
const { index, memory } = queryPlan(query);
const store = this.db.transaction(collection as unknown as string, 'readonly')
.objectStore(collection as unknown as string);
const records: S[K][] = [];
await asyncCursor<S[K]>(store.openCursor(index), (value) => {
for (const [key, resolver] of memory) {
if (!resolver(value[key as keyof S[K]])) {
return true;
}
}
records.push(value);
return true;
});
return records;
}
async delete<K extends keyof S>(collection: K, query: Query<S[K]> | null = null) {
const { index, memory } = queryPlan(query);
const store = this.db.transaction(collection as unknown as string, 'readwrite')
.objectStore(collection as unknown as string);
if (!memory.length) {
if (index) await asyncRequest(store.delete(index));
else await asyncRequest(store.clear());
return;
}
await asyncCursor<S[K]>(store.openCursor(index), (value, cursor) => {
for (const [key, resolver] of memory) {
if (!resolver(value[key as keyof S[K]])) {
return true;
}
}
cursor.delete();
return true;
});
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment