Created
June 7, 2025 19:56
-
-
Save robinsax/e42c89c7d6bd110eba8debe596e84811 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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