@aurios/jason
Version:
A simple, lightweight, and embeddable JSON document database built on Bun.
237 lines (205 loc) • 7.17 kB
text/typescript
import { FileSystem, Path } from "@effect/platform";
import { NodeContext } from "@effect/platform-node";
import {
Context,
Effect,
Exit,
GroupBy,
Layer,
Ref,
Runtime,
type Schema,
Scope,
Stream
} from "effect";
import { ConfigManager } from "../layers/config.js";
import { JsonFile } from "../layers/json-file.js";
import { Json } from "../layers/json.js";
import { WriteAheadLog } from "../layers/wal.js";
import { makeCollection } from "../make/collection.js";
import { makeStorageManager } from "../make/storage-manager.js";
import type {
Collection,
InferCollections,
JasonDBConfig
} from "../types/collection.js";
import type { Database, DatabaseEffect } from "../types/database.js";
import type { SchemaOrString } from "../types/schema.js";
export class JasonDB extends Context.Tag("DatabaseService")<
JasonDB,
DatabaseEffect<any>
>() {}
const makeJasonDB = <const T extends Record<string, SchemaOrString>>(
config: JasonDBConfig<T>
) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const wal = yield* WriteAheadLog;
const storage_managers = new Map<
string,
Effect.Effect.Success<ReturnType<typeof makeStorageManager>>
>();
const getStorageManager = (collectionName: string) =>
Effect.gen(function* () {
if (storage_managers.has(collectionName)) {
return storage_managers.get(collectionName)!;
}
const storageManager = yield* makeStorageManager<any>(collectionName);
storage_managers.set(collectionName, storageManager);
return storageManager;
});
const last_segment = yield* Ref.make(0);
const replay_effect = wal.replay.pipe(
Stream.flatMap(({ op, segment, position }) =>
op._tag === "BatchOp"
? Stream.fromIterable(
op.operations.map((innerOp) => ({
op: innerOp,
segment,
position
}))
)
: Stream.make({ op, segment, position })
),
Stream.tap(({ segment }) => Ref.set(last_segment, Math.max(segment, 0))),
Stream.groupByKey(
({ op }) =>
`${op.collection}/${op._tag === "CreateOp" ? op.data.id : (op as any).id}`,
{ bufferSize: 8192 }
),
(grouped_stream) =>
GroupBy.evaluate(grouped_stream, (_key, stream) =>
Stream.fromEffect(
Stream.runForEach(stream, ({ op }) =>
Effect.gen(function* () {
const storage = yield* getStorageManager(op.collection);
switch (op._tag) {
case "CreateOp": {
return yield* storage.write(op.data.id as string, op.data);
}
case "UpdateOp": {
return yield* storage.read(op.id).pipe(
Effect.flatMap((doc) =>
doc
? storage.write(op.id, { ...doc, ...op.data })
: Effect.void
),
Effect.catchTag("SystemError", () => Effect.void)
);
}
case "DeleteOp": {
return yield* storage
.remove(op.id)
.pipe(Effect.catchTag("SystemError", () => Effect.void));
}
}
})
)
)
),
Stream.runDrain
);
yield* replay_effect;
const final_last_segment = yield* Ref.get(last_segment);
if (final_last_segment > 0) {
yield* wal.checkpoint(final_last_segment);
}
const collection_names = config.collections
? Object.keys(config.collections)
: [];
const base_path = config.base_path;
yield* fs.makeDirectory(base_path, { recursive: true });
const collection_services = yield* Effect.all(
Object.fromEntries(
collection_names.map((name) => [name, makeCollection(name)])
)
);
type CollectionsSchema = {
[K in keyof T]: T[K] extends Schema.Schema<any, infer A> ? A : any;
};
const database_service: DatabaseEffect<CollectionsSchema> = {
collections: collection_services as any
};
return database_service;
});
export const createJasonDBLayer = <
const T extends Record<string, SchemaOrString>
>(
config: JasonDBConfig<T>
) => {
const ConfigLayer = ConfigManager.Default(config);
const BaseInfraLayer = Layer.mergeAll(
JsonFile.Default,
Json.Default,
WriteAheadLog.Default
);
const AppLayer = Layer.scoped(JasonDB, makeJasonDB<T>(config));
const FullInfraLayer = Layer.provideMerge(BaseInfraLayer, ConfigLayer);
return AppLayer.pipe(Layer.provide(FullInfraLayer));
};
/**
* Creates a Promise client from an Effect service.
*
* @param effect_service A Record of functions that return Effects or nested objects of such functions.
* @param run A function that takes an Effect and returns a Promise of the Effect's result.
* @returns A Record where each key is a function that returns a Promise or a nested object.
*/
function createPromiseClient(
effect_service: any,
run: (effect: Effect.Effect<any, any, any>) => Promise<any>
) {
const promise_client = {} as any;
Object.keys(effect_service).forEach((key) => {
const prop = effect_service[key];
if (typeof prop === "function") {
promise_client[key] = (...args: any[]) => run(prop(...args));
} else if (
typeof prop === "object" &&
prop !== null &&
!Effect.isEffect(prop)
) {
promise_client[key] = createPromiseClient(prop, run);
}
});
return promise_client;
}
/**
* Creates a JasonDB instance based on the provided configuration.
*
* @param config - The configuration object for the JasonDB instance.
* @returns A Promise that resolves to a Database instance with collections defined in the config.
*/
export const createJasonDB = async <
const T extends Record<string, SchemaOrString>
>(
config: JasonDBConfig<T>
): Promise<Database<InferCollections<T>>> => {
const layer = createJasonDBLayer(config).pipe(
Layer.provide(NodeContext.layer)
);
const scope = await Effect.runPromise(Scope.make());
const context = await Effect.runPromise(
Layer.buildWithScope(layer, scope) as any
);
const context_with_scope = Context.add(context as any, Scope.Scope, scope);
const runtime = Runtime.make({
...Runtime.defaultRuntime,
context: context_with_scope
});
const run = Runtime.runPromise(runtime);
const effect_base_db = await run(JasonDB);
const promise_based_collection = {} as {
[K in keyof InferCollections<T>]: Collection<InferCollections<T>[K]>;
};
for (const name in effect_base_db.collections) {
const effect_based_collection = effect_base_db.collections[name];
promise_based_collection[name as keyof typeof promise_based_collection] =
createPromiseClient(effect_based_collection, run);
}
return {
collections: promise_based_collection,
[Symbol.asyncDispose]: async () => {
await Effect.runPromise(Scope.close(scope, Exit.void));
}
};
};