diff --git a/packages/effect-fc-next/src/SubmittableForm.ts b/packages/effect-fc-next/src/SubmittableForm.ts index 122c9a3..0ba9a56 100644 --- a/packages/effect-fc-next/src/SubmittableForm.ts +++ b/packages/effect-fc-next/src/SubmittableForm.ts @@ -10,14 +10,14 @@ import * as View from "./View.js" export const SubmittableFormTypeId: unique symbol = Symbol.for("@effect-fc/Form/SubmittableForm") export type SubmittableFormTypeId = typeof SubmittableFormTypeId -export interface SubmittableForm +export interface SubmittableForm extends Form.Form { readonly [SubmittableFormTypeId]: SubmittableFormTypeId - readonly schema: Schema.ConstraintCodec - readonly context: Context.Context + readonly schema: Schema.ConstraintCodec + readonly context: Context.Context readonly mutation: Mutation.Mutation< - readonly [value: A, form: SubmittableForm], + readonly [value: A, form: SubmittableForm], MA, ME, MR > readonly validationFiber: View.View>, never, never> @@ -26,8 +26,8 @@ extends Form.Form { readonly submit: Effect.Effect | AsyncResult.Failure>, Cause.NoSuchElementError> } -export class SubmittableFormImpl -extends Pipeable.Class implements SubmittableForm { +export class SubmittableFormImpl +extends Pipeable.Class implements SubmittableForm { readonly [Form.FormTypeId]: Form.FormTypeId = Form.FormTypeId readonly [SubmittableFormTypeId]: SubmittableFormTypeId = SubmittableFormTypeId @@ -39,10 +39,10 @@ extends Pipeable.Class implements SubmittableForm { readonly isCommitting: View.View constructor( - readonly schema: Schema.ConstraintCodec, - readonly context: Context.Context, + readonly schema: Schema.ConstraintCodec, + readonly context: Context.Context, readonly mutation: Mutation.Mutation< - readonly [value: A, form: SubmittableForm], + readonly [value: A, form: SubmittableForm], MA, ME, MR >, readonly value: Lens.Lens, never, never, never, never>, @@ -165,26 +165,26 @@ export const isSubmittableForm = (u: unknown): u is SubmittableForm + export interface Options extends Mutation.make.Options< - readonly [value: NoInfer, form: SubmittableForm, NoInfer, NoInfer, unknown, unknown, unknown>], + readonly [value: NoInfer, form: SubmittableForm, NoInfer, NoInfer, NoInfer, unknown, unknown, unknown>], MA, ME, MR > { - readonly schema: Schema.ConstraintCodec + readonly schema: Schema.ConstraintCodec readonly initialEncodedValue: NoInfer } } -export const make = Effect.fnUntraced(function* ( - options: make.Options +export const make = Effect.fnUntraced(function* ( + options: make.Options ): Effect.fn.Return< - SubmittableForm, + SubmittableForm, never, - Scope.Scope | R | MR + Scope.Scope | RD | RE | MR > { return new SubmittableFormImpl( options.schema, - yield* Effect.context(), + yield* Effect.context(), yield* Mutation.make(options), Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none())), @@ -197,16 +197,16 @@ export const make = Effect.fnUntraced(function* - extends make.Options {} + export interface Options + extends make.Options {} } -export const service = ( - options: service.Options +export const service = ( + options: service.Options ): Effect.Effect< - SubmittableForm, + SubmittableForm, never, - Scope.Scope | R | MR + Scope.Scope | RD | RE | MR > => Effect.tap( make(options), form => Effect.forkScoped(form.run), diff --git a/packages/effect-fc-next/src/SynchronizedForm.ts b/packages/effect-fc-next/src/SynchronizedForm.ts index 96d6915..4cccb9f 100644 --- a/packages/effect-fc-next/src/SynchronizedForm.ts +++ b/packages/effect-fc-next/src/SynchronizedForm.ts @@ -1,204 +1,206 @@ -import { - Array, - type Context, - Effect, - Equal, - Fiber, - Option, - Pipeable, - Predicate, - Schema, - SchemaIssue, - SchemaParser, - type Scope, - Semaphore, - Stream, - SubscriptionRef, -} from "effect" +import type { StandardSchemaV1 } from "@standard-schema/spec" +import { Array, type Context, Effect, Equal, Fiber, Option, Pipeable, Predicate, Schema, SchemaIssue, type Scope, Semaphore, Stream, SubscriptionRef } from "effect" import * as Form from "./Form.js" import * as Lens from "./Lens.js" import * as View from "./View.js" -type FormSchema = Schema.Top & { - readonly Type: A - readonly Encoded: I - readonly DecodingServices: R - readonly EncodingServices: R -} - export const SynchronizedFormTypeId: unique symbol = Symbol.for("@effect-fc/Form/SynchronizedForm") export type SynchronizedFormTypeId = typeof SynchronizedFormTypeId -export interface SynchronizedForm< - in out A, - in out I = A, - in out R = never, - in out TER = never, - in out TEW = never, - in out TRR = never, - in out TRW = never, -> extends Form.Form { +export interface SynchronizedForm +extends Form.Form { readonly [SynchronizedFormTypeId]: SynchronizedFormTypeId - readonly schema: FormSchema - readonly context: Context.Context + + readonly schema: Schema.ConstraintCodec + readonly context: Context.Context readonly target: Lens.Lens - readonly validationFiber: View.View>, never, never> + readonly validationFiber: View.View>, never, never> + readonly run: Effect.Effect } -export class SynchronizedFormImpl< - in out A, - in out I = A, - in out R = never, - in out TER = never, - in out TEW = never, - in out TRR = never, - in out TRW = never, -> extends Pipeable.Class implements SynchronizedForm { +export class SynchronizedFormImpl +extends Pipeable.Class implements SynchronizedForm { readonly [Form.FormTypeId]: Form.FormTypeId = Form.FormTypeId readonly [SynchronizedFormTypeId]: SynchronizedFormTypeId = SynchronizedFormTypeId + readonly path = [] as const - readonly value: View.View, TER, never> + + readonly value: View.View, never, never> readonly encodedValue: Lens.Lens readonly isValidating: View.View readonly canCommit: View.View constructor( - readonly schema: FormSchema, - readonly context: Context.Context, + readonly schema: Schema.ConstraintCodec, + readonly context: Context.Context, readonly target: Lens.Lens, + readonly internalEncodedValue: Lens.Lens, - readonly issues: Lens.Lens, - readonly validationFiber: Lens.Lens>, never, never, never, never>, - readonly isCommitting: Lens.Lens, + readonly issues: Lens.Lens, + readonly validationFiber: Lens.Lens>, never, never, never, never>, + readonly isCommitting: Lens.Lens, + readonly runSemaphore: Semaphore.Semaphore, ) { super() - this.value = View.make({ - get: Effect.provide(Effect.map(target.get, Option.some), context), - changes: Stream.provideContext( - target.changes.pipe( - Stream.map(Option.some), - Stream.catchCause(() => Stream.make(Option.none())), + + this.value = Effect.succeed(this).pipe( + Effect.map(self => View.make({ + get: Effect.provide(Effect.option(self.target.get), self.context), + get changes() { + return Stream.provideContext( + self.target.changes.pipe( + Stream.map(Option.some), + Stream.catch(() => Stream.make(Option.none())), + ), + self.context, + ) + }, + })), + View.unwrap, + ) + this.encodedValue = Effect.all([ + Effect.succeed(this), + Effect.succeed(Lens.asLensImpl(this.internalEncodedValue)), + ]).pipe( + Effect.map(([self, parent]) => Lens.make({ + get: parent.get, + get changes() { return parent.changes }, + commit: a => Effect.andThen( + Effect.flatMap( + parent.resolve, + resolved => resolved.commit(Effect.succeed(a)), + ), + self.synchronizeEncodedValue(a), ), - context, - ), - }) - this.encodedValue = Lens.make({ - get: Lens.get(internalEncodedValue), - changes: internalEncodedValue.changes, - commit: encoded => Effect.andThen( - Lens.set(internalEncodedValue, encoded), - this.synchronizeEncodedValue(encoded), - ), - lock: Lens.asLensImpl(internalEncodedValue).lock, - }) as unknown as Lens.Lens - this.isValidating = View.map(validationFiber, Option.isSome) - const commitState = View.zipLatestAll(issues as any, validationFiber as any, isCommitting as any) as unknown as View.View>, - boolean, - ]> - this.canCommit = View.map( - commitState, - ([currentIssues, fiber, committing]) => currentIssues.length === 0 && Option.isNone(fiber) && !committing, + lock: parent.lock, + })), + Lens.unwrap, + ) + this.isValidating = Effect.succeed(this).pipe( + Effect.map(self => View.map(self.validationFiber, Option.isSome)), + View.unwrap, + ) + this.canCommit = Effect.succeed(this).pipe( + Effect.map(self => View.map( + View.zipLatestAll(self.issues, self.validationFiber, self.isCommitting), + ([issues, validationFiber, isCommitting]) => ( + Array.isReadonlyArrayEmpty(issues) && + Option.isNone(validationFiber) && + !isCommitting + ), + )), + View.unwrap, ) } - synchronizeEncodedValue(encodedValue: I): Effect.Effect { - const self = this - return Effect.gen(function*() { - const current = yield* Lens.get(self.validationFiber) - if (Option.isSome(current)) yield* Fiber.interrupt(current.value) - const fiber = yield* Effect.forkScoped( + synchronizeEncodedValue(encodedValue: I): Effect.Effect { + return Lens.get(this.validationFiber).pipe( + Effect.andThen(Option.match({ + onSome: Fiber.interrupt, + onNone: () => Effect.void, + })), + Effect.andThen(Effect.forkScoped( Effect.ensuring( - SchemaParser.decodeEffect(self.schema)(encodedValue), - Lens.set(self.validationFiber, Option.none()), + Schema.decodeEffect(this.schema, { errors: "all" })(encodedValue), + Lens.set(this.validationFiber, Option.none()), + ) + )), + Effect.tap(fiber => Lens.set(this.validationFiber, Option.some(fiber))), + Effect.flatMap(Fiber.join), + + Effect.flatMap(value => Effect.ensuring( + Lens.set(this.isCommitting, true).pipe( + Effect.andThen(Lens.set(this.issues, Array.empty())), + Effect.andThen(Lens.set(this.target, value)), ), - ) - yield* Lens.set(self.validationFiber, Option.some(fiber)) - yield* Fiber.join(fiber).pipe( - Effect.flatMap(value => Effect.ensuring( - Effect.andThen( - Lens.set(self.isCommitting, true), - Effect.andThen(Lens.set(self.issues, Array.empty()), Lens.set(self.target, value)), - ), - Lens.set(self.isCommitting, false), - )), - Effect.catchIf(SchemaIssue.isIssue, issue => Lens.set(self.issues, formatIssue(issue))), - ) - }).pipe(Effect.provide(this.context)) as Effect.Effect + Lens.set(this.isCommitting, false), + )), + Effect.catchIf( + Schema.isSchemaError, + error => Lens.set(this.issues, SchemaIssue.makeFormatterStandardSchemaV1()(error.issue).issues), + ), + + Effect.provide(this.context), + ) } - get run(): Effect.Effect { + get run(): Effect.Effect { return this.runSemaphore.withPermits(1)(Effect.provide( - Stream.runForEach(Stream.drop(this.target.changes, 1), targetValue => Effect.ignore( - Effect.flatMap(SchemaParser.encodeEffect(this.schema)(targetValue), encodedValue => Effect.flatMap( - Lens.get(this.internalEncodedValue), - current => Equal.equals(encodedValue, current) - ? Effect.succeed(undefined) - : Effect.andThen( + Stream.runForEach( + Stream.drop(Lens.changes(this.target), 1), + targetValue => Schema.encodeEffect(this.schema, { errors: "all" })(targetValue).pipe( + Effect.flatMap(encodedValue => Effect.when( + Effect.andThen( Lens.set(this.issues, Array.empty()), Lens.set(this.internalEncodedValue, encodedValue), ), - )), - )), + Effect.map( + Lens.get(this.internalEncodedValue), + currentEncodedValue => !Equal.equals(encodedValue, currentEncodedValue), + ), + )), + Effect.ignore, + ), + ), this.context, )) } } -const formatIssue = (issue: SchemaIssue.Issue): readonly Form.FormIssue[] => { - const formatted = SchemaIssue.makeFormatterStandardSchemaV1()(issue) - return formatted.issues.map(item => ({ - path: (item.path ?? []) as readonly PropertyKey[], - message: item.message, - })) -} - export const isSynchronizedForm = (u: unknown): u is SynchronizedForm => Predicate.hasProperty(u, SynchronizedFormTypeId) + export declare namespace make { - export interface Options { - readonly schema: FormSchema + export interface Options { + readonly schema: Schema.ConstraintCodec readonly target: Lens.Lens readonly initialEncodedValue?: NoInfer } } -export const make = Effect.fnUntraced(function* ( - options: make.Options, +export const make = Effect.fnUntraced(function* ( + options: make.Options ): Effect.fn.Return< - SynchronizedForm, - SchemaIssue.Issue | TER, - Scope.Scope | R | TRR | TRW + SynchronizedForm, + Schema.SchemaError | TER, + Scope.Scope | RD | RE | TRR | TRW > { const initialEncodedValue = options.initialEncodedValue !== undefined ? options.initialEncodedValue - : yield* Effect.flatMap(Lens.get(options.target), SchemaParser.encodeEffect(options.schema)) + : yield* Effect.flatMap( + Lens.get(options.target), + Schema.encodeEffect(options.schema), + ) return new SynchronizedFormImpl( options.schema, - yield* Effect.context(), + yield* Effect.context(), options.target, + Lens.fromSubscriptionRef(yield* SubscriptionRef.make(initialEncodedValue)), - Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Array.empty())), - Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none>())), + Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Array.empty())), + Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none>())), Lens.fromSubscriptionRef(yield* SubscriptionRef.make(false)), + yield* Semaphore.make(1), ) }) export declare namespace service { - export interface Options - extends make.Options {} + export interface Options + extends make.Options {} } -export const service = ( - options: service.Options, +export const service = ( + options: service.Options ): Effect.Effect< - SynchronizedForm, - SchemaIssue.Issue | TER, - Scope.Scope | R | TRR | TRW -> => Effect.tap(make(options), form => Effect.asVoid(Effect.forkScoped(form.run))) + SynchronizedForm, + Schema.SchemaError | TER, + Scope.Scope | RD | RE | TRR | TRW +> => Effect.tap( + make(options), + form => Effect.forkScoped(form.run), +)