From 6ca830bec7ab5a2689f38d19bb70389fec47797d Mon Sep 17 00:00:00 2001 From: jfet97 Date: Mon, 1 Sep 2025 16:46:17 +0200 Subject: [PATCH 01/18] wip --- .envrc | 1 + .gitignore | 2 + flake.lock | 61 ++++++++++++++++++++++++++++++ flake.nix | 27 +++++++++++++ frontend/composables/useCommand.ts | 10 +++++ frontend/pages/index.vue | 19 +++++----- 6 files changed, 111 insertions(+), 9 deletions(-) create mode 100644 .envrc create mode 100644 flake.lock create mode 100644 flake.nix diff --git a/.envrc b/.envrc new file mode 100644 index 000000000..8392d159f --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake \ No newline at end of file diff --git a/.gitignore b/.gitignore index f43d7baeb..eed1a8ca6 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,5 @@ fabric.properties # vitest? *.timestamp-*.mjs + +.direnv \ No newline at end of file diff --git a/flake.lock b/flake.lock new file mode 100644 index 000000000..e19c48ae9 --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1756542300, + "narHash": "sha256-tlOn88coG5fzdyqz6R93SQL5Gpq+m/DsWpekNFhqPQk=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "d7600c775f877cd87b4f5a831c28aa94137377aa", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 000000000..79917387a --- /dev/null +++ b/flake.nix @@ -0,0 +1,27 @@ +{ + description = "Node 22 + pnpm 10 dev shell"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { inherit system; }; + nodejs = pkgs.nodejs_24; + pnpm = pkgs.pnpm.override { inherit nodejs; }; + tools = with pkgs; [ + git + nixfmt-classic + nodejs + pnpm + typescript + ]; + in { + devShells.default = pkgs.mkShellNoCC { + packages = tools; + }; + }); +} diff --git a/frontend/composables/useCommand.ts b/frontend/composables/useCommand.ts index 29ae56a1a..1a0ff00c7 100644 --- a/frontend/composables/useCommand.ts +++ b/frontend/composables/useCommand.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { Cause, Context, Effect, Option, flow, Match, S } from "effect-app" +import type * as Result from "@effect-atom/atom/Result" import type { YieldWrap } from "effect/Utils" import { runFork } from "./client" import { asResult, reportRuntimeError } from "@effect-app/vue" @@ -7,6 +8,15 @@ import { reportMessage } from "@effect-app/vue/errorReporter" import { OperationFailure, OperationSuccess } from "effect-app/Operations" import { SupportedErrors } from "effect-app/client" +export interface Command> { + get: ComputedRef<{ + action: string + result: Result.Result + waiting: boolean + }> + set: (...args: Args) => void +} + export class CommandContext extends Context.Tag("CommandContext")< CommandContext, { action: string } diff --git a/frontend/pages/index.vue b/frontend/pages/index.vue index b3b77cd2c..bdf86e012 100644 --- a/frontend/pages/index.vue +++ b/frontend/pages/index.vue @@ -50,7 +50,8 @@ const helloWorld = await getHelloWorldQuery.query(req) const Command = useCommand() -const setState = Command.fn("HelloWorld.SetState")( +const temp = Command.fn("HelloWorld.SetState") +const { get: HWState, set: setHWState} = temp( function* () { const input = { state: new Date().toISOString() } @@ -104,19 +105,19 @@ onMounted(() => { - {{ setState.action }} + {{ HWState.action }} From 302340eb3b23124acd6f9d6f428685db916c17bf Mon Sep 17 00:00:00 2001 From: jfet97 Date: Mon, 1 Sep 2025 16:49:31 +0200 Subject: [PATCH 02/18] Add handler to Command interface and update useCommand implementation --- frontend/composables/useCommand.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/composables/useCommand.ts b/frontend/composables/useCommand.ts index 1a0ff00c7..61ff24e73 100644 --- a/frontend/composables/useCommand.ts +++ b/frontend/composables/useCommand.ts @@ -14,6 +14,7 @@ export interface Command> { result: Result.Result waiting: boolean }> + handler: (...a: Args) => Effect.Effect set: (...args: Args) => void } From 458a73f26aefd357bbfb63e30b7f66b2638e069b Mon Sep 17 00:00:00 2001 From: jfet97 Date: Tue, 2 Sep 2025 09:36:38 +0200 Subject: [PATCH 03/18] WIP --- frontend/composables/useCommand.ts | 327 ++++++++++++++++++++++++++++- 1 file changed, 319 insertions(+), 8 deletions(-) diff --git a/frontend/composables/useCommand.ts b/frontend/composables/useCommand.ts index 61ff24e73..84df825c9 100644 --- a/frontend/composables/useCommand.ts +++ b/frontend/composables/useCommand.ts @@ -1,13 +1,209 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { Cause, Context, Effect, Option, flow, Match, S } from "effect-app" +import { + Cause, + Context, + Effect, + Option, + flow, + Match, + S, + pipe, +} from "effect-app" import type * as Result from "@effect-atom/atom/Result" import type { YieldWrap } from "effect/Utils" import { runFork } from "./client" import { asResult, reportRuntimeError } from "@effect-app/vue" import { reportMessage } from "@effect-app/vue/errorReporter" import { OperationFailure, OperationSuccess } from "effect-app/Operations" -import { SupportedErrors } from "effect-app/client" +import { InvalidStateError, SupportedErrors } from "effect-app/client" +import type { RT } from "./runtime" +// TODOS +// 1) rewrite withToast and errorReporter as combinators +// 2) proper Command definiton +// 3) various tests, here/on libs + +export class CommandContext extends Context.Tag("CommandContext")< + CommandContext, + { action: string } +>() {} + +namespace CommandDraft { + export interface CommandDraft< + Args extends ReadonlyArray, + // TODO: we may do not want to keep track of the original types of the handler + AHandler, + EHandler, + RHandler, + // TODO: we may do not want to keep track of the actual types of inner combinators + ICs extends (( + e: Effect.Effect, + ) => Effect.Effect)[], + // TODO: we may do not want to keep track of the actual types of outer combinators + OCs extends (( + e: Effect.Effect, + ) => Effect.Effect)[], + // we let the user add inner combinators until they add an outer combinator + // because mixing inner and outer combinators can lead to too complex/unsafe type relationships + mode extends "inner" | "outer" = "inner", + // we really just need to keep track of the last inner and outer combinators' params + $ALastIC = AHandler, + $ELastIC = EHandler, + $RLastIC = RHandler, + $ALastOC = $ALastIC, + $ELastOC = $ELastIC, + $RLastOC = Exclude<$RLastIC, CommandContext>, // provided by the in between provideService + > { + actionName: string + action: string + handlerE: (...args: Args) => Effect.Effect + innerCombinators: ICs + outerCombinators: OCs + } + + export function make< + Args extends ReadonlyArray, + AHandler, + EHandler, + RHandler, + ICs extends (( + e: Effect.Effect, + ) => Effect.Effect)[], + OCs extends (( + e: Effect.Effect, + ) => Effect.Effect)[], + >(cd: CommandDraft) { + return cd + } + + export function addInnerCombinator< + Args extends ReadonlyArray, + AHandler, + EHandler, + RHandler, + ICs extends (( + e: Effect.Effect, + ) => Effect.Effect)[], + OCs extends (( + e: Effect.Effect, + ) => Effect.Effect)[], + ALastIC, + ELastIC, + RLastIC, + ALastOC, + ELastOC, + RLastOC, + AIC, + EIC, + RIC, + >( + cd: CommandDraft< + Args, + AHandler, + EHandler, + RHandler, + ICs, + OCs, + "inner", + ALastIC, + ELastIC, + RLastIC, + ALastOC, + ELastOC, + RLastOC + >, + inner: ( + e: Effect.Effect, + ) => Effect.Effect, + ): CommandDraft< + Args, + AHandler, + EHandler, + RHandler, + ICs, + OCs, + "inner", + AIC, + EIC, + RIC, + AIC, + EIC, + Exclude // provided by the in between provideService + > { + return make({ + actionName: cd.actionName, + action: cd.action, + handlerE: cd.handlerE, + innerCombinators: [...cd.innerCombinators, inner] as any, + outerCombinators: cd.outerCombinators, + }) + } + + export function addOuterCombinator< + Args extends ReadonlyArray, + AHandler, + EHandler, + RHandler, + ICs extends (( + e: Effect.Effect, + ) => Effect.Effect)[], + OCs extends (( + e: Effect.Effect, + ) => Effect.Effect)[], + ALastIC, + ELastIC, + RLastIC, + ALastOC, + ELastOC, + RLastOC, + AOC, + EOC, + ROC, + >( + cd: CommandDraft< + Args, + AHandler, + EHandler, + RHandler, + ICs, + OCs, + "inner" | "outer", + ALastIC, + ELastIC, + RLastIC, + ALastOC, + ELastOC, + RLastOC + >, + outer: ( + e: Effect.Effect, + ) => Effect.Effect, + ): CommandDraft< + Args, + AHandler, + EHandler, + RHandler, + ICs, + OCs, + "outer", + ALastIC, + ELastIC, + RLastIC, + AOC, + EOC, + ROC + > { + return make({ + actionName: cd.actionName, + action: cd.action, + handlerE: cd.handlerE, + innerCombinators: cd.innerCombinators, + outerCombinators: [...cd.outerCombinators, outer] as any, + }) + } +} + +// TODO: wip export interface Command> { get: ComputedRef<{ action: string @@ -18,11 +214,6 @@ export interface Command> { set: (...args: Args) => void } -export class CommandContext extends Context.Tag("CommandContext")< - CommandContext, - { action: string } ->() {} - export const useCommand = () => { const withToast = useWithToast() const { intl } = useIntl() @@ -128,7 +319,7 @@ export const useCommand = () => { * - waiting: Whether the mutation is currently in progress. (shorthand for .result.waiting). Can be used e.g as Button loading/disabled state. * Reporting status to the user is recommended to use the `withDefaultToast` helper, or render the .result inline */ - fn: + fnOld: (actionName: string) => // TODO constrain/type combinators < @@ -216,5 +407,125 @@ export const useCommand = () => { ), ) }, + + fn: + (actionName: string) => + < + Args extends Array, + Eff extends YieldWrap>, + AEff, + $EEff = Eff extends YieldWrap> + ? E + : never, + $REff = Eff extends YieldWrap> + ? R + : never, + >( + handler: (...args: Args) => Generator, + ) => { + const action = intl.value.formatMessage({ + id: `action.${actionName}`, + defaultMessage: actionName, + }) + + const handlerE = Effect.fnUntraced(handler) as ( + ...args: Args + ) => Effect.Effect + + return CommandDraft.make({ + actionName, + action, + handlerE, + innerCombinators: [], + outerCombinators: [], + }) + }, + + build: , A, E, R extends RT>( + cd: CommandDraft.CommandDraft< + Args, + any, + any, + any, + any, + any, + "inner" | "outer", + any, + any, + any, + A, + E, + R + >, + ) => { + const context = { action: cd.action } + + const theHandler = pipe( + cd.handlerE, + ...(cd.innerCombinators as [any]), + Effect.provideService(CommandContext, context), + _ => + Effect.annotateCurrentSpan({ action: cd.action }).pipe( + Effect.zipRight(_), + ), + ...(cd.outerCombinators as [any]), + Effect.withSpan(cd.actionName), + ) as any as (...args: Args) => Effect.Effect + + const [result, mut] = asResult(theHandler) + + return computed(() => + Object.assign( + flow( + mut, + runFork, + _ => {}, + ) /* make sure always create a new one, or the state won't properly propagate */, + { + action: cd.action, + result, + waiting: result.value.waiting, + }, + ), + ) + }, } } + +class MyTag extends Context.Tag("MyTag")() {} + +const commandTest = CommandDraft.make({ + actionName: "actionName", + action: "action", + handlerE: Effect.fnUntraced(function* (str: string) { + yield* MyTag + yield* CommandContext + + if (str.length < 3) { + return yield* new InvalidStateError("too short") + } else { + return [str.length, str] as const + } + }), + innerCombinators: [], + outerCombinators: [], +}) + +const addInnerCombinatorTest1 = CommandDraft.addInnerCombinator( + commandTest, + x => + x.pipe( + Effect.catchTag("InvalidStateError", e => + Effect.succeed([-1 as number, e.message] as const), + ), + ), +) + +const addInnerCombinatorTest2 = CommandDraft.addInnerCombinator( + addInnerCombinatorTest1, + x => + x.pipe( + Effect.map(([f, s]) => f), + Effect.provideService(MyTag, { mytag: "test" }), + ), +) From fdc14a3e444f7ca0c876612cf0b8019b580fcb62 Mon Sep 17 00:00:00 2001 From: jfet97 Date: Tue, 2 Sep 2025 09:50:01 +0200 Subject: [PATCH 04/18] WIP --- frontend/composables/useCommand.ts | 36 ++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/frontend/composables/useCommand.ts b/frontend/composables/useCommand.ts index 84df825c9..dc63bdcd1 100644 --- a/frontend/composables/useCommand.ts +++ b/frontend/composables/useCommand.ts @@ -17,6 +17,7 @@ import { reportMessage } from "@effect-app/vue/errorReporter" import { OperationFailure, OperationSuccess } from "effect-app/Operations" import { InvalidStateError, SupportedErrors } from "effect-app/client" import type { RT } from "./runtime" +import type { Covariant } from "effect/Types" // TODOS // 1) rewrite withToast and errorReporter as combinators @@ -59,6 +60,14 @@ namespace CommandDraft { handlerE: (...args: Args) => Effect.Effect innerCombinators: ICs outerCombinators: OCs + + mode?: Covariant + ALastIC?: Covariant<$ALastIC> + ELastIC?: Covariant<$ELastIC> + RLastIC?: Covariant<$RLastIC> + ALastOC?: Covariant<$ALastOC> + ELastOC?: Covariant<$ELastOC> + RLastOC?: Covariant<$RLastOC> } export function make< @@ -136,7 +145,7 @@ namespace CommandDraft { handlerE: cd.handlerE, innerCombinators: [...cd.innerCombinators, inner] as any, outerCombinators: cd.outerCombinators, - }) + }) as any } export function addOuterCombinator< @@ -199,7 +208,7 @@ namespace CommandDraft { handlerE: cd.handlerE, innerCombinators: cd.innerCombinators, outerCombinators: [...cd.outerCombinators, outer] as any, - }) + }) as any } } @@ -529,3 +538,26 @@ const addInnerCombinatorTest2 = CommandDraft.addInnerCombinator( Effect.provideService(MyTag, { mytag: "test" }), ), ) + +const addOuterCombinatorTest1Fail = CommandDraft.addOuterCombinator( + addInnerCombinatorTest2, + x => + x.pipe( + Effect.andThen(n => + MyTag.pipe(Effect.andThen(service => service.mytag + n)), + ), + ), +) + +const addOuterCombinatorTest1Ok = CommandDraft.addOuterCombinator( + addInnerCombinatorTest2, + x => x.pipe(Effect.andThen(n => n * 10)), +) + +useCommand().build(addOuterCombinatorTest1Fail) +useCommand().build(addOuterCombinatorTest1Ok) + +const addInnerCombinatorTestFail = CommandDraft.addInnerCombinator( + addOuterCombinatorTest1Ok, + x => x, +) From fde50aa72273b928fe97f6b8f5f14944b9fe4ff7 Mon Sep 17 00:00:00 2001 From: jfet97 Date: Tue, 2 Sep 2025 14:04:04 +0200 Subject: [PATCH 05/18] Refactor Command namespace --- frontend/composables/useCommand.ts | 548 +++++++++++++---------------- 1 file changed, 242 insertions(+), 306 deletions(-) diff --git a/frontend/composables/useCommand.ts b/frontend/composables/useCommand.ts index dc63bdcd1..ef1cfffdc 100644 --- a/frontend/composables/useCommand.ts +++ b/frontend/composables/useCommand.ts @@ -19,8 +19,19 @@ import { InvalidStateError, SupportedErrors } from "effect-app/client" import type { RT } from "./runtime" import type { Covariant } from "effect/Types" +/** + * Define a Command + * @param actionName The internal name of the action. will be used as Span. will be used to lookup user facing name via intl. `action.${actionName}` + * @returns A function that can be called to execute the mutation, like directly in a `@click` handler. Error reporting is built-in. + * the Effects **only** have access to the `CommandContext` service, which contains the user-facing action name. + * The function also has the following properties: + * - action: The user-facing name of the action, as defined in the intl messages. Can be used e.g as Button label. + * - result: The Result of the mutation + * - waiting: Whether the mutation is currently in progress. (shorthand for .result.waiting). Can be used e.g as Button loading/disabled state. + * Reporting status to the user is recommended to use the `withDefaultToast` helper, or render the .result inline + */ + // TODOS -// 1) rewrite withToast and errorReporter as combinators // 2) proper Command definiton // 3) various tests, here/on libs @@ -29,45 +40,37 @@ export class CommandContext extends Context.Tag("CommandContext")< { action: string } >() {} -namespace CommandDraft { +namespace Command { export interface CommandDraft< Args extends ReadonlyArray, - // TODO: we may do not want to keep track of the original types of the handler - AHandler, - EHandler, - RHandler, - // TODO: we may do not want to keep track of the actual types of inner combinators - ICs extends (( - e: Effect.Effect, - ) => Effect.Effect)[], - // TODO: we may do not want to keep track of the actual types of outer combinators - OCs extends (( - e: Effect.Effect, - ) => Effect.Effect)[], + // we really just need to keep track of the last inner and outer combinators' params + ALastIC, + ELastIC, + RLastIC, + ALastOC = ALastIC, + ELastOC = ELastIC, + RLastOC = Exclude, // provided by the in between provideService // we let the user add inner combinators until they add an outer combinator // because mixing inner and outer combinators can lead to too complex/unsafe type relationships mode extends "inner" | "outer" = "inner", - // we really just need to keep track of the last inner and outer combinators' params - $ALastIC = AHandler, - $ELastIC = EHandler, - $RLastIC = RHandler, - $ALastOC = $ALastIC, - $ELastOC = $ELastIC, - $RLastOC = Exclude<$RLastIC, CommandContext>, // provided by the in between provideService > { actionName: string action: string - handlerE: (...args: Args) => Effect.Effect - innerCombinators: ICs - outerCombinators: OCs - + handlerE: (...args: Args) => Effect.Effect + innerCombinators: (( + e: Effect.Effect, + ) => Effect.Effect)[] + outerCombinators: (( + e: Effect.Effect, + ) => Effect.Effect)[] + + ALastIC?: Covariant + ELastIC?: Covariant + RLastIC?: Covariant + ALastOC?: Covariant + ELastOC?: Covariant + RLastOC?: Covariant mode?: Covariant - ALastIC?: Covariant<$ALastIC> - ELastIC?: Covariant<$ELastIC> - RLastIC?: Covariant<$RLastIC> - ALastOC?: Covariant<$ALastOC> - ELastOC?: Covariant<$ELastOC> - RLastOC?: Covariant<$RLastOC> } export function make< @@ -75,27 +78,18 @@ namespace CommandDraft { AHandler, EHandler, RHandler, - ICs extends (( - e: Effect.Effect, - ) => Effect.Effect)[], - OCs extends (( - e: Effect.Effect, - ) => Effect.Effect)[], - >(cd: CommandDraft) { + >( + cd: CommandDraft & { + // so that AHandler, EHandler, RHandler gets properly inferred + handlerE: (...args: Args) => Effect.Effect + }, + ): CommandDraft { return cd } - export function addInnerCombinator< + // add a new inner combinators which runs after the last inner combinator + export function withCombinator< Args extends ReadonlyArray, - AHandler, - EHandler, - RHandler, - ICs extends (( - e: Effect.Effect, - ) => Effect.Effect)[], - OCs extends (( - e: Effect.Effect, - ) => Effect.Effect)[], ALastIC, ELastIC, RLastIC, @@ -108,36 +102,26 @@ namespace CommandDraft { >( cd: CommandDraft< Args, - AHandler, - EHandler, - RHandler, - ICs, - OCs, - "inner", ALastIC, ELastIC, RLastIC, ALastOC, ELastOC, - RLastOC + RLastOC, + "inner" // <-- cannot add inner combinators after having added an outer combinator >, inner: ( e: Effect.Effect, ) => Effect.Effect, ): CommandDraft< Args, - AHandler, - EHandler, - RHandler, - ICs, - OCs, - "inner", AIC, EIC, RIC, AIC, EIC, - Exclude // provided by the in between provideService + Exclude, // provided by the in between provideService + "inner" > { return make({ actionName: cd.actionName, @@ -148,17 +132,10 @@ namespace CommandDraft { }) as any } - export function addOuterCombinator< + // will add a new outer combinator which runs after all the inner combinators and + // after the last outer combinator + export function withOuterCombinator< Args extends ReadonlyArray, - AHandler, - EHandler, - RHandler, - ICs extends (( - e: Effect.Effect, - ) => Effect.Effect)[], - OCs extends (( - e: Effect.Effect, - ) => Effect.Effect)[], ALastIC, ELastIC, RLastIC, @@ -171,37 +148,19 @@ namespace CommandDraft { >( cd: CommandDraft< Args, - AHandler, - EHandler, - RHandler, - ICs, - OCs, - "inner" | "outer", ALastIC, ELastIC, RLastIC, ALastOC, ELastOC, - RLastOC + RLastOC, + "inner" | "outer" // <-- whatever input is fine... >, outer: ( e: Effect.Effect, ) => Effect.Effect, - ): CommandDraft< - Args, - AHandler, - EHandler, - RHandler, - ICs, - OCs, - "outer", - ALastIC, - ELastIC, - RLastIC, - AOC, - EOC, - ROC - > { + // ...but "outer" mode is forced as output + ): CommandDraft { return make({ actionName: cd.actionName, action: cd.action, @@ -210,10 +169,67 @@ namespace CommandDraft { outerCombinators: [...cd.outerCombinators, outer] as any, }) as any } + + export function withErrorReporter< + Args extends ReadonlyArray, + ALastIC, + ELastIC, + RLastIC, + ALastOC, + ELastOC, + RLastOC, + >( + cd: CommandDraft< + Args, + ALastIC, + ELastIC, + RLastIC, + ALastOC, + ELastOC, + RLastOC, + "inner" | "outer" + >, + ) { + return withOuterCombinator(cd, self => + self.pipe( + Effect.catchAllCause( + Effect.fnUntraced(function* (cause) { + if (Cause.isInterruptedOnly(cause)) { + console.info(`Interrupted while trying to ${cd.actionName}`) + return + } + + const fail = Cause.failureOption(cause) + if (Option.isSome(fail)) { + // if (fail.value._tag === "SuppressErrors") { + // console.info( + // `Suppressed error trying to ${action}`, + // fail.value, + // ) + // return + // } + const message = `Failure trying to ${cd.actionName}` + yield* reportMessage(message, { + action: cd.actionName, + error: fail.value, + }) + return + } + + const extra = { + action: cd.action, + message: `Unexpected Error trying to ${cd.actionName}`, + } + yield* reportRuntimeError(cause, extra) + }), + ), + ), + ) + } } // TODO: wip -export interface Command> { +export interface CommandI> { get: ComputedRef<{ action: string result: Result.Result @@ -227,27 +243,99 @@ export const useCommand = () => { const withToast = useWithToast() const { intl } = useIntl() - return { - /** Version of confirmOrInterrupt that automatically includes the action name in the default messages */ - confirmOrInterrupt: Effect.fnUntraced(function* ( - message: string | undefined = undefined, - ) { - const context = yield* CommandContext - yield* confirmOrInterrupt( - message ?? - intl.value.formatMessage( - { id: "handle.confirmation" }, - { action: context.action }, - ), - ) - }), - /** Version of withDefaultToast that automatically includes the action name in the default messages and uses intl */ - withDefaultToast: ( - self: Effect.Effect, - errorRenderer?: (e: E) => string | undefined, // undefined falls back to default? - ) => - Effect.gen(function* () { - const { action } = yield* CommandContext + const fn = + (actionName: string) => + < + Args extends Array, + Eff extends YieldWrap>, + AEff, + $EEff = Eff extends YieldWrap> + ? E + : never, + $REff = Eff extends YieldWrap> + ? R + : never, + >( + handler: (...args: Args) => Generator, + ) => { + const action = intl.value.formatMessage({ + id: `action.${actionName}`, + defaultMessage: actionName, + }) + + const handlerE = Effect.fnUntraced(handler) as ( + ...args: Args + ) => Effect.Effect + + return Command.make({ + actionName, + action, + handlerE, + innerCombinators: [], + outerCombinators: [], + }) + } + + const build = , A, E, R extends RT>( + cd: Command.CommandDraft, + ) => { + const context = { action: cd.action } + + const theHandler = pipe( + cd.handlerE, + ...(cd.innerCombinators as [any]), + Effect.provideService(CommandContext, context), + _ => + Effect.annotateCurrentSpan({ action: cd.action }).pipe( + Effect.zipRight(_), + ), + ...(cd.outerCombinators as [any]), + Effect.withSpan(cd.actionName), + ) as any as (...args: Args) => Effect.Effect + + const [result, mut] = asResult(theHandler) + + return computed(() => + Object.assign( + flow( + mut, + runFork, + _ => {}, + ) /* make sure always create a new one, or the state won't properly propagate */, + { + action: cd.action, + result, + waiting: result.value.waiting, + }, + ), + ) + } + + const withDefaultToast = < + Args extends ReadonlyArray, + ALastIC, + ELastIC, + RLastIC, + ALastOC, + ELastOC, + RLastOC, + >( + cd: Command.CommandDraft< + Args, + ALastIC, + ELastIC, + RLastIC, + ALastOC, + ELastOC, + RLastOC, + "inner" + >, + errorRenderer?: (e: ELastIC) => string | undefined, // undefined falls back to default? + ) => { + return Command.withCombinator( + cd, + Effect.fn(function* (self) { + const { action } = cd const defaultWarnMessage = intl.value.formatMessage( { id: "handle.with_warnings" }, @@ -257,7 +345,7 @@ export const useCommand = () => { { id: "handle.with_errors" }, { action }, ) - function renderError(e: E): string { + function renderError(e: ELastIC): string { if (errorRenderer) { const m = errorRenderer(e) if (m) { @@ -317,193 +405,43 @@ export const useCommand = () => { }), ) }), - /** - * Define a Command - * @param actionName The internal name of the action. will be used as Span. will be used to lookup user facing name via intl. `action.${actionName}` - * @returns A function that can be called to execute the mutation, like directly in a `@click` handler. Error reporting is built-in. - * the Effects **only** have access to the `CommandContext` service, which contains the user-facing action name. - * The function also has the following properties: - * - action: The user-facing name of the action, as defined in the intl messages. Can be used e.g as Button label. - * - result: The Result of the mutation - * - waiting: Whether the mutation is currently in progress. (shorthand for .result.waiting). Can be used e.g as Button loading/disabled state. - * Reporting status to the user is recommended to use the `withDefaultToast` helper, or render the .result inline - */ - fnOld: - (actionName: string) => - // TODO constrain/type combinators - < - Eff extends YieldWrap>, - AEff, - Args extends Array, - $WrappedEffectError = Eff extends YieldWrap< - Effect.Effect - > - ? E - : never, - >( - fn: (...args: Args) => Generator, - // TODO: combinators can freely take A, E, R and change it to whatever they want, as long as the end result Requires not more than CommandContext | RT - ...combinators: (( - e: Effect.Effect, - ) => Effect.Effect)[] - ) => { - const action = intl.value.formatMessage({ - id: `action.${actionName}`, - defaultMessage: actionName, - }) - const context = { action } - - const errorReporter = (self: Effect.Effect) => - self.pipe( - Effect.catchAllCause( - Effect.fnUntraced(function* (cause) { - if (Cause.isInterruptedOnly(cause)) { - console.info(`Interrupted while trying to ${actionName}`) - return - } - - const fail = Cause.failureOption(cause) - if (Option.isSome(fail)) { - // if (fail.value._tag === "SuppressErrors") { - // console.info( - // `Suppressed error trying to ${action}`, - // fail.value, - // ) - // return - // } - const message = `Failure trying to ${actionName}` - yield* reportMessage(message, { - action: actionName, - error: fail.value, - }) - return - } - - const extra = { - action, - message: `Unexpected Error trying to ${actionName}`, - } - yield* reportRuntimeError(cause, extra) - }), - ), - ) + ) + } - // TODO: override span stack set by Effect.fn as it points here instead of to the caller of Command.fn. - // perhaps copying Effect.fn implementation is better than using it? - const handler = Effect.fn(actionName)( - fn, - ...(combinators as [any]), - // all must be within the Effect.fn to fit within the Span - Effect.provideService(CommandContext, context), - _ => Effect.annotateCurrentSpan({ action }).pipe(Effect.zipRight(_)), - errorReporter, - ) as (...args: Args) => Effect.Effect - - const [result, mut] = asResult(handler) - - return computed(() => - Object.assign( - flow( - mut, - runFork, - _ => {}, - ) /* make sure always create a new one, or the state won't properly propagate */, - { - action, - result, - waiting: result.value.waiting, - }, - ), - ) - }, - - fn: - (actionName: string) => - < - Args extends Array, - Eff extends YieldWrap>, - AEff, - $EEff = Eff extends YieldWrap> - ? E - : never, - $REff = Eff extends YieldWrap> - ? R - : never, - >( - handler: (...args: Args) => Generator, - ) => { - const action = intl.value.formatMessage({ - id: `action.${actionName}`, - defaultMessage: actionName, - }) - - const handlerE = Effect.fnUntraced(handler) as ( - ...args: Args - ) => Effect.Effect - - return CommandDraft.make({ - actionName, - action, - handlerE, - innerCombinators: [], - outerCombinators: [], - }) - }, - - build: , A, E, R extends RT>( - cd: CommandDraft.CommandDraft< - Args, - any, - any, - any, - any, - any, - "inner" | "outer", - any, - any, - any, - A, - E, - R - >, - ) => { - const context = { action: cd.action } - - const theHandler = pipe( - cd.handlerE, - ...(cd.innerCombinators as [any]), - Effect.provideService(CommandContext, context), - _ => - Effect.annotateCurrentSpan({ action: cd.action }).pipe( - Effect.zipRight(_), + return { + /** Version of confirmOrInterrupt that automatically includes the action name in the default messages */ + confirmOrInterrupt: Effect.fnUntraced(function* ( + message: string | undefined = undefined, + ) { + const context = yield* CommandContext + yield* confirmOrInterrupt( + message ?? + intl.value.formatMessage( + { id: "handle.confirmation" }, + { action: context.action }, ), - ...(cd.outerCombinators as [any]), - Effect.withSpan(cd.actionName), - ) as any as (...args: Args) => Effect.Effect - - const [result, mut] = asResult(theHandler) - - return computed(() => - Object.assign( - flow( - mut, - runFork, - _ => {}, - ) /* make sure always create a new one, or the state won't properly propagate */, - { - action: cd.action, - result, - waiting: result.value.waiting, - }, - ), ) + }), + /** Version of withDefaultToast that automatically includes the action name in the default messages and uses intl */ + withDefaultToast, + fn, + build, + buildWithErrorReporter: < + Args extends ReadonlyArray, + A, + E, + R extends RT, + >( + cd: Command.CommandDraft, + ) => { + return build(Command.withErrorReporter(cd)) }, } } class MyTag extends Context.Tag("MyTag")() {} -const commandTest = CommandDraft.make({ +const commandTest = Command.make({ actionName: "actionName", action: "action", handlerE: Effect.fnUntraced(function* (str: string) { @@ -520,17 +458,15 @@ const commandTest = CommandDraft.make({ outerCombinators: [], }) -const addInnerCombinatorTest1 = CommandDraft.addInnerCombinator( - commandTest, - x => - x.pipe( - Effect.catchTag("InvalidStateError", e => - Effect.succeed([-1 as number, e.message] as const), - ), +const addInnerCombinatorTest1 = Command.withCombinator(commandTest, x => + x.pipe( + Effect.catchTag("InvalidStateError", e => + Effect.succeed([-1 as number, e.message] as const), ), + ), ) -const addInnerCombinatorTest2 = CommandDraft.addInnerCombinator( +const addInnerCombinatorTest2 = Command.withCombinator( addInnerCombinatorTest1, x => x.pipe( @@ -539,7 +475,7 @@ const addInnerCombinatorTest2 = CommandDraft.addInnerCombinator( ), ) -const addOuterCombinatorTest1Fail = CommandDraft.addOuterCombinator( +const addOuterCombinatorTest1Fail = Command.withOuterCombinator( addInnerCombinatorTest2, x => x.pipe( @@ -549,7 +485,7 @@ const addOuterCombinatorTest1Fail = CommandDraft.addOuterCombinator( ), ) -const addOuterCombinatorTest1Ok = CommandDraft.addOuterCombinator( +const addOuterCombinatorTest1Ok = Command.withOuterCombinator( addInnerCombinatorTest2, x => x.pipe(Effect.andThen(n => n * 10)), ) @@ -557,7 +493,7 @@ const addOuterCombinatorTest1Ok = CommandDraft.addOuterCombinator( useCommand().build(addOuterCombinatorTest1Fail) useCommand().build(addOuterCombinatorTest1Ok) -const addInnerCombinatorTestFail = CommandDraft.addInnerCombinator( +const addInnerCombinatorTestFail = Command.withCombinator( addOuterCombinatorTest1Ok, x => x, ) From 7899b29c29e97a90d37a0de5e647d1cd143b8a22 Mon Sep 17 00:00:00 2001 From: jfet97 Date: Tue, 2 Sep 2025 14:28:20 +0200 Subject: [PATCH 06/18] wip --- frontend/composables/useCommand.ts | 242 +++++++++++++++++++++-------- 1 file changed, 176 insertions(+), 66 deletions(-) diff --git a/frontend/composables/useCommand.ts b/frontend/composables/useCommand.ts index ef1cfffdc..2645b67f8 100644 --- a/frontend/composables/useCommand.ts +++ b/frontend/composables/useCommand.ts @@ -18,6 +18,7 @@ import { OperationFailure, OperationSuccess } from "effect-app/Operations" import { InvalidStateError, SupportedErrors } from "effect-app/client" import type { RT } from "./runtime" import type { Covariant } from "effect/Types" +import { dual } from "effect/Function" /** * Define a Command @@ -88,87 +89,157 @@ namespace Command { } // add a new inner combinators which runs after the last inner combinator - export function withCombinator< - Args extends ReadonlyArray, - ALastIC, - ELastIC, - RLastIC, - ALastOC, - ELastOC, - RLastOC, - AIC, - EIC, - RIC, - >( - cd: CommandDraft< - Args, + export const withCombinator = dual< + < + Args extends ReadonlyArray, ALastIC, ELastIC, RLastIC, ALastOC, ELastOC, RLastOC, - "inner" // <-- cannot add inner combinators after having added an outer combinator + AIC, + EIC, + RIC, + >( + inner: ( + e: Effect.Effect, + ) => Effect.Effect, + ) => ( + cd: CommandDraft< + Args, + ALastIC, + ELastIC, + RLastIC, + ALastOC, + ELastOC, + RLastOC, + "inner" // <-- cannot add inner combinators after having added an outer combinator + >, + ) => CommandDraft< + Args, + AIC, + EIC, + RIC, + AIC, + EIC, + Exclude, // provided by the in between provideService + "inner" >, - inner: ( - e: Effect.Effect, - ) => Effect.Effect, - ): CommandDraft< - Args, - AIC, - EIC, - RIC, - AIC, - EIC, - Exclude, // provided by the in between provideService - "inner" - > { - return make({ + < + Args extends ReadonlyArray, + ALastIC, + ELastIC, + RLastIC, + ALastOC, + ELastOC, + RLastOC, + AIC, + EIC, + RIC, + >( + cd: CommandDraft< + Args, + ALastIC, + ELastIC, + RLastIC, + ALastOC, + ELastOC, + RLastOC, + "inner" // <-- cannot add inner combinators after having added an outer combinator + >, + inner: ( + e: Effect.Effect, + ) => Effect.Effect, + ) => CommandDraft< + Args, + AIC, + EIC, + RIC, + AIC, + EIC, + Exclude, // provided by the in between provideService + "inner" + > + >(2, (cd, inner) => + make({ actionName: cd.actionName, action: cd.action, handlerE: cd.handlerE, innerCombinators: [...cd.innerCombinators, inner] as any, outerCombinators: cd.outerCombinators, - }) as any - } + }), + ) // will add a new outer combinator which runs after all the inner combinators and // after the last outer combinator - export function withOuterCombinator< - Args extends ReadonlyArray, - ALastIC, - ELastIC, - RLastIC, - ALastOC, - ELastOC, - RLastOC, - AOC, - EOC, - ROC, - >( - cd: CommandDraft< - Args, + export const withOuterCombinator = dual< + < + Args extends ReadonlyArray, ALastIC, ELastIC, RLastIC, ALastOC, ELastOC, RLastOC, - "inner" | "outer" // <-- whatever input is fine... - >, - outer: ( - e: Effect.Effect, - ) => Effect.Effect, - // ...but "outer" mode is forced as output - ): CommandDraft { - return make({ - actionName: cd.actionName, - action: cd.action, - handlerE: cd.handlerE, - innerCombinators: cd.innerCombinators, - outerCombinators: [...cd.outerCombinators, outer] as any, - }) as any - } + AOC, + EOC, + ROC, + >( + outer: ( + e: Effect.Effect, + ) => Effect.Effect, + ) => ( + cd: CommandDraft< + Args, + ALastIC, + ELastIC, + RLastIC, + ALastOC, + ELastOC, + RLastOC, + "inner" | "outer" // <-- whatever input is fine... + >, + // ...but "outer" mode is forced as output + ) => CommandDraft, + < + Args extends ReadonlyArray, + ALastIC, + ELastIC, + RLastIC, + ALastOC, + ELastOC, + RLastOC, + AOC, + EOC, + ROC, + >( + cd: CommandDraft< + Args, + ALastIC, + ELastIC, + RLastIC, + ALastOC, + ELastOC, + RLastOC, + "inner" | "outer" // <-- whatever input is fine... + >, + outer: ( + e: Effect.Effect, + ) => Effect.Effect, + // ...but "outer" mode is forced as output + ) => CommandDraft + >( + 2, + (cd, outer) => + make({ + actionName: cd.actionName, + action: cd.action, + handlerE: cd.handlerE, + innerCombinators: cd.innerCombinators, + outerCombinators: [...cd.outerCombinators, outer] as any, + }) as any, + ) export function withErrorReporter< Args extends ReadonlyArray, @@ -490,10 +561,49 @@ const addOuterCombinatorTest1Ok = Command.withOuterCombinator( x => x.pipe(Effect.andThen(n => n * 10)), ) -useCommand().build(addOuterCombinatorTest1Fail) -useCommand().build(addOuterCombinatorTest1Ok) - -const addInnerCombinatorTestFail = Command.withCombinator( - addOuterCombinatorTest1Ok, - x => x, +// useCommand().build(addOuterCombinatorTest1Fail) +// useCommand().build(addOuterCombinatorTest1Ok) + +// const addInnerCombinatorTestFail = Command.withCombinator( +// addOuterCombinatorTest1Ok, +// x => x, +// ) + +const pipeTest = pipe( + Command.make({ + actionName: "actionName", + action: "action", + handlerE: Effect.fnUntraced(function* (str: string) { + yield* MyTag + yield* CommandContext + + if (str.length < 3) { + return yield* new InvalidStateError("too short") + } else { + return [str.length, str] as const + } + }), + innerCombinators: [], + outerCombinators: [], + }), + Command.withCombinator(self => + self.pipe( + Effect.catchTag("InvalidStateError", e => + Effect.succeed([-1 as number, e.message] as const), + ), + ), + ), + Command.withCombinator(self => + self.pipe( + Effect.map(([f]) => f), + Effect.provideService(MyTag, { mytag: "test" }), + ), + ), + Command.withOuterCombinator(self => + self.pipe( + Effect.andThen(n => + MyTag.pipe(Effect.andThen(service => service.mytag + n)), + ), + ), + ), ) From 4f3cff5bdf0b8beccba467a55fa0e63eeb273186 Mon Sep 17 00:00:00 2001 From: jfet97 Date: Tue, 2 Sep 2025 14:35:50 +0200 Subject: [PATCH 07/18] add test --- frontend/composables/useCommand.ts | 118 +++++++++++++---------------- 1 file changed, 53 insertions(+), 65 deletions(-) diff --git a/frontend/composables/useCommand.ts b/frontend/composables/useCommand.ts index 2645b67f8..7b5f1ccb9 100644 --- a/frontend/composables/useCommand.ts +++ b/frontend/composables/useCommand.ts @@ -41,7 +41,7 @@ export class CommandContext extends Context.Tag("CommandContext")< { action: string } >() {} -namespace Command { +namespace CommandDraft { export interface CommandDraft< Args extends ReadonlyArray, // we really just need to keep track of the last inner and outer combinators' params @@ -74,7 +74,7 @@ namespace Command { mode?: Covariant } - export function make< + export const make = < Args extends ReadonlyArray, AHandler, EHandler, @@ -84,7 +84,7 @@ namespace Command { // so that AHandler, EHandler, RHandler gets properly inferred handlerE: (...args: Args) => Effect.Effect }, - ): CommandDraft { + ): CommandDraft => { return cd } @@ -241,7 +241,7 @@ namespace Command { }) as any, ) - export function withErrorReporter< + export const withErrorReporter = < Args extends ReadonlyArray, ALastIC, ELastIC, @@ -260,7 +260,7 @@ namespace Command { RLastOC, "inner" | "outer" >, - ) { + ) => { return withOuterCombinator(cd, self => self.pipe( Effect.catchAllCause( @@ -338,7 +338,7 @@ export const useCommand = () => { ...args: Args ) => Effect.Effect - return Command.make({ + return CommandDraft.make({ actionName, action, handlerE, @@ -348,7 +348,16 @@ export const useCommand = () => { } const build = , A, E, R extends RT>( - cd: Command.CommandDraft, + cd: CommandDraft.CommandDraft< + Args, + any, + any, + any, + A, + E, + R, + "inner" | "outer" + >, ) => { const context = { action: cd.action } @@ -391,7 +400,7 @@ export const useCommand = () => { ELastOC, RLastOC, >( - cd: Command.CommandDraft< + cd: CommandDraft.CommandDraft< Args, ALastIC, ELastIC, @@ -403,7 +412,7 @@ export const useCommand = () => { >, errorRenderer?: (e: ELastIC) => string | undefined, // undefined falls back to default? ) => { - return Command.withCombinator( + return CommandDraft.withCombinator( cd, Effect.fn(function* (self) { const { action } = cd @@ -503,64 +512,24 @@ export const useCommand = () => { E, R extends RT, >( - cd: Command.CommandDraft, + cd: CommandDraft.CommandDraft< + Args, + any, + any, + any, + A, + E, + R, + "inner" | "outer" + >, ) => { - return build(Command.withErrorReporter(cd)) + return build(CommandDraft.withErrorReporter(cd)) }, } } class MyTag extends Context.Tag("MyTag")() {} -const commandTest = Command.make({ - actionName: "actionName", - action: "action", - handlerE: Effect.fnUntraced(function* (str: string) { - yield* MyTag - yield* CommandContext - - if (str.length < 3) { - return yield* new InvalidStateError("too short") - } else { - return [str.length, str] as const - } - }), - innerCombinators: [], - outerCombinators: [], -}) - -const addInnerCombinatorTest1 = Command.withCombinator(commandTest, x => - x.pipe( - Effect.catchTag("InvalidStateError", e => - Effect.succeed([-1 as number, e.message] as const), - ), - ), -) - -const addInnerCombinatorTest2 = Command.withCombinator( - addInnerCombinatorTest1, - x => - x.pipe( - Effect.map(([f, s]) => f), - Effect.provideService(MyTag, { mytag: "test" }), - ), -) - -const addOuterCombinatorTest1Fail = Command.withOuterCombinator( - addInnerCombinatorTest2, - x => - x.pipe( - Effect.andThen(n => - MyTag.pipe(Effect.andThen(service => service.mytag + n)), - ), - ), -) - -const addOuterCombinatorTest1Ok = Command.withOuterCombinator( - addInnerCombinatorTest2, - x => x.pipe(Effect.andThen(n => n * 10)), -) - // useCommand().build(addOuterCombinatorTest1Fail) // useCommand().build(addOuterCombinatorTest1Ok) @@ -569,8 +538,10 @@ const addOuterCombinatorTest1Ok = Command.withOuterCombinator( // x => x, // ) +const cmd = useCommand() + const pipeTest = pipe( - Command.make({ + CommandDraft.make({ actionName: "actionName", action: "action", handlerE: Effect.fnUntraced(function* (str: string) { @@ -586,24 +557,41 @@ const pipeTest = pipe( innerCombinators: [], outerCombinators: [], }), - Command.withCombinator(self => + CommandDraft.withCombinator(self => self.pipe( Effect.catchTag("InvalidStateError", e => Effect.succeed([-1 as number, e.message] as const), ), ), ), - Command.withCombinator(self => + CommandDraft.withCombinator(self => self.pipe( Effect.map(([f]) => f), - Effect.provideService(MyTag, { mytag: "test" }), + Effect.provideService(MyTag, { mytag: "inner" }), ), ), - Command.withOuterCombinator(self => + CommandDraft.withOuterCombinator(self => self.pipe( Effect.andThen(n => MyTag.pipe(Effect.andThen(service => service.mytag + n)), ), ), ), + // fail because you cannot add inner combinators after outer combinators + // + // Command.withCombinator(self => + // self.pipe( + // Effect.map(([f]) => f), + // Effect.provideService(MyTag, { mytag: "test" }), + // ), + // ), + CommandDraft.withErrorReporter, + // + // fail because MyTag has not been provided + // cmd.build, + // + CommandDraft.withOuterCombinator(self => + self.pipe(Effect.provideService(MyTag, { mytag: "outern" })), + ), + cmd.build, ) From c515da088577684a6a78408cab5d9e6f7c62c2bf Mon Sep 17 00:00:00 2001 From: jfet97 Date: Tue, 2 Sep 2025 14:46:56 +0200 Subject: [PATCH 08/18] move build and buildWithoutErrorReporter functions to CommandDraft --- frontend/composables/useCommand.ts | 143 +++++++++++++++-------------- 1 file changed, 75 insertions(+), 68 deletions(-) diff --git a/frontend/composables/useCommand.ts b/frontend/composables/useCommand.ts index 7b5f1ccb9..d0d3ac56a 100644 --- a/frontend/composables/useCommand.ts +++ b/frontend/composables/useCommand.ts @@ -297,6 +297,79 @@ namespace CommandDraft { ), ) } + + export const buildWithoutErrorReporter = < + Args extends ReadonlyArray, + ALastIC, + ELastIC, + RLastIC, + ALastOC, + ELastOC, + RLastOC extends RT, // no other dependencies are allowed + >( + cd: CommandDraft< + Args, + ALastIC, + ELastIC, + RLastIC, + ALastOC, + ELastOC, + RLastOC, + "inner" | "outer" // <-- both can be built + >, + ) => { + const context = { action: cd.action } + + const theHandler = pipe( + cd.handlerE, + ...(cd.innerCombinators as [any]), + Effect.provideService(CommandContext, context), + _ => + Effect.annotateCurrentSpan({ action: cd.action }).pipe( + Effect.zipRight(_), + ), + ...(cd.outerCombinators as [any]), + Effect.withSpan(cd.actionName), + ) as any as (...args: Args) => Effect.Effect + + const [result, mut] = asResult(theHandler) + + return computed(() => + Object.assign( + flow( + mut, + runFork, + _ => {}, + ) /* make sure always create a new one, or the state won't properly propagate */, + { + action: cd.action, + result, + waiting: result.value.waiting, + }, + ), + ) + } + + export const build = < + Args extends ReadonlyArray, + ALastIC, + ELastIC, + RLastIC, + ALastOC, + ELastOC, + RLastOC extends RT, // no other dependencies are allowed + >( + cd: CommandDraft< + Args, + ALastIC, + ELastIC, + RLastIC, + ALastOC, + ELastOC, + RLastOC, + "inner" | "outer" // <-- both can be built + >, + ) => pipe(cd, withErrorReporter, buildWithoutErrorReporter) } // TODO: wip @@ -347,50 +420,6 @@ export const useCommand = () => { }) } - const build = , A, E, R extends RT>( - cd: CommandDraft.CommandDraft< - Args, - any, - any, - any, - A, - E, - R, - "inner" | "outer" - >, - ) => { - const context = { action: cd.action } - - const theHandler = pipe( - cd.handlerE, - ...(cd.innerCombinators as [any]), - Effect.provideService(CommandContext, context), - _ => - Effect.annotateCurrentSpan({ action: cd.action }).pipe( - Effect.zipRight(_), - ), - ...(cd.outerCombinators as [any]), - Effect.withSpan(cd.actionName), - ) as any as (...args: Args) => Effect.Effect - - const [result, mut] = asResult(theHandler) - - return computed(() => - Object.assign( - flow( - mut, - runFork, - _ => {}, - ) /* make sure always create a new one, or the state won't properly propagate */, - { - action: cd.action, - result, - waiting: result.value.waiting, - }, - ), - ) - } - const withDefaultToast = < Args extends ReadonlyArray, ALastIC, @@ -505,26 +534,6 @@ export const useCommand = () => { /** Version of withDefaultToast that automatically includes the action name in the default messages and uses intl */ withDefaultToast, fn, - build, - buildWithErrorReporter: < - Args extends ReadonlyArray, - A, - E, - R extends RT, - >( - cd: CommandDraft.CommandDraft< - Args, - any, - any, - any, - A, - E, - R, - "inner" | "outer" - >, - ) => { - return build(CommandDraft.withErrorReporter(cd)) - }, } } @@ -538,8 +547,6 @@ class MyTag extends Context.Tag("MyTag")() {} // x => x, // ) -const cmd = useCommand() - const pipeTest = pipe( CommandDraft.make({ actionName: "actionName", @@ -588,10 +595,10 @@ const pipeTest = pipe( CommandDraft.withErrorReporter, // // fail because MyTag has not been provided - // cmd.build, + // CommandDraft.build, // CommandDraft.withOuterCombinator(self => self.pipe(Effect.provideService(MyTag, { mytag: "outern" })), ), - cmd.build, + CommandDraft.build, ) From 773b903bf6dc735d48945291b756c834c3db7e26 Mon Sep 17 00:00:00 2001 From: jfet97 Date: Tue, 2 Sep 2025 14:51:48 +0200 Subject: [PATCH 09/18] fup --- frontend/composables/useCommand.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/composables/useCommand.ts b/frontend/composables/useCommand.ts index d0d3ac56a..f6672e0d3 100644 --- a/frontend/composables/useCommand.ts +++ b/frontend/composables/useCommand.ts @@ -298,7 +298,7 @@ namespace CommandDraft { ) } - export const buildWithoutErrorReporter = < + export const buildWithoutDefaultErrorReporter = < Args extends ReadonlyArray, ALastIC, ELastIC, @@ -369,7 +369,7 @@ namespace CommandDraft { RLastOC, "inner" | "outer" // <-- both can be built >, - ) => pipe(cd, withErrorReporter, buildWithoutErrorReporter) + ) => pipe(cd, withErrorReporter, buildWithoutDefaultErrorReporter) } // TODO: wip @@ -551,7 +551,7 @@ const pipeTest = pipe( CommandDraft.make({ actionName: "actionName", action: "action", - handlerE: Effect.fnUntraced(function* (str: string) { + handlerE: Effect.fnUntraced(function* ({ some: str }: { some: string }) { yield* MyTag yield* CommandContext @@ -600,5 +600,5 @@ const pipeTest = pipe( CommandDraft.withOuterCombinator(self => self.pipe(Effect.provideService(MyTag, { mytag: "outern" })), ), - CommandDraft.build, + CommandDraft.buildWithoutDefaultErrorReporter, ) From 4e05e6cb22a4aca0a95872e387dc92409185d3dc Mon Sep 17 00:00:00 2001 From: jfet97 Date: Tue, 2 Sep 2025 14:53:19 +0200 Subject: [PATCH 10/18] fup --- frontend/composables/useCommand.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/composables/useCommand.ts b/frontend/composables/useCommand.ts index f6672e0d3..0a50b478c 100644 --- a/frontend/composables/useCommand.ts +++ b/frontend/composables/useCommand.ts @@ -580,7 +580,7 @@ const pipeTest = pipe( CommandDraft.withOuterCombinator(self => self.pipe( Effect.andThen(n => - MyTag.pipe(Effect.andThen(service => service.mytag + n)), + MyTag.pipe(Effect.andThen(service => ({ tag: service.mytag, n }))), ), ), ), @@ -598,7 +598,7 @@ const pipeTest = pipe( // CommandDraft.build, // CommandDraft.withOuterCombinator(self => - self.pipe(Effect.provideService(MyTag, { mytag: "outern" })), + self.pipe(Effect.provideService(MyTag, { mytag: "outer" })), ), CommandDraft.buildWithoutDefaultErrorReporter, ) From 1983c1c3d4fd5136d301c92bcfff8f815161c384 Mon Sep 17 00:00:00 2001 From: jfet97 Date: Tue, 2 Sep 2025 15:01:26 +0200 Subject: [PATCH 11/18] fup --- frontend/composables/useCommand.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/frontend/composables/useCommand.ts b/frontend/composables/useCommand.ts index 0a50b478c..1ef66e27e 100644 --- a/frontend/composables/useCommand.ts +++ b/frontend/composables/useCommand.ts @@ -10,7 +10,6 @@ import { pipe, } from "effect-app" import type * as Result from "@effect-atom/atom/Result" -import type { YieldWrap } from "effect/Utils" import { runFork } from "./client" import { asResult, reportRuntimeError } from "@effect-app/vue" import { reportMessage } from "@effect-app/vue/errorReporter" @@ -19,6 +18,7 @@ import { InvalidStateError, SupportedErrors } from "effect-app/client" import type { RT } from "./runtime" import type { Covariant } from "effect/Types" import { dual } from "effect/Function" +import type { YieldWrap } from "effect/Utils" /** * Define a Command @@ -387,11 +387,14 @@ export const useCommand = () => { const withToast = useWithToast() const { intl } = useIntl() + // fn and withDefaultToast depend on intl and withToast + // so I keep their definitions here + const fn = (actionName: string) => < Args extends Array, - Eff extends YieldWrap>, + Eff extends YieldWrap>, AEff, $EEff = Eff extends YieldWrap> ? E @@ -400,7 +403,7 @@ export const useCommand = () => { ? R : never, >( - handler: (...args: Args) => Generator, + handler: (...args: Args) => Generator, ) => { const action = intl.value.formatMessage({ id: `action.${actionName}`, @@ -538,6 +541,7 @@ export const useCommand = () => { } class MyTag extends Context.Tag("MyTag")() {} +class MyTag2 extends Context.Tag("MyTag2")() {} // useCommand().build(addOuterCombinatorTest1Fail) // useCommand().build(addOuterCombinatorTest1Ok) @@ -555,6 +559,9 @@ const pipeTest = pipe( yield* MyTag yield* CommandContext + // won't build at the end of the pipeline because it is not provided + // yield* MyTag2 + if (str.length < 3) { return yield* new InvalidStateError("too short") } else { From 8f9ff2b0b4897ffd77a79b36b0798b83e294d8fa Mon Sep 17 00:00:00 2001 From: jfet97 Date: Tue, 2 Sep 2025 15:27:55 +0200 Subject: [PATCH 12/18] add another test --- frontend/composables/useCommand.ts | 192 +++++++++++++++++------------ 1 file changed, 113 insertions(+), 79 deletions(-) diff --git a/frontend/composables/useCommand.ts b/frontend/composables/useCommand.ts index 1ef66e27e..df78931e5 100644 --- a/frontend/composables/useCommand.ts +++ b/frontend/composables/useCommand.ts @@ -432,92 +432,98 @@ export const useCommand = () => { ELastOC, RLastOC, >( - cd: CommandDraft.CommandDraft< - Args, - ALastIC, - ELastIC, - RLastIC, - ALastOC, - ELastOC, - RLastOC, - "inner" - >, errorRenderer?: (e: ELastIC) => string | undefined, // undefined falls back to default? ) => { - return CommandDraft.withCombinator( - cd, - Effect.fn(function* (self) { - const { action } = cd - - const defaultWarnMessage = intl.value.formatMessage( - { id: "handle.with_warnings" }, - { action }, - ) - const defaultErrorMessage = intl.value.formatMessage( - { id: "handle.with_errors" }, - { action }, - ) - function renderError(e: ELastIC): string { - if (errorRenderer) { - const m = errorRenderer(e) - if (m) { - return m - } - } - if (!S.is(SupportedErrors)(e) && !S.ParseResult.isParseError(e)) { - if (typeof e === "object" && e !== null) { - if ("message" in e) { - return `${e.message}` + return ( + cd: CommandDraft.CommandDraft< + Args, + ALastIC, + ELastIC, + RLastIC, + ALastOC, + ELastOC, + RLastOC, + "inner" + >, + ) => + CommandDraft.withCombinator( + cd, + Effect.fn(function* (self) { + const { action } = cd + + const defaultWarnMessage = intl.value.formatMessage( + { id: "handle.with_warnings" }, + { action }, + ) + const defaultErrorMessage = intl.value.formatMessage( + { id: "handle.with_errors" }, + { action }, + ) + function renderError(e: ELastIC): string { + if (errorRenderer) { + const m = errorRenderer(e) + if (m) { + return m } - if ("_tag" in e) { - return `${e._tag}` + } + if (!S.is(SupportedErrors)(e) && !S.ParseResult.isParseError(e)) { + if (typeof e === "object" && e !== null) { + if ("message" in e) { + return `${e.message}` + } + if ("_tag" in e) { + return `${e._tag}` + } } + return "" } - return "" + const e2: SupportedErrors | S.ParseResult.ParseError = e + return Match.value(e2).pipe( + Match.tags({ + ParseError: e => { + console.warn(e.toString()) + return intl.value.formatMessage({ id: "validation.failed" }) + }, + }), + Match.orElse(e => `${e.message ?? e._tag ?? e}`), + ) } - const e2: SupportedErrors | S.ParseResult.ParseError = e - return Match.value(e2).pipe( - Match.tags({ - ParseError: e => { - console.warn(e.toString()) - return intl.value.formatMessage({ id: "validation.failed" }) - }, + + return yield* self.pipe( + withToast({ + onWaiting: intl.value.formatMessage( + { id: "handle.waiting" }, + { action }, + ), + onSuccess: a => + intl.value.formatMessage({ id: "handle.success" }, { action }) + + (S.is(OperationSuccess)(a) && a.message + ? "\n" + a.message + : ""), + onFailure: Option.match({ + onNone: () => + intl.value.formatMessage( + { id: "handle.unexpected_error" }, + { + action, + error: "-", // TODO consider again Cause.pretty(cause), // will be reported to Sentry/Otel anyway.. + }, + ), + onSome: e => + S.is(OperationFailure)(e) + ? { + level: "warn", + message: + defaultWarnMessage + e.message + ? "\n" + e.message + : "", + } + : `${defaultErrorMessage}:\n` + renderError(e), + }), }), - Match.orElse(e => `${e.message ?? e._tag ?? e}`), ) - } - - return yield* self.pipe( - withToast({ - onWaiting: intl.value.formatMessage( - { id: "handle.waiting" }, - { action }, - ), - onSuccess: a => - intl.value.formatMessage({ id: "handle.success" }, { action }) + - (S.is(OperationSuccess)(a) && a.message ? "\n" + a.message : ""), - onFailure: Option.match({ - onNone: () => - intl.value.formatMessage( - { id: "handle.unexpected_error" }, - { - action, - error: "-", // TODO consider again Cause.pretty(cause), // will be reported to Sentry/Otel anyway.. - }, - ), - onSome: e => - S.is(OperationFailure)(e) - ? { - level: "warn", - message: - defaultWarnMessage + e.message ? "\n" + e.message : "", - } - : `${defaultErrorMessage}:\n` + renderError(e), - }), - }), - ) - }), - ) + }), + ) } return { @@ -551,7 +557,7 @@ class MyTag2 extends Context.Tag("MyTag2")() {} // x => x, // ) -const pipeTest = pipe( +const pipeTest1 = pipe( CommandDraft.make({ actionName: "actionName", action: "action", @@ -609,3 +615,31 @@ const pipeTest = pipe( ), CommandDraft.buildWithoutDefaultErrorReporter, ) + +const Cmd = useCommand() + +const pipeTest2 = pipe( + Cmd.fn("actionName")(function* ({ some: str }: { some: string }) { + yield* MyTag + yield* CommandContext + + // won't build at the end of the pipeline because it is not provided + // yield* MyTag2 + + if (str.length < 3) { + return yield* new InvalidStateError("too short") + } else { + return [str.length, str] as const + } + }), + CommandDraft.withCombinator(self => + self.pipe( + Effect.provideService(MyTag, { mytag: "inner" }), + Effect.catchTag("InvalidStateError", e => + Effect.succeed([-1 as number, e.message] as const), + ), + ), + ), + Cmd.withDefaultToast(), + CommandDraft.build, +) From 88a881cc4950e97c57aa1da87a32d91cbd1c772c Mon Sep 17 00:00:00 2001 From: jfet97 Date: Tue, 2 Sep 2025 15:56:16 +0200 Subject: [PATCH 13/18] refactor: update CommandDraft usage and improve Command definition --- frontend/composables/useCommand.ts | 6 ++---- frontend/pages/index.vue | 30 +++++++++++++++--------------- 2 files changed, 17 insertions(+), 19 deletions(-) diff --git a/frontend/composables/useCommand.ts b/frontend/composables/useCommand.ts index df78931e5..f3a8cb8a7 100644 --- a/frontend/composables/useCommand.ts +++ b/frontend/composables/useCommand.ts @@ -33,15 +33,14 @@ import type { YieldWrap } from "effect/Utils" */ // TODOS -// 2) proper Command definiton -// 3) various tests, here/on libs +// 2) proper Command definiton instead of nested refs merged with updater fn export class CommandContext extends Context.Tag("CommandContext")< CommandContext, { action: string } >() {} -namespace CommandDraft { +export namespace CommandDraft { export interface CommandDraft< Args extends ReadonlyArray, // we really just need to keep track of the last inner and outer combinators' params @@ -379,7 +378,6 @@ export interface CommandI> { result: Result.Result waiting: boolean }> - handler: (...a: Args) => Effect.Effect set: (...args: Args) => void } diff --git a/frontend/pages/index.vue b/frontend/pages/index.vue index bdf86e012..3e5a9fe17 100644 --- a/frontend/pages/index.vue +++ b/frontend/pages/index.vue @@ -1,5 +1,5 @@