diff --git a/src/databases/indexeddb/storage.ts b/src/databases/indexeddb/storage.ts index e8de61d..51cbd27 100644 --- a/src/databases/indexeddb/storage.ts +++ b/src/databases/indexeddb/storage.ts @@ -13,19 +13,23 @@ const OBJECT_PROTOTYPE = Object.getPrototypeOf({}); const OBJECT_TAG = "[object Object]"; export class IndexedDBStorage extends Storage { + readonly pkey: string; + readonly log: DBLogger; + readonly #cache = new IndexedDBCache(); readonly #promise: Promise; #db?: IDBPDatabase; - constructor( - name: string, - indexes: IndexSpec[], - promise: Promise, - readonly log: DBLogger = function log() {}, - ) { + constructor(name: string, indexes: IndexSpec[], promise: Promise, log?: DBLogger) { super(name, indexes); + const index = this.indexes.find((index) => index.kind === "primary"); + if (index === undefined) { + throw new Error("missing required primary key index"); + } + this.pkey = index.field; + this.log = log ?? function log() {}; this.#promise = promise; } @@ -43,6 +47,60 @@ export class IndexedDBStorage extends return this; } + /* + |-------------------------------------------------------------------------------- + | Indexes + |-------------------------------------------------------------------------------- + */ + + #isPrimaryIndex(key: string): boolean { + for (const { field, kind } of this.indexes) { + if (key === field && kind === "primary") { + return true; + } + } + return false; + } + + #isUniqueIndex(key: string): boolean { + for (const { field, kind } of this.indexes) { + if (key === field && kind === "unique") { + return true; + } + } + return false; + } + + #isSharedIndex(key: string): boolean { + for (const { field, kind } of this.indexes) { + if (key === field && kind === "shared") { + return true; + } + } + return false; + } + + #getOptimalIndex(keys: string[]): string { + let best: string | undefined; + + for (const key of keys) { + if (this.#isPrimaryIndex(key)) { + return key; // cannot beat primary + } + + if (this.#isUniqueIndex(key)) { + best ??= key; + continue; + } + + if (best === undefined && this.#isSharedIndex(key)) { + best = key; + } + } + + return best ?? keys[0]; + } + /* |-------------------------------------------------------------------------------- | Insert @@ -53,7 +111,16 @@ export class IndexedDBStorage extends const logger = new InsertLog(this.name); const tx = this.db.transaction(this.name, "readwrite", { durability: "relaxed" }); - await Promise.all(documents.map((document) => tx.store.add(document))); + + await Promise.all( + documents.map(async (document) => { + const existing = await tx.store.get(document[this.pkey]); // Assuming 'id' is your key + if (existing === undefined) { + await tx.store.add(document); + } + }), + ); + await tx.done; this.broadcast("insert", documents); @@ -83,13 +150,14 @@ export class IndexedDBStorage extends } const indexes = this.#resolveIndexes(condition); - let cursor = new Query(condition).find(await this.#getAll({ ...options, ...indexes })); + + let cursor = new Query(condition).find(await this.#getAll({ ...options }, indexes)); if (options !== undefined) { cursor = addOptions(cursor, options); } const documents = cursor.all(); - this.#cache.set(this.#cache.hash(condition, options), documents); + this.#cache.set(hashCode, documents); this.log(logger.result()); @@ -100,7 +168,7 @@ export class IndexedDBStorage extends * TODO: Prototype! Needs to cover more mongodb query cases and investigation around * nested indexing in indexeddb. */ - #resolveIndexes(filter: any): { index?: { [key: string]: any } } { + #resolveIndexes(filter: any): { [key: string]: any } | undefined { const indexNames = this.db.transaction(this.name, "readonly").store.indexNames; const index: { [key: string]: any } = {}; for (const key in filter) { @@ -119,19 +187,74 @@ export class IndexedDBStorage extends } } if (Object.keys(index).length > 0) { - return { index }; + return index; } - return {}; } - async #getAll({ offset, range, limit }: QueryOptions) { - if (range !== undefined) { - return this.db.getAll(this.name, IDBKeyRange.bound(range.from, range.to)); + async #getAll( + { offset, range, limit }: QueryOptions, + indexes?: { [key: string]: IDBKeyRange | undefined }, + ): Promise { + const tx = this.db.transaction(this.name, "readonly"); + + const store = tx.objectStore(this.name); + + // ### Indexed + // Fetch all records by optimal index + + if (indexes) { + const indexName = this.#getOptimalIndex(Object.keys(indexes)); + const index = store.index(indexName); + + const key = indexes[indexName]; + + const results: TSchema[] = []; + + // Handle $in + + if (Array.isArray(key)) { + for (const value of key) { + const records = await index.getAll(value); + results.push(...records); + } + + // Deduplicate (required for $in) + + const unique = new Map(); + for (const doc of results) { + unique.set(this.pkey, doc); // adjust PK if needed + } + + await tx.done; + return [...unique.values()]; + } + + // Single-key lookup + + const records = await index.getAll(key); + + await tx.done; + return records; } - if (offset !== undefined) { + + // ### Range + // Fetch records in a given range. + + if (range) { + return store.getAll(IDBKeyRange.bound(range.from, range.to), limit); + } + + // ### Offset + // Offset-based query (cursor-based) + + if (offset) { return this.#getAllByOffset(offset.value, offset.direction, limit); } - return this.db.getAll(this.name, undefined, limit); + + // ### Default + // Fetch all records + + return store.getAll(undefined, limit); } async #getAllByOffset(value: string, direction: 1 | -1, limit?: number) {