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 @@