Add fabric-core

This commit is contained in:
Pablo Baleztena 2024-09-04 19:19:34 -03:00
parent 37df98d09c
commit b164c7d97f
59 changed files with 701 additions and 0 deletions

View File

@ -0,0 +1 @@
# @ulthar/fabric-core

View File

@ -0,0 +1,31 @@
{
"name": "@ulthar/fabric-core",
"type": "module",
"module": "dist/index.js",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": "./dist/index.js",
"./domain": "./dist/domain.js",
"./validation": "./dist/validation.js",
"./validation/fields": "./dist/validation/fields/index.js"
},
"files": [
"dist"
],
"private": true,
"packageManager": "yarn@4.1.1",
"devDependencies": {
"@types/validator": "^13.12.1",
"typescript": "^5.5.4",
"vitest": "^2.0.5"
},
"scripts": {
"test": "vitest",
"build": "tsc -p tsconfig.build.json"
},
"sideEffects": false,
"dependencies": {
"validator": "^13.12.0"
}
}

View File

@ -0,0 +1,11 @@
import { UUID } from "../types/uuid.js";
/**
* An entity is a domain object that is defined by its identity.
*
* Entities have a unique identity (`id`), which distinguishes
* them from other entities.
*/
export interface Entity {
id: UUID;
}

View File

@ -0,0 +1,10 @@
import { MimeType } from "./mime-type.js";
/**
* Represents a file. Its the base type for all files.
*/
export interface BaseFile {
name: string;
sizeInBytes: number;
mimeType: MimeType;
}

View File

@ -0,0 +1,11 @@
export function KiBs(value: number): number {
return value * 1024;
}
export function MiBs(value: number): number {
return KiBs(value * 1024);
}
export function GiBs(value: number): number {
return MiBs(value * 1024);
}

View File

@ -0,0 +1,9 @@
import { Entity } from "../entity.js";
import { BaseFile } from "./base-file.js";
/**
* Represents a file as managed by the domain.
*/
export interface DomainFile extends BaseFile, Entity {
url: string;
}

View File

@ -0,0 +1,9 @@
import { DomainFile } from "./domain-file.js";
import { ImageMimeType } from "./mime-type.js";
/**
* Represents an image file.
*/
export interface ImageFile extends DomainFile {
mimeType: ImageMimeType;
}

View File

@ -0,0 +1,10 @@
export * from "./base-file.js";
export * from "./bytes.js";
export * from "./domain-file.js";
export * from "./image-file.js";
export * from "./invalid-file-type-error.js";
export * from "./is-mime-type.js";
export * from "./is-uploaded-file.js";
export * from "./media-file.js";
export * from "./mime-type.js";
export * from "./uploaded-file.js";

View File

@ -0,0 +1,7 @@
import { TaggedError } from "../../../error/tagged-error.js";
export class InvalidFileTypeError extends TaggedError<"InvalidFileTypeError"> {
constructor() {
super("InvalidFileTypeError");
}
}

View File

@ -0,0 +1,21 @@
import { describe, expect, expectTypeOf, it } from "vitest";
import { isMimeType } from "./is-mime-type.js";
describe("isMimeType", () => {
it("should return true if the file type is the same as the mime type", () => {
const fileType = "image/png" as string;
const result = isMimeType("image/.*", fileType);
expect(result).toBe(true);
if (result) {
expectTypeOf(fileType).toEqualTypeOf<"image/.*">();
}
});
it("should return false if the file type is not the same as the mime type", () => {
const fileType = "image/png" as string;
expect(isMimeType("image/jpeg", fileType)).toBe(false);
const anotherFileType = "file:image/jpeg" as string;
expect(isMimeType("image/jpeg", anotherFileType)).toBe(false);
});
});

View File

@ -0,0 +1,11 @@
import { MimeType } from "./mime-type.js";
/**
* Checks if the actual file type is the same as the expected mime type.
*/
export function isMimeType<T extends MimeType>(
expectedMimeType: T,
actualFileType: string,
): actualFileType is T {
return actualFileType.match("^" + expectedMimeType + "$") !== null;
}

View File

@ -0,0 +1,26 @@
import validator from "validator";
import { isRecord } from "../../../record/is-record.js";
import { InMemoryFile } from "./uploaded-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 &&
typeof value.sizeInBytes === "number" &&
value.sizeInBytes >= 1
);
} catch {
return false;
}
}

View File

@ -0,0 +1,8 @@
import { DomainFile } from "./domain-file.js";
/**
* Represents a media file, either an image, a video or an audio file.
*/
export interface MediaFile extends DomainFile {
mimeType: `image/${string}` | `video/${string}` | `audio/${string}`;
}

View File

@ -0,0 +1,13 @@
export type MimeType = `${string}/${string}`;
export const ImageMimeType = `image/.*` as `image/${string}`;
export type ImageMimeType = typeof ImageMimeType;
export const VideoMimeType = `video/.*` as `video/${string}`;
export type VideoMimeType = typeof VideoMimeType;
export const AudioMimeType = `audio/.*` as `audio/${string}`;
export type AudioMimeType = typeof AudioMimeType;
export const PdfMimeType = `application/pdf` as `application/pdf`;
export type PdfMimeType = typeof PdfMimeType;

View File

@ -0,0 +1,9 @@
import { Base64String } from "../../types/base-64.js";
import { BaseFile } from "./base-file.js";
/**
* Represents a file with its contents in memory.
*/
export interface InMemoryFile extends BaseFile {
data: Base64String;
}

View File

@ -0,0 +1,2 @@
export * from "./entity.js";
export * from "./files/index.js";

View File

@ -0,0 +1,7 @@
import { TaggedVariant } from "../../variant/variant.js";
export interface Event<TTag extends string, TPayload>
extends TaggedVariant<TTag> {
payload: TPayload;
timestamp: number;
}

View File

@ -0,0 +1,4 @@
export * from "./entity/index.js";
export * from "./security/index.js";
export * from "./types/index.js";
export * from "./use-case/index.js";

View File

@ -0,0 +1 @@
export * from "./policy-map.js";

View File

@ -0,0 +1,4 @@
export type PolicyMap<
UserType extends string,
PolicyType extends string,
> = Record<UserType, PolicyType[]>;

View File

@ -0,0 +1 @@
export type Base64String = string;

View File

@ -0,0 +1 @@
export type Email = `${string}@${string}.${string}`;

View File

@ -0,0 +1,3 @@
export * from "./email.js";
export * from "./sem-ver.js";
export * from "./uuid.js";

View File

@ -0,0 +1,4 @@
/**
* Semantic versioning type. [Major].[Minor].[Patch]. Example: 1.0.0
*/
export type SemVer = `${number}.${number}.${number}`;

View File

@ -0,0 +1 @@
export type UUID = `${string}-${string}-${string}-${string}-${string}`;

View File

@ -0,0 +1,2 @@
export * from "./use-case-definition.js";
export * from "./use-case.js";

View File

@ -0,0 +1,51 @@
import { TaggedError } from "../../error/tagged-error.js";
import { UseCase } from "./use-case.js";
export type UseCaseDefinition<
TDependencies,
TPayload,
TOutput,
TErrors extends TaggedError<string>,
> = TPayload extends undefined
? {
/**
* The use case name.
*/
name: string;
/**
* Whether the use case requires authentication or not.
*/
isAuthRequired?: boolean;
/**
* The required permissions to execute the use case.
*/
requiredPermissions?: string[];
/**
* The use case function.
*/
useCase: UseCase<TDependencies, TPayload, TOutput, TErrors>;
}
: {
/**
* The use case name.
*/
name: string;
/**
* Whether the use case requires authentication or not.
*/
isAuthRequired?: boolean;
/**
* The required permissions to execute the use case.
*/
requiredPermissions?: string[];
/**
* The use case function.
*/
useCase: UseCase<TDependencies, TPayload, TOutput, TErrors>;
};

View File

@ -0,0 +1,22 @@
import { TaggedError } from "../../error/tagged-error.js";
import { AsyncResult } from "../../result/async-result.js";
/**
* A use case is a piece of domain logic that can be executed.
*
* It can be one of two types:
*
* - `Query`: A use case that only reads data.
* - `Command`: A use case that modifies data.
*/
export type UseCase<
TDependencies,
TPayload,
TOutput,
TErrors extends TaggedError<string>,
> = TPayload extends undefined
? (dependencies: TDependencies) => AsyncResult<TOutput, TErrors>
: (
dependencies: TDependencies,
payload: TPayload,
) => AsyncResult<TOutput, TErrors>;

View File

@ -0,0 +1,2 @@
export * from "./tagged-error.js";
export * from "./unexpected-error.js";

View File

@ -0,0 +1,23 @@
import { describe, expect, expectTypeOf, it } from "vitest";
import { Result } from "../result/result.js";
import { isError } from "./is-error.js";
import { TaggedError } from "./tagged-error.js";
describe("is-error", () => {
it("should determine if a value is an error", () => {
type DemoResult = Result<number, TaggedError<"DemoError">>;
//Ok should not be an error
const ok: DemoResult = 42;
expect(isError(ok)).toBe(false);
//Error should be an error
const error: DemoResult = new TaggedError("DemoError");
expect(isError(error)).toBe(true);
//After a check, typescript should be able to infer the type
if (isError(error)) {
expectTypeOf(error).toEqualTypeOf<TaggedError<"DemoError">>();
}
});
});

View File

@ -0,0 +1,15 @@
import { VariantTag } from "../variant/variant.js";
import { TaggedError } from "./tagged-error.js";
/**
* Indicates if a value is an error.
*
* In case it is an error, the type of the error is able to be inferred.
*/
export function isError(err: unknown): err is TaggedError<string> {
return (
err instanceof Error &&
VariantTag in err &&
typeof err[VariantTag] === "string"
);
}

View File

@ -0,0 +1,18 @@
import { TaggedVariant, VariantTag } from "../variant/index.js";
/**
* Un TaggedError es un error que tiene un tag que lo identifica, lo cual
* permite a los consumidores de la instancia de error identificar el tipo de
* error que ocurrió.
*/
export class TaggedError<Tag extends string>
extends Error
implements TaggedVariant<Tag>
{
readonly [VariantTag]: Tag;
constructor(tag: Tag) {
super();
this[VariantTag] = tag;
}
}

View File

@ -0,0 +1,14 @@
import { TaggedError } from "./tagged-error.js";
/**
* `UnexpectedError` representa cualquier tipo de error inesperado.
*
* Este error se utiliza para representar errores que no deberían ocurrir en
* la lógica de la aplicación, pero que siempre podrían suceder y
* debemos estar preparados para manejarlos.
*/
export class UnexpectedError extends TaggedError<"UnexpectedError"> {
constructor() {
super("UnexpectedError");
}
}

View File

@ -0,0 +1,6 @@
export * from "./domain/index.js";
export * from "./error/index.js";
export * from "./record/index.js";
export * from "./result/index.js";
export * from "./types/index.js";
export * from "./variant/index.js";

View File

@ -0,0 +1,2 @@
export * from "./is-record-empty.js";
export * from "./is-record.js";

View File

@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { isRecordEmpty } from "./is-record-empty.js";
describe("Record - Is Record Empty", () => {
it("should return true for an empty record", () => {
const result = isRecordEmpty({});
expect(result).toBe(true);
});
it("should return false for a non-empty record", () => {
const result = isRecordEmpty({ key: "value" });
expect(result).toBe(false);
});
it("should return false for a record with multiple keys", () => {
const result = isRecordEmpty({ key1: "value1", key2: "value2" });
expect(result).toBe(false);
});
});

View File

@ -0,0 +1,6 @@
/**
* Check if a record is empty (A record is empty when it doesn't contain any keys).
*/
export function isRecordEmpty(value: Record<string, unknown>): boolean {
return Object.keys(value).length === 0;
}

View File

@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { isRecord } from "./is-record.js";
describe("isRecord", () => {
it("should return true for an object", () => {
const obj = { name: "John", age: 30 };
expect(isRecord(obj)).toBe(true);
});
it("should return false for an array", () => {
const arr = [1, 2, 3];
expect(isRecord(arr)).toBe(false);
});
it("should return false for null", () => {
const value = null;
expect(isRecord(value)).toBe(false);
});
it("should return false for a string", () => {
const value = "Hello";
expect(isRecord(value)).toBe(false);
});
});

View File

@ -0,0 +1,6 @@
/**
* Checks if a value is a record (an object).
*/
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

View File

@ -0,0 +1,11 @@
import { TaggedError } from "../error/tagged-error.js";
import { Result } from "./result.js";
/**
* Un AsyncResult representa el resultado de una operación asíncrona que puede
* resolver en un valor de tipo `TValue` o en un error de tipo `TError`.
*/
export type AsyncResult<
TValue,
TError extends TaggedError<string> = never,
> = Promise<Result<TValue, TError>>;

View File

@ -0,0 +1,2 @@
export * from "./async-result.js";
export * from "./result.js";

View File

@ -0,0 +1,9 @@
import { TaggedError } from "../error/tagged-error.js";
/**
* Un Result representa el resultado de una operación
* que puede ser un valor de tipo `TValue` o un error `TError`.
*/
export type Result<TValue, TError extends TaggedError<string>> =
| TValue
| TError;

View File

@ -0,0 +1,3 @@
export * from "./posix-date.js";
export * from "./time-constants.js";
export * from "./timeout.js";

View File

@ -0,0 +1,9 @@
import { TaggedVariant } from "../variant/variant.js";
export class PosixDate {
constructor(public readonly timestamp: number) {}
}
export interface TimeZone extends TaggedVariant<"TimeZone"> {
timestamp: number;
}

View File

@ -0,0 +1,22 @@
/**
* Returns the number of milliseconds.
*
* This is a no-op function that is used to
* make the code more explicit and readable.
*/
export const ms = (n: number) => n;
/**
* Converts a number of seconds to milliseconds.
*/
export const seconds = (n: number) => n * 1000;
/**
* Converts a number of minutes to milliseconds.
*/
export const minutes = (n: number) => n * 60 * 1000;
/**
* Converts a number of hours to milliseconds.
*/
export const hours = (n: number) => n * 60 * 60 * 1000;

View File

@ -0,0 +1,44 @@
import { describe, expect, test } from "vitest";
import { timeout } from "./timeout.js";
const HEAVY_TESTS =
process.env.HEAVY_TESTS === "true" || process.env.RUN_ALL_TESTS === "true";
describe("timeout", () => {
test.runIf(HEAVY_TESTS)(
"timeout never triggers *before* the input time",
async () => {
const count = 10000;
const maxTimeInMs = 1000;
const result = await Promise.all(
new Array(count).fill(0).map(async (e, i) => {
const start = Date.now();
const ms = i % maxTimeInMs;
await timeout(ms);
const end = Date.now();
return end - start;
}),
);
expect(
result
.map((t, i) => {
return [t, i % maxTimeInMs]; //Actual time and expected time
})
.filter((e) => {
return e[0] < e[1]; //Actual time is less than the expected time
}),
).toEqual([]);
},
);
test("using ms we can define a timeout in milliseconds", async () => {
const start = Date.now();
await timeout(100);
const end = Date.now();
const time = end - start;
expect(time).toBeGreaterThanOrEqual(100);
});
});

View File

@ -0,0 +1,14 @@
export function timeout(ms: number) {
return new Promise<void>((resolve) => {
const start = Date.now();
setTimeout(() => {
const end = Date.now();
const remaining = ms - (end - start);
if (remaining > 0) {
timeout(remaining).then(resolve);
} else {
resolve();
}
}, ms);
});
}

View File

@ -0,0 +1,16 @@
/**
* A function that takes an argument of type `T` and returns a value of type `R`.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type Fn<T = any, R = any> = (arg: T) => R;
/**
* An action is a function that takes an argument of type `T` and returns nothing.
*/
export type Action<T> = Fn<T, void>;
/**
* A `Singleton`
* is function that takes no arguments and returns a value of type `T`.
*/
export type Singleton<T> = Fn<void, T>;

View File

@ -0,0 +1,2 @@
export * from "./fn.js";
export * from "./optional.js";

View File

@ -0,0 +1,13 @@
/**
* `Nothing` is a type that represents the absence of a value.
* In JavaScript, `undefined` represents that a value was not defined
* and `null` represents the absence of a value, but `Nothing` is a type that
* can be used to represent the absence of a value in a more explicit way.
*/
export type Nothing = null;
export const Nothing = null;
/**
* Un Optional es un tipo que puede ser un valor o no ser nada.
*/
export type Optional<T> = T | Nothing;

View File

@ -0,0 +1,2 @@
export * from "./match.js";
export * from "./variant.js";

View File

@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { match } from "./match.js";
import { TaggedVariant, VariantTag } from "./variant.js";
interface V1 extends TaggedVariant<"V1"> {
a: number;
}
interface V2 extends TaggedVariant<"V2"> {
b: string;
}
type Variant = V1 | V2;
describe("Pattern matching", () => {
it("Should match a pattern", () => {
const v = { [VariantTag]: "V1", a: 42 } as Variant;
const result = match(v).case({
V1: (v) => v.a,
V2: (v) => v.b,
});
expect(result).toBe(42);
});
it("Should alert that a pattern is not exhaustive", () => {
const v = { [VariantTag]: "V1", a: 42 } as Variant;
expect(() =>
// @ts-expect-error Testing non-exhaustive pattern matching
match(v).case({
V2: (v) => v.b,
}),
).toThrowError("Non-exhaustive pattern match");
});
});

View File

@ -0,0 +1,24 @@
import { Fn } from "../types/fn.js";
import { TaggedVariant, VariantFromTag, VariantTag } from "./variant.js";
export type VariantMatcher<TVariant extends TaggedVariant<string>> = {
[K in TVariant[VariantTag]]: Fn<VariantFromTag<TVariant, K>>;
};
export function match<const TVariant extends TaggedVariant<string>>(
v: TVariant,
) {
return {
case<const TMatcher extends VariantMatcher<TVariant>>(
cases: TMatcher,
): ReturnType<TMatcher[TVariant[VariantTag]]> {
if (!(v[VariantTag] in cases)) {
throw new Error("Non-exhaustive pattern match");
}
return cases[v[VariantTag] as TVariant[VariantTag]](
v as Extract<TVariant, { [VariantTag]: TVariant[VariantTag] }>,
);
},
};
}

View File

@ -0,0 +1,11 @@
export const VariantTag = "_tag";
export type VariantTag = typeof VariantTag;
export interface TaggedVariant<TTag extends string> {
readonly [VariantTag]: TTag;
}
export type VariantFromTag<
TVariant extends TaggedVariant<string>,
TTag extends TVariant[typeof VariantTag],
> = Extract<TVariant, { [VariantTag]: TTag }>;

View File

@ -0,0 +1,15 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"allowImportingTsExtensions": false,
"outDir": "dist"
},
"exclude": [
"src/**/*.spec.ts",
"dist",
"node_modules",
"coverage",
"vitest.config.ts"
]
}

View File

@ -0,0 +1,4 @@
{
"extends": "../../../tsconfig.json",
"exclude": ["dist", "node_modules"]
}

View File

@ -0,0 +1,10 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
coverage: {
exclude: ["**/index.ts"],
},
passWithNoTests: true,
},
});

View File

@ -12,6 +12,9 @@
"typescript": "^5.5.4",
"vitest": "^2.0.5"
},
"dependencies": {
"@ulthar/fabric-core": "workspace:^"
},
"scripts": {
"test": "vitest",
"build": "tsc -p tsconfig.build.json"

View File

@ -703,6 +703,13 @@ __metadata:
languageName: node
linkType: hard
"@types/validator@npm:^13.12.1":
version: 13.12.1
resolution: "@types/validator@npm:13.12.1"
checksum: 10c0/473b12e287f569e08741c24d4d91663e740ec6264032eeb311c21c8f00dfa274c6fe5af9190ffe1b4b527e95a1bb31c81598682d5dbd76e1604f898bc19adc2b
languageName: node
linkType: hard
"@typescript-eslint/eslint-plugin@npm:8.4.0":
version: 8.4.0
resolution: "@typescript-eslint/eslint-plugin@npm:8.4.0"
@ -819,10 +826,22 @@ __metadata:
languageName: node
linkType: hard
"@ulthar/fabric-core@workspace:^, @ulthar/fabric-core@workspace:packages/fabric/core":
version: 0.0.0-use.local
resolution: "@ulthar/fabric-core@workspace:packages/fabric/core"
dependencies:
"@types/validator": "npm:^13.12.1"
typescript: "npm:^5.5.4"
validator: "npm:^13.12.0"
vitest: "npm:^2.0.5"
languageName: unknown
linkType: soft
"@ulthar/lib-template@workspace:packages/templates/lib":
version: 0.0.0-use.local
resolution: "@ulthar/lib-template@workspace:packages/templates/lib"
dependencies:
"@ulthar/fabric-core": "workspace:^"
typescript: "npm:^5.5.4"
vitest: "npm:^2.0.5"
languageName: unknown
@ -3124,6 +3143,13 @@ __metadata:
languageName: node
linkType: hard
"validator@npm:^13.12.0":
version: 13.12.0
resolution: "validator@npm:13.12.0"
checksum: 10c0/21d48a7947c9e8498790550f56cd7971e0e3d724c73388226b109c1bac2728f4f88caddfc2f7ed4b076f9b0d004316263ac786a17e9c4edf075741200718cd32
languageName: node
linkType: hard
"vite-node@npm:2.0.5":
version: 2.0.5
resolution: "vite-node@npm:2.0.5"