From b164c7d97f8c91181c4577f5e7e0a5a0b52c9667 Mon Sep 17 00:00:00 2001 From: Pablo Baleztena Date: Wed, 4 Sep 2024 19:19:34 -0300 Subject: [PATCH] Add fabric-core --- packages/fabric/core/README.md | 1 + packages/fabric/core/package.json | 31 +++++++++++ .../fabric/core/src/domain/entity/entity.ts | 11 ++++ .../core/src/domain/entity/files/base-file.ts | 10 ++++ .../core/src/domain/entity/files/bytes.ts | 11 ++++ .../src/domain/entity/files/domain-file.ts | 9 ++++ .../src/domain/entity/files/image-file.ts | 9 ++++ .../core/src/domain/entity/files/index.ts | 10 ++++ .../entity/files/invalid-file-type-error.ts | 7 +++ .../domain/entity/files/is-mime-type.spec.ts | 21 ++++++++ .../src/domain/entity/files/is-mime-type.ts | 11 ++++ .../domain/entity/files/is-uploaded-file.ts | 26 ++++++++++ .../src/domain/entity/files/media-file.ts | 8 +++ .../core/src/domain/entity/files/mime-type.ts | 13 +++++ .../src/domain/entity/files/uploaded-file.ts | 9 ++++ .../fabric/core/src/domain/entity/index.ts | 2 + .../fabric/core/src/domain/events/event.ts | 7 +++ packages/fabric/core/src/domain/index.ts | 4 ++ .../fabric/core/src/domain/security/index.ts | 1 + .../core/src/domain/security/policy-map.ts | 4 ++ .../fabric/core/src/domain/types/base-64.ts | 1 + .../fabric/core/src/domain/types/email.ts | 1 + .../fabric/core/src/domain/types/index.ts | 3 ++ .../fabric/core/src/domain/types/sem-ver.ts | 4 ++ packages/fabric/core/src/domain/types/uuid.ts | 1 + .../fabric/core/src/domain/use-case/index.ts | 2 + .../domain/use-case/use-case-definition.ts | 51 +++++++++++++++++++ .../core/src/domain/use-case/use-case.ts | 22 ++++++++ packages/fabric/core/src/error/index.ts | 2 + .../fabric/core/src/error/is-error.spec.ts | 23 +++++++++ packages/fabric/core/src/error/is-error.ts | 15 ++++++ .../fabric/core/src/error/tagged-error.ts | 18 +++++++ .../fabric/core/src/error/unexpected-error.ts | 14 +++++ packages/fabric/core/src/index.ts | 6 +++ packages/fabric/core/src/record/index.ts | 2 + .../core/src/record/is-record-empty.spec.ts | 19 +++++++ .../fabric/core/src/record/is-record-empty.ts | 6 +++ .../fabric/core/src/record/is-record.spec.ts | 24 +++++++++ packages/fabric/core/src/record/is-record.ts | 6 +++ .../fabric/core/src/result/async-result.ts | 11 ++++ packages/fabric/core/src/result/index.ts | 2 + packages/fabric/core/src/result/result.ts | 9 ++++ packages/fabric/core/src/time/index.ts | 3 ++ packages/fabric/core/src/time/posix-date.ts | 9 ++++ .../fabric/core/src/time/time-constants.ts | 22 ++++++++ packages/fabric/core/src/time/timeout.spec.ts | 44 ++++++++++++++++ packages/fabric/core/src/time/timeout.ts | 14 +++++ packages/fabric/core/src/types/fn.ts | 16 ++++++ packages/fabric/core/src/types/index.ts | 2 + packages/fabric/core/src/types/optional.ts | 13 +++++ packages/fabric/core/src/variant/index.ts | 2 + .../fabric/core/src/variant/match.spec.ts | 36 +++++++++++++ packages/fabric/core/src/variant/match.ts | 24 +++++++++ packages/fabric/core/src/variant/variant.ts | 11 ++++ packages/fabric/core/tsconfig.build.json | 15 ++++++ packages/fabric/core/tsconfig.json | 4 ++ packages/fabric/core/vitest.config.ts | 10 ++++ packages/templates/lib/package.json | 3 ++ yarn.lock | 26 ++++++++++ 59 files changed, 701 insertions(+) create mode 100644 packages/fabric/core/README.md create mode 100644 packages/fabric/core/package.json create mode 100644 packages/fabric/core/src/domain/entity/entity.ts create mode 100644 packages/fabric/core/src/domain/entity/files/base-file.ts create mode 100644 packages/fabric/core/src/domain/entity/files/bytes.ts create mode 100644 packages/fabric/core/src/domain/entity/files/domain-file.ts create mode 100644 packages/fabric/core/src/domain/entity/files/image-file.ts create mode 100644 packages/fabric/core/src/domain/entity/files/index.ts create mode 100644 packages/fabric/core/src/domain/entity/files/invalid-file-type-error.ts create mode 100644 packages/fabric/core/src/domain/entity/files/is-mime-type.spec.ts create mode 100644 packages/fabric/core/src/domain/entity/files/is-mime-type.ts create mode 100644 packages/fabric/core/src/domain/entity/files/is-uploaded-file.ts create mode 100644 packages/fabric/core/src/domain/entity/files/media-file.ts create mode 100644 packages/fabric/core/src/domain/entity/files/mime-type.ts create mode 100644 packages/fabric/core/src/domain/entity/files/uploaded-file.ts create mode 100644 packages/fabric/core/src/domain/entity/index.ts create mode 100644 packages/fabric/core/src/domain/events/event.ts create mode 100644 packages/fabric/core/src/domain/index.ts create mode 100644 packages/fabric/core/src/domain/security/index.ts create mode 100644 packages/fabric/core/src/domain/security/policy-map.ts create mode 100644 packages/fabric/core/src/domain/types/base-64.ts create mode 100644 packages/fabric/core/src/domain/types/email.ts create mode 100644 packages/fabric/core/src/domain/types/index.ts create mode 100644 packages/fabric/core/src/domain/types/sem-ver.ts create mode 100644 packages/fabric/core/src/domain/types/uuid.ts create mode 100644 packages/fabric/core/src/domain/use-case/index.ts create mode 100644 packages/fabric/core/src/domain/use-case/use-case-definition.ts create mode 100644 packages/fabric/core/src/domain/use-case/use-case.ts create mode 100644 packages/fabric/core/src/error/index.ts create mode 100644 packages/fabric/core/src/error/is-error.spec.ts create mode 100644 packages/fabric/core/src/error/is-error.ts create mode 100644 packages/fabric/core/src/error/tagged-error.ts create mode 100644 packages/fabric/core/src/error/unexpected-error.ts create mode 100644 packages/fabric/core/src/index.ts create mode 100644 packages/fabric/core/src/record/index.ts create mode 100644 packages/fabric/core/src/record/is-record-empty.spec.ts create mode 100644 packages/fabric/core/src/record/is-record-empty.ts create mode 100644 packages/fabric/core/src/record/is-record.spec.ts create mode 100644 packages/fabric/core/src/record/is-record.ts create mode 100644 packages/fabric/core/src/result/async-result.ts create mode 100644 packages/fabric/core/src/result/index.ts create mode 100644 packages/fabric/core/src/result/result.ts create mode 100644 packages/fabric/core/src/time/index.ts create mode 100644 packages/fabric/core/src/time/posix-date.ts create mode 100644 packages/fabric/core/src/time/time-constants.ts create mode 100644 packages/fabric/core/src/time/timeout.spec.ts create mode 100644 packages/fabric/core/src/time/timeout.ts create mode 100644 packages/fabric/core/src/types/fn.ts create mode 100644 packages/fabric/core/src/types/index.ts create mode 100644 packages/fabric/core/src/types/optional.ts create mode 100644 packages/fabric/core/src/variant/index.ts create mode 100644 packages/fabric/core/src/variant/match.spec.ts create mode 100644 packages/fabric/core/src/variant/match.ts create mode 100644 packages/fabric/core/src/variant/variant.ts create mode 100644 packages/fabric/core/tsconfig.build.json create mode 100644 packages/fabric/core/tsconfig.json create mode 100644 packages/fabric/core/vitest.config.ts diff --git a/packages/fabric/core/README.md b/packages/fabric/core/README.md new file mode 100644 index 0000000..9bd5c19 --- /dev/null +++ b/packages/fabric/core/README.md @@ -0,0 +1 @@ +# @ulthar/fabric-core diff --git a/packages/fabric/core/package.json b/packages/fabric/core/package.json new file mode 100644 index 0000000..1352038 --- /dev/null +++ b/packages/fabric/core/package.json @@ -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" + } +} diff --git a/packages/fabric/core/src/domain/entity/entity.ts b/packages/fabric/core/src/domain/entity/entity.ts new file mode 100644 index 0000000..17d240e --- /dev/null +++ b/packages/fabric/core/src/domain/entity/entity.ts @@ -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; +} diff --git a/packages/fabric/core/src/domain/entity/files/base-file.ts b/packages/fabric/core/src/domain/entity/files/base-file.ts new file mode 100644 index 0000000..7a3ce27 --- /dev/null +++ b/packages/fabric/core/src/domain/entity/files/base-file.ts @@ -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; +} diff --git a/packages/fabric/core/src/domain/entity/files/bytes.ts b/packages/fabric/core/src/domain/entity/files/bytes.ts new file mode 100644 index 0000000..0df3ecc --- /dev/null +++ b/packages/fabric/core/src/domain/entity/files/bytes.ts @@ -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); +} diff --git a/packages/fabric/core/src/domain/entity/files/domain-file.ts b/packages/fabric/core/src/domain/entity/files/domain-file.ts new file mode 100644 index 0000000..bfb7992 --- /dev/null +++ b/packages/fabric/core/src/domain/entity/files/domain-file.ts @@ -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; +} diff --git a/packages/fabric/core/src/domain/entity/files/image-file.ts b/packages/fabric/core/src/domain/entity/files/image-file.ts new file mode 100644 index 0000000..bdbca39 --- /dev/null +++ b/packages/fabric/core/src/domain/entity/files/image-file.ts @@ -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; +} diff --git a/packages/fabric/core/src/domain/entity/files/index.ts b/packages/fabric/core/src/domain/entity/files/index.ts new file mode 100644 index 0000000..dde7c34 --- /dev/null +++ b/packages/fabric/core/src/domain/entity/files/index.ts @@ -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"; diff --git a/packages/fabric/core/src/domain/entity/files/invalid-file-type-error.ts b/packages/fabric/core/src/domain/entity/files/invalid-file-type-error.ts new file mode 100644 index 0000000..a69aa8e --- /dev/null +++ b/packages/fabric/core/src/domain/entity/files/invalid-file-type-error.ts @@ -0,0 +1,7 @@ +import { TaggedError } from "../../../error/tagged-error.js"; + +export class InvalidFileTypeError extends TaggedError<"InvalidFileTypeError"> { + constructor() { + super("InvalidFileTypeError"); + } +} diff --git a/packages/fabric/core/src/domain/entity/files/is-mime-type.spec.ts b/packages/fabric/core/src/domain/entity/files/is-mime-type.spec.ts new file mode 100644 index 0000000..02fb339 --- /dev/null +++ b/packages/fabric/core/src/domain/entity/files/is-mime-type.spec.ts @@ -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); + }); +}); diff --git a/packages/fabric/core/src/domain/entity/files/is-mime-type.ts b/packages/fabric/core/src/domain/entity/files/is-mime-type.ts new file mode 100644 index 0000000..c4630b4 --- /dev/null +++ b/packages/fabric/core/src/domain/entity/files/is-mime-type.ts @@ -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( + expectedMimeType: T, + actualFileType: string, +): actualFileType is T { + return actualFileType.match("^" + expectedMimeType + "$") !== null; +} diff --git a/packages/fabric/core/src/domain/entity/files/is-uploaded-file.ts b/packages/fabric/core/src/domain/entity/files/is-uploaded-file.ts new file mode 100644 index 0000000..8e37630 --- /dev/null +++ b/packages/fabric/core/src/domain/entity/files/is-uploaded-file.ts @@ -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; + } +} diff --git a/packages/fabric/core/src/domain/entity/files/media-file.ts b/packages/fabric/core/src/domain/entity/files/media-file.ts new file mode 100644 index 0000000..c27232a --- /dev/null +++ b/packages/fabric/core/src/domain/entity/files/media-file.ts @@ -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}`; +} diff --git a/packages/fabric/core/src/domain/entity/files/mime-type.ts b/packages/fabric/core/src/domain/entity/files/mime-type.ts new file mode 100644 index 0000000..2ff60c6 --- /dev/null +++ b/packages/fabric/core/src/domain/entity/files/mime-type.ts @@ -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; diff --git a/packages/fabric/core/src/domain/entity/files/uploaded-file.ts b/packages/fabric/core/src/domain/entity/files/uploaded-file.ts new file mode 100644 index 0000000..040e739 --- /dev/null +++ b/packages/fabric/core/src/domain/entity/files/uploaded-file.ts @@ -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; +} diff --git a/packages/fabric/core/src/domain/entity/index.ts b/packages/fabric/core/src/domain/entity/index.ts new file mode 100644 index 0000000..9e932a0 --- /dev/null +++ b/packages/fabric/core/src/domain/entity/index.ts @@ -0,0 +1,2 @@ +export * from "./entity.js"; +export * from "./files/index.js"; diff --git a/packages/fabric/core/src/domain/events/event.ts b/packages/fabric/core/src/domain/events/event.ts new file mode 100644 index 0000000..6c57cbb --- /dev/null +++ b/packages/fabric/core/src/domain/events/event.ts @@ -0,0 +1,7 @@ +import { TaggedVariant } from "../../variant/variant.js"; + +export interface Event + extends TaggedVariant { + payload: TPayload; + timestamp: number; +} diff --git a/packages/fabric/core/src/domain/index.ts b/packages/fabric/core/src/domain/index.ts new file mode 100644 index 0000000..c92f44f --- /dev/null +++ b/packages/fabric/core/src/domain/index.ts @@ -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"; diff --git a/packages/fabric/core/src/domain/security/index.ts b/packages/fabric/core/src/domain/security/index.ts new file mode 100644 index 0000000..3c864c3 --- /dev/null +++ b/packages/fabric/core/src/domain/security/index.ts @@ -0,0 +1 @@ +export * from "./policy-map.js"; diff --git a/packages/fabric/core/src/domain/security/policy-map.ts b/packages/fabric/core/src/domain/security/policy-map.ts new file mode 100644 index 0000000..0a87745 --- /dev/null +++ b/packages/fabric/core/src/domain/security/policy-map.ts @@ -0,0 +1,4 @@ +export type PolicyMap< + UserType extends string, + PolicyType extends string, +> = Record; diff --git a/packages/fabric/core/src/domain/types/base-64.ts b/packages/fabric/core/src/domain/types/base-64.ts new file mode 100644 index 0000000..7b092e3 --- /dev/null +++ b/packages/fabric/core/src/domain/types/base-64.ts @@ -0,0 +1 @@ +export type Base64String = string; diff --git a/packages/fabric/core/src/domain/types/email.ts b/packages/fabric/core/src/domain/types/email.ts new file mode 100644 index 0000000..4aa44b4 --- /dev/null +++ b/packages/fabric/core/src/domain/types/email.ts @@ -0,0 +1 @@ +export type Email = `${string}@${string}.${string}`; diff --git a/packages/fabric/core/src/domain/types/index.ts b/packages/fabric/core/src/domain/types/index.ts new file mode 100644 index 0000000..3495344 --- /dev/null +++ b/packages/fabric/core/src/domain/types/index.ts @@ -0,0 +1,3 @@ +export * from "./email.js"; +export * from "./sem-ver.js"; +export * from "./uuid.js"; diff --git a/packages/fabric/core/src/domain/types/sem-ver.ts b/packages/fabric/core/src/domain/types/sem-ver.ts new file mode 100644 index 0000000..cc1d928 --- /dev/null +++ b/packages/fabric/core/src/domain/types/sem-ver.ts @@ -0,0 +1,4 @@ +/** + * Semantic versioning type. [Major].[Minor].[Patch]. Example: 1.0.0 + */ +export type SemVer = `${number}.${number}.${number}`; diff --git a/packages/fabric/core/src/domain/types/uuid.ts b/packages/fabric/core/src/domain/types/uuid.ts new file mode 100644 index 0000000..07dd212 --- /dev/null +++ b/packages/fabric/core/src/domain/types/uuid.ts @@ -0,0 +1 @@ +export type UUID = `${string}-${string}-${string}-${string}-${string}`; diff --git a/packages/fabric/core/src/domain/use-case/index.ts b/packages/fabric/core/src/domain/use-case/index.ts new file mode 100644 index 0000000..ef6a8a0 --- /dev/null +++ b/packages/fabric/core/src/domain/use-case/index.ts @@ -0,0 +1,2 @@ +export * from "./use-case-definition.js"; +export * from "./use-case.js"; diff --git a/packages/fabric/core/src/domain/use-case/use-case-definition.ts b/packages/fabric/core/src/domain/use-case/use-case-definition.ts new file mode 100644 index 0000000..cdce112 --- /dev/null +++ b/packages/fabric/core/src/domain/use-case/use-case-definition.ts @@ -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, +> = 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; + } + : { + /** + * 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; + }; diff --git a/packages/fabric/core/src/domain/use-case/use-case.ts b/packages/fabric/core/src/domain/use-case/use-case.ts new file mode 100644 index 0000000..4d16295 --- /dev/null +++ b/packages/fabric/core/src/domain/use-case/use-case.ts @@ -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, +> = TPayload extends undefined + ? (dependencies: TDependencies) => AsyncResult + : ( + dependencies: TDependencies, + payload: TPayload, + ) => AsyncResult; diff --git a/packages/fabric/core/src/error/index.ts b/packages/fabric/core/src/error/index.ts new file mode 100644 index 0000000..7008925 --- /dev/null +++ b/packages/fabric/core/src/error/index.ts @@ -0,0 +1,2 @@ +export * from "./tagged-error.js"; +export * from "./unexpected-error.js"; diff --git a/packages/fabric/core/src/error/is-error.spec.ts b/packages/fabric/core/src/error/is-error.spec.ts new file mode 100644 index 0000000..63af83d --- /dev/null +++ b/packages/fabric/core/src/error/is-error.spec.ts @@ -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>; + + //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>(); + } + }); +}); diff --git a/packages/fabric/core/src/error/is-error.ts b/packages/fabric/core/src/error/is-error.ts new file mode 100644 index 0000000..c653499 --- /dev/null +++ b/packages/fabric/core/src/error/is-error.ts @@ -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 { + return ( + err instanceof Error && + VariantTag in err && + typeof err[VariantTag] === "string" + ); +} diff --git a/packages/fabric/core/src/error/tagged-error.ts b/packages/fabric/core/src/error/tagged-error.ts new file mode 100644 index 0000000..d489886 --- /dev/null +++ b/packages/fabric/core/src/error/tagged-error.ts @@ -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 + extends Error + implements TaggedVariant +{ + readonly [VariantTag]: Tag; + + constructor(tag: Tag) { + super(); + this[VariantTag] = tag; + } +} diff --git a/packages/fabric/core/src/error/unexpected-error.ts b/packages/fabric/core/src/error/unexpected-error.ts new file mode 100644 index 0000000..4a72754 --- /dev/null +++ b/packages/fabric/core/src/error/unexpected-error.ts @@ -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"); + } +} diff --git a/packages/fabric/core/src/index.ts b/packages/fabric/core/src/index.ts new file mode 100644 index 0000000..ffac2ee --- /dev/null +++ b/packages/fabric/core/src/index.ts @@ -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"; diff --git a/packages/fabric/core/src/record/index.ts b/packages/fabric/core/src/record/index.ts new file mode 100644 index 0000000..5a27eb7 --- /dev/null +++ b/packages/fabric/core/src/record/index.ts @@ -0,0 +1,2 @@ +export * from "./is-record-empty.js"; +export * from "./is-record.js"; diff --git a/packages/fabric/core/src/record/is-record-empty.spec.ts b/packages/fabric/core/src/record/is-record-empty.spec.ts new file mode 100644 index 0000000..02bb488 --- /dev/null +++ b/packages/fabric/core/src/record/is-record-empty.spec.ts @@ -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); + }); +}); diff --git a/packages/fabric/core/src/record/is-record-empty.ts b/packages/fabric/core/src/record/is-record-empty.ts new file mode 100644 index 0000000..e5e5399 --- /dev/null +++ b/packages/fabric/core/src/record/is-record-empty.ts @@ -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): boolean { + return Object.keys(value).length === 0; +} diff --git a/packages/fabric/core/src/record/is-record.spec.ts b/packages/fabric/core/src/record/is-record.spec.ts new file mode 100644 index 0000000..8b84a95 --- /dev/null +++ b/packages/fabric/core/src/record/is-record.spec.ts @@ -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); + }); +}); diff --git a/packages/fabric/core/src/record/is-record.ts b/packages/fabric/core/src/record/is-record.ts new file mode 100644 index 0000000..c0e8316 --- /dev/null +++ b/packages/fabric/core/src/record/is-record.ts @@ -0,0 +1,6 @@ +/** + * Checks if a value is a record (an object). + */ +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/fabric/core/src/result/async-result.ts b/packages/fabric/core/src/result/async-result.ts new file mode 100644 index 0000000..be0600e --- /dev/null +++ b/packages/fabric/core/src/result/async-result.ts @@ -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 = never, +> = Promise>; diff --git a/packages/fabric/core/src/result/index.ts b/packages/fabric/core/src/result/index.ts new file mode 100644 index 0000000..fed60c7 --- /dev/null +++ b/packages/fabric/core/src/result/index.ts @@ -0,0 +1,2 @@ +export * from "./async-result.js"; +export * from "./result.js"; diff --git a/packages/fabric/core/src/result/result.ts b/packages/fabric/core/src/result/result.ts new file mode 100644 index 0000000..48ee32f --- /dev/null +++ b/packages/fabric/core/src/result/result.ts @@ -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; diff --git a/packages/fabric/core/src/time/index.ts b/packages/fabric/core/src/time/index.ts new file mode 100644 index 0000000..420f025 --- /dev/null +++ b/packages/fabric/core/src/time/index.ts @@ -0,0 +1,3 @@ +export * from "./posix-date.js"; +export * from "./time-constants.js"; +export * from "./timeout.js"; diff --git a/packages/fabric/core/src/time/posix-date.ts b/packages/fabric/core/src/time/posix-date.ts new file mode 100644 index 0000000..ac794f4 --- /dev/null +++ b/packages/fabric/core/src/time/posix-date.ts @@ -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; +} diff --git a/packages/fabric/core/src/time/time-constants.ts b/packages/fabric/core/src/time/time-constants.ts new file mode 100644 index 0000000..34aebda --- /dev/null +++ b/packages/fabric/core/src/time/time-constants.ts @@ -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; diff --git a/packages/fabric/core/src/time/timeout.spec.ts b/packages/fabric/core/src/time/timeout.spec.ts new file mode 100644 index 0000000..aa5c98e --- /dev/null +++ b/packages/fabric/core/src/time/timeout.spec.ts @@ -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); + }); +}); diff --git a/packages/fabric/core/src/time/timeout.ts b/packages/fabric/core/src/time/timeout.ts new file mode 100644 index 0000000..fb91ba0 --- /dev/null +++ b/packages/fabric/core/src/time/timeout.ts @@ -0,0 +1,14 @@ +export function timeout(ms: number) { + return new Promise((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); + }); +} diff --git a/packages/fabric/core/src/types/fn.ts b/packages/fabric/core/src/types/fn.ts new file mode 100644 index 0000000..7dada92 --- /dev/null +++ b/packages/fabric/core/src/types/fn.ts @@ -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 = (arg: T) => R; + +/** + * An action is a function that takes an argument of type `T` and returns nothing. + */ +export type Action = Fn; + +/** + * A `Singleton` + * is function that takes no arguments and returns a value of type `T`. + */ +export type Singleton = Fn; diff --git a/packages/fabric/core/src/types/index.ts b/packages/fabric/core/src/types/index.ts new file mode 100644 index 0000000..aa45ed2 --- /dev/null +++ b/packages/fabric/core/src/types/index.ts @@ -0,0 +1,2 @@ +export * from "./fn.js"; +export * from "./optional.js"; diff --git a/packages/fabric/core/src/types/optional.ts b/packages/fabric/core/src/types/optional.ts new file mode 100644 index 0000000..3c469b4 --- /dev/null +++ b/packages/fabric/core/src/types/optional.ts @@ -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 | Nothing; diff --git a/packages/fabric/core/src/variant/index.ts b/packages/fabric/core/src/variant/index.ts new file mode 100644 index 0000000..c0edbd9 --- /dev/null +++ b/packages/fabric/core/src/variant/index.ts @@ -0,0 +1,2 @@ +export * from "./match.js"; +export * from "./variant.js"; diff --git a/packages/fabric/core/src/variant/match.spec.ts b/packages/fabric/core/src/variant/match.spec.ts new file mode 100644 index 0000000..983c8fa --- /dev/null +++ b/packages/fabric/core/src/variant/match.spec.ts @@ -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"); + }); +}); diff --git a/packages/fabric/core/src/variant/match.ts b/packages/fabric/core/src/variant/match.ts new file mode 100644 index 0000000..e4f6932 --- /dev/null +++ b/packages/fabric/core/src/variant/match.ts @@ -0,0 +1,24 @@ +import { Fn } from "../types/fn.js"; +import { TaggedVariant, VariantFromTag, VariantTag } from "./variant.js"; + +export type VariantMatcher> = { + [K in TVariant[VariantTag]]: Fn>; +}; + +export function match>( + v: TVariant, +) { + return { + case>( + cases: TMatcher, + ): ReturnType { + if (!(v[VariantTag] in cases)) { + throw new Error("Non-exhaustive pattern match"); + } + + return cases[v[VariantTag] as TVariant[VariantTag]]( + v as Extract, + ); + }, + }; +} diff --git a/packages/fabric/core/src/variant/variant.ts b/packages/fabric/core/src/variant/variant.ts new file mode 100644 index 0000000..dc1b464 --- /dev/null +++ b/packages/fabric/core/src/variant/variant.ts @@ -0,0 +1,11 @@ +export const VariantTag = "_tag"; +export type VariantTag = typeof VariantTag; + +export interface TaggedVariant { + readonly [VariantTag]: TTag; +} + +export type VariantFromTag< + TVariant extends TaggedVariant, + TTag extends TVariant[typeof VariantTag], +> = Extract; diff --git a/packages/fabric/core/tsconfig.build.json b/packages/fabric/core/tsconfig.build.json new file mode 100644 index 0000000..7706c0e --- /dev/null +++ b/packages/fabric/core/tsconfig.build.json @@ -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" + ] +} diff --git a/packages/fabric/core/tsconfig.json b/packages/fabric/core/tsconfig.json new file mode 100644 index 0000000..7a7fde8 --- /dev/null +++ b/packages/fabric/core/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../../tsconfig.json", + "exclude": ["dist", "node_modules"] +} diff --git a/packages/fabric/core/vitest.config.ts b/packages/fabric/core/vitest.config.ts new file mode 100644 index 0000000..f1362e1 --- /dev/null +++ b/packages/fabric/core/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + coverage: { + exclude: ["**/index.ts"], + }, + passWithNoTests: true, + }, +}); diff --git a/packages/templates/lib/package.json b/packages/templates/lib/package.json index 199d41c..4cf46cd 100644 --- a/packages/templates/lib/package.json +++ b/packages/templates/lib/package.json @@ -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" diff --git a/yarn.lock b/yarn.lock index fc1981d..9af4d0d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"