diff --git a/bun.lockb b/bun.lockb index 5ff5c9a..22706bc 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 7600ee7..02bf310 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@thilawyn/thilalib", - "version": "0.1.14", + "version": "0.1.15", "type": "module", "files": [ "./dist" @@ -47,6 +47,16 @@ "default": "./dist/Schema/MobX/index.cjs" } }, + "./Schema/TanStackForm": { + "import": { + "types": "./dist/Schema/TanStackForm/index.d.ts", + "default": "./dist/Schema/TanStackForm/index.js" + }, + "require": { + "types": "./dist/Schema/TanStackForm/index.d.cts", + "default": "./dist/Schema/TanStackForm/index.cjs" + } + }, "./Types": { "import": { "types": "./dist/Types/index.d.ts", @@ -66,20 +76,22 @@ "clean:node": "rm -rf node_modules" }, "dependencies": { - "remeda": "^2.11.0", - "type-fest": "^4.25.0" + "remeda": "^2.12.0", + "type-fest": "^4.26.0" }, "devDependencies": { - "@effect/schema": "^0.71.1", + "@effect/schema": "^0.72.0", + "@prisma/studio-server": "^0.502.0", + "@tanstack/form-core": "^0.30.0", "@types/jsonwebtoken": "^9.0.6", "bun-types": "^1.1.26", - "effect": "^3.6.5", + "effect": "^3.7.0", "jsonwebtoken": "^9.0.2", "mobx": "^6.13.1", - "npm-check-updates": "^17.1.0", + "npm-check-updates": "^17.1.1", "npm-sort": "^0.0.4", "tsup": "^8.2.4", - "tsx": "^4.18.0", + "tsx": "^4.19.0", "typescript": "^5.5.4" } } diff --git a/src/Layers/PrismaStudioRoute.ts b/src/Layers/PrismaStudioRoute.ts new file mode 100644 index 0000000..09cba29 --- /dev/null +++ b/src/Layers/PrismaStudioRoute.ts @@ -0,0 +1,44 @@ +// import { StudioServer } from "@prisma/studio-server" +// import { Config, Effect, Layer } from "effect" +// import proxy from "express-http-proxy" +// import { ExpressApp } from "../http/ExpressApp.service" +// import { PrismaClient } from "./PrismaClient.service" + + +// export const PrismaStudioRoute = ({ +// httpRoot = Config.succeed("/studio"), +// httpPort = Config.succeed(5555), +// schemaPath = Config.succeed(""), +// schemaText = Config.succeed(""), +// }: { +// readonly httpRoot?: Config.Config +// readonly httpPort?: Config.Config +// readonly schemaPath?: Config.Config +// readonly schemaText?: Config.Config +// } = {}) => +// Layer.effectDiscard(Effect.acquireRelease( +// Effect.gen(function*() { +// const prisma = yield* PrismaClient +// const app = yield* ExpressApp + +// const port = yield* httpPort + +// const server = new StudioServer({ +// port, +// schemaPath: yield* schemaPath, +// schemaText: yield* schemaText, +// versions: { prisma: prisma.Prisma.prismaVersion.client }, +// }) + +// app.use(yield* httpRoot, proxy(`http://localhost:${ port }`)) + +// yield* Effect.promise(() => server.start()) +// return server +// }), + +// server => Effect.sync(() => server.stop()), +// )) + + +// export const PrismaStudioRouteLive = Layer.empty +// export const PrismaStudioRouteDebug = PrismaStudioRoute() diff --git a/src/Schema/Email.ts b/src/Schema/Email.ts new file mode 100644 index 0000000..7762efd --- /dev/null +++ b/src/Schema/Email.ts @@ -0,0 +1,12 @@ +import { Schema } from "@effect/schema" + + +export const Email = Schema.pattern( + /^(?!\.)(?!.*\.\.)([A-Z0-9_+-.]*)[A-Z0-9_+-]@([A-Z0-9][A-Z0-9-]*\.)+[A-Z]{2,}$/i, + + { + identifier: "Email", + title: "email", + message: () => "Not an email address", + }, +) diff --git a/src/Schema/MobX/ObservableClass.ts b/src/Schema/MobX/ObservableClass.ts index 3be882a..61243c7 100644 --- a/src/Schema/MobX/ObservableClass.ts +++ b/src/Schema/MobX/ObservableClass.ts @@ -8,19 +8,19 @@ interface ObservableClassSelf { readonly fields: { readonly [K in keyof Schema.Struct.Fields]: Schema.Struct.Fields[K] } } -export const ObservableClass = ( - self: Self, - options?: Omit, -) => - class Observable extends self { - declare ["constructor"]: typeof Observable +interface ObservableClassOptions extends Omit {} - constructor(...args: any[]) { - super(...args) +export const ObservableClass = (options?: ObservableClassOptions) => + (self: Self) => + class Observable extends self { + declare ["constructor"]: typeof Observable - makeObservable(this, - mapValues(this.constructor.fields, () => observable), - options, - ) - } - } as Self + constructor(...args: any[]) { + super(...args) + + makeObservable(this, + mapValues(this.constructor.fields, () => observable), + options, + ) + } + } as Self diff --git a/src/Schema/TanStackForm/index.ts b/src/Schema/TanStackForm/index.ts new file mode 100644 index 0000000..ee81f8e --- /dev/null +++ b/src/Schema/TanStackForm/index.ts @@ -0,0 +1 @@ +export * from "./makeSchemaFormValidator" diff --git a/src/Schema/TanStackForm/makeSchemaFormValidator.ts b/src/Schema/TanStackForm/makeSchemaFormValidator.ts new file mode 100644 index 0000000..0e87f91 --- /dev/null +++ b/src/Schema/TanStackForm/makeSchemaFormValidator.ts @@ -0,0 +1,138 @@ +import { ArrayFormatter, Schema } from "@effect/schema" +import type { ParseOptions } from "@effect/schema/AST" +import type { DeepKeys, DeepValue, FieldApi, FormApi, FormValidationError, ValidationError, Validator } from "@tanstack/form-core" +import { Array, Effect, Either, flow, Layer, ManagedRuntime, pipe } from "effect" +import { mapValues } from "remeda" + + +export const makeSchemaFormValidator = ( + schema: Schema.Schema, + options?: ParseOptions, +) => { + const decodeEither = Schema.decodeEither(schema, options) + + return < + TFormData extends I, + TFormValidator extends Validator | undefined, + >(props: { + value: TFormData + formApi: FormApi + }): FormValidationError => + decodeEither(props.value).pipe(result => + Either.isLeft(result) + ? { + form: "Please check the fields", + fields: issuesToFieldsRecord(ArrayFormatter.formatErrorSync(result.left), props.formApi) as any, + } + : null + ) +} + +export const makeSchemaAsyncFormValidator = ( + schema: Schema.Schema, + options?: ParseOptions, +) => { + const runtime = ManagedRuntime.make(Layer.empty) + const decode = Schema.decode(schema, options) + + return < + TFormData extends I, + TFormValidator extends Validator | undefined, + >(props: { + value: TFormData + formApi: FormApi + signal: AbortSignal + }): Promise> => + decode(props.value).pipe( + Effect.matchEffect({ + onSuccess: () => Effect.succeed(null), + + onFailure: e => ArrayFormatter.formatError(e).pipe( + Effect.map(issues => ({ + form: "Please check the fields", + fields: issuesToFieldsRecord(issues, props.formApi) as any, + })) + ), + }), + + prgm => runtime.runPromise(prgm, { signal: props.signal }), + ) +} + +/** + * TODO: handle nested array fields + */ +const issuesToFieldsRecord = < + TFormData, + TFormValidator extends Validator | undefined = undefined, +>( + issues: readonly ArrayFormatter.Issue[], + _formApi: FormApi, +) => pipe(issues, + Array.groupBy(issue => issue.path.join(".")), + + mapValues(flow( + Array.map(issue => issue.message), + Array.join("\n"), + )), +) + + +export const makeSchemaFieldValidator = ( + schema: Schema.Schema, + options?: ParseOptions, +) => { + const decodeEither = Schema.decodeEither(schema, options) + + return < + TParentData, + TName extends DeepKeys, + TFieldValidator extends Validator, unknown> | undefined, + TFormValidator extends Validator | undefined, + TData extends I & DeepValue, + >(props: { + value: TData + fieldApi: FieldApi + }): ValidationError => + decodeEither(props.value).pipe(result => + Either.isLeft(result) + ? ArrayFormatter.formatErrorSync(result.left) + .map(issue => issue.message) + .join("\n") + : null + ) +} + +export const makeSchemaAsyncFieldValidator = ( + schema: Schema.Schema, + options?: ParseOptions, +) => { + const runtime = ManagedRuntime.make(Layer.empty) + const decode = Schema.decode(schema, options) + + return < + TParentData, + TName extends DeepKeys, + TFieldValidator extends Validator, unknown> | undefined, + TFormValidator extends Validator | undefined, + TData extends I & DeepValue, + >(props: { + value: TData + fieldApi: FieldApi + signal: AbortSignal + }): Promise => + decode(props.value).pipe( + Effect.matchEffect({ + onSuccess: () => Effect.succeed(null), + + onFailure: e => ArrayFormatter.formatError(e).pipe( + Effect.map(flow( + Array.map(issue => issue.message), + Array.join("\n"), + )) + ), + }), + + prgm => runtime.runPromise(prgm, { signal: props.signal }), + ) +} diff --git a/src/Schema/index.ts b/src/Schema/index.ts index b047a9e..a66cc17 100644 --- a/src/Schema/index.ts +++ b/src/Schema/index.ts @@ -1,5 +1,6 @@ export * from "./Class" export * from "./DateTime" +export * from "./Email" export * from "./encodedAsPrismaJsonValue" export * from "./Kind" export * as MobX from "./MobX" diff --git a/src/Schema/toJsonifiable.ts b/src/Schema/toJsonifiable.ts index 0112b3e..c7b0a4f 100644 --- a/src/Schema/toJsonifiable.ts +++ b/src/Schema/toJsonifiable.ts @@ -10,4 +10,4 @@ export const toJsonifiable = < jsonifiableSchema: Schema.Schema ) => (decodedSchema: Schema.Schema) => - Schema.compose(jsonifiableSchema, decodedSchema) + Schema.compose(jsonifiableSchema, decodedSchema, { strict: true }) diff --git a/src/tests.ts b/src/tests.ts index f1adbc7..de20458 100644 --- a/src/tests.ts +++ b/src/tests.ts @@ -1,5 +1,5 @@ import { Schema as S } from "@effect/schema" -import { reaction, runInAction } from "mobx" +import { computed, makeObservable, reaction, runInAction } from "mobx" import type { Simplify } from "type-fest" import { MutableTaggedClass, toJsonifiable } from "./Schema" import { ObservableClass } from "./Schema/MobX" @@ -18,12 +18,24 @@ type TestB = { type Merged = Simplify> -class User extends MutableTaggedClass()("User", { + +const UserSchema = MutableTaggedClass()("User", { id: S.BigIntFromSelf, role: S.Union(S.Literal("BasicUser"), S.Literal("Admin")), }).pipe( - ObservableClass -) {} + ObservableClass() +) + +class User extends UserSchema { + constructor(...args: ConstructorParameters) { + super(...args) + makeObservable(this, { idAsString: computed }) + } + + get idAsString() { + return this.id.toString() + } +} const JsonifiableUser = User.pipe( toJsonifiable(S.Struct({ diff --git a/tsup.config.ts b/tsup.config.ts index 2327cf8..3ea8c21 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -7,6 +7,7 @@ export default defineConfig({ "./src/Layers/index.ts", "./src/Schema/index.ts", "./src/Schema/MobX/index.ts", + "./src/Schema/TanStackForm/index.ts", "./src/Types/index.ts", ], format: ["esm", "cjs"],