(WIP) Add state-store utils

This commit is contained in:
Pablo Baleztena 2024-09-15 21:16:06 -03:00
parent c4483f073e
commit dca326d0c5
29 changed files with 622 additions and 48 deletions

View File

@ -7,4 +7,9 @@ export default tseslint.config(
eslint.configs.recommended,
...tseslint.configs.strict,
...tseslint.configs.stylistic,
{
rules: {
"@typescript-eslint/no-namespace": "off",
},
},
);

View File

@ -1,10 +1,12 @@
import { TaggedVariant } from "../../variant/variant.js";
import { UUID } from "../types/uuid.js";
/**
* An event is a tagged variant with a payload and a timestamp.
*/
export interface Event<TTag extends string, TPayload>
extends TaggedVariant<TTag> {
streamId: UUID;
payload: TPayload;
timestamp: number;
}

View File

@ -0,0 +1,32 @@
import { FieldDefinition } from "./fields/index.js";
export interface ModelDefinition<
TName extends string = string,
TFields extends Record<string, FieldDefinition> = Record<
string,
FieldDefinition
>,
> {
name: TName;
fields: TFields;
}
export type ModelName<
TModel extends ModelDefinition<string, Record<string, FieldDefinition>>,
> = TModel["name"];
export type ModelFromName<
TModels extends ModelDefinition<string, Record<string, FieldDefinition>>,
TName extends ModelName<TModels>,
> = Extract<TModels, { name: TName }>;
export type ModelFieldNames<
TModel extends ModelDefinition<string, Record<string, FieldDefinition>>,
> = keyof TModel["fields"];
export function createModel<
TName extends string,
TFields extends Record<string, FieldDefinition>,
>(opts: ModelDefinition<TName, TFields>): ModelDefinition<TName, TFields> {
return opts;
}

View File

@ -0,0 +1,5 @@
export interface BaseField {
isOptional?: boolean;
isUnique?: boolean;
isIndexed?: boolean;
}

View File

@ -0,0 +1,9 @@
import { UUID } from "../../types/uuid.js";
import { StringField } from "./string-field.js";
import { UUIDField } from "./uuid-field.js";
export type FieldToType<TField> = TField extends StringField
? string
: TField extends UUIDField
? UUID
: never;

View File

@ -0,0 +1,9 @@
import { createStringField, StringField } from "./string-field.js";
import { createUUIDField } from "./uuid-field.js";
export type FieldDefinition = StringField;
export namespace Field {
export const string = createStringField;
export const uuid = createUUIDField;
}

View File

@ -0,0 +1,20 @@
import { TaggedVariant, VariantTag } from "../../../variant/variant.js";
import { BaseField } from "./base-field.js";
export interface StringFieldOptions extends BaseField {
maxLength?: number;
minLength?: number;
}
export interface StringField
extends TaggedVariant<"StringField">,
StringFieldOptions {}
export function createStringField<T extends StringFieldOptions>(
opts: T,
): StringField & T {
return {
[VariantTag]: "StringField",
...opts,
} as const;
}

View File

@ -0,0 +1,15 @@
import { TaggedVariant, VariantTag } from "../../../variant/variant.js";
import { BaseField } from "./base-field.js";
export interface UUIDOptions extends BaseField {
isPrimaryKey?: boolean;
}
export interface UUIDField extends TaggedVariant<"UUID_FIELD">, UUIDOptions {}
export function createUUIDField(opts: UUIDOptions): UUIDField {
return {
[VariantTag]: "UUID_FIELD",
...opts,
};
}

View File

@ -0,0 +1,6 @@
import { ModelDefinition } from "./create-model.js";
import { FieldToType } from "./fields/field-to-type.js";
export type ModelToType<TModel extends ModelDefinition> = {
[K in keyof TModel["fields"]]: FieldToType<TModel["fields"][K]>;
};

View File

@ -0,0 +1,5 @@
import { OneToOneRelation } from "./one-to-one.js";
export type RelationDefinition = OneToOneRelation<string, string>;
export namespace Relations {}

View File

@ -0,0 +1,34 @@
import { TaggedVariant, VariantTag } from "../../../variant/variant.js";
export interface OneToManyRelationOptions<
TOwner extends string,
TTarget extends string,
> {
/**
* The owner of the relation. In this case is the "one" side of the relation.
*/
owner: TOwner;
/**
* The target of the relation. In this case is the "many" side of the relation.
*/
target: TTarget;
}
export interface OneToManyRelation<
TOwner extends string,
TTarget extends string,
> extends TaggedVariant<"ONE_TO_MANY_RELATION">,
OneToManyRelationOptions<TOwner, TTarget> {}
export function createOneToManyRelation<
TOwner extends string,
TTarget extends string,
>(
opts: OneToManyRelationOptions<TOwner, TTarget>,
): OneToManyRelation<TOwner, TTarget> {
return {
[VariantTag]: "ONE_TO_MANY_RELATION",
...opts,
};
}

View File

@ -0,0 +1,33 @@
import { TaggedVariant, VariantTag } from "../../../variant/variant.js";
export interface OneToOneRelationOptions<
TOwner extends string,
TTarget extends string,
> {
/**
* The owner of the relation.
*/
owner: TOwner;
/**
* The target of the relation
*
*/
target: TTarget;
}
export interface OneToOneRelation<TOwner extends string, TTarget extends string>
extends TaggedVariant<"ONE_TO_ONE_RELATION">,
OneToOneRelationOptions<TOwner, TTarget> {}
export function createOneToOneRelation<
TOwner extends string,
TTarget extends string,
>(
opts: OneToOneRelationOptions<TOwner, TTarget>,
): OneToOneRelation<TOwner, TTarget> {
return {
[VariantTag]: "ONE_TO_ONE_RELATION",
...opts,
};
}

View File

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

View File

@ -1,7 +0,0 @@
/**
* A PolicyMap maps user types to their security policies.
*/
export type PolicyMap<
UserType extends string,
PolicyType extends string,
> = Record<UserType, PolicyType[]>;

View File

@ -0,0 +1,7 @@
/**
* A Policy maps permissions to which user types are allowed to perform them.
*/
export type Policy<
TUserType extends string,
TPolicyType extends string,
> = Record<TPolicyType, TUserType[]>;

View File

@ -1,13 +1,20 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { TaggedError } from "../../error/tagged-error.js";
import { UseCase } from "./use-case.js";
export type UseCaseDefinition<
TDependencies = any,
TPayload = any,
TOutput = any,
TErrors extends TaggedError<string> = any,
> = BasicUseCaseDefinition<TDependencies, TPayload, TOutput, TErrors>;
interface BasicUseCaseDefinition<
TDependencies,
TPayload,
TOutput,
TErrors extends TaggedError<string>,
> = TPayload extends undefined
? {
> {
/**
* The use case name.
*/
@ -16,11 +23,11 @@ export type UseCaseDefinition<
/**
* Whether the use case requires authentication or not.
*/
isAuthRequired?: boolean;
isAuthRequired: boolean;
/**
* The required permissions to execute the use case.
*/
**/
requiredPermissions?: string[];
/**
@ -28,24 +35,3 @@ export type UseCaseDefinition<
*/
useCase: UseCase<TDependencies, TPayload, TOutput, TErrors>;
}
: {
/**
* The use case name.
*/
name: string;
/**
* Whether the use case requires authentication or not.
*/
isAuthRequired?: boolean;
/**
* The required permissions to execute the use case.
*/
requiredPermissions?: string[];
/**
* The use case function.
*/
useCase: UseCase<TDependencies, TPayload, TOutput, TErrors>;
};

View File

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

View File

@ -0,0 +1,54 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { ModelDefinition } from "../domain/models/create-model.js";
import { TaggedError } from "../error/tagged-error.js";
import { AsyncResult } from "../result/async-result.js";
import { QueryDefinition } from "./query/query.js";
import { CircularDependencyError } from "./utils/sort-by-dependencies.js";
export interface StorageDriver {
/**
* Insert data into the store
*/
insert(collectionName: string, record: any): AsyncResult<void, QueryError>;
/**
* Run a select query against the store.
*/
select(query: QueryDefinition): AsyncResult<any[], QueryError>;
/**
* Run a select query against the store.
*/
selectOne(query: QueryDefinition): AsyncResult<any, QueryError>;
/**
* Sincronice the store with the schema.
*/
sync(
schema: ModelDefinition[],
): AsyncResult<void, QueryError | CircularDependencyError>;
/**
* Drop the store. This is a destructive operation.
*/
drop(): AsyncResult<void, QueryError>;
/**
* Update a record in the store.
*/
update(
collectionName: string,
id: string,
record: Record<string, any>,
): AsyncResult<void, QueryError>;
}
export class QueryError extends TaggedError<"QueryError"> {
constructor(
public message: string,
public context: any,
) {
super("QueryError");
}
}

View File

@ -0,0 +1,93 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
export type FilterOptions<T = any> =
| SingleFilterOption<T>
| MultiFilterOption<T>;
export type SingleFilterOption<T = any> = {
[K in keyof T]?:
| T[K]
| LikeFilterOption<T[K]>
| ComparisonFilterOption<T[K]>
| InFilterOption<T[K]>;
};
export type MultiFilterOption<T = any> = SingleFilterOption<T>[];
export const FILTER_OPTION_TYPE_SYMBOL = Symbol("$type");
export const FILTER_OPTION_VALUE_SYMBOL = Symbol("$value");
export const FILTER_OPTION_OPERATOR_SYMBOL = Symbol("$operator");
export type LikeFilterOption<T> = T extends string
? {
[FILTER_OPTION_TYPE_SYMBOL]: "like";
[FILTER_OPTION_VALUE_SYMBOL]: string;
}
: never;
export interface InFilterOption<T> {
[FILTER_OPTION_TYPE_SYMBOL]: "in";
[FILTER_OPTION_VALUE_SYMBOL]: T[];
}
export interface ComparisonFilterOption<T> {
[FILTER_OPTION_TYPE_SYMBOL]: "comparison";
[FILTER_OPTION_OPERATOR_SYMBOL]: ComparisonOperator;
[FILTER_OPTION_VALUE_SYMBOL]: T;
}
export type ComparisonOperator = "<" | ">" | "<=" | ">=" | "<>";
export function isGreaterThan(value: any): ComparisonFilterOption<any> {
return {
[FILTER_OPTION_TYPE_SYMBOL]: "comparison",
[FILTER_OPTION_OPERATOR_SYMBOL]: ">",
[FILTER_OPTION_VALUE_SYMBOL]: value,
};
}
export function isLessThan(value: any): ComparisonFilterOption<any> {
return {
[FILTER_OPTION_TYPE_SYMBOL]: "comparison",
[FILTER_OPTION_OPERATOR_SYMBOL]: "<",
[FILTER_OPTION_VALUE_SYMBOL]: value,
};
}
export function isGreaterOrEqualTo(value: any): ComparisonFilterOption<any> {
return {
[FILTER_OPTION_TYPE_SYMBOL]: "comparison",
[FILTER_OPTION_OPERATOR_SYMBOL]: ">=",
[FILTER_OPTION_VALUE_SYMBOL]: value,
};
}
export function isLessOrEqualTo(value: any): ComparisonFilterOption<any> {
return {
[FILTER_OPTION_TYPE_SYMBOL]: "comparison",
[FILTER_OPTION_OPERATOR_SYMBOL]: "<=",
[FILTER_OPTION_VALUE_SYMBOL]: value,
};
}
export function isNotEqualTo(value: any): ComparisonFilterOption<any> {
return {
[FILTER_OPTION_TYPE_SYMBOL]: "comparison",
[FILTER_OPTION_OPERATOR_SYMBOL]: "<>",
[FILTER_OPTION_VALUE_SYMBOL]: value,
};
}
export function isLike(value: string): LikeFilterOption<string> {
return {
[FILTER_OPTION_TYPE_SYMBOL]: "like",
[FILTER_OPTION_VALUE_SYMBOL]: value,
};
}
export function isIn<T>(values: T[]): InFilterOption<T> {
return {
[FILTER_OPTION_TYPE_SYMBOL]: "in",
[FILTER_OPTION_VALUE_SYMBOL]: values,
};
}

View File

@ -0,0 +1,3 @@
export type OrderByOptions<T> = {
[K in keyof T]?: "ASC" | "DESC";
};

View File

@ -0,0 +1,74 @@
import {
ModelDefinition,
ModelFromName,
ModelName,
} from "../../domain/models/create-model.js";
import { ModelToType } from "../../domain/models/model-to-type.js";
import { AsyncResult } from "../../result/async-result.js";
import { Keyof } from "../../types/index.js";
import { QueryError, StorageDriver } from "../driver.js";
import { FilterOptions } from "./filter-options.js";
import { OrderByOptions } from "./order-by-options.js";
import {
QueryDefinition,
SelectableQuery,
StoreLimitableQuery,
StoreQuery,
StoreSortableQuery,
} from "./query.js";
export class QueryBuilder<
TModels extends ModelDefinition,
TEntityName extends ModelName<TModels>,
T = ModelToType<ModelFromName<TModels, TEntityName>>,
> implements StoreQuery<T>
{
constructor(
private driver: StorageDriver,
private query: QueryDefinition<TEntityName>,
) {}
where(where: FilterOptions<T>): StoreSortableQuery<T> {
this.query = {
...this.query,
where,
};
return this;
}
orderBy(opts: OrderByOptions<T>): StoreLimitableQuery<T> {
this.query = {
...this.query,
orderBy: opts,
};
return this;
}
limit(limit: number, offset?: number | undefined): SelectableQuery<T> {
this.query = {
...this.query,
limit,
offset,
};
return this;
}
select<K extends Keyof<T>>(
keys?: K[],
): AsyncResult<Pick<T, K>[], QueryError> {
return this.driver.select({
...this.query,
keys,
});
}
selectOne<K extends Keyof<T>>(
keys?: K[],
): AsyncResult<Pick<T, K>, QueryError> {
return this.driver.selectOne({
...this.query,
keys,
});
}
}

View File

@ -0,0 +1,56 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { AsyncResult } from "../../result/async-result.js";
import { Keyof } from "../../types/keyof.js";
import { QueryError } from "../driver.js";
import { FilterOptions } from "./filter-options.js";
import { OrderByOptions } from "./order-by-options.js";
export interface StoreQuery<T> {
where(where: FilterOptions<T>): StoreSortableQuery<T>;
orderBy(opts: OrderByOptions<T>): StoreLimitableQuery<T>;
limit(limit: number, offset?: number): SelectableQuery<T>;
select(): AsyncResult<T[], QueryError>;
select<K extends Keyof<T>>(keys: K[]): AsyncResult<Pick<T, K>[], QueryError>;
selectOne(): AsyncResult<T, QueryError>;
selectOne<K extends Keyof<T>>(keys: K[]): AsyncResult<Pick<T, K>, QueryError>;
}
export interface StoreSortableQuery<T> {
orderBy(opts: OrderByOptions<T>): StoreLimitableQuery<T>;
limit(limit: number, offset?: number): SelectableQuery<T>;
select(): AsyncResult<T[], QueryError>;
select<K extends Keyof<T>>(keys: K[]): AsyncResult<Pick<T, K>[], QueryError>;
selectOne(): AsyncResult<T, QueryError>;
selectOne<K extends Keyof<T>>(keys: K[]): AsyncResult<Pick<T, K>, QueryError>;
}
export interface StoreLimitableQuery<T> {
limit(limit: number, offset?: number): SelectableQuery<T>;
select(): AsyncResult<T[], QueryError>;
select<K extends Keyof<T>>(keys: K[]): AsyncResult<Pick<T, K>[], QueryError>;
selectOne(): AsyncResult<T, QueryError>;
selectOne<K extends Keyof<T>>(keys: K[]): AsyncResult<Pick<T, K>, QueryError>;
}
export interface SelectableQuery<T> {
select(): AsyncResult<T[], QueryError>;
select<K extends Keyof<T>>(keys: K[]): AsyncResult<Pick<T, K>[], QueryError>;
selectOne(): AsyncResult<T, QueryError>;
selectOne<K extends Keyof<T>>(keys: K[]): AsyncResult<Pick<T, K>, QueryError>;
}
export interface QueryDefinition<K extends string = string> {
from: K;
where?: FilterOptions<any>;
orderBy?: OrderByOptions<any>;
limit?: number;
offset?: number;
keys?: string[];
}

View File

@ -0,0 +1,16 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
ModelDefinition,
ModelFromName,
ModelName,
} from "../domain/models/create-model.js";
import { ModelToType } from "../domain/models/model-to-type.js";
import { StoreQuery } from "./query/query.js";
export interface StateStore<
TModels extends ModelDefinition<string, Record<string, any>>,
> {
from<TEntityName extends ModelName<TModels>>(
entityName: TEntityName,
): StoreQuery<ModelToType<ModelFromName<TModels, TEntityName>>>;
}

View File

@ -0,0 +1,54 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, expect, it } from "vitest";
import {
CircularDependencyError,
sortByDependencies,
} from "./sort-by-dependencies.js";
describe("sortByDependencies", () => {
it("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"] },
{ id: 3, name: "C", dependencies: [] },
{ id: 4, name: "D", dependencies: [] },
];
const result = sortByDependencies(array, {
keyGetter: (element) => element.name,
depGetter: (element) => element.dependencies,
});
expect(result).toEqual([
{ id: 3, name: "C", dependencies: [] },
{ id: 4, name: "D", dependencies: [] },
{ id: 1, name: "A", dependencies: ["C", "D"] },
{ id: 2, name: "B", dependencies: ["A", "D"] },
]);
});
it("should throw a CircularDependencyError when circular dependencies are detected", () => {
const array = [
{ id: 1, name: "A", dependencies: ["B"] },
{ id: 2, name: "B", dependencies: ["A"] },
];
expect(
sortByDependencies(array, {
keyGetter: (element) => element.name,
depGetter: (element) => element.dependencies,
}),
).toBeInstanceOf(CircularDependencyError);
});
it("should return an empty array when the input array is empty", () => {
const array: any[] = [];
const result = sortByDependencies(array, {
keyGetter: (element) => element.name,
depGetter: (element) => element.dependencies,
});
expect(result).toEqual([]);
});
});

View File

@ -0,0 +1,55 @@
import { TaggedError } from "../../error/tagged-error.js";
import { Result } from "../../result/result.js";
export function sortByDependencies<T>(
array: T[],
{
keyGetter,
depGetter,
}: {
keyGetter: (element: T) => string;
depGetter: (element: T) => string[];
},
): Result<T[], CircularDependencyError> {
const graph = new Map<string, string[]>();
const visited = new Set<string>();
const sorted: string[] = [];
array.forEach((element) => {
const key = keyGetter(element);
const deps = depGetter(element);
graph.set(key, deps);
});
const visit = (key: string, ancestors: string[]) => {
if (visited.has(key)) {
return;
}
ancestors.push(key);
const deps = graph.get(key) || [];
deps.forEach((dep) => {
if (ancestors.includes(dep)) {
throw new CircularDependencyError(key, dep);
}
visit(dep, ancestors.slice());
});
visited.add(key);
sorted.push(key);
};
try {
graph.forEach((deps, key) => {
visit(key, []);
});
} catch (e) {
return e as CircularDependencyError;
}
return sorted.map(
(key) => array.find((element) => keyGetter(element) === key) as T,
);
}
export class CircularDependencyError extends TaggedError<"CircularDependencyError"> {
context: { key: string; dep: string };
constructor(key: string, dep: string) {
super("CircularDependencyError");
this.context = { key, dep };
}
}

View File

@ -0,0 +1 @@
export type EnumToValues<T extends Record<string, string>> = T[keyof T];

View File

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

View File

@ -0,0 +1,4 @@
/**
* Only string keys are allowed in the keyof type
*/
export type Keyof<T> = Extract<keyof T, string>;