import dot from "dot-wild"; import find from "./find"; import { booleanOperators } from "./operators"; import { Transaction } from "./transaction"; import { update } from "./update"; import { deeplyRemoveEmptyObjects, isEmptyObject, isObject, Ok } from "./utils"; import { getCreateId } from "./ids"; import type { StorageAdapter } from "./adapter"; export type CollectionOptions = Partial<{ /** When true, automatically syncs to disk when a change is made to the database. */ autosync: boolean; /** When true, automatically adds timestamps to all records. */ timestamps: boolean; /** When true, document ids are integers that increment from 0. */ integerIds: boolean; /** The storage adapter to use. By default, uses a filesystem adapter. */ adapter: StorageAdapter; }>; export type CreateIndexOptions = Partial<{ key: string; unique: boolean; }>; export type QueryOptions = Partial<{ /** When true, attempts to deeply match the query against documents. */ deep: boolean; /** Specifies the key to return by. */ returnKey: string; /** When true, returns cloned data (not a reference). default true */ clonedData: boolean; /** Provide fallback values for null or undefined properties */ ifNull: Record; /** Provide fallback values for 'empty' properties ([], {}, "") */ ifEmpty: Record; /** Provide fallback values for null, undefined, or 'empty' properties. */ ifNullOrEmpty: Record; /** * -1 || 0: descending * 1: ascending */ sort: { [property: string]: -1 | 0 | 1 }; /** * Particularly useful when sorting, `skip` defines the number of documents * to ignore from the beginning of the result set. */ skip: number; /** Determines the number of documents returned. */ take: number; /** * 1: property included in result document * 0: property excluded from result document */ project: { [property: string]: 0 | 1; }; aggregate: { [property: string]: Record<"$floor", string> | Record<"$ceil", string> | Record<"$sub", (string|number)[]> | Record<"$mult", (string|number)[]> | Record<"$div", (string|number)[]> | Record<"$add", (string|number)[]> | Record<"$fn", (document) => unknown>; }; join: Array<{ /** The collection to join on. */ collection: Collection; /** The property containing the foreign key(s). */ from: string; /** The property on the joining collection that the foreign key should point to. */ on: string; /** The name of the property to be created while will contain the joined documents. */ as: string; /** QueryOptions that will be applied to the joined collection. */ options?: QueryOptions; }>; }>; export function defaultQueryOptions(): QueryOptions { return { deep: true, returnKey: ID_KEY, clonedData: true, sort: undefined, skip: undefined, project: undefined, }; } // before inserting, strip any boolean modifiers from the query, e.g. // { name: "Jean-Luc", title: { $oneOf: ["Captain", "Commander"] } } // becomes // { name: "Jean-Luc" }. export function stripBooleanModifiers(query: object): object { const ops = new Set(Ok(booleanOperators)); const stripObject = (obj: object): object => { return Object.entries(obj).reduce((acc, [key, value]) => { if (isObject(value)) { const stripped = stripObject(value); if (!isEmptyObject(stripped)) { acc[key] = stripped; } } else if (!ops.has(key)) { acc[key] = value; } return acc; }, {}); }; return deeplyRemoveEmptyObjects(stripObject(query)); } export let ID_KEY = "_id"; export let CREATED_AT_KEY = "_created_at"; export let UPDATED_AT_KEY = "_updated_at"; export type InternalData = { current: number; next_id: number; id_map: { [id: string]: string }; index: { valuesToId: { [key: string]: { [value: string]: string[] } }; idToValues: { [key: string]: { [cuid: string]: string | number } }; }; }; export type CollectionData = { [key: string]: any; internal?: InternalData; }; const isValidIndexValue = (value: unknown) => value !== undefined && (typeof value === "string" || typeof value === "number" || typeof value === "boolean"); export class Collection { options: CollectionOptions; data: CollectionData = {}; _transaction: Transaction = null; indices: { [key: string]: { unique: boolean } } = {}; createId: () => string; constructor( options: CollectionOptions = {} ) { options.autosync = options.autosync ?? true; options.timestamps = options.timestamps ?? true; options.integerIds = options.integerIds ?? false; if (!options.adapter) { throw new Error("No adapter provided."); } this.options = options; const defaultPrivateData = (): InternalData => ({ current: 0, next_id: 0, id_map: {}, index: { valuesToId: {}, idToValues: {}, }, }); this.adapterRead(); // Ensure we have the internal map after adapter read. if (!this.data.internal) { this.data.internal = defaultPrivateData(); } this.createId = getCreateId({ init: this.data.internal.current, len: 4 }); } static from(data: CollectionData = {}, options: CollectionOptions = {}) { const c = new Collection({ adapter: { read: () => ({} as any), write: () => {} }, autosync: false, timestamps: false, ...options, }); c.insert(data as any); const initial = c.data; c.adapterRead = () => { c.data = initial; }; return c; } adapterRead() { this.data = this.options.adapter.read(); } /** * Given objects found by a query, assign `document` directly to these objects. * Does not add timestamps or anything else. * Used by transaction update rollback. */ assign(id: unknown, document: T): T { if (id === undefined) return; if (this.options.integerIds) { const intid = id as number; const cuid = this.data.internal.id_map[intid]; if (cuid) { this.data[cuid] = document; return this.data[cuid]; } // a cuid wasn't found, so this is a new record. return this.insert({ ...document, [ID_KEY]: intid })[0]; } if (typeof id === "string" || typeof id === "number") { this.data[id] = document; return this.data[id]; } return undefined; } filter(fn: (document: T) => boolean): T[] { const _data = Object.assign({}, this.data); delete _data.internal; return Object.values(_data).filter((doc: T) => { try { return fn(doc); } catch (e) { return false; } }); } find(query?: object, options: QueryOptions = {}): T[] { return find(this.data, query, options, this.options, this); } update(query: object, operations: object, options: QueryOptions = {}): T[] { return update(this.data, query, operations, options, this.options, this); } upsert(query: object, operations: object, options: QueryOptions = {}): T[] { const updated = this.update(query, operations, options); if (updated.length) { return updated; } // Nothing was updated. // The idea is that we don't want the created document to be { name: "Jean-Luc", age: { $gt: 40 }, title: "Captain" }, // instead, it should be: { name: "Jean-Luc", title: "Captain" } query = stripBooleanModifiers(query); const inserted = this.insert(query as any); return update( inserted, query, operations, options, this.options, this ); } remove(query: object, options: QueryOptions = {}): T[] { const found = this.find(query, { ...options, clonedData: false }); // Copy the found array so we can return unmodified data. const cloned = found.map((doc) => Object.assign({}, doc)); found.forEach((document) => { let cuid: string; if (this.options.integerIds) { const intid = document[ID_KEY]; cuid = this.data.internal.id_map[intid]; delete this.data.internal.id_map[intid]; } else { cuid = document[ID_KEY]; } Object.keys(this.indices).forEach((key) => { const value = dot.get(document, key); if (isValidIndexValue(value)) { this.data.internal.index.valuesToId[key][value] = this.data.internal.index.valuesToId[key][value].filter((c) => c !== cuid); if (this.data.internal.index.valuesToId[key][value].length === 0) { delete this.data.internal.index.valuesToId[key][value]; } delete this.data.internal.index.idToValues[cuid]; } if (value === undefined) { // This is a bit annoying, but it needs to be done. // If the value for this document's indexed property is undefined, // it might have been removed accidentally by an update mutation or something. // We need to make sure we clean up any dangling indexes. Object.keys(this.data.internal.index.valuesToId[key]).forEach((value) => { this.data.internal.index.valuesToId[key][value] = this.data.internal.index.valuesToId[key][value].filter((c) => c !== cuid); if (this.data.internal.index.valuesToId[key][value].length === 0) { delete this.data.internal.index.valuesToId[key][value]; } }); } }); delete this.data[cuid]; }); this.sync(); return cloned; } insert(documents: T[] | T): T[] { if (!Array.isArray(documents)) documents = [documents]; if (!documents.length) return []; documents = documents.map((document) => { const cuid = this.getId(); this.data.internal.current++; if (this.options.timestamps) { document[CREATED_AT_KEY] = Date.now(); document[UPDATED_AT_KEY] = Date.now(); } // only assign an id if it's not already there // support explicit ids, e.g.: { _id: 0, ... } if (document[ID_KEY] === undefined) { document[ID_KEY] = cuid; if (this.options.integerIds) { const intid = this.nextIntegerId(); this.data.internal.id_map[intid] = cuid; document[ID_KEY] = intid; } } this.data[cuid] = document; Object.keys(this.indices).forEach((key) => { const value = String(dot.get(document, key)); if (isValidIndexValue(value)) { if (this.indices[key].unique) { if (this.data.internal.index.valuesToId?.[key]?.[value] !== undefined) { throw new Error(`Unique index violation for key "${key}" and value "${value}"`); } } this.data.internal.index.valuesToId[key] = this.data.internal.index.valuesToId[key] || {}; this.data.internal.index.valuesToId[key][value] = this.data.internal.index.valuesToId[key][value] || []; this.data.internal.index.valuesToId[key][value].push(cuid); this.data.internal.index.idToValues[cuid] = this.data.internal.index.idToValues[cuid] || {}; this.data.internal.index.idToValues[cuid][key] = value; } }); return document; }); if (this.options.autosync) { this.sync(); } return documents; } merge(id: string, item: T) { if (!id) return; /** * When merging a document, if we're using integer ids, * grab the cuid from the id map. */ if (this.options.integerIds) { const cuid = this.data.internal.id_map[id]; if (!cuid) return; if (this.data[cuid] === undefined) return; Object.assign(this.data[cuid], isEmptyObject(item) ? {} : item); this.sync(); return; } /** * Otherwise, the id is assumed to be a cuid. */ if (this.data[id] === undefined) return; Object.assign(this.data[id], isEmptyObject(item) ? {} : item); this.sync(); } sync() { return this.options.adapter.write(this.data); } drop() { this.data = { internal: { current: 0, next_id: 0, id_map: {}, index: { valuesToId: {}, idToValues: {}, }, }, }; } getId() { return this.createId(); } createIndex(options: CreateIndexOptions = {}) { if (!options.key) throw new Error(`createIndex requires a key`); options = { key: options.key, unique: options.unique ?? false, }; const { key, unique } = options; if (key.split(".").some((k) => !isNaN(Number(k)))) { throw new Error(`Cannot use a numeric property as an index key: ${key}`); } this.indices[key] = { unique }; if (this.data.internal.index.valuesToId[key]) { return; } Object.keys(this.data).forEach((cuid) => { if (cuid === "internal") return; const value = String(dot.get(this.data[cuid], key)); /* const value = String(key.split(".").reduce((acc, k) => acc[k], this.data[cuid])); */ if (isValidIndexValue(value)) { if (unique) { if (this.data.internal.index.valuesToId?.[key]?.[value] !== undefined) { throw new Error(`Unique index violation for key "${key}" and value "${value}"`); } } this.data.internal.index.valuesToId[key] = this.data.internal.index.valuesToId[key] || {}; this.data.internal.index.valuesToId[key][value] = this.data.internal.index.valuesToId[key][value] || []; this.data.internal.index.valuesToId[key][value].push(cuid); this.data.internal.index.idToValues[cuid] = this.data.internal.index.idToValues[cuid] || {}; this.data.internal.index.idToValues[cuid][key] = value; } else { throw new Error(`Invalid index value for property ${key}: ${value}`); } }); this.sync(); return this; } removeIndex(key: string): boolean { if (!this.indices[key]) return false; delete this.indices[key]; if (this.data.internal.index.valuesToId[key]) { delete this.data.internal.index.valuesToId[key]; } Object.keys(this.data.internal.index.idToValues).forEach((cuid) => { if (this.data.internal.index.idToValues[cuid][key] !== undefined) { delete this.data.internal.index.idToValues[cuid][key]; } if (isEmptyObject(this.data.internal.index.idToValues[cuid])) { delete this.data.internal.index.idToValues[cuid]; } }); this.sync(); return true; } nextIntegerId() { return this.data.internal.next_id++; } transaction(fn: (transaction: Transaction) => void): void { this._transaction = new Transaction(this); try { fn(this._transaction); } catch (e) { this._transaction.rollback(); throw e; } this._transaction.commit(); } }