tinycoll
Version:
A minimal reactive document store with Mongo-like querying, reactivity, TTL support, and optional persistence.
415 lines (414 loc) • 13.3 kB
JavaScript
"use strict";
var _a;
Object.defineProperty(exports, "__esModule", { value: true });
exports.Collection = void 0;
const signals_core_1 = require("@preact/signals-core");
const match_js_1 = require("./internal/match.js");
const utils_js_1 = require("./utils.js");
const promise_queue_js_1 = require("./internal/promise-queue.js");
const modifier_js_1 = require("./internal/modifier.js");
class Cursor {
#result = (0, signals_core_1.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();
(0, signals_core_1.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: (0, utils_js_1.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 && (0, match_js_1.matches)(doc, query) ? [doc] : [];
}
return Array.from(map.values()).filter((doc) => (0, match_js_1.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);
}
class Collection {
#dbKey;
#docs = (0, signals_core_1.signal)(new Map());
#storage;
#ttlIndexes = [];
#ttlInterval;
#isBatching = false;
#localClone = new Map();
#meta = null;
#ready;
#txQueue = new promise_queue_js_1.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 }]));
}
(0, signals_core_1.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 || (0, utils_js_1.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 = (0, modifier_js_1.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 = (0, modifier_js_1.applyModifier)(base, modifier, { inserting: true });
if (!applied.id)
applied.id = (0, utils_js_1.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();
}
}
exports.Collection = Collection;
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 });
}
}