From b85151e9582be40db3ea8fda16755b6bad3a871c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Fri, 23 Jan 2026 02:46:22 +0100 Subject: [PATCH] Add initial files --- .gitea/workflows/lint.yaml | 18 + .gitea/workflows/publish.yaml | 30 + .gitea/workflows/test-build.yaml | 27 + .gitignore | 133 ++++ .vscode/settings.json | 6 + LICENSE | 9 + README.md | 7 + biome.json | 40 + package.json | 26 + packages/effect-docker/README.md | 58 ++ packages/effect-docker/biome.json | 8 + packages/effect-docker/package.json | 46 ++ packages/effect-docker/src/Async.ts | 85 ++ packages/effect-docker/src/Component.ts | 732 ++++++++++++++++++ packages/effect-docker/src/ErrorObserver.ts | 62 ++ packages/effect-docker/src/Form.ts | 396 ++++++++++ packages/effect-docker/src/Memoized.ts | 51 ++ packages/effect-docker/src/Mutation.ts | 128 +++ packages/effect-docker/src/PropertyPath.ts | 98 +++ packages/effect-docker/src/PubSub.ts | 14 + packages/effect-docker/src/Query.ts | 316 ++++++++ packages/effect-docker/src/QueryClient.ts | 173 +++++ packages/effect-docker/src/ReactRuntime.ts | 85 ++ packages/effect-docker/src/Result.ts | 279 +++++++ packages/effect-docker/src/SetStateAction.ts | 12 + packages/effect-docker/src/Stream.ts | 33 + packages/effect-docker/src/Subscribable.ts | 52 ++ packages/effect-docker/src/SubscriptionRef.ts | 61 ++ .../effect-docker/src/SubscriptionSubRef.ts | 186 +++++ packages/effect-docker/src/index.ts | 17 + packages/effect-docker/src/utils.ts | 3 + packages/effect-docker/tsconfig.json | 38 + renovate.json | 19 + turbo.json | 30 + 34 files changed, 3278 insertions(+) create mode 100644 .gitea/workflows/lint.yaml create mode 100644 .gitea/workflows/publish.yaml create mode 100644 .gitea/workflows/test-build.yaml create mode 100644 .gitignore create mode 100644 .vscode/settings.json create mode 100644 LICENSE create mode 100644 README.md create mode 100644 biome.json create mode 100644 package.json create mode 100644 packages/effect-docker/README.md create mode 100644 packages/effect-docker/biome.json create mode 100644 packages/effect-docker/package.json create mode 100644 packages/effect-docker/src/Async.ts create mode 100644 packages/effect-docker/src/Component.ts create mode 100644 packages/effect-docker/src/ErrorObserver.ts create mode 100644 packages/effect-docker/src/Form.ts create mode 100644 packages/effect-docker/src/Memoized.ts create mode 100644 packages/effect-docker/src/Mutation.ts create mode 100644 packages/effect-docker/src/PropertyPath.ts create mode 100644 packages/effect-docker/src/PubSub.ts create mode 100644 packages/effect-docker/src/Query.ts create mode 100644 packages/effect-docker/src/QueryClient.ts create mode 100644 packages/effect-docker/src/ReactRuntime.ts create mode 100644 packages/effect-docker/src/Result.ts create mode 100644 packages/effect-docker/src/SetStateAction.ts create mode 100644 packages/effect-docker/src/Stream.ts create mode 100644 packages/effect-docker/src/Subscribable.ts create mode 100644 packages/effect-docker/src/SubscriptionRef.ts create mode 100644 packages/effect-docker/src/SubscriptionSubRef.ts create mode 100644 packages/effect-docker/src/index.ts create mode 100644 packages/effect-docker/src/utils.ts create mode 100644 packages/effect-docker/tsconfig.json create mode 100644 renovate.json create mode 100644 turbo.json diff --git a/.gitea/workflows/lint.yaml b/.gitea/workflows/lint.yaml new file mode 100644 index 0000000..c13cd5d --- /dev/null +++ b/.gitea/workflows/lint.yaml @@ -0,0 +1,18 @@ +name: Lint +run-name: Lint +on: [push] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + - name: Clone repo + uses: actions/checkout@v6 + - name: Install dependencies + run: bun install --frozen-lockfile + - name: Lint TypeScript + run: bun lint:tsc + - name: Lint Biome + run: bun lint:biome diff --git a/.gitea/workflows/publish.yaml b/.gitea/workflows/publish.yaml new file mode 100644 index 0000000..89eb0c7 --- /dev/null +++ b/.gitea/workflows/publish.yaml @@ -0,0 +1,30 @@ +# name: Publish +# run-name: Publish +# on: +# push: +# branches: +# - master + +# jobs: +# publish: +# runs-on: ubuntu-latest +# steps: +# - name: Setup Bun +# uses: oven-sh/setup-bun@v2 +# - name: Clone repo +# uses: actions/checkout@v6 +# - name: Install dependencies +# run: bun install --frozen-lockfile +# - name: Lint TypeScript +# run: bun lint:tsc +# - name: Lint Biome +# run: bun lint:biome +# - name: Build +# run: bun run build +# - name: Publish effect-docker +# uses: JS-DevTools/npm-publish@v4 +# with: +# package: packages/effect-docker +# access: public +# token: ${{ secrets.NPM_TOKEN }} +# registry: https://registry.npmjs.org diff --git a/.gitea/workflows/test-build.yaml b/.gitea/workflows/test-build.yaml new file mode 100644 index 0000000..729eb00 --- /dev/null +++ b/.gitea/workflows/test-build.yaml @@ -0,0 +1,27 @@ +name: Test build +run-name: Test build +on: + pull_request: + +jobs: + test-build: + runs-on: ubuntu-latest + steps: + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: "24" + - name: Clone repo + uses: actions/checkout@v6 + - name: Install dependencies + run: bun install --frozen-lockfile + - name: Lint TypeScript + run: bun lint:tsc + - name: Lint Biome + run: bun lint:biome + - name: Build + run: bun run build + - name: Pack + run: bun pack diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2519628 --- /dev/null +++ b/.gitignore @@ -0,0 +1,133 @@ +# ---> Node +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +.turbo diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..723e209 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "typescript.tsdk": "node_modules/typescript/lib", + "editor.codeActionsOnSave": { + "source.fixAll.biome": "explicit" + } +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fe6b51e --- /dev/null +++ b/LICENSE @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) 2024 Thilawyn + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3d7e9bb --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +# Effect FC Monorepo + +[Effect-TS](https://effect.website/) integration for React 19.2+ that allows you to write function components using Effect generators. + +This monorepo contains: +- [The `effect-fc` library](packages/effect-fc) +- [An example project](packages/example) diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..2621baf --- /dev/null +++ b/biome.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://biomejs.dev/schemas/latest/schema.json", + "vcs": { + "enabled": false, + "clientKind": "git", + "useIgnoreFile": false + }, + "files": { + "ignoreUnknown": false + }, + "formatter": { + "enabled": false + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "style": { + "useShorthandFunctionType": "off" + }, + "suspicious": { + "noExplicitAny": "off", + "noShadowRestrictedNames": "off" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..2a9349c --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "name": "@effect-docker/monorepo", + "packageManager": "bun@1.3.6", + "private": true, + "workspaces": [ + "./packages/*" + ], + "scripts": { + "build": "turbo build", + "lint:tsc": "turbo lint:tsc", + "lint:biome": "turbo lint:biome", + "pack": "turbo pack", + "clean:cache": "turbo clean:cache", + "clean:dist": "turbo clean:dist", + "clean:modules": "turbo clean:modules && rm -rf node_modules" + }, + "devDependencies": { + "@biomejs/biome": "^2.3.11", + "@effect/language-service": "^0.72.0", + "@types/bun": "^1.3.6", + "npm-check-updates": "^19.3.1", + "npm-sort": "^0.0.4", + "turbo": "^2.7.5", + "typescript": "^5.9.3" + } +} diff --git a/packages/effect-docker/README.md b/packages/effect-docker/README.md new file mode 100644 index 0000000..b3c7ecd --- /dev/null +++ b/packages/effect-docker/README.md @@ -0,0 +1,58 @@ +# Effect FC + +[Effect-TS](https://effect.website/) integration for React 19.2+ that allows you to write function components using Effect generators. + +This library is in early development. While it is (almost) feature complete and mostly usable, expect bugs and quirks. Things are still being ironed out, so ideas and criticisms are more than welcome. + +Documentation is currently being written. In the meantime, you can take a look at the `packages/example` directory. + +## Peer dependencies +- `effect` 3.19+ +- `react` & `@types/react` 19.2+ + +## Known issues +- React Refresh doesn't work for Effect FC's yet. Page reload is required to view changes. Regular React components are unaffected. + +## What writing components looks like +```typescript +export class Todos extends Component.make("Todos")(function*() { + const state = yield* TodosState + const [todos] = yield* useSubscribables(state.ref) + + yield* useOnMount(() => Effect.andThen( + Console.log("Todos mounted"), + Effect.addFinalizer(() => Console.log("Todos unmounted")), + )) + + const TodoFC = yield* Todo + + return ( + + Todos + + + + + {Chunk.map(todos, todo => + + )} + + + ) +}) {} + +const TodosStateLive = TodosState.Default("todos") + +const Index = Component.make("Index")(function*() { + const context = yield* useContext(TodosStateLive) + const TodosFC = yield* Effect.provide(Todos, context) + + return +}).pipe( + Component.withRuntime(runtime.context) +) + +export const Route = createFileRoute("/")({ + component: Index +}) +``` diff --git a/packages/effect-docker/biome.json b/packages/effect-docker/biome.json new file mode 100644 index 0000000..41d707b --- /dev/null +++ b/packages/effect-docker/biome.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://biomejs.dev/schemas/latest/schema.json", + "root": false, + "extends": "//", + "files": { + "includes": ["./src/**"] + } +} diff --git a/packages/effect-docker/package.json b/packages/effect-docker/package.json new file mode 100644 index 0000000..b32000f --- /dev/null +++ b/packages/effect-docker/package.json @@ -0,0 +1,46 @@ +{ + "name": "effect-docker", + "description": "Effect-based client to talk to the Docker API", + "version": "0.1.0", + "type": "module", + "files": [ + "./README.md", + "./dist" + ], + "license": "MIT", + "repository": { + "url": "git+https://github.com/Thiladev/effect-docker.git" + }, + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./*": [ + { + "types": "./dist/*/index.d.ts", + "default": "./dist/*/index.js" + }, + { + "types": "./dist/*.d.ts", + "default": "./dist/*.js" + } + ] + }, + "scripts": { + "build": "tsc", + "lint:tsc": "tsc --noEmit", + "lint:biome": "biome lint", + "pack": "npm pack", + "clean:cache": "rm -rf .turbo tsconfig.tsbuildinfo", + "clean:dist": "rm -rf dist", + "clean:modules": "rm -rf node_modules" + }, + "devDependencies": { + "@effect/platform-browser": "^0.74.0" + }, + "peerDependencies": { + "effect": "^3.19.0" + } +} diff --git a/packages/effect-docker/src/Async.ts b/packages/effect-docker/src/Async.ts new file mode 100644 index 0000000..3d1f02b --- /dev/null +++ b/packages/effect-docker/src/Async.ts @@ -0,0 +1,85 @@ +/** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */ +import { Effect, Function, Predicate, Runtime, Scope } from "effect" +import * as React from "react" +import * as Component from "./Component.js" + + +export const TypeId: unique symbol = Symbol.for("@effect-fc/Async/Async") +export type TypeId = typeof TypeId + +export interface Async extends Async.Options { + readonly [TypeId]: TypeId +} + +export namespace Async { + export interface Options { + readonly defaultFallback?: React.ReactNode + } + + export type Props = Omit +} + + +const AsyncProto = Object.freeze({ + [TypeId]: TypeId, + + makeFunctionComponent

( + this: Component.Component & Async, + runtimeRef: React.RefObject>>, + ) { + const SuspenseInner = (props: { readonly promise: Promise }) => React.use(props.promise) + + return ({ fallback, name, ...props }: Async.Props) => { + const promise = Runtime.runPromise(runtimeRef.current)( + Effect.andThen( + Component.useScope([], this), + scope => Effect.provideService(this.body(props as P), Scope.Scope, scope), + ) + ) + + return React.createElement( + React.Suspense, + { fallback: fallback ?? this.defaultFallback, name }, + React.createElement(SuspenseInner, { promise }), + ) + } + }, +} as const) + + +export const isAsync = (u: unknown): u is Async => Predicate.hasProperty(u, TypeId) + +export const async = >( + self: T +): ( + & Omit> + & Component.Component< + Component.Component.Props & Async.Props, + Component.Component.Success, + Component.Component.Error, + Component.Component.Context + > + & Async +) => Object.setPrototypeOf( + Object.assign(function() {}, self), + Object.freeze(Object.setPrototypeOf( + Object.assign({}, AsyncProto), + Object.getPrototypeOf(self), + )), +) + +export const withOptions: { + & Async>( + options: Partial + ): (self: T) => T + & Async>( + self: T, + options: Partial, + ): T +} = Function.dual(2, & Async>( + self: T, + options: Partial, +): T => Object.setPrototypeOf( + Object.assign(function() {}, self, options), + Object.getPrototypeOf(self), +)) diff --git a/packages/effect-docker/src/Component.ts b/packages/effect-docker/src/Component.ts new file mode 100644 index 0000000..3cd4b2d --- /dev/null +++ b/packages/effect-docker/src/Component.ts @@ -0,0 +1,732 @@ +/** biome-ignore-all lint/complexity/noBannedTypes: {} is the default type for React props */ +/** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */ +import { Context, type Duration, Effect, Effectable, Equivalence, ExecutionStrategy, Exit, Fiber, Function, HashMap, Layer, ManagedRuntime, Option, Predicate, Ref, Runtime, Scope, Tracer, type Utils } from "effect" +import * as React from "react" +import { Memoized } from "./index.js" + + +export const TypeId: unique symbol = Symbol.for("@effect-fc/Component/Component") +export type TypeId = typeof TypeId + +/** + * Interface representing an Effect-based React Component. + * + * This is both: + * - an Effect that produces a React function component + * - a constructor-like object with component metadata and options + */ +export interface Component

+extends + Effect.Effect<(props: P) => A, never, Exclude>, + Component.Options +{ + new(_: never): Record + readonly [TypeId]: TypeId + readonly "~Props": P + readonly "~Success": A + readonly "~Error": E + readonly "~Context": R + + readonly body: (props: P) => Effect.Effect + + /** @internal */ + makeFunctionComponent( + runtimeRef: React.Ref>> + ): (props: P) => A +} + +export declare namespace Component { + export type Props> = [T] extends [Component] ? P : never + export type Success> = [T] extends [Component] ? A : never + export type Error> = [T] extends [Component] ? E : never + export type Context> = [T] extends [Component] ? R : never + + export type AsComponent> = Component, Success, Error, Context> + + /** + * Options that can be set on the component + */ + export interface Options { + /** Custom displayName for React DevTools and debugging. */ + readonly displayName?: string + + /** + * Strategy used when executing finalizers on unmount/scope close. + * @default ExecutionStrategy.sequential + */ + readonly finalizerExecutionStrategy: ExecutionStrategy.ExecutionStrategy + + /** + * Debounce time before executing finalizers after component unmount. + * Helps avoid unnecessary work during fast remount/remount cycles. + * @default "100 millis" + */ + readonly finalizerExecutionDebounce: Duration.DurationInput + } +} + + +const ComponentProto = Object.freeze({ + ...Effectable.CommitPrototype, + [TypeId]: TypeId, + + commit: Effect.fnUntraced(function*

( + this: Component + ) { + // biome-ignore lint/style/noNonNullAssertion: React ref initialization + const runtimeRef = React.useRef>>(null!) + runtimeRef.current = yield* Effect.runtime>() + + return yield* React.useState(() => Runtime.runSync(runtimeRef.current)(Effect.cachedFunction( + (_services: readonly any[]) => Effect.sync(() => { + const f: React.FC

= this.makeFunctionComponent(runtimeRef) + f.displayName = this.displayName ?? "Anonymous" + return Memoized.isMemoized(this) + ? React.memo(f, this.propsAreEqual) + : f + }), + Equivalence.array(Equivalence.strict()), + )))[0](Array.from( + Context.omit(...nonReactiveTags)(runtimeRef.current.context).unsafeMap.values() + )) + }), + + makeFunctionComponent

( + this: Component, + runtimeRef: React.RefObject>>, + ) { + return (props: P) => Runtime.runSync(runtimeRef.current)( + Effect.andThen( + useScope([], this), + scope => Effect.provideService(this.body(props), Scope.Scope, scope), + ) + ) + }, +} as const) + +const defaultOptions: Component.Options = { + finalizerExecutionStrategy: ExecutionStrategy.sequential, + finalizerExecutionDebounce: "100 millis", +} + +const nonReactiveTags = [Tracer.ParentSpan] as const + + +export const isComponent = (u: unknown): u is Component<{}, React.ReactNode, unknown, unknown> => Predicate.hasProperty(u, TypeId) + +export declare namespace make { + export type Gen = { + >, A extends React.ReactNode, P extends {} = {}>( + body: (props: P) => Generator + ): Component< + P, A, + [Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap>] ? E : never, + [Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap>] ? R : never + > + >, A, B extends Effect.Effect, P extends {} = {}>( + body: (props: P) => Generator, + a: ( + _: Effect.Effect< + A, + [Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap>] ? E : never, + [Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap>] ? R : never + >, + props: NoInfer

, + ) => B, + ): Component>, Effect.Effect.Error, Effect.Effect.Context> + >, A, B, C extends Effect.Effect, P extends {} = {}>( + body: (props: P) => Generator, + a: ( + _: Effect.Effect< + A, + [Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap>] ? E : never, + [Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap>] ? R : never + >, + props: NoInfer

, + ) => B, + b: (_: B, props: NoInfer

) => C, + ): Component>, Effect.Effect.Error, Effect.Effect.Context> + >, A, B, C, D extends Effect.Effect, P extends {} = {}>( + body: (props: P) => Generator, + a: ( + _: Effect.Effect< + A, + [Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap>] ? E : never, + [Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap>] ? R : never + >, + props: NoInfer

, + ) => B, + b: (_: B, props: NoInfer

) => C, + c: (_: C, props: NoInfer

) => D, + ): Component>, Effect.Effect.Error, Effect.Effect.Context> + >, A, B, C, D, E extends Effect.Effect, P extends {} = {}>( + body: (props: P) => Generator, + a: ( + _: Effect.Effect< + A, + [Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap>] ? E : never, + [Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap>] ? R : never + >, + props: NoInfer

, + ) => B, + b: (_: B, props: NoInfer

) => C, + c: (_: C, props: NoInfer

) => D, + d: (_: D, props: NoInfer

) => E, + ): Component>, Effect.Effect.Error, Effect.Effect.Context> + >, A, B, C, D, E, F extends Effect.Effect, P extends {} = {}>( + body: (props: P) => Generator, + a: ( + _: Effect.Effect< + A, + [Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap>] ? E : never, + [Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap>] ? R : never + >, + props: NoInfer

, + ) => B, + b: (_: B, props: NoInfer

) => C, + c: (_: C, props: NoInfer

) => D, + d: (_: D, props: NoInfer

) => E, + e: (_: E, props: NoInfer

) => F, + ): Component>, Effect.Effect.Error, Effect.Effect.Context> + >, A, B, C, D, E, F, G extends Effect.Effect, P extends {} = {}>( + body: (props: P) => Generator, + a: ( + _: Effect.Effect< + A, + [Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap>] ? E : never, + [Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap>] ? R : never + >, + props: NoInfer

, + ) => B, + b: (_: B, props: NoInfer

) => C, + c: (_: C, props: NoInfer

) => D, + d: (_: D, props: NoInfer

) => E, + e: (_: E, props: NoInfer

) => F, + f: (_: F, props: NoInfer

) => G, + ): Component>, Effect.Effect.Error, Effect.Effect.Context> + >, A, B, C, D, E, F, G, H extends Effect.Effect, P extends {} = {}>( + body: (props: P) => Generator, + a: ( + _: Effect.Effect< + A, + [Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap>] ? E : never, + [Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap>] ? R : never + >, + props: NoInfer

, + ) => B, + b: (_: B, props: NoInfer

) => C, + c: (_: C, props: NoInfer

) => D, + d: (_: D, props: NoInfer

) => E, + e: (_: E, props: NoInfer

) => F, + f: (_: F, props: NoInfer

) => G, + g: (_: G, props: NoInfer

) => H, + ): Component>, Effect.Effect.Error, Effect.Effect.Context> + >, A, B, C, D, E, F, G, H, I extends Effect.Effect, P extends {} = {}>( + body: (props: P) => Generator, + a: ( + _: Effect.Effect< + A, + [Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap>] ? E : never, + [Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap>] ? R : never + >, + props: NoInfer

, + ) => B, + b: (_: B, props: NoInfer

) => C, + c: (_: C, props: NoInfer

) => D, + d: (_: D, props: NoInfer

) => E, + e: (_: E, props: NoInfer

) => F, + f: (_: F, props: NoInfer

) => G, + g: (_: G, props: NoInfer

) => H, + h: (_: H, props: NoInfer

) => I, + ): Component>, Effect.Effect.Error, Effect.Effect.Context> + >, A, B, C, D, E, F, G, H, I, J extends Effect.Effect, P extends {} = {}>( + body: (props: P) => Generator, + a: ( + _: Effect.Effect< + A, + [Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap>] ? E : never, + [Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap>] ? R : never + >, + props: NoInfer

, + ) => B, + b: (_: B, props: NoInfer

) => C, + c: (_: C, props: NoInfer

) => D, + d: (_: D, props: NoInfer

) => E, + e: (_: E, props: NoInfer

) => F, + f: (_: F, props: NoInfer

) => G, + g: (_: G, props: NoInfer

) => H, + h: (_: H, props: NoInfer

) => I, + i: (_: I, props: NoInfer

) => J, + ): Component>, Effect.Effect.Error, Effect.Effect.Context> + } + + export type NonGen = { + , P extends {} = {}>( + body: (props: P) => Eff + ): Component>, Effect.Effect.Error, Effect.Effect.Context> + , A, P extends {} = {}>( + body: (props: P) => A, + a: (_: A, props: NoInfer

) => Eff, + ): Component>, Effect.Effect.Error, Effect.Effect.Context> + , A, B, P extends {} = {}>( + body: (props: P) => A, + a: (_: A, props: NoInfer

) => B, + b: (_: B, props: NoInfer

) => Eff, + ): Component>, Effect.Effect.Error, Effect.Effect.Context> + , A, B, C, P extends {} = {}>( + body: (props: P) => A, + a: (_: A, props: NoInfer

) => B, + b: (_: B, props: NoInfer

) => C, + c: (_: C, props: NoInfer

) => Eff, + ): Component>, Effect.Effect.Error, Effect.Effect.Context> + , A, B, C, D, P extends {} = {}>( + body: (props: P) => A, + a: (_: A, props: NoInfer

) => B, + b: (_: B, props: NoInfer

) => C, + c: (_: C, props: NoInfer

) => D, + d: (_: D, props: NoInfer

) => Eff, + ): Component>, Effect.Effect.Error, Effect.Effect.Context> + , A, B, C, D, E, P extends {} = {}>( + body: (props: P) => A, + a: (_: A, props: NoInfer

) => B, + b: (_: B, props: NoInfer

) => C, + c: (_: C, props: NoInfer

) => D, + d: (_: D, props: NoInfer

) => E, + e: (_: E, props: NoInfer

) => Eff, + ): Component>, Effect.Effect.Error, Effect.Effect.Context> + , A, B, C, D, E, F, P extends {} = {}>( + body: (props: P) => A, + a: (_: A, props: NoInfer

) => B, + b: (_: B, props: NoInfer

) => C, + c: (_: C, props: NoInfer

) => D, + d: (_: D, props: NoInfer

) => E, + e: (_: E, props: NoInfer

) => F, + f: (_: F, props: NoInfer

) => Eff, + ): Component>, Effect.Effect.Error, Effect.Effect.Context> + , A, B, C, D, E, F, G, P extends {} = {}>( + body: (props: P) => A, + a: (_: A, props: NoInfer

) => B, + b: (_: B, props: NoInfer

) => C, + c: (_: C, props: NoInfer

) => D, + d: (_: D, props: NoInfer

) => E, + e: (_: E, props: NoInfer

) => F, + f: (_: F, props: NoInfer

) => G, + g: (_: G, props: NoInfer

) => Eff, + ): Component>, Effect.Effect.Error, Effect.Effect.Context> + , A, B, C, D, E, F, G, H, P extends {} = {}>( + body: (props: P) => A, + a: (_: A, props: NoInfer

) => B, + b: (_: B, props: NoInfer

) => C, + c: (_: C, props: NoInfer

) => D, + d: (_: D, props: NoInfer

) => E, + e: (_: E, props: NoInfer

) => F, + f: (_: F, props: NoInfer

) => G, + g: (_: G, props: NoInfer

) => H, + h: (_: H, props: NoInfer

) => Eff, + ): Component>, Effect.Effect.Error, Effect.Effect.Context> + , A, B, C, D, E, F, G, H, I, P extends {} = {}>( + body: (props: P) => A, + a: (_: A, props: NoInfer

) => B, + b: (_: B, props: NoInfer

) => C, + c: (_: C, props: NoInfer

) => D, + d: (_: D, props: NoInfer

) => E, + e: (_: E, props: NoInfer

) => F, + f: (_: F, props: NoInfer

) => G, + g: (_: G, props: NoInfer

) => H, + h: (_: H, props: NoInfer

) => I, + i: (_: I, props: NoInfer

) => Eff, + ): Component>, Effect.Effect.Error, Effect.Effect.Context> + } +} + +/** + * Creates an Effect-FC Component following the same overloads and pipeline style as `Effect.fn`. + * + * This is the **recommended** way to define components. It supports: + * - Generator syntax (yield* style) — most ergonomic and readable + * - Direct Effect return (non-generator) + * - Chained transformation functions (like Effect.fn pipelines) + * - Optional tracing span with automatic `displayName` + * + * When you provide a `spanName` as the first argument, two things happen automatically: + * 1. A tracing span is created with that name (unless using `makeUntraced`) + * 2. The resulting React component gets `displayName = spanName` + */ +export const make: ( + & make.Gen + & make.NonGen + & (( + spanName: string, + spanOptions?: Tracer.SpanOptions, + ) => make.Gen & make.NonGen) +) = (spanNameOrBody: Function | string, ...pipeables: any[]): any => { + if (typeof spanNameOrBody !== "string") { + return Object.setPrototypeOf( + Object.assign(function() {}, defaultOptions, { + body: Effect.fn(spanNameOrBody as any, ...pipeables), + }), + ComponentProto, + ) + } + else { + const spanOptions = pipeables[0] + return (body: any, ...pipeables: any[]) => Object.setPrototypeOf( + Object.assign(function() {}, defaultOptions, { + body: Effect.fn(spanNameOrBody, spanOptions)(body, ...pipeables as []), + displayName: spanNameOrBody, + }), + ComponentProto, + ) + } +} + +/** + * Same as `make`, but creates an **untraced** version — no automatic tracing span is created. + * + * Follows the exact same API shape as `Effect.fnUntraced`. + * Useful for: + * - Components where you want full manual control over tracing + * - Avoiding span noise in deeply nested UI + * + * When a string is provided as first argument, it is **only** used as the React component's `displayName` + * (no tracing span is created). + */ +export const makeUntraced: ( + & make.Gen + & make.NonGen + & ((name: string) => make.Gen & make.NonGen) +) = (spanNameOrBody: Function | string, ...pipeables: any[]): any => ( + typeof spanNameOrBody !== "string" + ? Object.setPrototypeOf( + Object.assign(function() {}, defaultOptions, { + body: Effect.fnUntraced(spanNameOrBody as any, ...pipeables as []), + }), + ComponentProto, + ) + : (body: any, ...pipeables: any[]) => Object.setPrototypeOf( + Object.assign(function() {}, defaultOptions, { + body: Effect.fnUntraced(body, ...pipeables as []), + displayName: spanNameOrBody, + }), + ComponentProto, + ) +) + +/** + * Creates a new component with modified options while preserving original behavior. + */ +export const withOptions: { + >( + options: Partial + ): (self: T) => T + >( + self: T, + options: Partial, + ): T +} = Function.dual(2, >( + self: T, + options: Partial, +): T => Object.setPrototypeOf( + Object.assign(function() {}, self, options), + Object.getPrototypeOf(self), +)) + +/** + * Wraps an Effect-FC `Component` and turns it into a regular React function component + * that serves as an **entrypoint** into an Effect-FC component hierarchy. + * + * This is the recommended way to connect Effect-FC components to the rest of your React app, + * especially when using routers (TanStack Router, React Router, etc.), lazy-loaded routes, + * or any place where a standard React component is expected. + * + * The runtime is obtained from the provided React Context, allowing you to: + * - Provide dependencies once at a high level + * - Use the same runtime across an entire route tree or feature + * + * @example Using TanStack Router + * ```tsx + * // Main + * export const runtime = ReactRuntime.make(Layer.empty) + * function App() { + * return ( + * + * + * + * ) + * } + * + * // Route + * export const Route = createFileRoute("/")({ + * component: Component.withRuntime(HomePage, runtime.context) + * }) + * ``` + * + * @param self - The Effect-FC Component you want to render as a regular React component. + * @param context - React Context that holds the Runtime to use for this component tree. See the `ReactRuntime` module to create one. + */ +export const withRuntime: { +

( + context: React.Context>, + ): (self: Component>) => (props: P) => A +

( + self: Component>, + context: React.Context>, + ): (props: P) => A +} = Function.dual(2,

( + self: Component, + context: React.Context>, +) => function WithRuntime(props: P) { + return React.createElement( + Runtime.runSync(React.useContext(context))(self), + props, + ) +}) + + +/** + * Service that keeps track of scopes associated with React components + * (used internally by the `useScope` hook). + */ +export class ScopeMap extends Effect.Service()("@effect-fc/Component/ScopeMap", { + effect: Effect.bind(Effect.Do, "ref", () => Ref.make(HashMap.empty())) +}) {} + +export declare namespace ScopeMap { + export interface Entry { + readonly scope: Scope.CloseableScope + readonly closeFiber: Option.Option> + } +} + + +export declare namespace useScope { + export interface Options { + readonly finalizerExecutionStrategy?: ExecutionStrategy.ExecutionStrategy + readonly finalizerExecutionDebounce?: Duration.DurationInput + } +} + +/** + * Hook that creates and manages a `Scope` for the current component instance. + * + * Automatically closes the scope whenever `deps` changes or the component unmounts. + * + * @param deps - dependency array like in `React.useEffect` + * @param options - finalizer execution control + */ +export const useScope = Effect.fnUntraced(function*( + deps: React.DependencyList, + options?: useScope.Options, +): Effect.fn.Return { + // biome-ignore lint/style/noNonNullAssertion: context initialization + const runtimeRef = React.useRef>(null!) + runtimeRef.current = yield* Effect.runtime() + + const { key, scope } = React.useMemo(() => Runtime.runSync(runtimeRef.current)(Effect.Do.pipe( + Effect.bind("scopeMapRef", () => Effect.map( + ScopeMap as unknown as Effect.Effect, + scopeMap => scopeMap.ref, + )), + Effect.let("key", () => ({})), + Effect.bind("scope", () => Scope.make(options?.finalizerExecutionStrategy ?? defaultOptions.finalizerExecutionStrategy)), + Effect.tap(({ scopeMapRef, key, scope }) => + Ref.update(scopeMapRef, HashMap.set(key, { + scope, + closeFiber: Option.none(), + })) + ), + // biome-ignore lint/correctness/useExhaustiveDependencies: use of React.DependencyList + )), deps) + + // biome-ignore lint/correctness/useExhaustiveDependencies: only reactive on "key" + React.useEffect(() => Runtime.runSync(runtimeRef.current)((ScopeMap as unknown as Effect.Effect).pipe( + Effect.map(scopeMap => scopeMap.ref), + Effect.tap(ref => ref.pipe( + Effect.andThen(HashMap.get(key)), + Effect.andThen(entry => Option.match(entry.closeFiber, { + onSome: Fiber.interruptFork, + onNone: () => Effect.void, + })), + )), + Effect.map(ref => + () => Runtime.runSync(runtimeRef.current)(Effect.andThen( + Effect.sleep(options?.finalizerExecutionDebounce ?? defaultOptions.finalizerExecutionDebounce).pipe( + Effect.andThen(Scope.close(scope, Exit.void)), + Effect.onExit(() => Ref.update(ref, HashMap.remove(key))), + Effect.forkDaemon, + ), + fiber => Ref.update(ref, HashMap.set(key, { + scope, + closeFiber: Option.some(fiber), + })), + )) + ), + )), [key]) + + return scope +}) + +/** + * Runs an effect and returns its result only once on component mount. + */ +export const useOnMount = Effect.fnUntraced(function* ( + f: () => Effect.Effect +): Effect.fn.Return { + const runtime = yield* Effect.runtime() + return yield* React.useState(() => Runtime.runSync(runtime)(Effect.cached(f())))[0] +}) + +export declare namespace useOnChange { + export interface Options extends useScope.Options {} +} + +/** + * Runs an effect and returns its result whenever dependencies change. + * + * Provides its own `Scope` which closes whenever `deps` changes or the component unmounts. + */ +export const useOnChange = Effect.fnUntraced(function* ( + f: () => Effect.Effect, + deps: React.DependencyList, + options?: useOnChange.Options, +): Effect.fn.Return> { + const runtime = yield* Effect.runtime>() + const scope = yield* useScope(deps, options) + + // biome-ignore lint/correctness/useExhaustiveDependencies: only reactive on "scope" + return yield* React.useMemo(() => Runtime.runSync(runtime)( + Effect.cached(Effect.provideService(f(), Scope.Scope, scope)) + ), [scope]) +}) + +export declare namespace useReactEffect { + export interface Options { + readonly finalizerExecutionMode?: "sync" | "fork" + readonly finalizerExecutionStrategy?: ExecutionStrategy.ExecutionStrategy + } +} + +/** + * Like `React.useEffect` but accepts an effect. + * + * Cleanup logic is handled through the `Scope` API rather than using imperative cleanup. + */ +export const useReactEffect = Effect.fnUntraced(function* ( + f: () => Effect.Effect, + deps?: React.DependencyList, + options?: useReactEffect.Options, +): Effect.fn.Return> { + const runtime = yield* Effect.runtime>() + // biome-ignore lint/correctness/useExhaustiveDependencies: use of React.DependencyList + React.useEffect(() => runReactEffect(runtime, f, options), deps) +}) + +const runReactEffect = ( + runtime: Runtime.Runtime>, + f: () => Effect.Effect, + options?: useReactEffect.Options, +) => Effect.Do.pipe( + Effect.bind("scope", () => Scope.make(options?.finalizerExecutionStrategy ?? defaultOptions.finalizerExecutionStrategy)), + Effect.bind("exit", ({ scope }) => Effect.exit(Effect.provideService(f(), Scope.Scope, scope))), + Effect.map(({ scope }) => + () => { + switch (options?.finalizerExecutionMode ?? "fork") { + case "sync": + Runtime.runSync(runtime)(Scope.close(scope, Exit.void)) + break + case "fork": + Runtime.runFork(runtime)(Scope.close(scope, Exit.void)) + break + } + } + ), + Runtime.runSync(runtime), +) + +export declare namespace useReactLayoutEffect { + export interface Options extends useReactEffect.Options {} +} + +/** + * Like `React.useReactLayoutEffect` but accepts an effect. + * + * Cleanup logic is handled through the `Scope` API rather than using imperative cleanup. + */ +export const useReactLayoutEffect = Effect.fnUntraced(function* ( + f: () => Effect.Effect, + deps?: React.DependencyList, + options?: useReactLayoutEffect.Options, +): Effect.fn.Return> { + const runtime = yield* Effect.runtime>() + // biome-ignore lint/correctness/useExhaustiveDependencies: use of React.DependencyList + React.useLayoutEffect(() => runReactEffect(runtime, f, options), deps) +}) + +/** + * Get a synchronous run function for the current runtime context. + */ +export const useRunSync = (): Effect.Effect< + (effect: Effect.Effect) => A, + never, + Scope.Scope | R +> => Effect.andThen(Effect.runtime(), Runtime.runSync) + +/** + * Get a Promise-based run function for the current runtime context. + */ +export const useRunPromise = (): Effect.Effect< + (effect: Effect.Effect) => Promise, + never, + Scope.Scope | R +> => Effect.andThen(Effect.runtime(), context => Runtime.runPromise(context)) + +/** + * Turns a function returning an effect into a memoized synchronous function. + */ +export const useCallbackSync = Effect.fnUntraced(function* ( + f: (...args: Args) => Effect.Effect, + deps: React.DependencyList, +): Effect.fn.Return<(...args: Args) => A, never, R> { + // biome-ignore lint/style/noNonNullAssertion: context initialization + const runtimeRef = React.useRef>(null!) + runtimeRef.current = yield* Effect.runtime() + + // biome-ignore lint/correctness/useExhaustiveDependencies: use of React.DependencyList + return React.useCallback((...args: Args) => Runtime.runSync(runtimeRef.current)(f(...args)), deps) +}) + +/** + * Turns a function returning an effect into a memoized Promise-based asynchronous function. + */ +export const useCallbackPromise = Effect.fnUntraced(function* ( + f: (...args: Args) => Effect.Effect, + deps: React.DependencyList, +): Effect.fn.Return<(...args: Args) => Promise, never, R> { + // biome-ignore lint/style/noNonNullAssertion: context initialization + const runtimeRef = React.useRef>(null!) + runtimeRef.current = yield* Effect.runtime() + + // biome-ignore lint/correctness/useExhaustiveDependencies: use of React.DependencyList + return React.useCallback((...args: Args) => Runtime.runPromise(runtimeRef.current)(f(...args)), deps) +}) + +export declare namespace useContext { + export interface Options extends useOnChange.Options {} +} + +/** + * Hook that constructs a layer and returns the created context. + * + * The layer gets reconstructed everytime `layer` changes, so make sure its value is stable. + * + * Building a layer containing asynchronous effects require the component calling this hook to be made async using `Async.async`. + */ +export const useContext = ( + layer: Layer.Layer, + options?: useContext.Options, +): Effect.Effect, E, Exclude> => useOnChange(() => Effect.context().pipe( + Effect.map(context => ManagedRuntime.make(Layer.provide(layer, Layer.succeedContext(context)))), + Effect.tap(runtime => Effect.addFinalizer(() => runtime.disposeEffect)), + Effect.andThen(runtime => runtime.runtimeEffect), + Effect.andThen(runtime => runtime.context), +), [layer], options) diff --git a/packages/effect-docker/src/ErrorObserver.ts b/packages/effect-docker/src/ErrorObserver.ts new file mode 100644 index 0000000..30f5424 --- /dev/null +++ b/packages/effect-docker/src/ErrorObserver.ts @@ -0,0 +1,62 @@ +import { type Cause, Context, Effect, Exit, Layer, Option, Pipeable, Predicate, PubSub, type Queue, type Scope, Supervisor } from "effect" + + +export const TypeId: unique symbol = Symbol.for("@effect-fc/ErrorObserver/ErrorObserver") +export type TypeId = typeof TypeId + +export interface ErrorObserver extends Pipeable.Pipeable { + readonly [TypeId]: TypeId + handle(effect: Effect.Effect): Effect.Effect + readonly subscribe: Effect.Effect>, never, Scope.Scope> +} + +export const ErrorObserver = (): Context.Tag> => Context.GenericTag("@effect-fc/ErrorObserver/ErrorObserver") + +class ErrorObserverImpl +extends Pipeable.Class() implements ErrorObserver { + readonly [TypeId]: TypeId = TypeId + readonly subscribe: Effect.Effect>, never, Scope.Scope> + + constructor( + readonly pubsub: PubSub.PubSub> + ) { + super() + this.subscribe = pubsub.subscribe + } + + handle(effect: Effect.Effect): Effect.Effect { + return Effect.tapErrorCause(effect, cause => PubSub.publish(this.pubsub, cause as Cause.Cause)) + } +} + +class ErrorObserverSupervisorImpl extends Supervisor.AbstractSupervisor { + readonly value = Effect.void + constructor(readonly pubsub: PubSub.PubSub>) { + super() + } + + onEnd(_value: Exit.Exit): void { + if (Exit.isFailure(_value)) { + Effect.runSync(PubSub.publish(this.pubsub, _value.cause as Cause.Cause)) + } + } +} + + +export const isErrorObserver = (u: unknown): u is ErrorObserver => Predicate.hasProperty(u, TypeId) + +export const layer: Layer.Layer = Layer.unwrapEffect(Effect.map( + PubSub.unbounded>(), + pubsub => Layer.merge( + Supervisor.addSupervisor(new ErrorObserverSupervisorImpl(pubsub)), + Layer.succeed(ErrorObserver(), new ErrorObserverImpl(pubsub)), + ), +)) + +export const handle = (effect: Effect.Effect): Effect.Effect => Effect.andThen( + Effect.serviceOption(ErrorObserver()), + Option.match({ + onSome: observer => observer.handle(effect), + onNone: () => effect, + }), +) diff --git a/packages/effect-docker/src/Form.ts b/packages/effect-docker/src/Form.ts new file mode 100644 index 0000000..a44da20 --- /dev/null +++ b/packages/effect-docker/src/Form.ts @@ -0,0 +1,396 @@ +import { Array, Cause, Chunk, type Context, type Duration, Effect, Equal, Exit, Fiber, flow, Hash, HashMap, identity, Option, ParseResult, Pipeable, Predicate, Ref, Schema, type Scope, Stream } from "effect" +import type * as React from "react" +import * as Component from "./Component.js" +import * as Mutation from "./Mutation.js" +import * as PropertyPath from "./PropertyPath.js" +import * as Result from "./Result.js" +import * as Subscribable from "./Subscribable.js" +import * as SubscriptionRef from "./SubscriptionRef.js" +import * as SubscriptionSubRef from "./SubscriptionSubRef.js" + + +export const FormTypeId: unique symbol = Symbol.for("@effect-fc/Form/Form") +export type FormTypeId = typeof FormTypeId + +export interface Form +extends Pipeable.Pipeable { + readonly [FormTypeId]: FormTypeId + + readonly schema: Schema.Schema + readonly context: Context.Context + readonly mutation: Mutation.Mutation< + readonly [value: A, form: Form], + MA, ME, MR, MP + > + readonly autosubmit: boolean + readonly debounce: Option.Option + + readonly value: Subscribable.Subscribable> + readonly encodedValue: SubscriptionRef.SubscriptionRef + readonly error: Subscribable.Subscribable> + readonly validationFiber: Subscribable.Subscribable>> + + readonly canSubmit: Subscribable.Subscribable + + field>( + path: P + ): Effect.Effect, PropertyPath.ValueFromPath>> + + readonly run: Effect.Effect + readonly submit: Effect.Effect>, Cause.NoSuchElementException> +} + +export class FormImpl +extends Pipeable.Class() implements Form { + readonly [FormTypeId]: FormTypeId = FormTypeId + + constructor( + readonly schema: Schema.Schema, + readonly context: Context.Context, + readonly mutation: Mutation.Mutation< + readonly [value: A, form: Form], + MA, ME, MR, MP + >, + readonly autosubmit: boolean, + readonly debounce: Option.Option, + + readonly value: SubscriptionRef.SubscriptionRef>, + readonly encodedValue: SubscriptionRef.SubscriptionRef, + readonly error: SubscriptionRef.SubscriptionRef>, + readonly validationFiber: SubscriptionRef.SubscriptionRef>>, + + readonly runSemaphore: Effect.Semaphore, + readonly fieldCache: Ref.Ref>>, + ) { + super() + + this.canSubmit = Subscribable.map( + Subscribable.zipLatestAll(this.value, this.error, this.validationFiber, this.mutation.result), + ([value, error, validationFiber, result]) => ( + Option.isSome(value) && + Option.isNone(error) && + Option.isNone(validationFiber) && + !(Result.isRunning(result) || Result.hasRefreshingFlag(result)) + ), + ) + } + + field>( + path: P + ): Effect.Effect, PropertyPath.ValueFromPath>> { + const key = new FormFieldKey(path) + return this.fieldCache.pipe( + Effect.map(HashMap.get(key)), + Effect.flatMap(Option.match({ + onSome: v => Effect.succeed(v as FormField, PropertyPath.ValueFromPath>), + onNone: () => Effect.tap( + Effect.succeed(makeFormField(this as Form, path)), + v => Ref.update(this.fieldCache, HashMap.set(key, v as FormField)), + ), + })), + ) + } + + readonly canSubmit: Subscribable.Subscribable + + get run(): Effect.Effect { + return this.runSemaphore.withPermits(1)(Stream.runForEach( + this.encodedValue.changes.pipe( + Option.isSome(this.debounce) ? Stream.debounce(this.debounce.value) : identity + ), + + encodedValue => this.validationFiber.pipe( + Effect.andThen(Option.match({ + onSome: Fiber.interrupt, + onNone: () => Effect.void, + })), + Effect.andThen( + Effect.forkScoped(Effect.onExit( + Schema.decode(this.schema, { errors: "all" })(encodedValue), + exit => Effect.andThen( + Exit.matchEffect(exit, { + onSuccess: v => Effect.andThen( + Ref.set(this.value, Option.some(v)), + Ref.set(this.error, Option.none()), + ), + onFailure: c => Option.match(Chunk.findFirst(Cause.failures(c), e => e._tag === "ParseError"), { + onSome: e => Ref.set(this.error, Option.some(e)), + onNone: () => Effect.void, + }), + }), + Ref.set(this.validationFiber, Option.none()), + ), + )).pipe( + Effect.tap(fiber => Ref.set(this.validationFiber, Option.some(fiber))), + Effect.andThen(Fiber.join), + Effect.andThen(value => this.autosubmit + ? Effect.asVoid(Effect.forkScoped(this.submitValue(value))) + : Effect.void + ), + Effect.forkScoped, + ) + ), + Effect.provide(this.context), + ), + )) + } + + get submit(): Effect.Effect>, Cause.NoSuchElementException> { + return this.value.pipe( + Effect.andThen(identity), + Effect.andThen(value => this.submitValue(value)), + ) + } + + submitValue(value: A): Effect.Effect>> { + return Effect.whenEffect( + Effect.tap( + this.mutation.mutate([value, this as any]), + result => Result.isFailure(result) + ? Option.match( + Chunk.findFirst( + Cause.failures(result.cause as Cause.Cause), + e => e._tag === "ParseError", + ), + { + onSome: e => Ref.set(this.error, Option.some(e)), + onNone: () => Effect.void, + }, + ) + : Effect.void + ), + this.canSubmit.get, + ) + } +} + +export const isForm = (u: unknown): u is Form => Predicate.hasProperty(u, FormTypeId) + +export declare namespace make { + export interface Options + extends Mutation.make.Options< + readonly [value: NoInfer, form: Form, NoInfer, NoInfer, unknown, unknown, unknown>], + MA, ME, MR, MP + > { + readonly schema: Schema.Schema + readonly initialEncodedValue: NoInfer + readonly autosubmit?: boolean + readonly debounce?: Duration.DurationInput + } +} + +export const make = Effect.fnUntraced(function* ( + options: make.Options +): Effect.fn.Return< + Form, MP>, + never, + Scope.Scope | R | Result.forkEffect.OutputContext +> { + return new FormImpl( + options.schema, + yield* Effect.context(), + yield* Mutation.make(options), + options.autosubmit ?? false, + Option.fromNullable(options.debounce), + + yield* SubscriptionRef.make(Option.none()), + yield* SubscriptionRef.make(options.initialEncodedValue), + yield* SubscriptionRef.make(Option.none()), + yield* SubscriptionRef.make(Option.none>()), + + yield* Effect.makeSemaphore(1), + yield* Ref.make(HashMap.empty>()), + ) +}) + +export declare namespace service { + export interface Options + extends make.Options {} +} + +export const service = ( + options: service.Options +): Effect.Effect< + Form, MP>, + never, + Scope.Scope | R | Result.forkEffect.OutputContext +> => Effect.tap( + make(options), + form => Effect.forkScoped(form.run), +) + + +export const FormFieldTypeId: unique symbol = Symbol.for("@effect-fc/Form/FormField") +export type FormFieldTypeId = typeof FormFieldTypeId + +export interface FormField +extends Pipeable.Pipeable { + readonly [FormFieldTypeId]: FormFieldTypeId + + readonly value: Subscribable.Subscribable, Cause.NoSuchElementException> + readonly encodedValue: SubscriptionRef.SubscriptionRef + readonly issues: Subscribable.Subscribable + readonly isValidating: Subscribable.Subscribable + readonly isSubmitting: Subscribable.Subscribable +} + +class FormFieldImpl +extends Pipeable.Class() implements FormField { + readonly [FormFieldTypeId]: FormFieldTypeId = FormFieldTypeId + + constructor( + readonly value: Subscribable.Subscribable, Cause.NoSuchElementException>, + readonly encodedValue: SubscriptionRef.SubscriptionRef, + readonly issues: Subscribable.Subscribable, + readonly isValidating: Subscribable.Subscribable, + readonly isSubmitting: Subscribable.Subscribable, + ) { + super() + } +} + +const FormFieldKeyTypeId: unique symbol = Symbol.for("@effect-fc/Form/FormFieldKey") +type FormFieldKeyTypeId = typeof FormFieldKeyTypeId + +class FormFieldKey implements Equal.Equal { + readonly [FormFieldKeyTypeId]: FormFieldKeyTypeId = FormFieldKeyTypeId + constructor(readonly path: PropertyPath.PropertyPath) {} + + [Equal.symbol](that: Equal.Equal) { + return isFormFieldKey(that) && PropertyPath.equivalence(this.path, that.path) + } + [Hash.symbol]() { + return Hash.array(this.path) + } +} + +export const isFormField = (u: unknown): u is FormField => Predicate.hasProperty(u, FormFieldTypeId) +const isFormFieldKey = (u: unknown): u is FormFieldKey => Predicate.hasProperty(u, FormFieldKeyTypeId) + +export const makeFormField = >>( + self: Form, + path: P, +): FormField, PropertyPath.ValueFromPath> => { + return new FormFieldImpl( + Subscribable.mapEffect(self.value, Option.match({ + onSome: v => Option.map(PropertyPath.get(v, path), Option.some), + onNone: () => Option.some(Option.none()), + })), + SubscriptionSubRef.makeFromPath(self.encodedValue, path), + Subscribable.mapEffect(self.error, Option.match({ + onSome: flow( + ParseResult.ArrayFormatter.formatError, + Effect.map(Array.filter(issue => PropertyPath.equivalence(issue.path, path))), + ), + onNone: () => Effect.succeed([]), + })), + Subscribable.map(self.validationFiber, Option.isSome), + Subscribable.map(self.mutation.result, result => Result.isRunning(result) || Result.hasRefreshingFlag(result)), + ) +} + + +export namespace useInput { + export interface Options { + readonly debounce?: Duration.DurationInput + } + + export interface Success { + readonly value: T + readonly setValue: React.Dispatch> + } +} + +export const useInput = Effect.fnUntraced(function* ( + field: FormField, + options?: useInput.Options, +): Effect.fn.Return, Cause.NoSuchElementException, Scope.Scope> { + const internalValueRef = yield* Component.useOnChange(() => Effect.tap( + Effect.andThen(field.encodedValue, SubscriptionRef.make), + internalValueRef => Effect.forkScoped(Effect.all([ + Stream.runForEach( + Stream.drop(field.encodedValue, 1), + upstreamEncodedValue => Effect.whenEffect( + Ref.set(internalValueRef, upstreamEncodedValue), + Effect.andThen(internalValueRef, internalValue => !Equal.equals(upstreamEncodedValue, internalValue)), + ), + ), + + Stream.runForEach( + internalValueRef.changes.pipe( + Stream.drop(1), + Stream.changesWith(Equal.equivalence()), + options?.debounce ? Stream.debounce(options.debounce) : identity, + ), + internalValue => Ref.set(field.encodedValue, internalValue), + ), + ], { concurrency: "unbounded" })), + ), [field, options?.debounce]) + + const [value, setValue] = yield* SubscriptionRef.useSubscriptionRefState(internalValueRef) + return { value, setValue } +}) + +export namespace useOptionalInput { + export interface Options extends useInput.Options { + readonly defaultValue: T + } + + export interface Success extends useInput.Success { + readonly enabled: boolean + readonly setEnabled: React.Dispatch> + } +} + +export const useOptionalInput = Effect.fnUntraced(function* ( + field: FormField>, + options: useOptionalInput.Options, +): Effect.fn.Return, Cause.NoSuchElementException, Scope.Scope> { + const [enabledRef, internalValueRef] = yield* Component.useOnChange(() => Effect.tap( + Effect.andThen( + field.encodedValue, + Option.match({ + onSome: v => Effect.all([SubscriptionRef.make(true), SubscriptionRef.make(v)]), + onNone: () => Effect.all([SubscriptionRef.make(false), SubscriptionRef.make(options.defaultValue)]), + }), + ), + + ([enabledRef, internalValueRef]) => Effect.forkScoped(Effect.all([ + Stream.runForEach( + Stream.drop(field.encodedValue, 1), + + upstreamEncodedValue => Effect.whenEffect( + Option.match(upstreamEncodedValue, { + onSome: v => Effect.andThen( + Ref.set(enabledRef, true), + Ref.set(internalValueRef, v), + ), + onNone: () => Effect.andThen( + Ref.set(enabledRef, false), + Ref.set(internalValueRef, options.defaultValue), + ), + }), + + Effect.andThen( + Effect.all([enabledRef, internalValueRef]), + ([enabled, internalValue]) => !Equal.equals(upstreamEncodedValue, enabled ? Option.some(internalValue) : Option.none()), + ), + ), + ), + + Stream.runForEach( + enabledRef.changes.pipe( + Stream.zipLatest(internalValueRef.changes), + Stream.drop(1), + Stream.changesWith(Equal.equivalence()), + options?.debounce ? Stream.debounce(options.debounce) : identity, + ), + ([enabled, internalValue]) => Ref.set(field.encodedValue, enabled ? Option.some(internalValue) : Option.none()), + ), + ], { concurrency: "unbounded" })), + ), [field, options.debounce]) + + const [enabled, setEnabled] = yield* SubscriptionRef.useSubscriptionRefState(enabledRef) + const [value, setValue] = yield* SubscriptionRef.useSubscriptionRefState(internalValueRef) + return { enabled, setEnabled, value, setValue } +}) diff --git a/packages/effect-docker/src/Memoized.ts b/packages/effect-docker/src/Memoized.ts new file mode 100644 index 0000000..61cea91 --- /dev/null +++ b/packages/effect-docker/src/Memoized.ts @@ -0,0 +1,51 @@ +/** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */ +import { type Equivalence, Function, Predicate } from "effect" +import type * as Component from "./Component.js" + + +export const TypeId: unique symbol = Symbol.for("@effect-fc/Memoized/Memoized") +export type TypeId = typeof TypeId + +export interface Memoized

extends Memoized.Options

{ + readonly [TypeId]: TypeId +} + +export namespace Memoized { + export interface Options

{ + readonly propsAreEqual?: Equivalence.Equivalence

+ } +} + + +const MemoizedProto = Object.freeze({ + [TypeId]: TypeId +} as const) + + +export const isMemoized = (u: unknown): u is Memoized => Predicate.hasProperty(u, TypeId) + +export const memoized = >( + self: T +): T & Memoized> => Object.setPrototypeOf( + Object.assign(function() {}, self), + Object.freeze(Object.setPrototypeOf( + Object.assign({}, MemoizedProto), + Object.getPrototypeOf(self), + )), +) + +export const withOptions: { + & Memoized>( + options: Partial>> + ): (self: T) => T + & Memoized>( + self: T, + options: Partial>>, + ): T +} = Function.dual(2, & Memoized>( + self: T, + options: Partial>>, +): T => Object.setPrototypeOf( + Object.assign(function() {}, self, options), + Object.getPrototypeOf(self), +)) diff --git a/packages/effect-docker/src/Mutation.ts b/packages/effect-docker/src/Mutation.ts new file mode 100644 index 0000000..be4b71e --- /dev/null +++ b/packages/effect-docker/src/Mutation.ts @@ -0,0 +1,128 @@ +import { type Context, Effect, Equal, type Fiber, Option, Pipeable, Predicate, type Scope, Stream, type Subscribable, SubscriptionRef } from "effect" +import * as Result from "./Result.js" + + +export const MutationTypeId: unique symbol = Symbol.for("@effect-fc/Mutation/Mutation") +export type MutationTypeId = typeof MutationTypeId + +export interface Mutation +extends Pipeable.Pipeable { + readonly [MutationTypeId]: MutationTypeId + + readonly context: Context.Context + readonly f: (key: K) => Effect.Effect + readonly initialProgress: P + + readonly latestKey: Subscribable.Subscribable> + readonly fiber: Subscribable.Subscribable>> + readonly result: Subscribable.Subscribable> + readonly latestFinalResult: Subscribable.Subscribable>> + + mutate(key: K): Effect.Effect> + mutateSubscribable(key: K): Effect.Effect>> +} + +export declare namespace Mutation { + export type AnyKey = readonly any[] +} + +export class MutationImpl +extends Pipeable.Class() implements Mutation { + readonly [MutationTypeId]: MutationTypeId = MutationTypeId + + constructor( + readonly context: Context.Context, + readonly f: (key: K) => Effect.Effect, + readonly initialProgress: P, + + readonly latestKey: SubscriptionRef.SubscriptionRef>, + readonly fiber: SubscriptionRef.SubscriptionRef>>, + readonly result: SubscriptionRef.SubscriptionRef>, + readonly latestFinalResult: SubscriptionRef.SubscriptionRef>>, + ) { + super() + } + + mutate(key: K): Effect.Effect> { + return SubscriptionRef.set(this.latestKey, Option.some(key)).pipe( + Effect.andThen(this.start(key)), + Effect.andThen(sub => this.watch(sub)), + Effect.provide(this.context), + ) + } + mutateSubscribable(key: K): Effect.Effect>> { + return SubscriptionRef.set(this.latestKey, Option.some(key)).pipe( + Effect.andThen(this.start(key)), + Effect.tap(sub => Effect.forkScoped(this.watch(sub))), + Effect.provide(this.context), + ) + } + + start(key: K): Effect.Effect< + Subscribable.Subscribable>, + never, + Scope.Scope | R + > { + return this.latestFinalResult.pipe( + Effect.andThen(initial => Result.unsafeForkEffect( + Effect.onExit(this.f(key), () => Effect.andThen( + Effect.all([Effect.fiberId, this.fiber]), + ([currentFiberId, fiber]) => Option.match(fiber, { + onSome: v => Equal.equals(currentFiberId, v.id()) + ? SubscriptionRef.set(this.fiber, Option.none()) + : Effect.void, + onNone: () => Effect.void, + }), + )), + + { + initial: Option.isSome(initial) ? Result.willFetch(initial.value) : Result.initial(), + initialProgress: this.initialProgress, + } as Result.unsafeForkEffect.Options, + )), + Effect.tap(([, fiber]) => SubscriptionRef.set(this.fiber, Option.some(fiber))), + Effect.map(([sub]) => sub), + ) + } + + watch( + sub: Subscribable.Subscribable> + ): Effect.Effect> { + return sub.get.pipe( + Effect.andThen(initial => Stream.runFoldEffect( + sub.changes, + initial, + (_, result) => Effect.as(SubscriptionRef.set(this.result, result), result), + ) as Effect.Effect>), + Effect.tap(result => SubscriptionRef.set(this.latestFinalResult, Option.some(result))), + ) + } +} + +export const isMutation = (u: unknown): u is Mutation => Predicate.hasProperty(u, MutationTypeId) + +export declare namespace make { + export interface Options { + readonly f: (key: K) => Effect.Effect>> + readonly initialProgress?: P + } +} + +export const make = Effect.fnUntraced(function* ( + options: make.Options +): Effect.fn.Return< + Mutation, P>, + never, + Scope.Scope | Result.forkEffect.OutputContext +> { + return new MutationImpl( + yield* Effect.context>(), + options.f as any, + options.initialProgress as P, + + yield* SubscriptionRef.make(Option.none()), + yield* SubscriptionRef.make(Option.none>()), + yield* SubscriptionRef.make(Result.initial()), + yield* SubscriptionRef.make(Option.none>()), + ) +}) diff --git a/packages/effect-docker/src/PropertyPath.ts b/packages/effect-docker/src/PropertyPath.ts new file mode 100644 index 0000000..b73d24d --- /dev/null +++ b/packages/effect-docker/src/PropertyPath.ts @@ -0,0 +1,98 @@ +import { Array, Equivalence, Function, Option, Predicate } from "effect" + + +export type PropertyPath = readonly PropertyKey[] + +type Prev = readonly [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + +export type Paths = readonly [] | ( + D extends never ? readonly [] : + T extends Seen ? readonly [] : + T extends readonly any[] ? { + [K in keyof T as K extends number ? K : never]: + | readonly [K] + | readonly [K, ...Paths] + } extends infer O + ? O[keyof O] + : never + : + T extends object ? { + [K in keyof T as K extends string | number | symbol ? K : never]-?: + NonNullable extends infer V + ? readonly [K] | readonly [K, ...Paths] + : never + } extends infer O + ? O[keyof O] + : never + : + never +) + +export type ValueFromPath = P extends readonly [infer Head, ...infer Tail] + ? Head extends keyof T + ? ValueFromPath + : T extends readonly any[] + ? Head extends number + ? ValueFromPath + : never + : never + : T + + +export const equivalence: Equivalence.Equivalence = Equivalence.array(Equivalence.strict()) + +export const unsafeGet: { + >(path: P): (self: T) => ValueFromPath + >(self: T, path: P): ValueFromPath +} = Function.dual(2, >(self: T, path: P): ValueFromPath => + path.reduce((acc: any, key: any) => acc?.[key], self) +) + +export const get: { + >(path: P): (self: T) => Option.Option> + >(self: T, path: P): Option.Option> +} = Function.dual(2, >(self: T, path: P): Option.Option> => + path.reduce( + (acc: Option.Option, key: any): Option.Option => Option.isSome(acc) + ? Predicate.hasProperty(acc.value, key) + ? Option.some(acc.value[key]) + : Option.none() + : acc, + + Option.some(self), + ) +) + +export const immutableSet: { + >(path: P, value: ValueFromPath): (self: T) => Option.Option + >(self: T, path: P, value: ValueFromPath): Option.Option +} = Function.dual(3, >(self: T, path: P, value: ValueFromPath): Option.Option => { + const key = Array.head(path as PropertyPath) + if (Option.isNone(key)) + return Option.some(value as T) + if (!Predicate.hasProperty(self, key.value)) + return Option.none() + + const child = immutableSet(self[key.value], Option.getOrThrow(Array.tail(path as PropertyPath)), value) + if (Option.isNone(child)) + return child + + if (Array.isArray(self)) + return typeof key.value === "number" + ? Option.some([ + ...self.slice(0, key.value), + child.value, + ...self.slice(key.value + 1), + ] as T) + : Option.none() + + if (typeof self === "object") + return Option.some( + Object.assign( + Object.create(Object.getPrototypeOf(self)), + { ...self, [key.value]: child.value }, + ) + ) + + return Option.none() +}) diff --git a/packages/effect-docker/src/PubSub.ts b/packages/effect-docker/src/PubSub.ts new file mode 100644 index 0000000..36eccbe --- /dev/null +++ b/packages/effect-docker/src/PubSub.ts @@ -0,0 +1,14 @@ +import { Effect, PubSub, type Scope } from "effect" +import type * as React from "react" +import * as Component from "./Component.js" + + +export const usePubSubFromReactiveValues = Effect.fnUntraced(function* ( + values: A +): Effect.fn.Return, never, Scope.Scope> { + const pubsub = yield* Component.useOnMount(() => Effect.acquireRelease(PubSub.unbounded(), PubSub.shutdown)) + yield* Component.useReactEffect(() => Effect.unlessEffect(PubSub.publish(pubsub, values), PubSub.isShutdown(pubsub)), values) + return pubsub +}) + +export * from "effect/PubSub" diff --git a/packages/effect-docker/src/Query.ts b/packages/effect-docker/src/Query.ts new file mode 100644 index 0000000..9c306b7 --- /dev/null +++ b/packages/effect-docker/src/Query.ts @@ -0,0 +1,316 @@ +import { type Cause, type Context, type Duration, Effect, Equal, Fiber, identity, Option, Pipeable, Predicate, type Scope, Stream, Subscribable, SubscriptionRef } from "effect" +import * as QueryClient from "./QueryClient.js" +import * as Result from "./Result.js" + + +export const QueryTypeId: unique symbol = Symbol.for("@effect-fc/Query/Query") +export type QueryTypeId = typeof QueryTypeId + +export interface Query +extends Pipeable.Pipeable { + readonly [QueryTypeId]: QueryTypeId + + readonly context: Context.Context + readonly key: Stream.Stream + readonly f: (key: K) => Effect.Effect + readonly initialProgress: P + + readonly staleTime: Duration.DurationInput + readonly refreshOnWindowFocus: boolean + + readonly latestKey: Subscribable.Subscribable> + readonly fiber: Subscribable.Subscribable>> + readonly result: Subscribable.Subscribable> + readonly latestFinalResult: Subscribable.Subscribable>> + + readonly run: Effect.Effect + fetch(key: K): Effect.Effect> + fetchSubscribable(key: K): Effect.Effect>> + readonly refresh: Effect.Effect, Cause.NoSuchElementException> + readonly refreshSubscribable: Effect.Effect>, Cause.NoSuchElementException> + + readonly invalidateCache: Effect.Effect + invalidateCacheEntry(key: K): Effect.Effect +} + +export declare namespace Query { + export type AnyKey = readonly any[] +} + +export class QueryImpl +extends Pipeable.Class() implements Query { + readonly [QueryTypeId]: QueryTypeId = QueryTypeId + + constructor( + readonly context: Context.Context, + readonly key: Stream.Stream, + readonly f: (key: K) => Effect.Effect, + readonly initialProgress: P, + + readonly staleTime: Duration.DurationInput, + readonly refreshOnWindowFocus: boolean, + + readonly latestKey: SubscriptionRef.SubscriptionRef>, + readonly fiber: SubscriptionRef.SubscriptionRef>>, + readonly result: SubscriptionRef.SubscriptionRef>, + readonly latestFinalResult: SubscriptionRef.SubscriptionRef>>, + + readonly runSemaphore: Effect.Semaphore, + ) { + super() + } + + get run(): Effect.Effect { + return Effect.all([ + Stream.runForEach(this.key, key => this.fetchSubscribable(key)), + + Effect.promise(() => import("@effect/platform-browser")).pipe( + Effect.andThen(({ BrowserStream }) => this.refreshOnWindowFocus + ? Stream.runForEach( + BrowserStream.fromEventListenerWindow("focus"), + () => this.refreshSubscribable, + ) + : Effect.void + ), + Effect.catchAllDefect(() => Effect.void), + ), + ], { concurrency: "unbounded" }).pipe( + Effect.ignore, + this.runSemaphore.withPermits(1), + ) + } + + get interrupt(): Effect.Effect { + return Effect.andThen(this.fiber, Option.match({ + onSome: Fiber.interrupt, + onNone: () => Effect.void, + })) + } + + fetch(key: K): Effect.Effect> { + return this.interrupt.pipe( + Effect.andThen(SubscriptionRef.set(this.latestKey, Option.some(key))), + Effect.andThen(this.latestFinalResult), + Effect.andThen(previous => this.startCached(key, Option.isSome(previous) + ? Result.willFetch(previous.value) as Result.Final + : Result.initial() + )), + Effect.andThen(sub => this.watch(key, sub)), + Effect.provide(this.context), + ) + } + + fetchSubscribable(key: K): Effect.Effect>> { + return this.interrupt.pipe( + Effect.andThen(SubscriptionRef.set(this.latestKey, Option.some(key))), + Effect.andThen(this.latestFinalResult), + Effect.andThen(previous => this.startCached(key, Option.isSome(previous) + ? Result.willFetch(previous.value) as Result.Final + : Result.initial() + )), + Effect.tap(sub => Effect.forkScoped(this.watch(key, sub))), + Effect.provide(this.context), + ) + } + + get refresh(): Effect.Effect, Cause.NoSuchElementException> { + return this.interrupt.pipe( + Effect.andThen(Effect.Do), + Effect.bind("latestKey", () => Effect.andThen(this.latestKey, identity)), + Effect.bind("latestFinalResult", () => this.latestFinalResult), + Effect.bind("subscribable", ({ latestKey, latestFinalResult }) => + this.startCached(latestKey, Option.isSome(latestFinalResult) + ? Result.willRefresh(latestFinalResult.value) as Result.Final + : Result.initial() + ) + ), + Effect.andThen(({ latestKey, subscribable }) => this.watch(latestKey, subscribable)), + Effect.provide(this.context), + ) + } + + get refreshSubscribable(): Effect.Effect< + Subscribable.Subscribable>, + Cause.NoSuchElementException + > { + return this.interrupt.pipe( + Effect.andThen(Effect.Do), + Effect.bind("latestKey", () => Effect.andThen(this.latestKey, identity)), + Effect.bind("latestFinalResult", () => this.latestFinalResult), + Effect.bind("subscribable", ({ latestKey, latestFinalResult }) => + this.startCached(latestKey, Option.isSome(latestFinalResult) + ? Result.willRefresh(latestFinalResult.value) as Result.Final + : Result.initial() + ) + ), + Effect.tap(({ latestKey, subscribable }) => Effect.forkScoped(this.watch(latestKey, subscribable))), + Effect.map(({ subscribable }) => subscribable), + Effect.provide(this.context), + ) + } + + startCached( + key: K, + initial: Result.Initial | Result.Final, + ): Effect.Effect< + Subscribable.Subscribable>, + never, + Scope.Scope | QueryClient.QueryClient | R + > { + return Effect.andThen(this.getCacheEntry(key), Option.match({ + onSome: entry => Effect.andThen( + QueryClient.isQueryClientCacheEntryStale(entry), + isStale => isStale + ? this.start(key, Result.willRefresh(entry.result) as Result.Final) + : Effect.succeed(Subscribable.make({ + get: Effect.succeed(entry.result as Result.Result), + get changes() { return Stream.make(entry.result as Result.Result) }, + })), + ), + onNone: () => this.start(key, initial), + })) + } + + start( + key: K, + initial: Result.Initial | Result.Final, + ): Effect.Effect< + Subscribable.Subscribable>, + never, + Scope.Scope | R + > { + return Result.unsafeForkEffect( + Effect.onExit(this.f(key), () => Effect.andThen( + Effect.all([Effect.fiberId, this.fiber]), + ([currentFiberId, fiber]) => Option.match(fiber, { + onSome: v => Equal.equals(currentFiberId, v.id()) + ? SubscriptionRef.set(this.fiber, Option.none()) + : Effect.void, + onNone: () => Effect.void, + }), + )), + + { + initial, + initialProgress: this.initialProgress, + } as Result.unsafeForkEffect.Options, + ).pipe( + Effect.tap(([, fiber]) => SubscriptionRef.set(this.fiber, Option.some(fiber))), + Effect.map(([sub]) => sub), + ) + } + + watch( + key: K, + sub: Subscribable.Subscribable> + ): Effect.Effect, never, QueryClient.QueryClient> { + return sub.get.pipe( + Effect.andThen(initial => Stream.runFoldEffect( + sub.changes, + initial, + (_, result) => Effect.as(SubscriptionRef.set(this.result, result), result), + ) as Effect.Effect>), + Effect.tap(result => SubscriptionRef.set(this.latestFinalResult, Option.some(result))), + Effect.tap(result => Result.isSuccess(result) + ? this.setCacheEntry(key, result) + : Effect.void + ), + ) + } + + makeCacheKey(key: K): QueryClient.QueryClientCacheKey { + return new QueryClient.QueryClientCacheKey(key, this.f as (key: Query.AnyKey) => Effect.Effect) + } + + getCacheEntry( + key: K + ): Effect.Effect, never, QueryClient.QueryClient> { + return Effect.andThen( + Effect.all([ + Effect.succeed(this.makeCacheKey(key)), + QueryClient.QueryClient, + ]), + ([key, client]) => client.getCacheEntry(key), + ) + } + + setCacheEntry( + key: K, + result: Result.Success, + ): Effect.Effect { + return Effect.andThen( + Effect.all([ + Effect.succeed(this.makeCacheKey(key)), + QueryClient.QueryClient, + ]), + ([key, client]) => client.setCacheEntry(key, result, this.staleTime), + ) + } + + get invalidateCache(): Effect.Effect { + return QueryClient.QueryClient.pipe( + Effect.andThen(client => client.invalidateCacheEntries(this.f as (key: Query.AnyKey) => Effect.Effect)), + Effect.provide(this.context), + ) + } + + invalidateCacheEntry(key: K): Effect.Effect { + return Effect.all([ + Effect.succeed(this.makeCacheKey(key)), + QueryClient.QueryClient, + ]).pipe( + Effect.andThen(([key, client]) => client.invalidateCacheEntry(key)), + Effect.provide(this.context), + ) + } +} + +export const isQuery = (u: unknown): u is Query => Predicate.hasProperty(u, QueryTypeId) + +export declare namespace make { + export interface Options { + readonly key: Stream.Stream + readonly f: (key: NoInfer) => Effect.Effect>> + readonly initialProgress?: P + readonly staleTime?: Duration.DurationInput + readonly refreshOnWindowFocus?: boolean + } +} + +export const make = Effect.fnUntraced(function* ( + options: make.Options +): Effect.fn.Return< + Query, P>, + never, + Scope.Scope | QueryClient.QueryClient | Result.forkEffect.OutputContext +> { + const client = yield* QueryClient.QueryClient + + return new QueryImpl( + yield* Effect.context>(), + options.key, + options.f as any, + options.initialProgress as P, + + options.staleTime ?? client.defaultStaleTime, + options.refreshOnWindowFocus ?? client.defaultRefreshOnWindowFocus, + + yield* SubscriptionRef.make(Option.none()), + yield* SubscriptionRef.make(Option.none>()), + yield* SubscriptionRef.make(Result.initial()), + yield* SubscriptionRef.make(Option.none>()), + + yield* Effect.makeSemaphore(1), + ) +}) + +export const service = ( + options: make.Options +): Effect.Effect< + Query, P>, + never, + Scope.Scope | QueryClient.QueryClient | Result.forkEffect.OutputContext +> => Effect.tap( + make(options), + query => Effect.forkScoped(query.run), +) diff --git a/packages/effect-docker/src/QueryClient.ts b/packages/effect-docker/src/QueryClient.ts new file mode 100644 index 0000000..fde7005 --- /dev/null +++ b/packages/effect-docker/src/QueryClient.ts @@ -0,0 +1,173 @@ +import { DateTime, Duration, Effect, Equal, Equivalence, Hash, HashMap, type Option, Pipeable, Predicate, Schedule, type Scope, type Subscribable, SubscriptionRef } from "effect" +import type * as Query from "./Query.js" +import type * as Result from "./Result.js" + + +export const QueryClientServiceTypeId: unique symbol = Symbol.for("@effect-fc/QueryClient/QueryClientService") +export type QueryClientServiceTypeId = typeof QueryClientServiceTypeId + +export interface QueryClientService extends Pipeable.Pipeable { + readonly [QueryClientServiceTypeId]: QueryClientServiceTypeId + + readonly cache: Subscribable.Subscribable> + readonly cacheGcTime: Duration.DurationInput + readonly defaultStaleTime: Duration.DurationInput + readonly defaultRefreshOnWindowFocus: boolean + + readonly run: Effect.Effect + getCacheEntry(key: QueryClientCacheKey): Effect.Effect> + setCacheEntry( + key: QueryClientCacheKey, + result: Result.Success, + staleTime: Duration.DurationInput, + ): Effect.Effect + invalidateCacheEntries(f: (key: Query.Query.AnyKey) => Effect.Effect): Effect.Effect + invalidateCacheEntry(key: QueryClientCacheKey): Effect.Effect +} + +export class QueryClient extends Effect.Service()("@effect-fc/QueryClient/QueryClient", { + scoped: Effect.suspend(() => service()) +}) {} + +export class QueryClientServiceImpl +extends Pipeable.Class() +implements QueryClientService { + readonly [QueryClientServiceTypeId]: QueryClientServiceTypeId = QueryClientServiceTypeId + + constructor( + readonly cache: SubscriptionRef.SubscriptionRef>, + readonly cacheGcTime: Duration.DurationInput, + readonly defaultStaleTime: Duration.DurationInput, + readonly defaultRefreshOnWindowFocus: boolean, + readonly runSemaphore: Effect.Semaphore, + ) { + super() + } + + get run(): Effect.Effect { + return this.runSemaphore.withPermits(1)(Effect.repeat( + Effect.andThen( + DateTime.now, + now => SubscriptionRef.update(this.cache, HashMap.filter(entry => + Duration.lessThan( + DateTime.distanceDuration(entry.lastAccessedAt, now), + Duration.sum(entry.staleTime, this.cacheGcTime), + ) + )), + ), + Schedule.spaced("30 second"), + )) + } + + getCacheEntry(key: QueryClientCacheKey): Effect.Effect> { + return Effect.all([ + Effect.andThen(this.cache, HashMap.get(key)), + DateTime.now, + ]).pipe( + Effect.map(([entry, now]) => new QueryClientCacheEntry(entry.result, entry.staleTime, entry.createdAt, now)), + Effect.tap(entry => SubscriptionRef.update(this.cache, HashMap.set(key, entry))), + Effect.option, + ) + } + + setCacheEntry( + key: QueryClientCacheKey, + result: Result.Success, + staleTime: Duration.DurationInput, + ): Effect.Effect { + return DateTime.now.pipe( + Effect.map(now => new QueryClientCacheEntry(result, staleTime, now, now)), + Effect.tap(entry => SubscriptionRef.update(this.cache, HashMap.set(key, entry))), + ) + } + + invalidateCacheEntries(f: (key: Query.Query.AnyKey) => Effect.Effect): Effect.Effect { + return SubscriptionRef.update(this.cache, HashMap.filter((_, key) => !Equivalence.strict()(key.f, f))) + } + invalidateCacheEntry(key: QueryClientCacheKey): Effect.Effect { + return SubscriptionRef.update(this.cache, HashMap.remove(key)) + } +} + +export const isQueryClientService = (u: unknown): u is QueryClientService => Predicate.hasProperty(u, QueryClientServiceTypeId) + +export declare namespace make { + export interface Options { + readonly cacheGcTime?: Duration.DurationInput + readonly defaultStaleTime?: Duration.DurationInput + readonly defaultRefreshOnWindowFocus?: boolean + } +} + +export const make = Effect.fnUntraced(function* (options: make.Options = {}): Effect.fn.Return { + return new QueryClientServiceImpl( + yield* SubscriptionRef.make(HashMap.empty()), + options.cacheGcTime ?? "5 minutes", + options.defaultStaleTime ?? "0 minutes", + options.defaultRefreshOnWindowFocus ?? true, + yield* Effect.makeSemaphore(1), + ) +}) + +export declare namespace service { + export interface Options extends make.Options {} +} + +export const service = (options?: service.Options): Effect.Effect => Effect.tap( + make(options), + client => Effect.forkScoped(client.run), +) + + +export const QueryClientCacheKeyTypeId: unique symbol = Symbol.for("@effect-fc/QueryClient/QueryClientCacheKey") +export type QueryClientCacheKeyTypeId = typeof QueryClientCacheKeyTypeId + +export class QueryClientCacheKey +extends Pipeable.Class() +implements Pipeable.Pipeable, Equal.Equal { + readonly [QueryClientCacheKeyTypeId]: QueryClientCacheKeyTypeId = QueryClientCacheKeyTypeId + + constructor( + readonly key: Query.Query.AnyKey, + readonly f: (key: Query.Query.AnyKey) => Effect.Effect, + ) { + super() + } + + [Equal.symbol](that: Equal.Equal) { + return isQueryClientCacheKey(that) && Equivalence.array(Equal.equivalence())(this.key, that.key) && Equivalence.strict()(this.f, that.f) + } + [Hash.symbol]() { + return Hash.combine(Hash.hash(this.f))(Hash.array(this.key)) + } +} + +export const isQueryClientCacheKey = (u: unknown): u is QueryClientCacheKey => Predicate.hasProperty(u, QueryClientCacheKeyTypeId) + + +export const QueryClientCacheEntryTypeId: unique symbol = Symbol.for("@effect-fc/QueryClient/QueryClientCacheEntry") +export type QueryClientCacheEntryTypeId = typeof QueryClientCacheEntryTypeId + +export class QueryClientCacheEntry +extends Pipeable.Class() +implements Pipeable.Pipeable { + readonly [QueryClientCacheEntryTypeId]: QueryClientCacheEntryTypeId = QueryClientCacheEntryTypeId + + constructor( + readonly result: Result.Success, + readonly staleTime: Duration.DurationInput, + readonly createdAt: DateTime.DateTime, + readonly lastAccessedAt: DateTime.DateTime, + ) { + super() + } +} + +export const isQueryClientCacheEntry = (u: unknown): u is QueryClientCacheEntry => Predicate.hasProperty(u, QueryClientCacheEntryTypeId) + +export const isQueryClientCacheEntryStale = ( + self: QueryClientCacheEntry +): Effect.Effect => Effect.andThen( + DateTime.now, + now => Duration.greaterThanOrEqualTo(DateTime.distanceDuration(self.createdAt, now), self.staleTime), +) diff --git a/packages/effect-docker/src/ReactRuntime.ts b/packages/effect-docker/src/ReactRuntime.ts new file mode 100644 index 0000000..a1a858b --- /dev/null +++ b/packages/effect-docker/src/ReactRuntime.ts @@ -0,0 +1,85 @@ +/** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */ +import { Effect, Layer, ManagedRuntime, Predicate, Runtime, Scope } from "effect" +import * as React from "react" +import * as Component from "./Component.js" +import * as ErrorObserver from "./ErrorObserver.js" +import * as QueryClient from "./QueryClient.js" + + +export const TypeId: unique symbol = Symbol.for("@effect-fc/ReactRuntime/ReactRuntime") +export type TypeId = typeof TypeId + +export interface ReactRuntime { + new(_: never): Record + readonly [TypeId]: TypeId + readonly runtime: ManagedRuntime.ManagedRuntime + readonly context: React.Context> +} + +const ReactRuntimeProto = Object.freeze({ [TypeId]: TypeId } as const) + +export const Prelude: Layer.Layer< + | Component.ScopeMap + | ErrorObserver.ErrorObserver + | QueryClient.QueryClient +> = Layer.mergeAll( + Component.ScopeMap.Default, + ErrorObserver.layer, + QueryClient.QueryClient.Default, +) + + +export const isReactRuntime = (u: unknown): u is ReactRuntime => Predicate.hasProperty(u, TypeId) + +export const make = ( + layer: Layer.Layer, + memoMap?: Layer.MemoMap, +): ReactRuntime | R, ER> => Object.setPrototypeOf( + Object.assign(function() {}, { + runtime: ManagedRuntime.make( + Layer.merge(layer, Prelude), + memoMap, + ), + // biome-ignore lint/style/noNonNullAssertion: context initialization + context: React.createContext>(null!), + }), + ReactRuntimeProto, +) + + +export namespace Provider { + export interface Props extends React.SuspenseProps { + readonly runtime: ReactRuntime + readonly children?: React.ReactNode + } +} + +export const Provider = ( + { runtime, children, ...suspenseProps }: Provider.Props +): React.ReactNode => { + const promise = React.useMemo(() => Effect.runPromise(runtime.runtime.runtimeEffect), [runtime]) + + return React.createElement( + React.Suspense, + suspenseProps, + React.createElement(ProviderInner, { runtime, promise, children }), + ) +} + +const ProviderInner = ( + { runtime, promise, children }: { + readonly runtime: ReactRuntime + readonly promise: Promise> + readonly children?: React.ReactNode + } +): React.ReactNode => { + const effectRuntime = React.use(promise) + const scope = Runtime.runSync(effectRuntime)(Component.useScope([effectRuntime])) + Runtime.runSync(effectRuntime)(Effect.provideService( + Component.useOnChange(() => Effect.addFinalizer(() => runtime.runtime.disposeEffect), [scope]), + Scope.Scope, + scope, + )) + + return React.createElement(runtime.context, { value: effectRuntime }, children) +} diff --git a/packages/effect-docker/src/Result.ts b/packages/effect-docker/src/Result.ts new file mode 100644 index 0000000..3cef66c --- /dev/null +++ b/packages/effect-docker/src/Result.ts @@ -0,0 +1,279 @@ +import { Cause, Context, Data, Effect, Equal, Exit, type Fiber, Hash, Layer, Match, Pipeable, Predicate, PubSub, pipe, Ref, type Scope, Stream, Subscribable } from "effect" + + +export const ResultTypeId: unique symbol = Symbol.for("@effect-fc/Result/Result") +export type ResultTypeId = typeof ResultTypeId + +export type Result = ( + | Initial + | Running

+ | Final +) + +// biome-ignore lint/complexity/noBannedTypes: "{}" is relevant here +export type Final = (Success | Failure) & ({} | Flags

) +export type Flags

= WillFetch | WillRefresh | Refreshing

+ +export declare namespace Result { + export interface Prototype extends Pipeable.Pipeable, Equal.Equal { + readonly [ResultTypeId]: ResultTypeId + } + + export type Success> = [R] extends [Result] ? A : never + export type Failure> = [R] extends [Result] ? E : never + export type Progress> = [R] extends [Result] ? P : never +} + +export declare namespace Flags { + export type Keys = keyof WillFetch & WillRefresh & Refreshing +} + +export interface Initial extends Result.Prototype { + readonly _tag: "Initial" +} + +export interface Running

extends Result.Prototype { + readonly _tag: "Running" + readonly progress: P +} + +export interface Success extends Result.Prototype { + readonly _tag: "Success" + readonly value: A +} + +export interface Failure extends Result.Prototype { + readonly _tag: "Failure" + readonly cause: Cause.Cause +} + +export interface WillFetch { + readonly _flag: "WillFetch" +} + +export interface WillRefresh { + readonly _flag: "WillRefresh" +} + +export interface Refreshing

{ + readonly _flag: "Refreshing" + readonly progress: P +} + + +const ResultPrototype = Object.freeze({ + ...Pipeable.Prototype, + [ResultTypeId]: ResultTypeId, + + [Equal.symbol](this: Result, that: Result): boolean { + if (this._tag !== that._tag || (this as Flags)._flag !== (that as Flags)._flag) + return false + if (hasRefreshingFlag(this) && !Equal.equals(this.progress, (that as Refreshing).progress)) + return false + return Match.value(this).pipe( + Match.tag("Initial", () => true), + Match.tag("Running", self => Equal.equals(self.progress, (that as Running).progress)), + Match.tag("Success", self => Equal.equals(self.value, (that as Success).value)), + Match.tag("Failure", self => Equal.equals(self.cause, (that as Failure).cause)), + Match.exhaustive, + ) + }, + + [Hash.symbol](this: Result): number { + return pipe(Hash.string(this._tag), + tagHash => Match.value(this).pipe( + Match.tag("Initial", () => tagHash), + Match.tag("Running", self => Hash.combine(Hash.hash(self.progress))(tagHash)), + Match.tag("Success", self => Hash.combine(Hash.hash(self.value))(tagHash)), + Match.tag("Failure", self => Hash.combine(Hash.hash(self.cause))(tagHash)), + Match.exhaustive, + ), + Hash.combine(Hash.hash((this as Flags)._flag)), + hash => hasRefreshingFlag(this) + ? Hash.combine(Hash.hash(this.progress))(hash) + : hash, + Hash.cached(this), + ) + }, +} as const satisfies Result.Prototype) + + +export const isResult = (u: unknown): u is Result => Predicate.hasProperty(u, ResultTypeId) +export const isFinal = (u: unknown): u is Final => isResult(u) && (isSuccess(u) || isFailure(u)) +export const isInitial = (u: unknown): u is Initial => isResult(u) && u._tag === "Initial" +export const isRunning = (u: unknown): u is Running => isResult(u) && u._tag === "Running" +export const isSuccess = (u: unknown): u is Success => isResult(u) && u._tag === "Success" +export const isFailure = (u: unknown): u is Failure => isResult(u) && u._tag === "Failure" +export const hasFlag = (u: unknown): u is Flags => isResult(u) && Predicate.hasProperty(u, "_flag") +export const hasWillFetchFlag = (u: unknown): u is WillFetch => isResult(u) && Predicate.hasProperty(u, "_flag") && u._flag === "WillFetch" +export const hasWillRefreshFlag = (u: unknown): u is WillRefresh => isResult(u) && Predicate.hasProperty(u, "_flag") && u._flag === "WillRefresh" +export const hasRefreshingFlag = (u: unknown): u is Refreshing => isResult(u) && Predicate.hasProperty(u, "_flag") && u._flag === "Refreshing" + +export const initial: { + (): Initial + (): Result +} = (): Initial => Object.setPrototypeOf({ _tag: "Initial" }, ResultPrototype) +export const running =

(progress?: P): Running

=> Object.setPrototypeOf({ _tag: "Running", progress }, ResultPrototype) +export const succeed = (value: A): Success => Object.setPrototypeOf({ _tag: "Success", value }, ResultPrototype) +export const fail = (cause: Cause.Cause ): Failure => Object.setPrototypeOf({ _tag: "Failure", cause }, ResultPrototype) + +export const willFetch = >( + result: R +): Omit & WillFetch => Object.setPrototypeOf( + Object.assign({}, result, { _flag: "WillFetch" }), + Object.getPrototypeOf(result), +) + +export const willRefresh = >( + result: R +): Omit & WillRefresh => Object.setPrototypeOf( + Object.assign({}, result, { _flag: "WillRefresh" }), + Object.getPrototypeOf(result), +) + +export const refreshing = , P = never>( + result: R, + progress?: P, +): Omit & Refreshing

=> Object.setPrototypeOf( + Object.assign({}, result, { _flag: "Refreshing", progress }), + Object.getPrototypeOf(result), +) + +export const fromExit: { + (exit: Exit.Success): Success + (exit: Exit.Failure): Failure + (exit: Exit.Exit): Success | Failure +} = exit => (exit._tag === "Success" ? succeed(exit.value) : fail(exit.cause)) as any + +export const toExit: { + (self: Success): Exit.Success + (self: Failure): Exit.Failure + (self: Final): Exit.Exit + (self: Result): Exit.Exit +} = (self: Result): any => { + switch (self._tag) { + case "Success": + return Exit.succeed(self.value) + case "Failure": + return Exit.failCause(self.cause) + default: + return Exit.fail(new Cause.NoSuchElementException()) + } +} + + +export interface State { + readonly get: Effect.Effect> + readonly set: (v: Result) => Effect.Effect +} + +export const State = (): Context.Tag, State> => Context.GenericTag("@effect-fc/Result/State") + +export interface Progress

{ + readonly update: ( + f: (previous: P) => Effect.Effect + ) => Effect.Effect +} + +export class PreviousResultNotRunningNorRefreshing extends Data.TaggedError("@effect-fc/Result/PreviousResultNotRunningNorRefreshing")<{ + readonly previous: Result +}> {} + +export const Progress =

(): Context.Tag, Progress

> => Context.GenericTag("@effect-fc/Result/Progress") + +export const makeProgressLayer = (): Layer.Layer< + Progress

, + never, + State +> => Layer.effect(Progress

(), Effect.gen(function*() { + const state = yield* State() + + return { + update: (f: (previous: P) => Effect.Effect) => Effect.Do.pipe( + Effect.bind("previous", () => Effect.andThen(state.get, previous => + (isRunning(previous) || hasRefreshingFlag(previous)) + ? Effect.succeed(previous) + : Effect.fail(new PreviousResultNotRunningNorRefreshing({ previous })), + )), + Effect.bind("progress", ({ previous }) => f(previous.progress)), + Effect.let("next", ({ previous, progress }) => isRunning(previous) + ? running(progress) + : refreshing(previous, progress) as Final & Refreshing

+ ), + Effect.andThen(({ next }) => state.set(next)), + ), + } +})) + + +export namespace unsafeForkEffect { + export type OutputContext = Exclude | Progress

| Progress> + + export interface Options { + readonly initial?: Initial | Final + readonly initialProgress?: P + } +} + +export const unsafeForkEffect = ( + effect: Effect.Effect, + options?: unsafeForkEffect.Options, NoInfer, P>, +): Effect.Effect< + readonly [result: Subscribable.Subscribable, never, never>, fiber: Fiber.Fiber], + never, + Scope.Scope | unsafeForkEffect.OutputContext +> => Effect.Do.pipe( + Effect.bind("ref", () => Ref.make(options?.initial ?? initial())), + Effect.bind("pubsub", () => PubSub.unbounded>()), + Effect.bind("fiber", ({ ref, pubsub }) => Effect.forkScoped(State().pipe( + Effect.andThen(state => state.set( + (isFinal(options?.initial) && hasWillRefreshFlag(options?.initial)) + ? refreshing(options.initial, options?.initialProgress) as Result + : running(options?.initialProgress) + ).pipe( + Effect.andThen(effect), + Effect.onExit(exit => Effect.andThen( + state.set(fromExit(exit)), + Effect.forkScoped(PubSub.shutdown(pubsub)), + )), + )), + Effect.provide(Layer.empty.pipe( + Layer.provideMerge(makeProgressLayer()), + Layer.provideMerge(Layer.succeed(State(), { + get: ref, + set: v => Effect.andThen(Ref.set(ref, v), PubSub.publish(pubsub, v)) + })), + )), + ))), + Effect.map(({ ref, pubsub, fiber }) => [ + Subscribable.make({ + get: ref, + changes: Stream.unwrapScoped(Effect.map( + Effect.all([ref, Stream.fromPubSub(pubsub, { scoped: true })]), + ([latest, stream]) => Stream.concat(Stream.make(latest), stream), + )), + }), + fiber, + ]), +) as Effect.Effect< + readonly [result: Subscribable.Subscribable, never, never>, fiber: Fiber.Fiber], + never, + Scope.Scope | unsafeForkEffect.OutputContext +> + +export namespace forkEffect { + export type InputContext = R extends Progress ? [X] extends [P] ? R : never : R + export type OutputContext = unsafeForkEffect.OutputContext + export interface Options extends unsafeForkEffect.Options {} +} + +export const forkEffect: { + ( + effect: Effect.Effect>>, + options?: forkEffect.Options, NoInfer, P>, + ): Effect.Effect< + readonly [result: Subscribable.Subscribable, never, never>, fiber: Fiber.Fiber], + never, + Scope.Scope | forkEffect.OutputContext + > +} = unsafeForkEffect diff --git a/packages/effect-docker/src/SetStateAction.ts b/packages/effect-docker/src/SetStateAction.ts new file mode 100644 index 0000000..10a596b --- /dev/null +++ b/packages/effect-docker/src/SetStateAction.ts @@ -0,0 +1,12 @@ +import { Function } from "effect" +import type * as React from "react" + + +export const value: { + (prevState: S): (self: React.SetStateAction) => S + (self: React.SetStateAction, prevState: S): S +} = Function.dual(2, (self: React.SetStateAction, prevState: S): S => + typeof self === "function" + ? (self as (prevState: S) => S)(prevState) + : self +) diff --git a/packages/effect-docker/src/Stream.ts b/packages/effect-docker/src/Stream.ts new file mode 100644 index 0000000..4eab704 --- /dev/null +++ b/packages/effect-docker/src/Stream.ts @@ -0,0 +1,33 @@ +import { Effect, Equivalence, Option, Stream } from "effect" +import * as React from "react" +import * as Component from "./Component.js" + + +export const useStream: { + ( + stream: Stream.Stream + ): Effect.Effect, never, R> + , E, R>( + stream: Stream.Stream, + initialValue: A, + ): Effect.Effect, never, R> +} = Effect.fnUntraced(function* , E, R>( + stream: Stream.Stream, + initialValue?: A, +) { + const [reactStateValue, setReactStateValue] = React.useState(() => initialValue + ? Option.some(initialValue) + : Option.none() + ) + + yield* Component.useReactEffect(() => Effect.forkScoped( + Stream.runForEach( + Stream.changesWith(stream, Equivalence.strict()), + v => Effect.sync(() => setReactStateValue(Option.some(v))), + ) + ), [stream]) + + return reactStateValue as Option.Some +}) + +export * from "effect/Stream" diff --git a/packages/effect-docker/src/Subscribable.ts b/packages/effect-docker/src/Subscribable.ts new file mode 100644 index 0000000..138f16e --- /dev/null +++ b/packages/effect-docker/src/Subscribable.ts @@ -0,0 +1,52 @@ +import { Effect, Equivalence, Stream, Subscribable } from "effect" +import * as React from "react" +import * as Component from "./Component.js" + + +export const zipLatestAll = []>( + ...elements: T +): Subscribable.Subscribable< + [T[number]] extends [never] + ? never + : { [K in keyof T]: T[K] extends Subscribable.Subscribable ? A : never }, + [T[number]] extends [never] ? never : T[number] extends Subscribable.Subscribable ? E : never, + [T[number]] extends [never] ? never : T[number] extends Subscribable.Subscribable ? R : never +> => Subscribable.make({ + get: Effect.all(elements.map(v => v.get)), + changes: Stream.zipLatestAll(...elements.map(v => v.changes)), +}) as any + +export declare namespace useSubscribables { + export type Success[]> = [T[number]] extends [never] + ? never + : { [K in keyof T]: T[K] extends Subscribable.Subscribable ? A : never } + + export interface Options { + readonly equivalence?: Equivalence.Equivalence + } +} + +export const useSubscribables = Effect.fnUntraced(function* []>( + elements: T, + options?: useSubscribables.Options>>, +): Effect.fn.Return< + useSubscribables.Success, + [T[number]] extends [never] ? never : T[number] extends Subscribable.Subscribable ? E : never, + [T[number]] extends [never] ? never : T[number] extends Subscribable.Subscribable ? R : never +> { + const [reactStateValue, setReactStateValue] = React.useState( + yield* Component.useOnMount(() => Effect.all(elements.map(v => v.get))) + ) + + yield* Component.useReactEffect(() => Stream.zipLatestAll(...elements.map(ref => ref.changes)).pipe( + Stream.changesWith((options?.equivalence as Equivalence.Equivalence | undefined) ?? Equivalence.array(Equivalence.strict())), + Stream.runForEach(v => + Effect.sync(() => setReactStateValue(v)) + ), + Effect.forkScoped, + ), elements) + + return reactStateValue as any +}) + +export * from "effect/Subscribable" diff --git a/packages/effect-docker/src/SubscriptionRef.ts b/packages/effect-docker/src/SubscriptionRef.ts new file mode 100644 index 0000000..0671ec5 --- /dev/null +++ b/packages/effect-docker/src/SubscriptionRef.ts @@ -0,0 +1,61 @@ +import { Effect, Equivalence, Ref, Stream, SubscriptionRef } from "effect" +import * as React from "react" +import * as Component from "./Component.js" +import * as SetStateAction from "./SetStateAction.js" + + +export declare namespace useSubscriptionRefState { + export interface Options { + readonly equivalence?: Equivalence.Equivalence + } +} + +export const useSubscriptionRefState = Effect.fnUntraced(function* ( + ref: SubscriptionRef.SubscriptionRef, + options?: useSubscriptionRefState.Options>, +): Effect.fn.Return>]> { + const [reactStateValue, setReactStateValue] = React.useState(yield* Component.useOnMount(() => ref)) + + yield* Component.useReactEffect(() => Effect.forkScoped( + Stream.runForEach( + Stream.changesWith(ref.changes, options?.equivalence ?? Equivalence.strict()), + v => Effect.sync(() => setReactStateValue(v)), + ) + ), [ref]) + + const setValue = yield* Component.useCallbackSync( + (setStateAction: React.SetStateAction) => Effect.andThen( + Ref.updateAndGet(ref, prevState => SetStateAction.value(setStateAction, prevState)), + v => setReactStateValue(v), + ), + [ref], + ) + + return [reactStateValue, setValue] +}) + +export declare namespace useSubscriptionRefFromState { + export interface Options { + readonly equivalence?: Equivalence.Equivalence + } +} + +export const useSubscriptionRefFromState = Effect.fnUntraced(function* ( + [value, setValue]: readonly [A, React.Dispatch>], + options?: useSubscriptionRefFromState.Options>, +): Effect.fn.Return> { + const ref = yield* Component.useOnChange(() => Effect.tap( + SubscriptionRef.make(value), + ref => Effect.forkScoped( + Stream.runForEach( + Stream.changesWith(ref.changes, options?.equivalence ?? Equivalence.strict()), + v => Effect.sync(() => setValue(v)), + ) + ), + ), [setValue]) + + yield* Component.useReactEffect(() => Ref.set(ref, value), [value]) + return ref +}) + +export * from "effect/SubscriptionRef" diff --git a/packages/effect-docker/src/SubscriptionSubRef.ts b/packages/effect-docker/src/SubscriptionSubRef.ts new file mode 100644 index 0000000..95e8b0e --- /dev/null +++ b/packages/effect-docker/src/SubscriptionSubRef.ts @@ -0,0 +1,186 @@ +import { Chunk, Effect, Effectable, Option, Predicate, Readable, Ref, Stream, Subscribable, SubscriptionRef, SynchronizedRef, type Types, type Unify } from "effect" +import * as PropertyPath from "./PropertyPath.js" + + +export const SubscriptionSubRefTypeId: unique symbol = Symbol.for("@effect-fc/SubscriptionSubRef/SubscriptionSubRef") +export type SubscriptionSubRefTypeId = typeof SubscriptionSubRefTypeId + +export interface SubscriptionSubRef> +extends SubscriptionSubRef.Variance, SubscriptionRef.SubscriptionRef { + readonly parent: B + + readonly [Unify.typeSymbol]?: unknown + readonly [Unify.unifySymbol]?: SubscriptionSubRefUnify + readonly [Unify.ignoreSymbol]?: SubscriptionSubRefUnifyIgnore +} + +export declare namespace SubscriptionSubRef { + export interface Variance { + readonly [SubscriptionSubRefTypeId]: { + readonly _A: Types.Invariant + readonly _B: Types.Invariant + } + } +} + +export interface SubscriptionSubRefUnify extends SubscriptionRef.SubscriptionRefUnify { + SubscriptionSubRef?: () => Extract> +} + +export interface SubscriptionSubRefUnifyIgnore extends SubscriptionRef.SubscriptionRefUnifyIgnore { + SubscriptionRef?: true +} + + +const refVariance = { _A: (_: any) => _ } +const synchronizedRefVariance = { _A: (_: any) => _ } +const subscriptionRefVariance = { _A: (_: any) => _ } +const subscriptionSubRefVariance = { _A: (_: any) => _, _B: (_: any) => _ } + +class SubscriptionSubRefImpl> +extends Effectable.Class implements SubscriptionSubRef { + readonly [Readable.TypeId]: Readable.TypeId = Readable.TypeId + readonly [Subscribable.TypeId]: Subscribable.TypeId = Subscribable.TypeId + readonly [Ref.RefTypeId] = refVariance + readonly [SynchronizedRef.SynchronizedRefTypeId] = synchronizedRefVariance + readonly [SubscriptionRef.SubscriptionRefTypeId] = subscriptionRefVariance + readonly [SubscriptionSubRefTypeId] = subscriptionSubRefVariance + + readonly get: Effect.Effect + + constructor( + readonly parent: B, + readonly getter: (parentValue: Effect.Effect.Success) => A, + readonly setter: (parentValue: Effect.Effect.Success, value: A) => Effect.Effect.Success, + ) { + super() + this.get = Effect.map(this.parent, this.getter) + } + + commit() { + return this.get + } + + get changes(): Stream.Stream { + return Stream.unwrap( + Effect.map(this.get, a => Stream.concat( + Stream.make(a), + Stream.map(this.parent.changes, this.getter), + )) + ) + } + + modify(f: (a: A) => readonly [C, A]): Effect.Effect { + return this.modifyEffect(a => Effect.succeed(f(a))) + } + + modifyEffect(f: (a: A) => Effect.Effect): Effect.Effect { + return Effect.Do.pipe( + Effect.bind("b", (): Effect.Effect> => this.parent), + Effect.bind("ca", ({ b }) => f(this.getter(b))), + Effect.tap(({ b, ca: [, a] }) => SubscriptionRef.set(this.parent, this.setter(b, a))), + Effect.map(({ ca: [c] }) => c), + ) + } +} + + +export const isSubscriptionSubRef = (u: unknown): u is SubscriptionSubRef> => Predicate.hasProperty(u, SubscriptionSubRefTypeId) + +export const makeFromGetSet = >( + parent: B, + options: { + readonly get: (parentValue: Effect.Effect.Success) => A + readonly set: (parentValue: Effect.Effect.Success, value: A) => Effect.Effect.Success + }, +): SubscriptionSubRef => new SubscriptionSubRefImpl(parent, options.get, options.set) + +export const makeFromPath = < + B extends SubscriptionRef.SubscriptionRef, + const P extends PropertyPath.Paths>, +>( + parent: B, + path: P, +): SubscriptionSubRef, P>, B> => new SubscriptionSubRefImpl( + parent, + parentValue => Option.getOrThrow(PropertyPath.get(parentValue, path)), + (parentValue, value) => Option.getOrThrow(PropertyPath.immutableSet(parentValue, path, value)), +) + +export const makeFromChunkIndex: { + >>( + parent: B, + index: number, + ): SubscriptionSubRef< + Effect.Effect.Success extends Chunk.NonEmptyChunk ? A : never, + B + > + >>( + parent: B, + index: number, + ): SubscriptionSubRef< + Effect.Effect.Success extends Chunk.Chunk ? A : never, + B + > +} = ( + parent: SubscriptionRef.SubscriptionRef>, + index: number, +) => new SubscriptionSubRefImpl( + parent, + parentValue => Chunk.unsafeGet(parentValue, index), + (parentValue, value) => Chunk.replace(parentValue, index, value), +) as any + +export const makeFromChunkFindFirst: { + >>( + parent: B, + findFirstPredicate: Predicate.Predicate extends Chunk.NonEmptyChunk ? A : never>, + ): SubscriptionSubRef< + Effect.Effect.Success extends Chunk.NonEmptyChunk ? A : never, + B + > + >>( + parent: B, + findFirstPredicate: Predicate.Predicate extends Chunk.Chunk ? A : never>, + ): SubscriptionSubRef< + Effect.Effect.Success extends Chunk.Chunk ? A : never, + B + > +} = ( + parent: SubscriptionRef.SubscriptionRef>, + findFirstPredicate: Predicate.Predicate.Any, +) => new SubscriptionSubRefImpl( + parent, + parentValue => Option.getOrThrow(Chunk.findFirst(parentValue, findFirstPredicate)), + (parentValue, value) => Option.getOrThrow(Option.andThen( + Chunk.findFirstIndex(parentValue, findFirstPredicate), + index => Chunk.replace(parentValue, index, value), + )), +) as any + +export const makeFromChunkFindLast: { + >>( + parent: B, + findLastPredicate: Predicate.Predicate extends Chunk.NonEmptyChunk ? A : never>, + ): SubscriptionSubRef< + Effect.Effect.Success extends Chunk.NonEmptyChunk ? A : never, + B + > + >>( + parent: B, + findLastPredicate: Predicate.Predicate extends Chunk.Chunk ? A : never>, + ): SubscriptionSubRef< + Effect.Effect.Success extends Chunk.Chunk ? A : never, + B + > +} = ( + parent: SubscriptionRef.SubscriptionRef>, + findLastPredicate: Predicate.Predicate.Any, +) => new SubscriptionSubRefImpl( + parent, + parentValue => Option.getOrThrow(Chunk.findLast(parentValue, findLastPredicate)), + (parentValue, value) => Option.getOrThrow(Option.andThen( + Chunk.findLastIndex(parentValue, findLastPredicate), + index => Chunk.replace(parentValue, index, value), + )), +) as any diff --git a/packages/effect-docker/src/index.ts b/packages/effect-docker/src/index.ts new file mode 100644 index 0000000..b5fe27e --- /dev/null +++ b/packages/effect-docker/src/index.ts @@ -0,0 +1,17 @@ +export * as Async from "./Async.js" +export * as Component from "./Component.js" +export * as ErrorObserver from "./ErrorObserver.js" +export * as Form from "./Form.js" +export * as Memoized from "./Memoized.js" +export * as Mutation from "./Mutation.js" +export * as PropertyPath from "./PropertyPath.js" +export * as PubSub from "./PubSub.js" +export * as Query from "./Query.js" +export * as QueryClient from "./QueryClient.js" +export * as ReactRuntime from "./ReactRuntime.js" +export * as Result from "./Result.js" +export * as SetStateAction from "./SetStateAction.js" +export * as Stream from "./Stream.js" +export * as Subscribable from "./Subscribable.js" +export * as SubscriptionRef from "./SubscriptionRef.js" +export * as SubscriptionSubRef from "./SubscriptionSubRef.js" diff --git a/packages/effect-docker/src/utils.ts b/packages/effect-docker/src/utils.ts new file mode 100644 index 0000000..44ed408 --- /dev/null +++ b/packages/effect-docker/src/utils.ts @@ -0,0 +1,3 @@ +export type ExcludeKeys = K extends keyof T ? ( + { [P in K]?: never } & Omit +) : T diff --git a/packages/effect-docker/tsconfig.json b/packages/effect-docker/tsconfig.json new file mode 100644 index 0000000..3a9e67c --- /dev/null +++ b/packages/effect-docker/tsconfig.json @@ -0,0 +1,38 @@ +{ + "compilerOptions": { + // Enable latest features + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "NodeNext", + "moduleDetection": "force", + "jsx": "react-jsx", + // "allowJs": true, + + // Bundler mode + "moduleResolution": "NodeNext", + // "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + // "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false, + + // Build + "outDir": "./dist", + "declaration": true, + "sourceMap": true, + + "plugins": [ + { "name": "@effect/language-service" } + ] + }, + + "include": ["./src"] +} diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..1a23465 --- /dev/null +++ b/renovate.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["config:recommended"], + "baseBranchPatterns": ["next"], + "packageRules": [ + { + "matchManagers": ["bun", "npm"], + "matchUpdateTypes": ["minor", "patch"], + "groupName": "bun minor+patch updates", + "groupSlug": "bun-minor-patch" + }, + { + "matchManagers": ["dockerfile", "docker-compose"], + "matchUpdateTypes": ["minor", "patch", "digest"], + "groupName": "docker minor+patch+digest updates", + "groupSlug": "docker-minor-patch-digest" + } + ] +} diff --git a/turbo.json b/turbo.json new file mode 100644 index 0000000..75a43f8 --- /dev/null +++ b/turbo.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://turbo.build/schema.json", + + "tasks": { + "lint:tsc": { + "cache": false + }, + "lint:biome": { + "cache": false + }, + "build": { + "dependsOn": ["^build"], + "inputs": ["./src/**"], + "outputs": ["./dist/**"] + }, + "pack": { + "dependsOn": ["^pack"], + "cache": false + }, + "clean:cache": { + "cache": false + }, + "clean:dist": { + "cache": false + }, + "clean:modules": { + "cache": false + } + } +}