UNPKG

@aurios/jason

Version:

A simple, lightweight, and embeddable JSON document database built on Bun.

366 lines (318 loc) 11.5 kB
import { FileSystem } from "@effect/platform"; import { Effect } from "effect"; import { DatabaseError } from "../core/errors.js"; import { ConfigManager } from "../layers/config.js"; import { makeIndexService } from "../layers/index.js"; import { WriteAheadLog } from "../layers/wal.js"; import type { BatchResult, Filter, QueryOptions } from "../types/collection.js"; import { makeMetadata } from "./metadata.js"; import { makeQuery } from "./query.js"; import { makeStorageManager } from "./storage-manager.js"; import { ValidationError } from "../core/errors.js"; export const makeCollection = <Doc extends Record<string, any>>( collection_name: string ) => Effect.gen(function* () { // load services const fs = yield* FileSystem.FileSystem; const config = yield* ConfigManager; const wal = yield* WriteAheadLog; // load path, schema and index const collection_path = yield* config.getCollectionPath(collection_name); const cache_config = yield* config.getCacheConfig; // make index if it's non existent yield* fs.makeDirectory(collection_path, { recursive: true }); const storage = yield* makeStorageManager<Doc>(collection_name, { cacheCapacity: cache_config?.document_capacity }); const indexService = yield* makeIndexService(collection_name); const metadataService = yield* makeMetadata(collection_name); const queryManager = yield* makeQuery<Doc>( collection_name, indexService, storage ); return { batch: { insert: (docs: Doc[]) => Effect.gen(function* () { const results: BatchResult = { success: 0, failures: [] }; const tasks = docs.map((data, i) => Effect.gen(function* () { const id = (data.id as string) ?? crypto.randomUUID(); const new_document = { ...data, id } as Doc; yield* storage.write(id, new_document); yield* Effect.all( [ metadataService.incrementCount, indexService.update(undefined, new_document) ], { discard: true, concurrency: "unbounded" } ); results.success++; return { _tag: "CreateOp", collection: collection_name, data: new_document } as const; }).pipe( Effect.catchAll((error) => { if (error._tag === "ValidationError") { results.failures.push({ index: i, error: error.message }); } else { results.failures.push({ index: i, error: error.toString() }); } return Effect.succeed(null); }) ) ); const ops = yield* Effect.all(tasks, { concurrency: "unbounded" }).pipe( Effect.map((results) => results.filter( (op): op is NonNullable<typeof op> => op !== null ) ) ); if (ops.length > 0) { yield* wal.log({ _tag: "BatchOp", collection: collection_name, operations: ops }); } return results; }).pipe( Effect.mapError( (cause) => new DatabaseError({ message: "Failed to insert batch", cause }) ) ), delete: (filter: Filter<Doc>) => Effect.gen(function* () { const docs_to_delete = yield* queryManager.find({ where: filter }); const results: BatchResult = { success: 0, failures: [] }; const tasks = docs_to_delete.map((doc, i) => Effect.gen(function* () { const id = doc.id; yield* storage.remove(id); yield* Effect.all( [ metadataService.decrementCount, indexService.update(doc, undefined) ], { discard: true, concurrency: "unbounded" } ); results.success++; return { _tag: "DeleteOp", collection: collection_name, id } as const; }).pipe( Effect.catchAll((error) => { results.failures.push({ index: i, error: error.toString() }); return Effect.succeed(null); }) ) ); const ops = yield* Effect.all(tasks, { concurrency: "unbounded" }).pipe( Effect.map((results) => results.filter( (op): op is NonNullable<typeof op> => op !== null ) ) ); if (ops.length > 0) { yield* wal.log({ _tag: "BatchOp", collection: collection_name, operations: ops } as any); } return results; }).pipe( Effect.mapError( (cause) => new DatabaseError({ message: "Failed to delete batch", cause }) ) ), update: (filter: Filter<Doc>, data: Partial<Omit<Doc, "id">>) => Effect.gen(function* () { const docs_to_update = yield* queryManager.find({ where: filter }); const results: BatchResult = { success: 0, failures: [] }; const tasks = docs_to_update.map((old_document, i) => Effect.gen(function* () { const id = old_document.id; const new_document = { ...old_document, ...data } as Doc; yield* storage.write(id, new_document); yield* Effect.all( [ metadataService.touch, indexService.update(old_document, new_document) ], { discard: true, concurrency: "unbounded" } ); results.success++; return { _tag: "UpdateOp", collection: collection_name, id, data } as const; }).pipe( Effect.catchAll((error) => { if (error._tag === "ValidationError") { results.failures.push({ index: i, error: error.message }); } else { results.failures.push({ index: i, error: error.toString() }); } return Effect.succeed(null); }) ) ); const ops = yield* Effect.all(tasks, { concurrency: "unbounded" }).pipe( Effect.map((results) => results.filter( (op): op is NonNullable<typeof op> => op !== null ) ) ); if (ops.length > 0) { yield* wal.log({ _tag: "BatchOp", collection: collection_name, operations: ops } as any); } return results; }).pipe( Effect.mapError( (cause) => new DatabaseError({ message: "Failed to update batch", cause }) ) ) }, find: queryManager.find, create: (data: Doc) => Effect.gen(function* () { const id = (data.id as string) ?? crypto.randomUUID(); const new_document = { ...data, id } as Doc; // Perform storage write first to ensure validation passes before logging to WAL // Actually, WAL should probably be first for durability, but if validation fails, // we don't want it in WAL. // Let's do validation explicitly if we want, or just let storage.write handle it. yield* storage.write(id, new_document); yield* wal.log({ _tag: "CreateOp", collection: collection_name, data: new_document }); yield* Effect.all( [ metadataService.incrementCount, indexService.update(undefined, new_document) ], { discard: true, concurrency: "unbounded" } ); return new_document; }).pipe( Effect.catchTag("ValidationError", (e) => Effect.fail(e)), Effect.mapError((cause) => { if (cause._tag === "ValidationError") return cause; return new DatabaseError({ message: "Failed to create document", cause }); }) ), findById: storage.read, update: (id: string, data: Partial<Doc>) => Effect.gen(function* () { const old_document = yield* storage.read(id); if (!old_document) return undefined; const new_document = { ...old_document, ...data } as Doc; yield* storage.write(id, new_document); yield* wal.log({ _tag: "UpdateOp", collection: collection_name, id, data }); yield* Effect.all( [ metadataService.touch, indexService.update(old_document, new_document) ], { concurrency: "unbounded", discard: true } ); return new_document; }).pipe( Effect.catchTag("ValidationError", (e) => Effect.fail(e)), Effect.mapError((cause) => { if (cause._tag === "ValidationError") return cause; return new DatabaseError({ message: `Failed to update document ${id}`, cause }); }) ), delete: (id: string) => Effect.gen(function* () { const old_document = yield* storage.read(id); if (!old_document) return false; yield* wal.log({ _tag: "DeleteOp", collection: collection_name, id }); yield* storage.remove(id); yield* Effect.all( [ metadataService.decrementCount, indexService.update(old_document, undefined) ], { concurrency: "unbounded", discard: true } ); return true; }).pipe( Effect.mapError( (cause) => new DatabaseError({ message: `Failed to delete document ${id}`, cause }) ) ), findOne: (options: QueryOptions<Doc>) => queryManager .find({ ...options, limit: 1 }) .pipe(Effect.map((docs) => docs[0])), has: storage.exists, count: metadataService.get.pipe(Effect.map((m) => m.document_count)), getMetadata: metadataService.get }; });