diff --git a/.envrc b/.envrc
new file mode 100644
index 00000000..8392d159
--- /dev/null
+++ b/.envrc
@@ -0,0 +1 @@
+use flake
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index f43d7bae..eed1a8ca 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 00000000..e19c48ae
--- /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 00000000..79917387
--- /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 29ae56a1..6f46cfe5 100644
--- a/frontend/composables/useCommand.ts
+++ b/frontend/composables/useCommand.ts
@@ -1,23 +1,796 @@
+/* eslint-disable unused-imports/no-unused-vars */
/* eslint-disable @typescript-eslint/no-explicit-any */
-import { Cause, Context, Effect, Option, flow, Match, S } from "effect-app"
-import type { YieldWrap } from "effect/Utils"
+import {
+ Cause,
+ Context,
+ Effect,
+ Option,
+ flow,
+ Match,
+ S,
+ pipe,
+} from "effect-app"
+import type * as Result from "@effect-atom/atom/Result"
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"
+import type { Covariant } from "effect/Types"
+import { dual } from "effect/Function"
+import type { YieldWrap } from "effect/Utils"
+
+/**
+ * Command system for building type-safe, composable mutation handlers with built-in error reporting and state management.
+ *
+ * This module provides a fluent API for creating commands that can be executed directly in click handlers.
+ * Commands support inner and outer combinators for customizing behavior, automatic error reporting,
+ * toast notifications, and state tracking.
+ *
+ * @example
+ * ```ts
+ * const cmd = useCommand()
+ *
+ * const deleteUser = pipe(
+ * cmd.fn("deleteUser")(function* (userId: string) {
+ * return yield* userService.delete(userId)
+ * }),
+ * cmd.withDefaultToast(),
+ * CommandDraft.build
+ * )
+ *
+ * // Usage in component
+ *
+ * ```
+ */
+/**
+ * Context service that provides the user-facing action name to command effects.
+ * This context is automatically provided during command execution and contains
+ * the internationalized action name.
+ *
+ * @example
+ * ```ts
+ * function* myCommandEffect() {
+ * const { action } = yield* CommandContext
+ * console.log(`Executing ${action}`)
+ * }
+ * ```
+ */
export class CommandContext extends Context.Tag("CommandContext")<
CommandContext,
{ action: string }
>() {}
+/**
+ * Represents a command in draft state that can be composed with combinators.
+ *
+ * @template Args - The arguments array type for the command handler
+ * @template ALastIC - Return type of the last inner combinator
+ * @template ELastIC - Error type of the last inner combinator
+ * @template RLastIC - Requirements type of the last inner combinator
+ * @template ALastOC - Return type of the last outer combinator
+ * @template ELastOC - Error type of the last outer combinator
+ * @template RLastOC - Requirements type of the last outer combinator
+ * @template mode - Whether the draft is in "inner" or "outer" combinator mode
+ */
+export interface CommandDraft<
+ Args extends ReadonlyArray,
+ // 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",
+> {
+ actionName: string
+ action: string
+ 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
+}
+
+/**
+ * Namespace containing the command draft system for building composable commands.
+ *
+ * The CommandDraft system provides a type-safe, fluent API for building commands
+ * with inner and outer combinators. Commands are built in stages:
+ * 1. Create initial draft with handler
+ * 2. Add inner combinators (run inside the command context)
+ * 3. Add outer combinators (run outside the command context)
+ * 4. Build final command
+ */
+export namespace CommandDraft {}
+
+// TODOS
+// 2) proper Command definiton instead of nested refs merged with updater fn
+export interface CommandI> {
+ get: ComputedRef<{
+ action: string
+ result: Result.Result
+ waiting: boolean
+ }>
+ set: (...args: Args) => void
+}
+
+/**
+ * Composable that provides command creation utilities with internationalization and toast integration.
+ *
+ * Returns an object containing:
+ * - fn: Creates a new command draft from an action name and handler
+ * - withDefaultToast: Adds automatic toast notifications to commands
+ * - confirmOrInterrupt: Utility for confirmation dialogs within commands
+ *
+ * The returned utilities automatically integrate with the application's i18n system
+ * and toast notification system.
+ *
+ * @returns Command creation utilities with i18n and toast integration
+ *
+ * @example
+ * ```ts
+ * const cmd = useCommand()
+ *
+ * const saveData = pipe(
+ * cmd.fn("saveData")(function* (data: SaveRequest) {
+ * return yield* dataService.save(data)
+ * }),
+ * cmd.withDefaultToast(),
+ * CommandDraft.build
+ * )
+ * ```
+ */
export const useCommand = () => {
const withToast = useWithToast()
const { intl } = useIntl()
+ /**
+ * Creates a properly typed `CommandDraft` from the provided configuration.
+ * This function primarily serves to ensure proper type inference.
+ *
+ * @template Args - The arguments array type for the command handler
+ * @template AHandler - Return type of the handler effect
+ * @template EHandler - Error type of the handler effect
+ * @template RHandler - Requirements type of the handler effect
+ * @param cd - The command draft configuration
+ * @returns A properly typed `CommandDraft`
+ */
+ const make = , AHandler, EHandler, RHandler>(
+ cd: CommandDraft & {
+ // so that AHandler, EHandler, RHandler gets properly inferred
+ handlerE: (...args: Args) => Effect.Effect
+ },
+ ): CommandDraft => {
+ return cd
+ }
+
+ /**
+ * Adds an inner combinator to the command draft. Inner combinators run after the handler
+ * but before outer combinators, and have access to the CommandContext service.
+ *
+ * Inner combinators are executed in FIFO order - the last added combinator runs last.
+ * Inner combinators cannot be added after outer combinators have been added.
+ *
+ * @param inner - The inner combinator function that transforms the effect
+ * @param cd - The command draft in "inner" mode
+ * @returns A new command draft with the inner combinator added
+ *
+ * @example
+ * ```ts
+ * const draft = pipe(
+ * myDraft,
+ * CommandDraft.withCombinator(effect =>
+ * effect.pipe(Effect.map(result => result.toUpperCase()))
+ * )
+ * )
+ * ```
+ */
+ const withCombinator = dual<
+ <
+ Args extends ReadonlyArray,
+ ALastIC,
+ ELastIC,
+ RLastIC,
+ ALastOC,
+ ELastOC,
+ RLastOC,
+ 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"
+ >,
+ <
+ 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,
+ }),
+ )
+
+ /**
+ * Adds an outer combinator to the command draft. Outer combinators run after all inner
+ * combinators and do not have access to the CommandContext service. They share the main
+ * span but not the inner annotations (see `build` function for execution order).
+ *
+ * Outer combinators are executed in FIFO order - the last added combinator runs last.
+ * Once an outer combinator is added, the draft switches to "outer" mode and no more
+ * inner combinators can be added.
+ *
+ * @param outer - The outer combinator function that transforms the effect
+ * @param cd - The command draft in "inner" or "outer" mode
+ * @returns A new command draft in "outer" mode with the outer combinator added
+ *
+ * @example
+ * ```ts
+ * const draft = pipe(
+ * myDraft,
+ * CommandDraft.withOuterCombinator(effect =>
+ * effect.pipe(Effect.timeout(5000))
+ * )
+ * )
+ * ```
+ */
+ const withOuterCombinator = dual<
+ <
+ Args extends ReadonlyArray,
+ ALastIC,
+ ELastIC,
+ RLastIC,
+ ALastOC,
+ ELastOC,
+ RLastOC,
+ 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,
+ )
+
+ /**
+ * Adds automatic error reporting to the command draft. This outer combinator
+ * catches all failures and reports them through the application's error reporting system.
+ *
+ * Handles different types of errors:
+ * - Interruptions: Logged as info
+ * - Known failures: Reported with action context
+ * - Runtime errors: Reported with full error details
+ *
+ * @param cd - The command draft to add error reporting to
+ * @returns A new command draft with error reporting added as an outer combinator
+ *
+ * @example
+ * ```ts
+ * const draft = pipe(
+ * myDraft,
+ * CommandDraft.withErrorReporter
+ * )
+ * ```
+ */
+ const 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)
+ }),
+ ),
+ ),
+ )
+ }
+
+ /**
+ * Builds the final command from a draft without adding default error reporting.
+ * Use this when you want to handle errors manually or have already added custom error handling.
+ *
+ * The built command returns a computed ref containing:
+ * - A function that executes the command when called
+ * - action: The internationalized action name
+ * - result: The result state of the command execution
+ * - waiting: Boolean indicating if the command is currently executing
+ *
+ * @template Args - The arguments array type for the command handler
+ * @template RLastOC - Requirements type (must extend RT - no other dependencies allowed)
+ * @param cd - The command draft to build (can be in "inner" or "outer" mode)
+ * @returns A computed ref with the executable command and its state
+ *
+ * @example
+ * ```ts
+ * const myCommand = pipe(
+ * myDraft,
+ * CommandDraft.buildWithoutDefaultErrorReporter
+ * )
+ *
+ * // Usage
+ * const cmd = myCommand.value
+ * cmd("argument") // Execute command
+ * cmd.waiting // Check if executing
+ * ```
+ */
+ const buildWithoutDefaultErrorReporter = <
+ 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,
+ },
+ ),
+ )
+ }
+
+ /**
+ * Builds the final command from a draft with default error reporting included.
+ * This is the most common way to build commands as it automatically adds error reporting.
+ *
+ * The built command returns a computed ref containing:
+ * - A function that executes the command when called
+ * - action: The internationalized action name
+ * - result: The result state of the command execution
+ * - waiting: Boolean indicating if the command is currently executing
+ *
+ * @template Args - The arguments array type for the command handler
+ * @template RLastOC - Requirements type (must extend RT - no other dependencies allowed)
+ * @param cd - The command draft to build (can be in "inner" or "outer" mode)
+ * @returns A computed ref with the executable command and its state
+ *
+ * @example
+ * ```ts
+ * const deleteUser = pipe(
+ * cmd.fn("deleteUser")(function* (userId: string) {
+ * return yield* userService.delete(userId)
+ * }),
+ * CommandDraft.build
+ * )
+ *
+ * // Usage in component
+ *
+ * ```
+ */
+ 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, buildWithoutDefaultErrorReporter)
+
+ /**
+ * Creates a new command draft from an action name and handler function.
+ *
+ * The action name is used for:
+ * - Span naming in tracing
+ * - Looking up the internationalized action name via `action.${actionName}` key
+ *
+ * @param actionName - The internal action name for tracing and i18n lookup
+ * @returns A curried function that accepts a generator handler and returns a CommandDraft
+ *
+ * @example
+ * ```ts
+ * const cmd = useCommand()
+ *
+ * const deleteUser = cmd.fn("deleteUser")(function* (userId: string) {
+ * const { action } = yield* CommandContext
+ * console.log(`Executing: ${action}`)
+ * return yield* userService.delete(userId)
+ * })
+ * ```
+ */
+ 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 make({
+ actionName,
+ action,
+ handlerE,
+ innerCombinators: [],
+ outerCombinators: [],
+ })
+ }
+
+ /**
+ * Adds automatic toast notifications to a command draft for success, failure, and waiting states.
+ *
+ * Provides default internationalized messages for:
+ * - Waiting state: Shows "Processing {action}..." message
+ * - Success state: Shows "Success: {action}" with optional operation message
+ * - Failure state: Shows appropriate error message based on error type
+ *
+ * @param errorRenderer - Optional custom error renderer function. Return undefined to use default rendering.
+ * @returns A function that accepts a CommandDraft and returns it with toast notifications added
+ *
+ * @example
+ * ```ts
+ * const cmd = useCommand()
+ *
+ * const saveUser = pipe(
+ * cmd.fn("saveUser")(function* (user: User) {
+ * return yield* userService.save(user)
+ * }),
+ * cmd.withDefaultToast((error) => {
+ * if (error._tag === "ValidationError") return "Please check your input"
+ * return undefined // Use default error rendering
+ * }),
+ * CommandDraft.build
+ * )
+ * ```
+ */
+ const withDefaultToast = <
+ Args extends ReadonlyArray,
+ ALastIC,
+ ELastIC,
+ RLastIC,
+ ALastOC,
+ ELastOC,
+ RLastOC,
+ >(
+ errorRenderer?: (e: ELastIC) => string | undefined, // undefined falls back to default?
+ ) => {
+ return (
+ cd: CommandDraft<
+ Args,
+ ALastIC,
+ ELastIC,
+ RLastIC,
+ ALastOC,
+ ELastOC,
+ RLastOC,
+ "inner"
+ >,
+ ) =>
+ 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}`
+ }
+ if ("_tag" in e) {
+ return `${e._tag}`
+ }
+ }
+ 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}`),
+ )
+ }
+
+ 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 {
- /** Version of confirmOrInterrupt that automatically includes the action name in the default messages */
+ /**
+ * Utility for showing confirmation dialogs within command effects.
+ * Automatically includes the action name in default confirmation messages.
+ *
+ * Can be used within command handlers to request user confirmation before
+ * proceeding with potentially destructive operations. Uses the CommandContext
+ * to access the current action name for messaging.
+ *
+ * @param message - Optional custom confirmation message. If not provided, uses default i18n message.
+ * @yields The confirmation dialog effect
+ * @throws Interrupts the command if user cancels
+ *
+ * @example
+ * ```ts
+ * const cmd = useCommand()
+ *
+ * const deleteUser = cmd.fn("deleteUser")(function* (userId: string) {
+ * yield* cmd.confirmOrInterrupt("Are you sure you want to delete this user?")
+ * return yield* userService.delete(userId)
+ * })
+ * ```
+ */
confirmOrInterrupt: Effect.fnUntraced(function* (
message: string | undefined = undefined,
) {
@@ -30,180 +803,103 @@ export const useCommand = () => {
),
)
}),
- /** 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 defaultWarnMessage = intl.value.formatMessage(
- { id: "handle.with_warnings" },
- { action },
- )
- const defaultErrorMessage = intl.value.formatMessage(
- { id: "handle.with_errors" },
- { action },
- )
- function renderError(e: E): 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}`
- }
- if ("_tag" in e) {
- return `${e._tag}`
- }
- }
- 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}`),
- )
- }
-
- 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),
- }),
- }),
- )
- }),
- /**
- * 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
- */
- fn:
- (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
- }
+ withDefaultToast,
+ fn,
+ build,
+ buildWithoutDefaultErrorReporter,
+ make,
+ withCombinator,
+ withOuterCombinator,
+ withErrorReporter,
+ }
+}
- 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
- }
+class MyTag extends Context.Tag("MyTag")() {}
+class MyTag2 extends Context.Tag("MyTag2")() {}
- const extra = {
- action,
- message: `Unexpected Error trying to ${actionName}`,
- }
- yield* reportRuntimeError(cause, extra)
- }),
- ),
- )
+const Cmd = useCommand()
- // 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,
- },
- ),
- )
- },
- }
-}
+const pipeTest1 = pipe(
+ Cmd.make({
+ actionName: "actionName",
+ action: "action",
+ handlerE: Effect.fnUntraced(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
+ }
+ }),
+ innerCombinators: [],
+ outerCombinators: [],
+ }),
+ Cmd.withCombinator(self =>
+ self.pipe(
+ Effect.catchTag("InvalidStateError", e =>
+ Effect.succeed([-1 as number, e.message] as const),
+ ),
+ ),
+ ),
+ Cmd.withCombinator(self =>
+ self.pipe(
+ Effect.map(([f]) => f),
+ Effect.provideService(MyTag, { mytag: "inner" }),
+ ),
+ ),
+ Cmd.withOuterCombinator(self =>
+ self.pipe(
+ Effect.andThen(n =>
+ MyTag.pipe(Effect.andThen(service => ({ tag: 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" }),
+ // ),
+ // ),
+ Cmd.withErrorReporter,
+ //
+ // fail because MyTag has not been provided
+ // Cmd.buildWithoutDefaultErrorReporter,
+ //
+ Cmd.withOuterCombinator(self =>
+ self.pipe(Effect.provideService(MyTag, { mytag: "outer" })),
+ ),
+ Cmd.buildWithoutDefaultErrorReporter,
+)
+
+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
+ }
+ }),
+ Cmd.withCombinator(self =>
+ self.pipe(
+ Effect.provideService(MyTag, { mytag: "inner" }),
+ Effect.catchTag("InvalidStateError", e =>
+ Effect.succeed([-1 as number, e.message] as const),
+ ),
+ ),
+ ),
+ Cmd.withDefaultToast(),
+ Cmd.build,
+)
diff --git a/frontend/pages/index.vue b/frontend/pages/index.vue
index b3b77cd2..3e5a9fe1 100644
--- a/frontend/pages/index.vue
+++ b/frontend/pages/index.vue
@@ -1,5 +1,5 @@