UNPKG

tinycoll

Version:

A minimal reactive document store with Mongo-like querying, reactivity, TTL support, and optional persistence.

411 lines (410 loc) 12.9 kB
var _a; import { signal, effect } from '@preact/signals-core'; import { matches } from './internal/match.js'; import { newShortId } from './utils.js'; import { PromiseQueue } from './internal/promise-queue.js'; import { applyModifier } from './internal/modifier.js'; class Cursor { #result = signal([]); #hasRun = false; #changeHandlers = new Set(); #isNotifying = false; #pendingChange = false; #queryFn; #options; constructor(queryFn, options = {}) { this.#queryFn = queryFn; this.#options = options; } #clone(opts) { return new _a(this.#queryFn, { ...this.#options, ...opts }); } sort(sort) { return this.#clone({ sort }); } limit(limit) { return this.#clone({ limit }); } skip(skip) { return this.#clone({ skip }); } project(projection) { return this.#clone({ projection: projection }); } paginate(page, perPage) { const skip = (page - 1) * perPage; return this.skip(skip).limit(perPage); } #ensureRun() { if (this.#hasRun) return; this.#hasRun = true; let prev = new Map(); effect(() => { let docs = this.#queryFn(); if (this.#options.sort) docs = sortDocs(docs, this.#options.sort); if (this.#options.skip || this.#options.limit !== undefined) { docs = docs.slice(this.#options.skip || 0, (this.#options.skip || 0) + (this.#options.limit ?? docs.length)); } if (this.#options.projection) { docs = docs.map((doc) => project(doc, this.#options.projection)); } const next = new Map(docs.map((doc) => [doc.id, doc])); const added = [...next.entries()].filter(([k]) => !prev.has(k)); const removed = [...prev.entries()].filter(([k]) => !next.has(k)); const changed = [...next.entries()].filter(([k, v]) => prev.has(k) && prev.get(k) !== v); for (const [_, doc] of added) this.#emit({ type: 'added', doc }); for (const [_, doc] of removed) this.#emit({ type: 'removed', doc }); for (const [_, doc] of changed) this.#emit({ type: 'changed', doc }); this.#result.value = Array.from(next.values()); prev = next; }); } #emit(change) { if (this.#isNotifying) { this.#pendingChange = true; return; } this.#isNotifying = true; queueMicrotask(() => { this.#isNotifying = false; for (const fn of this.#changeHandlers) { fn(change); } if (this.#pendingChange) { this.#pendingChange = false; this.#emit(change); } }); } observe(fn) { this.#ensureRun(); const wrapped = Object.assign(fn, { __id: newShortId() }); this.#changeHandlers.add(wrapped); return { id: wrapped.__id, stop: () => this.#changeHandlers.delete(wrapped), }; } watch(callback, options = {}) { let last; let scheduled = false; const run = () => { scheduled = false; const next = this.toArray(); if (last === undefined || next.length !== last.length || next.some((v, i) => v !== last[i])) { last = next; callback(next); } }; const observer = this.observe(() => { if (!scheduled) { scheduled = true; queueMicrotask(run); } }); if (options.immediate !== false) { queueMicrotask(run); } return observer; } toArray() { this.#ensureRun(); return this.#result.value; } map(fn) { this.#ensureRun(); return this.#result.value.map(fn); } forEach(fn) { this.#ensureRun(); this.#result.value.forEach(fn); } count() { this.#ensureRun(); return this.#result.value.length; } first() { this.#options.limit = 1; return this.toArray()[0]; } last() { return this.toArray().at(-1); } exists() { return this.count() > 0; } group(group) { return new _a(() => { this.#ensureRun(); return groupDocs(this.#result.value, group); }); } distinct(key) { return [...new Set(this.toArray().map((doc) => doc[key]))]; } } _a = Cursor; function getValue(obj, path) { return path.split('.').reduce((o, k) => o?.[k], obj); } function extractMatchingDocs(query, map) { if ('id' in query && typeof query.id === 'string') { const doc = map.get(query.id); return doc && matches(doc, query) ? [doc] : []; } return Array.from(map.values()).filter((doc) => matches(doc, query)); } function sortDocs(docs, sort) { return [...docs].sort((a, b) => { for (const [key, dir] of Object.entries(sort)) { const aVal = getValue(a, key); const bVal = getValue(b, key); if (aVal < bVal) return -1 * dir; if (aVal > bVal) return 1 * dir; } return 0; }); } function groupDocs(docs, group) { const { key: prop, ...rest } = group; const grouped = new Map(); for (const doc of docs) { const key = getValue(doc, prop); const list = grouped.get(key) || []; list.push(doc); grouped.set(key, list); } const results = []; for (const [key, groupDocs] of grouped.entries()) { const out = { id: key }; for (const [field, expr] of Object.entries(rest)) { if (typeof expr === 'object' && '$sum' in expr) { out[field] = groupDocs.length; } else if (typeof expr === 'object' && '$push' in expr) { const val = expr.$push; if (val === '$$ROOT') { out[field] = [...groupDocs]; } else if (typeof val === 'string') { out[field] = groupDocs.map((doc) => getValue(doc, val)); } } } results.push(out); } return results; } function project(doc, projection) { const out = {}; for (const key of Object.keys(projection)) { out[key] = getValue(doc, key); } return out; } function isValidName(name) { return /^[a-z][a-z0-9_]*$/.test(name); } export class Collection { #dbKey; #docs = signal(new Map()); #storage; #ttlIndexes = []; #ttlInterval; #isBatching = false; #localClone = new Map(); #meta = null; #ready; #txQueue = new PromiseQueue(); #ttlIntervalId; get meta() { if (!this.#meta) throw new Error('Collection is not initialized'); return this.#meta; } constructor(name, options) { if (this.constructor !== Meta && !isValidName(name)) { throw new Error(`Invalid collection name: ${name}`); } this.#dbKey = name; this.#storage = options?.storage; this.#ttlIndexes = options?.ttlIndexes || []; this.#ttlInterval = options?.ttlInterval ?? 60_000; if (name !== '_meta') { this.#meta = new Meta(name, { storage: options?.storage }); } this.#ready = new Promise(async (resolve) => { await Promise.all([ this.#initStorage(), this.#meta?.ready, ]); queueMicrotask(() => resolve()); }); if (this.#ttlIndexes.length > 0) { this.#ttlIntervalId = setInterval(() => this.#processTtlIndexes(), this.#ttlInterval); } } dispose() { clearInterval(this.#ttlIntervalId); } #processTtlIndexes() { const now = Date.now(); const updated = new Map(this.#docs.value); let changed = false; for (const [id, doc] of updated) { for (const index of this.#ttlIndexes) { const ts = getValue(doc, index.field); if (typeof ts === 'number' && now >= ts + index.expireAfterSeconds * 1000) { updated.delete(id); changed = true; break; } } } if (changed) this.#docs.value = updated; } async #initStorage() { if (!this.#storage) return; const stored = await this.#storage.get(this.#dbKey); if (Array.isArray(stored)) { // need to spread, the signal won't catch this change otherwise this.#docs.value = new Map(stored.map((doc) => [doc.id, { ...doc }])); } effect(() => { const docs = Array.from(this.#docs.value.values()); this.#storage?.set(this.#dbKey, docs); }); } onReady(callback) { void this.#ready.then(callback); } get ready() { return this.#ready; } batch(fn) { this.#isBatching = true; try { const result = fn(); if (result instanceof Promise) { return result.then(() => { }).finally(() => { this.#docs.value = this.#localClone; this.#localClone = new Map(); this.#isBatching = false; }); } this.#docs.value = this.#localClone; this.#localClone = new Map(); this.#isBatching = false; return result; } catch (err) { this.#localClone = new Map(); this.#isBatching = false; throw err; } } insert(doc) { const id = doc.id || newShortId(); const newDoc = { ...doc, id }; if (this.#isBatching) { this.#localClone.set(id, newDoc); } else { this.#docs.value = new Map(this.#docs.value).set(id, newDoc); } } update(query, modifier, opts = {}) { const updated = this.#isBatching ? this.#localClone : new Map(this.#docs.value); const matchesList = extractMatchingDocs(query, updated); let matchedCount = 0; let modifiedCount = 0; for (const doc of matchesList) { matchedCount++; const modified = applyModifier(doc, modifier); if (modified !== doc) { updated.set(modified.id, modified); modifiedCount++; } } let upsertedId = null; if (matchedCount === 0 && opts.upsert) { const base = { ...query }; const applied = applyModifier(base, modifier, { inserting: true }); if (!applied.id) applied.id = newShortId(); updated.set(applied.id, applied); upsertedId = applied.id; } if (!this.#isBatching) { this.#docs.value = updated; } return { matchedCount, modifiedCount, upsertedCount: upsertedId ? 1 : 0, upsertedId, }; } remove(query) { if (Object.keys(query).length === 0) { this.#docs.value = new Map(); return; } const updated = this.#isBatching ? this.#localClone : new Map(this.#docs.value); const targets = extractMatchingDocs(query, updated); for (const doc of targets) { updated.delete(doc.id); } if (!this.#isBatching) { this.#docs.value = updated; } } async transaction(fn) { return new Promise((resolve, reject) => { this.#txQueue.push(async () => { const snapshot = new Map(this.#docs.value); try { await this.batch(fn); resolve(); } catch (err) { this.#docs.value = snapshot; reject(err); } }); }); } find(query = {}, opts = {}) { return new Cursor(() => extractMatchingDocs(query, this.#docs.value), opts); } findOne(query, opts = {}) { return this.find(query, { ...opts, limit: 1 }).first(); } count(query = {}) { return this.find(query).count(); } } class Meta extends Collection { #name; constructor(name, options) { super('_meta', { storage: options.storage }); this.#name = name; } get(key) { return this.findOne({ id: this.#name })?.[key]; } set(key, value) { this.update({ id: this.#name }, { $set: { [key]: value } }, { upsert: true }); } }