@aurios/jason
Version:
A simple, lightweight, and embeddable JSON document database built on Bun.
275 lines (274 loc) • 10.8 kB
JavaScript
import crypto from "node:crypto";
import { access, mkdir, readFile, readdir, unlink } from "node:fs/promises";
import path from "node:path";
import { parse, stringify } from "devalue";
import { DeleteOperationError, DocumentNotFoundError } from "../core/errors.js";
import Writer from "../io/writer.js";
import Cache from "./cache.js";
import Metadata from "./metadata.js";
export default class Collection {
#basePath;
#schema;
#metadata;
#cache;
#writer;
/**
* Constructs a new Collection.
*
* @param basePath - The base path where the collection data will be stored.
* @param name - The name of the collection.
* @param options - Options for the collection.
* @param options.schema - The schema validation function for the collection.
* @param options.concurrencyStrategy - The concurrency strategy of the collection.
* @param options.cacheTimeout - The cache timeout in milliseconds.
*/
constructor(basePath, name, options = {}) {
this.#basePath = path.join(basePath, name);
this.#schema = options.schema || (() => true);
this.#metadata = options.generateMetadata
? new Metadata(this.#basePath)
: null;
this.#writer = new Writer(this.#basePath);
this.#cache = new Cache(options.cacheTimeout);
this.#ensureCollectionExists();
}
/**
* Ensures the collection exists by creating the base path if it does not exist.
*
* If the base path does not exist, it is created recursively. Then, the collection
* metadata is saved to initialize the collection.
*
* @returns A promise that resolves when the collection exists.
*/
async #ensureCollectionExists() {
try {
await access(this.#basePath);
return;
}
catch {
try {
await mkdir(this.#basePath, { recursive: true });
return;
}
catch (error) {
console.error("Failed to create collection directory", {
path: this.#basePath,
error: error instanceof Error ? error.message : "Unknown error",
});
throw new Error(`Failed to create collection directory: ${this.#basePath}`);
}
}
}
/**
* Returns the path to the document with the given id.
*
* @param id - The id of the document.
* @returns The path to the document.
*/
#getDocumentPath(id) {
return path.join(this.#basePath, `${id}.json`);
}
/**
* Creates a new document in the collection.
*
* Ensures that the collection exists. Generates a random id for the document and validates it against the schema.
* If the document fails schema validation, it throws an error.
*
* If the concurrency strategy is "versioning", it adds versioning metadata to the document.
*
* @param data - The document data to be created.
* @returns A promise that resolves to the created document.
* @throws An error if the document failed schema validation or if the collection did not exist.
*/
async create(data) {
try {
await this.#ensureCollectionExists();
await this.#metadata?.load();
const id = data.id ?? crypto.randomUUID();
const document = { ...data, id };
if (!this.#schema(document)) {
throw new Error("Document failed schema validation");
}
await Promise.all([
this.#writer.write(id, stringify(document)),
this.#metadata?.incrementDocumentCount(),
]);
this.#cache.update(id, document);
return document;
}
catch (error) {
if (error.message === "Document failed schema validation") {
throw error;
}
throw new Error("Failed to create document");
}
}
/**
* Reads a document from the collection.
*
* If the document was modified recently (less than 1 minute ago), it returns
* the cached version. Otherwise, it reads from disk and updates the cache.
*
* If the document does not exist, it returns null.
*
* @param id - The id of the document to read.
* @returns The document, or null if it does not exist.
*/
async read(id) {
const cached = this.#cache.get(id);
if (cached)
return cached;
try {
const documentPath = this.#getDocumentPath(id);
const data = await readFile(documentPath, "utf-8");
const document = parse(data);
this.#cache.update(id, document);
return document;
}
catch {
return null;
}
}
/**
* Reads all documents from the collection.
*
* Returns an array of documents.
*
* @param options - Optional parameters to control the result.
* @param options.skip - The number of documents to skip.
* @param options.limit - The maximum number of documents to return.
* @returns A promise that resolves to an array of documents.
*/
async readAll(options) {
const files = (await readdir(this.#basePath, { withFileTypes: true })).filter((file) => file.isFile() &&
!file.name.startsWith("_") &&
file.name.endsWith(".json"));
const batchSize = 100;
const skip = options?.skip ?? 0;
const limit = options?.limit;
const effectiveLimit = limit !== undefined ? skip + limit : Number.POSITIVE_INFINITY;
let result = [];
for (let i = 0; i < files.length && result.length < effectiveLimit; i += batchSize) {
const remaining = effectiveLimit - result.length;
const currentBatchSize = Math.min(batchSize, remaining);
const batch = files.slice(i, i + currentBatchSize);
const batchResults = await Promise.all(batch.map((file) => this.read(file.name.replace(".json", ""))));
result = result.concat(batchResults.filter(Boolean));
}
return result;
}
/**
* Checks if a document with the specified id exists in the collection.
*
* @param id - The id of the document to check.
* @returns A promise that resolves to true if the document exists, false otherwise.
*/
async has(id) {
try {
await access(this.#getDocumentPath(id));
return true;
}
catch {
return false;
}
}
/**
* Updates a document with the specified id using the provided data.
*
* If the concurrency strategy is "optimistic", it attempts to acquire a lock
* before proceeding with the update. If the lock cannot be acquired, an error
* is thrown. After updating, the lock is released.
*
* If the concurrency strategy is not "optimistic", the update is performed
* without locking.
*
* @param id - The id of the document to be updated.
* @param data - The partial data to update the document with.
* @returns A promise that resolves to the updated document or null if the
* document does not exist.
* @throws An error if the lock cannot be acquired or if the update operation
* encounters an error.
*/
async update(id, data) {
const current = await this.read(id);
if (!current)
return null;
const updated = {
...current,
...data,
id: current.id,
};
if (!this.#schema(updated)) {
throw new Error("Document failed schema validation");
}
await this.#writer.write(id, stringify(updated));
await this.#metadata?.updateLastModified();
this.#cache.update(id, updated);
return updated;
}
/**
* Deletes a document with the specified id from the collection.
*
* @param id - The id of the document to delete.
* @returns A promise that resolves to true if the document is successfully
* deleted.
* @throws DocumentNotFoundError if the document does not exist.
* @throws DeleteOperationError if an error is encountered during the deletion
* operation.
*/
async delete(id) {
try {
const documentPath = this.#getDocumentPath(id);
if (!(await this.has(id))) {
throw new DocumentNotFoundError(`Document: ${id} not found`);
}
unlink(documentPath);
this.#metadata?.decrementDocumentCount();
this.#cache.delete(id);
return true;
}
catch (error) {
throw new DeleteOperationError("Failed to delete document", error);
}
}
/**
* Queries documents in the collection based on the provided filter function.
*
* Retrieves documents from the collection, applies the filter function to each
* document, and returns an array of documents that match the filter criteria.
* Supports concurrent processing and batch processing for efficiency.
*
* @param filter - A function that takes a document and returns a boolean
* indicating whether the document matches the filter criteria.
* @param options - Optional parameters to control the query execution.
* @param options.batchSize - The number of documents to process in each batch.
* @param options.skip - The number of documents to skip from the start of the
* result.
* @param options.limit - The maximum number of documents to return.
* @returns A promise that resolves to an array of documents matching the filter
* criteria.
*/
async query(filter, options = {}) {
const files = await readdir(this.#basePath, { withFileTypes: true });
const jsonFiles = files
.filter((file) => file.isFile() && file.name.endsWith(".json"))
.map((file) => file.name);
const results = [];
const batchSize = options.batchSize ?? 100;
for (let i = 0; i < jsonFiles.length; i += batchSize) {
const batch = jsonFiles.slice(i, i + batchSize);
const batchDocs = (await Promise.all(batch.map((name) => this.read(name.replace(".json", "")))));
results.push(...batchDocs.filter((d) => d && filter(d)));
}
let finalResult = results;
const skip = options.skip ?? 0;
const limit = options.limit;
if (skip > 0) {
finalResult = results.slice(skip);
}
if (limit && limit >= 0) {
finalResult = finalResult.slice(0, limit);
}
return finalResult;
}
}