Compare commits

...

10 Commits

74 changed files with 625 additions and 207 deletions

18
.vscode/settings.json vendored
View File

@ -1,7 +1,6 @@
{ {
"cSpell.enabled": true, "cSpell.enabled": true,
"cSpell.language": "en,es", "cSpell.language": "en,es",
"editor.defaultFormatter": "denoland.vscode-deno",
"editor.formatOnSave": true, "editor.formatOnSave": true,
"files.autoSave": "off", "files.autoSave": "off",
"files.eol": "\n", "files.eol": "\n",
@ -9,9 +8,18 @@
"source.fixAll": "always", "source.fixAll": "always",
"source.organizeImports": "always" "source.organizeImports": "always"
}, },
"cSpell.words": ["autodocs", "Syntropy"], "cSpell.words": ["Syntropy"],
"deno.enable": true, "deno.enable": true,
"deno.lint": true, "editor.defaultFormatter": "denoland.vscode-deno",
"deno.unstable": [], "deno.future": true,
"deno.suggest.imports.autoDiscover": true "deno.codeLens.implementations": true,
"deno.codeLens.references": true,
"typescript.referencesCodeLens.enabled": true,
"deno.testing.args": [
"--allow-all"
],
"notebook.defaultFormatter": "denoland.vscode-deno",
"[json]": {
"editor.defaultFormatter": "denoland.vscode-deno"
}
} }

View File

@ -1,8 +1,6 @@
{ {
"tasks": { "tasks": {
"test": "deno test --allow-all --unstable-ffi", "check": "deno fmt && deno lint --fix && deno check **/*.ts && deno test -A",
"test:dev": "deno test --allow-all --unstable-ffi --watch",
"check": "deno fmt && deno lint --fix && deno check **/*.ts && deno task test",
"hook": "deno run --allow-read --allow-run --allow-write https://deno.land/x/deno_hooks@0.1.1/mod.ts" "hook": "deno run --allow-read --allow-run --allow-write https://deno.land/x/deno_hooks@0.1.1/mod.ts"
}, },
"workspace": [ "workspace": [
@ -10,16 +8,11 @@
"packages/fabric/domain", "packages/fabric/domain",
"packages/fabric/sqlite-store", "packages/fabric/sqlite-store",
"packages/fabric/testing", "packages/fabric/testing",
"packages/fabric/validations",
"packages/templates/domain", "packages/templates/domain",
"packages/templates/lib" "packages/templates/lib",
"apps/syntropy/domain"
], ],
"imports": {
"@db/sqlite": "jsr:@db/sqlite@^0.12.0",
"@quentinadam/decimal": "jsr:@quentinadam/decimal@^0.1.6",
"@std/expect": "jsr:@std/expect@^1.0.5",
"@std/testing": "jsr:@std/testing@^1.0.3",
"expect-type": "npm:expect-type@^1.1.0"
},
"compilerOptions": { "compilerOptions": {
"strict": true, "strict": true,
"exactOptionalPropertyTypes": true, "exactOptionalPropertyTypes": true,
@ -27,8 +20,8 @@
"noImplicitOverride": true, "noImplicitOverride": true,
"noUncheckedIndexedAccess": true "noUncheckedIndexedAccess": true
}, },
"unstable": ["ffi"],
"lint": { "lint": {
"include": ["src/"],
"rules": { "rules": {
"tags": ["recommended"], "tags": ["recommended"],
"exclude": ["no-namespace"] "exclude": ["no-namespace"]

View File

@ -5,7 +5,6 @@
"jsr:@db/sqlite@0.12": "0.12.0", "jsr:@db/sqlite@0.12": "0.12.0",
"jsr:@denosaurs/plug@1": "1.0.6", "jsr:@denosaurs/plug@1": "1.0.6",
"jsr:@quentinadam/assert@~0.1.7": "0.1.7", "jsr:@quentinadam/assert@~0.1.7": "0.1.7",
"jsr:@quentinadam/decimal@*": "0.1.6",
"jsr:@quentinadam/decimal@~0.1.6": "0.1.6", "jsr:@quentinadam/decimal@~0.1.6": "0.1.6",
"jsr:@std/assert@0.217": "0.217.0", "jsr:@std/assert@0.217": "0.217.0",
"jsr:@std/assert@0.221": "0.221.0", "jsr:@std/assert@0.221": "0.221.0",
@ -127,12 +126,32 @@
} }
}, },
"workspace": { "workspace": {
"members": {
"packages/fabric/domain": {
"dependencies": [
"jsr:@fabric/core@*",
"jsr:@fabric/validations@*",
"jsr:@quentinadam/decimal@~0.1.6"
]
},
"packages/fabric/sqlite-store": {
"dependencies": [ "dependencies": [
"jsr:@db/sqlite@0.12", "jsr:@db/sqlite@0.12",
"jsr:@quentinadam/decimal@~0.1.6", "jsr:@fabric/domain@*"
]
},
"packages/fabric/testing": {
"dependencies": [
"jsr:@std/expect@^1.0.5", "jsr:@std/expect@^1.0.5",
"jsr:@std/testing@^1.0.3", "jsr:@std/testing@^1.0.3",
"npm:expect-type@^1.1.0" "npm:expect-type@^1.1.0"
] ]
},
"packages/fabric/validations": {
"dependencies": [
"jsr:@fabric/core@*"
]
}
}
} }
} }

View File

@ -0,0 +1,23 @@
/**
* Represents a time of day in hours, minutes, and seconds.
*/
export class ClockTime {
readonly hours: number;
readonly minutes: number;
readonly seconds: number;
constructor(hours?: number, minutes?: number, seconds?: number) {
this.hours = hours ?? 0;
this.minutes = minutes ?? 0;
this.seconds = seconds ?? 0;
}
toString() {
return `${this.hours}:${this.minutes}:${this.seconds}`;
}
static fromString(time: string): ClockTime {
const [hours, minutes, seconds] = time.split(":").map(Number);
return new ClockTime(hours, minutes, seconds);
}
}

View File

@ -1,2 +1,3 @@
export * from "./clock-time.ts";
export * from "./posix-date.ts"; export * from "./posix-date.ts";
export * from "./time-constants.ts"; export * from "./time-constants.ts";

View File

@ -1,6 +1,9 @@
export * from "./email.ts";
export * from "./enum.ts"; export * from "./enum.ts";
export * from "./fn.ts"; export * from "./fn.ts";
export * from "./keyof.ts"; export * from "./keyof.ts";
export * from "./maybe-promise.ts"; export * from "./maybe-promise.ts";
export * from "./optional.ts"; export * from "./optional.ts";
export * from "./record.ts"; export * from "./record.ts";
export * from "./semver.ts";
export * from "./uuid.ts";

View File

@ -1,6 +1,6 @@
import { UnexpectedError } from "../error/unexpected-error.ts"; import { UnexpectedError } from "../error/unexpected-error.ts";
export function ensureValue<T>(value?: T): T { export function ensure<T>(value?: T): T {
if (!value) { if (!value) {
throw new UnexpectedError("Value is nullish."); throw new UnexpectedError("Value is nullish.");
} }

View File

@ -1 +1 @@
export * from "./ensure-value.ts"; export * from "./ensure.ts";

View File

@ -0,0 +1,12 @@
{
"name": "@fabric/domain",
"exports": {
".": "./index.ts",
"./mocks": "./mocks.ts"
},
"imports": {
"@fabric/core": "jsr:@fabric/core",
"@fabric/validations": "jsr:@fabric/validations",
"decimal": "jsr:@quentinadam/decimal@^0.1.6"
}
}

View File

@ -1,7 +0,0 @@
{
"name": "@fabric/domain",
"exports": {
".": "./index.ts",
"./mocks": "./mocks.ts"
}
}

View File

@ -2,11 +2,11 @@ import type {
AsyncResult, AsyncResult,
MaybePromise, MaybePromise,
PosixDate, PosixDate,
UUID,
VariantFromTag, VariantFromTag,
VariantTag, VariantTag,
} from "@fabric/core"; } from "@fabric/core";
import type { StoreQueryError } from "../errors/query-error.ts"; import type { StoreQueryError } from "../errors/query-error.ts";
import type { UUID } from "../types/uuid.ts";
import type { Event } from "./event.ts"; import type { Event } from "./event.ts";
import type { StoredEvent } from "./stored-event.ts"; import type { StoredEvent } from "./stored-event.ts";

View File

@ -1,6 +1,6 @@
// deno-lint-ignore-file no-explicit-any // deno-lint-ignore-file no-explicit-any
import type { VariantTag } from "@fabric/core"; import type { VariantTag } from "@fabric/core";
import type { UUID } from "../types/uuid.ts"; import type { UUID } from "../../core/types/uuid.ts";
/** /**
* An event is a tagged variant with a payload and a timestamp. * An event is a tagged variant with a payload and a timestamp.

View File

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

View File

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

View File

@ -1,10 +1,5 @@
export * from "./base-file.ts"; export * from "./base-file.ts";
export * from "./bytes.ts"; export * from "./bytes.ts";
export * from "./image-file.ts";
export * from "./in-memory-file.ts";
export * from "./invalid-file-type-error.ts"; export * from "./invalid-file-type-error.ts";
export * from "./is-in-memory-file.ts";
export * from "./is-mime-type.ts"; export * from "./is-mime-type.ts";
export * from "./media-file.ts";
export * from "./mime-type.ts"; export * from "./mime-type.ts";
export * from "./stored-file.ts";

View File

@ -1,21 +0,0 @@
import { isRecord } from "@fabric/core";
import type { InMemoryFile } from "./in-memory-file.ts";
export function isInMemoryFile(value: unknown): value is InMemoryFile {
try {
return (
isRecord(value) &&
"data" in value &&
typeof value.data === "string" &&
"mimeType" in value &&
typeof value.mimeType === "string" &&
"name" in value &&
typeof value.name === "string" &&
"sizeInBytes" in value &&
typeof value.sizeInBytes === "number" &&
value.sizeInBytes >= 1
);
} catch {
return false;
}
}

View File

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

View File

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

View File

@ -4,6 +4,6 @@ export * from "./files/index.ts";
export * from "./models/index.ts"; export * from "./models/index.ts";
export * from "./security/index.ts"; export * from "./security/index.ts";
export * from "./services/index.ts"; export * from "./services/index.ts";
export * from "./types/index.ts";
export * from "./use-case/index.ts"; export * from "./use-case/index.ts";
export * from "./utils/index.ts"; export * from "./utils/index.ts";
export * from "./validations/index.ts";

View File

@ -0,0 +1,37 @@
import type { Keyof } from "../../core/index.ts";
import type { CustomModelFields } from "./custom-model-fields.ts";
import { DefaultEntityFields, type EntityModel } from "./entity-model.ts";
import { Field } from "./fields/index.ts";
export const DefaultAggregateFields = {
...DefaultEntityFields,
streamId: Field.uuid({ isIndexed: true }),
streamVersion: Field.integer({
isUnsigned: true,
hasArbitraryPrecision: true,
}),
deletedAt: Field.timestamp({ isOptional: true }),
};
export interface AggregateModel<
TName extends string = string,
TFields extends CustomModelFields = CustomModelFields,
> extends EntityModel<TName, TFields> {
fields: typeof DefaultAggregateFields & TFields;
}
export function defineAggregateModel<
TName extends string,
TFields extends CustomModelFields,
>(name: TName, fields: TFields): AggregateModel<TName, TFields> {
return {
name,
fields: { ...DefaultAggregateFields, ...fields },
} as const;
}
export type ModelAddressableFields<TModel extends AggregateModel> = {
[K in Keyof<TModel["fields"]>]: TModel["fields"][K] extends { isUnique: true }
? K
: never;
}[Keyof<TModel["fields"]>];

View File

@ -0,0 +1,3 @@
import type { FieldDefinition } from "./fields/index.ts";
export type CustomModelFields = Record<string, FieldDefinition>;

View File

@ -0,0 +1,14 @@
import type { CustomModelFields } from "./custom-model-fields.ts";
import { Field } from "./fields/index.ts";
import type { Model } from "./model.ts";
export const DefaultEntityFields = {
id: Field.uuid({ isPrimaryKey: true }),
};
export interface EntityModel<
TName extends string = string,
TFields extends CustomModelFields = CustomModelFields,
> extends Model<TName, TFields> {
fields: typeof DefaultEntityFields & TFields;
}

View File

@ -0,0 +1,16 @@
import { type TaggedVariant, VariantTag } from "@fabric/core";
import type { BaseField } from "./base-field.ts";
export interface BooleanFieldOptions extends BaseField {}
export interface BooleanField
extends TaggedVariant<"BooleanField">, BooleanFieldOptions {}
export function createBooleanField<T extends BooleanFieldOptions>(
opts: T = {} as T,
): BooleanField & T {
return {
[VariantTag]: "BooleanField",
...opts,
} as const;
}

View File

@ -1,6 +1,7 @@
import type { PosixDate } from "@fabric/core"; import type { PosixDate } from "@fabric/core";
import type Decimal from "jsr:@quentinadam/decimal"; import type Decimal from "decimal";
import type { UUID } from "../../types/uuid.ts"; import type { UUID } from "../../../core/types/uuid.ts";
import type { BooleanField } from "./boolean-field.ts";
import type { DecimalField } from "./decimal.ts"; import type { DecimalField } from "./decimal.ts";
import type { EmbeddedField } from "./embedded.ts"; import type { EmbeddedField } from "./embedded.ts";
import type { FloatField } from "./float.ts"; import type { FloatField } from "./float.ts";
@ -22,6 +23,7 @@ export type FieldToType<TField> = TField extends StringField
: TField extends DecimalField ? MaybeOptional<TField, Decimal> : TField extends DecimalField ? MaybeOptional<TField, Decimal>
: TField extends FloatField ? MaybeOptional<TField, number> : TField extends FloatField ? MaybeOptional<TField, number>
: TField extends TimestampField ? MaybeOptional<TField, PosixDate> : TField extends TimestampField ? MaybeOptional<TField, PosixDate>
: TField extends BooleanField ? MaybeOptional<TField, boolean>
: TField extends EmbeddedField<infer TSubModel> : TField extends EmbeddedField<infer TSubModel>
? MaybeOptional<TField, TSubModel> ? MaybeOptional<TField, TSubModel>
: never; : never;

View File

@ -1,3 +1,4 @@
import { type BooleanField, createBooleanField } from "./boolean-field.ts";
import { createDecimalField, type DecimalField } from "./decimal.ts"; import { createDecimalField, type DecimalField } from "./decimal.ts";
import { createEmbeddedField, type EmbeddedField } from "./embedded.ts"; import { createEmbeddedField, type EmbeddedField } from "./embedded.ts";
import { createFloatField, type FloatField } from "./float.ts"; import { createFloatField, type FloatField } from "./float.ts";
@ -12,6 +13,7 @@ import { createUUIDField, type UUIDField } from "./uuid-field.ts";
export * from "./base-field.ts"; export * from "./base-field.ts";
export * from "./field-to-type.ts"; export * from "./field-to-type.ts";
export * from "./reference-field.ts"; export * from "./reference-field.ts";
export * from "./uuid-field.ts";
export type FieldDefinition = export type FieldDefinition =
| StringField | StringField
@ -21,7 +23,8 @@ export type FieldDefinition =
| DecimalField | DecimalField
| ReferenceField | ReferenceField
| TimestampField | TimestampField
| EmbeddedField; | EmbeddedField
| BooleanField;
export namespace Field { export namespace Field {
export const string = createStringField; export const string = createStringField;
@ -32,4 +35,5 @@ export namespace Field {
export const float = createFloatField; export const float = createFloatField;
export const timestamp = createTimestampField; export const timestamp = createTimestampField;
export const embedded = createEmbeddedField; export const embedded = createEmbeddedField;
export const boolean = createBooleanField;
} }

View File

@ -1,6 +1,6 @@
import { isError } from "@fabric/core"; import { isError } from "@fabric/core";
import { describe, expect, test } from "@fabric/testing"; import { describe, expect, test } from "@fabric/testing";
import { defineModel } from "../model.ts"; import { defineAggregateModel } from "../aggregate-model.ts";
import { Field } from "./index.ts"; import { Field } from "./index.ts";
import { import {
InvalidReferenceFieldError, InvalidReferenceFieldError,
@ -9,7 +9,7 @@ import {
describe("Validate Reference Field", () => { describe("Validate Reference Field", () => {
const schema = { const schema = {
User: defineModel("User", { User: defineAggregateModel("User", {
name: Field.string(), name: Field.string(),
password: Field.string(), password: Field.string(),
otherUnique: Field.integer({ isUnique: true }), otherUnique: Field.integer({ isUnique: true }),

View File

@ -1,3 +1,5 @@
export * from "./aggregate-model.ts";
export * from "./custom-model-fields.ts";
export * from "./fields/index.ts"; export * from "./fields/index.ts";
export * from "./model-schema.ts"; export * from "./model-schema.ts";
export * from "./model.ts"; export * from "./model.ts";

View File

@ -1,12 +1,13 @@
import type { PosixDate } from "@fabric/core"; import type { UUID } from "@fabric/core";
import { describe, expectTypeOf, test } from "@fabric/testing"; import { describe, expectTypeOf, test } from "@fabric/testing";
import type { UUID } from "../types/uuid.ts"; import type { PosixDate } from "../../core/index.ts";
import { defineAggregateModel } from "./aggregate-model.ts";
import { Field } from "./fields/index.ts"; import { Field } from "./fields/index.ts";
import { defineModel, type ModelToType } from "./model.ts"; import type { ModelToType } from "./model.ts";
describe("CreateModel", () => { describe("CreateModel", () => {
test("should create a model and it's interface type", () => { test("should create a model and it's interface type", () => {
const User = defineModel("User", { const User = defineAggregateModel("User", {
name: Field.string(), name: Field.string(),
password: Field.string(), password: Field.string(),
phone: Field.string({ isOptional: true }), phone: Field.string({ isOptional: true }),

View File

@ -1,10 +1,8 @@
import type { Keyof } from "@fabric/core"; import type { Keyof } from "@fabric/core";
import type { CustomModelFields } from "./custom-model-fields.ts";
import type { FieldToType } from "./fields/field-to-type.ts"; import type { FieldToType } from "./fields/field-to-type.ts";
import { Field, type FieldDefinition } from "./fields/index.ts";
export type CustomModelFields = Record<string, FieldDefinition>; export interface Model<
export interface Collection<
TName extends string = string, TName extends string = string,
TFields extends CustomModelFields = CustomModelFields, TFields extends CustomModelFields = CustomModelFields,
> { > {
@ -12,53 +10,20 @@ export interface Collection<
fields: TFields; fields: TFields;
} }
export const DefaultModelFields = {
id: Field.uuid({ isPrimaryKey: true }),
streamId: Field.uuid({ isIndexed: true }),
streamVersion: Field.integer({
isUnsigned: true,
hasArbitraryPrecision: true,
}),
deletedAt: Field.timestamp({ isOptional: true }),
};
export interface Model<
TName extends string = string,
TFields extends CustomModelFields = CustomModelFields,
> extends Collection<TName, TFields> {
fields: typeof DefaultModelFields & TFields;
}
export function defineModel< export function defineModel<
TName extends string, TName extends string,
TFields extends CustomModelFields, TFields extends CustomModelFields,
>(name: TName, fields: TFields): Model<TName, TFields> { >(name: TName, fields: TFields): Model<TName, TFields> {
return {
name,
fields: { ...DefaultModelFields, ...fields },
} as const;
}
export function defineCollection<
TName extends string,
TFields extends CustomModelFields,
>(name: TName, fields: TFields): Collection<TName, TFields> {
return { return {
name, name,
fields, fields,
} as const; } as const;
} }
export type ModelToType<TModel extends Collection> = { export type ModelToType<TModel extends Model> = {
[K in Keyof<TModel["fields"]>]: FieldToType<TModel["fields"][K]>; [K in Keyof<TModel["fields"]>]: FieldToType<TModel["fields"][K]>;
}; };
export type ModelFieldNames<TModel extends CustomModelFields> = Keyof< export type ModelFieldNames<TModel extends CustomModelFields> = Keyof<
TModel["fields"] TModel["fields"]
>; >;
export type ModelAddressableFields<TModel extends Model> = {
[K in Keyof<TModel["fields"]>]: TModel["fields"][K] extends { isUnique: true }
? K
: never;
}[Keyof<TModel["fields"]>];

View File

@ -1,9 +1,13 @@
import type { VariantTag } from "@fabric/core"; import type { VariantTag } from "@fabric/core";
import type { Event } from "../events/event.ts"; import type { Event } from "../events/event.ts";
import type { StoredEvent } from "../events/stored-event.ts"; import type { StoredEvent } from "../events/stored-event.ts";
import type { Model, ModelToType } from "../models/model.ts"; import type { AggregateModel } from "../models/aggregate-model.ts";
import type { ModelToType } from "../models/model.ts";
export interface Projection<TModel extends Model, TEvents extends Event> { export interface Projection<
TModel extends AggregateModel,
TEvents extends Event,
> {
model: TModel; model: TModel;
events: TEvents[VariantTag][]; events: TEvents[VariantTag][];
projection: ( projection: (

View File

@ -1,4 +1,4 @@
import type { UUID } from "../types/uuid.ts"; import type { UUID } from "../../core/types/uuid.ts";
import type { UUIDGenerator } from "./uuid-generator.ts"; import type { UUIDGenerator } from "./uuid-generator.ts";
export const UUIDGeneratorMock: UUIDGenerator = { export const UUIDGeneratorMock: UUIDGenerator = {

View File

@ -1,4 +1,4 @@
import type { UUID } from "../types/uuid.ts"; import type { UUID } from "../../core/types/uuid.ts";
export interface UUIDGenerator { export interface UUIDGenerator {
generate(): UUID; generate(): UUID;

View File

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

View File

@ -1,11 +0,0 @@
import type { UUID } from "./uuid.ts";
/**
* 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

@ -1,5 +0,0 @@
export * from "./base-64.ts";
export * from "./email.ts";
export * from "./entity.ts";
export * from "./semver.ts";
export * from "./uuid.ts";

View File

@ -1,10 +1,9 @@
import { expect } from "@std/expect"; import { describe, expect, test } from "@fabric/testing";
import { describe, it } from "@std/testing/bdd";
import { CircularDependencyError } from "../errors/circular-dependency-error.ts"; import { CircularDependencyError } from "../errors/circular-dependency-error.ts";
import { sortByDependencies } from "./sort-by-dependencies.ts"; import { sortByDependencies } from "./sort-by-dependencies.ts";
describe("sortByDependencies", () => { describe("sortByDependencies", () => {
it("should sort an array of objects by their dependencies", () => { test("should sort an array of objects by their dependencies", () => {
const array = [ const array = [
{ id: 1, name: "A", dependencies: ["C", "D"] }, { id: 1, name: "A", dependencies: ["C", "D"] },
{ id: 2, name: "B", dependencies: ["A", "D"] }, { id: 2, name: "B", dependencies: ["A", "D"] },
@ -25,7 +24,7 @@ describe("sortByDependencies", () => {
]); ]);
}); });
it("should throw a CircularDependencyError when circular dependencies are detected", () => { test("should throw a CircularDependencyError when circular dependencies are detected", () => {
const array = [ const array = [
{ id: 1, name: "A", dependencies: ["B"] }, { id: 1, name: "A", dependencies: ["B"] },
{ id: 2, name: "B", dependencies: ["A"] }, { id: 2, name: "B", dependencies: ["A"] },
@ -39,7 +38,7 @@ describe("sortByDependencies", () => {
).toBeInstanceOf(CircularDependencyError); ).toBeInstanceOf(CircularDependencyError);
}); });
it("should return an empty array when the input array is empty", () => { test("should return an empty array when the input array is empty", () => {
// deno-lint-ignore no-explicit-any // deno-lint-ignore no-explicit-any
const array: any[] = []; const array: any[] = [];

View File

@ -1,6 +1,9 @@
import { Result } from "@fabric/core"; import { Result } from "@fabric/core";
import { CircularDependencyError } from "../errors/circular-dependency-error.ts"; import { CircularDependencyError } from "../errors/circular-dependency-error.ts";
/**
* Sorts an array of elements based on their dependencies.
*/
export function sortByDependencies<T>( export function sortByDependencies<T>(
array: T[], array: T[],
{ {

View File

@ -0,0 +1,134 @@
import {
PosixDate,
Result,
TaggedError,
type VariantFromTag,
} from "@fabric/core";
import { isUUID, parseAndSanitizeString } from "@fabric/validations";
import type { FieldDefinition, FieldToType } from "../models/index.ts";
export type FieldParsers = {
[K in FieldDefinition["_tag"]]: FieldParser<
VariantFromTag<FieldDefinition, K>
>;
};
export const fieldParsers: FieldParsers = {
StringField: (f, v) => {
return parseStringValue(f, v);
},
UUIDField: (f, v) => {
return parseStringValue(f, v)
.flatMap((parsedString) =>
isUUID(parsedString)
? Result.ok(parsedString)
: Result.failWith(new InvalidFieldTypeError())
);
},
ReferenceField: (f, v) => {
return parseStringValue(f, v)
.flatMap((parsedString) =>
isUUID(parsedString)
? Result.ok(parsedString)
: Result.failWith(new InvalidFieldTypeError())
);
},
TimestampField: (f, v) => {
return parseOptionality(f, v).flatMap((parsedValue) => {
if (parsedValue === undefined) return Result.ok(undefined);
if (!(v instanceof PosixDate)) {
return Result.failWith(new InvalidFieldTypeError());
}
return Result.ok(v);
});
},
BooleanField: (f, v) => {
if (!f.isOptional && v === undefined) {
return Result.failWith(new MissingRequiredFieldError());
}
if (v === undefined) {
return Result.ok(undefined);
}
if (typeof v === "boolean") {
return Result.ok(v);
}
return Result.failWith(new InvalidFieldTypeError());
},
IntegerField: function (): Result<
number | bigint | undefined,
FieldParsingError
> {
throw new Error("Function not implemented.");
},
FloatField: function () {
throw new Error("Function not implemented.");
},
DecimalField: function () {
throw new Error("Function not implemented.");
},
EmbeddedField: function () {
throw new Error("Function not implemented.");
},
};
/**
* A function that takes a field definition and a value and returns a result
*/
export type FieldParser<TField extends FieldDefinition> = (
field: TField,
value: unknown,
) => Result<FieldToType<TField> | undefined, FieldParsingError>;
/**
* Field parsing errors
*/
export type FieldParsingError =
| InvalidFieldTypeError
| MissingRequiredFieldError;
/**
* An error that occurs when a field is invalid
*/
export class InvalidFieldTypeError extends TaggedError<"InvalidField"> {
constructor() {
super("InvalidField");
}
}
/**
* An error that occurs when a required field is missing
*/
export class MissingRequiredFieldError extends TaggedError<"RequiredField"> {
constructor() {
super("RequiredField");
}
}
/**
* Parses a string value including optionality
*/
function parseStringValue(
field: FieldDefinition,
value: unknown,
): Result<string | undefined, FieldParsingError> {
const parsedValue = parseAndSanitizeString(value);
return parseOptionality(field, parsedValue);
}
/**
* Parses the optionality of a field.
* In other words, if a field is required and the value is undefined, it will return a MissingRequiredFieldError.
* If the field is optional and the value is undefined, it will return the value as undefined.
*/
function parseOptionality<T>(
field: FieldDefinition,
value: T | undefined,
): Result<T | undefined, FieldParsingError> {
if (!field.isOptional && value === undefined) {
return Result.failWith(new MissingRequiredFieldError());
}
return Result.ok(value);
}

View File

@ -0,0 +1 @@
export * from "./parse-from-model.ts";

View File

@ -0,0 +1,43 @@
// deno-lint-ignore-file no-explicit-any
import { isRecordEmpty, Result, TaggedError } from "@fabric/core";
import type { FieldDefinition, Model, ModelToType } from "../models/index.ts";
import { fieldParsers, type FieldParsingError } from "./field-parsers.ts";
export function parseFromModel<
TModel extends Model,
>(
model: TModel,
value: unknown,
): Result<ModelToType<TModel>, SchemaParsingError<TModel>> {
const parsingErrors = {} as Record<keyof TModel, FieldParsingError>;
const parsedValue = {} as ModelToType<TModel>;
for (const key in model) {
const field = model[key] as FieldDefinition;
const fieldResult = fieldParsers[field._tag](field as any, value);
if (fieldResult.isOk()) {
parsedValue[key as keyof ModelToType<TModel>] = fieldResult
.value();
} else {
parsingErrors[key] = fieldResult.unwrapErrorOrThrow();
}
}
if (!isRecordEmpty(parsingErrors)) {
return Result.failWith(new SchemaParsingError(parsingErrors, parsedValue));
} else {
return Result.succeedWith(parsedValue);
}
}
export class SchemaParsingError<TModel extends Model>
extends TaggedError<"SchemaParsingFailed"> {
constructor(
public readonly errors: Record<keyof TModel, FieldParsingError>,
public readonly value?: Partial<ModelToType<TModel>>,
) {
super("SchemaParsingFailed");
}
}

View File

@ -0,0 +1,10 @@
{
"name": "@fabric/sqlite-store",
"exports": {
".": "./index.ts"
},
"imports": {
"@db/sqlite": "jsr:@db/sqlite@^0.12.0",
"@fabric/domain": "jsr:@fabric/domain"
}
}

View File

@ -1,6 +0,0 @@
{
"name": "@fabric/sqlite-store",
"exports": {
".": "./index.ts"
}
}

View File

@ -3,6 +3,7 @@ import {
MaybePromise, MaybePromise,
PosixDate, PosixDate,
Run, Run,
UUID,
VariantTag, VariantTag,
} from "@fabric/core"; } from "@fabric/core";
import { import {
@ -13,7 +14,6 @@ import {
JSONUtils, JSONUtils,
StoredEvent, StoredEvent,
StoreQueryError, StoreQueryError,
UUID,
} from "@fabric/domain"; } from "@fabric/domain";
import { SQLiteDatabase } from "../sqlite/sqlite-database.ts"; import { SQLiteDatabase } from "../sqlite/sqlite-database.ts";

View File

@ -1,5 +1,5 @@
import { import {
defineCollection, defineModel,
Field, Field,
isGreaterOrEqualTo, isGreaterOrEqualTo,
isGreaterThan, isGreaterThan,
@ -13,7 +13,7 @@ import { describe, expect, test } from "@fabric/testing";
import { filterToParams, filterToSQL } from "./filter-to-sql.ts"; import { filterToParams, filterToSQL } from "./filter-to-sql.ts";
describe("SQL where clause from filter options", () => { describe("SQL where clause from filter options", () => {
const col = defineCollection("users", { const col = defineModel("users", {
name: Field.string(), name: Field.string(),
age: Field.integer(), age: Field.integer(),
status: Field.string(), status: Field.string(),

View File

@ -1,12 +1,12 @@
// deno-lint-ignore-file no-explicit-any // deno-lint-ignore-file no-explicit-any
import { import {
Collection,
FieldDefinition, FieldDefinition,
FILTER_OPTION_OPERATOR_SYMBOL, FILTER_OPTION_OPERATOR_SYMBOL,
FILTER_OPTION_TYPE_SYMBOL, FILTER_OPTION_TYPE_SYMBOL,
FILTER_OPTION_VALUE_SYMBOL, FILTER_OPTION_VALUE_SYMBOL,
FilterOptions, FilterOptions,
FilterValue, FilterValue,
Model,
MultiFilterOption, MultiFilterOption,
SingleFilterOption, SingleFilterOption,
} from "@fabric/domain"; } from "@fabric/domain";
@ -24,7 +24,7 @@ export function filterToSQL(filterOptions?: FilterOptions) {
} }
export function filterToParams( export function filterToParams(
collection: Collection, collection: Model,
filterOptions?: FilterOptions, filterOptions?: FilterOptions,
) { ) {
if (!filterOptions) return {}; if (!filterOptions) return {};
@ -100,7 +100,7 @@ function getWhereFromKeyValue(
} }
function getParamsFromMultiFilterOption( function getParamsFromMultiFilterOption(
collection: Collection, collection: Model,
filterOptions: MultiFilterOption, filterOptions: MultiFilterOption,
) { ) {
return filterOptions.reduce( return filterOptions.reduce(
@ -115,7 +115,7 @@ function getParamsFromMultiFilterOption(
} }
function getParamsFromSingleFilterOption( function getParamsFromSingleFilterOption(
collection: Collection, collection: Model,
filterOptions: SingleFilterOption, filterOptions: SingleFilterOption,
opts: { postfix?: string } = {}, opts: { postfix?: string } = {},
) { ) {

View File

@ -1,9 +1,9 @@
import { defineCollection, Field } from "@fabric/domain"; import { defineModel, Field } from "@fabric/domain";
import { describe, expect, test } from "@fabric/testing"; import { describe, expect, test } from "@fabric/testing";
import { modelToSql } from "./model-to-sql.ts"; import { modelToSql } from "./model-to-sql.ts";
describe("ModelToSQL", () => { describe("ModelToSQL", () => {
const model = defineCollection("something", { const model = defineModel("something", {
id: Field.uuid({ isPrimaryKey: true }), id: Field.uuid({ isPrimaryKey: true }),
name: Field.string(), name: Field.string(),
age: Field.integer(), age: Field.integer(),

View File

@ -1,6 +1,6 @@
// deno-lint-ignore-file no-explicit-any // deno-lint-ignore-file no-explicit-any
import { Variant, VariantTag } from "@fabric/core"; import { Variant, VariantTag } from "@fabric/core";
import { Collection, FieldDefinition, getTargetKey } from "@fabric/domain"; import { FieldDefinition, getTargetKey, Model } from "@fabric/domain";
type FieldSQLDefinitionMap = { type FieldSQLDefinitionMap = {
[K in FieldDefinition[VariantTag]]: ( [K in FieldDefinition[VariantTag]]: (
@ -46,6 +46,9 @@ const FieldSQLDefinitionMap: FieldSQLDefinitionMap = {
EmbeddedField: (n, f): string => { EmbeddedField: (n, f): string => {
return [n, "TEXT", modifiersFromOpts(f)].join(" "); return [n, "TEXT", modifiersFromOpts(f)].join(" ");
}, },
BooleanField: (n, f): string => {
return [n, "BOOLEAN", modifiersFromOpts(f)].join(" ");
},
}; };
function fieldDefinitionToSQL(name: string, field: FieldDefinition) { function fieldDefinitionToSQL(name: string, field: FieldDefinition) {
return FieldSQLDefinitionMap[field[VariantTag]](name, field as any); return FieldSQLDefinitionMap[field[VariantTag]](name, field as any);
@ -61,7 +64,7 @@ function modifiersFromOpts(field: FieldDefinition) {
} }
export function modelToSql( export function modelToSql(
model: Collection<string, Record<string, FieldDefinition>>, model: Model<string, Record<string, FieldDefinition>>,
) { ) {
const fields = Object.entries(model.fields) const fields = Object.entries(model.fields)
.map(([name, type]) => fieldDefinitionToSQL(name, type)) .map(([name, type]) => fieldDefinitionToSQL(name, type))

View File

@ -23,7 +23,10 @@ export function recordToSQLKeyParams(record: Record<string, any>) {
/** /**
* Unfold a record into a string of it's keys separated by commas. * Unfold a record into a string of it's keys separated by commas.
*/ */
export function recordToSQLParams(model: Model, record: Record<string, any>) { export function recordToSQLParams(
model: Model,
record: Record<string, any>,
) {
return Object.keys(record).reduce( return Object.keys(record).reduce(
(acc, key) => ({ (acc, key) => ({
...acc, ...acc,

View File

@ -1,8 +1,8 @@
// deno-lint-ignore-file no-explicit-any // deno-lint-ignore-file no-explicit-any
import { PosixDate, VariantTag } from "@fabric/core"; import { PosixDate, VariantTag } from "@fabric/core";
import { Collection, FieldDefinition, FieldToType } from "@fabric/domain"; import { FieldDefinition, FieldToType, Model } from "@fabric/domain";
export function transformRow(model: Collection) { export function transformRow(model: Model) {
return (row: Record<string, any>) => { return (row: Record<string, any>) => {
const result: Record<string, any> = {}; const result: Record<string, any> = {};
for (const key in row) { for (const key in row) {
@ -41,4 +41,5 @@ const FieldSQLInsertMap: FieldSQLInsertMap = {
DecimalField: (_, v) => v, DecimalField: (_, v) => v,
TimestampField: (_, v) => new PosixDate(v), TimestampField: (_, v) => new PosixDate(v),
EmbeddedField: (_, v: string) => JSON.parse(v), EmbeddedField: (_, v: string) => JSON.parse(v),
BooleanField: (_, v) => v,
}; };

View File

@ -22,6 +22,7 @@ const FieldSQLInsertMap: FieldSQLInsertMap = {
DecimalField: (_, v) => v, DecimalField: (_, v) => v,
TimestampField: (_, v) => v.timestamp, TimestampField: (_, v) => v.timestamp,
EmbeddedField: (_, v: string) => JSON.stringify(v), EmbeddedField: (_, v: string) => JSON.stringify(v),
BooleanField: (_, v) => v,
}; };
export function fieldValueToSQL(field: FieldDefinition, value: any) { export function fieldValueToSQL(field: FieldDefinition, value: any) {

View File

@ -1,8 +1,8 @@
// deno-lint-ignore-file no-explicit-any // deno-lint-ignore-file no-explicit-any
import { AsyncResult, Keyof, Optional } from "@fabric/core"; import { AsyncResult, Keyof, Optional } from "@fabric/core";
import { import {
Collection,
FilterOptions, FilterOptions,
Model,
ModelSchema, ModelSchema,
OrderByOptions, OrderByOptions,
SelectableQuery, SelectableQuery,
@ -128,7 +128,7 @@ export class QueryBuilder<T> implements StoreQuery<T> {
} }
export function getSelectStatement( export function getSelectStatement(
collection: Collection, collection: Model,
query: StoreQueryDefinition, query: StoreQueryDefinition,
): [string, Record<string, any>] { ): [string, Record<string, any>] {
const selectFields = query.keys ? query.keys.join(", ") : "*"; const selectFields = query.keys ? query.keys.join(", ") : "*";

View File

@ -1,5 +1,5 @@
import { PosixDate, Run } from "@fabric/core"; import { PosixDate, Run, UUID } from "@fabric/core";
import { defineModel, Field, isLike, UUID } from "@fabric/domain"; import { defineAggregateModel, Field, isLike } from "@fabric/domain";
import { UUIDGeneratorMock } from "@fabric/domain/mocks"; import { UUIDGeneratorMock } from "@fabric/domain/mocks";
import { import {
afterEach, afterEach,
@ -13,11 +13,11 @@ import { SQLiteStateStore } from "./state-store.ts";
describe("State Store", () => { describe("State Store", () => {
const models = [ const models = [
defineModel("demo", { defineAggregateModel("demo", {
value: Field.float(), value: Field.float(),
owner: Field.reference({ targetModel: "users" }), owner: Field.reference({ targetModel: "users" }),
}), }),
defineModel("users", { defineAggregateModel("users", {
name: Field.string(), name: Field.string(),
}), }),
]; ];

View File

@ -1,11 +1,10 @@
import { AsyncResult, UnexpectedError } from "@fabric/core"; import { AsyncResult, UnexpectedError, UUID } from "@fabric/core";
import { import {
Model, type AggregateModel,
ModelSchemaFromModels, ModelSchemaFromModels,
ModelToType, ModelToType,
StoreQuery, StoreQuery,
StoreQueryError, StoreQueryError,
UUID,
WritableStateStore, WritableStateStore,
} from "@fabric/domain"; } from "@fabric/domain";
import { modelToSql } from "../sqlite/model-to-sql.ts"; import { modelToSql } from "../sqlite/model-to-sql.ts";
@ -19,7 +18,7 @@ import {
import { SQLiteDatabase } from "../sqlite/sqlite-database.ts"; import { SQLiteDatabase } from "../sqlite/sqlite-database.ts";
import { QueryBuilder } from "./query-builder.ts"; import { QueryBuilder } from "./query-builder.ts";
export class SQLiteStateStore<TModel extends Model> export class SQLiteStateStore<TModel extends AggregateModel>
implements WritableStateStore<TModel> { implements WritableStateStore<TModel> {
private schema: ModelSchemaFromModels<TModel>; private schema: ModelSchemaFromModels<TModel>;
private db: SQLiteDatabase; private db: SQLiteDatabase;

View File

@ -0,0 +1,11 @@
{
"name": "@fabric/testing",
"exports": {
".": "./index.ts"
},
"imports": {
"expect-type": "npm:expect-type@^1.1.0",
"@std/expect": "jsr:@std/expect@^1.0.5",
"@std/testing": "jsr:@std/testing@^1.0.3"
}
}

View File

@ -1,6 +0,0 @@
{
"name": "@fabric/testing",
"exports": {
".": "./index.ts"
}
}

View File

@ -0,0 +1,9 @@
{
"name": "@fabric/validations",
"exports": {
".": "./index.ts"
},
"imports": {
"@fabric/core": "jsr:@fabric/core"
}
}

View File

@ -0,0 +1,3 @@
export * from "./nullish/index.ts";
export * from "./number/index.ts";
export * from "./string/index.ts";

View File

@ -0,0 +1,3 @@
export * from "./is-null.ts";
export * from "./is-nullish.ts";
export * from "./is-undefined.ts";

View File

@ -0,0 +1,3 @@
export function isNull(value: unknown): value is null {
return value === null;
}

View File

@ -0,0 +1,6 @@
import { isNull } from "./is-null.ts";
import { isUndefined } from "./is-undefined.ts";
export function isNullish(value: unknown): value is null | undefined {
return isNull(value) || isUndefined(value);
}

View File

@ -0,0 +1,3 @@
export function isUndefined(value: unknown): value is undefined {
return value === undefined;
}

View File

@ -0,0 +1 @@
export * from "./is-number.ts";

View File

@ -0,0 +1,36 @@
import { describe, expect, test } from "@fabric/testing";
import { isNumber } from "./is-number.ts";
describe("Is a number", () => {
test("Given a number it should return true", () => {
expect(isNumber(1)).toBe(true);
});
test("Given a string it should return false", () => {
expect(isNumber("a")).toBe(false);
});
test("Given an empty string it should return false", () => {
expect(isNumber("")).toBe(false);
});
test("Given a boolean it should return false", () => {
expect(isNumber(false)).toBe(false);
});
test("Given an object it should return false", () => {
expect(isNumber({})).toBe(false);
});
test("Given an array it should return false", () => {
expect(isNumber([])).toBe(false);
});
test("Given a null it should return false", () => {
expect(isNumber(null)).toBe(false);
});
test("Given an undefined it should return false", () => {
expect(isNumber(undefined)).toBe(false);
});
});

View File

@ -0,0 +1,9 @@
/**
* Checks if a value is a number even if it is a string that can be converted to a number
*/
export function isNumber(value: unknown): value is number {
if (typeof value === "number") {
return !isNaN(value);
}
return false;
}

View File

@ -0,0 +1,3 @@
export * from "./is-string.ts";
export * from "./is-uuid.ts";
export * from "./sanitize-string.ts";

View File

@ -0,0 +1,3 @@
export function isString(value: unknown): value is string {
return typeof value === "string";
}

View File

@ -0,0 +1,54 @@
import { describe, expect, test } from "@fabric/testing";
import { isUUID } from "./is-uuid.ts";
describe("isUUID", () => {
test("should return true for a valid UUID", () => {
const validUUID = "123e4567-e89b-12d3-a456-426614174000";
expect(isUUID(validUUID)).toBe(true);
});
test("should return true for a valid UUID with uppercase letters", () => {
const validUUID = "123E4567-E89B-12D3-A456-426614174000";
expect(isUUID(validUUID)).toBe(true);
});
test("should return true for a nil UUID", () => {
const nilUUID = "00000000-0000-0000-0000-000000000000";
expect(isUUID(nilUUID)).toBe(true);
});
test("should return true for a max UUID", () => {
const maxUUID = "ffffffff-ffff-ffff-ffff-ffffffffffff";
expect(isUUID(maxUUID)).toBe(true);
});
test("should return false for an invalid UUID", () => {
const invalidUUID = "123e4567-e89b-12d3-a456-42661417400";
expect(isUUID(invalidUUID)).toBe(false);
});
test("should return false for a string that is not a UUID", () => {
const notUUID = "not-a-uuid";
expect(isUUID(notUUID)).toBe(false);
});
test("should return false for a number", () => {
const number = 1234567890;
expect(isUUID(number)).toBe(false);
});
test("should return false for a boolean", () => {
const boolean = true;
expect(isUUID(boolean)).toBe(false);
});
test("should return false for null", () => {
const nullValue = null;
expect(isUUID(nullValue)).toBe(false);
});
test("should return false for undefined", () => {
const undefinedValue = undefined;
expect(isUUID(undefinedValue)).toBe(false);
});
});

View File

@ -0,0 +1,10 @@
import type { UUID } from "@fabric/core";
import { isString } from "./is-string.ts";
// From https://github.com/uuidjs/uuid/blob/main/src/regex.ts
const uuidRegex =
/^(?:[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$/i;
export function isUUID(value: unknown): value is UUID {
return isString(value) && uuidRegex.test(value);
}

View File

@ -0,0 +1,40 @@
import { describe, expect, test } from "@fabric/testing";
import { parseAndSanitizeString } from "../string/sanitize-string.ts";
describe("Sanitize String", () => {
test("Given a string with low (control) characters it should sanitize it", () => {
const sanitized = parseAndSanitizeString("John\x00");
expect(sanitized).toBe("John");
});
test("Given a string with leading and trailing spaces it should trim them", () => {
const sanitized = parseAndSanitizeString(" John ");
expect(sanitized).toBe("John");
});
test("Given a number value it should convert it to a string", () => {
const sanitized = parseAndSanitizeString(123);
expect(sanitized).toBe("123");
});
test("Given a boolean value it should convert it to a string", () => {
const sanitized = parseAndSanitizeString(true);
expect(sanitized).toBe("true");
});
test("Given a null value it should return null", () => {
const sanitized = parseAndSanitizeString(null);
expect(sanitized).toBe(undefined);
});
test("Given an undefined value it should return undefined", () => {
const sanitized = parseAndSanitizeString(undefined);
expect(sanitized).toBe(undefined);
});
});

View File

@ -0,0 +1,17 @@
import { isNullish } from "../nullish/is-nullish.ts";
/**
* Parses and sanitizes an unknown value into a string
* The string is trimmed and all low characters are removed
*/
export function parseAndSanitizeString(
value: unknown,
): string | undefined {
if (isNullish(value)) return undefined;
return stripLow((String(value)).trim());
}
// deno-lint-ignore no-control-regex
const lowCharsRegex = /[\x00-\x09\x0B\x0C\x0E-\x1F\x7F]/g;
const stripLow = (str: string) => str.replace(lowCharsRegex, "");