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.language": "en,es",
"editor.defaultFormatter": "denoland.vscode-deno",
"editor.formatOnSave": true,
"files.autoSave": "off",
"files.eol": "\n",
@ -9,9 +8,18 @@
"source.fixAll": "always",
"source.organizeImports": "always"
},
"cSpell.words": ["autodocs", "Syntropy"],
"cSpell.words": ["Syntropy"],
"deno.enable": true,
"deno.lint": true,
"deno.unstable": [],
"deno.suggest.imports.autoDiscover": true
"editor.defaultFormatter": "denoland.vscode-deno",
"deno.future": 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": {
"test": "deno test --allow-all --unstable-ffi",
"test:dev": "deno test --allow-all --unstable-ffi --watch",
"check": "deno fmt && deno lint --fix && deno check **/*.ts && deno task test",
"check": "deno fmt && deno lint --fix && deno check **/*.ts && deno test -A",
"hook": "deno run --allow-read --allow-run --allow-write https://deno.land/x/deno_hooks@0.1.1/mod.ts"
},
"workspace": [
@ -10,16 +8,11 @@
"packages/fabric/domain",
"packages/fabric/sqlite-store",
"packages/fabric/testing",
"packages/fabric/validations",
"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": {
"strict": true,
"exactOptionalPropertyTypes": true,
@ -27,8 +20,8 @@
"noImplicitOverride": true,
"noUncheckedIndexedAccess": true
},
"unstable": ["ffi"],
"lint": {
"include": ["src/"],
"rules": {
"tags": ["recommended"],
"exclude": ["no-namespace"]

View File

@ -5,7 +5,6 @@
"jsr:@db/sqlite@0.12": "0.12.0",
"jsr:@denosaurs/plug@1": "1.0.6",
"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:@std/assert@0.217": "0.217.0",
"jsr:@std/assert@0.221": "0.221.0",
@ -127,12 +126,32 @@
}
},
"workspace": {
"members": {
"packages/fabric/domain": {
"dependencies": [
"jsr:@fabric/core@*",
"jsr:@fabric/validations@*",
"jsr:@quentinadam/decimal@~0.1.6"
]
},
"packages/fabric/sqlite-store": {
"dependencies": [
"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/testing@^1.0.3",
"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 "./time-constants.ts";

View File

@ -1,6 +1,9 @@
export * from "./email.ts";
export * from "./enum.ts";
export * from "./fn.ts";
export * from "./keyof.ts";
export * from "./maybe-promise.ts";
export * from "./optional.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";
export function ensureValue<T>(value?: T): T {
export function ensure<T>(value?: T): T {
if (!value) {
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,
MaybePromise,
PosixDate,
UUID,
VariantFromTag,
VariantTag,
} from "@fabric/core";
import type { StoreQueryError } from "../errors/query-error.ts";
import type { UUID } from "../types/uuid.ts";
import type { Event } from "./event.ts";
import type { StoredEvent } from "./stored-event.ts";

View File

@ -1,6 +1,6 @@
// deno-lint-ignore-file no-explicit-any
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.

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 "./bytes.ts";
export * from "./image-file.ts";
export * from "./in-memory-file.ts";
export * from "./invalid-file-type-error.ts";
export * from "./is-in-memory-file.ts";
export * from "./is-mime-type.ts";
export * from "./media-file.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 "./security/index.ts";
export * from "./services/index.ts";
export * from "./types/index.ts";
export * from "./use-case/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 Decimal from "jsr:@quentinadam/decimal";
import type { UUID } from "../../types/uuid.ts";
import type Decimal from "decimal";
import type { UUID } from "../../../core/types/uuid.ts";
import type { BooleanField } from "./boolean-field.ts";
import type { DecimalField } from "./decimal.ts";
import type { EmbeddedField } from "./embedded.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 FloatField ? MaybeOptional<TField, number>
: TField extends TimestampField ? MaybeOptional<TField, PosixDate>
: TField extends BooleanField ? MaybeOptional<TField, boolean>
: TField extends EmbeddedField<infer TSubModel>
? MaybeOptional<TField, TSubModel>
: never;

View File

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

View File

@ -1,6 +1,6 @@
import { isError } from "@fabric/core";
import { describe, expect, test } from "@fabric/testing";
import { defineModel } from "../model.ts";
import { defineAggregateModel } from "../aggregate-model.ts";
import { Field } from "./index.ts";
import {
InvalidReferenceFieldError,
@ -9,7 +9,7 @@ import {
describe("Validate Reference Field", () => {
const schema = {
User: defineModel("User", {
User: defineAggregateModel("User", {
name: Field.string(),
password: Field.string(),
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 "./model-schema.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 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 { defineModel, type ModelToType } from "./model.ts";
import type { ModelToType } from "./model.ts";
describe("CreateModel", () => {
test("should create a model and it's interface type", () => {
const User = defineModel("User", {
const User = defineAggregateModel("User", {
name: Field.string(),
password: Field.string(),
phone: Field.string({ isOptional: true }),

View File

@ -1,10 +1,8 @@
import type { Keyof } from "@fabric/core";
import type { CustomModelFields } from "./custom-model-fields.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 Collection<
export interface Model<
TName extends string = string,
TFields extends CustomModelFields = CustomModelFields,
> {
@ -12,53 +10,20 @@ export interface Collection<
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<
TName extends string,
TFields extends CustomModelFields,
>(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 {
name,
fields,
} as const;
}
export type ModelToType<TModel extends Collection> = {
export type ModelToType<TModel extends Model> = {
[K in Keyof<TModel["fields"]>]: FieldToType<TModel["fields"][K]>;
};
export type ModelFieldNames<TModel extends CustomModelFields> = Keyof<
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 { Event } from "../events/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;
events: TEvents[VariantTag][];
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";
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 {
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, it } from "@std/testing/bdd";
import { describe, expect, test } from "@fabric/testing";
import { CircularDependencyError } from "../errors/circular-dependency-error.ts";
import { sortByDependencies } from "./sort-by-dependencies.ts";
describe("sortByDependencies", () => {
it("should sort an array of objects by their dependencies", () => {
test("should sort an array of objects by their dependencies", () => {
const array = [
{ id: 1, name: "A", dependencies: ["C", "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 = [
{ id: 1, name: "A", dependencies: ["B"] },
{ id: 2, name: "B", dependencies: ["A"] },
@ -39,7 +38,7 @@ describe("sortByDependencies", () => {
).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
const array: any[] = [];

View File

@ -1,6 +1,9 @@
import { Result } from "@fabric/core";
import { CircularDependencyError } from "../errors/circular-dependency-error.ts";
/**
* Sorts an array of elements based on their dependencies.
*/
export function sortByDependencies<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,
PosixDate,
Run,
UUID,
VariantTag,
} from "@fabric/core";
import {
@ -13,7 +14,6 @@ import {
JSONUtils,
StoredEvent,
StoreQueryError,
UUID,
} from "@fabric/domain";
import { SQLiteDatabase } from "../sqlite/sqlite-database.ts";

View File

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

View File

@ -1,12 +1,12 @@
// deno-lint-ignore-file no-explicit-any
import {
Collection,
FieldDefinition,
FILTER_OPTION_OPERATOR_SYMBOL,
FILTER_OPTION_TYPE_SYMBOL,
FILTER_OPTION_VALUE_SYMBOL,
FilterOptions,
FilterValue,
Model,
MultiFilterOption,
SingleFilterOption,
} from "@fabric/domain";
@ -24,7 +24,7 @@ export function filterToSQL(filterOptions?: FilterOptions) {
}
export function filterToParams(
collection: Collection,
collection: Model,
filterOptions?: FilterOptions,
) {
if (!filterOptions) return {};
@ -100,7 +100,7 @@ function getWhereFromKeyValue(
}
function getParamsFromMultiFilterOption(
collection: Collection,
collection: Model,
filterOptions: MultiFilterOption,
) {
return filterOptions.reduce(
@ -115,7 +115,7 @@ function getParamsFromMultiFilterOption(
}
function getParamsFromSingleFilterOption(
collection: Collection,
collection: Model,
filterOptions: SingleFilterOption,
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 { modelToSql } from "./model-to-sql.ts";
describe("ModelToSQL", () => {
const model = defineCollection("something", {
const model = defineModel("something", {
id: Field.uuid({ isPrimaryKey: true }),
name: Field.string(),
age: Field.integer(),

View File

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

View File

@ -1,8 +1,8 @@
// deno-lint-ignore-file no-explicit-any
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>) => {
const result: Record<string, any> = {};
for (const key in row) {
@ -41,4 +41,5 @@ const FieldSQLInsertMap: FieldSQLInsertMap = {
DecimalField: (_, v) => v,
TimestampField: (_, v) => new PosixDate(v),
EmbeddedField: (_, v: string) => JSON.parse(v),
BooleanField: (_, v) => v,
};

View File

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

View File

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

View File

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

View File

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