Compare commits

..

No commits in common. "4ea00f515b05eddaf620af5712d24424ce6276be" and "d62b58803323953c90c49025d2c7f46517c8c9cb" have entirely different histories.

28 changed files with 260 additions and 792 deletions

View File

@ -9,18 +9,18 @@
"apps/**/*"
],
"devDependencies": {
"@eslint/js": "^9.12.0",
"@eslint/js": "^9.10.0",
"@types/eslint": "^9.6.1",
"@types/eslint__js": "^8.42.3",
"cross-env": "^7.0.3",
"eslint": "^9.12.0",
"eslint": "^9.10.0",
"husky": "^9.1.6",
"lint-staged": "^15.2.10",
"prettier": "^3.3.3",
"tsx": "^4.19.1",
"typescript": "^5.6.3",
"typescript-eslint": "^8.8.1",
"zx": "^8.1.9"
"typescript": "^5.6.2",
"typescript-eslint": "^8.6.0",
"zx": "^8.1.7"
},
"scripts": {
"lint": "eslint . --fix --report-unused-disable-directives",

View File

@ -1,7 +1,5 @@
{
"name": "@fabric/core",
"private": true,
"sideEffects": false,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@ -11,15 +9,19 @@
"files": [
"dist"
],
"private": true,
"packageManager": "yarn@4.1.1",
"devDependencies": {
"@vitest/coverage-v8": "^2.1.2",
"typescript": "^5.6.3",
"vitest": "^2.1.2"
"@types/validator": "^13.12.2",
"typescript": "^5.6.2",
"vitest": "^2.1.1"
},
"scripts": {
"test": "vitest",
"coverage": "vitest run --coverage",
"build": "tsc -p tsconfig.build.json"
},
"sideEffects": false,
"dependencies": {
"validator": "^13.12.0"
}
}

View File

@ -1,7 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { TaggedError } from "../error/tagged-error.js";
import { UnexpectedError } from "../error/unexpected-error.js";
import { MaybePromise } from "../types/maybe-promise.js";
import { Result } from "./result.js";
/**
@ -15,7 +14,7 @@ export type AsyncResult<
export namespace AsyncResult {
export async function tryFrom<T, TError extends TaggedError>(
fn: () => MaybePromise<T>,
fn: () => Promise<T>,
errorMapper: (error: any) => TError,
): AsyncResult<T, TError> {
try {
@ -26,8 +25,8 @@ export namespace AsyncResult {
}
export async function from<T>(
fn: () => MaybePromise<T>,
): AsyncResult<T, never> {
return tryFrom(fn, (error) => new UnexpectedError(error) as never);
fn: () => Promise<T>,
): AsyncResult<T, UnexpectedError> {
return tryFrom(fn, (error) => new UnexpectedError(error));
}
}

View File

@ -21,18 +21,6 @@ export namespace Run {
fn2: (value: T1) => AsyncResult<T2, TE2>,
fn3: (value: T2) => AsyncResult<T3, TE3>,
): AsyncResult<T3, TE1 | TE2 | TE3>;
// prettier-ignore
export async function seq<
T1, TE1 extends TaggedError,
T2, TE2 extends TaggedError,
T3, TE3 extends TaggedError,
T4, TE4 extends TaggedError,
>(
fn1: () => AsyncResult<T1, TE1>,
fn2: (value: T1) => AsyncResult<T2, TE2>,
fn3: (value: T2) => AsyncResult<T3, TE3>,
fn4: (value: T3) => AsyncResult<T4, TE4>,
): AsyncResult<T4, TE1 | TE2 | TE3 | TE4>;
export async function seq(
...fns: ((...args: any[]) => AsyncResult<any, any>)[]
): AsyncResult<any, any> {

View File

@ -1 +0,0 @@
export type EmptyRecord = Record<string, never>;

View File

@ -3,7 +3,7 @@ import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
coverage: {
exclude: ["dist/**", "vitest.config.ts", "**/index.ts", "**/*.spec.ts"],
exclude: ["**/index.ts"],
},
passWithNoTests: true,
},

View File

@ -1,7 +1,5 @@
{
"name": "@fabric/domain",
"private": true,
"sideEffects": false,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@ -12,11 +10,12 @@
"files": [
"dist"
],
"private": true,
"packageManager": "yarn@4.1.1",
"devDependencies": {
"@vitest/coverage-v8": "^2.1.2",
"typescript": "^5.6.3",
"vitest": "^2.1.2"
"@fabric/sqlite-store": "workspace:^",
"typescript": "^5.6.2",
"vitest": "^2.1.1"
},
"dependencies": {
"@fabric/core": "workspace:^",
@ -24,7 +23,6 @@
},
"scripts": {
"test": "vitest",
"coverage": "vitest run --coverage",
"build": "tsc -p tsconfig.build.json"
}
}

View File

@ -1,31 +1,21 @@
import { AsyncResult, MaybePromise, PosixDate } from "@fabric/core";
import { AsyncResult, PosixDate } from "@fabric/core";
import { StoreQueryError } from "../errors/query-error.js";
import { UUID } from "../types/uuid.js";
import { Event, EventFromKey } from "./event.js";
import { EventsFromStream, EventStream } from "./event-stream.js";
import { StoredEvent } from "./stored-event.js";
export interface EventStore<TEvents extends Event> {
export interface EventStore<TEventStream extends EventStream> {
/**
* Store a new event in the event store.
*/
append<T extends TEvents>(
append<
TStreamKey extends TEventStream["name"],
T extends EventsFromStream<TEventStream, TStreamKey>,
>(
streamName: TStreamKey,
event: T,
): AsyncResult<StoredEvent<T>, StoreQueryError>;
getEventsFromStream(
streamId: UUID,
): AsyncResult<StoredEvent<TEvents>[], StoreQueryError>;
subscribe<TEventKey extends TEvents["type"]>(
events: TEventKey[],
subscriber: EventSubscriber<EventFromKey<TEvents, TEventKey>>,
): void;
}
export type EventSubscriber<TEvents extends Event = Event> = (
event: StoredEvent<TEvents>,
) => MaybePromise<void>;
export interface EventFilterOptions {
fromDate?: PosixDate;
toDate?: PosixDate;

View File

@ -0,0 +1,16 @@
import { AsyncResult } from "@fabric/core";
import { UUID } from "../types/uuid.js";
import { Event } from "./event.js";
import { StoredEvent } from "./stored-event.js";
export interface EventStream<
TName extends string = string,
TEvent extends Event = Event,
> {
id: UUID;
name: TName;
append<T extends TEvent>(event: Event): AsyncResult<StoredEvent<T>>;
}
export type EventsFromStream<T extends EventStream, TKey extends string> =
T extends EventStream<TKey, infer TEvent> ? TEvent : never;

View File

@ -10,8 +10,3 @@ export interface Event<TTag extends string = string, TPayload = any> {
readonly streamId: UUID;
readonly payload: TPayload;
}
export type EventFromKey<
TEvents extends Event,
TKey extends TEvents["type"],
> = Extract<TEvents, { type: TKey }>;

View File

@ -1,3 +1,4 @@
export * from "./event-store.js";
export * from "./event-stream.js";
export * from "./event.js";
export * from "./stored-event.js";

View File

@ -1,14 +1,19 @@
import { isRecord } from "@fabric/core";
import validator from "validator";
import { InMemoryFile } from "./in-memory-file.js";
const { isBase64, isMimeType } = validator;
export function isInMemoryFile(value: unknown): value is InMemoryFile {
try {
return (
isRecord(value) &&
"data" in value &&
typeof value.data === "string" &&
isBase64(value.data.split(",")[1]) &&
"mimeType" in value &&
typeof value.mimeType === "string" &&
isMimeType(value.mimeType) &&
"name" in value &&
typeof value.name === "string" &&
"sizeInBytes" in value &&

View File

@ -6,4 +6,3 @@ export * from "./security/index.js";
export * from "./services/index.js";
export * from "./types/index.js";
export * from "./use-case/index.js";
export * from "./utils/index.js";

View File

@ -1,2 +1 @@
export * from "./json-utils.js";
export * from "./sort-by-dependencies.js";

View File

@ -3,7 +3,7 @@ import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
coverage: {
exclude: ["dist/**", "vitest.config.ts", "**/index.ts", "**/*.spec.ts"],
exclude: ["**/index.ts"],
},
passWithNoTests: true,
},

View File

@ -1,7 +1,5 @@
{
"name": "@fabric/sqlite-store",
"private": true,
"sideEffects": false,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@ -11,11 +9,11 @@
"files": [
"dist"
],
"private": true,
"packageManager": "yarn@4.1.1",
"devDependencies": {
"@vitest/coverage-v8": "^2.1.2",
"typescript": "^5.6.3",
"vitest": "^2.1.2"
"typescript": "^5.6.2",
"vitest": "^2.1.1"
},
"dependencies": {
"@fabric/core": "workspace:^",
@ -24,7 +22,6 @@
},
"scripts": {
"test": "vitest",
"coverage": "vitest run --coverage",
"build": "tsc -p tsconfig.build.json"
}
}

View File

@ -1,77 +0,0 @@
import { PosixDate, Run } from "@fabric/core";
import { Event } from "@fabric/domain";
import { UUIDGeneratorMock } from "@fabric/domain/mocks";
import { afterEach, beforeEach, describe, expect, it, vitest } from "vitest";
import { SQLiteEventStore } from "./event-store.js";
describe("Event Store", () => {
type UserCreated = Event<"UserCreated", { name: string }>;
type UserUpdated = Event<"UserUpdated", { name: string }>;
type UserDeleted = Event<"UserDeleted", void>;
type UserEvents = UserCreated | UserUpdated | UserDeleted;
let store: SQLiteEventStore<UserEvents>;
beforeEach(async () => {
store = new SQLiteEventStore(":memory:");
await Run.UNSAFE(() => store.migrate());
});
afterEach(async () => {
await Run.UNSAFE(() => store.close());
});
it("Should append an event", async () => {
const newUUID = UUIDGeneratorMock.generate();
const userCreated: UserCreated = {
type: "UserCreated",
id: newUUID,
streamId: newUUID,
payload: { name: "test" },
};
await Run.UNSAFE(() => store.append(userCreated));
const events = await Run.UNSAFE(() => store.getEventsFromStream(newUUID));
expect(events).toHaveLength(1);
expect(events[0]).toEqual({
id: newUUID,
streamId: newUUID,
type: "UserCreated",
version: BigInt(1),
timestamp: expect.any(PosixDate),
payload: { name: "test" },
});
});
it("should notify subscribers on append", async () => {
const newUUID = UUIDGeneratorMock.generate();
const userCreated: UserCreated = {
type: "UserCreated",
id: newUUID,
streamId: newUUID,
payload: { name: "test" },
};
const subscriber = vitest.fn();
store.subscribe(["UserCreated"], subscriber);
await Run.UNSAFE(() => store.append(userCreated));
expect(subscriber).toHaveBeenCalledTimes(1);
expect(subscriber).toHaveBeenCalledWith({
id: newUUID,
streamId: newUUID,
type: "UserCreated",
version: BigInt(1),
timestamp: expect.any(PosixDate),
payload: { name: "test" },
});
});
});

View File

@ -1,176 +0,0 @@
import { AsyncResult, MaybePromise, PosixDate, Run } from "@fabric/core";
import {
Event,
EventFromKey,
EventStore,
EventSubscriber,
JSONUtils,
StoredEvent,
StoreQueryError,
UUID,
} from "@fabric/domain";
import { SQLiteDatabase } from "../sqlite/sqlite-database.js";
export class SQLiteEventStore<TEvents extends Event>
implements EventStore<TEvents>
{
private db: SQLiteDatabase;
private streamVersions = new Map<UUID, bigint>();
private eventSubscribers = new Map<
TEvents["type"],
EventSubscriber<TEvents>[]
>();
constructor(private readonly dbPath: string) {
this.db = new SQLiteDatabase(dbPath);
}
async migrate(): AsyncResult<void, StoreQueryError> {
return AsyncResult.tryFrom(
async () => {
await this.db.init();
await this.db.run(
`CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
streamId TEXT NOT NULL,
version INTEGER NOT NULL,
timestamp NUMERIC NOT NULL,
payload TEXT NOT NULL,
UNIQUE(streamId, version)
)`,
);
},
(error) => new StoreQueryError(error.message, { error }),
);
}
async getEventsFromStream(
streamId: UUID,
): AsyncResult<StoredEvent<TEvents>[], StoreQueryError> {
return AsyncResult.tryFrom(
async () => {
const events = await this.db.allPrepared(
`SELECT * FROM events WHERE streamId = $id`,
{
$id: streamId,
},
(event) => ({
id: event.id,
streamId: event.streamId,
type: event.type,
version: BigInt(event.version),
timestamp: new PosixDate(event.timestamp),
payload: JSONUtils.parse(event.payload),
}),
);
return events;
},
(error) => new StoreQueryError(error.message, { error }),
);
}
async append<T extends TEvents>(
event: T,
): AsyncResult<StoredEvent<T>, StoreQueryError> {
return Run.seq(
() => this.getLastVersion(event.streamId),
(version) =>
AsyncResult.from(() => {
this.streamVersions.set(event.streamId, version + 1n);
return version;
}),
(version) => this.storeEvent(event.streamId, version + 1n, event),
(storedEvent) =>
AsyncResult.from(async () => {
await this.notifySubscribers(storedEvent);
return storedEvent;
}),
);
}
private async notifySubscribers(
event: StoredEvent<TEvents>,
): AsyncResult<void> {
return AsyncResult.from(async () => {
const subscribers = this.eventSubscribers.get(event.type) || [];
await Promise.all(subscribers.map((subscriber) => subscriber(event)));
});
}
private async getLastVersion(
streamId: UUID,
): AsyncResult<bigint, StoreQueryError> {
return AsyncResult.tryFrom(
async () => {
const { lastVersion } = await this.db.onePrepared(
`SELECT max(version) as lastVersion FROM events WHERE streamId = $id`,
{
$id: streamId,
},
);
return !lastVersion ? 0n : BigInt(lastVersion);
},
(error) =>
new StoreQueryError(`Error getting last version:${error.message}`, {
error,
}),
);
}
subscribe<TEventKey extends TEvents["type"]>(
events: TEventKey[],
subscriber: (
event: StoredEvent<EventFromKey<TEvents, TEventKey>>,
) => MaybePromise<void>,
): void {
events.forEach((event) => {
const subscribers = this.eventSubscribers.get(event) || [];
const newSubscribers = [
...subscribers,
subscriber,
] as EventSubscriber<TEvents>[];
this.eventSubscribers.set(event, newSubscribers);
});
}
close(): AsyncResult<void, StoreQueryError> {
return AsyncResult.tryFrom(
() => this.db.close(),
(error) => new StoreQueryError(error.message, { error }),
);
}
private storeEvent<T extends Event>(
streamId: UUID,
version: bigint,
event: T,
): AsyncResult<StoredEvent<T>, StoreQueryError> {
return AsyncResult.tryFrom(
async () => {
const storedEvent: StoredEvent<T> = {
...event,
version: version,
timestamp: new PosixDate(),
};
await this.db.runPrepared(
`INSERT INTO events (id, streamId, type, version, timestamp, payload)
VALUES ($id, $streamId, $type, $version, $timestamp, $payload)`,
{
$id: storedEvent.id,
$streamId: streamId,
$type: storedEvent.type,
$version: storedEvent.version.toString(),
$timestamp: storedEvent.timestamp.timestamp,
$payload: JSON.stringify(storedEvent.payload),
},
);
return storedEvent;
},
(error) => new StoreQueryError("Error appending event", { error }),
);
}
}

View File

@ -1 +0,0 @@
export * from "./event-store.js";

View File

@ -79,11 +79,7 @@ export class SQLiteDatabase {
if (err) {
reject(err);
} else {
try {
resolve(transformer ? rows.map(transformer) : rows);
} catch (e) {
reject(e);
}
resolve(transformer ? rows.map(transformer) : rows);
}
},
);
@ -104,11 +100,7 @@ export class SQLiteDatabase {
if (err) {
reject(err);
} else {
try {
resolve(transformer ? rows.map(transformer)[0] : rows[0]);
} catch (e) {
reject(e);
}
resolve(transformer ? rows.map(transformer)[0] : rows[0]);
}
},
);

View File

@ -14,7 +14,7 @@ import {
} from "@fabric/domain";
import { filterToParams, filterToSQL } from "../sqlite/filter-to-sql.js";
import { transformRow } from "../sqlite/sql-to-value.js";
import { SQLiteDatabase } from "../sqlite/sqlite-database.js";
import { SQLiteDatabase } from "../sqlite/sqlite-wrapper.js";
export class QueryBuilder<T> implements StoreQuery<T> {
constructor(

View File

@ -16,7 +16,7 @@ import {
recordToSQLParams,
recordToSQLSet,
} from "../sqlite/record-utils.js";
import { SQLiteDatabase } from "../sqlite/sqlite-database.js";
import { SQLiteDatabase } from "../sqlite/sqlite-wrapper.js";
import { QueryBuilder } from "./query-builder.js";
export class SQLiteStateStore<TModel extends Model>
@ -26,7 +26,7 @@ export class SQLiteStateStore<TModel extends Model>
private db: SQLiteDatabase;
constructor(
private readonly dbPath: string,
private dbPath: string,
models: TModel[],
) {
this.schema = models.reduce((acc, model: TModel) => {

View File

@ -3,7 +3,7 @@ import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
coverage: {
exclude: ["dist/**", "vitest.config.ts", "**/index.ts", "**/*.spec.ts"],
exclude: ["**/index.ts"],
},
passWithNoTests: true,
},

View File

@ -1,18 +1,16 @@
{
"name": "@ulthar/template-domain",
"private": true,
"sideEffects": false,
"type": "module",
"module": "dist/index.js",
"main": "dist/index.js",
"files": [
"dist"
],
"private": true,
"packageManager": "yarn@4.1.1",
"devDependencies": {
"@vitest/coverage-v8": "^2.1.2",
"typescript": "^5.6.3",
"vitest": "^2.1.2"
"typescript": "^5.6.2",
"vitest": "^2.1.1"
},
"dependencies": {
"@fabric/core": "workspace:^",
@ -20,7 +18,6 @@
},
"scripts": {
"test": "vitest",
"coverage": "vitest run --coverage",
"build": "tsc -p tsconfig.build.json"
}
}

View File

@ -3,7 +3,7 @@ import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
coverage: {
exclude: ["dist/**", "vitest.config.ts", "**/index.ts", "**/*.spec.ts"],
exclude: ["**/index.ts"],
},
passWithNoTests: true,
},

View File

@ -1,7 +1,5 @@
{
"name": "@ulthar/lib-template",
"private": true,
"sideEffects": false,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@ -11,18 +9,17 @@
"files": [
"dist"
],
"private": true,
"packageManager": "yarn@4.1.1",
"devDependencies": {
"@vitest/coverage-v8": "^2.1.2",
"typescript": "^5.6.3",
"vitest": "^2.1.2"
"typescript": "^5.6.2",
"vitest": "^2.1.1"
},
"dependencies": {
"@fabric/core": "workspace:^"
},
"scripts": {
"test": "vitest",
"coverage": "vitest run --coverage",
"build": "tsc -p tsconfig.build.json"
}
}

View File

@ -3,7 +3,7 @@ import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
coverage: {
exclude: ["dist/**", "vitest.config.ts", "**/index.ts", "**/*.spec.ts"],
exclude: ["**/index.ts"],
},
passWithNoTests: true,
},

632
yarn.lock

File diff suppressed because it is too large Load Diff