diff --git a/.changeset/effect-4-0-1-floor.md b/.changeset/effect-4-0-1-floor.md new file mode 100644 index 0000000..bbb3687 --- /dev/null +++ b/.changeset/effect-4-0-1-floor.md @@ -0,0 +1,7 @@ +--- +"@systemfsoftware/effect-atom": patch +"@systemfsoftware/effect-atom-react": patch +"@systemfsoftware/storybook-gherkin": patch +--- + +The published `effect` and `@effect/*` dependency ranges now start at 4.0.1 (`^4.0.1`, was `^4`), so a consumer resolves at least Effect 4.0.1. `@systemfsoftware/discern` also bundles `@effect/platform-node` and `@effect/platform-node-shared` 4.0.1 instead of 4.0.0. diff --git a/.changeset/effect-4-0-5-peer-floor.md b/.changeset/effect-4-0-5-peer-floor.md new file mode 100644 index 0000000..327503d --- /dev/null +++ b/.changeset/effect-4-0-5-peer-floor.md @@ -0,0 +1,11 @@ +--- +"@systemfsoftware/effect-atom-react": patch +"@systemfsoftware/effect-atom": patch +"@systemfsoftware/storybook-gherkin": patch +--- + +The Effect dependency now resolves to 4.0.5, and so does every `@effect/*` package installed with it. + +- Effect 4.0.5 requires `effect@^4.0.5` as a peer, so a tree mixing 4.0.5 with an earlier 4.0.x fails at import, not at install. Install the versions your tree's `@effect/*` packages name. +- `FileSystem.File.Info` reports `dev`, `ino`, `rdev`, `nlink` and `blocks` as `bigint` where it reported `number`. Convert at your own boundary with `BigInt` or `Number`. +- A decision's `instructions` and its classify and probability `criteria` accept a JSON object or array as well as a string, and a rating's `criteria` are `{ label, description }` entries rather than bare labels. Read a level's `label`, not the entry. diff --git a/.changeset/full-version-dependency-ranges.md b/.changeset/full-version-dependency-ranges.md new file mode 100644 index 0000000..252d60c --- /dev/null +++ b/.changeset/full-version-dependency-ranges.md @@ -0,0 +1,7 @@ +--- +"@systemfsoftware/effect-atom": patch +"@systemfsoftware/effect-atom-react": patch +"@systemfsoftware/storybook-gherkin": patch +--- + +Published dependency ranges now name a full version: `vitest`, `@vitest/*` and `@types/node` ranges start at 5.0.1 and 26.6.2 (were `^5` and `^26`), `typescript` at 7.0.2 (was `^7`) and `playwright` at 1.63.0 (was `^1`). The `vitest` peer dependency of `@systemfsoftware/effect-gherkin-spec`, `@systemfsoftware/effect-schema-law`, `@systemfsoftware/effect-schema-vite`, `@systemfsoftware/effect-spec-runtime`, `@systemfsoftware/storybook-gherkin`, `@systemfsoftware/trace-spec`, `@systemfsoftware/vitest` and `@systemfsoftware/vitest-config` is now `^5.0.1`, so a consumer needs at least Vitest 5.0.1. `@systemfsoftware/trace-spec` depends on `@opentelemetry/semantic-conventions` `^1.43.0` (was `^1.33`). diff --git a/.changeset/funny-goats-repeat.md b/.changeset/funny-goats-repeat.md new file mode 100644 index 0000000..ffb8a76 --- /dev/null +++ b/.changeset/funny-goats-repeat.md @@ -0,0 +1,7 @@ +--- +"@systemfsoftware/effect-atom": patch +"@systemfsoftware/effect-atom-react": patch +"@systemfsoftware/storybook-gherkin": patch +--- + +The packed `devDependencies` range on `@systemfsoftware/oxlint-config-recommended` moves to its new major. diff --git a/.changeset/hungry-doors-stare.md b/.changeset/hungry-doors-stare.md new file mode 100644 index 0000000..3037bde --- /dev/null +++ b/.changeset/hungry-doors-stare.md @@ -0,0 +1,5 @@ +--- +"@systemfsoftware/oxlint-config-frontend": patch +--- + +The frontend oxlint preset moved into the frontend repository with its rules unchanged. diff --git a/.changeset/loose-moons-assert.md b/.changeset/loose-moons-assert.md new file mode 100644 index 0000000..a6c4da3 --- /dev/null +++ b/.changeset/loose-moons-assert.md @@ -0,0 +1,9 @@ +--- +"@systemfsoftware/storybook-gherkin": minor +--- + +Step contexts now carry `expect`: the assertions of whichever runner the play runs in. + +Under Storybook it stays `storybook/test`'s `expect`. In vitest browser mode (through `@storybook/addon-vitest`) it is vitest's own `expect`, so a step handler can reach the matchers only vitest has — `toMatchScreenshot`, `toMatchAriaInlineSnapshot`, `toMatchTextContent` — as `await ctx.expect(locator).toMatchAriaInlineSnapshot(…)`, without importing `vitest` where a project bans that import. + +The assertions are promise-shaped: `await` them. `vitest` is an optional peer dependency. diff --git a/.changeset/quiet-hooks-subscribe.md b/.changeset/quiet-hooks-subscribe.md new file mode 100644 index 0000000..b5c6635 --- /dev/null +++ b/.changeset/quiet-hooks-subscribe.md @@ -0,0 +1,12 @@ +--- +"@systemfsoftware/effect-atom-react": major +--- + +The code that mounts the app owns the registry; the binding never creates one or writes registry state during render. Call `Atom.Hydration.hydrate(registry, state)` before the first render, while the atoms the state names have not been read yet: hydration assigns each named atom its saved value and replaces a value the registry already holds. + +- `RegistryProvider` requires a `registry`. Make it with `Atom.Registry.make({ initialValues, scheduleTask: AtomReact.scheduleTask })` before the first render and dispose it at teardown. +- `HydrationBoundary` is removed: call `Atom.Hydration.hydrate(registry, state)` before `hydrateRoot`. +- `useAtomInitialValues` and `AnyInitialValue` are removed: pass `initialValues` to `Atom.Registry.make`. +- `useAtomSubscribe` is removed: use `Atom.Registry.subscribe` in a scoped Effect. + +An island makes its registry at module scope; a Storybook story makes it in its loader and disposes it in `beforeEach`'s cleanup. diff --git a/.compound-engineering/config.yaml b/.compound-engineering/config.yaml new file mode 100644 index 0000000..973b461 --- /dev/null +++ b/.compound-engineering/config.yaml @@ -0,0 +1,7 @@ +packs: + - source: compound-packs/ui-state + - source: compound-packs/storybook-testing + - source: https://github.com/systemfsoftware/systemfsoftware + ref: d1ab631935c9439bcd16abf2894b09b843494701 + path: compound-packs + pack: [cell-architecture, boundary-testing, schema-laws, package-topology, mutation-discipline, turbo-task-graph] diff --git a/.gitignore b/.gitignore index cf5e4a6..fbfda96 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,5 @@ apps/site-e2e/artifacts/ .journeys/ .sfs-deps .cache/ +temp/ +.vitest/ diff --git a/.mergify.yml b/.mergify.yml new file mode 100644 index 0000000..563ee81 --- /dev/null +++ b/.mergify.yml @@ -0,0 +1,40 @@ +# Mergify merges every PR into main. A PR that meets queue_conditions is queued automatically; nobody comments. +# The first PR in this repository (SYS-440) is a bootstrap merge: kiro-systemf merges it once with +# --match-head-commit. From the next PR the queue takes over. +queue_rules: + - name: default + merge_method: squash + queue_conditions: + - base = main + - -draft + - -label = hold + - "approved-reviews-by = kiro-systemf[bot]" + - "#changes-requested-reviews-by = 0" + - check-success = check (format) + - check-success = check (lint) + - check-success = check (typecheck) + - check-success = check (test) + - check-success = check (dist) + - check-success = check (sfs-sources) + - check-success = journeys + - check-success = rules + - check-success = lint PR commits + - check-success = changeset · shared tooling + merge_conditions: + - check-success = check (format) + - check-success = check (lint) + - check-success = check (typecheck) + - check-success = check (test) + - check-success = check (dist) + - check-success = check (sfs-sources) + - check-success = journeys + - check-success = rules + - check-success = lint PR commits + - check-success = changeset · shared tooling + +pull_request_rules: + - name: queue when ready + conditions: + - base = main + actions: + queue: diff --git a/README.md b/README.md index 1d1152c..61475de 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ To remove it: 6. `apps/site/alchemy.run.ts`: drop the `migrations` option from `Cloudflare.D1.Database('Database', …)`. 7. `pnpm-workspace.yaml`: drop the `@systemfsoftware/xstate` line under `catalog:` and its mirror under `overrides:`; `apps/site/package.json`: drop the `@systemfsoftware/xstate` dependency. 8. Run `pnpm install --no-frozen-lockfile` from the shell you already have open. It rewrites `pnpm-lock.yaml` without its xstate entries; `--frozen-lockfile` (pnpm's default when `CI` is set) refuses that rewrite. The devshell builds its pnpm store from `pnpm-lock.yaml`, so it can't start again until the lockfile drops xstate. -9. `flake.nix`: drop the `systemfsoftware-xstate` input, its name in the `outputs` arguments, and its two places in the `sfs-deps` derivation (the copy loop and the `jq -s add` merge); then run `nix flake lock`. +9. `flake.nix`: drop the `systemfsoftware-xstate` input, its name in the `outputs` arguments, and its place in the `extraDepsOf` copy loop; then run `nix flake lock`. 10. Delete this README's guestbook text, from "The site ships one example feature" through the D1 note below. Removal leaves a deployed D1 as it is: the `guestbook_entries` table and its `0001_create_guestbook_entries.sql` and `0002_add_guestbook_entry_state.sql` rows in `__alchemy_migrations` stay; drop them from the D1 console in the Cloudflare dashboard with `DROP TABLE guestbook_entries; DELETE FROM __alchemy_migrations WHERE name IN ('0001_create_guestbook_entries.sql', '0002_add_guestbook_entry_state.sql');`. diff --git a/compound-packs/storybook-testing/README.md b/compound-packs/storybook-testing/README.md new file mode 100644 index 0000000..041bdb2 --- /dev/null +++ b/compound-packs/storybook-testing/README.md @@ -0,0 +1,16 @@ +# Storybook Testing Compound Pack + +How a UI's behaviour is proven with Storybook stories: where a story gets its I/O and how each story's fakes start fresh, how a journey reads, how a story finds and asserts, where stories run and what fails them, when a layout claim counts, which test kind proves what a person sees, and where a story's visual evidence comes from. + +Rules: + +- `stories-compose-at-their-own-root`: the preview gives stories port fakes through the provider the production entry uses, each root building its registry before it renders; no first-party module is substituted by `sb.mock`, alias or subpath import. +- `fresh-fakes-per-story`: every story starts from freshly built fakes in a registry its loader builds and its `beforeEach` cleanup disposes, and sets I/O state only through their configurable responses. +- `journeys-are-embedded-gherkin`: a journey is one short embedded scenario whose Given sets state, whose one When acts, whose Then asserts what a person observes, and whose steps hold every query. +- `real-browser-axe-as-error`: stories run in a real browser with axe violations failing the command-line run; an exception disables one named rule with its reason. +- `assert-through-accessible-queries`: play functions and steps find elements by role, label or text, never by test id or selector. +- `pin-the-viewport-a-claim-needs`: a layout claim pins `globals.viewport`, and a responsive claim is proven on each side of the breakpoint. +- `stories-are-the-ui-integration-tests`: user-visible behaviour is proven by stories over the real app, not by a simulated-DOM suite or a shell test against a double. +- `visual-evidence-comes-from-the-runner`: each story's image comes from one capture hook the runner runs for every story, and failure images from Vitest; both folders are ignored run output that CI uploads as they are, and no story calls `page.screenshot`. + +What makes a port fake admissible, and how adapters are tested against local oracles, is governed by boundary-testing (`port-fakes-pass-the-port-contract.md`, `real-system-oracles.md`, `no-mocks-on-internal-glue.md`). The step DSL's API is documented by the `@systemfsoftware/storybook-gherkin` README and its API report. diff --git a/compound-packs/storybook-testing/assert-through-accessible-queries.md b/compound-packs/storybook-testing/assert-through-accessible-queries.md new file mode 100644 index 0000000..0054787 --- /dev/null +++ b/compound-packs/storybook-testing/assert-through-accessible-queries.md @@ -0,0 +1,47 @@ +--- +title: Play functions and steps find elements by role, label or text and assert what a person perceives +applies_when: + - writing a play function or a step definition that finds or asserts on an element + - an AI agent proposes a test id, a CSS selector or `querySelector` in a story + - reviewing an assertion on markup structure or on a class name +tags: [storybook, queries, accessibility, testing-library, play] +--- + +Play functions and steps find elements by role, label or visible text and assert what a person perceives, never by test id or markup selector. A query by role and accessible name is the query a person using assistive technology makes, so it fails when a control loses its name or role. A test id or selector finds an element no person can reach and passes on a restyle that changed what a person sees. + +Testing Library ranks its queries by how closely they resemble the way users interact with the page: queries accessible to everyone first, then semantic queries, with test ids last, for "cases where you can't match by role or text or it doesn't make sense". + +1. **Query through the canvas.** A play function destructures `canvas` from its context, and a step uses the `canvas` on its step context; both are queries scoped to the story's root. Assertions use `expect` from `storybook/test`, so failures render in the Interactions panel. +2. **Role, label, text, in that order.** Find a control by `getByRole` with its accessible name, a field by `getByLabelText`, and content by `getByText`. Scope a query inside a found region with `within`. +3. **No test id, no selector.** Never query by `getByTestId`, `querySelector`, a class name or a DOM path. If no accessible query can find the element, the element lacks an accessible name, and that is the defect to fix. +4. **Assert the perceived result.** Assert visibility, accessible text, and the enabled or pressed state a person perceives. Never assert a class name or attribute only the markup carries. + +```tsx +import type { Meta, StoryObj } from '@storybook/react-vite' +import { expect, within } from 'storybook/test' + +const meta = { component: MessageList, args: { channel: 'General' } } satisfies Meta +export default meta +type Story = StoryObj + +// WRONG: a test id and a class selector find nodes no person can name, so the story +// passes when the list loses its accessible name. +export const ShowsMessageByTestId: Story = { + play: async ({ canvas, canvasElement }) => { + await expect(canvas.getByTestId('message-list')).toBeInTheDocument() + await expect(canvasElement.querySelector('li.message')).toHaveTextContent('Hello all') + }, +} + +// RIGHT: the list is found by its accessible name and the message inside it by its text. +export const ShowsPostedMessage: Story = { + play: async ({ canvas }) => { + const messages = canvas.getByRole('list', { name: 'Messages' }) + await expect(within(messages).getByText('Hello all')).toBeVisible() + }, +} +``` + +Grounds: Testing Library, query priority (https://testing-library.com/docs/queries/about/#priority); Storybook 10.6, Interaction tests (https://storybook.js.org/docs/writing-tests/interaction-testing). + +Gate: `review` — reject a play function or step that queries by test id, `querySelector`, class name or DOM path, or that asserts on markup a person does not perceive. diff --git a/compound-packs/storybook-testing/fresh-fakes-per-story.md b/compound-packs/storybook-testing/fresh-fakes-per-story.md new file mode 100644 index 0000000..2a12aac --- /dev/null +++ b/compound-packs/storybook-testing/fresh-fakes-per-story.md @@ -0,0 +1,140 @@ +--- +title: Every story starts from freshly built fakes in a registry built before it renders, and sets I/O state only through their configurable responses +applies_when: + - a story needs a fault armed, a history seeded or a peer to answer a certain way + - a preview or story creates, seeds, provides or disposes the atom registry a story renders against + - an AI agent proposes a module-scope switch, a shared record, a reset list in `beforeEach`, or a registry created inside a decorator or component + - reviewing why a story passes alone and fails after another story, or the reverse +tags: [storybook, fakes, isolation, configurable-responses, parameters, registry, loaders, lifecycle] +--- + +Every story starts from freshly built fakes and sets I/O state and faults only through those fakes' configurable responses, so no switch or record survives into the next story. A switch at module scope outlives the story that set it. The next story then passes or fails by the order stories ran in, and a reset list kept by hand misses whatever state was added after the list was written. Shore's Configurable Responses names the alternative: the factory that builds the dependency takes the desired response as a parameter, defined by the dependency's externally visible behaviour. + +Freshness is structural. The preview's loader builds each story's registry before the story renders, seeded with a fake built from that story's parameters. Its decorator only provides that registry, and its `beforeEach` returns the cleanup that disposes it. Each story therefore starts from a new registry, and every piece of state its fake Layer allocates is discarded with that registry. Storybook 10.6 runs a story's loaders before render and hands their result to decorators as `context.loaded`. It runs `beforeEach` after the loaders on the same context, and keeps the function `beforeEach` returns as a cleanup: + +- **Storybook UI:** loaders and `beforeEach` run again on every render of the story, an args change included, so each render that reaches `beforeEach` builds its own registry and adds its own cleanup. A render aborted between the two leaves a registry nothing read, which holds seeds and runs no atom. Every cleanup runs when the story is torn down, before Storybook leaves the canvas to the next story. A disposed registry notifies no listener, so it starts no render of the old story's tree. +- **Portable stories (`@storybook/addon-vitest`):** the cleanup runs, after the story's React root unmounts, when the next story starts. + +`afterEach` runs as soon as `play` ends, while the story is still on screen, so it is never where the registry is disposed. Storybook's Interaction tests page requires that state be reset between stories to keep them isolated; this seam meets that requirement by construction rather than by the `beforeEach` reset the page shows. + +1. **State is allocated inside the fake's construction.** A fake that records what it was sent, or serves a history, allocates that state while its Layer is built. It never closes over a variable at module scope. +2. **A story configures responses through `parameters`.** The story states what the fakes answer, such as a fault to arm or a history to seed, in its exported `parameters`. The preview's loader reads those parameters and builds the fake from them, the mechanism Storybook's Mocking providers page documents for varying a provided value per story. +3. **No reset list.** A preview or story never resets fake state in `beforeEach` or a cleanup function. A fake built fresh has nothing to reset, and a list that resets by name misses every switch added after it. The one cleanup a preview's `beforeEach` returns disposes the story's registry. That ends every fake built into the registry at once, and resets none by name. +4. **No arming during play.** A play function or step never flips a fake's switch partway through a story. A story that needs the peer to change its answer configures that sequence of responses up front. +5. **The registry is built before render, never during it.** No decorator, provider or component creates the story's registry in render or in render state (`useState`, `useRef`, a `key` remount), and no story renders a provider that builds its own registry. React keeps no state for a render that suspended before its first mount, and retries that tree from scratch. A registry made in render is therefore rebuilt on every retry, the suspended atom starts again in each new registry, and nothing disposes the abandoned ones. The loader builds the registry with `Atom.Registry.make`, passing `AtomReact.scheduleTask` so atom work keeps the React scheduler the provider used. The decorator provides `context.loaded`'s registry. + +Incident (2026-10-13), from two browser probes: + +- **The effect-atom React binding:** a registry provider whose first mount suspended on an atom never showed its content. It built six registries and started the atom six times. With the `Suspense` boundary inside the provider, the same atom showed its value from one registry. +- **Storybook 10.6 under `@storybook/addon-vitest`:** an owner kept in component state was created twice across a suspended first mount, and nothing disposed the first. An owner built by the story's loader was the same object through the suspended retry, was seen by `beforeEach`, the decorator and the render, and was disposed before the next story built its own. + +```tsx +import type { Preview, StoryObj } from '@storybook/react-vite' +import { Atom } from '@systemfsoftware/effect-atom' +import { AtomReact } from '@systemfsoftware/effect-atom-react' +import { Context, Data, Effect, Layer, Ref } from 'effect' +import { type ReactNode, useState } from 'react' + +class SendFailed extends Data.TaggedError('SendFailed') {} + +class Messages extends Context.Service Effect.Effect + readonly history: Effect.Effect> +}>()('Messages') {} + +const appLayer = Atom.make(Layer.effect(Messages)(Effect.die('no Messages Layer was provided'))) + +// WRONG: the fault switch and the history live at module scope; a story arms the switch +// during play, and the reset list in beforeEach forgets the history. +let sendFails = false +const sent: Array = [] + +const sharedFake = Layer.succeed(Messages)({ + post: (text) => (sendFails ? Effect.fail(new SendFailed()) : Effect.sync(() => void sent.push(text))), + history: Effect.sync(() => [...sent]), +}) + +export const sharedPreview = { + beforeEach: () => { + sendFails = false + }, +} satisfies Preview + +export const SendFailsBySwitch = { + play: () => { + sendFails = true + }, +} satisfies StoryObj + +// WRONG: the decorator builds the registry in render state. A first mount that suspends +// rebuilds it on every retry, and no one disposes the registries it leaves behind. +function StoryRegistry(props: { readonly children: ReactNode }) { + const [registry] = useState(() => Atom.Registry.make({ initialValues: [Atom.initialValue(appLayer, sharedFake)] })) + return {props.children} +} + +export const renderBuiltPreview = { + decorators: [ + (Story, { id }) => ( + + + + ), + ], +} satisfies Preview + +// RIGHT: the story states its responses in parameters. The loader builds the story's registry +// before render, seeded with a fake that allocates its history while it is built; the decorator +// only provides it, and the cleanup beforeEach returns disposes it. +interface MessagesResponses { + readonly sendFails: boolean + readonly history: ReadonlyArray +} + +const noFaults: MessagesResponses = { sendFails: false, history: [] } + +const fakeMessages = (responses: MessagesResponses) => + Layer.effect(Messages)( + Effect.gen(function*() { + const sentHere = yield* Ref.make(responses.history) + return { + post: (text: string) => + responses.sendFails ? Effect.fail(new SendFailed()) : Ref.update(sentHere, (all) => [...all, text]), + history: Ref.get(sentHere), + } + }), + ) + +export default { + loaders: [ + ({ parameters }) => ({ + registry: Atom.Registry.make({ + initialValues: [Atom.initialValue(appLayer, fakeMessages({ ...noFaults, ...parameters['messages'] }))], + scheduleTask: AtomReact.scheduleTask, + }), + }), + ], + beforeEach: ({ loaded }) => () => Atom.Registry.dispose(loaded['registry']), + decorators: [ + (Story, { loaded }) => ( + + + + ), + ], +} satisfies Preview + +export const SendFails = { parameters: { messages: { sendFails: true } } } satisfies StoryObj +``` + +Grounds: + +- James Shore, Testing Without Mocks, Configurable Responses (https://www.jamesshore.com/v2/projects/nullables/testing-without-mocks). +- Storybook 10.6: Mocking providers (https://storybook.js.org/docs/writing-stories/mocking-data-and-modules/mocking-providers), Loaders (https://storybook.js.org/docs/writing-stories/loaders) and Interaction tests (https://storybook.js.org/docs/writing-tests/interaction-testing). +- Storybook source at v10.6.0: + - `StoryRender.ts`: loaders before render (L288), the shared context passed to the story function (L284), an aborted render returning before `beforeEach` (L291-L293), the `beforeEach` cleanup registered (L295-L296), `afterEach` after play (L395), a rerender running the whole render again (L440-L445), and cleanup at teardown before the canvas is torn down (L469-L483). https://github.com/storybookjs/storybook/blob/v10.6.0/code/core/src/preview-api/modules/preview-web/render/StoryRender.ts + - `portable-stories.ts`: cleanups run when the next story starts (L311-L314), then loaders (L327), `beforeEach` (L333) and `afterEach` (L363). https://github.com/storybookjs/storybook/blob/v10.6.0/code/core/src/preview-api/modules/store/csf/portable-stories.ts +- `@systemfsoftware/effect-atom`: a new registry only records its seeds until an atom is read (`packages/atom/effect-atom/src/registry-engine.ts:531-539`); disposing a registry removes every node (`:763-779`), and removing a node clears its listeners (`atom-node.ts:438-442`). +- react.dev, `` caveats: "React does not preserve any state for renders that got suspended before they were able to mount for the first time" (https://react.dev/reference/react/Suspense). + +Gate: `review` — reject fake state held at module scope, a switch flipped during play, a reset list in `beforeEach` or a cleanup function, or a story registry created by a decorator, provider or component during render; verify each story's responses come from its own `parameters` into a fake seeded into a registry the story's loader builds, and that the cleanup `beforeEach` returns disposes that registry. diff --git a/compound-packs/storybook-testing/journeys-are-embedded-gherkin.md b/compound-packs/storybook-testing/journeys-are-embedded-gherkin.md new file mode 100644 index 0000000..df64e66 --- /dev/null +++ b/compound-packs/storybook-testing/journeys-are-embedded-gherkin.md @@ -0,0 +1,76 @@ +--- +title: A journey is one short embedded Gherkin scenario whose steps hold every query and call +applies_when: + - writing a story that walks the app through one cross-capability behaviour + - adding Given, When or Then steps with `@systemfsoftware/storybook-gherkin` + - reviewing a scenario that carries a selector, a URL or a setup call +tags: [storybook, gherkin, bdd, journey, domain-language, play] +--- + +A cross-capability journey is one short scenario written with `@systemfsoftware/storybook-gherkin` in the story file: Given puts the app in a known state without user interaction, When is the one action, Then asserts an outcome a person can observe, step definitions hold every query and call, and no `.feature` file duplicates it. The scenario is the only statement of the behaviour a reader gets. A scenario that names markup, a URL or a setup call describes how the page is built instead of what the person can do, and it breaks on every restyle while the behaviour holds. + +The Cucumber reference fixes what each keyword means. Given steps "put the system in a known state before the user (or external system) starts interacting with the system", When steps "describe an event, or an action", and a Then outcome "should be on an observable output". Implementation details "should be hidden in the step definitions". + +1. **Given sets state, never by interacting.** A Given establishes a precondition, such as who is signed in or what a channel already holds. The state comes from the story's fakes. It never clicks, types or navigates. +2. **One When.** A scenario has one When: the single action whose outcome it proves. Two actions are two behaviours and two scenarios. +3. **Then is what a person observes.** A Then asserts what comes out of the app: text, an announcement, an enabled or disabled control. It never asserts a fake's call record or an atom's internal value. +4. **Steps hold the queries.** Every query, call and assertion lives in a step definition, a named step built once with `Given`, `When` or `Then` and reused across scenarios. Group repeated preconditions with `Steps`, and pass values through `capture` holes and the scenario's `with`. The scenario itself reads as domain prose. +5. **Embedded, never a sidecar.** The story is the specification. A `.feature` file beside it is forbidden by `storybook-gherkin`'s `SG5`. Story annotations such as `parameters` and `tags` go on the exported object literal that spreads the scenario, as its `SG10` requires. + +```tsx +import type { Meta } from '@storybook/react-vite' +import { capture, feature, Given, Steps, Then, When } from '@systemfsoftware/storybook-gherkin' +import { expect, within } from 'storybook/test' + +const meta = { title: 'Journeys/Post a message', component: App } satisfies Meta +const f = feature(meta, {}) +export default meta + +// WRONG: the scenario carries a URL, a markup selector and two actions, and its Then +// asserts nothing a person sees. +export const PostingWorks = f.scenario( + 'Ada opens the channel, posts and then edits a message', + { with: { text: 'Hello all' } }, + Given`Ada opens /#/channels/general`(() => { + window.location.hash = '#/channels/general' + }), + When`Ada types ${capture('text')} into textarea.composer, sends it and edits it`(async (ctx, caps) => { + const composer = ctx.canvasElement.querySelector('textarea.composer') + if (composer instanceof HTMLTextAreaElement) await ctx.userEvent.type(composer, caps.text) + await ctx.userEvent.click(ctx.canvas.getByRole('button', { name: 'Send' })) + await ctx.userEvent.click(ctx.canvas.getByRole('button', { name: 'Edit' })) + }), + Then`it worked`(() => {}), +) + +// RIGHT: domain-language steps defined once; the scenario is one behaviour in prose. +const isSignedIn = Given`${capture('person')} is signed in to ${capture('channel')}`(async (ctx, caps) => { + await expect(await ctx.canvas.findByRole('heading', { name: caps.channel })).toBeVisible() +}) +const SignedIn = Steps(isSignedIn) + +const postsMessage = When`${capture('person')} posts ${capture('text')}`(async (ctx, caps) => { + await ctx.userEvent.type(ctx.canvas.getByRole('textbox', { name: 'Message' }), caps.text) + await ctx.userEvent.click(ctx.canvas.getByRole('button', { name: 'Send' })) +}) + +const messageIsShown = Then`${capture('text')} is shown in the channel`(async (ctx, caps) => { + const messages = ctx.canvas.getByRole('list', { name: 'Messages' }) + await expect(await within(messages).findByText(caps.text)).toBeVisible() +}) + +export const PostedMessageIsShown = { + ...f.scenario( + 'Ada posts a message and the channel shows it', + { with: { person: 'Ada', channel: 'General', text: 'Hello all' } }, + SignedIn, + postsMessage, + messageIsShown, + ), + tags: ['journey'], +} +``` + +Grounds: Cucumber, Gherkin reference (https://cucumber.io/docs/gherkin/reference); the `storybook-gherkin` API report (`packages/gherkin/storybook-gherkin/etc/storybook-gherkin.api.md`) and its `AGENTS.md` mandates `SG5` and `SG10`. + +Gate: `review` — reject a scenario whose Given interacts, that has more than one When, whose Then asserts something no person observes, that carries a selector, URL or setup call in its text or inline handlers, or that has a `.feature` file beside its story. diff --git a/compound-packs/storybook-testing/pin-the-viewport-a-claim-needs.md b/compound-packs/storybook-testing/pin-the-viewport-a-claim-needs.md new file mode 100644 index 0000000..a9c357c --- /dev/null +++ b/compound-packs/storybook-testing/pin-the-viewport-a-claim-needs.md @@ -0,0 +1,54 @@ +--- +title: A story that claims a layout pins its viewport, and a responsive claim is proven on each side of the breakpoint +applies_when: + - a story asserts that something collapses, hides, reflows or appears at a screen size + - an AI agent writes a responsive story with no viewport set + - reviewing a claim that a layout responds to width +tags: [storybook, viewport, layout, responsive, globals] +--- + +A story that claims a layout pins its viewport through `globals.viewport`, and a responsive claim is proven by a story on each side of the breakpoint. A story with no pinned viewport runs at whatever size the test page defaults to, so a claim about narrow screens is tested at a wide one and passes or fails for a reason it does not state. One side of a breakpoint proves only that the layout looks that way at one width. It cannot show that the layout responds to width at all. + +1. **Pin through `globals.viewport`.** A story or its meta sets `globals: { viewport: { value, isRotated } }`, which Storybook's Viewport docs define. The Vitest addon applies that global to the test page before the story runs. A viewport set in `globals` cannot be changed from the toolbar, so the story always renders at that size. +2. **Name a configured viewport.** The `value` is a key the project's viewport options define. The Vitest addon falls back to its default size when the key is unknown and does not report it, so a misspelt key silently tests the wrong width. Which keys exist and their sizes stay in the project's Storybook configuration. +3. **Both sides of the breakpoint.** A claim that the layout changes at a width is proven by one story pinned below the breakpoint and one pinned above it. Each asserts what a person sees at that size. + +```tsx +import type { Meta, StoryObj } from '@storybook/react-vite' +import { expect } from 'storybook/test' + +const meta = { component: AppShell } satisfies Meta +export default meta +type Story = StoryObj + +// WRONG: claims a collapse but pins no viewport, so it runs at the default size, and no +// story shows the navigation open on the other side of the breakpoint. +export const NavigationCollapses: Story = { + play: async ({ canvas }) => { + await expect(canvas.getByRole('button', { name: 'Open navigation' })).toBeVisible() + }, +} + +// RIGHT: one story on each side of the breakpoint, each pinned through globals.viewport. +export const NavigationCollapsesOnNarrowScreens: Story = { + globals: { viewport: { value: 'mobile1', isRotated: false } }, + play: async ({ canvas }) => { + const toggle = canvas.getByRole('button', { name: 'Open navigation' }) + await expect(toggle).toBeVisible() + await expect(toggle).toHaveAttribute('aria-expanded', 'false') + }, +} + +export const NavigationStaysOpenOnWideScreens: Story = { + globals: { viewport: { value: 'desktop', isRotated: false } }, + play: async ({ canvas }) => { + const navigation = canvas.getByRole('navigation', { name: 'Main' }) + await expect(navigation).toBeVisible() + await expect(canvas.queryByRole('button', { name: 'Open navigation' })).toBeNull() + }, +} +``` + +Grounds: Storybook 10.6, Viewport (https://storybook.js.org/docs/essentials/viewport); Storybook source, the Vitest plugin's `setViewport` (https://github.com/storybookjs/storybook/blob/v10.6.1/code/addons/vitest/src/vitest-plugin/viewports.ts#L55-L95). + +Gate: `review` — reject a story that asserts a layout without pinning `globals.viewport` to a configured key, and a responsive claim proven on one side of its breakpoint only. diff --git a/compound-packs/storybook-testing/real-browser-axe-as-error.md b/compound-packs/storybook-testing/real-browser-axe-as-error.md new file mode 100644 index 0000000..38f7c56 --- /dev/null +++ b/compound-packs/storybook-testing/real-browser-axe-as-error.md @@ -0,0 +1,59 @@ +--- +title: Stories run as tests in a real browser with axe violations as errors, and an exception names one rule +applies_when: + - running stories as tests through the Storybook Vitest addon + - a story trips an axe rule that misfires on markup the app does not own + - an AI agent proposes setting a story's accessibility test to `'off'` or `'todo'` + - adding or editing a Vitest setup file that calls `setProjectAnnotations` +tags: [storybook, accessibility, axe, vitest, browser] +--- + +Stories run as tests in a real browser through the Vitest addon, with axe violations failing the command-line run project-wide, and an exception disables one named axe rule with its reason rather than a story's whole check. Storybook runs the Vitest addon in browser mode because a real browser "is more accurate than simulations like JSDom or HappyDom". A story whose accessibility check is turned off passes every violation it has, including ones unrelated to the rule that prompted the change. + +1. **A real browser runs every story.** Stories run as tests under the Vitest addon in browser mode. A simulated DOM never stands in for it (see `stories-are-the-ui-integration-tests.md`). +2. **Violations are errors for the whole project.** The preview sets `parameters.a11y.test` to `'error'`. With `'todo'`, Storybook reports violations as warnings in its UI and produces nothing in CI. +3. **The command-line run is the gate.** The a11y addon throws on violations only when Vitest runs outside Storybook. The Vitest plugin marks a run launched from Storybook's UI, and there the addon records a failed report instead of throwing. CI runs Vitest from the command line, and that run decides. +4. **A setup file composes the a11y annotations itself.** When a Vitest setup file in the Storybook config directory calls `setProjectAnnotations`, the Vitest addon skips its automatic provisioning of preview annotations. The setup file must then compose the a11y addon's annotations (`@storybook/addon-a11y/preview`, as `a11yAnnotations`) alongside the project's own. Otherwise axe never runs and the suite reads green. +5. **An exception names one rule and its reason.** A story that trips an axe rule which misfires on markup the app does not own disables that rule by id in `parameters.a11y.config.rules`, with a comment saying why it misfires there. It never sets its `a11y.test` to `'off'` or `'todo'`. + +```tsx +// .storybook/preview.ts +// Violations fail the run for every story in the project. +import type { Preview } from '@storybook/react-vite' + +export default { parameters: { a11y: { test: 'error' } } } satisfies Preview + +// Composer.stories.tsx +import type { Meta, StoryObj } from '@storybook/react-vite' +import { Composer } from './Composer.tsx' + +const meta = { component: Composer } satisfies Meta +export default meta +type Story = StoryObj + +// WRONG: one misfiring rule turns off every axe check on the story, so an unlabelled +// message field would pass too. +export const ComposerWithEmojiPickerUnchecked: Story = { + parameters: { a11y: { test: 'off' } }, +} + +// RIGHT: the story disables the one rule that misfires, by id, with its reason, and +// every other axe rule still fails the run. +export const ComposerWithEmojiPicker: Story = { + parameters: { + a11y: { + config: { + rules: [ + // The third-party emoji picker ships its own stylesheet, and its grey category + // labels fail color-contrast; the app cannot restyle markup it does not own. + { id: 'color-contrast', enabled: false }, + ], + }, + }, + }, +} +``` + +Grounds: Storybook 10.6, Vitest addon (https://storybook.js.org/docs/writing-tests/integrations/vitest-addon) and Accessibility tests (https://storybook.js.org/docs/writing-tests/accessibility-testing); Storybook source, the a11y addon's `afterEach` (https://github.com/storybookjs/storybook/blob/v10.6.1/code/addons/a11y/src/preview.tsx#L64-L76) and `getIsVitestStandaloneRun` (https://github.com/storybookjs/storybook/blob/v10.6.1/code/addons/a11y/src/utils.ts#L1-L8), and the Vitest plugin's `VITEST_STORYBOOK` (https://github.com/storybookjs/storybook/blob/v10.6.1/code/addons/vitest/src/vitest-plugin/index.ts#L401) and `requiresProjectAnnotations` (https://github.com/storybookjs/storybook/blob/v10.6.1/code/addons/vitest/src/vitest-plugin/utils.ts#L41-L61). + +Gate: `review` — reject a story whose `a11y.test` is `'off'` or `'todo'`, a disabled axe rule with no stated reason, a preview without `a11y.test: 'error'`, and a Vitest setup file that calls `setProjectAnnotations` without the a11y addon's annotations. diff --git a/compound-packs/storybook-testing/stories-are-the-ui-integration-tests.md b/compound-packs/storybook-testing/stories-are-the-ui-integration-tests.md new file mode 100644 index 0000000..903d8a5 --- /dev/null +++ b/compound-packs/storybook-testing/stories-are-the-ui-integration-tests.md @@ -0,0 +1,54 @@ +--- +title: User-visible behaviour is proven by stories over the real app, never by a simulated-DOM suite or a shell test against a double +applies_when: + - proving what a person sees or can do in a UI + - an AI agent proposes a jsdom, happy-dom or Testing Library render test for a component + - an AI agent proposes a unit test that renders a UI shell against a mocked module or a stubbed port + - deciding where a UI change's tests belong +tags: [storybook, integration, browser, jsdom, test-placement, ui] +--- + +User-visible behaviour is proven by stories over the real app, and no jsdom, happy-dom or Testing Library render suite and no unit test of a UI shell against a double stands in for one; pure decisions keep their property tests. Dodds' "Write tests. Not too many. Mostly integration." puts confidence in tests that run the pieces together the way a user meets them. A simulated DOM lets a test pass while the shipped browser behaviour is broken, because Suspense, `useSyncExternalStore` and layout only behave as shipped in a real browser. A shell test against a double asserts the call the shell made. It proves neither that the fake is admissible nor what a person sees. + +1. **A story over the real app is the proof.** A behaviour a person can see or perform is proven by a story that renders the real app over admissible port fakes (see `stories-compose-at-their-own-root.md`), run in a real browser by the Storybook Vitest addon (see `real-browser-axe-as-error.md`). +2. **No simulated DOM.** A jsdom, happy-dom or Testing Library render suite never proves UI behaviour. When one exists for a behaviour a story already proves, it is deleted. When it is the only proof, it is replaced by a story. +3. **No shell test against a double.** A test that renders a component against a mocked module or a stubbed port, and asserts what the stand-in received, is not written. The story asserts the outcome a person perceives instead. What an adapter's own tests may double is owned by `boundary-testing/no-mocks-on-internal-glue.md`. +4. **Pure decisions keep their property tests.** A decision the UI calls is proven by its property test in the lane the test-discipline Test Lanes table assigns (`packages/oxlint-plugin/oxlint-plugin-test-discipline/README.md`). The story proves that the UI shows the decision's outcome, and does not re-prove every input. + +```tsx +// @vitest-environment jsdom +import type { Meta, StoryObj } from '@storybook/react-vite' +import { render, screen } from '@testing-library/react' +import { expect, userEvent } from 'storybook/test' +import { test, vi } from 'vitest' +import { postMessage } from '../src/channel-messages.ts' + +// WRONG: a simulated DOM and a mocked module; the test proves the stand-in was called, +// not that a person sees the message. +vi.mock('../src/channel-messages.ts') + +test('the composer posts the typed message', async () => { + render() + await userEvent.type(screen.getByRole('textbox', { name: 'Message' }), 'Hello all') + await userEvent.click(screen.getByRole('button', { name: 'Send' })) + await expect(vi.mocked(postMessage)).toHaveBeenCalledWith('Hello all') +}) + +// RIGHT: a story over the real app on the preview's fakes, in a real browser, asserting +// what a person sees. +const meta = { component: App } satisfies Meta +export default meta + +export const PostedMessageAppears: StoryObj = { + play: async ({ canvas, userEvent }) => { + await userEvent.type(canvas.getByRole('textbox', { name: 'Message' }), 'Hello all') + await userEvent.click(canvas.getByRole('button', { name: 'Send' })) + await expect(await canvas.findByText('Hello all')).toBeVisible() + await expect(canvas.getByRole('textbox', { name: 'Message' })).toHaveValue('') + }, +} +``` + +Grounds: Kent C. Dodds, Write tests. Not too many. Mostly integration. (https://kentcdodds.com/blog/write-tests); Storybook 10.6, Vitest addon (https://storybook.js.org/docs/writing-tests/integrations/vitest-addon); `docs/solutions/tooling-decisions/atom-react-browser-test-toolchain.md`. + +Gate: `review` — reject a jsdom, happy-dom or Testing Library render suite and a shell test against a double offered as proof of UI behaviour. Under `src/`, test-discipline's `no-test-file-in-src` already refuses any test file that is not a property test. diff --git a/compound-packs/storybook-testing/stories-compose-at-their-own-root.md b/compound-packs/storybook-testing/stories-compose-at-their-own-root.md new file mode 100644 index 0000000..44301cd --- /dev/null +++ b/compound-packs/storybook-testing/stories-compose-at-their-own-root.md @@ -0,0 +1,95 @@ +--- +title: A story gets its I/O from port fakes the preview provides through the production entry's provider, never by module substitution +applies_when: + - a story's components reach I/O, navigation history or other infrastructure + - an AI agent proposes `sb.mock`, an alias or a subpath import to stand in for a first-party module + - writing or reviewing the loaders, `beforeEach` or decorators in `.storybook/preview.*`, or the registry the production entry renders with +tags: [storybook, composition-root, provider, layer, sb-mock, fakes, registry] +--- + +A story whose components reach I/O, navigation history or other infrastructure gets it from port fakes that the preview provides through the same provider the production entry uses, and no preview or story substitutes a first-party module by `sb.mock`, alias or subpath import. Storybook documents both seams: module mocking, and decorators that wrap a story in a provider. This pack picks the provider. Seemann places composition at an application's entry point. A Storybook build is a separate application, so its preview composes the real app, and only the Layer it provides differs from production. A provider passes a Layer, which is a value, and Bernhardt argues for values as the boundaries between components. + +`sb.mock` costs four things. It registers only in the preview, so every story shares one substitution. It binds to the module's file path, so moving the module breaks every story that relied on it. With no mock file beside the module, it automocks every export into a mock function that returns `undefined` until a story configures it. And reaching the mock takes the `mocked()` wrapper plus an `import()` path for typing. Shore's "Replace Mocks with Nullables" replaces mocks, spies and other test doubles, module mocks included, with a Nulled version of the real dependency. + +1. **One provider, two roots.** Components reach I/O only through a runtime that reads one writable Layer atom. The production entry builds its registry before it renders, seeds that atom with the live Layer, and renders the registry provider with it. The preview's loader builds each story's registry and seeds the same atom with a fake Layer, and its decorator renders the same provider with it. Nothing else differs. Neither root builds a registry while it renders; `fresh-fakes-per-story.md` gives the story registry's lifetime. +2. **The default fails on use.** The atom's own default is a Layer typed to the app's services that fails when used. A component module therefore never imports the live adapters, and only the production entry references them. +3. **No module substitution for first-party code.** No preview or story calls `sb.mock` on a first-party module, aliases its import or redirects it through a subpath import. A story that needs different I/O behaviour gets it from the fake's configuration (see `fresh-fakes-per-story.md`). +4. **Only admissible fakes.** A fake is provided to stories once it is typed to its port and passes the contract suite it shares with the real adapter. Admissibility is defined in `boundary-testing/port-fakes-pass-the-port-contract.md`, and doubles inside an adapter's own tests in `boundary-testing/no-mocks-on-internal-glue.md`. Neither is restated here. + +```tsx +import type { Preview, StoryObj } from '@storybook/react-vite' +import { Atom } from '@systemfsoftware/effect-atom' +import { AtomReact } from '@systemfsoftware/effect-atom-react' +import { Context, Data, Effect, Layer } from 'effect' +import { mocked, sb } from 'storybook/test' +import { postMessage } from '../src/channel-messages.ts' + +// WRONG: the preview swaps a first-party module by its path and a story steers it through +// `mocked()`; every story shares the swap, and moving the module breaks them all. +sb.mock(import('../src/channel-messages.ts')) + +export const SendFailsThroughModuleMock = { + beforeEach: () => { + mocked(postMessage).mockRejectedValue(new Error('send failed')) + }, +} satisfies StoryObj + +// RIGHT: one Layer atom; the entry seeds it live, the preview seeds it with a fake built +// from the story's parameters. Each root builds its registry before render and passes it +// through the same provider. +class SendFailed extends Data.TaggedError('SendFailed') {} + +class Messages extends Context.Service Effect.Effect +}>()('Messages') {} + +const appLayer = Atom.make(Layer.effect(Messages)(Effect.die('no Messages Layer was provided'))) +const appRuntime = Atom.context()((get) => get(appLayer)) +const send = appRuntime.fn((text: string) => Messages.use((messages) => messages.post(text))) + +function Composer() { + const post = AtomReact.useAtomSet(send) + return +} + +const liveMessages = Layer.succeed(Messages)({ post: (text) => sendToServer(text) }) + +const entryRegistry = Atom.Registry.make({ + initialValues: [Atom.initialValue(appLayer, liveMessages)], + scheduleTask: AtomReact.scheduleTask, +}) + +export const entry = ( + + + +) + +const fakeMessages = (io: { readonly sendFails: boolean }) => + Layer.succeed(Messages)({ post: () => (io.sendFails ? Effect.fail(new SendFailed()) : Effect.void) }) + +export default { + loaders: [ + ({ parameters }) => ({ + registry: Atom.Registry.make({ + initialValues: [Atom.initialValue(appLayer, fakeMessages(parameters['io'] ?? { sendFails: false }))], + scheduleTask: AtomReact.scheduleTask, + }), + }), + ], + beforeEach: ({ loaded }) => () => Atom.Registry.dispose(loaded['registry']), + decorators: [ + (Story, { loaded }) => ( + + + + ), + ], +} satisfies Preview + +export const SendFails = { parameters: { io: { sendFails: true } } } satisfies StoryObj +``` + +Grounds: Mark Seemann, Composition Root (https://blog.ploeh.dk/2011/07/28/CompositionRoot/); Gary Bernhardt, Boundaries (https://www.destroyallsoftware.com/talks/boundaries); James Shore, Testing Without Mocks, Replace Mocks with Nullables (https://www.jamesshore.com/v2/projects/nullables/testing-without-mocks); Storybook 10.6, Mocking modules (https://storybook.js.org/docs/writing-stories/mocking-data-and-modules/mocking-modules) and Mocking providers (https://storybook.js.org/docs/writing-stories/mocking-data-and-modules/mocking-providers); Storybook source, `resolveMock` (https://github.com/storybookjs/storybook/blob/v10.6.1/code/core/src/mocking-utils/resolve.ts#L54-L79). + +Gate: `review` — reject a preview or story that calls `sb.mock` on a first-party module, aliases or redirects one, or provides I/O through anything but the provider the production entry uses; reject a production entry or preview that builds its registry while rendering; reject a fake provided to stories without the shared contract suite. diff --git a/compound-packs/storybook-testing/visual-evidence-comes-from-the-runner.md b/compound-packs/storybook-testing/visual-evidence-comes-from-the-runner.md new file mode 100644 index 0000000..4ca80ef --- /dev/null +++ b/compound-packs/storybook-testing/visual-evidence-comes-from-the-runner.md @@ -0,0 +1,76 @@ +--- +title: A story's visual evidence comes from the runner's capture hook, never from page.screenshot in a story or a CI collection script +applies_when: + - adding screenshots or other visual evidence to stories + - adding or changing a CI step that collects, summarises or uploads story evidence + - an AI agent writes `page.screenshot` in a story, play function, step handler or a helper they call + - a pull request commits images under a story capture folder (`__screenshots__/`) or Vitest's attachments folder (`.vitest/`) +tags: [storybook, screenshots, visual-evidence, vitest, ci, artifacts] +--- + +A story's evidence image comes from one capture hook the test runner runs for every story, and a failure's image comes from Vitest. CI uploads both folders as they are. A capture written into each story records whatever the step reached, under a name the story chose, and a CI step that hunts for those files and summarises them is a second, untested path that decides what evidence exists. Stories already run as Vitest tests, and the runner can capture each one, so the capture belongs to the runner rather than to each story. + +1. **One hook, run by the runner for every story.** Each story is captured once, after its test finishes, by one hook registered for every story; no story, play function or step handler registers a capture of its own. The hook captures through a capture library such as `@storycap-testrun/browser` (`screenshot(page, context)`, with its `storycap(...)` Vitest plugin from `@storycap-testrun/browser/vitest-plugin` in the project's `plugins`). Where the test runner admits it, a Vitest setup file's `afterEach` is that hook, as in the reference app. Where it does not, as `@systemfsoftware/vitest` refuses `afterEach`, the Storybook preview's project `beforeEach` registers `onTestFinished` instead, guarded to test mode with the runner modules imported inside the guard so the Storybook UI never loads them (RIGHT below). That works because the Vitest addon runs each composed story inside its Vitest test (Storybook `v10.6.0`, `code/addons/vitest/src/vitest-plugin/test-utils.ts:101-132`), the preview `beforeEach` runs before every story (`code/core/src/csf/story.ts:395-402`), and `onTestFinished` must be called within a test and runs when it finishes, pass or fail (Vitest `v5.0.1`, `packages/vitest/src/runtime/runner/hooks.ts:257-294`). +2. **The preview reaches the runner.** The addon applies the project's preview annotations itself unless a setup file in the Storybook config directory contains `setProjectAnnotations` (`code/addons/vitest/src/vitest-plugin/index.ts:334-343`, `utils.ts:41-51`). Such a file passes the project's own preview annotations, or the preview's `beforeEach` never runs and a hook it registers captures nothing (`real-browser-axe-as-error.md` item 4 composes them the same way). +3. **Failures through Vitest.** The Storybook Vitest project sets `browser.screenshotFailures: true` explicitly: the addon sets it to `false` whenever the project has a `test.browser` config and leaves the flag unset (Storybook `v10.6.0`, `code/addons/vitest/src/vitest-plugin/index.ts:418-425`), which overrides Vitest's own default. Vitest writes failure images to `/failure-screenshots//.png` (Vitest `v5.0.1`, `packages/browser/src/client/tester/runner.ts:187-204`), not to the screenshot folder. +4. **No capture in a story.** No story, play function, step handler or helper they call runs `page.screenshot`. The hook captures after the story's test finishes, so a state worth an image is its own story, the way `journeys-are-embedded-gherkin.md` gives each journey one When. +5. **Run output, never committed.** The hook's output folder (default `__screenshots__`) and Vitest's attachments folder (default `.vitest/attachments`, ignored as `.vitest/`), where item 3's failure images land, are both in the repository's ignore list, and no story evidence image is committed. A reference screenshot recorded from the component's own render is the subject's output, which `boundary-testing/expected-values-never-come-from-the-subjects-output.md` already refuses. +6. **CI uploads the folders as they are.** CI uploads both folders, at the paths the run writes them. No step re-captures story images, decides which of them exist, or writes its own summary of them. Failure images exist only for failing runs, so an upload that skips failing runs carries none of item 3's evidence. + +Playwright journeys against a running app are outside this rule: their `page` is the server-side Playwright object, which Vitest documents as a different object from its browser `page`. + +```tsx +// WRONG (a product repo, 2026-10-11): each story step captures its own image, and a CI +// script later collects whatever files the steps wrote. +import { page } from 'vitest/browser' + +export const SubmitsTheOrder: Story = { + play: async ({ canvas, userEvent }) => { + await userEvent.click(canvas.getByRole('button', { name: 'Submit order' })) + await page.screenshot({ path: `evidence/${Date.now()}-submitted.png` }) + await expect(await canvas.findByText('Order placed')).toBeVisible() + }, +} + +// RIGHT (an unnamed reference app's capture, registered through the preview because +// @systemfsoftware/vitest refuses afterEach): one capture runs for every story. +// .storybook/preview.ts +const captureEachStory = async (): Promise => { + if (import.meta.env.MODE !== 'test') return + const [{ onTestFinished }, { page }, { screenshot }] = await Promise.all([ + import('@systemfsoftware/vitest'), + import('vitest/browser'), + import('@storycap-testrun/browser'), + ]) + onTestFinished(async (context) => { + await screenshot(page, context) + }) +} + +export default { beforeEach: captureEachStory } + +// vitest.config.ts, the Storybook project +// plugins: [storybookTest({ configDir: '.storybook' }), storycap({ viewport: { width: 1280, height: 720 } })], +// test: { browser: { enabled: true, screenshotFailures: true } } +// .gitignore +// __screenshots__/ +// .vitest/ +// CI, after the test step, which runs from apps/web +// - if: ${{ !cancelled() }} +// uses: actions/upload-artifact@v7 +// with: { name: story-evidence, path: "apps/web/__screenshots__/\napps/web/.vitest/" } + +// The story asserts only what a person sees. +export const SubmitsTheOrder: Story = { + play: async ({ canvas, userEvent }) => { + await userEvent.click(canvas.getByRole('button', { name: 'Submit order' })) + await expect(await canvas.findByText('Order placed')).toBeVisible() + }, +} +``` + +Grounds: the operator's ruling (2026-10-12) that Storybook already produces screenshots through the reference-app pattern, whose one capture hook writes a gitignored folder that CI uploads as the evidence artifact; Storybook source, the addon's project-annotation provisioning (https://github.com/storybookjs/storybook/blob/v10.6.0/code/addons/vitest/src/vitest-plugin/index.ts#L334-L343) and its setup-file detection (https://github.com/storybookjs/storybook/blob/v10.6.0/code/addons/vitest/src/vitest-plugin/utils.ts#L41-L51), story run (https://github.com/storybookjs/storybook/blob/v10.6.0/code/addons/vitest/src/vitest-plugin/test-utils.ts#L101-L132), preview `beforeEach` (https://github.com/storybookjs/storybook/blob/v10.6.0/code/core/src/csf/story.ts#L395-L402) and `screenshotFailures` override (https://github.com/storybookjs/storybook/blob/v10.6.0/code/addons/vitest/src/vitest-plugin/index.ts#L418-L425); Vitest source, `onTestFinished` (https://github.com/vitest-dev/vitest/blob/v5.0.1/packages/vitest/src/runtime/runner/hooks.ts#L257-L294) and the failure screenshot (https://github.com/vitest-dev/vitest/blob/v5.0.1/packages/browser/src/client/tester/runner.ts#L187-L204); `@storycap-testrun/browser` README (https://github.com/reg-viz/storycap-testrun/blob/main/packages/browser/README.md); Vitest, `browser.screenshotFailures` (https://vitest.dev/config/browser/screenshotfailures) and the browser context API, whose `page` "is not the same object" as Playwright's (https://vitest.dev/api/browser/context); GitHub Actions status-check functions (https://docs.github.com/en/actions/reference/workflows-and-actions/expressions#status-check-functions). + +Gate: `review` — Outside a linter's planted fixtures, does the diff run `page.screenshot` in a story, play function, step handler or a helper they call? Does it register a story capture other than one hook run for every story, or add one without ignore entries for its capture folder and `.vitest/`? Does a setup file it adds or changes in the Storybook config directory call `setProjectAnnotations` without the project's preview annotations? Does a Storybook Vitest project it adds or changes leave `browser.screenshotFailures` unset or `false`? Does it commit a story evidence image? Does a CI step re-capture story images, decide which of them exist, or write its own summary of them? + +Lint: `@systemfsoftware/oxlint-config-frontend` sets `no-restricted-properties` for `page.screenshot` on `**/*.stories.ts` and `**/*.stories.tsx`, each message naming `storybook-testing/visual-evidence-comes-from-the-runner`. The file suffix is the lint's proxy for "a story", not the rule's scope: review owns the helpers a story calls, a renamed `page`, the hook registration, ignore entries and CI steps. diff --git a/compound-packs/ui-state/README.md b/compound-packs/ui-state/README.md new file mode 100644 index 0000000..8b20ffc --- /dev/null +++ b/compound-packs/ui-state/README.md @@ -0,0 +1,15 @@ +# UI State Compound Pack + +Components render and dispatch only. How a React application holds state: server data lives in effect-atom atoms over Effect services, a component performs no I/O and React code runs no React Effect, every Effect the UI runs is an atom of one runtime built from a Layer, and display derivation is a named pure function. The pack governs application UI, meaning the components, hooks and view-models of an app or UI package. Four of its rules do not govern the implementation of the effect-atom bindings (`packages/atom/effect-atom`, `packages/atom/effect-atom-react`), which are the runtime edge: `useAtomValue`'s own inline `Atom.map` (`packages/atom/effect-atom-react/src/hooks-value.ts`) is not a `view-models-are-pure-functions` finding. `react-runs-no-effect` governs `packages/atom/effect-atom-react` as it governs every other React code, with no exception. + +Rules: + +- `server-data-lives-in-atoms`: data from a server, an Effect service or another component is read through atoms, never copied into `useState`, `useReducer` or `useRef`, and no module imports a React server-state library. Incidents a, b and c (2026-10-11); incident g (2026-10-11) is the UI-local `useState` the rule allows. +- `components-perform-no-io`: a `.tsx` module performs no I/O and names no I/O global, clock or timer. Incidents b, e and f (2026-10-11). +- `components-dispatch-to-runtime-atoms`: every Effect the UI runs is a runtime atom of one runtime built at module scope; an event handler dispatches at most one command. Incidents c and d (2026-10-11). +- `view-models-are-pure-functions`: sorting, filtering, grouping, formatting and status-to-label mapping are named pure functions in a `.ts` module, applied as `useAtomValue(atom, f)`. Incident h (2026-10-11). +- `react-runs-no-effect`: React code runs no `useEffect`, `useLayoutEffect`, `useInsertionEffect` or `useEffectEvent` from `react` and imports `react` by name, with no exception; binding code subscribes through `useSyncExternalStore`, and the code that mounts the app restores saved state before the first render. Incidents b, c, e and f (2026-10-11) and the ruling of 2026-10-13. + +Three rules are partly mechanized in `@systemfsoftware/oxlint-config-frontend`: `no-restricted-imports` for the server-state libraries, `no-restricted-globals` and `no-restricted-properties` for `fetch` in `**/*.tsx`, and, for `react-runs-no-effect`, `no-restricted-imports` on `react` beside `@systemfsoftware/ui-state/no-react-effect`. The preset also enables the oxlint React plugin: every `correctness` rule at error, the React Compiler checks included. `react/rules-of-hooks` is an error in `**/*.tsx`; it is off in `**/*.ts`, where it decides what is a hook from its name alone and so reports Effect's `Service.use(...)`; `react/hooks` checks the Rules of Hooks in every file instead. `react/capitalized-calls`, `react/rule-suppression`, `react/unsupported-syntax`, `react/no-unstable-nested-components`, `react/no-array-index-key`, `react/no-clone-element`, `react/no-react-children` and `react/prefer-function-component` are errors too. "Only call Hooks from React functions" stays review-owned. Each lint is narrower than its rule, and review covers the rest. No lint tells server data from UI-local `useState`, so none exists. Under `src/`, the preset's `effecttsgo/global-date` already reports `new Date(x)`, `new Date()` and `Date.now()`, `effecttsgo/global-timers` reports `setTimeout` and `setInterval`, and effect-platform's `no-unported-time-source` reports `performance.now()`, in `.tsx` as in `.ts`. + +Owned elsewhere and cited, not restated: the Layer-choice atom, its failing default and how stories and the production entry seed it (storybook-testing `stories-compose-at-their-own-root.md`); services, Layers and the single composition root (cell-architecture `service-and-layer-boundaries.md`); `ManagedRuntime.make` inside a function body under `src/`, refused by effect-platform's `runtime-construction-placement` lint; and the property-test and mutation obligations on pure functions (storybook-testing `stories-are-the-ui-integration-tests.md`, mutation-discipline `kill-survivors-with-contract-properties.md`). diff --git a/compound-packs/ui-state/components-dispatch-to-runtime-atoms.md b/compound-packs/ui-state/components-dispatch-to-runtime-atoms.md new file mode 100644 index 0000000..e3e4ed1 --- /dev/null +++ b/compound-packs/ui-state/components-dispatch-to-runtime-atoms.md @@ -0,0 +1,89 @@ +--- +title: Every Effect the UI runs is an atom of one module-scope runtime, and an event handler dispatches at most one command +applies_when: + - writing or reviewing a React component, hook, or `**/*.tsx` file + - changing a UI package (a package that renders React), excluding the effect-atom bindings' own implementation +tags: [ui-state, runtime, atom, dispatch, composition-root, effect] +--- + +Every Effect the UI runs is a runtime atom. Reads are `appRuntime.atom(effect)`, wrapped in `Atom.family` when parameterised, and consumed with `useAtomValue` or `useAtomSuspense`. Commands are `appRuntime.fn(...)`, dispatched with the setter from `useAtomSet`. No component or hook builds, holds or runs a runtime, a fiber or an Effect: the registry owns each fiber's lifetime, and a component that runs its own Effect owns a fiber nothing interrupts and a result nothing else can read. + +1. **One runtime, built once at module scope in a `.ts` module**, as `appRuntime = Atom.context()((get) => get(appLayerAtom))`. The Layer-choice atom `appLayerAtom` and its default that fails on use are owned by `compound-packs/storybook-testing/stories-compose-at-their-own-root.md` (items 1-2 at `:14-15`, RIGHT block at `:46-66`); this rule shows the same shape and does not redefine it. The production entry seeds the live Layer into the registry it builds before rendering, `Atom.Registry.make({ initialValues: [Atom.initialValue(appLayerAtom, AppLive)], scheduleTask: AtomReact.scheduleTask })`, and the Storybook preview and a browser test seed their fakes into the same atom the same way. Services, Layers and the composition root are cell-architecture's `service-and-layer-boundaries.md`. +2. **Never seed `appRuntime.layer` directly.** It replaces the merged Layer that carries the `Reactivity` service, so commands die and invalidation stops. A probe on 2026-10-11 against `@systemfsoftware/effect-atom` 6.0.0 showed it: seeding `runtime.layer` swapped the read to the fake, but the command ended in a `Failure` and the watched atom never re-ran; seeding the Layer-choice atom swapped the read, the command succeeded and the watched atom re-ran. +3. **An event handler dispatches at most one command**: the `useAtomSet` setter of an `appRuntime.fn` or of a server-backed writable atom. It may also call UI-local setters (as `server-data-lives-in-atoms.md` defines UI-local state), such as closing a dialog after submitting. It passes the event's data or the component's UI-local draft, awaits nothing, chains no `.then`, runs no Effect, dispatches no second command, and branches on no atom or service value; branching on event data (`e.key === 'Enter'`) is fine. Sequencing and domain branching move into the `appRuntime.fn` Effect. +4. **Atoms read only IO-layer service functions.** A machine or actor that runs in the shell reaches a component through an Effect service in the runtime's Layer (`*.service.ts`, cell-architecture's `service-and-layer-boundaries.md`), whose functions an atom reads and an `appRuntime.fn` calls. The atom never imports the actor and never subscribes to it directly. Where a shell machine lives and what it may decide is cell-architecture's to say; this rule does not restate it. + +Incidents, all 2026-10-11, in one application UI. (c) A component held `useState(null)`; a `useEffect` built an Effect, ran it with `runtime.runFork(...)`, and pushed the result back with `Effect.sync(() => setView(...))` and the error with `Effect.catch((e) => Effect.sync(() => setError(e.message)))`. Effect was used, but server data still lived in `useState` and the fiber was managed by hand. (d) A component held `useState(undefined)`: the composition root was built inside a component. + +```tsx +// WRONG (incident c): the component runs the Effect and copies the result into useState +export function OrderView({ id }: { readonly id: OrderId }) { + const [view, setView] = useState(null) + const [error, setError] = useState(null) + useEffect(() => { + const fiber = runtime.runFork( + Orders.view(id).pipe( + Effect.tap((loaded) => Effect.sync(() => setView(loaded))), + Effect.catch((e) => Effect.sync(() => setError(e.message))), + ), + ) + return () => runtime.runFork(Fiber.interrupt(fiber)) + }, [id]) + return view === null ?

{error}

: +} +``` + +```tsx +// WRONG (incident d): the runtime is built inside a component +export function App() { + const [runtime, setRuntime] = useState(undefined) + useEffect(() => setRuntime(ManagedRuntime.make(AppLive)), []) + return runtime === undefined ? null : +} +``` + +```tsx +// WRONG: a handler that awaits and sequences, and a handler that dispatches two commands + + +``` + +```ts +// RIGHT: app.runtime.ts, one runtime over the Layer-choice atom +export const appLayerAtom = Atom.make(Layer.effect(AppServices)(Effect.die('no AppServices Layer was provided'))) +export const appRuntime = Atom.context()((get) => get(appLayerAtom)) + +// order.atoms.ts, a read and a command that sequences save, refresh and navigation through services +export const orderAtom = Atom.family((id: OrderId) => appRuntime.atom(Orders.use((orders) => orders.view(id)))) +export const saveOrder = appRuntime.fn((draft: Draft, get) => + Effect.gen(function*() { + yield* Orders.use((orders) => orders.save(draft)) + get.refresh(orderAtom(draft.id)) + yield* Navigation.use((navigation) => navigation.toOrder(draft.id)) + }) +) +``` + +```tsx +// RIGHT: one command plus a UI-local setter +export function SaveDialog({ draft }: { readonly draft: Draft }) { + const [open, setOpen] = useState(true) + const save = AtomReact.useAtomSet(saveOrder) + return open + ? ( + + ) + : null +} +``` + +`Atom.context` returns a factory whose result carries the `atom` and `fn` members (`packages/atom/effect-atom/src/atom.blueprint.ts:2081-2136`); `fn`'s second argument is a `FnContext` with `refresh` (`:2489-2496`); `Atom.initialValue` is `:3545`. `AtomReact.useAtomSet` is `packages/atom/effect-atom-react/src/hooks-value.ts:163`, and `Atom.Registry.make`'s `initialValues` and `scheduleTask` options are `packages/atom/effect-atom/src/registry.handle.ts:114-115`. Building `ManagedRuntime.make` inside a function body under `src/` is already refused by effect-platform's `runtime-construction-placement` lint; this rule does not restate it. + +Gate: `review` — Does any component or hook in the diff call `Effect.run*`, `runtime.run*`, `ManagedRuntime.make` or `Atom.context(`, hold a runtime or fiber in state or a ref, or contain an event handler that dispatches two commands, uses `await` or `.then`, or branches (`if`, `switch`, ternary, `&&`) on a value read from an atom or service? Does an atom, atoms module or component in the diff import an actor or a machine, or subscribe to an actor's snapshots, instead of reading a service function from the runtime's Layer? diff --git a/compound-packs/ui-state/components-perform-no-io.md b/compound-packs/ui-state/components-perform-no-io.md new file mode 100644 index 0000000..e212805 --- /dev/null +++ b/compound-packs/ui-state/components-perform-no-io.md @@ -0,0 +1,55 @@ +--- +title: A `.tsx` module performs no I/O and names no I/O global, clock or timer +applies_when: + - writing or reviewing a React component, hook, or `**/*.tsx` file + - changing a UI package (a package that renders React), excluding the effect-atom bindings' own implementation +tags: [ui-state, io, fetch, storage, clock, timers, tsx] +--- + +A `.tsx` module performs no I/O and names no I/O global, server components included. That means no `fetch` (bare, aliased, as a type, or as a property of `globalThis`, `window` or `self`), `XMLHttpRequest`, `WebSocket` or `EventSource`; no `localStorage`, `sessionStorage`, `indexedDB` or `document.cookie`; no clock read (`Date.now()`, `new Date()` with no argument, `performance.now()`); and no `setTimeout` or `setInterval`. I/O goes through an Effect service built in a `.ts` module and reached through a runtime atom, as `components-dispatch-to-runtime-atoms.md` prescribes. No `.tsx` has a legitimate `fetch`: a story gets its I/O from port fakes the preview provides through the production provider (storybook-testing `stories-compose-at-their-own-root.md`), and a browser test drives the same provider. + +Formatting a passed value for display is not I/O; it belongs to `view-models-are-pure-functions.md`. Build the date with Effect's `DateTime`, not `new Date(x)`: under `src/` the preset's `effecttsgo/global-date` reports `new Date(x)` as well as `new Date()` and `Date.now()`, and it accepts `DateTime.format(DateTime.makeUnsafe(x), { dateStyle: 'medium' })` (`repos/effect/packages/effect/src/DateTime.ts:653,2472`, effect 4.0.1). + +Incidents, all 2026-10-11, in one application UI. (b) A `useEffect` called `fetch(url)` and stored the response in `useState`. (e) A `useEffect` started `setInterval(() => setNow(new Date()), 30_000)`, an ambient clock read inside a component. (f) A `useEffect` wrote `localStorage.setItem(key, JSON.stringify(value))` whenever the value changed. + +```tsx +// WRONG (incident e): an ambient clock read on a timer inside a component +export function LastSeen() { + const [now, setNow] = useState(new Date()) + useEffect(() => { + const timer = setInterval(() => setNow(new Date()), 30_000) + return () => clearInterval(timer) + }, []) + return +} +``` + +```tsx +// WRONG (incident f): persistence written from a component +export function Density({ density }: { readonly density: Density }) { + useEffect(() => { + localStorage.setItem('density', JSON.stringify(density)) + }, [density]) + return null +} +``` + +```ts +// RIGHT: clock, persistence and debounce are atoms over the runtime (clock.atoms.ts, settings.atoms.ts) +export const nowAtom = appRuntime.atom(DateTime.now).pipe(Atom.withRefresh('30 seconds')) + +export const densityAtom = Atom.kvs({ + runtime: settingsRuntime, // Atom.context()(...) over a Layer that provides KeyValueStore + key: 'density', + schema: Density, + defaultValue: () => 'comfortable', +}) + +export const searchTermAtom = Atom.make('').pipe(Atom.debounce('300 millis')) +``` + +`Atom.withRefresh` (`packages/atom/effect-atom/src/atom.blueprint.ts:3662`), `Atom.debounce` (`:3625`) and `Atom.kvs` (`src/atom-kvs.ts:236-244`, whose `runtime` must provide `KeyValueStore`) are listed in `etc/effect-atom.api.md`; `DateTime.now` is `repos/effect/packages/effect/src/DateTime.ts:838`. + +Gate: `review` — Does any `.tsx` file in the diff name `fetch`, `XMLHttpRequest`, `WebSocket`, `EventSource`, `localStorage`, `sessionStorage`, `indexedDB` or `document.cookie`, read the clock with `Date.now()`, `new Date()` with no argument or `performance.now()`, or call `setTimeout` or `setInterval`? + +Lint: `@systemfsoftware/oxlint-config-frontend` sets, on `**/*.tsx`, `no-restricted-globals` for `fetch` and `no-restricted-properties` for `fetch` on `globalThis`, `window` and `self`, each message naming `ui-state/components-perform-no-io`. Review covers what that lint misses: storage, `XMLHttpRequest`, `WebSocket` and `EventSource`, and the clock reads and timers outside `src/`. diff --git a/compound-packs/ui-state/react-runs-no-effect.md b/compound-packs/ui-state/react-runs-no-effect.md new file mode 100644 index 0000000..a0db6f3 --- /dev/null +++ b/compound-packs/ui-state/react-runs-no-effect.md @@ -0,0 +1,99 @@ +--- +title: React code runs no React Effect; it reads atoms and subscribes through useSyncExternalStore +applies_when: + - writing or reviewing a React component, hook, or `**/*.tsx` file + - changing a UI package (a package that renders React), the effect-atom bindings included +tags: [ui-state, react-effect, useEffect, useSyncExternalStore, hydration, atom] +--- + +React code runs no React Effect. react.dev says Effects "let you 'step outside' of React and synchronize your components with some external system", and in a UI built on effect-atom every external system is an Effect service reached through a runtime atom: a component reads with `useAtomValue` or `useAtomSuspense` and dispatches with `useAtomSet`. Binding code that subscribes React to a store uses `useSyncExternalStore`, which react.dev calls "a purpose-built Hook for subscribing to an external store that is preferred instead" of an Effect. That leaves no React Effect to write, in application code or in the bindings (ruling of 2026-10-13). + +1. **No React Effect hook in any form.** No module calls or references `useEffect`, `useLayoutEffect`, `useInsertionEffect` or `useEffectEvent` from `react`: not through a named or aliased import, a namespace or default member, a destructured binding, a re-export, or a value passed around (`const afterCommit = useEffect`). react.dev reserves `useInsertionEffect` "for CSS-in-JS library authors", and Effect Events "can only be called from inside Effects", so with Effects gone neither has a use. There is no exception. +2. **`react` is imported by name.** Namespace and default imports of `react` are refused, type-only ones included, and so are `import('react')` and `require('react')`, which load the whole module with its Effect hooks as members. Every React API a module reaches is visible in its import list: `import { useMemo, useSyncExternalStore } from 'react'`, `import type { ReactNode } from 'react'`. +3. **Saved data reaches the registry before the first render.** The code that mounts the app owns the registry: it seeds it with `Atom.Registry.make({ initialValues })`, restores saved state with `Atom.Hydration.hydrate(registry, state)` before `hydrateRoot` or `createRoot(...).render`, and disposes it at teardown. The binding writes no registry state from React, and no Effect waits for commit to write it. `Atom.Hydration.hydrate` assigns the saved value to each atom the state names and replaces a value the registry already holds, so the owner restores saved state before the first render, while those atoms have not been read yet; restored before render, saved data never overwrites a value already on screen (ruling of 2026-10-13). Upstream hydrates during render and defers atoms the page already holds to a `useEffect`, because hydrating a live registry during a transition that is later abandoned would change the current page: tim-smart/effect-atom `packages/atom-react/src/ReactHydration.ts` at `2fc0f70` holds existing atoms back "until _after_ the render phase" because "If the transition is aborted, we will have hydrated any _new_ Atom values, but we throw away the fresh data for any existing ones" (lines 36-42); TanStack/query `packages/react-query/src/HydrationBoundary.tsx` at `0fbbc62` does the same for queries (lines 106-112). Hydrating before render leaves no live registry to protect, so that Effect has no job here. The one write after commit is the engine's own: an entry the server dehydrated while still pending is completed when the server's promise resolves, outside React, and components see it through `useSyncExternalStore`. +4. **The ban takes no inline exception.** No `oxlint-disable` or `eslint-disable` directive, in any of its line, next-line or block forms, names the Effect rule, covers a refused import or load under items 1-2 without naming a rule, or names the import ban where it covers a refused `react` import. A directive that turns off an unrelated rule is outside this rule. + +Incidents, all 2026-10-11, in one application UI, each an Effect gluing an external system to React state. (b) A `useEffect` fetched server data and copied it into `useState`. (c) A `useEffect` built an Effect, ran it with `runtime.runFork(...)` and pushed the result and the error back with `Effect.sync(() => setView(...))`. (e) A `useEffect` started `setInterval(() => setNow(new Date()), 30_000)`. (f) A `useEffect` wrote `localStorage.setItem(key, JSON.stringify(value))` whenever the value changed. Where each goes instead: (b) and (c) are runtime atoms (`server-data-lives-in-atoms.md`, `components-dispatch-to-runtime-atoms.md`); (e) and (f) are atoms over the clock and `KeyValueStore` (`components-perform-no-io.md`). + +```tsx +// WRONG (incident c): an Effect runs a fiber by hand and pushes the result into useState +export function OrderPanel({ orderId }: { readonly orderId: string }) { + const [view, setView] = useState(null) + useEffect(() => { + const fiber = runtime.runFork( + Orders.view(orderId).pipe(Effect.tap((next) => Effect.sync(() => setView(next)))), + ) + return () => runtime.runFork(Fiber.interrupt(fiber)) + }, [orderId]) + return view === null ? : +} +``` + +```ts +// WRONG: a hook subscribes in an Effect and mirrors the store into useState +export const useStock = (store: StockStore): number => { + const [stock, setStock] = useState(store.read()) + useEffect(() => store.subscribe(() => setStock(store.read())), [store]) + return stock +} +``` + +```tsx +// WRONG: saved state is written into the registry after commit +export function SavedPage({ state, children }: { readonly state: SavedState; readonly children: ReactNode }) { + const registry = AtomReact.useRegistry() + useEffect(() => { + Atom.Hydration.hydrate(registry, state) + }, [registry, state]) + return children +} +``` + +```tsx +// RIGHT: the component reads a runtime atom (orders.atoms.ts holds the atom, built as server-data-lives-in-atoms.md shows) +export const orderViewAtom = Atom.family((orderId: string) => + appRuntime.atom(Orders.use((orders) => orders.view(orderId))) +) + +export function OrderPanel({ orderId }: { readonly orderId: string }) { + const view = AtomReact.useAtomValue(orderViewAtom(orderId)) + return Atom.AsyncResult.match(view, { + onInitial: () => , + onFailure: () => Order unavailable, + onSuccess: ({ value }) => (Context.isContext(value) ? null : ), + }) +} +``` + +```ts +// RIGHT: the effect-atom binding subscribes React to the registry (effect-atom-react src/hooks-value.ts:15-25) +function useStore(registry: Atom.Registry.Registry, atom: Atom.Atom): A { + const subscribe = useMemo( + () => (onStoreChange: () => void) => Atom.Registry.subscribe(registry, atom, () => onStoreChange()), + [registry, atom], + ) + return useSyncExternalStore( + subscribe, + () => Atom.Registry.get(registry, atom), + () => Atom.getServerValue(atom, registry), + ) +} +``` + +```tsx +// RIGHT: the entry that mounts the app restores saved state before the first render +const registry = Atom.Registry.make({ scheduleTask: AtomReact.scheduleTask }) +Atom.Hydration.hydrate(registry, savedState) +hydrateRoot( + container, + + + , +) +``` + +A reaction to an atom change that must run code, not render, is no hook: it is `Atom.Registry.subscribe(registry, atom, f)` inside a scoped Effect outside React, released with its scope. + +Gate: `review` — Does the diff add a call or reference to `useEffect`, `useLayoutEffect`, `useInsertionEffect` or `useEffectEvent` from `react` in any form? Does it import `react` through a namespace or default specifier, `import('react')` or `require('react')`? Does it add a disable directive that turns either ban off where it applies? + +Lint: `@systemfsoftware/oxlint-config-frontend` sets `no-restricted-imports` on `react` for `useEffect`, `useLayoutEffect`, `useInsertionEffect`, `useEffectEvent` and `default`, which also refuses every namespace import of `react`, type-only included, every re-export of those names and every `export * from 'react'`; and `@systemfsoftware/ui-state/no-react-effect` reports every `import('react')` and `require('react')` with a literal specifier and every directive item 4 refuses. Each message names `ui-state/react-runs-no-effect`. Review covers what neither sees: a computed specifier such as `import(name)` and an Effect hook re-exported by a third-party package. diff --git a/compound-packs/ui-state/server-data-lives-in-atoms.md b/compound-packs/ui-state/server-data-lives-in-atoms.md new file mode 100644 index 0000000..cb0a5d8 --- /dev/null +++ b/compound-packs/ui-state/server-data-lives-in-atoms.md @@ -0,0 +1,55 @@ +--- +title: Server data is read through effect-atom atoms over Effect services, never held in React state or a React server-state library +applies_when: + - writing or reviewing a React component, hook, or `**/*.tsx` file + - changing a UI package (a package that renders React), excluding the effect-atom bindings' own implementation +tags: [ui-state, atom, server-state, useState, react-query] +--- + +Data that originates from a server, an Effect service or another component is read through effect-atom atoms (`useAtomValue`, `useAtomSuspense`) and is never copied into `useState`, `useReducer` or `useRef`. A copy is a second store beside the registry: it goes stale when the atom refreshes, and the fetch, the retry and the error state move into the component. + +No module imports a React server-state library, in any import form, type-only included: `@tanstack/react-query`, `@tanstack/react-query-devtools`, `@tanstack/react-query-persist-client`, `@tanstack/react-query-next-experimental`, `react-query`, `swr`, `urql`, `@apollo/client/react`, `@reduxjs/toolkit/query/react`, `@trpc/react-query` and `@trpc/tanstack-react-query`, each with its subpaths. A module that re-exports one of them is banned the same way as the library, and so is every import of that module. + +`useState` holds UI-local state only. A reviewer recognizes it from the diff by four facts: no other component reads the value; it is not derived from an atom, service or fetch result; it is never persisted; and it dies with the component. A DOM element handle, an open/closed flag, hover and focus, and an uncommitted input draft qualify (incident g, 2026-10-11: `useState(null)` for a scroll container, and `useState(null)` for which option's dialog is opening). A `useState` that receives a fetched count, or is initialised from `useAtomValue(...)`, does not. + +Incidents, all 2026-10-11, in one application UI. (a) A page called `useMutation` from `@tanstack/react-query`, and several components called `useQuery(api..({ params }))`, where `useQuery` was react-query's hook re-exported through the app's own internal package. (b) A `useEffect` fetched a URL and merged the response into `useState` through its setter. (c) A `useState` received the result of an Effect the component ran itself. + +```tsx +// WRONG (incident b): server data fetched in an effect and held in useState +export function DemandBadge({ sku }: { readonly sku: Sku }) { + const [demand, setDemand] = useState>({}) + useEffect(() => { + fetch(`/demand/${sku}`) + .then((response) => response.json()) + .then((body) => setDemand((current) => ({ ...current, [sku]: body.pending }))) + }, []) + return {demand[sku]} +} +``` + +```ts +// WRONG (incident a): a server-state hook re-exported through an internal package +export { useMutation, useQuery } from '@tanstack/react-query' +``` + +```tsx +// RIGHT: a parameterised runtime atom over the Demand service, rendered as an AsyncResult +// demand.atoms.ts +export const demandAtom = Atom.family((sku: Sku) => appRuntime.atom(Demand.use((demand) => demand.pending(sku)))) + +// DemandBadge.tsx +export function DemandBadge({ sku }: { readonly sku: Sku }) { + const demand = AtomReact.useAtomValue(demandAtom(sku)) + return Atom.AsyncResult.match(demand, { + onInitial: () => , + onFailure: () => Demand unavailable, + onSuccess: ({ value }) => (Context.isContext(value) ? null : {value}), + }) +} +``` + +`Atom.family`, `Atom.AsyncResult` and the runtime's `atom` member are exported by `@systemfsoftware/effect-atom` (`etc/effect-atom.api.md:9`; `AsyncResult.match` at `src/async-result.schema.ts:747`, runtime `atom` at `src/atom.blueprint.ts:2081-2099`), and `AtomReact.useAtomValue` by `@systemfsoftware/effect-atom-react` (`src/hooks-value.ts:101`). `appRuntime` is built as `components-dispatch-to-runtime-atoms.md` prescribes. At this pin a runtime atom's success value is typed `A | Context.Context` (`src/atom.blueprint.ts:2088`), so the component narrows with effect's `Context.isContext` before rendering; `{value}` without it fails with TS2322. + +Gate: `review` — Does the diff import a banned specifier, or a local module that re-exports one, or put into `useState`/`useReducer`/`useRef` a value that another component reads, that is derived from an atom, service or fetch result, that is persisted, or that outlives the component? + +Lint: `@systemfsoftware/oxlint-config-frontend` sets `no-restricted-imports` with the specifiers above and their subpaths, its message naming `ui-state/server-data-lives-in-atoms`. It reports the import of a library and the module that re-exports it; review covers what it cannot see: a component importing the hook from that internal module, and server data held in React state. diff --git a/compound-packs/ui-state/view-models-are-pure-functions.md b/compound-packs/ui-state/view-models-are-pure-functions.md new file mode 100644 index 0000000..4f3f1c4 --- /dev/null +++ b/compound-packs/ui-state/view-models-are-pure-functions.md @@ -0,0 +1,53 @@ +--- +title: Display derivation is a named pure function in a `.ts` module, applied as `useAtomValue(atom, f)` +applies_when: + - writing or reviewing a React component, hook, or `**/*.tsx` file + - changing a UI package (a package that renders React), excluding the effect-atom bindings' own implementation +tags: [ui-state, view-model, pure-function, derivation, atom-map] +--- + +Display derivation over atom data (sorting, filtering, grouping, formatting, status-to-label mapping) is a named pure function, defined at module scope in a `.ts` module. A component applies it only as `useAtomValue(atom, f)`. A derived atom built with `Atom.map` or `Atom.mapResult` is defined at module scope, or inside `Atom.family`, never in a component body. The derivation is never written inline in the body or JSX, and never as an inline arrow. + +Why not an inline arrow: `useAtomValue(atom, f)` derives with `React.useMemo(() => Atom.map(atom, f), [atom, f])` (`packages/atom/effect-atom-react/src/hooks-value.ts:110`), so a fresh arrow on every render derives a new atom on every render. A named function in a `.ts` module is stable, and it is the pure core that kb://software/fcis puts beside the shell: "Purity is per-function by return type, never per-folder." Its property tests and mutation measure are owned elsewhere and not restated: storybook-testing `stories-are-the-ui-integration-tests.md` and mutation-discipline `kill-survivors-with-contract-properties.md`. + +Rendering load state is not domain branching: `Atom.AsyncResult.match(result, { onInitial, onFailure, onSuccess })` in a component passes. + +Incident h, 2026-10-11, in one application UI: sorting, filtering, date formatting and status-to-label branching were written inline in component bodies, with no pure function and no property test. + +```tsx +// WRONG (incident h): derivation inline, in an arrow passed to useAtomValue and in JSX +export function Orders() { + const rows = AtomReact.useAtomValue(ordersAtom, (orders) => [...orders].sort(byPlacedAt).filter(isOpen)) + return ( +
    + {rows.map((order) => ( +
  • + {order.status === 'paid' ? 'Paid' : 'Due'} {new Date(order.placedAt).toLocaleDateString()} +
  • + ))} +
+ ) +} +``` + +```ts +// RIGHT: order-rows.ts, exported and property-tested there +export const toOrderRows = (orders: ReadonlyArray): ReadonlyArray => + orders.filter(isOpen).toSorted(byPlacedAt).map((order) => ({ + id: order.id, + status: statusLabel[order.status], + placed: DateTime.format(DateTime.makeUnsafe(order.placedAt), { dateStyle: 'medium' }), + })) +``` + +```tsx +// RIGHT: Orders.tsx applies the named function and renders +export function Orders() { + const rows = AtomReact.useAtomValue(ordersAtom, toOrderRows) + return
    {rows.map((row) =>
  • {row.status} {row.placed}
  • )}
+} +``` + +`useAtomValue(atom, f)` is the second overload at `packages/atom/effect-atom-react/src/hooks-value.ts:101-103`; `Atom.map` and `Atom.mapResult` are listed in `packages/atom/effect-atom/etc/effect-atom.api.md`. `DateTime.format` and `DateTime.makeUnsafe` are `repos/effect/packages/effect/src/DateTime.ts:2472,653`. + +Gate: `review` — Does any component in the diff sort, filter, group, format or branch on a domain status inline (in the body, in JSX, or in an arrow passed to `useAtomValue`), or call `Atom.map`/`Atom.mapResult` in a component body, instead of passing a named function imported from a `.ts` module? diff --git a/dprint.json b/dprint.json index 1bae13d..723f4ff 100644 --- a/dprint.json +++ b/dprint.json @@ -19,7 +19,8 @@ "coverage/**", "reports/**", "**/snapshots/*.md", - "repos/**" + "repos/**", + "**/etc/*.api.md" ], "plugins": [ "https://plugins.dprint.dev/typescript-0.96.1.wasm", diff --git a/flake.lock b/flake.lock index c0226ff..1945c45 100644 --- a/flake.lock +++ b/flake.lock @@ -17,6 +17,23 @@ "type": "github" } }, + "are-the-types-wrong-effect_2": { + "flake": false, + "locked": { + "lastModified": 1789533049, + "narHash": "sha256-UsnhLvTURM27VB3T/gxc6Oql/av0FYYI6nLv5UBKusw=", + "owner": "systemfsoftware", + "repo": "are-the-types-wrong-effect", + "rev": "d43a8020f7dc82a32de410823c6da5298c53a3b6", + "type": "github" + }, + "original": { + "owner": "systemfsoftware", + "repo": "are-the-types-wrong-effect", + "rev": "d43a8020f7dc82a32de410823c6da5298c53a3b6", + "type": "github" + } + }, "comment-checker": { "inputs": { "nixpkgs": [ @@ -107,6 +124,42 @@ "type": "github" } }, + "crane": { + "locked": { + "lastModified": 1787326676, + "narHash": "sha256-lWhBbBvC05/xwivKBBiM2YNizpmgqCgyOIzomvRuwxs=", + "owner": "ipetkov", + "repo": "crane", + "rev": "692f7e9ef2ece8125b466f66f2af532b3edaed0d", + "type": "github" + }, + "original": { + "owner": "ipetkov", + "ref": "v0.24.0", + "repo": "crane", + "type": "github" + } + }, + "gritlint": { + "inputs": { + "crane": "crane", + "nixpkgs": "nixpkgs_2", + "rust-overlay": "rust-overlay_2" + }, + "locked": { + "lastModified": 1791663728, + "narHash": "sha256-ggy1StwlilTlhEQdaDiPoJ5L4FOrndRF0BrLS94IdXQ=", + "owner": "systemfsoftware", + "repo": "gritlint", + "rev": "fe886c5073e7a1d3327106fc05702053472325af", + "type": "github" + }, + "original": { + "owner": "systemfsoftware", + "repo": "gritlint", + "type": "github" + } + }, "importPnpmLock": { "inputs": { "nixpkgs": [ @@ -207,6 +260,22 @@ "type": "github" } }, + "nixpkgs_3": { + "locked": { + "lastModified": 1791191277, + "narHash": "sha256-i8KuaINOYLvB2oQikGvtRmdZRxATRxsI9Yad2pp7dEA=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "aa48d347080940b8a2b8d2f48228674e280a3514", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, "pnpm-release-management": { "inputs": { "comment-checker": [ @@ -220,17 +289,17 @@ ] }, "locked": { - "lastModified": 1791406932, - "narHash": "sha256-c/L2F4BkHV/QPF1aS/uEp58WAjvqsWTGNKJsx0wcmS0=", + "lastModified": 1791510278, + "narHash": "sha256-UXWg+FzruRo4IyLB8iYjOu4+sODj5N3cE03+VILreGs=", "owner": "systemfsoftware", "repo": "pnpm-release-management", - "rev": "aaba9ed07a06a9267fb728c4349437566bfbedaf", + "rev": "8cd6e83009531de040cd9c03e1370b4966fd2a12", "type": "github" }, "original": { "owner": "systemfsoftware", - "ref": "main", "repo": "pnpm-release-management", + "rev": "8cd6e83009531de040cd9c03e1370b4966fd2a12", "type": "github" } }, @@ -288,7 +357,7 @@ "release-tools": { "inputs": { "comment-checker": "comment-checker_3", - "nixpkgs": "nixpkgs_2" + "nixpkgs": "nixpkgs_3" }, "locked": { "lastModified": 1791338691, @@ -307,12 +376,14 @@ }, "root": { "inputs": { + "are-the-types-wrong-effect": "are-the-types-wrong-effect", "comment-checker": "comment-checker", "importPnpmLock": "importPnpmLock", "nixpkgs": "nixpkgs", "pnpm-release-management": "pnpm-release-management", "stryker-js-effect": "stryker-js-effect", "systemfsoftware": "systemfsoftware", + "systemfsoftware-effect-cell-types-7": "systemfsoftware-effect-cell-types-7", "systemfsoftware-xstate": "systemfsoftware-xstate" } }, @@ -341,6 +412,7 @@ "inputs": { "nixpkgs": [ "systemfsoftware", + "gritlint", "nixpkgs" ] }, @@ -504,26 +576,26 @@ "comment-checker": [ "comment-checker" ], + "gritlint": "gritlint", "nixpkgs": [ "nixpkgs" ], "pnpm-release-management": [ "pnpm-release-management" - ], - "rust-overlay": "rust-overlay_2" + ] }, "locked": { - "lastModified": 1791507027, - "narHash": "sha256-sbVsKZqhLd9/Vp9Nck3Q3OxSH0YQKhQdpkG/mFW/CmM=", + "lastModified": 1792540218, + "narHash": "sha256-96fXTtFTGrMgkERA4JF35rL6PosVI2uo4zMRM+ErCvo=", "owner": "systemfsoftware", "repo": "systemfsoftware", - "rev": "c6eafec3a21048b6ef05e8a5218179bc6b091ed3", + "rev": "d1ab631935c9439bcd16abf2894b09b843494701", "type": "github" }, "original": { "owner": "systemfsoftware", - "ref": "main", "repo": "systemfsoftware", + "rev": "d1ab631935c9439bcd16abf2894b09b843494701", "type": "github" } }, @@ -544,9 +616,26 @@ "type": "github" } }, + "systemfsoftware-effect-cell-types-7_2": { + "flake": false, + "locked": { + "lastModified": 1789152418, + "narHash": "sha256-j6RoO74XnpW0zJX6CKdp6eW88WkptcqXWPFEXZsiELY=", + "owner": "systemfsoftware", + "repo": "systemfsoftware", + "rev": "4cf0f77016f1827fc54ed00144cc85e00eb729a1", + "type": "github" + }, + "original": { + "owner": "systemfsoftware", + "repo": "systemfsoftware", + "rev": "4cf0f77016f1827fc54ed00144cc85e00eb729a1", + "type": "github" + } + }, "systemfsoftware-xstate": { "inputs": { - "are-the-types-wrong-effect": "are-the-types-wrong-effect", + "are-the-types-wrong-effect": "are-the-types-wrong-effect_2", "comment-checker": "comment-checker_2", "importPnpmLock": "importPnpmLock_2", "nixpkgs": [ @@ -556,7 +645,7 @@ "release-tools": "release-tools", "stryker-js-effect": "stryker-js-effect_2", "systemfsoftware": "systemfsoftware_2", - "systemfsoftware-effect-cell-types-7": "systemfsoftware-effect-cell-types-7" + "systemfsoftware-effect-cell-types-7": "systemfsoftware-effect-cell-types-7_2" }, "locked": { "lastModified": 1791513925, diff --git a/flake.nix b/flake.nix index 1f41bdf..7cbd8e5 100644 --- a/flake.nix +++ b/flake.nix @@ -9,14 +9,20 @@ url = "github:systemfsoftware/comment-checker"; inputs.nixpkgs.follows = "nixpkgs"; }; + # The systemfsoftware pin's own pin: from this commit on, + # mkPnpmWorkspacePackages takes `files`, which this workspace's tarball + # build needs for the lockfile's file:.sfs-deps tarballs. pnpm-release-management = { - url = "github:systemfsoftware/pnpm-release-management/main"; + url = "github:systemfsoftware/pnpm-release-management/8cd6e83009531de040cd9c03e1370b4966fd2a12"; inputs.nixpkgs.follows = "nixpkgs"; inputs.comment-checker.follows = "comment-checker"; inputs.importPnpmLock.follows = "importPnpmLock"; }; + # The generic toolchain at a fixed commit. Its lib.mkConsumerStore builds + # the sandbox store, and its tarballs are the file:.sfs-deps specs the + # lockfile names, so a version moves only when this pin moves. systemfsoftware = { - url = "github:systemfsoftware/systemfsoftware/main"; + url = "github:systemfsoftware/systemfsoftware/d1ab631935c9439bcd16abf2894b09b843494701"; inputs.nixpkgs.follows = "nixpkgs"; inputs.comment-checker.follows = "comment-checker"; inputs.pnpm-release-management.follows = "pnpm-release-management"; @@ -33,6 +39,19 @@ url = "github:systemfsoftware/xstate/main"; inputs.nixpkgs.follows = "nixpkgs"; }; + # The attw CLI every package's `attw` script runs, built from source at + # the released 4.2.0 tag, so no @systemfsoftware/* package is fetched from + # a registry. flake = false: the input is the pinned tree. + are-the-types-wrong-effect = { + url = "github:systemfsoftware/are-the-types-wrong-effect/d43a8020f7dc82a32de410823c6da5298c53a3b6"; + flake = false; + }; + # @systemfsoftware/effect-cell-types@v7.0.0's tag commit: the version + # attw 4.2.0's lockfile resolves. Only the attw install takes it. + systemfsoftware-effect-cell-types-7 = { + url = "github:systemfsoftware/systemfsoftware/4cf0f77016f1827fc54ed00144cc85e00eb729a1"; + flake = false; + }; # The pnpm store is hashless: each tarball's lockfile integrity is its fetch hash, so a lockfile change needs no hash edit. importPnpmLock = { url = "github:Scrumplex/importPnpmLock.nix"; @@ -40,12 +59,162 @@ }; }; - outputs = { self, nixpkgs, comment-checker, pnpm-release-management, systemfsoftware, stryker-js-effect, systemfsoftware-xstate, importPnpmLock }: + outputs = { self, nixpkgs, comment-checker, pnpm-release-management, systemfsoftware, stryker-js-effect, systemfsoftware-xstate, are-the-types-wrong-effect, systemfsoftware-effect-cell-types-7, importPnpmLock }: let + lib = nixpkgs.lib; systems = [ "x86_64-linux" "aarch64-linux" ]; - forEachSystem = fn: nixpkgs.lib.genAttrs systems (system: fn nixpkgs.legacyPackages.${system}); + forEachSystem = fn: lib.genAttrs systems (system: fn nixpkgs.legacyPackages.${system}); + + # The systemfsoftware packages this workspace installs, by flake + # attribute: every @systemfsoftware/* lockfile entry outside the + # stryker-js-effect, xstate and attw tarballs. + sfsPackages = [ + "conformance-spec" + "differential-spec" + "effect-cell-types" + "effect-gherkin-spec" + "effect-schema-discovery" + "effect-schema-law" + "effect-schema-recursion-budget" + "effect-schema-vite" + "effect-sim-kernel" + "effect-spec-runtime" + "oxlint-config-cell-architecture" + "oxlint-config-dmmf" + "oxlint-config-recommended" + "oxlint-config-rule-authoring" + "oxlint-plugin-cell-architecture" + "oxlint-plugin-dmmf-workflow" + "oxlint-plugin-effect-platform" + "oxlint-plugin-effect-schema" + "oxlint-plugin-test-discipline" + "stryker-config" + "tsconfig" + "tsdown-config" + "vitest" + "vitest-config" + ]; + + # The tarballs the lockfile names that the systemfsoftware flake does + # not build: stryker-js-effect's, xstate's, and the attw CLI built from + # source the way xstate builds it. + extraDepsOf = pkgs: + let + system = pkgs.stdenv.hostPlatform.system; + sys-tarballs = systemfsoftware.packages.${system}.workspace-tarballs; + mkPnpmStore = pkgs.callPackage ./nix/pnpm-store.nix { + inherit (importPnpmLock.legacyPackages.${system}) importPnpmLock; + nodejs = pkgs.nodejs_24; + pnpm = pkgs.pnpm_12; + }; + fromSource = args: pkgs.callPackage ./nix/from-source.nix ({ + inherit mkPnpmStore; + nodejs = pkgs.nodejs_24; + pnpm = pkgs.pnpm_12; + } // args); + effect-cell-types-7 = fromSource { + pname = "effect-cell-types"; + src = systemfsoftware-effect-cell-types-7; + overlay = ./nix/effect-cell-types; + deps = sys-tarballs; + packed = [ + { name = "@systemfsoftware/effect-cell-types"; dir = "packages/effect-cell-types"; } + ]; + }; + # The attw install takes effect-cell-types 7 beside the + # systemfsoftware set. + attw-deps = pkgs.runCommand "starter-attw-deps" { } '' + mkdir -p "$out" + cp -r ${sys-tarballs}/. "$out"/ + cp -r ${effect-cell-types-7.tarballs}/. "$out"/ + ''; + attw = fromSource { + pname = "arethetypeswrong-cli"; + src = are-the-types-wrong-effect; + overlay = ./nix/attw; + deps = attw-deps; + # The CLI bundles the engine into dist/main.mjs, so the engine is + # built but only the CLI is packed. + built = [ + { name = "@systemfsoftware/arethetypeswrong"; dir = "packages/arethetypeswrong"; } + ]; + packed = [ + { name = "@systemfsoftware/arethetypeswrong-cli"; dir = "apps/arethetypeswrong-cli"; } + ]; + }; + in + pkgs.runCommand "starter-extra-deps" { } '' + mkdir "$out" + for dir in ${stryker-js-effect.packages.${system}.workspace-tarballs} ${systemfsoftware-xstate.packages.${system}.workspace-tarballs} ${attw.tarballs}; do + cp "$dir"/*.tgz "$out"/ + done + ''; + + # Every tarball the lockfile names, as plain files under the names the + # file:.sfs-deps/-.tgz specs use: the sandbox reads no + # store path outside its closure, so the dev shell copies this directory. + sfsDepsOf = pkgs: + let + sys = systemfsoftware.packages.${pkgs.stdenv.hostPlatform.system}; + in + pkgs.runCommand "sfs-deps" { } '' + mkdir "$out" + cp ${extraDepsOf pkgs}/*.tgz "$out"/ + ${lib.concatMapStrings (name: '' + cp ${sys.${name}} "$out/${sys.${name}.name}" + '') sfsPackages} + ''; + + # The lockfile, the workspace doc, every manifest and the patches + # patchedDependencies names, with the non-systemfsoftware tarballs at + # .sfs-deps; lib.mkConsumerStore lays the systemfsoftware ones beside them. + consumerSrcOf = pkgs: + let + manifests = lib.fileset.toSource { + root = ./.; + fileset = lib.fileset.unions [ + ./pnpm-lock.yaml + ./pnpm-workspace.yaml + ./patches + (lib.fileset.fileFilter (file: file.name == "package.json") ./.) + ]; + }; + in + pkgs.runCommand "starter-consumer-src" { } '' + cp -r ${manifests} "$out" + chmod -R u+w "$out" + mkdir -p "$out/.sfs-deps" + cp ${extraDepsOf pkgs}/*.tgz "$out/.sfs-deps"/ + ''; + + workspaceOf = pkgs: + removeAttrs (pnpm-release-management.lib.mkPnpmWorkspacePackages { + inherit pkgs; + src = self; + pname = "starter"; + pnpm = pkgs.pnpm_12; + files.".sfs-deps" = sfsDepsOf pkgs; + }) [ "pnpm-store" "mitmCache" ]; in { + # A consumer's sandbox store: its lockfile's registry packages plus the + # named workspace tarballs, which its package.json depends on as + # `file:
/-.tgz`. + lib.mkConsumerStore = { pkgs, src, packages, dir ? ".sfs-deps", lockFile ? src + "/pnpm-lock.yaml" }: + let + workspace = workspaceOf pkgs; + members = workspace.workspace-tarballs.members; + chosen = map (name: + lib.findFirst (member: member.attr == name) + (throw "lib.mkConsumerStore: no public workspace package named ${name}") + members) packages; + in + pnpm-release-management.lib.mkPnpmConsumerStore { + inherit pkgs src lockFile; + files.${dir} = pkgs.linkFarm "starter-consumer-tarballs" + (map (member: { name = member.tarball; path = workspace.${member.attr}; }) chosen); + }; + packages = forEachSystem (pkgs: let system = pkgs.stdenv.hostPlatform.system; @@ -56,44 +225,28 @@ sandboxed = pkgs.callPackage ./nix/comment-checker-sandbox.nix { comment-checker = unwrapped; }; - sfs-deps = pkgs.runCommand "sfs-deps" { nativeBuildInputs = [ pkgs.jq ]; } '' - mkdir $out - for dir in ${systemfsoftware.packages.${system}.workspace-tarballs} ${stryker-js-effect.packages.${system}.workspace-tarballs} ${systemfsoftware-xstate.packages.${system}.workspace-tarballs}; do - cp "$dir"/*.tgz $out/ - done - jq -s add ${systemfsoftware.packages.${system}.workspace-tarballs}/index.json ${stryker-js-effect.packages.${system}.workspace-tarballs}/index.json ${systemfsoftware-xstate.packages.${system}.workspace-tarballs}/index.json > $out/index.json - ''; - pnpm-store = pnpm-release-management.lib.mkPnpmConsumerStore { - inherit pkgs; - pname = "starter"; - src = pkgs.lib.fileset.toSource { - root = ./.; - fileset = pkgs.lib.fileset.unions [ - ./pnpm-lock.yaml - ./pnpm-workspace.yaml - (pkgs.lib.fileset.fileFilter (file: file.name == "package.json") ./.) - ]; - }; - files.".sfs-deps" = sfs-deps; - }; sandbox = pkgs.callPackage "${pnpm-release-management}/nix/sandbox/default.nix" { }; - workspace = pnpm-release-management.lib.mkPnpmWorkspacePackages { - inherit pkgs; - src = self; - pname = "starter"; - pnpm = pkgs.pnpm_12; + workspace = workspaceOf pkgs; + own = { + inherit dprint sandbox; + sfs-deps = sfsDepsOf pkgs; + pnpm-store = systemfsoftware.lib.mkConsumerStore { + inherit pkgs; + src = consumerSrcOf pkgs; + packages = sfsPackages; + }; + sandbox-proofs = (pkgs.callPackage "${pnpm-release-management}/nix/sandbox/proofs.nix" { + inherit pkgs sandbox; + inherit (importPnpmLock.legacyPackages.${system}) iplConfigHook; + }).sandbox-proofs; + comment-checker = sandboxed; + comment-checker-unwrapped = unwrapped; + default = dprint; }; - in { - inherit dprint sfs-deps sandbox pnpm-store; - inherit (workspace) workspace-tarballs; - sandbox-proofs = (pkgs.callPackage "${pnpm-release-management}/nix/sandbox/proofs.nix" { - inherit pkgs sandbox; - inherit (importPnpmLock.legacyPackages.${system}) iplConfigHook; - }).sandbox-proofs; - comment-checker = sandboxed; - comment-checker-unwrapped = unwrapped; - default = dprint; - }); + clashes = builtins.attrNames (builtins.intersectAttrs own workspace); + in + assert clashes == [ ] || throw "flake.nix: workspace packages ${lib.concatStringsSep ", " clashes} collide with flake packages"; + workspace // own); devShells = forEachSystem (pkgs: let @@ -104,13 +257,17 @@ withWebkit = false; withFfmpeg = false; }; + # Without a font configuration the sandboxed browser lays text out + # at zero height, so a visibility assertion on text fails. + fontsConf = pkgs.makeFontsConf { fontDirectories = [ pkgs.dejavu_fonts ]; }; # The launcher reads only store paths on PATH, the command and the - # pnpm store, so a bare --pass-env cannot carry the browsers in. A - # PATH entry whose closure reaches them makes the store path readable. + # pnpm store, so a bare --pass-env cannot carry the browsers or the + # font configuration in. A PATH entry whose closure reaches them + # makes those store paths readable. playwrightBrowsersAnchor = pkgs.writeShellApplication { name = "playwright-browsers-anchor"; runtimeInputs = [ playwrightBrowsers ]; - text = ":"; + text = ": ${fontsConf}"; }; in { default = pkgs.mkShell { @@ -133,6 +290,7 @@ ''; env = pkgs.lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux { PLAYWRIGHT_BROWSERS_PATH = "${playwrightBrowsers}"; + FONTCONFIG_FILE = "${fontsConf}"; }; }; }); diff --git a/nix/attw/package.json b/nix/attw/package.json new file mode 100644 index 0000000..81bc84f --- /dev/null +++ b/nix/attw/package.json @@ -0,0 +1,6 @@ +{ + "license": "Apache-2.0", + "name": "are-the-types-wrong-build", + "private": true, + "type": "module" +} diff --git a/nix/attw/pnpm-lock.yaml b/nix/attw/pnpm-lock.yaml new file mode 100644 index 0000000..ad79411 --- /dev/null +++ b/nix/attw/pnpm-lock.yaml @@ -0,0 +1,1152 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +catalogs: + attw: + typescript: + specifier: ^6.0.3 + version: 6.0.3 + default: + '@loaderkit/resolve': + specifier: ^1.0.6 + version: 1.0.6 + '@systemfsoftware/npm-package': + specifier: file:.sfs-deps/npm-package-1.0.0.tgz + version: file:.sfs-deps/npm-package-1.0.0.tgz + '@systemfsoftware/tsconfig': + specifier: file:.sfs-deps/tsconfig-2.0.1.tgz + version: file:.sfs-deps/tsconfig-2.0.1.tgz + '@types/node': + specifier: ^24.13.4 + version: 24.19.1 + '@types/semver': + specifier: ^7.8.0 + version: 7.8.0 + '@types/validate-npm-package-name': + specifier: ^4.0.2 + version: 4.0.2 + cjs-module-lexer: + specifier: ^2.2.1 + version: 2.3.0 + semver: + specifier: ^7.8.5 + version: 7.8.5 + tsdown: + specifier: ^0.23.0 + version: 0.23.0 + validate-npm-package-name: + specifier: ^8.0.0 + version: 8.0.0 + +overrides: + effect: 4.0.0-rc.112 + '@effect/platform-node': 4.0.0-rc.112 + '@effect/platform-node-shared': 4.0.0-rc.112 + '@systemfsoftware/effect-cell-types': file:.sfs-deps/effect-cell-types-7.0.0.tgz + +importers: + + .: {} + + apps/arethetypeswrong-cli: + devDependencies: + '@effect/platform-node': + specifier: 4.0.0-rc.112 + version: 4.0.0-rc.112(effect@4.0.0-rc.112)(redis@6.3.0) + '@effect/platform-node-shared': + specifier: 4.0.0-rc.112 + version: 4.0.0-rc.112(effect@4.0.0-rc.112) + '@systemfsoftware/arethetypeswrong': + specifier: workspace:* + version: link:../../packages/arethetypeswrong + '@systemfsoftware/effect-cell-types': + specifier: file:../../.sfs-deps/effect-cell-types-7.0.0.tgz + version: file:.sfs-deps/effect-cell-types-7.0.0.tgz(effect@4.0.0-rc.112) + '@systemfsoftware/tsconfig': + specifier: 'catalog:' + version: file:.sfs-deps/tsconfig-2.0.1.tgz + '@types/node': + specifier: 'catalog:' + version: 24.19.1 + effect: + specifier: 4.0.0-rc.112 + version: 4.0.0-rc.112 + tsdown: + specifier: 'catalog:' + version: 0.23.0(typescript@6.0.3) + + packages/arethetypeswrong: + dependencies: + '@loaderkit/resolve': + specifier: 'catalog:' + version: 1.0.6 + '@systemfsoftware/npm-package': + specifier: 'catalog:' + version: file:.sfs-deps/npm-package-1.0.0.tgz(effect@4.0.0-rc.112) + cjs-module-lexer: + specifier: 'catalog:' + version: 2.3.0 + effect: + specifier: 4.0.0-rc.112 + version: 4.0.0-rc.112 + semver: + specifier: 'catalog:' + version: 7.8.5 + typescript: + specifier: catalog:attw + version: 6.0.3 + validate-npm-package-name: + specifier: 'catalog:' + version: 8.0.0 + devDependencies: + '@systemfsoftware/effect-cell-types': + specifier: file:../../.sfs-deps/effect-cell-types-7.0.0.tgz + version: file:.sfs-deps/effect-cell-types-7.0.0.tgz(effect@4.0.0-rc.112) + '@systemfsoftware/tsconfig': + specifier: 'catalog:' + version: file:.sfs-deps/tsconfig-2.0.1.tgz + '@types/node': + specifier: 'catalog:' + version: 24.19.1 + '@types/semver': + specifier: 'catalog:' + version: 7.8.0 + '@types/validate-npm-package-name': + specifier: 'catalog:' + version: 4.0.2 + tsdown: + specifier: 'catalog:' + version: 0.23.0(typescript@6.0.3) + +packages: + + '@andrewbranch/untar.js@1.0.4': + resolution: {integrity: sha512-pVXSwPsLuw8IGLo2Di0EaOfsk+ntVvpkk942J/sHYIkwvtKUakEcPh7HBgZ6tuimgzKSEHgCvO4XgQ05DEbwDw==} + + '@braidai/lang@1.1.2': + resolution: {integrity: sha512-qBcknbBufNHlui137Hft8xauQMTZDKdophmLFv05r2eNmdIv/MlPuP4TdUknHG68UdWLgVZwgxVe735HzJNIwA==} + + '@effect/platform-node-shared@4.0.0-rc.112': + resolution: {integrity: sha512-ttjz0xKamFN7vL8pNDYVwddJLjZvqKePc05djlz2VcdaKbLsnYbtMnL1rbOfHgEnIUSHGh7FkjaN4DM1Ov81sQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + effect: 4.0.0-rc.112 + + '@effect/platform-node@4.0.0-rc.112': + resolution: {integrity: sha512-/BMAcdNGQQskLmI0Zoa95KfTZkr9HV9N4NSxaSrusG6GeW6Ulp9KvZ+Rlaiw8lnOt43CXjFLdfll5/k5rxL4hQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + effect: 4.0.0-rc.112 + redis: '>=5.0.0 <7.0.0' + + '@loaderkit/resolve@1.0.6': + resolution: {integrity: sha512-G8FdIoF5CypfwmD9rl8BXod5HDn8JqB0CCNBXDTaRZ+yRYhARrrSToX1zg1zy9jX3zLqigsELwhT4gNtkdQAUg==} + + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==} + cpu: [arm64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + resolution: {integrity: sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==} + cpu: [x64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + resolution: {integrity: sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==} + cpu: [arm64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + resolution: {integrity: sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==} + cpu: [arm] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + resolution: {integrity: sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==} + cpu: [x64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + resolution: {integrity: sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==} + cpu: [x64] + os: [win32] + + '@oxc-project/types@0.152.0': + resolution: {integrity: sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==} + + '@quansync/fs@1.1.0': + resolution: {integrity: sha512-qAPG/t3HqML1TlN7sY/pTbEjzFVAKsMjNNMGheyDosro+kT4iw2KCUoHcVdmliwWjorm4elZbgNQyU2eD97eDg==} + + '@redis/bloom@6.3.0': + resolution: {integrity: sha512-NQ5poYpZr0jv6zazN1cy6JY5kIipKFZ3R9GTL4kOUTX3a9YAfY29U8bsE4lWhVQEM+G4V0BXVC57mubIdDijeA==} + engines: {node: '>= 20.0.0'} + peerDependencies: + '@redis/client': ^6.3.0 + + '@redis/client@6.3.0': + resolution: {integrity: sha512-fHrjwGCBvAANGi+TuRuwKi83sIOzgHr8eh+Uf48w3fSX7Hhrz174NrWqfJhAC2vnN0d/tfaZpdr6+o6JC3QHwg==} + engines: {node: '>= 20.0.0'} + peerDependencies: + '@node-rs/xxhash': ^1.1.0 + '@opentelemetry/api': '>=1 <2' + peerDependenciesMeta: + '@node-rs/xxhash': + optional: true + '@opentelemetry/api': + optional: true + + '@redis/json@6.3.0': + resolution: {integrity: sha512-yt6vRfPBXtW/3qm53cF81TSQpLMjWdW4/2E9WQ2GgbvsKkuCKCFQIcMiKlil1ZMnezK+IQuS795xKU5psbaegw==} + engines: {node: '>= 20.0.0'} + peerDependencies: + '@redis/client': ^6.3.0 + + '@redis/search@6.3.0': + resolution: {integrity: sha512-RPXAZmKjZZG7Tm7m1w9keFdXzbL91PiSHQBfQRe9xwW40jNsTRbG0FwhIz4QlHPU4gKDAsCy8IQHj4yZl4H9rw==} + engines: {node: '>= 20.0.0'} + peerDependencies: + '@redis/client': ^6.3.0 + + '@redis/time-series@6.3.0': + resolution: {integrity: sha512-+fJOB8mN1z5WRakCJNh5lVdz1KqvHGSOistsKeitD24QsCtPwyRQXWC0iq6T/i/ubLE/YcguZqPm8F598MKk4A==} + engines: {node: '>= 20.0.0'} + peerDependencies: + '@redis/client': ^6.3.0 + + '@rolldown/binding-android-arm-eabi@1.2.12': + resolution: {integrity: sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.12': + resolution: {integrity: sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.12': + resolution: {integrity: sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.12': + resolution: {integrity: sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.12': + resolution: {integrity: sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.12': + resolution: {integrity: sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.12': + resolution: {integrity: sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.12': + resolution: {integrity: sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.12': + resolution: {integrity: sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.12': + resolution: {integrity: sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.12': + resolution: {integrity: sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.12': + resolution: {integrity: sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.12': + resolution: {integrity: sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.12': + resolution: {integrity: sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.12': + resolution: {integrity: sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@systemfsoftware/effect-cell-types@file:.sfs-deps/effect-cell-types-7.0.0.tgz': + resolution: {integrity: sha512-ongebnJPlxOT85vAh3zKehUvJdgWR7FkWhFCu1+NlQI7vjmXuRiuXHE4LtHyekzNbjfS7GsfrFcC6wDpWD3IGg==, tarball: file:.sfs-deps/effect-cell-types-7.0.0.tgz} + version: 7.0.0 + peerDependencies: + effect: 4.0.0-rc.112 + + '@systemfsoftware/npm-package@file:.sfs-deps/npm-package-1.0.0.tgz': + resolution: {integrity: sha512-f5tqj5qVkRV//uNDFbdQwmcNikgAQMc0KApBMPtBXx7XHdsCNdeWj4qKub95FHJOMapZ1t/0r8cxpwawp6PvFw==, tarball: file:.sfs-deps/npm-package-1.0.0.tgz} + version: 1.0.0 + engines: {node: '>=24'} + peerDependencies: + effect: 4.0.0-rc.112 + + '@systemfsoftware/tsconfig@file:.sfs-deps/tsconfig-2.0.1.tgz': + resolution: {integrity: sha512-DOBlgfypDiU1IdMAkWTio/xV42eoZj5v+4vTMpRzQu0GvLqK1RdeEtsTE7PrtF3YqbPYyaoTlx8GdgOiMeoXsg==, tarball: file:.sfs-deps/tsconfig-2.0.1.tgz} + version: 2.0.1 + + '@types/node@24.19.1': + resolution: {integrity: sha512-aS3/DG0oM05K0RIXXP+hKjinGG5IgSSVGzswZxW3O0sS3pH4/fycXundUC9XsszgKCk4gHXylTEK6hyFxVxnoQ==} + + '@types/semver@7.8.0': + resolution: {integrity: sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ==} + + '@types/validate-npm-package-name@4.0.2': + resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==} + + '@types/ws@8.18.2': + resolution: {integrity: sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==} + + '@yuku-codegen/binding-android-arm64@0.10.2': + resolution: {integrity: sha512-Oc2KInVkPfUjEB4PeV6X0NvIoyYTzDe/WmqFrTwBqKs6YKnz4A7XwWQvJTb7M0rLz9a6WgpUUiiS5QKaCi4O/g==} + cpu: [arm64] + os: [android] + deprecated: yuku-codegen is pure JavaScript since 0.14 + + '@yuku-codegen/binding-darwin-arm64@0.10.2': + resolution: {integrity: sha512-3H7eNPIHJndUJXZ4QUGBPq1zC6RGcUZfm7bymJes+/N7wTVWUn1bxZ9JcozYHpkWQNm9f23G8YWbaHD9aYH72A==} + cpu: [arm64] + os: [darwin] + deprecated: yuku-codegen is pure JavaScript since 0.14 + + '@yuku-codegen/binding-darwin-x64@0.10.2': + resolution: {integrity: sha512-AJOUpR2s3LF9AWiiub/Uyc/UoXNTWcq8hK3cVI6fUSXtwh34dG21J6hRuNlO6JVJFTo4o3ScVvZOrh/YFfUEAA==} + cpu: [x64] + os: [darwin] + deprecated: yuku-codegen is pure JavaScript since 0.14 + + '@yuku-codegen/binding-freebsd-x64@0.10.2': + resolution: {integrity: sha512-ms7DcZu87u5CiK/wMwvpKAvAmtaqKjCQIXNweMLypG6qbuiaOMQf1jpbB5+j46xBcgCovo8TQMcv0bCQLKIL9w==} + cpu: [x64] + os: [freebsd] + deprecated: yuku-codegen is pure JavaScript since 0.14 + + '@yuku-codegen/binding-linux-arm-gnu@0.10.2': + resolution: {integrity: sha512-xJDpYAsKV5+eaiBhTYl05fvT6sst4PsCeuIFMRu9b0/WCSrNQPfCDtAQ5/MS6xNpsdujdZEPR+gmfWk3ZG5i3A==} + cpu: [arm] + os: [linux] + libc: [glibc] + deprecated: yuku-codegen is pure JavaScript since 0.14 + + '@yuku-codegen/binding-linux-arm-musl@0.10.2': + resolution: {integrity: sha512-qfTkgd7AEx61l4K9VtXWCGTxc/fmXJ4ZheDz3i+/AAJUtg4sa0bPrc0VBxPggB/rayR8Z3+JfrBItuyanBJJ9Q==} + cpu: [arm] + os: [linux] + libc: [musl] + deprecated: yuku-codegen is pure JavaScript since 0.14 + + '@yuku-codegen/binding-linux-arm64-gnu@0.10.2': + resolution: {integrity: sha512-4e6Mifm/4UdjtU3D8mATIrvgP+xEiH8xtQOjd0zpd72XKWT7ug3sdyhq2utkkZFP6BvYp7SgZrI+aR4VeIOHdw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + deprecated: yuku-codegen is pure JavaScript since 0.14 + + '@yuku-codegen/binding-linux-arm64-musl@0.10.2': + resolution: {integrity: sha512-RdsJrUfDYFVV3JOmWFUWwSu31fa1APn12xDeIKxgl/YWxrabwtxsDBNjF2561c7tmcbiewdCUvngqRs8AyGVLA==} + cpu: [arm64] + os: [linux] + libc: [musl] + deprecated: yuku-codegen is pure JavaScript since 0.14 + + '@yuku-codegen/binding-linux-x64-gnu@0.10.2': + resolution: {integrity: sha512-mVzWimEPPreaPyXIUvxsHoJzvO7ckZ5j0LgQFHz04zQIRwt9A9T2hA7K5/jYjJKkMxWG/U2VjhGNwVhtyU+uqg==} + cpu: [x64] + os: [linux] + libc: [glibc] + deprecated: yuku-codegen is pure JavaScript since 0.14 + + '@yuku-codegen/binding-linux-x64-musl@0.10.2': + resolution: {integrity: sha512-Y77fyISurmr2mvO1yeq3u+x/WJbACgPR4w+lzEVx30ViCxZzIGrZPRN1yEdZ9xUNPNsIO5thBHIdRNG+UGUG+Q==} + cpu: [x64] + os: [linux] + libc: [musl] + deprecated: yuku-codegen is pure JavaScript since 0.14 + + '@yuku-codegen/binding-win32-arm64@0.10.2': + resolution: {integrity: sha512-1+tGLyG0u5YYy0lev4ck7/0sd8xJ9SZde0dLut7qLDPZV5WVTV8x6OxNXUne/PGuHZyO1vK+txPQzzHOyXHGSw==} + cpu: [arm64] + os: [win32] + deprecated: yuku-codegen is pure JavaScript since 0.14 + + '@yuku-codegen/binding-win32-x64@0.10.2': + resolution: {integrity: sha512-8a2SExRohRbwC0MY4VpOF0/RSckrJ2ASZqAor5/RYSJJaeadR93n10gZzcKT6pY9ukiSOIZCRKVRD2tH73T0qA==} + cpu: [x64] + os: [win32] + deprecated: yuku-codegen is pure JavaScript since 0.14 + + '@yuku-parser/binding-android-arm64@0.10.2': + resolution: {integrity: sha512-2VPBU9fRGRAQ2xPAvghnec3oou5Nrxm2Bezhf+13UMspAxQSkF/32r+ySKXSwIPA5/RivumDJDxAxc301Sgicw==} + cpu: [arm64] + os: [android] + deprecated: yuku-parser runs on yuku-core since 0.14 + + '@yuku-parser/binding-darwin-arm64@0.10.2': + resolution: {integrity: sha512-LD+PMZE51tCYTOss5HBkm3/AE39MvcMDBfWJx7A4yDcjfNbAQDnHZKtzSOuqrswx+TQHY0ws5xn6+fWOwtmfBA==} + cpu: [arm64] + os: [darwin] + deprecated: yuku-parser runs on yuku-core since 0.14 + + '@yuku-parser/binding-darwin-x64@0.10.2': + resolution: {integrity: sha512-mmZ8cND+AoIIMRERyMinlg5ApHxP23B9jH2B5wT7T+dliPa9rubLxneB/SUjFwyUjGaFnB5G7t4YvpfbO5zbkQ==} + cpu: [x64] + os: [darwin] + deprecated: yuku-parser runs on yuku-core since 0.14 + + '@yuku-parser/binding-freebsd-x64@0.10.2': + resolution: {integrity: sha512-gVIjaaIddbRfAhHlC8N809wQWml7mxfSVnzaLtzGXObTeFTPAg/YVnQXt1UOhPM1ah5eG/8Y0RUlNpB4GVe7eQ==} + cpu: [x64] + os: [freebsd] + deprecated: yuku-parser runs on yuku-core since 0.14 + + '@yuku-parser/binding-linux-arm-gnu@0.10.2': + resolution: {integrity: sha512-q/XPPQQAPdlw05aPj30ygBhekmQryGOwxVraBgApjKK8yY1kNQgqq6XCYLF1WHSee+lhxclrTO7W0/bt7dR1GA==} + cpu: [arm] + os: [linux] + libc: [glibc] + deprecated: yuku-parser runs on yuku-core since 0.14 + + '@yuku-parser/binding-linux-arm-musl@0.10.2': + resolution: {integrity: sha512-A+Cb0I1hFF4wilTQpWs79k1aNnj4B2tkrHx3zsuUNF9BtVY2zPZ4yeQEM/zjXrS/qJlNMZVK9NrWJjwdpnTrfg==} + cpu: [arm] + os: [linux] + libc: [musl] + deprecated: yuku-parser runs on yuku-core since 0.14 + + '@yuku-parser/binding-linux-arm64-gnu@0.10.2': + resolution: {integrity: sha512-aGNSzqIqqphFwAdIFwVvKIyXD1Iy3CxrEJVIZAT87Ecyi4vCmmDD2v2l9h+Gd3/wSy3JZlOI792LJbKAjyy9rQ==} + cpu: [arm64] + os: [linux] + libc: [glibc] + deprecated: yuku-parser runs on yuku-core since 0.14 + + '@yuku-parser/binding-linux-arm64-musl@0.10.2': + resolution: {integrity: sha512-Ddl1sF0rtuCXyHGSOV6dcmdx+ETv1iD+IVFqQuOjzkJZWclxJxdBWX6ZJMRtTiGqGUTbqLEKiTXxpR/Bvqk+2A==} + cpu: [arm64] + os: [linux] + libc: [musl] + deprecated: yuku-parser runs on yuku-core since 0.14 + + '@yuku-parser/binding-linux-x64-gnu@0.10.2': + resolution: {integrity: sha512-/nlcpR6IF5U+0m5L9wvAIXeQa2DT+BXuvqKDsTcOTlcc0B4dHNyqE5hTXsS4hAyimpy2ZK8A1zMNF5hTrjg2cg==} + cpu: [x64] + os: [linux] + libc: [glibc] + deprecated: yuku-parser runs on yuku-core since 0.14 + + '@yuku-parser/binding-linux-x64-musl@0.10.2': + resolution: {integrity: sha512-GX80dxTQD/M/OryyuxJcFzFENzry3cDFPvCFTyWogNWQH3fSHfMrNfpwpl1YzMos1eV9NygK5GSDG7VArQlb4w==} + cpu: [x64] + os: [linux] + libc: [musl] + deprecated: yuku-parser runs on yuku-core since 0.14 + + '@yuku-parser/binding-win32-arm64@0.10.2': + resolution: {integrity: sha512-agePQBV4VHewiGU0ACSjscZ/hJd283f9JfAF19Gf1rI5+wy5tTgx4GS36i0b3xim15AQ4RCpDRosEvhLZ4zAOw==} + cpu: [arm64] + os: [win32] + deprecated: yuku-parser runs on yuku-core since 0.14 + + '@yuku-parser/binding-win32-x64@0.10.2': + resolution: {integrity: sha512-s8//CMpgL5+y1lvDCdyh1rwGI5+ytDJywFJRe1vnhI7n0j+caxshNucurikYLLVeQ2SYFex6aPrzgQxkabBQRA==} + cpu: [x64] + os: [win32] + deprecated: yuku-parser runs on yuku-core since 0.14 + + '@yuku-toolchain/types@0.10.2': + resolution: {integrity: sha512-sSeo4SSSToiS+sSD+bwn/s94EEcaLJ7tG5LCp8gFYC1G5VzxzX7fqB6m9RU1yMD8KTpu6zdU/I1NlQf8hVBJ9Q==} + + cac@7.0.0: + resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} + engines: {node: '>=20.19.0'} + + cjs-module-lexer@2.3.0: + resolution: {integrity: sha512-lmNyBzi6iYiqyrExTctfmjd8tInx+dHu9PBomTfH5RXS53c27JMFJgzNv5TyAnm3PgGf+MLy0+Ws41/HlVxdKQ==} + + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dts-resolver@3.0.0: + resolution: {integrity: sha512-1T1f+z+4tl9XD+m+0HBgWoL/nm0bOIffyWaUuUSBlFg/86IWvfx+wjNaO/ybU0AJzG9/Mi5hBUgGV6zCmWEN7Q==} + engines: {node: ^22.18.0 || >=24.0.0} + peerDependencies: + oxc-resolver: '>=11.0.0' + peerDependenciesMeta: + oxc-resolver: + optional: true + + effect@4.0.0-rc.112: + resolution: {integrity: sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A==} + + empathic@2.1.0: + resolution: {integrity: sha512-AnfC1ATldl49/cvZdLPDjBfrRNwbDO05aibiOtzQu3qtlbJtomNLhF30HEtn/7iBz50dlMECqATo3fG0LrdEgw==} + engines: {node: '>=14'} + + fast-check@4.10.2: + resolution: {integrity: sha512-iK2f+YrcmoeGqk6fA0ea2bptcu/itMIm4NfEozq6N25+aG6h7s5HZbB/k1aV7b5w5sFLMCbbtRUsTVR+BgC3xw==} + engines: {node: '>=12.17.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + + get-tsconfig@5.0.0-beta.6: + resolution: {integrity: sha512-X6fBC0pmImC70gvX2zm56go9hx0MyoGVdG0tUCkg/D+Xnh5TJsOZ7iDbOdI3PvmtrDxnu1YdDufpK2QJX1Meqw==} + engines: {node: '>=20.20.0'} + + hookable@6.1.2: + resolution: {integrity: sha512-+abwxtiEA52GCVIsQqut3S/uKTbUwYIp4Pe/vv+6py5XiXBCqMZHg6pA6Y5qhgLSEys0/cYuPbO0z1QFj5ZCmg==} + + import-without-cache@0.4.1: + resolution: {integrity: sha512-vXoV9PjKHEednCUu01e98TkImxy67e3BJXbTIOmIq4Hyzw3IAwYRcuPkfeTzJzwyyts4kjuA73x+pWJLc4f86A==} + engines: {node: ^22.18.0 || >=24.0.0} + + mime@4.1.0: + resolution: {integrity: sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==} + engines: {node: '>=16'} + hasBin: true + + msgpackr-extract@3.0.4: + resolution: {integrity: sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==} + hasBin: true + + msgpackr@2.1.0: + resolution: {integrity: sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ==} + + node-gyp-build-optional-packages@5.2.2: + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} + hasBin: true + + obug@2.2.1: + resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} + engines: {node: '>=12.20.0'} + + obug@3.0.0: + resolution: {integrity: sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==} + engines: {node: '>=12.20.0'} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + pure-rand@8.4.2: + resolution: {integrity: sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==} + + quansync@1.0.0: + resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} + + redis@6.3.0: + resolution: {integrity: sha512-XFQbPie1lGpKeUZ8ySYY43yLnQy/iIQtHNvIr/1XdaABpb96rUSdm3Mfl+98FEVbv9ZJIzINMno5s0Nh8E5LEw==} + engines: {node: '>= 20.0.0'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + rolldown-plugin-dts@0.28.6: + resolution: {integrity: sha512-qKrFtBfRfR2hP233m7Ic9zf3wv6MSYdZghWKMdEO6dRYZGlNfINJAa1P5ZVvyHh3mrcd7IJQvIY8L9vnm+v5rQ==} + engines: {node: ^22.18.0 || ^24.11.0 || >=26.0.0} + peerDependencies: + '@typescript/native-preview': '*' + '@volar/typescript': ~2.4.0 + '@vue/language-core': ~3.2.0 || ~3.3.0 + rolldown: ^1.2.0 + typescript: ^5.0.0 || ^6.0.0 || ~7.0.0 + vue-tsc: ~3.2.0 || ~3.3.0 + peerDependenciesMeta: + '@typescript/native-preview': + optional: true + '@volar/typescript': + optional: true + '@vue/language-core': + optional: true + typescript: + optional: true + vue-tsc: + optional: true + + rolldown@1.2.12: + resolution: {integrity: sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + tsdown@0.23.0: + resolution: {integrity: sha512-BaT+ep1xnj5hdyICLd5r5SYYE+TXnI1ATePLykv9vcKpHpFTWUWRH+x1AF/E2qcu3YB6+vI8IdyxIIIffEqw5Q==} + engines: {node: ^22.18.0 || ^24.11.0 || >=26.0.0} + hasBin: true + peerDependencies: + '@arethetypeswrong/core': ^0.18.1 + '@tsdown/css': 0.23.0 + '@tsdown/exe': 0.23.0 + '@vitejs/devtools': '*' + publint: ^0.3.8 + tsx: '*' + typescript: ^5.0.0 || ^6.0.0 || ^7.0.0 + unplugin-unused: '>=0.5.0' + unrun: '*' + peerDependenciesMeta: + '@arethetypeswrong/core': + optional: true + '@tsdown/css': + optional: true + '@tsdown/exe': + optional: true + '@vitejs/devtools': + optional: true + publint: + optional: true + tsx: + optional: true + typescript: + optional: true + unplugin-unused: + optional: true + unrun: + optional: true + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + unconfig-core@7.5.0: + resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==} + + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + + undici@8.11.2: + resolution: {integrity: sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==} + engines: {node: '>=22.19.0'} + + validate-npm-package-name@8.0.0: + resolution: {integrity: sha512-SCv6OOV6Xj2/3cXy3dGmADluJTNcL3o7hZAglNPTe+WYuEuvxgJzxPrSDLZhF+CwyQOubqgecjMmTJGMVLWjYQ==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + verkit@0.4.1: + resolution: {integrity: sha512-mMVzj0TXExtVdlDEq+Mzp0eyOuyznNpFobNM3uAqe3HVm/Ps2c+u17BligMkZjZCA6EiXpoon8iRTer3iu40SQ==} + engines: {node: '>=18.12.0'} + + ws@8.22.0: + resolution: {integrity: sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + yuku-ast@0.10.2: + resolution: {integrity: sha512-UnG9mA6giglCvSErft2/40TVFX750Sj1xgwddPLpG7J7rlr/P1wPADg9G2RK+d/1tNLtRVoLdMMOMVBB9561TQ==} + + yuku-codegen@0.10.2: + resolution: {integrity: sha512-hentl2dtrF6cPjiAirtkfxfFZBA6Hdm61UqRJ7cA0qrlDNMk9PZGOjw5w1mx3E0hu/6pHcJF4dJW+D0SXHPZOA==} + + yuku-parser@0.10.2: + resolution: {integrity: sha512-CgaU0/PPjCAIEZ3WQroosOxTY3eeKldAN3h+vk8pMNz4+jl1CZzBR4pW+K/rRPF112VooCL5FdjJoiMNjDrL2A==} + +snapshots: + + '@andrewbranch/untar.js@1.0.4': {} + + '@braidai/lang@1.1.2': {} + + '@effect/platform-node-shared@4.0.0-rc.112(effect@4.0.0-rc.112)': + dependencies: + '@types/ws': 8.18.2 + effect: 4.0.0-rc.112 + ws: 8.22.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@effect/platform-node@4.0.0-rc.112(effect@4.0.0-rc.112)(redis@6.3.0)': + dependencies: + '@effect/platform-node-shared': 4.0.0-rc.112(effect@4.0.0-rc.112) + effect: 4.0.0-rc.112 + mime: 4.1.0 + redis: 6.3.0 + undici: 8.11.2 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@loaderkit/resolve@1.0.6': + dependencies: + '@braidai/lang': 1.1.2 + + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + optional: true + + '@oxc-project/types@0.152.0': {} + + '@quansync/fs@1.1.0': + dependencies: + quansync: 1.0.0 + + '@redis/bloom@6.3.0(@redis/client@6.3.0)': + dependencies: + '@redis/client': 6.3.0 + + '@redis/client@6.3.0': {} + + '@redis/json@6.3.0(@redis/client@6.3.0)': + dependencies: + '@redis/client': 6.3.0 + + '@redis/search@6.3.0(@redis/client@6.3.0)': + dependencies: + '@redis/client': 6.3.0 + + '@redis/time-series@6.3.0(@redis/client@6.3.0)': + dependencies: + '@redis/client': 6.3.0 + + '@rolldown/binding-android-arm-eabi@1.2.12': + optional: true + + '@rolldown/binding-android-arm64@1.2.12': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.12': + optional: true + + '@rolldown/binding-darwin-x64@1.2.12': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.12': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.12': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.12': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.12': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.12': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.12': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.12': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.12': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.12': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.12': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.12': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@systemfsoftware/effect-cell-types@file:.sfs-deps/effect-cell-types-7.0.0.tgz(effect@4.0.0-rc.112)': + dependencies: + effect: 4.0.0-rc.112 + + '@systemfsoftware/npm-package@file:.sfs-deps/npm-package-1.0.0.tgz(effect@4.0.0-rc.112)': + dependencies: + '@andrewbranch/untar.js': 1.0.4 + '@systemfsoftware/effect-cell-types': file:.sfs-deps/effect-cell-types-7.0.0.tgz(effect@4.0.0-rc.112) + effect: 4.0.0-rc.112 + fflate: 0.8.3 + + '@systemfsoftware/tsconfig@file:.sfs-deps/tsconfig-2.0.1.tgz': {} + + '@types/node@24.19.1': + dependencies: + undici-types: 7.24.6 + + '@types/semver@7.8.0': {} + + '@types/validate-npm-package-name@4.0.2': {} + + '@types/ws@8.18.2': + dependencies: + '@types/node': 24.19.1 + + '@yuku-codegen/binding-android-arm64@0.10.2': + optional: true + + '@yuku-codegen/binding-darwin-arm64@0.10.2': + optional: true + + '@yuku-codegen/binding-darwin-x64@0.10.2': + optional: true + + '@yuku-codegen/binding-freebsd-x64@0.10.2': + optional: true + + '@yuku-codegen/binding-linux-arm-gnu@0.10.2': + optional: true + + '@yuku-codegen/binding-linux-arm-musl@0.10.2': + optional: true + + '@yuku-codegen/binding-linux-arm64-gnu@0.10.2': + optional: true + + '@yuku-codegen/binding-linux-arm64-musl@0.10.2': + optional: true + + '@yuku-codegen/binding-linux-x64-gnu@0.10.2': + optional: true + + '@yuku-codegen/binding-linux-x64-musl@0.10.2': + optional: true + + '@yuku-codegen/binding-win32-arm64@0.10.2': + optional: true + + '@yuku-codegen/binding-win32-x64@0.10.2': + optional: true + + '@yuku-parser/binding-android-arm64@0.10.2': + optional: true + + '@yuku-parser/binding-darwin-arm64@0.10.2': + optional: true + + '@yuku-parser/binding-darwin-x64@0.10.2': + optional: true + + '@yuku-parser/binding-freebsd-x64@0.10.2': + optional: true + + '@yuku-parser/binding-linux-arm-gnu@0.10.2': + optional: true + + '@yuku-parser/binding-linux-arm-musl@0.10.2': + optional: true + + '@yuku-parser/binding-linux-arm64-gnu@0.10.2': + optional: true + + '@yuku-parser/binding-linux-arm64-musl@0.10.2': + optional: true + + '@yuku-parser/binding-linux-x64-gnu@0.10.2': + optional: true + + '@yuku-parser/binding-linux-x64-musl@0.10.2': + optional: true + + '@yuku-parser/binding-win32-arm64@0.10.2': + optional: true + + '@yuku-parser/binding-win32-x64@0.10.2': + optional: true + + '@yuku-toolchain/types@0.10.2': {} + + cac@7.0.0: {} + + cjs-module-lexer@2.3.0: {} + + defu@6.1.7: {} + + detect-libc@2.1.2: + optional: true + + dts-resolver@3.0.0: {} + + effect@4.0.0-rc.112: + dependencies: + fast-check: 4.10.2 + msgpackr: 2.1.0 + + empathic@2.1.0: {} + + fast-check@4.10.2: + dependencies: + pure-rand: 8.4.2 + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fflate@0.8.3: {} + + get-tsconfig@5.0.0-beta.6: + dependencies: + resolve-pkg-maps: 1.0.0 + + hookable@6.1.2: {} + + import-without-cache@0.4.1: {} + + mime@4.1.0: {} + + msgpackr-extract@3.0.4: + dependencies: + node-gyp-build-optional-packages: 5.2.2 + optionalDependencies: + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.4 + optional: true + + msgpackr@2.1.0: + optionalDependencies: + msgpackr-extract: 3.0.4 + + node-gyp-build-optional-packages@5.2.2: + dependencies: + detect-libc: 2.1.2 + optional: true + + obug@2.2.1: {} + + obug@3.0.0: {} + + picomatch@4.0.7: {} + + pure-rand@8.4.2: {} + + quansync@1.0.0: {} + + redis@6.3.0: + dependencies: + '@redis/bloom': 6.3.0(@redis/client@6.3.0) + '@redis/client': 6.3.0 + '@redis/json': 6.3.0(@redis/client@6.3.0) + '@redis/search': 6.3.0(@redis/client@6.3.0) + '@redis/time-series': 6.3.0(@redis/client@6.3.0) + transitivePeerDependencies: + - '@node-rs/xxhash' + - '@opentelemetry/api' + + resolve-pkg-maps@1.0.0: {} + + rolldown-plugin-dts@0.28.6(rolldown@1.2.12)(typescript@6.0.3): + dependencies: + dts-resolver: 3.0.0 + get-tsconfig: 5.0.0-beta.6 + obug: 3.0.0 + rolldown: 1.2.12 + yuku-ast: 0.10.2 + yuku-codegen: 0.10.2 + yuku-parser: 0.10.2 + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - oxc-resolver + + rolldown@1.2.12: + dependencies: + '@oxc-project/types': 0.152.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.12 + '@rolldown/binding-android-arm64': 1.2.12 + '@rolldown/binding-darwin-arm64': 1.2.12 + '@rolldown/binding-darwin-x64': 1.2.12 + '@rolldown/binding-freebsd-x64': 1.2.12 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.12 + '@rolldown/binding-linux-arm64-gnu': 1.2.12 + '@rolldown/binding-linux-arm64-musl': 1.2.12 + '@rolldown/binding-linux-ppc64-gnu': 1.2.12 + '@rolldown/binding-linux-s390x-gnu': 1.2.12 + '@rolldown/binding-linux-x64-gnu': 1.2.12 + '@rolldown/binding-linux-x64-musl': 1.2.12 + '@rolldown/binding-openharmony-arm64': 1.2.12 + '@rolldown/binding-win32-arm64-msvc': 1.2.12 + '@rolldown/binding-win32-x64-msvc': 1.2.12 + + semver@7.8.5: {} + + tinyexec@1.3.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tree-kill@1.2.2: {} + + tsdown@0.23.0(typescript@6.0.3): + dependencies: + cac: 7.0.0 + defu: 6.1.7 + empathic: 2.1.0 + hookable: 6.1.2 + import-without-cache: 0.4.1 + obug: 2.2.1 + picomatch: 4.0.7 + rolldown: 1.2.12 + rolldown-plugin-dts: 0.28.6(rolldown@1.2.12)(typescript@6.0.3) + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + tree-kill: 1.2.2 + unconfig-core: 7.5.0 + verkit: 0.4.1 + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - '@typescript/native-preview' + - '@volar/typescript' + - '@vue/language-core' + - oxc-resolver + - vue-tsc + + typescript@6.0.3: {} + + unconfig-core@7.5.0: + dependencies: + '@quansync/fs': 1.1.0 + quansync: 1.0.0 + + undici-types@7.24.6: {} + + undici@8.11.2: {} + + validate-npm-package-name@8.0.0: {} + + verkit@0.4.1: {} + + ws@8.22.0: {} + + yuku-ast@0.10.2: + dependencies: + '@yuku-toolchain/types': 0.10.2 + + yuku-codegen@0.10.2: + dependencies: + '@yuku-toolchain/types': 0.10.2 + optionalDependencies: + '@yuku-codegen/binding-android-arm64': 0.10.2 + '@yuku-codegen/binding-darwin-arm64': 0.10.2 + '@yuku-codegen/binding-darwin-x64': 0.10.2 + '@yuku-codegen/binding-freebsd-x64': 0.10.2 + '@yuku-codegen/binding-linux-arm-gnu': 0.10.2 + '@yuku-codegen/binding-linux-arm-musl': 0.10.2 + '@yuku-codegen/binding-linux-arm64-gnu': 0.10.2 + '@yuku-codegen/binding-linux-arm64-musl': 0.10.2 + '@yuku-codegen/binding-linux-x64-gnu': 0.10.2 + '@yuku-codegen/binding-linux-x64-musl': 0.10.2 + '@yuku-codegen/binding-win32-arm64': 0.10.2 + '@yuku-codegen/binding-win32-x64': 0.10.2 + + yuku-parser@0.10.2: + dependencies: + '@yuku-toolchain/types': 0.10.2 + yuku-ast: 0.10.2 + optionalDependencies: + '@yuku-parser/binding-android-arm64': 0.10.2 + '@yuku-parser/binding-darwin-arm64': 0.10.2 + '@yuku-parser/binding-darwin-x64': 0.10.2 + '@yuku-parser/binding-freebsd-x64': 0.10.2 + '@yuku-parser/binding-linux-arm-gnu': 0.10.2 + '@yuku-parser/binding-linux-arm-musl': 0.10.2 + '@yuku-parser/binding-linux-arm64-gnu': 0.10.2 + '@yuku-parser/binding-linux-arm64-musl': 0.10.2 + '@yuku-parser/binding-linux-x64-gnu': 0.10.2 + '@yuku-parser/binding-linux-x64-musl': 0.10.2 + '@yuku-parser/binding-win32-arm64': 0.10.2 + '@yuku-parser/binding-win32-x64': 0.10.2 diff --git a/nix/attw/pnpm-workspace.yaml b/nix/attw/pnpm-workspace.yaml new file mode 100644 index 0000000..f3a560f --- /dev/null +++ b/nix/attw/pnpm-workspace.yaml @@ -0,0 +1,39 @@ +allowBuilds: + cpu-features: false + msgpackr-extract: false + onnxruntime-node: false + protobufjs: false + sharp: false + ssh2: false +packages: + - apps/arethetypeswrong-cli + - packages/arethetypeswrong +catalog: + "@effect/platform-node": 4.0.0-rc.112 + "@effect/platform-node-shared": 4.0.0-rc.112 + "@loaderkit/resolve": ^1.0.6 + "@systemfsoftware/effect-cell-types": file:.sfs-deps/effect-cell-types-7.0.0.tgz + "@systemfsoftware/npm-package": file:.sfs-deps/npm-package-1.0.0.tgz + "@systemfsoftware/tsconfig": file:.sfs-deps/tsconfig-2.0.1.tgz + "@types/node": ^24.13.4 + "@types/semver": ^7.8.0 + "@types/validate-npm-package-name": ^4.0.2 + cjs-module-lexer: ^2.2.1 + effect: 4.0.0-rc.112 + semver: ^7.8.5 + tsdown: ^0.23.0 + typescript: ^7.0.2 + validate-npm-package-name: ^8.0.0 +catalogMode: prefer +catalogs: + attw: + typescript: ^6.0.3 +overrides: + effect: 4.0.0-rc.112 + "@effect/platform-node": 4.0.0-rc.112 + "@effect/platform-node-shared": 4.0.0-rc.112 + "@systemfsoftware/effect-cell-types": "catalog:" +supportedArchitectures: + os: [linux, darwin] + cpu: [x64, arm64] + libc: [glibc] diff --git a/nix/attw/prepare-source.mjs b/nix/attw/prepare-source.mjs new file mode 100755 index 0000000..a2d3fb6 --- /dev/null +++ b/nix/attw/prepare-source.mjs @@ -0,0 +1,66 @@ +#!/usr/bin/env node + +import { readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +const root = process.argv[2] +if (!root) throw new Error('usage: prepare-source.mjs ') + +const HERE = import.meta.dirname + +const BUILD_DEV_DEPENDENCIES = new Set([ + '@effect/platform-node', + '@effect/platform-node-shared', + '@systemfsoftware/arethetypeswrong', + '@systemfsoftware/effect-cell-types', + '@systemfsoftware/tsconfig', + '@types/node', + '@types/semver', + '@types/validate-npm-package-name', + 'effect', + 'tsdown', +]) + +const PACKAGES = ['apps/arethetypeswrong-cli', 'packages/arethetypeswrong'] +const DEP_FIELDS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'] +const STRIPPED_FIELDS = ['devDependencies', 'optionalDependencies'] +const LOCAL = /^(workspace:|file:|link:)/ +const WORKSPACE = /^workspace:/ +const STRY = '@systemfsoftware/' + +const fileCatalogNames = () => { + const yaml = readFileSync(join(HERE, 'pnpm-workspace.yaml'), 'utf8') + const names = new Set() + for (const line of yaml.split('\n')) { + const match = /^\s+"(@systemfsoftware\/[^"]+)":\s+file:/.exec(line) + if (match) names.add(match[1]) + } + return names +} + +const fileLocal = fileCatalogNames() + +for (const dir of PACKAGES) { + const path = join(root, dir, 'package.json') + const manifest = JSON.parse(readFileSync(path, 'utf8')) + for (const field of STRIPPED_FIELDS) { + const deps = manifest[field] + if (!deps) continue + for (const name of Object.keys(deps)) { + if (!BUILD_DEV_DEPENDENCIES.has(name)) delete deps[name] + } + } + for (const field of DEP_FIELDS) { + const deps = manifest[field] + if (!deps) continue + for (const [name, value] of Object.entries(deps)) { + if (WORKSPACE.test(value) && fileLocal.has(name)) deps[name] = 'catalog:' + if (!name.startsWith(STRY)) continue + if (LOCAL.test(value)) continue + if (value === 'catalog:' && fileLocal.has(name)) continue + throw new Error(`${dir}: ${name}@${value} is not a workspace:, file: or link: resolution`) + } + if (Object.keys(deps).length === 0) delete manifest[field] + } + writeFileSync(path, `${JSON.stringify(manifest, null, 2)}\n`) +} diff --git a/nix/effect-cell-types/package.json b/nix/effect-cell-types/package.json new file mode 100644 index 0000000..3074a42 --- /dev/null +++ b/nix/effect-cell-types/package.json @@ -0,0 +1,6 @@ +{ + "license": "Apache-2.0", + "name": "effect-cell-types-build", + "private": true, + "type": "module" +} diff --git a/nix/effect-cell-types/pnpm-lock.yaml b/nix/effect-cell-types/pnpm-lock.yaml new file mode 100644 index 0000000..b511eb6 --- /dev/null +++ b/nix/effect-cell-types/pnpm-lock.yaml @@ -0,0 +1,1065 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +catalogs: + default: + '@systemfsoftware/tsconfig': + specifier: file:.sfs-deps/tsconfig-2.0.1.tgz + version: file:.sfs-deps/tsconfig-2.0.1.tgz + '@types/node': + specifier: ^24 + version: 24.19.1 + tsdown: + specifier: ^0.22.14 + version: 0.22.14 + typescript: + specifier: ^7 + version: 7.0.2 + +overrides: + effect: 4.0.0-rc.112 + '@effect/platform-node': 4.0.0-rc.112 + '@effect/platform-node-shared': 4.0.0-rc.112 + +importers: + + .: {} + + packages/effect-cell-types: + devDependencies: + '@systemfsoftware/tsconfig': + specifier: 'catalog:' + version: file:.sfs-deps/tsconfig-2.0.1.tgz + '@types/node': + specifier: 'catalog:' + version: 24.19.1 + effect: + specifier: 4.0.0-rc.112 + version: 4.0.0-rc.112 + tsdown: + specifier: 'catalog:' + version: 0.22.14(typescript@7.0.2) + typescript: + specifier: 'catalog:' + version: 7.0.2 + +packages: + + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==} + cpu: [arm64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + resolution: {integrity: sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==} + cpu: [x64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + resolution: {integrity: sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==} + cpu: [arm64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + resolution: {integrity: sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==} + cpu: [arm] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + resolution: {integrity: sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==} + cpu: [x64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + resolution: {integrity: sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==} + cpu: [x64] + os: [win32] + + '@oxc-project/types@0.152.0': + resolution: {integrity: sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==} + + '@quansync/fs@1.1.0': + resolution: {integrity: sha512-qAPG/t3HqML1TlN7sY/pTbEjzFVAKsMjNNMGheyDosro+kT4iw2KCUoHcVdmliwWjorm4elZbgNQyU2eD97eDg==} + + '@rolldown/binding-android-arm-eabi@1.2.12': + resolution: {integrity: sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.12': + resolution: {integrity: sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.12': + resolution: {integrity: sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.12': + resolution: {integrity: sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.12': + resolution: {integrity: sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.12': + resolution: {integrity: sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.12': + resolution: {integrity: sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.12': + resolution: {integrity: sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.12': + resolution: {integrity: sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.12': + resolution: {integrity: sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.12': + resolution: {integrity: sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.12': + resolution: {integrity: sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.12': + resolution: {integrity: sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.12': + resolution: {integrity: sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.12': + resolution: {integrity: sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@systemfsoftware/tsconfig@file:.sfs-deps/tsconfig-2.0.1.tgz': + resolution: {integrity: sha512-DOBlgfypDiU1IdMAkWTio/xV42eoZj5v+4vTMpRzQu0GvLqK1RdeEtsTE7PrtF3YqbPYyaoTlx8GdgOiMeoXsg==, tarball: file:.sfs-deps/tsconfig-2.0.1.tgz} + version: 2.0.1 + + '@types/node@24.19.1': + resolution: {integrity: sha512-aS3/DG0oM05K0RIXXP+hKjinGG5IgSSVGzswZxW3O0sS3pH4/fycXundUC9XsszgKCk4gHXylTEK6hyFxVxnoQ==} + + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + + '@yuku-codegen/binding-android-arm64@0.8.7': + resolution: {integrity: sha512-C/0zV5IhgVdYhGJTwrY0v8dknxlhiKwtVJkMUaexu9/QvRmzlV4vfU3hZlUSgqc2BxQHntL1mCVbDq8j0FRFDw==} + cpu: [arm64] + os: [android] + deprecated: yuku-codegen is pure JavaScript since 0.14 + + '@yuku-codegen/binding-darwin-arm64@0.8.7': + resolution: {integrity: sha512-/u+REDMI4a0/lsJXTM4c53/w31OGZLOReZIyg62uhgLs0kc8NHsj/nOcxTdlQjq5gi0zhdkccD9LaLTXcdzPvw==} + cpu: [arm64] + os: [darwin] + deprecated: yuku-codegen is pure JavaScript since 0.14 + + '@yuku-codegen/binding-darwin-x64@0.8.7': + resolution: {integrity: sha512-sMMzFOwCo4WXR+/6zIBThOocSC50iIIZZdfiIDbaLvj0Ax/rWt/iavyfEAqajyvzydLyCqR/ZItdLWSRlu1umw==} + cpu: [x64] + os: [darwin] + deprecated: yuku-codegen is pure JavaScript since 0.14 + + '@yuku-codegen/binding-freebsd-x64@0.8.7': + resolution: {integrity: sha512-MpdpKXix9P+Y1rKgjvcNeNtGjXeL1CmttNhYINrWls8kRpm4xM/oBGTmn6w7to8lAlwj5jm8q03dQPl5mRv4Qw==} + cpu: [x64] + os: [freebsd] + deprecated: yuku-codegen is pure JavaScript since 0.14 + + '@yuku-codegen/binding-linux-arm-gnu@0.8.7': + resolution: {integrity: sha512-rr1srFLlPAmC1vtxfc9C1YLDe3iH09YjfSeeIidBqKhzx1MATjOAq4mjlRUOnhr/L27MotWIYOFKwVsd5JZFOg==} + cpu: [arm] + os: [linux] + libc: [glibc] + deprecated: yuku-codegen is pure JavaScript since 0.14 + + '@yuku-codegen/binding-linux-arm-musl@0.8.7': + resolution: {integrity: sha512-eAufXh8qBRpiSO6ueaMDL+yyoXIGLhpUce72YbcACtZU2qhExwBIJyEtQf5kH2Ki0X2aqkSjSfog4OPtKXan3Q==} + cpu: [arm] + os: [linux] + libc: [musl] + deprecated: yuku-codegen is pure JavaScript since 0.14 + + '@yuku-codegen/binding-linux-arm64-gnu@0.8.7': + resolution: {integrity: sha512-gw4w6wPoHObBrdIC4duVWLmJOvpdE25j5D7yrM5mACNlK4klRz/lv8hK+ssQk9EJHBgZjSaqZIJVFgqNYbfv7A==} + cpu: [arm64] + os: [linux] + libc: [glibc] + deprecated: yuku-codegen is pure JavaScript since 0.14 + + '@yuku-codegen/binding-linux-arm64-musl@0.8.7': + resolution: {integrity: sha512-L68N6Y4XkqcIaKo3Ra88JEvBEH4AHff44A4INcrxeVWZ8CZtu2tCpfVxe3hR8qQQMcvBSQlDn24KpkCKEhVvfA==} + cpu: [arm64] + os: [linux] + libc: [musl] + deprecated: yuku-codegen is pure JavaScript since 0.14 + + '@yuku-codegen/binding-linux-x64-gnu@0.8.7': + resolution: {integrity: sha512-dzyAbltJmf3Cqlb8HcFuYIf5Yn0fl1vTr3XJ9HiVNNvOlhqPSArOqtw9vI1p6/VTXTjrMLXlU+s+/kNHiIy/Cw==} + cpu: [x64] + os: [linux] + libc: [glibc] + deprecated: yuku-codegen is pure JavaScript since 0.14 + + '@yuku-codegen/binding-linux-x64-musl@0.8.7': + resolution: {integrity: sha512-Otw4MH3404q0Bbvl+YTdW9aoUV5vXmUw8260bWvt1XlaoIX/ceSgI4ygheRDMfBPoHt6FDayQaO9OLVUZAgkFA==} + cpu: [x64] + os: [linux] + libc: [musl] + deprecated: yuku-codegen is pure JavaScript since 0.14 + + '@yuku-codegen/binding-win32-arm64@0.8.7': + resolution: {integrity: sha512-qo/jyrzryiBuKEsFiuWaBCBe3tRMynQ0qFWFgOEjcCMQeZfBm+wKiVEUEFXXLc7bh8YezguAWp0Mtnhq4ARNyA==} + cpu: [arm64] + os: [win32] + deprecated: yuku-codegen is pure JavaScript since 0.14 + + '@yuku-codegen/binding-win32-x64@0.8.7': + resolution: {integrity: sha512-D5lDsVDx6m00E6bWySlWdH72Ca4TPSaphDqB6QjU6MpuNLIJqoGoatYyq2rOmBE8Zv/kunot/o58KGL03P3eiA==} + cpu: [x64] + os: [win32] + deprecated: yuku-codegen is pure JavaScript since 0.14 + + '@yuku-parser/binding-android-arm64@0.8.7': + resolution: {integrity: sha512-eGKYiUDX7Y0V7tDTmg+JTVnXnjMqfXXsorZ+EDf5kxwchQ3Or1HS14MzI2fw+jFhHR85fCWt+mtX33Yao73hIQ==} + cpu: [arm64] + os: [android] + deprecated: yuku-parser runs on yuku-core since 0.14 + + '@yuku-parser/binding-darwin-arm64@0.8.7': + resolution: {integrity: sha512-Re0RHelKLnjEURulY2/KxW+Ngb8zuNA4BRZuMwgGQNzVumT6u4U2N2hc01oeYVNVof0i7GrXE4UCNBgbpRRnjQ==} + cpu: [arm64] + os: [darwin] + deprecated: yuku-parser runs on yuku-core since 0.14 + + '@yuku-parser/binding-darwin-x64@0.8.7': + resolution: {integrity: sha512-Hn8DROtQkjlA1ACbPgj4a7eP9IuVOI504oiTwpkWPbpaDWD9KdmnVYCqW+1LfenNK/g7O9NhWGpXEdaCNX7lIA==} + cpu: [x64] + os: [darwin] + deprecated: yuku-parser runs on yuku-core since 0.14 + + '@yuku-parser/binding-freebsd-x64@0.8.7': + resolution: {integrity: sha512-bAP2OV8wRuzplX/jYxv9+vvqQT8JxyNphI8fLfXGL054Xs+4/J5u33cIm3y4rxY8rdoLmmdiJs2Tq7r7lrDRfA==} + cpu: [x64] + os: [freebsd] + deprecated: yuku-parser runs on yuku-core since 0.14 + + '@yuku-parser/binding-linux-arm-gnu@0.8.7': + resolution: {integrity: sha512-kTYwJQQgmZeAWdDIWabiReIZMpmfLueIj1tCmjStUtFGhR1Z0qwxonKVfUC4N7h/VhGGzLZ//7O1kgt1QKqgCg==} + cpu: [arm] + os: [linux] + libc: [glibc] + deprecated: yuku-parser runs on yuku-core since 0.14 + + '@yuku-parser/binding-linux-arm-musl@0.8.7': + resolution: {integrity: sha512-uL4jE8HPT2BLlxAXyD10LqgPuXa9eDa0BKpCdSANmzIJghq/2eZo3/gQNtaxPZMupWoxjYzSad9IXrwu7aYPXQ==} + cpu: [arm] + os: [linux] + libc: [musl] + deprecated: yuku-parser runs on yuku-core since 0.14 + + '@yuku-parser/binding-linux-arm64-gnu@0.8.7': + resolution: {integrity: sha512-3gVN4pWSKZmXiNX7cU164dR9MPvesCHnlH6nPfpK+yQsCuYphjKKplcb4SnZBrhiqmXbgb2HR0c2TS0IZUPhgA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + deprecated: yuku-parser runs on yuku-core since 0.14 + + '@yuku-parser/binding-linux-arm64-musl@0.8.7': + resolution: {integrity: sha512-S0mwfEjoLpxzXeZw802Wa4RaELsQiPtWqG6INcy8j4GtvNFtl4LCX3eGO1XLn9pyLAISLzTRyU3zUCBUPin8lg==} + cpu: [arm64] + os: [linux] + libc: [musl] + deprecated: yuku-parser runs on yuku-core since 0.14 + + '@yuku-parser/binding-linux-x64-gnu@0.8.7': + resolution: {integrity: sha512-lnbWdPmerE5D1uH1G4IEZKnPzCrWCStRGrtgpSIe1RibAo5bZIjDbbbPYXmMHCEh4F+x/JaJpElh26a3r+BPbg==} + cpu: [x64] + os: [linux] + libc: [glibc] + deprecated: yuku-parser runs on yuku-core since 0.14 + + '@yuku-parser/binding-linux-x64-musl@0.8.7': + resolution: {integrity: sha512-769uwndMvMzUvATWbAcEvyLHKA+DzhHSCl/obBUrRdYfRo26yxui6S8y3z7uJ+Naup7UKrDxrpK7OnQkxkl9KQ==} + cpu: [x64] + os: [linux] + libc: [musl] + deprecated: yuku-parser runs on yuku-core since 0.14 + + '@yuku-parser/binding-win32-arm64@0.8.7': + resolution: {integrity: sha512-mEB/9PlaAkisJ6KWGz0zvywXoU6+80dTlR2LwS7s/jcXXoU6fm2+sitBZXtqu3+Q4DcDgPxM45uWMCzPs0TSRw==} + cpu: [arm64] + os: [win32] + deprecated: yuku-parser runs on yuku-core since 0.14 + + '@yuku-parser/binding-win32-x64@0.8.7': + resolution: {integrity: sha512-8vNB2DP0ou61nGb8tc/qfi41gfyDXz1MHr2zqL3nR+cJ6CEbiuWV/l/a/vv151gCgiZLLAyGkQGENpozdg716w==} + cpu: [x64] + os: [win32] + deprecated: yuku-parser runs on yuku-core since 0.14 + + '@yuku-toolchain/types@0.8.7': + resolution: {integrity: sha512-2Z53dNxAJL6UvFoIrDZvYf3zlO8s4VJK4O2hhaB4mXVwwpX/7ajtss3cmfqKvamlNLWyt9FSWs4eoYdlbxpnHA==} + + ansis@4.4.0: + resolution: {integrity: sha512-9k3v7xcHwgdO/DruxGIg4HtjvlAZlcnsX/mzqUb1t3NkYnl9kK2UJ+Gq0io+vQf7iT//BD/HB/NBkUR1LWxoeA==} + engines: {node: '>=14'} + + cac@7.0.0: + resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} + engines: {node: '>=20.19.0'} + + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dts-resolver@3.0.0: + resolution: {integrity: sha512-1T1f+z+4tl9XD+m+0HBgWoL/nm0bOIffyWaUuUSBlFg/86IWvfx+wjNaO/ybU0AJzG9/Mi5hBUgGV6zCmWEN7Q==} + engines: {node: ^22.18.0 || >=24.0.0} + peerDependencies: + oxc-resolver: '>=11.0.0' + peerDependenciesMeta: + oxc-resolver: + optional: true + + effect@4.0.0-rc.112: + resolution: {integrity: sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A==} + + empathic@2.1.0: + resolution: {integrity: sha512-AnfC1ATldl49/cvZdLPDjBfrRNwbDO05aibiOtzQu3qtlbJtomNLhF30HEtn/7iBz50dlMECqATo3fG0LrdEgw==} + engines: {node: '>=14'} + + fast-check@4.10.2: + resolution: {integrity: sha512-iK2f+YrcmoeGqk6fA0ea2bptcu/itMIm4NfEozq6N25+aG6h7s5HZbB/k1aV7b5w5sFLMCbbtRUsTVR+BgC3xw==} + engines: {node: '>=12.17.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + get-tsconfig@5.0.0-beta.5: + resolution: {integrity: sha512-/6gFNr0N04nob252sTQxyFLi3eKFRqIg1I87YcqAMT1i6SQrSF6KujUEQrtrjMV0H/eejTCltLdDSTEMzHbnsQ==} + engines: {node: '>=20.20.0'} + + hookable@6.1.2: + resolution: {integrity: sha512-+abwxtiEA52GCVIsQqut3S/uKTbUwYIp4Pe/vv+6py5XiXBCqMZHg6pA6Y5qhgLSEys0/cYuPbO0z1QFj5ZCmg==} + + import-without-cache@0.4.1: + resolution: {integrity: sha512-vXoV9PjKHEednCUu01e98TkImxy67e3BJXbTIOmIq4Hyzw3IAwYRcuPkfeTzJzwyyts4kjuA73x+pWJLc4f86A==} + engines: {node: ^22.18.0 || >=24.0.0} + + msgpackr-extract@3.0.4: + resolution: {integrity: sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==} + hasBin: true + + msgpackr@2.1.0: + resolution: {integrity: sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ==} + + node-gyp-build-optional-packages@5.2.2: + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} + hasBin: true + + obug@2.2.1: + resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} + engines: {node: '>=12.20.0'} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + pure-rand@8.4.2: + resolution: {integrity: sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==} + + quansync@1.0.0: + resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + rolldown-plugin-dts@0.27.14: + resolution: {integrity: sha512-ZvuDDwoIpRK9RPxDXratCpklFO9QZZWndf/sd0VBFb4LEj0jj07UcHK9OCh7V4XiFz2Z89ziyBC2K6tJiDjrbw==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@typescript/native-preview': '*' + '@volar/typescript': ~2.4.0 + rolldown: ^1.0.0 + typescript: ^5.0.0 || ^6.0.0 || ~7.0.0 + vue-tsc: ~3.2.0 || ~3.3.0 + peerDependenciesMeta: + '@typescript/native-preview': + optional: true + '@volar/typescript': + optional: true + typescript: + optional: true + vue-tsc: + optional: true + + rolldown@1.2.12: + resolution: {integrity: sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + tsdown@0.22.14: + resolution: {integrity: sha512-ule7Y+fsAN2iZbLDoo7C4KYljFJNJJ+fLshyn+9gozeTspVersWHxwdGB+Dm2hzA38s6muFnUTl0jK3vJm9ifQ==} + engines: {node: ^22.18.0 || >=24.11.0} + hasBin: true + peerDependencies: + '@arethetypeswrong/core': ^0.18.1 + '@tsdown/css': 0.22.14 + '@tsdown/exe': 0.22.14 + '@vitejs/devtools': '*' + publint: ^0.3.8 + tsx: '*' + typescript: ^5.0.0 || ^6.0.0 || ^7.0.0 + unplugin-unused: ^0.5.0 + unrun: '*' + peerDependenciesMeta: + '@arethetypeswrong/core': + optional: true + '@tsdown/css': + optional: true + '@tsdown/exe': + optional: true + '@vitejs/devtools': + optional: true + publint: + optional: true + tsx: + optional: true + typescript: + optional: true + unplugin-unused: + optional: true + unrun: + optional: true + + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} + hasBin: true + + unconfig-core@7.5.0: + resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==} + + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + + verkit@0.3.2: + resolution: {integrity: sha512-zj/ob3UsvJGN0whEAKFp53REA5X66hvffVqoCtVQAakJKnKlH+/PcOfMoFwIG/o4rElqLv/ycAFlx8ZlXUorCg==} + engines: {node: '>=18.12.0'} + + yuku-ast@0.8.7: + resolution: {integrity: sha512-h6+4bDfyootiMB9vckk5uKo5r5j0GHrkr17FQTDNfEsFT3DWlN9uu1HJwQwc64pgmLCI945fWM3lbTIqxjT3GQ==} + + yuku-codegen@0.8.7: + resolution: {integrity: sha512-adwDZSh8oVDzhE6Du9PwVWxcOxeV0e2EVhUuMKWfhSY4wkrDq9eqixlxFF3l/XGUV1E7UFzhpz9393MUumkyNw==} + + yuku-parser@0.8.7: + resolution: {integrity: sha512-vRD9nwt4L3aYpxNqeSC4WqLv58xrXef0Ong1Mc45CTXTIpvLafx7JO05sczmQZwdLEZvywrLOGdNC5+Rp5N1BQ==} + +snapshots: + + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + optional: true + + '@oxc-project/types@0.152.0': {} + + '@quansync/fs@1.1.0': + dependencies: + quansync: 1.0.0 + + '@rolldown/binding-android-arm-eabi@1.2.12': + optional: true + + '@rolldown/binding-android-arm64@1.2.12': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.12': + optional: true + + '@rolldown/binding-darwin-x64@1.2.12': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.12': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.12': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.12': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.12': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.12': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.12': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.12': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.12': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.12': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.12': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.12': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@systemfsoftware/tsconfig@file:.sfs-deps/tsconfig-2.0.1.tgz': {} + + '@types/node@24.19.1': + dependencies: + undici-types: 7.24.6 + + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true + + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true + + '@typescript/typescript-darwin-x64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm@7.0.2': + optional: true + + '@typescript/typescript-linux-loong64@7.0.2': + optional: true + + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true + + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + + '@yuku-codegen/binding-android-arm64@0.8.7': + optional: true + + '@yuku-codegen/binding-darwin-arm64@0.8.7': + optional: true + + '@yuku-codegen/binding-darwin-x64@0.8.7': + optional: true + + '@yuku-codegen/binding-freebsd-x64@0.8.7': + optional: true + + '@yuku-codegen/binding-linux-arm-gnu@0.8.7': + optional: true + + '@yuku-codegen/binding-linux-arm-musl@0.8.7': + optional: true + + '@yuku-codegen/binding-linux-arm64-gnu@0.8.7': + optional: true + + '@yuku-codegen/binding-linux-arm64-musl@0.8.7': + optional: true + + '@yuku-codegen/binding-linux-x64-gnu@0.8.7': + optional: true + + '@yuku-codegen/binding-linux-x64-musl@0.8.7': + optional: true + + '@yuku-codegen/binding-win32-arm64@0.8.7': + optional: true + + '@yuku-codegen/binding-win32-x64@0.8.7': + optional: true + + '@yuku-parser/binding-android-arm64@0.8.7': + optional: true + + '@yuku-parser/binding-darwin-arm64@0.8.7': + optional: true + + '@yuku-parser/binding-darwin-x64@0.8.7': + optional: true + + '@yuku-parser/binding-freebsd-x64@0.8.7': + optional: true + + '@yuku-parser/binding-linux-arm-gnu@0.8.7': + optional: true + + '@yuku-parser/binding-linux-arm-musl@0.8.7': + optional: true + + '@yuku-parser/binding-linux-arm64-gnu@0.8.7': + optional: true + + '@yuku-parser/binding-linux-arm64-musl@0.8.7': + optional: true + + '@yuku-parser/binding-linux-x64-gnu@0.8.7': + optional: true + + '@yuku-parser/binding-linux-x64-musl@0.8.7': + optional: true + + '@yuku-parser/binding-win32-arm64@0.8.7': + optional: true + + '@yuku-parser/binding-win32-x64@0.8.7': + optional: true + + '@yuku-toolchain/types@0.8.7': {} + + ansis@4.4.0: {} + + cac@7.0.0: {} + + defu@6.1.7: {} + + detect-libc@2.1.2: + optional: true + + dts-resolver@3.0.0: {} + + effect@4.0.0-rc.112: + dependencies: + fast-check: 4.10.2 + msgpackr: 2.1.0 + + empathic@2.1.0: {} + + fast-check@4.10.2: + dependencies: + pure-rand: 8.4.2 + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + get-tsconfig@5.0.0-beta.5: + dependencies: + resolve-pkg-maps: 1.0.0 + + hookable@6.1.2: {} + + import-without-cache@0.4.1: {} + + msgpackr-extract@3.0.4: + dependencies: + node-gyp-build-optional-packages: 5.2.2 + optionalDependencies: + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.4 + optional: true + + msgpackr@2.1.0: + optionalDependencies: + msgpackr-extract: 3.0.4 + + node-gyp-build-optional-packages@5.2.2: + dependencies: + detect-libc: 2.1.2 + optional: true + + obug@2.2.1: {} + + picomatch@4.0.7: {} + + pure-rand@8.4.2: {} + + quansync@1.0.0: {} + + resolve-pkg-maps@1.0.0: {} + + rolldown-plugin-dts@0.27.14(rolldown@1.2.12)(typescript@7.0.2): + dependencies: + dts-resolver: 3.0.0 + get-tsconfig: 5.0.0-beta.5 + obug: 2.2.1 + rolldown: 1.2.12 + yuku-ast: 0.8.7 + yuku-codegen: 0.8.7 + yuku-parser: 0.8.7 + optionalDependencies: + typescript: 7.0.2 + transitivePeerDependencies: + - oxc-resolver + + rolldown@1.2.12: + dependencies: + '@oxc-project/types': 0.152.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.12 + '@rolldown/binding-android-arm64': 1.2.12 + '@rolldown/binding-darwin-arm64': 1.2.12 + '@rolldown/binding-darwin-x64': 1.2.12 + '@rolldown/binding-freebsd-x64': 1.2.12 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.12 + '@rolldown/binding-linux-arm64-gnu': 1.2.12 + '@rolldown/binding-linux-arm64-musl': 1.2.12 + '@rolldown/binding-linux-ppc64-gnu': 1.2.12 + '@rolldown/binding-linux-s390x-gnu': 1.2.12 + '@rolldown/binding-linux-x64-gnu': 1.2.12 + '@rolldown/binding-linux-x64-musl': 1.2.12 + '@rolldown/binding-openharmony-arm64': 1.2.12 + '@rolldown/binding-win32-arm64-msvc': 1.2.12 + '@rolldown/binding-win32-x64-msvc': 1.2.12 + + tinyexec@1.3.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tree-kill@1.2.2: {} + + tsdown@0.22.14(typescript@7.0.2): + dependencies: + ansis: 4.4.0 + cac: 7.0.0 + defu: 6.1.7 + empathic: 2.1.0 + hookable: 6.1.2 + import-without-cache: 0.4.1 + obug: 2.2.1 + picomatch: 4.0.7 + rolldown: 1.2.12 + rolldown-plugin-dts: 0.27.14(rolldown@1.2.12)(typescript@7.0.2) + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + tree-kill: 1.2.2 + unconfig-core: 7.5.0 + verkit: 0.3.2 + optionalDependencies: + typescript: 7.0.2 + transitivePeerDependencies: + - '@typescript/native-preview' + - '@volar/typescript' + - oxc-resolver + - vue-tsc + + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 + + unconfig-core@7.5.0: + dependencies: + '@quansync/fs': 1.1.0 + quansync: 1.0.0 + + undici-types@7.24.6: {} + + verkit@0.3.2: {} + + yuku-ast@0.8.7: + dependencies: + '@yuku-toolchain/types': 0.8.7 + + yuku-codegen@0.8.7: + dependencies: + '@yuku-toolchain/types': 0.8.7 + optionalDependencies: + '@yuku-codegen/binding-android-arm64': 0.8.7 + '@yuku-codegen/binding-darwin-arm64': 0.8.7 + '@yuku-codegen/binding-darwin-x64': 0.8.7 + '@yuku-codegen/binding-freebsd-x64': 0.8.7 + '@yuku-codegen/binding-linux-arm-gnu': 0.8.7 + '@yuku-codegen/binding-linux-arm-musl': 0.8.7 + '@yuku-codegen/binding-linux-arm64-gnu': 0.8.7 + '@yuku-codegen/binding-linux-arm64-musl': 0.8.7 + '@yuku-codegen/binding-linux-x64-gnu': 0.8.7 + '@yuku-codegen/binding-linux-x64-musl': 0.8.7 + '@yuku-codegen/binding-win32-arm64': 0.8.7 + '@yuku-codegen/binding-win32-x64': 0.8.7 + + yuku-parser@0.8.7: + dependencies: + '@yuku-toolchain/types': 0.8.7 + yuku-ast: 0.8.7 + optionalDependencies: + '@yuku-parser/binding-android-arm64': 0.8.7 + '@yuku-parser/binding-darwin-arm64': 0.8.7 + '@yuku-parser/binding-darwin-x64': 0.8.7 + '@yuku-parser/binding-freebsd-x64': 0.8.7 + '@yuku-parser/binding-linux-arm-gnu': 0.8.7 + '@yuku-parser/binding-linux-arm-musl': 0.8.7 + '@yuku-parser/binding-linux-arm64-gnu': 0.8.7 + '@yuku-parser/binding-linux-arm64-musl': 0.8.7 + '@yuku-parser/binding-linux-x64-gnu': 0.8.7 + '@yuku-parser/binding-linux-x64-musl': 0.8.7 + '@yuku-parser/binding-win32-arm64': 0.8.7 + '@yuku-parser/binding-win32-x64': 0.8.7 diff --git a/nix/effect-cell-types/pnpm-workspace.yaml b/nix/effect-cell-types/pnpm-workspace.yaml new file mode 100644 index 0000000..c1b09f6 --- /dev/null +++ b/nix/effect-cell-types/pnpm-workspace.yaml @@ -0,0 +1,28 @@ +allowBuilds: + "@parcel/watcher": false + cpu-features: false + esbuild: false + msgpackr-extract: false + onnxruntime-node: false + playwright: false + protobufjs: false + sharp: false + ssh2: false + unrs-resolver: false +packages: + - packages/effect-cell-types +catalog: + "@systemfsoftware/tsconfig": file:.sfs-deps/tsconfig-2.0.1.tgz + "@types/node": ^24 + effect: 4.0.0-rc.112 + tsdown: ^0.22.14 + typescript: ^7 +catalogMode: prefer +overrides: + effect: 4.0.0-rc.112 + "@effect/platform-node": 4.0.0-rc.112 + "@effect/platform-node-shared": 4.0.0-rc.112 +supportedArchitectures: + os: [linux, darwin] + cpu: [x64, arm64] + libc: [glibc] diff --git a/nix/effect-cell-types/prepare-source.mjs b/nix/effect-cell-types/prepare-source.mjs new file mode 100755 index 0000000..1d4d1e5 --- /dev/null +++ b/nix/effect-cell-types/prepare-source.mjs @@ -0,0 +1,71 @@ +#!/usr/bin/env node + +import { readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +const root = process.argv[2] +if (!root) throw new Error('usage: prepare-source.mjs ') + +const HERE = import.meta.dirname + +const BUILD_DEV_DEPENDENCIES = new Set([ + '@systemfsoftware/tsconfig', + '@types/node', + 'effect', + 'tsdown', + 'typescript', +]) + +const PACKAGE = 'packages/effect-cell-types' +const DEP_FIELDS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'] +const STRIPPED_FIELDS = ['devDependencies', 'optionalDependencies'] +const LOCAL = /^(workspace:|file:|link:)/ +const WORKSPACE = /^workspace:/ +const STRY = '@systemfsoftware/' +const API_GATE = /\s*&&\s*pnpm api:check$/ + +const fileCatalogNames = () => { + const yaml = readFileSync(join(HERE, 'pnpm-workspace.yaml'), 'utf8') + const names = new Set() + for (const line of yaml.split('\n')) { + const match = /^\s+"(@systemfsoftware\/[^"]+)":\s+file:/.exec(line) + if (match) names.add(match[1]) + } + return names +} + +const fileLocal = fileCatalogNames() +const path = join(root, PACKAGE, 'package.json') +const manifest = JSON.parse(readFileSync(path, 'utf8')) + +for (const field of STRIPPED_FIELDS) { + const deps = manifest[field] + if (!deps) continue + for (const name of Object.keys(deps)) { + if (!BUILD_DEV_DEPENDENCIES.has(name)) delete deps[name] + } +} + +manifest.devDependencies = { typescript: 'catalog:', ...manifest.devDependencies } + +for (const field of DEP_FIELDS) { + const deps = manifest[field] + if (!deps) continue + for (const [name, value] of Object.entries(deps)) { + if (value === 'catalog:peers') deps[name] = 'catalog:' + if (WORKSPACE.test(deps[name]) && fileLocal.has(name)) deps[name] = 'catalog:' + if (!name.startsWith(STRY)) continue + const resolved = deps[name] + if (LOCAL.test(resolved)) continue + if (resolved === 'catalog:' && fileLocal.has(name)) continue + throw new Error(`${PACKAGE}: ${name}@${resolved} is not a workspace:, file: or link: resolution`) + } + if (Object.keys(deps).length === 0) delete manifest[field] +} + +manifest.scripts.build = manifest.scripts.build.replace(API_GATE, '') +if (manifest.scripts.build.includes('api:check')) { + throw new Error(`${PACKAGE}: build script still runs api:check`) +} + +writeFileSync(path, `${JSON.stringify(manifest, null, 2)}\n`) diff --git a/nix/from-source.nix b/nix/from-source.nix new file mode 100644 index 0000000..87c9375 --- /dev/null +++ b/nix/from-source.nix @@ -0,0 +1,101 @@ +# One builder for every package this repo builds from source: an instance is +# its upstream tree plus this repo's manifest overlay, installed offline from +# a hashless pnpm store and packed one tarball per entry in `packed`. +{ + lib, + runCommand, + stdenvNoCC, + nodejs, + pnpm, + pname, + # The pinned upstream checkout (a flake = false input). + src, + # This instance's manifest data: package.json, pnpm-workspace.yaml, + # pnpm-lock.yaml and prepare-source.mjs, and nothing else. + overlay, + # The systemfsoftware workspace tarballs. Every instance lockfile names + # them file:.sfs-deps/*.tgz, so the source tree carries them as .sfs-deps. + deps, + # { name, dir; } per tarball: dir is built, then packed as + # -.tgz. + packed, + # { name, dir; } built before the packed entries but packed themselves: a + # workspace dependency the packed entry bundles. + built ? [ ], + # Directories the instance lockfile refers to beyond .sfs-deps, by path + # relative to the workspace root, e.g. { patches = ./patches; }. + extraStoreFiles ? { }, + mkPnpmStore, +}: + +let + helpers = ./from-source; + + source = runCommand "${pname}-source" { + nativeBuildInputs = [ nodejs ]; + } '' + cp -r ${src}/. "$out" + chmod -R u+w "$out" + cp ${overlay}/package.json "$out/package.json" + cp ${overlay}/pnpm-workspace.yaml "$out/pnpm-workspace.yaml" + cp ${overlay}/pnpm-lock.yaml "$out/pnpm-lock.yaml" + cp -r ${deps} "$out/.sfs-deps" + node ${overlay}/prepare-source.mjs "$out" + ''; + + store = mkPnpmStore { + inherit pname; + lockFile = "${overlay}/pnpm-lock.yaml"; + workspaceFile = "${overlay}/pnpm-workspace.yaml"; + files = { ".sfs-deps" = deps; } // extraStoreFiles; + }; +in +{ + inherit source; + + tarballs = stdenvNoCC.mkDerivation { + name = "${pname}-tarballs"; + dontUnpack = true; + dontInstall = true; + nativeBuildInputs = [ nodejs pnpm ]; + buildPhase = '' + runHook preBuild + cp -r ${source} workspace + chmod -R u+w workspace + export HOME="$TMPDIR" + # pnpm writes index.db while it reads the store, so it gets a writable + # view of the read-only store, as the sandbox launcher builds one. + store_view="$TMPDIR/pnpm-store" + for layout in ${store}/v*; do + mkdir -p "$store_view/''${layout##*/}" + for entry in "$layout"/*; do + if [ "''${entry##*/}" = index.db ]; then + install -m 0644 "$entry" "$store_view/''${layout##*/}/index.db" + else + ln -s "$entry" "$store_view/''${layout##*/}/''${entry##*/}" + fi + done + done + export pnpm_config_store_dir="$store_view" + export pnpm_config_offline=true + export pnpm_config_frozen_lockfile=true + export pnpm_config_trust_lockfile=true + export pnpm_config_package_import_method=clone-or-copy + pushd workspace + pnpm install + ${lib.concatMapStrings (p: '' + pnpm --filter ${lib.escapeShellArg p.name} run build + '') (built ++ packed)} + mkdir -p "$out" + # pnpm pack composes CHANGELOG.md from the registry's published history + # and ignores offline, so with network access (nix on Darwin builds + # unsandboxed) the bytes would follow the registry. A closed port makes + # the tarball depend on the pinned source alone, as a sandboxed build. + ${lib.concatMapStrings (p: '' + pnpm_config_registry=http://127.0.0.1:9/ node ${helpers}/pack.mjs ${lib.escapeShellArg p.dir} "$out" + '') packed} + popd + runHook postBuild + ''; + }; +} diff --git a/nix/from-source/pack.mjs b/nix/from-source/pack.mjs new file mode 100755 index 0000000..f75633a --- /dev/null +++ b/nix/from-source/pack.mjs @@ -0,0 +1,23 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process' +import { copyFileSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +const [dir, out] = process.argv.slice(2) +if (!dir || !out) throw new Error('usage: pack.mjs ') + +const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) +const unscoped = manifest.name.replace(/^@[^/]+\//, '') +const staged = mkdtempSync(join(tmpdir(), 'from-source-pack-')) +try { + execFileSync('pnpm', ['pack', '--pack-destination', staged], { cwd: dir, stdio: 'inherit' }) + const files = readdirSync(staged) + if (files.length !== 1) throw new Error(`pnpm pack produced ${files.length} files for ${manifest.name}`) + const target = join(out, `${unscoped}-${manifest.version}.tgz`) + copyFileSync(join(staged, files[0]), target) + process.stdout.write(`${manifest.name}@${manifest.version} -> ${target}\n`) +} finally { + rmSync(staged, { force: true, recursive: true }) +} diff --git a/nix/pnpm-store.nix b/nix/pnpm-store.nix new file mode 100644 index 0000000..e9f8dda --- /dev/null +++ b/nix/pnpm-store.nix @@ -0,0 +1,81 @@ +# The pnpm store the sandbox installs from, built offline from a lockfile. +# Every tarball is its own fixed-output fetch whose hash is the lockfile's +# integrity for it, so the store has no hash of its own to go stale. +{ + lib, + runCommand, + stdenvNoCC, + yq-go, + mitm-cache, + nodejs, + pnpm, + importPnpmLock, +}: +{ + pname, + lockFile, + workspaceFile, + # Directories the lockfile and workspace doc refer to (file: tarballs, + # patchedDependencies), by path relative to the workspace root, e.g. + # { patches = ./patches; ".sfs-deps" = ; }. + files ? { }, +}: +let + # A file: tarball is read from files; it has no URL to fetch. + fetchable = runCommand "${pname}-fetchable-pnpm-lock.yaml" { nativeBuildInputs = [ yq-go ]; } '' + yq 'del(.packages[] | select(.resolution.tarball // "" | test("^file:")))' ${lockFile} > "$out" + ''; + # An absolute https tarball URL (a registry mirror, such as npm.jsr.io) would + # be requested as written, and no https handshake with the replay cache can + # succeed. pnpm reads this lockfile instead: same integrity, http URLs. + httpLock = runCommand "${pname}-http-pnpm-lock.yaml" { nativeBuildInputs = [ yq-go ]; } '' + yq '(.packages[] | select(.resolution.tarball // "" | test("^https://")) | .resolution.tarball) |= sub("^https://"; "http://")' ${lockFile} > "$out" + ''; + # pnpm on darwin rejects the leaf certificates mitm-cache forges for https + # (`invalid peer certificate: EkuError` on macos-latest), whichever CA it is + # handed. So the replay serves the same tarballs over plain http: pnpm fetches + # from http://registry.npmjs.org/, and the cache answers from the https tree it + # recorded. The lockfile's integrity, not the transport, authenticates every + # tarball. + recorded = importPnpmLock { + inherit pname; + version = "0"; + lockFile = fetchable; + }; + overHttp = runCommand "${pname}-pnpm-mitm-cache-http" { } '' + mkdir "$out" + ln -s ${recorded}/https "$out/http" + ''; +in +stdenvNoCC.mkDerivation { + name = "${pname}-pnpm-store"; + dontUnpack = true; + mitmCache = overHttp; + nativeBuildInputs = [ + mitm-cache + nodejs + pnpm + ]; + buildPhase = '' + runHook preBuild + cp ${httpLock} pnpm-lock.yaml + cp ${workspaceFile} pnpm-workspace.yaml + ${lib.concatStrings ( + lib.mapAttrsToList (dir: source: '' + mkdir -p ${lib.escapeShellArg dir} + cp -r ${source}/. ${lib.escapeShellArg dir}/ + '') files + )} + # mitm-cache replays the fetches as a plain-HTTP proxy on 127.0.0.1. + export HOME="$TMPDIR" http_proxy="http://$http_proxy" + unset https_proxy + export pnpm_config_store_dir="$out" pnpm_config_trust_lockfile=true pnpm_config_update_notifier=false + echo 'registry=http://registry.npmjs.org/' > .npmrc + pnpm fetch + # pnpm may register the build directory as a project using the store; + # the link would dangle, and the launcher binds this directory read-only. + rm -rf "$out"/v*/projects + runHook postBuild + ''; + dontInstall = true; +} diff --git a/package.json b/package.json index 33e3d56..3bcbf8b 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "oxlint": "catalog:", "oxlint-tsgolint": "catalog:", "turbo": "catalog:", + "tstyche-typescript": "npm:typescript@6.0.3", "typescript": "catalog:" }, "license": "Apache-2.0", @@ -35,15 +36,15 @@ "destroy": "./bin/cloud pnpm --filter @endgame/site exec alchemy destroy --stage \"${ALCHEMY_STAGE:?set ALCHEMY_STAGE, e.g. pr-}\" --no-input --yes", "format": "./bin/dprint fmt", "format:check": "./bin/dprint check", - "gate:dist": "sandbox -- turbo --concurrency=${TURBO_CONCURRENCY:-100%} build", - "gate:tasks": "sandbox --allow-host jsr.io -- turbo --concurrency=${TURBO_CONCURRENCY:-50%} --continue lint lint:suppressions typecheck typecheck:node test test:scripts", - "lint": "sandbox -- turbo --concurrency=${TURBO_CONCURRENCY:-50%} lint lint:suppressions", - "lint:suppressions": "./bin/sandboxed -- no-inline-suppression", + "gate:dist": "sandbox -- turbo --concurrency=${TURBO_CONCURRENCY:-100%} build attw", + "gate:tasks": "sandbox --allow-host jsr.io --pass-env PLAYWRIGHT_BROWSERS_PATH --pass-env FONTCONFIG_FILE -- turbo --concurrency=${TURBO_CONCURRENCY:-50%} --continue lint lint:tsgo lint:suppressions typecheck typecheck:node test:types test test:browser test:scripts", + "lint": "sandbox -- turbo --concurrency=${TURBO_CONCURRENCY:-50%} lint lint:tsgo lint:suppressions", + "lint:suppressions": "./bin/sandboxed -- no-inline-suppression $(git ls-files -- '*.ts' '*.tsx' '*.mts' '*.cts' '*.js' '*.jsx' '*.mjs' '*.cjs' ':!:packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/' ':!:packages/gherkin/storybook-gherkin/test/browser/scenario-title.deny.ts')", "precommit": "pnpm exec lint-staged --no-revert --config .lintstagedrc.js", "prepare": "effect-tsgo patch --oxlint --no-typescript --skip-missing", - "test": "sandbox --allow-host jsr.io -- turbo --concurrency=${TURBO_CONCURRENCY:-50%} test test:scripts && pnpm test:sandbox", + "test": "sandbox --allow-host jsr.io --pass-env PLAYWRIGHT_BROWSERS_PATH --pass-env FONTCONFIG_FILE -- turbo --concurrency=${TURBO_CONCURRENCY:-50%} test test:browser test:scripts && pnpm test:sandbox", "test:sandbox": "nix run .#sandbox-proofs && deno test --config=scripts/deno.json --allow-run=git,sandbox,sh --allow-env --allow-read --allow-write sandbox-proofs/", - "typecheck": "sandbox -- turbo --concurrency=${TURBO_CONCURRENCY:-50%} --continue typecheck typecheck:node", + "typecheck": "sandbox -- turbo --concurrency=${TURBO_CONCURRENCY:-50%} --continue typecheck typecheck:node test:types", "typecheck:node": "./bin/sandboxed -- tsc -p tsconfig.node.json --noEmit", "test:scripts": "./bin/sandboxed --allow-host jsr.io -- deno test --config=scripts/deno.json --allow-read --allow-write --allow-import=jsr.io scripts/" }, diff --git a/packages/atom/effect-atom-react/.attw.json b/packages/atom/effect-atom-react/.attw.json new file mode 100644 index 0000000..76f3418 --- /dev/null +++ b/packages/atom/effect-atom-react/.attw.json @@ -0,0 +1 @@ +{ "ignoreRules": ["no-resolution", "cjs-resolves-to-esm"] } diff --git a/packages/atom/effect-atom-react/.gitignore b/packages/atom/effect-atom-react/.gitignore new file mode 100644 index 0000000..f23046f --- /dev/null +++ b/packages/atom/effect-atom-react/.gitignore @@ -0,0 +1,3 @@ +# Vitest browser-mode artifacts +.vitest-attachments/ +tests/__screenshots__/ diff --git a/packages/atom/effect-atom-react/LICENSE b/packages/atom/effect-atom-react/LICENSE new file mode 100644 index 0000000..19731ff --- /dev/null +++ b/packages/atom/effect-atom-react/LICENSE @@ -0,0 +1,231 @@ +Copyright (c) 2026 Ryan Lee (systemfsoftware) + +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Ryan Lee (systemfsoftware) + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + +---------------------------------------------------------------------- +Third-party notices + +Portions of this software are derived from effect-atom +(https://github.com/tim-smart/effect-atom), which is licensed under +the MIT License: + +Copyright (c) 2023-present The Contributors + +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/packages/atom/effect-atom-react/README.md b/packages/atom/effect-atom-react/README.md new file mode 100644 index 0000000..cc89b35 --- /dev/null +++ b/packages/atom/effect-atom-react/README.md @@ -0,0 +1,73 @@ +# @systemfsoftware/effect-atom-react + +React bindings for effect-atom — forked under systemfsoftware from tim-smart/effect-atom. + +## Install + +```sh +pnpm add @systemfsoftware/effect-atom-react 'effect@^4' 'react@^19.2.8' 'react-dom@^19.2.8' 'scheduler@*' +``` + +Those are peer dependencies: this package declares them but does not install them, so one copy is shared with the rest of your project. + +## Entry points + +One entry, one namespace: + +```ts +import { AtomReact } from '@systemfsoftware/effect-atom-react' + +// inside a component under +const value = AtomReact.useAtomValue(count) +``` + +## Owning the registry + +The code that mounts the app owns its registry: an app's entry, an island's module, a per-request server handler, or a Storybook story's loader. It creates the registry before the first render, passes it to `RegistryProvider`, and disposes it at teardown. `RegistryProvider` only provides the registry it is given. + +```ts +import { Atom } from '@systemfsoftware/effect-atom' +import { AtomReact } from '@systemfsoftware/effect-atom-react' +import { createElement } from 'react' +import { createRoot } from 'react-dom/client' + +const registry = Atom.Registry.make({ scheduleTask: AtomReact.scheduleTask }) +const root = createRoot(container) +root.render(createElement(AtomReact.RegistryProvider, { registry }, createElement(App))) + +// at teardown +root.unmount() +Atom.Registry.dispose(registry) +``` + +Pass `scheduleTask: AtomReact.scheduleTask` so registry work runs at React's low priority. Without it, the registry runs work on the host's own dispatcher. + +Create the registry outside render. React keeps no state for a render that suspended before its first mount and retries it from scratch, so a registry created during render is rebuilt on every retry and never disposed. Unmounting the root disposes nothing; the owner's `Atom.Registry.dispose` does. An owner that runs inside an Effect can compose `Atom.Registry.layer` instead, which disposes the registry when its scope closes. + +## Restoring saved state before the first render + +Saved data reaches a registry before the first render; the binding never writes saved data. `Atom.Hydration.hydrate` assigns the saved value to each atom the state names and replaces a value the registry already holds, so the owner restores saved state before the first render, while those atoms have not been read yet; a registry that already holds one of them is not a hydrate target. + +The owner writes it in one of three forms, before `hydrateRoot` or `createRoot(...).render`: + +1. **Seeds.** `Atom.Registry.make({ initialValues, scheduleTask: AtomReact.scheduleTask })` for typed values the owner already holds, such as an app entry's Layer choice or a story's fakes. +2. **Saved state.** `Atom.Hydration.hydrate(registry, state)` on state the server produced with `Atom.Hydration.dehydrate`. On the server, each request makes, hydrates and disposes its own registry. +3. **Routed apps.** The router's own hydrate option, run before render. + +One write happens later, outside React: an entry the server dehydrated while still pending (`encodeInitialAs: 'deferred'`) is completed when the server's promise resolves. It finishes the same server render rather than delivering a second saved page, and components see it through their subscription like any other update. + +## Islands + +An island that takes no server-data props makes its registry at module scope, `Atom.Registry.make({ scheduleTask: AtomReact.scheduleTask })`, and its root component renders `RegistryProvider` with it. The module is imported before the framework creates the island's root, so the registry exists before any render, and every instance of the island on the page shares it. + +- **Props.** A supported island seeds no atom from its props: props exist only inside the island's first render. Its server data comes through atoms over the app runtime's services. Props that seed nothing, such as a label or an id, are fine. +- **Server render.** When the island is also rendered on the server, its module-scope registry is shared by every request in that process. Every atom the island reads during a server render carries `Atom.withServerValue` with a request-independent value, or `Atom.withServerValueInitial` for a result atom; otherwise render it client-only. +- **Teardown.** The island's teardown is the document's unload. When a client-side navigation unmounts the island, each atom it no longer reads is released and its fiber interrupted; a `keepAlive` atom stays in the registry for the next page that mounts the island. No island calls `Atom.Registry.dispose`. + +## API + +The public surface is generated from the source and versioned with the package: [`etc/effect-atom-react.api.md`](./etc/effect-atom-react.api.md). + +## License + +Apache-2.0. Part of [systemfsoftware](https://github.com/systemfsoftware/systemfsoftware/tree/main/packages/effect-atom/atom-react#readme). diff --git a/packages/atom/effect-atom-react/api-extractor.json b/packages/atom/effect-atom-react/api-extractor.json new file mode 100644 index 0000000..a26fe4a --- /dev/null +++ b/packages/atom/effect-atom-react/api-extractor.json @@ -0,0 +1,54 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + "projectFolder": ".", + "mainEntryPointFilePath": "/dist/mod.d.ts", + "bundledPackages": [], + "compiler": { + "tsconfigFilePath": "/tsconfig.api.json" + }, + "apiReport": { + "enabled": true, + "reportFolder": "/etc/", + "reportTempFolder": "/temp/main/", + "reportFileName": "effect-atom-react.api.md" + }, + "docModel": { + "enabled": false + }, + "dtsRollup": { + "enabled": false + }, + "tsdocMetadata": { + "enabled": false + }, + "messages": { + "extractorMessageReporting": { + "default": { + "logLevel": "warning" + }, + "ae-forgotten-export": { + "logLevel": "error" + }, + "ae-missing-release-tag": { + "logLevel": "none" + }, + "ae-internal-missing-underscore": { + "logLevel": "none" + } + }, + "tsdocMessageReporting": { + "default": { + "logLevel": "warning" + }, + "tsdoc-undefined-tag": { + "logLevel": "none" + }, + "tsdoc-unsupported-tag": { + "logLevel": "none" + }, + "tsdoc-param-tag-missing-hyphen": { + "logLevel": "none" + } + } + } +} diff --git a/packages/atom/effect-atom-react/etc/effect-atom-react.api.md b/packages/atom/effect-atom-react/etc/effect-atom-react.api.md new file mode 100644 index 0000000..5ba2464 --- /dev/null +++ b/packages/atom/effect-atom-react/etc/effect-atom-react.api.md @@ -0,0 +1,14 @@ +## API Report File for "@systemfsoftware/effect-atom-react" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +// @public (undocumented) +export namespace AtomReact { + export { AnyAtom, RegistryContext, RegistryProvider, RegistryProviderOptions, ScopedAtom, TypeId, make, scheduleTask, useAtom, useAtomMount, useAtomRef, useAtomRefProp, useAtomRefPropValue, useAtomRefresh, useAtomSet, useAtomSetResult, useAtomSuspense, useAtomUpdate, useAtomValue, useRegistry }; +} + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/atom/effect-atom-react/oxlint.config.ts b/packages/atom/effect-atom-react/oxlint.config.ts new file mode 100644 index 0000000..b6f1225 --- /dev/null +++ b/packages/atom/effect-atom-react/oxlint.config.ts @@ -0,0 +1,4 @@ +import recommended from '@systemfsoftware/oxlint-config-frontend' +import { defineConfig } from 'oxlint' + +export default defineConfig({ extends: [recommended] }) diff --git a/packages/atom/effect-atom-react/package.json b/packages/atom/effect-atom-react/package.json new file mode 100644 index 0000000..120d901 --- /dev/null +++ b/packages/atom/effect-atom-react/package.json @@ -0,0 +1,108 @@ +{ + "name": "@systemfsoftware/effect-atom-react", + "license": "Apache-2.0", + "version": "2.0.0", + "author": "TODO ", + "repository": { + "type": "git", + "url": "git+https://github.com/systemfsoftware/frontend.git", + "directory": "packages/atom/effect-atom-react" + }, + "homepage": "https://github.com/systemfsoftware/frontend/tree/main/packages/atom/effect-atom-react#readme", + "bugs": "https://github.com/systemfsoftware/frontend/issues", + "description": "React bindings for effect-atom — forked under systemfsoftware from tim-smart/effect-atom.", + "keywords": [ + "effect", + "effect-ts", + "atom", + "react", + "reactive", + "state" + ], + "type": "module", + "exports": { + ".": { + "@systemfsoftware/source": "./src/mod.ts", + "types": "./dist/mod.d.ts", + "default": "./dist/mod.mjs" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist" + ], + "scripts": { + "clean": "rimraf dist", + "build": "tsdown -l warn && pnpm dts:check && pnpm api:check", + "typecheck": "tsc -b", + "test": "vitest run --passWithNoTests", + "mutation": "stryker run", + "test:types": "TSTYCHE_TYPESCRIPT_MODULE=tstyche-typescript tstyche", + "lint": "f=${OXLINT_FORMAT:-${AGENT:+agent}}; oxlint . --config oxlint.config.ts --format=${f:-default}", + "lint:tsgo": "effect-tsgo diagnostics --project tsconfig.app.json --format ${TSGO_FORMAT:-text} && effect-tsgo diagnostics --project tsconfig.test.json --format ${TSGO_FORMAT:-text}", + "attw": "attw --pack .", + "dts:check": "tsc --noEmit -p tsconfig.dts.json", + "api:check": "api-extractor-quiet run", + "api:update": "api-extractor run --local" + }, + "dependencies": { + "@systemfsoftware/effect-atom": "workspace:^", + "@systemfsoftware/effect-cell-types": "^12.0.0" + }, + "peerDependencies": { + "effect": "catalog:peers", + "react": "catalog:", + "react-dom": "catalog:", + "scheduler": "*" + }, + "devDependencies": { + "@microsoft/api-extractor": "catalog:", + "@systemfsoftware/arethetypeswrong-cli": "catalog:", + "@systemfsoftware/effect-gherkin-spec": "^7.0.1", + "@systemfsoftware/effect-schema-law": "^5.0.1", + "@systemfsoftware/effect-schema-vite": "^3.0.1", + "@systemfsoftware/oxlint-config-frontend": "workspace:^", + "@systemfsoftware/stryker-config": "^0.1.0", + "@systemfsoftware/stryker-ignorer-effect-schema-declarations": "catalog:stryker", + "@systemfsoftware/stryker-ignorer-in-source-vitest-block": "catalog:stryker", + "@systemfsoftware/stryker-js": "catalog:stryker", + "@systemfsoftware/stryker-js-typescript-checker": "catalog:stryker", + "@systemfsoftware/stryker-js-vitest-runner": "catalog:stryker", + "@systemfsoftware/tsconfig": "^2.0.1", + "@systemfsoftware/tsdown-config": "^0.1.0", + "@systemfsoftware/vitest": "^2.0.0", + "@systemfsoftware/vitest-config": "^0.1.0", + "@testing-library/dom": "catalog:", + "@testing-library/react": "catalog:", + "@types/node": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@types/scheduler": "catalog:", + "@vitest/browser": "catalog:", + "@vitest/browser-playwright": "catalog:", + "@vitest/coverage-istanbul": "catalog:", + "effect": "catalog:", + "oxlint": "catalog:oxlint", + "playwright": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "react-error-boundary": "catalog:", + "rimraf": "catalog:", + "scheduler": "catalog:", + "tsdown": "catalog:", + "tstyche": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + }, + "publishConfig": { + "access": "public", + "exports": { + ".": { + "types": "./dist/mod.d.ts", + "default": "./dist/mod.mjs" + }, + "./package.json": "./package.json" + }, + "provenance": true + } +} diff --git a/packages/atom/effect-atom-react/src/AtomReact/mod.ts b/packages/atom/effect-atom-react/src/AtomReact/mod.ts new file mode 100644 index 0000000..d45fa56 --- /dev/null +++ b/packages/atom/effect-atom-react/src/AtomReact/mod.ts @@ -0,0 +1,26 @@ +/** + * React bindings for Effect atoms: registry provision, value hooks, ref hooks, + * Suspense, and scoped atoms. + * + * @since 4.0.0 + */ +export { useAtomRef, useAtomRefProp, useAtomRefPropValue } from '../hooks-ref.js' +export { useAtomSuspense } from '../hooks-suspense.js' +export { + useAtom, + useAtomMount, + useAtomRefresh, + useAtomSet, + useAtomSetResult, + useAtomUpdate, + useAtomValue, +} from '../hooks-value.js' +export { + type AnyAtom, + RegistryContext, + RegistryProvider, + type RegistryProviderOptions, + scheduleTask, + useRegistry, +} from '../registry-context.js' +export { make, type ScopedAtom, TypeId } from '../scoped-atom.blueprint.js' diff --git a/packages/atom/effect-atom-react/src/__tests__/resolve-atom-suspense.workflow.property.test.ts b/packages/atom/effect-atom-react/src/__tests__/resolve-atom-suspense.workflow.property.test.ts new file mode 100644 index 0000000..db87e67 --- /dev/null +++ b/packages/atom/effect-atom-react/src/__tests__/resolve-atom-suspense.workflow.property.test.ts @@ -0,0 +1,56 @@ +import { it } from '@systemfsoftware/vitest' +import { Schema } from 'effect' +import { absurd } from 'effect/Function' +import * as Result from 'effect/Result' +import { + AtomReady, + AtomRefused, + AtomSuspended, + ResolveAtomSuspense, + resolveAtomSuspense, + type ResolveAtomSuspenseDecision, +} from '../resolve-atom-suspense.workflow.js' + +const decide = ( + initial: boolean, + waiting: boolean, + suspendOnWaiting: boolean, + failure: boolean, + includeFailure: boolean, +): Result.Result => + resolveAtomSuspense(ResolveAtomSuspense.make({ initial, waiting, suspendOnWaiting, failure, includeFailure })) + +const decisionOf = (outcome: Result.Result): ResolveAtomSuspenseDecision => + Result.match(outcome, { + onFailure: (error: never): ResolveAtomSuspenseDecision => absurd(error), + onSuccess: (decision) => decision, + }) + +it.prop( + '∀a_InitialAtom_≡Suspended', + { of: [Schema.Boolean, Schema.Boolean], subject: decide }, + (subject, [suspendOnWaiting, failure]) => + Schema.is(AtomSuspended)(subject(true, false, suspendOnWaiting, failure, false).pipe(decisionOf)), +) + +it.prop( + '∀a_WaitingWhileSuspended_≡Suspended', + { of: [Schema.Boolean, Schema.Boolean], subject: decide }, + (subject, [failure, includeFailure]) => + Schema.is(AtomSuspended)(subject(false, true, true, failure, includeFailure).pipe(decisionOf)), +) + +it.prop( + '∀a_SettledWithinSuspend_≡Ready', + { of: [Schema.Boolean, Schema.Boolean], subject: decide }, + (subject, [suspendOnWaiting, includeFailure]) => + Schema.is(AtomReady)(subject(false, false, suspendOnWaiting, false, includeFailure).pipe(decisionOf)), +) + +it.prop( + '∀a_Refused_≡UnexposedFailure', + { of: [Schema.Boolean], subject: decide }, + (subject, [includeFailure]) => + Schema.is(AtomRefused)(subject(false, false, false, true, includeFailure).pipe(decisionOf)) === + (includeFailure === false), +) diff --git a/packages/atom/effect-atom-react/src/hooks-ref.ts b/packages/atom/effect-atom-react/src/hooks-ref.ts new file mode 100644 index 0000000..bb465a1 --- /dev/null +++ b/packages/atom/effect-atom-react/src/hooks-ref.ts @@ -0,0 +1,110 @@ +/** + * React hooks for reading and deriving `Atom.Ref` values. + * + * @since 4.0.0 + */ +'use client' + +import { Atom } from '@systemfsoftware/effect-atom' +import * as Equal from 'effect/Equal' +import { dual } from 'effect/Function' +import { useMemo, useSyncExternalStore } from 'react' + +interface RefStore { + readonly subscribe: (onStoreChange: () => void) => () => void + readonly snapshot: () => A +} + +const makeRefStore = (ref: Atom.Ref.ReadonlyRef): RefStore => { + let held = Atom.Ref.get(ref) + return { + subscribe: (onStoreChange) => Atom.Ref.subscribe(ref, () => onStoreChange()), + snapshot: () => { + const next = Atom.Ref.get(ref) + if (!Equal.equals(next, held)) { + held = next + } + return held + }, + } +} + +/** + * Subscribes to an atom ref and returns its latest value. + * + * **When to use** + * + * Use when a React component should render from an `Atom.Ref.ReadonlyRef` + * directly instead of reading an atom through the current registry. + * + * **Details** + * + * The hook subscribes with `Atom.Ref.subscribe` through React's + * `useSyncExternalStore` and returns the current `Atom.Ref.get(ref)` value, + * keeping the previous value while the next one is `Equal` to it, so a ref + * whose read builds a new object, such as one from `Atom.Ref.map`, renders + * once per change. + * + * @see {@link useAtomValue} for reading an `Atom` from the current registry + * @see {@link useAtomRefPropValue} for reading a property ref value + * + * @since 4.0.0 + */ +export const useAtomRef = (ref: Atom.Ref.ReadonlyRef): A => { + const store = useMemo(() => makeRefStore(ref), [ref]) + return useSyncExternalStore(store.subscribe, store.snapshot, store.snapshot) +} + +/** + * Returns a memoized atom ref for a property of another atom ref. + * + * **When to use** + * + * Use to derive an `AtomRef` for one property of an object-shaped atom ref. + * + * **Details** + * + * The hook memoizes `Atom.Ref.prop(ref, prop)` for the `[ref, prop]` + * dependency pair and returns the property ref so callers can read, set, + * update, or subscribe to that nested property. + * @see {@link useAtomRef} for subscribing to an atom ref value + * @see {@link useAtomRefPropValue} for subscribing directly to a property value + * + * @since 4.0.0 + */ +export const useAtomRefProp: { + (prop: K): (ref: Atom.Ref.AtomRef) => Atom.Ref.AtomRef + (ref: Atom.Ref.AtomRef, prop: K): Atom.Ref.AtomRef +} = dual( + 2, + (ref: Atom.Ref.AtomRef, prop: K): Atom.Ref.AtomRef => + useMemo(() => Atom.Ref.prop(ref, prop), [ref, prop]), +) + +/** + * Subscribes to a property ref derived from an atom ref and returns its current + * value. + * + * **When to use** + * + * Use when a React component needs only the current value of one property from + * an object-shaped `AtomRef`. + * + * **Details** + * + * The hook composes `useAtomRefProp(ref, prop)` with `useAtomRef`, so the + * property ref is memoized for the `[ref, prop]` pair and then subscribed + * through `Atom.Ref.subscribe`. + * + * @see {@link useAtomRefProp} for returning the property ref directly + * @see {@link useAtomRef} for subscribing to a whole atom ref value + * + * @since 4.0.0 + */ +export const useAtomRefPropValue: { + (prop: K): (ref: Atom.Ref.AtomRef) => A[K] + (ref: Atom.Ref.AtomRef, prop: K): A[K] +} = dual( + 2, + (ref: Atom.Ref.AtomRef, prop: K): A[K] => useAtomRef(useAtomRefProp(ref, prop)), +) diff --git a/packages/atom/effect-atom-react/src/hooks-suspense.ts b/packages/atom/effect-atom-react/src/hooks-suspense.ts new file mode 100644 index 0000000..cc8aecd --- /dev/null +++ b/packages/atom/effect-atom-react/src/hooks-suspense.ts @@ -0,0 +1,310 @@ +/** + * React hook that reads an `AsyncResult` atom through React Suspense. + * + * @since 4.0.0 + */ +'use client' + +import { Atom } from '@systemfsoftware/effect-atom' +import * as Arr from 'effect/Array' +import * as Cause from 'effect/Cause' +import * as Context from 'effect/Context' +import { absurd, dual } from 'effect/Function' +import * as Result from 'effect/Result' +import * as Schema from 'effect/Schema' +import { use } from 'react' +import { useAtomValue } from './hooks-value.js' +import { useRegistry } from './registry-context.js' +import { + AtomRefused, + AtomSuspended, + ResolveAtomSuspense, + resolveAtomSuspense, + type ResolveAtomSuspenseDecision, +} from './resolve-atom-suspense.workflow.js' + +type AnyAtom = Atom.Atom +type AnyPromiseMap = WeakMap, Promise> + +type AtomPromiseMaps = { + readonly suspendOnWaiting: AnyPromiseMap + readonly default: AnyPromiseMap +} + +class AtomPromiseMapsKey extends Context.Service()( + '@systemfsoftware/effect-atom-react/hooks-suspense/AtomPromiseMapsKey', +) {} + +function booleanOrFalse(value: boolean | undefined): boolean { + if (value === undefined) { + return false + } + return value +} + +function suspendOnWaitingFrom(options?: { + readonly suspendOnWaiting?: boolean | undefined +}): boolean { + if (options === undefined) { + return false + } + return booleanOrFalse(options.suspendOnWaiting) +} + +function includeFailureFrom(options?: { + readonly includeFailure?: boolean | undefined +}): boolean { + if (options === undefined) { + return false + } + return booleanOrFalse(options.includeFailure) +} + +function promiseMapFor( + registry: Atom.Registry.Registry, + suspendOnWaiting: boolean, +): AnyPromiseMap { + const maps = Atom.Registry.storage(registry, AtomPromiseMapsKey, () => ({ + suspendOnWaiting: new WeakMap>(), + default: new WeakMap>(), + })) + return suspendOnWaiting ? maps.suspendOnWaiting : maps.default +} + +function waitingOf(result: Atom.AsyncResult.Result): boolean { + if (Atom.AsyncResult.isInitial(result)) { + return false + } + return result.waiting +} + +const suspenseBitMasks = { + initial: 1, + waiting: 2, + suspendOnWaiting: 4, + failure: 8, + includeFailure: 16, +} as const + +const suspenseDecisionTypeCount = 32 + +const bitOf = (index: number, mask: number): boolean => (index & mask) !== 0 + +const suspenseDecisions: ReadonlyArray = Array.from( + { length: suspenseDecisionTypeCount }, + (_, index): ResolveAtomSuspenseDecision => + Result.match( + resolveAtomSuspense( + ResolveAtomSuspense.make({ + initial: bitOf(index, suspenseBitMasks.initial), + waiting: bitOf(index, suspenseBitMasks.waiting), + suspendOnWaiting: bitOf(index, suspenseBitMasks.suspendOnWaiting), + failure: bitOf(index, suspenseBitMasks.failure), + includeFailure: bitOf(index, suspenseBitMasks.includeFailure), + }), + ), + { onFailure: (error: never): ResolveAtomSuspenseDecision => absurd(error), onSuccess: (decision) => decision }, + ), +) + +const suspenseIndexOf = ( + initial: boolean, + waiting: boolean, + suspendOnWaiting: boolean, + failure: boolean, + includeFailure: boolean, +): number => + Number(initial) * suspenseBitMasks.initial + + Number(waiting) * suspenseBitMasks.waiting + + Number(suspendOnWaiting) * suspenseBitMasks.suspendOnWaiting + + Number(failure) * suspenseBitMasks.failure + + Number(includeFailure) * suspenseBitMasks.includeFailure + +function suspenseDecision( + result: Atom.AsyncResult.Result, + suspendOnWaiting: boolean, + includeFailure: boolean, +): ResolveAtomSuspenseDecision { + return Arr.getUnsafe( + suspenseDecisions, + suspenseIndexOf( + Atom.AsyncResult.isInitial(result), + waitingOf(result), + suspendOnWaiting, + Atom.AsyncResult.isFailure(result), + includeFailure, + ), + ) +} + +function resultIsPending( + result: Atom.AsyncResult.Result, + suspendOnWaiting: boolean, +): boolean { + return Schema.is(AtomSuspended)(suspenseDecision(result, suspendOnWaiting, true)) +} + +function shouldKeepPending( + settled: boolean, + result: Atom.AsyncResult.Result, + suspendOnWaiting: boolean, +): boolean { + if (settled) { + return true + } + return resultIsPending(result, suspendOnWaiting) +} + +function settleAtomPromise( + state: { settled: boolean }, + dispose: () => void, + resolve: () => void, + map: AnyPromiseMap, + atom: AnyAtom, +): void { + state.settled = true + dispose() + resolve() + map.delete(atom) +} + +function onAtomPromiseResult( + state: { settled: boolean }, + result: Atom.AsyncResult.Result, + suspendOnWaiting: boolean, + dispose: () => void, + resolve: () => void, + map: AnyPromiseMap, + atom: Atom.Atom>, +): void { + if (shouldKeepPending(state.settled, result, suspendOnWaiting)) { + return + } + settleAtomPromise(state, dispose, resolve, map, atom) +} + +function createAtomPromise( + registry: Atom.Registry.Registry, + atom: Atom.Atom>, + suspendOnWaiting: boolean, + map: AnyPromiseMap, +): Promise { + const { promise, resolve } = Promise.withResolvers() + const state = { settled: false } + const dispose = Atom.Registry.subscribe(registry, atom, (result) => { + onAtomPromiseResult(state, result, suspendOnWaiting, dispose, resolve, map, atom) + }) + map.set(atom, promise) + return promise +} + +function atomToPromise( + registry: Atom.Registry.Registry, + atom: Atom.Atom>, + suspendOnWaiting: boolean, +): Promise { + const map = promiseMapFor(registry, suspendOnWaiting) + const cached = map.get(atom) + if (cached !== undefined) { + return cached + } + return createAtomPromise(registry, atom, suspendOnWaiting, map) +} + +function isReadyResult( + value: Atom.AsyncResult.Result, + suspendOnWaiting: boolean, +): value is Atom.AsyncResult.Success | Atom.AsyncResult.Failure { + return !resultIsPending(value, suspendOnWaiting) +} + +function useAtomResultOrSuspend( + registry: Atom.Registry.Registry, + atom: Atom.Atom>, + suspendOnWaiting: boolean, +): Atom.AsyncResult.Success | Atom.AsyncResult.Failure { + let current = useAtomValue(atom) + while (!isReadyResult(current, suspendOnWaiting)) { + use(atomToPromise(registry, atom, suspendOnWaiting)) + current = Atom.Registry.get(registry, atom) + } + return current +} + +function failureResultOrThrow( + result: Atom.AsyncResult.Failure, + options?: { + readonly includeFailure?: boolean | undefined + }, +): Atom.AsyncResult.Failure { + if (Schema.is(AtomRefused)(suspenseDecision(result, false, includeFailureFrom(options)))) { + throw Cause.squash(result.cause) + } + return result +} + +function resolveFailureOrPassthrough( + result: Atom.AsyncResult.Success | Atom.AsyncResult.Failure, + options?: { + readonly includeFailure?: boolean | undefined + }, +): Atom.AsyncResult.Success | Atom.AsyncResult.Failure { + if (Atom.AsyncResult.isFailure(result)) { + return failureResultOrThrow(result, options) + } + return result +} + +/** + * Reads an `AsyncResult` atom through React Suspense, suspending while the + * result is initial or configured as waiting. + * + * **When to use** + * + * Use when a React component should render only after an `AsyncResult` atom has + * left its initial state, with loading delegated to a Suspense boundary. + * + * **Details** + * + * `suspendOnWaiting` defaults to `false`. When `includeFailure` is `true`, a + * failure result is returned instead of being thrown. + * + * **Gotchas** + * + * Without `includeFailure`, failure results are thrown with + * `Cause.squash(result.cause)`, so callers need an error boundary for failures. + * + * @see {@link useAtomValue} for reading the raw `AsyncResult` value without Suspense + * + * @since 4.0.0 + */ +export const useAtomSuspense: { + ( + options?: { + readonly suspendOnWaiting?: boolean | undefined + readonly includeFailure?: boolean | undefined + }, + ): (atom: Atom.Atom>) => Atom.AsyncResult.Success | Atom.AsyncResult.Failure + ( + atom: Atom.Atom>, + options?: { + readonly suspendOnWaiting?: boolean | undefined + readonly includeFailure?: boolean | undefined + }, + ): Atom.AsyncResult.Success | Atom.AsyncResult.Failure +} = dual( + (args) => Atom.isAtom(args[0]), + ( + atom: Atom.Atom>, + options?: { + readonly suspendOnWaiting?: boolean | undefined + readonly includeFailure?: boolean | undefined + }, + ): Atom.AsyncResult.Success | Atom.AsyncResult.Failure => { + const registry = useRegistry() + return resolveFailureOrPassthrough( + useAtomResultOrSuspend(registry, atom, suspendOnWaitingFrom(options)), + options, + ) + }, +) diff --git a/packages/atom/effect-atom-react/src/hooks-value.ts b/packages/atom/effect-atom-react/src/hooks-value.ts new file mode 100644 index 0000000..2677681 --- /dev/null +++ b/packages/atom/effect-atom-react/src/hooks-value.ts @@ -0,0 +1,214 @@ +/** + * React hooks that read, write, mount, refresh, and subscribe to atoms from + * the current `RegistryContext`. + * + * @since 4.0.0 + */ +'use client' + +import { Atom } from '@systemfsoftware/effect-atom' +import * as Effect from 'effect/Effect' +import { constVoid, dual } from 'effect/Function' +import { useCallback, useMemo, useSyncExternalStore } from 'react' +import { useRegistry } from './registry-context.js' + +function useStore(registry: Atom.Registry.Registry, atom: Atom.Atom): A { + const subscribe = useMemo( + () => (onStoreChange: () => void) => Atom.Registry.subscribe(registry, atom, () => onStoreChange()), + [registry, atom], + ) + return useSyncExternalStore( + subscribe, + () => Atom.Registry.get(registry, atom), + () => Atom.getServerValue(atom, registry), + ) +} + +/** + * Subscribes to an atom in the current React registry and returns its current + * value, optionally mapped through a selector. + * + * **When to use** + * + * Use when a React component needs to render from an atom value without also + * returning a setter. + * + * **Details** + * + * When a selector is provided, the hook maps the atom before subscribing so the + * component reads the selected value from the current `RegistryContext`. + * + * @see {@link useAtom} for reading and updating a writable atom from one component + * @see `useAtomRef` for reading an `AtomRef` directly + * + * @since 4.0.0 + */ +export const useAtomValue: { + (atom: Atom.Atom): A + (atom: Atom.Atom, f: (_: A) => B): B + (f: (_: A) => B): (atom: Atom.Atom) => B +} = dual( + (args) => typeof args[0] !== 'function', + (atom: Atom.Atom, f?: (_: A) => A): A => { + const registry = useRegistry() + const selected = useMemo(() => f === undefined ? atom : Atom.map(atom, f), [atom, f]) + return useStore(registry, selected) + }, +) + +function useMountAtom(registry: Atom.Registry.Registry, atom: Atom.Atom): void { + const subscribe = useMemo( + () => () => Atom.Registry.subscribe(registry, atom, constVoid, { immediate: true }), + [registry, atom], + ) + useSyncExternalStore(subscribe, constVoid, constVoid) +} + +/** + * Mounts an atom in the current React registry for the lifetime of the + * component. + * + * **When to use** + * + * Use to keep an atom mounted from a React component without reading, writing, + * or refreshing it. + * + * **Details** + * + * The hook uses the current `RegistryContext` and releases the mount when the + * component unmounts or when the registry or atom changes. + * + * @see {@link useAtomSet} for mounting a writable atom while returning a setter + * @see {@link useAtomRefresh} for mounting an atom while returning a refresh callback + * + * @since 4.0.0 + */ +export const useAtomMount = (atom: Atom.Atom): void => { + const registry = useRegistry() + useMountAtom(registry, atom) +} + +/** + * Mounts a writable atom and returns a setter without subscribing to its value. + * + * **When to use** + * + * Use when a React component needs to update a writable atom without rendering + * from that atom's value. + * + * The hook mounts the atom and returns a setter that writes a complete value. + * For updates computed from the current value use `useAtomUpdate`. + * + * @see {@link useAtom} for reading and updating the same writable atom + * @see {@link useAtomSetResult} for a setter that resolves once the write settles + * @see {@link useAtomUpdate} for a setter that applies an updater function + * + * @since 4.0.0 + */ +export const useAtomSet = (atom: Atom.Writable): (value: W) => void => { + const registry = useRegistry() + useMountAtom(registry, atom) + return useCallback((value: W) => { + Atom.Registry.set(registry, atom, value) + }, [registry, atom]) +} + +/** + * Mounts a writable `AsyncResult` atom and returns a setter whose returned + * effect resolves to the settled success value. + * + * **When to use** + * + * Use when a component writes to an `AsyncResult` atom and needs to know when + * the write has been applied, so a save button can show a confirming state or + * report a failure. + * + * The hook mounts the atom and returns a setter that writes a new value and + * returns the effect of the atom leaving its initial state, failing with the + * write result's cause when the write fails. + * + * @see {@link useAtomSet} for writing without waiting for settlement + * + * @since 4.0.0 + */ +export const useAtomSetResult = ( + atom: Atom.Writable, W>, +): (value: W) => Effect.Effect => { + const registry = useRegistry() + useMountAtom(registry, atom) + return useCallback((value: W) => { + Atom.Registry.set(registry, atom, value) + return Atom.Registry.getResult(registry, atom, { suspendOnWaiting: true }) + }, [registry, atom]) +} + +/** + * Mounts a writable atom and returns an updater that applies a function to the + * current value. + * + * **When to use** + * + * Use when a component needs to update a writable atom from its current value, + * such as incrementing a counter, without subscribing to the atom. + * + * @see {@link useAtomSet} for writing a complete value + * + * @since 4.0.0 + */ +export const useAtomUpdate = (atom: Atom.Writable): (f: (previous: R) => W) => void => { + const registry = useRegistry() + useMountAtom(registry, atom) + return useCallback((f: (previous: R) => W) => { + Atom.Registry.update(registry, atom, f) + }, [registry, atom]) +} + +/** + * Mounts an atom and returns a callback that refreshes it in the current React + * registry. + * + * **When to use** + * + * Use to expose a React callback that requests a refresh for an atom without + * reading or writing its value. + * + * **Details** + * + * The hook uses the current `RegistryContext`, mounts the atom for the + * component lifetime, and returns a callback that calls `registry.refresh`. + * + * @see {@link useAtomMount} for mounting an atom without returning a refresh callback + * + * @since 4.0.0 + */ +export const useAtomRefresh = (atom: Atom.Atom): () => void => { + const registry = useRegistry() + useMountAtom(registry, atom) + return useCallback(() => { + Atom.Registry.refresh(registry, atom) + }, [registry, atom]) +} + +/** + * Subscribes to a writable atom and returns its current value together with a + * setter for updating it. + * + * **When to use** + * + * Use when a React component needs both to render the current value of a + * writable atom and update it from the same component. + * + * @see {@link useAtomValue} for subscribing to an atom without a setter + * @see {@link useAtomSet} for updating a writable atom without subscribing to its value + * + * @since 4.0.0 + */ +export const useAtom = ( + atom: Atom.Writable, +): readonly [value: R, write: (value: W) => void] => { + const registry = useRegistry() + return [ + useStore(registry, atom), + useCallback((value: W) => Atom.Registry.set(registry, atom, value), [registry, atom]), + ] +} diff --git a/packages/atom/effect-atom-react/src/mod.ts b/packages/atom/effect-atom-react/src/mod.ts new file mode 100644 index 0000000..407ed9f --- /dev/null +++ b/packages/atom/effect-atom-react/src/mod.ts @@ -0,0 +1 @@ +export * as AtomReact from './AtomReact/mod.js' diff --git a/packages/atom/effect-atom-react/src/registry-context.ts b/packages/atom/effect-atom-react/src/registry-context.ts new file mode 100644 index 0000000..4fdf53e --- /dev/null +++ b/packages/atom/effect-atom-react/src/registry-context.ts @@ -0,0 +1,109 @@ +/** + * React context and provider for the Atom registry used by Effect Atom hooks. + * The registry stores atom values, schedules update work, and cleans up unused + * atoms. Sharing one registry through React context lets components in the same + * subtree read and write the same atom state. + * + * @since 4.0.0 + */ +'use client' + +import type { Atom } from '@systemfsoftware/effect-atom' +import { createContext, createElement, type ReactNode, useContext } from 'react' +import * as Scheduler from 'scheduler' + +/** + * Schedules Atom registry work with React's scheduler at low priority and + * returns a cancellation function for the scheduled task. + * + * @since 4.0.0 + */ +export function scheduleTask(f: () => void): () => void { + const node = Scheduler.unstable_scheduleCallback(Scheduler.unstable_LowPriority, f) + return () => Scheduler.unstable_cancelCallback(node) +} + +/** + * Provides a React context that supplies the `Registry` used by Atom hooks. + * The context has no default, so reading it outside a provider yields nothing + * for {@link useRegistry} to return. + * + * **When to use** + * + * Use to supply a `Registry` through React context directly, without + * {@link RegistryProvider}. + * + * @see {@link RegistryProvider} for providing the registry its owner created + * @see {@link useRegistry} for reading the registry supplied to the nearest provider + * + * @since 4.0.0 + */ +export const RegistryContext = createContext(undefined) + +/** + * Returns the `Registry` supplied by the nearest provider of + * {@link RegistryContext}. + * + * **When to use** + * + * Use inside a hook or component that needs the current registry and can + * require a provider to exist. + * + * **Gotchas** + * + * Throws when no registry is in context. Wrap the component tree in a + * {@link RegistryProvider} to supply one. + * + * @see {@link RegistryContext} for supplying the registry directly + * + * @since 4.0.0 + */ +export function useRegistry(): Atom.Registry.Registry { + const registry = useContext(RegistryContext) + if (registry === undefined) { + throw new Error('No registry found in context: wrap the component tree in a RegistryProvider.') + } + return registry +} + +export type AnyAtom = Atom.Atom + +/** + * Props accepted by {@link RegistryProvider}. + * + * @since 4.0.0 + */ +export type RegistryProviderOptions = { + readonly registry: Atom.Registry.Registry + readonly children?: ReactNode | undefined +} + +/** + * Provides the `Registry` its owner created to a React subtree. + * + * **When to use** + * + * Use at the root of an app, island or story, with the registry the code that + * mounts it created before the first render. + * + * **Details** + * + * The provider creates, seeds, hydrates and disposes nothing. The owner calls + * `Atom.Registry.make` (passing `scheduleTask: AtomReact.scheduleTask` and any + * `initialValues`), hydrates saved state with `Atom.Hydration.hydrate` before + * rendering, and calls `Atom.Registry.dispose` at teardown, or composes the + * registry with `Atom.Registry.layer`, which disposes it when its scope closes. + * + * **Gotchas** + * + * React keeps no state for a render that suspended before its first mount, so a + * registry created during render is rebuilt on every retry and never disposed. + * Create it outside render. + * + * @see {@link RegistryContext} for the React context supplied by this provider + * @see {@link scheduleTask} for the scheduler the owner passes to the registry + * + * @since 4.0.0 + */ +export const RegistryProvider = (options: RegistryProviderOptions) => + createElement(RegistryContext.Provider, { value: options.registry }, options.children) diff --git a/packages/atom/effect-atom-react/src/resolve-atom-suspense.workflow.ts b/packages/atom/effect-atom-react/src/resolve-atom-suspense.workflow.ts new file mode 100644 index 0000000..cc131ac --- /dev/null +++ b/packages/atom/effect-atom-react/src/resolve-atom-suspense.workflow.ts @@ -0,0 +1,64 @@ +/** + * Resolves how React should read an `AsyncResult` atom under Suspense options. + * + * @since 4.0.0 + */ +import { Workflow } from '@systemfsoftware/effect-cell-types' +import { Boolean, Match, Schema } from 'effect' +import * as Result from 'effect/Result' + +const ResolveAtomSuspenseDecisionTypeId: unique symbol = Symbol.for( + '@systemfsoftware/effect-atom-react/ResolveAtomSuspense', +) +type ResolveAtomSuspenseDecisionTypeId = typeof ResolveAtomSuspenseDecisionTypeId + +export class AtomSuspended extends Schema.TaggedClass()('AtomSuspended', {}) { + readonly [ResolveAtomSuspenseDecisionTypeId] = ResolveAtomSuspenseDecisionTypeId +} + +export class AtomReady extends Schema.TaggedClass()('AtomReady', {}) { + readonly [ResolveAtomSuspenseDecisionTypeId] = ResolveAtomSuspenseDecisionTypeId +} + +export class AtomRefused extends Schema.TaggedClass()('AtomRefused', {}) { + readonly [ResolveAtomSuspenseDecisionTypeId] = ResolveAtomSuspenseDecisionTypeId +} + +export const ResolveAtomSuspenseDecision = Schema.Union([AtomSuspended, AtomReady, AtomRefused]) +export type ResolveAtomSuspenseDecision = typeof ResolveAtomSuspenseDecision.Type + +export class ResolveAtomSuspense extends Schema.TaggedClass()('ResolveAtomSuspense', { + initial: Schema.Boolean, + waiting: Schema.Boolean, + suspendOnWaiting: Schema.Boolean, + failure: Schema.Boolean, + includeFailure: Schema.Boolean, +}) { + static readonly [Workflow.InstrumentationBrand] = {} as const +} + +export const resolveAtomSuspense = Workflow.make({ + command: ResolveAtomSuspense, + decision: ResolveAtomSuspenseDecision, + error: Schema.Never, + decide: (command): Result.Result => + Match.value(Boolean.or(command.initial, Boolean.and(command.suspendOnWaiting, command.waiting))).pipe( + Match.when(true, (): Result.Result => Result.succeed(AtomSuspended.make())), + Match.when( + false, + (): Result.Result => + Match.value(Boolean.and(command.failure, Boolean.not(command.includeFailure))).pipe( + Match.when( + true, + (): Result.Result => Result.succeed(AtomRefused.make()), + ), + Match.when( + false, + (): Result.Result => Result.succeed(AtomReady.make()), + ), + Match.exhaustive, + ), + ), + Match.exhaustive, + ), +}) diff --git a/packages/atom/effect-atom-react/src/schema-laws.test.ts b/packages/atom/effect-atom-react/src/schema-laws.test.ts new file mode 100644 index 0000000..336ce12 --- /dev/null +++ b/packages/atom/effect-atom-react/src/schema-laws.test.ts @@ -0,0 +1 @@ +export {} diff --git a/packages/atom/effect-atom-react/src/scoped-atom.blueprint.ts b/packages/atom/effect-atom-react/src/scoped-atom.blueprint.ts new file mode 100644 index 0000000..3a01ce6 --- /dev/null +++ b/packages/atom/effect-atom-react/src/scoped-atom.blueprint.ts @@ -0,0 +1,249 @@ +/** + * React helpers for creating Atom instances that belong to one component + * subtree. `make` returns a scoped atom — a cold blueprint whose targets are a + * provider, a context, and a `use` accessor. Each provider creates its own + * Atom once, so different subtrees can use the same scoped atom definition + * without sharing state. + * + * @since 4.0.0 + */ +'use client' + +import { Blueprint } from '@systemfsoftware/effect-cell-types' +import { type Context, createContext, createElement, type FC, type ReactNode, useContext, useState } from 'react' +import { type AnyAtom } from './registry-context.js' + +type Top = A + +/** + * The identity every scoped atom carries. + * + * @since 4.0.0 + */ +export const TypeId: unique symbol = Symbol.for('~@effect/atom-react/ScopedAtom') + +/** + * @since 4.0.0 + */ +export type TypeId = typeof TypeId + +/** + * The type index of a scoped atom: the atom it hands back through `use` and the + * input its factory accepts. + * + * @since 4.0.0 + */ +export interface ScopedAtomIndex { + readonly Atom: Top + readonly Input: Top +} + +type FieldOf = (X & ScopedAtomIndex)[K] + +/** + * The `use` accessor of a scoped atom: a hook returning the atom the nearest + * provider created. + * + * @since 4.0.0 + */ +export interface ScopedAtomUse extends Blueprint.Target { + readonly target: () => FieldOf +} + +type ProviderOf = [FieldOf] extends [never] ? FC<{ readonly children?: ReactNode | undefined }> + : FC<{ readonly children?: ReactNode | undefined; readonly value: FieldOf }> + +/** + * The provider component of a scoped atom, taking a `value` prop when the + * factory expects input. + * + * @since 4.0.0 + */ +export interface ScopedAtomProvider extends Blueprint.Target { + readonly target: ProviderOf +} + +/** + * The React context a scoped atom's provider writes the created atom into. + * + * @since 4.0.0 + */ +export interface ScopedAtomContext extends Blueprint.Target { + readonly target: Context | undefined> +} + +/** + * The operations and targets every scoped atom carries. + * + * @since 4.0.0 + */ +export interface ScopedAtomOps { + readonly use: ScopedAtomUse + readonly Provider: ScopedAtomProvider + readonly Context: ScopedAtomContext +} + +/** + * The cold description one scoped atom carries. The provider and context are + * stored with their atom type erased; the `use`, `Provider`, and `Context` + * targets read them back typed by the scoped atom. + * + * @since 4.0.0 + */ +export interface ScopedAtomSpec { + readonly factory: (() => AnyAtom) | ((input: never) => AnyAtom) + readonly use: () => AnyAtom + readonly Provider: Top + readonly Context: Top +} + +/** + * Scoped Atom: a blueprint with a provider-backed instance. + * + * **Example** (Providing and reading a scoped atom) + * + * ```ts + * import { Atom } from "@systemfsoftware/effect-atom" + * import { make, useAtomValue } from "@systemfsoftware/effect-atom-react" + * import { createElement } from "react" + * import { renderToStaticMarkup } from "react-dom/server" + * + * const Counter = make(() => Atom.make(0)) + * + * function View() { + * const atom = Counter.use() + * const value = useAtomValue(atom) + * return createElement("div", null, value) + * } + * + * export function App() { + * return createElement(Counter.Provider, null, createElement(View)) + * } + * + * renderToStaticMarkup(createElement(App)) + * ``` + * + * @since 4.0.0 + */ +export type ScopedAtom = Blueprint.Blueprint< + TypeId, + ScopedAtomSpec, + ScopedAtomOps, + { readonly Atom: A; readonly Input: Input } +> + +/** + * A scoped atom with its atom type and factory input erased. + * + * @since 4.0.0 + */ +export type AnyScopedAtom = ScopedAtom + +function hasNoParameters( + factory: (() => A) | ((input: Input) => A), +): factory is () => A { + return factory.length === 0 +} + +function createScopedAtomFromInput( + factory: (input: Input) => A, + value: Input | undefined, +): A { + if (value === undefined) { + throw new Error('ScopedAtom Provider requires a value') + } + return factory(value) +} + +function createScopedAtom( + factory: (() => A) | ((input: Input) => A), + value: Input | undefined, +): A { + if (hasNoParameters(factory)) { + return factory() + } + return createScopedAtomFromInput(factory, value) +} + +const ScopedAtoms = Blueprint.make()(TypeId).operations()({ + operations: {}, + targets: { + use: (self: AnyScopedAtom) => self.spec.use, + Provider: (self: AnyScopedAtom) => self.spec.Provider, + Context: (self: AnyScopedAtom) => self.spec.Context, + }, +}) + +/** + * Creates a ScopedAtom from a factory function. + * + * **When to use** + * + * Use to create an atom instance that is owned by a React provider and scoped + * to a component subtree. + * + * **Details** + * + * The returned scoped atom includes a `Provider`, `Context`, and `use` + * accessor. The provider creates the atom once for its lifetime, passing the + * `value` prop to the factory when the scoped atom expects input. + * + * **Gotchas** + * + * `use` must run under the matching provider. Changing the provider `value` + * prop after mount does not recreate the atom. + * + * **Example** (Creating a scoped atom with input) + * + * ```ts + * import { Atom } from "@systemfsoftware/effect-atom" + * import { make, useAtomValue } from "@systemfsoftware/effect-atom-react" + * import { createElement } from "react" + * import { renderToStaticMarkup } from "react-dom/server" + * + * const User = make((name: string) => Atom.make(name)) + * + * function UserName() { + * const atom = User.use() + * const value = useAtomValue(atom) + * return createElement("span", null, value) + * } + * + * export function App() { + * return createElement( + * User.Provider, + * { value: "Ada" }, + * createElement(UserName) + * ) + * } + * + * renderToStaticMarkup(createElement(App)) + * ``` + * + * @since 4.0.0 + */ +export const make = ( + f: (() => A) | ((input: Input) => A), +): ScopedAtom => { + const ScopeContext = createContext(undefined) + + const use = (): A => { + const atom = useContext(ScopeContext) + if (atom === undefined) { + throw new Error('ScopedAtom used outside of its Provider') + } + return atom + } + + const Provider: FC<{ readonly children?: ReactNode | undefined; readonly value?: Input }> = (props) => { + const [atom] = useState(() => createScopedAtom(f, props.value)) + return createElement(ScopeContext.Provider, { value: atom }, props.children) + } + + return ScopedAtoms.of<{ readonly Atom: A; readonly Input: Input }>({ + factory: f, + use, + Provider, + Context: ScopeContext, + }) +} diff --git a/packages/atom/effect-atom-react/stryker.config.ts b/packages/atom/effect-atom-react/stryker.config.ts new file mode 100644 index 0000000..d8ec8f7 --- /dev/null +++ b/packages/atom/effect-atom-react/stryker.config.ts @@ -0,0 +1,25 @@ +import { sharedConfig } from '@systemfsoftware/stryker-config' +import { defineConfig, type PartialStrykerOptions } from '@systemfsoftware/stryker-js/config' + +const config: PartialStrykerOptions = defineConfig({ + ...sharedConfig, + testRunner: { + plugin: import.meta.resolve('@systemfsoftware/stryker-js-vitest-runner'), + options: { configFile: 'vitest.node.config.ts', dir: '.', related: true }, + }, + checkers: [ + { + plugin: import.meta.resolve('@systemfsoftware/stryker-js-typescript-checker'), + options: { prioritizePerformanceOverAccuracy: true }, + }, + ], + ignorers: [ + import.meta.resolve('@systemfsoftware/stryker-ignorer-effect-schema-declarations'), + import.meta.resolve('@systemfsoftware/stryker-ignorer-in-source-vitest-block'), + ], + thresholds: { high: 100, low: 100, break: 100 }, + dryRunTimeoutMinutes: 10, + ignorePatterns: [], +}) + +export default config diff --git a/packages/atom/effect-atom-react/test-types/atom-react.tst.ts b/packages/atom/effect-atom-react/test-types/atom-react.tst.ts new file mode 100644 index 0000000..4d2c539 --- /dev/null +++ b/packages/atom/effect-atom-react/test-types/atom-react.tst.ts @@ -0,0 +1,32 @@ +import { Atom } from '@systemfsoftware/effect-atom' +import { AtomReact } from '@systemfsoftware/effect-atom-react' +import { describe, expect, it } from 'tstyche' + +const count = Atom.make(0) +const readOnly = Atom.readable((get) => get(count) * 2) + +describe('useAtomValue with a selector', () => { + it('Should_ReturnTheSelectorOutputType_When_UseAtomValueIsGivenASelector', () => { + expect( + AtomReact.useAtomValue(count, (n) => { + expect(n).type.toBe() + return `${n}` + }), + ).type.toBe() + }) + + it('Should_AcceptTheSelectorInDataLastComposition_When_UseAtomValueIsPiped', () => { + expect(AtomReact.useAtomValue((n: number) => n > 0)).type.toBeCallableWith(count) + expect(AtomReact.useAtomValue(count, (n) => n > 0)).type.toBe() + }) +}) + +describe('useAtomSet', () => { + it('Should_AcceptAWritableAtom_When_UseAtomSetIsGivenAnAtom', () => { + expect(AtomReact.useAtomSet).type.toBeCallableWith(count) + }) + + it('Should_RejectAReadOnlyDerivedAtom_When_UseAtomSetIsGivenOne', () => { + expect(AtomReact.useAtomSet).type.not.toBeCallableWith(readOnly) + }) +}) diff --git a/packages/atom/effect-atom-react/tests/__fixtures__/Unavailable.fixture.ts b/packages/atom/effect-atom-react/tests/__fixtures__/Unavailable.fixture.ts new file mode 100644 index 0000000..645a4e6 --- /dev/null +++ b/packages/atom/effect-atom-react/tests/__fixtures__/Unavailable.fixture.ts @@ -0,0 +1,3 @@ +import * as Schema from 'effect/Schema' + +export class Unavailable extends Schema.TaggedError()('Unavailable', {}) {} diff --git a/packages/atom/effect-atom-react/tests/__fixtures__/find-widget.ts b/packages/atom/effect-atom-react/tests/__fixtures__/find-widget.ts new file mode 100644 index 0000000..0e187ca --- /dev/null +++ b/packages/atom/effect-atom-react/tests/__fixtures__/find-widget.ts @@ -0,0 +1,16 @@ +import { screen, waitFor } from '@testing-library/react' + +interface WidgetShowing { + readonly testId: string + readonly text: string +} + +/** Waits until the widget shows this text, then hands it back so the step's check runs on a settled state. */ +export const findWidgetShowing = ({ testId, text }: WidgetShowing): Promise => + waitFor(() => { + const widget = screen.getByTestId(testId) + if (widget.textContent !== text) { + throw new Error(`the widget ${testId} shows ${JSON.stringify(widget.textContent)}, not ${JSON.stringify(text)}`) + } + return widget + }) diff --git a/packages/atom/effect-atom-react/tests/__fixtures__/registry-wrapper.ts b/packages/atom/effect-atom-react/tests/__fixtures__/registry-wrapper.ts new file mode 100644 index 0000000..4e9fdda --- /dev/null +++ b/packages/atom/effect-atom-react/tests/__fixtures__/registry-wrapper.ts @@ -0,0 +1,15 @@ +import type { Atom } from '@systemfsoftware/effect-atom' +import { AtomReact } from '@systemfsoftware/effect-atom-react' +import { act, screen } from '@testing-library/react' +import { createElement, type ReactNode } from 'react' + +type WrapperProps = { readonly children?: ReactNode } + +export const withRegistry = (registry: Atom.Registry.Registry) => ({ children }: WrapperProps) => + createElement(AtomReact.RegistryContext.Provider, { value: registry }, children) + +export const pressButton = (name: string): void => { + act(() => { + screen.getByRole('button', { name }).click() + }) +} diff --git a/packages/atom/effect-atom-react/tests/__fixtures__/render-cleanup.ts b/packages/atom/effect-atom-react/tests/__fixtures__/render-cleanup.ts new file mode 100644 index 0000000..d06a328 --- /dev/null +++ b/packages/atom/effect-atom-react/tests/__fixtures__/render-cleanup.ts @@ -0,0 +1,25 @@ +import { Atom } from '@systemfsoftware/effect-atom' +import { AtomReact } from '@systemfsoftware/effect-atom-react' +import { cleanup } from '@testing-library/react' +import { Effect, Layer } from 'effect' + +const owned: Atom.Registry.Registry[] = [] + +type InitialValues = NonNullable[0]>['initialValues'] + +export const ownRegistry = (initialValues?: InitialValues): Atom.Registry.Registry => { + const registry = Atom.Registry.make({ scheduleTask: AtomReact.scheduleTask, initialValues }) + owned.push(registry) + return registry +} + +const unmountThenDispose = (): void => { + cleanup() + for (const registry of owned.splice(0)) { + Atom.Registry.dispose(registry) + } +} + +export const renderCleanupLayer = Layer.effectDiscard( + Effect.addFinalizer(() => Effect.sync(unmountThenDispose)), +) diff --git a/packages/atom/effect-atom-react/tests/__fixtures__/render-suspending.ts b/packages/atom/effect-atom-react/tests/__fixtures__/render-suspending.ts new file mode 100644 index 0000000..6e1ac61 --- /dev/null +++ b/packages/atom/effect-atom-react/tests/__fixtures__/render-suspending.ts @@ -0,0 +1,7 @@ +import { act, render } from '@testing-library/react' +import * as Effect from 'effect/Effect' +import { constVoid } from 'effect/Function' +import type { ReactElement } from 'react' + +export const renderSuspending = (element: ReactElement): Effect.Effect => + Effect.promise(() => Promise.resolve(act(() => Promise.resolve(render(element)).then(constVoid)))) diff --git a/packages/atom/effect-atom-react/tests/keeping-registry-alive.integration.test.ts b/packages/atom/effect-atom-react/tests/keeping-registry-alive.integration.test.ts new file mode 100644 index 0000000..240f9c4 --- /dev/null +++ b/packages/atom/effect-atom-react/tests/keeping-registry-alive.integration.test.ts @@ -0,0 +1,388 @@ +import { Atom } from '@systemfsoftware/effect-atom' +import { AtomReact } from '@systemfsoftware/effect-atom-react' +import { Gherkin, Given, it, makeFeature, Then, When } from '@systemfsoftware/effect-gherkin-spec' +import { vi } from '@systemfsoftware/vitest' +import { render, renderHook, screen } from '@testing-library/react' +import '@vitest/browser/matchers' +import * as Effect from 'effect/Effect' +import * as Layer from 'effect/Layer' +import { createElement, Fragment, type ReactNode, StrictMode, Suspense, use, useState, useTransition } from 'react' +import { ErrorBoundary, getErrorMessage } from 'react-error-boundary' +import { findWidgetShowing } from './__fixtures__/find-widget.js' +import { pressButton, withRegistry } from './__fixtures__/registry-wrapper.js' +import { ownRegistry, renderCleanupLayer } from './__fixtures__/render-cleanup.js' +import { renderSuspending } from './__fixtures__/render-suspending.js' + +const Feature = makeFeature({ it }) + +const watchedValue = (lifecycle: string[]) => + Atom.make((get) => { + lifecycle.push('computed') + get.addFinalizer(() => lifecycle.push('released')) + return 0 + }) + +const registryWorkSettled = Effect.callback((resume) => { + const cancel = AtomReact.scheduleTask(() => resume(Effect.void)) + return Effect.sync(cancel) +}) + +Feature('Keeping a shared registry alive') + .live('renders a real page in Chromium and waits on browser timers') + .withLayer(Layer.empty) + .withScenarioLayer(renderCleanupLayer) + .body(({ scenario }) => { + scenario( + 'A widget under a provider reads the value that provider holds', + Gherkin.Do.pipe( + Given('a widget showing a value under its own provider')('ctx', () => + Effect.sync(() => { + const rating = Atom.make(0) + function Widget() { + const value = AtomReact.useAtomValue(rating) + return createElement('div', { 'data-testid': 'provided-rating' }, value) + } + render(createElement(AtomReact.RegistryProvider, { registry: ownRegistry() }, createElement(Widget))) + return {} + })), + When('the widget is shown')('shown', () => Effect.succeed(true)), + Then('the value the provider starts with is on screen')((_s, expect) => + Effect.promise(() => screen.findByTestId('provided-rating')).pipe( + Effect.map((rating) => expect(rating).toHaveTextContent('0')), + ) + ), + ), + ) + + scenario( + 'A widget shown without any provider reports that a provider is required', + Gherkin.Do.pipe( + Given('a widget reading a value with no provider anywhere above it, inside an error boundary')( + 'ctx', + () => + Effect.sync(() => { + const rating = Atom.make(0) + function Widget() { + const value = AtomReact.useAtomValue(rating) + return createElement('div', { 'data-testid': 'unprovided-value' }, value) + } + render( + createElement( + ErrorBoundary, + { + fallbackRender: ({ error }) => + createElement( + 'div', + { 'data-testid': 'missing-registry' }, + getErrorMessage(error) ?? 'no message given', + ), + }, + createElement(Widget), + ), + ) + return {} + }), + ), + When('the widget is shown')('shown', () => Effect.succeed(true)), + Then('the error message says a provider is what supplies the data source')((_s, expect) => + Effect.promise(() => screen.findByTestId('missing-registry')).pipe( + Effect.map((notice) => + expect({ notice: notice.textContent, unprovidedValue: screen.queryByTestId('unprovided-value') }) + .toMatchObject({ + notice: expect.stringMatching(/RegistryProvider/), + unprovidedValue: null, + }) + ), + ) + ), + ), + ) + + scenario( + 'Two providers side by side keep their own values for the same shared value', + Gherkin.Do.pipe( + Given('two providers next to each other, each holding a different starting value for one shared value')( + 'ctx', + () => + Effect.sync(() => { + const rating = Atom.make(0) + function Widget({ id }: { readonly id: string }) { + const value = AtomReact.useAtomValue(rating) + return createElement('div', { 'data-testid': id }, value) + } + render( + createElement( + Fragment, + null, + createElement( + AtomReact.RegistryProvider, + { registry: ownRegistry([[rating, 2]]) }, + createElement(Widget, { id: 'left-rating' }), + ), + createElement( + AtomReact.RegistryProvider, + { registry: ownRegistry([[rating, 6]]) }, + createElement(Widget, { id: 'right-rating' }), + ), + ), + ) + return {} + }), + ), + When('both widgets are shown')('shown', () => Effect.succeed(true)), + Then('each widget shows the value its own provider holds')((_s, expect) => + Effect.promise(() => Promise.all([screen.findByTestId('left-rating'), screen.findByTestId('right-rating')])) + .pipe( + Effect.map(([left, right]) => + expect({ left: left.textContent, right: right.textContent }).toEqual({ left: '2', right: '6' }) + ), + ) + ), + ), + ) + + scenario( + 'A strict page that mounts twice and re-renders keeps its value computed once and held', + Gherkin.Do.pipe( + Given('a page under StrictMode that keeps a value alive in the registry its owner made')( + 'ctx', + () => + Effect.sync(() => { + const lifecycle: string[] = [] + const watched = watchedValue(lifecycle) + const registry = ownRegistry() + const seenRegistries: Atom.Registry.Registry[] = [] + function Page() { + seenRegistries.push(AtomReact.useRegistry()) + AtomReact.useAtomMount(watched) + const [renders, setRenders] = useState(0) + return createElement( + 'button', + { type: 'button', onClick: () => setRenders((n) => n + 1) }, + `re-render ${renders}`, + ) + } + render( + createElement( + StrictMode, + null, + createElement(AtomReact.RegistryProvider, { registry }, createElement(Page)), + ), + ) + return { lifecycle, registry, seenRegistries } + }), + ), + When('the page is re-rendered and the registry has run its queued work')( + 'done', + () => Effect.sync(() => pressButton('re-render 0')).pipe(Effect.andThen(registryWorkSettled)), + ), + Then("the value was computed once and is still held, and every render saw the owner's registry")(( + s, + expect, + ) => + expect({ lifecycle: s.ctx.lifecycle, seen: [...new Set(s.ctx.seenRegistries)] }).toEqual({ + lifecycle: ['computed'], + seen: [s.ctx.registry], + }) + ), + ), + ) + + scenario( + "A section whose loading never finishes reads the owner's registry and leaves nothing running", + Gherkin.Do.pipe( + Given('a page that starts loading a section which reads a value and then waits forever')( + 'ctx', + () => + Effect.sync(() => { + const lifecycle: string[] = [] + const watched = watchedValue(lifecycle) + const registry = ownRegistry() + const seenRegistries: Atom.Registry.Registry[] = [] + const neverLoads = Effect.runPromise(Effect.never) + function NeverLoads(): ReactNode { + seenRegistries.push(AtomReact.useRegistry()) + AtomReact.useAtomValue(watched) + return use(neverLoads) + } + function Page() { + const [loading, setLoading] = useState(false) + const [, startTransition] = useTransition() + return createElement( + Fragment, + null, + createElement('div', { 'data-testid': 'section' }, loading ? 'loading' : 'idle'), + createElement('button', { + type: 'button', + onClick: () => startTransition(() => setLoading(true)), + }, 'load section'), + loading ? createElement(NeverLoads) : null, + ) + } + render( + createElement( + AtomReact.RegistryProvider, + { registry }, + createElement(Suspense, { fallback: null }, createElement(Page)), + ), + ) + return { lifecycle, registry, seenRegistries } + }), + ), + When('the section starts loading and the registry releases what its render read')( + 'shown', + (s) => + Effect.sync(() => pressButton('load section')).pipe( + Effect.andThen(Effect.promise(() => + vi.waitFor(() => { + if (!s.ctx.lifecycle.includes('released')) { + throw new Error('the value the loading section read is still held') + } + }) + )), + Effect.andThen(Effect.promise(() => findWidgetShowing({ testId: 'section', text: 'idle' }))), + ), + ), + Then("the page never committed the section, its value was released, and it read the owner's registry")(( + s, + expect, + ) => + expect({ + section: s.shown.textContent, + lifecycle: s.ctx.lifecycle, + seen: [...new Set(s.ctx.seenRegistries)], + }).toEqual({ + section: 'idle', + lifecycle: ['computed', 'released'], + seen: [s.ctx.registry], + }) + ), + ), + ) + + scenario( + 'A provider below a waiting note shows the value once it arrives, from one registry', + Gherkin.Do.pipe( + Given('a provider inside a waiting note whose widget waits on a slow value the first time it mounts')( + 'ctx', + () => + Effect.suspend(() => { + const counts = { runs: 0 } + const slow = Atom.make( + Effect.sync(() => { + counts.runs += 1 + }).pipe(Effect.andThen(Effect.sleep('50 millis')), Effect.as(7)), + ) + const registry = ownRegistry() + const seenRegistries: Atom.Registry.Registry[] = [] + function Widget() { + seenRegistries.push(AtomReact.useRegistry()) + const result = AtomReact.useAtomSuspense(slow) + return createElement('div', { 'data-testid': 'slow-value' }, Atom.AsyncResult.getOrThrow(result)) + } + return renderSuspending( + createElement( + Suspense, + { fallback: createElement('div', null, 'waiting') }, + createElement(AtomReact.RegistryProvider, { registry }, createElement(Widget)), + ), + ).pipe(Effect.as({ counts, registry, seenRegistries })) + }), + ), + When('the value arrives')( + 'shown', + () => Effect.promise(() => findWidgetShowing({ testId: 'slow-value', text: '7' })), + ), + Then('the value shows, every attempt saw the one registry, and the value ran at most once per attempt')(( + s, + expect, + ) => + expect({ + shown: s.shown.textContent, + seen: [...new Set(s.ctx.seenRegistries)], + ranAtMostOncePerAttempt: s.ctx.counts.runs <= s.ctx.seenRegistries.length, + }).toEqual({ shown: '7', seen: [s.ctx.registry], ranAtMostOncePerAttempt: true }) + ), + ), + ) + + scenario( + 'A page that leaves keeps its registry until the owner puts it away', + Gherkin.Do.pipe( + Given('a page showing a value kept alive in the registry its owner made')('ctx', () => + Effect.sync(() => { + const lifecycle: string[] = [] + const kept = Atom.make((get) => { + lifecycle.push('computed') + get.addFinalizer(() => lifecycle.push('released')) + return 5 + }).pipe(Atom.keepAlive) + const registry = ownRegistry() + function Widget() { + return createElement('div', { 'data-testid': 'kept' }, AtomReact.useAtomValue(kept)) + } + const { unmount } = render(createElement(AtomReact.RegistryProvider, { registry }, createElement(Widget))) + return { kept, lifecycle, registry, unmount } + })), + When('the page leaves, the registry runs its queued work, and the owner then disposes it')( + 'done', + (s) => + Effect.promise(() => findWidgetShowing({ testId: 'kept', text: '5' })).pipe( + Effect.andThen(Effect.sync(() => s.ctx.unmount())), + Effect.andThen(registryWorkSettled), + Effect.map(() => { + const readAfterUnmount = Atom.Registry.get(s.ctx.registry, s.ctx.kept) + const afterUnmount = [...s.ctx.lifecycle] + Atom.Registry.dispose(s.ctx.registry) + return { readAfterUnmount, afterUnmount } + }), + ), + ), + Then('the registry still answered after the page left, and the value was released only by the owner')(( + s, + expect, + ) => + expect({ ...s.done, afterDispose: s.ctx.lifecycle }).toEqual({ + readAfterUnmount: 5, + afterUnmount: ['computed'], + afterDispose: ['computed', 'released'], + }) + ), + ), + ) + + scenario( + 'A widget that only keeps a value alive computes it once and releases it when it leaves the page', + Gherkin.Do.pipe( + Given('a widget that keeps a value alive without reading it')('ctx', () => + Effect.sync(() => { + const lifecycle: string[] = [] + const watched = watchedValue(lifecycle) + const { unmount } = renderHook(() => AtomReact.useAtomMount(watched), { + wrapper: withRegistry(ownRegistry()), + }) + return { lifecycle, unmount, whileShown: [...lifecycle] } + })), + When('the widget leaves the page')('done', (s) => + Effect.sync(() => { + s.ctx.unmount() + })), + Then('the value was computed while shown and released afterwards')((s, expect) => + Effect.promise(() => + vi.waitFor(() => { + if (!s.ctx.lifecycle.includes('released')) { + throw new Error('the value is still held') + } + }) + ).pipe( + Effect.map(() => + expect({ whileShown: s.ctx.whileShown, after: s.ctx.lifecycle }).toEqual({ + whileShown: ['computed'], + after: ['computed', 'released'], + }) + ), + ) + ), + ), + ) + }) diff --git a/packages/atom/effect-atom-react/tests/page-address-read-write.integration.test.ts b/packages/atom/effect-atom-react/tests/page-address-read-write.integration.test.ts new file mode 100644 index 0000000..9090461 --- /dev/null +++ b/packages/atom/effect-atom-react/tests/page-address-read-write.integration.test.ts @@ -0,0 +1,123 @@ +import { Atom } from '@systemfsoftware/effect-atom' +import { Gherkin, Given, it, makeFeature, Then, When } from '@systemfsoftware/effect-gherkin-spec' +import { vi } from '@systemfsoftware/vitest' +import * as Effect from 'effect/Effect' +import { constVoid } from 'effect/Function' +import * as Layer from 'effect/Layer' + +const Feature = makeFeature({ it }) + +/** + * A registry whose deferred address-bar rewrites are held back so a scenario + * can flush them at a chosen moment. + */ +const heldBackRegistry = (heldBack: Array<() => void>): Atom.Registry.Registry => + Atom.Registry.make({ + scheduleTimer: (rewrite) => { + heldBack.push(rewrite) + return constVoid + }, + }) + +const recordAddressBarWrites = () => { + const pushState = vi.spyOn(window.history, 'pushState').mockImplementation(() => {}) + return { + writtenAddresses: () => pushState.mock.calls.map((call) => String(call[2])), + stopRecording: () => pushState.mockRestore(), + } +} + +Feature('Remembering page choices in the address bar') + .live('renders real components in Chromium and reads the address bar') + .withLayer(Layer.empty) + .body(({ scenario }) => { + scenario( + 'Two choices changed at once are written into the address bar together', + Gherkin.Do.pipe( + Given('a page remembering two choices in the address bar, with its rewrites held back')( + 'ctx', + () => + Effect.sync(() => { + const heldBack: Array<() => void> = [] + const recording = recordAddressBarWrites() + const registry = heldBackRegistry(heldBack) + return { + heldBack, + recording, + registry, + sizeChoice: Atom.searchParam('size'), + colourChoice: Atom.searchParam('colour'), + } + }), + ), + When('both choices are changed in the same instant, then the page catches up')( + 'result', + (s) => + Effect.sync(() => { + Atom.Registry.set(s.ctx.registry, s.ctx.sizeChoice, '1') + Atom.Registry.set(s.ctx.registry, s.ctx.colourChoice, '2') + for (const rewrite of s.ctx.heldBack) { + rewrite() + } + const writtenAddresses = s.ctx.recording.writtenAddresses() + s.ctx.recording.stopRecording() + return { writtenAddresses } + }), + ), + Then('the address bar is rewritten once and carries both choices')((s, expect) => + expect(s.result.writtenAddresses).toSatisfy( + (addresses) => + addresses.length === 1 && + addresses.every((address) => address.includes('size=1') && address.includes('colour=2')), + 'exactly one address rewrite, carrying the chosen size and the chosen colour', + ) + ), + ), + ) + scenario( + 'A page never writes another page pending address bar change', + Gherkin.Do.pipe( + Given('two pages sharing the address bar, each remembering a different value for the same choice')( + 'ctx', + () => + Effect.sync(() => { + const recording = recordAddressBarWrites() + const firstHeldBack: Array<() => void> = [] + const secondHeldBack: Array<() => void> = [] + const firstPage = heldBackRegistry(firstHeldBack) + const secondPage = heldBackRegistry(secondHeldBack) + const choice = Atom.searchParam('size') + return { + choice, + firstHeldBack, + firstPage, + recording, + secondPage, + } + }), + ), + When('both pages record their choice in the same instant, then only the first page catches up')( + 'result', + (s) => + Effect.sync(() => { + Atom.Registry.set(s.ctx.firstPage, s.ctx.choice, '1') + Atom.Registry.set(s.ctx.secondPage, s.ctx.choice, '2') + for (const rewrite of s.ctx.firstHeldBack) { + rewrite() + } + const writtenAddresses = s.ctx.recording.writtenAddresses() + s.ctx.recording.stopRecording() + return { writtenAddresses } + }), + ), + Then('the address bar carries only the choice the first page recorded itself')((s, expect) => + expect(s.result.writtenAddresses).toSatisfy( + (addresses) => + addresses.length === 1 && + addresses.every((address) => address.includes('size=1') && !address.includes('size=2')), + "exactly one address rewrite, carrying the first page's choice and not the other page's", + ) + ), + ), + ) + }) diff --git a/packages/atom/effect-atom-react/tests/restoring-saved-state.integration.test.ts b/packages/atom/effect-atom-react/tests/restoring-saved-state.integration.test.ts new file mode 100644 index 0000000..67b55d6 --- /dev/null +++ b/packages/atom/effect-atom-react/tests/restoring-saved-state.integration.test.ts @@ -0,0 +1,103 @@ +import { Atom } from '@systemfsoftware/effect-atom' +import { Gherkin, Given, it, makeFeature, Then, When } from '@systemfsoftware/effect-gherkin-spec' +import { render, screen } from '@testing-library/react' +import '@vitest/browser/matchers' +import { AtomReact } from '@systemfsoftware/effect-atom-react' +import * as Effect from 'effect/Effect' +import * as Layer from 'effect/Layer' +import * as Schema from 'effect/Schema' +import { createElement } from 'react' +import { ownRegistry, renderCleanupLayer } from './__fixtures__/render-cleanup.js' + +const Feature = makeFeature({ it }) + +Feature('Restoring saved page state') + .live('renders real components in Chromium and waits on browser timers') + .withLayer(Layer.empty) + .withScenarioLayer(renderCleanupLayer) + .body(({ scenario }) => { + scenario( + 'A page whose owner restores a saved value before rendering shows it in its first commit', + Gherkin.Do.pipe( + Given('saved state for an atom the page has not loaded yet, restored by the owner before the page renders')( + 'ctx', + () => + Effect.sync(() => { + const temperature = Atom.make(18).pipe( + Atom.serializable({ key: 'owned-temperature', schema: Schema.Finite }), + ) + const savedPage = Atom.Registry.make() + Atom.Registry.set(savedPage, temperature, 23) + const registry = ownRegistry() + Atom.Hydration.hydrate(registry, Atom.Hydration.dehydrate(savedPage)) + function Page() { + const value = AtomReact.useAtomValue(temperature) + return createElement('div', { 'data-testid': 'owned-temperature' }, value) + } + render(createElement(AtomReact.RegistryProvider, { registry }, createElement(Page))) + return {} + }), + ), + When('the first commit is read')( + 'firstCommit', + () => Effect.sync(() => screen.getByTestId('owned-temperature').textContent), + ), + Then('the saved value is what the page first showed')((s, expect) => expect(s.firstCommit).toBe('23')), + ), + ) + + scenario( + 'A registry that already holds a value for an atom shows the saved value after the owner restores saved state naming that atom', + Gherkin.Do.pipe( + Given('a live registry already holding a value, then saved state for the same atom, restored by the owner')( + 'ctx', + () => + Effect.sync(() => { + const temperature = Atom.make(18).pipe( + Atom.serializable({ key: 'live-temperature', schema: Schema.Finite }), + ) + const savedPage = Atom.Registry.make() + Atom.Registry.set(savedPage, temperature, 23) + const registry = ownRegistry() + Atom.Registry.set(registry, temperature, 18) + Atom.Hydration.hydrate(registry, Atom.Hydration.dehydrate(savedPage)) + function Page() { + const value = AtomReact.useAtomValue(temperature) + return createElement('div', { 'data-testid': 'live-temperature' }, value) + } + render(createElement(AtomReact.RegistryProvider, { registry }, createElement(Page))) + return {} + }), + ), + When('the first commit is read')( + 'firstCommit', + () => Effect.sync(() => screen.getByTestId('live-temperature').textContent), + ), + Then('the saved value replaced the value the registry held')((s, expect) => expect(s.firstCommit).toBe('23')), + ), + ) + + scenario( + 'A page whose owner restores no saved state shows the values the owner set', + Gherkin.Do.pipe( + Given('a value the owner sets before rendering, with nothing saved to restore')('ctx', () => + Effect.sync(() => { + const room = Atom.make(4) + const registry = ownRegistry() + Atom.Registry.set(registry, room, 4) + Atom.Hydration.hydrate(registry, []) + function Page() { + const value = AtomReact.useAtomValue(room) + return createElement('div', { 'data-testid': 'owned-room' }, value) + } + render(createElement(AtomReact.RegistryProvider, { registry }, createElement(Page))) + return {} + })), + When('the first commit is read')( + 'firstCommit', + () => Effect.sync(() => screen.getByTestId('owned-room').textContent), + ), + Then('the value the owner set is what the page first showed')((s, expect) => expect(s.firstCommit).toBe('4')), + ), + ) + }) diff --git a/packages/atom/effect-atom-react/tests/scoped-atoms.integration.test.ts b/packages/atom/effect-atom-react/tests/scoped-atoms.integration.test.ts new file mode 100644 index 0000000..5add2dc --- /dev/null +++ b/packages/atom/effect-atom-react/tests/scoped-atoms.integration.test.ts @@ -0,0 +1,173 @@ +import { Atom } from '@systemfsoftware/effect-atom' +import { Gherkin, Given, it, makeFeature, Then, When } from '@systemfsoftware/effect-gherkin-spec' +import { render, screen } from '@testing-library/react' +import '@vitest/browser/matchers' +import { AtomReact } from '@systemfsoftware/effect-atom-react' +import * as Effect from 'effect/Effect' +import * as Layer from 'effect/Layer' +import { createElement, useState } from 'react' +import { ErrorBoundary } from 'react-error-boundary' +import { findWidgetShowing } from './__fixtures__/find-widget.js' +import { pressButton } from './__fixtures__/registry-wrapper.js' +import { renderCleanupLayer } from './__fixtures__/render-cleanup.js' + +const Feature = makeFeature({ it }) + +Feature('Scoped atoms that belong to one part of the page') + .live('renders real components in Chromium and waits on browser timers') + .withLayer(Layer.empty) + .withScenarioLayer(renderCleanupLayer) + .body(({ scenario }) => { + scenario( + 'A scoped counter is created for its subtree and updates through its setter', + Gherkin.Do.pipe( + Given('a scoped counter atom with a widget that can read and update it')('ctx', () => + Effect.sync(() => { + const Counter = AtomReact.make(() => Atom.make(0)) + function Widget() { + const atom = Counter.use() + const value = AtomReact.useAtomValue(atom) + const set = AtomReact.useAtomSet(atom) + const increment = AtomReact.useAtomUpdate(atom) + return createElement( + 'div', + null, + createElement('div', { 'data-testid': 'scoped-counter' }, value), + createElement('button', { type: 'button', onClick: () => set(5) }, 'set to five'), + createElement('button', { + type: 'button', + onClick: () => increment((previous) => previous + 1), + }, 'increase'), + ) + } + render( + createElement( + AtomReact.RegistryContext.Provider, + { value: Atom.Registry.make() }, + createElement(Counter.Provider, null, createElement(Widget)), + ), + ) + return {} + })), + When('the counter is set to a new value and then increased from the current value')( + 'done', + () => + Effect.sync(() => { + pressButton('set to five') + pressButton('increase') + }), + ), + Then('the widget shows the updated counter')((_s, expect) => + Effect.promise(() => findWidgetShowing({ testId: 'scoped-counter', text: '6' })).pipe( + Effect.map((counter) => expect(counter).toHaveTextContent('6')), + ) + ), + ), + ) + + scenario( + 'A scoped atom created with an input starts with that input', + Gherkin.Do.pipe( + Given('a scoped atom that takes a name as its input')('ctx', () => + Effect.sync(() => { + const UserName = AtomReact.make((name: string) => Atom.make(name)) + function Greeting() { + const atom = UserName.use() + const value = AtomReact.useAtomValue(atom) + return createElement('div', { 'data-testid': 'greeting' }, value) + } + render( + createElement( + AtomReact.RegistryContext.Provider, + { value: Atom.Registry.make() }, + createElement(UserName.Provider, { value: 'Ada' }, createElement(Greeting)), + ), + ) + return {} + })), + When('the greeting is shown')('shown', () => Effect.succeed(true)), + Then('the input name is on screen')((_s, expect) => + Effect.promise(() => screen.findByTestId('greeting')).pipe( + Effect.map((greeting) => expect(greeting).toHaveTextContent('Ada')), + ) + ), + ), + ) + + scenario( + 'A scoped atom provider that renders again keeps its original atom', + Gherkin.Do.pipe( + Given('a page that can rename the input of a scoped atom provider')('ctx', () => + Effect.sync(() => { + const UserName = AtomReact.make((name: string) => Atom.make(name)) + function Greeting() { + const atom = UserName.use() + const value = AtomReact.useAtomValue(atom) + return createElement('div', { 'data-testid': 'kept-name' }, value) + } + function Page() { + const [name, setName] = useState('Ada') + return createElement( + 'div', + null, + createElement(UserName.Provider, { value: name }, createElement(Greeting)), + createElement('button', { type: 'button', onClick: () => setName('Grace') }, 'rename'), + ) + } + render( + createElement( + AtomReact.RegistryContext.Provider, + { value: Atom.Registry.make() }, + createElement(Page), + ), + ) + return {} + })), + When('the page asks for a new name for the provider')('done', () => + Effect.sync(() => { + pressButton('rename') + })), + Then('the original atom is still on screen')((_s, expect) => + Effect.promise(() => screen.findByTestId('kept-name')).pipe( + Effect.map((keptName) => expect(keptName).toHaveTextContent('Ada')), + ) + ), + ), + ) + + scenario( + 'A scoped atom read outside its provider reports that the provider is missing', + Gherkin.Do.pipe( + Given('a widget that reads a scoped atom without its provider, inside an error boundary')( + 'ctx', + () => + Effect.sync(() => { + const Counter = AtomReact.make(() => Atom.make(0)) + function Widget() { + const atom = Counter.use() + const value = AtomReact.useAtomValue(atom) + return createElement('div', { 'data-testid': 'unscoped-value' }, value) + } + render( + createElement( + AtomReact.RegistryContext.Provider, + { value: Atom.Registry.make() }, + createElement( + ErrorBoundary, + { fallback: createElement('div', { 'data-testid': 'missing-provider' }, 'provider missing') }, + createElement(Widget), + ), + ), + ) + return {} + }), + ), + When('the widget is shown')('shown', () => Effect.succeed(true)), + Then('the error boundary reports the missing provider')((_s, expect) => + Effect.promise(() => screen.findByTestId('missing-provider')).pipe( + Effect.map((missingProvider) => expect(missingProvider).toHaveTextContent('provider missing')), + ) + ), + ), + ) + }) diff --git a/packages/atom/effect-atom-react/tests/seeding-shared-values.integration.test.ts b/packages/atom/effect-atom-react/tests/seeding-shared-values.integration.test.ts new file mode 100644 index 0000000..e4fb093 --- /dev/null +++ b/packages/atom/effect-atom-react/tests/seeding-shared-values.integration.test.ts @@ -0,0 +1,57 @@ +import { Atom } from '@systemfsoftware/effect-atom' +import { Gherkin, Given, it, makeFeature, Then, When } from '@systemfsoftware/effect-gherkin-spec' +import { render, screen } from '@testing-library/react' +import '@vitest/browser/matchers' +import { AtomReact } from '@systemfsoftware/effect-atom-react' +import * as Effect from 'effect/Effect' +import * as Layer from 'effect/Layer' +import { createElement } from 'react' +import { ownRegistry, renderCleanupLayer } from './__fixtures__/render-cleanup.js' + +const Feature = makeFeature({ it }) + +Feature('Seeding shared values') + .live('renders real components in Chromium and waits on browser timers') + .withLayer(Layer.empty) + .withScenarioLayer(renderCleanupLayer) + .body(({ scenario }) => { + scenario( + 'Two pages whose owners seed different starting values each show their own in the first commit', + Gherkin.Do.pipe( + Given('two pages, each under a registry its owner seeded with its own starting value')( + 'ctx', + () => + Effect.sync(() => { + const balance = Atom.make(0) + function Page({ id }: { readonly id: string }) { + const value = AtomReact.useAtomValue(balance) + return createElement('div', { 'data-testid': id }, value) + } + render( + createElement( + AtomReact.RegistryProvider, + { registry: ownRegistry([[balance, 3]]) }, + createElement(Page, { id: 'first-owned-balance' }), + ), + ) + render( + createElement( + AtomReact.RegistryProvider, + { registry: ownRegistry([[balance, 8]]) }, + createElement(Page, { id: 'second-owned-balance' }), + ), + ) + return {} + }), + ), + When('the first commits are read')('firstCommits', () => + Effect.sync(() => ({ + first: screen.getByTestId('first-owned-balance').textContent, + second: screen.getByTestId('second-owned-balance').textContent, + }))), + Then('each page first showed its own starting value')((s, expect) => + expect(s.firstCommits).toEqual({ first: '3', second: '8' }) + ), + ), + ) + }) diff --git a/packages/atom/effect-atom-react/tests/shared-values-read-write.integration.test.ts b/packages/atom/effect-atom-react/tests/shared-values-read-write.integration.test.ts new file mode 100644 index 0000000..88110b1 --- /dev/null +++ b/packages/atom/effect-atom-react/tests/shared-values-read-write.integration.test.ts @@ -0,0 +1,283 @@ +import { Atom } from '@systemfsoftware/effect-atom' +import { Gherkin, Given, it, makeFeature, Then, When } from '@systemfsoftware/effect-gherkin-spec' +import { render, renderHook, screen } from '@testing-library/react' +import '@vitest/browser/matchers' +import { AtomReact } from '@systemfsoftware/effect-atom-react' +import * as Effect from 'effect/Effect' +import * as Layer from 'effect/Layer' +import { createElement, Suspense } from 'react' +import { findWidgetShowing } from './__fixtures__/find-widget.js' +import { pressButton, withRegistry } from './__fixtures__/registry-wrapper.js' +import { ownRegistry, renderCleanupLayer } from './__fixtures__/render-cleanup.js' + +const Feature = makeFeature({ it }) + +Feature('Reading and changing shared values from on-screen widgets') + .live('renders real components in Chromium and advances the browser timer queue') + .withLayer(Layer.empty) + .withScenarioLayer(renderCleanupLayer) + .body(({ scenario }) => { + scenario( + 'A writer who saves through the confirming setter knows when the save has finished', + Gherkin.Do.pipe( + Given('a form whose save button waits for the save to finish')('ctx', () => + Effect.sync(() => { + const draft = Atom.fn((n: number) => Effect.succeed(n)) + const { result } = renderHook(() => AtomReact.useAtomSetResult(draft), { + wrapper: withRegistry(Atom.Registry.make()), + }) + return { save: () => result.current } + })), + When('the writer saves a new draft')('saved', (s) => + Effect.gen(function*() { + const confirmed = yield* s.ctx.save()(42) + return confirmed + })), + Then('the save is confirmed with the stored draft')((s, expect) => expect(s.saved).toBe(42)), + ), + ) + + scenario( + 'A page that starts with seeded values shows them right away', + Gherkin.Do.pipe( + Given('a page whose starting values its owner seeds before it renders')('ctx', () => + Effect.sync(() => { + const startingBalance = Atom.make(0) + function Page() { + const balance = AtomReact.useAtomValue(startingBalance) + return createElement('div', { 'data-testid': 'balance' }, balance) + } + render( + createElement( + AtomReact.RegistryProvider, + { registry: ownRegistry([[startingBalance, 7]]) }, + createElement(Page), + ), + ) + return {} + })), + When('the page is shown')('shown', () => Effect.succeed(true)), + Then('the seeded value is already on screen')((_s, expect) => + Effect.promise(() => screen.findByTestId('balance')).pipe( + Effect.map((balance) => expect(balance).toHaveTextContent('7')), + ) + ), + ), + ) + + scenario( + 'A reader who asks for fresh data sees the value recomputed', + Gherkin.Do.pipe( + Given('a widget showing a reading that can be refreshed on demand')('ctx', () => + Effect.sync(() => { + let readings = 0 + const reading = Atom.make(Effect.sync(() => ++readings)) + function Widget() { + const refresh = AtomReact.useAtomRefresh(reading) + const value = AtomReact.useAtomValue(reading, Atom.AsyncResult.getOrThrow) + return createElement( + 'div', + null, + createElement('div', { 'data-testid': 'reading' }, value), + createElement('button', { type: 'button', onClick: refresh }, 'refresh'), + ) + } + render( + createElement( + AtomReact.RegistryContext.Provider, + { value: Atom.Registry.make() }, + createElement(Widget), + ), + ) + return {} + })), + When('the reader asks for fresh data twice')('done', () => + Effect.sync(() => { + pressButton('refresh') + pressButton('refresh') + })), + Then('the widget shows the recomputed reading')((_s, expect) => + Effect.promise(() => findWidgetShowing({ testId: 'reading', text: '3' })).pipe( + Effect.map((reading) => expect(reading).toHaveTextContent('3')), + ) + ), + ), + ) + + scenario( + 'A view of one field of a shared record stays in sync with that field', + Gherkin.Do.pipe( + Given('a shared record with a view onto one of its fields')('ctx', () => + Effect.sync(() => { + const record = Atom.Ref.make({ name: 'ada', age: 36 }) + function View() { + const nameRef = AtomReact.useAtomRefProp(record, 'name') + const name = AtomReact.useAtomRef(nameRef) + return createElement( + 'div', + null, + createElement('div', { 'data-testid': 'name' }, name), + createElement( + 'button', + { type: 'button', onClick: () => nameRef.pipe(Atom.Ref.set('grace')) }, + 'rename', + ), + ) + } + render( + createElement( + AtomReact.RegistryContext.Provider, + { value: Atom.Registry.make() }, + createElement(View), + ), + ) + return { record } + })), + When('the field is edited through the view')('done', () => + Effect.sync(() => { + pressButton('rename') + })), + Then('the view shows the new value and the rest of the record is untouched')((s, expect) => + Effect.promise(() => findWidgetShowing({ testId: 'name', text: 'grace' })).pipe( + Effect.map((name) => + expect({ name: name.textContent, record: Atom.Ref.get(s.ctx.record) }) + .toEqual({ name: 'grace', record: { name: 'grace', age: 36 } }) + ), + ) + ), + ), + ) + + scenario( + 'A summary derived from a shared record shows on screen and follows edits to the record', + Gherkin.Do.pipe( + Given('a shared record and a view of a summary computed from it')('ctx', () => + Effect.sync(() => { + const record = Atom.Ref.make({ name: 'ada', age: 36 }) + const summary = Atom.Ref.map(record, ({ name, age }) => ({ line: `${name}, ${age}` })) + function View() { + const { line } = AtomReact.useAtomRef(summary) + return createElement( + 'div', + null, + createElement('div', { 'data-testid': 'summary' }, line), + createElement( + 'button', + { type: 'button', onClick: () => record.pipe(Atom.Ref.set({ name: 'grace', age: 85 })) }, + 'replace', + ), + ) + } + render( + createElement( + AtomReact.RegistryContext.Provider, + { value: Atom.Registry.make() }, + createElement(View), + ), + ) + return { record } + })), + When('the record is replaced')('done', () => + Effect.sync(() => { + pressButton('replace') + })), + Then('the summary shows the new record')((_s, expect) => + Effect.promise(() => findWidgetShowing({ testId: 'summary', text: 'grace, 85' })).pipe( + Effect.map((summary) => expect(summary.textContent).toBe('grace, 85')), + ) + ), + ), + ) + + scenario( + 'A writer who saves through the exit-reporting setter learns whether the save worked or failed', + Gherkin.Do.pipe( + Given('a form whose save button reports success or failure')('ctx', () => + Effect.sync(() => { + const saveDraft = (n: number): Effect.Effect => { + if (n > 0) { + return Effect.succeed(n) + } + return Effect.fail('rejected') + } + const draft = Atom.fn(saveDraft) + const { result } = renderHook(() => AtomReact.useAtomSetResult(draft), { + wrapper: withRegistry(Atom.Registry.make()), + }) + return { save: () => result.current } + })), + When('the writer saves one acceptable draft and one unacceptable draft')( + 'outcomes', + (s) => + Effect.gen(function*() { + const accepted = yield* s.ctx.save()(5) + const rejected = yield* Effect.flip(s.ctx.save()(-1)) + return { accepted, rejected } + }), + ), + Then('the first save is reported as accepted and the second as rejected')((s, expect) => + expect(s.outcomes).toEqual({ accepted: 5, rejected: 'rejected' }) + ), + ), + ) + + scenario( + 'A reader who transforms what they read sees the transformed value', + Gherkin.Do.pipe( + Given('a widget showing a transformed reading')('ctx', () => + Effect.sync(() => { + const base = Atom.make(7) + function Widget() { + const tripled = AtomReact.useAtomValue(base, (n) => n * 3) + return createElement('div', { 'data-testid': 'tripled' }, tripled) + } + render( + createElement( + AtomReact.RegistryContext.Provider, + { value: Atom.Registry.make() }, + createElement(Widget), + ), + ) + return {} + })), + When('the widget is shown')('shown', () => Effect.succeed(true)), + Then('the transformed value is on screen')((_s, expect) => + Effect.promise(() => screen.findByTestId('tripled')).pipe( + Effect.map((tripled) => expect(tripled).toHaveTextContent('21')), + ) + ), + ), + ) + + scenario( + 'A reader who accepts failures sees the failure instead of the widget crashing', + Gherkin.Do.pipe( + Given('a widget that shows failures instead of crashing')('ctx', () => + Effect.sync(() => { + const failing = Atom.make(Effect.fail<'unavailable'>('unavailable')) + function Widget() { + const result = AtomReact.useAtomSuspense(failing, { includeFailure: true }) + return createElement('div', { 'data-testid': 'outcome' }, result._tag) + } + render( + createElement( + AtomReact.RegistryContext.Provider, + { value: Atom.Registry.make() }, + createElement( + Suspense, + { fallback: createElement('div', { 'data-testid': 'pending' }, 'loading') }, + createElement(Widget), + ), + ), + ) + return {} + })), + When('the widget is shown')('shown', () => Effect.succeed(true)), + Then('the failure is on screen')((_s, expect) => + Effect.promise(() => screen.findByTestId('outcome')).pipe( + Effect.map((outcome) => expect(outcome).toHaveTextContent('Failure')), + ) + ), + ), + ) + }) diff --git a/packages/atom/effect-atom-react/tests/ssr.integration.test.ts b/packages/atom/effect-atom-react/tests/ssr.integration.test.ts new file mode 100644 index 0000000..785c6a8 --- /dev/null +++ b/packages/atom/effect-atom-react/tests/ssr.integration.test.ts @@ -0,0 +1,253 @@ +/** + * Server-side rendering scenarios for the React atom hooks. + * + * These scenarios run in a dedicated node-mode Vitest project + * (`test.projects[].node` in `vitest.config.ts`) because the browser-mode + * suite cannot exercise `useSyncExternalStore`'s server-snapshot path: the + * node project renders components with `renderToString` from + * `react-dom/server`, which calls `getServerSnapshot` for every store read. + * + * @since 4.0.0 + */ +import { Atom } from '@systemfsoftware/effect-atom' +import { AtomReact } from '@systemfsoftware/effect-atom-react' +import { Gherkin, Given, it, makeFeature, Then, When } from '@systemfsoftware/effect-gherkin-spec' +import { vi } from '@systemfsoftware/vitest' +import * as Effect from 'effect/Effect' +import * as Fiber from 'effect/Fiber' +import * as Latch from 'effect/Latch' +import * as Layer from 'effect/Layer' +import * as Schema from 'effect/Schema' +import { createElement } from 'react' +import { renderToString } from 'react-dom/server' + +const Feature = makeFeature({ it }) + +Feature('Server-side rendering of React atom hooks') + .withLayer(Layer.empty) + .body(({ scenario }) => { + scenario( + 'Atoms run during SSR when no server snapshot is given', + Gherkin.Do.pipe( + Given('an atom whose read runs the atom function during SSR')('ctx', () => + Effect.sync(() => { + let reads = 0 + const counterAtom = Atom.make(() => { + reads += 1 + return 0 + }) + + function TestComponent() { + const count = AtomReact.useAtomValue(counterAtom) + return createElement('div', null, count) + } + + const ssrHtml = renderToString( + createElement( + AtomReact.RegistryContext.Provider, + { value: Atom.Registry.make() }, + createElement(TestComponent), + ), + ) + return { reads: () => reads, ssrHtml } + })), + When('the rendered markup is inspected')('result', (s) => Effect.sync(() => s.ctx)), + Then('the atom read function was called and the value is in the markup')((s, expect) => + expect({ html: s.result.ssrHtml, reads: s.result.reads() }).toEqual({ + html: '
0
', + reads: 1, + }) + ), + ), + ) + + scenario( + 'Atoms with a server snapshot skip their effects during SSR', + Gherkin.Do.pipe( + Given('an atom with a configured server snapshot')('ctx', () => + Effect.sync(() => { + const mockFetchData = vi.fn<() => number>(() => 0) + const userDataAtom = Atom.make(Effect.sync(() => mockFetchData())).pipe(Atom.withServerValueInitial) + const registry = Atom.Registry.make() + + function TestComponent() { + const result = AtomReact.useAtomValue(userDataAtom) + return createElement( + 'div', + null, + Atom.AsyncResult.match(result, { + onInitial: () => 'Initial', + onSuccess: () => 'Success', + onFailure: () => 'Failure', + }), + ) + } + + const ssrHtml = renderToString( + createElement( + AtomReact.RegistryContext.Provider, + { value: registry }, + createElement(TestComponent), + ), + ) + return { mockFetchData, registry, ssrHtml, userDataAtom } + })), + Then('the server markup shows the atom has not run its effect yet')((s, expect) => + expect({ calls: s.ctx.mockFetchData.mock.calls, html: s.ctx.ssrHtml }).toMatchObject({ + calls: [], + html: '
Initial
', + }) + ), + When('the server markup is observed and the client reads the atom')('result', (s) => + Effect.sync(() => { + const clientValue = Atom.Registry.get(s.ctx.registry, s.ctx.userDataAtom) + return { clientValue } + })), + Then('the client read ran the effect and settled the atom')((s, expect) => + expect({ calls: s.ctx.mockFetchData.mock.calls, clientValue: s.result.clientValue }).toMatchObject({ + calls: [[]], + clientValue: { _tag: 'Success', value: 0 }, + }) + ), + ), + ) + + scenario( + 'A request registry restored from dehydrated server state renders the saved values', + Gherkin.Do.pipe( + Given('dehydrated state for serializable atoms, restored into a registry made for this request')( + 'ctx', + () => + Effect.sync(() => { + const atomBasic = Atom.make(0).pipe(Atom.serializable({ key: 'owned-basic', schema: Schema.Finite })) + const makeAtomResult = (key: string, effect: Effect.Effect) => + Atom.make(effect).pipe( + Atom.serializable({ + key, + schema: Atom.AsyncResult.Schema({ success: Schema.Finite, error: Schema.String }), + }), + ) + const atomResult1 = makeAtomResult('owned-success', Effect.succeed(123)) + const atomResult2 = makeAtomResult('owned-errored', Effect.fail('error')) + const atomResult3 = makeAtomResult('owned-pending', Effect.never) + + const serverRegistry = Atom.Registry.make() + Atom.Registry.set(serverRegistry, atomBasic, 1) + Atom.Registry.subscribe(serverRegistry, atomResult1, () => {}, { immediate: true }) + Atom.Registry.subscribe(serverRegistry, atomResult2, () => {}, { immediate: true }) + Atom.Registry.subscribe(serverRegistry, atomResult3, () => {}, { immediate: true }) + const dehydratedState = Atom.Hydration.dehydrate(serverRegistry, { encodeInitialAs: 'value-only' }) + + const showResult = (testId: number, atom: typeof atomResult1) => + function Result() { + return Atom.AsyncResult.match(AtomReact.useAtomValue(atom), { + onSuccess: (success) => createElement('div', { 'data-testid': `value-${testId}` }, success.value), + onFailure: () => createElement('div', { 'data-testid': `error-${testId}` }, 'Error'), + onInitial: () => createElement('div', { 'data-testid': `loading-${testId}` }, 'Loading...'), + }) + } + function Basic() { + return createElement('div', { 'data-testid': 'value' }, AtomReact.useAtomValue(atomBasic)) + } + + const requestRegistry = Atom.Registry.make() + Atom.Hydration.hydrate(requestRegistry, dehydratedState) + const ssrHtml = renderToString( + createElement( + AtomReact.RegistryProvider, + { registry: requestRegistry }, + createElement(Basic), + createElement(showResult(1, atomResult1)), + createElement(showResult(2, atomResult2)), + createElement(showResult(3, atomResult3)), + ), + ) + Atom.Registry.dispose(requestRegistry) + Atom.Registry.dispose(serverRegistry) + return { ssrHtml } + }), + ), + When('the rendered markup is inspected')('html', (s) => Effect.succeed(s.ctx.ssrHtml)), + Then('the restored values are present in the markup')((s, expect) => + expect(s.html).toEqual( + '
1
' + + '
123
' + + '
Error
' + + '
Loading...
', + ) + ), + ), + ) + + scenario( + 'A request registry completes a deferred entry when the server value settles', + Gherkin.Do.pipe( + Given('a pending serializable atom dehydrated as deferred, restored into a registry made for this request')( + 'ctx', + () => + Effect.sync(() => { + const latch = Latch.makeUnsafe() + const counters = { start: 0, stop: 0 } + const atom = Atom.make( + Effect.gen(function*() { + counters.start += 1 + yield* latch.await + counters.stop += 1 + return 1 + }), + ).pipe( + Atom.serializable({ + key: 'owned-deferred', + schema: Atom.AsyncResult.Schema({ success: Schema.Finite }), + }), + ) + + const serverRegistry = Atom.Registry.make() + Atom.Registry.subscribe(serverRegistry, atom, () => {}, { immediate: true }) + const before = { ...counters } + const dehydratedState = Atom.Hydration.dehydrate(serverRegistry, { encodeInitialAs: 'deferred' }) + + function TestComponent() { + return createElement( + 'div', + null, + Atom.AsyncResult.match(AtomReact.useAtomValue(atom), { + onInitial: () => 'Initial', + onSuccess: () => 'Success', + onFailure: () => 'Failure', + }), + ) + } + + const requestRegistry = Atom.Registry.make() + const applied = Atom.Hydration.hydrate(requestRegistry, dehydratedState) + const ssrHtml = renderToString( + createElement(AtomReact.RegistryProvider, { registry: requestRegistry }, createElement(TestComponent)), + ) + return { applied, atom, before, counters, latch, requestRegistry, ssrHtml } + }), + ), + When('the server-side value settles and the deferred entry completes')( + 'settled', + (s) => + Effect.gen(function*() { + s.ctx.latch.openUnsafe() + yield* Fiber.join(s.ctx.applied) + return Atom.AsyncResult.getOrThrow(Atom.Registry.get(s.ctx.requestRegistry, s.ctx.atom)) + }), + ), + Then('the markup showed the pending entry and the request registry now holds the settled value once')(( + s, + expect, + ) => + expect({ before: s.ctx.before, counters: s.ctx.counters, html: s.ctx.ssrHtml, settled: s.settled }) + .toEqual({ + before: { start: 1, stop: 0 }, + counters: { start: 1, stop: 1 }, + html: '
Initial
', + settled: 1, + }) + ), + ), + ) + }) diff --git a/packages/atom/effect-atom-react/tests/suspense-thenable.integration.test.ts b/packages/atom/effect-atom-react/tests/suspense-thenable.integration.test.ts new file mode 100644 index 0000000..aa27b4f --- /dev/null +++ b/packages/atom/effect-atom-react/tests/suspense-thenable.integration.test.ts @@ -0,0 +1,157 @@ +import { Atom } from '@systemfsoftware/effect-atom' +import { AtomReact } from '@systemfsoftware/effect-atom-react' +import { Gherkin, Given, it, makeFeature, Then, When } from '@systemfsoftware/effect-gherkin-spec' +import { act, render, screen } from '@testing-library/react' +import * as Deferred from 'effect/Deferred' +import * as Effect from 'effect/Effect' +import * as Layer from 'effect/Layer' +import { createElement, Suspense } from 'react' +import { ErrorBoundary } from 'react-error-boundary' +import { ownRegistry, renderCleanupLayer } from './__fixtures__/render-cleanup.js' +import { renderSuspending } from './__fixtures__/render-suspending.js' + +const Feature = makeFeature({ it }) + +function waitingNote() { + return createElement('div', { 'data-testid': 'waiting-note' }, 'still waiting') +} + +Feature('Suspending a screen until its value is ready') + .live('renders a real suspending screen in Chromium and waits on React commits') + .withLayer(Layer.empty) + .withScenarioLayer(renderCleanupLayer) + .body(({ scenario }) => { + scenario( + 'A screen that waits for a value shows a note while waiting and the value once it arrives', + Gherkin.Do.pipe( + Given('a value delivered from outside and a screen that waits for it behind a waiting note')( + 'ctx', + () => + Effect.suspend(() => { + const source = Deferred.makeUnsafe() + const loaded = Atom.make(source.pipe(Deferred.await)) + function Screen() { + const result = AtomReact.useAtomSuspense(loaded, { suspendOnWaiting: true }) + return createElement( + 'div', + { 'data-testid': 'arrived-value' }, + Atom.AsyncResult.getOrThrow(result), + ) + } + return renderSuspending( + createElement( + AtomReact.RegistryProvider, + { registry: ownRegistry() }, + createElement(Suspense, { fallback: waitingNote() }, createElement(Screen)), + ), + ).pipe(Effect.as({ source })) + }), + ), + When('the value is delivered')( + 'seen', + (s) => + Effect.sync(() => ({ noteWhileWaiting: screen.queryByTestId('waiting-note')?.textContent ?? null })).pipe( + Effect.tap(() => + Effect.promise(() => Promise.resolve(act(() => Effect.runPromise(Deferred.succeed(s.ctx.source, 9))))) + ), + ), + ), + Then('the note was shown while waiting, and now the value is on screen and the note is gone')((s, expect) => + Effect.promise(() => screen.findByTestId('arrived-value')).pipe( + Effect.map((arrived) => + expect({ + arrived: arrived.textContent, + noteNow: screen.queryByTestId('waiting-note'), + noteWhileWaiting: s.seen.noteWhileWaiting, + }).toEqual({ arrived: '9', noteNow: null, noteWhileWaiting: 'still waiting' }) + ), + ) + ), + ), + ) + + scenario( + 'Two screens waiting on the same value both appear after it is delivered once', + Gherkin.Do.pipe( + Given('one value delivered from outside with two screens waiting on it')('ctx', () => + Effect.suspend(() => { + const source = Deferred.makeUnsafe() + const loaded = Atom.make(source.pipe(Deferred.await)) + function Screen({ id }: { readonly id: string }) { + const result = AtomReact.useAtomSuspense(loaded, { suspendOnWaiting: true }) + return createElement('div', { 'data-testid': `${id}-value` }, Atom.AsyncResult.getOrThrow(result)) + } + return renderSuspending( + createElement( + AtomReact.RegistryProvider, + { registry: ownRegistry() }, + createElement( + Suspense, + { fallback: waitingNote() }, + createElement(Screen, { id: 'left' }), + createElement(Screen, { id: 'right' }), + ), + ), + ).pipe(Effect.as({ source })) + })), + When('the value is delivered once')('done', (s) => + Effect.promise(() => { + const next = act(() => Effect.runPromise(Deferred.succeed(s.ctx.source, 4))) + return Promise.resolve(next) + })), + Then('both screens show the delivered value and the note is gone')((_s, expect) => + Effect.promise(() => Promise.all([screen.findByTestId('left-value'), screen.findByTestId('right-value')])) + .pipe( + Effect.map(([left, right]) => + expect({ + left: left.textContent, + note: screen.queryByTestId('waiting-note'), + right: right.textContent, + }).toEqual({ left: '4', note: null, right: '4' }) + ), + ) + ), + ), + ) + + scenario( + 'A value that goes wrong shows the nearest notice instead of the screen', + Gherkin.Do.pipe( + Given('a screen waiting on a value that goes wrong, above a notice')('ctx', () => + Effect.sync(() => { + const broken = Atom.make(Effect.fail('unavailable')) + function Screen() { + AtomReact.useAtomSuspense(broken) + return createElement('div', { 'data-testid': 'never-screen' }, 'unexpected') + } + render( + createElement( + AtomReact.RegistryProvider, + { registry: ownRegistry() }, + createElement( + ErrorBoundary, + { fallback: createElement('div', { 'data-testid': 'problem-note' }, 'could not load') }, + createElement( + Suspense, + { fallback: waitingNote() }, + createElement(Screen), + ), + ), + ), + ) + return {} + })), + When('the broken value settles')('shown', () => Effect.succeed(true)), + Then('the notice is on screen and the screen is not rendered')((_s, expect) => + Effect.promise(() => screen.findByTestId('problem-note')).pipe( + Effect.map((notice) => + expect({ + neverScreen: screen.queryByTestId('never-screen'), + notice: notice.textContent, + }).toEqual({ neverScreen: null, notice: 'could not load' }) + ), + ) + ), + ), + ) + }) diff --git a/packages/atom/effect-atom-react/tests/waiting-for-async-values.integration.test.ts b/packages/atom/effect-atom-react/tests/waiting-for-async-values.integration.test.ts new file mode 100644 index 0000000..25e00b6 --- /dev/null +++ b/packages/atom/effect-atom-react/tests/waiting-for-async-values.integration.test.ts @@ -0,0 +1,189 @@ +import { Atom } from '@systemfsoftware/effect-atom' +import { Gherkin, Given, it, makeFeature, Then, When } from '@systemfsoftware/effect-gherkin-spec' +import { act, render, screen, waitFor } from '@testing-library/react' +import '@vitest/browser/matchers' +import { AtomReact } from '@systemfsoftware/effect-atom-react' +import * as Deferred from 'effect/Deferred' +import * as Effect from 'effect/Effect' +import * as Layer from 'effect/Layer' +import { createElement, Suspense } from 'react' +import { ErrorBoundary } from 'react-error-boundary' +import { findWidgetShowing } from './__fixtures__/find-widget.js' +import { pressButton } from './__fixtures__/registry-wrapper.js' +import { renderCleanupLayer } from './__fixtures__/render-cleanup.js' +import { renderSuspending } from './__fixtures__/render-suspending.js' +import { Unavailable } from './__fixtures__/Unavailable.fixture.js' + +const Feature = makeFeature({ it }) + +Feature('Waiting for asynchronous values') + .live('renders real components in Chromium and waits on React commits in the browser') + .withLayer(Layer.empty) + .withScenarioLayer(renderCleanupLayer) + .body(({ scenario }) => { + scenario( + 'A reader who waits through loading sees the value once it arrives', + Gherkin.Do.pipe( + Given('a value that arrives once it is delivered and a widget that waits through loading')( + 'ctx', + () => + Effect.suspend(() => { + const source = Deferred.makeUnsafe() + const loaded = Atom.make(source.pipe(Deferred.await)) + function Widget() { + const result = AtomReact.useAtomSuspense(loaded, { suspendOnWaiting: true }) + return createElement( + 'div', + { 'data-testid': 'loaded-value' }, + Atom.AsyncResult.getOrThrow(result), + ) + } + return renderSuspending( + createElement( + AtomReact.RegistryContext.Provider, + { value: Atom.Registry.make() }, + createElement( + Suspense, + { fallback: createElement('div', { 'data-testid': 'loading' }, 'loading') }, + createElement(Widget), + ), + ), + ).pipe(Effect.as({ source })) + }), + ), + When('the value is delivered to the widget')( + 'settled', + (s) => + Effect.promise(() => { + const next = act(() => Effect.runPromise(Deferred.succeed(s.ctx.source, 5))) + return Promise.resolve(next) + }), + ), + Then('the loaded value is on screen')((_s, expect) => + Effect.promise(() => findWidgetShowing({ testId: 'loaded-value', text: '5' })).pipe( + Effect.map((widget) => expect(widget).toHaveTextContent('5')), + ) + ), + ), + ) + + scenario( + 'A reader who waits through a refresh sees the refreshed value', + Gherkin.Do.pipe( + Given('a value that reloads on demand and a widget that waits through loading')( + 'ctx', + () => + Effect.suspend(() => { + const pending: Deferred.Deferred[] = [] + const loaded = Atom.make( + Effect.gen(function*() { + const current = Deferred.makeUnsafe() + pending.push(current) + return yield* Deferred.await(current) + }), + ) + function Widget() { + const refresh = AtomReact.useAtomRefresh(loaded) + const result = AtomReact.useAtomSuspense(loaded, { suspendOnWaiting: true }) + return createElement( + 'div', + null, + createElement('div', { 'data-testid': 'refreshed-value' }, Atom.AsyncResult.getOrThrow(result)), + createElement('button', { type: 'button', onClick: refresh }, 'refresh'), + ) + } + return renderSuspending( + createElement( + AtomReact.RegistryContext.Provider, + { value: Atom.Registry.make() }, + createElement( + Suspense, + { fallback: createElement('div', { 'data-testid': 'refreshing' }, 'loading') }, + createElement(Widget), + ), + ), + ).pipe(Effect.as({ pending })) + }), + ), + When('the first value arrives, the reader asks for a refresh, and the newer value arrives')( + 'settled', + (s) => + Effect.promise(() => + Promise.resolve() + .then(() => { + const [firstPending] = s.ctx.pending + if (firstPending === undefined) { + throw new Error('expected a pending deferred') + } + return act(() => Effect.runPromise(Deferred.succeed(firstPending, 1))) + }) + .then(() => findWidgetShowing({ testId: 'refreshed-value', text: '1' })) + .then(() => pressButton('refresh')) + .then(() => + waitFor(() => { + if (s.ctx.pending.length !== 2) { + throw new Error(`the widget has asked for ${s.ctx.pending.length} values, not 2`) + } + }) + ) + .then(() => { + const secondPending = s.ctx.pending[1] + if (secondPending === undefined) { + throw new Error('expected a second pending deferred') + } + return act(() => Effect.runPromise(Deferred.succeed(secondPending, 2))) + }) + .then(() => findWidgetShowing({ testId: 'refreshed-value', text: '2' })) + .then(() => ({ pendingReads: s.ctx.pending.length })) + ), + ), + Then('the widget shows the refreshed value')((s, expect) => + expect({ + pendingReads: s.settled.pendingReads, + refreshed: screen.getByTestId('refreshed-value').textContent, + }).toEqual({ pendingReads: 2, refreshed: '2' }) + ), + ), + ) + + scenario( + 'A reader who does not accept failures sees the error message instead of the widget', + Gherkin.Do.pipe( + Given('a widget backed by a value that fails, wrapped in an error boundary')('ctx', () => + Effect.sync(() => { + const failing = Atom.make(Unavailable.make({}).pipe(Effect.fail)) + function Widget() { + AtomReact.useAtomSuspense(failing) + return createElement('div', { 'data-testid': 'unexpected-widget' }, 'unexpected') + } + render( + createElement( + AtomReact.RegistryContext.Provider, + { value: Atom.Registry.make() }, + createElement( + ErrorBoundary, + { fallback: createElement('div', { 'data-testid': 'failure-message' }, 'failed to load') }, + createElement( + Suspense, + { fallback: createElement('div', { 'data-testid': 'waiting' }, 'loading') }, + createElement(Widget), + ), + ), + ), + ) + return {} + })), + When('the widget is shown')('shown', () => Effect.succeed(true)), + Then('the error boundary shows the failure message and the widget is not rendered')((_s, expect) => + Effect.promise(() => screen.findByTestId('failure-message')).pipe( + Effect.map((message) => + expect({ + message: message.textContent, + unexpectedWidget: screen.queryByTestId('unexpected-widget'), + }).toEqual({ message: 'failed to load', unexpectedWidget: null }) + ), + ) + ), + ), + ) + }) diff --git a/packages/atom/effect-atom-react/tests/widgets-independent-scopes.integration.test.ts b/packages/atom/effect-atom-react/tests/widgets-independent-scopes.integration.test.ts new file mode 100644 index 0000000..0dd6a0f --- /dev/null +++ b/packages/atom/effect-atom-react/tests/widgets-independent-scopes.integration.test.ts @@ -0,0 +1,87 @@ +import { Atom } from '@systemfsoftware/effect-atom' +import { AtomReact } from '@systemfsoftware/effect-atom-react' +import { Gherkin, Given, it, makeFeature, Then, When } from '@systemfsoftware/effect-gherkin-spec' +import { vi } from '@systemfsoftware/vitest' +import { render, screen } from '@testing-library/react' +import * as Effect from 'effect/Effect' +import * as Layer from 'effect/Layer' +import { createElement, Suspense } from 'react' +import { renderCleanupLayer } from './__fixtures__/render-cleanup.js' + +const Feature = makeFeature({ it }) + +Feature('Keeping two on-screen widgets showing values from separate data sources independent of each other') + .live('renders real components in Chromium and advances the browser timer queue') + .withLayer(Layer.empty) + .withScenarioLayer(renderCleanupLayer) + .body(({ scenario }) => { + scenario( + "A widget still loading is not affected when a different widget's cleanup timer runs", + Gherkin.Do.pipe( + Given( + 'two independent widgets, each backed by a value that never finishes loading, with a short cleanup timer', + )( + 'ctx', + () => + Effect.sync(() => { + vi.useFakeTimers() + const atom = Atom.make(Effect.never) + const first = Atom.Registry.make({ defaultIdleTTL: 5 }) + const second = Atom.Registry.make({ defaultIdleTTL: 5 }) + return { atom, first, second } + }), + ), + When('both widgets are shown, and time passes long enough for cleanup to run')( + 'state', + (s) => + Effect.sync(() => { + function Comp({ id }: { readonly id: string }) { + const result = AtomReact.useAtomSuspense(s.ctx.atom) + let value = 0 + if (Atom.AsyncResult.isSuccess(result)) { + value = result.value + } + return createElement('div', { 'data-testid': `${id}-value` }, value) + } + + render( + createElement( + AtomReact.RegistryContext.Provider, + { value: s.ctx.first }, + createElement( + Suspense, + { fallback: createElement('div', { 'data-testid': 'first-loading' }, 'L1') }, + createElement(Comp, { id: 'first' }), + ), + ), + ) + render( + createElement( + AtomReact.RegistryContext.Provider, + { value: s.ctx.second }, + createElement( + Suspense, + { fallback: createElement('div', { 'data-testid': 'second-loading' }, 'L2') }, + createElement(Comp, { id: 'second' }), + ), + ), + ) + + vi.advanceTimersByTime(100) + + const first = screen.queryByTestId('first-value')?.textContent ?? null + const second = screen.queryByTestId('second-value')?.textContent ?? null + + vi.useRealTimers() + return { first, second } + }), + ), + Then('the widgets do not both flip to the same state together')((s, expect) => + expect(s.state).toSatisfy( + (state) => state.first === null || state.second === null, + 'at least one widget is still waiting for its value, so one cleanup did not settle both', + ) + ), + ), + ) + }) diff --git a/packages/atom/effect-atom-react/tests/window-focus-and-search-params.integration.test.ts b/packages/atom/effect-atom-react/tests/window-focus-and-search-params.integration.test.ts new file mode 100644 index 0000000..996c0f6 --- /dev/null +++ b/packages/atom/effect-atom-react/tests/window-focus-and-search-params.integration.test.ts @@ -0,0 +1,245 @@ +/** + * Browser scenarios for the two page-owned event sources on-screen values can + * follow: whether the page is in view, and what the page's web address says. + * + * These run in the browser Vitest project, so the events are real dispatches on + * the real document and address. Every outcome is read off the widgets on the + * page; no event source is replaced. + * + * @since 4.0.0 + */ +import { Atom } from '@systemfsoftware/effect-atom' +import { AtomReact } from '@systemfsoftware/effect-atom-react' +import { Gherkin, Given, it, makeFeature, Then, When } from '@systemfsoftware/effect-gherkin-spec' +import { vi } from '@systemfsoftware/vitest' +import { act, render, screen } from '@testing-library/react' +import * as Effect from 'effect/Effect' +import * as Layer from 'effect/Layer' +import * as Option from 'effect/Option' +import * as Schema from 'effect/Schema' +import { createElement } from 'react' +import { renderCleanupLayer } from './__fixtures__/render-cleanup.js' + +const Feature = makeFeature({ it }) + +let pageIsInView: DocumentVisibilityState = document.visibilityState + +Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: () => pageIsInView, +}) + +const showThePage = (): void => { + pageIsInView = 'visible' +} + +const announceThePageCameIntoView = (): void => { + window.dispatchEvent(new Event('visibilitychange')) +} + +const announceTheAddressChanged = (): void => { + window.dispatchEvent(new Event('popstate')) +} + +const visit = (search: string): void => { + window.history.replaceState({}, '', `${window.location.pathname}${search}`) +} + +const readLog = () => { + let reads = 0 + return { + record: (): void => { + reads += 1 + }, + seen: (): number => reads, + counted: (): number => { + reads += 1 + return reads + }, + } +} + +const waitUntilRegistryEmpties = (registry: Atom.Registry.Registry): Promise => + vi.waitFor(() => { + const held = Atom.Registry.getNodes(registry).size + if (held !== 0) { + throw new Error(`the registry still holds ${held} values`) + } + }) + +function FocusFollowingValue({ atom }: { readonly atom: Atom.Atom }) { + return createElement('span', { 'data-testid': 'focus-following-value' }, AtomReact.useAtomValue(atom)) +} + +function AddressFollowingValue({ atom }: { readonly atom: Atom.Atom> }) { + const pageNumber = AtomReact.useAtomValue(atom) + return createElement( + 'span', + { 'data-testid': 'address-following-value' }, + Option.getOrElse(pageNumber, () => 'no page chosen'), + ) +} + +function AddressTracker({ atom }: { readonly atom: Atom.Atom }) { + return createElement('span', { 'data-testid': 'address-value' }, AtomReact.useAtomValue(atom)) +} + +Feature('Keeping on-screen values in step with the browser page') + .live('renders real components in Chromium and reacts to page focus and search params') + .withLayer(Layer.empty) + .withScenarioLayer(renderCleanupLayer) + .body(({ scenario, scenarioOutline }) => { + scenarioOutline( + 'A value that follows page focus when the page turns ', + [ + { outcome: 'is read again', readAgain: true, state: 'visible', toward: 'into view' }, + { outcome: 'keeps its last value', readAgain: false, state: 'hidden', toward: 'out of view' }, + ] as const, + (row) => + Gherkin.Do.pipe( + Given('a widget showing a value that follows page focus, after the page has come back into view once')( + 'ctx', + () => + Effect.sync(() => { + const reads = readLog() + const followed = Atom.refreshOnWindowFocus(Atom.make(reads.counted)) + const registry = Atom.Registry.make() + render( + createElement( + AtomReact.RegistryContext.Provider, + { value: registry }, + createElement(FocusFollowingValue, { atom: followed }), + ), + ) + showThePage() + act(() => { + announceThePageCameIntoView() + }) + return { focusedValue: screen.getByTestId('focus-following-value') } + }), + ), + When(`the page turns ${row.toward}`)('seen', (s) => + Effect.sync(() => { + const before = s.ctx.focusedValue.textContent + pageIsInView = row.state + act(() => { + announceThePageCameIntoView() + }) + return { after: s.ctx.focusedValue.textContent, before } + })), + Then(`the widget ${row.outcome}`)((s, expect) => + expect(s.seen).toSatisfy( + (seen) => (seen.after !== seen.before) === row.readAgain, + row.readAgain + ? 'the page turned into view, so the widget read its value again' + : 'the page turned out of view, so the widget kept its last value', + ) + ), + ), + ) + + scenarioOutline( + 'A web address naming shows on the page', + [ + { named: 'page 42', search: '?page=42', shown: '42' }, + { named: 'a page number that is not a number', search: '?page=not-a-number', shown: 'no page chosen' }, + ] as const, + (row) => + Gherkin.Do.pipe( + Given(`a page whose address names ${row.named}`)('ctx', () => + Effect.sync(() => { + visit(row.search) + const pageNumber = Atom.searchParam('page', { schema: Schema.FiniteFromString }) + return { pageNumber, registry: Atom.Registry.make() } + })), + When('the widget that reads the page number from the address appears')('shown', (s) => + Effect.sync(() => { + render( + createElement( + AtomReact.RegistryContext.Provider, + { value: s.ctx.registry }, + createElement(AddressFollowingValue, { atom: s.ctx.pageNumber }), + ), + ) + return { page: screen.getByTestId('address-following-value').textContent } + })), + Then(`the page shows ${row.shown}`)((s, expect) => expect(s.shown.page).toBe(row.shown)), + ), + ) + + scenario( + 'A widget that leaves the page stops following focus and address changes', + Gherkin.Do.pipe( + Given( + 'a page showing a widget that follows page focus and another that follows the page number in the address', + )( + 'ctx', + () => + Effect.sync(() => { + const focusReads = readLog() + const addressReads = readLog() + const followed = Atom.refreshOnWindowFocus(Atom.make(focusReads.counted)) + const chosenPage = Atom.searchParam('page') + const trackedPage = Atom.make((get) => { + addressReads.record() + return get(chosenPage) + }) + const registry = Atom.Registry.make() + visit('?page=7') + const view = render( + createElement( + AtomReact.RegistryContext.Provider, + { value: registry }, + createElement(FocusFollowingValue, { atom: followed }), + createElement(AddressTracker, { atom: trackedPage }), + ), + ) + showThePage() + act(() => { + announceThePageCameIntoView() + }) + visit('?page=8') + act(() => { + announceTheAddressChanged() + }) + return { addressReads, focusReads, registry, view } + }), + ), + When('both widgets leave the page, and the page turns into view again and names a different page number')( + 'observed', + (s) => + Effect.gen(function*() { + const attending = { address: s.ctx.addressReads.seen(), focus: s.ctx.focusReads.seen() } + act(() => { + s.ctx.view.unmount() + }) + yield* Effect.promise(() => waitUntilRegistryEmpties(s.ctx.registry)) + showThePage() + act(() => { + announceThePageCameIntoView() + }) + visit('?page=9') + act(() => { + announceTheAddressChanged() + }) + return { + afterLeaving: { address: s.ctx.addressReads.seen(), focus: s.ctx.focusReads.seen() }, + attending, + } + }), + ), + Then('neither widget read its value again')((s, expect) => + expect({ afterLeaving: s.observed.afterLeaving, attending: s.observed.attending }).toMatchObject({ + afterLeaving: { + address: s.observed.attending.address, + focus: s.observed.attending.focus, + }, + attending: { + address: expect.schemaMatching(Schema.Int.pipe(Schema.check(Schema.isGreaterThan(0)))), + focus: expect.schemaMatching(Schema.Int.pipe(Schema.check(Schema.isGreaterThan(0)))), + }, + }) + ), + ), + ) + }) diff --git a/packages/atom/effect-atom-react/tsconfig.api.json b/packages/atom/effect-atom-react/tsconfig.api.json new file mode 100644 index 0000000..6942393 --- /dev/null +++ b/packages/atom/effect-atom-react/tsconfig.api.json @@ -0,0 +1,7 @@ +{ + "extends": "@systemfsoftware/tsconfig/tsc/dom/library-monorepo", + "include": ["dist/mod.d.ts"], + "compilerOptions": { + "customConditions": [] + } +} diff --git a/packages/atom/effect-atom-react/tsconfig.app.json b/packages/atom/effect-atom-react/tsconfig.app.json new file mode 100644 index 0000000..7d00955 --- /dev/null +++ b/packages/atom/effect-atom-react/tsconfig.app.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": [ + "@systemfsoftware/tsconfig/tsc/dom/library-monorepo", + "@systemfsoftware/tsconfig/effect" + ], + "compilerOptions": { + "composite": true, + "tsBuildInfoFile": "node_modules/.cache/tsconfig.app.tsbuildinfo", + "jsx": "react-jsx", + "types": [], + "customConditions": [ + "@systemfsoftware/source" + ], + "emitDeclarationOnly": true, + "outDir": "node_modules/.cache/tsc/app" + }, + "include": ["src"] +} diff --git a/packages/atom/effect-atom-react/tsconfig.build.json b/packages/atom/effect-atom-react/tsconfig.build.json new file mode 100644 index 0000000..38f293d --- /dev/null +++ b/packages/atom/effect-atom-react/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.app.json", + "compilerOptions": { + "declarationMap": false, + "sourceMap": false, + "stripInternal": true + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts", "src/**/*.spec.ts"] +} diff --git a/packages/atom/effect-atom-react/tsconfig.dts.json b/packages/atom/effect-atom-react/tsconfig.dts.json new file mode 100644 index 0000000..760bc07 --- /dev/null +++ b/packages/atom/effect-atom-react/tsconfig.dts.json @@ -0,0 +1,5 @@ +{ + "extends": "@systemfsoftware/tsconfig/tsc/dom/library-monorepo", + "compilerOptions": { "skipLibCheck": false, "types": ["node"] }, + "include": ["dist/*.d.ts"] +} diff --git a/packages/atom/effect-atom-react/tsconfig.json b/packages/atom/effect-atom-react/tsconfig.json new file mode 100644 index 0000000..38412bc --- /dev/null +++ b/packages/atom/effect-atom-react/tsconfig.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "files": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.test.json" + }, + { + "path": "./tsconfig.tstyche.json" + }, + { + "path": "./tsconfig.node.json" + } + ] +} diff --git a/packages/atom/effect-atom-react/tsconfig.node.json b/packages/atom/effect-atom-react/tsconfig.node.json new file mode 100644 index 0000000..cc4af84 --- /dev/null +++ b/packages/atom/effect-atom-react/tsconfig.node.json @@ -0,0 +1,15 @@ +{ + "extends": "@systemfsoftware/tsconfig/node", + "compilerOptions": { + "composite": true, + "noEmit": true, + "tsBuildInfoFile": "node_modules/.cache/tsconfig.node.tsbuildinfo" + }, + "include": [ + "oxlint.config.ts", + "stryker.config.ts", + "tsdown.config.ts", + "vitest.config.ts", + "vitest.node.config.ts" + ] +} diff --git a/packages/atom/effect-atom-react/tsconfig.test.json b/packages/atom/effect-atom-react/tsconfig.test.json new file mode 100644 index 0000000..99370ac --- /dev/null +++ b/packages/atom/effect-atom-react/tsconfig.test.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": [ + "@systemfsoftware/tsconfig/tsc/dom/library-monorepo", + "@systemfsoftware/tsconfig/effect/entrypoint" + ], + "compilerOptions": { + "composite": true, + "tsBuildInfoFile": "node_modules/.cache/tsconfig.test.tsbuildinfo", + "jsx": "react-jsx", + "customConditions": [ + "@systemfsoftware/source" + ], + "emitDeclarationOnly": true, + "outDir": "node_modules/.cache/tsc/test" + }, + "include": ["tests"], + "references": [ + { + "path": "./tsconfig.app.json" + } + ] +} diff --git a/packages/atom/effect-atom-react/tsconfig.tstyche.json b/packages/atom/effect-atom-react/tsconfig.tstyche.json new file mode 100644 index 0000000..3ddbbfd --- /dev/null +++ b/packages/atom/effect-atom-react/tsconfig.tstyche.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": [ + "@systemfsoftware/tsconfig/tsc/dom/library-monorepo", + "@systemfsoftware/tsconfig/effect" + ], + "compilerOptions": { + "composite": true, + "noEmit": true, + "tsBuildInfoFile": "node_modules/.cache/tsconfig.tstyche.tsbuildinfo", + "jsx": "react-jsx", + "customConditions": [ + "@systemfsoftware/source" + ] + }, + "include": [ + "test-types" + ], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.test.json" + } + ] +} diff --git a/packages/atom/effect-atom-react/tsdown.config.ts b/packages/atom/effect-atom-react/tsdown.config.ts new file mode 100644 index 0000000..5ccbdc7 --- /dev/null +++ b/packages/atom/effect-atom-react/tsdown.config.ts @@ -0,0 +1,39 @@ +import { quietBuild } from '@systemfsoftware/tsdown-config/quiet-build' +import { defineConfig } from 'tsdown' + +type ExportEntry = string | Record + +const typesMap: Record = { + '.': './dist/mod.d.ts', +} + +const injectTypes = (exports: Record): Record => { + for (const [subpath, types] of Object.entries(typesMap)) { + const entry = exports[subpath] + if (typeof entry === 'string') { + exports[subpath] = { types, default: entry } + } else if (typeof entry === 'object' && Boolean(entry)) { + const { default: defaultEntry, types: _existingTypes, ...rest } = entry + let withDefault: Record = {} + if (typeof defaultEntry === 'string') { + withDefault = { default: defaultEntry } + } + exports[subpath] = { ...rest, types, ...withDefault } + } + } + return exports +} + +export default defineConfig({ + ...quietBuild, + entry: { mod: './src/mod.ts' }, + format: 'esm', + dts: true, + exports: { + devExports: '@systemfsoftware/source', + customExports: injectTypes, + }, + outExtensions: () => ({ js: '.mjs', dts: '.d.ts' }), + deps: { onlyBundle: false }, + tsconfig: './tsconfig.build.json', +}) diff --git a/packages/atom/effect-atom-react/tstyche.json b/packages/atom/effect-atom-react/tstyche.json new file mode 100644 index 0000000..54cdbda --- /dev/null +++ b/packages/atom/effect-atom-react/tstyche.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/tstyche/schemas/config.json", + "rejectAnyType": true, + "rejectNeverType": true, + "testFileMatch": [ + "test-types/**/*.tst.ts" + ] +} diff --git a/packages/atom/effect-atom-react/vitest.config.ts b/packages/atom/effect-atom-react/vitest.config.ts new file mode 100644 index 0000000..b153b34 --- /dev/null +++ b/packages/atom/effect-atom-react/vitest.config.ts @@ -0,0 +1,40 @@ +import { inlineSchemaTests } from '@systemfsoftware/effect-schema-vite' +import { defineConfig, sharedConfig } from '@systemfsoftware/vitest-config' +import { playwright } from '@vitest/browser-playwright' + +export default defineConfig({ + ...sharedConfig, + plugins: [inlineSchemaTests()], + test: { + ...sharedConfig.test, + projects: [ + { + extends: true, + test: { + name: 'browser', + include: ['./tests/**/*.integration.test.ts', '!./tests/ssr.integration.test.ts'], + browser: { + enabled: true, + provider: playwright(), + headless: true, + instances: [{ browser: 'chromium' }], + }, + }, + }, + { + extends: true, + test: { + name: 'node', + include: ['./tests/ssr.integration.test.ts', './src/**/*.test.ts'], + environment: 'node', + }, + }, + ], + coverage: { + ...sharedConfig.test?.coverage, + provider: 'istanbul', + include: ['src/**/*.{ts,tsx}'], + exclude: ['src/**/*.test.{ts,tsx}'], + }, + }, +}) diff --git a/packages/atom/effect-atom-react/vitest.node.config.ts b/packages/atom/effect-atom-react/vitest.node.config.ts new file mode 100644 index 0000000..aceacbd --- /dev/null +++ b/packages/atom/effect-atom-react/vitest.node.config.ts @@ -0,0 +1,31 @@ +import { inlineSchemaTests } from '@systemfsoftware/effect-schema-vite' +import { defineConfig, sharedConfig } from '@systemfsoftware/vitest-config' + +/** + * The node test project. `vitest.config.ts` runs it beside the browser project; + * Stryker runs it alone, because the CI mutation job installs no browser. + */ +export const nodeTest: { + readonly name: string + readonly include: string[] + readonly pool: 'forks' + readonly environment: 'node' +} = { + name: 'node', + include: [ + './tests/ssr.integration.test.ts', + './src/**/*.test.ts', + './src/__tests__/*.workflow.property.test.ts', + ], + pool: 'forks', + environment: 'node', +} + +export default defineConfig({ + ...sharedConfig, + plugins: [inlineSchemaTests()], + test: { + ...sharedConfig.test, + ...nodeTest, + }, +}) diff --git a/packages/atom/effect-atom/.attw.json b/packages/atom/effect-atom/.attw.json new file mode 100644 index 0000000..76f3418 --- /dev/null +++ b/packages/atom/effect-atom/.attw.json @@ -0,0 +1 @@ +{ "ignoreRules": ["no-resolution", "cjs-resolves-to-esm"] } diff --git a/packages/atom/effect-atom/LICENSE b/packages/atom/effect-atom/LICENSE new file mode 100644 index 0000000..19731ff --- /dev/null +++ b/packages/atom/effect-atom/LICENSE @@ -0,0 +1,231 @@ +Copyright (c) 2026 Ryan Lee (systemfsoftware) + +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Ryan Lee (systemfsoftware) + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + +---------------------------------------------------------------------- +Third-party notices + +Portions of this software are derived from effect-atom +(https://github.com/tim-smart/effect-atom), which is licensed under +the MIT License: + +Copyright (c) 2023-present The Contributors + +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/packages/atom/effect-atom/README.md b/packages/atom/effect-atom/README.md new file mode 100644 index 0000000..60527b6 --- /dev/null +++ b/packages/atom/effect-atom/README.md @@ -0,0 +1,33 @@ +# @systemfsoftware/effect-atom + +Reactive toolkit for Effect — forked under systemfsoftware from tim-smart/effect-atom. + +## Install + +```sh +pnpm add @systemfsoftware/effect-atom 'effect@^4' +``` + +Those are peer dependencies: this package declares them but does not install them, so one copy is shared with the rest of your project. + +## Entry points + +One entry, one namespace: + +```ts +import { Atom } from '@systemfsoftware/effect-atom' + +const count = Atom.make(0) +const registry = Atom.Registry.make() +Atom.Registry.set(registry, count, 1) +``` + +`Atom.Registry`, `Atom.AsyncResult`, `Atom.Hydration`, `Atom.Ref`, `Atom.HttpApi`, and `Atom.Rpc` replace the former `/Registry`, `/Result`, `/Hydration`, `/AtomRef`, `/AtomHttpApi`, and `/AtomRpc` subpaths. + +## API + +The public surface is generated from the source and versioned with the package: [`etc/effect-atom.api.md`](./etc/effect-atom.api.md). + +## License + +Apache-2.0. Part of [systemfsoftware](https://github.com/systemfsoftware/systemfsoftware/tree/main/packages/effect-atom/atom#readme). diff --git a/packages/atom/effect-atom/api-extractor.json b/packages/atom/effect-atom/api-extractor.json new file mode 100644 index 0000000..bff7d1c --- /dev/null +++ b/packages/atom/effect-atom/api-extractor.json @@ -0,0 +1,54 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + "projectFolder": ".", + "mainEntryPointFilePath": "/dist/mod.d.ts", + "bundledPackages": [], + "compiler": { + "tsconfigFilePath": "/tsconfig.api.json" + }, + "apiReport": { + "enabled": true, + "reportFolder": "/etc/", + "reportTempFolder": "/temp/main/", + "reportFileName": "effect-atom.api.md" + }, + "docModel": { + "enabled": false + }, + "dtsRollup": { + "enabled": false + }, + "tsdocMetadata": { + "enabled": false + }, + "messages": { + "extractorMessageReporting": { + "default": { + "logLevel": "warning" + }, + "ae-forgotten-export": { + "logLevel": "error" + }, + "ae-missing-release-tag": { + "logLevel": "none" + }, + "ae-internal-missing-underscore": { + "logLevel": "none" + } + }, + "tsdocMessageReporting": { + "default": { + "logLevel": "warning" + }, + "tsdoc-undefined-tag": { + "logLevel": "none" + }, + "tsdoc-unsupported-tag": { + "logLevel": "none" + }, + "tsdoc-param-tag-missing-hyphen": { + "logLevel": "none" + } + } + } +} diff --git a/packages/atom/effect-atom/etc/effect-atom.api.md b/packages/atom/effect-atom/etc/effect-atom.api.md new file mode 100644 index 0000000..d2bf49a --- /dev/null +++ b/packages/atom/effect-atom/etc/effect-atom.api.md @@ -0,0 +1,14 @@ +## API Report File for "@systemfsoftware/effect-atom" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +// @public (undocumented) +export namespace Atom { + export { async_result_d_exports as AsyncResult, Atom, AtomContext, AtomResultFn, AtomRuntime, Failure, FnContext, atom_http_api_service_d_exports as HttpApi, hydration_d_exports as Hydration, Interrupt, PullResult, PullSuccess, atom_ref_handle_d_exports as Ref, registry_handle_d_exports as Registry, RegistryRuntimeFactory, Reset, atom_rpc_service_d_exports as Rpc, RuntimeFactory, Serializable, SerializableJson, SharedRuntimeFactory, Success, Type, TypeId$1 as TypeId, With, WithoutSerializable, Writable, WriteContext, autoDispose, batch, context, debounce, family, fn, fnSync, get$1 as get, getResult, getServerValue, initialValue, isAtom, isSerializable, isWritable, keepAlive, kvs, make$1 as make, makeRead, makeReadWith, makeRefreshOnSignal, makeWith, map$1 as map, mapResult, modify, mount, optimistic, optimisticFn, pull, readable, refresh, refreshOnWindowFocus, searchParam, serializable, set$1 as set, setIdleTTL, setLazy, subscriptionRef, swr, toStream, toStreamResult, transform, update$1 as update, windowFocusSignal, withEquality, withFallback, withLabel, withReactivity, withRefresh, withServerValue, withServerValueInitial, writable }; +} + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/atom/effect-atom/oxlint.config.ts b/packages/atom/effect-atom/oxlint.config.ts new file mode 100644 index 0000000..b6f1225 --- /dev/null +++ b/packages/atom/effect-atom/oxlint.config.ts @@ -0,0 +1,4 @@ +import recommended from '@systemfsoftware/oxlint-config-frontend' +import { defineConfig } from 'oxlint' + +export default defineConfig({ extends: [recommended] }) diff --git a/packages/atom/effect-atom/package.json b/packages/atom/effect-atom/package.json new file mode 100644 index 0000000..c2c208a --- /dev/null +++ b/packages/atom/effect-atom/package.json @@ -0,0 +1,99 @@ +{ + "name": "@systemfsoftware/effect-atom", + "license": "Apache-2.0", + "version": "6.0.0", + "author": "TODO ", + "repository": { + "type": "git", + "url": "git+https://github.com/systemfsoftware/frontend.git", + "directory": "packages/atom/effect-atom" + }, + "homepage": "https://github.com/systemfsoftware/frontend/tree/main/packages/atom/effect-atom#readme", + "bugs": "https://github.com/systemfsoftware/frontend/issues", + "description": "Reactive toolkit for Effect — forked under systemfsoftware from tim-smart/effect-atom.", + "keywords": [ + "effect", + "effect-ts", + "atom", + "reactive", + "state" + ], + "type": "module", + "exports": { + ".": { + "@systemfsoftware/source": "./src/mod.ts", + "types": "./dist/mod.d.ts", + "default": "./dist/mod.mjs" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist" + ], + "scripts": { + "clean": "rimraf dist", + "build": "tsdown -l warn && pnpm dts:check && pnpm api:check", + "typecheck": "tsc -b", + "test": "vitest run --passWithNoTests", + "test:watch": "vitest", + "test:types": "TSTYCHE_TYPESCRIPT_MODULE=tstyche-typescript tstyche", + "lint": "f=${OXLINT_FORMAT:-${AGENT:+agent}}; oxlint . --config oxlint.config.ts --format=${f:-default}", + "lint:tsgo": "effect-tsgo diagnostics --project tsconfig.app.json --format ${TSGO_FORMAT:-text} && effect-tsgo diagnostics --project tsconfig.test.json --format ${TSGO_FORMAT:-text}", + "attw": "attw --pack .", + "mutation": "stryker run", + "dts:check": "tsc --noEmit -p tsconfig.dts.json", + "api:check": "api-extractor-quiet run", + "api:update": "api-extractor run --local" + }, + "dependencies": { + "@systemfsoftware/effect-cell-types": "^12.0.0" + }, + "peerDependencies": { + "effect": "catalog:peers" + }, + "devDependencies": { + "@microsoft/api-extractor": "catalog:", + "@systemfsoftware/arethetypeswrong-cli": "catalog:", + "@systemfsoftware/conformance-spec": "^1.0.0", + "@systemfsoftware/differential-spec": "^1.0.1", + "@systemfsoftware/effect-gherkin-spec": "^7.0.1", + "@systemfsoftware/effect-schema-law": "^5.0.1", + "@systemfsoftware/effect-schema-vite": "^3.0.1", + "@systemfsoftware/oxlint-config-frontend": "workspace:^", + "@systemfsoftware/stryker-config": "^0.1.0", + "@systemfsoftware/stryker-ignorer-effect-schema-declarations": "catalog:stryker", + "@systemfsoftware/stryker-ignorer-in-source-vitest-block": "catalog:stryker", + "@systemfsoftware/stryker-js": "catalog:stryker", + "@systemfsoftware/stryker-js-typescript-checker": "catalog:stryker", + "@systemfsoftware/stryker-js-vitest-runner": "catalog:stryker", + "@systemfsoftware/tsconfig": "^2.0.1", + "@systemfsoftware/tsdown-config": "^0.1.0", + "@systemfsoftware/vitest": "^2.0.0", + "@systemfsoftware/vitest-config": "^0.1.0", + "@types/node": "catalog:", + "@vitest/browser": "catalog:", + "@vitest/browser-playwright": "catalog:", + "@vitest/coverage-istanbul": "catalog:", + "effect": "catalog:", + "fast-check": "catalog:", + "oxlint": "catalog:oxlint", + "playwright": "catalog:", + "rimraf": "catalog:", + "tsdown": "catalog:", + "tstyche": "catalog:", + "type-fest": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + }, + "publishConfig": { + "access": "public", + "exports": { + ".": { + "types": "./dist/mod.d.ts", + "default": "./dist/mod.mjs" + }, + "./package.json": "./package.json" + }, + "provenance": true + } +} diff --git a/packages/atom/effect-atom/src/Atom/mod.ts b/packages/atom/effect-atom/src/Atom/mod.ts new file mode 100644 index 0000000..8962aa3 --- /dev/null +++ b/packages/atom/effect-atom/src/Atom/mod.ts @@ -0,0 +1,79 @@ +/** + * Reactive state primitives for values managed by a registry. + * + * @since 4.0.0 + */ +export * as AsyncResult from '../async-result.js' +export { refreshOnWindowFocus, searchParam, windowFocusSignal } from '../atom-browser.js' +export * as HttpApi from '../atom-http-api.service.js' +export { kvs } from '../atom-kvs.js' +export * as Ref from '../atom-ref.handle.js' +export * as Rpc from '../atom-rpc.service.js' +export { Interrupt, Reset } from '../atom-sentinels.js' +export { makeRefreshOnSignal } from '../atom.blueprint.js' +export { + debounce, + family, + initialValue, + map, + mapResult, + optimistic, + optimisticFn, + swr, + withFallback, + withRefresh, +} from '../atom.blueprint.js' +export { + type AtomResultFn, + batch, + context, + type Failure, + fn, + type FnContext, + fnSync, + make, + makeRead, + makeReadWith, + makeWith, + pull, + type PullResult, + type PullSuccess, + type RegistryRuntimeFactory, + type RuntimeFactory, + type SharedRuntimeFactory, + subscriptionRef, + type Success, + withReactivity, +} from '../atom.blueprint.js' +export { get, getResult, modify, mount, refresh, set, toStream, toStreamResult, update } from '../atom.blueprint.js' +export { + type Atom, + type AtomContext, + type AtomRuntime, + autoDispose, + isAtom, + isSerializable, + isWritable, + keepAlive, + readable, + type Serializable, + serializable, + type SerializableJson, + setIdleTTL, + setLazy, + transform, + type Type, + TypeId, + type With, + withEquality, + withLabel, + type WithoutSerializable, + withServerValue, + withServerValueInitial, + type Writable, + writable, + type WriteContext, +} from '../atom.blueprint.js' +export * as Hydration from '../hydration.js' +export { getServerValue } from '../registry.handle.js' +export * as Registry from '../registry.handle.js' diff --git a/packages/atom/effect-atom/src/advance-batch-phase.workflow.ts b/packages/atom/effect-atom/src/advance-batch-phase.workflow.ts new file mode 100644 index 0000000..a61dd6e --- /dev/null +++ b/packages/atom/effect-atom/src/advance-batch-phase.workflow.ts @@ -0,0 +1,56 @@ +import { Workflow } from '@systemfsoftware/effect-cell-types' +import { Match, Schema } from 'effect' +import * as Result from 'effect/Result' + +const BatchStepDecisionTypeId: unique symbol = Symbol.for( + '@systemfsoftware/effect-atom/BatchStepDecision', +) +type BatchStepDecisionTypeId = typeof BatchStepDecisionTypeId + +export class RebuildAndNotify extends Schema.TaggedClass()('RebuildAndNotify', {}) { + readonly [BatchStepDecisionTypeId] = BatchStepDecisionTypeId +} + +export class ResetBatch extends Schema.TaggedClass()('ResetBatch', {}) { + readonly [BatchStepDecisionTypeId] = BatchStepDecisionTypeId +} + +export class StayNested extends Schema.TaggedClass()('StayNested', {}) { + readonly [BatchStepDecisionTypeId] = BatchStepDecisionTypeId +} + +export const BatchStepDecision = Schema.Union([RebuildAndNotify, ResetBatch, StayNested]) +export type BatchStepDecision = typeof BatchStepDecision.Type + +export const BatchEvent = Schema.Literals(['commit-request', 'finish-request']) +export type BatchEvent = Schema.Schema.Type + +export class BatchStep extends Schema.TaggedClass()('BatchStep', { + depth: Schema.Int, + event: BatchEvent, +}) { + static readonly [Workflow.InstrumentationBrand] = {} as const +} + +const outermost = (command: BatchStep): boolean => command.depth === 1 + +const outermostVerdict = (command: BatchStep): BatchStepDecision => + Match.value(command.event).pipe( + Match.when('commit-request', () => RebuildAndNotify.make({})), + Match.when('finish-request', () => ResetBatch.make({})), + Match.exhaustive, + ) + +export const advanceBatchPhase = Workflow.make({ + command: BatchStep, + decision: BatchStepDecision, + error: Schema.Never, + decide: (command): Result.Result => + Result.succeed( + Match.value(outermost(command)).pipe( + Match.when(true, () => outermostVerdict(command)), + Match.when(false, () => StayNested.make({})), + Match.exhaustive, + ), + ), +}) diff --git a/packages/atom/effect-atom/src/advance-node-state.workflow.ts b/packages/atom/effect-atom/src/advance-node-state.workflow.ts new file mode 100644 index 0000000..fe4bfa1 --- /dev/null +++ b/packages/atom/effect-atom/src/advance-node-state.workflow.ts @@ -0,0 +1,119 @@ +import { Workflow } from '@systemfsoftware/effect-cell-types' +import { Match, Schema } from 'effect' +import * as Result from 'effect/Result' +import { NodePhase } from './node-phase.schema.js' + +const NodeStateDecisionTypeId: unique symbol = Symbol.for( + '@systemfsoftware/effect-atom/NodeStateDecision', +) +type NodeStateDecisionTypeId = typeof NodeStateDecisionTypeId + +export class BecomesValid extends Schema.TaggedClass()('BecomesValid', {}) { + readonly [NodeStateDecisionTypeId] = NodeStateDecisionTypeId +} + +export class BecomesStale extends Schema.TaggedClass()('BecomesStale', {}) { + readonly [NodeStateDecisionTypeId] = NodeStateDecisionTypeId +} + +export class BecomesChecking extends Schema.TaggedClass()('BecomesChecking', {}) { + readonly [NodeStateDecisionTypeId] = NodeStateDecisionTypeId +} + +export class TakeBuiltValue extends Schema.TaggedClass()('TakeBuiltValue', {}) { + readonly [NodeStateDecisionTypeId] = NodeStateDecisionTypeId +} + +export class KeepsPhase extends Schema.TaggedClass()('KeepsPhase', {}) { + readonly [NodeStateDecisionTypeId] = NodeStateDecisionTypeId +} + +export const NodeStateDecision = Schema.Union([ + BecomesValid, + BecomesStale, + BecomesChecking, + TakeBuiltValue, + KeepsPhase, +]) +export type NodeStateDecision = typeof NodeStateDecision.Type + +export const NodeStateEvent = Schema.Literals([ + 'settled', + 'invalidated', + 'descending', + 'restaled', + 'built', +]) +export type NodeStateEvent = Schema.Schema.Type + +export class AdvanceNodeState extends Schema.TaggedClass()('AdvanceNodeState', { + phase: NodePhase, + event: NodeStateEvent, + invalidatedDuringBuild: Schema.Boolean, + preserveInitialValueOnBuild: Schema.Boolean, +}) { + static readonly [Workflow.InstrumentationBrand] = {} as const +} + +const isCurrentPhase = (phase: NodePhase): boolean => ['valid', 'checking'].includes(phase) + +const isWaitingPhase = (phase: NodePhase): boolean => ['uninitialized', 'stale'].includes(phase) + +const settledVerdict = (phase: NodePhase): NodeStateDecision => + Match.value(phase === 'checking').pipe( + Match.when(true, () => BecomesValid.make({})), + Match.when(false, () => KeepsPhase.make({})), + Match.exhaustive, + ) + +const invalidatedVerdict = (phase: NodePhase): NodeStateDecision => + Match.value(isCurrentPhase(phase)).pipe( + Match.when(true, () => BecomesStale.make({})), + Match.when(false, () => KeepsPhase.make({})), + Match.exhaustive, + ) + +const descendingVerdict = (phase: NodePhase): NodeStateDecision => + Match.value(phase === 'valid').pipe( + Match.when(true, () => BecomesChecking.make({})), + Match.when(false, () => KeepsPhase.make({})), + Match.exhaustive, + ) + +const restaledVerdict = (phase: NodePhase, invalidatedDuringBuild: boolean): NodeStateDecision => + Match.value([phase === 'valid', invalidatedDuringBuild].includes(false)).pipe( + Match.when(true, () => KeepsPhase.make({})), + Match.when(false, () => BecomesStale.make({})), + Match.exhaustive, + ) + +const preservedOrTaken = (preserveInitialValueOnBuild: boolean): NodeStateDecision => + Match.value(preserveInitialValueOnBuild).pipe( + Match.when(true, () => BecomesValid.make({})), + Match.when(false, () => TakeBuiltValue.make({})), + Match.exhaustive, + ) + +const builtVerdict = (phase: NodePhase, preserveInitialValueOnBuild: boolean): NodeStateDecision => + Match.value(isWaitingPhase(phase)).pipe( + Match.when(true, () => preservedOrTaken(preserveInitialValueOnBuild)), + Match.when(false, () => KeepsPhase.make({})), + Match.exhaustive, + ) + +const eventVerdict = (command: AdvanceNodeState): NodeStateDecision => + Match.value(command.event).pipe( + Match.when('settled', () => settledVerdict(command.phase)), + Match.when('invalidated', () => invalidatedVerdict(command.phase)), + Match.when('descending', () => descendingVerdict(command.phase)), + Match.when('restaled', () => restaledVerdict(command.phase, command.invalidatedDuringBuild)), + Match.when('built', () => builtVerdict(command.phase, command.preserveInitialValueOnBuild)), + Match.exhaustive, + ) + +export const advanceNodeState = Workflow.make({ + command: AdvanceNodeState, + decision: NodeStateDecision, + error: Schema.Never, + decide: (command): Result.Result => Result.succeed(eventVerdict(command)), +}) diff --git a/packages/atom/effect-atom/src/async-result.schema.ts b/packages/atom/effect-atom/src/async-result.schema.ts new file mode 100644 index 0000000..05803f1 --- /dev/null +++ b/packages/atom/effect-atom/src/async-result.schema.ts @@ -0,0 +1,1545 @@ +/** + * The `Result` model: the type, its schema codec, and every pure operation + * that builds, inspects, maps, matches or combines a result. + * + * Clock-reading entry points live in `./async-result.js`, which delegates here + * for the pure construction and keeps every public name. + * + * @since 4.0.0 + */ +import * as Cause from 'effect/Cause' +import * as Effect from 'effect/Effect' +import * as Equal from 'effect/Equal' +import * as Exit from 'effect/Exit' +import { constTrue, dual, identity, type LazyArg } from 'effect/Function' +import * as Hash from 'effect/Hash' +import * as Match from 'effect/Match' +import * as Option from 'effect/Option' +import * as PipeableModule from 'effect/Pipeable' +import { type Pipeable, pipeArguments } from 'effect/Pipeable' +import { hasProperty, isIterable, isTagged, type Predicate, type Refinement } from 'effect/Predicate' +import * as Either from 'effect/Result' +import * as Schema_ from 'effect/Schema' +import * as SchemaGetter from 'effect/SchemaGetter' +import * as SchemaIssue from 'effect/SchemaIssue' +import * as SchemaParser from 'effect/SchemaParser' +import * as SchemaTransformation from 'effect/SchemaTransformation' +import type * as Types from 'effect/Types' +/** + * Type-level identifier used to recognize `Result` values. + * + * @since 4.0.0 + */ + +export type TypeId = '~effect-atom/atom/Result' + +/** + * Runtime identifier attached to `Result` values and used by `isResult`. + * + * @since 4.0.0 + */ + +export const TypeId: TypeId = '~effect-atom/atom/Result' + +/** + * Namespace containing the shared prototype shape and type-level helpers for + * `Result` values. The interface members are declared ahead of their use in + * the variant interfaces below. + * + * @since 4.0.0 + */ +export declare namespace Result { + /** + * Common prototype fields implemented by every `Result` variant, including + * pipeability, the type marker, phantom type members, and the `waiting` flag. + * + * @since 4.0.0 + */ + export interface Proto extends Pipeable { + readonly [TypeId]: { + readonly E: (_: never) => E + readonly A: (_: never) => A + } + readonly waiting: boolean + } + + /** + * Extracts the success value type from an `Result`. + * + * @since 4.0.0 + */ + export type Success = R extends Result ? A : never + + /** + * Extracts the failure error type from an `Result`. + * + * @since 4.0.0 + */ + export type Failure = R extends Result ? E : never +} + +/** + * Represents the state of an asynchronous value as `Initial`, `Success`, or + * `Failure`, with a `waiting` flag for in-flight refreshes. + * + * @since 4.0.0 + */ + +export type Result = Initial | Success | Failure + +type AnyResult
= Result + +/** + * Shared prototype every `Result` variant inherits from. The three + * constructors (`initial`, `success`, `failure`) use it; `waiting` in + * `Result.ts` also reaches it directly. + * + * The schema codec (`internal/result-schema.ts`) serializes only the tagged + * variant fields (`value`, `waiting`, `timestamp`, `cause`, + * `previousSuccess`) — nothing added to this prototype is wire-carried, so + * any future proto-private state must be mirrored in that encode/decode pair. + * + * @since 4.0.0 + */ + +const ResultProto = { + [TypeId]: { + E: identity, + A: identity, + }, + pipe() { + return pipeArguments(this, arguments) + }, + [Equal.symbol](this: AnyResult, that: AnyResult): boolean { + if (this.waiting !== that.waiting) { + return false + } + return Match.value(this).pipe( + Match.tag('Initial', () => Match.value(that).pipe(Match.tag('Initial', () => true), Match.orElse(() => false))), + Match.tag( + 'Success', + (s) => + Match.value(that).pipe( + Match.tag('Success', (t) => Equal.equals(s.value, t.value)), + Match.orElse(() => false), + ), + ), + Match.tag( + 'Failure', + (f) => + Match.value(that).pipe( + Match.tag('Failure', (g) => Equal.equals(f.cause, g.cause)), + Match.orElse(() => false), + ), + ), + Match.exhaustive, + ) + }, + [Hash.symbol](this: AnyResult): number { + const tagHash = Hash.string(`${this._tag}:${this.waiting}`) + return Match.value(this).pipe( + Match.tag('Initial', () => tagHash), + Match.tag('Success', (s) => Hash.combine(tagHash)(Hash.hash(s.value))), + Match.tag('Failure', (f) => Hash.combine(tagHash)(f.cause.pipe(Hash.hash))), + Match.exhaustive, + ) + }, +} + +const InitialTag = { _tag: 'Initial' } as const + +export type InitialTag = typeof InitialTag + +/** + * Initial `Result` state before a success value or failure cause is available. + * + * @since 4.0.0 + */ + +export interface Initial extends Result.Proto, InitialTag {} + +const SuccessTag = { _tag: 'Success' } as const + +export type SuccessTag = typeof SuccessTag + +/** + * Successful `Result` containing the current value, its timestamp, and the + * shared waiting flag. + * + * @since 4.0.0 + */ + +export interface Success extends Result.Proto, SuccessTag { + readonly value: A + readonly timestamp: number +} + +const FailureTag = { _tag: 'Failure' } as const + +export type FailureTag = typeof FailureTag + +/** + * Failed `Result` containing a failure cause and the latest previous success + * when one is available. + * + * @since 4.0.0 + */ + +export interface Failure extends Result.Proto, FailureTag { + readonly cause: Cause.Cause + readonly previousSuccess: Option.Option> +} + +/** + * Returns `true` when a value is an `Result`. + * + * @since 4.0.0 + */ + +export const isResult = (u: unknown): u is AnyResult => hasProperty(u, TypeId) + +/** + * Returns `true` when a value is a `Result`. Alias of {@link isResult}. + * + * @since 4.0.0 + */ +export const isAsyncResult = (u: unknown): u is AnyResult => isResult(u) + +/** + * Creates an `Initial` result, optionally marking it as waiting. + * + * @since 4.0.0 + */ + +export const initial = (waiting = false): Initial => { + const result: Initial = { + ...ResultProto, + ...InitialTag, + waiting, + } + return result +} + +type WaitingOptions = { + readonly waiting?: boolean | undefined +} + +type PreviousSuccessOptions = { + readonly previousSuccess?: Option.Option> | undefined +} + +const waitingFromDefined = (options: WaitingOptions): boolean => { + if (options.waiting === undefined) { + return false + } + return options.waiting +} + +const waitingOption = (options: WaitingOptions | undefined): boolean => { + if (options === undefined) { + return false + } + return waitingFromDefined(options) +} + +const previousSuccessFromDefined = ( + options: PreviousSuccessOptions, +): Option.Option> => { + if (options.previousSuccess === undefined) { + return Option.none() + } + return options.previousSuccess +} + +const previousSuccessOption = ( + options: PreviousSuccessOptions | undefined, +): Option.Option> => { + if (options === undefined) { + return Option.none() + } + return previousSuccessFromDefined(options) +} + +/** + * @since 4.0.0 + */ + +export const successAt: { + (options: { + readonly waiting?: boolean | undefined + readonly timestamp: number + }): (value: A) => Success + (value: A, options: { + readonly waiting?: boolean | undefined + readonly timestamp: number + }): Success +} = dual( + 2, + ( + value: A, + options: { readonly timestamp: number; readonly waiting?: boolean | undefined }, + ): Success => { + const result: Success = { + ...ResultProto, + ...SuccessTag, + value, + waiting: waitingOption(options), + timestamp: options.timestamp, + } + return result + }, +) + +/** + * Creates a `Failure` result from a `Cause`, optionally preserving a previous + * success and marking the result as waiting. + * + * @since 4.0.0 + */ + +export const failure: { + (options?: { + readonly previousSuccess?: Option.Option> | undefined + readonly waiting?: boolean | undefined + }): (cause: Cause.Cause) => Failure + ( + cause: Cause.Cause, + options?: { + readonly previousSuccess?: Option.Option> | undefined + readonly waiting?: boolean | undefined + }, + ): Failure +} = dual( + (args) => Cause.isCause(args[0]), + ( + cause: Cause.Cause, + options?: { + readonly previousSuccess?: Option.Option> | undefined + readonly waiting?: boolean | undefined + }, + ): Failure => { + const result: Failure = { + ...ResultProto, + ...FailureTag, + cause, + previousSuccess: previousSuccessOption(options), + waiting: waitingOption(options), + } + return result + }, +) + +type AnySuccess = Success + +type Top = A + +/** + * Rebuilds an `Result` with new success and failure types while preserving the variant of another result. + * + * @since 4.0.0 + */ + +export type With = R extends Initial ? Initial + : R extends Success ? Success + : R extends Failure ? Failure + : never + +/** + * Returns whether an `Result` is currently waiting for an asynchronous computation or refresh to finish. + * + * @since 4.0.0 + */ + +export const isWaiting = (result: Result): boolean => result.waiting + +const markedWaiting = (result: Result): Result => + result.waiting ? result : { ...result, waiting: true } + +/** + * Creates a waiting result from an optional previous result, using `Initial(true)` when no previous result exists. + * + * @since 4.0.0 + */ + +export const waitingFrom = (previous: Option.Option>): Result => { + if (Option.isNone(previous)) { + return initial(true) + } + return markedWaiting(previous.value) +} + +/** + * Returns `true` when an `Result` is in the `Initial` state. + * + * @since 4.0.0 + */ + +export const isInitial = (result: Result): result is Initial => isTagged(result, 'Initial') + +/** + * Returns `true` when an `Result` is either `Success` or `Failure`. + * + * @since 4.0.0 + */ + +export const isNotInitial = (result: Result): result is Success | Failure => !isInitial(result) + +/** + * Returns `true` when an `Result` is a `Success`. + * + * @since 4.0.0 + */ + +export const isSuccess = (result: Result): result is Success => isTagged(result, 'Success') + +/** + * Returns `true` when an `Result` is a `Failure`. + * + * @since 4.0.0 + */ + +export const isFailure = (result: Result): result is Failure => isTagged(result, 'Failure') + +/** + * Returns `true` when an `Result` is a `Failure` whose cause contains only interruptions. + * + * @since 4.0.0 + */ + +export const isInterrupted = (result: Result): result is Failure => + isFailure(result) && Cause.hasInterruptsOnly(result.cause) + +const previousSuccessFromNonSuccess = (result: Initial | Failure): Option.Option> => { + if (isFailure(result)) { + return result.previousSuccess + } + return Option.none() +} + +const previousSuccessFromResult = (result: Result): Option.Option> => { + if (isSuccess(result)) { + return Option.some(result) + } + return previousSuccessFromNonSuccess(result) +} + +/** + * Creates a `Failure` result from a `Cause`, carrying forward the latest success stored in a previous result. + * + * @since 4.0.0 + */ + +export const failureWithPrevious: { + (options: { + readonly previous: Option.Option> + readonly waiting?: boolean | undefined + }): (cause: Cause.Cause) => Failure + ( + cause: Cause.Cause, + options: { + readonly previous: Option.Option> + readonly waiting?: boolean | undefined + }, + ): Failure +} = dual( + 2, + ( + cause: Cause.Cause, + options: { + readonly previous: Option.Option> + readonly waiting?: boolean | undefined + }, + ): Failure => + failure(cause, { + previousSuccess: Option.flatMap(options.previous, previousSuccessFromResult), + waiting: options.waiting, + }), +) + +/** + * Creates a `Failure` result from a typed error, wrapping it in `Cause.fail`. + * + * To carry forward a previous success or mark the result as waiting, use + * `failWith`. + * + * @since 4.0.0 + */ + +export const fail = (error: E): Failure => failWith(error, {}) + +/** + * Can also be called data-last inside `pipe`: `failWith(options)(error)`. + * + * @since 4.0.0 + */ + +export const failWith: { + (options: { + readonly previousSuccess?: Option.Option> | undefined + readonly waiting?: boolean | undefined + }): (error: E) => Failure + ( + error: E, + options: { + readonly previousSuccess?: Option.Option> | undefined + readonly waiting?: boolean | undefined + }, + ): Failure +} = dual( + 2, + ( + error: E, + options: { + readonly previousSuccess?: Option.Option> | undefined + readonly waiting?: boolean | undefined + }, + ): Failure => failure(Cause.fail(error), options), +) + +/** + * Creates a `Failure` result from a typed error while carrying forward the latest success stored in a previous result. + * + * @since 4.0.0 + */ + +export const failWithPrevious: { + (options: { + readonly previous: Option.Option> + readonly waiting?: boolean | undefined + }): (error: E) => Failure + ( + error: E, + options: { + readonly previous: Option.Option> + readonly waiting?: boolean | undefined + }, + ): Failure +} = dual( + 2, + ( + error: E, + options: { + readonly previous: Option.Option> + readonly waiting?: boolean | undefined + }, + ): Failure => failureWithPrevious(Cause.fail(error), options), +) + +/** + * Replaces a `Failure` value's stored previous success with the latest success + * found in another result. + * + * @since 4.0.0 + */ + +export const replacePrevious: { + (previous: Option.Option>): (self: R) => With> + ( + self: R, + previous: Option.Option>, + ): With> +} = dual( + 2, + ( + self: Result, + previous: Option.Option>, + ): AnyResult => { + if (isFailure(self)) { + return failureWithPrevious(self.cause, { previous, waiting: self.waiting }) + } + return self + }, +) + +const valueFromNonSuccess = (self: Initial | Failure): Option.Option => { + if (isFailure(self)) { + return Option.map(self.previousSuccess, (s) => s.value) + } + return Option.none() +} + +/** + * Returns the current success value, or the previous success value stored in a failure, as an `Option`. + * + * @since 4.0.0 + */ + +export const value = (self: Result): Option.Option => { + if (isSuccess(self)) { + return Option.some(self.value) + } + return valueFromNonSuccess(self) +} + +/** + * Returns the available value from `value`, or evaluates the fallback when no current or previous success exists. + * + * @since 4.0.0 + */ + +export const getOrElse: { + (orElse: LazyArg): (self: Result) => A | B + (self: Result, orElse: LazyArg): A | B +} = dual(2, (self: Result, orElse: LazyArg): A | B => Option.getOrElse(value(self), orElse)) + +/** + * Returns the available value from `value`, or throws `NoSuchElementError` when no current or previous success exists. + * + * @since 4.0.0 + */ + +export const getOrThrow = (self: Result): A => + Option.getOrThrowWith(value(self), () => new Cause.NoSuchElementError('Result.getOrThrow: no value found')) + +/** + * Returns the failure cause when the result is a `Failure`, otherwise `None`. + * + * @since 4.0.0 + */ + +export const cause = (self: Result): Option.Option> => { + if (isFailure(self)) { + return Option.some(self.cause) + } + return Option.none() +} + +/** + * Returns the first typed error from a failure cause, or `None` for successes, initial results, defects, and interrupt-only causes. + * + * @since 4.0.0 + */ + +export const error = (self: Result): Option.Option => { + if (isFailure(self)) { + return Cause.findErrorOption(self.cause) + } + return Option.none() +} + +const toExitNonSuccess = (self: Initial | Failure): Exit.Exit => { + if (isFailure(self)) { + return Exit.failCause(self.cause) + } + return Exit.fail(new Cause.NoSuchElementError()) +} + +/** + * Converts a result to an `Exit`, succeeding with a success value, failing with a failure cause, or failing with `NoSuchElementError` for `Initial`. + * + * @since 4.0.0 + */ + +export const toExit: { + (self: Success | Failure): Exit.Exit + (self: Result): Exit.Exit +} = ( + self: Result, +): Exit.Exit => { + if (isSuccess(self)) { + return Exit.succeed(self.value) + } + return toExitNonSuccess(self) +} + +const mapNonSuccess = (self: Initial | Failure, f: (a: A) => B): Result => { + if (isFailure(self)) { + return failure(self.cause, { + previousSuccess: Option.map(self.previousSuccess, (s) => successAt(f(s.value), s)), + waiting: self.waiting, + }) + } + return initial(self.waiting) +} + +/** + * Maps the success value of an `Result`, also mapping any previous success stored in a failure while leaving initial results unchanged. + * + * @since 4.0.0 + */ + +export const map: { + (f: (a: A) => B): (self: Result) => Result + (self: Result, f: (a: A) => B): Result +} = dual(2, (self: Result, f: (a: A) => B): Result => { + if (isSuccess(self)) { + return successAt(f(self.value), self) + } + return mapNonSuccess(self, f) +}) + +const successOption = (next: Result): Option.Option> => { + if (isSuccess(next)) { + return Option.some(next) + } + return Option.none() +} + +const flatMapNonSuccess = ( + self: Initial | Failure, + f: (a: A, prev: Success) => Result, +): Result => { + if (isFailure(self)) { + return failure(self.cause, { + previousSuccess: Option.flatMap(self.previousSuccess, (s) => successOption(f(s.value, s))), + waiting: self.waiting, + }) + } + return initial(self.waiting) +} + +/** + * Maps the success value of an `Result` and flattens the result. + * + * **When to use** + * + * Use to sequence computations that may return another `Result` while + * preserving initial and failure states. + * + * **Details** + * + * Initial results are left unchanged. Failures preserve their cause and remap + * the stored previous success when the mapping function returns a success. + * + * @since 4.0.0 + */ + +export const flatMap: { + ( + f: (a: A, prev: Success) => Result, + ): (self: Result) => Result + (self: Result, f: (a: A, prev: Success) => Result): Result +} = dual( + 2, + ( + self: Result, + f: (a: A, prev: Success) => Result, + ): Result => { + if (isSuccess(self)) { + return f(self.value, self) + } + return flatMapNonSuccess(self, f) + }, +) + +type MatchHandlers = { + readonly onInitial: (_: Initial) => X + readonly onFailure: (_: Failure) => Y + readonly onSuccess: (_: Success) => Z +} + +const matchNonSuccess = ( + self: Initial | Failure, + options: MatchHandlers, +): X | Y | Z => { + if (isFailure(self)) { + return options.onFailure(self) + } + return options.onInitial(self) +} + +/** + * Pattern matches an `Result` by calling the handler for `Initial`, `Failure`, or `Success`. + * + * @since 4.0.0 + */ + +export const match: { + (options: { + readonly onInitial: (_: Initial) => X + readonly onFailure: (_: Failure) => Y + readonly onSuccess: (_: Success) => Z + }): (self: Result) => X | Y | Z + (self: Result, options: { + readonly onInitial: (_: Initial) => X + readonly onFailure: (_: Failure) => Y + readonly onSuccess: (_: Success) => Z + }): X | Y | Z +} = dual(2, (self: Result, options: { + readonly onInitial: (_: Initial) => X + readonly onFailure: (_: Failure) => Y + readonly onSuccess: (_: Success) => Z +}): X | Y | Z => { + if (isSuccess(self)) { + return options.onSuccess(self) + } + return matchNonSuccess(self, options) +}) + +type ErrorHandlers = { + readonly onError: (error: E, _: Failure) => X + readonly onDefect: (defect: Top, _: Failure) => Y +} + +const matchFailureErrorOrDefect = ( + self: Failure, + options: ErrorHandlers, +): X | Y => { + const result = Cause.findError(self.cause) + if (Either.isFailure(result)) { + return options.onDefect(Cause.squash(result.failure), self) + } + return options.onError(result.success, self) +} + +type MatchWithErrorHandlers = ErrorHandlers & { + readonly onInitial: (_: Initial) => W + readonly onSuccess: (_: Success) => Z +} + +const matchWithErrorNonSuccess = ( + self: Initial | Failure, + options: MatchWithErrorHandlers, +): W | X | Y | Z => { + if (isFailure(self)) { + return matchFailureErrorOrDefect(self, options) + } + return options.onInitial(self) +} + +/** + * Pattern matches a result, handling successes and initials directly while splitting failures into typed errors or squashed non-error causes passed to `onDefect`. + * + * @since 4.0.0 + */ + +export const matchWithError: { + (options: { + readonly onInitial: (_: Initial) => W + readonly onError: (error: E, _: Failure) => X + readonly onDefect: (defect: Top, _: Failure) => Y + readonly onSuccess: (_: Success) => Z + }): (self: Result) => W | X | Y | Z + (self: Result, options: { + readonly onInitial: (_: Initial) => W + readonly onError: (error: E, _: Failure) => X + readonly onDefect: (defect: Top, _: Failure) => Y + readonly onSuccess: (_: Success) => Z + }): W | X | Y | Z +} = dual(2, (self: Result, options: { + readonly onInitial: (_: Initial) => W + readonly onError: (error: E, _: Failure) => X + readonly onDefect: (defect: Top, _: Failure) => Y + readonly onSuccess: (_: Success) => Z +}): W | X | Y | Z => { + if (isSuccess(self)) { + return options.onSuccess(self) + } + return matchWithErrorNonSuccess(self, options) +}) + +type MatchWithWaitingHandlers = ErrorHandlers & { + readonly onWaiting: (_: Result) => W + readonly onSuccess: (_: Success) => Z +} + +const matchWithWaitingNonSuccess = ( + self: Initial | Failure, + options: MatchWithWaitingHandlers, +): W | X | Y | Z => { + if (isFailure(self)) { + return matchFailureErrorOrDefect(self, options) + } + return options.onWaiting(self) +} + +const matchWithWaitingSettled = ( + self: Result, + options: MatchWithWaitingHandlers, +): W | X | Y | Z => { + if (isSuccess(self)) { + return options.onSuccess(self) + } + return matchWithWaitingNonSuccess(self, options) +} + +/** + * Pattern matches a result by calling `onWaiting` for waiting or initial states, otherwise handling successes and splitting failures into typed errors or squashed non-error causes. + * + * @since 4.0.0 + */ + +export const matchWithWaiting: { + (options: { + readonly onWaiting: (_: Result) => W + readonly onError: (error: E, _: Failure) => X + readonly onDefect: (defect: Top, _: Failure) => Y + readonly onSuccess: (_: Success) => Z + }): (self: Result) => W | X | Y | Z + (self: Result, options: { + readonly onWaiting: (_: Result) => W + readonly onError: (error: E, _: Failure) => X + readonly onDefect: (defect: Top, _: Failure) => Y + readonly onSuccess: (_: Success) => Z + }): W | X | Y | Z +} = dual(2, (self: Result, options: { + readonly onWaiting: (_: Result) => W + readonly onError: (error: E, _: Failure) => X + readonly onDefect: (defect: Top, _: Failure) => Y + readonly onSuccess: (_: Success) => Z +}): W | X | Y | Z => { + if (self.waiting) { + return options.onWaiting(self) + } + return matchWithWaitingSettled(self, options) +}) + +/** + * Combines an iterable or record of `Result` and plain values into one `Result`, returning the first non-success result or a success of the collected values marked waiting when any input success is waiting. + * + * @since 4.0.0 + */ + +export type AllSuccess = [Arg] extends [readonly Top[]] ? { + -readonly [K in keyof Arg]: [Arg[K]] extends [Result] ? _A : Arg[K] + } + : [Arg] extends [Iterable] ? _A extends Result ? _AA : _A + : [Arg] extends [Record] ? { + -readonly [K in keyof Arg]: [Arg[K]] extends [Result] ? _A : Arg[K] + } + : never + +export type AllError = [Arg] extends [readonly Top[]] ? Result.Failure + : [Arg] extends [Iterable] ? Result.Failure<_A> + : [Arg] extends [Record] ? Result.Failure + : never + +export const allAt: { + (results: Iterable | Record, timestamp: number): AnyResult + (timestamp: number): (results: Iterable | Record) => AnyResult +} = dual( + 2, + (results: Iterable | Record, timestamp: number): AnyResult => allImpl(results, timestamp), +) + +type CollectDone = { + readonly done: true + readonly result: AnyResult +} + +type CollectContinue = { + readonly done: false + readonly waiting: boolean + readonly value: Top +} + +type CollectOutcome = CollectDone | CollectContinue + +const collectSuccessItem = (result: AnySuccess, waiting: boolean): CollectContinue => { + if (result.waiting) { + return { done: false, waiting: true, value: result.value } + } + return { done: false, waiting, value: result.value } +} + +const collectResultItem = (result: AnyResult, waiting: boolean): CollectOutcome => { + if (!isSuccess(result)) { + return { done: true, result } + } + return collectSuccessItem(result, waiting) +} + +const collectItem = (result: T, waiting: boolean): CollectOutcome => { + if (!isResult(result)) { + return { done: false, waiting, value: result } + } + return collectResultItem(result, waiting) +} + +type AllArrayState = { + waiting: boolean + early: AnyResult | undefined + successes: Top[] +} + +const applyArrayContinue = (state: AllArrayState, outcome: CollectContinue): void => { + state.waiting = outcome.waiting + state.successes.push(outcome.value) +} + +const applyArrayOutcome = (state: AllArrayState, outcome: CollectOutcome): void => { + if (outcome.done) { + state.early = outcome.result + return + } + applyArrayContinue(state, outcome) +} + +const collectIntoArray = (state: AllArrayState, result: T): void => { + if (state.early !== undefined) { + return + } + applyArrayOutcome(state, collectItem(result, state.waiting)) +} + +const finishArray = (state: AllArrayState, timestamp: number): AnyResult => { + if (state.early !== undefined) { + return state.early + } + return successAt(state.successes, { timestamp, waiting: state.waiting }) +} + +const allIterable = (results: Iterable, timestamp: number): AnyResult => { + const state: AllArrayState = { waiting: false, early: undefined, successes: [] } + for (const result of results) { + collectIntoArray(state, result) + } + return finishArray(state, timestamp) +} + +type AllRecordState = { + waiting: boolean + early: AnyResult | undefined + successes: Record +} + +const applyRecordContinue = (state: AllRecordState, key: string, outcome: CollectContinue): void => { + state.waiting = outcome.waiting + state.successes[key] = outcome.value +} + +const applyRecordOutcome = (state: AllRecordState, key: string, outcome: CollectOutcome): void => { + if (outcome.done) { + state.early = outcome.result + return + } + applyRecordContinue(state, key, outcome) +} + +const collectIntoRecord = (state: AllRecordState, key: string, result: T): void => { + if (state.early !== undefined) { + return + } + applyRecordOutcome(state, key, collectItem(result, state.waiting)) +} + +const finishRecord = (state: AllRecordState, timestamp: number): AnyResult => { + if (state.early !== undefined) { + return state.early + } + return successAt(state.successes, { timestamp, waiting: state.waiting }) +} + +const allRecord = (results: Record, timestamp: number): AnyResult => { + const state: AllRecordState = { waiting: false, early: undefined, successes: {} } + for (const [key, result] of Object.entries(results)) { + collectIntoRecord(state, key, result) + } + return finishRecord(state, timestamp) +} + +const allImpl = (results: Iterable | Record, timestamp: number): AnyResult => { + if (isIterable(results)) { + return allIterable(results, timestamp) + } + return allRecord(results, timestamp) +} + +type BuilderFor = Builder< + never, + A extends Success ? _A : never, + A extends Failure ? _E : never, + A extends Initial ? true : never, + A extends Failure ? Defect | Interrupt : never +> + +/** + * Creates a typed builder for rendering an `Result` by handling waiting, initial, success, error, defect, interrupt, and failure cases. + * + * @since 4.0.0 + */ + +export function builder(self: A): BuilderFor + +/** + * The implementation signature is erased because `Builder` is a phantom state + * machine and `BuilderImpl` is not. `Builder` records what has been handled in + * its own parameters - `onError` returns a builder whose error type is `never`, + * `onDefect` drops `Defect` from the outstanding set - while the class keeps one + * mutable value and its parameters unchanged, so no instantiation of it relates + * to `BuilderFor` (measured: TS2322 through `onWaiting` into `onErrorIf`). The + * declaration above is the contract; the class is the mechanism. + */ + +export function builder(self: AnyResult): Top { + return new BuilderImpl(self) +} + +/** + * Type marker used by `Builder` to track whether defect failures still need to be handled. + * + * @since 4.0.0 + */ + +export interface Defect { + readonly _: unique symbol +} + +/** + * Type marker used by `Builder` to track whether interrupt failures still need to be handled. + * + * @since 4.0.0 + */ + +export interface Interrupt { + readonly _: unique symbol +} + +/** + * Fluent renderer for `Result` values that tracks unhandled cases at the type level and exposes `exhaustive` only after all possible cases are handled. + * + * @since 4.0.0 + */ + +export type Builder = + & Pipeable + & { + onWaiting(f: (result: Result) => B): Builder + orElse(orElse: LazyArg): Out | B + orNull(): Out | null + render(): [A | I] extends [never] ? Out : Out | null + } + & ([A | E | I | F] extends [never] ? { + exhaustive(): Out + } + : Top) + & ([I] extends [never] ? Top + : { + onInitial(f: (result: Initial) => B): Builder + onInitialOrWaiting(f: (result: Result) => B): Builder + }) + & ([A] extends [never] ? Top + : { + onSuccess(f: (value: A, result: Success) => B): Builder + }) + & ([E] extends [never] ? Top : { + onError(f: (error: E, result: Failure) => B): Builder + + onErrorIf( + refinement: Refinement, + f: (error: B, result: Failure) => C, + ): Builder>, I, F> + onErrorIf( + predicate: Predicate, + f: (error: E, result: Failure) => C, + ): Builder + + onErrorTag[], B>( + tags: Tags, + f: (error: Types.ExtractTag, result: Failure) => B, + ): Builder, I, F> + onErrorTag, B>( + tag: Tag, + f: (error: Types.ExtractTag, result: Failure) => B, + ): Builder, I, F> + }) + & ([E | F] extends [never] ? Top : { + onFailure(f: (cause: Cause.Cause, result: Failure) => B): Builder + }) + & (Interrupt extends F ? { + onInterrupt( + f: (interruptors: ReadonlySet, result: Failure) => B, + ): Builder> + } + : Top) + & (Defect extends F ? { + onDefect(f: (defect: Top, result: Failure) => B): Builder> + } + : Top) + +const errorMatchesTag = (tag: string | readonly string[], e: E): boolean => { + if (typeof tag === 'string') { + return isTagged(e, tag) + } + return tag.some((t) => isTagged(e, t)) +} + +const defectOption = ( + result: Failure, + f: (defect: Top, result: Failure) => B, +): Option.Option => { + const defect = Cause.findDefect(result.cause) + if (Either.isFailure(defect)) { + return Option.none() + } + return Option.some(f(defect.success, result)) +} + +const interruptOption = ( + result: Failure, + f: (interruptors: ReadonlySet, result: Failure) => B, +): Option.Option => { + const interruptors = Cause.filterInterruptors(result.cause) + if (Either.isFailure(interruptors)) { + return Option.none() + } + return Option.some(f(interruptors.success, result)) +} + +class BuilderImpl extends PipeableModule.Class { + constructor(result: Result) { + super() + this.result = result + } + readonly result: Result + private output: Option.Option = Option.none() + + when, C>( + refinement: Refinement, B>, + f: (result: B) => Option.Option, + ): BuilderImpl + when( + refinement: Predicate>, + f: (result: Result) => Option.Option, + ): BuilderImpl + when( + refinement: Predicate>, + f: (result: Result) => Option.Option, + ): BuilderImpl { + if (Option.isNone(this.output)) { + this.tryWhen(refinement, f) + } + return this + } + + private tryWhen( + refinement: Predicate>, + f: (result: Result) => Option.Option, + ): void { + if (refinement(this.result)) { + this.captureWhen(f(this.result)) + } + } + + private captureWhen(value: Option.Option): void { + if (Option.isSome(value)) { + this.output = value + } + } + + onWaiting(f: (result: Result) => B): BuilderImpl { + return this.when((r) => r.waiting, (r) => Option.some(f(r))) + } + + onInitialOrWaiting(f: (result: Result) => B): BuilderImpl { + return this.when((r) => isInitial(r) || r.waiting, (r) => Option.some(f(r))) + } + + onInitial(f: (result: Initial) => B): BuilderImpl { + return this.when(isInitial, (r) => Option.some(f(r))) + } + + onSuccess(f: (value: A, result: Success) => B): BuilderImpl { + return this.when(isSuccess, (r) => Option.some(f(r.value, r))) + } + + onFailure(f: (cause: Cause.Cause, result: Failure) => B): BuilderImpl { + return this.when(isFailure, (r) => Option.some(f(r.cause, r))) + } + + onError(f: (error: E, result: Failure) => B): BuilderImpl { + return this.onErrorIf(constTrue, f) + } + + onErrorIf( + refinement: Predicate, + f: (error: E, result: Failure) => C, + ): BuilderImpl { + return this.when(isFailure, (result) => + Cause.findErrorOption(result.cause).pipe( + Option.filter(refinement), + Option.map((error) => f(error, result)), + )) + } + + onErrorTag( + tag: string | readonly string[], + f: (error: E, result: Failure) => B, + ): BuilderImpl { + return this.onErrorIf( + (e) => errorMatchesTag(tag, e), + f, + ) + } + + onDefect(f: (defect: Top, result: Failure) => B): BuilderImpl { + return this.when(isFailure, (result) => defectOption(result, f)) + } + + onInterrupt(f: (interruptors: ReadonlySet, result: Failure) => B): BuilderImpl { + return this.when(isFailure, (result) => interruptOption(result, f)) + } + + orElse(orElse: LazyArg): Out | B + orElse(orElse: LazyArg): Top { + return Option.getOrElse(this.output, orElse) + } + + orNull(): Out | null + orNull(): Top { + return Option.getOrNull(this.output) + } + + render(): Out | null + render(): Top { + if (Option.isSome(this.output)) { + return this.output.value + } + return this.renderMissing() + } + + private renderMissing(): Top { + if (isFailure(this.result)) { + throw Cause.squash(this.result.cause) + } + return null + } + + exhaustive(): Out + exhaustive(): Top { + return this.render() + } +} + +/** + * Schema interface for `Result` values, retaining the schemas used for + * success values and failure errors. + * + * @since 4.0.0 + */ + +export interface Schema< + Success extends Schema_.Constraint, + Error extends Schema_.Constraint, +> extends + Schema_.declareConstructor< + Result<(Success | typeof Schema_.Never)['Type'], (Error | typeof Schema_.Never)['Type']>, + Result<(Success | typeof Schema_.Never)['Encoded'], (Error | typeof Schema_.Never)['Encoded']>, + readonly [Success | typeof Schema_.Never, Schema_.Cause] + > +{ + readonly success: Success | typeof Schema_.Never + readonly error: Error | typeof Schema_.Never +} + +const schemaOrNever = ( + schema: A | undefined, +): A | typeof Schema_.Never => { + if (schema === undefined) { + return Schema_.Never + } + return schema +} + +const isSuccessResult = (result: Result): result is Success => hasProperty(result, 'value') + +const isFailureResult = (result: Result): result is Failure => hasProperty(result, 'cause') + +type AnyFailure = Failure + +/** + * Creates a schema for `Result` values using optional schemas for success values and failure errors. + * + * @since 4.0.0 + */ + +export const Schema = < + A extends Schema_.Constraint = typeof Schema_.Never, + E extends Schema_.Constraint = typeof Schema_.Never, +>( + options: { + readonly success?: A | undefined + readonly error?: E | undefined + }, +): Schema => { + const success_ = schemaOrNever(options.success) + const error_ = schemaOrNever(options.error) + const schema = Schema_.declareConstructor< + Result<(A | typeof Schema_.Never)['Type'], (E | typeof Schema_.Never)['Type']>, + Result<(A | typeof Schema_.Never)['Encoded'], (E | typeof Schema_.Never)['Encoded']> + >()( + [success_, Schema_.Cause(error_, Schema_.Defect())], + ([value, cause]) => (input, ast, options) => { + const parseFailureKnown = (failed: AnyFailure) => { + const prevSuccessEffect = failed.previousSuccess.pipe( + Option.map((ps) => + Effect.mapBothEager( + SchemaParser.decodeUnknownEffect(value)(ps.value, options), + { + onSuccess: (value) => Option.some(successAt(value, ps)), + onFailure: (issue) => + new SchemaIssue.Composite( + ast, + [ + new SchemaIssue.Pointer(['previousSuccess', 'value'], issue), + ], + input, + options, + ), + }, + ) + ), + Option.getOrElse(() => Effect.succeedNone), + ) + const causeEffect = Effect.mapErrorEager( + SchemaParser.decodeUnknownEffect(cause)(failed.cause, options), + (issue) => new SchemaIssue.Composite(ast, [new SchemaIssue.Pointer(['cause'], issue)], input, options), + ) + return Effect.flatMapEager( + prevSuccessEffect, + (previousSuccess) => + Effect.mapEager(causeEffect, (cause) => + failure(cause, { + previousSuccess, + waiting: failed.waiting, + })), + ) + } + + const parseSuccessOrInitial = (known: AnyResult) => { + if (isSuccessResult(known)) { + return Effect.mapBothEager( + SchemaParser.decodeUnknownEffect(value)(known.value, options), + { + onSuccess: (value) => successAt(value, known), + onFailure: (issue) => + new SchemaIssue.Composite(ast, [new SchemaIssue.Pointer(['value'], issue)], input, options), + }, + ) + } + return Effect.succeed(initial(known.waiting)) + } + + const parseKnown = (known: AnyResult) => { + if (isFailureResult(known)) { + return parseFailureKnown(known) + } + return parseSuccessOrInitial(known) + } + + if (!isResult(input)) { + return Effect.fail(new SchemaIssue.InvalidType(ast, input, options)) + } + return parseKnown(input) + }, + { + expected: 'Result', + toCodec([value, cause]) { + const SuccessSchema = Schema_.TaggedStruct('Success', { + value, + waiting: Schema_.Boolean, + timestamp: Schema_.Finite, + }) + const encodedSchema = Schema_.Union([ + Schema_.TaggedStruct('Initial', { waiting: Schema_.Boolean }), + SuccessSchema, + Schema_.TaggedStruct('Failure', { + cause, + previousSuccess: Schema_.Option(SuccessSchema), + waiting: Schema_.Boolean, + }), + ]) + return Schema_.link< + Result<(A | typeof Schema_.Never)['Encoded'], (E | typeof Schema_.Never)['Encoded']> + >()( + encodedSchema, + SchemaTransformation.transform({ + decode: (encoded) => { + function decodeRest(rest: typeof encoded) { + if (hasProperty(rest, 'cause')) { + return failure(rest.cause, { + previousSuccess: Option.map(rest.previousSuccess, (ps) => successAt(ps.value, ps)), + waiting: rest.waiting, + }) + } + return initial<(A | typeof Schema_.Never)['Encoded'], (E | typeof Schema_.Never)['Encoded']>( + rest.waiting, + ) + } + if (hasProperty(encoded, 'value')) { + return successAt(encoded.value, { waiting: encoded.waiting, timestamp: encoded.timestamp }) + } + return decodeRest(encoded) + }, + encode(result): (typeof encodedSchema)['Type'] { + function encodeRest(rest: typeof result): (typeof encodedSchema)['Type'] { + if (hasProperty(rest, 'cause')) { + return { + _tag: 'Failure', + cause: rest.cause, + previousSuccess: rest.previousSuccess, + waiting: rest.waiting, + } + } + return { _tag: 'Initial', waiting: rest.waiting } + } + if (hasProperty(result, 'value')) { + return { + _tag: 'Success', + value: result.value, + waiting: result.waiting, + timestamp: result.timestamp, + } + } + return encodeRest(result) + }, + }), + ) + }, + toEquivalence: Equal.asEquivalence, + // Native arbitrary compiles Declaration via toCodecArbitrary. The previous + // `toArbitrary` constructed `Initial`; keep that constructor, now as a + // Schema link, until Cause/Defect generation is a real encoded subset. + toCodecArbitrary: () => + Schema_.link< + Result<(A | typeof Schema_.Never)['Type'], (E | typeof Schema_.Never)['Type']> + >()( + Schema_.TaggedStruct('Initial', { waiting: Schema_.Boolean }), + { + decode: SchemaGetter.transform((encoded) => + initial<(A | typeof Schema_.Never)['Type'], (E | typeof Schema_.Never)['Type']>( + encoded.waiting, + ) + ), + encode: SchemaGetter.transform((result) => ({ + _tag: 'Initial' as const, + waiting: result.waiting, + })), + }, + ), + toFormatter: ([value, cause]) => (t) => { + function formatRest(rest: typeof t) { + if (hasProperty(rest, 'cause')) { + return `Result.Failure(${cause(rest.cause)}, ${rest.waiting})` + } + return `Result.Initial(${rest.waiting})` + } + if (hasProperty(t, 'value')) { + return `Result.Success(${value(t.value)}, ${t.waiting}, ${t.timestamp})` + } + return formatRest(t) + }, + }, + ) + return Object.assign(schema, { + success: success_, + error: error_, + }) +} + +/** + * A codec for `Result` values built from the given success and error schemas. + */ + +export const schemaCodec: { + (error: Schema_.Top): (success: Schema_.Top) => Schema + (success: Schema_.Top, error: Schema_.Top): Schema +} = dual( + 2, + (success: Schema_.Top, error: Schema_.Top): Schema => Schema({ success, error }), +) + +export const ResultSchema: Schema = Schema({ + success: Schema_.Finite, + error: Schema_.String, +}) diff --git a/packages/atom/effect-atom/src/async-result.ts b/packages/atom/effect-atom/src/async-result.ts new file mode 100644 index 0000000..ffc470f --- /dev/null +++ b/packages/atom/effect-atom/src/async-result.ts @@ -0,0 +1,200 @@ +/** + * Clock-reading entry points for the `Result` model. + * + * The type, its schema codec, and every pure operation live in + * `./async-result.schema.js`. The operations here read the clock to default a + * `Success` timestamp: they compute the timestamp and delegate to the schema + * file's pure constructors, so the pure core stays free of clock reads. Every + * public name is re-exported so the `AsyncResult` namespace is unchanged. + * + * @since 4.0.0 + */ +import * as Effect from 'effect/Effect' +import * as Exit from 'effect/Exit' +import { dual } from 'effect/Function' +import * as Option from 'effect/Option' +import { allAt, failure, failureWithPrevious, isResult, isSuccess, successAt } from './async-result.schema.js' +import type { AllError, AllSuccess, Failure, Result, Success } from './async-result.schema.js' + +export type { Builder, Defect, Failure, Initial, Interrupt, Result, Success, With } from './async-result.schema.js' +export { + builder, + cause, + error, + fail, + failure, + failureWithPrevious, + failWith, + failWithPrevious, + flatMap, + getOrElse, + getOrThrow, + initial, + isAsyncResult, + isFailure, + isInitial, + isInterrupted, + isNotInitial, + isResult, + isSuccess, + isWaiting, + map, + match, + matchWithError, + matchWithWaiting, + replacePrevious, + Schema, + schemaCodec, + toExit, + TypeId, + value, + waitingFrom, +} from './async-result.schema.js' + +type Top = A + +type AnyResult = Result + +type TimestampOptions = { + readonly timestamp?: number | undefined +} + +const now = (): number => Effect.runSync(Effect.clockWith((clock) => clock.currentTimeMillis)) + +const timestampFromDefined = (options: TimestampOptions): number => { + if (options.timestamp === undefined) { + return now() + } + return options.timestamp +} + +const timestampOption = (options: TimestampOptions | undefined): number => { + if (options === undefined) { + return now() + } + return timestampFromDefined(options) +} + +/** + * @since 4.0.0 + */ +export const success = (value: A): Success => successWith(value, {}) + +/** + * Creates a `Success` result, defaulting its timestamp to the current time when the options omit one. + * + * @since 4.0.0 + */ +export const successWith: { + (options: { + readonly waiting?: boolean | undefined + readonly timestamp?: number | undefined + }): (value: A) => Success + (value: A, options: { + readonly waiting?: boolean | undefined + readonly timestamp?: number | undefined + }): Success +} = dual( + 2, + (value: A, options: TimestampOptions & { readonly waiting?: boolean | undefined }): Success => + successAt(value, { waiting: options.waiting ?? false, timestamp: timestampOption(options) }), +) + +type TouchOptions = { + readonly touch?: boolean | undefined +} + +const maybeTouchDefined = (self: R, options: TouchOptions): R => { + if (options.touch === true) { + return touch(self) + } + return self +} + +const maybeTouch = (self: R, options: TouchOptions | undefined): R => { + if (options === undefined) { + return self + } + return maybeTouchDefined(self, options) +} + +/** + * Marks an `Result` as waiting, optionally touching the timestamp when the result is a `Success`. + * + * @since 4.0.0 + */ +export const waiting: { + (options?: { + readonly touch?: boolean | undefined + }): (self: R) => R + (self: R, options?: { + readonly touch?: boolean | undefined + }): R +} = dual( + (args) => isResult(args[0]), + (self: R, options?: { + readonly touch?: boolean | undefined + }): R => { + if (self.waiting) { + return maybeTouch(self, options) + } + return maybeTouch({ ...self, waiting: true }, options) + }, +) + +/** + * Refreshes the timestamp of a `Success` result while preserving its value and waiting flag; non-success results are returned unchanged. + * + * @since 4.0.0 + */ +export const touch = (result: A): A => { + if (isSuccess(result)) { + return { ...result, timestamp: now() } + } + return result +} + +/** + * Converts an `Exit` into a `Success` when it succeeds or a `Failure` carrying the exit cause when it fails. + * + * @since 4.0.0 + */ +export const fromExit = (exit: Exit.Exit): Success | Failure => { + if (Exit.isSuccess(exit)) { + return success(exit.value) + } + return failure(exit.cause) +} + +/** + * Converts an `Exit` to a result, preserving the latest previous success when the exit is a failure. + * + * @since 4.0.0 + */ +export const fromExitWithPrevious: { + (previous: Option.Option>): (exit: Exit.Exit) => Success | Failure + (exit: Exit.Exit, previous: Option.Option>): Success | Failure +} = dual( + 2, + ( + exit: Exit.Exit, + previous: Option.Option>, + ): Success | Failure => { + if (Exit.isSuccess(exit)) { + return success(exit.value) + } + return failureWithPrevious(exit.cause, { previous }) + }, +) + +/** + * Combines an iterable or record of `Result` and plain values into one `Result`, returning the first non-success result or a success of the collected values marked waiting when any input success is waiting. + * + * @since 4.0.0 + */ +export function all | Record>( + results: Arg, +): Result, AllError> +export function all(results: Iterable | Record): AnyResult { + return allAt(results, now()) +} diff --git a/packages/atom/effect-atom/src/atom-browser.ts b/packages/atom/effect-atom/src/atom-browser.ts new file mode 100644 index 0000000..57eaf5b --- /dev/null +++ b/packages/atom/effect-atom/src/atom-browser.ts @@ -0,0 +1,334 @@ +/** + * Browser-only `Atom` helpers. + * + * This module holds the parts of `Atom` that touch `window`, `history`, or + * `document`: window focus tracking and URL search parameter atoms. All + * exports are re-exported from `Atom` so consumers keep importing everything + * from there. + * + * @since 4.0.0 + */ +import * as Exit from 'effect/Exit' +import * as Option from 'effect/Option' +import * as Schema from 'effect/Schema' +import { makeRefreshOnSignal, readable, writable } from './atom.blueprint.js' +import type { Atom, WithoutSerializable, Writable, WriteContext } from './atom.blueprint.js' +import type { RegistryImpl } from './registry-engine.js' +import * as Registry from './registry.handle.js' +import { type SearchParamCoordinator, SearchParamUpdates } from './search-param.service.js' + +type AnyAtom = Atom +type StringCodec = Schema.ConstraintCodec +type Top = A + +// ----------------------------------------------------------------------------- +// Focus +// ----------------------------------------------------------------------------- + +/** + * Creates a browser-only signal atom that increments when the document becomes visible. + * + * **Details** + * + * It listens for `visibilitychange` events on `window` and removes the listener + * when the atom is disposed. + * + * @since 4.0.0 + */ +export const windowFocusSignal: Atom = readable((get) => { + let count = 0 + function update() { + if (document.visibilityState === 'visible') { + get.setSelf(++count) + } + } + window.addEventListener('visibilitychange', update) + get.addFinalizer(() => { + window.removeEventListener('visibilitychange', update) + }) + return count +}) + +/** + * Refreshes an atom whenever `windowFocusSignal` changes. + * + * **Details** + * + * This helper is browser-only because `windowFocusSignal` depends on `window` and + * `document.visibilityState`. + * + * @since 4.0.0 + */ +export const refreshOnWindowFocus: (self: A) => WithoutSerializable = makeRefreshOnSignal( + windowFocusSignal, +) + +// ----------------------------------------------------------------------------- +// URL search params +// ----------------------------------------------------------------------------- + +/** + * Creates an atom that reads and writes a URL search parameter. + * + * **Gotchas** + * + * If you pass a schema, it has to be synchronous and have no context. + * + * @since 4.0.0 + */ +export function searchParam(options?: { + readonly schema?: S | undefined +}): (name: string) => Writable<[S] extends [never] ? string : Option.Option> +export function searchParam( + name: string, + options?: { + readonly schema?: S | undefined + }, +): Writable<[S] extends [never] ? string : Option.Option> +export function searchParam( + ...args: readonly [ + nameOrOptions?: string | { + readonly schema?: StringCodec | undefined + }, + options?: { + readonly schema?: StringCodec | undefined + }, + ] +): Top { + const [nameOrOptions, options] = args + if (typeof nameOrOptions === 'string') { + return makeSearchParam(nameOrOptions, options) + } + return (name: string) => makeSearchParam(name, nameOrOptions) +} + +function makeSearchParam( + name: string, + options?: { + readonly schema?: S | undefined + }, +): Writable | S['Type'], string | Option.Option> { + type R = string | Option.Option | S['Type'] + type W = string | Option.Option + const decode = schemaDecoder(options) + const encode = schemaEncoder(options) + return writable( + (get): R => { + if (typeof window === 'undefined') { + return readWithoutWindow() + } + return readWithWindow(get) + }, + (ctx: WriteContext, value: W) => { + if (typeof window === 'undefined') { + ctx.setSelf(value) + return + } + writeWithWindow(ctx, value) + }, + ) + + function readWithoutWindow(): R { + if (decode !== undefined) { + return Option.none() + } + return '' + } + + function readWithWindow(get: { + readonly addFinalizer: (f: () => void) => void + readonly registry: RegistryImpl + readonly setSelf: (value: R) => void + readonly self: () => Option.Option + }): R { + const coordinator = coordinatorOf(get.registry.handle) + const handleUpdate = () => { + if (coordinator.updating === true) { + return + } + applyWindowUpdate(get) + } + window.addEventListener('popstate', handleUpdate) + window.addEventListener('pushstate', handleUpdate) + get.addFinalizer(() => { + window.removeEventListener('popstate', handleUpdate) + window.removeEventListener('pushstate', handleUpdate) + }) + const value = paramOrEmpty(new URLSearchParams(window.location.search).get(name)) + return decodeSearchValue(value) + } + + function applyWindowUpdate(get: { + readonly setSelf: (value: R) => void + readonly self: () => Option.Option + }): void { + const newValue = paramOrEmpty(new URLSearchParams(window.location.search).get(name)) + if (decode !== undefined) { + get.setSelf(Exit.getSuccess(decode(newValue))) + return + } + applyPlainWindowUpdate(get, newValue) + } + + function applyPlainWindowUpdate( + get: { + readonly setSelf: (value: R) => void + readonly self: () => Option.Option + }, + newValue: string, + ): void { + if (newValue !== Option.getOrUndefined(get.self())) { + get.setSelf(newValue) + } + } + + function decodeSearchValue(value: string): R { + if (decode !== undefined) { + return Exit.getSuccess(decode(value)) + } + return value + } + + function writeWithWindow(ctx: WriteContext, value: W): void { + const coordinator = ctx.get(searchParamCoordinator) + const encoder = encode + if (encoder !== undefined) { + writeEncoded(ctx, value, encoder, coordinator) + } else { + writePlain(ctx, value, coordinator) + } + scheduleSearchParamUpdate(coordinator) + } + + function writeEncoded( + ctx: WriteContext, + value: W, + encoder: NonNullable, + coordinator: SearchParamCoordinator, + ): void { + const encoded = Option.flatMap( + optionValue(value), + (v) => Exit.getSuccess(encoder(v)), + ) + coordinator.updates.set(name, Option.getOrElse(encoded, () => '')) + if (Option.isOption(value)) { + ctx.setSelf(Option.zipRight(encoded, value)) + } + } + + function writePlain(ctx: WriteContext, value: W, coordinator: SearchParamCoordinator): void { + if (typeof value === 'string') { + coordinator.updates.set(name, value) + ctx.setSelf(value) + } + } +} + +const optionValue = (value: A | Option.Option): Option.Option => { + if (Option.isOption(value)) { + return value + } + return Option.none() +} + +const makeSearchParamCoordinator = (registry: Registry.Registry): SearchParamCoordinator => ({ + generation: 0, + updates: new Map(), + updating: false, + registry, +}) + +const coordinatorOf = (registry: Registry.Registry): SearchParamCoordinator => + Registry.storage(registry, SearchParamUpdates, () => makeSearchParamCoordinator(registry)) + +/** + * Resolves the evaluating registry's coordinator. + * + * **Details** + * + * Write contexts cannot see the registry they run in, so a write reads this + * atom to reach the coordinator that batches its URL update. + */ +const searchParamCoordinator: Atom = readable((get) => coordinatorOf(get.registry.handle)) + +const SEARCH_PARAM_UPDATE_DELAY_MILLIS = 500 + +const scheduleSearchParamUpdate = (coordinator: SearchParamCoordinator): void => { + coordinator.generation++ + const generation = coordinator.generation + Registry.scheduleTimer(coordinator.registry, () => { + runScheduledSearchParamUpdate(coordinator, generation) + }, SEARCH_PARAM_UPDATE_DELAY_MILLIS) +} + +const runScheduledSearchParamUpdate = (coordinator: SearchParamCoordinator, generation: number): void => { + if (coordinator.generation === generation) { + updateSearchParams(coordinator) + } +} + +function updateSearchParams(coordinator: SearchParamCoordinator): void { + coordinator.updating = true + const searchParams = new URLSearchParams(window.location.search) + for (const [key, value] of coordinator.updates.entries()) { + applySearchParam(searchParams, key, value) + } + coordinator.updates.clear() + const newUrl = `${window.location.pathname}?${searchParams.toString()}` + window.history.pushState({}, '', newUrl) + coordinator.updating = false +} + +const schemaDecoder = ( + options?: { + readonly schema?: S | undefined + }, +) => { + if (options === undefined) { + return undefined + } + return schemaCodec(options.schema, Schema.decodeExit) +} + +const schemaEncoder = ( + options?: { + readonly schema?: S | undefined + }, +) => { + if (options === undefined) { + return undefined + } + return schemaCodec(options.schema, Schema.encodeExit) +} + +const schemaCodec = ( + schema: S | undefined, + codec: (schema: S) => C, +): C | undefined => { + if (schema === undefined) { + return undefined + } + return codec(schema) +} + +const paramOrEmpty = (value: string | null): string => { + if (value === null) { + return '' + } + return emptyToEmpty(value) +} + +const emptyToEmpty = (value: string): string => { + if (value === '') { + return '' + } + return value +} + +const applySearchParam = (searchParams: URLSearchParams, key: string, value: string): void => { + if (value.length > 0) { + searchParams.set(key, value) + return + } + searchParams.delete(key) +} diff --git a/packages/atom/effect-atom/src/atom-http-api.service.ts b/packages/atom/effect-atom/src/atom-http-api.service.ts new file mode 100644 index 0000000..34b920d --- /dev/null +++ b/packages/atom/effect-atom/src/atom-http-api.service.ts @@ -0,0 +1,705 @@ +/** + * Connects typed `HttpApi` clients to atoms. + * + * The service created here exposes the generated HTTP API client plus + * atom-based query and mutation helpers. Query atoms call endpoints and track + * their asynchronous result, while mutations run endpoint calls that can + * invalidate reactivity keys after a successful request. Query atoms can also be + * cached, serialized for hydration, and kept alive with a time-to-live. + * + * @since 4.0.0 + */ +import * as Context from 'effect/Context' +import * as Duration from 'effect/Duration' +import * as Effect from 'effect/Effect' +import type * as HttpApi from 'effect/http-api/HttpApi' +import * as HttpApiClient from 'effect/http-api/HttpApiClient' +import * as HttpApiEndpoint from 'effect/http-api/HttpApiEndpoint' +import type * as HttpApiGroup from 'effect/http-api/HttpApiGroup' +import type * as HttpApiMiddleware from 'effect/http-api/HttpApiMiddleware' +import * as HttpApiSchema from 'effect/http-api/HttpApiSchema' +import type * as HttpClient from 'effect/http/HttpClient' +import * as HttpClientError from 'effect/http/HttpClientError' +import type { HttpClientResponse } from 'effect/http/HttpClientResponse' +import * as Layer from 'effect/Layer' +import * as Predicate from 'effect/Predicate' +import * as Reactivity from 'effect/reactivity/Reactivity' +import type { ReadonlyRecord } from 'effect/Record' +import * as Schema from 'effect/Schema' +import type { Simplify } from 'effect/Types' +import * as AsyncResult from './async-result.js' +import { schemaCodec } from './async-result.js' +import * as Atom from './atom-modules.js' +type AnyAtom = Atom.Atom +type Top = A +type ReactivityKeys = readonly K[] | ReadonlyRecord +type AnyEffect = Effect.Effect +// rc.108 does not expose HttpApiEndpoint.getSuccessSchemas/getErrorSchemas (added upstream +// after rc.108); replicate them against the public .success/.error schema sets. +const getSuccessSchemas = (endpoint: HttpApiEndpoint.Top): readonly [Schema.Top, ...Array] => { + const [first, ...rest] = Array.from(endpoint.success) + if (first === undefined) { + return [HttpApiSchema.NoContent] + } + return [first, ...rest] +} +const getErrorSchemas = (endpoint: HttpApiEndpoint.Top): readonly Schema.Top[] => Array.from(endpoint.error) + +const isGroupCandidate = (candidate: unknown, group: string): candidate is object => + Predicate.hasProperty(candidate, 'identifier') && candidate.identifier === group + +const endpointsOf = (candidate: object): object | undefined => { + const endpoints: Top = Reflect.get(candidate, 'endpoints') + return Predicate.isObject(endpoints) ? endpoints : undefined +} + +const isMatchingEndpoint = (definition: unknown, endpoint: string): definition is HttpApiEndpoint.Top => + HttpApiEndpoint.isHttpApiEndpoint(definition) && definition.identifier === endpoint + +const matchingEndpoint = ( + endpoints: object, + endpoint: string, +): HttpApiEndpoint.Top | undefined => { + const values = Object.values(endpoints) + return values.find((definition): definition is HttpApiEndpoint.Top => isMatchingEndpoint(definition, endpoint)) +} + +const findEndpointInGroup = ( + candidate: object, + endpoint: string, +): HttpApiEndpoint.Top | undefined => { + const endpoints = endpointsOf(candidate) + if (endpoints === undefined) { + return undefined + } + return matchingEndpoint(endpoints, endpoint) +} + +const requireEndpoint = ( + candidate: object, + group: string, + endpoint: string, +): HttpApiEndpoint.Top => { + const found = findEndpointInGroup(candidate, endpoint) + if (found === undefined) { + throw new Error(`Unknown endpoint: ${group}.${endpoint}`) + } + return found +} + +const endpointFor = ( + groups: ReadonlyArray, + group: string, + endpoint: string, +): HttpApiEndpoint.Top => { + const candidate = groups.find((value): value is object => isGroupCandidate(value, group)) + if (candidate === undefined) { + throw new Error(`Unknown endpoint: ${group}.${endpoint}`) + } + return requireEndpoint(candidate, group, endpoint) +} + +interface EndpointRequest

{ + readonly params?: P + readonly query?: Q + readonly payload?: Pl + readonly headers?: H + readonly responseMode?: HttpApiEndpoint.ClientResponseMode | undefined +} + +interface EndpointCall { + ( + request: EndpointRequest, + ): Effect.Effect +} + +const isEndpointCall = (u: unknown): u is EndpointCall => Predicate.isFunction(u) + +const propertyOf = (client: C, group: string): Top => { + if (!Predicate.isObjectKeyword(client)) { + return undefined + } + const prop: Top = Reflect.get(client, group) + return prop +} + +const groupEntryOf = (client: C, group: string): object => { + const groupEntry = propertyOf(client, group) + if (!Predicate.isObject(groupEntry)) { + throw new Error(`Unknown API group: ${group}`) + } + return groupEntry +} + +const callFromGroup = ( + groupEntry: object, + group: string, + endpoint: string, + request: EndpointRequest, +): Effect.Effect => { + const call: Top = Reflect.get(groupEntry, endpoint) + if (!isEndpointCall(call)) { + throw new Error(`Unknown endpoint: ${group}.${endpoint}`) + } + return call(request) +} + +const callEndpoint = ( + client: C, + group: string, + endpoint: string, + request: EndpointRequest, +): Effect.Effect => + callFromGroup(groupEntryOf(client, group), group, endpoint, request) + +const isDieError = ( + e: HttpClientError.HttpClientError | Schema.SchemaError | Error, +): e is HttpClientError.HttpClientError | Schema.SchemaError => { + if (Schema.isSchemaError(e)) { + return true + } + return HttpClientError.isHttpClientError(e) +} + +const catchError = ( + e: HttpClientError.HttpClientError | Schema.SchemaError | Error, +): Effect.Effect => { + if (isDieError(e)) { + return Effect.die(e) + } + return Effect.fail(e) +} + +const catchErrors = Effect.catch(catchError) + +const runtimeFactoryOf = (runtime: Atom.RuntimeFactory | undefined): Atom.RuntimeFactory => { + if (runtime === undefined) { + return Atom.context() + } + return runtime +} + +const clientLayer = ( + service: Context.Key, + make: Effect.Effect, + client: Layer.Layer | ((get: Atom.AtomContext) => Layer.Layer), +) => { + const layer = Layer.effect(service, make) + if (typeof client === 'function') { + return (get: Atom.AtomContext) => Layer.provide(layer, client(get)) + } + return Layer.provide(layer, client) +} + +const responseModeOrDecoded = ( + responseMode: HttpApiEndpoint.ClientResponseMode | undefined, +): HttpApiEndpoint.ClientResponseMode => { + if (responseMode === undefined) { + return 'decoded-only' + } + return responseMode +} + +const responseModeFromOptions = ( + options?: { + readonly responseMode?: HttpApiEndpoint.ClientResponseMode | undefined + }, +): HttpApiEndpoint.ClientResponseMode => { + if (options === undefined) { + return 'decoded-only' + } + return responseModeOrDecoded(options.responseMode) +} + +const withReactivityKeys = ( + effect: Effect.Effect, + reactivityKeys: ReactivityKeys | undefined, +): Effect.Effect => { + if (reactivityKeys === undefined) { + return effect + } + return Reactivity.mutation(effect, reactivityKeys) +} + +const hasSerializationKey = (key: string | undefined): key is string => { + if (key === undefined) { + return false + } + return key !== '' +} + +const durationFromInput = (timeToLive: Duration.Input | undefined): Duration.Duration | undefined => { + if (timeToLive === undefined) { + return undefined + } + return Duration.fromInputUnsafe(timeToLive) +} + +const applyQueryTtl = (atom: Atom.Atom, timeToLive: Duration.Duration): Atom.Atom => { + if (Duration.isFinite(timeToLive)) { + return Atom.setIdleTTL(atom, timeToLive) + } + return Atom.keepAlive(atom) +} + +const withQueryTtl = (atom: Atom.Atom, timeToLive: Duration.Duration | undefined): Atom.Atom => { + if (timeToLive === undefined) { + return atom + } + return applyQueryTtl(atom, timeToLive) +} + +/** + * A `Context.Service` for an HTTP API client integrated with atom reactivity. + * + * **Details** + * + * It exposes the generated HTTP API client, an atom runtime, mutation helpers that + * return `AtomResultFn`s, and query helpers that return atoms of endpoint results. + * + * @since 4.0.0 + */ +export interface AtomHttpApiClient + extends Context.Service> +{ + new(_: never): Context.ServiceClass.Shape> + + readonly runtime: Atom.AtomRuntime + + readonly mutation: < + GroupIdentifier extends HttpApiGroup.Identifier, + EndpointIdentifier extends HttpApiEndpoint.Identifier>, + Group extends HttpApiGroup.WithIdentifier = HttpApiGroup.WithIdentifier< + Groups, + GroupIdentifier + >, + Endpoint extends HttpApiEndpoint.WithIdentifier< + HttpApiGroup.Endpoints, + EndpointIdentifier + > = HttpApiEndpoint.WithIdentifier< + HttpApiGroup.Endpoints, + EndpointIdentifier + >, + const ResponseMode extends HttpApiEndpoint.ClientResponseMode = HttpApiEndpoint.ClientResponseMode, + >( + group: GroupIdentifier, + endpoint: EndpointIdentifier, + options?: { + readonly responseMode?: ResponseMode | undefined + }, + ) => [Endpoint] extends [ + HttpApiEndpoint.HttpApiEndpoint< + infer _Identifier, + infer _Method, + infer _Path, + infer _Params, + infer _Query, + infer _Payload, + infer _Headers, + infer _Success, + infer _Error, + infer _Middleware, + infer _RE + >, + ] ? Atom.AtomResultFn< + Simplify< + HttpApiEndpoint.ClientRequest<_Params, _Query, _Payload, _Headers, 'decoded-only'> & { + readonly reactivityKeys?: ReactivityKeys | undefined + } + >, + ResponseByMode['Type'], ResponseMode>, + ErrorByMode<_Error, _Middleware, ResponseMode> + > + : never + + readonly query: < + GroupIdentifier extends HttpApiGroup.Identifier, + EndpointIdentifier extends HttpApiEndpoint.Identifier>, + Group extends HttpApiGroup.WithIdentifier = HttpApiGroup.WithIdentifier< + Groups, + GroupIdentifier + >, + Endpoint extends HttpApiEndpoint.WithIdentifier< + HttpApiGroup.Endpoints, + EndpointIdentifier + > = HttpApiEndpoint.WithIdentifier< + HttpApiGroup.Endpoints, + EndpointIdentifier + >, + const ResponseMode extends HttpApiEndpoint.ClientResponseMode = 'decoded-only', + >( + group: GroupIdentifier, + endpoint: EndpointIdentifier, + request: [Endpoint] extends [ + HttpApiEndpoint.HttpApiEndpoint< + infer _Identifier, + infer _Method, + infer _Path, + infer _Params, + infer _Query, + infer _Payload, + infer _Headers, + infer _Success, + infer _Error, + infer _R, + infer _RE + >, + ] ? Simplify< + HttpApiEndpoint.ClientRequest<_Params, _Query, _Payload, _Headers, ResponseMode> & { + readonly reactivityKeys?: ReactivityKeys | undefined + readonly timeToLive?: Duration.Input | undefined + readonly serializationKey?: string | undefined + } + > + : never, + ) => [Endpoint] extends [ + HttpApiEndpoint.HttpApiEndpoint< + infer _Identifier, + infer _Method, + infer _Path, + infer _Params, + infer _Query, + infer _Payload, + infer _Headers, + infer _Success, + infer _Error, + infer _Middleware, + infer _RE + >, + ] ? Atom.Atom< + AsyncResult.Result< + ResponseByMode['Type'], ResponseMode>, + ErrorByMode<_Error, _Middleware, ResponseMode> + > + > + : never +} + +declare global { + interface ErrorConstructor { + stackTraceLimit: number + } +} + +/** + * Creates a `Context.Service` class for an HTTP API client backed by an atom + * runtime. + * + * **Details** + * + * The options provide the API definition, HTTP client layer, optional client and + * response transforms, base URL, and runtime factory used by the query and + * mutation helpers. + * + * @since 4.0.0 + */ +export const Service = + () => + ( + id: Id, + options: { + readonly api: HttpApi.HttpApi + readonly httpClient: + | Layer.Layer< + | HttpApiGroup.ClientServices + | HttpApiGroup.MiddlewareClient + | HttpClient.HttpClient + > + | ((get: Atom.AtomContext) => Layer.Layer< + | HttpApiGroup.ClientServices + | HttpApiGroup.MiddlewareClient + | HttpClient.HttpClient + >) + readonly transformClient?: ((client: HttpClient.HttpClient) => HttpClient.HttpClient) | undefined + readonly transformResponse?: + | ((effect: AnyEffect) => AnyEffect) + | undefined + readonly baseUrl?: URL | string | undefined + readonly runtime?: Atom.RuntimeFactory | undefined + }, + ): AtomHttpApiClient => { + const service = Context.Service< + Self, + HttpApiClient.Client + >()(id) + + const runtime = runtimeFactoryOf(options.runtime)( + clientLayer(service, HttpApiClient.make(options.api, options), options.httpClient), + ) + + const mutationFamily = Atom.family(({ endpoint, group, responseMode }: MutationKey) => { + const fnAtom = runtime.fn()( + Effect.fnUntraced(function*(opts) { + const client = yield* service + const effect = catchErrors(callEndpoint(client, group, endpoint, { + ...opts, + responseMode, + })) + return yield* withReactivityKeys(effect, opts.reactivityKeys) + }), + ) + if (responseMode !== 'decoded-only') { + return fnAtom + } + const definition = endpointFor(Object.values(options.api.groups), group, endpoint) + return Atom.serializable(fnAtom, { + key: `AtomHttpApi:mutation:${group}:${endpoint}`, + schema: schemaCodec( + Schema.Union(definition.pipe(getSuccessSchemas)), + Schema.Union(definition.pipe(getErrorSchemas)), + ), + }) + }) + + type MutationReturn< + Endpoint extends HttpApiEndpoint.Constraint, + ResponseMode extends HttpApiEndpoint.ClientResponseMode, + > = [Endpoint] extends [ + HttpApiEndpoint.HttpApiEndpoint< + infer _Identifier, + infer _Method, + infer _Path, + infer _Params, + infer _Query, + infer _Payload, + infer _Headers, + infer _Success, + infer _Error, + infer _Middleware, + infer _RE + >, + ] ? Atom.AtomResultFn< + Simplify< + HttpApiEndpoint.ClientRequest<_Params, _Query, _Payload, _Headers, 'decoded-only'> & { + readonly reactivityKeys?: ReactivityKeys | undefined + } + >, + ResponseByMode['Type'], ResponseMode>, + ErrorByMode<_Error, _Middleware, ResponseMode> + > + : never + + function mutation< + GroupIdentifier extends HttpApiGroup.Identifier, + EndpointIdentifier extends HttpApiEndpoint.Identifier>, + Group extends HttpApiGroup.WithIdentifier = HttpApiGroup.WithIdentifier< + Groups, + GroupIdentifier + >, + Endpoint extends HttpApiEndpoint.WithIdentifier< + HttpApiGroup.Endpoints, + EndpointIdentifier + > = HttpApiEndpoint.WithIdentifier< + HttpApiGroup.Endpoints, + EndpointIdentifier + >, + const ResponseMode extends HttpApiEndpoint.ClientResponseMode = HttpApiEndpoint.ClientResponseMode, + >( + group: GroupIdentifier, + endpoint: EndpointIdentifier, + options?: { + readonly responseMode?: ResponseMode | undefined + }, + ): MutationReturn + function mutation( + group: string, + endpoint: string, + options?: { + readonly responseMode?: HttpApiEndpoint.ClientResponseMode | undefined + }, + ): AnyAtom { + return mutationFamily({ + group, + endpoint, + responseMode: responseModeFromOptions(options), + }) + } + + const withQueryReactivity = ( + atom: A, + reactivityKeys: ReactivityKeys | undefined, + ): A => { + if (reactivityKeys === undefined) { + return atom + } + return runtime.factory.withReactivity(reactivityKeys)(atom) + } + + const serializeIfKeyed = ( + atom: A, + opts: QueryKey, + ): A => { + const serializationKey = opts.serializationKey + if (!hasSerializationKey(serializationKey)) { + return atom + } + const endpoint = endpointFor(Object.values(options.api.groups), opts.group, opts.endpoint) + return Atom.serializable(atom, { + key: `AtomHttpApi:${opts.group}:${opts.endpoint}:${serializationKey}`, + schema: schemaCodec( + Schema.Union(endpoint.pipe(getSuccessSchemas)), + Schema.Union(endpoint.pipe(getErrorSchemas)), + ), + }) + } + + const withQuerySerialization = ( + atom: A, + opts: QueryKey, + ): A => { + if (opts.responseMode !== 'decoded-only') { + return atom + } + return serializeIfKeyed(atom, opts) + } + + const queryFamily = Atom.family((opts: QueryKey) => + withQueryTtl( + withQuerySerialization( + withQueryReactivity( + runtime.atom( + service.use((client) => catchErrors(callEndpoint(client, opts.group, opts.endpoint, opts))), + ), + opts.reactivityKeys, + ), + opts, + ), + opts.timeToLive, + ) + ) + + type QueryRequest< + Endpoint extends HttpApiEndpoint.Constraint, + ResponseMode extends HttpApiEndpoint.ClientResponseMode, + > = [Endpoint] extends [ + HttpApiEndpoint.HttpApiEndpoint< + infer _Identifier, + infer _Method, + infer _Path, + infer _Params, + infer _Query, + infer _Payload, + infer _Headers, + infer _Success, + infer _Error, + infer _R, + infer _RE + >, + ] ? Simplify< + HttpApiEndpoint.ClientRequest<_Params, _Query, _Payload, _Headers, ResponseMode> & { + readonly reactivityKeys?: ReactivityKeys | undefined + readonly timeToLive?: Duration.Input | undefined + readonly serializationKey?: string | undefined + } + > + : never + + type QueryReturn< + Endpoint extends HttpApiEndpoint.Constraint, + ResponseMode extends HttpApiEndpoint.ClientResponseMode, + > = [Endpoint] extends [ + HttpApiEndpoint.HttpApiEndpoint< + infer _Identifier, + infer _Method, + infer _Path, + infer _Params, + infer _Query, + infer _Payload, + infer _Headers, + infer _Success, + infer _Error, + infer _Middleware, + infer _RE + >, + ] ? Atom.Atom< + AsyncResult.Result< + ResponseByMode['Type'], ResponseMode>, + ErrorByMode<_Error, _Middleware, ResponseMode> + > + > + : never + + function query< + GroupIdentifier extends HttpApiGroup.Identifier, + EndpointIdentifier extends HttpApiEndpoint.Identifier>, + Group extends HttpApiGroup.WithIdentifier = HttpApiGroup.WithIdentifier< + Groups, + GroupIdentifier + >, + Endpoint extends HttpApiEndpoint.WithIdentifier< + HttpApiGroup.Endpoints, + EndpointIdentifier + > = HttpApiEndpoint.WithIdentifier< + HttpApiGroup.Endpoints, + EndpointIdentifier + >, + const ResponseMode extends HttpApiEndpoint.ClientResponseMode = 'decoded-only', + >( + group: GroupIdentifier, + endpoint: EndpointIdentifier, + request: QueryRequest, + ): QueryReturn + function query( + group: string, + endpoint: string, + request: EndpointRequest & { + readonly reactivityKeys?: ReactivityKeys | undefined + readonly timeToLive?: Duration.Input | undefined + readonly serializationKey?: string | undefined + }, + ): AnyAtom { + const key: QueryKey = { + group, + endpoint, + params: request.params, + query: request.query, + payload: request.payload, + headers: request.headers, + responseMode: responseModeOrDecoded(request.responseMode), + reactivityKeys: request.reactivityKeys, + timeToLive: durationFromInput(request.timeToLive), + serializationKey: request.serializationKey, + } + return queryFamily(key) + } + + const client: AtomHttpApiClient = Object.assign(service, { + runtime, + mutation, + query, + }) + return client + } + +interface MutationKey { + group: string + endpoint: string + responseMode: HttpApiEndpoint.ClientResponseMode +} + +interface QueryKey { + group: string + endpoint: string + params: Top + query: Top + headers: Top + payload: Top + responseMode: HttpApiEndpoint.ClientResponseMode + reactivityKeys: ReactivityKeys | undefined + timeToLive: Duration.Duration | undefined + serializationKey: string | undefined +} + +type ResponseByMode = [ResponseMode] extends + ['decoded-and-response'] ? [Success, HttpClientResponse] + : [ResponseMode] extends ['response-only'] ? HttpClientResponse + : Success + +type ErrorByMode< + Error extends Schema.Constraint, + Middleware, + ResponseMode extends HttpApiEndpoint.ClientResponseMode, +> = + | HttpApiMiddleware.Error + | HttpApiMiddleware.ClientError + | ([ResponseMode] extends ['response-only'] ? never : Error['Type']) diff --git a/packages/atom/effect-atom/src/atom-kvs.ts b/packages/atom/effect-atom/src/atom-kvs.ts new file mode 100644 index 0000000..33e5033 --- /dev/null +++ b/packages/atom/effect-atom/src/atom-kvs.ts @@ -0,0 +1,269 @@ +import type { LazyArg } from 'effect/Function' +import * as Option from 'effect/Option' +import * as KeyValueStore from 'effect/persistence/KeyValueStore' +import * as Schema from 'effect/Schema' +import * as AsyncResult from './async-result.js' +import { + type Atom, + type AtomContext, + type AtomResultFn, + type AtomRuntime, + type Writable, + writable, + type WriteContext, +} from './atom.blueprint.js' + +type Top = A + +function readKvs, Mode extends 'sync' | 'async'>( + get: AtomContext, + options: { + readonly runtime: AtomRuntime + readonly key: string + readonly schema: S + readonly defaultValue: LazyArg + readonly mode?: Mode | undefined + }, + setAtom: AtomResultFn, + resultAtom: Atom, + isWritten: () => boolean, + setWritten: (written: boolean) => void, +): AsyncResult.Result | S['Type'] { + if (options.mode === 'async') { + return readKvsAsync(get, options, setAtom, resultAtom, isWritten, setWritten) + } + return readKvsSync(get, options, setAtom, resultAtom, isWritten, setWritten) +} + +function readKvsAsync>( + get: AtomContext, + options: { + readonly defaultValue: LazyArg + }, + setAtom: AtomResultFn, + resultAtom: Atom, + isWritten: () => boolean, + setWritten: (written: boolean) => void, +): AsyncResult.Result { + setWritten(false) + get.mount(setAtom) + get.subscribe(resultAtom, (result) => onKvsAsyncResult(get, options, setAtom, isWritten, result)) + return kvsAsyncValue(get, options, resultAtom) +} + +function kvsHasStoreOption(result: unknown): result is AsyncResult.Success, Top> { + if (AsyncResult.isResult(result) === false) { + return false + } + return kvsSuccessHasOption(result) +} + +function kvsSuccessHasOption( + result: AsyncResult.Result, +): result is AsyncResult.Success, Top> { + if (AsyncResult.isSuccess(result) === false) { + return false + } + return Option.isOption(result.value) +} + +function kvsAsyncValue>( + get: AtomContext, + options: { readonly defaultValue: LazyArg }, + resultAtom: Atom, +): AsyncResult.Result { + const result = get.once(resultAtom) + if (kvsHasStoreOption(result) === false) { + return AsyncResult.initial() + } + return kvsAsyncFromOption(options, result.value) +} + +function kvsAsyncFromOption>( + options: { readonly defaultValue: LazyArg }, + value: Option.Option, +): AsyncResult.Result { + if (Option.isSome(value)) { + return AsyncResult.success(value.value) + } + return AsyncResult.success(options.defaultValue()) +} + +function onKvsAsyncResult>( + get: AtomContext, + options: { readonly defaultValue: LazyArg }, + setAtom: AtomResultFn, + isWritten: () => boolean, + result: Top, +): void { + if (isWritten()) { + return + } + applyKvsAsyncResult(get, options, setAtom, result) +} + +function applyKvsAsyncResult>( + get: AtomContext, + options: { readonly defaultValue: LazyArg }, + setAtom: AtomResultFn, + result: Top, +): void { + if (kvsHasStoreOption(result) === false) { + return + } + applyKvsAsyncOption(get, options, setAtom, result.value) +} + +function applyKvsAsyncOption>( + get: AtomContext, + options: { readonly defaultValue: LazyArg }, + setAtom: AtomResultFn, + value: Option.Option, +): void { + if (Option.isSome(value)) { + get.setSelf(AsyncResult.success(value.value)) + return + } + const next = options.defaultValue() + get.set(setAtom, next) + get.setSelf(AsyncResult.success(next)) +} + +function readKvsSync>( + get: AtomContext, + options: { readonly defaultValue: LazyArg }, + setAtom: AtomResultFn, + resultAtom: Atom, + isWritten: () => boolean, + setWritten: (written: boolean) => void, +): S['Type'] { + setWritten(false) + get.mount(setAtom) + get.subscribe(resultAtom, (result) => onKvsSyncResult(get, options, setAtom, isWritten, result), { immediate: true }) + return Option.getOrElse(get.self(), options.defaultValue) +} + +function onKvsSyncResult>( + get: AtomContext, + options: { readonly defaultValue: LazyArg }, + setAtom: AtomResultFn, + isWritten: () => boolean, + result: Top, +): void { + if (kvsHasStoreOption(result) === false) { + return + } + applyKvsSyncOption(get, options, setAtom, isWritten, result.value) +} + +function applyKvsSyncOption>( + get: AtomContext, + options: { readonly defaultValue: LazyArg }, + setAtom: AtomResultFn, + isWritten: () => boolean, + value: Option.Option, +): void { + if (isWritten()) { + return + } + applyKvsSyncValue(get, options, setAtom, value) +} + +function applyKvsSyncValue>( + get: AtomContext, + options: { readonly defaultValue: LazyArg }, + setAtom: AtomResultFn, + value: Option.Option, +): void { + if (Option.isSome(value)) { + get.setSelf(value.value) + return + } + const next = Option.getOrElse(get.self(), options.defaultValue) + get.setSelf(next) + get.set(setAtom, next) +} + +function writeKvs>( + ctx: WriteContext | S['Type']>, + options: { readonly mode?: 'sync' | 'async' | undefined }, + setAtom: AtomResultFn, + value: S['Type'], +): void { + ctx.set(setAtom, value) + ctx.setSelf(kvsWrittenValue(options.mode, value)) +} + +function kvsWrittenValue(mode: 'sync' | 'async' | undefined, value: A): AsyncResult.Result | A { + if (mode === 'async') { + return AsyncResult.success(value) + } + return value +} + +// ----------------------------------------------------------------------------- +// KeyValueStore +// ----------------------------------------------------------------------------- +/** + * Creates a writable atom backed by a `KeyValueStore` entry. + * + * **Details** + * + * Values are encoded and decoded with the supplied schema. In sync mode the atom + * exposes the decoded value and writes the default value when the key is missing; + * in async mode it exposes an `AsyncResult` of the decoded value. + * + * **Gotchas** + * + * Error surfacing differs by mode. Async mode reports a failed store read as an + * `AsyncResult.failure`; sync mode has no error channel on its bare value, so a + * failed read renders the default value instead. Writes are optimistic in both + * modes: the value is shown immediately and the store write is fired in the + * background, so a write the store later refuses is not reflected in the atom. + * Use async mode when store failures must be observable. + * + * @since 4.0.0 + */ +export function kvs, const Mode extends 'sync' | 'async' = never>( + options: { + readonly runtime: AtomRuntime + readonly key: string + readonly schema: S + readonly defaultValue: LazyArg + readonly mode?: Mode | undefined + }, +): Writable<'async' extends Mode ? AsyncResult.Result : S['Type'], S['Type']> +export function kvs, const Mode extends 'sync' | 'async' = never>( + options: { + readonly runtime: AtomRuntime + readonly key: string + readonly schema: S + readonly defaultValue: LazyArg + readonly mode?: Mode | undefined + }, +): Writable | S['Type'], S['Type']> { + const setAtom = options.runtime.fn( + (value: S['Type']) => + KeyValueStore.KeyValueStore.use((store) => + KeyValueStore.toSchemaStore(store, options.schema).set(options.key, value) + ), + ) + const resultAtom = options.runtime.atom( + KeyValueStore.KeyValueStore.use((store) => KeyValueStore.toSchemaStore(store, options.schema).get(options.key)), + ) + let written = false + return writable( + (get): AsyncResult.Result | S['Type'] => + readKvs(get, options, setAtom, resultAtom, () => written, (next) => { + written = next + }), + (ctx, value: S['Type']) => { + written = true + writeKvs(ctx, options, setAtom, value) + }, + ) +} + +// ----------------------------------------------------------------------------- +// conversions +// ----------------------------------------------------------------------------- diff --git a/packages/atom/effect-atom/src/atom-modules.ts b/packages/atom/effect-atom/src/atom-modules.ts new file mode 100644 index 0000000..009699c --- /dev/null +++ b/packages/atom/effect-atom/src/atom-modules.ts @@ -0,0 +1,7 @@ +/** + * Every atom module in one namespace, for sibling modules that build on atoms. + * + * @since 4.0.0 + */ +export * from './atom-sentinels.js' +export * from './atom.blueprint.js' diff --git a/packages/atom/effect-atom/src/atom-node.ts b/packages/atom/effect-atom/src/atom-node.ts new file mode 100644 index 0000000..4bf50ee --- /dev/null +++ b/packages/atom/effect-atom/src/atom-node.ts @@ -0,0 +1,1400 @@ +import * as Effect from 'effect/Effect' +import * as Exit from 'effect/Exit' +import { absurd } from 'effect/Function' +import * as Match from 'effect/Match' +import * as Option from 'effect/Option' +import * as Pipeable from 'effect/Pipeable' +import * as Queue from 'effect/Queue' +import { match as matchResult, type Result as EffectResult } from 'effect/Result' +import * as Stream from 'effect/Stream' +import { + advanceBatchPhase, + type BatchEvent, + BatchStep, + type BatchStepDecision, +} from './advance-batch-phase.workflow.js' +import { + AdvanceNodeState, + advanceNodeState, + type NodeStateDecision, + type NodeStateEvent, +} from './advance-node-state.workflow.js' +import * as Result from './async-result.js' +import type * as Atom from './atom.blueprint.js' +import type { BatchPhaseName } from './batch-phase.schema.js' +import { + AsyncRead, + type AsyncReadDecision, + type AsyncReadStage, + type AsyncResultPhase, + judgeAsyncRead, +} from './judge-async-read.workflow.js' +import { type InvalidationDecision, JudgeInvalidation, judgeInvalidation } from './judge-invalidation.workflow.js' +import { judgeListenerNotify, ListenerNotify, type ListenerNotifyDecision } from './judge-listener-notify.workflow.js' +import { JudgeNodeFate, judgeNodeFate } from './judge-node-fate.workflow.js' +import { JudgeNodeRead, judgeNodeRead, type NodeReadDecision } from './judge-node-read.workflow.js' +import { + judgePropagationSweep, + type PropagationSweepDecision, + type SweepKind, + SweepMember, +} from './judge-propagation-sweep.workflow.js' +import { judgeValuePresence, PresenceQuery, type ValuePresenceDecision } from './judge-value-presence.workflow.js' +import type { NodePhase } from './node-phase.schema.js' +import { + BuildInvalidation, + type BuildInvalidationDecision, + recordBuildInvalidation, +} from './record-build-invalidation.workflow.js' +import type { RegistryImpl } from './registry-engine.js' +import type { Registry } from './registry.handle.js' + +type AnyNode = NodeImpl +type AnyLifetime = Lifetime + +const notifyListener = (listener: () => void): void => { + listener() +} + +type AnyValue = A +type NodeAction = (node: AnyNode) => void +type NodeValueAction = (node: AnyNode, value: AnyValue) => void + +/** + * Every decision below is a pure function of a closed set of engine facts, so + * each is resolved once through its workflow at module load and the shell's + * per-read and per-propagation paths are a table lookup (KTD4 in the + * workflow-only-mutation plan). + */ +const PUBLIC_PHASE: Record = { + uninitialized: 'uninitialized', + stale: 'stale', + checking: 'stale', + valid: 'valid', + removed: 'removed', +} + +const phaseTable = (build: (phase: NodePhase) => T): Record => ({ + uninitialized: build('uninitialized'), + stale: build('stale'), + checking: build('checking'), + valid: build('valid'), + removed: build('removed'), +}) + +type FlagKey = 0 | 1 + +const flagKey = (value: boolean): FlagKey => (value ? 1 : 0) + +const flagTable = (build: (value: boolean) => T): Record => ({ + 0: build(false), + 1: build(true), +}) + +const batchPhaseTable = (build: (phase: BatchPhaseName) => T): Record => ({ + disabled: build('disabled'), + collect: build('collect'), + commit: build('commit'), +}) + +const keepNodeUnchanged: NodeAction = () => {} +const keepValueUnchanged: NodeValueAction = () => {} + +const decided = (result: EffectResult): Decision => + matchResult(result, { + onFailure: (error) => absurd(error), + onSuccess: (decision) => decision, + }) + +const readVerdict = (phase: NodePhase): NodeReadDecision => decided(judgeNodeRead(JudgeNodeRead.make({ phase }))) + +const readAction = (phase: NodePhase): NodeAction => + Match.value(readVerdict(phase)).pipe( + Match.tags({ + SettleChecking: () => settleAndContinue, + RebuildWaiting: () => rebuildNodeValue, + KeepValue: () => keepNodeUnchanged, + }), + Match.exhaustive, + ) + +const READ_ACTIONS: Record = phaseTable(readAction) + +const presenceVerdict = (phase: NodePhase): ValuePresenceDecision => + decided(judgeValuePresence(PresenceQuery.make({ phase }))) + +const holdsValue = (phase: NodePhase): boolean => + Match.value(presenceVerdict(phase)).pipe( + Match.tags({ HoldsValue: () => true, HoldsNothing: () => false }), + Match.exhaustive, + ) + +const HOLDS_VALUE: Record = phaseTable(holdsValue) + +const assignFirstValueAction: NodeValueAction = (node, value) => assignFirstValue(node, value) +const assignInitialValueAction: NodeValueAction = (node, value) => assignInitialUninitialized(node, value) +const replaceInitializedValueAction: NodeValueAction = (node, value) => replaceInitializedValue(node, value) +const setValueAction: NodeValueAction = (node, value) => node.setValue(value) + +const VALUE_ACTIONS: Record = phaseTable((phase) => + HOLDS_VALUE[phase] ? replaceInitializedValueAction : assignFirstValueAction +) + +const INITIAL_VALUE_ACTIONS: Record = phaseTable((phase) => + HOLDS_VALUE[phase] ? setValueAction : assignInitialValueAction +) + +const stateVerdict = ( + event: NodeStateEvent, + phase: NodePhase, + invalidatedDuringBuild: boolean, + preserveInitialValueOnBuild: boolean, +): NodeStateDecision => + decided(advanceNodeState(AdvanceNodeState.make({ + phase, + event, + invalidatedDuringBuild, + preserveInitialValueOnBuild, + }))) + +const SETTLED_ACTIONS: Record = phaseTable((phase) => + Match.value(stateVerdict('settled', phase, false, false)).pipe( + Match.tags({ + BecomesValid: () => becomeValid, + BecomesStale: () => keepNodeUnchanged, + BecomesChecking: () => keepNodeUnchanged, + TakeBuiltValue: () => keepNodeUnchanged, + KeepsPhase: () => keepNodeUnchanged, + }), + Match.exhaustive, + ) +) + +const ABANDON_ACTIONS: Record = phaseTable((phase) => + Match.value(stateVerdict('invalidated', phase, false, false)).pipe( + Match.tags({ + BecomesValid: () => keepNodeUnchanged, + BecomesStale: () => abandonToStale, + BecomesChecking: () => keepNodeUnchanged, + TakeBuiltValue: () => keepNodeUnchanged, + KeepsPhase: () => keepNodeUnchanged, + }), + Match.exhaustive, + ) +) + +const DESCEND_ACTIONS: Record = phaseTable((phase) => + Match.value(stateVerdict('descending', phase, false, false)).pipe( + Match.tags({ + BecomesValid: () => keepNodeUnchanged, + BecomesStale: () => keepNodeUnchanged, + BecomesChecking: () => becomeChecking, + TakeBuiltValue: () => keepNodeUnchanged, + KeepsPhase: () => keepNodeUnchanged, + }), + Match.exhaustive, + ) +) + +const RESTALE_ACTIONS: Record> = flagTable((invalidatedDuringBuild) => + phaseTable((phase) => + Match.value(stateVerdict('restaled', phase, invalidatedDuringBuild, false)).pipe( + Match.tags({ + BecomesValid: () => keepNodeUnchanged, + BecomesStale: () => clearBuildFlagToStale, + BecomesChecking: () => keepNodeUnchanged, + TakeBuiltValue: () => keepNodeUnchanged, + KeepsPhase: () => keepNodeUnchanged, + }), + Match.exhaustive, + ) + ) +) + +const BUILT_ACTIONS: Record> = flagTable((preserveInitialValueOnBuild) => + phaseTable((phase) => + Match.value(stateVerdict('built', phase, false, preserveInitialValueOnBuild)).pipe( + Match.tags({ + BecomesValid: () => pinPreservedValue, + BecomesStale: () => keepValueUnchanged, + BecomesChecking: () => keepValueUnchanged, + TakeBuiltValue: () => takeBuiltValue, + KeepsPhase: () => keepValueUnchanged, + }), + Match.exhaustive, + ) + ) +) + +const invalidationVerdict = ( + batchPhase: BatchPhaseName, + lazy: boolean, + hasListeners: boolean, + childrenActive: boolean, +): InvalidationDecision => + decided(judgeInvalidation(JudgeInvalidation.make({ + batchPhase, + lazy, + hasListeners, + childrenActive, + }))) + +const invalidationAction = ( + batchPhase: BatchPhaseName, + lazy: boolean, + hasListeners: boolean, + childrenActive: boolean, +): NodeAction => + Match.value(invalidationVerdict(batchPhase, lazy, hasListeners, childrenActive)).pipe( + Match.tags({ + DeferToBatch: () => deferInvalidationToBatch, + SkipLazyChildren: () => skipLazyInvalidation, + InvalidateValue: () => readInvalidatedValue, + }), + Match.exhaustive, + ) + +const INVALIDATION_ACTIONS: Record< + BatchPhaseName, + Record>> +> = batchPhaseTable((batchPhase) => + flagTable((lazy) => + flagTable((hasListeners) => + flagTable((childrenActive) => invalidationAction(batchPhase, lazy, hasListeners, childrenActive)) + ) + ) +) + +const notifyVerdict = (batchPhase: BatchPhaseName, hasListeners: boolean): ListenerNotifyDecision => + decided(judgeListenerNotify(ListenerNotify.make({ batchPhase, hasListeners }))) + +const notifyAction = (batchPhase: BatchPhaseName, hasListeners: boolean): NodeAction => + Match.value(notifyVerdict(batchPhase, hasListeners)).pipe( + Match.tags({ + StaySilent: () => keepNodeUnchanged, + QueueForBatch: () => queueNotificationForBatch, + NotifyNow: () => notifyNodeNow, + }), + Match.exhaustive, + ) + +const NOTIFY_ACTIONS: Record> = batchPhaseTable((batchPhase) => + flagTable((hasListeners) => notifyAction(batchPhase, hasListeners)) +) + +const recordInvalidationVerdict = (building: boolean, batchPhase: BatchPhaseName): BuildInvalidationDecision => + decided(recordBuildInvalidation(BuildInvalidation.make({ building, batchPhase }))) + +const recordInvalidationAction = (building: boolean, batchPhase: BatchPhaseName): NodeAction => + Match.value(recordInvalidationVerdict(building, batchPhase)).pipe( + Match.tags({ + RecordDuringBuild: () => recordInvalidation, + SkipRecording: () => keepNodeUnchanged, + }), + Match.exhaustive, + ) + +const RECORD_INVALIDATION_ACTIONS: Record> = flagTable((building) => + batchPhaseTable((batchPhase) => recordInvalidationAction(building, batchPhase)) +) + +const nodeFateDrops = ( + keepAlive: boolean, + hasListeners: boolean, + hasChildren: boolean, + isLive: boolean, + isWaiting: boolean, +): boolean => { + const command = JudgeNodeFate.make({ keepAlive, hasListeners, hasChildren, isLive, isWaiting }) + const fate = decided(judgeNodeFate(command)) + return Match.value(fate).pipe( + Match.tags({ + KeepNode: () => false, + DropNode: () => true, + }), + Match.exhaustive, + ) +} + +const NODE_FATE_DROPS: Record< + FlagKey, + Record>>> +> = flagTable((keepAlive) => + flagTable((hasListeners) => + flagTable((hasChildren) => + flagTable((isLive) => + flagTable((isWaiting) => nodeFateDrops(keepAlive, hasListeners, hasChildren, isLive, isWaiting)) + ) + ) + ) +) + +const sweepVerdict = (sweep: SweepKind, phase: NodePhase): PropagationSweepDecision => + decided(judgePropagationSweep(SweepMember.make({ phase, sweep }))) + +const sweepActed = (sweep: SweepKind, phase: NodePhase): boolean => + Match.value(sweepVerdict(sweep, phase)).pipe( + Match.tags({ ActOnMember: () => true, SkipMember: () => false }), + Match.exhaustive, + ) + +const INVALIDATED_CHILD_ACTS: Record = phaseTable((phase) => sweepActed('invalidated-child', phase)) +const RELINKED_CHILD_ACTS: Record = phaseTable((phase) => sweepActed('relinked-child', phase)) +const REBUILT_PARENT_ACTS: Record = phaseTable((phase) => sweepActed('rebuilt-parent', phase)) + +/** */ +export class NodeImpl extends Pipeable.Class { + constructor( + registry: RegistryImpl, + atom: Atom.Atom, + ) { + super() + this.registry = registry + this.atom = atom + this.writeContext = new WriteContextImpl(registry, this) + } + readonly registry: RegistryImpl + readonly atom: Atom.Atom + state: NodePhase = 'uninitialized' + lifetime: Lifetime | undefined + writeContext: WriteContextImpl + preserveInitialValueOnBuild = false + + parents = new Set() + previousParents: Set | undefined + children = new Set() + listeners = new Set<() => void>() + skipInvalidation = false + building = false + invalidatedDuringBuild = false + + currentState(): 'uninitialized' | 'stale' | 'valid' | 'removed' { + return PUBLIC_PHASE[this.state] + } + + get canBeRemoved(): boolean { + const keepAlive = flagKey(this.atom.spec.keepAlive) + const hasListeners = flagKey(this.listeners.size > 0) + const hasChildren = flagKey(this.children.size > 0) + const isLive = flagKey(this.state !== 'removed') + const isWaiting = flagKey(isWaitingForInitial(this._value)) + return NODE_FATE_DROPS[keepAlive][hasListeners][hasChildren][isLive][isWaiting] + } + + _value!: A + value(): A { + READ_ACTIONS[this.state](this) + return this._value + } + + valueOption(): Option.Option { + return HOLDS_VALUE[this.state] ? Option.some(this._value) : Option.none() + } + + setInitialValue(value: A): void { + INITIAL_VALUE_ACTIONS[this.state](this, value) + } + + setValue(value: A): void { + VALUE_ACTIONS[this.state](this, value) + } + + addParent(parent: AnyNode): void { + this.parents.add(parent) + forgetPreviousParent(this, parent) + linkChild(this, parent) + } + + removeChild(child: AnyNode): void { + this.children.delete(child) + } + + invalidate(): void { + markStale(this) + continueInvalidate(this) + } + + invalidateChildren(): void { + if (this.children.size === 0) { + return + } + invalidateChildSet(this) + } + + notify(): void { + this.listeners.forEach(notifyListener) + + const batch = this.registry.batch + if (batch.phase === BatchPhase.commit) { + batch.notify.delete(this) + } + } + + disposeLifetime(): void { + disposeCurrentLifetime(this) + stashParents(this) + } + + remove() { + this.state = 'removed' + this.listeners.clear() + removeLifetimeAndParents(this) + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } +} + +function isWaitingForInitial(value: T): boolean { + if (Result.isResult(value) === false) { + return false + } + return isInitialWaiting(value) +} + +function isInitialWaiting(value: Result.Result): boolean { + if (Result.isInitial(value) === false) { + return false + } + return value.waiting +} + +function settleAndContinue(node: NodeImpl): void { + settleChecking(node) + READ_ACTIONS[node.state](node) +} + +function settleChecking(node: NodeImpl): void { + for (const parent of node.parents) { + parent.value() + } + SETTLED_ACTIONS[node.state](node) +} + +function becomeValid(node: NodeImpl): void { + node.state = 'valid' +} + +function pinPreservedValue(node: NodeImpl): void { + node.preserveInitialValueOnBuild = false + node.state = 'valid' +} + +function takeBuiltValue(node: NodeImpl, value: A): void { + node.setValue(value) +} + +function rebuildNodeValue(node: NodeImpl): void { + node.lifetime = makeLifetime(node) + node.building = true + const value = node.atom.read(node.lifetime) + node.building = false + BUILT_ACTIONS[flagKey(node.preserveInitialValueOnBuild)][node.state](node, value) + detachPreviousParents(node) +} + +function detachPreviousParents(node: NodeImpl): void { + if (node.previousParents === undefined) { + return + } + detachParents(node, node.previousParents) +} + +function detachParents(node: NodeImpl, parents: Set): void { + node.previousParents = undefined + for (const parent of parents) { + detachParent(node, parent) + } +} + +function detachParent(node: NodeImpl, parent: AnyNode): void { + parent.removeChild(node) + scheduleRemovalIfIdle(node, parent) +} + +function scheduleRemovalIfIdle(node: NodeImpl, parent: AnyNode): void { + if (parent.canBeRemoved) { + node.registry.scheduleNodeRemoval(parent) + } +} + +function assignInitialUninitialized(node: NodeImpl, value: A): void { + node.preserveInitialValueOnBuild = true + node.state = 'stale' + node._value = value + notifyListenersIfPresent(node) +} + +function assignFirstValue(node: NodeImpl, value: A): void { + node.state = 'valid' + node._value = value + notifyListenersIfPresent(node) +} + +function notifyListenersIfPresent(node: NodeImpl): void { + NOTIFY_ACTIONS[BATCH_PHASE_NAMES[node.registry.batch.phase]][flagKey(node.listeners.size > 0)](node) +} + +function queueNotificationForBatch(node: NodeImpl): void { + node.registry.batch.notify.add(node) +} + +function notifyNodeNow(node: NodeImpl): void { + node.notify() +} + +function replaceInitializedValue(node: NodeImpl, value: A): void { + node.state = 'valid' + replaceIfChanged(node, value) +} + +function replaceIfChanged(node: NodeImpl, value: A): void { + if (node.atom.equals(node._value, value)) { + return + } + commitChangedValue(node, value) +} + +function commitChangedValue(node: NodeImpl, value: A): void { + node._value = value + invalidateAfterValueChange(node) + notifyListenersIfPresent(node) +} + +function invalidateAfterValueChange(node: NodeImpl): void { + if (node.skipInvalidation) { + node.skipInvalidation = false + return + } + node.invalidateChildren() +} + +function forgetPreviousParent(node: NodeImpl, parent: AnyNode): void { + if (node.previousParents !== undefined) { + dropPreviousParent(node, node.previousParents, parent) + } +} + +function dropPreviousParent( + node: NodeImpl, + previousParents: Set, + parent: AnyNode, +): void { + previousParents.delete(parent) + clearPreviousParentsIfEmpty(node, previousParents) +} + +function clearPreviousParentsIfEmpty( + node: NodeImpl, + previousParents: Set, +): void { + if (previousParents.size === 0) { + node.previousParents = undefined + } +} + +function linkChild(node: NodeImpl, parent: AnyNode): void { + parent.children.add(node) + clearSkipInvalidation(parent) +} + +function clearSkipInvalidation(parent: AnyNode): void { + if (parent.skipInvalidation) { + parent.skipInvalidation = false + } +} + +function markStale(node: NodeImpl): void { + RECORD_INVALIDATION_ACTIONS[flagKey(node.building)][BATCH_PHASE_NAMES[node.registry.batch.phase]](node) + ABANDON_ACTIONS[node.state](node) + markDescendantsChecking(node) +} + +function recordInvalidation(node: NodeImpl): void { + node.invalidatedDuringBuild = true +} + +function abandonToStale(node: NodeImpl): void { + node.state = 'stale' + node.disposeLifetime() +} + +function markDescendantsChecking(node: NodeImpl): void { + for (const child of node.children) { + DESCEND_ACTIONS[child.state](child) + } +} + +function becomeChecking(node: AnyNode): void { + node.state = 'checking' + markDescendantsChecking(node) +} + +function continueInvalidate(node: NodeImpl): void { + INVALIDATION_ACTIONS[BATCH_PHASE_NAMES[node.registry.batch.phase]][flagKey(node.atom.spec.lazy)][ + flagKey( + node.listeners.size > 0, + ) + ][flagKey(childrenAreActive(node.children))](node) +} + +function deferInvalidationToBatch(node: NodeImpl): void { + node.registry.batch.stale.add(node) +} + +function skipLazyInvalidation(node: NodeImpl): void { + node.invalidateChildren() + node.skipInvalidation = true +} + +function readInvalidatedValue(node: NodeImpl): void { + node.value() +} + +function invalidateChildSet(node: NodeImpl): void { + const children = node.children + node.children = new Set() + children.forEach(markStale) + children.forEach(continueInvalidatedChild) + children.forEach((child) => relinkSweptChild(node, child)) +} + +function continueInvalidatedChild(node: AnyNode): void { + if (INVALIDATED_CHILD_ACTS[node.state]) { + continueInvalidate(node) + } +} + +function relinkSweptChild(node: NodeImpl, child: AnyNode): void { + if (RELINKED_CHILD_ACTS[child.state]) { + node.children.add(child) + } +} + +function disposeCurrentLifetime(node: NodeImpl): void { + if (node.lifetime !== undefined) { + node.lifetime.dispose() + node.lifetime = undefined + } +} + +function stashParents(node: NodeImpl): void { + if (node.parents.size !== 0) { + node.previousParents = node.parents + node.parents = new Set() + } +} + +function removeLifetimeAndParents(node: NodeImpl): void { + node.disposeLifetime() + detachRemovedParents(node) +} + +function detachRemovedParents(node: NodeImpl): void { + if (node.previousParents === undefined) { + return + } + removeFromParents(node, node.previousParents) +} + +function removeFromParents(node: NodeImpl, parents: Set): void { + node.previousParents = undefined + for (const parent of parents) { + removeFromParent(node, parent) + } +} + +function removeFromParent(node: NodeImpl, parent: AnyNode): void { + parent.removeChild(node) + removeParentIfIdle(node, parent) +} + +function removeParentIfIdle(node: NodeImpl, parent: AnyNode): void { + if (parent.canBeRemoved) { + node.registry.removeNode(parent) + } +} + +function childrenAreActive(children: Set): boolean { + if (children.size === 0) { + return false + } + return walkActiveChildren(children) +} + +function walkActiveChildren(start: Set): boolean { + const stack: Array> = [start] + return walkActiveStack(stack, 0) +} + +function walkActiveStack(stack: Array>, index: number): boolean { + if (index >= stack.length) { + return false + } + return scanThenContinue(stack, index) +} + +function scanThenContinue(stack: Array>, index: number): boolean { + if (scanActiveSet(stack[index], stack)) { + return true + } + return walkActiveStack(stack, index + 1) +} + +function scanActiveSet( + current: Set | undefined, + stack: Array>, +): boolean { + if (current === undefined) { + return false + } + return scanDefinedSet(current, stack) +} + +function scanDefinedSet( + current: Set, + stack: Array>, +): boolean { + let found = false + current.forEach((child) => { + found = takeActiveChild(found, child, stack) + }) + return found +} + +function takeActiveChild( + found: boolean, + child: AnyNode, + stack: Array>, +): boolean { + if (found) { + return true + } + return childSignalsActive(child, stack) +} + +function childSignalsActive( + child: AnyNode, + stack: Array>, +): boolean { + if (childIsLive(child)) { + return true + } + pushChildSet(child, stack) + return false +} + +function childIsLive(child: AnyNode): boolean { + if (child.atom.spec.lazy === false) { + return true + } + return child.listeners.size > 0 +} + +function pushChildSet(child: AnyNode, stack: Array>): void { + if (child.children.size > 0) { + stack.push(child.children) + } +} + +interface Lifetime extends Atom.AtomContext { + isFn: boolean + readonly registry: RegistryImpl + readonly node: NodeImpl + finalizers: (() => void)[] | undefined + disposed: boolean + readonly dispose: () => void +} + +type ResultOptions = { + readonly suspendOnWaiting?: boolean | undefined +} + +type StreamOptions = { + readonly withoutInitialValue?: boolean +} + +const LifetimeProto: Omit = { + addFinalizer(this: AnyLifetime, f: () => void): void { + if (this.disposed) { + f() + return + } + pushFinalizer(this, f) + }, + + get(this: AnyLifetime, atom: Atom.Atom): A { + if (this.disposed) { + return this.node.registry.get(atom) + } + return getLive(this, atom) + }, + + result( + this: AnyLifetime, + atom: Atom.Atom>, + options?: ResultOptions, + ): Effect.Effect { + if (isDisposedOrFn(this)) { + return this.resultOnce(atom, options) + } + return resultFromLive(this, atom, options) + }, + + resultOnce( + this: AnyLifetime, + atom: Atom.Atom>, + options?: ResultOptions, + ): Effect.Effect { + return Effect.callback((resume) => resultOnceCallback(this, atom, options, resume)) + }, + + setResult( + this: AnyLifetime, + atom: Atom.Writable, W>, + value: W, + ): Effect.Effect { + if (this.disposed) return Effect.never + this.node.registry.set(atom, value) + return this.resultOnce(atom, { suspendOnWaiting: true }) + }, + + some(this: AnyLifetime, atom: Atom.Atom>): Effect.Effect { + if (isDisposedOrFn(this)) { + return this.someOnce(atom) + } + return someFromOption(this.get(atom)) + }, + + someOnce(this: AnyLifetime, atom: Atom.Atom>): Effect.Effect { + return Effect.callback((resume) => someOnceCallback(this, atom, resume)) + }, + + once(this: AnyLifetime, atom: Atom.Atom): A { + return this.node.registry.get(atom) + }, + + self(this: Lifetime): Option.Option { + if (this.disposed) return Option.none() + return this.node.valueOption() + }, + + refresh(this: AnyLifetime, atom: Atom.Atom): void { + if (this.disposed) return + this.node.registry.refresh(atom) + }, + + refreshSelf(this: AnyLifetime): void { + if (this.disposed) return + this.node.invalidate() + }, + + mount(this: AnyLifetime, atom: Atom.Atom): void { + if (this.disposed) return + this.addFinalizer(this.node.registry.mount(atom)) + }, + + subscribe(this: AnyLifetime, atom: Atom.Atom, f: (_: A) => void, options?: { + readonly immediate?: boolean + }): void { + if (this.disposed) return + this.addFinalizer(this.node.registry.subscribe(atom, f, options)) + }, + + setSelf(this: AnyLifetime, a: A): void { + if (this.disposed) return + this.node.setValue(a) + }, + + set(this: AnyLifetime, atom: Atom.Writable, value: W): void { + if (this.disposed) return + this.node.registry.set(atom, value) + }, + + stream(this: AnyLifetime, atom: Atom.Atom, options?: StreamOptions) { + if (this.disposed) return Stream.empty + return Stream.callback((queue) => + Effect.sync(() => { + this.subscribe(atom, (value) => Queue.offerUnsafe(queue, value), { + immediate: includeInitialValue(options), + }) + }) + ) + }, + + streamResult(this: AnyLifetime, atom: Atom.Atom>, options?: { + readonly withoutInitialValue?: boolean + readonly bufferSize?: number + }): Stream.Stream { + return this.stream(atom, options).pipe( + Stream.filter(Result.isNotInitial), + Stream.mapEffect((result) => streamResultEffect(result)), + ) + }, + + dispose(this: AnyLifetime): void { + this.disposed = true + runFinalizers(this) + }, +} + +function isDisposedOrFn(lifetime: AnyLifetime): boolean { + if (lifetime.disposed) { + return true + } + return lifetime.isFn +} + +function pushFinalizer(lifetime: AnyLifetime, f: () => void): void { + if (lifetime.finalizers === undefined) { + lifetime.finalizers = [f] + return + } + lifetime.finalizers.push(f) +} + +function getLive(lifetime: AnyLifetime, atom: Atom.Atom): A { + const parent = lifetime.node.registry.ensureNode(atom) + const value = parent.value() + lifetime.node.addParent(parent) + return value +} + +function resultFromLive( + lifetime: AnyLifetime, + atom: Atom.Atom>, + options: ResultOptions | undefined, +): Effect.Effect { + return resultFromValue(lifetime.get(atom), options) +} + +const suspendOnWaitingOption = (options: ResultOptions | undefined): boolean => + options === undefined ? false : options.suspendOnWaiting === true + +const settledResultPhaseOf = (result: Result.Result): AsyncResultPhase => + Result.isSuccess(result) ? 'success' : 'failure' + +const resultPhaseOf = (result: Result.Result): AsyncResultPhase => + Result.isInitial(result) ? 'initial' : settledResultPhaseOf(result) + +const asyncReadStageTable = (build: (stage: AsyncReadStage) => T): Record => ({ + immediate: build('immediate'), + 'await-start': build('await-start'), + 'await-event': build('await-event'), +}) + +const asyncResultPhaseTable = (build: (phase: AsyncResultPhase) => T): Record => ({ + initial: build('initial'), + success: build('success'), + failure: build('failure'), +}) + +const asyncReadVerdictFor = ( + stage: AsyncReadStage, + phase: AsyncResultPhase, + waiting: boolean, + suspendOnWaiting: boolean, +): AsyncReadDecision => decided(judgeAsyncRead(AsyncRead.make({ stage, phase, waiting, suspendOnWaiting }))) + +const ASYNC_READ_VERDICTS: Record< + AsyncReadStage, + Record>> +> = asyncReadStageTable((stage) => + asyncResultPhaseTable((phase) => + flagTable((waiting) => + flagTable((suspendOnWaiting) => asyncReadVerdictFor(stage, phase, waiting, suspendOnWaiting)) + ) + ) +) + +const asyncReadVerdict = ( + stage: AsyncReadStage, + result: Result.Result, + options: ResultOptions | undefined, +): AsyncReadDecision => + ASYNC_READ_VERDICTS[stage][resultPhaseOf(result)][flagKey(result.waiting)][ + flagKey(suspendOnWaitingOption(options)) + ] + +function resultFromValue( + result: Result.Result, + options: ResultOptions | undefined, +): Effect.Effect { + return Match.value(asyncReadVerdict('immediate', result, options)).pipe( + Match.tags({ + SuspendImmediately: () => Effect.never, + ResolveWithResult: () => resultToEffect(result), + AwaitNextResult: () => Effect.never, + KeepAwaiting: () => Effect.never, + }), + Match.exhaustive, + ) +} + +function resultToEffect(result: Result.Result): Effect.Effect { + if (Result.isFailure(result)) { + return Exit.failCause(result.cause) + } + return succeedResult(result) +} + +function succeedResult(result: Result.Result): Effect.Effect { + if (Result.isSuccess(result)) { + return Effect.succeed(result.value) + } + return Effect.never +} + +function resultOnceCallback( + lifetime: AnyLifetime, + atom: Atom.Atom>, + options: ResultOptions | undefined, + resume: (effect: Effect.Effect) => void, +): Effect.Effect | void { + const result = lifetime.once(atom) + return Match.value(asyncReadVerdict('await-start', result, options)).pipe( + Match.tags({ + AwaitNextResult: () => subscribeUntilReady(lifetime, atom, options, resume), + ResolveWithResult: () => resumeReady(result, resume), + SuspendImmediately: () => resumeReady(result, resume), + KeepAwaiting: () => resumeReady(result, resume), + }), + Match.exhaustive, + ) +} + +function subscribeUntilReady( + lifetime: AnyLifetime, + atom: Atom.Atom>, + options: ResultOptions | undefined, + resume: (effect: Effect.Effect) => void, +): Effect.Effect { + const cancel = lifetime.node.registry.subscribe(atom, (next) => { + onResultSubscription(next, options, cancel, resume) + }, { immediate: false }) + return Effect.sync(cancel) +} + +function onResultSubscription( + result: Result.Result, + options: ResultOptions | undefined, + cancel: () => void, + resume: (effect: Effect.Effect) => void, +): void { + Match.value(asyncReadVerdict('await-event', result, options)).pipe( + Match.tags({ + KeepAwaiting: () => undefined, + ResolveWithResult: () => resolveResultSubscription(result, cancel, resume), + SuspendImmediately: () => resolveResultSubscription(result, cancel, resume), + AwaitNextResult: () => resolveResultSubscription(result, cancel, resume), + }), + Match.exhaustive, + ) +} + +function resolveResultSubscription( + result: Result.Result, + cancel: () => void, + resume: (effect: Effect.Effect) => void, +): void { + cancel() + resumeReady(result, resume) +} + +function resumeReady( + result: Result.Result, + resume: (effect: Effect.Effect) => void, +): void { + if (Result.isInitial(result)) { + return + } + resume(Result.toExit(result)) +} + +function someFromOption(result: Option.Option): Effect.Effect { + if (Option.isNone(result)) { + return Effect.never + } + return Effect.succeed(result.value) +} + +function someOnceCallback( + lifetime: AnyLifetime, + atom: Atom.Atom>, + resume: (effect: Effect.Effect) => void, +): Effect.Effect | void { + const result = lifetime.once(atom) + if (Option.isSome(result)) { + return resume(Effect.succeed(result.value)) + } + return subscribeUntilSome(lifetime, atom, resume) +} + +function subscribeUntilSome( + lifetime: AnyLifetime, + atom: Atom.Atom>, + resume: (effect: Effect.Effect) => void, +): Effect.Effect { + const cancel = lifetime.node.registry.subscribe(atom, (next) => { + onSomeSubscription(next, cancel, resume) + }, { immediate: false }) + return Effect.sync(cancel) +} + +function onSomeSubscription( + result: Option.Option, + cancel: () => void, + resume: (effect: Effect.Effect) => void, +): void { + if (Option.isNone(result)) { + return + } + cancel() + resume(Effect.succeed(result.value)) +} + +function includeInitialValue(options: StreamOptions | undefined): boolean { + if (options === undefined) { + return true + } + return options.withoutInitialValue !== true +} + +function streamResultEffect(result: Result.Result): Effect.Effect { + if (Result.isSuccess(result)) { + return Effect.succeed(result.value) + } + return failIfFailure(result) +} + +function failIfFailure(result: Result.Result): Effect.Effect { + if (Result.isFailure(result)) { + return Effect.failCause(result.cause) + } + return Effect.never +} + +function runFinalizers(lifetime: AnyLifetime): void { + if (lifetime.finalizers === undefined) { + return + } + runFinalizerList(lifetime, lifetime.finalizers) +} + +function runFinalizerList(lifetime: AnyLifetime, finalizers: Array<() => void>): void { + lifetime.finalizers = undefined + runFinalizersFrom(finalizers, finalizers.length - 1) +} + +function runFinalizersFrom(finalizers: Array<() => void>, i: number): void { + if (i < 0) { + return + } + runOneThenRest(finalizers, i) +} + +function runOneThenRest(finalizers: Array<() => void>, i: number): void { + runFinalizer(finalizers[i]) + runFinalizersFrom(finalizers, i - 1) +} + +function runFinalizer(finalizer: (() => void) | undefined): void { + if (finalizer !== undefined) { + finalizer() + } +} + +const makeLifetime = (node: NodeImpl): Lifetime => { + const lifetime: Lifetime = Object.assign( + function get(atom: Atom.Atom): A2 { + if (isDisposedOrFn(lifetime)) { + return node.registry.get(atom) + } + return readAndLink(node, atom) + }, + LifetimeProto, + { + isFn: false, + disposed: false, + finalizers: undefined, + node, + registry: node.registry, + }, + ) + return lifetime +} + +function readAndLink(node: NodeImpl, atom: Atom.Atom): A2 { + const parent = node.registry.ensureNode(atom) + const value = parent.value() + node.addParent(parent) + return value +} +class WriteContextImpl extends Pipeable.Class implements Atom.WriteContext { + constructor( + registry: RegistryImpl, + node: NodeImpl, + ) { + super() + this.writeRegistry = registry + this.registryHandle = registry.handle + this.node = node + } + get registry(): Registry { + return this.registryHandle + } + readonly writeRegistry: RegistryImpl + readonly registryHandle: Registry + readonly node: NodeImpl + get(atom: Atom.Atom): A { + return this.writeRegistry.get(atom) + } + set(atom: Atom.Writable, value: W) { + return this.writeRegistry.set(atom, value) + } + setSelf(value: A) { + return this.node.setValue(value) + } + refreshSelf() { + return this.node.invalidate() + } +} + +// ----------------------------------------------------------------------------- +// batching +// ----------------------------------------------------------------------------- + +/** */ +export const BatchPhase: { + readonly disabled: 0 + readonly collect: 1 + readonly commit: 2 +} = { + disabled: 0, + collect: 1, + commit: 2, +} +/** */ +export type BatchPhase = 0 | 1 | 2 + +const BATCH_PHASE_NAMES: Record = { + [BatchPhase.disabled]: 'disabled', + [BatchPhase.collect]: 'collect', + [BatchPhase.commit]: 'commit', +} + +export interface BatchState { + phase: BatchPhase + depth: number + readonly stale: Set + readonly notify: Set +} + +/** */ +export const makeBatchState = (): BatchState => ({ + phase: BatchPhase.disabled, + depth: 0, + stale: new Set(), + notify: new Set(), +}) + +/** + * Batch state lifecycle for one registry, run by the engine's `batchOn`. + * + * **Details** + * + * Starting, committing, and finishing take the engine's own state record so + * concurrent registries never share collect, rebuild, or notification state. + */ +export interface BatchRunner { + readonly startBatch: (batch: BatchState) => void + readonly commitBatchIfOutermost: (batch: BatchState) => void + readonly finishBatch: (batch: BatchState) => void +} +export const batchRunner: BatchRunner = { + startBatch, + commitBatchIfOutermost, + finishBatch, +} + +const batchStepVerdict = (depth: number, event: BatchEvent): BatchStepDecision => + decided(advanceBatchPhase(BatchStep.make({ depth, event }))) + +function startBatch(batch: BatchState): void { + batch.phase = BatchPhase.collect + batch.depth++ +} + +function commitBatchIfOutermost(batch: BatchState): void { + Match.value(batchStepVerdict(batch.depth, 'commit-request')).pipe( + Match.tags({ + RebuildAndNotify: () => commitOutermostBatch(batch), + ResetBatch: () => undefined, + StayNested: () => undefined, + }), + Match.exhaustive, + ) +} + +function commitOutermostBatch(batch: BatchState): void { + rebuildStaleNodes(batch) + notifyBatchedNodes(batch) +} + +function finishBatch(batch: BatchState): void { + const verdict = batchStepVerdict(batch.depth, 'finish-request') + batch.depth-- + Match.value(verdict).pipe( + Match.tags({ + ResetBatch: () => resetFinishedBatch(batch), + RebuildAndNotify: () => undefined, + StayNested: () => undefined, + }), + Match.exhaustive, + ) +} + +function resetFinishedBatch(batch: BatchState): void { + batch.phase = BatchPhase.disabled + batch.stale.clear() +} + +function rebuildStaleNodes(batch: BatchState): void { + for (const node of batch.stale) { + batchRebuildNode(node) + } +} + +function notifyBatchedNodes(batch: BatchState): void { + batch.phase = BatchPhase.commit + for (const node of batch.notify) { + node.notify() + } + batch.notify.clear() +} + +function batchRebuildNode(node: AnyNode): void { + RESTALE_ACTIONS[flagKey(node.invalidatedDuringBuild)][node.state](node) + rebuildParents(node) + node.value() +} + +function clearBuildFlagToStale(node: AnyNode): void { + node.invalidatedDuringBuild = false + node.state = 'stale' + node.disposeLifetime() +} + +function rebuildParents(node: AnyNode): void { + for (const parent of node.parents) { + rebuildParentIfNeeded(parent) + } +} + +function rebuildParentIfNeeded(parent: AnyNode): void { + if (REBUILT_PARENT_ACTS[parent.state]) { + batchRebuildNode(parent) + } +} diff --git a/packages/atom/effect-atom/src/atom-ref.handle.ts b/packages/atom/effect-atom/src/atom-ref.handle.ts new file mode 100644 index 0000000..999f254 --- /dev/null +++ b/packages/atom/effect-atom/src/atom-ref.handle.ts @@ -0,0 +1,662 @@ +/** + * Mutable reactive references for local, in-memory state. + * + * `AtomRef` provides small observable state cells that can be read, updated, + * mapped, and subscribed to without going through a registry. Mutable + * refs can also create refs for nested properties. The module also provides a + * collection helper that stores item refs and notifies subscribers when items are + * inserted, removed, or changed. + * + * @since 4.0.0 + */ +import { Handle } from '@systemfsoftware/effect-cell-types' +import * as Equal from 'effect/Equal' +import type { Equal as EqualType } from 'effect/Equal' +import { dual } from 'effect/Function' +import * as Hash from 'effect/Hash' +import * as Pipeable from 'effect/Pipeable' + +type Top = A +type AnyValue = A + +/** + * The identity every ref handle carries. + * + * @since 4.0.0 + */ +export const TypeId: unique symbol = Symbol.for('~effect/reactivity/AtomRef') + +/** + * The runtime type id used to identify `AtomRef` values. + * + * @since 4.0.0 + */ +export type TypeId = typeof TypeId + +/** + * The protocol data every ref handle carries: equality and hashing are decided + * by the current value of the ref. + * + * @since 4.0.0 + */ +export interface RefProtocol { + readonly [Equal.symbol]: (that: EqualType) => boolean + readonly [Hash.symbol]: () => number +} + +/** + * The internal machinery behind a read-only ref handle. + * + * @since 4.0.0 + */ +export interface ReadonlyRefEngine { + value(): A + subscribe(f: (a: A) => void): () => void + map(f: (a: A) => B): ReadonlyRef + equals(that: EqualType): boolean + hash(): number +} + +/** + * The internal machinery behind a mutable ref handle. + * + * @since 4.0.0 + */ +export interface AtomRefEngine extends ReadonlyRefEngine { + set(value: A): void + update(f: (value: A) => A): void + prop(prop: K): AtomRef +} + +/** + * The internal machinery behind a collection handle. + * + * @since 4.0.0 + */ +export interface CollectionEngine extends ReadonlyRefEngine[]> { + push(item: A): void + insertAt(index: number, item: A): void + remove(ref: AtomRef): void + toArray(): A[] +} + +/** + * The slot a read-only ref handle carries: its engine typed by the value the + * handle is indexed at. + * + * @since 4.0.0 + */ +export interface ReadonlyRefSlot extends Handle.Indexed { + readonly slot: ReadonlyRefEngine +} + +/** + * The slot a mutable ref handle carries: its engine typed by the value the + * handle is indexed at. + * + * @since 4.0.0 + */ +export interface AtomRefSlot extends Handle.Indexed { + readonly slot: AtomRefEngine +} + +/** + * The item type of the item-ref array a collection handle is indexed at. + */ +type ItemOf = X extends readonly AtomRef[] ? A : never + +/** + * The slot a collection handle carries: its engine typed by the item type of + * the item-ref array the handle is indexed at. A collection is indexed at the + * refs it carries, so it stays a `ReadonlyRef` of those refs. + * + * @since 4.0.0 + */ +export interface CollectionSlot extends Handle.Indexed { + readonly slot: CollectionEngine> +} + +const Refs = Handle.make()(TypeId) + +const MutableRefs = Handle.make()(TypeId) + +const Collections = Handle.make()(TypeId) + +/** + * A read-only reactive reference. + * + * **Details** + * + * It is a handle value: the protocol record minted by the kind, carrying + * equality and hashing by the current value and a private slot holding the + * engine behind it. The current value, the subscriptions, and the derived views + * are reached through the `dual` functions in this module, never through methods + * on the handle. The handle value itself is the ref's identity. + * + * Equality and hashing are based on the current value. + * + * @since 4.0.0 + */ +export type ReadonlyRef = Handle.Handle + +/** + * A mutable reactive reference. + * + * **Details** + * + * The whole value can be replaced, updated from the current value, and + * mutable references to nested properties can be created. All of that is + * reached through the `dual` functions in this module. + * + * @since 4.0.0 + */ +export type AtomRef = Handle.Handle + +/** + * A reactive collection of mutable item references. + * + * **Details** + * + * A collection is indexed at the item refs it carries, so it is a + * `ReadonlyRef` of those refs: reading or subscribing to it reports the current + * refs. Items can be added and removed and the raw item values can be listed. + * Every collection operation is a `dual` function in this module. + * + * @since 4.0.0 + */ +export type Collection = Handle.Handle[]> + +/** + * Returns `true` when a value is a reactive reference. + * + * @since 4.0.0 + */ +export const isReadonlyRef = Refs.is + +const refProtocol = (self: ReadonlyRefEngine): RefProtocol => ({ + [Equal.symbol]: (that) => isReadonlyRef(that) && self.equals(that), + [Hash.symbol]: () => self.hash(), +}) + +const mintReadonlyRef = (self: ReadonlyRefEngine): ReadonlyRef => Refs.make(refProtocol(self), self) + +const mintAtomRef = (self: AtomRefEngine): AtomRef => MutableRefs.make(refProtocol(self), self) + +const mintCollection = (self: CollectionEngine): Collection => + Collections.make[]>(refProtocol(self), self) + +const collectionEngineOf = (self: Collection): CollectionEngine => + Collections.slot[]>(self) + +/** + * Creates a mutable reactive reference initialized with the supplied value. + * + * @since 4.0.0 + */ +export const make = (value: A): AtomRef => mintAtomRef(new AtomRefEngineImpl(value)) + +/** + * Creates a reactive collection from an iterable of initial item values. + * + * **Details** + * + * Each item is wrapped in an `AtomRef`, and changes to item refs notify the + * collection subscribers. + * + * @since 4.0.0 + */ +export const collection = (items: Iterable): Collection => mintCollection(new CollectionImpl(items)) + +/** + * Reads the current value of a reactive reference. + * + * @since 4.0.0 + */ +export const get = (self: ReadonlyRef): A => Refs.slot(self).value() + +/** + * Replaces the whole value of a mutable reactive reference. Equal values leave + * the ref untouched. + * + * @since 4.0.0 + */ +export const set: { + (value: A): (self: AtomRef) => AtomRef + (self: AtomRef, value: A): AtomRef +} = dual(2, (self: AtomRef, value: A): AtomRef => { + MutableRefs.slot(self).set(value) + return self +}) + +/** + * Replaces the whole value using the current value of a mutable reactive + * reference. Equal results leave the ref untouched. + * + * @since 4.0.0 + */ +export const update: { + (f: (value: A) => A): (self: AtomRef) => AtomRef + (self: AtomRef, f: (value: A) => A): AtomRef +} = dual(2, (self: AtomRef, f: (value: A) => A): AtomRef => { + MutableRefs.slot(self).update(f) + return self +}) + +/** + * Listens to value changes of a reactive reference and returns a function that + * removes the listener again. + * + * @since 4.0.0 + */ +export const subscribe: { + (f: (a: A) => void): (self: ReadonlyRef) => () => void + (self: ReadonlyRef, f: (a: A) => void): () => void +} = dual(2, (self: ReadonlyRef, f: (a: A) => void): () => void => Refs.slot(self).subscribe(f)) + +/** + * Creates a read-only reactive reference derived from the current value. + * + * @since 4.0.0 + */ +export const map: { + (f: (a: A) => B): (self: ReadonlyRef) => ReadonlyRef + (self: ReadonlyRef, f: (a: A) => B): ReadonlyRef +} = dual(2, (self: ReadonlyRef, f: (a: A) => B): ReadonlyRef => Refs.slot(self).map(f)) + +/** + * Creates a mutable reactive reference to one property of the current value. + * + * @since 4.0.0 + */ +export const prop: { + (prop: K): (self: AtomRef) => AtomRef + (self: AtomRef, prop: K): AtomRef +} = dual(2, (self: AtomRef, prop: K): AtomRef => MutableRefs.slot(self).prop(prop)) + +/** + * Appends an item to the end of a reactive collection. + * + * @since 4.0.0 + */ +export const push: { + (item: A): (self: Collection) => Collection + (self: Collection, item: A): Collection +} = dual(2, (self: Collection, item: A): Collection => { + collectionEngineOf(self).push(item) + return self +}) + +/** + * Inserts an item into a reactive collection at the supplied index. + * + * @since 4.0.0 + */ +export const insertAt: { + (index: number, item: A): (self: Collection) => Collection + (self: Collection, index: number, item: A): Collection +} = dual(3, (self: Collection, index: number, item: A): Collection => { + collectionEngineOf(self).insertAt(index, item) + return self +}) + +/** + * Removes a reactive reference from a reactive collection. Removing a reference + * that is not part of the collection leaves the collection untouched. + * + * @since 4.0.0 + */ +export const remove: { + (ref: AtomRef): (self: Collection) => Collection + (self: Collection, ref: AtomRef): Collection +} = dual(2, (self: Collection, ref: AtomRef): Collection => { + collectionEngineOf(self).remove(ref) + return self +}) + +/** + * Lists the current item values of a reactive collection. + * + * @since 4.0.0 + */ +export const toArray = (self: Collection): A[] => collectionEngineOf(self).toArray() + +const isArrayWithProp = (value: A, _prop: K): value is A & Array => Array.isArray(value) + +const hasProp = (value: A, prop: K): boolean => { + if (value != null) { + return propInObject(value, prop) + } + return false +} + +const propInObject = (value: NonNullable, prop: PropertyKey): boolean => { + const boxed: AnyValue = Object(value) + return hasKey(boxed, prop) +} + +const isInTarget = (value: unknown): value is object => { + if (typeof value === 'object') { + return value !== null + } + return typeof value === 'function' +} + +const hasKey = (value: T, prop: PropertyKey): boolean => { + if (isInTarget(value)) { + return prop in value + } + return false +} + +const emitIfChanged = (next: B, previous: B, f: (value: B) => void): B => { + if (Equal.equals(next, previous)) { + return previous + } + f(next) + return next +} + +const emitPropIfPresent = ( + prop: K, + a: A, + previous: A[K], + f: (value: A[K]) => void, +): A[K] => { + if (hasProp(a, prop)) { + return emitIfChanged(a[prop], previous, f) + } + return previous +} + +type Listener = { + readonly f: (a: A) => void + prev: Listener | null + next: Listener | null +} + +interface Listeners { + listeners: Listener | null +} + +const linkListener = (self: Listeners, listener: Listener): void => { + if (self.listeners !== null) { + self.listeners.prev = listener + } +} + +const unlinkIfHead = (self: Listeners, listener: Listener): void => { + if (self.listeners === listener) { + self.listeners = listener.next + } +} + +const unlinkPrev = (listener: Listener): void => { + if (listener.prev !== null) { + listener.prev.next = listener.next + } +} + +const unlinkNext = (listener: Listener): void => { + if (listener.next !== null) { + listener.next.prev = listener.prev + } +} + +const unlinkListener = (self: Listeners, listener: Listener): void => { + unlinkIfHead(self, listener) + unlinkPrev(listener) + unlinkNext(listener) +} + +class ValueRefEngine extends Pipeable.Class implements ReadonlyRefEngine, Listeners { + current: A + listeners: Listener | null = null + + constructor(value: A) { + super() + this.current = value + } + + value(): A { + return this.current + } + + equals(that: EqualType): boolean { + return isReadonlyRef(that) && Equal.equals(this.current, Refs.slot(that).value()) + } + + hash(): number { + return Hash.hash(this.current) + } + + notify(a: A) { + let listener = this.listeners + while (listener !== null) { + listener.f(a) + listener = listener.next + } + } + + subscribe(f: (a: A) => void): () => void { + const listener: Listener = { + f, + prev: null, + next: this.listeners, + } + linkListener(this, listener) + this.listeners = listener + + return () => { + unlinkListener(this, listener) + } + } + + map(f: (a: A) => B): ReadonlyRef { + return mintReadonlyRef(new MapRefEngine(this, f)) + } +} + +class AtomRefEngineImpl extends ValueRefEngine implements AtomRefEngine { + set(value: A) { + if (Equal.equals(value, this.current)) { + return + } + this.current = value + this.notify(value) + } + + update(f: (value: A) => A) { + this.set(f(this.current)) + } + + prop(prop: K): AtomRef { + return mintAtomRef(new PropRefEngine(this, prop)) + } +} + +class MapRefEngine extends Pipeable.Class implements ReadonlyRefEngine { + readonly parent: ReadonlyRefEngine + readonly transform: (a: A) => B + + constructor(parent: ReadonlyRefEngine, transform: (a: A) => B) { + super() + this.parent = parent + this.transform = transform + } + + value(): B { + return this.transform(this.parent.value()) + } + + equals(that: EqualType): boolean { + return isReadonlyRef(that) && Equal.equals(this.value(), Refs.slot(that).value()) + } + + hash(): number { + return Hash.hash(this.value()) + } + + subscribe(f: (a: B) => void): () => void { + let previous = this.transform(this.parent.value()) + return this.parent.subscribe((a) => { + const next = this.transform(a) + if (Equal.equals(next, previous)) { + return + } + previous = next + f(next) + }) + } + + map(f: (a: B) => C): ReadonlyRef { + return mintReadonlyRef(new MapRefEngine(this, f)) + } +} + +class PropRefEngine extends Pipeable.Class implements AtomRefEngine { + private previous: A[K] + readonly parent: AtomRefEngine + readonly _prop: K + + constructor(parent: AtomRefEngine, _prop: K) { + super() + this.parent = parent + this._prop = _prop + this.previous = parent.value()[_prop] + } + + equals(that: EqualType): boolean { + return isReadonlyRef(that) && Equal.equals(this.value(), Refs.slot(that).value()) + } + + hash(): number { + return Hash.hash(this.value()) + } + + value(): A[K] { + const parentValue = this.parent.value() + if (hasProp(parentValue, this._prop)) { + this.previous = parentValue[this._prop] + } + return this.previous + } + + subscribe(f: (a: A[K]) => void): () => void { + let previous = this.value() + return this.parent.subscribe((a) => { + previous = emitPropIfPresent(this._prop, a, previous, f) + }) + } + + map(f: (a: A[K]) => C): ReadonlyRef { + return mintReadonlyRef(new MapRefEngine(this, f)) + } + + prop(prop: CK): AtomRef { + return mintAtomRef(new PropRefEngine(this, prop)) + } + + set(value: A[K]): void { + this.writeParent((current) => { + if (isArrayWithProp(current, this._prop)) { + const newArray = Object.assign(new Array(), current) + newArray[Number(this._prop)] = value + return newArray + } + return { + ...current, + [this._prop]: value, + } + }) + } + + update(f: (value: A[K]) => A[K]): void { + this.writeParent((current) => { + if (isArrayWithProp(current, this._prop)) { + const newArray = Object.assign(new Array(), current) + newArray[Number(this._prop)] = f(current[this._prop]) + return newArray + } + return { + ...current, + [this._prop]: f(current[this._prop]), + } + }) + } + + writeParent(write: (current: A) => A): void { + this.parent.set(write(this.parent.value())) + } +} + +class CollectionImpl extends ValueRefEngine[]> implements CollectionEngine { + private readonly linked = new Set>() + private items: AtomRef[] = [] + + override value(): readonly AtomRef[] { + return this.items + } + + constructor(items: Iterable) { + super([]) + for (const item of items) { + this.items.push(this.makeRef(item)) + } + } + + makeRef(value: A): AtomRef { + const itemEngine = new CollectionItemEngine(value, this) + this.linked.add(itemEngine) + return mintAtomRef(itemEngine) + } + + itemChanged(itemEngine: CollectionItemEngine) { + if (this.linked.has(itemEngine)) { + this.notify(this.value()) + } + } + + push(item: A) { + this.items.push(this.makeRef(item)) + this.notify(this.value()) + } + + insertAt(index: number, item: A) { + this.items.splice(index, 0, this.makeRef(item)) + this.notify(this.value()) + } + + remove(ref: AtomRef) { + const index = this.items.indexOf(ref) + if (index !== -1) { + this.items.splice(index, 1) + this.unlink(ref) + this.notify(this.value()) + } + } + + unlink(ref: AtomRef) { + const itemEngine = MutableRefs.slot(ref) + if (isCollectionItemEngine(itemEngine)) { + this.linked.delete(itemEngine) + } + } + + toArray(): A[] { + return this.items.map((ref) => Refs.slot(ref).value()) + } +} + +const isCollectionItemEngine = (itemEngine: AtomRefEngine): itemEngine is CollectionItemEngine => + itemEngine instanceof CollectionItemEngine + +class CollectionItemEngine extends AtomRefEngineImpl { + constructor(value: A, private readonly collection: CollectionImpl) { + super(value) + } + + override notify(a: A) { + super.notify(a) + this.collection.itemChanged(this) + } +} diff --git a/packages/atom/effect-atom/src/atom-rpc.service.ts b/packages/atom/effect-atom/src/atom-rpc.service.ts new file mode 100644 index 0000000..7cca6b0 --- /dev/null +++ b/packages/atom/effect-atom/src/atom-rpc.service.ts @@ -0,0 +1,581 @@ +/** + * Connects typed RPC clients to atoms. + * + * The service created here exposes a flattened RPC client plus atom-based query + * and mutation helpers. Query atoms call RPCs and track asynchronous or + * streaming results, while mutations run RPC calls that can invalidate + * reactivity keys after success. Query atoms can also use request headers, + * time-to-live settings, and serialization keys for hydration. + * + * @since 4.0.0 + */ +import * as Context from 'effect/Context' +import * as Duration from 'effect/Duration' +import * as Effect from 'effect/Effect' +import * as Headers from 'effect/http/Headers' +import * as Layer from 'effect/Layer' +import * as Reactivity from 'effect/reactivity/Reactivity' +import type { ReadonlyRecord } from 'effect/Record' +import type * as Rpc from 'effect/rpc/Rpc' +import * as RpcClient from 'effect/rpc/RpcClient' +import { RpcClientError } from 'effect/rpc/RpcClientError' +import type * as RpcGroup from 'effect/rpc/RpcGroup' +import type { RequestId } from 'effect/rpc/RpcMessage' +import * as RpcSchema from 'effect/rpc/RpcSchema' +import * as Schema from 'effect/Schema' +import * as Scope from 'effect/Scope' +import * as Stream from 'effect/Stream' +import type { NoInfer } from 'effect/Types' +import type { SetParameterType, SetReturnType } from 'type-fest' +import * as AsyncResult from './async-result.js' +import { schemaCodec } from './async-result.js' +import * as Atom from './atom-modules.js' + +type AnyAtom = Atom.Atom +type ReactivityKey = readonly K[] | ReadonlyRecord +type AnyRecord = Record +interface QueryOptions { + readonly headers?: Headers.Input | undefined + readonly reactivityKeys?: ReactivityKey | undefined + readonly timeToLive?: Duration.Input | undefined + readonly serializationKey?: string | undefined +} + +interface QueryKey { + tag: Rpc.Tag + payload: Rpc.PayloadConstructor + headers: Headers.Headers | undefined + reactivityKeys: ReactivityKey | undefined + timeToLive: Duration.Duration | undefined + serializationKey: string | undefined +} + +const orElse = (value: A | undefined, fallback: () => A): A => { + if (value === undefined) { + return fallback() + } + return value +} + +const protocolLayer = ( + service: Context.Key, + make: Effect.Effect, + protocol: Layer.Layer | ((get: Atom.AtomContext) => Layer.Layer), +) => { + const layer = Layer.effect(service, make) + if (typeof protocol === 'function') { + return (get: Atom.AtomContext) => Layer.provide(layer, Layer.orDie(protocol(get))) + } + return Layer.provide(layer, Layer.orDie(protocol)) +} + +const isObject = (u: unknown): u is object => { + if (typeof u !== 'object') { + return false + } + return u !== null +} + +const isRecordLike = (u: unknown): u is object => { + if (typeof u === 'function') { + return true + } + return isObject(u) +} + +const hasRpcSchemas = (u: object): boolean => + ['payloadSchema' in u, 'successSchema' in u, 'errorSchema' in u].includes(false) === false + +const isAnyWithProps = (u: unknown): u is Rpc.AnyWithProps => { + if (!isRecordLike(u)) { + return false + } + return hasRpcSchemas(u) +} + +const requireRpc = (rpc: R, tag: T): Rpc.AnyWithProps => { + if (isAnyWithProps(rpc)) { + return rpc + } + throw new Error(`Unknown RPC tag: ${String(tag)}`) +} + +const headersFromInput = ( + headers: Headers.Input | undefined, +): Headers.Headers | undefined => { + if (headers === undefined) { + return undefined + } + return Headers.fromInput(headers) +} + +const headersFromQueryOptions = ( + options: QueryOptions | undefined, +): Headers.Headers | undefined => { + if (options === undefined) { + return undefined + } + return headersFromInput(options.headers) +} + +const reactivityKeysFromQueryOptions = ( + options: QueryOptions | undefined, +): QueryOptions['reactivityKeys'] => { + if (options === undefined) { + return undefined + } + return options.reactivityKeys +} + +const durationFromInput = ( + timeToLive: Duration.Input | undefined, +): Duration.Duration | undefined => { + if (timeToLive === undefined) { + return undefined + } + return Duration.fromInputUnsafe(timeToLive) +} + +const timeToLiveFromQueryOptions = ( + options: QueryOptions | undefined, +): Duration.Duration | undefined => { + if (options === undefined) { + return undefined + } + return durationFromInput(options.timeToLive) +} + +const serializationKeyFromQueryOptions = ( + options: QueryOptions | undefined, +): string | undefined => { + if (options === undefined) { + return undefined + } + return options.serializationKey +} + +const makeQueryKey = ( + tag: Rpc.Tag, + payload: Rpc.PayloadConstructor, + options: QueryOptions | undefined, +): QueryKey => ({ + tag, + payload, + headers: headersFromQueryOptions(options), + reactivityKeys: reactivityKeysFromQueryOptions(options), + timeToLive: timeToLiveFromQueryOptions(options), + serializationKey: serializationKeyFromQueryOptions(options), +}) + +const hasSerializationKey = (key: string | undefined): key is string => { + if (key === undefined) { + return false + } + return key !== '' +} + +const applyFiniteOrKeepAlive = ( + atom: A, + timeToLive: Duration.Duration, +): A => { + if (Duration.isFinite(timeToLive)) { + return Atom.setIdleTTL(atom, timeToLive) + } + return Atom.keepAlive(atom) +} + +const applyQueryTtl = ( + atom: A, + timeToLive: Duration.Duration | undefined, +): A => { + if (timeToLive === undefined) { + return atom + } + return applyFiniteOrKeepAlive(atom, timeToLive) +} + +/** + * A `Context.Service` for a flattened RPC client integrated with atom reactivity. + * + * **Details** + * + * It exposes the RPC client, an atom runtime, mutation helpers that return `AtomResultFn`s, and query helpers that + * return atoms or pull atoms for RPC results. + * + * @since 4.0.0 + */ +export interface AtomRpcClient extends + Context.Service< + Self, + RpcClient.RpcClient.Flat + > +{ + new(_: never): Context.ServiceClass.Shape< + Id, + RpcClient.RpcClient.Flat + > + + readonly runtime: Atom.AtomRuntime + + readonly mutation: >( + arg: Tag, + ) => Rpc.ExtractTag extends Rpc.Rpc< + infer _Tag, + infer _Payload, + infer _Success, + infer _Error, + infer _Middleware, + infer _Requires + > ? [_Success] extends [RpcSchema.Stream] ? never + : Atom.AtomResultFn< + { + readonly payload: Rpc.PayloadConstructor> + readonly reactivityKeys?: ReactivityKey | undefined + readonly headers?: Headers.Input | undefined + }, + _Success['Type'], + _Error['Type'] | RpcClientError | _Middleware['error']['Type'] + > + : never + + readonly query: >( + tag: Tag, + payload: Rpc.PayloadConstructor>, + options?: { + readonly headers?: Headers.Input | undefined + readonly reactivityKeys?: ReactivityKey | undefined + readonly timeToLive?: Duration.Input | undefined + readonly serializationKey?: string | undefined + }, + ) => Rpc.ExtractTag extends Rpc.Rpc< + infer _Tag, + infer _Payload, + infer _Success, + infer _Error, + infer _Middleware + > ? [_Success] extends [RpcSchema.Stream] ? Atom.Writable< + Atom.PullResult< + _A['Type'], + _E['Type'] | _Error['Type'] | RpcClientError | _Middleware['error']['Type'] + >, + void + > + : Atom.Atom< + AsyncResult.Result< + _Success['Type'], + _Error['Type'] | RpcClientError | _Middleware['error']['Type'] + > + > + : never +} + +declare global { + interface ErrorConstructor { + stackTraceLimit: number + } +} + +/** + * Creates a `Context.Service` class for an RPC client backed by an atom runtime. + * + * **Details** + * + * The options provide the RPC group, protocol layer, tracing options, request id generation, optional custom client + * effect, and runtime factory used by the query and mutation helpers. + * + * @since 4.0.0 + */ +export const Service = () => +< + const Id extends string, + Rpcs extends Rpc.Any, + ER, + RM = + | RpcClient.Protocol + | Rpc.MiddlewareClient> + | Rpc.ServicesClient>, +>( + id: Id, + options: { + readonly group: RpcGroup.RpcGroup + readonly protocol: + | Layer.Layer, Scope.Scope>, ER> + | ((get: Atom.AtomContext) => Layer.Layer, Scope.Scope>, ER>) + readonly spanPrefix?: string | undefined + readonly spanAttributes?: AnyRecord | undefined + readonly generateRequestId?: (() => RequestId) | undefined + readonly disableTracing?: boolean | undefined + readonly makeEffect?: + | Effect.Effect< + RpcClient.RpcClient.Flat, + never, + | RM + | RpcClient.Protocol + | Rpc.MiddlewareClient + | Scope.Scope + > + | undefined + readonly runtime?: Atom.RuntimeFactory | undefined + }, +): AtomRpcClient => { + const service = Context.Service< + Self, + RpcClient.RpcClient.Flat + >()(id) + + const make = orElse(options.makeEffect, () => + RpcClient.make(options.group, { + ...options, + flatten: true, + })) + + const runtime = orElse(options.runtime, () => Atom.context())( + protocolLayer(service, make, options.protocol), + ) + + const getRpc = (tag: Rpc.Tag): Rpc.AnyWithProps => requireRpc(options.group.requests.get(tag), tag) + + /** Every payload constructor any RPC in this group accepts. */ + type AnyPayload = Rpc.PayloadConstructor + + /** Every value any RPC in this group succeeds with - a stream for a streaming one. */ + type AnySuccess = Rpc.Success + + /** Every error any RPC in this group fails with, plus the client's own transport error. */ + type AnyError = Rpc.Error | RpcClientError + + /** What a request-shaped call returns once the tag is the whole tag union. */ + type AnyRequestResult = Effect.Effect + + /** What a streaming call returns: the chunk types and exit errors of the streaming RPCs. */ + type AnyStreamResult = Stream.Stream, Rpc.ErrorExit | RpcClientError, never> + + /** + * `Flat`'s call with the two parameters that cannot resolve here replaced, and + * its per-tag return collapsed to the two shapes a tag can select. This service + * dispatches on a tag that only exists at runtime - `Atom.family` fixes one + * `Arg` per family, so the tag arrives inside a cache key - and `Flat` computes + * the payload constructor, an options object that differs for streaming + * requests, and the return from it. Deriving the type from `Flat` rather than + * restating it keeps the tag parameter exact and makes a change to `Flat`'s + * parameters break here. + */ + type ErasedFlatCall = SetParameterType< + SetReturnType, AnyRequestResult | AnyStreamResult>, + { 1: AnyPayload; 2: { readonly headers?: Headers.Input | undefined } } + > + + /** `Flat` is a callable, so this narrowing is a check rather than a claim. */ + const isErasedFlatCall = (client: unknown): client is ErasedFlatCall => typeof client === 'function' + + /** + * Calls the flat client for a tag known only at runtime. + * + * The two declarations state what the compiler cannot derive: which of the two + * shapes the tag selects, and that the requirement channel is empty - the + * client discharged its own requirements before `service` yielded it, while + * `Flat` still reports the schemas' encoding and decoding services for a tag it + * cannot resolve. + */ + function callFlat( + client: RpcClient.RpcClient.Flat, + tag: Rpc.Tag, + payload: AnyPayload, + headers: Headers.Input | undefined, + shape: 'effect', + ): AnyRequestResult + function callFlat( + client: RpcClient.RpcClient.Flat, + tag: Rpc.Tag, + payload: AnyPayload, + headers: Headers.Input | undefined, + shape: 'stream', + ): AnyStreamResult + function callFlat( + client: RpcClient.RpcClient.Flat, + tag: Rpc.Tag, + payload: AnyPayload, + headers: Headers.Input | undefined, + _shape: 'effect' | 'stream', + ): AnyRequestResult | AnyStreamResult { + if (!isErasedFlatCall(client)) { + throw new Error(`RpcClient.Flat is not callable for tag: ${tag}`) + } + return client(tag, payload, { headers }) + } + + const resultSchema = schemaCodec + + const mutationFamily = Atom.family(>(tag: Tag) => { + const rpc = getRpc(tag) + const fnAtom = runtime.fn<{ + readonly payload: Rpc.PayloadConstructor> + readonly reactivityKeys?: ReactivityKey | undefined + readonly headers?: Headers.Input | undefined + }>()( + Effect.fnUntraced(function*({ headers, payload, reactivityKeys }) { + const client = yield* service + const effect = callFlat(client, tag, payload, headers, 'effect') + if (reactivityKeys === undefined) { + return yield* effect + } + return yield* Reactivity.mutation(effect, reactivityKeys) + }), + ) + return Atom.serializable(fnAtom, { + key: `AtomRpc:mutation:${tag}`, + schema: resultSchema(rpc.successSchema, makeErrorSchema(rpc)), + }) + }) + + type MutationReturn> = Rpc.ExtractTag extends Rpc.Rpc< + infer _Tag, + infer _Payload, + infer _Success, + infer _Error, + infer _Middleware, + infer _Requires + > ? [_Success] extends [RpcSchema.Stream] ? never + : Atom.AtomResultFn< + { + readonly payload: Rpc.PayloadConstructor> + readonly reactivityKeys?: ReactivityKey | undefined + readonly headers?: Headers.Input | undefined + }, + _Success['Type'], + _Error['Type'] | RpcClientError | _Middleware['error']['Type'] + > + : never + + function mutation>(arg: Tag): MutationReturn + function mutation(arg: Rpc.Tag): AnyAtom { + return mutationFamily(arg) + } + + const makeStreamQueryAtom = (key: QueryKey): AnyAtom => + runtime.pull( + Stream.unwrap( + service.use((client) => + Effect.succeed( + callFlat(client, key.tag, key.payload, key.headers, 'stream'), + ) + ), + ), + ) + + const makeEffectQueryAtom = (key: QueryKey): AnyAtom => + runtime.atom( + service.use((client) => callFlat(client, key.tag, key.payload, key.headers, 'effect')), + ) + + const makeQueryAtom = (key: QueryKey): AnyAtom => { + const rpc = getRpc(key.tag) + if (RpcSchema.isStreamSchema(rpc.successSchema)) { + return makeStreamQueryAtom(key) + } + return makeEffectQueryAtom(key) + } + + const withQueryReactivity = ( + atom: A, + reactivityKeys: QueryKey['reactivityKeys'], + ): A => { + if (reactivityKeys === undefined) { + return atom + } + return runtime.factory.withReactivity(reactivityKeys)(atom) + } + + const serializeNonStreamQueryAtom = ( + atom: A, + key: QueryKey, + rpc: Rpc.AnyWithProps, + ): A => { + if (hasSerializationKey(key.serializationKey)) { + return Atom.serializable(atom, { + key: `AtomRpc:${key.tag}:${key.serializationKey}`, + schema: resultSchema(rpc.successSchema, makeErrorSchema(rpc)), + }) + } + return atom + } + + const serializeQueryAtom = ( + atom: A, + key: QueryKey, + ): A => { + const rpc = getRpc(key.tag) + if (RpcSchema.isStreamSchema(rpc.successSchema)) { + return atom + } + return serializeNonStreamQueryAtom(atom, key, rpc) + } + + const decorateQueryAtom = ( + atom: A, + key: QueryKey, + ): A => + applyQueryTtl( + serializeQueryAtom(withQueryReactivity(atom, key.reactivityKeys), key), + key.timeToLive, + ) + + const queryFamily = Atom.family((key: QueryKey) => decorateQueryAtom(makeQueryAtom(key), key)) + + type QueryReturn> = Rpc.ExtractTag extends Rpc.Rpc< + infer _Tag, + infer _Payload, + infer _Success, + infer _Error, + infer _Middleware + > ? [_Success] extends [RpcSchema.Stream] ? Atom.Writable< + Atom.PullResult< + _A['Type'], + _E['Type'] | _Error['Type'] | RpcClientError | _Middleware['error']['Type'] + >, + void + > + : Atom.Atom< + AsyncResult.Result< + _Success['Type'], + _Error['Type'] | RpcClientError | _Middleware['error']['Type'] + > + > + : never + + function query>( + tag: Tag, + payload: Rpc.PayloadConstructor>, + options?: { + readonly headers?: Headers.Input | undefined + readonly reactivityKeys?: ReactivityKey | undefined + readonly timeToLive?: Duration.Input | undefined + readonly serializationKey?: string | undefined + }, + ): QueryReturn + function query( + tag: Rpc.Tag, + payload: Rpc.PayloadConstructor, + options?: { + readonly headers?: Headers.Input | undefined + readonly reactivityKeys?: ReactivityKey | undefined + readonly timeToLive?: Duration.Input | undefined + readonly serializationKey?: string | undefined + }, + ): AnyAtom { + return queryFamily(makeQueryKey(tag, payload, options)) + } + + return Object.assign(service, { + runtime, + mutation, + query, + }) +} + +const makeErrorSchema = (rpc: Rpc.AnyWithProps): Schema.Top => + Schema.Union([ + rpc.errorSchema, + ...Array.from(rpc.middlewares, (middleware) => middleware.error), + RpcClientError, + ]) diff --git a/packages/atom/effect-atom/src/atom-sentinels.ts b/packages/atom/effect-atom/src/atom-sentinels.ts new file mode 100644 index 0000000..00b4cab --- /dev/null +++ b/packages/atom/effect-atom/src/atom-sentinels.ts @@ -0,0 +1,15 @@ +/** + * Control symbols an `AtomResultFn` write accepts beside function arguments. + * + * They live outside the atom blueprint because that module exports its own + * `TypeId` symbol and declares no other symbol. + * + * @since 4.0.0 + */ +export const Reset = Symbol.for('effect/reactivity/atom/Atom/Reset') + +export type Reset = typeof Reset + +export const Interrupt = Symbol.for('effect/reactivity/atom/Atom/Interrupt') + +export type Interrupt = typeof Interrupt diff --git a/packages/atom/effect-atom/src/atom.blueprint.ts b/packages/atom/effect-atom/src/atom.blueprint.ts new file mode 100644 index 0000000..f86a998 --- /dev/null +++ b/packages/atom/effect-atom/src/atom.blueprint.ts @@ -0,0 +1,3981 @@ +/** + * The atom kind: a cold description of one reactive value that a registry + * evaluates. + * + * An atom is a {@link Blueprint} value. Its spec carries the read and write + * functions and the settings a registry honours: keep-alive, laziness, idle + * time-to-live, label, equality, serialization, and server value. Each + * configuration step returns a new atom of the same variant. `read`, `write`, + * `equals`, and `serverValue` are targets typed by the atom's value. + * + * @since 4.0.0 + */ +import { Blueprint } from '@systemfsoftware/effect-cell-types' +import * as Arr from 'effect/Array' +import * as Cause from 'effect/Cause' +import * as Channel from 'effect/Channel' +import * as Context from 'effect/Context' +import * as Duration from 'effect/Duration' +import * as Effect from 'effect/Effect' +import * as Exit from 'effect/Exit' +import * as Fiber from 'effect/Fiber' +import { constant, constTrue, constVoid, dual, pipe } from 'effect/Function' +import * as Layer from 'effect/Layer' +import * as MutableHashMap from 'effect/MutableHashMap' +import * as Option from 'effect/Option' +import * as Predicate from 'effect/Predicate' +import * as Pull from 'effect/Pull' +import * as Reactivity from 'effect/reactivity/Reactivity' +import type { ReadonlyRecord } from 'effect/Record' +import * as Result from 'effect/Result' +import * as Scheduler from 'effect/Scheduler' +import * as Schema from 'effect/Schema' +import * as Scope from 'effect/Scope' +import * as Stream from 'effect/Stream' +import * as SubscriptionRef from 'effect/SubscriptionRef' +import type { NoInfer } from 'effect/Types' +import * as AsyncResult from './async-result.js' +import { Interrupt, Reset } from './atom-sentinels.js' +import { Current } from './current-registry.service.js' +import type { RegistryImpl } from './registry-engine.js' +import * as Registry from './registry.handle.js' + +/** + * The identity every atom carries. + * + * @since 4.0.0 + */ +export const TypeId: unique symbol = Symbol.for('~effect/reactivity/Atom') + +/** + * @since 4.0.0 + */ +export type TypeId = typeof TypeId + +/** + * Context passed to atom read functions for reading dependencies, awaiting `AsyncResult` or `Option` values, managing subscriptions and finalizers, refreshing atoms, and updating writable atoms. + * + * @since 4.0.0 + */ +export interface AtomContext { + (atom: Atom): A + get(this: AtomContext, atom: Atom): A + result(this: AtomContext, atom: Atom>, options?: { + readonly suspendOnWaiting?: boolean | undefined + }): Effect.Effect + resultOnce(this: AtomContext, atom: Atom>, options?: { + readonly suspendOnWaiting?: boolean | undefined + }): Effect.Effect + once(this: AtomContext, atom: Atom): A + addFinalizer(this: AtomContext, f: () => void): void + mount(this: AtomContext, atom: Atom): void + refresh(this: AtomContext, atom: Atom): void + refreshSelf(this: AtomContext): void + self(this: AtomContext): Option.Option + setSelf(this: AtomContext, a: A): void + set(this: AtomContext, atom: Writable, value: W): void + setResult(this: AtomContext, atom: Writable, W>, value: W): Effect.Effect + some(this: AtomContext, atom: Atom>): Effect.Effect + someOnce(this: AtomContext, atom: Atom>): Effect.Effect + stream(this: AtomContext, atom: Atom, options?: { + readonly withoutInitialValue?: boolean + readonly bufferSize?: number + }): Stream.Stream + streamResult(this: AtomContext, atom: Atom>, options?: { + readonly withoutInitialValue?: boolean + readonly bufferSize?: number + }): Stream.Stream + subscribe(this: AtomContext, atom: Atom, f: (_: A) => void, options?: { + readonly immediate?: boolean + }): void + isFn?: boolean | undefined + readonly registry: RegistryImpl +} + +/** + * Context passed to writable atom write functions for reading atoms, refreshing or setting the current atom, and writing to other writable atoms. + * + * @since 4.0.0 + */ +export interface WriteContext { + readonly registry: Registry.Registry + get(this: WriteContext, atom: Atom): T + refreshSelf(this: WriteContext): void + setSelf(this: WriteContext, a: A): void + set(this: WriteContext, atom: Writable, value: W): void +} + +/** + * JSON values a serializable atom's codec encodes to. + * + * @since 4.0.0 + */ +export type SerializableJson = + | string + | number + | boolean + | null + | ReadonlyArray + | { readonly [key: string]: SerializableJson } + +/** + * The serialization an atom carries: the key naming it in dehydrated state and the JSON codec for its value. + * + * @since 4.0.0 + */ +export interface SerializableSpec { + readonly key: string + readonly codecJson: Schema.toCodecJson +} + +/** + * The runtime members an atom spec stores, with their service and error types erased. + * + * @since 4.0.0 + */ +export interface ErasedRuntime { + readonly factory: RuntimeMembers['factory'] + readonly layer: Atom + readonly atom: (...args: never[]) => Top + readonly fn: (...args: never[]) => Top + readonly pull: (...args: never[]) => Top + readonly subscriptionRef: (...args: never[]) => Top +} + +/** + * The cold description one atom carries. + * + * **Details** + * + * Functions are stored with their value type erased; the `read`, `write`, + * `equals`, and `serverValue` targets read them back typed by the atom. + * + * @since 4.0.0 + */ +export interface AtomSpec { + readonly read: (get: AtomContext) => Top + readonly write: ((ctx: WriteContext, value: never) => void) | undefined + readonly equals: (value: never, next: never) => boolean + readonly refresh: Refresh | undefined + readonly keepAlive: boolean + readonly lazy: boolean + readonly label: readonly [name: string, stack: string] | undefined + readonly idleTTL: number | undefined + readonly initialValueTarget: Atom | undefined + readonly serializable: SerializableSpec | undefined + readonly serverValue: ((get: (atom: Atom) => A) => Top) | undefined + readonly runtime: ErasedRuntime | undefined +} + +/** + * A custom refresh: the atoms to refresh when this atom is refreshed. + * + * @since 4.0.0 + */ +export type Refresh = (f: (atom: Atom) => void) => void + +/** + * The type index of an atom: the value it reads. + * + * @since 4.0.0 + */ +export interface AtomIndex { + readonly Value: Top +} + +/** + * The type index of a writable atom: the value it reads and the input it accepts. + * + * @since 4.0.0 + */ +export interface WritableIndex extends AtomIndex { + readonly Write: (value: never) => void +} + +type ValueOf = X extends { readonly Value: infer A } ? A : never + +type WriteOf = X extends { readonly Write: (value: infer W) => void } ? W : never + +type ServicesOf = ValueOf extends AsyncResult.Result, infer _> ? R : never + +type RuntimeErrorOf = ValueOf extends AsyncResult.Result ? E : never + +interface Equivalent { + bivariant(value: A, next: A): boolean +} + +/** + * A server-side read of an atom's value. + * + * @since 4.0.0 + */ +export type ServerRead = (get: (atom: Atom) => A2) => A + +interface Read extends Blueprint.Target { + readonly target: (get: AtomContext) => ValueOf +} + +interface Equals extends Blueprint.Target { + readonly target: Equivalent>['bivariant'] +} + +interface ServerValue extends Blueprint.Target { + readonly target: ServerRead> | undefined +} + +interface Write extends Blueprint.Target { + readonly target: (ctx: WriteContext>, value: WriteOf) => void +} + +interface RuntimeTarget extends Blueprint.Target { + readonly target: RuntimeMembers, RuntimeErrorOf>[K] +} + +interface KeepAlive extends Blueprint.Step { + readonly last: (self: A) => A +} + +interface AutoDispose extends Blueprint.Step { + readonly last: (self: A) => A +} + +interface WithServerValueInitial extends Blueprint.Step { + readonly last: >>(self: A) => A +} + +interface SetLazy extends Blueprint.Step { + readonly last: (lazy: boolean) => (self: A) => A +} + +interface WithLabel extends Blueprint.Step { + readonly last: (name: string) => (self: A) => A +} + +interface SetIdleTTL extends Blueprint.Step { + readonly last: (duration: Duration.Input) => (self: A) => A +} + +/** + * The invalidation keys a `Reactivity` service refreshes an atom on. + * + * @since 4.0.0 + */ +export type ReactivityKeys = ReadonlyArray | ReadonlyRecord> + +/** + * The atom that resolves the `Reactivity` service an atom registers its keys with. + * + * @since 4.0.0 + */ +export type ReactivityAtom = Atom> + +interface WithReactivity extends Blueprint.Operation { + readonly params: readonly [keys: ReactivityKeys, reactivity: ReactivityAtom] + readonly lastFirst: ReactivityKeys + readonly lastRest: readonly [reactivity: ReactivityAtom] + readonly out: this['Self'] + readonly last: (keys: ReactivityKeys, reactivity: ReactivityAtom) => (self: A) => A +} + +interface WithEquality extends Blueprint.Operation { + readonly params: readonly [equals: Equivalent>['bivariant']] + readonly lastFirst: never + readonly lastRest: readonly [] + readonly out: this['Self'] + readonly last: (equals: (value: Type, next: Type) => boolean) => (self: T) => T +} + +interface WithServerValue extends Blueprint.Operation { + readonly params: readonly [read: ServerRead>] + readonly lastFirst: never + readonly lastRest: readonly [] + readonly out: this['Self'] + readonly last: (read: ServerRead>) => (self: A) => A +} + +/** + * The options that make an atom serializable: its dehydration key and the schema of its value. + * + * @since 4.0.0 + */ +export interface SerializableOptions { + readonly key: string + readonly schema: S +} + +type SchemaOf = Args extends readonly [SerializableOptions] ? S : never + +interface MakeSerializable extends Blueprint.Operation { + readonly params: readonly [options: SerializableOptions] + readonly lastFirst: never + readonly lastRest: readonly [] + readonly out: this['Self'] & Serializable> + readonly last: ( + options: SerializableOptions, + ) => (self: R) => R & Serializable +} + +/** + * The operations and targets every atom carries. + * + * @since 4.0.0 + */ +export interface AtomOps { + readonly keepAlive: KeepAlive + readonly autoDispose: AutoDispose + readonly setLazy: SetLazy + readonly withLabel: WithLabel + readonly setIdleTTL: SetIdleTTL + readonly withEquality: WithEquality + readonly withServerValue: WithServerValue + readonly withServerValueInitial: WithServerValueInitial + readonly withReactivity: WithReactivity + readonly serializable: MakeSerializable + readonly read: Read + readonly equals: Equals + readonly serverValue: ServerValue +} + +/** + * The operations and targets a writable atom carries. + * + * @since 4.0.0 + */ +export interface WritableOps extends AtomOps { + readonly write: Write +} + +/** + * The operations and targets a runtime atom carries. + * + * @since 4.0.0 + */ +export interface RuntimeOps extends AtomOps { + readonly atom: RuntimeTarget<'atom'> + readonly fn: RuntimeTarget<'fn'> + readonly pull: RuntimeTarget<'pull'> + readonly subscriptionRef: RuntimeTarget<'subscriptionRef'> + readonly factory: RuntimeTarget<'factory'> + readonly layer: RuntimeTarget<'layer'> +} + +/** + * Reactive value read by a registry, with settings controlling caching, laziness, refresh behavior, and initial value targeting. + * + * @since 4.0.0 + */ +export type Atom = Blueprint.Blueprint + +/** + * Atom that can also be written to, using a `WriteContext` and an input value to update reactive state. + * + * @since 4.0.0 + */ +export type Writable = Blueprint.Blueprint< + TypeId, + AtomSpec, + WritableOps, + { readonly Value: R; readonly Write: (value: W) => void } +> + +/** + * An atom that builds a layer's services once per registry and constructs atoms that run with them. + * + * @since 4.0.0 + */ +export type AtomRuntime = Blueprint.Blueprint< + TypeId, + AtomSpec, + RuntimeOps, + { readonly Value: AsyncResult.Result, ER> } +> + +/** + * Serialization carried by an atom, typed by its schema. + * + * @since 4.0.0 + */ +export interface Serializable { + readonly spec: { readonly serializable: SerializableSpec } +} + +/** + * Extracts the value type produced by an `Atom`. + * + * @since 4.0.0 + */ +export type Type = T extends { readonly read: (get: AtomContext) => infer A } ? A : never + +/** + * An atom reading `B` that keeps the write input of `R` when `R` is writable. + * + * @since 4.0.0 + */ +export type With = R extends { readonly write: (ctx: never, value: infer W) => void } + ? Writable + : Atom + +/** + * An atom type without serializable metadata, preserving `Writable` read and write types when the input atom is writable. + * + * @since 4.0.0 + */ +export type WithoutSerializable = With> + +const defaults = { + write: undefined, + equals: Object.is, + refresh: undefined, + keepAlive: false, + lazy: true, + label: undefined, + idleTTL: undefined, + initialValueTarget: undefined, + serializable: undefined, + serverValue: undefined, + runtime: undefined, +} as const + +const stackLine = (stack: string): string => stack.split('\n')[5] ?? '' + +/** + * The call site that labelled an atom, read from the current stack. + * + * @since 4.0.0 + */ +export const stackLabel = (): string => stackLine(new Error().stack ?? '') + +const finiteMillis = (duration: Duration.Duration): number | undefined => + Duration.isFinite(duration) ? Duration.toMillis(duration) : undefined + +const withIdleTTL = (spec: AtomSpec, input: Duration.Input): AtomSpec => { + const duration = Duration.fromInputUnsafe(input) + return { ...spec, keepAlive: !Duration.isFinite(duration), idleTTL: finiteMillis(duration) } +} + +const labelFor = (spec: AtomSpec, key: string): readonly [string, string] => spec.label ?? [key, stackLabel()] + +const withSerializable = (spec: AtomSpec, options: SerializableOptions): AtomSpec => ({ + ...spec, + label: labelFor(spec, options.key), + serializable: { key: options.key, codecJson: Schema.toCodecJson(options.schema) }, +}) + +const refreshingOn = (source: Atom, keys: ReactivityKeys, reactivity: ReactivityAtom): AtomSpec => ({ + ...source.spec, + read: (get) => { + const store = AsyncResult.getOrThrow(get(reactivity)) + get.addFinalizer(store.registerUnsafe(keys, () => get.refresh(source))) + get.subscribe(source, (value) => get.setSelf(value)) + return get.once(source) + }, +}) + +const operations = { + keepAlive: (self: Atom) => remint({ ...self.spec, keepAlive: true }), + autoDispose: (self: Atom) => remint({ ...self.spec, keepAlive: false }), + setLazy: (self: Atom, lazy: boolean) => remint({ ...self.spec, lazy }), + withLabel: (self: Atom, name: string) => remint({ ...self.spec, label: [name, stackLabel()] }), + setIdleTTL: (self: Atom, duration: Duration.Input) => remint(withIdleTTL(self.spec, duration)), + withEquality: (self: Atom, equals: (value: never, next: never) => boolean) => remint({ ...self.spec, equals }), + withServerValue: (self: Atom, serverValue: ServerRead) => remint({ ...self.spec, serverValue }), + withServerValueInitial: (self: Atom) => remint({ ...self.spec, serverValue: constant(AsyncResult.initial(true)) }), + serializable: (self: Atom, options: SerializableOptions) => remint(withSerializable(self.spec, options)), + withReactivity: (self: Atom, keys: ReactivityKeys, reactivity: ReactivityAtom) => + remint(refreshingOn(self, keys, reactivity)), +} + +const targets = { + read: (self: Atom) => self.spec.read, + equals: (self: Atom) => self.spec.equals, + serverValue: (self: Atom) => self.spec.serverValue, +} + +const Atoms = Blueprint.make()(TypeId).operations()({ operations, targets }) + +const Writables = Blueprint.make()(TypeId).operations()({ + operations, + targets: { ...targets, write: (self: Atom) => self.spec.write }, +}) + +const runtimeOf = (self: Atom): ErasedRuntime | undefined => self.spec.runtime + +const Runtimes = Blueprint.make()(TypeId).operations()({ + operations, + targets: { + ...targets, + atom: (self: Atom) => runtimeOf(self)?.atom, + fn: (self: Atom) => runtimeOf(self)?.fn, + pull: (self: Atom) => runtimeOf(self)?.pull, + subscriptionRef: (self: Atom) => runtimeOf(self)?.subscriptionRef, + factory: (self: Atom) => runtimeOf(self)?.factory, + layer: (self: Atom) => runtimeOf(self)?.layer, + }, +}) + +const mintPlain = (spec: AtomSpec): Atom => (spec.write === undefined ? Atoms.of(spec) : Writables.of(spec)) + +function remint(spec: AtomSpec): Atom { + if (spec.runtime === undefined) { + return mintPlain(spec) + } + return Runtimes.of(spec) +} + +/** + * Returns `true` when a value is an `Atom`. + * + * @since 4.0.0 + */ +export const isAtom = Atoms.is + +/** + * Returns `true` when an atom is writable. + * + * @since 4.0.0 + */ +export const isWritable = (atom: Atom): atom is Writable => atom.spec.write !== undefined + +/** + * Returns `true` when an atom carries serialization. + * + * @since 4.0.0 + */ +export const isSerializable = (self: Atom): self is Atom & Serializable => + self.spec.serializable !== undefined + +/** + * Creates a read-only atom from a read function and an optional custom refresh registration callback. + * + * @since 4.0.0 + */ +export const readable: { + (read: (get: AtomContext) => A, refresh?: Refresh): Atom + (refresh?: Refresh): (read: (get: AtomContext) => A) => Atom +} = dual( + (args) => args.length !== 0, + (read: (get: AtomContext) => A, refresh?: Refresh): Atom => + Atoms.of<{ readonly Value: A }>({ ...defaults, read, refresh }), +) + +/** + * Creates a writable atom from read and write functions, with an optional custom refresh registration callback. + * + * @since 4.0.0 + */ +export const writable: { + (read: (get: AtomContext) => R, write: (ctx: WriteContext, value: W) => void, refresh?: Refresh): Writable< + R, + W + > + (write: (ctx: WriteContext, value: W) => void, refresh?: Refresh): ( + read: (get: AtomContext) => R, + ) => Writable +} = dual( + (args) => args.length >= 2, + (read: (get: AtomContext) => R, write: (ctx: WriteContext, value: W) => void, refresh?: Refresh) => + Writables.of<{ readonly Value: R; readonly Write: (value: W) => void }>({ ...defaults, read, write, refresh }), +) + +/** + * Creates a runtime atom from its read function and the members its factory builds. + * + * @since 4.0.0 + */ +export const runtime = (parts: { + readonly read: (get: AtomContext) => AsyncResult.Result, ER> + readonly members: RuntimeMembers +}): AtomRuntime => + Runtimes.of<{ readonly Value: AsyncResult.Result, ER> }>({ + ...defaults, + read: parts.read, + runtime: parts.members, + }) + +const refreshThrough = (self: Atom): Refresh => self.spec.refresh ?? ((refresh) => refresh(self)) + +const rootTarget = (atom: Atom): Atom => + atom.spec.initialValueTarget === undefined ? atom : rootTarget(atom.spec.initialValueTarget) + +const initialTargetOf = (target: Atom | undefined): Atom | undefined => + target === undefined ? undefined : rootTarget(target) + +const forwardWrite = (self: Writable) => (ctx: WriteContext, value: never): void => { + ctx.set(self, value) +} + +const writeThrough = (self: Atom): AtomSpec['write'] => (isWritable(self) ? forwardWrite(self) : undefined) + +const derivedSpec = ( + self: R, + f: (get: AtomContext, atom: R) => B, + initialValueTarget: Atom | undefined, +): AtomSpec => ({ + ...defaults, + read: (get) => f(get, self), + write: writeThrough(self), + refresh: refreshThrough(self), + idleTTL: 0, + initialValueTarget: initialTargetOf(initialValueTarget), +}) + +const mintDerived = (spec: AtomSpec): Atom => + spec.write === undefined + ? Atoms.of<{ readonly Value: B }>(spec) + : Writables.of<{ readonly Value: B; readonly Write: (value: never) => void }>(spec) + +/** + * Creates a derived atom by reading another atom with a custom `AtomContext` function. + * + * **Details** + * + * If the source is writable, the derived atom keeps the source write input and + * forwards writes to the source. `initialValueTarget` controls which atom receives + * preloaded initial values for the derived atom. + * + * @since 4.0.0 + */ +export const transform: { + ( + f: (get: AtomContext, atom: R) => B, + options?: { readonly initialValueTarget?: Atom | undefined }, + ): (self: R) => With + ( + self: R, + f: (get: AtomContext, atom: R) => B, + options?: { readonly initialValueTarget?: Atom | undefined }, + ): With +} = dual( + (args) => isAtom(args[0]), + ( + self: R, + f: (get: AtomContext, atom: R) => B, + options?: { readonly initialValueTarget?: Atom | undefined }, + ): Atom => mintDerived(derivedSpec(self, f, options?.initialValueTarget)), +) + +/** + * Returns a copy of an atom that remains cached and mounted even when no subscribers are using it. + * + * @since 4.0.0 + */ +export const keepAlive: (self: A) => A = Atoms.operations.keepAlive + +/** + * Allows a reactive value to be disposed of when it is not in use. + * + * @since 4.0.0 + */ +export const autoDispose: (self: A) => A = Atoms.operations.autoDispose + +/** + * Sets whether an atom should be lazy. + * + * @since 4.0.0 + */ +export const setLazy: { + (lazy: boolean): (self: A) => A + (self: A, lazy: boolean): A +} = Atoms.operations.setLazy + +/** + * Names an atom, recording the call site that named it. + * + * @since 4.0.0 + */ +export const withLabel: { + (name: string): (self: A) => A + (self: A, name: string): A +} = Atoms.operations.withLabel + +/** + * Returns a copy of an atom with an idle time-to-live: finite durations dispose it after inactivity, while an infinite duration keeps it alive. + * + * @since 4.0.0 + */ +export const setIdleTTL: { + (duration: Duration.Input): (self: A) => A + (self: A, duration: Duration.Input): A +} = Atoms.operations.setIdleTTL + +/** + * Returns a copy of an atom that uses a custom equality function to detect value changes. + * + * @since 4.0.0 + */ +export const withEquality: { + (equals: (value: Type, next: Type) => boolean): (self: T) => T + (self: T, equals: (value: Type, next: Type) => boolean): T +} = dual( + 2, + (self: T, equals: (value: Type, next: Type) => boolean): T => + Atoms.operations.withEquality(equals)(self), +) + +/** + * Sets the value of an atom when read on the server. + * + * @since 4.0.0 + */ +export const withServerValue: { + (read: ServerRead>): (self: A) => A + (self: A, read: ServerRead>): A +} = dual( + 2, + (self: A, read: ServerRead>): A => Atoms.operations.withServerValue(read)(self), +) + +/** + * Sets an `AsyncResult` atom's server-side value to `AsyncResult.initial(true)`. + * + * @since 4.0.0 + */ +export const withServerValueInitial: >>(self: A) => A = + Atoms.operations.withServerValueInitial + +/** + * Attaches serialization to an atom using a schema and stable key. + * + * **Details** + * + * The schema is converted to a JSON codec used to encode values when a + * registry is dehydrated and to decode them when one is hydrated; values that + * fail either direction are recorded as refusals on the registry. + * + * @since 4.0.0 + */ +export const serializable: { + (options: SerializableOptions): (self: R) => R & Serializable + (self: R, options: SerializableOptions): R & Serializable +} = dual( + 2, + (self: R, options: SerializableOptions): R & Serializable => + Atoms.operations.serializable(options)(self), +) + +/** + * Refreshes an atom whenever one of the keys changes in the `Reactivity` service the supplied atom resolves to. + * + * @since 4.0.0 + */ +export const withReactivityOn: { + (keys: ReactivityKeys, reactivity: ReactivityAtom): (self: A) => A + (self: A, keys: ReactivityKeys, reactivity: ReactivityAtom): A +} = Atoms.operations.withReactivity + +export type Top = A +export type AnyAtom = Atom +type AnyAtomResultFn = AtomResultFn +export type AnyResult = AsyncResult.Result +type AnyFnReactivityKeys = readonly K[] | ReadonlyRecord + +/** + * Extracts the success value type from an atom whose value is an `AsyncResult`. + * + * @since 4.0.0 + */ +export type Success = T extends Atom> ? A : never + +/** + * Extracts the item type from an atom whose value is a `PullResult`. + * + * @since 4.0.0 + */ +export type PullSuccess = T extends Atom> ? A : never + +/** + * Extracts the failure error type from an atom whose value is an `AsyncResult`. + * + * @since 4.0.0 + */ +export type Failure = T extends Atom> ? E : never + +type FnOptions = { + readonly initialValue?: Init + readonly reactivityKeys?: AnyFnReactivityKeys | undefined + readonly concurrent?: boolean | undefined +} + +function membersFn(self: () => AtomRuntime): RuntimeMembers['fn'] { + function fn(): { + ( + fn: (arg: Arg, get: FnContext) => Effect.Effect, + options?: FnOptions, + ): AtomResultFn | AnyAtomResultFn + ( + fn: (arg: Arg, get: FnContext) => Stream.Stream, + options?: FnOptions, + ): AtomResultFn | AnyAtomResultFn + } + function fn( + fn: (arg: Arg, get: FnContext) => Effect.Effect, + options?: FnOptions, + ): AtomResultFn | AnyAtomResultFn + function fn( + fn: (arg: Arg, get: FnContext) => Stream.Stream, + options?: FnOptions, + ): AtomResultFn | AnyAtomResultFn + function fn( + fnOrUndefined?: (arg: Top, get: FnContext) => + | Effect.Effect + | Stream.Stream, + options?: FnOptions, + ): Top { + if (fnOrUndefined === undefined) { + return ( + fn2: (arg: Arg2, get: FnContext) => + | Effect.Effect + | Stream.Stream, + curriedOptions?: FnOptions, + ) => makeFnRuntime(self(), fn2, curriedOptions) + } + return makeFnRuntime(self(), fnOrUndefined, options) + } + return fn +} + +function makeRuntimeMembers( + self: () => AtomRuntime, + factory: RuntimeFactory, + layer: Atom>, +): RuntimeMembers { + function atom( + create: + | ((get: AtomContext) => Effect.Effect) + | Effect.Effect + | ((get: AtomContext) => Stream.Stream) + | Stream.Stream, + options?: { + readonly initialValue?: A + readonly uninterruptible?: boolean | undefined + }, + ): Atom, E | ER | Cause.NoSuchElementError>> { + return readable, E | ER | Cause.NoSuchElementError>>((get) => + readRuntimeAtom(get, self(), create, options) + ) + } + function pull( + create: + | ((get: AtomContext) => Stream.Stream) + | Stream.Stream, + options?: { + readonly disableAccumulation?: boolean + readonly initialValue?: readonly A[] + }, + ): Writable, E | ER | Cause.NoSuchElementError>, void> { + const pullSignal = setIdleTTL(state(0), 0) + const pullAtom = readable, E | ER | Cause.NoSuchElementError>>((get) => + readRuntimePull(get, self(), pullSignal, create, options) + ) + return makeStreamPull(pullSignal, pullAtom) + } + function subscriptionRef( + create: + | Effect.Effect, E, Scope.Scope | R | Current | Reactivity.Reactivity> + | ((get: AtomContext) => Effect.Effect< + SubscriptionRef.SubscriptionRef, + E, + Scope.Scope | R | Current | Reactivity.Reactivity + >), + ): Writable, E | ER | Cause.NoSuchElementError>, A> { + const refAtom = setIdleTTL( + readable< + | SubscriptionRef.SubscriptionRef + | AsyncResult.Result | Context.Context, E | ER> + >((get) => readRuntimeRefAtom(get, self(), create)), + 0, + ) + return makeSubRef( + refAtom, + (get, ref) => readRuntimeSubRef(get, self(), ref), + ) + } + return { factory, layer, atom, fn: membersFn(self), pull, subscriptionRef } +} + +const makeFnRuntime = ( + self: AtomRuntime, + fn: (arg: Arg, get: FnContext) => + | Effect.Effect + | Stream.Stream, + options?: { + readonly initialValue?: A + readonly reactivityKeys?: AnyFnReactivityKeys | undefined + }, +) => { + const [read, write, argAtom] = makeResultFn( + wrapFnWithReactivity(fn, options), + options, + ) + return writable< + AsyncResult.Result, E | ER | Cause.NoSuchElementError>, + Arg | Reset | Interrupt + >( + (get) => { + const previous = get.self, E | ER | Cause.NoSuchElementError>>() + get.get(argAtom) + const runtimeResult = get.get(self) + if (AsyncResult.isSuccess(runtimeResult)) { + return read(get, runtimeResult.value) + } + return AsyncResult.replacePrevious(runtimeResult, previous) + }, + write, + ) +} + +function constSetSelf(ctx: WriteContext, value: A) { + ctx.setSelf(value) +} + +function isCreateFunction(value: T | ((get: AtomContext) => T)): value is (get: AtomContext) => T { + return typeof value === 'function' +} + +function resolveCreate(value: T | ((get: AtomContext) => T), get: AtomContext): T { + if (isCreateFunction(value)) { + return value(get) + } + return value +} + +function resultFromInitialValue(initialValue: A | undefined): AsyncResult.Result { + if (initialValue === undefined) { + return AsyncResult.initial() + } + return AsyncResult.success(initialValue) +} + +function resultFromOptions(options?: { readonly initialValue?: A | undefined }): AsyncResult.Result { + if (options === undefined) { + return AsyncResult.initial() + } + return resultFromInitialValue(options.initialValue) +} + +function effectOrStream( + get: AtomContext, + value: Effect.Effect | Stream.Stream, + options?: { + readonly initialValue?: A + readonly uninterruptible?: boolean | undefined + }, + services?: Context.Context, +): AsyncResult.Result { + if (Effect.isEffect(value)) { + return effect(get, value, options, services) + } + return stream(get, value, options, services) +} + +function readRuntimeAtom( + get: AtomContext, + runtime: AtomRuntime, + arg: + | ((get: AtomContext) => Effect.Effect) + | Effect.Effect + | ((get: AtomContext) => Stream.Stream) + | Stream.Stream, + options?: { + readonly initialValue?: A + readonly uninterruptible?: boolean | undefined + }, +): AsyncResult.Result, E | ER | Cause.NoSuchElementError> { + const previous = get.self, E | ER | Cause.NoSuchElementError>>() + const runtimeResult = get(runtime) + if (AsyncResult.isSuccess(runtimeResult)) { + return effectOrStream(get, resolveCreate(arg, get), options, runtimeResult.value) + } + return AsyncResult.replacePrevious(runtimeResult, previous) +} + +function readRuntimePull( + get: AtomContext, + runtime: AtomRuntime, + pullSignal: Atom, + create: + | ((get: AtomContext) => Stream.Stream) + | Stream.Stream, + options?: { + readonly disableAccumulation?: boolean + readonly initialValue?: readonly A[] + }, +): PullResult, E | ER | Cause.NoSuchElementError> { + const previous = get.self, E | ER | Cause.NoSuchElementError>>() + const runtimeResult = get(runtime) + if (AsyncResult.isSuccess(runtimeResult)) { + return makeEffect( + get, + makeStreamPullEffect(get, pullSignal, resolveCreate(create, get), options), + AsyncResult.initial(true), + runtimeResult.value, + false, + ) + } + return AsyncResult.replacePrevious(runtimeResult, previous) +} + +function readRuntimeRefAtom( + get: AtomContext, + runtime: AtomRuntime, + create: + | Effect.Effect, E, Scope.Scope | R | Current | Reactivity.Reactivity> + | (( + get: AtomContext, + ) => Effect.Effect< + SubscriptionRef.SubscriptionRef, + E, + Scope.Scope | R | Current | Reactivity.Reactivity + >), +): + | SubscriptionRef.SubscriptionRef + | AsyncResult.Result | Context.Context, E | ER> +{ + const previous = get.self< + AsyncResult.Result | Context.Context, E | ER> + >() + const runtimeResult = get(runtime) + if (AsyncResult.isSuccess(runtimeResult)) { + return makeEffect(get, resolveCreate(create, get), AsyncResult.initial(true), runtimeResult.value, false) + } + return AsyncResult.replacePrevious(runtimeResult, previous) +} + +function isUnsuccessfulResult( + ref: unknown, +): ref is AnyResult { + if (AsyncResult.isResult(ref) === false) { + return false + } + return AsyncResult.isSuccess(ref) === false +} + +function readRuntimeSubRef( + get: AtomContext, + runtime: AtomRuntime, + ref: + | SubscriptionRef.SubscriptionRef + | AsyncResult.Result | Context.Context, E | ER>, +): AsyncResult.Result, E | ER | Cause.NoSuchElementError> { + if (isUnsuccessfulResult(ref)) { + return readRefResult(get, ref) + } + return readRuntimeSubRefWithServices(get, AsyncResult.getOrThrow(get(runtime)), ref) +} + +function readRuntimeSubRefWithServices( + get: AtomContext, + runtime: Context.Context, + ref: + | SubscriptionRef.SubscriptionRef + | AsyncResult.Result | Context.Context, E>, +): AsyncResult.Result, E | Cause.NoSuchElementError> { + if (AsyncResult.isResult(ref)) { + return readRefResult(get, ref, runtime) + } + return AsyncResult.success(readRefDirect(get, ref, runtime)) +} + +type FnReactivityKeys = AnyFnReactivityKeys + +function wrapFnWithReactivity( + fn: (arg: Arg, get: FnContext) => + | Effect.Effect + | Stream.Stream, + options?: { + readonly initialValue?: A + readonly reactivityKeys?: FnReactivityKeys | undefined + }, +) { + if (options === undefined) { + return fn + } + return wrapFnWithFnReactivityKeys(fn, options.reactivityKeys) +} + +function wrapFnWithFnReactivityKeys( + fn: (arg: Arg, get: FnContext) => + | Effect.Effect + | Stream.Stream, + keys: FnReactivityKeys | undefined, +) { + if (keys === undefined) { + return fn + } + return (a: Arg, get: FnContext) => wrapReactiveValue(fn(a, get), keys) +} + +function wrapReactiveValue( + value: Effect.Effect | Stream.Stream, + keys: FnReactivityKeys, +) { + if (Effect.isEffect(value)) { + return Reactivity.mutation(value, keys) + } + return Stream.ensuring(value, Reactivity.invalidate(keys)) +} + +function isAtomFnArg( + u: unknown, +): u is ( + get: AtomContext, + services?: Context.Context, +) => Effect.Effect | Stream.Stream | A { + return typeof u === 'function' +} + +function makeReadOrAtom( + arg: + | Effect.Effect + | (( + get: AtomContext, + services?: Context.Context, + ) => Effect.Effect) + | Stream.Stream + | ((get: AtomContext, services?: Context.Context) => Stream.Stream) + | ((get: AtomContext, services?: Context.Context) => A) + | A, + options?: { + readonly initialValue?: Top + readonly uninterruptible?: boolean | undefined + }, +): ((get: AtomContext, services?: Context.Context) => Top) | Writable { + if (Effect.isEffect(arg)) { + return function(get: AtomContext, providedServices?: Context.Context) { + return effect(get, arg, options, providedServices) + } + } + return makeReadOrAtomNonEffect(arg, options) +} + +function makeReadOrAtomNonEffect( + arg: + | (( + get: AtomContext, + services?: Context.Context, + ) => Effect.Effect) + | Stream.Stream + | ((get: AtomContext, services?: Context.Context) => Stream.Stream) + | ((get: AtomContext, services?: Context.Context) => A) + | A, + options?: { + readonly initialValue?: Top + readonly uninterruptible?: boolean | undefined + }, +): ((get: AtomContext, services?: Context.Context) => Top) | Writable { + if (Stream.isStream(arg)) { + return function(get: AtomContext, providedServices?: Context.Context) { + return stream(get, arg, options, providedServices) + } + } + return makeReadOrAtomFnOrState(arg, options) +} + +function makeReadOrAtomFnOrState( + arg: + | (( + get: AtomContext, + services?: Context.Context, + ) => Effect.Effect | Stream.Stream | A) + | A, + options?: { + readonly initialValue?: Top + readonly uninterruptible?: boolean | undefined + }, +): ((get: AtomContext, services?: Context.Context) => Top) | Writable { + if (isAtomFnArg(arg)) { + return function(get: AtomContext, providedServices?: Context.Context) { + return valueFromCreated(arg(get, providedServices), get, options, providedServices) + } + } + return state(arg) +} + +function valueFromCreated( + value: Effect.Effect | Stream.Stream | A, + get: AtomContext, + options?: { + readonly initialValue?: Top + readonly uninterruptible?: boolean | undefined + }, + services?: Context.Context, +): Top { + if (Effect.isEffect(value)) { + return effect(get, value, options, services) + } + return valueFromCreatedNonEffect(value, get, options, services) +} + +function valueFromCreatedNonEffect( + value: Stream.Stream | A, + get: AtomContext, + options?: { + readonly initialValue?: Top + readonly uninterruptible?: boolean | undefined + }, + services?: Context.Context, +): Top { + if (Stream.isStream(value)) { + return stream(get, value, options, services) + } + return value +} + +function asReadableAtom( + readOrAtom: ((get: AtomContext, services?: Context.Context) => A) | AnyAtom, +): AnyAtom { + if (isAtom(readOrAtom)) { + return readOrAtom + } + return readable(readOrAtom) +} + +/** + * A registry-scoped memo map: the atom is evaluated once per registry, so each + * registry memoizes its own layer builds. + */ +function makeRegistryMemoMap(): Atom { + return setIdleTTL(make(() => Layer.makeMemoMapUnsafe()), 0) +} + +function contextMemoMap( + options?: { readonly memoMap: Atom | Layer.MemoMap }, +): Atom | Layer.MemoMap { + if (options === undefined) { + return makeRegistryMemoMap() + } + return options.memoMap +} + +function resolveContextMemoMap( + get: AtomContext, + memoMap: Atom | Layer.MemoMap, +): Layer.MemoMap { + if (isAtom(memoMap)) { + return get(memoMap) + } + return memoMap +} + +/** + * The `Reactivity` service of the registry evaluating an atom, built in that + * atom's scope through the supplied memo map. + */ +function makeReactivityAtom( + resolveMemoMap: (get: AtomContext) => Layer.MemoMap, +): Atom> { + return setIdleTTL( + make((get) => + Effect.contextWith((services: Context.Context) => + Layer.buildWithMemoMap(Reactivity.layer, resolveMemoMap(get), Context.get(services, Scope.Scope)) + ).pipe( + Effect.map(Context.get(Reactivity.Reactivity)), + ) + ), + 0, + ) +} + +const defaultReactivityMemoMap: Atom = makeRegistryMemoMap() + +const defaultReactivityAtom: Atom> = makeReactivityAtom((get) => + get(defaultReactivityMemoMap) +) + +function runtimeLayerAtom( + create: Layer.Layer | ((get: AtomContext) => Layer.Layer), + globalLayer: Layer.Layer, +): Atom> { + if (typeof create === 'function') { + return readable((get) => Layer.provideMerge(create(get), globalLayer)) + } + return readable(() => Layer.provideMerge(create, globalLayer)) +} + +function mapStreamFail(e: E | Cause.Done): E | Cause.NoSuchElementError { + if (Cause.isDone(e)) { + return new Cause.NoSuchElementError() + } + return e +} + +function readSubscriptionRefSource( + get: AtomContext, + ref: + | SubscriptionRef.SubscriptionRef + | ((get: AtomContext) => SubscriptionRef.SubscriptionRef) + | Effect.Effect, E, Scope.Scope | Current> + | (( + get: AtomContext, + ) => Effect.Effect, E, Scope.Scope | Current>), +) { + const value = resolveCreate(ref, get) + if (Effect.isEffect(value)) { + return makeEffect(get, value, AsyncResult.initial(true)) + } + return value +} + +function readSubRefSource( + get: AtomContext, + source: RefSource, +) { + if (AsyncResult.isResult(source)) { + return readRefResult(get, source) + } + return readRefDirect(get, source) +} + +function refFromSuccessValue(value: SubscriptionRef.SubscriptionRef | Context.Context) { + if (Context.isContext(value)) { + return Option.none() + } + return Option.some(value) +} + +function readRefPending( + source: AsyncResult.Result | Context.Context, E>, +): AsyncResult.Result, E | Cause.NoSuchElementError> { + if (AsyncResult.isFailure(source)) { + return AsyncResult.failure, E>(source.cause, { waiting: source.waiting }) + } + return AsyncResult.initial, E>(source.waiting) +} + +function onStreamCause( + ctx: AtomContext, + cause: Cause.Cause>, + latest: Option.Option, + previous: Option.Option>, +): void { + if (Pull.isDoneCause(cause)) { + settleStreamDone(ctx, latest, previous) + return + } + settleStreamFailure(ctx, cause, previous) +} + +function settleStreamDone( + ctx: AtomContext, + latest: Option.Option, + previous: Option.Option>, +): void { + pipe( + Option.orElse( + latest, + () => Option.flatMap, A>(previous, AsyncResult.value), + ), + Option.match({ + onNone: () => + ctx.setSelf( + AsyncResult.failWithPrevious(new Cause.NoSuchElementError(), { + previous, + }), + ), + onSome: (a) => ctx.setSelf(AsyncResult.success(a)), + }), + ) +} + +function settleStreamFailure( + ctx: AtomContext, + cause: Cause.Cause>, + previous: Option.Option>, +): void { + ctx.setSelf(AsyncResult.failureWithPrevious(Cause.map(cause, mapStreamFail), { + previous, + })) +} + +function readFnSync( + get: AtomContext, + argAtom: Writable<[number, Arg | undefined]>, + f: (arg: Arg, get: FnContext) => A, + initialValue: A | undefined, + hasInitialValue: boolean, +): Option.Option | A { + const [counter, arg] = get.get(argAtom) + if (counter === 0) { + return fnSyncIdleValue(initialValue) + } + return fnSyncCalledValue(get, arg, f, hasInitialValue) +} + +function fnSyncIdleValue(initialValue: A | undefined): Option.Option | A { + if (initialValue === undefined) { + return Option.none() + } + return initialValue +} + +function fnSyncOptionValue(options?: { readonly initialValue?: A }): A | undefined { + if (options === undefined) { + return undefined + } + return options.initialValue +} + +function fnSyncCalledValue( + get: AtomContext, + arg: Arg | undefined, + f: (arg: Arg, get: FnContext) => A, + hasInitialValue: boolean, +): Option.Option | A { + const argValue = Option.getOrThrowWith( + Option.fromUndefinedOr(arg), + () => new Error('fnSync: function atom read without an argument'), + ) + if (hasInitialValue) { + return f(argValue, get) + } + return Option.some(f(argValue, get)) +} + +function resultFnConcurrent(options?: { readonly concurrent?: boolean | undefined }): boolean { + if (options === undefined) { + return false + } + return options.concurrent === true +} + +function resultFnFibersAtom( + options?: { readonly concurrent?: boolean | undefined }, +): Atom>> | undefined { + if (resultFnConcurrent(options) === false) { + return undefined + } + return setIdleTTL( + readable((get) => { + const fibers = new Set>() + get.addFinalizer(() => fibers.forEach((f) => f.interruptUnsafe())) + return fibers + }), + 0, + ) +} + +function readResultFn( + get: AtomContext, + argAtom: Atom]>, + f: ( + arg: Arg, + get: FnContext, + ) => Effect.Effect | Stream.Stream, + initialValue: AsyncResult.Result, + fibersAtom: Atom>> | undefined, + services?: Context.Context, +): AsyncResult.Result { + const [counter, arg] = get.get(argAtom) + if (counter === 0) { + return initialValue + } + return readResultFnCalled(get, arg, f, initialValue, resultFnFibers(get, fibersAtom), services) +} + +function resultFnFibers( + get: AtomContext, + fibersAtom: Atom>> | undefined, +): Set> | undefined { + if (fibersAtom === undefined) { + return undefined + } + return get(fibersAtom) +} + +function readResultFnCalled( + get: AtomContext, + arg: Option.Option, + f: ( + arg: Arg, + get: FnContext, + ) => Effect.Effect | Stream.Stream, + initialValue: AsyncResult.Result, + fibers: Set> | undefined, + services?: Context.Context, +): AsyncResult.Result { + const argValue = Option.getOrThrow(arg) + if (argValue === Interrupt) { + return AsyncResult.failureWithPrevious(Cause.interrupt(), { previous: Option.none() }) + } + return readResultFnValue(get, f(argValue, get), initialValue, fibers, services) +} + +function readResultFnValue( + get: AtomContext, + value: Effect.Effect | Stream.Stream, + initialValue: AsyncResult.Result, + fibers: Set> | undefined, + services?: Context.Context, +): AsyncResult.Result { + if (Effect.isEffect(value)) { + return readResultFnEffect(get, value, initialValue, fibers, services) + } + return makeStream(get, value, initialValue, services) +} + +function readResultFnEffect( + get: AtomContext, + value: Effect.Effect, + initialValue: AsyncResult.Result, + fibers: Set> | undefined, + services?: Context.Context, +): AsyncResult.Result { + if (fibers === undefined) { + return makeEffect(get, value, initialValue, services, false) + } + return makeEffect(get, joinResultFnFibers(value, fibers), initialValue, services, false) +} + +function joinResultFnFibers( + value: Effect.Effect, + fibers: Set>, +): Effect.Effect { + return Effect.flatMap( + Effect.forkDetach(value, { startImmediately: true }), + (fiber) => { + fibers.add(fiber) + fiber.addObserver(() => fibers.delete(fiber)) + return Effect.map(Fiber.joinAll(fibers), (arr) => Option.getOrThrow(Arr.head(arr))) + }, + ) +} + +function writeResultFnArg( + ctx: WriteContext>, + argAtom: Writable]>, + arg: Arg | Reset | Interrupt, +): void { + if (arg === Reset) { + ctx.set(argAtom, [0, Option.none()]) + return + } + writeResultFnNonReset(ctx, argAtom, arg) +} + +function writeResultFnNonReset( + ctx: WriteContext>, + argAtom: Writable]>, + arg: Arg | Interrupt, +): void { + if (arg === Interrupt) { + ctx.set(argAtom, [ctx.get(argAtom)[0] + 1, Option.some(Interrupt)]) + return + } + ctx.set(argAtom, [ctx.get(argAtom)[0] + 1, Option.some(arg)]) +} + +function onPullFailure( + cause: Cause.Cause>, + acc: readonly A[], +): Effect.Effect<{ done: boolean; items: Arr.NonEmptyReadonlyArray }, Cause.NoSuchElementError | E> { + const filtered = Pull.filterDone(cause) + if (Result.isSuccess(filtered)) { + return onPullDone(acc) + } + return Effect.failCause(filtered.failure) +} + +function onPullDone( + acc: readonly A[], +): Effect.Effect<{ done: boolean; items: Arr.NonEmptyReadonlyArray }, Cause.NoSuchElementError> { + if (Arr.isReadonlyArrayNonEmpty(acc) === false) { + return Effect.fail(new Cause.NoSuchElementError(`Atom.pull: no items`)) + } + return Effect.succeed({ done: true, items: acc }) +} + +function onPullSuccess( + chunk: Iterable, + acc: readonly A[], + options: { readonly disableAccumulation?: boolean | undefined } | undefined, + setAcc: (items: readonly A[]) => void, + pull: Effect.Effect< + { done: boolean; items: Arr.NonEmptyReadonlyArray }, + Cause.NoSuchElementError | E, + Current + >, +): Effect.Effect<{ done: boolean; items: Arr.NonEmptyReadonlyArray }, Cause.NoSuchElementError | E, Current> { + const items = pullAccumulatedItems(chunk, acc, options, setAcc) + if (Arr.isReadonlyArrayNonEmpty(items) === false) { + return pull + } + return Effect.succeed({ done: false, items }) +} + +function pullDisableAccumulation( + options: { readonly disableAccumulation?: boolean | undefined } | undefined, +): boolean { + if (options === undefined) { + return false + } + return options.disableAccumulation === true +} + +function pullAccumulatedItems( + chunk: Iterable, + acc: readonly A[], + options: { readonly disableAccumulation?: boolean | undefined } | undefined, + setAcc: (items: readonly A[]) => void, +): readonly A[] { + if (pullDisableAccumulation(options)) { + return Arr.fromIterable(chunk) + } + const items = Arr.appendAll(acc, chunk) + setAcc(items) + return items +} + +function finishPullCallback( + get: AtomContext, + cancels: Set<() => void>, + cancel: (() => void) | undefined, + exit: Exit.Exit<{ readonly done: boolean; readonly items: Arr.NonEmptyReadonlyArray }, E>, +): void { + if (cancel !== undefined) { + cancels.delete(cancel) + } + const result = AsyncResult.fromExitWithPrevious(exit, Option.none()) + get.setSelf(pullCallbackResult(result, cancels.size > 0)) +} + +function pullCallbackResult( + result: AsyncResult.Result, + pending: boolean, +): AsyncResult.Result { + if (pending) { + return AsyncResult.waiting(result) + } + return result +} + +function addPullCancel(cancels: Set<() => void>, cancel: (() => void) | undefined): void { + if (cancel === undefined) { + return + } + cancels.add(cancel) +} + +/** + * Creates an atom from a synchronous value or read function, or from an `Effect` or `Stream` whose state is exposed as an `AsyncResult`; plain values create writable state atoms. + * + * To pass `initialValue` or `uninterruptible`, use `makeWith`. + * + * @since 4.0.0 + */ +export function make( + create: (get: AtomContext) => Effect.Effect, +): Atom> +export function make( + effect: Effect.Effect, +): Atom> +export function make( + create: (get: AtomContext) => Stream.Stream, +): Atom> +export function make( + stream: Stream.Stream, +): Atom> +export function make(create: (get: AtomContext) => A): Atom +export function make(initialValue: A): Writable +export function make( + arg: + | Effect.Effect + | (( + get: AtomContext, + services?: Context.Context, + ) => Effect.Effect) + | Stream.Stream + | ((get: AtomContext, services?: Context.Context) => Stream.Stream) + | ((get: AtomContext, services?: Context.Context) => A) + | A, +): Top { + return asReadableAtom(makeReadOrAtom(arg)) +} + +/** + * `make` for the Effect, Effect-returning function, Stream, and + * Stream-returning function forms, with required `initialValue` and + * `uninterruptible` options. + * + * @since 4.0.0 + */ +export const makeWith: { + ( + create: (get: AtomContext) => Effect.Effect, + options: { + readonly initialValue?: A | undefined + readonly uninterruptible?: boolean | undefined + }, + ): Atom> + ( + effect: Effect.Effect, + options: { + readonly initialValue?: A + readonly uninterruptible?: boolean | undefined + }, + ): Atom> + ( + create: (get: AtomContext) => Stream.Stream, + options: { + readonly initialValue?: A + }, + ): Atom> + ( + stream: Stream.Stream, + options: { + readonly initialValue?: A + }, + ): Atom> + ( + options: { + readonly initialValue?: A | undefined + readonly uninterruptible?: boolean | undefined + }, + ): (create: (get: AtomContext) => Effect.Effect) => Atom> + ( + options: { + readonly initialValue?: A | undefined + readonly uninterruptible?: boolean | undefined + }, + ): (effect: Effect.Effect) => Atom> + ( + options: { + readonly initialValue?: A + }, + ): ( + create: (get: AtomContext) => Stream.Stream, + ) => Atom> + ( + options: { + readonly initialValue?: A + }, + ): (stream: Stream.Stream) => Atom> +} = dual( + 2, + ( + arg: + | Effect.Effect + | (( + get: AtomContext, + services?: Context.Context, + ) => Effect.Effect) + | Stream.Stream + | ((get: AtomContext, services?: Context.Context) => Stream.Stream) + | ((get: AtomContext, services?: Context.Context) => A) + | A, + options: { + readonly initialValue?: Top + readonly uninterruptible?: boolean | undefined + }, + ): Top => asReadableAtom(makeReadOrAtom(arg, options)), +) + +// ----------------------------------------------------------------------------- +// constructors - effect +// ----------------------------------------------------------------------------- + +export function makeRead( + effect: Effect.Effect, +): (get: AtomContext, services?: Context.Context) => AsyncResult.Result +export function makeRead( + create: (get: AtomContext) => Effect.Effect, +): (get: AtomContext, services?: Context.Context) => AsyncResult.Result +export function makeRead( + stream: Stream.Stream, +): (get: AtomContext, services?: Context.Context) => AsyncResult.Result +export function makeRead( + create: (get: AtomContext) => Stream.Stream, +): (get: AtomContext, services?: Context.Context) => AsyncResult.Result +export function makeRead( + create: (get: AtomContext) => A, +): (get: AtomContext, services?: Context.Context) => A +export function makeRead(initialValue: A): Writable +export function makeRead( + arg: + | Effect.Effect + | (( + get: AtomContext, + services?: Context.Context, + ) => Effect.Effect) + | Stream.Stream + | ((get: AtomContext, services?: Context.Context) => Stream.Stream) + | ((get: AtomContext, services?: Context.Context) => A) + | A, +): Top { + return makeReadOrAtom(arg) +} + +/** + * `makeRead` for the Effect, Effect-returning function, Stream, and + * Stream-returning function forms, with required `initialValue` and + * `uninterruptible` options. + * + * @since 4.0.0 + */ +export const makeReadWith: { + ( + effect: Effect.Effect, + options: { + readonly initialValue?: A + readonly uninterruptible?: boolean | undefined + }, + ): (get: AtomContext, services?: Context.Context) => AsyncResult.Result + ( + create: (get: AtomContext) => Effect.Effect, + options: { + readonly initialValue?: A + readonly uninterruptible?: boolean | undefined + }, + ): (get: AtomContext, services?: Context.Context) => AsyncResult.Result + ( + stream: Stream.Stream, + options: { + readonly initialValue?: A + readonly uninterruptible?: boolean | undefined + }, + ): (get: AtomContext, services?: Context.Context) => AsyncResult.Result + ( + create: (get: AtomContext) => Stream.Stream, + options: { + readonly initialValue?: A + readonly uninterruptible?: boolean | undefined + }, + ): (get: AtomContext, services?: Context.Context) => AsyncResult.Result + ( + options: { + readonly initialValue?: A + readonly uninterruptible?: boolean | undefined + }, + ): ( + effect: Effect.Effect, + ) => (get: AtomContext, services?: Context.Context) => AsyncResult.Result + ( + options: { + readonly initialValue?: A + readonly uninterruptible?: boolean | undefined + }, + ): ( + create: (get: AtomContext) => Effect.Effect, + ) => (get: AtomContext, services?: Context.Context) => AsyncResult.Result + ( + options: { + readonly initialValue?: A + readonly uninterruptible?: boolean | undefined + }, + ): ( + stream: Stream.Stream, + ) => (get: AtomContext, services?: Context.Context) => AsyncResult.Result + ( + options: { + readonly initialValue?: A + readonly uninterruptible?: boolean | undefined + }, + ): ( + create: (get: AtomContext) => Stream.Stream, + ) => (get: AtomContext, services?: Context.Context) => AsyncResult.Result +} = dual( + 2, + ( + arg: + | Effect.Effect + | (( + get: AtomContext, + services?: Context.Context, + ) => Effect.Effect) + | Stream.Stream + | ((get: AtomContext, services?: Context.Context) => Stream.Stream) + | ((get: AtomContext, services?: Context.Context) => A) + | A, + options: { + readonly initialValue?: Top + readonly uninterruptible?: boolean | undefined + }, + ): Top => makeReadOrAtom(arg, options), +) + +export const state = ( + initialValue: A, +): Writable => + writable(function(_get) { + return initialValue + }, constSetSelf) + +const effect = ( + get: AtomContext, + effect: Effect.Effect, + options?: { + readonly initialValue?: A + readonly uninterruptible?: boolean | undefined + }, + services?: Context.Context, +): AsyncResult.Result => makeEffect(get, effect, resultFromOptions(options), services, options?.uninterruptible) + +type EffectSyncState = { + syncResult: AsyncResult.Result | undefined + isAsync: boolean +} + +function makeEffect( + ctx: AtomContext, + effect: Effect.Effect, + initialValue: AsyncResult.Result, + services?: Context.Context, + uninterruptible?: boolean, +): AsyncResult.Result { + return runMakeEffect(ctx, effect, initialValue, contextOrEmpty(services), uninterruptible === true) +} + +function contextOrEmpty(services?: Context.Context): Context.Context { + if (services === undefined) { + return Context.empty() + } + return services +} + +function runMakeEffect( + ctx: AtomContext, + effect: Effect.Effect, + initialValue: AsyncResult.Result, + services: Context.Context, + uninterruptible: boolean, +): AsyncResult.Result { + const previous = ctx.self>() + const scope = Scope.makeUnsafe() + ctx.addFinalizer(() => { + Effect.runForkWith(services)(Scope.close(scope, Exit.void)) + }) + const servicesMap = new Map(services.mapUnsafe) + servicesMap.set(Scope.Scope.key, scope) + servicesMap.set(Current.key, ctx.registry.handle) + servicesMap.set(Scheduler.Scheduler.key, ctx.registry.scheduler) + const state: EffectSyncState = { syncResult: undefined, isAsync: false } + const cancel = runCallbackSync( + Context.makeUnsafe(servicesMap), + effect, + (exit) => onEffectExit(ctx, state, previous, exit), + uninterruptible, + ) + state.isAsync = true + addCancelFinalizer(ctx, cancel) + return settleEffectResult(previous, state.syncResult, initialValue) +} + +function onEffectExit( + ctx: AtomContext, + state: EffectSyncState, + previous: Option.Option>, + exit: Exit.Exit, +): void { + state.syncResult = AsyncResult.fromExitWithPrevious(exit, previous) + setSelfIfAsync(ctx, state) +} + +function setSelfIfAsync(ctx: AtomContext, state: EffectSyncState): void { + if (state.isAsync) { + ctx.setSelf(state.syncResult) + } +} + +function addCancelFinalizer(ctx: AtomContext, cancel: (() => void) | undefined): void { + if (cancel === undefined) { + return + } + ctx.addFinalizer(cancel) +} + +function settleEffectResult( + previous: Option.Option>, + syncResult: AsyncResult.Result | undefined, + initialValue: AsyncResult.Result, +): AsyncResult.Result { + if (syncResult !== undefined) { + return syncResult + } + return waitingEffectResult(previous, initialValue) +} + +function waitingEffectResult( + previous: Option.Option>, + initialValue: AsyncResult.Result, +): AsyncResult.Result { + if (Option.isSome(previous)) { + return AsyncResult.waitingFrom(previous) + } + return AsyncResult.waiting(initialValue) +} + +function runCallbackSync( + services: Context.Context, + effect: Effect.Effect | Exit.Exit, + onExit: (exit: Exit.Exit) => void, + uninterruptible?: boolean, +): (() => void) | undefined { + return runCallbackSyncFlag(services, effect, onExit, uninterruptible === true) +} + +function runCallbackSyncFlag( + services: Context.Context, + effect: Effect.Effect | Exit.Exit, + onExit: (exit: Exit.Exit) => void, + uninterruptible: boolean, +): (() => void) | undefined { + if (Exit.isExit(effect)) { + onExit(effect) + return undefined + } + return runForkedCallback(services, effect, onExit, uninterruptible) +} + +function runForkedCallback( + services: Context.Context, + effect: Effect.Effect, + onExit: (exit: Exit.Exit) => void, + uninterruptible: boolean, +): (() => void) | undefined { + const runFork = Effect.runForkWith(services) + const fiber = runFork(effect) + fiber.currentDispatcher.flush() + const result = fiber.pollUnsafe() + if (result !== undefined) { + onExit(result) + return undefined + } + return observeForkedFiber(fiber, onExit, uninterruptible) +} + +function observeForkedFiber( + fiber: Fiber.Fiber, + onExit: (exit: Exit.Exit) => void, + uninterruptible: boolean, +): () => void { + const remove = fiber.addObserver(onExit) + return function cancel() { + interruptForkedFiber(remove, fiber, uninterruptible) + } +} + +function interruptForkedFiber( + remove: () => void, + fiber: Fiber.Fiber, + uninterruptible: boolean, +): void { + remove() + if (uninterruptible === false) { + fiber.interruptUnsafe() + } +} + +// ----------------------------------------------------------------------------- +// context +// ----------------------------------------------------------------------------- + +/** + * The members a runtime atom exposes: its factory, its layer, and the constructors for atoms, functions, pulls, and subscription refs that run with its services. + * + * @since 4.0.0 + */ +export interface RuntimeMembers { + readonly factory: RuntimeFactory + + readonly layer: Atom> + + readonly atom: { + ( + create: (get: AtomContext) => Effect.Effect, + options?: { + readonly initialValue?: A + readonly uninterruptible?: boolean | undefined + }, + ): Atom, E | ER | Cause.NoSuchElementError>> + (effect: Effect.Effect, options?: { + readonly initialValue?: A + readonly uninterruptible?: boolean | undefined + }): Atom, E | ER | Cause.NoSuchElementError>> + (create: (get: AtomContext) => Stream.Stream, options?: { + readonly initialValue?: A + }): Atom, E | ER | Cause.NoSuchElementError>> + (stream: Stream.Stream, options?: { + readonly initialValue?: A + }): Atom, E | ER | Cause.NoSuchElementError>> + } + + readonly fn: { + (): { + ( + fn: (arg: Arg, get: FnContext) => Effect.Effect, + options?: { + readonly initialValue?: A | undefined + readonly reactivityKeys?: readonly Top[] | ReadonlyRecord | undefined + readonly concurrent?: boolean | undefined + }, + ): AtomResultFn | AnyAtomResultFn + ( + fn: (arg: Arg, get: FnContext) => Stream.Stream, + options?: { + readonly initialValue?: A | undefined + readonly reactivityKeys?: readonly Top[] | ReadonlyRecord | undefined + readonly concurrent?: boolean | undefined + }, + ): AtomResultFn | AnyAtomResultFn + } + ( + fn: (arg: Arg, get: FnContext) => Effect.Effect, + options?: { + readonly initialValue?: A | undefined + readonly reactivityKeys?: readonly Top[] | ReadonlyRecord | undefined + readonly concurrent?: boolean | undefined + }, + ): AtomResultFn | AnyAtomResultFn + ( + fn: (arg: Arg, get: FnContext) => Stream.Stream, + options?: { + readonly initialValue?: A | undefined + readonly reactivityKeys?: readonly Top[] | ReadonlyRecord | undefined + readonly concurrent?: boolean | undefined + }, + ): AtomResultFn | AnyAtomResultFn + } + + readonly pull: ( + create: + | ((get: AtomContext) => Stream.Stream) + | Stream.Stream, + options?: { + readonly disableAccumulation?: boolean + readonly initialValue?: readonly A[] + }, + ) => Writable, E | ER | Cause.NoSuchElementError>, void> + + readonly subscriptionRef: ( + create: + | Effect.Effect, E, Scope.Scope | R | Current | Reactivity.Reactivity> + | (( + get: AtomContext, + ) => Effect.Effect< + SubscriptionRef.SubscriptionRef, + E, + Scope.Scope | R | Current | Reactivity.Reactivity + >), + ) => Writable, E | ER | Cause.NoSuchElementError>, A> +} + +/** + * Factory for `AtomRuntime` values that share a set of global layers. + * + * @since 4.0.0 + */ +export interface RuntimeFactory { + ( + create: + | Layer.Layer + | ((get: AtomContext) => Layer.Layer), + ): AtomRuntime + readonly addGlobalLayer: (layer: Layer.Layer) => void + + /** + * Uses the `Reactivity` service from the runtime to refresh the atom whenever + * the keys change. + */ + readonly withReactivity: ( + keys: readonly Top[] | ReadonlyRecord, + ) => >(atom: A) => A +} + +/** + * A `RuntimeFactory` backed by an atom whose memo map is scoped to each registry. + * + * @since 4.0.0 + */ +export interface RegistryRuntimeFactory extends RuntimeFactory { + readonly memoMap: Atom +} + +/** + * A `RuntimeFactory` backed by a concrete memo map shared across registries. + * + * @since 4.0.0 + */ +export interface SharedRuntimeFactory extends RuntimeFactory { + readonly memoMap: Layer.MemoMap +} + +/** + * Creates a `RuntimeFactory` backed by a registry-scoped memo map by default, + * or by the supplied atom or concrete `Layer.MemoMap`. + * + * @since 4.0.0 + */ +export function context(): RegistryRuntimeFactory +export function context(options: { readonly memoMap: Atom }): RegistryRuntimeFactory +export function context(options: { readonly memoMap: Layer.MemoMap }): SharedRuntimeFactory +export function context(options?: { + readonly memoMap: Atom | Layer.MemoMap +}): RegistryRuntimeFactory | SharedRuntimeFactory { + const memoMap = contextMemoMap(options) + const resolveMemoMap = (get: AtomContext): Layer.MemoMap => resolveContextMemoMap(get, memoMap) + // widened container: accumulates global layers across merges of heterogeneous output types. + // ROut=never is sound: Layer is contravariant in ROut, so any accumulated layer is assignable + // back into this slot; E=never after orDie keeps the slot closed; RIn=Current is the one + // requirement every merged layer shares after Reactivity is provided. + let globalLayer: Layer.Layer = Reactivity.layer + const addGlobalLayer = (layer: Layer.Layer): void => { + globalLayer = Layer.provideMerge(globalLayer, Layer.orDie(Layer.provide(layer, Reactivity.layer))) + } + const reactivityAtom = makeReactivityAtom(resolveMemoMap) + const withReactivity = (keys: readonly Top[] | ReadonlyRecord) => + withReactivityOn(keys, reactivityAtom) + const factoryFn = function makeRuntime( + create: + | Layer.Layer + | ((get: AtomContext) => Layer.Layer), + ): AtomRuntime { + const layerAtom = keepAlive(runtimeLayerAtom(create, globalLayer)) + let runtimeValue: AtomRuntime + const members = makeRuntimeMembers(() => runtimeValue, factory, layerAtom) + runtimeValue = runtime({ + read: (get) => { + const layer = get(layerAtom) + const built = Effect.flatMap( + Effect.scope, + (scope) => Layer.buildWithMemoMap(layer, resolveMemoMap(get), scope), + ) + return effect(get, built, { uninterruptible: true }) + }, + members, + }) + return runtimeValue + } + const factory = assignRuntimeFactory(factoryFn, memoMap, addGlobalLayer, withReactivity) + return factory +} + +function assignRuntimeFactory( + factoryFn: ( + create: Layer.Layer | ((get: AtomContext) => Layer.Layer), + ) => AtomRuntime, + memoMap: Atom | Layer.MemoMap, + addGlobalLayer: (layer: Layer.Layer) => void, + withReactivity: ( + keys: readonly Top[] | ReadonlyRecord, + ) => >(atom: A) => A, +): RegistryRuntimeFactory | SharedRuntimeFactory { + if (isAtom(memoMap)) { + return Object.assign(factoryFn, { memoMap, addGlobalLayer, withReactivity }) + } + return Object.assign(factoryFn, { memoMap, addGlobalLayer, withReactivity }) +} + +/** + * Refreshes an atom whenever one or more invalidation keys change in the + * `Reactivity` service. + * + * **When to use** + * + * Use to refresh an atom whenever the keys change. The reactivity service is + * resolved per registry, so each registry refreshes against its own + * invalidation keys. + * + * @since 4.0.0 + */ +export const withReactivity: ( + keys: readonly Top[] | ReadonlyRecord, +) => >(atom: A) => A = (keys) => withReactivityOn(keys, defaultReactivityAtom) + +// ----------------------------------------------------------------------------- +// constructors - stream +// ----------------------------------------------------------------------------- + +const stream = ( + get: AtomContext, + stream: Stream.Stream, + options?: { + readonly initialValue?: A + }, + services?: Context.Context, +): AsyncResult.Result => makeStream(get, stream, resultFromOptions(options), services) + +function makeStream( + ctx: AtomContext, + stream: Stream.Stream, + initialValue: AsyncResult.Result, + services?: Context.Context, +): AsyncResult.Result { + return runMakeStream(ctx, stream, initialValue, contextOrEmpty(services)) +} + +function runMakeStream( + ctx: AtomContext, + stream: Stream.Stream, + initialValue: AsyncResult.Result, + services: Context.Context, +): AsyncResult.Result { + const previous = ctx.self>() + services = Context.add(services, Current, ctx.registry.handle) + + // What this run emitted. The done branch settles from it: `previous` is the + // state from BEFORE the run, so consulting it alone discards the value the + // loop just wrote and reports an empty stream for a stream that produced one. + let latest: Option.Option = Option.none() + + const run = Effect.scopedWith((scope) => + Effect.flatMap(Channel.toPullScoped(stream.channel, scope), (pull) => + Effect.whileLoop({ + while: constTrue, + body: () => pull, + step(arr) { + const last = Arr.lastNonEmpty(arr) + latest = Option.some(last) + ctx.setSelf(AsyncResult.successWith(last, { + waiting: true, + })) + }, + })) + ).pipe( + Effect.catchCause((cause) => { + onStreamCause(ctx, cause, latest, previous) + return Effect.void + }), + ) + const servicesMap = new Map(services.mapUnsafe) + servicesMap.set(Current.key, ctx.registry.handle) + servicesMap.set(Scheduler.Scheduler.key, ctx.registry.scheduler) + + const cancel = runCallbackSync( + Context.makeUnsafe(servicesMap), + run, + constVoid, + false, + ) + addCancelFinalizer(ctx, cancel) + return waitingEffectResult(previous, initialValue) +} + +// ----------------------------------------------------------------------------- +// constructors - subscription ref +// ----------------------------------------------------------------------------- + +/** + * Creates a writable atom backed by a `SubscriptionRef`, or by an effect that produces one, updating from ref changes and writing atom updates back to the ref. + * + * @since 4.0.0 + */ +export const subscriptionRef: { + ( + ref: SubscriptionRef.SubscriptionRef | ((get: AtomContext) => SubscriptionRef.SubscriptionRef), + ): Writable | Writable + ( + effect: + | Effect.Effect, E, Scope.Scope | Current> + | ((get: AtomContext) => Effect.Effect, E, Scope.Scope | Current>), + ): Writable, A> | Writable +} = ( + ref: + | SubscriptionRef.SubscriptionRef + | ((get: AtomContext) => SubscriptionRef.SubscriptionRef) + | Effect.Effect, E, Scope.Scope | Current> + | (( + get: AtomContext, + ) => Effect.Effect, E, Scope.Scope | Current>), +): Writable< + A | AsyncResult.Result, E | Cause.NoSuchElementError>, + A +> => + makeSubRef( + readable((get) => readSubscriptionRefSource(get, ref)), + (get, source) => readSubRefSource(get, source), + ) + +/** What a ref-producing atom yields: a ref outright, or a result that may carry one. */ +type RefSource = + | SubscriptionRef.SubscriptionRef + | AsyncResult.Result | Context.Context, E> + +/** The ref a source carries, when it has produced one. */ +const refOf = ( + source: RefSource, +): Option.Option> => { + if (AsyncResult.isResult(source) === false) { + return Option.some(source) + } + return refOfResult(source) +} + +function refOfResult( + source: AsyncResult.Result | Context.Context, E>, +): Option.Option> { + if (AsyncResult.isSuccess(source) === false) { + return Option.none() + } + return refFromSuccessValue(source.value) +} + +/** + * Reads a ref the caller already holds: subscribes to its changes for the + * lifetime of the read and yields its current value. A ref handed over outright + * has nothing to await, so this reports the value itself, never a result. + */ +const readRefDirect = ( + get: AtomContext, + ref: SubscriptionRef.SubscriptionRef, + services?: Context.Context, +): A => runReadRefDirect(get, ref, contextOrEmpty(services)) + +function runReadRefDirect( + get: AtomContext, + ref: SubscriptionRef.SubscriptionRef, + services: Context.Context, +): A { + get.addFinalizer( + SubscriptionRef.changes(ref).pipe( + Stream.runForEachArray((arr) => { + for (let i = 0; i < arr.length; i++) { + get.setSelf(arr[i]) + } + return Effect.void + }), + Effect.runCallbackWith(services), + ), + ) + return Effect.runSyncWith(services)(ref.pipe(SubscriptionRef.get)) +} + +/** + * Reads a ref that is still being produced, so the value reports every state the + * producer reaches rather than only its outcome. + */ +const readRefResult = ( + get: AtomContext, + source: AsyncResult.Result | Context.Context, E>, + services?: Context.Context, +): AsyncResult.Result, E | Cause.NoSuchElementError> => { + if (AsyncResult.isSuccess(source)) { + return readRefSuccess(get, source.value, contextOrEmpty(services)) + } + return readRefPending(source) +} + +function readRefSuccess( + get: AtomContext, + value: SubscriptionRef.SubscriptionRef | Context.Context, + services: Context.Context, +): AsyncResult.Result, E | Cause.NoSuchElementError> { + if (Context.isContext(value)) { + return AsyncResult.success, E>(value) + } + return makeStream(get, SubscriptionRef.changes(value), AsyncResult.initial(true), services) +} + +const makeSubRef = ( + refAtom: Atom>, + read: (get: AtomContext, source: RefSource) => Out, +): Writable => { + function write(ctx: WriteContext, value: A) { + const ref = refOf(ctx.get(refAtom)) + if (Option.isSome(ref)) { + Effect.runSync(SubscriptionRef.set(ref.value, value)) + } + } + return writable((get) => read(get, get(refAtom)), write) +} + +// ----------------------------------------------------------------------------- +// constructors - functions +// ----------------------------------------------------------------------------- + +/** + * Context passed to `fn` and `fnSync` computations for reading atoms, awaiting results, registering finalizers, refreshing atoms, subscribing to changes, and writing updates. + * + * @since 4.0.0 + */ +export interface FnContext { + (atom: Atom): A + result(this: FnContext, atom: Atom>, options?: { + readonly suspendOnWaiting?: boolean | undefined + }): Effect.Effect + addFinalizer(this: FnContext, f: () => void): void + mount(this: FnContext, atom: Atom): void + refresh(this: FnContext, atom: Atom): void + self(this: FnContext): Option.Option + setSelf(this: FnContext, a: A): void + set(this: FnContext, atom: Writable, value: W): void + setResult(this: FnContext, atom: Writable, W>, value: W): Effect.Effect + some(this: FnContext, atom: Atom>): Effect.Effect + stream(this: FnContext, atom: Atom, options?: { + readonly withoutInitialValue?: boolean + readonly bufferSize?: number + }): Stream.Stream + streamResult(this: FnContext, atom: Atom>, options?: { + readonly withoutInitialValue?: boolean + readonly bufferSize?: number + }): Stream.Stream + subscribe(this: FnContext, atom: Atom, f: (_: A) => void, options?: { + readonly immediate?: boolean + }): void + readonly registry: RegistryImpl +} + +/** + * Creates a writable atom for a synchronous function; writing an argument re-runs the function, returning `Option.none` before the first call unless an initial value is supplied. + * + * @since 4.0.0 + */ +export function fnSync(): { + ( + f: (arg: Arg, get: FnContext) => A, + ): Writable, Arg> + ( + f: (arg: Arg, get: FnContext) => A, + options: { readonly initialValue: A }, + ): Writable +} +export function fnSync( + f: (arg: Arg, get: FnContext) => A, +): Writable, Arg> +export function fnSync( + f: (arg: Arg, get: FnContext) => A, + options: { readonly initialValue: A }, +): Writable +export function fnSync( + options: { readonly initialValue: A }, +): (f: (arg: Arg, get: FnContext) => A) => Writable +export function fnSync( + ...args: readonly [ + fOrOptions?: ((arg: Top, get: FnContext) => Top) | { readonly initialValue?: Top }, + options?: { readonly initialValue?: Top }, + ] +): Top { + const [fOrOptions, options] = args + return fnSyncFromArgs(fOrOptions, options, args.length) +} + +function fnSyncFromArgs( + fOrOptions: ((arg: Top, get: FnContext) => Top) | { readonly initialValue?: Top } | undefined, + options: { readonly initialValue?: Top } | undefined, + argCount: number, +): Top { + if (argCount === 0) { + return makeFnSync + } + return applyFnSync(fOrOptions, options) +} + +function applyFnSync( + fOrOptions: ((arg: Top, get: FnContext) => Top) | { readonly initialValue?: Top } | undefined, + options: { readonly initialValue?: Top } | undefined, +): Top { + return typeof fOrOptions === 'function' + ? makeFnSync(fOrOptions, options) + : (f: (arg: Top, get: FnContext) => Top) => makeFnSync(f, fOrOptions) +} + +const makeFnSync = (f: (arg: Arg, get: FnContext) => A, options?: { + readonly initialValue?: A +}): Writable | A, Arg> => { + const argAtom = setIdleTTL(state<[number, Arg | undefined]>([0, undefined]), 0) + const hasInitialValue = options?.initialValue !== undefined + return writable(function(get) { + get.isFn = true + return readFnSync(get, argAtom, f, fnSyncOptionValue(options), hasInitialValue) + }, function(ctx, arg: Arg) { + batch(ctx.registry, () => { + ctx.set(argAtom, [ctx.get(argAtom)[0] + 1, arg]) + ctx.refreshSelf() + }) + }) +} + +/** + * Writable async function atom whose value is an `AsyncResult` and whose writes accept function arguments plus `Reset` and `Interrupt` controls. + * + * @since 4.0.0 + */ +export type AtomResultFn = Writable, Arg | Reset | Interrupt> + +/** + * Creates a writable atom for an `Effect` or `Stream` function; writing an argument starts the computation and exposes its state as an `AsyncResult`. + * + * @since 4.0.0 + */ +export function fn(): ( + fn: (arg: Arg, get: FnContext) => Effect.Effect, + options?: { + readonly initialValue?: A | undefined + readonly concurrent?: boolean | undefined + }, +) => AtomResultFn +export function fn( + fn: (arg: Arg, get: FnContext) => Effect.Effect, + options?: { + readonly initialValue?: A | undefined + readonly concurrent?: boolean | undefined + }, +): AtomResultFn +export function fn(): ( + fn: (arg: Arg, get: FnContext) => Stream.Stream, + options?: { + readonly initialValue?: A | undefined + readonly concurrent?: boolean | undefined + }, +) => AtomResultFn +export function fn( + fn: (arg: Arg, get: FnContext) => Stream.Stream, + options?: { + readonly initialValue?: A | undefined + readonly concurrent?: boolean | undefined + }, +): AtomResultFn +export function fn( + options?: { + readonly initialValue?: A | undefined + readonly concurrent?: boolean | undefined + }, +): (fn: (arg: Arg, get: FnContext) => Effect.Effect) => AtomResultFn +export function fn( + options?: { + readonly initialValue?: A | undefined + readonly concurrent?: boolean | undefined + }, +): ( + fn: (arg: Arg, get: FnContext) => Stream.Stream, +) => AtomResultFn +export function fn( + ...args: readonly [ + fnOrOptions?: + | (( + arg: Top, + get: FnContext, + ) => Effect.Effect | Stream.Stream) + | { + readonly initialValue?: Top + readonly concurrent?: boolean | undefined + }, + options?: { + readonly initialValue?: Top + readonly concurrent?: boolean | undefined + }, + ] +): Top { + const [fnOrOptions, options] = args + return fnFromArgs(fnOrOptions, options, args.length) +} + +function fnFromArgs( + fnOrOptions: + | (( + arg: Top, + get: FnContext, + ) => Effect.Effect | Stream.Stream) + | { + readonly initialValue?: Top + readonly concurrent?: boolean | undefined + } + | undefined, + options: + | { + readonly initialValue?: Top + readonly concurrent?: boolean | undefined + } + | undefined, + argCount: number, +): Top { + if (argCount === 0) { + return makeFn + } + return applyFn(fnOrOptions, options) +} + +function applyFn( + fnOrOptions: + | (( + arg: Top, + get: FnContext, + ) => Effect.Effect | Stream.Stream) + | { + readonly initialValue?: Top + readonly concurrent?: boolean | undefined + } + | undefined, + options: + | { + readonly initialValue?: Top + readonly concurrent?: boolean | undefined + } + | undefined, +): Top { + return typeof fnOrOptions === 'function' + ? makeFn(fnOrOptions, options) + : ( + f: ( + arg: Top, + get: FnContext, + ) => Effect.Effect | Stream.Stream, + ) => makeFn(f, fnOrOptions) +} + +function makeFn( + f: (arg: Arg, get: FnContext) => Effect.Effect, + options?: { + readonly initialValue?: A | undefined + readonly concurrent?: boolean | undefined + }, +): AtomResultFn +function makeFn( + f: (arg: Arg, get: FnContext) => Stream.Stream, + options?: { + readonly initialValue?: A | undefined + readonly concurrent?: boolean | undefined + }, +): AtomResultFn +function makeFn( + f: (arg: Top, get: FnContext) => + | Effect.Effect + | Stream.Stream, + options?: { + readonly initialValue?: Top + readonly concurrent?: boolean | undefined + }, +): AtomResultFn +function makeFn( + f: (arg: Arg, get: FnContext) => Stream.Stream | Effect.Effect, + options?: { + readonly initialValue?: A | undefined + readonly concurrent?: boolean | undefined + }, +): AtomResultFn { + const [read, write] = makeResultFn(f, options) + return writable, Arg | Reset | Interrupt>(read, write) +} + +function makeResultFn( + f: ( + arg: Arg, + get: FnContext, + ) => Effect.Effect | Stream.Stream, + options?: { + readonly initialValue?: A | undefined + readonly concurrent?: boolean | undefined + }, +) { + const argAtom = setIdleTTL(state]>([0, Option.none()]), 0) + const initialValue = resultFromOptions(options) + const fibersAtom = resultFnFibersAtom(options) + + function read( + get: AtomContext, + services?: Context.Context, + ): AsyncResult.Result { + get.isFn = true + return readResultFn(get, argAtom, f, initialValue, fibersAtom, services) + } + function write( + ctx: WriteContext>, + arg: Arg | Reset | Interrupt, + ) { + batch(ctx.registry, () => { + writeResultFnArg(ctx, argAtom, arg) + ctx.refreshSelf() + }) + } + return [read, write, argAtom] as const +} + +/** + * `AsyncResult` produced by `pull`, containing a non-empty batch of pulled items and a `done` flag, or `NoSuchElementError` when the stream completes without items. + * + * @since 4.0.0 + */ +export type PullResult = AsyncResult.Result<{ + readonly done: boolean + readonly items: Arr.NonEmptyReadonlyArray +}, E | Cause.NoSuchElementError> + +/** + * Creates a writable atom that pulls an initial chunk from a stream and then pulls the next chunk whenever it is written to, accumulating items unless `disableAccumulation` is enabled. + * + * @since 4.0.0 + */ +export const pull: { + ( + options?: { + readonly disableAccumulation?: boolean | undefined + }, + ): ( + create: ((get: AtomContext) => Stream.Stream) | Stream.Stream, + ) => Writable, void> + ( + create: ((get: AtomContext) => Stream.Stream) | Stream.Stream, + options?: { + readonly disableAccumulation?: boolean | undefined + }, + ): Writable, void> +} = dual( + (args) => Stream.isStream(args[0]) || Predicate.isFunction(args[0]), + ( + create: ((get: AtomContext) => Stream.Stream) | Stream.Stream, + options?: { + readonly disableAccumulation?: boolean | undefined + }, + ): Writable, void> => { + const pullSignal = setIdleTTL(state(0), 0) + const pullAtom = readable(makeRead((get) => makeStreamPullEffect(get, pullSignal, create, options))) + return makeStreamPull(pullSignal, pullAtom) + }, +) + +const makeStreamPullEffect = ( + get: AtomContext, + pullSignal: Atom, + create: Stream.Stream | ((get: AtomContext) => Stream.Stream), + options?: { + readonly disableAccumulation?: boolean | undefined + }, +): Effect.Effect< + { readonly done: boolean; readonly items: Arr.NonEmptyReadonlyArray }, + E | Cause.NoSuchElementError, + R0 | Scope.Scope | Current +> => + Effect.flatMap( + Stream.toPull(resolveCreate(create, get)), + (pullChunk) => { + const fiber = Fiber.getCurrent() + if (fiber === undefined) { + return Effect.die(new Error('Atom.pull: no fiber in scope')) + } + const services = Context.empty().pipe( + Context.add(Scope.Scope, Context.getUnsafe(fiber.context, Scope.Scope)), + Context.add(Current, Context.getUnsafe(fiber.context, Current)), + ) + let acc: readonly A[] = Arr.empty() + const pull: Effect.Effect< + { + done: boolean + items: Arr.NonEmptyReadonlyArray + }, + Cause.NoSuchElementError | E, + Current + > = Effect.matchCauseEffect(pullChunk, { + onFailure(cause): Effect.Effect< + { done: boolean; items: Arr.NonEmptyReadonlyArray }, + Cause.NoSuchElementError | E + > { + return onPullFailure(cause, acc) + }, + onSuccess(chunk) { + return onPullSuccess(chunk, acc, options, (next) => { + acc = next + }, pull) + }, + }) + + const cancels = new Set<() => void>() + get.addFinalizer(() => { + for (const cancel of cancels) cancel() + }) + get.once(pullSignal) + get.subscribe(pullSignal, () => { + get.setSelf(AsyncResult.waitingFrom(Option.none())) + let cancel: (() => void) | undefined = undefined + cancel = runCallbackSync(services, pull, (exit) => { + finishPullCallback(get, cancels, cancel, exit) + }) + addPullCancel(cancels, cancel) + }) + + return pull + }, + ) + +const makeStreamPull = ( + pullSignal: Writable, + pullAtom: Atom>, +): Writable, void> => + writable(pullAtom.read, function(ctx) { + ctx.set(pullSignal, ctx.get(pullSignal) + 1) + }) + +/** + * Runs synchronous atom updates as a batch on one registry. + * + * **Details** + * + * Stale nodes are rebuilt and listeners are notified after the callback completes, + * so dependent updates observe the final batched state. + * + * @since 4.0.0 + */ +export const batch: typeof Registry.batch = Registry.batch + +function familyUnsupported(): boolean { + const flags = [ + typeof WeakRef === 'undefined', + typeof FinalizationRegistry === 'undefined', + ] + return flags.includes(true) +} + +function makeFamily(): (f: (arg: Arg) => T) => (arg: Arg) => T { + if (familyUnsupported()) { + return familyStrong + } + return familyWeak +} + +function familyStrong(f: (arg: Arg) => T): (arg: Arg) => T { + const atoms = MutableHashMap.empty() + return function(arg) { + return familyStrongGet(atoms, arg, f) + } +} + +function familyStrongGet( + atoms: MutableHashMap.MutableHashMap, + arg: Arg, + f: (arg: Arg) => T, +): T { + const atomEntry = MutableHashMap.get(atoms, arg) + if (Option.isSome(atomEntry)) { + return atomEntry.value + } + const newAtom = f(arg) + MutableHashMap.set(atoms, arg, newAtom) + return newAtom +} + +function familyWeak(f: (arg: Arg) => T): (arg: Arg) => T { + const atoms = MutableHashMap.empty>() + const registry = new FinalizationRegistry((arg) => { + MutableHashMap.remove(atoms, arg) + }) + return function(arg) { + return familyWeakGet(atoms, registry, arg, f) + } +} + +function familyWeakGet( + atoms: MutableHashMap.MutableHashMap>, + registry: FinalizationRegistry, + arg: Arg, + f: (arg: Arg) => T, +): T { + const atomEntry = MutableHashMap.get(atoms, arg).pipe( + Option.flatMapNullishOr((ref) => ref.deref()), + ) + if (Option.isSome(atomEntry)) { + return atomEntry.value + } + const newAtom = f(arg) + MutableHashMap.set(atoms, arg, new WeakRef(newAtom)) + registry.register(newAtom, arg) + return newAtom +} + +function readWithFallback( + get: AtomContext, + self: Atom, + fallback: Atom>, +): AnyResult | AsyncResult.Result { + const result = get(self) + if (AsyncResult.isInitial(result)) { + return AsyncResult.waiting(get(fallback)) + } + return result +} + +function copyWithFallback, A2, E2>( + self: R, + withFallback: (get: AtomContext) => AnyResult | AsyncResult.Result, +): Atom> { + const refresh = atomRefresh(self) + if (isWritable(self)) { + return writable(withFallback, self.write, refresh) + } + return readable(withFallback, refresh) +} + +function atomRefresh(self: Atom): (refresh: (atom: Atom) => void) => void { + const refresh = self.spec.refresh + if (refresh === undefined) { + return function(refresh) { + refresh(self) + } + } + return refresh +} + +function mapResultValue( + value: Top, + f: (value: Top) => Top, +): AnyResult { + if (AsyncResult.isResult(value) === false) { + throw new TypeError('mapResult: expected an AsyncResult atom') + } + return mapResultMapped(value, f) +} + +function mapResultMapped( + value: AnyResult, + f: (value: Top) => Top, +): AnyResult { + if (AsyncResult.isSuccess(value)) { + return AsyncResult.success(f(value.value)) + } + return value +} + +function mapResultBinary(selfOrF: Top, f: Top): Top { + if (isAtom(selfOrF) === false) { + throw new TypeError('mapResult expects an atom and a function argument') + } + return mapResultBinaryFn(selfOrF, f) +} + +function mapResultBinaryFn(self: AnyAtom, f: Top): Top { + if (isMapResultMapper(f) === false) { + throw new TypeError('mapResult expects an atom and a function argument') + } + return mapResultImpl(self, f) +} + +function mapResultCurried(selfOrF: Top): (self: AnyAtom) => Top { + return (self: AnyAtom) => mapResultCurriedApply(self, selfOrF) +} + +function mapResultCurriedApply(self: AnyAtom, selfOrF: Top): Top { + if (isMapResultMapper(selfOrF) === false) { + throw new TypeError('mapResult expects a function argument') + } + return mapResultImpl(self, selfOrF) +} + +type SwrOptions = { + readonly staleTime: Duration.Input + readonly revalidateOnMount?: boolean | undefined + readonly revalidateOnFocus?: boolean | 'always' | undefined + readonly focusSignal?: AnyAtom | undefined +} + +function revalidateOnFocusEnabled(value: boolean | 'always' | undefined): boolean { + if (value === undefined) { + return false + } + return value !== false +} + +function shouldSubscribeSwrFocus(options: SwrOptions): options is SwrOptions & { readonly focusSignal: AnyAtom } { + if (options.focusSignal === undefined) { + return false + } + return revalidateOnFocusEnabled(options.revalidateOnFocus) +} + +function swrFocusListener( + get: AtomContext, + self: Atom>, + options: SwrOptions, + staleTime: number, +): () => void { + if (options.revalidateOnFocus === 'always') { + return () => get.refresh(self) + } + return () => revalidateSwrIfStale(get, self, staleTime) +} + +function revalidateSwrIfStale( + get: AtomContext, + self: Atom>, + staleTime: number, +): void { + const current = get.once(self) + if (shouldRevalidateSWR(current, staleTime, get.registry.now())) { + get.refresh(self) + } +} + +function subscribeSwrFocus( + get: AtomContext, + self: Atom>, + options: SwrOptions, + staleTime: number, +): void { + if (shouldSubscribeSwrFocus(options) === false) { + return + } + get.once(options.focusSignal) + get.subscribe(options.focusSignal, swrFocusListener(get, self, options, staleTime)) +} + +function skipSwrRevalidateOnMount(options: SwrOptions, firstRead: boolean): boolean { + if (firstRead === false) { + return false + } + return options.revalidateOnMount === false +} + +function readSwr( + get: AtomContext, + self: Atom>, + options: SwrOptions, + staleTime: number, +): AsyncResult.Result { + const current = get.once(self) + get.subscribe(self, (value) => { + get.setSelf(value) + }) + subscribeSwrFocus(get, self, options, staleTime) + return finishSwrRead(get, self, options, staleTime, current) +} + +function finishSwrRead( + get: AtomContext, + self: Atom>, + options: SwrOptions, + staleTime: number, + current: AsyncResult.Result, +): AsyncResult.Result { + if (skipSwrRevalidateOnMount(options, Option.isNone(get.self()))) { + return current + } + revalidateSwrIfStale(get, self, staleTime) + return current +} + +function swrFailureTimestamp(result: AsyncResult.Result): Option.Option { + if (AsyncResult.isFailure(result)) { + return Option.map(result.previousSuccess, (success) => success.timestamp) + } + return Option.none() +} + +function shouldRevalidateSettledSWR( + result: AsyncResult.Result, + staleTime: number, + now: number, +): boolean { + const timestamp = result.pipe(swrTimestamp, Option.getOrUndefined) + if (timestamp === undefined) { + return result.pipe(AsyncResult.isInitial) === false + } + return isFreshWithin(timestamp, staleTime, now) === false +} + +type OptimisticState = { + lastValue: A + needsRefresh: boolean + transitions: Set>> + cancels: Set<() => void> +} + +function readOptimistic( + get: AtomContext, + self: Atom, + writeAtom: Atom> | undefined]>, +): A { + const state: OptimisticState = { + lastValue: get.once(self), + needsRefresh: false, + transitions: new Set(), + cancels: new Set(), + } + get.subscribe(self, (value) => applyOptimisticSource(get, state, value)) + get.subscribe(writeAtom, ([, atom]) => subscribeOptimisticWrite(get, self, state, atom)) + get.addFinalizer(() => finalizeOptimistic(state)) + return state.lastValue +} + +function applyOptimisticSource(get: AtomContext, state: OptimisticState, value: A): void { + state.lastValue = value + if (state.transitions.size > 0) { + return + } + applyOptimisticSourceIdle(get, state, value) +} + +function applyOptimisticSourceIdle(get: AtomContext, state: OptimisticState, value: A): void { + state.needsRefresh = false + if (AsyncResult.isAsyncResult(value) === false) { + get.setSelf(value) + return + } + applyOptimisticResult(get, value) +} + +function applyOptimisticResult(get: AtomContext, value: AsyncResult.Result): void { + const current = Option.getOrUndefined(get.self()) + if (AsyncResult.isInitial(value)) { + applyOptimisticInitial(get, current, value) + return + } + applyOptimisticNonInitial(get, current, value) +} + +function applyOptimisticInitial( + get: AtomContext, + current: Top, + value: AsyncResult.Initial, +): void { + if (isInitialResult(current) === false) { + return + } + get.setSelf(value) +} + +function isInitialResult(current: Top): boolean { + if (AsyncResult.isResult(current) === false) { + return false + } + return AsyncResult.isInitial(current) +} + +function applyOptimisticNonInitial( + get: AtomContext, + current: Top, + value: AsyncResult.Success | AsyncResult.Failure, +): void { + if (AsyncResult.isSuccess(value)) { + applyOptimisticSuccess(get, current, value) + return + } + get.setSelf(value) +} + +function applyOptimisticSuccess( + get: AtomContext, + current: Top, + value: AsyncResult.Success, +): void { + if (isSuccessResult(current)) { + applyOptimisticNewerSuccess(get, current, value) + return + } + get.setSelf(value) +} + +function isSuccessResult(current: unknown): current is AsyncResult.Success { + if (AsyncResult.isResult(current) === false) { + return false + } + return AsyncResult.isSuccess(current) +} + +function applyOptimisticNewerSuccess( + get: AtomContext, + current: AsyncResult.Success, + value: AsyncResult.Success, +): void { + if (shouldReplaceOptimisticSuccess(current, value) === false) { + return + } + get.setSelf(value) +} + +function shouldReplaceOptimisticSuccess( + current: AsyncResult.Success, + value: AsyncResult.Success, +): boolean { + if (value.waiting) { + return false + } + return value.timestamp >= current.timestamp +} + +function subscribeOptimisticWrite( + get: AtomContext, + self: Atom, + state: OptimisticState, + atom: Atom> | undefined, +): void { + if (atom === undefined) { + return + } + subscribeOptimisticWriteAtom(get, self, state, atom) +} + +function subscribeOptimisticWriteAtom( + get: AtomContext, + self: Atom, + state: OptimisticState, + atom: Atom>, +): void { + if (state.transitions.has(atom)) { + return + } + startOptimisticTransition(get, self, state, atom) +} + +function startOptimisticTransition( + get: AtomContext, + self: Atom, + state: OptimisticState, + atom: Atom>, +): void { + state.transitions.add(atom) + let cancel: (() => void) | undefined = undefined + cancel = get.registry.subscribe(atom, (result) => { + onOptimisticTransition(get, self, state, atom, cancel, result) + }, { immediate: true }) + keepOptimisticCancel(state, atom, cancel) +} + +function keepOptimisticCancel( + state: OptimisticState, + atom: Atom>, + cancel: () => void, +): void { + if (state.transitions.has(atom)) { + state.cancels.add(cancel) + return + } + cancel() +} + +function onOptimisticTransition( + get: AtomContext, + self: Atom, + state: OptimisticState, + atom: Atom>, + cancel: (() => void) | undefined, + result: AsyncResult.Result, +): void { + if (isWaitingSuccess(result)) { + get.setSelf(result.value) + return + } + finishOptimisticTransition(get, self, state, atom, cancel, result) +} + +function isWaitingSuccess(result: AsyncResult.Result): result is AsyncResult.Success { + if (AsyncResult.isSuccess(result) === false) { + return false + } + return result.waiting +} + +function finishOptimisticTransition( + get: AtomContext, + self: Atom, + state: OptimisticState, + atom: Atom>, + cancel: (() => void) | undefined, + result: AsyncResult.Result, +): void { + state.transitions.delete(atom) + dropOptimisticCancel(state, cancel) + markOptimisticRefresh(state, result) + settleOptimisticTransitions(get, self, state) +} + +function dropOptimisticCancel(state: OptimisticState, cancel: (() => void) | undefined): void { + if (cancel === undefined) { + return + } + state.cancels.delete(cancel) + cancel() +} + +function markOptimisticRefresh(state: OptimisticState, result: AsyncResult.Result): void { + if (shouldMarkOptimisticRefresh(state, result) === false) { + return + } + state.needsRefresh = true +} + +function shouldMarkOptimisticRefresh(state: OptimisticState, result: AsyncResult.Result): boolean { + if (state.needsRefresh) { + return false + } + return AsyncResult.isFailure(result) === false +} + +function settleOptimisticTransitions(get: AtomContext, self: Atom, state: OptimisticState): void { + if (state.transitions.size !== 0) { + return + } + settleOptimisticIdle(get, self, state) +} + +function settleOptimisticIdle(get: AtomContext, self: Atom, state: OptimisticState): void { + if (state.needsRefresh) { + state.needsRefresh = false + get.refresh(self) + return + } + get.setSelf(state.lastValue) +} + +function finalizeOptimistic(state: OptimisticState): void { + for (const cancel of state.cancels) cancel() + state.transitions.clear() + state.cancels.clear() +} + +function runOptimisticFn( + self: Writable>>, + options: { + readonly reducer: (current: NoInfer, update: OW) => NoInfer + readonly fn: AtomResultFn | ((set: (result: NoInfer) => void) => AtomResultFn) + }, + transition: Writable>, + arg: OW, + get: FnContext, +): Effect.Effect { + const value = optimisticFnValue(options.reducer(get(self), arg)) + get.set(transition, AsyncResult.successWith(value, { waiting: true })) + get.set(self, transition) + const fnAtom = optimisticFnAtom(options.fn, transition, get) + get.set(fnAtom, arg) + return Effect.callback((resume) => { + get.subscribe(fnAtom, (result) => { + if (optimisticFnPending(result)) { + return + } + get.set(transition, AsyncResult.map(result, () => value)) + resumeOptimisticFn(resume, result) + }, { immediate: true }) + }) +} + +function optimisticFnValue(value: W): W { + if (AsyncResult.isAsyncResult(value) === false) { + return value + } + return AsyncResult.waiting(value, { touch: true }) +} + +function optimisticFnAtom( + fn: AtomResultFn | ((set: (result: NoInfer) => void) => AtomResultFn), + transition: Writable>, + get: FnContext, +): AtomResultFn { + if (typeof fn === 'function') { + return autoDispose(fn((value) => setOptimisticFnTransition(get, transition, value))) + } + return fn +} + +function setOptimisticFnTransition( + get: FnContext, + transition: Writable>, + value: W, +): void { + get.set(transition, AsyncResult.successWith(waitingIfResult(value), { waiting: true })) +} + +function waitingIfResult(value: W): W { + if (AsyncResult.isAsyncResult(value) === false) { + return value + } + return AsyncResult.waiting(value) +} + +function resumeOptimisticFn( + resume: (effect: Effect.Effect) => void, + result: AsyncResult.Result, +): void { + if (AsyncResult.isSuccess(result)) { + resume(Effect.succeed(result.value)) + return + } + resumeOptimisticFailure(resume, result) +} + +function resumeOptimisticFailure( + resume: (effect: Effect.Effect) => void, + result: AsyncResult.Result, +): void { + if (AsyncResult.isFailure(result) === false) { + return + } + resume(Effect.failCause(result.cause)) +} + +function optimisticFnPending(result: AsyncResult.Result): boolean { + if (AsyncResult.isInitial(result)) { + return true + } + return result.waiting +} + +/** + * Creates a memoized atom factory that returns the same object for the same argument, using weak references for cached values when the platform supports them. + * + * @since 4.0.0 + */ +export const family = makeFamily() + +/** + * Uses a fallback `AsyncResult` atom while the primary atom is `Initial`, marking the fallback result as waiting until the primary atom produces a non-initial result. + * + * @since 4.0.0 + */ +export const withFallback: { + ( + fallback: Atom>, + ): >>( + self: R, + ) => With | AsyncResult.Result> + >, A2, E2>( + self: R, + fallback: Atom>, + ): With | AsyncResult.Result> +} = dual(2, >, A2, E2>( + self: R, + fallback: Atom>, +): Atom< + AsyncResult.Result | AsyncResult.Result +> => { + function withFallback(get: AtomContext): AsyncResult.Result | AsyncResult.Result { + return readWithFallback(get, self, fallback) + } + return copyWithFallback(self, withFallback) +}) + +/** + * Pairs an atom with an initial value for registry initialization. + * + * **When to use** + * + * Use to preload an atom value when constructing or seeding a registry. + * + * **Details** + * + * The returned tuple can be supplied to registry initial values so the atom + * starts with the provided value before it is first rebuilt. + * + * @since 4.0.0 + */ +export const initialValue: { + (initialValue: A): (self: Atom) => readonly [Atom, A] + (self: Atom, initialValue: A): readonly [Atom, A] +} = dual< + (initialValue: A) => (self: Atom) => readonly [Atom, A], + (self: Atom, initialValue: A) => readonly [Atom, A] +>(2, (self, initialValue) => [self, initialValue]) + +/** + * Maps the value of an atom by reading the source atom, applying the function, + * + * **Details** + * + * When the source atom is writable, the returned atom remains writable and keeps + * the source atom's write input type. + * + * @since 4.0.0 + */ +export const map: { + , B>( + f: (_: Type) => B, + ): (self: R) => With + , B>( + self: R, + f: (_: Type) => B, + ): With +} = dual( + 2, + (self: Atom, f: (_: A) => B): Atom => transform(self, (get) => f(get(self))), +) + +/** + * Maps the successful value inside an `AsyncResult` atom. + * + * **Details** + * + * Initial and failure states are preserved, and writable source atoms keep their + * original write input type. + * + * @since 4.0.0 + */ +const mapResultImpl = ( + self: Atom, + f: (value: Top) => Top, +): Atom> => + transform(self, (get): AsyncResult.Result => mapResultValue(get(self), f)) + +type MapResultMapper = (s: Top) => Top +const isMapResultMapper = (arg: unknown): arg is MapResultMapper => typeof arg === 'function' + +export function mapResult>, B>( + f: (_: AsyncResult.Result.Success>) => B, +): ( + self: R, +) => With>>> +export function mapResult>, B>( + self: R, + f: (_: AsyncResult.Result.Success>) => B, +): With>>> +export function mapResult( + selfOrF: Top, + f?: Top, +): Top { + if (arguments.length >= 2) { + return mapResultBinary(selfOrF, f) + } + return mapResultCurried(selfOrF) +} + +/** + * Creates an atom that publishes source changes only after the source has stopped + * changing for the specified duration. + * + * **Details** + * + * The current source value is used immediately, and any pending debounce timer is + * cleared when the derived atom is disposed. + * + * @since 4.0.0 + */ +export const debounce: { + (duration: Duration.Input): >(self: A) => WithoutSerializable + >(self: A, duration: Duration.Input): WithoutSerializable +} = dual( + 2, + (self: Atom, duration: Duration.Input): Atom => { + const millis = Duration.toMillis(Duration.fromInputUnsafe(duration)) + return transform(self, function(get) { + let timeout: (() => void) | undefined + let value = get.once(self) + function update() { + timeout = undefined + get.setSelf(value) + } + get.addFinalizer(function() { + if (timeout !== undefined) timeout() + }) + get.subscribe(self, function(val) { + value = val + timeout?.() + timeout = get.registry.scheduleTimer(update, millis) + }) + return value + }, { initialValueTarget: self }) + }, +) + +/** + * Creates a derived atom that reads the source and schedules a refresh after the + * specified duration. + * + * **Details** + * + * The scheduled refresh is canceled when the derived atom's lifetime is disposed. + * + * @since 4.0.0 + */ +export const withRefresh: { + (duration: Duration.Input): >(self: A) => WithoutSerializable + >(self: A, duration: Duration.Input): WithoutSerializable +} = dual( + 2, + (self: Atom, duration: Duration.Input): Atom => { + const millis = Duration.toMillis(Duration.fromInputUnsafe(duration)) + return transform(self, function(get) { + const cancel = get.registry.scheduleTimer(() => get.refresh(self), millis) + get.addFinalizer(cancel) + return get(self) + }, { initialValueTarget: self }) + }, +) + +/** + * Adds stale-while-revalidate refresh behavior to an async result atom. + * + * **Details** + * + * Automatic revalidation during reads is skipped while the current value is + * fresh within `staleTime`. Manual `refresh` calls remain forceful and always + * forward to the wrapped atom. Use `revalidateOnMount` to control whether stale data should trigger a + * background refresh on first mount. Use `revalidateOnFocus` to control + * focus behavior. `true` respects `staleTime` and `"always"` forces refetch. + * + * @since 4.0.0 + */ +export const swr: { + ( + options: { + readonly staleTime: Duration.Input + readonly revalidateOnMount?: boolean | undefined + readonly revalidateOnFocus?: boolean | 'always' | undefined + readonly focusSignal?: Atom | undefined + }, + ): >>(self: R) => WithoutSerializable + >>( + self: R, + options: { + readonly staleTime: Duration.Input + readonly revalidateOnMount?: boolean | undefined + readonly revalidateOnFocus?: boolean | 'always' | undefined + readonly focusSignal?: Atom | undefined + }, + ): WithoutSerializable +} = dual( + 2, + ( + self: Atom>, + options: { + readonly staleTime: Duration.Input + readonly revalidateOnMount?: boolean | undefined + readonly revalidateOnFocus?: boolean | 'always' | undefined + readonly focusSignal?: Atom | undefined + }, + ): Atom> => { + const staleTime = Duration.toMillis(Duration.fromInputUnsafe(options.staleTime)) + return transform(self, (get) => readSwr(get, self, options, staleTime), { initialValueTarget: self }) + }, +) + +const swrTimestamp = (result: AsyncResult.Result): Option.Option => { + if (AsyncResult.isSuccess(result)) { + return Option.some(result.timestamp) + } + return swrFailureTimestamp(result) +} + +const isFreshWithin = (timestamp: number, staleTime: number, now: number): boolean => now - timestamp < staleTime + +const shouldRevalidateSWR = ( + result: AsyncResult.Result, + staleTime: number, + now: number, +): boolean => { + if (result.waiting) { + return false + } + return shouldRevalidateSettledSWR(result, staleTime, now) +} + +/** + * Wraps an atom in a writable optimistic atom. + * + * **Details** + * + * Writes accept transition atoms containing `AsyncResult` values. Waiting + * successes are shown optimistically while transitions run; when successful + * transitions finish, the source atom is refreshed, and failures roll the value + * back to the latest source value. + * + * @since 4.0.0 + */ +export const optimistic = (self: Atom): Writable>> => { + let counter = 0 + const writeAtom = setIdleTTL( + state> | undefined]>( + [counter, undefined] as const, + ), + 0, + ) + return writable( + (get) => readOptimistic(get, self, writeAtom), + (ctx, atom) => ctx.set(writeAtom, [++counter, atom]), + (refresh) => refresh(self), + ) +} + +/** + * Creates an `AtomResultFn` that applies an optimistic update before running the + * underlying mutation. + * + * **Details** + * + * The reducer computes the provisional value from the current value and mutation + * input. The wrapped function result then completes the transition or updates the + * optimistic value through the provided setter callback. + * + * @since 4.0.0 + */ +export const optimisticFn: { + ( + options: { + readonly reducer: (current: NoInfer, update: OW) => NoInfer + readonly fn: + | AtomResultFn + | ((set: (result: NoInfer) => void) => AtomResultFn) + }, + ): ( + self: Writable>>, + ) => AtomResultFn + ( + self: Writable>>, + options: { + readonly reducer: (current: NoInfer, update: OW) => NoInfer + readonly fn: + | AtomResultFn + | ((set: (result: NoInfer) => void) => AtomResultFn) + }, + ): AtomResultFn +} = dual(2, ( + self: Writable>>, + options: { + readonly reducer: (current: NoInfer, update: OW) => NoInfer + readonly fn: + | AtomResultFn + | ((set: (result: NoInfer) => void) => AtomResultFn) + }, +): AtomResultFn => { + const transition = setIdleTTL(AsyncResult.initial().pipe(state>), 0) + return fn((arg: OW, get) => runOptimisticFn(self, options, transition, arg, get)) +}) + +/** + * Converts an atom into a stream using the `Current` service. + * + * **Details** + * + * The stream emits the atom's current value immediately and then emits subsequent + * changes until the stream scope is closed. + * + * @since 4.0.0 + */ +export const toStream = (self: Atom): Stream.Stream => + Stream.unwrap(Current.use((r) => Effect.succeed(Registry.toStream(r, self)))) + +/** + * Converts an `AsyncResult` atom into a stream using the `Current` service. + * + * **Details** + * + * Initial results are skipped, successes are emitted as stream values, and + * failures fail the stream with the result cause. + * + * @since 4.0.0 + */ +export const toStreamResult = (self: Atom>): Stream.Stream => + Stream.unwrap(Current.use((r) => Effect.succeed(Registry.toStreamResult(r, self)))) + +/** + * Reads an atom's current value from the `Current` service. + * + * @since 4.0.0 + */ +export const get = (self: Atom): Effect.Effect => + Current.use((r) => Effect.succeed(Registry.get(r, self))) + +/** + * Reads a writable atom, computes a return value and next write value, writes the + * next value, and returns the computed result. + * + * @since 4.0.0 + */ +export const modify: { + ( + f: (_: R) => [A, W], + ): (self: Writable) => Effect.Effect + (self: Writable, f: (_: R) => [A, W]): Effect.Effect +} = dual( + 2, + (self: Writable, f: (_: R) => [returnValue: A, nextValue: W]): Effect.Effect => + Effect.map(Current, (r) => Registry.modify(r, self, f)), +) + +/** + * Writes a value to a writable atom through the `Current` service. + * + * @since 4.0.0 + */ +export const set: { + (value: W): (self: Writable) => Effect.Effect + (self: Writable, value: W): Effect.Effect +} = dual( + 2, + (self: Writable, value: W): Effect.Effect => + Effect.map(Current, (r) => Registry.set(r, self, value)), +) + +/** + * Updates a writable atom by reading its current value from the registry and + * writing the value returned by the update function. + * + * @since 4.0.0 + */ +export const update: { + (f: (_: R) => W): (self: Writable) => Effect.Effect + (self: Writable, f: (_: R) => W): Effect.Effect +} = dual( + 2, + (self: Writable, f: (_: R) => W): Effect.Effect => + Effect.map(Current, (r) => Registry.update(r, self, f)), +) + +/** + * Reads an `AsyncResult` atom as an effect through the `Current` service. + * + * **Details** + * + * The effect waits while the result is `Initial`, and also while it is waiting + * when `suspendOnWaiting` is enabled. Successes succeed with the value and + * failures fail with the result cause. + * + * @since 4.0.0 + */ +export const getResult: { + ( + self: Atom>, + options?: { readonly suspendOnWaiting?: boolean | undefined }, + ): Effect.Effect + ( + options?: { readonly suspendOnWaiting?: boolean | undefined }, + ): (self: Atom>) => Effect.Effect +} = dual( + (args) => isAtom(args[0]), + ( + self: Atom>, + options?: { readonly suspendOnWaiting?: boolean | undefined }, + ): Effect.Effect => Current.use(Registry.getResult(self, options)), +) + +/** + * Runs a refresh request for an atom through the `Current` service. + * + * **When to use** + * + * Use to invalidate and recompute an atom from an Effect that has access to the + * active registry. + * + * @since 4.0.0 + */ +export const refresh = (self: Atom): Effect.Effect => + Effect.map(Current, (r) => Registry.refresh(r, self)) + +/** + * Mounts an atom in the registry for the lifetime of the current scope. + * + * **Details** + * + * Mounting keeps the atom subscribed with a no-op listener until the scope + * finalizer releases it. + * + * @since 4.0.0 + */ +export const mount = (self: Atom): Effect.Effect => + Current.use((r) => Registry.mount(r, self)) + +// ----------------------------------------------------------------------------- +// Serializable +// ----------------------------------------------------------------------------- + +// ----------------------------------------------------------------------------- +// Atom operations: refresh on a signal +// ----------------------------------------------------------------------------- + +/** + * Creates a combinator that refreshes an atom whenever the supplied signal atom + * changes. + * + * **Details** + * + * The derived atom also subscribes to the source atom so normal source updates are + * forwarded to its own value. + * + * @since 4.0.0 + */ +export const makeRefreshOnSignal = (signal: Atom) => { + function refreshOnSignal(self: A): WithoutSerializable + function refreshOnSignal>( + self: A & Atom, + ): With, V> { + return transform(self, (get) => { + get.once(signal) + get.subscribe(signal, () => get.refresh(self)) + get.subscribe(self, (value: V) => get.setSelf(value)) + return get.once(self) + }, { initialValueTarget: self }) + } + return refreshOnSignal +} diff --git a/packages/atom/effect-atom/src/batch-phase.schema.ts b/packages/atom/effect-atom/src/batch-phase.schema.ts new file mode 100644 index 0000000..544f47e --- /dev/null +++ b/packages/atom/effect-atom/src/batch-phase.schema.ts @@ -0,0 +1,4 @@ +import { Schema as S } from 'effect' + +export const BatchPhaseName = S.Literals(['disabled', 'collect', 'commit']) +export type BatchPhaseName = S.Schema.Type diff --git a/packages/atom/effect-atom/src/current-registry.service.ts b/packages/atom/effect-atom/src/current-registry.service.ts new file mode 100644 index 0000000..c21e5dd --- /dev/null +++ b/packages/atom/effect-atom/src/current-registry.service.ts @@ -0,0 +1,16 @@ +import { Context } from 'effect' +import type { Registry } from './registry.handle.js' + +/** + * Service tag for the registry evaluating an effect. + * + * **When to use** + * + * Use to access or provide the registry that stores atom values, + * dependencies, subscriptions, and disposal state for a reactive lifetime. + * + * @since 4.0.0 + */ +export class Current + extends Context.Service()('@systemfsoftware/effect-atom/current-registry.service/Current') +{} diff --git a/packages/atom/effect-atom/src/dehydrated-atom.schema.ts b/packages/atom/effect-atom/src/dehydrated-atom.schema.ts new file mode 100644 index 0000000..68bc882 --- /dev/null +++ b/packages/atom/effect-atom/src/dehydrated-atom.schema.ts @@ -0,0 +1,18 @@ +import { Schema as S } from 'effect' + +/** + * Wire shape of one dehydrated atom entry; hydration decodes every incoming + * entry through it. + * + * @since 4.0.0 + */ +export const DehydratedAtomValue = S.Struct({ + '~effect/reactivity/DehydratedAtom': S.Literal(true), + key: S.String, + value: S.Unknown, + dehydratedAt: S.Finite.pipe(S.check(S.isGreaterThanOrEqualTo(0))), +}) +/** + * @since 4.0.0 + */ +export type DehydratedAtomValue = S.Schema.Type diff --git a/packages/atom/effect-atom/src/hydration.schema.ts b/packages/atom/effect-atom/src/hydration.schema.ts new file mode 100644 index 0000000..651559c --- /dev/null +++ b/packages/atom/effect-atom/src/hydration.schema.ts @@ -0,0 +1,176 @@ +/** + * The hydration vocabulary: the dehydrated entry shapes and the pure decisions + * that encode a registry's serializable atoms and decode an incoming payload. + * + * `dehydrate` and `hydrate` themselves live in `./registry.handle.js`: they walk + * live registry nodes, read deferreds and record refusals. This file declares the + * data they carry and every choice over it that needs no registry. + * + * @since 4.0.0 + */ +import type * as Exit from 'effect/Exit' +import { dual } from 'effect/Function' +import * as Schema from 'effect/Schema' +import { isAsyncResult, isInitial } from './async-result.schema.js' +import type * as Atom from './atom.blueprint.js' +import { DehydratedAtomValue as DehydratedAtomValueSchema } from './dehydrated-atom.schema.js' + +type AnyValue = A + +type EncodingCodec = Schema.ConstraintEncoder + +type SerializerSpec = Atom.SerializableSpec + +/** How a registry carries an `Initial` async result at dehydration time. */ +export type EncodeInitialAs = 'ignore' | 'deferred' | 'value-only' + +type EncodeInitialOptions = { + readonly encodeInitialAs?: EncodeInitialAs | undefined +} + +/** + * Marker interface for entries in a dehydrated atom registry state. + * + * @since 4.0.0 + */ +export interface DehydratedAtom { + readonly '~effect/reactivity/DehydratedAtom': true +} + +/** + * A dehydrated serializable atom value. + * + * **Details** + * + * It stores the atom serialization key, encoded value, and dehydration + * timestamp. + * + * @since 4.0.0 + */ +export interface DehydratedAtomValue extends DehydratedAtom { + readonly key: string + readonly value: V + readonly dehydratedAt: number +} + +/** + * One entry of a hydration payload as received from outside the process: the + * in-process `DehydratedAtomValue` returned by `dehydrate`, or any JSON value + * parsed from a transport. Every entry is decoded before it reaches the + * registry; entries that fail to decode are recorded as refusals. + * + * @since 4.0.0 + */ +export type HydrationEntry = DehydratedAtomValue | Schema.Json + +const isEncodingCodec = (value: unknown): value is EncodingCodec => Schema.isSchema(value) + +/** + * Reads the JSON codec a serializable atom encodes its value through, when it + * carries one. + * + * @since 4.0.0 + */ +export const encodingCodecOf = (serializer: SerializerSpec): EncodingCodec | undefined => { + const codec = serializer.codecJson + if (isEncodingCodec(codec) === false) { + return undefined + } + return codec +} + +/** + * Defaults the `encodeInitialAs` option to `"ignore"`. + * + * @since 4.0.0 + */ +export const encodeInitialMode = (options?: EncodeInitialOptions): EncodeInitialAs => { + if (options === undefined) { + return 'ignore' + } + return encodeInitialOrIgnore(options.encodeInitialAs) +} + +const encodeInitialOrIgnore = (mode: EncodeInitialAs | undefined): EncodeInitialAs => { + if (mode === undefined) { + return 'ignore' + } + return mode +} + +/** + * Decides whether an initial value is dropped from the dehydrated state. + * + * @since 4.0.0 + */ +export const shouldSkipInitial: { + (mode: EncodeInitialAs, isInitial: boolean): boolean + (isInitial: boolean): (mode: EncodeInitialAs) => boolean +} = dual(2, (mode: EncodeInitialAs, isInitial: boolean): boolean => { + if (mode !== 'ignore') { + return false + } + return isInitial +}) + +/** + * Decides whether an initial value is carried as a pending update. + * + * @since 4.0.0 + */ +export const shouldAttachDeferred: { + (mode: EncodeInitialAs, isInitial: boolean): boolean + (isInitial: boolean): (mode: EncodeInitialAs) => boolean +} = dual(2, (mode: EncodeInitialAs, isInitial: boolean): boolean => { + if (mode !== 'deferred') { + return false + } + return isInitial +}) + +/** + * Reports whether an atom value is an unsettled `AsyncResult`. + * + * @since 4.0.0 + */ +export const isInitialResult = (value: AnyValue): boolean => { + if (isAsyncResult(value) === false) { + return false + } + return isInitial(value) +} + +/** + * Reports whether an atom value is a settled `AsyncResult`. + * + * @since 4.0.0 + */ +export const isSettledResult = (value: AnyValue): boolean => { + if (isAsyncResult(value) === false) { + return false + } + return isInitial(value) === false +} + +/** + * Encodes a value through a serializable atom's codec, as plain data. + * + * @since 4.0.0 + */ +export const encodeHydrationValue: { + (codec: EncodingCodec, value: AnyValue): Exit.Exit + (value: AnyValue): (codec: EncodingCodec) => Exit.Exit +} = dual( + 2, + (codec: EncodingCodec, value: AnyValue): Exit.Exit => + Schema.encodeUnknownExit(codec)(value), +) + +/** + * Decodes one incoming hydration entry, as plain data. + * + * @since 4.0.0 + */ +export const decodeHydrationEntry = ( + entry: HydrationEntry, +): Exit.Exit => Schema.decodeUnknownExit(DehydratedAtomValueSchema)(entry) diff --git a/packages/atom/effect-atom/src/hydration.ts b/packages/atom/effect-atom/src/hydration.ts new file mode 100644 index 0000000..bedabda --- /dev/null +++ b/packages/atom/effect-atom/src/hydration.ts @@ -0,0 +1,12 @@ +/** + * The `Hydration` namespace barrel. + * + * The dehydrated-state vocabulary is declared in `./hydration.schema.js`, and the + * `dehydrate`/`hydrate` operations that walk a registry live in + * `./registry.handle.js`, the module that declares the handle. This module keeps + * the `Atom.Hydration` namespace importable where it was. + * + * @since 4.0.0 + */ +export type { DehydratedAtom, DehydratedAtomValue, HydrationEntry } from './hydration.schema.js' +export { dehydrate, hydrate } from './registry.handle.js' diff --git a/packages/atom/effect-atom/src/internal/host-timer.ts b/packages/atom/effect-atom/src/internal/host-timer.ts new file mode 100644 index 0000000..ea3fd97 --- /dev/null +++ b/packages/atom/effect-atom/src/internal/host-timer.ts @@ -0,0 +1,87 @@ +/** + * Host clock and timer primitives for the atom registry. + * + * This module is the named host boundary for wall-clock reads and delayed + * scheduling. The registry itself is a fiber-free store, and consumers who + * need deterministic idle eviction pass their own `now` / `scheduleTimer` to + * `Registry.make` / `Registry.layer`. + * + * The defaults take the clock and the scheduler from the Effect fiber that is + * running when the registry is built, once, and keep them for the registry's + * lifetime: registry work runs later from queued tasks and timer callbacks, + * where no fiber is current. A scenario on the simulation kernel supplies its + * own `Scheduler` and `Clock`, so a registry that configures nothing still + * routes every timer and queued task through the run it was built in. A + * registry built outside any fiber — at module scope or on the platform — + * falls back to the Effect-native primitives: the default `Clock` reference's + * wall clock, and `Effect.sleep` forked on the default runtime, so this + * boundary never reaches for the `Date` or timer globals directly. + * + * @since 4.0.0 + */ +import * as Clock from 'effect/Clock' +import * as Context from 'effect/Context' +import * as Effect from 'effect/Effect' +import * as Fiber from 'effect/Fiber' +import * as Option from 'effect/Option' +import * as Scheduler from 'effect/Scheduler' + +/** @internal */ +export interface HostPorts { + readonly now: () => number + /** `undefined` outside a run, so `MixedScheduler` keeps its own host dispatch. */ + readonly scheduleTask: ((f: () => void) => () => void) | undefined + readonly scheduleTimer: (f: () => void, delayMillis: number) => () => void +} + +const cancelledTask = (): void => {} + +const serviceOf = (context: Context.Context | undefined, key: Context.Key): S | undefined => + context === undefined ? undefined : Option.getOrUndefined(Context.getOption(context, key)) + +const currentContext = (): Context.Context | undefined => Fiber.getCurrent()?.context + +const clockOf = (context: Context.Context | undefined): Clock.Clock => + serviceOf(context, Clock.Clock) ?? Clock.Clock.defaultValue() + +const taskOn = (scheduler: Scheduler.Scheduler | undefined): HostPorts['scheduleTask'] => { + if (scheduler === undefined) { + return undefined + } + const dispatcher = scheduler.makeDispatcher() + return (f) => { + dispatcher.scheduleTask(f, 0) + return cancelledTask + } +} + +const forkOn = (effect: Effect.Effect, scheduler: Scheduler.Scheduler | undefined): Fiber.Fiber => + scheduler === undefined ? Effect.runFork(effect) : Effect.runFork(effect, { scheduler }) + +const timerOn = + (clock: Clock.Clock, scheduler: Scheduler.Scheduler | undefined): HostPorts['scheduleTimer'] => (f, delayMillis) => { + const fiber = forkOn( + Effect.provideService(Effect.andThen(Effect.sleep(delayMillis), Effect.sync(f)), Clock.Clock, clock), + scheduler, + ) + return () => { + forkOn(Fiber.interrupt(fiber), scheduler) + } + } + +/** + * The clock, task dispatcher, and timer of the fiber running now, resolved + * once; outside any fiber, the default clock and runtime. + * + * @internal + */ +export const hostPorts = (): HostPorts => { + const context = currentContext() + const clock = clockOf(context) + const scheduler = serviceOf(context, Scheduler.Scheduler) + return { + now: () => clock.currentTimeMillisUnsafe(), + scheduleTask: taskOn(scheduler), + scheduleTimer: timerOn(clock, scheduler), + } +} diff --git a/packages/atom/effect-atom/src/judge-async-read.workflow.ts b/packages/atom/effect-atom/src/judge-async-read.workflow.ts new file mode 100644 index 0000000..1fbcd6d --- /dev/null +++ b/packages/atom/effect-atom/src/judge-async-read.workflow.ts @@ -0,0 +1,76 @@ +import { Workflow } from '@systemfsoftware/effect-cell-types' +import { Match, Schema } from 'effect' +import * as Result from 'effect/Result' + +const AsyncReadDecisionTypeId: unique symbol = Symbol.for( + '@systemfsoftware/effect-atom/AsyncReadDecision', +) +type AsyncReadDecisionTypeId = typeof AsyncReadDecisionTypeId + +export class SuspendImmediately extends Schema.TaggedClass()('SuspendImmediately', {}) { + readonly [AsyncReadDecisionTypeId] = AsyncReadDecisionTypeId +} + +export class AwaitNextResult extends Schema.TaggedClass()('AwaitNextResult', {}) { + readonly [AsyncReadDecisionTypeId] = AsyncReadDecisionTypeId +} + +export class KeepAwaiting extends Schema.TaggedClass()('KeepAwaiting', {}) { + readonly [AsyncReadDecisionTypeId] = AsyncReadDecisionTypeId +} + +export class ResolveWithResult extends Schema.TaggedClass()('ResolveWithResult', {}) { + readonly [AsyncReadDecisionTypeId] = AsyncReadDecisionTypeId +} + +export const AsyncReadDecision = Schema.Union([ + SuspendImmediately, + AwaitNextResult, + KeepAwaiting, + ResolveWithResult, +]) +export type AsyncReadDecision = typeof AsyncReadDecision.Type + +export const AsyncReadStage = Schema.Literals(['immediate', 'await-start', 'await-event']) +export type AsyncReadStage = Schema.Schema.Type + +export const AsyncResultPhase = Schema.Literals(['initial', 'success', 'failure']) +export type AsyncResultPhase = Schema.Schema.Type + +export class AsyncRead extends Schema.TaggedClass()('AsyncRead', { + stage: AsyncReadStage, + phase: AsyncResultPhase, + waiting: Schema.Boolean, + suspendOnWaiting: Schema.Boolean, +}) { + static readonly [Workflow.InstrumentationBrand] = {} as const +} + +const waitingSuspends = (command: AsyncRead): boolean => ![command.suspendOnWaiting, command.waiting].includes(false) + +const suspends = (command: AsyncRead): boolean => [command.phase === 'initial', waitingSuspends(command)].includes(true) + +const suspendOrResolve = ( + command: AsyncRead, + suspended: AsyncReadDecision, +): AsyncReadDecision => + Match.value(suspends(command)).pipe( + Match.when(true, () => suspended), + Match.when(false, () => ResolveWithResult.make({})), + Match.exhaustive, + ) + +const stageVerdict = (command: AsyncRead): AsyncReadDecision => + Match.value(command.stage).pipe( + Match.when('immediate', () => suspendOrResolve(command, SuspendImmediately.make({}))), + Match.when('await-start', () => suspendOrResolve(command, AwaitNextResult.make({}))), + Match.when('await-event', () => suspendOrResolve(command, KeepAwaiting.make({}))), + Match.exhaustive, + ) + +export const judgeAsyncRead = Workflow.make({ + command: AsyncRead, + decision: AsyncReadDecision, + error: Schema.Never, + decide: (command): Result.Result => Result.succeed(stageVerdict(command)), +}) diff --git a/packages/atom/effect-atom/src/judge-idle-remaining.workflow.ts b/packages/atom/effect-atom/src/judge-idle-remaining.workflow.ts new file mode 100644 index 0000000..25625d5 --- /dev/null +++ b/packages/atom/effect-atom/src/judge-idle-remaining.workflow.ts @@ -0,0 +1,54 @@ +import { Workflow } from '@systemfsoftware/effect-cell-types' +import { Match, Schema } from 'effect' +import * as Result from 'effect/Result' + +const IdleRemainingDecisionTypeId: unique symbol = Symbol.for( + '@systemfsoftware/effect-atom/IdleRemainingDecision', +) +type IdleRemainingDecisionTypeId = typeof IdleRemainingDecisionTypeId + +export class EvictDue extends Schema.TaggedClass()('EvictDue', {}) { + readonly [IdleRemainingDecisionTypeId] = IdleRemainingDecisionTypeId +} + +export class StillIdle extends Schema.TaggedClass()('StillIdle', { + millis: Schema.Finite, +}) { + readonly [IdleRemainingDecisionTypeId] = IdleRemainingDecisionTypeId +} + +export const IdleRemainingDecision = Schema.Union([EvictDue, StillIdle]) +export type IdleRemainingDecision = typeof IdleRemainingDecision.Type + +export class IdleRemaining extends Schema.TaggedClass()('IdleRemaining', { + nodeIdleTtl: Schema.Finite, + elapsedTtl: Schema.Finite, + swept: Schema.Boolean, +}) { + static readonly [Workflow.InstrumentationBrand] = {} as const +} + +const remainingAfterSweep = (command: IdleRemaining): number => command.nodeIdleTtl - command.elapsedTtl + +const remaining = (command: IdleRemaining): number => + Match.value(command.swept).pipe( + Match.when(true, () => remainingAfterSweep(command)), + Match.when(false, () => command.nodeIdleTtl), + Match.exhaustive, + ) + +const expired = (command: IdleRemaining): boolean => remaining(command) <= 0 + +export const judgeIdleRemaining = Workflow.make({ + command: IdleRemaining, + decision: IdleRemainingDecision, + error: Schema.Never, + decide: (command): Result.Result => + Result.succeed( + Match.value(expired(command)).pipe( + Match.when(true, () => EvictDue.make({})), + Match.when(false, () => StillIdle.make({ millis: remaining(command) })), + Match.exhaustive, + ), + ), +}) diff --git a/packages/atom/effect-atom/src/judge-idle-ttl.workflow.ts b/packages/atom/effect-atom/src/judge-idle-ttl.workflow.ts new file mode 100644 index 0000000..a6fc90f --- /dev/null +++ b/packages/atom/effect-atom/src/judge-idle-ttl.workflow.ts @@ -0,0 +1,49 @@ +import { Workflow } from '@systemfsoftware/effect-cell-types' +import { Match, Schema } from 'effect' +import * as Result from 'effect/Result' + +const IdleTtlDecisionTypeId: unique symbol = Symbol.for( + '@systemfsoftware/effect-atom/IdleTtlDecision', +) +type IdleTtlDecisionTypeId = typeof IdleTtlDecisionTypeId + +export class SweepsAfterIdle extends Schema.TaggedClass()('SweepsAfterIdle', {}) { + readonly [IdleTtlDecisionTypeId] = IdleTtlDecisionTypeId +} + +export class NoIdleSweep extends Schema.TaggedClass()('NoIdleSweep', {}) { + readonly [IdleTtlDecisionTypeId] = IdleTtlDecisionTypeId +} + +export const IdleTtlDecision = Schema.Union([SweepsAfterIdle, NoIdleSweep]) +export type IdleTtlDecision = typeof IdleTtlDecision.Type + +export class JudgeIdleTtl extends Schema.TaggedClass()('JudgeIdleTtl', { + keepAlive: Schema.Boolean, + idleTtlSet: Schema.Boolean, + idleTtlZero: Schema.Boolean, + defaultIdleTtlSet: Schema.Boolean, +}) { + static readonly [Workflow.InstrumentationBrand] = {} as const +} + +const sweeps = (command: JudgeIdleTtl): boolean => + Match.value([command.keepAlive, command.idleTtlZero].includes(true)).pipe( + Match.when(true, () => false), + Match.when(false, () => [command.idleTtlSet, command.defaultIdleTtlSet].includes(true)), + Match.exhaustive, + ) + +export const judgeIdleTtl = Workflow.make({ + command: JudgeIdleTtl, + decision: IdleTtlDecision, + error: Schema.Never, + decide: (command): Result.Result => + Result.succeed( + Match.value(sweeps(command)).pipe( + Match.when(true, () => SweepsAfterIdle.make({})), + Match.when(false, () => NoIdleSweep.make({})), + Match.exhaustive, + ), + ), +}) diff --git a/packages/atom/effect-atom/src/judge-invalidation.workflow.ts b/packages/atom/effect-atom/src/judge-invalidation.workflow.ts new file mode 100644 index 0000000..fbaa11a --- /dev/null +++ b/packages/atom/effect-atom/src/judge-invalidation.workflow.ts @@ -0,0 +1,63 @@ +import { Workflow } from '@systemfsoftware/effect-cell-types' +import { Match, Schema } from 'effect' +import * as Result from 'effect/Result' +import { BatchPhaseName } from './batch-phase.schema.js' + +const InvalidationDecisionTypeId: unique symbol = Symbol.for( + '@systemfsoftware/effect-atom/InvalidationDecision', +) +type InvalidationDecisionTypeId = typeof InvalidationDecisionTypeId + +export class DeferToBatch extends Schema.TaggedClass()('DeferToBatch', {}) { + readonly [InvalidationDecisionTypeId] = InvalidationDecisionTypeId +} + +export class SkipLazyChildren extends Schema.TaggedClass()('SkipLazyChildren', {}) { + readonly [InvalidationDecisionTypeId] = InvalidationDecisionTypeId +} + +export class InvalidateValue extends Schema.TaggedClass()('InvalidateValue', {}) { + readonly [InvalidationDecisionTypeId] = InvalidationDecisionTypeId +} + +export const InvalidationDecision = Schema.Union([DeferToBatch, SkipLazyChildren, InvalidateValue]) +export type InvalidationDecision = typeof InvalidationDecision.Type + +export class JudgeInvalidation extends Schema.TaggedClass()('JudgeInvalidation', { + batchPhase: BatchPhaseName, + lazy: Schema.Boolean, + hasListeners: Schema.Boolean, + childrenActive: Schema.Boolean, +}) { + static readonly [Workflow.InstrumentationBrand] = {} as const +} + +const insideCollect = (command: JudgeInvalidation): boolean => command.batchPhase === 'collect' + +const skipsLazy = (command: JudgeInvalidation): boolean => + ![ + command.lazy, + command.hasListeners === false, + command.childrenActive === false, + ].includes(false) + +const outsideCollectVerdict = (command: JudgeInvalidation): InvalidationDecision => + Match.value(skipsLazy(command)).pipe( + Match.when(true, () => SkipLazyChildren.make({})), + Match.when(false, () => InvalidateValue.make({})), + Match.exhaustive, + ) + +export const judgeInvalidation = Workflow.make({ + command: JudgeInvalidation, + decision: InvalidationDecision, + error: Schema.Never, + decide: (command): Result.Result => + Result.succeed( + Match.value(insideCollect(command)).pipe( + Match.when(true, () => DeferToBatch.make({})), + Match.when(false, () => outsideCollectVerdict(command)), + Match.exhaustive, + ), + ), +}) diff --git a/packages/atom/effect-atom/src/judge-listener-notify.workflow.ts b/packages/atom/effect-atom/src/judge-listener-notify.workflow.ts new file mode 100644 index 0000000..f4776fe --- /dev/null +++ b/packages/atom/effect-atom/src/judge-listener-notify.workflow.ts @@ -0,0 +1,54 @@ +import { Workflow } from '@systemfsoftware/effect-cell-types' +import { Match, Schema } from 'effect' +import * as Result from 'effect/Result' +import { BatchPhaseName } from './batch-phase.schema.js' + +const ListenerNotifyDecisionTypeId: unique symbol = Symbol.for( + '@systemfsoftware/effect-atom/ListenerNotifyDecision', +) +type ListenerNotifyDecisionTypeId = typeof ListenerNotifyDecisionTypeId + +export class StaySilent extends Schema.TaggedClass()('StaySilent', {}) { + readonly [ListenerNotifyDecisionTypeId] = ListenerNotifyDecisionTypeId +} + +export class QueueForBatch extends Schema.TaggedClass()('QueueForBatch', {}) { + readonly [ListenerNotifyDecisionTypeId] = ListenerNotifyDecisionTypeId +} + +export class NotifyNow extends Schema.TaggedClass()('NotifyNow', {}) { + readonly [ListenerNotifyDecisionTypeId] = ListenerNotifyDecisionTypeId +} + +export const ListenerNotifyDecision = Schema.Union([StaySilent, QueueForBatch, NotifyNow]) +export type ListenerNotifyDecision = typeof ListenerNotifyDecision.Type + +export class ListenerNotify extends Schema.TaggedClass()('ListenerNotify', { + batchPhase: BatchPhaseName, + hasListeners: Schema.Boolean, +}) { + static readonly [Workflow.InstrumentationBrand] = {} as const +} + +const queuesForBatch = (command: ListenerNotify): boolean => command.batchPhase === 'collect' + +const audibleVerdict = (command: ListenerNotify): ListenerNotifyDecision => + Match.value(queuesForBatch(command)).pipe( + Match.when(true, () => QueueForBatch.make({})), + Match.when(false, () => NotifyNow.make({})), + Match.exhaustive, + ) + +export const judgeListenerNotify = Workflow.make({ + command: ListenerNotify, + decision: ListenerNotifyDecision, + error: Schema.Never, + decide: (command): Result.Result => + Result.succeed( + Match.value(command.hasListeners).pipe( + Match.when(true, () => audibleVerdict(command)), + Match.when(false, () => StaySilent.make({})), + Match.exhaustive, + ), + ), +}) diff --git a/packages/atom/effect-atom/src/judge-node-fate.workflow.ts b/packages/atom/effect-atom/src/judge-node-fate.workflow.ts new file mode 100644 index 0000000..ba1c9ec --- /dev/null +++ b/packages/atom/effect-atom/src/judge-node-fate.workflow.ts @@ -0,0 +1,50 @@ +import { Workflow } from '@systemfsoftware/effect-cell-types' +import { Match, Schema } from 'effect' +import * as Result from 'effect/Result' + +const NodeFateDecisionTypeId: unique symbol = Symbol.for( + '@systemfsoftware/effect-atom/NodeFateDecision', +) +type NodeFateDecisionTypeId = typeof NodeFateDecisionTypeId + +export class KeepNode extends Schema.TaggedClass()('KeepNode', {}) { + readonly [NodeFateDecisionTypeId] = NodeFateDecisionTypeId +} + +export class DropNode extends Schema.TaggedClass()('DropNode', {}) { + readonly [NodeFateDecisionTypeId] = NodeFateDecisionTypeId +} + +export const NodeFateDecision = Schema.Union([KeepNode, DropNode]) +export type NodeFateDecision = typeof NodeFateDecision.Type + +export class JudgeNodeFate extends Schema.TaggedClass()('JudgeNodeFate', { + keepAlive: Schema.Boolean, + hasListeners: Schema.Boolean, + hasChildren: Schema.Boolean, + isLive: Schema.Boolean, + isWaiting: Schema.Boolean, +}) { + static readonly [Workflow.InstrumentationBrand] = {} as const +} + +const pinned = (command: JudgeNodeFate): boolean => + [ + command.keepAlive, + command.hasListeners, + command.hasChildren, + command.isLive === false, + command.isWaiting, + ].includes(true) + +export const judgeNodeFate = Workflow.make({ + command: JudgeNodeFate, + decision: NodeFateDecision, + error: Schema.Never, + decide: (command): Result.Result => + Match.value(pinned(command)).pipe( + Match.when(true, () => Result.succeed(KeepNode.make({}))), + Match.when(false, () => Result.succeed(DropNode.make({}))), + Match.exhaustive, + ), +}) diff --git a/packages/atom/effect-atom/src/judge-node-read.workflow.ts b/packages/atom/effect-atom/src/judge-node-read.workflow.ts new file mode 100644 index 0000000..d38f3c8 --- /dev/null +++ b/packages/atom/effect-atom/src/judge-node-read.workflow.ts @@ -0,0 +1,55 @@ +import { Workflow } from '@systemfsoftware/effect-cell-types' +import { Match, Schema } from 'effect' +import * as Result from 'effect/Result' +import { NodePhase } from './node-phase.schema.js' + +const NodeReadDecisionTypeId: unique symbol = Symbol.for( + '@systemfsoftware/effect-atom/NodeReadDecision', +) +type NodeReadDecisionTypeId = typeof NodeReadDecisionTypeId + +export class SettleChecking extends Schema.TaggedClass()('SettleChecking', {}) { + readonly [NodeReadDecisionTypeId] = NodeReadDecisionTypeId +} + +export class RebuildWaiting extends Schema.TaggedClass()('RebuildWaiting', {}) { + readonly [NodeReadDecisionTypeId] = NodeReadDecisionTypeId +} + +export class KeepValue extends Schema.TaggedClass()('KeepValue', {}) { + readonly [NodeReadDecisionTypeId] = NodeReadDecisionTypeId +} + +export const NodeReadDecision = Schema.Union([SettleChecking, RebuildWaiting, KeepValue]) +export type NodeReadDecision = typeof NodeReadDecision.Type + +export class JudgeNodeRead extends Schema.TaggedClass()('JudgeNodeRead', { + phase: NodePhase, +}) { + static readonly [Workflow.InstrumentationBrand] = {} as const +} + +const stillChecking = (command: JudgeNodeRead): boolean => command.phase === 'checking' + +const stillWaiting = (command: JudgeNodeRead): boolean => ['uninitialized', 'stale'].includes(command.phase) + +const waitingVerdict = (command: JudgeNodeRead): NodeReadDecision => + Match.value(stillWaiting(command)).pipe( + Match.when(true, () => RebuildWaiting.make({})), + Match.when(false, () => KeepValue.make({})), + Match.exhaustive, + ) + +export const judgeNodeRead = Workflow.make({ + command: JudgeNodeRead, + decision: NodeReadDecision, + error: Schema.Never, + decide: (command): Result.Result => + Result.succeed( + Match.value(stillChecking(command)).pipe( + Match.when(true, () => SettleChecking.make({})), + Match.when(false, () => waitingVerdict(command)), + Match.exhaustive, + ), + ), +}) diff --git a/packages/atom/effect-atom/src/judge-propagation-sweep.workflow.ts b/packages/atom/effect-atom/src/judge-propagation-sweep.workflow.ts new file mode 100644 index 0000000..b569e06 --- /dev/null +++ b/packages/atom/effect-atom/src/judge-propagation-sweep.workflow.ts @@ -0,0 +1,58 @@ +import { Workflow } from '@systemfsoftware/effect-cell-types' +import { Match, Schema } from 'effect' +import * as Result from 'effect/Result' +import { NodePhase } from './node-phase.schema.js' + +const PropagationSweepDecisionTypeId: unique symbol = Symbol.for( + '@systemfsoftware/effect-atom/PropagationSweepDecision', +) +type PropagationSweepDecisionTypeId = typeof PropagationSweepDecisionTypeId + +export class ActOnMember extends Schema.TaggedClass()('ActOnMember', {}) { + readonly [PropagationSweepDecisionTypeId] = PropagationSweepDecisionTypeId +} + +export class SkipMember extends Schema.TaggedClass()('SkipMember', {}) { + readonly [PropagationSweepDecisionTypeId] = PropagationSweepDecisionTypeId +} + +export const PropagationSweepDecision = Schema.Union([ActOnMember, SkipMember]) +export type PropagationSweepDecision = typeof PropagationSweepDecision.Type + +export const SweepKind = Schema.Literals(['invalidated-child', 'relinked-child', 'rebuilt-parent']) +export type SweepKind = Schema.Schema.Type + +export class SweepMember extends Schema.TaggedClass()('SweepMember', { + phase: NodePhase, + sweep: SweepKind, +}) { + static readonly [Workflow.InstrumentationBrand] = {} as const +} + +const continuesIntoChild = (phase: NodePhase): boolean => ['uninitialized', 'stale'].includes(phase) + +const relinksChild = (phase: NodePhase): boolean => phase === 'stale' + +const rebuildsParent = (phase: NodePhase): boolean => phase !== 'valid' + +const acted = (command: SweepMember): boolean => + Match.value(command.sweep).pipe( + Match.when('invalidated-child', () => continuesIntoChild(command.phase)), + Match.when('relinked-child', () => relinksChild(command.phase)), + Match.when('rebuilt-parent', () => rebuildsParent(command.phase)), + Match.exhaustive, + ) + +export const judgePropagationSweep = Workflow.make({ + command: SweepMember, + decision: PropagationSweepDecision, + error: Schema.Never, + decide: (command): Result.Result => + Result.succeed( + Match.value(acted(command)).pipe( + Match.when(true, () => ActOnMember.make({})), + Match.when(false, () => SkipMember.make({})), + Match.exhaustive, + ), + ), +}) diff --git a/packages/atom/effect-atom/src/judge-value-presence.workflow.ts b/packages/atom/effect-atom/src/judge-value-presence.workflow.ts new file mode 100644 index 0000000..143cf41 --- /dev/null +++ b/packages/atom/effect-atom/src/judge-value-presence.workflow.ts @@ -0,0 +1,42 @@ +import { Workflow } from '@systemfsoftware/effect-cell-types' +import { Match, Schema } from 'effect' +import * as Result from 'effect/Result' +import { NodePhase } from './node-phase.schema.js' + +const ValuePresenceDecisionTypeId: unique symbol = Symbol.for( + '@systemfsoftware/effect-atom/ValuePresenceDecision', +) +type ValuePresenceDecisionTypeId = typeof ValuePresenceDecisionTypeId + +export class HoldsValue extends Schema.TaggedClass()('HoldsValue', {}) { + readonly [ValuePresenceDecisionTypeId] = ValuePresenceDecisionTypeId +} + +export class HoldsNothing extends Schema.TaggedClass()('HoldsNothing', {}) { + readonly [ValuePresenceDecisionTypeId] = ValuePresenceDecisionTypeId +} + +export const ValuePresenceDecision = Schema.Union([HoldsValue, HoldsNothing]) +export type ValuePresenceDecision = typeof ValuePresenceDecision.Type + +export class PresenceQuery extends Schema.TaggedClass()('PresenceQuery', { + phase: NodePhase, +}) { + static readonly [Workflow.InstrumentationBrand] = {} as const +} + +const holdsValue = (command: PresenceQuery): boolean => ['stale', 'checking', 'valid'].includes(command.phase) + +export const judgeValuePresence = Workflow.make({ + command: PresenceQuery, + decision: ValuePresenceDecision, + error: Schema.Never, + decide: (command): Result.Result => + Result.succeed( + Match.value(holdsValue(command)).pipe( + Match.when(true, () => HoldsValue.make({})), + Match.when(false, () => HoldsNothing.make({})), + Match.exhaustive, + ), + ), +}) diff --git a/packages/atom/effect-atom/src/mod.ts b/packages/atom/effect-atom/src/mod.ts new file mode 100644 index 0000000..1fea90d --- /dev/null +++ b/packages/atom/effect-atom/src/mod.ts @@ -0,0 +1 @@ +export * as Atom from './Atom/mod.js' diff --git a/packages/atom/effect-atom/src/node-phase.schema.ts b/packages/atom/effect-atom/src/node-phase.schema.ts new file mode 100644 index 0000000..963a85f --- /dev/null +++ b/packages/atom/effect-atom/src/node-phase.schema.ts @@ -0,0 +1,4 @@ +import { Schema as S } from 'effect' + +export const NodePhase = S.Literals(['uninitialized', 'stale', 'checking', 'valid', 'removed']) +export type NodePhase = S.Schema.Type diff --git a/packages/atom/effect-atom/src/record-build-invalidation.workflow.ts b/packages/atom/effect-atom/src/record-build-invalidation.workflow.ts new file mode 100644 index 0000000..5b6a0d4 --- /dev/null +++ b/packages/atom/effect-atom/src/record-build-invalidation.workflow.ts @@ -0,0 +1,44 @@ +import { Workflow } from '@systemfsoftware/effect-cell-types' +import { Match, Schema } from 'effect' +import * as Result from 'effect/Result' +import { BatchPhaseName } from './batch-phase.schema.js' + +const BuildInvalidationDecisionTypeId: unique symbol = Symbol.for( + '@systemfsoftware/effect-atom/BuildInvalidationDecision', +) +type BuildInvalidationDecisionTypeId = typeof BuildInvalidationDecisionTypeId + +export class RecordDuringBuild extends Schema.TaggedClass()('RecordDuringBuild', {}) { + readonly [BuildInvalidationDecisionTypeId] = BuildInvalidationDecisionTypeId +} + +export class SkipRecording extends Schema.TaggedClass()('SkipRecording', {}) { + readonly [BuildInvalidationDecisionTypeId] = BuildInvalidationDecisionTypeId +} + +export const BuildInvalidationDecision = Schema.Union([RecordDuringBuild, SkipRecording]) +export type BuildInvalidationDecision = typeof BuildInvalidationDecision.Type + +export class BuildInvalidation extends Schema.TaggedClass()('BuildInvalidation', { + building: Schema.Boolean, + batchPhase: BatchPhaseName, +}) { + static readonly [Workflow.InstrumentationBrand] = {} as const +} + +const records = (command: BuildInvalidation): boolean => + ![command.building, command.batchPhase === 'collect'].includes(false) + +export const recordBuildInvalidation = Workflow.make({ + command: BuildInvalidation, + decision: BuildInvalidationDecision, + error: Schema.Never, + decide: (command): Result.Result => + Result.succeed( + Match.value(records(command)).pipe( + Match.when(true, () => RecordDuringBuild.make({})), + Match.when(false, () => SkipRecording.make({})), + Match.exhaustive, + ), + ), +}) diff --git a/packages/atom/effect-atom/src/registry-engine.ts b/packages/atom/effect-atom/src/registry-engine.ts new file mode 100644 index 0000000..13d61c7 --- /dev/null +++ b/packages/atom/effect-atom/src/registry-engine.ts @@ -0,0 +1,780 @@ +import * as Cause from 'effect/Cause' +import * as Context from 'effect/Context' +import * as Exit from 'effect/Exit' +import { absurd, constVoid, type LazyArg } from 'effect/Function' +import * as Match from 'effect/Match' +import * as Option from 'effect/Option' +import * as Pipeable from 'effect/Pipeable' +import * as Result from 'effect/Result' +import { MixedScheduler, type Scheduler, type SchedulerDispatcher } from 'effect/Scheduler' +import * as Schema from 'effect/Schema' +import { batchRunner, type BatchState, makeBatchState, NodeImpl } from './atom-node.js' +import type * as Atom from './atom.blueprint.js' +import { hostPorts } from './internal/host-timer.js' +import { IdleRemaining, type IdleRemainingDecision, judgeIdleRemaining } from './judge-idle-remaining.workflow.js' +import { type IdleTtlDecision, JudgeIdleTtl, judgeIdleTtl } from './judge-idle-ttl.workflow.js' +import type { Node, PreloadRefused, Registry } from './registry.handle.js' + +type AnyValue = A + +class Refusals extends Context.Service }>()( + '@systemfsoftware/effect-atom/registry-engine/Refusals', +) {} +// ----------------------------------------------------------------------------- +// internal +// ----------------------------------------------------------------------------- + +const constImmediate = { immediate: true } + +function atomKey(atom: Atom.Atom): Atom.Atom | string { + const serializable = atom.spec.serializable + if (serializable === undefined) { + return atom + } + return serializable.key +} + +/** + * Nodes are stored in one heterogeneous map keyed by `atomKey`. A node found + * under an atom's key is that atom's own node, so key equality re-establishes + * the erased `A` type across the map boundary. + */ +const isNodeImplFor = (atom: Atom.Atom, node: NodeImpl): node is NodeImpl => + atomKey(node.atom) === atomKey(atom) + +type TimeoutBucket = readonly [nodes: Set, cancel: () => void] + +const configuredOr = (configured: F | undefined, host: F): F => configured ?? host + +function timeoutResolutionFromIdleTTL(defaultIdleTTL: number | undefined): number { + if (defaultIdleTTL === undefined) { + return 1000 + } + return Math.round(defaultIdleTTL / 2) +} + +function resolveTimeoutResolution( + timeoutResolution: number | undefined, + defaultIdleTTL: number | undefined, +): number { + if (timeoutResolution === undefined) { + return timeoutResolutionFromIdleTTL(defaultIdleTTL) + } + return timeoutResolution +} + +function applyInitialValues( + registry: RegistryImpl, + initialValues: Iterable | undefined, +): void { + if (initialValues === undefined) { + return + } + setEachInitialValue(registry, initialValues) +} + +function setEachInitialValue( + registry: RegistryImpl, + initialValues: Iterable, +): void { + for (const [atom, value] of initialValues) { + registry.setInitialValue(atom, value) + } +} + +function resolveInitialValueTarget(atom: Atom.Atom): Atom.Atom { + const target = atom.spec.initialValueTarget + if (target === undefined) { + return atom + } + return resolveInitialValueTarget(target) +} + +function valueOptionIfForAtom(atom: Atom.Atom, node: NodeImpl): Option.Option { + if (isNodeImplFor(atom, node) === false) { + return Option.none() + } + return node.valueOption() +} + +const isDecodingCodec = (u: unknown): u is Schema.ConstraintDecoder => Schema.isSchema(u) + +const decodingCodecOf = (serializer: Atom.SerializableSpec): Schema.ConstraintDecoder | undefined => { + const codec = serializer.codecJson + if (isDecodingCodec(codec) === false) { + return undefined + } + return codec +} + +function applyDecodedSerializable( + registry: RegistryImpl, + node: NodeImpl, + serializer: Atom.SerializableSpec, + encoded: AnyValue, +): void { + const codec = decodingCodecOf(serializer) + if (codec === undefined) { + return + } + applyDecodedExit(registry, node, serializer.key, Schema.decodeUnknownExit(codec)(encoded)) +} + +function applyDecodedExit( + registry: RegistryImpl, + node: NodeImpl, + key: string, + exit: Exit.Exit, +): void { + if (Exit.isFailure(exit)) { + recordRefusalOn(registry, key, exit.cause) + return + } + assignDecodedSerializable(registry, node, exit.value) +} + +function recordRefusalOn(registry: RegistryImpl, key: string, cause: Cause.Cause): void { + registry.refusals().entries.push({ key, issue: formatSchemaError(cause) }) +} + +function formatSchemaError(cause: Cause.Cause): string { + const found = Cause.findErrorOption(cause) + if (Option.isNone(found)) { + return 'undecodable value' + } + return found.value.message +} + +function assignDecodedSerializable( + registry: RegistryImpl, + node: NodeImpl, + decoded: AnyValue, +): void { + const target = resolveInitialValueTarget(node.atom) + if (target === node.atom) { + node.setValue(decoded) + return + } + registry.ensureNode(target).setInitialValue(decoded) +} + +function applySerializableValue( + registry: RegistryImpl, + node: NodeImpl, + encoded: AnyValue, +): void { + const serializer = node.atom.spec.serializable + if (serializer === undefined) { + return + } + applyDecodedSerializable(registry, node, serializer, encoded) +} + +function notifyIfImmediate( + current: A, + f: (_: A) => void, + options: { readonly immediate?: boolean } | undefined, +): void { + if (options === undefined) { + return + } + notifyIfImmediateFlag(current, f, options.immediate) +} + +function notifyIfImmediateFlag(current: A, f: (_: A) => void, immediate: boolean | undefined): void { + if (immediate === true) { + f(current) + } +} + +function hearIfChanged(node: NodeImpl, lastSeen: { value: A }, f: (_: A) => void): void { + if (node.atom.equals(lastSeen.value, node._value)) { + return + } + lastSeen.value = node._value + f(node._value) +} + +type Bit = 0 | 1 + +const bitOf = (value: boolean): Bit => (value ? 1 : 0) + +const bitTable = (build: (value: boolean) => T): Record => ({ + 0: build(false), + 1: build(true), +}) + +const decided = (result: Result.Result): Decision => + Result.match(result, { + onFailure: (error) => absurd(error), + onSuccess: (decision) => decision, + }) + +const idleTtlVerdict = ( + keepAlive: boolean, + idleTtlSet: boolean, + idleTtlZero: boolean, + defaultIdleTtlSet: boolean, +): IdleTtlDecision => + decided(judgeIdleTtl(JudgeIdleTtl.make({ + keepAlive, + idleTtlSet, + idleTtlZero, + defaultIdleTtlSet, + }))) + +const idleTtlSweeps = ( + keepAlive: boolean, + idleTtlSet: boolean, + idleTtlZero: boolean, + defaultIdleTtlSet: boolean, +): boolean => + Match.value(idleTtlVerdict(keepAlive, idleTtlSet, idleTtlZero, defaultIdleTtlSet)).pipe( + Match.tags({ SweepsAfterIdle: () => true, NoIdleSweep: () => false }), + Match.exhaustive, + ) + +const IDLE_TTL_SWEEPS: Record>>> = bitTable((keepAlive) => + bitTable((idleTtlSet) => + bitTable((idleTtlZero) => + bitTable((defaultIdleTtlSet) => idleTtlSweeps(keepAlive, idleTtlSet, idleTtlZero, defaultIdleTtlSet)) + ) + ) +) + +function notifyNodeAdded(registry: RegistryImpl, node: NodeImpl): void { + if (registry.onNodeAdded === undefined) { + return + } + registry.onNodeAdded(node) +} + +function notifyNodeRemoved(registry: RegistryImpl, node: NodeImpl): void { + if (registry.onNodeRemoved === undefined) { + return + } + registry.onNodeRemoved(node) +} + +function applySeededValue(registry: RegistryImpl, node: NodeImpl, key: Atom.Atom | string): void { + if (registry.seededValues.has(key) === false) { + return + } + const value = registry.seededValues.get(key) + registry.seededValues.delete(key) + node.setInitialValue(value) +} + +function createAndStoreNode( + registry: RegistryImpl, + atom: Atom.Atom, + key: Atom.Atom | string, +): NodeImpl { + const node = registry.createNode(atom) + registry.nodes.set(key, node) + applySeededValue(registry, node, key) + notifyNodeAdded(registry, node) + return node +} + +function existingOrCreateNode( + registry: RegistryImpl, + atom: Atom.Atom, + key: Atom.Atom | string, + existing: NodeImpl, +): NodeImpl { + if (isNodeImplFor(atom, existing)) { + return reuseExistingNode(registry, atom, existing) + } + return createAndStoreNode(registry, atom, key) +} + +function reuseExistingNode( + registry: RegistryImpl, + atom: Atom.Atom, + existing: NodeImpl, +): NodeImpl { + if (registry.atomHasTtl(atom)) { + registry.removeNodeTimeout(existing) + } + return existing +} + +function nodeForKey( + registry: RegistryImpl, + atom: Atom.Atom, + key: Atom.Atom | string, +): NodeImpl { + const existing = registry.nodes.get(key) + if (existing === undefined) { + return createAndStoreNode(registry, atom, key) + } + return existingOrCreateNode(registry, atom, key, existing) +} + +function applyPreloadedIfStringKey( + registry: RegistryImpl, + node: NodeImpl, + key: Atom.Atom | string, +): void { + if (typeof key !== 'string') { + return + } + applyPreloadedSerializable(registry, node, key) +} + +function holdsStateFor(registry: RegistryImpl, key: Atom.Atom | string): boolean { + return registry.nodes.has(key) || hasPendingPreload(registry, key) +} + +function hasPendingPreload(registry: RegistryImpl, key: Atom.Atom | string): boolean { + return typeof key === 'string' && registry.preloadedSerializable.has(key) +} + +function applyPreloadedSerializable( + registry: RegistryImpl, + node: NodeImpl, + key: string, +): void { + if (registry.preloadedSerializable.has(key) === false) { + return + } + const encoded = registry.preloadedSerializable.get(key) + registry.preloadedSerializable.delete(key) + applySerializableValue(registry, node, encoded) +} + +function atomLabel(atom: Atom.Atom): string { + const label = atom.spec.label + if (label === undefined) { + return 'unknown' + } + return label[0] +} + +function throwIfDisposed(registry: RegistryImpl, atom: Atom.Atom): void { + if (registry.disposed) { + throw new Error(`Cannot access Atom ${atomLabel(atom)}: registry is disposed`) + } +} + +function scheduleRemovalUnlessPinned(registry: RegistryImpl, node: NodeImpl): void { + if (node.canBeRemoved) { + registry.scheduleAtomRemoval(node.atom) + } +} + +function removeNodeIfCanBeRemoved(registry: RegistryImpl, node: NodeImpl): void { + if (node.canBeRemoved) { + registry.removeNode(node) + } +} + +function removeAtomIfIdle(registry: RegistryImpl, atom: Atom.Atom): void { + const node = registry.nodes.get(atomKey(atom)) + if (node === undefined) { + return + } + removeNodeIfCanBeRemoved(registry, node) +} + +function evictNode(registry: RegistryImpl, node: NodeImpl): void { + clearNodeTimeout(registry, node) + registry.nodes.delete(atomKey(node.atom)) + node.remove() + notifyNodeRemoved(registry, node) +} + +function clearNodeTimeout(registry: RegistryImpl, node: NodeImpl): void { + const bucket = registry.nodeTimeoutBucket.get(node) + if (bucket === undefined) { + return + } + registry.nodeTimeoutBucket.delete(node) + dropNodeFromTimeoutBucket(registry, node, bucket) +} + +function evictNodeIfIdle(registry: RegistryImpl, node: NodeImpl): void { + if (node.canBeRemoved) { + evictNode(registry, node) + } +} + +function idleTtlOf(atom: Atom.Atom, defaultIdleTTL: number | undefined): number | undefined { + if (atom.spec.idleTTL === undefined) { + return defaultIdleTTL + } + return atom.spec.idleTTL +} + +const idleRemainingVerdict = ( + nodeIdleTTL: number, + currentSweepTTL: number | null, +): IdleRemainingDecision => + decided(judgeIdleRemaining(IdleRemaining.make({ + nodeIdleTtl: nodeIdleTTL, + elapsedTtl: currentSweepTTL ?? 0, + swept: currentSweepTTL !== null, + }))) + +function scheduleIdleSweep( + registry: RegistryImpl, + node: NodeImpl, + nodeIdleTTL: number, + currentSweepTTL: number | null, +): void { + Match.value(idleRemainingVerdict(nodeIdleTTL, currentSweepTTL)).pipe( + Match.tags({ + EvictDue: () => evictNodeIfIdle(registry, node), + StillIdle: (verdict) => addNodeToTimeoutBucket(registry, node, verdict.millis), + }), + Match.exhaustive, + ) +} + +function timeoutBucketFor(registry: RegistryImpl, idleTTL: number): number { + const ttl = Math.ceil(idleTTL / registry.timeoutResolution) * registry.timeoutResolution + const timestamp = registry.now() + ttl + return timestamp - (timestamp % registry.timeoutResolution) + registry.timeoutResolution +} + +function createTimeoutBucket(registry: RegistryImpl, bucket: number): TimeoutBucket { + const nodes = new Set() + const cancel = registry.scheduleTimer(() => registry.sweepBucket(bucket), bucket - registry.now()) + const entry: TimeoutBucket = [nodes, cancel] + registry.timeoutBuckets.set(bucket, entry) + return entry +} + +function ensureTimeoutBucket(registry: RegistryImpl, bucket: number): TimeoutBucket { + const existing = registry.timeoutBuckets.get(bucket) + if (existing !== undefined) { + return existing + } + return createTimeoutBucket(registry, bucket) +} + +function addNodeToTimeoutBucket(registry: RegistryImpl, node: NodeImpl, idleTTL: number): void { + const bucket = timeoutBucketFor(registry, idleTTL) + const entry = ensureTimeoutBucket(registry, bucket) + entry[0].add(node) + registry.nodeTimeoutBucket.set(node, bucket) +} + +function dropNodeFromTimeoutBucket( + registry: RegistryImpl, + node: NodeImpl, + bucket: number, +): void { + const entry = registry.timeoutBuckets.get(bucket) + if (entry === undefined) { + return + } + removeNodeFromBucketEntry(registry, node, bucket, entry) +} + +function removeNodeFromBucketEntry( + registry: RegistryImpl, + node: NodeImpl, + bucket: number, + entry: TimeoutBucket, +): void { + const [nodes, cancel] = entry + nodes.delete(node) + if (nodes.size === 0) { + cancel() + registry.timeoutBuckets.delete(bucket) + } +} + +/** + * The registry engine: one instance stores atom nodes, coordinates reads, + * writes, refreshes, subscriptions, and disposal for one registry handle. + */ +export class RegistryImpl extends Pipeable.Class { + readonly timeoutResolution: number + readonly defaultIdleTTL: number | undefined + readonly scheduler: Scheduler + readonly schedulerAsync: Scheduler + readonly dispatcher: SchedulerDispatcher + readonly now: () => number + readonly scheduleTimer: (f: () => void, delayMillis: number) => () => void + readonly handle: Registry + readonly batch: BatchState + onNodeAdded?: ((node: Node) => void) | undefined + onNodeRemoved?: ((node: Node) => void) | undefined + + constructor( + mint: (engine: RegistryImpl) => Registry, + initialValues?: Iterable, + scheduleTask?: (cb: () => void) => () => void, + timeoutResolution?: number, + defaultIdleTTL?: number, + now?: () => number, + scheduleTimer?: (f: () => void, delayMillis: number) => () => void, + ) { + super() + this.handle = mint(this) + this.batch = makeBatchState() + const host = hostPorts() + const scheduled = configuredOr(scheduleTask, host.scheduleTask) + this.scheduler = new MixedScheduler('sync', scheduled) + this.schedulerAsync = new MixedScheduler('async', scheduled) + this.dispatcher = this.schedulerAsync.makeDispatcher() + this.defaultIdleTTL = defaultIdleTTL + this.now = configuredOr(now, host.now) + this.scheduleTimer = configuredOr(scheduleTimer, host.scheduleTimer) + this.timeoutResolution = resolveTimeoutResolution(timeoutResolution, defaultIdleTTL) + applyInitialValues(this, initialValues) + } + + setInitialValue(atom: Atom.Atom, value: A): void { + const target = resolveInitialValueTarget(atom) + const key = atomKey(target) + if (this.nodes.has(key)) { + this.ensureNode(target).setInitialValue(value) + return + } + this.seededValues.set(key, value) + } + + readonly nodes = new Map() + readonly preloadedSerializable = new Map() + readonly seededValues = new Map() + readonly timeoutBuckets = new Map() + readonly nodeTimeoutBucket = new Map() + disposed = false + storage: Context.Context = Context.empty() + + storageFor(key: Context.Key, make: LazyArg): A { + const found = Context.getOption(this.storage, key) + if (Option.isSome(found)) { + return found.value + } + const created = make() + this.storage = Context.add(this.storage, key, created) + return created + } + + refusals(): { readonly entries: Array } { + return this.storageFor(Refusals, () => ({ entries: [] })) + } + + getNodes() { + return this.nodes + } + + get(atom: Atom.Atom): A { + return this.ensureNode(atom).value() + } + + getRaw(atom: Atom.Atom): Option.Option { + const node = this.nodes.get(atomKey(atom)) + if (node === undefined) { + return Option.none() + } + return valueOptionIfForAtom(atom, node) + } + + set(atom: Atom.Writable, value: W): void { + atom.write(this.ensureNode(atom).writeContext, value) + } + batchOn(f: () => void): void { + batchRunner.startBatch(this.batch) + try { + f() + batchRunner.commitBatchIfOutermost(this.batch) + } finally { + batchRunner.finishBatch(this.batch) + } + } + setSerializable(key: string, encoded: T): void { + const node = this.nodes.get(key) + if (node === undefined) { + this.preloadedSerializable.set(key, encoded) + return + } + applySerializableValue(this, node, encoded) + } + + modify(atom: Atom.Writable, f: (_: R) => [returnValue: A, nextValue: W]): A { + const node = this.ensureNode(atom) + const result = f(node.value()) + atom.write(node.writeContext, result[1]) + return result[0] + } + + update(atom: Atom.Writable, f: (_: R) => W): void { + const node = this.ensureNode(atom) + atom.write(node.writeContext, f(node.value())) + } + + refresh = (atom: Atom.Atom): void => { + const custom = atom.spec.refresh + if (custom === undefined) { + this.invalidateAtom(atom) + return + } + custom(this.refresh) + } + + subscribe(atom: Atom.Atom, f: (_: A) => void, options?: { readonly immediate?: boolean }): () => void { + const node = this.ensureNode(atom) + const lastSeen = { value: node.value() } + notifyIfImmediate(lastSeen.value, f, options) + const remove = node.subscribe(function() { + hearIfChanged(node, lastSeen, f) + }) + return () => { + remove() + if (node.canBeRemoved) { + this.scheduleNodeRemoval(node) + } + } + } + + mount(atom: Atom.Atom) { + return this.subscribe(atom, constVoid, constImmediate) + } + + atomHasTtl(atom: Atom.Atom): boolean { + return IDLE_TTL_SWEEPS[bitOf(atom.spec.keepAlive)][bitOf(atom.spec.idleTTL !== undefined)][ + bitOf( + atom.spec.idleTTL === 0, + ) + ][bitOf(this.defaultIdleTTL !== undefined)] + } + + ensureNode(atom: Atom.Atom): NodeImpl { + const key = atomKey(atom) + const node = nodeForKey(this, atom, key) + applyPreloadedIfStringKey(this, node, key) + return node + } + + createNode(atom: Atom.Atom): NodeImpl { + throwIfDisposed(this, atom) + const node = new NodeImpl(this, atom) + scheduleRemovalUnlessPinned(this, node) + return node + } + + invalidateAtom = (atom: Atom.Atom): void => { + throwIfDisposed(this, atom) + if (holdsStateFor(this, atomKey(atom))) { + this.ensureNode(atom).invalidate() + } + } + + scheduleAtomRemoval(atom: Atom.Atom): void { + this.dispatcher.scheduleTask(() => { + removeAtomIfIdle(this, atom) + }, 0) + } + + scheduleNodeRemoval(node: NodeImpl): void { + this.dispatcher.scheduleTask(() => { + removeNodeIfCanBeRemoved(this, node) + }, 0) + } + + removeNode(node: NodeImpl): void { + if (this.atomHasTtl(node.atom)) { + this.setNodeTimeout(node) + return + } + evictNode(this, node) + } + + setNodeTimeout(node: NodeImpl): void { + if (this.nodeTimeoutBucket.has(node)) { + return + } + this.setNodeTimeoutIfIdle(node) + } + + private setNodeTimeoutIfIdle(node: NodeImpl): void { + const nodeIdleTTL = idleTtlOf(node.atom, this.defaultIdleTTL) + if (nodeIdleTTL === undefined) { + return + } + this.scheduleOrEvictIdleNode(node, nodeIdleTTL) + } + + private scheduleOrEvictIdleNode(node: NodeImpl, nodeIdleTTL: number): void { + scheduleIdleSweep(this, node, nodeIdleTTL, this.#currentSweepTTL) + } + + removeNodeTimeout(node: NodeImpl): void { + if (this.nodeTimeoutBucket.has(node) === false) { + return + } + clearNodeTimeout(this, node) + this.scheduleNodeRemoval(node) + } + + #currentSweepTTL: number | null = null + sweepBucket(bucket: number): void { + const entry = this.timeoutBuckets.get(bucket) + if (entry === undefined) { + return + } + this.sweepBucketEntry(bucket, entry) + } + + private sweepBucketEntry(bucket: number, entry: TimeoutBucket): void { + this.timeoutBuckets.delete(bucket) + entry[0].forEach((node) => { + this.sweepIdleNode(node) + }) + } + + private sweepIdleNode(node: NodeImpl): void { + this.nodeTimeoutBucket.delete(node) + this.sweepIdleNodeIfRemovable(node) + } + + private sweepIdleNodeIfRemovable(node: NodeImpl): void { + if (node.canBeRemoved === false) { + return + } + this.sweepRemoveNode(node) + } + + private sweepRemoveNode(node: NodeImpl): void { + this.nodes.delete(atomKey(node.atom)) + notifyNodeRemoved(this, node) + this.removeNodeWithSweepTtl(node) + } + + private removeNodeWithSweepTtl(node: NodeImpl): void { + this.assignSweepTtl(idleTtlOf(node.atom, this.defaultIdleTTL)) + node.remove() + this.#currentSweepTTL = null + } + + private assignSweepTtl(idleTTL: number | undefined): void { + if (idleTTL === undefined) { + return + } + this.#currentSweepTTL = idleTTL + } + + reset(): void { + this.timeoutBuckets.forEach(([, cancel]) => cancel()) + this.timeoutBuckets.clear() + this.nodeTimeoutBucket.clear() + + this.nodes.forEach((node) => { + node.remove() + notifyNodeRemoved(this, node) + }) + this.nodes.clear() + } + + dispose(): void { + this.disposed = true + this.storage = Context.empty() + this.reset() + } +} diff --git a/packages/atom/effect-atom/src/registry.handle.ts b/packages/atom/effect-atom/src/registry.handle.ts new file mode 100644 index 0000000..82c9e5e --- /dev/null +++ b/packages/atom/effect-atom/src/registry.handle.ts @@ -0,0 +1,973 @@ +/** + * Stores and runs atoms for one reactive runtime. + * + * A registry evaluates atoms, caches their current values, tracks + * dependencies, applies writes and refreshes, manages subscriptions, and + * disposes unused nodes. Each registry is independent, so the same atom can hold + * different values in different registries. Serializable atom values can also be + * preloaded before the first read. + * + * @since 4.0.0 + */ +import { Handle } from '@systemfsoftware/effect-cell-types' +import * as Cause from 'effect/Cause' +import * as Context from 'effect/Context' +import * as Deferred from 'effect/Deferred' +import * as Effect from 'effect/Effect' +import * as Exit from 'effect/Exit' +import * as Fiber from 'effect/Fiber' +import { constVoid, dual, type LazyArg } from 'effect/Function' +import * as Layer from 'effect/Layer' +import * as Option from 'effect/Option' +import { hasProperty } from 'effect/Predicate' +import * as Queue from 'effect/Queue' +import * as Schema from 'effect/Schema' +import * as Scope from 'effect/Scope' +import * as Stream from 'effect/Stream' +import * as Result from './async-result.js' +import type { Failure, Success } from './async-result.js' +import type * as Atom from './atom.blueprint.js' +import { + decodeHydrationEntry, + type DehydratedAtomValue, + encodeHydrationValue, + encodeInitialMode, + encodingCodecOf, + type HydrationEntry, + isInitialResult, + isSettledResult, + shouldAttachDeferred, + shouldSkipInitial, +} from './hydration.schema.js' +import { RegistryImpl } from './registry-engine.js' + +export { Current } from './current-registry.service.js' +export type { DehydratedAtom, DehydratedAtomValue, HydrationEntry } from './hydration.schema.js' + +type AnyValue = A + +/** + * The identity every registry handle carries. + * + * @since 4.0.0 + */ +export const TypeId: unique symbol = Symbol.for('~effect-atom/atom/Registry') + +/** + * @since 4.0.0 + */ +export type TypeId = typeof TypeId + +const RegistryDef = Handle.make, RegistryImpl>()(TypeId) + +/** + * A handle to a running registry. + * + * **Details** + * + * The handle is a pipeable record minted by the registry kind; its data is + * empty and its private slot holds the engine. Every operation is a `dual` + * function in this module that forwards to the engine behind the handle. One + * engine stores atom nodes, coordinates reads, writes, refreshes, + * subscriptions, and disposal. + * + * @since 4.0.0 + */ +export type Registry = Handle.Handle, RegistryImpl> + +/** + * Returns `true` when the value is a registry handle. + * + * @since 4.0.0 + */ +export const isRegistry = RegistryDef.is + +const engineOf = (self: Registry): RegistryImpl => RegistryDef.slot(self) + +const mintRegistry = (engine: RegistryImpl): Registry => RegistryDef.make({}, engine) + +/** + * A registry node for a single atom. + * + * **Details** + * + * Nodes expose the current value, parent and child dependency links, listener set, + * and current lifecycle state. + * + * @since 4.0.0 + */ +export interface Node { + readonly atom: Atom.Atom + readonly value: () => A + /** + * Read-only views of the registry's dependency graph. The registry mutates + * these sets internally; consumers can inspect them but must not coordinate + * through them. + */ + readonly parents: ReadonlySet + readonly children: ReadonlySet + readonly listeners: ReadonlySet<() => void> + currentState(): 'uninitialized' | 'stale' | 'valid' | 'removed' +} + +type RegistryMakeOptions = { + readonly initialValues?: Iterable | undefined + readonly scheduleTask?: ((f: () => void) => () => void) | undefined + readonly timeoutResolution?: number | undefined + readonly defaultIdleTTL?: number | undefined + readonly now?: (() => number) | undefined + readonly scheduleTimer?: ((f: () => void, delayMillis: number) => () => void) | undefined +} + +/** + * Creates a registry handle backed by a fresh engine. + * + * **Details** + * + * Options can preload initial atom values, provide a custom task scheduler, + * configure timeout bucket resolution, and set a default idle time-to-live for + * unused atoms. + * + * @since 4.0.0 + */ +export const make = ( + options?: RegistryMakeOptions, +): Registry => { + if (options === undefined) { + return new RegistryImpl(mintRegistry).handle + } + return new RegistryImpl( + mintRegistry, + options.initialValues, + options.scheduleTask, + options.timeoutResolution, + options.defaultIdleTTL, + options.now, + options.scheduleTimer, + ).handle +} + +/** + * Service tag for the registry evaluating an effect. + * + * **When to use** + * + * Use to access or provide the registry that stores atom values, + * dependencies, subscriptions, and disposal state for a reactive lifetime. + * + * @since 4.0.0 + */ + +/** + * A recorded serialization refusal: a value that could not be encoded while + * dehydrating, or an external payload entry that could not be preloaded, with + * the atom key it named (when one was legible) and the schema issue that + * rejected it. + * + * @since 4.0.0 + */ +export interface PreloadRefused { + readonly key: string | undefined + readonly issue: string +} + +/** + * Records a serialization refusal on the registry. + * + * @since 4.0.0 + */ +export const recordRefusal: { + (refusal: PreloadRefused): (self: Registry) => void + (self: Registry, refusal: PreloadRefused): void +} = dual(2, (self: Registry, refusal: PreloadRefused): void => { + engineOf(self).refusals().entries.push(refusal) +}) + +/** + * Returns every serialization refusal recorded on the registry, oldest first. + * + * @since 4.0.0 + */ +export const refusals = (self: Registry): ReadonlyArray => { + const engine = engineOf(self) + return [...engine.refusals().entries] +} + +/** + * Creates a layer that provides a registry for the given service tag, + * configured with the supplied options. + * + * **Details** + * + * The registry is disposed when the layer scope is finalized. Distinct tags + * hold distinct registries at once; passing `Current` provides the registry + * atom evaluations read. + * + * @since 4.0.0 + */ +export const layer: { + (options?: RegistryMakeOptions): (tag: Context.Key) => Layer.Layer + (tag: Context.Key, options?: RegistryMakeOptions): Layer.Layer +} = dual( + (args) => hasProperty(args[0], Context.ServiceTypeId), + (tag: Context.Key, options?: RegistryMakeOptions): Layer.Layer => + Layer.effect( + tag, + Effect.gen(function*() { + const registry = make(options) + const scope = yield* Effect.scope + yield* Scope.addFinalizer(scope, Effect.sync(() => dispose(registry))) + return registry + }), + ), +) + +// ----------------------------------------------------------------------------- +// operations +// ----------------------------------------------------------------------------- + +/** + * The nodes currently held by a registry. + * + * @since 4.0.0 + */ +export const getNodes = (self: Registry): ReadonlyMap => engineOf(self).getNodes() + +/** + * The current time according to the registry's clock. + * + * **Details** + * + * The clock is resolved once when the registry is built — from the fiber that + * builds it, or the platform clock outside any fiber — so a registry on the + * simulation kernel dehydrates with the kernel's time, not the wall clock. + * + * @since 4.0.0 + */ +export const now = (self: Registry): number => engineOf(self).now() + +/** + * Reads the current value of an atom. + * + * @since 4.0.0 + */ +export const get: { + (atom: Atom.Atom): (self: Registry) => A + (self: Registry, atom: Atom.Atom): A +} = dual( + (args) => isRegistry(args[0]), + (self: Registry, atom: Atom.Atom): A => engineOf(self).get(atom), +) + +/** + * Reads an atom from a registry through its `serverValue` target when one is + * present, and falls back to the registry's own read otherwise. + * + * @since 4.0.0 + */ +export const getServerValue: { + (registry: Registry): (self: Atom.Atom) => A + (self: Atom.Atom, registry: Registry): A +} = dual( + 2, + (self: Atom.Atom, registry: Registry): A => { + const read = self.serverValue + if (read !== undefined) { + return read((atom) => get(registry, atom)) + } + return get(registry, self) + }, +) + +/** + * Returns the current value of an atom when its node has been initialized, without rebuilding a stale or uninitialized node. + * + * @since 4.0.0 + */ +export const getRaw: { + (atom: Atom.Atom): (self: Registry) => Option.Option + (self: Registry, atom: Atom.Atom): Option.Option +} = dual( + (args) => isRegistry(args[0]), + (self: Registry, atom: Atom.Atom): Option.Option => engineOf(self).getRaw(atom), +) + +/** + * Writes a value to a writable atom. + * + * @since 4.0.0 + */ +export const set: { + (atom: Atom.Writable, value: W): (self: Registry) => void + (self: Registry, atom: Atom.Writable, value: W): void +} = dual( + (args) => isRegistry(args[0]), + (self: Registry, atom: Atom.Writable, value: W): void => engineOf(self).set(atom, value), +) + +/** + * Stores an encoded serializable value, applying it to the matching atom now + * or when the atom is first read. + * + * @since 4.0.0 + */ +export const setSerializable: { + (key: string, encoded: T): (self: Registry) => void + (self: Registry, key: string, encoded: T): void +} = dual( + (args) => isRegistry(args[0]), + (self: Registry, key: string, encoded: T): void => engineOf(self).setSerializable(key, encoded), +) + +/** + * Preloads an atom's value before the atom is first read. + * + * @since 4.0.0 + */ +export const setInitialValue: { + (atom: Atom.Atom, value: A): (self: Registry) => void + (self: Registry, atom: Atom.Atom, value: A): void +} = dual( + (args) => isRegistry(args[0]), + (self: Registry, atom: Atom.Atom, value: A): void => engineOf(self).setInitialValue(atom, value), +) + +/** + * Reads a writable atom, computes a return value and next write value, and writes the next value. + * + * @since 4.0.0 + */ +type ModifyFunction = (_: R) => [returnValue: A, nextValue: W] + +export const modify: { + (atom: Atom.Writable, f: ModifyFunction): (self: Registry) => A + (self: Registry, atom: Atom.Writable, f: ModifyFunction): A +} = dual( + (args) => isRegistry(args[0]), + ( + self: Registry, + atom: Atom.Writable, + f: (_: R) => [returnValue: A, nextValue: W], + ): A => engineOf(self).modify(atom, f), +) + +/** + * Reads a writable atom and writes the next value computed from the current one. + * + * @since 4.0.0 + */ +export const update: { + (atom: Atom.Writable, f: (_: R) => W): (self: Registry) => void + (self: Registry, atom: Atom.Writable, f: (_: R) => W): void +} = dual( + (args) => isRegistry(args[0]), + (self: Registry, atom: Atom.Writable, f: (_: R) => W): void => engineOf(self).update(atom, f), +) + +/** + * Requests a refresh of an atom. + * + * @since 4.0.0 + */ +export const refresh: { + (atom: Atom.Atom): (self: Registry) => void + (self: Registry, atom: Atom.Atom): void +} = dual( + (args) => isRegistry(args[0]), + (self: Registry, atom: Atom.Atom): void => engineOf(self).refresh(atom), +) + +/** + * Subscribes to an atom's changes and returns a function that removes the + * subscription. + * + * @since 4.0.0 + */ +export const subscribe: { + ( + atom: Atom.Atom, + f: (_: A) => void, + options?: { readonly immediate?: boolean }, + ): (self: Registry) => () => void + (self: Registry, atom: Atom.Atom, f: (_: A) => void, options?: { + readonly immediate?: boolean + }): () => void +} = dual( + (args) => isRegistry(args[0]), + (self: Registry, atom: Atom.Atom, f: (_: A) => void, options?: { + readonly immediate?: boolean + }): () => void => engineOf(self).subscribe(atom, f, options), +) + +/** + * Schedules a delayed callback through the registry's configured timer + * scheduler and returns a function that cancels it. + * + * @since 4.0.0 + */ +export const scheduleTimer: { + (f: () => void, delayMillis: number): (self: Registry) => () => void + (self: Registry, f: () => void, delayMillis: number): () => void +} = dual( + (args) => isRegistry(args[0]), + (self: Registry, f: () => void, delayMillis: number): () => void => engineOf(self).scheduleTimer(f, delayMillis), +) + +/** + * Removes every node from the registry and cancels its pending timers. + * + * @since 4.0.0 + */ +export const reset = (self: Registry): void => engineOf(self).reset() + +/** + * Disposes the registry: pending work stops and further reads fail. + * + * @since 4.0.0 + */ +export const dispose = (self: Registry): void => engineOf(self).dispose() + +/** + * Returns the value this registry stores under `key`, creating it with `make` + * on first use. Storage belongs to the registry and is dropped when the + * registry is disposed, so per-registry caches never outlive their registry. + * + * @since 4.0.0 + */ +export const storage: { + (key: Context.Key, make: LazyArg): (self: Registry) => A + (self: Registry, key: Context.Key, make: LazyArg): A +} = dual( + 3, + (self: Registry, key: Context.Key, make: LazyArg): A => engineOf(self).storageFor(key, make), +) + +// ----------------------------------------------------------------------------- +// conversions +// ----------------------------------------------------------------------------- + +/** + * Converts an atom in this registry into a stream. + * + * **Details** + * + * The stream emits the current value immediately, emits subsequent changes, and + * unsubscribes from the registry when the stream scope closes. + * + * @since 4.0.0 + */ +export const toStream: { + (atom: Atom.Atom): (self: Registry) => Stream.Stream + (self: Registry, atom: Atom.Atom): Stream.Stream +} = dual( + 2, + (self: Registry, atom: Atom.Atom) => + Stream.callback((queue) => + Effect.suspend(() => { + const fiber = Fiber.getCurrent() + if (fiber === undefined) { + return Effect.die(new Error('Expected a current fiber when converting an atom to a stream')) + } + const scope = Context.getUnsafe(fiber.context, Scope.Scope) + const cancel = engineOf(self).subscribe(atom, (value) => Queue.offerUnsafe(queue, value), { + immediate: true, + }) + return Scope.addFinalizer(scope, Effect.sync(cancel)) + }) + ), +) + +function resultToEffect(result: Success | Failure): Effect.Effect { + if (Result.isSuccess(result)) { + return Effect.succeed(result.value) + } + return Effect.failCause(result.cause) +} + +/** + * Converts an `AsyncResult` atom in this registry into a stream of successful + * values. + * + * **Details** + * + * Initial results are skipped, failures fail the stream with their cause, and + * duplicate stream values are dropped with `Stream.changes`. + * + * @since 4.0.0 + */ +export const toStreamResult: { + (atom: Atom.Atom>): (self: Registry) => Stream.Stream + (self: Registry, atom: Atom.Atom>): Stream.Stream +} = dual( + 2, + (self: Registry, atom: Atom.Atom>): Stream.Stream => + toStream(self, atom).pipe( + Stream.filter(Result.isNotInitial), + Stream.mapEffect(resultToEffect), + Stream.changes, + ), +) + +function suspendOnWaitingFrom(options?: { + readonly suspendOnWaiting?: boolean | undefined +}): boolean { + if (options === undefined) { + return false + } + return booleanOrFalse(options.suspendOnWaiting) +} + +function booleanOrFalse(value: boolean | undefined): boolean { + if (value === undefined) { + return false + } + return value +} + +function shouldWaitForNonInitial( + result: Success | Failure, + suspendOnWaiting: boolean, +): boolean { + if (suspendOnWaiting === false) { + return false + } + return result.waiting +} + +function resumeNonInitialIfSettled( + value: Success | Failure, + suspendOnWaiting: boolean, + resume: (effect: Effect.Effect) => void, + cancel: () => void, +): void { + if (shouldWaitForNonInitial(value, suspendOnWaiting)) { + return + } + resume(Result.toExit(value)) + cancel() +} + +function onSubscribedResult( + value: Result.Result, + suspendOnWaiting: boolean, + resume: (effect: Effect.Effect) => void, + cancel: () => void, +): void { + if (Result.isInitial(value)) { + return + } + resumeNonInitialIfSettled(value, suspendOnWaiting, resume, cancel) +} + +function subscribeUntilSettled( + self: Registry, + atom: Atom.Atom>, + suspendOnWaiting: boolean, + resume: (effect: Effect.Effect) => void, +): Effect.Effect { + const cancel = engineOf(self).subscribe(atom, (value) => { + onSubscribedResult(value, suspendOnWaiting, resume, cancel) + }) + return Effect.sync(cancel) +} + +function resumeSettledOrSubscribe( + self: Registry, + atom: Atom.Atom>, + result: Success | Failure, + suspendOnWaiting: boolean, + resume: (effect: Effect.Effect) => void, +): void | Effect.Effect { + if (shouldWaitForNonInitial(result, suspendOnWaiting)) { + return subscribeUntilSettled(self, atom, suspendOnWaiting, resume) + } + return resume(Result.toExit(result)) +} + +function getResultCallback( + self: Registry, + atom: Atom.Atom>, + suspendOnWaiting: boolean, + resume: (effect: Effect.Effect) => void, +): void | Effect.Effect { + const result = engineOf(self).get(atom) + if (Result.isInitial(result)) { + return subscribeUntilSettled(self, atom, suspendOnWaiting, resume) + } + return resumeSettledOrSubscribe(self, atom, result, suspendOnWaiting, resume) +} + +/** + * Reads an `AsyncResult` atom from this registry as an effect. + * + * **Details** + * + * The effect waits for the result to leave `Initial`, and also waits through + * waiting results when `suspendOnWaiting` is enabled. + * + * @since 4.0.0 + */ +export const getResult: { + (atom: Atom.Atom>, options?: { + readonly suspendOnWaiting?: boolean | undefined + }): (self: Registry) => Effect.Effect + (self: Registry, atom: Atom.Atom>, options?: { + readonly suspendOnWaiting?: boolean | undefined + }): Effect.Effect +} = dual( + (args) => isRegistry(args[0]), + (self: Registry, atom: Atom.Atom>, options?: { + readonly suspendOnWaiting?: boolean | undefined + }): Effect.Effect => { + const suspendOnWaiting = suspendOnWaitingFrom(options) + return Effect.callback((resume) => getResultCallback(self, atom, suspendOnWaiting, resume)) + }, +) + +/** + * Mounts an atom in this registry for the lifetime of the current scope. + * + * **Details** + * + * The atom is subscribed with a no-op listener and the subscription is released + * when the scope finalizer runs. + * + * @since 4.0.0 + */ +export const mount: { + (atom: Atom.Atom): (self: Registry) => Effect.Effect + (self: Registry, atom: Atom.Atom): Effect.Effect +} = dual( + 2, + (self: Registry, atom: Atom.Atom) => + Effect.acquireRelease( + Effect.sync(() => engineOf(self).mount(atom)), + (release) => Effect.sync(release), + ), +) + +/** + * Runs synchronous atom updates as a batch on one registry. + * + * **Details** + * + * Stale nodes are rebuilt and listeners are notified after the callback completes, + * so dependent updates observe the final batched state. + * + * @since 4.0.0 + */ +export const batch: { + (f: () => void): (self: Registry) => void + (self: Registry, f: () => void): void +} = dual( + (args) => isRegistry(args[0]), + (self: Registry, f: () => void): void => { + engineOf(self).batchOn(f) + }, +) + +// ----------------------------------------------------------------------------- +// dehydrate / hydrate +// ----------------------------------------------------------------------------- + +type AnyAtom = Atom.Atom + +const pendingResultKey = '~effect-atom/Hydration/pendingResult' + +const readPendingResult = (entry: HydrationEntry): Option.Option> => + Option.some(entry).pipe( + Option.filter(hasProperty(pendingResultKey)), + Option.map((withSlot) => withSlot[pendingResultKey]), + Option.filter(Deferred.isDeferred), + ) + +const writePendingResult = (entry: DehydratedAtomValue, deferred: Deferred.Deferred): void => { + Object.defineProperty(entry, pendingResultKey, { + value: deferred, + enumerable: false, + configurable: true, + writable: false, + }) +} + +/** + * Encodes the serializable atoms currently stored in a registry into dehydrated + * state. + * + * **Details** + * + * Only atoms marked with `Atom.serializable` are included. `encodeInitialAs` + * controls whether `Result.Initial` values are ignored, encoded as values, + * or carried as a pending update that completes when the atom leaves the + * initial state. + * + * @since 4.0.0 + */ +export const dehydrate: { + (options?: { + /** + * How to encode `Result.Initial` values. Default is "ignore". + */ + readonly encodeInitialAs?: 'ignore' | 'deferred' | 'value-only' | undefined + }): (registry: Registry) => DehydratedAtomValue[] + ( + registry: Registry, + options?: { + /** + * How to encode `Result.Initial` values. Default is "ignore". + */ + readonly encodeInitialAs?: 'ignore' | 'deferred' | 'value-only' | undefined + }, + ): DehydratedAtomValue[] +} = dual( + (args) => isRegistry(args[0]), + ( + registry: Registry, + options?: { + readonly encodeInitialAs?: 'ignore' | 'deferred' | 'value-only' | undefined + }, + ): DehydratedAtomValue[] => { + const encodeInitialResultMode = encodeInitialMode(options) + const arr: DehydratedAtomValue[] = [] + const dehydratedAt = now(registry) + getNodes(registry).forEach((node, key) => { + dehydrateNode(registry, node, key, encodeInitialResultMode, dehydratedAt, arr) + }) + return arr + }, +) + +const dehydrateNode = ( + registry: Registry, + node: { readonly atom: AnyAtom; readonly value: () => AnyValue }, + key: AnyValue, + encodeInitialResultMode: 'ignore' | 'deferred' | 'value-only', + now: number, + arr: DehydratedAtomValue[], +): void => { + const serializer = node.atom.spec.serializable + if (serializer === undefined) { + return + } + dehydrateSerializable( + registry, + node.atom, + serializer, + node.value(), + key, + encodeInitialResultMode, + now, + arr, + ) +} + +type Serializer = Atom.SerializableSpec + +const dehydrateSerializable = ( + registry: Registry, + atom: AnyAtom, + serializer: Serializer, + value: AnyValue, + key: AnyValue, + encodeInitialResultMode: 'ignore' | 'deferred' | 'value-only', + now: number, + arr: DehydratedAtomValue[], +): void => { + const isInitial = isInitialResult(value) + if (shouldSkipInitial(encodeInitialResultMode, isInitial)) { + return + } + dehydrateKeyed(registry, atom, serializer, value, key, encodeInitialResultMode, isInitial, now, arr) +} + +const encodeOrRefuse = ( + registry: Registry, + serializer: Serializer, + value: AnyValue, +): Option.Option => { + const codec = encodingCodecOf(serializer) + if (codec === undefined) { + return Option.none() + } + return encodeWithCodec(registry, serializer.key, codec, value) +} + +const encodeWithCodec = ( + registry: Registry, + key: string, + codec: Schema.ConstraintEncoder, + value: AnyValue, +): Option.Option => { + const exit = encodeHydrationValue(codec, value) + if (Exit.isSuccess(exit)) { + return Option.some(exit.value) + } + refuseEncode(registry, key, exit.cause) + return Option.none() +} + +const refuseEncode = (registry: Registry, key: string, cause: Cause.Cause): void => { + const issue = Option.match(Cause.findErrorOption(cause), { + onNone: () => 'value could not be encoded', + onSome: (error) => error.message, + }) + recordRefusal(registry, { key, issue }) +} + +const dehydrateKeyed = ( + registry: Registry, + atom: AnyAtom, + serializer: Serializer, + value: AnyValue, + key: AnyValue, + encodeInitialResultMode: 'ignore' | 'deferred' | 'value-only', + isInitial: boolean, + now: number, + arr: DehydratedAtomValue[], +): void => { + if (typeof key !== 'string') { + return + } + Option.match(encodeOrRefuse(registry, serializer, value), { + onNone: constVoid, + onSome: (encoded) => { + const entry: DehydratedAtomValue = { + '~effect/reactivity/DehydratedAtom': true, + key, + value: encoded, + dehydratedAt: now, + } + attachDeferred(registry, atom, serializer, entry, encodeInitialResultMode, isInitial) + arr.push(entry) + }, + }) +} + +const attachDeferred = ( + registry: Registry, + atom: AnyAtom, + serializer: Serializer, + entry: DehydratedAtomValue, + encodeInitialResultMode: 'ignore' | 'deferred' | 'value-only', + isInitial: boolean, +): void => { + if (!shouldAttachDeferred(encodeInitialResultMode, isInitial)) { + return + } + const deferred = Deferred.makeUnsafe() + const unsubscribe = subscribe(registry, atom, (newValue) => { + completeDeferred(registry, deferred, unsubscribe, serializer, newValue) + }) + writePendingResult(entry, deferred) +} + +const completeDeferred = ( + registry: Registry, + deferred: Deferred.Deferred, + unsubscribe: () => void, + serializer: Serializer, + newValue: AnyValue, +): void => { + if (!isSettledResult(newValue)) { + return + } + unsubscribe() + Deferred.doneUnsafe( + deferred, + Option.match(encodeOrRefuse(registry, serializer, newValue), { + onNone: () => Effect.interrupt, + onSome: Effect.succeed, + }), + ) +} + +/** + * Applies dehydrated atom state to a registry. + * + * **When to use** + * + * Use to preload serialized atom values into a target registry before those + * atoms are read. + * + * **Details** + * + * Encoded values are preloaded by serialization key. Entries whose initial + * state was carried as pending (see `dehydrate`'s `encodeInitialAs`) update the + * matching registry node, or preload the resolved value, when that pending + * value completes. + * + * Returns a fiber that completes once every pending update has been applied to + * the registry. Callers that need the state fully settled — tests, SSR flushes + * — can join it; fire-and-forget callers can ignore it. + * + * @since 4.0.0 + */ + +export const hydrate: { + (dehydratedState: Iterable): (registry: Registry) => Fiber.Fiber + (registry: Registry, dehydratedState: Iterable): Fiber.Fiber +} = dual( + 2, + (registry: Registry, dehydratedState: Iterable): Fiber.Fiber => { + const pending: Effect.Effect[] = [] + for (const entry of dehydratedState) { + hydrateOne(registry, pending, entry) + } + return Effect.runFork(Effect.forEach(pending, (effect) => effect, { discard: true })) + }, +) + +const refuseMalformedEntry = ( + registry: Registry, + cause: Cause.Cause, +): void => { + const found = Cause.findErrorOption(cause) + const issue = Option.match(found, { + onNone: () => 'entry is not a dehydrated atom value', + onSome: (error) => error.message, + }) + recordRefusal(registry, { key: undefined, issue }) +} + +const queuePendingResult = ( + registry: Registry, + pending: Effect.Effect[], + datom: DehydratedAtomValue, + result: Deferred.Deferred, +): void => { + pending.push( + Deferred.await(result).pipe( + Effect.exit, + Effect.map(Exit.match({ + onFailure: constVoid, + onSuccess: (resolvedValue) => setSerializable(registry, datom.key, resolvedValue), + })), + ), + ) +} + +const applyDecodedEntry = ( + registry: Registry, + pending: Effect.Effect[], + datom: DehydratedAtomValue, + entry: HydrationEntry, +): void => { + setSerializable(registry, datom.key, datom.value) + Option.match(readPendingResult(entry), { + onNone: constVoid, + onSome: (result) => queuePendingResult(registry, pending, datom, result), + }) +} + +const hydrateOne = ( + registry: Registry, + pending: Effect.Effect[], + entry: HydrationEntry, +): void => { + const exit = decodeHydrationEntry(entry) + if (Exit.isSuccess(exit)) { + applyDecodedEntry(registry, pending, exit.value, entry) + return + } + refuseMalformedEntry(registry, exit.cause) +} diff --git a/packages/atom/effect-atom/src/schema-laws.test.ts b/packages/atom/effect-atom/src/schema-laws.test.ts new file mode 100644 index 0000000..7fa4d5b --- /dev/null +++ b/packages/atom/effect-atom/src/schema-laws.test.ts @@ -0,0 +1,5 @@ +// Generated: @systemfsoftware/effect-schema-vite rewrites this file, injecting a +// `ruleOfSchemas` law pair for every exported Effect Schema in this package. +// The generated body carries real import edges to each schema file so vitest's +// related-file walk (and therefore `stryker --related`) reaches them. +export {} diff --git a/packages/atom/effect-atom/src/search-param.service.ts b/packages/atom/effect-atom/src/search-param.service.ts new file mode 100644 index 0000000..181155e --- /dev/null +++ b/packages/atom/effect-atom/src/search-param.service.ts @@ -0,0 +1,28 @@ +/** + * Registry-storage identity for the URL search-parameter write coordinator. + * + * The coordinator lives in a `*.service.ts` module because the atom blueprint + * may not declare a `Context.Service`: a blueprint carries no ambient + * environment identity, so its private per-registry cache keys live beside the + * other service identities (see `current-registry.service.ts`). + * + * @since 4.0.0 + */ +import * as Context from 'effect/Context' +import type * as Registry from './registry.handle.js' + +export interface SearchParamCoordinator { + generation: number + readonly updates: Map + updating: boolean + readonly registry: Registry.Registry +} + +/** + * Registry-storage key for the search-parameter coordinator. + * + * @since 4.0.0 + */ +export class SearchParamUpdates extends Context.Service()( + '@systemfsoftware/effect-atom/search-param.service/SearchParamUpdates', +) {} diff --git a/packages/atom/effect-atom/stryker.config.ts b/packages/atom/effect-atom/stryker.config.ts new file mode 100644 index 0000000..18f4cc3 --- /dev/null +++ b/packages/atom/effect-atom/stryker.config.ts @@ -0,0 +1,28 @@ +import { sharedConfig } from '@systemfsoftware/stryker-config' +import { defineConfig, type PartialStrykerOptions } from '@systemfsoftware/stryker-js/config' + +// Composite node projects emit declarations, and the inferred `defineConfig` +// return type is not nameable from outside `@systemfsoftware/stryker-js`'s +// internal chunks (TS2883); naming it here keeps the emitted declaration portable. +const config: PartialStrykerOptions = defineConfig({ + ...sharedConfig, + testRunner: { + plugin: import.meta.resolve('@systemfsoftware/stryker-js-vitest-runner'), + options: { configFile: 'vitest.node.config.ts', dir: '.', related: true }, + }, + checkers: [ + { + plugin: import.meta.resolve('@systemfsoftware/stryker-js-typescript-checker'), + options: { prioritizePerformanceOverAccuracy: true }, + }, + ], + ignorers: [ + import.meta.resolve('@systemfsoftware/stryker-ignorer-effect-schema-declarations'), + import.meta.resolve('@systemfsoftware/stryker-ignorer-in-source-vitest-block'), + ], + thresholds: { high: 100, low: 100, break: 100 }, + dryRunTimeoutMinutes: 10, + ignorePatterns: [], +}) + +export default config diff --git a/packages/atom/effect-atom/test-types/atom.tst.ts b/packages/atom/effect-atom/test-types/atom.tst.ts new file mode 100644 index 0000000..0fa2318 --- /dev/null +++ b/packages/atom/effect-atom/test-types/atom.tst.ts @@ -0,0 +1,118 @@ +import { Atom } from '@systemfsoftware/effect-atom' +import { Context, Effect, Layer, pipe, Schema } from 'effect' +import { describe, expect, it } from 'tstyche' + +class NotRegistry extends Context.Service()( + '@systemfsoftware/effect-atom/test-types/atom.tst/NotRegistry', +) {} + +declare const registry: Atom.Registry.Registry +declare const count: Atom.Atom +declare const ref: Atom.Ref.AtomRef +declare const draft: Atom.Writable +declare const loading: Atom.Atom> +declare const writeContext: Atom.WriteContext + +describe('Atom.make', () => { + it('Should_MakeAWritableAtomOfThatValue_When_TheInitialValueIsPlain', () => { + expect(Atom.make(0)).type.toBe>() + }) + + it('Should_ReadAsAnAsyncResultWithEveryChannelPinned_When_TheInitialValueIsAnEffect', () => { + expect(Atom.make(Effect.succeed(1))).type.toBe>>() + }) +}) + +describe('Atom.get', () => { + it('Should_AnswerThroughTheCurrentRegistryServiceWithEveryChannelPinned_When_GetIsCalled', () => { + expect(Atom.get(count)).type.toBe>() + }) +}) + +describe('Atom.Registry.layer', () => { + it('Should_AcceptTheCurrentRegistryTagAndRefuseAnotherShape_When_LayerIsCalled', () => { + expect(Atom.Registry.layer).type.toBeCallableWith(Atom.Registry.Current) + expect(Atom.Registry.layer(Atom.Registry.Current)).type.toBe>() + expect(Atom.Registry.layer).type.not.toBeCallableWith(NotRegistry) + }) +}) + +describe('dual operations', () => { + it('Should_AcceptDataFirstAndDataLastCalls_When_RegistryGetIsCalled', () => { + expect(Atom.Registry.get).type.toBeCallableWith(registry, count) + expect(Atom.Registry.get(registry, count)).type.toBe() + expect(Atom.Registry.get(count)).type.toBe<(self: Atom.Registry.Registry) => number>() + expect(pipe(registry, Atom.Registry.get(count))).type.toBe() + }) + + it('Should_AcceptDataFirstAndDataLastCalls_When_RefSetIsCalled', () => { + expect(Atom.Ref.make(0)).type.toBe>() + expect(Atom.Ref.set).type.toBeCallableWith(ref, 1) + expect(Atom.Ref.set(ref, 1)).type.toBe>() + expect(Atom.Ref.set(1)).type.toBe<(self: Atom.Ref.AtomRef) => Atom.Ref.AtomRef>() + expect(pipe(ref, Atom.Ref.set(1))).type.toBe>() + }) +}) + +describe('an atom is a blueprint', () => { + it('Should_KeepTheWritableVariantInEveryForm_When_ConfigurationStepIsApplied', () => { + expect(draft.keepAlive()).type.toBe>() + expect(Atom.keepAlive(draft)).type.toBe>() + expect(pipe(draft, Atom.keepAlive)).type.toBe>() + expect(Atom.setIdleTTL(draft, '1 second')).type.toBe>() + expect(pipe(draft, Atom.setIdleTTL('1 second'))).type.toBe>() + expect(pipe(count, Atom.setLazy(false), Atom.withLabel('count'))).type.toBe>() + }) + + it('Should_TypeItsCallbackByTheAtomValueAndRefuseAnotherValueType_When_AnEqualityStepIsApplied', () => { + expect( + pipe( + count, + Atom.withEquality((value, next) => { + expect(value).type.toBe() + return value === next + }), + ), + ).type.toBe>() + expect(Atom.withEquality).type.toBeCallableWith(count, (value: number, next: number) => value === next) + expect(Atom.withEquality).type.not.toBeCallableWith(count, (value: string, next: string) => value === next) + }) + + it('Should_TypeTheServerValueByTheAtomValueAndRequireAnAsyncResultAtomForTheInitialValue_When_WithServerValueIsApplied', () => { + expect(pipe(draft, Atom.withServerValue((get) => get(count) + 1))).type.toBe>() + expect(Atom.withServerValue).type.toBeCallableWith(count, () => 1) + expect(Atom.withServerValue).type.not.toBeCallableWith(count, () => 'one') + expect(pipe(loading, Atom.withServerValueInitial)).type.toBe< + Atom.Atom> + >() + }) + + it('Should_AddTheSchemaToTheAtomItConfigures_When_SerializableIsApplied', () => { + expect(Atom.serializable(draft, { key: 'draft', schema: Schema.Finite })).type.toBe< + Atom.Writable & Atom.Serializable + >() + expect(pipe(count, Atom.serializable({ key: 'count', schema: Schema.Finite }))).type.toBe< + Atom.Atom & Atom.Serializable + >() + }) + + it('Should_TypeTheReadAndWriteTargetsByTheAtom_When_TheBlueprintIsRead', () => { + expect(count.read).type.toBe<(get: Atom.AtomContext) => number>() + expect(draft.write).type.toBe<(ctx: Atom.WriteContext, value: string) => void>() + expect(draft.write).type.toBeCallableWith(writeContext, 'text') + expect(draft.write).type.not.toBeCallableWith(writeContext, 1) + }) + + it('Should_StandInForAnAtomOfItsValueAndNeverTheReverse_When_TheAtomIsWritable', () => { + expect>().type.toBeAssignableTo>() + expect>().type.toBeAssignableTo>() + expect>().type.not.toBeAssignableTo>() + expect>().type.not.toBeAssignableTo>() + }) + + it('Should_KeepTheWriteInputOfAWritableSource_When_TheAtomIsDerived', () => { + expect(Atom.transform(draft, (get, self) => get(self) > 0)).type.toBe>() + expect(pipe(count, Atom.transform((get, self) => String(get(self))))).type.toBe>() + expect>().type.toBe() + }) +}) diff --git a/packages/atom/effect-atom/tests/Atom.integration.test.ts b/packages/atom/effect-atom/tests/Atom.integration.test.ts new file mode 100644 index 0000000..d7f6a9d --- /dev/null +++ b/packages/atom/effect-atom/tests/Atom.integration.test.ts @@ -0,0 +1,1981 @@ +import { Atom } from '@systemfsoftware/effect-atom' +import { Gherkin, Given, it, makeFeature, Then, When } from '@systemfsoftware/effect-gherkin-spec' +import { Context, Deferred, Effect, Latch, Layer, Option, Schema, Stream, SubscriptionRef } from 'effect' +import { KeyValueStore } from 'effect/persistence' +import { TestClock } from 'effect/testing' + +const Feature = makeFeature({ it }) + +/** Blocks until the registry reports a value the predicate holds for, resuming from the subscription. */ +const waitForValue = ( + registry: Atom.Registry.Registry, + atom: Atom.Atom, + holds: (value: A) => boolean, +): Effect.Effect => + Effect.callback((resume) => { + let unsubscribe = (): void => {} + unsubscribe = Atom.Registry.subscribe( + registry, + atom, + (value) => { + if (!holds(value)) return + resume(Effect.succeed(value)) + unsubscribe() + }, + { immediate: true }, + ) + return Effect.sync(unsubscribe) + }) + +const isSuccessfulResult = (value: unknown): value is Atom.AsyncResult.Success => + Atom.AsyncResult.isResult(value) && Atom.AsyncResult.isSuccess(value) + +const isFailureResult = (value: unknown): value is Atom.AsyncResult.Failure => + Atom.AsyncResult.isResult(value) && Atom.AsyncResult.isFailure(value) + +Feature('Deriving values from other values on a page') + .withLayer(Layer.empty) + .body(({ scenario }) => { + scenario( + 'A plain value on the page reads back exactly what was set', + Gherkin.Do.pipe( + Given('a page holding a constant value')('ctx', () => + Effect.sync(() => { + const value = Atom.make(42) + const page = Atom.Registry.make() + return { page, value } + })), + When('the value is read')('reading', (s) => Effect.sync(() => Atom.Registry.get(s.ctx.page, s.ctx.value))), + Then('it matches what was set')((s, expect) => expect(s.reading).toBe(42)), + ), + ) + scenario( + 'A page value built from an empty object holds that object and can be replaced', + Gherkin.Do.pipe( + Given('a page whose value starts as an empty object')('ctx', () => + Effect.sync(() => { + const value = Atom.make({}) + const page = Atom.Registry.make() + return { page, value } + })), + When('the value is read, replaced, and read again')('readings', (s) => + Effect.sync(() => { + const before = Atom.Registry.get(s.ctx.page, s.ctx.value) + Atom.Registry.set(s.ctx.page, s.ctx.value, { filled: true }) + return { before, after: Atom.Registry.get(s.ctx.page, s.ctx.value) } + })), + Then('the page first held the empty object and then the replacement')( + (s, expect) => + expect({ before: s.readings.before, after: s.readings.after }).toEqual({ + before: {}, + after: { filled: true }, + }), + ), + ), + ) + scenario( + 'A value computed from another value stays in sync with it', + Gherkin.Do.pipe( + Given('a page with a value doubled from another value')('ctx', () => + Effect.sync(() => { + const base = Atom.make(10) + const doubled = Atom.map(base, (n) => n * 2) + const page = Atom.Registry.make() + return { page, doubled } + })), + When('the doubled value is read')( + 'reading', + (s) => Effect.sync(() => Atom.Registry.get(s.ctx.page, s.ctx.doubled)), + ), + Then('it reflects the source value doubled')((s, expect) => expect(s.reading).toBe(20)), + ), + ) + scenario( + 'A value that also refreshes on named changes keeps showing every change to the value it wraps', + Gherkin.Do.pipe( + Given('a page watching a count that is also refreshed whenever "counts" change')( + 'ctx', + () => + Effect.sync(() => { + const count = Atom.make(0) + const watched = Atom.withReactivity(['counts'])(count) + const page = Atom.Registry.make() + return { page, count, watched } + }), + ), + When('the count is changed to 1 and then to 2')('heard', (s) => + Effect.sync(() => { + const heard: Array = [] + const stop = Atom.Registry.subscribe(s.ctx.page, s.ctx.watched, (value) => heard.push(value), { + immediate: true, + }) + Atom.Registry.set(s.ctx.page, s.ctx.count, 1) + Atom.Registry.set(s.ctx.page, s.ctx.count, 2) + stop() + return heard + })), + Then('the watcher heard 0, then 1, then 2')((s, expect) => expect(s.heard).toEqual([0, 1, 2])), + ), + ) + scenario( + 'Two items with different keys get their own independent values', + Gherkin.Do.pipe( + Given('a page with a value that depends on which item is being shown')('ctx', () => + Effect.sync(() => { + const lengthOfName = Atom.family((name: string) => Atom.make(name.length)) + const page = Atom.Registry.make() + return { page, lengthOfName } + })), + When('the value is read for two different items')('readings', (s) => + Effect.sync(() => ({ + first: Atom.Registry.get(s.ctx.page, s.ctx.lengthOfName('foo')), + second: Atom.Registry.get(s.ctx.page, s.ctx.lengthOfName('bar')), + }))), + Then('each item keeps its own value')( + (s, expect) => expect(s.readings).toMatchObject({ first: 3, second: 3 }), + ), + ), + ) + scenario( + 'A value that never finishes loading still reports loading after being asked to refresh', + Gherkin.Do.pipe( + Given('a page with a value that never finishes loading')('ctx', () => + Effect.sync(() => { + const value = Atom.make(Effect.never) + const page = Atom.Registry.make() + return { page, value } + })), + When('the value is read, then the page is asked to refresh it, then read again')( + 'readings', + (s) => + Effect.sync(() => { + const firstReading = Atom.Registry.get(s.ctx.page, s.ctx.value) + Atom.Registry.refresh(s.ctx.page, s.ctx.value) + const secondReading = Atom.Registry.get(s.ctx.page, s.ctx.value) + return { firstReading, secondReading } + }), + ), + Then('both readings show it is still loading')( + (s, expect) => + expect(s.readings).toMatchObject({ + firstReading: { _tag: 'Initial' }, + secondReading: { _tag: 'Initial' }, + }), + ), + ), + ) + scenario( + 'A value with a stand-in shows the stand-in while the source is still loading', + Gherkin.Do.pipe( + Given('a source that is still loading and a stand-in value')('ctx', () => + Effect.sync(() => { + const source = Atom.make(Effect.never) + const withStandIn = source.pipe(Atom.withFallback(Atom.make(Atom.AsyncResult.success('cached' as const)))) + const page = Atom.Registry.make() + return { page, withStandIn } + })), + When('the value is read before the source finishes')( + 'reading', + (s) => Effect.sync(() => Atom.Registry.get(s.ctx.page, s.ctx.withStandIn)), + ), + Then('the stand-in is shown, marked as still loading')( + (s, expect) => expect(s.reading).toMatchObject({ _tag: 'Success', value: 'cached', waiting: true }), + ), + ), + ) + scenario( + 'Once the source finishes, its real outcome replaces the stand-in', + Gherkin.Do.pipe( + Given('a source that fails and a stand-in value')('ctx', () => + Effect.sync(() => { + const source = Atom.make(Effect.fail('down' as const)) + const withStandIn = source.pipe(Atom.withFallback(Atom.make(Atom.AsyncResult.success('cached' as const)))) + const page = Atom.Registry.make() + return { page, withStandIn } + })), + When('the value is read after the source has settled')('reading', (s) => + Effect.sync(() => { + Atom.Registry.get(s.ctx.page, s.ctx.withStandIn) + Atom.Registry.refresh(s.ctx.page, s.ctx.withStandIn) + return Atom.Registry.get(s.ctx.page, s.ctx.withStandIn) + })), + Then('the real failure is shown, not the stand-in')( + (s, expect) => + expect(s.reading).toMatchObject({ _tag: 'Failure', cause: { reasons: [{ _tag: 'Fail', error: 'down' }] } }), + ), + ), + ) + scenario( + 'A change shows up right away and disappears when the confirmation is rejected', + Gherkin.Do.pipe( + Given('a stored value with optimistic updates, and a confirmation that will be rejected')( + 'ctx', + () => + Effect.sync(() => { + const latch = Latch.makeUnsafe() + let stored = 1 + const source = Atom.make(() => stored) + const optimisticValue = source.pipe(Atom.optimistic) + const save = optimisticValue.pipe( + Atom.optimisticFn({ + reducer: (_current, update: number) => update, + fn: Atom.fn(Effect.fnUntraced(function*() { + yield* latch.await + return yield* Effect.fail('rejected' as const) + })), + }), + Atom.keepAlive, + ) + const page = Atom.Registry.make() + return { + page, + save, + optimisticValue, + latch, + setStored: (n: number) => { + stored = n + }, + } + }), + ), + When('the change is made and the rejection arrives')('readings', (s) => + Effect.sync(() => { + const before = Atom.Registry.get(s.ctx.page, s.ctx.optimisticValue) + Atom.Registry.set(s.ctx.page, s.ctx.save, 99) + const whilePending = Atom.Registry.get(s.ctx.page, s.ctx.optimisticValue) + s.ctx.latch.openUnsafe() + const afterRejection = Atom.Registry.get(s.ctx.page, s.ctx.optimisticValue) + return { before, whilePending, afterRejection } + })), + Then('the change showed immediately, then rolled back to the stored value')( + (s, expect) => expect(s.readings).toMatchObject({ before: 1, whilePending: 99, afterRejection: 1 }), + ), + ), + ) + scenario( + 'A change that is confirmed stays, refreshed from the store', + Gherkin.Do.pipe( + Given('a stored value with optimistic updates, and a confirmation that will be accepted')( + 'ctx', + () => + Effect.sync(() => { + const latch = Latch.makeUnsafe() + let stored = 1 + const source = Atom.make(() => stored) + const optimisticValue = source.pipe(Atom.optimistic) + const save = optimisticValue.pipe( + Atom.optimisticFn({ + reducer: (_current, update: number) => update, + fn: Atom.fn(Effect.fnUntraced(function*() { + yield* latch.await + })), + }), + Atom.keepAlive, + ) + const page = Atom.Registry.make() + return { + page, + save, + optimisticValue, + latch, + setStored: (n: number) => { + stored = n + }, + } + }), + ), + When('the change is made and the store accepts it')('readings', (s) => + Effect.sync(() => { + Atom.Registry.set(s.ctx.page, s.ctx.save, 99) + const whilePending = Atom.Registry.get(s.ctx.page, s.ctx.optimisticValue) + s.ctx.setStored(99) + s.ctx.latch.openUnsafe() + const afterConfirmation = Atom.Registry.get(s.ctx.page, s.ctx.optimisticValue) + return { whilePending, afterConfirmation } + })), + Then('the confirmed value stays on screen')( + (s, expect) => expect(s.readings).toMatchObject({ whilePending: 99, afterConfirmation: 99 }), + ), + ), + ) + scenario( + 'A burst of quick edits arrives as one final value', + Gherkin.Do.pipe( + Given('a value that only updates after things quiet down')('ctx', () => + Effect.sync(() => { + const base = Atom.make(0) + const quieted = base.pipe(Atom.debounce(100)) + const page = Atom.Registry.make() + Atom.Registry.subscribe(page, quieted, () => {}, { immediate: true }) + return { page, base, quieted } + })), + When('several edits happen in quick succession, then things go quiet')( + 'readings', + (s) => + Effect.gen(function*() { + Atom.Registry.set(s.ctx.page, s.ctx.base, 1) + Atom.Registry.set(s.ctx.page, s.ctx.base, 2) + Atom.Registry.set(s.ctx.page, s.ctx.base, 3) + const duringBurst = Atom.Registry.get(s.ctx.page, s.ctx.quieted) + yield* TestClock.adjust('150 millis') + const afterQuiet = Atom.Registry.get(s.ctx.page, s.ctx.quieted) + return { duringBurst, afterQuiet } + }), + ), + Then('nothing changed during the burst, and the final edit arrived once it was quiet')( + (s, expect) => expect(s.readings).toMatchObject({ duringBurst: 0, afterQuiet: 3 }), + ), + ), + ) + scenario( + 'A value with a short custom timer is cleaned up even when the page default is long', + Gherkin.Do.pipe( + Given('a value with its own short cleanup timer on a page with a long default')('ctx', () => + Effect.sync(() => { + let starts = 0 + const value = Atom.make(Effect.sync(() => { + starts++ + return 1 + })).pipe(Atom.setIdleTTL(10)) + const page = Atom.Registry.make({ defaultIdleTTL: 10_000, timeoutResolution: 10 }) + return { page, value, starts: () => starts } + })), + When('the value is read, its short timer runs out, and it is read again')( + 'readings', + (s) => + Effect.gen(function*() { + Atom.Registry.get(s.ctx.page, s.ctx.value) + yield* TestClock.adjust('100 millis') + Atom.Registry.get(s.ctx.page, s.ctx.value) + const starts = s.ctx.starts() + return { starts } + }), + ), + Then('the value was cleaned up on its own schedule and started over')( + (s, expect) => expect(s.readings.starts).toBe(2), + ), + ), + ) + scenario( + 'A family member nobody uses is cleaned up while the family lives on', + Gherkin.Do.pipe( + Given('a family of values with a short cleanup timer')('ctx', () => + Effect.sync(() => { + let starts = 0 + const family = Atom.family((id: number) => + Atom.make(Effect.callback((resume) => { + starts++ + resume(Effect.succeed(id)) + })) + ) + const page = Atom.Registry.make({ defaultIdleTTL: 10 }) + return { page, family, starts: () => starts } + })), + When('one member is read, the timer runs out, and it is read again')('readings', (s) => + Effect.gen(function*() { + const first = Atom.Registry.get(s.ctx.page, s.ctx.family(7)) + yield* TestClock.adjust('100 millis') + const second = Atom.Registry.get(s.ctx.page, s.ctx.family(7)) + const starts = s.ctx.starts() + return { first, second, starts } + })), + Then('the member was cleaned up and recreated on demand')((s, expect) => expect(s.readings.starts).toBe(2)), + ), + ) + scenario( + 'A reader following a feed sees every update arrive in order', + Gherkin.Do.pipe( + Given('a feed of three updates')('ctx', () => + Effect.sync(() => { + const feed = Atom.pull(Stream.make(1, 2, 3)) + const page = Atom.Registry.make() + Atom.Registry.subscribe(page, feed, () => {}, { immediate: true }) + return { page, feed } + })), + When('the reader pulls until the feed finishes')('final', (s) => + Effect.sync(() => { + Atom.Registry.set(s.ctx.page, s.ctx.feed, void 0) + Atom.Registry.set(s.ctx.page, s.ctx.feed, void 0) + Atom.Registry.set(s.ctx.page, s.ctx.feed, void 0) + Atom.Registry.set(s.ctx.page, s.ctx.feed, void 0) + return Atom.Registry.get(s.ctx.page, s.ctx.feed) + })), + Then('every update arrived in order and the feed is marked finished')( + (s, expect) => expect(s.final).toMatchObject({ _tag: 'Success', value: { done: true, items: [1, 2, 3] } }), + ), + ), + ) + scenario( + 'A view backed by a shared reference shows every change as it happens', + Gherkin.Do.pipe( + Given('a view backed by a shared reference')('ctx', () => + Effect.gen(function*() { + const ref = yield* SubscriptionRef.make(0) + const view = Atom.subscriptionRef(ref) + const page = Atom.Registry.make() + Atom.Registry.subscribe(page, view, () => {}, { immediate: true }) + return { ref, view, page } + })), + When('the reference changes twice')('readings', (s) => + Effect.gen(function*() { + yield* SubscriptionRef.set(s.ctx.ref, 5) + const first = yield* waitForValue(s.ctx.page, s.ctx.view, (value) => value === 5) + yield* SubscriptionRef.set(s.ctx.ref, 9) + const second = yield* waitForValue(s.ctx.page, s.ctx.view, (value) => value === 9) + return { first, second } + })), + Then('the view tracked both changes')( + (s, expect) => expect(s.readings).toMatchObject({ first: 5, second: 9 }), + ), + ), + ) + scenario( + 'A value remembered in the store is still there on a fresh page', + Gherkin.Do.pipe( + Given('a remembered value backed by an in-memory store')('ctx', () => + Effect.sync(() => { + const memoMap = Layer.makeMemoMapUnsafe() + const runtime = Atom.context({ memoMap })(KeyValueStore.layerMemory) + const remembered = Atom.kvs({ + runtime, + key: 'count', + schema: Schema.Finite, + defaultValue: () => 0, + }) + const page = Atom.Registry.make() + return { remembered, page } + })), + When('the value is changed and a fresh page reads it')('readings', (s) => + Effect.sync(() => { + Atom.Registry.subscribe(s.ctx.page, s.ctx.remembered, () => {}, { immediate: true }) + Atom.Registry.set(s.ctx.page, s.ctx.remembered, 42) + const freshPage = Atom.Registry.make() + Atom.Registry.subscribe(freshPage, s.ctx.remembered, () => {}, { immediate: true }) + return { onFreshPage: Atom.Registry.get(freshPage, s.ctx.remembered) } + })), + Then('the fresh page sees the remembered value')((s, expect) => expect(s.readings.onFreshPage).toBe(42)), + ), + ) + scenario( + 'A value remembered in the page address reads as empty when there is no address bar', + Gherkin.Do.pipe( + Given('a value remembered in the page address')('ctx', () => + Effect.sync(() => { + const plain = Atom.searchParam('q') + const decoded = Atom.searchParam('n', { schema: Schema.FiniteFromString }) + const page = Atom.Registry.make() + return { plain, decoded, page } + })), + When('the values are read and written with no address bar available')('readings', (s) => + Effect.sync(() => { + const plainBefore = Atom.Registry.get(s.ctx.page, s.ctx.plain) + const decodedBefore = Atom.Registry.get(s.ctx.page, s.ctx.decoded) + Atom.Registry.set(s.ctx.page, s.ctx.plain, 'hello') + const plainAfter = Atom.Registry.get(s.ctx.page, s.ctx.plain) + return { plainBefore, decodedBefore, plainAfter } + })), + Then('both read as empty, and a write keeps the value locally')( + (s, expect) => + expect(s.readings).toMatchObject({ + plainBefore: '', + decodedBefore: { _tag: 'None' }, + plainAfter: 'hello', + }), + ), + ), + ) + scenario( + 'A value past its stale time refreshes itself when read again', + Gherkin.Do.pipe( + Given('a stored value that goes stale quickly')('ctx', () => + Effect.sync(() => { + let stored = 1 + const source = Atom.make(Effect.sync(() => stored)) + const staleAware = source.pipe(Atom.swr({ staleTime: 100 })) + const page = Atom.Registry.make() + return { + page, + staleAware, + setStored: (n: number) => { + stored = n + }, + } + })), + When('the value goes stale, the store changes, and the value is read again')( + 'readings', + (s) => + Effect.gen(function*() { + Atom.Registry.get(s.ctx.page, s.ctx.staleAware) + Atom.Registry.refresh(s.ctx.page, s.ctx.staleAware) + const fresh = Atom.Registry.get(s.ctx.page, s.ctx.staleAware) + s.ctx.setStored(2) + yield* TestClock.adjust('200 millis') + Atom.Registry.get(s.ctx.page, s.ctx.staleAware) + const revalidated = Atom.Registry.get(s.ctx.page, s.ctx.staleAware) + return { fresh, revalidated } + }), + ), + Then('the first read was fresh, and the stale read refreshed to the new value')( + (s, expect) => + expect(s.readings).toMatchObject({ + fresh: { _tag: 'Success', value: 1 }, + revalidated: { _tag: 'Success', value: 2 }, + }), + ), + ), + ) + scenario( + 'Asking a family for the same member twice gives the very same member', + Gherkin.Do.pipe( + Given('a family of values')('ctx', () => + Effect.sync(() => { + const family = Atom.family((id: number) => Atom.make(id * 10)) + return { family } + })), + When('the same member is asked for twice')( + 'members', + (s) => Effect.sync(() => ({ first: s.ctx.family(1), second: s.ctx.family(1) })), + ), + Then('both asks returned the same member')((s, expect) => expect(s.members.first).toBe(s.members.second)), + ), + ) + scenario( + 'A change whose confirmation is still running reports itself as in flight', + Gherkin.Do.pipe( + Given('a stored value with optimistic updates that report their progress')('ctx', () => + Effect.sync(() => { + const latch = Latch.makeUnsafe() + let stored = 1 + const source = Atom.make(Effect.sync(() => stored)) + const optimisticValue = source.pipe(Atom.optimistic) + const save = optimisticValue.pipe( + Atom.optimisticFn({ + reducer: (_current, update: number) => Atom.AsyncResult.successWith(update, { waiting: true }), + fn: Atom.fn(Effect.fnUntraced(function*() { + yield* latch.await + })), + }), + Atom.keepAlive, + ) + const page = Atom.Registry.make() + return { + page, + save, + optimisticValue, + latch, + setStored: (n: number) => { + stored = n + }, + } + })), + When('the change is made and the store accepts it')('readings', (s) => + Effect.sync(() => { + Atom.Registry.set(s.ctx.page, s.ctx.save, 99) + const whilePending = Atom.Registry.get(s.ctx.page, s.ctx.optimisticValue) + s.ctx.setStored(99) + s.ctx.latch.openUnsafe() + const afterConfirmation = Atom.Registry.get(s.ctx.page, s.ctx.optimisticValue) + return { whilePending, afterConfirmation } + })), + Then('the change reported itself in flight, then settled on the stored value')( + (s, expect) => + expect(s.readings).toMatchObject({ + whilePending: { _tag: 'Success', waiting: true, value: 99 }, + afterConfirmation: { _tag: 'Success', value: 99, waiting: false }, + }), + ), + ), + ) + scenario( + 'A value derived from another value stays in step even when written through the derived value', + Gherkin.Do.pipe( + Given('a page with a value and values derived from it in two ways')('ctx', () => + Effect.sync(() => { + const base = Atom.make(10) + const doubled = Atom.map(base, (n) => n * 2) + const quadrupled = doubled.pipe(Atom.map((n) => n * 2)) + const page = Atom.Registry.make() + return { page, base, doubled, quadrupled } + })), + When('the derived values are read, then the doubled one is written through')( + 'readings', + (s) => + Effect.sync(() => { + const beforeDouble = Atom.Registry.get(s.ctx.page, s.ctx.doubled) + const beforeQuad = Atom.Registry.get(s.ctx.page, s.ctx.quadrupled) + Atom.Registry.set(s.ctx.page, s.ctx.doubled, 100) + const baseAfter = Atom.Registry.get(s.ctx.page, s.ctx.base) + const afterDouble = Atom.Registry.get(s.ctx.page, s.ctx.doubled) + const afterQuad = Atom.Registry.get(s.ctx.page, s.ctx.quadrupled) + return { beforeDouble, beforeQuad, baseAfter, afterDouble, afterQuad } + }), + ), + Then('both derived values tracked the write, and the source changed underneath')( + (s, expect) => + expect(s.readings).toMatchObject({ + beforeDouble: 20, + beforeQuad: 40, + baseAfter: 100, + afterDouble: 200, + afterQuad: 400, + }), + ), + ), + ) + scenario( + 'A value derived from a value that loads shows the mapped outcome once it arrives', + Gherkin.Do.pipe( + Given('a page with a value that loads and two mappings of it')('ctx', () => + Effect.sync(() => { + const count = Atom.fn((n: number) => Effect.succeed(n + 1)) + const mapped = count.pipe(Atom.mapResult((v) => v * 10)) + const mappedAgain = Atom.mapResult(mapped, (v) => v + 1) + const page = Atom.Registry.make() + return { page, count, mapped, mappedAgain } + })), + When('the mapped values are read before and after the source is asked to run')( + 'readings', + (s) => + Effect.sync(() => { + const before = Atom.Registry.get(s.ctx.page, s.ctx.mapped) + Atom.Registry.set(s.ctx.page, s.ctx.count, 1) + const after = Atom.Registry.get(s.ctx.page, s.ctx.mapped) + const afterAgain = Atom.Registry.get(s.ctx.page, s.ctx.mappedAgain) + return { before, after, afterAgain } + }), + ), + Then('the mapped values stayed empty until the source ran, then showed the mapped outcomes')( + (s, expect) => + expect(s.readings).toMatchObject({ + before: { _tag: 'Initial' }, + after: { _tag: 'Success', value: 20 }, + afterAgain: { _tag: 'Success', value: 21 }, + }), + ), + ), + ) + scenario( + 'A value requested on demand stays empty until it is asked for, and a value with a stand-in starts filled in', + Gherkin.Do.pipe( + Given('a page with a plain on-demand value and one with a stand-in')('ctx', () => + Effect.sync(() => { + const plain = Atom.fnSync()((n) => n * 2) + const withInitial = Atom.fnSync()((n) => n + 1, { initialValue: 0 }) + const plainFn = Atom.fn()((n) => Effect.succeed(n * 10)) + const withInitialFn = Atom.fn((n: number) => Effect.succeed(n + 1), { initialValue: 0 }) + const page = Atom.Registry.make() + return { page, plain, withInitial, plainFn, withInitialFn } + })), + When('each value is read before and after being asked to run')('readings', (s) => + Effect.sync(() => { + const plainBefore = Atom.Registry.get(s.ctx.page, s.ctx.plain) + const withInitialBefore = Atom.Registry.get(s.ctx.page, s.ctx.withInitial) + const plainFnBefore = Atom.Registry.get(s.ctx.page, s.ctx.plainFn) + const withInitialFnBefore = Atom.Registry.get(s.ctx.page, s.ctx.withInitialFn) + Atom.Registry.set(s.ctx.page, s.ctx.plain, 3) + Atom.Registry.set(s.ctx.page, s.ctx.withInitial, 4) + Atom.Registry.set(s.ctx.page, s.ctx.plainFn, 2) + Atom.Registry.set(s.ctx.page, s.ctx.withInitialFn, 5) + const plainAfter = Atom.Registry.get(s.ctx.page, s.ctx.plain) + const withInitialAfter = Atom.Registry.get(s.ctx.page, s.ctx.withInitial) + const plainFnAfter = Atom.Registry.get(s.ctx.page, s.ctx.plainFn) + const withInitialFnAfter = Atom.Registry.get(s.ctx.page, s.ctx.withInitialFn) + return { + plainBefore, + withInitialBefore, + plainFnBefore, + withInitialFnBefore, + plainAfter, + withInitialAfter, + plainFnAfter, + withInitialFnAfter, + } + })), + Then('the plain values were empty until asked, and the ones with stand-ins started filled in')( + (s, expect) => + expect(s.readings).toMatchObject({ + plainBefore: { _tag: 'None' }, + withInitialBefore: 0, + plainFnBefore: { _tag: 'Initial' }, + withInitialFnBefore: { _tag: 'Success', value: 0 }, + plainAfter: { _tag: 'Some', value: 6 }, + withInitialAfter: 5, + plainFnAfter: { _tag: 'Success', value: 20 }, + withInitialFnAfter: { _tag: 'Success', value: 6 }, + }), + ), + ), + ) + scenario( + 'A computation that is interrupted can be reset and started again', + Gherkin.Do.pipe( + Given('a page with a computation that waits for the go-ahead')('ctx', () => + Effect.sync(() => { + const latch = Latch.makeUnsafe() + const task = Atom.fn(() => latch.await) + const page = Atom.Registry.make() + Atom.Registry.subscribe(page, task, () => {}, { immediate: true }) + return { page, task, latch } + })), + When('the computation is started, interrupted, reset, and started again')( + 'readings', + (s) => + Effect.sync(() => { + const before = Atom.Registry.get(s.ctx.page, s.ctx.task) + Atom.Registry.set(s.ctx.page, s.ctx.task, void 0) + const running = Atom.Registry.get(s.ctx.page, s.ctx.task) + Atom.Registry.set(s.ctx.page, s.ctx.task, Atom.Interrupt) + const interrupted = Atom.Registry.get(s.ctx.page, s.ctx.task) + Atom.Registry.set(s.ctx.page, s.ctx.task, Atom.Reset) + const reset = Atom.Registry.get(s.ctx.page, s.ctx.task) + Atom.Registry.set(s.ctx.page, s.ctx.task, void 0) + const restarted = Atom.Registry.get(s.ctx.page, s.ctx.task) + s.ctx.latch.openUnsafe() + const finished = Atom.Registry.get(s.ctx.page, s.ctx.task) + return { before, running, interrupted, reset, restarted, finished } + }), + ), + Then('the computation reported empty, waiting, interrupted, empty again, waiting again, then done')( + (s, expect) => + expect(s.readings).toMatchObject({ + before: { _tag: 'Initial' }, + running: { _tag: 'Initial', waiting: true }, + interrupted: { _tag: 'Failure', cause: { reasons: [{ _tag: 'Interrupt' }] } }, + reset: { _tag: 'Initial' }, + restarted: { _tag: 'Initial', waiting: true }, + finished: { _tag: 'Success' }, + }), + ), + ), + ) + scenario( + 'Several runs of a computation proceed side by side and all finish', + Gherkin.Do.pipe( + Given('a page with a computation that can run several times at once')('ctx', () => + Effect.sync(() => { + const latches: Latch.Latch[] = [] + let done = 0 + const task = Atom.fn((_input: number) => { + const latch = Latch.makeUnsafe() + latches.push(latch) + return latch.await.pipe(Effect.tap(() => Effect.sync(() => done++))) + }, { concurrent: true }) + const page = Atom.Registry.make() + Atom.Registry.subscribe(page, task, () => {}, { immediate: true }) + return { page, task, latches: () => latches, done: () => done } + })), + When('the computation is asked to run three times before any of them finish')( + 'readings', + (s) => + Effect.sync(() => { + const before = Atom.Registry.get(s.ctx.page, s.ctx.task) + Atom.Registry.set(s.ctx.page, s.ctx.task, 1) + Atom.Registry.set(s.ctx.page, s.ctx.task, 2) + Atom.Registry.set(s.ctx.page, s.ctx.task, 3) + const during = Atom.Registry.get(s.ctx.page, s.ctx.task) + const started = s.ctx.latches().length + const finishedBefore = s.ctx.done() + s.ctx.latches().forEach((latch) => latch.openUnsafe()) + const finishedAfter = s.ctx.done() + const after = Atom.Registry.get(s.ctx.page, s.ctx.task) + return { before, during, started, finishedBefore, finishedAfter, after } + }), + ), + Then('all three runs started and finished, and the value reflects the latest run')( + (s, expect) => + expect(s.readings).toMatchObject({ + before: { _tag: 'Initial' }, + during: { _tag: 'Initial', waiting: true }, + started: 3, + finishedBefore: 0, + finishedAfter: 3, + after: { _tag: 'Success' }, + }), + ), + ), + ) + scenario( + 'A value with a stand-in shows the stand-in until the fetch answers, then the real answer', + Gherkin.Do.pipe( + Given('a page with a value that fetches from a signal it cannot control yet')('ctx', () => + Effect.sync(() => { + const gate = Deferred.makeUnsafe() + const value = Atom.makeWith(Deferred.await(gate), { initialValue: 0 }) + const page = Atom.Registry.make() + Atom.Registry.subscribe(page, value, () => {}, { immediate: true }) + return { gate, page, value } + })), + When('the value is read, the fetch answers, and the value is read again')( + 'readings', + (s) => + Effect.gen(function*() { + const before = Atom.Registry.get(s.ctx.page, s.ctx.value) + yield* Deferred.succeed(s.ctx.gate, 1) + const after = Atom.Registry.get(s.ctx.page, s.ctx.value) + return { before, after } + }), + ), + Then('the stand-in was shown while waiting, then the fetched answer replaced it')( + (s, expect) => + expect(s.readings).toMatchObject({ + before: { _tag: 'Success', waiting: true, value: 0 }, + after: { _tag: 'Success', waiting: false, value: 1 }, + }), + ), + ), + ) + scenario( + 'A value that streams its result fills in once the pieces arrive', + Gherkin.Do.pipe( + Given('a page with a value that streams its result when asked to run')('ctx', () => + Effect.sync(() => { + const gate = Deferred.makeUnsafe() + const count = Atom.fn((start: number) => Stream.fromEffect(Deferred.await(gate).pipe(Effect.as(start + 1)))) + const page = Atom.Registry.make() + Atom.Registry.subscribe(page, count, () => {}, { immediate: true }) + return { gate, page, count } + })), + When('the value is read, the computation is asked to run, and the signal arrives before it is read again')( + 'readings', + (s) => + Effect.gen(function*() { + const before = Atom.Registry.get(s.ctx.page, s.ctx.count) + Atom.Registry.set(s.ctx.page, s.ctx.count, 1) + const during = Atom.Registry.get(s.ctx.page, s.ctx.count) + yield* Deferred.succeed(s.ctx.gate, 1) + const after = Atom.Registry.get(s.ctx.page, s.ctx.count) + return { before, during, after } + }), + ), + Then('the value was empty, then waiting, then filled in with the streamed answer')( + (s, expect) => + expect(s.readings).toMatchObject({ + before: { _tag: 'Initial' }, + during: { _tag: 'Initial', waiting: true }, + after: { _tag: 'Success', waiting: false, value: 2 }, + }), + ), + ), + ) + scenario( + 'A streamed value shows its stand-in first, then the real answer, and keeps it across a refresh', + Gherkin.Do.pipe( + Given('a page with a streamed value that waits for a signal before emitting')('ctx', () => + Effect.sync(() => { + const gate = Deferred.makeUnsafe() + const value = Atom.makeWith(Stream.fromEffect(Deferred.await(gate)), { initialValue: 0 }) + const page = Atom.Registry.make() + Atom.Registry.subscribe(page, value, () => {}, { immediate: true }) + return { gate, page, value } + })), + When('the value is read, the signal arrives, and the value is read again before and after a refresh')( + 'readings', + (s) => + Effect.gen(function*() { + const before = Atom.Registry.get(s.ctx.page, s.ctx.value) + yield* Deferred.succeed(s.ctx.gate, 5) + const loaded = Atom.Registry.get(s.ctx.page, s.ctx.value) + Atom.Registry.refresh(s.ctx.page, s.ctx.value) + const afterRefresh = Atom.Registry.get(s.ctx.page, s.ctx.value) + const settled = Atom.Registry.get(s.ctx.page, s.ctx.value) + return { before, loaded, afterRefresh, settled } + }), + ), + Then('the stand-in showed first, the streamed answer arrived, the refresh kept it, and it settled')( + (s, expect) => + expect(s.readings).toMatchObject({ + before: { _tag: 'Success', waiting: true, value: 0 }, + loaded: { _tag: 'Success', value: 5 }, + afterRefresh: { _tag: 'Success', value: 5 }, + settled: { _tag: 'Success', waiting: false, value: 5 }, + }), + ), + ), + ) + scenario( + 'A streamed value that runs dry, one that fails, and one read from a plain recipe all report their own fate', + Gherkin.Do.pipe( + Given('a page with a streamed value that runs dry, one that fails, and one from a plain recipe')( + 'ctx', + () => + Effect.sync(() => { + const empty = Atom.make(Stream.empty) + const failing = Atom.make(Stream.fail('boom' as const)) + const streamed = Atom.make(() => Stream.succeed(7)) + const page = Atom.Registry.make() + Atom.Registry.subscribe(page, empty, () => {}, { immediate: true }) + Atom.Registry.subscribe(page, failing, () => {}, { immediate: true }) + Atom.Registry.subscribe(page, streamed, () => {}, { immediate: true }) + return { page, empty, failing, streamed } + }), + ), + When('all three are read after their streams have had a chance to finish')( + 'readings', + (s) => + Effect.sync(() => { + const emptyResult = Atom.Registry.get(s.ctx.page, s.ctx.empty) + const failingResult = Atom.Registry.get(s.ctx.page, s.ctx.failing) + const streamedResult = Atom.Registry.get(s.ctx.page, s.ctx.streamed) + return { emptyResult, failingResult, streamedResult } + }), + ), + Then( + 'the dry stream reported nothing to show, the failing stream reported the failure, and the plain one filled in', + )( + (s, expect) => + expect(s.readings).toMatchObject({ + emptyResult: { + _tag: 'Failure', + cause: { reasons: [{ _tag: 'Fail', error: { _tag: 'NoSuchElementError' } }] }, + }, + failingResult: { _tag: 'Failure', cause: { reasons: [{ _tag: 'Fail', error: 'boom' }] } }, + streamedResult: { _tag: 'Success', value: 7 }, + }), + ), + ), + ) + scenario( + 'A feed pulled in batches keeps only the newest batch when asked to', + Gherkin.Do.pipe( + Given('a page with a feed that arrives in two batches, keeping only the newest')( + 'ctx', + () => + Effect.sync(() => { + const feed = Atom.pull(Stream.make(1, 2).pipe(Stream.concat(Stream.make(3, 4))), { + disableAccumulation: true, + }) + const page = Atom.Registry.make() + Atom.Registry.subscribe(page, feed, () => {}, { immediate: true }) + return { page, feed } + }), + ), + When('the feed is pulled twice')('result', (s) => + Effect.sync(() => { + Atom.Registry.set(s.ctx.page, s.ctx.feed, void 0) + return Atom.Registry.get(s.ctx.page, s.ctx.feed) + })), + Then('the second batch replaced the first instead of joining it')( + (s, expect) => expect(s.result).toMatchObject({ _tag: 'Success', value: { done: false, items: [3, 4] } }), + ), + ), + ) + scenario( + 'A feed that runs dry reports that it had nothing more to show', + Gherkin.Do.pipe( + Given('a page with a feed that is empty')('ctx', () => + Effect.sync(() => { + const feed = Atom.pull(Stream.empty) + const page = Atom.Registry.make() + Atom.Registry.subscribe(page, feed, () => {}, { immediate: true }) + return { page, feed } + })), + When('the feed is read after its batch has had a chance to arrive')( + 'result', + (s) => Effect.sync(() => Atom.Registry.get(s.ctx.page, s.ctx.feed)), + ), + Then('the feed reports that there was nothing to show')( + (s, expect) => + expect(s.result).toMatchObject({ + _tag: 'Failure', + cause: { reasons: [{ _tag: 'Fail', error: { _tag: 'NoSuchElementError' } }] }, + }), + ), + ), + ) + scenario( + 'A feed that fails reports the failure', + Gherkin.Do.pipe( + Given('a page with a feed that fails')('ctx', () => + Effect.sync(() => { + const feed = Atom.pull(Stream.fail('boom' as const)) + const page = Atom.Registry.make() + Atom.Registry.subscribe(page, feed, () => {}, { immediate: true }) + return { page, feed } + })), + When('the feed is read after its batch has had a chance to arrive')( + 'result', + (s) => Effect.sync(() => Atom.Registry.get(s.ctx.page, s.ctx.feed)), + ), + Then('the feed reports the failure')((s, expect) => + expect(s.result).toMatchObject({ _tag: 'Failure', cause: { reasons: [{ _tag: 'Fail', error: 'boom' }] } }) + ), + ), + ) + scenario( + 'Two requests for the next batch at the same time both arrive once the signal comes', + Gherkin.Do.pipe( + Given('a page with a feed whose batches wait for a signal before arriving')('ctx', () => + Effect.sync(() => { + const gate = Deferred.makeUnsafe() + const feed = Atom.pull(() => Stream.fromEffectRepeat(Deferred.await(gate).pipe(Effect.as(7)))) + const page = Atom.Registry.make() + Atom.Registry.subscribe(page, feed, () => {}, { immediate: true }) + return { gate, page, feed } + })), + When('the feed is asked for two batches at once, then the signal arrives')( + 'result', + (s) => + Effect.gen(function*() { + Atom.Registry.set(s.ctx.page, s.ctx.feed, void 0) + Atom.Registry.set(s.ctx.page, s.ctx.feed, void 0) + yield* Deferred.succeed(s.ctx.gate, 7) + return yield* waitForValue( + s.ctx.page, + s.ctx.feed, + (value) => Atom.AsyncResult.isSuccess(value) && [...value.value.items].length === 3, + ) + }), + ), + Then('every batch that was asked for arrived once the signal came')( + (s, expect) => expect(s.result).toMatchObject({ _tag: 'Success', value: { done: false, items: [7, 7, 7] } }), + ), + ), + ) + scenario( + 'A live view of a shared reference follows the reference both ways, and a view whose reference cannot start reports it', + Gherkin.Do.pipe( + Given('a page with several live views of shared references')('ctx', () => + Effect.gen(function*() { + const ref = yield* SubscriptionRef.make(0) + const view = Atom.subscriptionRef(ref) + const effectView = Atom.subscriptionRef(SubscriptionRef.make(0)) + const functionView = Atom.subscriptionRef((_get) => SubscriptionRef.make(0)) + const brokenView = Atom.subscriptionRef(Effect.fail('nope' as const)) + const page = Atom.Registry.make() + return { ref, page, view, effectView, functionView, brokenView } + })), + When('the views are read, written through, and the underlying reference changes')( + 'readings', + (s) => + Effect.gen(function*() { + const viewBefore = yield* waitForValue(s.ctx.page, s.ctx.view, (value) => value === 0) + const effectBefore = yield* waitForValue(s.ctx.page, s.ctx.effectView, isSuccessfulResult) + const functionBefore = yield* waitForValue(s.ctx.page, s.ctx.functionView, isSuccessfulResult) + const brokenBefore = yield* waitForValue(s.ctx.page, s.ctx.brokenView, isFailureResult) + Atom.Registry.set(s.ctx.page, s.ctx.view, 5) + const viewWritten = yield* waitForValue(s.ctx.page, s.ctx.view, (value) => value === 5) + Atom.Registry.set(s.ctx.page, s.ctx.effectView, 3) + const effectWritten = yield* waitForValue(s.ctx.page, s.ctx.effectView, isSuccessfulResult) + yield* SubscriptionRef.set(s.ctx.ref, 9) + const viewChanged = yield* waitForValue(s.ctx.page, s.ctx.view, (value) => value === 9) + return { viewBefore, effectBefore, functionBefore, brokenBefore, viewWritten, effectWritten, viewChanged } + }), + ), + Then( + 'every view tracked its reference, writes reached the references, and the broken view reported its problem', + )( + (s, expect) => + expect(s.readings).toMatchObject({ + viewBefore: 0, + effectBefore: { _tag: 'Success' }, + functionBefore: { _tag: 'Success' }, + brokenBefore: { _tag: 'Failure', cause: { reasons: [{ _tag: 'Fail', error: 'nope' }] } }, + viewWritten: 5, + effectWritten: { _tag: 'Success' }, + viewChanged: 9, + }), + ), + ), + ) + scenario( + 'A page with services computes values and runs tasks against those services', + Gherkin.Do.pipe( + Given('a page with services that supply a starting number')('ctx', () => + Effect.sync(() => { + class Counter extends Context.Service()( + '@systemfsoftware/effect-atom/tests/Atom.integration.test/Counter', + ) {} + const counterRuntime = Atom.context()(Layer.sync(Counter, () => 1)) + const count = counterRuntime.atom(Counter.use((n) => Effect.succeed(n))) + const doubled = counterRuntime.atom((_get) => Counter.use((n) => Effect.succeed(n * 2))) + const add = counterRuntime.fn((n: number) => Counter.use((c) => Effect.succeed(c + n))) + const curried = counterRuntime.fn()((n) => Counter.use((c) => Effect.succeed(c + n * 10))) + const reactive = counterRuntime.fn((n: number) => Counter.use((c) => Effect.succeed(c + n)), { + reactivityKeys: ['count'], + }) + const reactiveStream = counterRuntime.fn((n: number) => Stream.succeed(n), { + reactivityKeys: ['count'], + }) + const feed = counterRuntime.pull(Stream.make(1)) + const streamed = counterRuntime.atom(Stream.succeed(1)) + const refView = counterRuntime.subscriptionRef(SubscriptionRef.make(0)) + const refFromFunction = counterRuntime.subscriptionRef(() => SubscriptionRef.make(0)) + const page = Atom.Registry.make() + return { + page, + count, + doubled, + add, + curried, + reactive, + reactiveStream, + feed, + streamed, + refView, + refFromFunction, + } + })), + When('the values are read and the tasks are asked to run')('readings', (s) => + Effect.sync(() => { + const countResult = Atom.Registry.get(s.ctx.page, s.ctx.count) + const doubledResult = Atom.Registry.get(s.ctx.page, s.ctx.doubled) + Atom.Registry.set(s.ctx.page, s.ctx.add, 4) + const addResult = Atom.Registry.get(s.ctx.page, s.ctx.add) + Atom.Registry.set(s.ctx.page, s.ctx.curried, 2) + const curriedResult = Atom.Registry.get(s.ctx.page, s.ctx.curried) + Atom.Registry.set(s.ctx.page, s.ctx.reactive, 4) + const reactiveResult = Atom.Registry.get(s.ctx.page, s.ctx.reactive) + Atom.Registry.set(s.ctx.page, s.ctx.reactiveStream, 3) + const reactiveStreamResult = Atom.Registry.get(s.ctx.page, s.ctx.reactiveStream) + Atom.Registry.subscribe(s.ctx.page, s.ctx.feed, () => {}, { immediate: true }) + Atom.Registry.set(s.ctx.page, s.ctx.feed, void 0) + const feedResult = Atom.Registry.get(s.ctx.page, s.ctx.feed) + Atom.Registry.subscribe(s.ctx.page, s.ctx.streamed, () => {}, { immediate: true }) + const streamedResult = Atom.Registry.get(s.ctx.page, s.ctx.streamed) + Atom.Registry.subscribe(s.ctx.page, s.ctx.refView, () => {}, { immediate: true }) + Atom.Registry.subscribe(s.ctx.page, s.ctx.refFromFunction, () => {}, { immediate: true }) + const refResult = Atom.Registry.get(s.ctx.page, s.ctx.refView) + const refFunctionResult = Atom.Registry.get(s.ctx.page, s.ctx.refFromFunction) + return { + countResult, + doubledResult, + addResult, + curriedResult, + reactiveResult, + reactiveStreamResult, + feedResult, + streamedResult, + refResult, + refFunctionResult, + } + })), + Then('every value came from the services and every task ran against them')( + (s, expect) => + expect(s.readings).toMatchObject({ + countResult: { _tag: 'Success', value: 1 }, + doubledResult: { _tag: 'Success', value: 2 }, + addResult: { _tag: 'Success', value: 5 }, + curriedResult: { _tag: 'Success', value: 21 }, + reactiveResult: { _tag: 'Success', value: 5 }, + reactiveStreamResult: { _tag: 'Success', value: 3 }, + feedResult: { _tag: 'Success', value: { done: true } }, + streamedResult: { _tag: 'Success', value: 1 }, + refResult: { _tag: 'Success', value: 0 }, + refFunctionResult: { _tag: 'Success', value: 0 }, + }), + ), + ), + ) + scenario( + 'A page whose services are built from a recipe answers with the recipe result', + Gherkin.Do.pipe( + Given('a page whose services are built from a recipe that reads the page first')( + 'ctx', + () => + Effect.sync(() => { + class CounterFromRecipe extends Context.Service()( + '@systemfsoftware/effect-atom/tests/Atom.integration.test/CounterFromRecipe', + ) {} + const recipeRuntime = Atom.context()((_get) => Layer.sync(CounterFromRecipe, () => 7)) + const count = recipeRuntime.atom(CounterFromRecipe.use((n) => Effect.succeed(n))) + const page = Atom.Registry.make() + return { page, count } + }), + ), + When('the value is read')('reading', (s) => Effect.sync(() => Atom.Registry.get(s.ctx.page, s.ctx.count))), + Then('it reflects the number the recipe provided')( + (s, expect) => expect(s.reading).toMatchObject({ _tag: 'Success', value: 7 }), + ), + ), + ) + scenario( + 'A page whose services fail reports that every value is unavailable', + Gherkin.Do.pipe( + Given('a page whose services fail to start')('ctx', () => + Effect.sync(() => { + const brokenRuntime = Atom.context()(Layer.effectDiscard(Effect.fail('boom' as const))) + const count = brokenRuntime.atom(Effect.succeed(1)) + const add = brokenRuntime.fn((n: number) => Effect.succeed(n)) + const feed = brokenRuntime.pull(Stream.make(1)) + const refView = brokenRuntime.subscriptionRef(SubscriptionRef.make(0)) + const page = Atom.Registry.make() + return { page, count, add, feed, refView } + })), + When('every value is read')('readings', (s) => + Effect.sync(() => ({ + count: Atom.Registry.get(s.ctx.page, s.ctx.count), + add: Atom.Registry.get(s.ctx.page, s.ctx.add), + feed: Atom.Registry.get(s.ctx.page, s.ctx.feed), + refView: Atom.Registry.get(s.ctx.page, s.ctx.refView), + }))), + Then('every value reports the failure')( + (s, expect) => + expect(s.readings).toMatchObject({ + count: { _tag: 'Failure', cause: { reasons: [{ _tag: 'Fail', error: 'boom' }] } }, + add: { _tag: 'Failure', cause: { reasons: [{ _tag: 'Fail', error: 'boom' }] } }, + feed: { _tag: 'Failure', cause: { reasons: [{ _tag: 'Fail', error: 'boom' }] } }, + refView: { _tag: 'Failure', cause: { reasons: [{ _tag: 'Fail', error: 'boom' }] } }, + }), + ), + ), + ) + scenario( + 'A saved value starts a fresh page already filled in', + Gherkin.Do.pipe( + Given('a saved value and a fresh page seeded with it')('ctx', () => + Effect.sync(() => { + const count = Atom.make(0).pipe(Atom.serializable({ key: 'count', schema: Schema.Finite })) + const page = Atom.Registry.make({ initialValues: [Atom.initialValue(count, 10)] }) + return { page, count } + })), + When('the value is read and changed')('readings', (s) => + Effect.sync(() => { + const seeded = Atom.Registry.get(s.ctx.page, s.ctx.count) + Atom.Registry.set(s.ctx.page, s.ctx.count, 5) + const afterChange = Atom.Registry.get(s.ctx.page, s.ctx.count) + return { seeded, afterChange } + })), + Then('the page started with the saved value and kept the change')( + (s, expect) => expect(s.readings).toMatchObject({ seeded: 10, afterChange: 5 }), + ), + ), + ) + scenario( + 'A saved value arriving while the page already shows the value updates it in place', + Gherkin.Do.pipe( + Given('a page already showing a saved value')('ctx', () => + Effect.sync(() => { + const count = Atom.make(0).pipe(Atom.serializable({ key: 'count', schema: Schema.Finite })) + const page = Atom.Registry.make() + return { page, count } + })), + When('the value is shown, then the saved copy arrives')('readings', (s) => + Effect.sync(() => { + const before = Atom.Registry.get(s.ctx.page, s.ctx.count) + Atom.Registry.setSerializable(s.ctx.page, 'count', 42) + const after = Atom.Registry.get(s.ctx.page, s.ctx.count) + return { before, after } + })), + Then('the value on the page was replaced by the saved copy')( + (s, expect) => expect(s.readings).toMatchObject({ before: 0, after: 42 }), + ), + ), + ) + scenario( + 'A saved value arriving before the page asks for it is applied when it does', + Gherkin.Do.pipe( + Given('a saved value arriving before the page is asked about it')('ctx', () => + Effect.sync(() => { + const count = Atom.make(0).pipe(Atom.serializable({ key: 'count', schema: Schema.Finite })) + const page = Atom.Registry.make() + Atom.Registry.setSerializable(page, 'count', 42) + return { page, count } + })), + When('the value is read')('reading', (s) => Effect.sync(() => Atom.Registry.get(s.ctx.page, s.ctx.count))), + Then('it shows the saved value')((s, expect) => expect(s.reading).toBe(42)), + ), + ) + scenario( + 'A saved derived value restores the value it was derived from, until the derived value refreshes', + Gherkin.Do.pipe( + Given('a page with a derived value that refreshes on its own schedule, saved under a key')( + 'ctx', + () => + Effect.sync(() => { + const base = Atom.make(0) + const derived = base.pipe(Atom.withRefresh(1000)) + const saved = derived.pipe(Atom.serializable({ key: 'derived', schema: Schema.Finite })) + const page = Atom.Registry.make() + const unmount = Atom.Registry.subscribe(page, saved, () => {}, { immediate: true }) + return { page, saved, unmount } + }), + ), + When('the saved copy arrives, the value is read, then its refresh schedule runs out and it is read again')( + 'readings', + (s) => + Effect.gen(function*() { + Atom.Registry.setSerializable(s.ctx.page, 'derived', 99) + const restored = Atom.Registry.get(s.ctx.page, s.ctx.saved) + yield* TestClock.adjust('2000 millis') + const afterRefresh = Atom.Registry.get(s.ctx.page, s.ctx.saved) + s.ctx.unmount() + return { restored, afterRefresh } + }), + ), + Then('the derived value was restored from the saved copy, then returned to its own schedule')( + (s, expect) => expect(s.readings).toMatchObject({ restored: 99, afterRefresh: 0 }), + ), + ), + ) + scenario( + 'A saved value keeps its own name when given one', + Gherkin.Do.pipe( + Given('a saved value that was given a name before being saved')('ctx', () => + Effect.sync(() => { + const named = Atom.make(0).pipe( + Atom.withLabel('my-count'), + Atom.serializable({ key: 'named', schema: Schema.Finite }), + ) + const page = Atom.Registry.make() + Atom.Registry.subscribe(page, named, () => {}, { immediate: true }) + return { page, named } + })), + When('the value is read and its name is asked for')('readings', (s) => + Effect.sync(() => ({ + value: Atom.Registry.get(s.ctx.page, s.ctx.named), + name: s.ctx.named.spec.label?.[0], + }))), + Then('the value works normally and keeps the name it was given')( + (s, expect) => expect(s.readings).toMatchObject({ value: 0, name: 'my-count' }), + ), + ), + ) + scenario( + 'A page serves its values to the server, using each value\u2019s own server recipe', + Gherkin.Do.pipe( + Given('a page with plain, overridden, and nested values')('ctx', () => + Effect.sync(() => { + const local = Atom.make(0) + const overridden = Atom.make(1).pipe(Atom.withServerValue(() => 7)) + const nested = Atom.make((get) => get(local) * 2).pipe(Atom.withServerValue((get) => get(local) * 3)) + const notYetLoaded = Atom.make(Effect.succeed(3)).pipe(Atom.withServerValueInitial) + const page = Atom.Registry.make() + return { page, local, overridden, nested, notYetLoaded } + })), + When('the local value is changed and every value is read for the server')('readings', (s) => + Effect.sync(() => { + Atom.Registry.set(s.ctx.page, s.ctx.local, 5) + const plain = Atom.getServerValue(s.ctx.local, s.ctx.page) + const overriddenValue = Atom.getServerValue(s.ctx.overridden, s.ctx.page) + const nestedValue = Atom.getServerValue(s.ctx.nested, s.ctx.page) + const notYetLoadedValue = Atom.getServerValue(s.ctx.notYetLoaded, s.ctx.page) + return { plain, overriddenValue, nestedValue, notYetLoadedValue } + })), + Then('each value followed its own server recipe')( + (s, expect) => + expect(s.readings).toMatchObject({ + plain: 5, + overriddenValue: 7, + nestedValue: 15, + notYetLoadedValue: { _tag: 'Initial', waiting: true }, + }), + ), + ), + ) + scenario( + 'A writable value with a stored copy shows the copy until it runs, and writes go through to it', + Gherkin.Do.pipe( + Given('a page with a writable value and a stored copy to fall back on')('ctx', () => + Effect.sync(() => { + const fallback = Atom.make(Atom.AsyncResult.success('cached' as const)) + const source = Atom.fn((n: number) => Effect.succeed(String(n))) + const withStandIn = source.pipe(Atom.withFallback(fallback)) + const mappedSource = source.pipe(Atom.mapResult((v) => `${v}!`)) + const mappedWithStandIn = mappedSource.pipe(Atom.withFallback(fallback)) + const page = Atom.Registry.make() + return { page, withStandIn, mappedWithStandIn } + })), + When('the values are read, written through, and read again')('readings', (s) => + Effect.sync(() => { + const before = Atom.Registry.get(s.ctx.page, s.ctx.withStandIn) + const mappedBefore = Atom.Registry.get(s.ctx.page, s.ctx.mappedWithStandIn) + Atom.Registry.set(s.ctx.page, s.ctx.withStandIn, 1) + const after = Atom.Registry.get(s.ctx.page, s.ctx.withStandIn) + Atom.Registry.set(s.ctx.page, s.ctx.mappedWithStandIn, 2) + const mappedAfter = Atom.Registry.get(s.ctx.page, s.ctx.mappedWithStandIn) + return { before, after, mappedBefore, mappedAfter } + })), + Then('the stored copy showed until the value ran, then the real outcome replaced it')( + (s, expect) => + expect(s.readings).toMatchObject({ + before: { _tag: 'Success', waiting: true, value: 'cached' }, + after: { _tag: 'Success', waiting: false, value: '1' }, + mappedBefore: { _tag: 'Success', waiting: true, value: 'cached' }, + mappedAfter: { _tag: 'Success', waiting: false, value: '2!' }, + }), + ), + ), + ) + scenario( + 'A value past its fresh time refreshes when the page regains attention, and one set to always refresh does too', + Gherkin.Do.pipe( + Given('a page with two values that refresh on attention, one only when stale')('ctx', () => + Effect.sync(() => { + let stored = 1 + const source = Atom.make(Effect.sync(() => stored)) + const focus = Atom.make(0) + const onFocus = source.pipe(Atom.swr({ staleTime: 100, revalidateOnFocus: true, focusSignal: focus })) + const alwaysOnFocus = source.pipe( + Atom.swr({ staleTime: 100, revalidateOnFocus: 'always', focusSignal: focus }), + ) + const page = Atom.Registry.make() + return { + page, + onFocus, + alwaysOnFocus, + focus, + setStored: (n: number) => { + stored = n + }, + } + })), + When('the values are read, they go stale, the store changes, and the page regains attention')( + 'readings', + (s) => + Effect.gen(function*() { + const first = Atom.Registry.get(s.ctx.page, s.ctx.onFocus) + const firstAlways = Atom.Registry.get(s.ctx.page, s.ctx.alwaysOnFocus) + s.ctx.setStored(2) + yield* TestClock.adjust('200 millis') + Atom.Registry.set(s.ctx.page, s.ctx.focus, 1) + const revalidated = Atom.Registry.get(s.ctx.page, s.ctx.onFocus) + const revalidatedAlways = Atom.Registry.get(s.ctx.page, s.ctx.alwaysOnFocus) + return { first, firstAlways, revalidated, revalidatedAlways } + }), + ), + Then('both values refreshed to the new store value when attention returned')( + (s, expect) => + expect(s.readings).toMatchObject({ + first: { _tag: 'Success', value: 1 }, + firstAlways: { _tag: 'Success', value: 1 }, + revalidated: { _tag: 'Success', value: 2 }, + revalidatedAlways: { _tag: 'Success', value: 2 }, + }), + ), + ), + ) + scenario( + 'A value that is already stale when first shown is not fetched again until something asks for it', + Gherkin.Do.pipe( + Given('a page with a value that should not fetch again on first sight')('ctx', () => + Effect.sync(() => { + let reads = 0 + const source = Atom.make(Effect.sync(() => { + reads++ + return 1 + })) + const quiet = source.pipe(Atom.swr({ staleTime: 0, revalidateOnMount: false })) + const page = Atom.Registry.make() + return { page, quiet, reads: () => reads } + })), + When('the value is read, then asked to refresh, and read again')('readings', (s) => + Effect.sync(() => { + const first = Atom.Registry.get(s.ctx.page, s.ctx.quiet) + const readsAfterFirst = s.ctx.reads() + Atom.Registry.refresh(s.ctx.page, s.ctx.quiet) + const second = Atom.Registry.get(s.ctx.page, s.ctx.quiet) + const readsAfterSecond = s.ctx.reads() + return { first, readsAfterFirst, second, readsAfterSecond } + })), + Then('the first sight did not fetch again, and only the later refresh did')((s, expect) => + expect({ + first: Atom.AsyncResult.value(s.readings.first), + readsAfterFirst: s.readings.readsAfterFirst, + second: Atom.AsyncResult.value(s.readings.second), + readsAfterSecond: s.readings.readsAfterSecond, + }).toMatchObject({ + first: Option.some(1), + readsAfterFirst: 1, + second: Option.some(1), + readsAfterSecond: expect.schemaMatching(Schema.Int.pipe(Schema.check(Schema.isGreaterThan(1)))), + }) + ), + ), + ) + scenario( + 'A value that failed and one that is still waiting are left alone', + Gherkin.Do.pipe( + Given('a page with a value that failed and one that is still waiting')('ctx', () => + Effect.sync(() => { + const failing = Atom.make(Effect.fail('down' as const)).pipe(Atom.swr({ staleTime: 100 })) + const waiting = Atom.make(() => Atom.AsyncResult.successWith(1, { waiting: true })).pipe( + Atom.swr({ staleTime: 100 }), + ) + const page = Atom.Registry.make() + return { page, failing, waiting } + })), + When('both values are read')('readings', (s) => + Effect.sync(() => ({ + failing: Atom.Registry.get(s.ctx.page, s.ctx.failing), + waiting: Atom.Registry.get(s.ctx.page, s.ctx.waiting), + }))), + Then('the failed value reports its failure and the waiting value stays waiting')( + (s, expect) => + expect(s.readings).toMatchObject({ + failing: { _tag: 'Failure', cause: { reasons: [{ _tag: 'Fail', error: 'down' }] } }, + waiting: { _tag: 'Success', waiting: true, value: 1 }, + }), + ), + ), + ) + scenario( + 'A change confirmed through a working copy reports its progress and then the confirmed value', + Gherkin.Do.pipe( + Given('a page with two working copies that report progress as they confirm')('ctx', () => + Effect.sync(() => { + const latch = Latch.makeUnsafe() + let stored = 1 + const source = Atom.make(() => stored) + const optimisticValue = source.pipe(Atom.optimistic) + const save = optimisticValue.pipe( + Atom.optimisticFn({ + reducer: (_current, update: number) => update, + fn: (set) => + Atom.fn(Effect.fnUntraced(function*(n: number) { + set(n * 10) + yield* latch.await + return n + })), + }), + Atom.keepAlive, + ) + const latch2 = Latch.makeUnsafe() + let stored2 = 1 + const source2 = Atom.make(Effect.sync(() => stored2)) + const optimistic2 = source2.pipe(Atom.optimistic) + const save2 = optimistic2.pipe( + Atom.optimisticFn({ + reducer: (_current, update: number) => Atom.AsyncResult.successWith(update, { waiting: true }), + fn: (set) => + Atom.fn(Effect.fnUntraced(function*(n: number) { + set(Atom.AsyncResult.successWith(n * 10, { waiting: true })) + yield* latch2.await + return n + })), + }), + Atom.keepAlive, + ) + const page = Atom.Registry.make() + return { + page, + optimisticValue, + save, + latch, + setStored: (n: number) => { + stored = n + }, + optimistic2, + save2, + latch2, + setStored2: (n: number) => { + stored2 = n + }, + } + })), + When('both changes are made, confirmed, and read throughout')('readings', (s) => + Effect.sync(() => { + const before = Atom.Registry.get(s.ctx.page, s.ctx.optimisticValue) + Atom.Registry.set(s.ctx.page, s.ctx.save, 99) + const whilePending = Atom.Registry.get(s.ctx.page, s.ctx.optimisticValue) + Atom.Registry.set(s.ctx.page, s.ctx.save, 99) + s.ctx.setStored(99) + s.ctx.latch.openUnsafe() + const afterConfirmation = Atom.Registry.get(s.ctx.page, s.ctx.optimisticValue) + const before2 = Atom.Registry.get(s.ctx.page, s.ctx.optimistic2) + Atom.Registry.set(s.ctx.page, s.ctx.save2, 99) + const whilePending2 = Atom.Registry.get(s.ctx.page, s.ctx.optimistic2) + s.ctx.setStored2(99) + s.ctx.latch2.openUnsafe() + const afterConfirmation2 = Atom.Registry.get(s.ctx.page, s.ctx.optimistic2) + return { before, whilePending, afterConfirmation, before2, whilePending2, afterConfirmation2 } + })), + Then('each change showed its progress, then settled on the confirmed value')( + (s, expect) => + expect(s.readings).toMatchObject({ + before: 1, + whilePending: 990, + afterConfirmation: 99, + before2: { _tag: 'Success', value: 1 }, + whilePending2: { _tag: 'Success', waiting: true, value: 990 }, + afterConfirmation2: { _tag: 'Success', waiting: false, value: 99 }, + }), + ), + ), + ) + scenario( + 'A change that is accepted right away settles on the confirmed value without waiting', + Gherkin.Do.pipe( + Given('a page with a working copy whose confirmation is immediate')('ctx', () => + Effect.sync(() => { + let stored = 1 + const source = Atom.make(() => stored) + const optimisticValue = source.pipe(Atom.optimistic) + const save = optimisticValue.pipe( + Atom.optimisticFn({ + reducer: (_current, update: number) => update, + fn: Atom.fn((n: number) => + Effect.sync(() => { + stored = n + }) + ), + }), + Atom.keepAlive, + ) + const page = Atom.Registry.make() + return { page, optimisticValue, save } + })), + When('the change is made and the store accepts it right away')('readings', (s) => + Effect.sync(() => { + Atom.Registry.set(s.ctx.page, s.ctx.save, 99) + const afterConfirmation = Atom.Registry.get(s.ctx.page, s.ctx.optimisticValue) + return { afterConfirmation } + })), + Then('the confirmed value settled on screen right away')( + (s, expect) => expect(s.readings.afterConfirmation).toBe(99), + ), + ), + ) + scenario( + 'A value remembered in a slower store shows the fallback until the store answers, without overwriting the store', + Gherkin.Do.pipe( + Given('a page remembering a value in a store that answers only after a signal')('ctx', () => + Effect.sync(() => { + const storage = new Map() + storage.set('known-key', '42') + const gate = Deferred.makeUnsafe() + const DelayedKVS = Layer.succeed( + KeyValueStore.KeyValueStore, + KeyValueStore.makeStringOnly({ + get: (key) => Deferred.await(gate).pipe(Effect.as(storage.get(key))), + set: (key, value) => + Effect.sync(() => { + storage.set(key, value) + }), + remove: (key) => + Effect.sync(() => { + storage.delete(key) + }), + clear: Effect.sync(() => storage.clear()), + size: Effect.sync(() => storage.size), + }), + ) + const kvsRuntime = Atom.context()(DelayedKVS) + const remembered = Atom.kvs({ + runtime: kvsRuntime, + key: 'known-key', + schema: Schema.Finite, + defaultValue: () => 0, + }) + const page = Atom.Registry.make() + Atom.Registry.subscribe(page, remembered, () => {}, { immediate: true }) + return { gate, page, remembered, storage } + })), + When('the value is read, the store answers, and the value is read again')( + 'readings', + (s) => + Effect.gen(function*() { + const whileLoading = Atom.Registry.get(s.ctx.page, s.ctx.remembered) + yield* Deferred.succeed(s.ctx.gate, void 0) + const loaded = Atom.Registry.get(s.ctx.page, s.ctx.remembered) + const stored = s.ctx.storage.get('known-key') + return { whileLoading, loaded, stored } + }), + ), + Then('the fallback showed while loading, then the stored value appeared and the store was untouched')( + (s, expect) => expect(s.readings).toMatchObject({ whileLoading: 0, loaded: 42, stored: '42' }), + ), + ), + ) + scenario( + 'A write made before the store answers wins over the slower store read', + Gherkin.Do.pipe( + Given('a page remembering a value in a store that answers only after a signal')('ctx', () => + Effect.sync(() => { + const storage = new Map() + storage.set('known-key', '42') + const gate = Deferred.makeUnsafe() + const DelayedKVS = Layer.succeed( + KeyValueStore.KeyValueStore, + KeyValueStore.makeStringOnly({ + get: (key) => + Effect.sync(() => storage.get(key)).pipe( + Effect.flatMap((stale) => Deferred.await(gate).pipe(Effect.as(stale))), + ), + set: (key, value) => + Effect.sync(() => { + storage.set(key, value) + }), + remove: (key) => + Effect.sync(() => { + storage.delete(key) + }), + clear: Effect.sync(() => storage.clear()), + size: Effect.sync(() => storage.size), + }), + ) + const kvsRuntime = Atom.context()(DelayedKVS) + const remembered = Atom.kvs({ + runtime: kvsRuntime, + key: 'known-key', + schema: Schema.Finite, + defaultValue: () => 0, + }) + const page = Atom.Registry.make() + Atom.Registry.subscribe(page, remembered, () => {}, { immediate: true }) + return { gate, page, remembered } + })), + When('a write is made, then the store answers')('value', (s) => + Effect.gen(function*() { + Atom.Registry.set(s.ctx.page, s.ctx.remembered, 99) + yield* Deferred.succeed(s.ctx.gate, void 0) + return Atom.Registry.get(s.ctx.page, s.ctx.remembered) + })), + Then('the written value wins over the slower store read')((s, expect) => expect(s.value).toBe(99)), + ), + ) + scenario( + 'A value remembered in loading mode exposes its loading state and accepts writes', + Gherkin.Do.pipe( + Given('a page remembering a missing value in a store that answers only after a signal')( + 'ctx', + () => + Effect.sync(() => { + const storage = new Map() + const gate = Deferred.makeUnsafe() + const DelayedKVS = Layer.succeed( + KeyValueStore.KeyValueStore, + KeyValueStore.makeStringOnly({ + get: (key) => Deferred.await(gate).pipe(Effect.as(storage.get(key))), + set: (key, value) => + Effect.sync(() => { + storage.set(key, value) + }), + remove: (key) => + Effect.sync(() => { + storage.delete(key) + }), + clear: Effect.sync(() => storage.clear()), + size: Effect.sync(() => storage.size), + }), + ) + const kvsRuntime = Atom.context()(DelayedKVS) + const remembered = Atom.kvs({ + mode: 'async', + runtime: kvsRuntime, + key: 'fresh-key', + schema: Schema.Finite, + defaultValue: () => 0, + }) + const page = Atom.Registry.make() + Atom.Registry.subscribe(page, remembered, () => {}, { immediate: true }) + return { gate, page, remembered, storage } + }), + ), + When('the value is read, the store answers, the value is written, and it is read again')( + 'readings', + (s) => + Effect.gen(function*() { + const whileLoading = Atom.Registry.get(s.ctx.page, s.ctx.remembered) + yield* Deferred.succeed(s.ctx.gate, void 0) + const loaded = Atom.Registry.get(s.ctx.page, s.ctx.remembered) + Atom.Registry.set(s.ctx.page, s.ctx.remembered, 99) + const afterWrite = Atom.Registry.get(s.ctx.page, s.ctx.remembered) + const stored = s.ctx.storage.get('fresh-key') + return { whileLoading, loaded, afterWrite, stored } + }), + ), + Then('the value reported loading, filled in the fallback, accepted the write, and wrote it through')( + (s, expect) => + expect(s.readings).toMatchObject({ + whileLoading: { _tag: 'Initial' }, + loaded: { _tag: 'Success', value: 0 }, + afterWrite: { _tag: 'Success', value: 99 }, + stored: '99', + }), + ), + ), + ) + scenario( + 'A running value that is no longer needed is stopped, and one that must not be interrupted keeps running quietly', + Gherkin.Do.pipe( + Given('a page with a running value and one that must not be interrupted')('ctx', () => + Effect.sync(() => { + const running = Atom.makeWith(Effect.never, { initialValue: 1 }) + const guarded = Atom.makeWith(Effect.never, { initialValue: 1, uninterruptible: true }) + const page = Atom.Registry.make() + const stopRunning = Atom.Registry.subscribe(page, running, () => {}, { immediate: true }) + const stopGuarded = Atom.Registry.subscribe(page, guarded, () => {}, { immediate: true }) + return { page, running, guarded, stopRunning, stopGuarded } + })), + When('both values are read, then both are released')('readings', (s) => + Effect.sync(() => { + const before = Atom.Registry.get(s.ctx.page, s.ctx.running) + const guardedBefore = Atom.Registry.get(s.ctx.page, s.ctx.guarded) + s.ctx.stopRunning() + s.ctx.stopGuarded() + const restarted = Atom.Registry.get(s.ctx.page, s.ctx.running) + const restartedGuarded = Atom.Registry.get(s.ctx.page, s.ctx.guarded) + return { before, guardedBefore, restarted, restartedGuarded } + })), + Then('both values showed their stand-in while running, and started over once read again')( + (s, expect) => + expect(s.readings).toMatchObject({ + before: { _tag: 'Success', waiting: true, value: 1 }, + guardedBefore: { _tag: 'Success', waiting: true, value: 1 }, + restarted: { _tag: 'Success', waiting: true, value: 1 }, + restartedGuarded: { _tag: 'Success', waiting: true, value: 1 }, + }), + ), + ), + ) + scenario( + 'A value can be read, written, updated, changed, and refreshed through the page service', + Gherkin.Do.pipe( + Given('a page with a value')('ctx', () => + Effect.sync(() => { + const value = Atom.make(0) + const page = Atom.Registry.make() + return { page, value } + })), + When('the value is worked on through the page service')('readings', (s) => + Effect.gen(function*() { + const withPage = (effect: Effect.Effect) => + Effect.provideService(Atom.Registry.Current, s.ctx.page)(effect) + yield* withPage(s.ctx.value.pipe(Atom.mount)) + const initial = yield* withPage(s.ctx.value.pipe(Atom.get)) + yield* withPage(Atom.set(s.ctx.value, 5)) + const afterSet = yield* withPage(s.ctx.value.pipe(Atom.get)) + yield* withPage(Atom.update(s.ctx.value, (n) => n + 1)) + const afterUpdate = yield* withPage(s.ctx.value.pipe(Atom.get)) + const doubled = yield* withPage(Atom.modify(s.ctx.value, (n) => [n * 2, n * 2])) + yield* withPage(s.ctx.value.pipe(Atom.refresh)) + const finalValue = yield* withPage(s.ctx.value.pipe(Atom.get)) + return { initial, afterSet, afterUpdate, doubled, finalValue } + })), + Then('each step saw the value the previous step left behind, and the refresh returned it to its start')( + (s, expect) => + expect(s.readings).toMatchObject({ + initial: 0, + afterSet: 5, + afterUpdate: 6, + doubled: 12, + finalValue: 0, + }), + ), + ), + ) + scenario( + 'A value built from another value follows it, and values built from nothing keep their own shape', + Gherkin.Do.pipe( + Given('a page with a value that follows another, and values built from plain recipes')( + 'ctx', + () => + Effect.sync(() => { + const source = Atom.make(2) + const tracked = Atom.make((get) => { + const v = get(source) + get.subscribe(source, (next) => get.setSelf(next * 10)) + return v * 10 + }) + const nothing = Atom.make(() => null) + const objectValue = Atom.make(() => ({ n: 1 })) + const effectValue = Atom.make(() => Effect.succeed(5)) + const page = Atom.Registry.make() + Atom.Registry.subscribe(page, tracked, () => {}, { immediate: true }) + return { page, source, tracked, nothing, objectValue, effectValue } + }), + ), + When('the values are read, the source changes, and the following value is read again')( + 'readings', + (s) => + Effect.sync(() => { + const before = Atom.Registry.get(s.ctx.page, s.ctx.tracked) + Atom.Registry.set(s.ctx.page, s.ctx.source, 3) + const after = Atom.Registry.get(s.ctx.page, s.ctx.tracked) + const nullRead = Atom.Registry.get(s.ctx.page, s.ctx.nothing) + const objectRead = Atom.Registry.get(s.ctx.page, s.ctx.objectValue) + const effectRead = Atom.Registry.get(s.ctx.page, s.ctx.effectValue) + return { before, after, nullRead, objectRead, effectRead } + }), + ), + Then('the following value tracked the change, and each plain value kept its own shape')( + (s, expect) => + expect({ + before: s.readings.before, + after: s.readings.after, + nullRead: s.readings.nullRead, + objectRead: s.readings.objectRead, + effectRead: s.readings.effectRead, + }).toEqual({ + before: 20, + after: 30, + nullRead: null, + objectRead: { n: 1 }, + effectRead: expect.objectContaining({ _tag: 'Success', value: 5 }), + }), + ), + ), + ) + scenario( + 'A value that was about to update is cleaned up without firing its pending update', + Gherkin.Do.pipe( + Given('a page with a value that waits for quiet, one with a pending update, and one without')( + 'ctx', + () => + Effect.sync(() => { + const base = Atom.make(0) + const withPending = base.pipe(Atom.debounce(100)) + const quiet = base.pipe(Atom.debounce(100)) + const page = Atom.Registry.make() + const stopPending = Atom.Registry.subscribe(page, withPending, () => {}, { immediate: true }) + const stopQuiet = Atom.Registry.subscribe(page, quiet, () => {}, { immediate: true }) + return { page, base, withPending, stopPending, stopQuiet } + }), + ), + When('the source changes, then both quieted values are released, then time passes')( + 'readings', + (s) => + Effect.gen(function*() { + Atom.Registry.set(s.ctx.page, s.ctx.base, 1) + s.ctx.stopPending() + s.ctx.stopQuiet() + yield* TestClock.adjust('200 millis') + const after = Atom.Registry.get(s.ctx.page, s.ctx.withPending) + return { after } + }), + ), + Then('no pending update fired after the values were released, and the value started over from its source')( + (s, expect) => expect(s.readings.after).toBe(0), + ), + ), + ) + scenario( + 'Two runtimes built from separate factories each see only the extra service they were given', + Gherkin.Do.pipe( + Given('two runtimes from separate factories, each given its own extra service')('ctx', () => + Effect.sync(() => { + class StationName extends Context.Service()( + '@systemfsoftware/effect-atom/tests/Atom.integration.test/StationName', + ) {} + const stationOf = (runtime: Atom.RuntimeFactory) => + runtime(Layer.empty).atom( + Effect.contextWith((services: Context.Context) => + Effect.succeed(Option.getOrNull(Context.getOption(services, StationName))) + ), + ) + const first = Atom.context() + first.addGlobalLayer(Layer.succeed(StationName, 'first')) + const second = Atom.context() + second.addGlobalLayer(Layer.succeed(StationName, 'second')) + const page = Atom.Registry.make() + return { + page, + firstStation: stationOf(first), + secondStation: stationOf(second), + } + })), + When('the extra service is read through both runtimes on the same page')('readings', (s) => + Effect.sync(() => ({ + first: Atom.Registry.get(s.ctx.page, s.ctx.firstStation), + second: Atom.Registry.get(s.ctx.page, s.ctx.secondStation), + }))), + Then('each runtime reports only the extra service it was given')( + (s, expect) => + expect(s.readings).toMatchObject({ + first: { _tag: 'Success', value: 'first' }, + second: { _tag: 'Success', value: 'second' }, + }), + ), + ), + ) + }) diff --git a/packages/atom/effect-atom/tests/AtomHttpApi.integration.test.ts b/packages/atom/effect-atom/tests/AtomHttpApi.integration.test.ts new file mode 100644 index 0000000..354ceeb --- /dev/null +++ b/packages/atom/effect-atom/tests/AtomHttpApi.integration.test.ts @@ -0,0 +1,575 @@ +import { Atom } from '@systemfsoftware/effect-atom' +import { Gherkin, Given, it, makeFeature, Then, When } from '@systemfsoftware/effect-gherkin-spec' +import { Context, Effect, Layer, Option, Schema } from 'effect' +import { HttpClient, HttpClientRequest, HttpClientResponse } from 'effect/http' +import { HttpApi, HttpApiEndpoint, HttpApiGroup } from 'effect/http-api' +import type * as HttpClientError from 'effect/http/HttpClientError' + +const Feature = makeFeature({ it }) + +/** Blocks until the registry reports a settled value for the atom, resuming from the subscription. */ +const waitForSettled = ( + registry: Atom.Registry.Registry, + atom: Atom.Writable, W> | Atom.Atom>, + holds: (value: Atom.AsyncResult.Result) => boolean = (value) => + !Atom.AsyncResult.isInitial(value) && !Atom.AsyncResult.isWaiting(value), +): Effect.Effect> => + Effect.callback((resume) => { + let unsubscribe = (): void => {} + unsubscribe = Atom.Registry.subscribe( + registry, + atom, + (value) => { + if (!holds(value)) return + resume(Effect.succeed(value)) + unsubscribe() + }, + { immediate: true }, + ) + return Effect.sync(unsubscribe) + }) + +/** + * A test double that answers every request through the same preprocess and + * postprocess machinery a real client uses. + */ +const stubHttpClient = ( + handle: (request: HttpClientRequest.HttpClientRequest) => Effect.Effect, +): HttpClient.HttpClient => + HttpClient.makeWith( + (requestEffect: Effect.Effect) => + Effect.flatMap(requestEffect, handle), + (request: HttpClientRequest.HttpClientRequest) => Effect.succeed(request), + ) + +const Api = HttpApi.make('api').add( + HttpApiGroup.make('group').add( + HttpApiEndpoint.get('get', '/users/:id', { + params: { id: Schema.FiniteFromString }, + }), + ), +) + +const MutationApi = HttpApi.make('api').add( + HttpApiGroup.make('group').add( + HttpApiEndpoint.post('create', '/users', { + payload: Schema.Struct({ name: Schema.String }), + success: Schema.Struct({ id: Schema.Finite, name: Schema.String }), + }), + ), +) + +const QueryAndMutationApi = HttpApi.make('api').add( + HttpApiGroup.make('group').add( + HttpApiEndpoint.get('get', '/users/:id', { + params: { id: Schema.FiniteFromString }, + }), + HttpApiEndpoint.post('create', '/users', { + payload: Schema.Struct({ name: Schema.String }), + success: Schema.Struct({ id: Schema.Finite, name: Schema.String }), + }), + ), +) + +const ApiWithRejection = HttpApi.make('api').add( + HttpApiGroup.make('group').add( + HttpApiEndpoint.post('create', '/users', { + payload: Schema.Struct({ name: Schema.String }), + success: Schema.Struct({ id: Schema.Finite, name: Schema.String }), + error: Schema.Struct({ message: Schema.String }), + }), + ), +) + +Feature('Reusing a fetched profile after the page reloads, without asking the server again') + .withLayer(Layer.empty) + .body(({ scenario }) => { + scenario( + 'A profile fetched once is still available on a freshly reloaded page', + Gherkin.Do.pipe( + Given('a page that fetches a user profile from a server that always answers')('ctx', () => + Effect.sync(() => { + let callCount = 0 + const httpClient = stubHttpClient((request) => { + callCount++ + return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 204 }))) + }) + const Client = Atom.HttpApi.Service()('Client', { + api: Api, + httpClient: Layer.succeed(HttpClient.HttpClient, httpClient), + }) + const profile = Client.query('group', 'get', { params: { id: 1 }, serializationKey: '1' }) + const registry = Atom.Registry.make() + return { profile, registry, callsMade: () => callCount } + })), + When('the profile is read, the page is reloaded, and the profile is read again on the fresh page')( + 'result', + (s) => + Effect.gen(function*() { + const unmount = Atom.Registry.subscribe(s.ctx.registry, s.ctx.profile, () => {}, { immediate: true }) + yield* waitForSettled(s.ctx.registry, s.ctx.profile) + const savedPage = Atom.Hydration.dehydrate(s.ctx.registry) + unmount() + const freshPage = Atom.Registry.make() + Atom.Hydration.hydrate(freshPage, savedPage) + const secondReading = Atom.Registry.get(freshPage, s.ctx.profile) + return { secondReading, calls: s.ctx.callsMade() } + }), + ), + Then('the fresh page shows the profile without fetching it a second time')( + (s, expect) => expect(s.result.calls).toBe(1), + ), + ), + ) + scenario( + 'Two parts of the page reading a profile that never arrives still agree it is loading', + Gherkin.Do.pipe( + Given('a page that fetches a user profile from a server that never answers')('ctx', () => + Effect.sync(() => { + const httpClient = stubHttpClient((_request) => Effect.never) + const Client = Atom.HttpApi.Service()('Client', { + api: Api, + httpClient: Layer.succeed(HttpClient.HttpClient, httpClient), + }) + const profile = Client.query('group', 'get', { params: { id: 1 } }) + const registry = Atom.Registry.make() + return { profile, registry } + })), + When('two parts of the page read the profile while the request is still pending')( + 'result', + (s) => + Effect.sync(() => ({ + firstReading: Atom.Registry.get(s.ctx.registry, s.ctx.profile), + secondReading: Atom.Registry.get(s.ctx.registry, s.ctx.profile), + })), + ), + Then('both parts agree the profile is still loading, not a stale or broken value')((s, expect) => + expect(s.result).toMatchObject({ + firstReading: { _tag: 'Initial', waiting: true }, + secondReading: { _tag: 'Initial', waiting: true }, + }) + ), + ), + ) + scenario( + 'A submitted change runs the call and reports the created record', + Gherkin.Do.pipe( + Given('a page that submits new records to a server that accepts them')('ctx', () => + Effect.sync(() => { + const httpClient = stubHttpClient((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify({ id: 1, name: 'grace' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ), + ) + ) + const Client = Atom.HttpApi.Service()('Client', { + api: MutationApi, + httpClient: Layer.succeed(HttpClient.HttpClient, httpClient), + }) + const create = Client.mutation('group', 'create') + const registry = Atom.Registry.make() + return { create, registry } + })), + When('a new record is submitted')('outcome', (s) => + Effect.gen(function*() { + Atom.Registry.subscribe(s.ctx.registry, s.ctx.create, () => {}, { immediate: true }) + Atom.Registry.set(s.ctx.registry, s.ctx.create, { payload: { name: 'grace' } }) + yield* waitForSettled(s.ctx.registry, s.ctx.create) + return Atom.Registry.get(s.ctx.registry, s.ctx.create) + })), + Then('the created record is reported')( + (s, expect) => + expect({ tag: s.outcome._tag, value: Atom.AsyncResult.value(s.outcome) }).toEqual({ + tag: 'Success', + value: Option.some({ id: 1, name: 'grace' }), + }), + ), + ), + ) + scenario( + 'A submission the server rejects is reported as a failure', + Gherkin.Do.pipe( + Given('a page that submits new records to a server that rejects them')('ctx', () => + Effect.sync(() => { + const httpClient = stubHttpClient((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify({ message: 'nope' }), { + status: 500, + headers: { 'content-type': 'application/json' }, + }), + ), + ) + ) + const Client = Atom.HttpApi.Service()('Client', { + api: MutationApi, + httpClient: Layer.succeed(HttpClient.HttpClient, httpClient), + }) + const create = Client.mutation('group', 'create') + const registry = Atom.Registry.make() + return { create, registry } + })), + When('a new record is submitted')('outcome', (s) => + Effect.gen(function*() { + Atom.Registry.subscribe(s.ctx.registry, s.ctx.create, () => {}, { immediate: true }) + Atom.Registry.set(s.ctx.registry, s.ctx.create, { payload: { name: 'grace' } }) + yield* waitForSettled(s.ctx.registry, s.ctx.create, Atom.AsyncResult.isFailure) + return Atom.Registry.get(s.ctx.registry, s.ctx.create) + })), + Then('the submission is reported as failed')( + (s, expect) => + expect(s.outcome).toMatchObject({ + _tag: 'Failure', + cause: { + reasons: [{ + _tag: 'Die', + defect: { _tag: 'HttpClientError', reason: { _tag: 'DecodeError', response: { status: 500 } } }, + }], + }, + }), + ), + ), + ) + scenario( + 'A client whose http client comes from a function answers the same', + Gherkin.Do.pipe( + Given('a page that builds its http client from the page context')('ctx', () => + Effect.sync(() => { + let callCount = 0 + const httpClient = stubHttpClient((request) => { + callCount++ + return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 204 }))) + }) + const Client = Atom.HttpApi.Service()('Client', { + api: Api, + httpClient: () => Layer.succeed(HttpClient.HttpClient, httpClient), + }) + const profile = Client.query('group', 'get', { params: { id: 1 } }) + const registry = Atom.Registry.make() + return { profile, registry, callsMade: () => callCount } + })), + When('the profile is read')('outcome', (s) => + Effect.gen(function*() { + const unmount = Atom.Registry.subscribe(s.ctx.registry, s.ctx.profile, () => {}, { immediate: true }) + yield* waitForSettled(s.ctx.registry, s.ctx.profile) + const outcome = Atom.Registry.get(s.ctx.registry, s.ctx.profile) + unmount() + return outcome + })), + Then('the profile is reported from the derived client')( + (s, expect) => + expect({ tag: s.outcome._tag, calls: s.ctx.callsMade() }).toEqual({ tag: 'Success', calls: 1 }), + ), + ), + ) + scenario( + 'A submission with reactivity keys refetches the watched profile', + Gherkin.Do.pipe( + Given('a page with a watched profile query and a record submission that invalidates the profiles key')( + 'ctx', + () => + Effect.sync(() => { + let callCount = 0 + const httpClient = stubHttpClient((request) => { + callCount++ + if (request.url === '/users/1') { + return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 204 }))) + } + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify({ id: 1, name: 'grace' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ), + ) + }) + const Client = Atom.HttpApi.Service()('Client', { + api: QueryAndMutationApi, + httpClient: Layer.succeed(HttpClient.HttpClient, httpClient), + }) + const profile = Client.query('group', 'get', { params: { id: 1 }, reactivityKeys: ['profiles'] }) + const create = Client.mutation('group', 'create') + const registry = Atom.Registry.make() + return { profile, create, registry, callsMade: () => callCount } + }), + ), + When( + 'the profile is read, a record is submitted while invalidating the profiles key, and the profile is read again', + )('readings', (s) => + Effect.gen(function*() { + const unmount = Atom.Registry.subscribe(s.ctx.registry, s.ctx.profile, () => {}, { immediate: true }) + yield* waitForSettled(s.ctx.registry, s.ctx.profile) + const first = Atom.Registry.get(s.ctx.registry, s.ctx.profile) + Atom.Registry.set(s.ctx.registry, s.ctx.create, { + payload: { name: 'grace' }, + reactivityKeys: ['profiles'], + }) + yield* waitForSettled(s.ctx.registry, s.ctx.profile) + const second = Atom.Registry.get(s.ctx.registry, s.ctx.profile) + const calls = s.ctx.callsMade() + unmount() + return { first, second, calls } + })), + Then('the submission ran and the watched profile was fetched again')( + (s, expect) => + expect({ first: s.readings.first._tag, second: s.readings.second._tag, calls: s.readings.calls }).toEqual({ + first: 'Success', + second: 'Success', + calls: 3, + }), + ), + ), + ) + scenario( + 'A submission that asks for the raw response reports it without decoding', + Gherkin.Do.pipe( + Given('a page that submits records and keeps the raw response')('ctx', () => + Effect.sync(() => { + const httpClient = stubHttpClient((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify({ id: 1, name: 'grace' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ), + ) + ) + const Client = Atom.HttpApi.Service()('Client', { + api: MutationApi, + httpClient: Layer.succeed(HttpClient.HttpClient, httpClient), + }) + const create = Client.mutation('group', 'create', { responseMode: 'response-only' }) + const registry = Atom.Registry.make() + return { create, registry } + })), + When('a new record is submitted')('outcome', (s) => + Effect.gen(function*() { + Atom.Registry.subscribe(s.ctx.registry, s.ctx.create, () => {}, { immediate: true }) + Atom.Registry.set(s.ctx.registry, s.ctx.create, { payload: { name: 'grace' } }) + yield* waitForSettled(s.ctx.registry, s.ctx.create) + return Atom.Registry.get(s.ctx.registry, s.ctx.create) + })), + Then('the raw response is reported')( + (s, expect) => expect(s.outcome).toMatchObject({ _tag: 'Success', value: { status: 200 } }), + ), + ), + ) + scenario( + 'A profile fetched with reactivity keys, a minute-long retention, and a hydration key survives reload, while a profile asked to stay alive forever is kept', + Gherkin.Do.pipe( + Given( + 'a page that fetches the profile with reactivity keys, a finite retention, and a hydration key, and another profile kept alive forever', + )('ctx', () => + Effect.sync(() => { + const httpClient = stubHttpClient((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 204 }))) + ) + const Client = Atom.HttpApi.Service()('Client', { + api: Api, + httpClient: Layer.succeed(HttpClient.HttpClient, httpClient), + }) + const profile = Client.query('group', 'get', { + params: { id: 1 }, + reactivityKeys: ['profiles'], + timeToLive: '1 minute', + serializationKey: '1', + }) + const keptProfile = Client.query('group', 'get', { + params: { id: 2 }, + timeToLive: 'Infinity', + serializationKey: 'keep', + }) + const registry = Atom.Registry.make() + return { + profile, + keptProfile, + registry, + idleTTL: profile.spec.idleTTL, + keepAlive: keptProfile.spec.keepAlive, + } + })), + When('the profile is read, the page is reloaded, and the profile is read again')( + 'result', + (s) => + Effect.gen(function*() { + const unmount = Atom.Registry.subscribe(s.ctx.registry, s.ctx.profile, () => {}, { immediate: true }) + yield* waitForSettled(s.ctx.registry, s.ctx.profile) + const savedPage = Atom.Hydration.dehydrate(s.ctx.registry) + unmount() + const freshPage = Atom.Registry.make() + Atom.Hydration.hydrate(freshPage, savedPage) + const secondReading = Atom.Registry.get(freshPage, s.ctx.profile) + return { secondReading } + }), + ), + Then('the first profile keeps its retention and the second stays alive, and reload still shows the profile')( + (s, expect) => + expect({ + idleTTL: s.ctx.idleTTL, + keepAlive: s.ctx.keepAlive, + secondReading: s.result.secondReading._tag, + }).toEqual({ idleTTL: 60_000, keepAlive: true, secondReading: 'Success' }), + ), + ), + ) + scenario( + 'A submission answered with a body that does not match the contract is reported as a defect rather than a normal failure', + Gherkin.Do.pipe( + Given('a page that submits records to a server that answers with an unreadable body')( + 'ctx', + () => + Effect.sync(() => { + const httpClient = stubHttpClient((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify({ oops: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ), + ) + ) + const Client = Atom.HttpApi.Service()('Client', { + api: MutationApi, + httpClient: Layer.succeed(HttpClient.HttpClient, httpClient), + }) + const create = Client.mutation('group', 'create') + const registry = Atom.Registry.make() + return { create, registry } + }), + ), + When('a new record is submitted')('outcome', (s) => + Effect.gen(function*() { + Atom.Registry.subscribe(s.ctx.registry, s.ctx.create, () => {}, { immediate: true }) + Atom.Registry.set(s.ctx.registry, s.ctx.create, { payload: { name: 'grace' } }) + yield* waitForSettled(s.ctx.registry, s.ctx.create) + return Atom.Registry.get(s.ctx.registry, s.ctx.create) + })), + Then('the submission is reported as a defect rather than a normal failure')( + (s, expect) => + expect({ tag: s.outcome._tag, error: Atom.AsyncResult.error(s.outcome) }).toEqual({ + tag: 'Failure', + error: Option.none(), + }), + ), + ), + ) + scenario( + 'A submission the server rejects with a matching error body is reported as a normal failure carrying that error', + Gherkin.Do.pipe( + Given('a page that submits records to a server that rejects them with a described error')( + 'ctx', + () => + Effect.sync(() => { + const httpClient = stubHttpClient((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify({ message: 'nope' }), { + status: 500, + headers: { 'content-type': 'application/json' }, + }), + ), + ) + ) + const Client = Atom.HttpApi.Service()('Client', { + api: ApiWithRejection, + httpClient: Layer.succeed(HttpClient.HttpClient, httpClient), + }) + const create = Client.mutation('group', 'create') + const registry = Atom.Registry.make() + return { create, registry } + }), + ), + When('a new record is submitted')('outcome', (s) => + Effect.gen(function*() { + Atom.Registry.subscribe(s.ctx.registry, s.ctx.create, () => {}, { immediate: true }) + Atom.Registry.set(s.ctx.registry, s.ctx.create, { payload: { name: 'grace' } }) + yield* waitForSettled(s.ctx.registry, s.ctx.create) + return Atom.Registry.get(s.ctx.registry, s.ctx.create) + })), + Then('the submission is reported as a normal failure with the described error')( + (s, expect) => + expect({ tag: s.outcome._tag, error: Atom.AsyncResult.error(s.outcome) }).toEqual({ + tag: 'Failure', + error: Option.some({ message: 'nope' }), + }), + ), + ), + ) + scenario( + 'Two clients defined without their own runtime each keep the extra service added to their own runtime factory', + Gherkin.Do.pipe( + Given( + 'two API clients defined without their own runtime, each runtime factory later given its own extra service', + )( + 'ctx', + () => + Effect.sync(() => { + class Greeting extends Context.Service()( + '@systemfsoftware/effect-atom/tests/AtomHttpApi.integration.test/Greeting', + ) {} + const httpClient = stubHttpClient((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 204 }))) + ) + const clientWithGreeting = (greeting: string) => { + const Client = Atom.HttpApi.Service()('Client', { + api: Api, + httpClient: Layer.succeed(HttpClient.HttpClient, httpClient), + }) + Client.runtime.factory.addGlobalLayer(Layer.succeed(Greeting, greeting)) + return { + greeting: Client.runtime.factory(Layer.empty).atom( + Effect.contextWith((services: Context.Context) => + Effect.succeed(Option.getOrNull(Context.getOption(services, Greeting))) + ), + ), + profile: Client.query('group', 'get', { params: { id: 1 } }), + } + } + return { + registry: Atom.Registry.make(), + first: clientWithGreeting('first'), + second: clientWithGreeting('second'), + } + }), + ), + When('the extra service is read from each runtime factory, and one client answers a request')( + 'readings', + (s) => + Effect.gen(function*() { + const unmount = Atom.Registry.subscribe(s.ctx.registry, s.ctx.first.profile, () => {}, { + immediate: true, + }) + yield* waitForSettled(s.ctx.registry, s.ctx.first.profile) + const profile = Atom.Registry.get(s.ctx.registry, s.ctx.first.profile) + unmount() + return { + firstGreeting: Atom.Registry.get(s.ctx.registry, s.ctx.first.greeting), + secondGreeting: Atom.Registry.get(s.ctx.registry, s.ctx.second.greeting), + profile, + } + }), + ), + Then('each runtime factory reports only the extra service it was given itself, and the request is answered')( + (s, expect) => + expect({ + firstGreeting: Atom.AsyncResult.getOrThrow(s.readings.firstGreeting), + secondGreeting: Atom.AsyncResult.getOrThrow(s.readings.secondGreeting), + profile: s.readings.profile._tag, + }).toEqual({ firstGreeting: 'first', secondGreeting: 'second', profile: 'Success' }), + ), + ), + ) + }) diff --git a/packages/atom/effect-atom/tests/AtomRef.integration.test.ts b/packages/atom/effect-atom/tests/AtomRef.integration.test.ts new file mode 100644 index 0000000..3f6449f --- /dev/null +++ b/packages/atom/effect-atom/tests/AtomRef.integration.test.ts @@ -0,0 +1,555 @@ +import { Atom } from '@systemfsoftware/effect-atom' +import { Gherkin, Given, it, makeFeature, Then, When } from '@systemfsoftware/effect-gherkin-spec' +import { Effect, Equal, Layer } from 'effect' + +const Feature = makeFeature({ it }) + +interface RefBundle { + readonly value: Atom.Ref.AtomRef + readonly record: Atom.Ref.AtomRef<{ readonly name: string; readonly other: string }> + readonly items: Atom.Ref.Collection +} + +interface Call { + readonly direct: PairValue + readonly piped: PairValue +} + +type PairValue = + | number + | string + | ReadonlyArray + | ReadonlyArray> + +interface RefCall { + readonly operation: string + readonly attempt: (refs: RefBundle) => Call +} + +Feature('Keeping a piece of shared local state in sync across several parts of the page') + .withLayer(Layer.empty) + .body(({ scenario, scenarioOutline }) => { + scenario( + 'A derived read-only value only notifies when its own computed value actually changes', + Gherkin.Do.pipe( + Given('shared state with a derived value showing whether it is over a threshold')( + 'ctx', + () => + Effect.sync(() => { + const count = Atom.Ref.make(0) + const isOverFive = Atom.Ref.map(count, (n) => n > 5) + const notifications: boolean[] = [] + const cancel = Atom.Ref.subscribe(isOverFive, (v) => notifications.push(v)) + return { count, notifications, cancel } + }), + ), + When('the count changes several times without crossing the threshold, then crosses it')( + 'ctx', + (s) => + Effect.sync(() => { + Atom.Ref.set(s.ctx.count, 1) + Atom.Ref.set(s.ctx.count, 2) + Atom.Ref.set(s.ctx.count, 3) + Atom.Ref.set(s.ctx.count, 6) + s.ctx.cancel() + return s.ctx + }), + ), + Then('the derived value only reports the one real change')( + (s, expect) => expect(s.ctx.notifications).toEqual([true]), + ), + ), + ) + scenario( + 'Editing one item in a list of shared items does not affect the others', + Gherkin.Do.pipe( + Given('a list of three shared items, with a view into the middle one')('ctx', () => + Effect.sync(() => { + const list = Atom.Ref.make([10, 20, 30]) + const middleItem = Atom.Ref.prop(list, 1) + return { list, middleItem } + })), + When('the middle item is set to a new value')('ctx', (s) => + Effect.sync(() => { + Atom.Ref.set(s.ctx.middleItem, 99) + return s.ctx + })), + Then('only the middle item changed, its neighbours are untouched')( + (s, expect) => expect(Atom.Ref.get(s.ctx.list)).toEqual([10, 99, 30]), + ), + ), + ) + scenario( + 'Removing an item from a shared collection stops it from affecting the collection, and the remaining items are unchanged', + Gherkin.Do.pipe( + Given('a shared collection of three items, being watched by a listener')('ctx', () => + Effect.sync(() => { + const items = Atom.Ref.collection([1, 2, 3]) + let notifications = 0 + const cancel = Atom.Ref.subscribe(items, () => { + notifications++ + }) + return { items, getNotifications: () => notifications, cancel } + })), + When('the middle item is removed, then the removed item is changed on its own')( + 'result', + (s) => + Effect.sync(() => { + const removed = Atom.Ref.get(s.ctx.items)[1] + if (removed === undefined) throw new Error('expected a middle item') + Atom.Ref.remove(s.ctx.items, removed) + const afterRemoveNotifications = s.ctx.getNotifications() + Atom.Ref.set(removed, 999) + const afterStaleEditNotifications = s.ctx.getNotifications() + s.ctx.cancel() + return { + afterRemoveNotifications, + afterStaleEditNotifications, + remaining: Atom.Ref.toArray(s.ctx.items), + } + }), + ), + Then('the collection reflects the removal, and editing the removed item no longer notifies the collection')( + (s, expect) => + expect({ + remaining: s.result.remaining, + staleEditNotificationDelta: s.result.afterStaleEditNotifications - s.result.afterRemoveNotifications, + }).toEqual({ remaining: [1, 3], staleEditNotificationDelta: 0 }), + ), + ), + ) + scenario( + 'A listener that leaves the middle of the chain stops hearing, while the others keep hearing', + Gherkin.Do.pipe( + Given('a shared value being watched by three listeners')('ctx', () => + Effect.sync(() => { + const value = Atom.Ref.make(0) + const first: number[] = [] + const second: number[] = [] + const third: number[] = [] + Atom.Ref.subscribe(value, (v) => first.push(v)) + const cancelSecond = Atom.Ref.subscribe(value, (v) => second.push(v)) + Atom.Ref.subscribe(value, (v) => third.push(v)) + return { value, first, second, third, cancelSecond } + })), + When('the middle listener leaves and the value changes')('ctx', (s) => + Effect.sync(() => { + s.ctx.cancelSecond() + Atom.Ref.set(s.ctx.value, 1) + return s.ctx + })), + Then('the departed listener heard nothing while the others heard the change')( + (s, expect) => + expect({ first: s.ctx.first, second: s.ctx.second, third: s.ctx.third }).toEqual({ + first: [1], + second: [], + third: [1], + }), + ), + ), + ) + scenario( + 'A view into a key that only appears later stays quiet until the key exists', + Gherkin.Do.pipe( + Given('a shared record with no name yet, and a view into its name')('ctx', () => + Effect.sync(() => { + const record = Atom.Ref.make<{ name?: string; other?: string }>({ other: 'o' }) + const name = Atom.Ref.prop(record, 'name') + const heard: (string | undefined)[] = [] + Atom.Ref.subscribe(name, (v) => heard.push(v)) + return { record, heard } + })), + When('an unrelated key changes, then the name appears')('ctx', (s) => + Effect.sync(() => { + Atom.Ref.set(s.ctx.record, { other: 'changed' }) + Atom.Ref.set(s.ctx.record, { other: 'changed', name: 'arrived' }) + return s.ctx + })), + Then('the view stayed quiet until the name existed, then reported it')( + (s, expect) => expect(s.ctx.heard).toEqual(['arrived']), + ), + ), + ) + scenario( + 'Updating one item in a list with a function changes just that item', + Gherkin.Do.pipe( + Given('a list of three shared items, with a view into the middle one')('ctx', () => + Effect.sync(() => { + const list = Atom.Ref.make([10, 20, 30]) + const middleItem = Atom.Ref.prop(list, 1) + return { list, middleItem } + })), + When('the middle item is updated by adding one')('ctx', (s) => + Effect.sync(() => { + Atom.Ref.update(s.ctx.middleItem, (n) => n + 1) + return s.ctx + })), + Then('only the middle item changed, its neighbours are untouched')( + (s, expect) => expect(Atom.Ref.get(s.ctx.list)).toEqual([10, 21, 30]), + ), + ), + ) + scenario( + 'Removing an item that is not in the collection leaves the collection untouched', + Gherkin.Do.pipe( + Given('a shared collection, being watched by a listener')('ctx', () => + Effect.sync(() => { + const items = Atom.Ref.collection([1, 2, 3]) + const stranger = Atom.Ref.get(Atom.Ref.collection([9]))[0] + if (stranger === undefined) throw new Error('expected an item in the other collection') + let notifications = 0 + const cancel = Atom.Ref.subscribe(items, () => { + notifications++ + }) + return { items, stranger, getNotifications: () => notifications, cancel } + })), + When('an item that belongs to another collection is removed from this one')('result', (s) => + Effect.sync(() => { + Atom.Ref.remove(s.ctx.items, s.ctx.stranger) + const result = { remaining: Atom.Ref.toArray(s.ctx.items), notifications: s.ctx.getNotifications() } + s.ctx.cancel() + return result + })), + Then('the collection is unchanged and its listener heard nothing')( + (s, expect) => expect(s.result).toEqual({ remaining: [1, 2, 3], notifications: 0 }), + ), + ), + ) + scenario( + 'Updating one field of a shared record with a function changes just that field', + Gherkin.Do.pipe( + Given('a shared record with a view into one of its fields')('ctx', () => + Effect.sync(() => { + const record = Atom.Ref.make({ name: 'ada', other: 'x' }) + const name = Atom.Ref.prop(record, 'name') + return { record, name } + })), + When('that field is updated by turning it uppercase')('ctx', (s) => + Effect.sync(() => { + Atom.Ref.update(s.ctx.name, (n) => n.toUpperCase()) + return s.ctx + })), + Then('only that field changed, its neighbour is untouched')( + (s, expect) => expect(Atom.Ref.get(s.ctx.record)).toEqual({ name: 'ADA', other: 'x' }), + ), + ), + ) + scenario( + 'A nested view into an item of a shared collection keeps the collection in sync while the item changes, and goes quiet once the item is removed', + Gherkin.Do.pipe( + Given('a shared collection of object items, with nested views into the first one, being watched by a listener')( + 'ctx', + () => + Effect.sync(() => { + const items = Atom.Ref.collection([ + { name: 'ada', address: { city: 'london' } }, + { name: 'grace', address: { city: 'paris' } }, + ]) + const first = Atom.Ref.get(items)[0] + if (first === undefined) throw new Error('expected a first item') + const firstName = Atom.Ref.prop(first, 'name') + const address = Atom.Ref.prop(first, 'address') + const city = Atom.Ref.prop(address, 'city') + let notifications = 0 + const cancel = Atom.Ref.subscribe(items, () => { + notifications++ + }) + return { items, firstName, city, getNotifications: () => notifications, cancel } + }), + ), + When('the nested views change the first item, then that item is removed and changed again')( + 'result', + (s) => + Effect.sync(() => { + Atom.Ref.set(s.ctx.firstName, 'bob') + const afterFieldSet = { notifications: s.ctx.getNotifications(), city: Atom.Ref.get(s.ctx.city) } + Atom.Ref.update(s.ctx.city, (c) => c.toUpperCase()) + const afterNestedUpdate = { + notifications: s.ctx.getNotifications(), + items: Atom.Ref.toArray(s.ctx.items), + } + const removed = Atom.Ref.get(s.ctx.items)[0] + if (removed === undefined) throw new Error('expected a first item') + Atom.Ref.remove(s.ctx.items, removed) + Atom.Ref.set(Atom.Ref.prop(removed, 'name'), 'zed') + const afterRemoval = { notifications: s.ctx.getNotifications(), items: Atom.Ref.toArray(s.ctx.items) } + s.ctx.cancel() + return { afterFieldSet, afterNestedUpdate, afterRemoval } + }), + ), + Then( + 'the collection heard each change while the item was present, the nested views stayed in sync, and silence returned after the removal', + )( + (s, expect) => + expect({ + afterFieldSet: s.result.afterFieldSet, + afterNestedUpdate: s.result.afterNestedUpdate, + afterRemoval: s.result.afterRemoval, + }).toEqual({ + afterFieldSet: { notifications: 1, city: 'london' }, + afterNestedUpdate: { + notifications: 2, + items: [ + { name: 'bob', address: { city: 'LONDON' } }, + { name: 'grace', address: { city: 'paris' } }, + ], + }, + afterRemoval: { notifications: 3, items: [{ name: 'grace', address: { city: 'paris' } }] }, + }), + ), + ), + ) + scenario( + 'Setting a shared value to what it already holds leaves its listeners quiet', + Gherkin.Do.pipe( + Given('a shared value being watched by a listener')('ctx', () => + Effect.sync(() => { + const value = Atom.Ref.make(5) + const heard: number[] = [] + Atom.Ref.subscribe(value, (v) => heard.push(v)) + return { value, heard } + })), + When('the value is set to the number it already holds, then set to a different number')( + 'result', + (s) => + Effect.sync(() => { + const sameRef = Atom.Ref.set(s.ctx.value, 5) + Atom.Ref.set(s.ctx.value, 6) + return { heard: s.ctx.heard, sameRef, value: s.ctx.value } + }), + ), + Then('the listener only heard the real change, and setting the same value handed back the same reference')( + (s, expect) => + expect({ heard: s.result.heard, sameRef: s.result.sameRef, value: s.result.value }).toSatisfy( + ({ heard, sameRef, value }) => Equal.equals(heard, [6]) && Object.is(sameRef, value), + 'the listener only heard the real change, and setting the same value handed back the same reference', + ), + ), + ), + ) + scenario( + 'A view into a field that appears later reflects the field once it exists, and keeps following it', + Gherkin.Do.pipe( + Given('a shared record that has no name yet, with a view into its name')('ctx', () => + Effect.sync(() => { + const record = Atom.Ref.make<{ name?: string; other?: string }>({ other: 'x' }) + const name = Atom.Ref.prop(record, 'name') + const heard: (string | undefined)[] = [] + Atom.Ref.subscribe(name, (v) => heard.push(v)) + return { record, name, heard } + })), + When('the record is updated to add the name, then the name is read and changed')( + 'result', + (s) => + Effect.sync(() => { + Atom.Ref.update(s.ctx.record, (r) => ({ ...r, name: 'ada' })) + const afterAppearing = Atom.Ref.get(s.ctx.name) + Atom.Ref.update(s.ctx.record, (r) => ({ ...r, name: 'bob' })) + return { heard: s.ctx.heard, afterAppearing, current: Atom.Ref.get(s.ctx.name) } + }), + ), + Then('the view reported the name when it appeared and always reads the current name')( + (s, expect) => + expect({ + heard: s.result.heard, + afterAppearing: s.result.afterAppearing, + current: s.result.current, + }).toEqual({ heard: ['ada', 'bob'], afterAppearing: 'ada', current: 'bob' }), + ), + ), + ) + scenario( + 'A view into one field of a shared record stays quiet while other fields change around it', + Gherkin.Do.pipe( + Given('a shared record with several fields, being watched through a view into one of them')( + 'ctx', + () => + Effect.sync(() => { + const record = Atom.Ref.make({ name: 'ada', other: 'x' }) + const name = Atom.Ref.prop(record, 'name') + const heard: string[] = [] + Atom.Ref.subscribe(name, (v) => heard.push(v)) + return { record, name, heard } + }), + ), + When('the record changes without touching the watched field, then changes the watched field')( + 'result', + (s) => + Effect.sync(() => { + Atom.Ref.set(s.ctx.record, { name: 'ada', other: 'y' }) + const afterUnrelatedChange = [...s.ctx.heard] + Atom.Ref.set(s.ctx.record, { name: 'bob', other: 'y' }) + return { afterUnrelatedChange, heard: s.ctx.heard } + }), + ), + Then('the view stayed quiet for the unrelated change and only reported the watched field changing')( + (s, expect) => + expect({ afterUnrelatedChange: s.result.afterUnrelatedChange, heard: s.result.heard }).toEqual({ + afterUnrelatedChange: [], + heard: ['bob'], + }), + ), + ), + ) + scenario( + 'Two equally valued pieces of shared state keep their own identity', + Gherkin.Do.pipe( + Given('two pieces of shared state holding the same number, created one after the other')( + 'ctx', + () => + Effect.sync(() => { + const first = Atom.Ref.make(7) + const second = Atom.Ref.make(7) + return { first, second } + }), + ), + Then('each piece has its own identity while equal values still compare as equal')( + (s, expect) => + expect({ first: s.ctx.first, second: s.ctx.second }).toSatisfy( + ({ first, second }) => !Object.is(first, second) && Equal.equals(first, second), + 'two separate pieces of state holding equal values', + ), + ), + ), + ) + scenario( + 'Changing a shared value notifies every listener exactly once with the new value', + Gherkin.Do.pipe( + Given('a shared value being watched by two listeners')('ctx', () => + Effect.sync(() => { + const value = Atom.Ref.make(0) + const first: number[] = [] + const second: number[] = [] + Atom.Ref.subscribe(value, (v) => first.push(v)) + Atom.Ref.subscribe(value, (v) => second.push(v)) + return { value, first, second } + })), + When('the value changes for real')('ctx', (s) => + Effect.sync(() => { + Atom.Ref.set(s.ctx.value, 9) + return s.ctx + })), + Then('each listener heard the new value exactly once')( + (s, expect) => expect({ first: s.ctx.first, second: s.ctx.second }).toEqual({ first: [9], second: [9] }), + ), + ), + ) + scenario( + 'Changing a nested field updates its parent and notifies whoever watches the parent', + Gherkin.Do.pipe( + Given('a shared record whose whole content is being watched')('ctx', () => + Effect.sync(() => { + const record = Atom.Ref.make({ name: 'ada', other: 'x' }) + const heard: { name: string; other: string }[] = [] + Atom.Ref.subscribe(record, (v) => heard.push(v)) + return { record, heard } + })), + When('the nested field is changed through its own view')('ctx', (s) => + Effect.sync(() => { + Atom.Ref.set(Atom.Ref.prop(s.ctx.record, 'name'), 'grace') + return s.ctx + })), + Then('the parent carries the new field and its watchers heard the whole parent')( + (s, expect) => + expect({ record: Atom.Ref.get(s.ctx.record), heard: s.ctx.heard }).toEqual({ + record: { name: 'grace', other: 'x' }, + heard: [{ name: 'grace', other: 'x' }], + }), + ), + ), + ) + scenario( + 'Adding and removing items of a shared collection notifies whoever watches the collection', + Gherkin.Do.pipe( + Given('a shared collection of two items, being watched by a listener')('ctx', () => + Effect.sync(() => { + const items = Atom.Ref.collection([1, 2]) + const heard: number[][] = [] + Atom.Ref.subscribe(items, (refs) => heard.push(refs.map((ref) => Atom.Ref.get(ref)))) + return { items, heard } + })), + When('an item is added and then removed again')('result', (s) => + Effect.sync(() => { + Atom.Ref.push(s.ctx.items, 3) + const added = Atom.Ref.get(s.ctx.items)[2] + if (added === undefined) throw new Error('expected an added item') + Atom.Ref.remove(s.ctx.items, added) + return { heard: s.ctx.heard, remaining: Atom.Ref.toArray(s.ctx.items) } + })), + Then('the listener heard the addition and the removal, and only the original items remain')( + (s, expect) => + expect({ heard: s.result.heard, remaining: s.result.remaining }).toEqual({ + heard: [[1, 2, 3], [1, 2]], + remaining: [1, 2], + }), + ), + ), + ) + scenarioOutline( + 'Reading and changing shared state with the helper gives the same answer however it is called', + [ + { + operation: 'read', + attempt: (refs: RefBundle): Call => ({ + direct: Atom.Ref.get(refs.value), + piped: refs.value.pipe(Atom.Ref.get), + }), + }, + { + operation: 'view a field', + attempt: (refs: RefBundle): Call => ({ + direct: Atom.Ref.get(Atom.Ref.prop(refs.record, 'name')), + piped: refs.record.pipe(Atom.Ref.prop('name'), Atom.Ref.get), + }), + }, + { + operation: 'derive a value', + attempt: (refs: RefBundle): Call => ({ + direct: Atom.Ref.get(Atom.Ref.map(refs.value, (n) => n + 1)), + piped: refs.value.pipe(Atom.Ref.map((n) => n + 1), Atom.Ref.get), + }), + }, + { + operation: 'replace the value', + attempt: (refs: RefBundle): Call => { + Atom.Ref.set(refs.value, 5) + return { direct: Atom.Ref.get(refs.value), piped: Atom.Ref.get(refs.value) } + }, + }, + { + operation: 'update the value', + attempt: (refs: RefBundle): Call => { + Atom.Ref.update(refs.value, (n) => n + 1) + return { direct: Atom.Ref.get(refs.value), piped: Atom.Ref.get(refs.value) } + }, + }, + { + operation: 'listen', + attempt: (refs: RefBundle): Call => { + const first: Array = [] + const second: Array = [] + const cancelFirst = Atom.Ref.subscribe(refs.value, (v) => first.push(v)) + const cancelSecond = refs.value.pipe(Atom.Ref.subscribe((v) => second.push(v))) + Atom.Ref.set(refs.value, 9) + cancelFirst() + cancelSecond() + return { direct: first, piped: second } + }, + }, + ], + (row: RefCall) => + Gherkin.Do.pipe( + Given('a shared value, a shared record, and a shared collection')('ctx', () => + Effect.sync(() => ({ + value: Atom.Ref.make(4), + record: Atom.Ref.make({ name: 'ada', other: 'x' }), + items: Atom.Ref.collection([1]), + }))), + When('the helper is called directly and through a pipe')('result', (s) => + Effect.sync(() => row.attempt(s.ctx))), + Then('both calls agreed with each other')((s, expect) => + expect(s.result.piped).toEqual(s.result.direct) + ), + ), + ) + }) diff --git a/packages/atom/effect-atom/tests/AtomRpc.integration.test.ts b/packages/atom/effect-atom/tests/AtomRpc.integration.test.ts new file mode 100644 index 0000000..9711282 --- /dev/null +++ b/packages/atom/effect-atom/tests/AtomRpc.integration.test.ts @@ -0,0 +1,392 @@ +import { Atom } from '@systemfsoftware/effect-atom' +import { Gherkin, Given, it, makeFeature, Then, When } from '@systemfsoftware/effect-gherkin-spec' +import { Effect, Layer, Option, Schema, Stream } from 'effect' +import { Rpc, RpcGroup } from 'effect/rpc' +import * as RpcTest from 'effect/rpc/RpcTest' + +const Feature = makeFeature({ it }) + +/** Blocks until the registry reports a settled value for the atom, resuming from the subscription. */ +const waitForSettled = ( + registry: Atom.Registry.Registry, + atom: Atom.Writable, W> | Atom.Atom>, +): Effect.Effect> => + Effect.callback((resume) => { + let unsubscribe = (): void => {} + unsubscribe = Atom.Registry.subscribe( + registry, + atom, + (value) => { + if (Atom.AsyncResult.isInitial(value) || Atom.AsyncResult.isWaiting(value)) return + resume(Effect.succeed(value)) + unsubscribe() + }, + { immediate: true }, + ) + return Effect.sync(unsubscribe) + }) + +const Group = RpcGroup.make( + Rpc.make('getUser', { + payload: Schema.Struct({ id: Schema.FiniteFromString }), + success: Schema.Struct({ id: Schema.Finite, name: Schema.String }), + }), + Rpc.make('createUser', { + payload: Schema.Struct({ name: Schema.String }), + success: Schema.Struct({ id: Schema.Finite, name: Schema.String }), + }), +) + +const StreamGroup = RpcGroup.make( + Rpc.make('getItems', { + payload: Schema.Struct({ count: Schema.Finite }), + success: Schema.Struct({ id: Schema.Finite, name: Schema.String }), + stream: true, + }), +) + +type GroupRpcs = RpcGroup.Rpcs +type StreamRpcs = RpcGroup.Rpcs + +/** + * An in-memory RPC server for a group. + * + * `RpcTest` wires the real client to real handlers over the no-serialization + * transport, so each scenario consumes a genuine `RpcClient.Flat` rather than a + * hand-written client shape. + */ +const serverFor = (handlers: Layer.Layer>) => + RpcTest.makeClient(Group, { flatten: true }).pipe(Effect.provide(handlers)) + +const itemsServer = (handlers: Layer.Layer>) => + RpcTest.makeClient(StreamGroup, { flatten: true }).pipe(Effect.provide(handlers)) + +Feature('Reusing an rpc-fetched user after the page reloads, without calling the server again') + .withLayer(Layer.empty) + .body(({ scenario }) => { + scenario( + 'A user fetched once over rpc is still available on a freshly reloaded page', + Gherkin.Do.pipe( + Given('a page that calls an rpc server that always answers, remembering the answer for reload')( + 'ctx', + () => + Effect.sync(() => { + let callCount = 0 + const Handlers: Layer.Layer> = Group.toLayer({ + getUser: ({ id }) => + Effect.sync(() => { + callCount += 1 + return { id, name: `user-${id}` } + }), + createUser: () => Effect.die('unexpected tag: createUser'), + }) + const makeEffect = serverFor(Handlers) + const Client = Atom.Rpc.Service()('Client', { + group: Group, + protocol: Layer.empty, + makeEffect, + }) + const user = Client.query('getUser', { id: 1 }, { serializationKey: '1' }) + const registry = Atom.Registry.make() + return { user, registry, callsMade: () => callCount } + }), + ), + When('the user is read, the page is reloaded, and read again on the fresh page')( + 'result', + (s) => + Effect.sync(() => { + const unmount = Atom.Registry.subscribe(s.ctx.registry, s.ctx.user, () => {}, { immediate: true }) + const savedPage = Atom.Hydration.dehydrate(s.ctx.registry) + unmount() + const freshPage = Atom.Registry.make() + Atom.Hydration.hydrate(freshPage, savedPage) + const secondReading = Atom.Registry.get(freshPage, s.ctx.user) + return { secondReading, calls: s.ctx.callsMade() } + }), + ), + Then('the fresh page shows the user without calling the server a second time')( + (s, expect) => expect(s.result.calls).toBe(1), + ), + ), + ) + scenario( + 'Asking for the same query twice yields the same atom, so the server is called once', + Gherkin.Do.pipe( + Given('a page that calls an rpc server that counts calls')('ctx', () => + Effect.sync(() => { + let callCount = 0 + const Handlers: Layer.Layer> = Group.toLayer({ + getUser: ({ id }) => + Effect.sync(() => { + callCount += 1 + return { id, name: `user-${id}` } + }), + createUser: () => Effect.die('unexpected tag: createUser'), + }) + const makeEffect = serverFor(Handlers) + const Client = Atom.Rpc.Service()('Client', { + group: Group, + protocol: Layer.empty, + makeEffect, + }) + const first = Client.query('getUser', { id: 1 }, { serializationKey: '1' }) + const second = Client.query('getUser', { id: 1 }, { serializationKey: '1' }) + const registry = Atom.Registry.make() + return { first, second, registry, callsMade: () => callCount } + })), + When('both queries are mounted and read')('result', (s) => + Effect.sync(() => { + Atom.Registry.subscribe(s.ctx.registry, s.ctx.first, () => {}, { immediate: true }) + Atom.Registry.subscribe(s.ctx.registry, s.ctx.second, () => {}, { immediate: true }) + return { first: s.ctx.first, second: s.ctx.second, calls: s.ctx.callsMade() } + })), + Then('both queries share one atom and the server is called once')( + (s, expect) => + expect({ first: s.result.first, second: s.result.second, calls: s.result.calls }).toSatisfy( + ({ first, second, calls }) => first === second && calls === 1, + 'the two queries share one atom, so the server was called once', + ), + ), + ), + ) + scenario( + 'A change submitted over rpc runs the call and reports the created record', + Gherkin.Do.pipe( + Given('a page that submits new records over rpc to a server that accepts them')('ctx', () => + Effect.sync(() => { + const Handlers: Layer.Layer> = Group.toLayer({ + getUser: () => Effect.die('unexpected tag: getUser'), + createUser: ({ name }) => Effect.succeed({ id: 1, name }), + }) + const makeEffect = serverFor(Handlers) + const Client = Atom.Rpc.Service()('Client', { + group: Group, + protocol: Layer.empty, + makeEffect, + }) + const create = Client.mutation('createUser') + const registry = Atom.Registry.make() + return { create, registry } + })), + When('a new record is submitted')('outcome', (s) => + Effect.gen(function*() { + Atom.Registry.subscribe(s.ctx.registry, s.ctx.create, () => {}, { immediate: true }) + Atom.Registry.set(s.ctx.registry, s.ctx.create, { payload: { name: 'grace' } }) + yield* waitForSettled(s.ctx.registry, s.ctx.create) + return Atom.Registry.get(s.ctx.registry, s.ctx.create) + })), + Then('the created record is reported')( + (s, expect) => + expect({ tag: s.outcome._tag, value: Atom.AsyncResult.value(s.outcome) }).toEqual({ + tag: 'Success', + value: Option.some({ id: 1, name: 'grace' }), + }), + ), + ), + ) + scenario( + 'A stream of records from an rpc server is pulled into the page as it arrives', + Gherkin.Do.pipe( + Given('a page whose rpc server streams two records')('ctx', () => + Effect.sync(() => { + let callCount = 0 + const Handlers: Layer.Layer> = StreamGroup.toLayer({ + getItems: () => + Stream.fromIterable([ + { id: 1, name: 'first' }, + { id: 2, name: 'second' }, + ]), + }) + const makeEffect = itemsServer(Handlers) + const Client = Atom.Rpc.Service()('Client', { + group: StreamGroup, + protocol: Layer.empty, + makeEffect, + }) + const feed = Client.query('getItems', { count: 2 }) + const registry = Atom.Registry.make() + return { feed, registry, callsMade: () => callCount } + })), + When('the feed is mounted and pulled until the server finishes')('final', (s) => + Effect.gen(function*() { + const unmount = Atom.Registry.subscribe(s.ctx.registry, s.ctx.feed, () => {}, { immediate: true }) + Atom.Registry.set(s.ctx.registry, s.ctx.feed, void 0) + Atom.Registry.set(s.ctx.registry, s.ctx.feed, void 0) + Atom.Registry.set(s.ctx.registry, s.ctx.feed, void 0) + yield* waitForSettled(s.ctx.registry, s.ctx.feed) + const final = Atom.Registry.get(s.ctx.registry, s.ctx.feed) + unmount() + return final + })), + Then('the records arrived in order and the feed is marked finished')( + (s, expect) => + expect({ tag: s.final._tag, value: Atom.AsyncResult.value(s.final) }).toEqual({ + tag: 'Success', + value: Option.some({ + done: true, + items: [ + { id: 1, name: 'first' }, + { id: 2, name: 'second' }, + ], + }), + }), + ), + ), + ) + scenario( + 'A change submitted with reactivity keys refetches the queries watching those keys', + Gherkin.Do.pipe( + Given('a page with a watched user query and a record submission that invalidates the users key')( + 'ctx', + () => + Effect.sync(() => { + let callCount = 0 + const Handlers: Layer.Layer> = Group.toLayer({ + getUser: () => + Effect.sync(() => { + callCount += 1 + return { id: 1, name: 'user-1' } + }), + createUser: ({ name }) => + Effect.sync(() => { + callCount += 1 + return { id: 1, name } + }), + }) + const makeEffect = serverFor(Handlers) + const Client = Atom.Rpc.Service()('Client', { + group: Group, + protocol: Layer.empty, + makeEffect, + }) + const user = Client.query('getUser', { id: 1 }, { reactivityKeys: ['users'] }) + const create = Client.mutation('createUser') + const registry = Atom.Registry.make() + return { user, create, registry, callsMade: () => callCount } + }), + ), + When( + 'the user is read, a record is submitted while invalidating the users key, and the user is read again', + )('readings', (s) => + Effect.gen(function*() { + const unmount = Atom.Registry.subscribe(s.ctx.registry, s.ctx.user, () => {}, { immediate: true }) + yield* waitForSettled(s.ctx.registry, s.ctx.user) + const first = Atom.Registry.get(s.ctx.registry, s.ctx.user) + Atom.Registry.set(s.ctx.registry, s.ctx.create, { + payload: { name: 'grace' }, + reactivityKeys: ['users'], + }) + yield* waitForSettled(s.ctx.registry, s.ctx.user) + const second = Atom.Registry.get(s.ctx.registry, s.ctx.user) + const calls = s.ctx.callsMade() + unmount() + return { first, second, calls } + })), + Then('the change ran and the watched user was fetched again')( + (s, expect) => + expect({ first: s.readings.first._tag, second: s.readings.second._tag, calls: s.readings.calls }).toEqual({ + first: 'Success', + second: 'Success', + calls: 3, + }), + ), + ), + ) + scenario( + 'A client built with a protocol function answers the same as one built with a layer', + Gherkin.Do.pipe( + Given('a page that builds its rpc client with a protocol function')('ctx', () => + Effect.sync(() => { + const Handlers: Layer.Layer> = Group.toLayer({ + getUser: ({ id }) => Effect.succeed({ id, name: `user-${id}` }), + createUser: () => Effect.die('unexpected tag: createUser'), + }) + const makeEffect = serverFor(Handlers) + const Client = Atom.Rpc.Service()('Client', { + group: Group, + protocol: () => Layer.empty, + makeEffect, + }) + const user = Client.query('getUser', { id: 1 }) + const registry = Atom.Registry.make() + return { user, registry } + })), + When('the user is read')('outcome', (s) => + Effect.gen(function*() { + const unmount = Atom.Registry.subscribe(s.ctx.registry, s.ctx.user, () => {}, { immediate: true }) + yield* waitForSettled(s.ctx.registry, s.ctx.user) + const outcome = Atom.Registry.get(s.ctx.registry, s.ctx.user) + unmount() + return outcome + })), + Then('the user is reported')( + (s, expect) => + expect({ tag: s.outcome._tag, value: Atom.AsyncResult.value(s.outcome) }).toEqual({ + tag: 'Success', + value: Option.some({ id: 1, name: 'user-1' }), + }), + ), + ), + ) + scenario( + 'A query asked for headers, a minute-long retention, and a hydration key survives reload, while a query asked to stay alive forever is kept', + Gherkin.Do.pipe( + Given( + 'a page that fetches the user with headers, a finite retention, and a hydration key, and another user kept alive forever', + )('ctx', () => + Effect.sync(() => { + let callCount = 0 + const Handlers: Layer.Layer> = Group.toLayer({ + getUser: ({ id }) => + Effect.sync(() => { + callCount += 1 + return { id, name: `user-${id}` } + }), + createUser: () => Effect.die('unexpected tag: createUser'), + }) + const makeEffect = serverFor(Handlers) + const Client = Atom.Rpc.Service()('Client', { + group: Group, + protocol: Layer.empty, + makeEffect, + }) + const user = Client.query('getUser', { id: 1 }, { + headers: { 'x-page': 'home' }, + timeToLive: '1 minute', + serializationKey: '1', + }) + const keptUser = Client.query('getUser', { id: 2 }, { + timeToLive: 'Infinity', + serializationKey: 'keep', + }) + const registry = Atom.Registry.make() + return { + user, + keptUser, + registry, + callsMade: () => callCount, + idleTTL: user.spec.idleTTL, + keepAlive: keptUser.spec.keepAlive, + } + })), + When('the user is read and the page is reloaded')('result', (s) => + Effect.sync(() => { + const unmount = Atom.Registry.subscribe(s.ctx.registry, s.ctx.user, () => {}, { immediate: true }) + const savedPage = Atom.Hydration.dehydrate(s.ctx.registry) + unmount() + const freshPage = Atom.Registry.make() + Atom.Hydration.hydrate(freshPage, savedPage) + const secondReading = Atom.Registry.get(freshPage, s.ctx.user) + return { secondReading, calls: s.ctx.callsMade() } + })), + Then('the first query keeps its retention and the second stays alive, and reload does not refetch')( + (s, expect) => + expect({ idleTTL: s.ctx.idleTTL, keepAlive: s.ctx.keepAlive, calls: s.result.calls }).toEqual({ + idleTTL: 60_000, + keepAlive: true, + calls: 1, + }), + ), + ), + ) + }) diff --git a/packages/atom/effect-atom/tests/Hydration.integration.test.ts b/packages/atom/effect-atom/tests/Hydration.integration.test.ts new file mode 100644 index 0000000..ca367d8 --- /dev/null +++ b/packages/atom/effect-atom/tests/Hydration.integration.test.ts @@ -0,0 +1,454 @@ +import { Atom } from '@systemfsoftware/effect-atom' +import { Gherkin, Given, it, makeFeature, Then, When } from '@systemfsoftware/effect-gherkin-spec' +import { Deferred, Effect, Fiber, Layer, Schema } from 'effect' +import { TestClock } from 'effect/testing' + +const Feature = makeFeature({ it }) + +const throughText = (saved: ReadonlyArray) => + Schema.encodeUnknownEffect(Schema.fromJsonString(Schema.Array(Schema.Json)))(saved).pipe( + Effect.flatMap((text) => + Schema.decodeEffect(Schema.fromJsonString(Schema.Array(Schema.Json)))(text).pipe( + Effect.map((copy) => ({ text, copy })), + ) + ), + Effect.orDie, + ) + +Feature("Saving a page's values so a reloaded page starts with them already filled in") + .withLayer(Layer.empty) + .body(({ scenario }) => { + scenario( + 'A saved value is still there after the page reloads and its cleanup timer runs', + Gherkin.Do.pipe( + Given('a page with a saved value, and cleanup enabled after a short idle period')( + 'ctx', + () => + Effect.sync(() => { + const base = Atom.make(42) + const savedValue = base.pipe( + Atom.serializable({ + key: 'k1', + schema: Schema.Finite, + }), + ) + const page = Atom.Registry.make({ defaultIdleTTL: 5 }) + Atom.Registry.subscribe(page, savedValue, () => {}) + const saved = Atom.Hydration.dehydrate(page) + const reloadedPage = Atom.Registry.make({ defaultIdleTTL: 5 }) + Atom.Hydration.hydrate(reloadedPage, saved) + return { reloadedPage, savedValue } + }), + ), + When('the value is read on the reloaded page, then read again after the cleanup timer runs')( + 'result', + (s) => + Effect.gen(function*() { + const firstReading = Atom.Registry.get(s.ctx.reloadedPage, s.ctx.savedValue) + yield* TestClock.adjust('100 millis') + const secondReading = Atom.Registry.get(s.ctx.reloadedPage, s.ctx.savedValue) + return { firstReading, secondReading } + }), + ), + Then('the reloaded page shows the saved value both times')( + (s, expect) => expect(s.result).toMatchObject({ firstReading: 42, secondReading: 42 }), + ), + ), + ) + scenario( + 'A value that had already finished loading is restored as finished, not restarted, after reload', + Gherkin.Do.pipe( + Given('a page with a value that already finished loading, and a short cleanup timer')( + 'ctx', + () => + Effect.sync(() => { + const base = Atom.make(Effect.succeed(123)) + const savedValue = base.pipe( + Atom.serializable({ + key: 'k-eff', + schema: Atom.AsyncResult.Schema({ success: Schema.Finite }), + }), + ) + const page = Atom.Registry.make({ defaultIdleTTL: 5 }) + Atom.Registry.subscribe(page, savedValue, () => {}) + const saved = Atom.Hydration.dehydrate(page) + const reloadedPage = Atom.Registry.make({ defaultIdleTTL: 5 }) + Atom.Hydration.hydrate(reloadedPage, saved) + return { reloadedPage, savedValue } + }), + ), + When('the value is read on the reloaded page after the cleanup timer runs')( + 'reading', + (s) => + Effect.gen(function*() { + yield* TestClock.adjust('100 millis') + const reading = Atom.Registry.get(s.ctx.reloadedPage, s.ctx.savedValue) + return reading + }), + ), + Then('the reloaded page shows the value as already finished, with the saved answer')( + (s, expect) => expect(s.reading).toMatchObject({ _tag: 'Success', value: 123 }), + ), + ), + ) + scenario( + 'A value that is still loading when the page is saved automatically fills in once it finishes, even after the page already reloaded', + Gherkin.Do.pipe( + Given('a page saved while a value is still loading, reloaded before that value finishes')( + 'ctx', + () => + Effect.gen(function*() { + const source = yield* Deferred.make() + const stillLoading = Atom.make(Deferred.await(source)).pipe( + Atom.serializable({ + key: 'k-pending', + schema: Atom.AsyncResult.Schema({ success: Schema.Finite }), + }), + ) + const savedPage = Atom.Registry.make() + Atom.Registry.subscribe(savedPage, stillLoading, () => {}) + const saved = Atom.Hydration.dehydrate(savedPage, { encodeInitialAs: 'deferred' }) + const reloadedPage = Atom.Registry.make() + const applied = Atom.Hydration.hydrate(reloadedPage, saved) + return { reloadedPage, stillLoading, source, applied } + }), + ), + When('the reloaded page is read before and after the value finishes loading')( + 'reading', + (s) => + Effect.gen(function*() { + const beforeItFinishes = Atom.Registry.get(s.ctx.reloadedPage, s.ctx.stillLoading) + yield* Deferred.succeed(s.ctx.source, 42) + yield* Fiber.join(s.ctx.applied) + const afterItFinishes = Atom.Registry.get(s.ctx.reloadedPage, s.ctx.stillLoading) + return { beforeItFinishes, afterItFinishes } + }), + ), + Then('the reloaded page starts out loading, then fills in with the finished answer on its own')( + (s, expect) => + expect(s.reading).toMatchObject({ + beforeItFinishes: { _tag: 'Initial' }, + afterItFinishes: { _tag: 'Success', value: 42 }, + }), + ), + ), + ) + scenario( + 'A value still loading when the page is saved is left out of the default saved state', + Gherkin.Do.pipe( + Given('a page with a still-loading saved value, saved in the default way')('ctx', () => + Effect.gen(function*() { + const source = yield* Deferred.make() + const stillLoading = Atom.make(Deferred.await(source)).pipe( + Atom.serializable({ + key: 'k-pending-default', + schema: Atom.AsyncResult.Schema({ success: Schema.Finite }), + }), + ) + const page = Atom.Registry.make() + Atom.Registry.subscribe(page, stillLoading, () => {}) + return { page, stillLoading } + })), + When('the page is saved')('saved', (s) => Effect.sync(() => Atom.Hydration.dehydrate(s.ctx.page))), + Then('the still-loading value is not included')((s, expect) => expect(s.saved).toEqual([])), + ), + ) + scenario( + 'A still-loading value that is asked to reload while the page is saved still fills in automatically once it finishes', + Gherkin.Do.pipe( + Given('a page with a still-loading value, saved so a reloaded page receives it once it finishes')( + 'ctx', + () => + Effect.gen(function*() { + const source = yield* Deferred.make() + const stillLoading = Atom.make(Deferred.await(source)).pipe( + Atom.serializable({ + key: 'k-refresh', + schema: Atom.AsyncResult.Schema({ success: Schema.Finite }), + }), + ) + const savedPage = Atom.Registry.make() + Atom.Registry.subscribe(savedPage, stillLoading, () => {}) + const saved = Atom.Hydration.dehydrate(savedPage, { encodeInitialAs: 'deferred' }) + const reloadedPage = Atom.Registry.make() + const applied = Atom.Hydration.hydrate(reloadedPage, saved) + return { savedPage, reloadedPage, stillLoading, source, applied } + }), + ), + When( + 'the still-loading value is asked to reload again, then it finishes, and the reloaded page is read before and after', + )( + 'reading', + (s) => + Effect.gen(function*() { + const beforeItFinishes = Atom.Registry.get(s.ctx.reloadedPage, s.ctx.stillLoading) + Atom.Registry.refresh(s.ctx.savedPage, s.ctx.stillLoading) + yield* Deferred.succeed(s.ctx.source, 42) + yield* Fiber.join(s.ctx.applied) + const afterItFinishes = Atom.Registry.get(s.ctx.reloadedPage, s.ctx.stillLoading) + return { beforeItFinishes, afterItFinishes } + }), + ), + Then('the reloaded page starts out loading and then fills in with the finished answer on its own')( + (s, expect) => + expect(s.reading).toMatchObject({ + beforeItFinishes: { _tag: 'Initial' }, + afterItFinishes: { _tag: 'Success', value: 42 }, + }), + ), + ), + ) + scenario( + 'A value that is not marked for saving is left out of the saved state', + Gherkin.Do.pipe( + Given('a page holding both a saved value and a plain value')('ctx', () => + Effect.sync(() => { + const savedValue = Atom.make(42).pipe( + Atom.serializable({ + key: 'k-plain', + schema: Schema.Finite, + }), + ) + const plainValue = Atom.make('not saved') + const page = Atom.Registry.make() + Atom.Registry.subscribe(page, savedValue, () => {}) + Atom.Registry.subscribe(page, plainValue, () => {}) + return { page } + })), + When('the page is saved')('saved', (s) => Effect.sync(() => Atom.Hydration.dehydrate(s.ctx.page))), + Then('only the saved value is included')( + (s, expect) => + expect(s.saved.map((entry) => ({ key: entry.key, value: entry.value }))).toEqual([ + { key: 'k-plain', value: 42 }, + ]), + ), + ), + ) + scenario( + 'A saved number that breaks the page rule is noted and the page computes its own number', + Gherkin.Do.pipe( + Given('a page whose saved number must stay above zero')('ctx', () => + Effect.sync(() => { + const count = Atom.make(7).pipe( + Atom.serializable({ + key: 'count-above-zero', + schema: Schema.Finite.pipe(Schema.check(Schema.isGreaterThan(0))), + }), + ) + const page = Atom.Registry.make() + return { page, count } + })), + When('a saved payload arrives carrying a negative number for it')('result', (s) => + Effect.sync(() => { + Atom.Hydration.hydrate(s.ctx.page, [{ + '~effect/reactivity/DehydratedAtom': true, + key: 'count-above-zero', + value: -5, + dehydratedAt: 1, + }]) + return { + reading: Atom.Registry.get(s.ctx.page, s.ctx.count), + notes: Atom.Registry.refusals(s.ctx.page), + } + })), + Then('one note names the number and the broken rule, and the page shows its own number')( + (s, expect) => + expect({ + reading: s.result.reading, + notes: s.result.notes.map((note) => ({ key: note.key, issue: note.issue })), + }).toEqual({ + reading: 7, + notes: [{ key: 'count-above-zero', issue: expect.stringMatching(/greater than/) }], + }), + ), + ), + ) + scenario( + 'A number that breaks its saving rule is noted and left out of the saved state', + Gherkin.Do.pipe( + Given('a page holding a zero where the saved number must stay above zero, next to a valid saved word')( + 'ctx', + () => + Effect.sync(() => { + const count = Atom.make(0).pipe( + Atom.serializable({ + key: 'count-must-be-positive', + schema: Schema.Finite.pipe(Schema.check(Schema.isGreaterThan(0))), + }), + ) + const label = Atom.make('hello').pipe(Atom.serializable({ key: 'label', schema: Schema.String })) + const page = Atom.Registry.make() + Atom.Registry.subscribe(page, count, () => {}) + Atom.Registry.subscribe(page, label, () => {}) + return { page } + }), + ), + When('the page is saved')('result', (s) => + Effect.sync(() => ({ + saved: Atom.Hydration.dehydrate(s.ctx.page).map((entry) => entry.key), + notes: Atom.Registry.refusals(s.ctx.page), + }))), + Then('only the valid word is saved and the broken number is noted by name')( + (s, expect) => + expect({ saved: s.result.saved, noted: s.result.notes.map((note) => note.key) }).toEqual({ + saved: ['label'], + noted: ['count-must-be-positive'], + }), + ), + ), + ) + scenario( + 'A garbled word for one saved number is noted while the good number next to it still loads', + Gherkin.Do.pipe( + Given('a page holding two saved numbers')('ctx', () => + Effect.sync(() => { + const first = Atom.make(1).pipe( + Atom.serializable({ key: 'first-number', schema: Schema.Finite }), + ) + const second = Atom.make(2).pipe( + Atom.serializable({ key: 'second-number', schema: Schema.Finite }), + ) + const page = Atom.Registry.make() + return { page, first, second } + })), + When('a saved payload arrives with a word for the first and a number for the second')( + 'result', + (s) => + Effect.sync(() => { + Atom.Hydration.hydrate(s.ctx.page, [ + { + '~effect/reactivity/DehydratedAtom': true, + key: 'first-number', + value: 'invalid', + dehydratedAt: 1, + }, + { + '~effect/reactivity/DehydratedAtom': true, + key: 'second-number', + value: 9, + dehydratedAt: 1, + }, + ]) + return { + firstReading: Atom.Registry.get(s.ctx.page, s.ctx.first), + secondReading: Atom.Registry.get(s.ctx.page, s.ctx.second), + notes: Atom.Registry.refusals(s.ctx.page), + } + }), + ), + Then('the garbled entry is noted and the good number still loads')( + (s, expect) => + expect({ + firstReading: s.result.firstReading, + secondReading: s.result.secondReading, + noted: s.result.notes.map((note) => note.key), + }).toEqual({ firstReading: 1, secondReading: 9, noted: ['first-number'] }), + ), + ), + ) + scenario( + 'A saved entry with no name is turned away before it can reach the page', + Gherkin.Do.pipe( + Given('a page with a saved number')('ctx', () => + Effect.sync(() => { + const count = Atom.make(3).pipe( + Atom.serializable({ key: 'named-number', schema: Schema.Finite }), + ) + const page = Atom.Registry.make() + return { page, count } + })), + When('a saved payload arrives with an entry missing its name')('result', (s) => + Effect.sync(() => { + const nameless: Atom.Hydration.HydrationEntry = { + '~effect/reactivity/DehydratedAtom': true, + value: 9, + dehydratedAt: 1, + } + Atom.Hydration.hydrate(s.ctx.page, [nameless]) + return { + reading: Atom.Registry.get(s.ctx.page, s.ctx.count), + notes: Atom.Registry.refusals(s.ctx.page), + } + })), + Then('the nameless entry is noted without a name and the page is untouched')( + (s, expect) => + expect({ reading: s.result.reading, noted: s.result.notes.map((note) => note.key) }).toEqual({ + reading: 3, + noted: [undefined], + }), + ), + ), + ) + scenario( + 'A saved loading value carries no hidden machinery through a text copy, yet still fills in from the live page', + Gherkin.Do.pipe( + Given('a page saved while a value is still loading')('ctx', () => + Effect.gen(function*() { + const source = yield* Deferred.make() + const stillLoading = Atom.make(Deferred.await(source)).pipe( + Atom.serializable({ + key: 'k-text-copy', + schema: Atom.AsyncResult.Schema({ success: Schema.Finite }), + }), + ) + const savedPage = Atom.Registry.make() + Atom.Registry.subscribe(savedPage, stillLoading, () => {}) + const saved = Atom.Hydration.dehydrate(savedPage, { encodeInitialAs: 'deferred' }) + return { savedPage, stillLoading, source, saved } + })), + When('the saved state travels through a text copy onto a reloaded page')( + 'reading', + (s) => + Effect.gen(function*() { + const { text } = yield* throughText(s.ctx.saved) + const reloadedPage = Atom.Registry.make() + const applied = Atom.Hydration.hydrate(reloadedPage, s.ctx.saved) + const beforeItFinishes = Atom.Registry.get(reloadedPage, s.ctx.stillLoading) + yield* Deferred.succeed(s.ctx.source, 42) + yield* Fiber.join(applied) + const afterItFinishes = Atom.Registry.get(reloadedPage, s.ctx.stillLoading) + return { text, beforeItFinishes, afterItFinishes } + }), + ), + Then('the text copy hides the machinery and the reloaded page still fills in')( + (s, expect) => + expect(s.reading).toMatchObject({ + text: expect.stringMatching(/^(?!.*Deferred)/), + beforeItFinishes: { _tag: 'Initial' }, + afterItFinishes: { _tag: 'Success', value: 42 }, + }), + ), + ), + ) + scenario( + 'A finished loading value survives a text copy round trip with its answer intact', + Gherkin.Do.pipe( + Given('a page with a value that already finished loading')('ctx', () => + Effect.sync(() => { + const finished = Atom.make(Effect.succeed(123)).pipe( + Atom.serializable({ + key: 'k-round-trip', + schema: Atom.AsyncResult.Schema({ success: Schema.Finite }), + }), + ) + const page = Atom.Registry.make() + Atom.Registry.subscribe(page, finished, () => {}) + return { page, finished } + })), + When('the saved state travels through a text copy onto a reloaded page')( + 'reading', + (s) => + throughText(Atom.Hydration.dehydrate(s.ctx.page)).pipe( + Effect.map(({ copy }) => { + const reloadedPage = Atom.Registry.make() + Atom.Hydration.hydrate(reloadedPage, copy) + return Atom.Registry.get(reloadedPage, s.ctx.finished) + }), + ), + ), + Then('the reloaded page shows the value as already finished, with the saved answer')( + (s, expect) => expect(s.reading).toMatchObject({ _tag: 'Success', value: 123 }), + ), + ), + ) + }) diff --git a/packages/atom/effect-atom/tests/Registry.integration.test.ts b/packages/atom/effect-atom/tests/Registry.integration.test.ts new file mode 100644 index 0000000..aead4d2 --- /dev/null +++ b/packages/atom/effect-atom/tests/Registry.integration.test.ts @@ -0,0 +1,1735 @@ +import { Atom } from '@systemfsoftware/effect-atom' +import { Gherkin, Given, it, makeFeature, Then, When } from '@systemfsoftware/effect-gherkin-spec' +import { + Cause, + Context, + Effect, + Exit, + Fiber, + HashSet, + Latch, + Layer, + Option, + Result, + Schema, + Scope, + Stream, +} from 'effect' +import { TestClock } from 'effect/testing' + +const Feature = makeFeature({ it }) + +class FirstRegistry extends Context.Service()( + '@systemfsoftware/effect-atom/tests/Registry.integration.test/FirstRegistry', +) {} +class SecondRegistry extends Context.Service()( + '@systemfsoftware/effect-atom/tests/Registry.integration.test/SecondRegistry', +) {} + +class VisitLog extends Context.Service }>()( + '@systemfsoftware/effect-atom/tests/Registry.integration.test/VisitLog', +) {} + +/** The names of the values a page still holds, out of the values a scenario names. */ +const heldNames = ( + nodes: Iterable, + named: Readonly>, +): ReadonlyArray => + [...nodes].map((node) => Object.entries(named).find(([, atom]) => atom === node)?.[0] ?? 'unnamed').sort() + +Feature('Keeping a value that is still loading available to every reader') + .withLayer(Layer.empty) + .body(({ scenario, scenarioOutline }) => { + scenario( + 'A value that never finishes loading is not started over after several readers check it', + Gherkin.Do.pipe( + Given('a value that never finishes loading, with cleanup enabled after a short idle period')( + 'setup', + () => + Effect.sync(() => { + let startCount = 0 + const atom = Atom.make( + Effect.callback(() => { + startCount++ + }), + ) + const registry = Atom.Registry.make({ defaultIdleTTL: 10 }) + return { registry, atom, timesStarted: () => startCount } + }), + ), + When('two readers check the value while it is still loading, and the cleanup timer runs')( + 'result', + (s) => + Effect.gen(function*() { + const firstReading = Atom.Registry.get(s.setup.registry, s.setup.atom) + const secondReading = Atom.Registry.get(s.setup.registry, s.setup.atom) + yield* TestClock.adjust('100 millis') + const readingAfterCleanup = Atom.Registry.get(s.setup.registry, s.setup.atom) + const started = s.setup.timesStarted() + return { firstReading, secondReading, readingAfterCleanup, started } + }), + ), + Then('the work only ever started once, and every reader still sees it loading')((s, expect) => + expect(s.result).toMatchObject({ + started: 1, + firstReading: { _tag: 'Initial', waiting: true }, + secondReading: { _tag: 'Initial', waiting: true }, + readingAfterCleanup: { _tag: 'Initial', waiting: true }, + }) + ), + ), + ) + scenario( + 'A value marked to always stay available is never dropped or restarted by cleanup', + Gherkin.Do.pipe( + Given('a value marked to always stay available, with cleanup enabled after a short idle period')( + 'ctx', + () => + Effect.sync(() => { + let startCount = 0 + const atom = Atom.keepAlive( + Atom.make(Effect.callback(() => { + startCount++ + })), + ) + const registry = Atom.Registry.make({ defaultIdleTTL: 5 }) + return { registry, atom, timesStarted: () => startCount } + }), + ), + When('a reader checks the value and the cleanup timer runs, then checks it again')( + 'res', + (s) => + Effect.gen(function*() { + const firstReading = Atom.Registry.get(s.ctx.registry, s.ctx.atom) + yield* TestClock.adjust('100 millis') + const secondReading = Atom.Registry.get(s.ctx.registry, s.ctx.atom) + const started = s.ctx.timesStarted() + return { firstReading, secondReading, started } + }), + ), + Then('the value is still available and its work only ran once')((s, expect) => expect(s.res.started).toBe(1)), + ), + ) + scenario( + 'A derived value that switches sources lets the abandoned source be cleaned up', + Gherkin.Do.pipe( + Given('a derived value that can follow one of two sources, with a short cleanup timer')( + 'ctx', + () => + Effect.sync(() => { + let useFirst = true + const first = Atom.make('first') + const second = Atom.make('second') + const switching = Atom.readable((get) => { + if (useFirst) { + return get(first) + } + return get(second) + }).pipe(Atom.keepAlive) + const page = Atom.Registry.make({ defaultIdleTTL: 10, timeoutResolution: 5 }) + return { + page, + first, + second, + switching, + flip: () => { + useFirst = false + }, + } + }), + ), + When('the derived value switches sources and the cleanup timer runs')('nodes', (s) => + Effect.gen(function*() { + const before = Atom.Registry.get(s.ctx.page, s.ctx.switching) + s.ctx.flip() + Atom.Registry.refresh(s.ctx.page, s.ctx.switching) + const after = Atom.Registry.get(s.ctx.page, s.ctx.switching) + yield* TestClock.adjust('100 millis') + const keys = HashSet.fromIterable(Atom.Registry.getNodes(s.ctx.page).keys()) + return { + before, + after, + held: heldNames(keys, { + first: s.ctx.first, + second: s.ctx.second, + switching: s.ctx.switching, + }), + } + })), + Then('the abandoned source is gone and the followed one stays')((s, expect) => + expect(s.nodes).toEqual({ before: 'first', after: 'second', held: ['second', 'switching'] }) + ), + ), + ) + scenario( + 'A reader who asks for a settled answer during a refresh gets the fresh one, not the stale one', + Gherkin.Do.pipe( + Given('a stored answer that takes time to refresh')('ctx', () => + Effect.sync(() => { + const latch = Latch.makeUnsafe() + let stored = 1 + const effect: Effect.Effect = Effect.gen(function*() { + yield* latch.await + return stored + }) + const source = Atom.make(effect) + const page = Atom.Registry.make() + return { + page, + source, + latch, + setStored: (n: number) => { + stored = n + }, + } + })), + When('the answer is refreshed and a reader asks for the settled value mid-refresh')( + 'answer', + (s) => + Effect.gen(function*() { + s.ctx.latch.openUnsafe() + const first = Atom.Registry.get(s.ctx.page, s.ctx.source) + s.ctx.setStored(2) + s.ctx.latch.closeUnsafe() + Atom.Registry.refresh(s.ctx.page, s.ctx.source) + const pending = yield* Effect.forkChild( + Atom.Registry.getResult(s.ctx.page, s.ctx.source, { suspendOnWaiting: true }), + ) + s.ctx.latch.openUnsafe() + const settled = yield* Fiber.join(pending) + return { first, settled } + }), + ), + Then('the reader waited and received the fresh answer')( + (s, expect) => expect(s.answer).toMatchObject({ first: { _tag: 'Success', value: 1 }, settled: 2 }), + ), + ), + ) + scenario( + 'A listener who asks to hear the current value immediately hears it before any change', + Gherkin.Do.pipe( + Given('a value that already exists')('ctx', () => + Effect.sync(() => { + const value = Atom.make(5) + const page = Atom.Registry.make() + Atom.Registry.get(page, value) + return { page, value } + })), + When('a listener attaches asking for the current value immediately')('heard', (s) => + Effect.sync(() => { + const heard: number[] = [] + Atom.Registry.subscribe(s.ctx.page, s.ctx.value, (v) => heard.push(v), { immediate: true }) + return heard + })), + Then('the listener heard the current value without waiting for a change')( + (s, expect) => expect(s.heard).toEqual([5]), + ), + ), + ) + scenario( + 'Two values with the same cleanup schedule are swept together', + Gherkin.Do.pipe( + Given('two values with the same short cleanup timer')('ctx', () => + Effect.sync(() => { + const first = Atom.make(1) + const second = Atom.make(2) + const page = Atom.Registry.make({ defaultIdleTTL: 10, timeoutResolution: 5 }) + return { page, first, second } + })), + When('both are read and the cleanup timer runs')('nodes', (s) => + Effect.gen(function*() { + Atom.Registry.get(s.ctx.page, s.ctx.first) + Atom.Registry.get(s.ctx.page, s.ctx.second) + yield* TestClock.adjust('100 millis') + const keys = HashSet.fromIterable(Atom.Registry.getNodes(s.ctx.page).keys()) + return { held: heldNames(keys, { first: s.ctx.first, second: s.ctx.second }) } + })), + Then('both are gone')((s, expect) => expect(s.nodes).toEqual({ held: [] })), + ), + ) + scenario( + 'A value that is used again while its cleanup is pending is not swept', + Gherkin.Do.pipe( + Given('a value with a short cleanup timer')('ctx', () => + Effect.sync(() => { + let starts = 0 + const value = Atom.make(Effect.sync(() => { + starts++ + return 1 + })) + const page = Atom.Registry.make({ defaultIdleTTL: 100, timeoutResolution: 10 }) + return { page, value, starts: () => starts } + })), + When('the value is read again while its cleanup is pending, then left alone')( + 'readings', + (s) => + Effect.gen(function*() { + Atom.Registry.get(s.ctx.page, s.ctx.value) + yield* TestClock.adjust('50 millis') + Atom.Registry.get(s.ctx.page, s.ctx.value) + yield* TestClock.adjust('60 millis') + const afterFirstWindow = s.ctx.starts() + yield* TestClock.adjust('100 millis') + Atom.Registry.get(s.ctx.page, s.ctx.value) + const afterSecondWindow = s.ctx.starts() + return { afterFirstWindow, afterSecondWindow } + }), + ), + Then('the value survived the first window and was swept only after being left alone')( + (s, expect) => + expect({ afterFirstWindow: s.readings.afterFirstWindow, afterSecondWindow: s.readings.afterSecondWindow }) + .toEqual({ afterFirstWindow: 1, afterSecondWindow: 2 }), + ), + ), + ) + scenario( + 'A value mounted for a lifetime is released when that lifetime closes', + Gherkin.Do.pipe( + Given('a value mounted for a bounded lifetime')('ctx', () => + Effect.sync(() => { + const value = Atom.make(1) + const page = Atom.Registry.make() + return { page, value } + })), + When('the lifetime closes')('nodes', (s) => + Effect.gen(function*() { + yield* Effect.scoped(Atom.Registry.mount(s.ctx.page, s.ctx.value)) + yield* Effect.eventually( + Effect.flatMap( + Effect.sync(() => Atom.Registry.getNodes(s.ctx.page).has(s.ctx.value)), + (stillMounted) => stillMounted ? Effect.fail('the value is still mounted') : Effect.void, + ), + ) + const keys = HashSet.fromIterable(Atom.Registry.getNodes(s.ctx.page).keys()) + return { held: heldNames(keys, { value: s.ctx.value }) } + })), + Then('the value is gone')((s, expect) => expect(s.nodes).toEqual({ held: [] })), + ), + ) + scenario( + 'A stream of a value emits the current value first, then every change until it is released', + Gherkin.Do.pipe( + Given('a value that changes over time')('ctx', () => + Effect.sync(() => { + const value = Atom.make(1) + const page = Atom.Registry.make() + return { page, value } + })), + When('a stream of the value is collected while it changes, then released')( + 'heard', + (s) => + Effect.gen(function*() { + const heard: number[] = [] + const first = Latch.makeUnsafe() + const second = Latch.makeUnsafe() + const fiber = yield* Effect.forkChild( + Effect.scoped( + Stream.runForEach(Atom.Registry.toStream(s.ctx.page, s.ctx.value), (n) => + Effect.sync(() => { + heard.push(n) + if (heard.length === 1) first.openUnsafe() + if (n === 2) second.openUnsafe() + })), + ), + ) + yield* first.await + Atom.Registry.set(s.ctx.page, s.ctx.value, 2) + yield* second.await + yield* Fiber.interrupt(fiber) + return { heard } + }), + ), + Then('the stream delivered the current value first and then the change')( + (s, expect) => expect(s.heard.heard).toEqual([1, 2]), + ), + ), + ) + scenario( + 'A stream of settled results skips the loading state, deduplicates, and fails when the result fails', + Gherkin.Do.pipe( + Given('a result that loads, settles, repeats, and finally fails')('ctx', () => + Effect.sync(() => { + const value = Atom.make>(Atom.AsyncResult.initial(false)) + const page = Atom.Registry.make() + Atom.Registry.subscribe(page, value, () => {}) + return { page, value } + })), + When('a stream of the settled results is collected through all of its states')( + 'outcome', + (s) => + Effect.gen(function*() { + const heard: number[] = [] + const first = Latch.makeUnsafe() + const second = Latch.makeUnsafe() + const fiber = yield* Effect.forkChild( + Effect.scoped( + Stream.runForEach(Atom.Registry.toStreamResult(s.ctx.page, s.ctx.value), (n) => + Effect.sync(() => { + heard.push(n) + if (heard.length === 1) first.openUnsafe() + if (n === 2) second.openUnsafe() + })), + ), + { startImmediately: true }, + ) + Atom.Registry.set(s.ctx.page, s.ctx.value, Atom.AsyncResult.success(1)) + yield* first.await + Atom.Registry.set(s.ctx.page, s.ctx.value, Atom.AsyncResult.success(2)) + yield* second.await + Atom.Registry.set(s.ctx.page, s.ctx.value, Atom.AsyncResult.success(2)) + const afterDuplicate = heard.length + Atom.Registry.set(s.ctx.page, s.ctx.value, Atom.AsyncResult.failure(Cause.fail('boom'))) + const exit = yield* Effect.exit(Fiber.join(fiber)) + return { heard, afterDuplicate, exit } + }), + ), + Then('the loading state was skipped, duplicates were dropped, and the failure surfaced')( + (s, expect) => + expect({ + heard: s.outcome.heard, + afterDuplicate: s.outcome.afterDuplicate, + exit: Exit.findError(s.outcome.exit), + }).toEqual({ heard: [1, 2], afterDuplicate: 2, exit: Result.succeed('boom') }), + ), + ), + ) + scenario( + 'A stream of a failed result fails right away', + Gherkin.Do.pipe( + Given('a result that is already failed')('ctx', () => + Effect.sync(() => { + const failing = Atom.make>( + Atom.AsyncResult.failure(Cause.fail('boom')), + ) + const page = Atom.Registry.make() + return { page, failing } + })), + When('a stream of its settled values is collected')('outcome', (s) => + Effect.gen(function*() { + const fiber = yield* Effect.forkChild( + Effect.scoped(Stream.runCollect(Atom.Registry.toStreamResult(s.ctx.page, s.ctx.failing))), + ) + const exit = yield* Effect.exit(Fiber.join(fiber)) + return { exit } + })), + Then('the stream failed immediately with the failure')( + (s, expect) => expect(Exit.findError(s.outcome.exit)).toEqual(Result.succeed('boom')), + ), + ), + ) + scenario( + 'A stream created through a value reads settled results and failures', + Gherkin.Do.pipe( + Given('a value that exposes streams of a settled and of a failed result')('ctx', () => + Effect.sync(() => { + const successResult = Atom.make>(Atom.AsyncResult.success(3)) + const failureResult = Atom.make>( + Atom.AsyncResult.failure(Cause.fail('boom')), + ) + const successStream = Atom.keepAlive(Atom.readable((get) => get.streamResult(successResult))) + const failureStream = Atom.keepAlive(Atom.readable((get) => get.streamResult(failureResult))) + const page = Atom.Registry.make() + return { page, successStream, failureStream } + })), + When('both streams are collected')('outcome', (s) => + Effect.gen(function*() { + const heard: number[] = [] + const got = Latch.makeUnsafe() + const successFiber = yield* Effect.forkChild( + Effect.scoped( + Stream.runForEach(Atom.Registry.get(s.ctx.page, s.ctx.successStream), (n) => + Effect.sync(() => { + heard.push(n) + got.openUnsafe() + })), + ), + ) + yield* got.await + yield* Fiber.interrupt(successFiber) + const failureFiber = yield* Effect.forkChild( + Effect.scoped(Stream.runCollect(Atom.Registry.get(s.ctx.page, s.ctx.failureStream))), + ) + const exit = yield* Effect.exit(Fiber.join(failureFiber)) + return { chunk: heard, exit } + })), + Then('the settled stream delivered its value and the failed one failed')( + (s, expect) => + expect({ chunk: s.outcome.chunk, exit: Exit.findError(s.outcome.exit) }).toEqual({ + chunk: [3], + exit: Result.succeed('boom'), + }), + ), + ), + ) + scenario( + 'A reader asking for a settled answer hears it immediately when one is already available', + Gherkin.Do.pipe( + Given('a result that already holds a settled value')('ctx', () => + Effect.sync(() => { + const settled = Atom.make>(Atom.AsyncResult.success(10)) + const page = Atom.Registry.make() + return { page, settled } + })), + When('a reader asks for the settled answer')('answer', (s) => + Effect.gen(function*() { + const immediate = yield* Atom.Registry.getResult(s.ctx.page, s.ctx.settled) + return { immediate } + })), + Then('the reader heard the settled value without waiting')((s, expect) => expect(s.answer.immediate).toBe(10)), + ), + ) + scenario( + 'A reader asking for a settled answer waits through loading and waiting states until a final value arrives', + Gherkin.Do.pipe( + Given('three results in the loading state')('ctx', () => + Effect.sync(() => { + const loading = Atom.make>(Atom.AsyncResult.initial(false)) + const waiting = Atom.make>(Atom.AsyncResult.initial(false)) + const flickering = Atom.make>(Atom.AsyncResult.initial(false)) + const page = Atom.Registry.make() + return { page, loading, waiting, flickering } + })), + When('readers ask for settled answers while each result settles in turn')( + 'answers', + (s) => + Effect.gen(function*() { + const fromLoading = yield* Effect.forkChild( + Atom.Registry.getResult(s.ctx.page, s.ctx.loading), + { startImmediately: true }, + ) + Atom.Registry.set(s.ctx.page, s.ctx.loading, Atom.AsyncResult.success(20)) + const waited = yield* Fiber.join(fromLoading) + const fromWaiting = yield* Effect.forkChild( + Atom.Registry.getResult(s.ctx.page, s.ctx.waiting, { suspendOnWaiting: true }), + { startImmediately: true }, + ) + Atom.Registry.set(s.ctx.page, s.ctx.waiting, Atom.AsyncResult.successWith(1, { waiting: true })) + Atom.Registry.set(s.ctx.page, s.ctx.waiting, Atom.AsyncResult.success(2)) + const waitedThrough = yield* Fiber.join(fromWaiting) + const fromFlicker = yield* Effect.forkChild( + Atom.Registry.getResult(s.ctx.page, s.ctx.flickering), + { startImmediately: true }, + ) + Atom.Registry.set(s.ctx.page, s.ctx.flickering, Atom.AsyncResult.initial(true)) + Atom.Registry.set(s.ctx.page, s.ctx.flickering, Atom.AsyncResult.success(30)) + const waitedPastFlicker = yield* Fiber.join(fromFlicker) + return { waited, waitedThrough, waitedPastFlicker } + }), + ), + Then('every reader waited only for the final settled value')( + (s, expect) => + expect({ + waited: s.answers.waited, + waitedThrough: s.answers.waitedThrough, + waitedPastFlicker: s.answers.waitedPastFlicker, + }).toEqual({ waited: 20, waitedThrough: 2, waitedPastFlicker: 30 }), + ), + ), + ) + scenario( + 'A listener hears only the final value when several writes happen inside one batch', + Gherkin.Do.pipe( + Given('a value with a listener attached')('ctx', () => + Effect.sync(() => { + const value = Atom.make(1) + const page = Atom.Registry.make() + return { page, value } + })), + When('several writes happen inside one batch')('heard', (s) => + Effect.sync(() => { + const heard: number[] = [] + Atom.Registry.subscribe(s.ctx.page, s.ctx.value, (v) => heard.push(v)) + Atom.Registry.batch(s.ctx.page, () => { + Atom.Registry.set(s.ctx.page, s.ctx.value, 2) + Atom.Registry.set(s.ctx.page, s.ctx.value, 3) + Atom.Registry.set(s.ctx.page, s.ctx.value, 4) + }) + return { heard } + })), + Then('the listener heard only the final value once')((s, expect) => expect(s.heard.heard).toEqual([4])), + ), + ) + scenario( + 'A write in a second registry is announced immediately while the first registry is inside a batch', + Gherkin.Do.pipe( + Given('two separate registries holding the same value, with a listener on the second')( + 'ctx', + () => + Effect.sync(() => { + const batching = Atom.Registry.make() + const listening = Atom.Registry.make() + const value = Atom.make(1) + return { batching, listening, value } + }), + ), + When('the first registry runs a batch that writes to a value held by the second registry')( + 'heard', + (s) => + Effect.sync(() => { + const heard: number[] = [] + const heardWhileBatching: number[][] = [] + Atom.Registry.subscribe(s.ctx.listening, s.ctx.value, (v) => heard.push(v)) + Atom.Registry.batch(s.ctx.batching, () => { + Atom.Registry.set(s.ctx.listening, s.ctx.value, 2) + heardWhileBatching.push([...heard]) + }) + return { heard, heardWhileBatching } + }), + ), + Then('the listener on the second registry heard the new value before that batch finished, and only once')( + (s, expect) => + expect({ whileBatching: s.heard.heardWhileBatching, after: s.heard.heard }).toEqual({ + whileBatching: [[2]], + after: [2], + }), + ), + ), + ) + scenario( + 'A value that invalidates itself while building inside a batch is rebuilt once and settles on the new value', + Gherkin.Do.pipe( + Given('a value whose first build writes to the source it reads')('ctx', () => + Effect.sync(() => { + const source = Atom.make(0) + let firstBuild = true + const selfInvalidating = Atom.readable((get) => { + const s = get(source) + if (firstBuild) { + firstBuild = false + get.set(source, s + 1) + } + return s + }) + const page = Atom.Registry.make() + return { page, selfInvalidating } + })), + When('the value is built inside a batch')('result', (s) => + Effect.sync(() => { + Atom.Registry.batch(s.ctx.page, () => { + Atom.Registry.get(s.ctx.page, s.ctx.selfInvalidating) + }) + return { value: Atom.Registry.get(s.ctx.page, s.ctx.selfInvalidating) } + })), + Then('the value was rebuilt once and settled on the newer source value')( + (s, expect) => expect(s.result.value).toBe(1), + ), + ), + ) + scenario( + 'A batch that invalidates both a value and its source rebuilds the source first', + Gherkin.Do.pipe( + Given('a value derived from a source')('ctx', () => + Effect.sync(() => { + const source = Atom.make(1) + const derived = Atom.readable((get) => get(source)) + const page = Atom.Registry.make() + return { page, source, derived } + })), + When('both are refreshed inside one batch')('result', (s) => + Effect.sync(() => { + Atom.Registry.get(s.ctx.page, s.ctx.source) + Atom.Registry.get(s.ctx.page, s.ctx.derived) + Atom.Registry.batch(s.ctx.page, () => { + Atom.Registry.refresh(s.ctx.page, s.ctx.derived) + Atom.Registry.refresh(s.ctx.page, s.ctx.source) + }) + return { + source: Atom.Registry.get(s.ctx.page, s.ctx.source), + derived: Atom.Registry.get(s.ctx.page, s.ctx.derived), + } + })), + Then('both were rebuilt in dependency order and kept their values')( + (s, expect) => + expect({ source: s.result.source, derived: s.result.derived }).toEqual({ source: 1, derived: 1 }), + ), + ), + ) + scenario( + 'A value that refreshes itself while building inside a batch is rebuilt once', + Gherkin.Do.pipe( + Given('a value whose first build refreshes itself')('ctx', () => + Effect.sync(() => { + const source = Atom.make(0) + let firstBuild = true + const selfRefreshing = Atom.readable((get) => { + const s = get(source) + if (firstBuild) { + firstBuild = false + get.refreshSelf() + } + return s + }) + const page = Atom.Registry.make() + return { page, source, selfRefreshing } + })), + When('the value is built inside a batch')('result', (s) => + Effect.sync(() => { + Atom.Registry.batch(s.ctx.page, () => { + Atom.Registry.get(s.ctx.page, s.ctx.selfRefreshing) + }) + return { value: Atom.Registry.get(s.ctx.page, s.ctx.selfRefreshing) } + })), + Then('the refresh happened without leaving the value behind')((s, expect) => expect(s.result.value).toBe(0)), + ), + ) + scenario( + 'A preloaded value is announced to listeners and kept as the first value', + Gherkin.Do.pipe( + Given('a value with a listener attached but no value yet')('ctx', () => + Effect.sync(() => { + const value = Atom.make(1) + const page = Atom.Registry.make() + return { page, value } + })), + When('an initial value is set before the value is ever read')('result', (s) => + Effect.sync(() => { + const heard: number[] = [] + Atom.Registry.subscribe(s.ctx.page, s.ctx.value, (v) => heard.push(v)) + Atom.Registry.setInitialValue(s.ctx.page, s.ctx.value, 10) + return { heard, read: Atom.Registry.get(s.ctx.page, s.ctx.value) } + })), + Then('the listener heard the preloaded value and the first read returned it')( + (s, expect) => expect({ heard: s.result.heard, read: s.result.read }).toEqual({ heard: [10], read: 10 }), + ), + ), + ) + scenario( + 'Setting a new initial value on an already-built value replaces it', + Gherkin.Do.pipe( + Given('a value that has already been read')('ctx', () => + Effect.sync(() => { + const value = Atom.make(1) + const page = Atom.Registry.make() + return { page, value } + })), + When('a new initial value is set on the built value')('result', (s) => + Effect.sync(() => { + Atom.Registry.get(s.ctx.page, s.ctx.value) + Atom.Registry.setInitialValue(s.ctx.page, s.ctx.value, 7) + return { read: Atom.Registry.get(s.ctx.page, s.ctx.value) } + })), + Then('the built value now holds the new initial value')((s, expect) => expect(s.result.read).toBe(7)), + ), + ) + scenario( + 'Setting an initial value inside a batch still announces it', + Gherkin.Do.pipe( + Given('a value that has never been read')('ctx', () => + Effect.sync(() => { + const fresh = Atom.make(2) + const page = Atom.Registry.make() + return { page, fresh } + })), + When('an initial value is set for it inside a batch')('result', (s) => + Effect.sync(() => { + Atom.Registry.batch(s.ctx.page, () => { + Atom.Registry.setInitialValue(s.ctx.page, s.ctx.fresh, 5) + }) + return { read: Atom.Registry.get(s.ctx.page, s.ctx.fresh) } + })), + Then('the value kept the initial value set inside the batch')((s, expect) => expect(s.result.read).toBe(5)), + ), + ) + scenario( + 'Setting an initial value on a derived value routes it to its source', + Gherkin.Do.pipe( + Given('a derived value that stores its initial value on its source')('ctx', () => + Effect.sync(() => { + const source = Atom.make(1) + const derived = Atom.transform(source, (get) => get(source), { initialValueTarget: source }) + const page = Atom.Registry.make() + return { page, source, derived } + })), + When('an initial value is set on the derived value')('result', (s) => + Effect.sync(() => { + Atom.Registry.setInitialValue(s.ctx.page, s.ctx.derived, 7) + return { + derived: Atom.Registry.get(s.ctx.page, s.ctx.derived), + source: Atom.Registry.get(s.ctx.page, s.ctx.source), + } + })), + Then('the initial value was routed through to the source and flowed back')( + (s, expect) => + expect({ derived: s.result.derived, source: s.result.source }).toEqual({ derived: 7, source: 7 }), + ), + ), + ) + scenario( + 'A stored serializable value is applied directly when the value already exists', + Gherkin.Do.pipe( + Given('a serializable value that has already been read')('ctx', () => + Effect.sync(() => { + const direct = Atom.make(2).pipe(Atom.serializable({ key: 'direct-key', schema: Schema.Finite })) + const page = Atom.Registry.make() + return { page, direct } + })), + When('a stored value arrives for it')('result', (s) => + Effect.sync(() => { + Atom.Registry.get(s.ctx.page, s.ctx.direct) + Atom.Registry.setSerializable(s.ctx.page, 'direct-key', 9) + return { read: Atom.Registry.get(s.ctx.page, s.ctx.direct) } + })), + Then('the existing value was replaced by the stored one')((s, expect) => expect(s.result.read).toBe(9)), + ), + ) + scenario( + 'A stored serializable value is routed to the source when the value is derived', + Gherkin.Do.pipe( + Given('a derived value that stores its initial value on its source')('ctx', () => + Effect.sync(() => { + const source = Atom.make(1) + const derived = Atom.transform(source, (get) => get(source), { initialValueTarget: source }).pipe( + Atom.serializable({ key: 'derived-key', schema: Schema.Finite }), + ) + const page = Atom.Registry.make() + return { page, source, derived } + })), + When('a stored value arrives before the derived value is ever read')('result', (s) => + Effect.sync(() => { + Atom.Registry.setSerializable(s.ctx.page, 'derived-key', 7) + return { + derived: Atom.Registry.get(s.ctx.page, s.ctx.derived), + source: Atom.Registry.get(s.ctx.page, s.ctx.source), + } + })), + Then('the stored value became the source initial value and flowed through')( + (s, expect) => + expect({ derived: s.result.derived, source: s.result.source }).toEqual({ derived: 7, source: 7 }), + ), + ), + ) + scenario( + 'A stored serializable value is applied even before the value is ever read, and refreshing it rebuilds it from its definition', + Gherkin.Do.pipe( + Given('a serializable value that has a listener but has never been read')('ctx', () => + Effect.sync(() => { + const direct = Atom.make(2).pipe(Atom.serializable({ key: 'unread-key', schema: Schema.Finite })) + const page = Atom.Registry.make() + return { page, direct } + })), + When('a stored value arrives and the value is then refreshed')('result', (s) => + Effect.sync(() => { + Atom.Registry.subscribe(s.ctx.page, s.ctx.direct, () => {}) + Atom.Registry.setSerializable(s.ctx.page, 'unread-key', 9) + const stored = Atom.Registry.get(s.ctx.page, s.ctx.direct) + Atom.Registry.refresh(s.ctx.page, s.ctx.direct) + const rebuilt = Atom.Registry.get(s.ctx.page, s.ctx.direct) + return { stored, rebuilt } + })), + Then('the stored value was applied to the never-read value and then rebuilt from its definition')( + (s, expect) => + expect({ stored: s.result.stored, rebuilt: s.result.rebuilt }).toEqual({ stored: 9, rebuilt: 2 }), + ), + ), + ) + scenario( + 'A registry provided for the shared name serves values and honors preloaded options', + Gherkin.Do.pipe( + Given('two values')('ctx', () => + Effect.sync(() => { + const value = Atom.make(1) + const other = Atom.make(2) + return { value, other } + })), + When('one registry is provided under the shared name, another with a preloaded value')( + 'reads', + (s) => + Effect.gen(function*() { + const defaultRead = yield* Effect.provide(Atom.Registry.layer(Atom.Registry.Current))( + Effect.gen(function*() { + const registry = yield* Atom.Registry.Current + return Atom.Registry.get(registry, s.ctx.value) + }), + ) + const preloadedRead = yield* Effect.provide( + Atom.Registry.layer(Atom.Registry.Current, { initialValues: [[s.ctx.other, 9]] }), + )( + Effect.gen(function*() { + const registry = yield* Atom.Registry.Current + return Atom.Registry.get(registry, s.ctx.other) + }), + ) + return { defaultRead, preloadedRead } + }), + ), + Then('both reads went through the provided registries')( + (s, expect) => + expect({ defaultRead: s.reads.defaultRead, preloadedRead: s.reads.preloadedRead }).toEqual({ + defaultRead: 1, + preloadedRead: 9, + }), + ), + ), + ) + scenario( + 'A disposed registry refuses to create nodes', + Gherkin.Do.pipe( + Given('a registry holding a value')('ctx', () => + Effect.sync(() => { + const page = Atom.Registry.make() + const value = Atom.make(1) + Atom.Registry.get(page, value) + return { page } + })), + When('the registry is disposed and a new value is read through it')('result', (s) => + Effect.sync(() => { + Atom.Registry.dispose(s.ctx.page) + const remaining = Atom.Registry.getNodes(s.ctx.page).size + let message: string | undefined + try { + Atom.Registry.get(s.ctx.page, Atom.make(2)) + } catch (error) { + if (error instanceof Error) { + message = error.message + } + } + return { remaining, message } + })), + Then('the registry is empty and reading through it throws')( + (s, expect) => expect(s.result).toEqual({ remaining: 0, message: expect.stringContaining('disposed') }), + ), + ), + ) + scenario( + 'When a child is swept, its idle parent is swept in the same pass instead of waiting for a new window', + Gherkin.Do.pipe( + Given('a value derived from a source, both idle on one cleanup schedule')('ctx', () => + Effect.sync(() => { + const source = Atom.make(1) + const derived = Atom.readable((get) => get(source)) + const page = Atom.Registry.make({ defaultIdleTTL: 10, timeoutResolution: 5 }) + return { page, source, derived } + })), + When('both fall idle and the shared cleanup timer runs')('result', (s) => + Effect.gen(function*() { + Atom.Registry.get(s.ctx.page, s.ctx.derived) + const maybeNode = Atom.Registry.getNodes(s.ctx.page).get(s.ctx.derived) + if (maybeNode === undefined) { + throw new Error('expected a node after reading the value') + } + const node = maybeNode + const before = node.currentState() + yield* TestClock.adjust('100 millis') + const keys = HashSet.fromIterable(Atom.Registry.getNodes(s.ctx.page).keys()) + const after = node.currentState() + return { + before, + after, + held: heldNames(keys, { derived: s.ctx.derived, source: s.ctx.source }), + } + })), + Then('the source was swept in the same pass right after the derived value')( + (s, expect) => expect(s.result).toEqual({ before: 'valid', after: 'removed', held: [] }), + ), + ), + ) + scenario( + 'A parent that is still in use survives the sweep that removes its child', + Gherkin.Do.pipe( + Given('a source with a listener, and a derived value that reads it')('ctx', () => + Effect.sync(() => { + const source = Atom.make(1) + const derived = Atom.readable((get) => get(source)) + const page = Atom.Registry.make({ defaultIdleTTL: 10, timeoutResolution: 5 }) + return { page, source, derived } + })), + When('both fall idle while the source is still listened to, and the cleanup timer runs')( + 'result', + (s) => + Effect.gen(function*() { + Atom.Registry.get(s.ctx.page, s.ctx.derived) + Atom.Registry.subscribe(s.ctx.page, s.ctx.source, () => {}) + yield* TestClock.adjust('100 millis') + const keys = HashSet.fromIterable(Atom.Registry.getNodes(s.ctx.page).keys()) + return { held: heldNames(keys, { derived: s.ctx.derived, source: s.ctx.source }) } + }), + ), + Then('the derived value is gone while the source stays because someone still listens')( + (s, expect) => expect(s.result).toEqual({ held: ['source'] }), + ), + ), + ) + scenario( + 'A parent with a longer cleanup schedule is swept in its own window after its child', + Gherkin.Do.pipe( + Given('a value derived from a source that keeps its value twice as long')('ctx', () => + Effect.sync(() => { + const source = Atom.setIdleTTL(20)(Atom.make(1)) + const derived = Atom.readable((get) => get(source)) + const page = Atom.Registry.make({ defaultIdleTTL: 10, timeoutResolution: 5 }) + return { page, source, derived } + })), + When('both fall idle and the cleanup timers run past both windows')('result', (s) => + Effect.gen(function*() { + Atom.Registry.get(s.ctx.page, s.ctx.derived) + yield* TestClock.adjust('15 millis') + const afterFirstWindow = HashSet.fromIterable(Atom.Registry.getNodes(s.ctx.page).keys()) + yield* TestClock.adjust('100 millis') + const afterSecondWindow = HashSet.fromIterable(Atom.Registry.getNodes(s.ctx.page).keys()) + return { + afterFirst: heldNames(afterFirstWindow, { derived: s.ctx.derived, source: s.ctx.source }), + afterSecond: heldNames(afterSecondWindow, { derived: s.ctx.derived, source: s.ctx.source }), + } + })), + Then('the child is swept first and the parent is swept in its own later window')( + (s, expect) => expect(s.result).toEqual({ afterFirst: ['source'], afterSecond: [] }), + ), + ), + ) + scenario( + 'Re-reading values before their cleanup window removes their pending timers', + Gherkin.Do.pipe( + Given('two values sharing one cleanup window')('ctx', () => + Effect.sync(() => { + const first = Atom.make(1) + const second = Atom.make(2) + const page = Atom.Registry.make({ defaultIdleTTL: 10, timeoutResolution: 5 }) + return { page, first, second } + })), + When('both fall idle and are read again before their window')('result', (s) => + Effect.gen(function*() { + Atom.Registry.get(s.ctx.page, s.ctx.first) + Atom.Registry.get(s.ctx.page, s.ctx.second) + yield* TestClock.adjust('1 millis') + Atom.Registry.get(s.ctx.page, s.ctx.first) + Atom.Registry.get(s.ctx.page, s.ctx.second) + yield* TestClock.adjust('100 millis') + const keys = HashSet.fromIterable(Atom.Registry.getNodes(s.ctx.page).keys()) + return { held: heldNames(keys, { first: s.ctx.first, second: s.ctx.second }) } + })), + Then('both are swept only after being left alone again')( + (s, expect) => expect(s.result).toEqual({ held: [] }), + ), + ), + ) + scenario( + 'A listener builds the value it watches, and leaving removes it', + Gherkin.Do.pipe( + Given('a value in a plain registry')('ctx', () => + Effect.sync(() => { + const value = Atom.make(1) + const page = Atom.Registry.make() + return { page, value } + })), + When('a listener attaches without reading, then releases')('result', (s) => + Effect.gen(function*() { + const cancel = Atom.Registry.subscribe(s.ctx.page, s.ctx.value, () => {}) + const maybeNode = Atom.Registry.getNodes(s.ctx.page).get(s.ctx.value) + if (maybeNode === undefined) { + throw new Error('expected a node after the listener attached') + } + const before = maybeNode.currentState() + cancel() + yield* Effect.eventually( + Effect.flatMap( + Effect.sync(() => Atom.Registry.getNodes(s.ctx.page).has(s.ctx.value)), + (stillThere) => stillThere ? Effect.fail('the value is still there') : Effect.void, + ), + ) + const keys = HashSet.fromIterable(Atom.Registry.getNodes(s.ctx.page).keys()) + return { before, held: heldNames(keys, { value: s.ctx.value }) } + })), + Then('the listener built the value, and it was removed once the listener left')( + (s, expect) => expect(s.result).toEqual({ before: 'valid', held: [] }), + ), + ), + ) + scenario( + 'A lazy value with an active child rebuilds when refreshed', + Gherkin.Do.pipe( + Given('a lazy value with a child that is not lazy')('ctx', () => + Effect.sync(() => { + const source = Atom.make(1) + const root = Atom.readable((get) => get(source)) + const activeChild = Atom.setLazy(false)(Atom.readable((get) => get(root))) + const page = Atom.Registry.make() + return { page, source, root, activeChild } + })), + When('the lazy value is refreshed while its child is active')('result', (s) => + Effect.sync(() => { + Atom.Registry.get(s.ctx.page, s.ctx.root) + Atom.Registry.get(s.ctx.page, s.ctx.activeChild) + const maybeNode = Atom.Registry.getNodes(s.ctx.page).get(s.ctx.root) + if (maybeNode === undefined) { + throw new Error('expected a node after reading the value') + } + const node = maybeNode + Atom.Registry.refresh(s.ctx.page, s.ctx.root) + return { state: node.currentState(), value: Atom.Registry.get(s.ctx.page, s.ctx.root) } + })), + Then('the lazy value rebuilt immediately')( + (s, expect) => expect({ state: s.result.state, value: s.result.value }).toEqual({ state: 'valid', value: 1 }), + ), + ), + ) + scenario( + 'A lazy value with only inactive descendants stays stale until read again and forgets the skipped invalidation once a new child appears', + Gherkin.Do.pipe( + Given('a chain of lazy values with a shared root')('ctx', () => + Effect.sync(() => { + const source = Atom.make(1) + const root = Atom.readable((get) => get(source)) + const left = Atom.readable((get) => get(root)) + const right = Atom.readable((get) => get(root)) + const leftChild = Atom.readable((get) => get(left)) + const rightChild = Atom.readable((get) => get(right)) + const page = Atom.Registry.make() + return { page, source, root, left, right, leftChild, rightChild } + })), + When('the root is refreshed while nothing listens, then a new value starts reading it')( + 'result', + (s) => + Effect.sync(() => { + Atom.Registry.get(s.ctx.page, s.ctx.root) + Atom.Registry.get(s.ctx.page, s.ctx.left) + Atom.Registry.get(s.ctx.page, s.ctx.right) + Atom.Registry.get(s.ctx.page, s.ctx.leftChild) + Atom.Registry.get(s.ctx.page, s.ctx.rightChild) + const maybeNode = Atom.Registry.getNodes(s.ctx.page).get(s.ctx.root) + if (maybeNode === undefined) { + throw new Error('expected a node after reading the value') + } + const node = maybeNode + Atom.Registry.refresh(s.ctx.page, s.ctx.root) + const afterRefresh = node.currentState() + const newcomer = Atom.readable((get) => get(s.ctx.root)) + Atom.Registry.get(s.ctx.page, newcomer) + return { + afterRefresh, + finalState: node.currentState(), + value: Atom.Registry.get(s.ctx.page, s.ctx.root), + } + }), + ), + Then('the root stayed stale until a new reader came, then rebuilt and cleared the skipped invalidation')( + (s, expect) => + expect({ + afterRefresh: s.result.afterRefresh, + finalState: s.result.finalState, + value: s.result.value, + }).toEqual({ afterRefresh: 'stale', finalState: 'valid', value: 1 }), + ), + ), + ) + scenario( + 'A derived value that stops following a source leaves the source alone while someone still listens', + Gherkin.Do.pipe( + Given('a source that someone still listens to, and a derived value that can switch away')( + 'ctx', + () => + Effect.sync(() => { + const first = Atom.make(1) + const second = Atom.make(2) + let useFirst = true + const switching = Atom.readable((get) => { + if (useFirst) { + return get(first) + } + return get(second) + }) + const page = Atom.Registry.make() + return { + page, + first, + second, + switching, + flip: () => { + useFirst = false + }, + } + }), + ), + When('the derived value switches sources')('nodes', (s) => + Effect.sync(() => { + Atom.Registry.subscribe(s.ctx.page, s.ctx.first, () => {}) + Atom.Registry.get(s.ctx.page, s.ctx.switching) + s.ctx.flip() + Atom.Registry.refresh(s.ctx.page, s.ctx.switching) + const value = Atom.Registry.get(s.ctx.page, s.ctx.switching) + const keys = HashSet.fromIterable(Atom.Registry.getNodes(s.ctx.page).keys()) + return { + value, + held: heldNames(keys, { + first: s.ctx.first, + second: s.ctx.second, + switching: s.ctx.switching, + }), + } + })), + Then('the abandoned source is kept because it is still in use, and the new one is followed')((s, expect) => + expect(s.nodes).toEqual({ value: 2, held: ['first', 'second', 'switching'] }) + ), + ), + ) + scenario( + 'Work scheduled by a value stops when the value is invalidated', + Gherkin.Do.pipe( + Given('a value that schedules several kinds of work for its lifetime')('ctx', () => + Effect.sync(() => { + const registry = Atom.Registry.make() + const source = Atom.make(1) + const plain = Atom.make(3) + const plainWritable = Atom.make(0) + const resultWritable = Atom.make>(Atom.AsyncResult.initial(false)) + const settled = Atom.make>(Atom.AsyncResult.success(5)) + const loading = Atom.make>(Atom.AsyncResult.initial(true)) + const initialResult = Atom.make>(Atom.AsyncResult.initial(false)) + const waiting = Atom.make>( + Atom.AsyncResult.successWith(1, { waiting: true }), + ) + const failed = Atom.make>( + Atom.AsyncResult.failure(Cause.fail('boom')), + ) + const settledOption = Atom.make>(Option.some(1)) + const noOption = Atom.make>(Option.none()) + const fibers: Fiber.Fiber[] = [] + const value = Atom.keepAlive(Atom.readable((get) => { + void get.result(settled) + void get.result(failed) + void get.result(initialResult) + void get.result(waiting, { suspendOnWaiting: true }) + void get.some(settledOption) + void get.some(noOption) + fibers.push(Effect.runFork(get.resultOnce(settled))) + fibers.push(Effect.runFork(get.resultOnce(loading))) + fibers.push(Effect.runFork(get.someOnce(settledOption))) + fibers.push(Effect.runFork(get.resultOnce(waiting, { suspendOnWaiting: true }))) + fibers.push(Effect.runFork(get.someOnce(noOption))) + Effect.runFork(get.setResult(resultWritable, Atom.AsyncResult.success(4))) + get.addFinalizer(() => { + get.self() + }) + get.addFinalizer(() => { + get(plain) + get.get(plain) + }) + get.addFinalizer(() => { + get.refresh(plain) + }) + get.addFinalizer(() => { + get.refreshSelf() + }) + get.addFinalizer(() => { + get.mount(plain) + }) + get.addFinalizer(() => { + get.subscribe(plain, () => {}) + }) + get.addFinalizer(() => { + get.set(plainWritable, 2) + }) + get.addFinalizer(() => { + get.setSelf(4) + }) + get.addFinalizer(() => { + const _stream = get.stream(plain) + }) + get.addFinalizer(() => { + const _effect = get.setResult(resultWritable, Atom.AsyncResult.success(4)) + }) + get.addFinalizer(() => { + const _result = get.result(failed) + }) + get.addFinalizer(() => { + const _some = get.some(noOption) + }) + get.addFinalizer(() => { + get.addFinalizer(() => {}) + }) + return get.get(source) + })) + return { registry, value, loading, waiting, noOption, fibers } + })), + When('the value is built, its pending results settle, and the value is then invalidated')( + 'result', + (s) => + Effect.gen(function*() { + Atom.Registry.get(s.ctx.registry, s.ctx.value) + Atom.Registry.set(s.ctx.registry, s.ctx.loading, Atom.AsyncResult.initial(false)) + Atom.Registry.set(s.ctx.registry, s.ctx.loading, Atom.AsyncResult.success(7)) + Atom.Registry.set(s.ctx.registry, s.ctx.waiting, Atom.AsyncResult.successWith(2, { waiting: true })) + Atom.Registry.set(s.ctx.registry, s.ctx.waiting, Atom.AsyncResult.success(3)) + Atom.Registry.set(s.ctx.registry, s.ctx.noOption, Option.some(5)) + const [settledFiber, loadingFiber, optionFiber, waitingFiber, noneFiber] = s.ctx.fibers + if ( + settledFiber === undefined || loadingFiber === undefined || optionFiber === undefined || + waitingFiber === undefined || noneFiber === undefined + ) { + throw new Error('expected five in-flight fibers') + } + const settledValue = yield* Fiber.join(settledFiber) + const resumedValue = yield* Fiber.join(loadingFiber) + const optionValue = yield* Fiber.join(optionFiber) + const throughWaiting = yield* Fiber.join(waitingFiber) + const throughNone = yield* Fiber.join(noneFiber) + Atom.Registry.refresh(s.ctx.registry, s.ctx.value) + const maybeNode = Atom.Registry.getNodes(s.ctx.registry).get(s.ctx.value) + if (maybeNode === undefined) { + throw new Error('expected a node after reading the value') + } + const node = maybeNode + return { + settledValue, + resumedValue, + optionValue, + throughWaiting, + throughNone, + state: node.currentState(), + } + }), + ), + Then('the forked reads settled and the invalidated value stopped all scheduled work')( + (s, expect) => + expect({ + settledValue: s.result.settledValue, + resumedValue: s.result.resumedValue, + optionValue: s.result.optionValue, + throughWaiting: s.result.throughWaiting, + throughNone: s.result.throughNone, + state: s.result.state, + }).toEqual({ + settledValue: 5, + resumedValue: 7, + optionValue: 1, + throughWaiting: 3, + throughNone: 5, + state: 'stale', + }), + ), + ), + ) + scenario( + 'Two pages that keep their own time let an idle value expire on their own schedules', + Gherkin.Do.pipe( + Given('two pages that each keep their own time, both holding the same idle value')( + 'ctx', + () => + Effect.sync(() => { + const value = Atom.make(1) + const early = manualClock() + const late = manualClock() + const first = Atom.Registry.make({ defaultIdleTTL: 100, timeoutResolution: 10, ...early }) + const second = Atom.Registry.make({ defaultIdleTTL: 100, timeoutResolution: 10, ...late }) + Atom.Registry.get(first, value) + Atom.Registry.get(second, value) + return { value, early, late, first, second } + }), + ), + When("only the first page's time passes the cleanup window")('held', (s) => + Effect.sync(() => { + s.ctx.early.advance(200) + s.ctx.late.advance(50) + return { + firstPage: heldNames(Atom.Registry.getNodes(s.ctx.first).keys(), { value: s.ctx.value }), + secondPage: heldNames(Atom.Registry.getNodes(s.ctx.second).keys(), { value: s.ctx.value }), + } + })), + Then('the first page has let the value go while the second still holds it')( + (s, expect) => expect(s.held).toEqual({ firstPage: [], secondPage: ['value'] }), + ), + ), + ) + scenario( + 'A group of changes that ends where it started tells no listener anything', + Gherkin.Do.pipe( + Given('a listener watching a counter and a doubled counter')('ctx', () => + Effect.sync(() => { + const counter = Atom.make(0) + const doubled = Atom.make((get) => get(counter) * 2) + const page = Atom.Registry.make() + const heard: Array = [] + Atom.Registry.subscribe(page, counter, (value) => heard.push(`counter ${value}`)) + Atom.Registry.subscribe(page, doubled, (value) => heard.push(`doubled ${value}`)) + return { page, counter, heard } + })), + When('one group moves the counter to 5 and back to 0, and a second group moves it to 3')( + 'heard', + (s) => + Effect.sync(() => { + Atom.Registry.batch(s.ctx.page, () => { + Atom.Registry.set(s.ctx.page, s.ctx.counter, 5) + Atom.Registry.set(s.ctx.page, s.ctx.counter, 0) + }) + Atom.Registry.batch(s.ctx.page, () => { + Atom.Registry.set(s.ctx.page, s.ctx.counter, 1) + Atom.Registry.set(s.ctx.page, s.ctx.counter, 3) + }) + return s.ctx.heard + }), + ), + Then('the listeners hear only the second group, once each')( + (s, expect) => expect(s.heard).toEqual(['counter 3', 'doubled 6']), + ), + ), + ) + scenario( + 'Listening from inside a group of changes hears only what changes after it started listening', + Gherkin.Do.pipe( + Given('a doubled counter nobody has read yet')('ctx', () => + Effect.sync(() => { + const counter = Atom.make(0) + const doubled = Atom.make((get) => get(counter) * 2) + return { page: Atom.Registry.make(), counter, doubled } + })), + When('one group starts a listener and an immediate listener, and a second group moves the counter to 1')( + 'heard', + (s) => + Effect.sync(() => { + const heard: Array = [] + Atom.Registry.batch(s.ctx.page, () => { + Atom.Registry.subscribe(s.ctx.page, s.ctx.doubled, (value) => heard.push(`listener ${value}`)) + Atom.Registry.subscribe(s.ctx.page, s.ctx.doubled, (value) => heard.push(`immediate ${value}`), { + immediate: true, + }) + }) + heard.push('group ended') + Atom.Registry.batch(s.ctx.page, () => Atom.Registry.set(s.ctx.page, s.ctx.counter, 1)) + return heard + }), + ), + Then('the immediate listener hears the start value once, and both hear the change once')( + (s, expect) => expect(s.heard).toEqual(['immediate 0', 'group ended', 'listener 2', 'immediate 2']), + ), + ), + ) + scenario( + 'A value kept alive holds on to the counter it is computed from', + Gherkin.Do.pipe( + Given('a page that keeps a running total of a counter alive, and has shown it once')( + 'ctx', + () => + Effect.sync(() => { + const counter = Atom.make(0) + const total = Atom.make((get) => get(counter)).pipe(Atom.keepAlive) + const page = Atom.Registry.make({ defaultIdleTTL: 10, timeoutResolution: 5 }) + Atom.Registry.get(page, total) + return { page, counter, total } + }), + ), + When('the counter is set to 1 and the page sits idle well past its cleanup time')( + 'result', + (s) => + Effect.gen(function*() { + Atom.Registry.set(s.ctx.page, s.ctx.counter, 1) + yield* TestClock.adjust('100 millis') + const result = { + counter: Atom.Registry.get(s.ctx.page, s.ctx.counter), + total: Atom.Registry.get(s.ctx.page, s.ctx.total), + } + return result + }), + ), + Then('the counter still reads 1 and the total shows 1')( + (s, expect) => expect(s.result).toEqual({ counter: 1, total: 1 }), + ), + ), + ) + scenario( + 'A value watched again after going stale hears every later change', + Gherkin.Do.pipe( + Given('a doubled counter that was watched once and released, and whose counter was then refreshed')( + 'ctx', + () => + Effect.sync(() => { + const counter = Atom.make(0) + const doubled = Atom.make((get) => get(counter) * 2) + const page = Atom.Registry.make() + Atom.Registry.subscribe(page, doubled, () => {}, { immediate: true })() + Atom.Registry.refresh(page, counter) + return { page, counter, doubled } + }), + ), + When('a new listener watches the doubled counter and the counter goes up by one')( + 'result', + (s) => + Effect.sync(() => { + const heard: Array = [] + Atom.Registry.subscribe(s.ctx.page, s.ctx.doubled, (value) => heard.push(value)) + Atom.Registry.update(s.ctx.page, s.ctx.counter, (n) => n + 1) + return { heard, doubled: Atom.Registry.get(s.ctx.page, s.ctx.doubled) } + }), + ), + Then('the listener hears 2 and the doubled counter reads 2')( + (s, expect) => expect(s.result).toEqual({ heard: [2], doubled: 2 }), + ), + ), + ) + scenario( + 'Refreshing a value nobody has read computes nothing, alone or among other changes', + Gherkin.Do.pipe( + Given('a value that counts how often it is computed and has never been read')('ctx', () => + Effect.sync(() => { + const computed = { times: 0 } + const counter = Atom.make(0) + const counted = Atom.make((get) => { + computed.times++ + return get(counter) + }) + const page = Atom.Registry.make() + return { page, counter, counted, computed } + })), + When('it is refreshed on its own, and again inside a group of changes to the counter')( + 'times', + (s) => + Effect.sync(() => { + Atom.Registry.refresh(s.ctx.page, s.ctx.counted) + Atom.Registry.batch(s.ctx.page, () => { + Atom.Registry.set(s.ctx.page, s.ctx.counter, 1) + Atom.Registry.refresh(s.ctx.page, s.ctx.counted) + }) + return s.ctx.computed.times + }), + ), + Then('it has never been computed')((s, expect) => expect(s.times).toBe(0)), + ), + ) + scenario( + 'A counter is forgotten once the value read from it has gone idle', + Gherkin.Do.pipe( + Given('a doubled counter that was read once')('ctx', () => + Effect.sync(() => { + const counter = Atom.make(0) + const doubled = Atom.make((get) => get(counter) * 2) + const page = Atom.Registry.make({ defaultIdleTTL: 10, timeoutResolution: 5 }) + Atom.Registry.get(page, doubled) + return { page, counter } + })), + When('the counter is set to 1 and the page sits idle well past its cleanup time')( + 'counter', + (s) => + Effect.gen(function*() { + Atom.Registry.set(s.ctx.page, s.ctx.counter, 1) + yield* TestClock.adjust('100 millis') + const counter = Atom.Registry.get(s.ctx.page, s.ctx.counter) + return counter + }), + ), + Then('the counter reads its starting 0 again')((s, expect) => expect(s.counter).toBe(0)), + ), + ) + scenario( + 'Two registries provided side by side keep their values separate', + Gherkin.Do.pipe( + Given('a value that either registry could hold')('ctx', () => Effect.sync(() => ({ value: Atom.make(0) }))), + When('both registries are provided together, each under its own name, and one records a new value')( + 'seen', + (s) => + Effect.provide(Layer.merge(Atom.Registry.layer(FirstRegistry), Atom.Registry.layer(SecondRegistry)))( + Effect.gen(function*() { + const first = yield* FirstRegistry + const second = yield* SecondRegistry + Atom.Registry.set(first, s.ctx.value, 11) + return { + first, + second, + onFirst: Atom.Registry.get(first, s.ctx.value), + onSecond: Atom.Registry.get(second, s.ctx.value), + } + }), + ), + ), + Then('the registries are different, and only the first one shows the new value')( + (s, expect) => + expect({ + first: s.seen.first, + second: s.seen.second, + onFirst: s.seen.onFirst, + onSecond: s.seen.onSecond, + }).toSatisfy( + ({ first, second, onFirst, onSecond }) => first !== second && onFirst === 11 && onSecond === 0, + 'the two registries are different, and only the first one shows the new value', + ), + ), + ), + ) + scenario( + 'A registry whose providing scope closes refuses further reads', + Gherkin.Do.pipe( + Given('a registry living for a bounded scope, already holding a value')('ctx', () => + Effect.gen(function*() { + const scope = yield* Scope.make() + const context = yield* Layer.buildWithScope(Atom.Registry.layer(Atom.Registry.Current), scope) + const registry = Context.get(context, Atom.Registry.Current) + const value = Atom.make(5) + Atom.Registry.get(registry, value) + return { registry, scope, value } + })), + When('the scope closes and the value is read again')('afterwards', (s) => + Effect.andThen( + Scope.close(s.ctx.scope, Exit.void), + Effect.sync(() => { + let message: string | undefined + try { + Atom.Registry.get(s.ctx.registry, s.ctx.value) + } catch (error) { + if (error instanceof Error) { + message = error.message + } + } + return { message } + }), + )), + Then('the read failed, and the failure said the registry is gone')( + (s, expect) => expect(s.afterwards.message).toContain('disposed'), + ), + ), + ) + scenarioOutline( + 'Asking a registry for gives the same answer however it is asked', + [ + { + question: 'the value it holds', + expected: 5, + direct: (registry: Atom.Registry.Registry, value: Atom.Writable) => + Atom.Registry.get(registry, value), + curried: (registry: Atom.Registry.Registry, value: Atom.Writable) => + registry.pipe(Atom.Registry.get(value)), + }, + { + question: 'a replacement for the value it holds', + expected: 8, + direct: (registry: Atom.Registry.Registry, value: Atom.Writable) => { + Atom.Registry.set(registry, value, 8) + return Atom.Registry.get(registry, value) + }, + curried: (registry: Atom.Registry.Registry, value: Atom.Writable) => { + registry.pipe(Atom.Registry.set(value, 8)) + return Atom.Registry.get(registry, value) + }, + }, + { + question: 'an adjustment to the value it holds', + expected: 7, + direct: (registry: Atom.Registry.Registry, value: Atom.Writable) => { + Atom.Registry.update(registry, value, (n) => n + 2) + return Atom.Registry.get(registry, value) + }, + curried: (registry: Atom.Registry.Registry, value: Atom.Writable) => { + registry.pipe(Atom.Registry.update(value, (n) => n + 2)) + return Atom.Registry.get(registry, value) + }, + }, + { + question: 'a swap that hands back the old value', + expected: 5, + direct: (registry: Atom.Registry.Registry, value: Atom.Writable) => + Atom.Registry.modify(registry, value, (n) => [n, 9]), + curried: (registry: Atom.Registry.Registry, value: Atom.Writable) => + registry.pipe(Atom.Registry.modify(value, (n) => [n, 9])), + }, + { + question: 'a seed placed before the value is read', + expected: 3, + direct: (registry: Atom.Registry.Registry, value: Atom.Writable) => { + Atom.Registry.setInitialValue(registry, value, 3) + return Atom.Registry.get(registry, value) + }, + curried: (registry: Atom.Registry.Registry, value: Atom.Writable) => { + registry.pipe(Atom.Registry.setInitialValue(value, 3)) + return Atom.Registry.get(registry, value) + }, + }, + { + question: 'a change while it listens', + expected: 6, + direct: (registry: Atom.Registry.Registry, value: Atom.Writable) => { + const heard: number[] = [] + const stop = Atom.Registry.subscribe(registry, value, (n) => heard.push(n)) + Atom.Registry.set(registry, value, 6) + stop() + return heard[0] + }, + curried: (registry: Atom.Registry.Registry, value: Atom.Writable) => { + const heard: number[] = [] + const stop = registry.pipe(Atom.Registry.subscribe(value, (n) => heard.push(n))) + Atom.Registry.set(registry, value, 6) + stop() + return heard[0] + }, + }, + ] as const, + (row) => + Gherkin.Do.pipe( + Given('two pages, each holding a value of five')('ctx', () => + Effect.sync(() => ({ + first: { registry: Atom.Registry.make(), value: Atom.make(5) }, + second: { registry: Atom.Registry.make(), value: Atom.make(5) }, + }))), + When('the question is asked directly of the first page, and through the pipe of the second')( + 'answers', + (s) => + Effect.sync(() => ({ + direct: row.direct(s.ctx.first.registry, s.ctx.first.value), + curried: row.curried(s.ctx.second.registry, s.ctx.second.value), + })), + ), + Then('both answers match, and they are the expected one')( + (s, expect) => + expect({ direct: s.answers.direct, curried: s.answers.curried }).toEqual({ + direct: row.expected, + curried: row.expected, + }), + ), + ), + ) + scenario( + 'Each page keeps its own notes, and a closed page starts over', + Gherkin.Do.pipe( + Given('two open pages')( + 'ctx', + () => Effect.sync(() => ({ first: Atom.Registry.make(), second: Atom.Registry.make() })), + ), + When('the first page notes a visit, then is closed and asked for its notes again')( + 'notes', + (s) => + Effect.sync(() => { + Atom.Registry.storage(s.ctx.first, VisitLog, () => ({ visits: [] })).visits.push('home') + const firstBeforeClose = Atom.Registry.storage(s.ctx.first, VisitLog, () => ({ visits: [] })).visits + const second = Atom.Registry.storage(s.ctx.second, VisitLog, () => ({ visits: [] })).visits + Atom.Registry.dispose(s.ctx.first) + const firstAfterClose = s.ctx.first.pipe(Atom.Registry.storage(VisitLog, () => ({ visits: [] }))).visits + return { firstBeforeClose: [...firstBeforeClose], second, firstAfterClose } + }), + ), + Then('the first page remembered its visit, the second saw none, and the closed page forgot it')( + (s, expect) => expect(s.notes).toEqual({ firstBeforeClose: ['home'], second: [], firstAfterClose: [] }), + ), + ), + ) + }) + +function manualClock() { + const state: { + time: number + tasks: ReadonlyArray<() => void> + timers: ReadonlyArray<{ readonly due: number; readonly run: () => void }> + } = { time: 0, tasks: [], timers: [] } + const scheduleTask = (task: () => void): () => void => { + state.tasks = [...state.tasks, task] + return () => { + state.tasks = state.tasks.filter((pending) => pending !== task) + } + } + const runTasks = (): void => { + const pending = state.tasks + state.tasks = [] + pending.forEach((task) => task()) + } + const now = (): number => state.time + const scheduleTimer = (run: () => void, delayMillis: number): () => void => { + const timer = { due: state.time + delayMillis, run } + state.timers = [...state.timers, timer] + return () => { + state.timers = state.timers.filter((pending) => pending !== timer) + } + } + const advance = (millis: number): void => { + runTasks() + state.time += millis + const due = state.timers.filter((timer) => timer.due <= state.time) + state.timers = state.timers.filter((timer) => timer.due > state.time) + due.forEach((timer) => timer.run()) + runTasks() + } + return { now, scheduleTask, scheduleTimer, advance } +} diff --git a/packages/atom/effect-atom/tests/Result.integration.test.ts b/packages/atom/effect-atom/tests/Result.integration.test.ts new file mode 100644 index 0000000..ae4225e --- /dev/null +++ b/packages/atom/effect-atom/tests/Result.integration.test.ts @@ -0,0 +1,1232 @@ +import { Atom } from '@systemfsoftware/effect-atom' +import { Gherkin, Given, it, makeFeature, Then, When } from '@systemfsoftware/effect-gherkin-spec' +import { Cause, Effect, Equal, Hash, Layer, Option, Predicate, Result as EffectResult, Schema } from 'effect' +import { resultSchema, type TaggedError, taggedSchema } from './__fixtures__/Result.fixture.js' + +type SampleResult = Schema.Schema.Type +type TaggedSample = Schema.Schema.Type + +const rememberedSuccess = ( + result: Atom.AsyncResult.Result, +): Option.Option> => { + if (Atom.AsyncResult.isSuccess(result)) { + return Option.some(result) + } + if (Atom.AsyncResult.isFailure(result)) { + return result.previousSuccess + } + return Option.none() +} + +const sameResultTag = (first: SampleResult, second: SampleResult): boolean => { + if (Atom.AsyncResult.isInitial(first) && Atom.AsyncResult.isInitial(second)) { + return true + } + if (Atom.AsyncResult.isSuccess(first) && Atom.AsyncResult.isSuccess(second)) { + return true + } + if (Atom.AsyncResult.isFailure(first) && Atom.AsyncResult.isFailure(second)) { + return true + } + return false +} + +const waitingPreservesTagAndValue = ( + result: SampleResult, + waited: SampleResult, + touched: SampleResult, +): boolean => { + if (Atom.AsyncResult.isInitial(result)) { + return Atom.AsyncResult.isInitial(waited) && Atom.AsyncResult.isInitial(touched) + } + if (Atom.AsyncResult.isSuccess(result)) { + return Atom.AsyncResult.isSuccess(waited) && Equal.equals(waited.value, result.value) && + Atom.AsyncResult.isSuccess(touched) && Equal.equals(touched.value, result.value) + } + return Atom.AsyncResult.isFailure(waited) && Atom.AsyncResult.isFailure(touched) +} + +const exitRoundtripHolds = (result: SampleResult): boolean => { + const roundtripped = Atom.AsyncResult.fromExit(Atom.AsyncResult.toExit(result)) + if (Atom.AsyncResult.isInitial(result)) { + return Atom.AsyncResult.isFailure(roundtripped) + } + if (Atom.AsyncResult.isSuccess(result)) { + return Equal.equals(roundtripped, Atom.AsyncResult.success(result.value)) + } + return Equal.equals(roundtripped, Atom.AsyncResult.failure(result.cause)) +} + +const errorOrDefect = (cause: Cause.Cause): 'error' | 'defect' => { + if (EffectResult.isSuccess(Cause.findError(cause))) { + return 'error' + } + return 'defect' +} + +const matchRouteHolds = (result: SampleResult, routed: string): boolean => { + if (Atom.AsyncResult.isInitial(result)) { + return routed === 'initial' + } + if (Atom.AsyncResult.isSuccess(result)) { + return routed === 'success' + } + return routed === 'failure' +} + +const matchWithErrorHolds = (result: SampleResult, routed: string): boolean => { + if (Atom.AsyncResult.isInitial(result)) { + return routed === 'initial' + } + if (Atom.AsyncResult.isSuccess(result)) { + return routed === 'success' + } + return routed === errorOrDefect(result.cause) +} + +const matchWithWaitingHolds = (result: SampleResult, routed: string): boolean => { + if (result.waiting) { + return routed === 'waiting' + } + if (Atom.AsyncResult.isInitial(result)) { + return routed === 'waiting' + } + if (Atom.AsyncResult.isSuccess(result)) { + return routed === 'success' + } + return routed === errorOrDefect(result.cause) +} + +const flatMapInitialAndSuccessHolds = (result: SampleResult): boolean => { + if (Atom.AsyncResult.isInitial(result)) { + return Equal.equals(Atom.AsyncResult.flatMap(result, () => Atom.AsyncResult.success(0)), result) + } + if (Atom.AsyncResult.isSuccess(result)) { + return Equal.equals( + Atom.AsyncResult.flatMap(result, (n: number) => Atom.AsyncResult.success(n + 1)), + Atom.AsyncResult.success(result.value + 1), + ) + } + return true +} + +const builderFirstHandler = (result: SampleResult): string => { + if (Atom.AsyncResult.isInitial(result)) { + return 'initial' + } + if (result.waiting) { + return 'waiting' + } + if (Atom.AsyncResult.isSuccess(result)) { + return 'success' + } + return 'failure' +} + +const isTaggedError = (e: unknown): e is TaggedError => Predicate.hasProperty(e, 'code') + +const taggedErrorCode = (result: TaggedSample): number => { + if (!Atom.AsyncResult.isFailure(result)) { + return -1 + } + return Option.getOrElse( + Option.map( + Option.filter(Cause.findErrorOption(result.cause), isTaggedError), + (e) => e.code, + ), + () => -1, + ) +} + +/** + * The draws that break a law: empty exactly when every draw satisfies it. + */ +const violations = ( + samples: ReadonlyArray, + holds: (sample: Sample) => boolean, +): ReadonlyArray => samples.filter((sample) => !holds(sample)) + +const Feature = makeFeature({ it }) + +Feature('Keeping the last good answer on screen when a retry fails') + .withLayer(Layer.empty) + .body(({ scenario }) => { + scenario( + 'A page keeps showing the previous answer after a refresh fails', + Gherkin.Do.pipe( + Given('a calculation that succeeds the first time and fails on every retry')('ctx', () => + Effect.sync(() => { + let attempt = 0 + const atom = Atom.make(Effect.suspend(() => { + attempt++ + if (attempt === 1) { + return Effect.succeed(10) + } + return Effect.fail('server unavailable' as const) + })) + const page = Atom.Registry.make() + return { page, atom } + })), + When('the value is read, the page is refreshed, and the value is read again')( + 'reading', + (s) => + Effect.sync(() => { + Atom.Registry.get(s.ctx.page, s.ctx.atom) + Atom.Registry.refresh(s.ctx.page, s.ctx.atom) + return Atom.Registry.get(s.ctx.page, s.ctx.atom) + }), + ), + Then('the refresh reports a failure, but the previous answer is still remembered')( + (s, expect) => expect(s.reading).toMatchObject({ _tag: 'Failure', previousSuccess: { _tag: 'Some' } }), + ), + ), + ) + + scenario( + 'A calculation that has never succeeded has no previous answer to fall back on', + Gherkin.Do.pipe( + Given('a calculation that always fails')('ctx', () => + Effect.sync(() => { + const atom = Atom.make(Effect.fail('server unavailable' as const)) + const page = Atom.Registry.make() + return { page, atom } + })), + When('the value is read for the first time')( + 'reading', + (s) => Effect.sync(() => Atom.Registry.get(s.ctx.page, s.ctx.atom)), + ), + Then('the failure carries no previous answer')( + (s, expect) => expect(s.reading).toMatchObject({ _tag: 'Failure', previousSuccess: { _tag: 'None' } }), + ), + ), + ) + + scenario( + 'A sampled result survives an encode-decode roundtrip', + Gherkin.Do.pipe( + Given('every representative result')('samples', () => Effect.sync(() => RESULT_SAMPLES)), + When('the law is checked against every draw')( + 'violations', + (s) => + Effect.sync(() => + violations(s.samples, (result) => { + const enc = Option.getOrThrow(Schema.encodeOption(resultSchema)(result)) + const dec = Option.getOrThrow(Schema.decodeOption(resultSchema)(enc)) + return Equal.equals(dec, result) + }) + ), + ), + Then('every draw satisfies the law')((s, expect) => expect(s.violations).toEqual([])), + ), + ) + scenario( + 'Mapping a sampled result with the identity leaves it unchanged', + Gherkin.Do.pipe( + Given('every representative result')('samples', () => Effect.sync(() => RESULT_SAMPLES)), + When('the law is checked against every draw')( + 'violations', + (s) => + Effect.sync(() => + violations(s.samples, (result) => Equal.equals(Atom.AsyncResult.map(result, (n) => n), result)) + ), + ), + Then('every draw satisfies the law')((s, expect) => expect(s.violations).toEqual([])), + ), + ) + scenario( + 'Mapping twice composes exactly like a direct call', + Gherkin.Do.pipe( + Given('every representative result')('samples', () => Effect.sync(() => RESULT_SAMPLES)), + When('the law is checked against every draw')( + 'violations', + (s) => + Effect.sync(() => + violations(s.samples, (result) => + Equal.equals( + Atom.AsyncResult.map(Atom.AsyncResult.map(result, (n: number) => n + 1), (n) => n * 2), + Atom.AsyncResult.map(result, (n: number) => (n + 1) * 2), + )) + ), + ), + Then('every draw satisfies the law')((s, expect) => expect(s.violations).toEqual([])), + ), + ) + scenario( + 'Two way-equal results hash identically', + Gherkin.Do.pipe( + Given('every representative result')('samples', () => Effect.sync(() => RESULT_SAMPLES)), + When('the law is checked against every draw')( + 'violations', + (s) => + Effect.sync(() => + violations(s.samples, (result) => + (() => { + const rebuilt = Atom.AsyncResult.match(result, { + onInitial: (t) => Atom.AsyncResult.initial(t.waiting), + onFailure: (t) => + Atom.AsyncResult.failure(t.cause, { previousSuccess: t.previousSuccess, waiting: t.waiting }), + onSuccess: (t) => + Atom.AsyncResult.successWith(t.value, { waiting: t.waiting, timestamp: t.timestamp }), + }) + return !Equal.equals(result, rebuilt) || Hash.hash(result) === Hash.hash(rebuilt) + })()) + ), + ), + Then('every draw satisfies the law')((s, expect) => expect(s.violations).toEqual([])), + ), + ) + scenario( + 'A list combines to success exactly when every entry succeeded', + Gherkin.Do.pipe( + Given('a batch of generated inputs')('samples', () => Effect.sync(() => PAIR_SAMPLES)), + When('the law is checked against every draw')( + 'violations', + (s) => + Effect.sync(() => + violations( + s.samples, + ([first, second]) => + Atom.AsyncResult.isSuccess(Atom.AsyncResult.all([first, second])) === + (Atom.AsyncResult.isSuccess(first) && Atom.AsyncResult.isSuccess(second)), + ) + ), + ), + Then('every draw satisfies the law')((s, expect) => expect(s.violations).toEqual([])), + ), + ) + scenario( + 'ReplacePrevious swaps the remembered success of a failure', + Gherkin.Do.pipe( + Given('a batch of generated inputs')('samples', () => Effect.sync(() => PAIR_SAMPLES)), + When('the law is checked against every draw')( + 'violations', + (s) => + Effect.sync(() => + violations(s.samples, ([first, second]) => { + const replaced = Atom.AsyncResult.replacePrevious(first, Option.some(second)) + const expected = rememberedSuccess(second) + if (Atom.AsyncResult.isFailure(first)) { + return Atom.AsyncResult.isFailure(replaced) && Equal.equals(replaced.previousSuccess, expected) + } + return Equal.equals(replaced, first) + }) + ), + ), + Then('every draw satisfies the law')((s, expect) => expect(s.violations).toEqual([])), + ), + ) + scenario( + 'A non-result shape is rejected by the result schema', + Gherkin.Do.pipe( + Given('a batch of generated inputs')('samples', () => Effect.sync(() => GARBAGE_SAMPLES)), + When('the law is checked against every draw')( + 'violations', + (s) => + Effect.sync(() => + violations(s.samples, (garbage) => Option.isNone(Schema.decodeUnknownOption(resultSchema)(garbage))) + ), + ), + Then('every draw satisfies the law')((s, expect) => expect(s.violations).toEqual([])), + ), + ) + scenario( + 'A labeled all keeps the labels of entries that all succeeded', + Gherkin.Do.pipe( + Given('a batch of generated inputs')('samples', () => Effect.sync(() => PAIR_SAMPLES)), + When('the law is checked against every draw')( + 'violations', + (s) => + Effect.sync(() => + violations(s.samples, ([first, second]) => + (() => { + const combined = Atom.AsyncResult.all({ first, second }) + if (!Atom.AsyncResult.isSuccess(combined)) { + return !Atom.AsyncResult.isSuccess(first) || !Atom.AsyncResult.isSuccess(second) + } + return Atom.AsyncResult.isSuccess(first) && Atom.AsyncResult.isSuccess(second) && + Equal.equals(combined.value.first, first.value) && Equal.equals(combined.value.second, second.value) + })()) + ), + ), + Then('every draw satisfies the law')((s, expect) => expect(s.violations).toEqual([])), + ), + ) + scenario( + 'A failure rebuilt from its exit keeps its previous success', + Gherkin.Do.pipe( + Given('a batch of generated inputs')('samples', () => Effect.sync(() => PAIR_SAMPLES)), + When('the law is checked against every draw')( + 'violations', + (s) => + Effect.sync(() => + violations(s.samples, ([first, second]) => { + if (!Atom.AsyncResult.isFailure(first)) { + return true + } + const rebuilt = Atom.AsyncResult.fromExitWithPrevious( + Atom.AsyncResult.toExit(first), + Option.some(second), + ) + const expected = rememberedSuccess(second) + return Atom.AsyncResult.isFailure(rebuilt) && Equal.equals(rebuilt.previousSuccess, expected) + }) + ), + ), + Then('every draw satisfies the law')((s, expect) => expect(s.violations).toEqual([])), + ), + ) + scenario( + 'The builder routes a typed error through onError and everything else to the fallback', + Gherkin.Do.pipe( + Given('every representative result')('samples', () => Effect.sync(() => RESULT_SAMPLES)), + When('the law is checked against every draw')( + 'violations', + (s) => + Effect.sync(() => + violations(s.samples, (result) => { + const routed = Atom.AsyncResult.builder(result).onError(() => 'typed' as const).orElse(() => + 'other' as const + ) + const hasTypedError = Atom.AsyncResult.isFailure(result) && + EffectResult.isSuccess(Cause.findError(result.cause)) + if (hasTypedError) { + return routed === 'typed' + } + return routed === 'other' + }) + ), + ), + Then('every draw satisfies the law')((s, expect) => expect(s.violations).toEqual([])), + ), + ) + scenario( + 'Two sampled results with different tags are never equal', + Gherkin.Do.pipe( + Given('a batch of generated inputs')('samples', () => Effect.sync(() => PAIR_SAMPLES)), + When('the law is checked against every draw')( + 'violations', + (s) => + Effect.sync(() => + violations(s.samples, ([first, second]) => sameResultTag(first, second) || !Equal.equals(first, second)) + ), + ), + Then('every draw satisfies the law')((s, expect) => expect(s.violations).toEqual([])), + ), + ) + scenario( + 'Marking a settled result waiting flips only the waiting flag and touching preserves tag and value', + Gherkin.Do.pipe( + Given('every representative result')('samples', () => Effect.sync(() => RESULT_SAMPLES)), + When('the law is checked against every draw')( + 'violations', + (s) => + Effect.sync(() => + violations(s.samples, (result) => { + const waited = Atom.AsyncResult.waiting(result) + const touched = Atom.AsyncResult.waiting(result, { touch: true }) + return waited.waiting === true && touched.waiting === true && + waitingPreservesTagAndValue(result, waited, touched) + }) + ), + ), + Then('every draw satisfies the law')((s, expect) => expect(s.violations).toEqual([])), + ), + ) + scenario( + 'flatMap passes an untouched failure through', + Gherkin.Do.pipe( + Given('every representative result')('samples', () => Effect.sync(() => RESULT_SAMPLES)), + When('the law is checked against every draw')( + 'violations', + (s) => + Effect.sync(() => + violations(s.samples, (result) => + !Atom.AsyncResult.isFailure(result) || + Equal.equals(Atom.AsyncResult.flatMap(result, (n: number) => Atom.AsyncResult.success(n + 1)), result)) + ), + ), + Then('every draw satisfies the law')((s, expect) => expect(s.violations).toEqual([])), + ), + ) + scenario( + 'Marking a result waiting makes it unequal to its unmarked original', + Gherkin.Do.pipe( + Given('every representative result')('samples', () => Effect.sync(() => RESULT_SAMPLES)), + When('the law is checked against every draw')( + 'violations', + (s) => + Effect.sync(() => + violations(s.samples, (result) => { + const equal = Equal.equals(result, Atom.AsyncResult.waiting(result)) + if (result.waiting) { + return equal + } + return !equal + }) + ), + ), + Then('every draw satisfies the law')((s, expect) => expect(s.violations).toEqual([])), + ), + ) + scenario( + 'A bare failure carries no value and reports its error', + Gherkin.Do.pipe( + Given('a batch of generated inputs')('samples', () => Effect.sync(() => MSG_SAMPLES)), + When('the law is checked against every draw')( + 'violations', + (s) => + Effect.sync(() => + violations(s.samples, (message) => + (() => { + const bare = Atom.AsyncResult.failure(Cause.fail(message)) + return Option.isNone(Atom.AsyncResult.value(bare)) && + Equal.equals(Atom.AsyncResult.error(bare), Option.some(message)) + })()) + ), + ), + Then('every draw satisfies the law')((s, expect) => expect(s.violations).toEqual([])), + ), + ) + scenario( + 'An exit roundtrip collapses to the bare value or cause', + Gherkin.Do.pipe( + Given('every representative result')('samples', () => Effect.sync(() => RESULT_SAMPLES)), + When('the law is checked against every draw')( + 'violations', + (s) => Effect.sync(() => violations(s.samples, (result) => exitRoundtripHolds(result))), + ), + Then('every draw satisfies the law')((s, expect) => expect(s.violations).toEqual([])), + ), + ) + scenario( + 'A successful exit supersedes any remembered previous result', + Gherkin.Do.pipe( + Given('every representative result')('samples', () => Effect.sync(() => RESULT_SAMPLES)), + When('the law is checked against every draw')( + 'violations', + (s) => + Effect.sync(() => + violations(s.samples, (result) => + !Atom.AsyncResult.isSuccess(result) || + Equal.equals( + Atom.AsyncResult.fromExitWithPrevious(Atom.AsyncResult.toExit(result), Option.some(result)), + Atom.AsyncResult.success(result.value), + )) + ), + ), + Then('every draw satisfies the law')((s, expect) => expect(s.violations).toEqual([])), + ), + ) + scenario( + 'waitingFrom yields a waiting initial from nothing and reuses a given result', + Gherkin.Do.pipe( + Given('every representative result')('samples', () => Effect.sync(() => RESULT_SAMPLES)), + When('the law is checked against every draw')( + 'violations', + (s) => + Effect.sync(() => + violations(s.samples, (result) => + (() => { + const fromNothing = Atom.AsyncResult.waitingFrom(Option.none()) + const fromResult = Atom.AsyncResult.waitingFrom(Option.some(result)) + return Atom.AsyncResult.isInitial(fromNothing) && fromNothing.waiting === true && + Equal.equals(fromResult, Atom.AsyncResult.waiting(result)) + })()) + ), + ), + Then('every draw satisfies the law')((s, expect) => expect(s.violations).toEqual([])), + ), + ) + scenario( + 'An interrupted failure carries no value or typed error', + Gherkin.Do.pipe( + Given('a batch of generated inputs')('samples', () => Effect.sync(() => [interruptedResult])), + When('the law is checked against every draw')( + 'results', + () => + Effect.sync(() => ({ + interrupted: interruptedResult, + plainFailure: Atom.AsyncResult.failure(Cause.fail('plain')), + })), + ), + Then('every draw satisfies the law')((s, expect) => + expect({ + interrupted: s.results.interrupted, + plainFailure: s.results.plainFailure, + value: Atom.AsyncResult.value(s.results.interrupted), + error: Atom.AsyncResult.error(s.results.interrupted), + }).toEqual({ + interrupted: Atom.AsyncResult.failure(Cause.interrupt(1)), + plainFailure: Atom.AsyncResult.failure(Cause.fail('plain')), + value: Option.none(), + error: Option.none(), + }) + ), + ), + ) + scenario( + 'Mapping preserves the value a result carries', + Gherkin.Do.pipe( + Given('every representative result')('samples', () => Effect.sync(() => RESULT_SAMPLES)), + When('the law is checked against every draw')( + 'violations', + (s) => + Effect.sync(() => + violations(s.samples, (result) => + Equal.equals( + Atom.AsyncResult.value(Atom.AsyncResult.map(result, (n: number) => n + 1)), + Option.map(Atom.AsyncResult.value(result), (n: number) => n + 1), + )) + ), + ), + Then('every draw satisfies the law')((s, expect) => expect(s.violations).toEqual([])), + ), + ) + scenario( + 'Mapping a failure keeps its cause and maps the remembered success', + Gherkin.Do.pipe( + Given('every representative result')('samples', () => Effect.sync(() => RESULT_SAMPLES)), + When('the law is checked against every draw')( + 'violations', + (s) => + Effect.sync(() => + violations(s.samples, (result) => + (() => { + if (!Atom.AsyncResult.isFailure(result)) return true + const mapped = Atom.AsyncResult.map(result, (n: number) => n + 1) + return Atom.AsyncResult.isFailure(mapped) && mapped.waiting === result.waiting && + Equal.equals(mapped.cause, result.cause) && + Equal.equals( + mapped.previousSuccess, + Option.map(result.previousSuccess, (s) => Atom.AsyncResult.successWith(s.value + 1, s)), + ) + })()) + ), + ), + Then('every draw satisfies the law')((s, expect) => expect(s.violations).toEqual([])), + ), + ) + scenario( + 'A cause is available exactly for failures', + Gherkin.Do.pipe( + Given('every representative result')('samples', () => Effect.sync(() => RESULT_SAMPLES)), + When('the law is checked against every draw')( + 'violations', + (s) => + Effect.sync(() => + violations( + s.samples, + (result) => + Option.isSome(Atom.AsyncResult.cause(result)) === Atom.AsyncResult.isFailure(result) && + (!Atom.AsyncResult.isFailure(result) || + Equal.equals(Atom.AsyncResult.cause(result), Option.some(result.cause))), + ) + ), + ), + Then('every draw satisfies the law')((s, expect) => expect(s.violations).toEqual([])), + ), + ) + scenario( + 'A typed error is reported exactly for failures with a typed cause', + Gherkin.Do.pipe( + Given('every representative result')('samples', () => Effect.sync(() => RESULT_SAMPLES)), + When('the law is checked against every draw')( + 'violations', + (s) => + Effect.sync(() => + violations(s.samples, (result) => { + const err = Atom.AsyncResult.error(result) + const hasTypedCause = Atom.AsyncResult.isFailure(result) && + Option.isSome(Cause.findErrorOption(result.cause)) + if (hasTypedCause) { + return Option.isSome(err) + } + return Option.isNone(err) + }) + ), + ), + Then('every draw satisfies the law')((s, expect) => expect(s.violations).toEqual([])), + ), + ) + scenario( + 'Matching dispatches each state to its own handler', + Gherkin.Do.pipe( + Given('every representative result')('samples', () => Effect.sync(() => RESULT_SAMPLES)), + When('the law is checked against every draw')( + 'violations', + (s) => + Effect.sync(() => + violations(s.samples, (result) => { + const routed = Atom.AsyncResult.match(result, { + onInitial: () => 'initial', + onFailure: () => 'failure', + onSuccess: () => 'success', + }) + return matchRouteHolds(result, routed) + }) + ), + ), + Then('every draw satisfies the law')((s, expect) => expect(s.violations).toEqual([])), + ), + ) + scenario( + 'Matching with an error splits failures into typed errors and defects', + Gherkin.Do.pipe( + Given('every representative result')('samples', () => Effect.sync(() => RESULT_SAMPLES)), + When('the law is checked against every draw')( + 'violations', + (s) => + Effect.sync(() => + violations(s.samples, (result) => { + const routed = Atom.AsyncResult.matchWithError(result, { + onInitial: () => 'initial', + onError: () => 'error', + onDefect: () => 'defect', + onSuccess: () => 'success', + }) + return matchWithErrorHolds(result, routed) + }) + ), + ), + Then('every draw satisfies the law')((s, expect) => expect(s.violations).toEqual([])), + ), + ) + scenario( + 'Waiting matching routes waiting states before the tag', + Gherkin.Do.pipe( + Given('every representative result')('samples', () => Effect.sync(() => RESULT_SAMPLES)), + When('the law is checked against every draw')( + 'violations', + (s) => + Effect.sync(() => + violations(s.samples, (result) => { + const routed = Atom.AsyncResult.matchWithWaiting(result, { + onWaiting: () => 'waiting', + onError: () => 'error', + onDefect: () => 'defect', + onSuccess: () => 'success', + }) + return matchWithWaitingHolds(result, routed) + }) + ), + ), + Then('every draw satisfies the law')((s, expect) => expect(s.violations).toEqual([])), + ), + ) + scenario( + 'flatMap keeps initial results untouched and applies its function to successes', + Gherkin.Do.pipe( + Given('every representative result')('samples', () => Effect.sync(() => RESULT_SAMPLES)), + When('the law is checked against every draw')( + 'violations', + (s) => Effect.sync(() => violations(s.samples, (result) => flatMapInitialAndSuccessHolds(result))), + ), + Then('every draw satisfies the law')((s, expect) => expect(s.violations).toEqual([])), + ), + ) + scenario( + 'flatMap with a failing function drops the remembered success', + Gherkin.Do.pipe( + Given('every representative result')('samples', () => Effect.sync(() => RESULT_SAMPLES)), + When('the law is checked against every draw')( + 'violations', + (s) => + Effect.sync(() => + violations(s.samples, (result) => + (() => { + if (!Atom.AsyncResult.isFailure(result)) return true + const mapped = Atom.AsyncResult.flatMap(result, () => Atom.AsyncResult.failure(Cause.fail('nope'))) + return Atom.AsyncResult.isFailure(mapped) && Option.isNone(mapped.previousSuccess) && + mapped.waiting === result.waiting && Equal.equals(mapped.cause, result.cause) + })()) + ), + ), + Then('every draw satisfies the law')((s, expect) => expect(s.violations).toEqual([])), + ), + ) + scenario( + 'Combining mixed entries passes plain values through and stops at the first failure', + Gherkin.Do.pipe( + Given('a batch of generated inputs')('samples', () => Effect.sync(() => PAIR_SAMPLES)), + When('the law is checked against every draw')( + 'violations', + (s) => + Effect.sync(() => + violations(s.samples, ([first, second]) => { + const bothSucceeded = Atom.AsyncResult.isSuccess(first) && Atom.AsyncResult.isSuccess(second) + const list = Atom.AsyncResult.all([first, 7, second]) + const record = Atom.AsyncResult.all({ first, marker: 7, second }) + let listOk = !bothSucceeded + if (Atom.AsyncResult.isSuccess(list)) { + listOk = bothSucceeded && Equal.equals(list.value[0], first.value) && + Equal.equals(list.value[1], 7) && Equal.equals(list.value[2], second.value) + } + let recordOk = !bothSucceeded + if (Atom.AsyncResult.isSuccess(record)) { + recordOk = bothSucceeded && Equal.equals(record.value.first, first.value) && + Equal.equals(record.value.marker, 7) && Equal.equals(record.value.second, second.value) + } + return listOk && recordOk + }) + ), + ), + Then('every draw satisfies the law')((s, expect) => expect(s.violations).toEqual([])), + ), + ) + scenario( + 'The builder fires only the first matching handler', + Gherkin.Do.pipe( + Given('every representative result')('samples', () => Effect.sync(() => RESULT_SAMPLES)), + When('the law is checked against every draw')( + 'violations', + (s) => + Effect.sync(() => + violations(s.samples, (result) => { + const routed = Atom.AsyncResult.builder(result).onInitial(() => 'initial').onWaiting(() => 'waiting') + .onSuccess(() => 'success').onFailure(() => 'failure').orElse(() => 'other') + return routed === builderFirstHandler(result) + }) + ), + ), + Then('every draw satisfies the law')((s, expect) => expect(s.violations).toEqual([])), + ), + ) + scenario( + 'The builder treats initial and waiting states as one case', + Gherkin.Do.pipe( + Given('every representative result')('samples', () => Effect.sync(() => RESULT_SAMPLES)), + When('the law is checked against every draw')( + 'violations', + (s) => + Effect.sync(() => + violations(s.samples, (result) => + (() => { + const fired = Atom.AsyncResult.builder(result).onInitialOrWaiting(() => true).orElse(() => false) + return fired === (Atom.AsyncResult.isInitial(result) || result.waiting) + })()) + ), + ), + Then('every draw satisfies the law')((s, expect) => expect(s.violations).toEqual([])), + ), + ) + scenario( + 'Tagged error matching fires only when the failure carries that tag', + Gherkin.Do.pipe( + Given('a batch of generated inputs')('samples', () => Effect.sync(() => TAGGED_SAMPLES)), + When('the law is checked against every draw')( + 'violations', + (s) => + Effect.sync(() => + violations(s.samples, (result) => { + const byTag = Atom.AsyncResult.builder(result).onErrorTag('T', (e) => e.code).orElse(() => -1) + const byTags = Atom.AsyncResult.builder(result).onErrorTag(['T'], (e) => e.code).orElse(() => -1) + const expected = taggedErrorCode(result) + return Equal.equals(byTag, expected) && Equal.equals(byTags, expected) + }) + ), + ), + Then('every draw satisfies the law')((s, expect) => expect(s.violations).toEqual([])), + ), + ) + scenario( + 'The builder reports defects only for defect failures', + Gherkin.Do.pipe( + Given('every representative result')('samples', () => Effect.sync(() => RESULT_SAMPLES)), + When('the law is checked against every draw')( + 'violations', + (s) => + Effect.sync(() => + violations( + s.samples, + (result) => + (Atom.AsyncResult.builder(result).onDefect(() => true).orElse(() => false)) === + (Atom.AsyncResult.isFailure(result) && EffectResult.isSuccess(Cause.findDefect(result.cause))), + ) + ), + ), + Then('every draw satisfies the law')((s, expect) => expect(s.violations).toEqual([])), + ), + ) + scenario( + 'The builder passes the defect itself to its handler', + Gherkin.Do.pipe( + Given('every representative result')('samples', () => Effect.sync(() => RESULT_SAMPLES)), + When('the law is checked against every draw')( + 'violations', + (s) => + Effect.sync(() => + violations(s.samples, (result) => + (() => { + if (!Atom.AsyncResult.isFailure(result)) { + const routed = Atom.AsyncResult.builder(result).onSuccess(() => 's').orElse(() => 'o') + if (Atom.AsyncResult.isSuccess(result)) { + return routed === 's' + } + return routed === 'o' + } + const handled = Atom.AsyncResult.builder(result).onDefect((received) => received).orElse(() => null) + if (EffectResult.isSuccess(Cause.findDefect(result.cause))) { + return Equal.equals(handled, Cause.squash(result.cause)) + } + return handled === null + })()) + ), + ), + Then('every draw satisfies the law')((s, expect) => expect(s.violations).toEqual([])), + ), + ) + scenario( + 'An exhaustive builder renders the handled tagged case', + Gherkin.Do.pipe( + Given('a batch of generated inputs')('samples', () => Effect.sync(() => [interruptedResult])), + When('the exhaustive render is inspected')( + 'rendered', + () => + Effect.sync(() => + Atom.AsyncResult.builder(exhaustiveResult).onErrorTag('T', (e) => `missing:${e.code}`).onDefect( + () => 'defect', + ).onInterrupt(() => 'interrupt').exhaustive() + ), + ), + Then('every draw satisfies the law')((s, expect) => expect(s.rendered).toEqual('missing:7')), + ), + ) + scenario( + 'A builder with no matching handlers renders nothing', + Gherkin.Do.pipe( + Given('every representative result')('samples', () => Effect.sync(() => RESULT_SAMPLES)), + When('the law is checked against every draw')( + 'violations', + (s) => + Effect.sync(() => + violations(s.samples, (result) => { + const value: AnyValue = Atom.AsyncResult.builder(result).orNull() + return value === null + }) + ), + ), + Then('every draw satisfies the law')((s, expect) => expect(s.violations).toEqual([])), + ), + ) + scenario( + 'The builder reports interruptions only for interrupted failures', + Gherkin.Do.pipe( + Given('every representative result')('samples', () => Effect.sync(() => RESULT_SAMPLES)), + When('the law is checked against every draw')( + 'checks', + (s) => + Effect.sync(() => ({ + unhandledInterruptions: violations( + s.samples.filter((input) => !Atom.AsyncResult.isInterrupted(input)), + (result: SampleResult) => + Atom.AsyncResult.builder(result).onInterrupt(() => true).orElse(() => false) === false, + ), + interruptedResultHandled: Atom.AsyncResult.builder(interruptedResult).onInterrupt(() => true).orElse( + () => false, + ), + })), + ), + Then('every draw satisfies the law')( + (s, expect) => expect(s.checks).toEqual({ unhandledInterruptions: [], interruptedResultHandled: true }), + ), + ), + ) + scenario( + 'Rendering reports a handled success, nothing for unhandled non-failures, and throws for an unhandled failure', + Gherkin.Do.pipe( + Given('every representative result')('samples', () => Effect.sync(() => RESULT_SAMPLES)), + When('the law is checked against every draw')( + 'violations', + (s) => + Effect.sync(() => + violations(s.samples, (result) => + (() => { + if (Atom.AsyncResult.isSuccess(result)) { + return Equal.equals( + Atom.AsyncResult.builder(result).onSuccess((n: number) => n + 1).render(), + result.value + 1, + ) + } + if (Atom.AsyncResult.isInitial(result)) { + const rendered: AnyValue = Atom.AsyncResult.builder(result).render() + return rendered === null + } + let threw = false + try { + Atom.AsyncResult.builder(result).render() + } catch { + threw = true + } + return threw + })()) + ), + ), + Then('every draw satisfies the law')((s, expect) => expect(s.violations).toEqual([])), + ), + ) + scenario( + 'A schema without a value schema rejects successes and remembered successes', + Gherkin.Do.pipe( + Given('every representative result')('samples', () => Effect.sync(() => RESULT_SAMPLES)), + When('the law is checked against every draw')( + 'violations', + (s) => + Effect.sync(() => + violations(s.samples, (result) => + (() => { + const noValue = Atom.AsyncResult.Schema({ error: Schema.String }) + const encoded = Option.getOrThrow(Schema.encodeOption(resultSchema)(result)) + const decoded = Schema.decodeUnknownOption(noValue)(encoded) + const expectRejected = Atom.AsyncResult.isSuccess(result) || + (Atom.AsyncResult.isFailure(result) && Option.isSome(result.previousSuccess)) + if (expectRejected) { + return Option.isNone(decoded) + } + return Option.isSome(decoded) + })()) + ), + ), + Then('every draw satisfies the law')((s, expect) => expect(s.violations).toEqual([])), + ), + ) + scenario( + 'The wire codec roundtrips every sampled state', + Gherkin.Do.pipe( + Given('every representative result')('samples', () => Effect.sync(() => RESULT_SAMPLES)), + When('the law is checked against every draw')( + 'violations', + (s) => + Effect.sync(() => + violations(s.samples, (result) => + (() => { + const codex = Schema.toCodecJson(resultSchema) + const enc = Option.getOrThrow(Schema.encodeOption(codex)(result)) + const dec = Option.getOrThrow(Schema.decodeOption(codex)(enc)) + return Equal.equals(dec, result) + })()) + ), + ), + Then('every draw satisfies the law')((s, expect) => expect(s.violations).toEqual([])), + ), + ) + scenario( + 'Formatting renders every sampled state to a named string', + Gherkin.Do.pipe( + Given('every representative result')('samples', () => Effect.sync(() => RESULT_SAMPLES)), + When('the law is checked against every draw')( + 'violations', + (s) => + Effect.sync(() => + violations(s.samples, (result) => + (() => { + const formatted = Schema.toFormatter(resultSchema)(result) + return typeof formatted === 'string' && formatted.length > 0 + })()) + ), + ), + Then('every draw satisfies the law')((s, expect) => expect(s.violations).toEqual([])), + ), + ) + scenario( + 'A result composes through pipe like a direct call', + Gherkin.Do.pipe( + Given('every representative result')('samples', () => Effect.sync(() => RESULT_SAMPLES)), + When('the law is checked against every draw')( + 'violations', + (s) => + Effect.sync(() => + violations(s.samples, (result) => + Equal.equals( + result.pipe(Atom.AsyncResult.map((n: number) => n + 1)), + Atom.AsyncResult.map(result, (n: number) => n + 1), + )) + ), + ), + Then('every draw satisfies the law')((s, expect) => expect(s.violations).toEqual([])), + ), + ) + scenario( + 'A builder composes through pipe', + Gherkin.Do.pipe( + Given('every representative result')('samples', () => Effect.sync(() => RESULT_SAMPLES)), + When('the law is checked against every draw')( + 'violations', + (s) => + Effect.sync(() => + violations(s.samples, (result) => { + let expected = 0 + if (Atom.AsyncResult.isSuccess(result)) { + expected = result.value + 1 + } + return Equal.equals( + Atom.AsyncResult.builder(result).pipe((b) => b.onSuccess((n: number) => n + 1).orElse(() => 0)), + expected, + ) + }) + ), + ), + Then('every draw satisfies the law')((s, expect) => expect(s.violations).toEqual([])), + ), + ) + scenario( + 'The state guards agree with each other and the stored flags', + Gherkin.Do.pipe( + Given('every representative result')('samples', () => Effect.sync(() => RESULT_SAMPLES)), + When('the law is checked against every draw')( + 'violations', + (s) => + Effect.sync(() => + violations(s.samples, ( + result, + ) => (Atom.AsyncResult.isWaiting(result) === result.waiting && + Atom.AsyncResult.isNotInitial(result) === !Atom.AsyncResult.isInitial(result) && + Atom.AsyncResult.isNotInitial(result) === + (Atom.AsyncResult.isSuccess(result) || Atom.AsyncResult.isFailure(result)))) + ), + ), + Then('every draw satisfies the law')((s, expect) => expect(s.violations).toEqual([])), + ), + ) + scenario( + 'Getting the value agrees with the available value and throws otherwise', + Gherkin.Do.pipe( + Given('every representative result')('samples', () => Effect.sync(() => RESULT_SAMPLES)), + When('the law is checked against every draw')( + 'violations', + (s) => + Effect.sync(() => + violations(s.samples, (result) => + (() => { + const available = Atom.AsyncResult.value(result) + if (Option.isSome(available)) { + return Equal.equals(Atom.AsyncResult.getOrElse(result, () => -1), Option.getOrNull(available)) && + Equal.equals(Atom.AsyncResult.getOrThrow(result), Option.getOrNull(available)) + } + let threw = false + try { + Atom.AsyncResult.getOrThrow(result) + } catch { + threw = true + } + return Atom.AsyncResult.getOrElse(result, () => -1) === -1 && threw + })()) + ), + ), + Then('every draw satisfies the law')((s, expect) => expect(s.violations).toEqual([])), + ), + ) + scenario( + 'Failing with an error reports exactly that error', + Gherkin.Do.pipe( + Given('a batch of generated inputs')('samples', () => Effect.sync(() => MSG_SAMPLES)), + When('the law is checked against every draw')( + 'violations', + (s) => + Effect.sync(() => + violations(s.samples, ( + message, + ) => (Atom.AsyncResult.isFailure(Atom.AsyncResult.fail(message)) && + Equal.equals(Atom.AsyncResult.error(Atom.AsyncResult.fail(message)), Option.some(message)) && + Option.isNone(Atom.AsyncResult.value(Atom.AsyncResult.fail(message))))) + ), + ), + Then('every draw satisfies the law')((s, expect) => expect(s.violations).toEqual([])), + ), + ) + scenario( + 'A success built from an empty object keeps the object as its value', + Gherkin.Do.pipe( + Given('a success whose value is an empty object')( + 'outcome', + () => Effect.sync(() => Atom.AsyncResult.success({})), + ), + When('the outcome is inspected')('reading', (s) => Effect.sync(() => s.outcome)), + Then('it is a success carrying exactly that object')( + (s, expect) => expect({ tag: s.reading._tag, value: s.reading.value }).toEqual({ tag: 'Success', value: {} }), + ), + ), + ) + scenario( + 'A failure built from an empty object is a failure carrying that error', + Gherkin.Do.pipe( + Given('a failure whose error is an empty object')( + 'outcome', + () => Effect.sync(() => Atom.AsyncResult.fail({})), + ), + When('the outcome is inspected')('reading', (s) => Effect.sync(() => s.outcome)), + Then('it reports a failure whose error is exactly that object')( + (s, expect) => + expect({ tag: s.reading._tag, error: Atom.AsyncResult.error(s.reading) }).toEqual({ + tag: 'Failure', + error: Option.some({}), + }), + ), + ), + ) + scenario( + 'Failing with a previous result carries forward its remembered success', + Gherkin.Do.pipe( + Given('every representative result')('samples', () => Effect.sync(() => RESULT_SAMPLES)), + When('the law is checked against every draw')( + 'violations', + (s) => + Effect.sync(() => + violations(s.samples, (result) => + (() => { + const failed = Atom.AsyncResult.failWithPrevious('boom', { previous: Option.some(result) }) + const expected = rememberedSuccess(result) + return Atom.AsyncResult.isFailure(failed) && failed.waiting === false && + Equal.equals(failed.previousSuccess, expected) && + Equal.equals(Atom.AsyncResult.error(failed), Option.some('boom')) + })()) + ), + ), + Then('every draw satisfies the law')((s, expect) => expect(s.violations).toEqual([])), + ), + ) + scenario( + 'A failure whose cause does not fit the error schema is rejected on decode', + Gherkin.Do.pipe( + Given('every representative result')('samples', () => Effect.sync(() => RESULT_SAMPLES)), + When('the law is checked against every draw')( + 'violations', + (s) => + Effect.sync(() => + violations(s.samples, (_result) => + (() => { + const decoded = Schema.decodeUnknownOption(resultSchema)(Atom.AsyncResult.failure(Cause.fail(7))) + return Option.isNone(decoded) + })()) + ), + ), + Then('every draw satisfies the law')((s, expect) => expect(s.violations).toEqual([])), + ), + ) + }) +const RESULT_SAMPLES: readonly Schema.Schema.Type[] = [ + Atom.AsyncResult.initial(false), + Atom.AsyncResult.initial(true), + Atom.AsyncResult.success(1), + Atom.AsyncResult.successWith(2, { timestamp: 0 }), + Atom.AsyncResult.successWith(3, { waiting: true }), + Atom.AsyncResult.failure(Cause.fail('x')), + Atom.AsyncResult.failure(Cause.fail('x'), { + previousSuccess: Option.some(Atom.AsyncResult.success(1)), + waiting: true, + }), + Atom.AsyncResult.failure(Cause.die('boom')), + Atom.AsyncResult.failure(Cause.interrupt(1)), +] +const PAIR_SAMPLES: + readonly (readonly [Schema.Schema.Type, Schema.Schema.Type])[] = + RESULT_SAMPLES.flatMap((a) => RESULT_SAMPLES.map((b) => [a, b] as const)) +const MSG_SAMPLES = ['oops', 'boom', ''] as const +type AnyValue = A +const GARBAGE_SAMPLES: readonly AnyValue[] = [null, 5, 'x', { a: 1 }, { _tag: 'Weird' }, []] +const TAGGED_SAMPLES: readonly Schema.Schema.Type[] = [ + Atom.AsyncResult.success(2), + Atom.AsyncResult.failure(Cause.fail({ _tag: 'T', code: 7 })), + Atom.AsyncResult.failure(Cause.fail('plain')), +] +const interruptedResult = Atom.AsyncResult.failure(Cause.interrupt(1)) +const exhaustiveResult = Atom.AsyncResult.fail({ _tag: 'T', code: 7 }) diff --git a/packages/atom/effect-atom/tests/__fixtures__/Result.fixture.ts b/packages/atom/effect-atom/tests/__fixtures__/Result.fixture.ts new file mode 100644 index 0000000..5ad77a5 --- /dev/null +++ b/packages/atom/effect-atom/tests/__fixtures__/Result.fixture.ts @@ -0,0 +1,9 @@ +import { Atom } from '@systemfsoftware/effect-atom' +import * as S from 'effect/Schema' + +export const TagError = S.TaggedStruct('T', { code: S.Finite }) +export type TaggedError = S.Schema.Type + +export const resultSchema = Atom.AsyncResult.Schema({ success: S.Finite, error: S.String }) + +export const taggedSchema = Atom.AsyncResult.Schema({ success: S.Finite, error: S.Union([TagError, S.String]) }) diff --git a/packages/atom/effect-atom/tests/__fixtures__/collection.model.ts b/packages/atom/effect-atom/tests/__fixtures__/collection.model.ts new file mode 100644 index 0000000..a434cd4 --- /dev/null +++ b/packages/atom/effect-atom/tests/__fixtures__/collection.model.ts @@ -0,0 +1,60 @@ +import { Match, Schema } from 'effect' + +export const CollectionCommand = Schema.Union([ + Schema.TaggedStruct('Push', { value: Schema.Literals([1, 2, 3]) }), + Schema.TaggedStruct('InsertAt', { index: Schema.Literals([0, 1, 2, 3]), value: Schema.Literals([1, 2, 3]) }), + Schema.TaggedStruct('RemoveAt', { index: Schema.Literals([0, 1, 2, 3]) }), +]) + +export type CollectionCommand = Schema.Schema.Type + +export const CollectionState = Schema.Struct({ items: Schema.Array(Schema.Finite) }) + +export type CollectionState = Schema.Schema.Type + +export const initialCollectionState: CollectionState = { items: [] } + +const mayRun = (state: CollectionState, command: CollectionCommand): boolean => + Match.value(command).pipe( + Match.tag('Push', () => true), + Match.tag('InsertAt', (at) => at.index <= state.items.length), + Match.tag('RemoveAt', (at) => at.index < state.items.length), + Match.exhaustive, + ) + +const appended = (state: CollectionState, value: number): readonly [CollectionState, ReadonlyArray] => { + const items = [...state.items, value] + return [{ items }, items] +} + +const inserted = ( + state: CollectionState, + index: number, + value: number, +): readonly [CollectionState, ReadonlyArray] => { + const items = [...state.items.slice(0, index), value, ...state.items.slice(index)] + return [{ items }, items] +} + +const removedAt = (state: CollectionState, index: number): readonly [CollectionState, ReadonlyArray] => { + const items = [...state.items.slice(0, index), ...state.items.slice(index + 1)] + return [{ items }, items] +} + +const stepped = ( + state: CollectionState, + command: CollectionCommand, +): readonly [CollectionState, ReadonlyArray] => + Match.value(command).pipe( + Match.tag('Push', (push) => appended(state, push.value)), + Match.tag('InsertAt', (at) => inserted(state, at.index, at.value)), + Match.tag('RemoveAt', (at) => removedAt(state, at.index)), + Match.exhaustive, + ) + +export const collectionModel = { + state: CollectionState, + initial: initialCollectionState, + precondition: mayRun, + step: stepped, +} diff --git a/packages/atom/effect-atom/tests/__fixtures__/hydration.model.ts b/packages/atom/effect-atom/tests/__fixtures__/hydration.model.ts new file mode 100644 index 0000000..edefdce --- /dev/null +++ b/packages/atom/effect-atom/tests/__fixtures__/hydration.model.ts @@ -0,0 +1,60 @@ +import { Match, Schema } from 'effect' + +export const HydrationCommand = Schema.Union([ + Schema.TaggedStruct('Save', {}), + Schema.TaggedStruct('Reload', {}), + Schema.TaggedStruct('Resolve', {}), + Schema.TaggedStruct('Read', {}), +]) + +export type HydrationCommand = Schema.Schema.Type + +export const HydrationState = Schema.Struct({ + saved: Schema.Boolean, + reloaded: Schema.Boolean, + resolved: Schema.Boolean, +}) + +export type HydrationState = Schema.Schema.Type + +export const initialHydrationState: HydrationState = { saved: false, reloaded: false, resolved: false } + +const mayRun = (state: HydrationState, command: HydrationCommand): boolean => + Match.value(command).pipe( + Match.tag('Save', () => !state.saved), + Match.tag('Reload', () => state.saved && !state.reloaded), + Match.tag('Resolve', () => state.reloaded && !state.resolved), + Match.tag('Read', () => state.reloaded), + Match.exhaustive, + ) + +const stepped = ( + state: HydrationState, + command: HydrationCommand, +): readonly [HydrationState, ReadonlyArray | undefined] => + Match.value(command).pipe( + Match.tag( + 'Save', + (): readonly [HydrationState, ReadonlyArray | undefined] => [{ ...state, saved: true }, [1]], + ), + Match.tag( + 'Reload', + (): readonly [HydrationState, ReadonlyArray | undefined] => [{ ...state, reloaded: true }, undefined], + ), + Match.tag( + 'Resolve', + (): readonly [HydrationState, ReadonlyArray | undefined] => [{ ...state, resolved: true }, [42]], + ), + Match.tag( + 'Read', + (): readonly [HydrationState, ReadonlyArray | undefined] => [state, state.resolved ? [42] : []], + ), + Match.exhaustive, + ) + +export const hydrationModel = { + state: HydrationState, + initial: initialHydrationState, + precondition: mayRun, + step: stepped, +} diff --git a/packages/atom/effect-atom/tests/__fixtures__/registry-program.fixture.ts b/packages/atom/effect-atom/tests/__fixtures__/registry-program.fixture.ts new file mode 100644 index 0000000..46e1376 --- /dev/null +++ b/packages/atom/effect-atom/tests/__fixtures__/registry-program.fixture.ts @@ -0,0 +1,847 @@ +import { Atom } from '@systemfsoftware/effect-atom' +import { Array as Arr, Data, Effect, Match, Order } from 'effect' +import * as fc from 'fast-check' + +/** + * One step of a program run against a registry holding a counter `c`, three + * values derived from it (`c`, `2c`, `3c`, and `3c` when `c` is odd else `-1`), + * a synchronous effect atom plus a `keepAlive` twin whose reads re-run and + * report a run count, and a mirror writable a listener can copy heard values + * into. `watched` picks one of those seven by position. + * + * `Advance` is only ever generated far past the idle TTL plus its resolution + * (`advanceMillis`), so the registry either forgets an unheld value entirely or + * keeps it; no step ever lands inside the eviction window. + */ +export type Step = Data.TaggedEnum<{ + Set: { readonly value: number } + Increment: {} + Read: { readonly watched: number } + Subscribe: { readonly watched: number; readonly writer: boolean; readonly immediate: boolean } + Unsubscribe: { readonly subscription: number } + Batch: { readonly steps: ReadonlyArray } + Refresh: { readonly watched: number } + Modify: { readonly value: number } + Mount: { readonly watched: number } + Unmount: {} + Advance: { readonly millis: number } + Dispose: {} +}> + +const Step = Data.taggedEnum() + +const counterValue = fc.integer({ min: -3, max: 3 }) +const derivedWatched = fc.integer({ min: 0, max: 3 }) +const anyWatched = fc.integer({ min: 0, max: 6 }) +const mountWatched = fc.integer({ min: 0, max: 5 }) +const subscriptionId = fc.integer({ min: 0, max: 5 }) + +const counterAtom = 0 +const effectAtom = 4 +const keepEffectAtom = 5 +const mirrorAtom = 6 +const atomCount = 7 +const atomIndices: ReadonlyArray = Array.from({ length: atomCount }, (_, atom) => atom) + +/** The registry under test evicts idle nodes this long after their last use... */ +const idleTTL = 30 +/** ...rounded onto buckets of this resolution. */ +const resolution = 10 +/** Every generated `Advance` is far past `idleTTL + resolution`. */ +const advanceMillis = 100 + +const nonEffectWatched = fc.oneof(derivedWatched, fc.constant(mirrorAtom)) +const sharedWriterTarget = counterAtom +const subscribeStep: fc.Arbitrary = fc.tuple(nonEffectWatched, fc.boolean(), fc.boolean()).chain( + ([watched, writer, immediate]) => + fc.constant(Step.Subscribe({ watched: writer ? sharedWriterTarget : watched, writer, immediate })), +) + +const leafStep: fc.Arbitrary = fc.oneof( + counterValue.map((value) => Step.Set({ value })), + fc.constant(Step.Increment()), + anyWatched.map((watched) => Step.Read({ watched })), + counterValue.map((value) => Step.Modify({ value })), + subscriptionId.map((subscription) => Step.Unsubscribe({ subscription })), + mountWatched.map((watched) => Step.Refresh({ watched })), +) + +const batchedStep: fc.Arbitrary = fc.oneof(leafStep, subscribeStep) + +const batchStep: fc.Arbitrary = fc.oneof( + fc.array(batchedStep, { maxLength: 3 }).map((steps) => Step.Batch({ steps })), + fc.array(batchedStep, { maxLength: 2 }).chain((inner) => + fc.array(batchedStep, { maxLength: 2 }).map((outer) => + Step.Batch({ steps: [...outer, Step.Batch({ steps: inner })] }) + ) + ), +) + +const step: fc.Arbitrary = fc.oneof( + { arbitrary: leafStep, weight: 8 }, + { arbitrary: subscribeStep, weight: 8 }, + { arbitrary: batchStep, weight: 6 }, + { arbitrary: mountWatched.map((watched) => Step.Mount({ watched })), weight: 3 }, + { arbitrary: fc.constant(Step.Unmount()), weight: 3 }, + { arbitrary: fc.constant(Step.Advance({ millis: advanceMillis })), weight: 5 }, + { arbitrary: fc.constant(Step.Dispose()), weight: 1 }, +) + +export const programs: fc.Arbitrary> = fc.array(step, { maxLength: 25 }) + +/** A batch holds other steps; `unbatched` flattens one into separate top-level steps. */ +const isBatch = (current: Step): current is Step & { readonly _tag: 'Batch' } => + Match.value(current).pipe( + Match.tag('Batch', () => true), + Match.orElse(() => false), + ) +/** Flattens every batch into separate top-level steps: the same program, unbatched. */ +export const unbatched = (program: ReadonlyArray): ReadonlyArray => + program.flatMap((current) => (isBatch(current) ? unbatched(current.steps) : [current])) + +// ----------------------------------------------------------------------------- +// what the atoms are +// ----------------------------------------------------------------------------- + +/** Counter, `2c`, `3c`, odd-only `3c`, effect, keepAlive effect, mirror. */ +const derive = (counter: number): readonly [number, number, number, number] => [ + counter, + counter * 2, + counter * 3, + Math.abs(counter % 2) === 1 ? counter * 3 : -1, +] + +/** The atoms each value reads while it is built; the counter and mirror read nothing. */ +const parentsOf: ReadonlyArray> = [ + [], + [counterAtom], + [1], + [counterAtom, 2], + [counterAtom], + [counterAtom], + [], +] + +const isKeepAlive = (atom: number): boolean => atom === keepEffectAtom + +const derivedAt = (counter: number, watched: number): number => derive(counter)[watched] ?? Number.NaN + +// ----------------------------------------------------------------------------- +// the model: only what a consumer can observe +// ----------------------------------------------------------------------------- + +interface Listener { + readonly kind: 'listener' + readonly watched: number + readonly writer: boolean + readonly lastHeard: number +} +interface MountSlot { + readonly kind: 'mount' + readonly watched: number +} +type Slot = Listener | MountSlot + +/** + * Every field is something a consumer can see: the two values a read returns + * directly, the subscription table that decides what each listener hears and in + * what order, the batch depth that defers notification, the registry's disposal + * and the log the differential compares. + */ +interface ModelState { + /** The counter's value; a writable nobody holds restarts at its definition. */ + readonly counter: number + readonly mirror: number + readonly materialized: ReadonlyArray + /** Subscription slots, addressed by the id a `heard` line names. */ + readonly slots: ReadonlyArray + /** The slots `Unmount` releases, most recent last. */ + readonly mountSlots: ReadonlyArray + readonly depth: number + readonly disposed: boolean + /** Per effect atom: writes that moved its source, refreshes of it, and idle times that forgot it. */ + readonly invalidations: readonly [number, number] + readonly log: ReadonlyArray +} + +const initial: ModelState = { + counter: 0, + mirror: 0, + materialized: Array.from({ length: atomCount }, () => false), + slots: [], + mountSlots: [], + depth: 0, + invalidations: [0, 0], + disposed: false, + log: [], +} + +const logLine = (state: ModelState, line: string): ModelState => ({ ...state, log: [...state.log, line] }) + +const watchedValue = (state: ModelState, watched: number): number => + watched === mirrorAtom ? state.mirror : derivedAt(state.counter, watched) + +const slotHolds = (state: ModelState, atom: number): boolean => + state.slots.some((slot) => slot !== undefined && slot.watched === atom) + +/** Reading a value computes it and everything it reads. */ +const materializedWith = (state: ModelState, atom: number): ModelState => { + const materialized = [...state.materialized] + const visit = (current: number): void => { + if (materialized[current] === true) { + return + } + materialized[current] = true + for (const parent of parentsOf[current] ?? []) { + visit(parent) + } + } + visit(atom) + return materialized.every((isMaterialized, at) => isMaterialized === state.materialized[at]) + ? state + : { ...state, materialized } +} + +/** A held node keeps the nodes it reads alive: a live dependent pins its parents. */ +const closeUnderParents = (state: ModelState, held: ReadonlyArray): ReadonlyArray => { + const stepped = held.map((isHeld, atom) => + isHeld || + (state.materialized[atom] === true && + parentsOf.some((parents, child) => held[child] === true && parents.includes(atom))) + ) + return stepped.every((isHeld, atom) => isHeld === held[atom]) + ? stepped + : closeUnderParents(state, stepped) +} + +const heldAtoms = (state: ModelState): ReadonlyArray => + closeUnderParents( + state, + atomIndices.map((atom) => state.materialized[atom] === true && (isKeepAlive(atom) || slotHolds(state, atom))), + ) + +/** One write into the mirror, and the mirror's own listeners hear the copy once. */ +const writeMirror = (state: ModelState, value: number, lines: Array): ModelState => { + if (state.mirror === value) { + return state + } + if (state.depth > 0) { + return { ...state, mirror: value } + } + const slots = [...state.slots] + slots.forEach((slot, id) => { + if (slot === undefined || slot.kind !== 'listener' || slot.watched !== mirrorAtom) { + return + } + if (slot.lastHeard === value) { + return + } + slots[id] = { ...slot, lastHeard: value } + lines.push(listenerLine(id, mirrorAtom, value)) + }) + return { ...state, mirror: value, slots } +} + +const listenerKind = (watched: number): string => (watched === mirrorAtom ? 'mirror' : 'heard') + +const listenerLine = (id: number, watched: number, value: number): string => `${listenerKind(watched)} ${id}: ${value}` + +const baselineLine = (id: number, watched: number, value: number): string => + `${listenerKind(watched)}-from ${id}: ${value}` + +/** + * Each listener hears the change of the value it watches, once, in subscription + * order; a listener that copies what it hears into the mirror pulls the + * mirror's own listeners in with it. + */ +const hear = (state: ModelState): ModelState => { + const lines: Array = [] + let current: ModelState = state + for (const id of current.slots.keys()) { + const slot = current.slots[id] + if (slot === undefined || slot.kind !== 'listener') { + continue + } + const heard = watchedValue(current, slot.watched) + if (heard === slot.lastHeard) { + continue + } + current = { + ...current, + slots: current.slots.map((entry, at) => + at === id && entry !== undefined && entry.kind === 'listener' ? { ...entry, lastHeard: heard } : entry + ), + } + if (slot.writer) { + current = writeMirror(current, heard, lines) + continue + } + lines.push(listenerLine(id, slot.watched, heard)) + } + return { ...current, log: [...current.log, ...lines] } +} +const setCounter = (state: ModelState, value: number): ModelState => { + const touched = materializedWith(state, counterAtom) + if (value === touched.counter) { + return touched + } + const [effect, keepEffect] = touched.invalidations + const written = { ...touched, counter: value, invalidations: [effect + 1, keepEffect + 1] as const } + return state.depth > 0 ? written : hear(written) +} + +const readCaption = (target: ModelState, watched: number): string => + watched === effectAtom || watched === keepEffectAtom + ? `read ${watched}: success(${target.counter})` + : `read ${watched}: ${watchedValue(target, watched)}` +const readStep = (state: ModelState, watched: number): ModelState => { + const touched = materializedWith(state, watched) + return logLine(touched, readCaption(touched, watched)) +} +const subscribeStepModel = ( + state: ModelState, + watched: number, + writer: boolean, + immediate: boolean, +): ModelState => { + const id = state.slots.length + const value = watchedValue(state, watched) + const touched = materializedWith(state, watched) + const attached: ModelState = { + ...touched, + slots: [...touched.slots, { kind: 'listener', watched, writer, lastHeard: value }], + } + if (immediate === false) { + return writer ? attached : logLine(attached, baselineLine(id, watched, value)) + } + if (writer) { + const lines: Array = [] + const copied = writeMirror(attached, value, lines) + return { ...copied, log: [...copied.log, ...lines] } + } + return logLine(attached, listenerLine(id, watched, value)) +} + +const unsubscribeStep = (state: ModelState, subscription: number): ModelState => { + if (state.slots[subscription] === undefined) { + return state + } + return { ...state, slots: state.slots.map((slot, id) => (id === subscription ? undefined : slot)) } +} + +const invalidatedEffect = (state: ModelState, watched: number): ModelState => { + const [effect, keepEffect] = state.invalidations + return { + ...state, + invalidations: [effect + (watched === effectAtom ? 1 : 0), keepEffect + (watched === keepEffectAtom ? 1 : 0)], + } +} + +const refreshCounter = (state: ModelState): ModelState => { + const reset = setCounter(state, 0) + if (reset.invalidations !== state.invalidations) { + return reset + } + const [effect, keepEffect] = reset.invalidations + return { ...reset, invalidations: [effect + 1, keepEffect + 1] } +} + +const refreshStep = (state: ModelState, watched: number): ModelState => + watched === counterAtom && state.materialized[counterAtom] === true + ? refreshCounter(state) + : invalidatedEffect(state, watched) + +const modifyStep = (state: ModelState, value: number): ModelState => { + const returned = state.counter + return logLine(setCounter(state, value), `modify returned: ${returned}`) +} + +const mountStep = (state: ModelState, watched: number): ModelState => { + const touched = materializedWith(state, watched) + return { + ...touched, + materialized: touched.materialized.map((isMaterialized, atom) => isMaterialized || atom === watched), + slots: [...touched.slots, { kind: 'mount', watched }], + mountSlots: [...touched.mountSlots, touched.slots.length], + } +} + +const unmountStep = (state: ModelState): ModelState => { + const slot = state.mountSlots[state.mountSlots.length - 1] + if (slot === undefined) { + return state + } + return { + ...state, + slots: state.slots.map((entry, id) => (id === slot ? undefined : entry)), + mountSlots: state.mountSlots.slice(0, -1), + } +} + +const batchModel = (state: ModelState, steps: ReadonlyArray): ModelState => { + if (state.disposed) { + return steps.reduce(modelStep, state) + } + const done = steps.reduce(modelStep, { ...state, depth: state.depth + 1 }) + return state.depth > 0 ? { ...done, depth: state.depth } : hear({ ...done, depth: 0 }) +} +/** + * An `Advance` far past the idle TTL forgets every value nothing holds: a + * writable restarts at its definition and derived values recompute on the next + * read. A listener, a mount and a `keepAlive` hold a value, and a held value + * holds the values it reads. + */ +const advanceStep = (state: ModelState): ModelState => { + const held = heldAtoms(state) + const forgotten = state.materialized[effectAtom] === true && held[effectAtom] === false + return { + ...(forgotten ? invalidatedEffect(state, effectAtom) : state), + materialized: held, + counter: held[counterAtom] === true ? state.counter : 0, + mirror: held[mirrorAtom] === true ? state.mirror : 0, + } +} + +const disposedStep = (state: ModelState, current: Step): ModelState => + Match.valueTags(current, { + Set: () => logLine(state, 'write disposed'), + Increment: () => logLine(state, 'write disposed'), + Modify: () => logLine(state, 'write disposed'), + Refresh: () => logLine(state, 'write disposed'), + Batch: ({ steps }) => steps.reduce(modelStep, state), + Read: () => logLine(state, 'read disposed'), + Subscribe: () => logLine(state, 'subscribe disposed'), + Mount: () => logLine(state, 'subscribe disposed'), + Unsubscribe: () => state, + Unmount: () => state, + Advance: () => state, + Dispose: () => state, + }) + +const modelStep = (state: ModelState, current: Step): ModelState => { + if (state.disposed) { + return disposedStep(state, current) + } + return Match.valueTags(current, { + Set: ({ value }) => setCounter(state, value), + Increment: () => setCounter(state, state.counter + 1), + Read: ({ watched }) => readStep(state, watched), + Subscribe: ({ watched, writer, immediate }) => subscribeStepModel(state, watched, writer, immediate), + Unsubscribe: ({ subscription }) => unsubscribeStep(state, subscription), + Batch: ({ steps }) => batchModel(state, steps), + Refresh: ({ watched }) => refreshStep(state, watched), + Modify: ({ value }) => modifyStep(state, value), + Mount: ({ watched }) => mountStep(state, watched), + Unmount: () => unmountStep(state), + Advance: () => advanceStep(state), + Dispose: () => ({ ...state, disposed: true }), + }) +} + +const finalLine = (state: ModelState): ModelState => { + if (state.disposed) { + return logLine(state, 'final: disposed') + } + return logLine( + state, + `final: ${derive(state.counter).join(',')} effects=${state.counter},${state.counter} mirror=${state.mirror}`, + ) +} + +// ----------------------------------------------------------------------------- +// R19 without engine internals: a run bound, and the freshness a read promises +// ----------------------------------------------------------------------------- + +/** + * R19 as a consumer states it: one invalidation re-runs an effect at most once, + * so an effect runs at most once for its first build plus once per invalidation + * — a write that moves the counter, a refresh of the effect itself, or an + * `Advance` that forgets it. Observed and model both log the verdict for each + * effect, so a registry that re-runs twice for one invalidation is visible. + */ +const effectRunBounds = (program: ReadonlyArray): readonly [number, number] => { + const [effect, keepEffect] = program.reduce(modelStep, initial).invalidations + return [effect + 1, keepEffect + 1] +} + +const boundLines = (program: ReadonlyArray, runs: readonly [number, number]): ReadonlyArray => + [effectAtom, keepEffectAtom].map((watched, which) => { + const bound = effectRunBounds(program)[which] ?? 0 + const ran = runs[which] ?? 0 + return ran <= bound + ? `effect ${watched} runs within bound` + : `effect ${watched} runs exceeded bound (${ran} > ${bound})` + }) + +/** The specification: what a subscriber hears and what a read returns. */ +const modelLines = (program: ReadonlyArray): ReadonlyArray => { + const end = program.reduce(modelStep, initial) + return [ + ...finalLine(end).log, + `effect ${effectAtom} runs within bound`, + `effect ${keepEffectAtom} runs within bound`, + ] +} + +const listenerId = (line: string): number => Number(line.slice(line.indexOf(' ') + 1, line.indexOf(':'))) +const byListener = Order.mapInput(Order.Number, listenerId) +const isListenerLine = (line: string): boolean => line.startsWith('heard') || line.startsWith('mirror') +const listenerValue = (line: string): string => line.slice(line.indexOf(':') + 2) + +interface Segments { + readonly done: ReadonlyArray + readonly heard: ReadonlyArray +} + +const flushHeard = (segments: Segments): ReadonlyArray => [ + ...segments.done, + ...Arr.sort(segments.heard.filter((line) => line.startsWith('heard ')), byListener), +] + +const collect = (segments: Segments, line: string): Segments => + isListenerLine(line) + ? { done: segments.done, heard: [...segments.heard, line] } + : { done: [...flushHeard(segments), line], heard: [] } + +const lastPerListener = (lines: ReadonlyArray): ReadonlyArray => + lines.filter((line, index) => !lines.slice(index + 1).some((later) => listenerId(later) === listenerId(line))) + +const mirrorEndings = (log: ReadonlyArray): ReadonlyArray => + Arr.sort(lastPerListener(log.filter((line) => line.startsWith('mirror'))), byListener).map((line) => + `mirror listener ${listenerId(line)} ends on ${listenerValue(line)}` + ) + +const repeatedLines = (log: ReadonlyArray): ReadonlyArray => { + const last = new Map() + return log.filter(isListenerLine).flatMap((line) => { + const id = listenerId(line) + const repeated = last.get(id) === listenerValue(line) + last.set(id, listenerValue(line)) + return repeated ? [`listener ${id} heard ${listenerValue(line)} twice in a row`] : [] + }) +} + +const normalized = (log: ReadonlyArray): ReadonlyArray => [ + ...flushHeard(log.reduce(collect, { done: [], heard: [] })), + ...mirrorEndings(log), + ...repeatedLines(log), +] + +/** + * The specification: what a subscriber hears and what a read returns, written + * without a registry. A subscriber hears each change of the value it watches, + * once, after the write (or the whole batch) that caused it; an `immediate` + * subscribe hears the current value at once. Every read shows the counter + * through its definition, and an effect read shows a success carrying the + * counter as it stands after the write that invalidated it. + */ +export const specified = (program: ReadonlyArray): Effect.Effect> => + Effect.sync(() => normalized(modelLines(program))) + +// ----------------------------------------------------------------------------- +// the registry under test +// ----------------------------------------------------------------------------- + +interface Timer { + readonly due: number + readonly run: () => void +} + +const manualClock = () => { + const state: { + time: number + tasks: ReadonlyArray<() => void> + timers: ReadonlyArray + } = { time: 0, tasks: [], timers: [] } + const scheduleTask = (task: () => void): () => void => { + state.tasks = [...state.tasks, task] + return () => { + state.tasks = state.tasks.filter((pending) => pending !== task) + } + } + const runTasks = (): void => { + const pending = state.tasks + state.tasks = [] + pending.forEach((task) => task()) + } + const now = (): number => state.time + const scheduleTimer = (run: () => void, delayMillis: number): () => void => { + const timer = { due: state.time + delayMillis, run } + state.timers = [...state.timers, timer] + return () => { + state.timers = state.timers.filter((pending) => pending !== timer) + } + } + const earliestDueBy = (target: number): Timer | undefined => + state.timers + .filter((timer) => timer.due <= target) + .reduce( + (earliest, timer) => (earliest === undefined || timer.due < earliest.due ? timer : earliest), + undefined, + ) + const fireUntil = (target: number): void => { + runTasks() + const next = earliestDueBy(target) + if (next === undefined) { + state.time = target + return + } + state.time = next.due + state.timers = state.timers.filter((timer) => timer !== next) + next.run() + fireUntil(target) + } + const advance = (millis: number): void => { + fireUntil(state.time + millis) + runTasks() + } + return { now, scheduleTask, scheduleTimer, advance } +} + +/** The disposed outcome the registry documents: the operation fails, every time. */ +const disposedOutcome = (kind: 'read' | 'write' | 'subscribe', op: () => void): string => { + try { + op() + return `${kind} succeeded on a disposed registry` + } catch (error) { + const message = error instanceof Error ? error.message : '' + return message.endsWith('registry is disposed') + ? `${kind} disposed` + : `${kind} failed unexpectedly: ${message}` + } +} + +/** Runs a program against a fresh registry and returns what was heard and read. */ +export const observed = (program: ReadonlyArray): Effect.Effect> => + Effect.sync(() => { + const clock = manualClock() + const registry = Atom.Registry.make({ + defaultIdleTTL: idleTTL, + timeoutResolution: resolution, + now: clock.now, + scheduleTimer: clock.scheduleTimer, + scheduleTask: clock.scheduleTask, + }) + const runs: [number, number] = [0, 0] + const counter = Atom.make(0) + const doubled = Atom.make((get) => get(counter) * 2) + const tripled = Atom.make((get) => get(doubled) + get(doubled) / 2) + const oddOnly = Atom.make((get) => (Math.abs(get(counter) % 2) === 1 ? get(tripled) : -1)) + const effectValue = Atom.make((get) => { + runs[0]++ + return Effect.succeed(get(counter)) + }) + const keepEffectValue = Atom.make((get) => { + runs[1]++ + return Effect.succeed(get(counter)) + }).pipe(Atom.keepAlive) + const mirror = Atom.make(0) + + const watchedAtom = (watched: number): Atom.Atom => + watched === 0 ? counter : watched === 1 ? doubled : watched === 2 ? tripled : watched === 3 ? oddOnly : mirror + const effectOf = (watched: number): 0 | 1 => (watched === effectAtom ? 0 : 1) + + let disposed = false + const log: Array = [] + const cancels: Array<(() => void) | undefined> = [] + const mountSlots: Array = [] + + const subscribeTo = (watched: number, f: (value: number) => void, immediate: boolean): void => { + Atom.Registry.subscribe(registry, watchedAtom(watched), f, immediate ? { immediate: true } : undefined) + } + const run = (current: Step): void => + Match.valueTags(current, { + Set: ({ value }) => Atom.Registry.set(registry, counter, value), + Increment: () => Atom.Registry.update(registry, counter, (n) => n + 1), + Read: ({ watched }) => { + if (watched === effectAtom || watched === keepEffectAtom) { + const result = Atom.Registry.get(registry, effectOf(watched) === 0 ? effectValue : keepEffectValue) + log.push( + `read ${watched}: ${Atom.AsyncResult.isSuccess(result) ? `success(${result.value})` : 'pending'}`, + ) + return + } + log.push(`read ${watched}: ${Atom.Registry.get(registry, watchedAtom(watched))}`) + }, + Subscribe: ({ watched, writer, immediate }) => { + const id = cancels.length + if (writer === false && immediate === false) { + log.push(baselineLine(id, watched, Atom.Registry.get(registry, watchedAtom(watched)))) + } + cancels.push( + writer + ? Atom.Registry.subscribe( + registry, + watchedAtom(watched), + (value) => Atom.Registry.set(registry, mirror, value), + immediate ? { immediate: true } : undefined, + ) + : Atom.Registry.subscribe( + registry, + watchedAtom(watched), + (value) => log.push(listenerLine(id, watched, value)), + immediate ? { immediate: true } : undefined, + ), + ) + }, + Unsubscribe: ({ subscription }) => { + const cancel = cancels[subscription] + if (cancel !== undefined) { + cancels[subscription] = undefined + } + cancel?.() + }, + Batch: ({ steps }) => { + if (disposed) { + steps.forEach(runDisposed) + return + } + Atom.Registry.batch(registry, () => steps.forEach(run)) + }, + Refresh: ({ watched }) => { + if (watched === effectAtom) { + Atom.Registry.refresh(registry, effectValue) + return + } + if (watched === keepEffectAtom) { + Atom.Registry.refresh(registry, keepEffectValue) + return + } + Atom.Registry.refresh(registry, watchedAtom(watched)) + }, + Modify: ({ value }) => { + const returned = Atom.Registry.modify(registry, counter, (n) => [n, value]) + log.push(`modify returned: ${returned}`) + }, + Mount: ({ watched }) => { + const slot = cancels.length + cancels.push( + watched === effectAtom + ? Atom.Registry.subscribe(registry, effectValue, () => {}, { immediate: true }) + : watched === keepEffectAtom + ? Atom.Registry.subscribe(registry, keepEffectValue, () => {}, { immediate: true }) + : Atom.Registry.subscribe(registry, watchedAtom(watched), () => {}, { immediate: true }), + ) + mountSlots.push(slot) + }, + Unmount: () => { + const slot = mountSlots.pop() + if (slot === undefined) { + return + } + const cancel = cancels[slot] + cancels[slot] = undefined + cancel?.() + }, + Advance: ({ millis }) => { + clock.advance(millis) + }, + Dispose: () => { + disposed = true + Atom.Registry.dispose(registry) + }, + }) + + const runDisposed = (current: Step): void => + Match.valueTags(current, { + Set: ({ value }) => { + log.push(disposedOutcome('write', () => Atom.Registry.set(registry, counter, value))) + }, + Increment: () => { + log.push(disposedOutcome('write', () => Atom.Registry.update(registry, counter, (n) => n + 1))) + }, + Modify: ({ value }) => { + log.push( + disposedOutcome('write', () => { + Atom.Registry.modify(registry, counter, (n) => [n, value]) + }), + ) + }, + Refresh: ({ watched }) => { + log.push( + disposedOutcome('write', () => { + if (watched === effectAtom) { + Atom.Registry.refresh(registry, effectValue) + return + } + if (watched === keepEffectAtom) { + Atom.Registry.refresh(registry, keepEffectValue) + return + } + Atom.Registry.refresh(registry, watchedAtom(watched)) + }), + ) + }, + Read: ({ watched }) => { + log.push( + disposedOutcome('read', () => { + Atom.Registry.get(registry, watchedAtom(watched)) + }), + ) + }, + Subscribe: ({ watched }) => { + log.push( + disposedOutcome('subscribe', () => { + subscribeTo(watched, () => {}, false) + }), + ) + }, + Mount: ({ watched }) => { + log.push( + disposedOutcome('subscribe', () => { + subscribeTo(watched, () => {}, true) + }), + ) + }, + Batch: ({ steps }) => { + steps.forEach(runDisposed) + }, + Unsubscribe: ({ subscription }) => { + const cancel = cancels[subscription] + cancels[subscription] = undefined + cancel?.() + }, + Unmount: () => { + const slot = mountSlots.pop() + if (slot === undefined) { + return + } + const cancel = cancels[slot] + cancels[slot] = undefined + cancel?.() + }, + Advance: ({ millis }) => { + clock.advance(millis) + }, + Dispose: () => { + Atom.Registry.dispose(registry) + }, + }) + + program.forEach((current) => { + if (disposed) { + runDisposed(current) + return + } + run(current) + }) + + if (disposed) { + log.push('final: disposed') + } else { + const finalCounter = Atom.Registry.get(registry, counter) + const finalDoubled = Atom.Registry.get(registry, doubled) + const finalTripled = Atom.Registry.get(registry, tripled) + const finalOdd = Atom.Registry.get(registry, oddOnly) + const finalEffect = Atom.Registry.get(registry, effectValue) + const finalKeep = Atom.Registry.get(registry, keepEffectValue) + const finalMirror = Atom.Registry.get(registry, mirror) + const effectWorth = Atom.AsyncResult.isSuccess(finalEffect) ? finalEffect.value : Number.NaN + const keepWorth = Atom.AsyncResult.isSuccess(finalKeep) ? finalKeep.value : Number.NaN + log.push( + `final: ${finalCounter},${finalDoubled},${finalTripled},${finalOdd} effects=${effectWorth},${keepWorth} mirror=${finalMirror}`, + ) + } + log.push(...boundLines(program, [runs[0], runs[1]])) + return normalized(log) + }) diff --git a/packages/atom/effect-atom/tests/__fixtures__/registry.model.ts b/packages/atom/effect-atom/tests/__fixtures__/registry.model.ts new file mode 100644 index 0000000..f01ab65 --- /dev/null +++ b/packages/atom/effect-atom/tests/__fixtures__/registry.model.ts @@ -0,0 +1,257 @@ +import { Match, Schema } from 'effect' + +export const RegistryCommand = Schema.Union([ + Schema.TaggedStruct('GetSource', {}), + Schema.TaggedStruct('GetDerived', {}), + Schema.TaggedStruct('SetSource', { value: Schema.Literals([0, 1, 2]) }), + Schema.TaggedStruct('UpdateSource', { by: Schema.Literals([1, 2]) }), + Schema.TaggedStruct('ModifySource', {}), + Schema.TaggedStruct('RefreshDerived', {}), + Schema.TaggedStruct('Reset', {}), +]) + +export type RegistryCommand = Schema.Schema.Type + +export const RegistryState = Schema.Struct({ source: Schema.Finite, derived: Schema.Finite }) + +export type RegistryState = Schema.Schema.Type + +export const doubled = (source: number): number => source * 2 + +export const initialRegistryState: RegistryState = { source: 0, derived: 0 } + +const written = (source: number): RegistryState => ({ source, derived: doubled(source) }) + +const unchanged = (state: RegistryState): readonly [RegistryState, number | undefined] => [state, undefined] + +const observed = (state: RegistryState, value: number): readonly [RegistryState, number | undefined] => [state, value] + +const graphStepped = ( + state: RegistryState, + command: RegistryCommand, +): readonly [RegistryState, number | undefined] => + Match.value(command).pipe( + Match.tag('GetSource', () => observed(state, state.source)), + Match.tag('GetDerived', () => observed(state, state.derived)), + Match.tag('SetSource', (set) => unchanged(written(set.value))), + Match.tag('UpdateSource', (update) => unchanged(written(state.source + update.by))), + Match.tag('ModifySource', () => observed(written(state.source + 1), state.source)), + Match.tag('RefreshDerived', () => unchanged(state)), + Match.tag('Reset', () => unchanged(initialRegistryState)), + Match.exhaustive, + ) + +export const registryModel = { + state: RegistryState, + initial: initialRegistryState, + step: graphStepped, +} + +export const DerivedCommand = Schema.Union([ + Schema.TaggedStruct('GetDerived', {}), + Schema.TaggedStruct('SetSource', { value: Schema.Literals([0, 1, 2]) }), +]) + +export type DerivedCommand = Schema.Schema.Type + +const derivedStepped = ( + state: RegistryState, + command: DerivedCommand, +): readonly [RegistryState, number | undefined] => + Match.value(command).pipe( + Match.tag('GetDerived', () => observed(state, state.derived)), + Match.tag('SetSource', (set) => unchanged(written(set.value))), + Match.exhaustive, + ) + +export const derivedModel = { + state: RegistryState, + initial: initialRegistryState, + step: derivedStepped, +} + +export const SubscriptionCommand = Schema.Union([ + Schema.TaggedStruct('Subscribe', {}), + Schema.TaggedStruct('SetSource', { value: Schema.Literals([0, 1, 2]) }), +]) + +export type SubscriptionCommand = Schema.Schema.Type + +export const SubscriptionState = Schema.Struct({ + subscribed: Schema.Boolean, + source: Schema.Finite, + delivered: Schema.Array(Schema.Finite), +}) + +export type SubscriptionState = Schema.Schema.Type + +export const initialSubscriptionState: SubscriptionState = { + subscribed: false, + source: 0, + delivered: [], +} + +const subscriptionMayRun = (state: SubscriptionState, command: SubscriptionCommand): boolean => + Match.value(command).pipe( + Match.tag('Subscribe', () => !state.subscribed), + Match.tag('SetSource', () => state.subscribed), + Match.exhaustive, + ) + +const deliveredAfterSet = ( + state: SubscriptionState, + value: number, +): readonly [SubscriptionState, ReadonlyArray] => { + if (value === state.source) { + return [state, state.delivered] + } + const delivered = [...state.delivered, value] + return [{ ...state, source: value, delivered }, delivered] +} + +const subscriptionStepped = ( + state: SubscriptionState, + command: SubscriptionCommand, +): readonly [SubscriptionState, ReadonlyArray] => + Match.value(command).pipe( + Match.tag( + 'Subscribe', + (): readonly [SubscriptionState, ReadonlyArray] => [{ ...state, subscribed: true }, state.delivered], + ), + Match.tag('SetSource', (set) => deliveredAfterSet(state, set.value)), + Match.exhaustive, + ) + +export const subscriptionModel = { + state: SubscriptionState, + initial: initialSubscriptionState, + precondition: subscriptionMayRun, + step: subscriptionStepped, +} + +export const LifetimeCommand = Schema.Union([ + Schema.TaggedStruct('Mount', {}), + Schema.TaggedStruct('Unmount', {}), + Schema.TaggedStruct('AwaitDeadline', {}), + Schema.TaggedStruct('Observe', {}), +]) + +export type LifetimeCommand = Schema.Schema.Type + +export const LifetimeState = Schema.Struct({ mounted: Schema.Boolean, evicted: Schema.Boolean }) + +export type LifetimeState = Schema.Schema.Type + +export const initialLifetimeState: LifetimeState = { mounted: false, evicted: false } + +const lifetimeMayRun = (state: LifetimeState, command: LifetimeCommand): boolean => + Match.value(command).pipe( + Match.tag('Mount', () => !state.mounted), + Match.tag('Unmount', () => state.mounted), + Match.tag('AwaitDeadline', () => !state.mounted && !state.evicted), + Match.tag('Observe', () => state.mounted || state.evicted), + Match.exhaustive, + ) + +const lifetimeStepped = ( + state: LifetimeState, + command: LifetimeCommand, +): readonly [LifetimeState, void | boolean] => + Match.value(command).pipe( + Match.tag('Mount', (): readonly [LifetimeState, void | boolean] => [{ mounted: true, evicted: false }, undefined]), + Match.tag('Unmount', (): readonly [LifetimeState, void | boolean] => [{ ...state, mounted: false }, undefined]), + Match.tag( + 'AwaitDeadline', + (): readonly [LifetimeState, void | boolean] => [{ ...state, evicted: true }, undefined], + ), + Match.tag('Observe', (): readonly [LifetimeState, void | boolean] => [state, state.mounted]), + Match.exhaustive, + ) + +export const lifetimeModel = { + state: LifetimeState, + initial: initialLifetimeState, + precondition: lifetimeMayRun, + step: lifetimeStepped, +} + +export const StreamCommand = Schema.Union([ + Schema.TaggedStruct('SetSource', { value: Schema.Literals([0, 1, 2, 3]) }), + Schema.TaggedStruct('ReadCurrent', {}), +]) + +export type StreamCommand = Schema.Schema.Type + +export const StreamState = Schema.Struct({ source: Schema.Finite }) + +export type StreamState = Schema.Schema.Type + +export const initialStreamState: StreamState = { source: 1 } + +const streamMayRun = (state: StreamState, command: StreamCommand): boolean => + Match.value(command).pipe( + Match.tag('SetSource', () => true), + Match.tag('ReadCurrent', () => true), + Match.exhaustive, + ) + +const streamStepped = ( + state: StreamState, + command: StreamCommand, +): readonly [StreamState, ReadonlyArray | undefined] => + Match.value(command).pipe( + Match.tag( + 'SetSource', + (set): readonly [StreamState, ReadonlyArray | undefined] => [{ source: set.value }, undefined], + ), + Match.tag( + 'ReadCurrent', + (): readonly [StreamState, ReadonlyArray | undefined] => [state, [state.source]], + ), + Match.exhaustive, + ) + +export const streamModel = { + state: StreamState, + initial: initialStreamState, + precondition: streamMayRun, + step: streamStepped, +} + +export const ContextStreamCommand = Schema.Union([ + Schema.TaggedStruct('Settle', { value: Schema.Literals([0, 1, 2, 3]) }), + Schema.TaggedStruct('ReadSettled', {}), +]) + +export type ContextStreamCommand = Schema.Schema.Type + +export const ContextStreamState = Schema.Struct({ settled: Schema.Finite }) + +export type ContextStreamState = Schema.Schema.Type + +export const initialContextStreamState: ContextStreamState = { settled: 3 } + +const contextStreamStepped = ( + state: ContextStreamState, + command: ContextStreamCommand, +): readonly [ContextStreamState, ReadonlyArray | undefined] => + Match.value(command).pipe( + Match.tag( + 'Settle', + ( + settle, + ): readonly [ContextStreamState, ReadonlyArray | undefined] => [{ settled: settle.value }, undefined], + ), + Match.tag( + 'ReadSettled', + (): readonly [ContextStreamState, ReadonlyArray | undefined] => [state, [state.settled]], + ), + Match.exhaustive, + ) + +export const contextStreamModel = { + state: ContextStreamState, + initial: initialContextStreamState, + precondition: (): boolean => true, + step: contextStreamStepped, +} diff --git a/packages/atom/effect-atom/tests/atom-ref-collection.conformance.test.ts b/packages/atom/effect-atom/tests/atom-ref-collection.conformance.test.ts new file mode 100644 index 0000000..b6a2de6 --- /dev/null +++ b/packages/atom/effect-atom/tests/atom-ref-collection.conformance.test.ts @@ -0,0 +1,86 @@ +import { Conformance } from '@systemfsoftware/conformance-spec' +import { Atom } from '@systemfsoftware/effect-atom' +import { Gherkin, Given, it, makeFeature, Then, When } from '@systemfsoftware/effect-gherkin-spec' +import { Context, Effect, Layer, Match } from 'effect' + +import { CollectionCommand, collectionModel } from './__fixtures__/collection.model.js' + +/** The budget the sequential check is given: every one of these histories is checked. */ +const COLLECTION_ROUNDS = 1000 + +const Feature = makeFeature({ it }) + +type Items = ReadonlyArray + +interface CollectionHandle { + readonly push: (value: number) => Items + readonly insertAt: (index: number, value: number) => Items + readonly removeAt: (index: number) => Items +} + +class Collections extends Context.Service()( + '@systemfsoftware/effect-atom/tests/atom-ref-collection.conformance.test/Collections', +) {} + +const removedAt = (collection: Atom.Ref.Collection, index: number): Items => { + const ref = Atom.Ref.get(collection)[index] + return ref === undefined + ? Atom.Ref.toArray(collection) + : Atom.Ref.toArray(Atom.Ref.remove(collection, ref)) +} + +const freshHandle = (): CollectionHandle => { + const collection = Atom.Ref.collection([]) + return { + push: (value) => Atom.Ref.toArray(Atom.Ref.push(collection, value)), + insertAt: (index, value) => Atom.Ref.toArray(Atom.Ref.insertAt(collection, index, value)), + removeAt: (index) => removedAt(collection, index), + } +} + +const collectionLayer: Layer.Layer = Layer.effect(Collections, Effect.sync(freshHandle)) + +const applied = (handle: CollectionHandle, command: CollectionCommand): Items => + Match.value(command).pipe( + Match.tagsExhaustive({ + Push: (push) => handle.push(push.value), + InsertAt: (at) => handle.insertAt(at.index, at.value), + RemoveAt: (at) => handle.removeAt(at.index), + }), + ) +const runCollectionCommand = (command: CollectionCommand): Effect.Effect => + Effect.flatMap(Collections, (handle) => Effect.sync(() => applied(handle, command))) + +const collectionCheck = ( + subject: Layer.Layer, + spec: { readonly sequences: number; readonly operations: number }, +) => + Conformance.sequential(subject, { + commands: CollectionCommand, + model: collectionModel, + run: runCollectionCommand, + sequences: spec.sequences, + operations: spec.operations, + }) + +Feature('A shopping list collection that stays with a plain list', { timeout: 0 }) + .withLayer(Layer.empty) + .live('each scenario drives the simulation kernel itself, and a conformance check cannot run inside a kernel run') + .body(({ scenario }) => { + scenario( + 'A shopping list keeps every push, insert, and removal a plain list would keep', + Gherkin.Do.pipe( + Given('an empty shopping list next to an empty plain list')( + 'subject', + () => Effect.succeed(collectionLayer), + ), + When('a thousand rounds of pushing 1, 2, or 3, inserting, and removing are replayed')( + 'report', + (s) => collectionCheck(s.subject, { sequences: COLLECTION_ROUNDS, operations: 10 }), + ), + Then('the shopping list matches the plain list after every round')((s, expect) => + expect(s.report, Conformance.render(s.report)).toMatchObject({ _tag: 'Pass', histories: COLLECTION_ROUNDS }) + ), + ), + ) + }) diff --git a/packages/atom/effect-atom/tests/browser/window-focus-and-search-params.integration.test.ts b/packages/atom/effect-atom/tests/browser/window-focus-and-search-params.integration.test.ts new file mode 100644 index 0000000..42adbf0 --- /dev/null +++ b/packages/atom/effect-atom/tests/browser/window-focus-and-search-params.integration.test.ts @@ -0,0 +1,565 @@ +/** + * Browser scenarios for the two page-owned event sources a watched value can + * follow: whether the page is in view, and what the page's web address says. + * + * These run in the browser Vitest project, so the events are real dispatches + * on the real document and address. Every outcome is read off the values in a + * registry through the core package alone; no event source is replaced and no + * React hook is involved. + * + * @since 4.0.0 + */ +import { Atom } from '@systemfsoftware/effect-atom' +import { Gherkin, Given, it, makeFeature, Then, When } from '@systemfsoftware/effect-gherkin-spec' +import { vi } from '@systemfsoftware/vitest' +import { Effect, Layer, Option, Schema } from 'effect' + +const Feature = makeFeature({ it }) + +let pageIsInView: DocumentVisibilityState = document.visibilityState + +Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: () => pageIsInView, +}) + +const showThePage = (): void => { + pageIsInView = 'visible' +} + +const hideThePage = (): void => { + pageIsInView = 'hidden' +} + +const announceThePageCameIntoView = (): void => { + window.dispatchEvent(new Event('visibilitychange')) +} + +const announceTheAddressChanged = (): void => { + window.dispatchEvent(new Event('popstate')) +} + +const visit = (search: string): void => { + window.history.replaceState({}, '', `${window.location.pathname}${search}`) +} + +const readLog = () => { + let reads = 0 + return { + record: (): void => { + reads += 1 + }, + seen: (): number => reads, + counted: (): number => { + reads += 1 + return reads + }, + } +} + +const pages: Array = [] +const recordings: Array<() => void> = [] + +const openPage = (options?: Parameters[0]): Atom.Registry.Registry => { + const page = Atom.Registry.make(options) + pages.push(page) + return page +} + +/** + * A page whose deferred address-bar rewrites wait for the scenario to flush + * them, so batching is observed without racing the real rewrite timer. + */ +const heldBackPage = (heldBack: Array<() => void>): Atom.Registry.Registry => + openPage({ + scheduleTimer: (rewrite) => { + heldBack.push(rewrite) + return () => { + heldBack.splice(heldBack.indexOf(rewrite), 1) + } + }, + }) + +const recordAddressBarWrites = () => { + const pushState = vi.spyOn(window.history, 'pushState').mockImplementation(() => {}) + recordings.push(() => pushState.mockRestore()) + return { + writtenAddresses: (): ReadonlyArray => pushState.mock.calls.map((call) => String(call[2])), + } +} + +/** + * Counts `visibilitychange` registrations on `window`, where + * `windowFocusSignal` installs its listener, so a scenario can prove the + * listener is gone after the last watcher leaves. + */ +const watchVisibilityListeners = () => { + const added = vi.spyOn(window, 'addEventListener') + const removed = vi.spyOn(window, 'removeEventListener') + recordings.push(() => { + added.mockRestore() + removed.mockRestore() + }) + const visibilityChanges = (calls: ReadonlyArray): number => + calls.filter((call) => call[0] === 'visibilitychange').length + return { + added: (): number => visibilityChanges(added.mock.calls), + removed: (): number => visibilityChanges(removed.mock.calls), + } +} + +/** The query a rewritten address carries, keyed by name. */ +const addressQuery = (address: string): Record => + Object.fromEntries(new URL(address, window.location.href).searchParams) + +const waitUntilRegistryEmpties = (registry: Atom.Registry.Registry) => + vi.waitFor(() => { + const nodes = Atom.Registry.getNodes(registry).size + if (nodes !== 0) { + throw new Error(`the page still holds ${nodes} nodes`) + } + }) + +const clearBrowserState = (): void => { + for (const stopRecording of recordings.splice(0)) { + stopRecording() + } + for (const page of pages.splice(0)) { + Atom.Registry.dispose(page) + } + document.querySelector('[data-testid="page-marker"]')?.remove() + visit('') +} + +const browserCleanupLayer = Layer.effectDiscard( + Effect.addFinalizer(() => Effect.sync(clearBrowserState)), +) + +Feature('Keeping watched values in step with the browser page') + .live('dispatches real browser events and waits on the registry timer to sweep idle values') + .withLayer(Layer.empty) + .withScenarioLayer(browserCleanupLayer) + .body(({ scenario, scenarioOutline }) => { + scenario( + 'A count of page visits rises only when the page is in view', + Gherkin.Do.pipe( + Given('a registry counting how often the page has come into view, with the page out of view')( + 'ctx', + () => + Effect.sync(() => { + hideThePage() + const page = openPage() + const focusCount = Atom.windowFocusSignal + Atom.Registry.subscribe(page, focusCount, () => {}) + return { focusCount, page } + }), + ), + When('the page comes into view, leaves, then comes into view again')('readings', (s) => + Effect.sync(() => { + showThePage() + announceThePageCameIntoView() + const afterFirstVisit = Atom.Registry.get(s.ctx.page, s.ctx.focusCount) + hideThePage() + announceThePageCameIntoView() + const whileAway = Atom.Registry.get(s.ctx.page, s.ctx.focusCount) + showThePage() + announceThePageCameIntoView() + const afterSecondVisit = Atom.Registry.get(s.ctx.page, s.ctx.focusCount) + return { afterFirstVisit, afterSecondVisit, whileAway } + })), + Then('the count rose once per visit and never while the page was away')( + (s, expect) => + expect({ + afterFirstVisit: s.readings.afterFirstVisit, + whileAway: s.readings.whileAway, + afterSecondVisit: s.readings.afterSecondVisit, + }).toEqual({ afterFirstVisit: 1, whileAway: 1, afterSecondVisit: 2 }), + ), + ), + ) + + scenario( + 'A value following page focus is reread on every return to the page', + Gherkin.Do.pipe( + Given('a registry holding a value that follows page focus')('ctx', () => + Effect.sync(() => { + const reads = readLog() + const followed = Atom.refreshOnWindowFocus(Atom.make(reads.counted)) + const page = openPage() + Atom.Registry.subscribe(page, followed, () => {}) + return { followed, page, reads } + })), + When('the page comes into view, leaves, then comes into view again')('readings', (s) => + Effect.sync(() => { + const before = s.ctx.reads.seen() + showThePage() + announceThePageCameIntoView() + const afterFirstVisit = s.ctx.reads.seen() + hideThePage() + announceThePageCameIntoView() + const whileAway = s.ctx.reads.seen() + showThePage() + announceThePageCameIntoView() + const afterSecondVisit = s.ctx.reads.seen() + return { afterFirstVisit, afterSecondVisit, before, whileAway } + })), + Then('the value was reread once per visit and never while the page was away')( + (s, expect) => + expect({ + afterFirstVisit: s.readings.afterFirstVisit, + whileAway: s.readings.whileAway, + afterSecondVisit: s.readings.afterSecondVisit, + }).toEqual({ + afterFirstVisit: s.readings.before + 1, + whileAway: s.readings.before + 1, + afterSecondVisit: s.readings.before + 2, + }), + ), + ), + ) + + scenario( + 'A value following page focus stops listening once nobody watches it', + Gherkin.Do.pipe( + Given('a registry holding a value that follows page focus, watched by one listener')( + 'ctx', + () => + Effect.sync(() => { + const watching = watchVisibilityListeners() + const reads = readLog() + const followed = Atom.refreshOnWindowFocus(Atom.make(reads.counted)) + const page = openPage() + const stopWatching = Atom.Registry.subscribe(page, followed, () => {}) + return { followed, page, reads, stopWatching, watching } + }), + ), + When('the listener leaves and the page comes into view again')('observed', (s) => + Effect.gen(function*() { + const readsWhileWatched = s.ctx.reads.seen() + s.ctx.stopWatching() + yield* Effect.promise(() => waitUntilRegistryEmpties(s.ctx.page)) + const added = s.ctx.watching.added() + const removed = s.ctx.watching.removed() + showThePage() + announceThePageCameIntoView() + return { added, readsAfterLeaving: s.ctx.reads.seen(), readsWhileWatched, removed } + })), + Then('the page no longer listens for visibility changes and the value stays put')( + (s, expect) => + expect({ + readsWhileWatched: s.observed.readsWhileWatched, + readsAfterLeaving: s.observed.readsAfterLeaving, + added: s.observed.added, + removed: s.observed.removed, + }).toSatisfy( + ({ readsWhileWatched, readsAfterLeaving, added, removed }) => + readsWhileWatched > 0 && readsAfterLeaving === readsWhileWatched && added > 0 && removed === added, + 'the page stopped listening for visibility changes and the value stayed put', + ), + ), + ), + ) + + scenario( + 'A value refreshed from a custom signal changes on each signal change', + Gherkin.Do.pipe( + Given('a registry holding a base value refreshed from a separate custom signal')( + 'ctx', + () => + Effect.sync(() => { + const reads = readLog() + const signal = Atom.make(0) + const refreshed = Atom.makeRefreshOnSignal(signal)(Atom.make(reads.counted)) + const page = openPage() + Atom.Registry.subscribe(page, refreshed, () => {}) + return { page, reads, refreshed, signal } + }), + ), + When('the custom signal changes twice')('readings', (s) => + Effect.sync(() => { + const before = s.ctx.reads.seen() + Atom.Registry.set(s.ctx.page, s.ctx.signal, 1) + const afterFirstChange = s.ctx.reads.seen() + Atom.Registry.set(s.ctx.page, s.ctx.signal, 2) + const afterSecondChange = s.ctx.reads.seen() + return { + afterFirstChange, + afterSecondChange, + before, + value: Atom.Registry.get(s.ctx.page, s.ctx.refreshed), + } + })), + Then('the value refreshed once per signal change')( + (s, expect) => + expect({ + afterFirstChange: s.readings.afterFirstChange, + afterSecondChange: s.readings.afterSecondChange, + value: s.readings.value, + }).toEqual({ + afterFirstChange: s.readings.before + 1, + afterSecondChange: s.readings.before + 2, + value: s.readings.before + 2, + }), + ), + ), + ) + + scenario( + 'A choice written to the address bar updates the address without reloading the page', + Gherkin.Do.pipe( + Given('a registry tracking a choice in the address, with the address naming page 7')( + 'ctx', + () => + Effect.sync(() => { + visit('?page=7') + const page = openPage() + const chosenPage = Atom.searchParam('page') + Atom.Registry.subscribe(page, chosenPage, () => {}) + const marker = document.createElement('span') + marker.setAttribute('data-testid', 'page-marker') + document.body.append(marker) + return { chosenPage, marker, page } + }), + ), + When('the choice is read, then set to page 8')('seen', (s) => + Effect.gen(function*() { + const readFromAddress = Atom.Registry.get(s.ctx.page, s.ctx.chosenPage) + Atom.Registry.set(s.ctx.page, s.ctx.chosenPage, '8') + yield* Effect.promise(() => + vi.waitFor(() => { + const page = new URLSearchParams(window.location.search).get('page') + if (page !== '8') { + throw new Error(`the address still names page ${page ?? 'nothing'}`) + } + }, { interval: 25, timeout: 5_000 }) + ) + return { + readFromAddress, + search: new URLSearchParams(window.location.search).get('page'), + } + })), + Then('the address first named page 7, now names page 8, and the page never reloaded')((s, expect) => + expect({ + readFromAddress: s.seen.readFromAddress, + search: s.seen.search, + markerAttached: s.ctx.marker.isConnected, + }).toEqual({ readFromAddress: '7', search: '8', markerAttached: true }) + ), + ), + ) + + scenario( + 'Two choices written at once reach the address bar in a single update', + Gherkin.Do.pipe( + Given('a registry tracking a size choice and a colour choice, with address updates held back')( + 'ctx', + () => + Effect.sync(() => { + const heldBack: Array<() => void> = [] + const recording = recordAddressBarWrites() + const page = heldBackPage(heldBack) + const size = Atom.searchParam('size') + const colour = Atom.searchParam('colour') + Atom.Registry.subscribe(page, size, () => {}) + Atom.Registry.subscribe(page, colour, () => {}) + return { colour, heldBack, page, recording, size } + }), + ), + When('both choices change in the same instant, then the held-back updates run')( + 'written', + (s) => + Effect.sync(() => { + Atom.Registry.set(s.ctx.page, s.ctx.size, '1') + Atom.Registry.set(s.ctx.page, s.ctx.colour, '2') + for (const rewrite of s.ctx.heldBack) { + rewrite() + } + return { addresses: s.ctx.recording.writtenAddresses() } + }), + ), + Then('the address bar is rewritten once and names both choices')( + (s, expect) => expect(s.written.addresses.map(addressQuery)).toEqual([{ size: '1', colour: '2' }]), + ), + ), + ) + + scenario( + 'An address change announced by the browser reaches the tracked choice', + Gherkin.Do.pipe( + Given('a registry tracking the page number in the address, naming page 1')('ctx', () => + Effect.sync(() => { + visit('?page=1') + const page = openPage() + const chosenPage = Atom.searchParam('page') + Atom.Registry.subscribe(page, chosenPage, () => {}) + return { chosenPage, page } + })), + When('the browser moves to page 2 and announces the change')('seen', (s) => + Effect.sync(() => { + const before = Atom.Registry.get(s.ctx.page, s.ctx.chosenPage) + window.history.pushState({}, '', `${window.location.pathname}?page=2`) + announceTheAddressChanged() + return { after: Atom.Registry.get(s.ctx.page, s.ctx.chosenPage), before } + })), + Then('the tracked choice follows the new address')( + (s, expect) => expect({ before: s.seen.before, after: s.seen.after }).toEqual({ before: '1', after: '2' }), + ), + ), + ) + + scenarioOutline( + 'A page number written as in the address reads as ', + [ + { expected: Option.some(42), read: 'page 42', search: '?page=42', written: 'a number' }, + { + expected: Option.none(), + read: 'no page at all', + search: '?page=not-a-number', + written: 'not a number', + }, + ] as const, + (row) => + Gherkin.Do.pipe( + Given('a registry reading the page number from the address')('ctx', () => + Effect.sync(() => { + visit(row.search) + const page = openPage() + const chosenPage = Atom.searchParam('page', { schema: Schema.FiniteFromString }) + return { chosenPage, page } + })), + When('the page number is read')('read', (s) => + Effect.sync(() => Atom.Registry.get(s.ctx.page, s.ctx.chosenPage))), + Then('the reading matches the address')((s, expect) => + expect(s.read).toEqual(row.expected) + ), + ), + ) + + scenario( + 'A value chosen in the address with a schema survives a round trip back to the address', + Gherkin.Do.pipe( + Given('a registry reading a numeric page number from the address')( + 'ctx', + () => + Effect.sync(() => { + visit('?page=42') + const heldBack: Array<() => void> = [] + const recording = recordAddressBarWrites() + const page = heldBackPage(heldBack) + const pageNumber = Atom.searchParam('page', { schema: Schema.FiniteFromString }) + Atom.Registry.subscribe(page, pageNumber, () => {}) + return { heldBack, page, pageNumber, recording } + }), + ), + When('the page number is read back, written as a number, then the held-back update runs')( + 'written', + (s) => + Effect.sync(() => { + const readBack = Atom.Registry.get(s.ctx.page, s.ctx.pageNumber) + Atom.Registry.set(s.ctx.page, s.ctx.pageNumber, Option.some(7)) + for (const rewrite of s.ctx.heldBack) { + rewrite() + } + return { addresses: s.ctx.recording.writtenAddresses(), readBack } + }), + ), + Then('the value read was the decoded number and the rewrite carries its encoding')( + (s, expect) => + expect({ readBack: s.written.readBack, addresses: s.written.addresses.map(addressQuery) }).toEqual({ + readBack: Option.some(42), + addresses: [{ page: '7' }], + }), + ), + ), + ) + + scenario( + 'An address update ignores an announcement that arrives while it is being written', + Gherkin.Do.pipe( + Given('a registry tracking a size choice, with the address bar waiting on its held-back update')( + 'ctx', + () => + Effect.sync(() => { + visit('?size=1') + const heldBack: Array<() => void> = [] + const recording = recordAddressBarWrites() + const page = heldBackPage(heldBack) + const size = Atom.searchParam('size') + Atom.Registry.subscribe(page, size, () => {}) + Atom.Registry.set(page, size, '2') + return { heldBack, page, recording, size } + }), + ), + When('the held-back update runs while a stale announcement arrives mid-write')( + 'written', + (s) => + Effect.sync(() => { + const pushState = vi.spyOn(window.history, 'pushState') + recordings.push(() => pushState.mockRestore()) + pushState.mockImplementationOnce(() => { + announceTheAddressChanged() + }) + for (const rewrite of s.ctx.heldBack) { + rewrite() + } + return { + addresses: s.ctx.recording.writtenAddresses(), + value: Atom.Registry.get(s.ctx.page, s.ctx.size), + } + }), + ), + Then('the value written wins and the announcement changes nothing')( + (s, expect) => + expect({ addresses: s.written.addresses.map(addressQuery), value: s.written.value }).toEqual({ + addresses: [{ size: '2' }], + value: '2', + }), + ), + ), + ) + + scenario( + 'A choice recorded on one page is never written by another page', + Gherkin.Do.pipe( + Given('two registries sharing the address bar, each remembering its own size choice')( + 'ctx', + () => + Effect.sync(() => { + visit('') + const recording = recordAddressBarWrites() + const firstHeldBack: Array<() => void> = [] + const secondHeldBack: Array<() => void> = [] + const firstPage = heldBackPage(firstHeldBack) + const secondPage = heldBackPage(secondHeldBack) + const size = Atom.searchParam('size') + Atom.Registry.subscribe(firstPage, size, () => {}) + Atom.Registry.subscribe(secondPage, size, () => {}) + return { firstHeldBack, firstPage, recording, secondHeldBack, secondPage, size } + }), + ), + When('each page records its own choice in the same instant, then each catches up in turn')( + 'written', + (s) => + Effect.sync(() => { + Atom.Registry.set(s.ctx.firstPage, s.ctx.size, '1') + Atom.Registry.set(s.ctx.secondPage, s.ctx.size, '2') + for (const rewrite of s.ctx.firstHeldBack) { + rewrite() + } + const firstWrite = s.ctx.recording.writtenAddresses() + for (const rewrite of s.ctx.secondHeldBack) { + rewrite() + } + return { addresses: s.ctx.recording.writtenAddresses(), firstWrite } + }), + ), + Then('each page wrote only its own choice, once each')( + (s, expect) => + expect({ + addresses: s.written.addresses.map(addressQuery), + firstWrite: s.written.firstWrite.map(addressQuery), + }).toEqual({ addresses: [{ size: '1' }, { size: '2' }], firstWrite: [{ size: '1' }] }), + ), + ), + ) + }) diff --git a/packages/atom/effect-atom/tests/hydration-refusal.integration.test.ts b/packages/atom/effect-atom/tests/hydration-refusal.integration.test.ts new file mode 100644 index 0000000..ffc1601 --- /dev/null +++ b/packages/atom/effect-atom/tests/hydration-refusal.integration.test.ts @@ -0,0 +1,118 @@ +import { Atom } from '@systemfsoftware/effect-atom' +import { Gherkin, Given, it, makeFeature, Then, When } from '@systemfsoftware/effect-gherkin-spec' +import { Effect, Layer, Schema } from 'effect' + +const Feature = makeFeature({ it }) + +type DamagedRow = { + readonly damage: string + readonly noted: string + readonly entry: Atom.Hydration.HydrationEntry + readonly noteKey: string | undefined + readonly issueMentions: string +} + +const damagedEntries: ReadonlyArray = [ + { + damage: 'without its name', + noted: 'no name', + entry: { '~effect/reactivity/DehydratedAtom': true, value: 9, dehydratedAt: 1 }, + noteKey: undefined, + issueMentions: 'at ["key"]', + }, + { + damage: 'with a name that is a number', + noted: 'no name', + entry: { '~effect/reactivity/DehydratedAtom': true, key: 7, value: 9, dehydratedAt: 1 }, + noteKey: undefined, + issueMentions: 'at ["key"]', + }, + { + damage: 'without the saved-state mark', + noted: 'no name', + entry: { key: 'count', value: 9, dehydratedAt: 1 }, + noteKey: undefined, + issueMentions: 'at ["~effect/reactivity/DehydratedAtom"]', + }, + { + damage: 'with a save time before time began', + noted: 'no name', + entry: { '~effect/reactivity/DehydratedAtom': true, key: 'count', value: 9, dehydratedAt: -1 }, + noteKey: undefined, + issueMentions: 'at ["dehydratedAt"]', + }, + { + damage: 'with a save time beyond any clock', + noted: 'no name', + entry: { + '~effect/reactivity/DehydratedAtom': true, + key: 'count', + value: 9, + dehydratedAt: Number.POSITIVE_INFINITY, + }, + noteKey: undefined, + issueMentions: 'at ["dehydratedAt"]', + }, + { + damage: 'with a save time that is not a number', + noted: 'no name', + entry: { '~effect/reactivity/DehydratedAtom': true, key: 'count', value: 9, dehydratedAt: Number.NaN }, + noteKey: undefined, + issueMentions: 'at ["dehydratedAt"]', + }, + { + damage: 'carrying a word where the page keeps a number', + noted: 'the saved count', + entry: { '~effect/reactivity/DehydratedAtom': true, key: 'count', value: 'twelve', dehydratedAt: 1 }, + noteKey: 'count', + issueMentions: 'Expected number', + }, + { + damage: 'carrying a number that breaks the page rule', + noted: 'the saved count', + entry: { '~effect/reactivity/DehydratedAtom': true, key: 'count', value: -5, dehydratedAt: 1 }, + noteKey: 'count', + issueMentions: 'greater than', + }, +] + +Feature("Reloading a page's saved values without letting damaged entries through") + .withLayer(Layer.empty) + .body(({ scenarioOutline }) => { + scenarioOutline( + 'A saved entry that arrives is turned away, noted under , and the page keeps its own number', + damagedEntries, + (row: DamagedRow) => + Gherkin.Do.pipe( + Given('a page whose saved count must stay a number above zero')('ctx', () => + Effect.sync(() => { + const count = Atom.make(7).pipe( + Atom.serializable({ + key: 'count', + schema: Schema.Finite.pipe(Schema.check(Schema.isGreaterThan(0))), + }), + ) + const page = Atom.Registry.make() + return { page, count } + })), + When('the damaged entry arrives')('result', (s) => + Effect.sync(() => { + Atom.Hydration.hydrate(s.ctx.page, [row.entry]) + return { + reading: Atom.Registry.get(s.ctx.page, s.ctx.count), + notes: Atom.Registry.refusals(s.ctx.page), + } + })), + Then('the page shows its own number and exactly one note records the refusal')( + (s, expect) => + expect({ + reading: s.result.reading, + notes: s.result.notes.map((note) => ({ key: note.key, issue: note.issue })), + }).toEqual({ + reading: 7, + notes: [{ key: row.noteKey, issue: expect.stringContaining(row.issueMentions) }], + }), + ), + ), + ) + }) diff --git a/packages/atom/effect-atom/tests/hydration.conformance.test.ts b/packages/atom/effect-atom/tests/hydration.conformance.test.ts new file mode 100644 index 0000000..de75d81 --- /dev/null +++ b/packages/atom/effect-atom/tests/hydration.conformance.test.ts @@ -0,0 +1,114 @@ +import { Conformance } from '@systemfsoftware/conformance-spec' +import { Atom } from '@systemfsoftware/effect-atom' +import { Gherkin, Given, it, makeFeature, Then, When } from '@systemfsoftware/effect-gherkin-spec' +import { Context, Deferred, Effect, Fiber, Layer, Match, Option, Ref, Schema } from 'effect' + +import { HydrationCommand, hydrationModel } from './__fixtures__/hydration.model.js' + +/** The budget the sequential check is given: every one of these histories is checked. */ +const HYDRATION_ROUNDS = 50 + +const Feature = makeFeature({ it }) + +interface HydrationPages { + readonly savedPage: Atom.Registry.Registry + readonly reloadedPage: Atom.Registry.Registry + readonly source: Deferred.Deferred + readonly stillLoading: Atom.Atom> + readonly saved: Ref.Ref>> + readonly applied: Ref.Ref>> +} + +class Pages extends Context.Service()( + '@systemfsoftware/effect-atom/tests/hydration.conformance.test/Pages', +) {} + +const pagesLayer: Layer.Layer = Layer.effect( + Pages, + Effect.gen(function*() { + const source = Deferred.makeUnsafe() + const stillLoading = Atom.make(Deferred.await(source)).pipe( + Atom.serializable({ + key: 'k-pending', + schema: Atom.AsyncResult.Schema({ success: Schema.Finite }), + }), + ) + const savedPage = Atom.Registry.make() + Atom.Registry.subscribe(savedPage, stillLoading, () => {}) + const saved = yield* Ref.make(Option.none>()) + const applied = yield* Ref.make(Option.none>()) + return { savedPage, reloadedPage: Atom.Registry.make(), source, stillLoading, saved, applied } + }), +).pipe(Layer.orDie) + +const shownBy = (handle: HydrationPages): ReadonlyArray => { + const result = Atom.Registry.get(handle.reloadedPage, handle.stillLoading) + return Atom.AsyncResult.isSuccess(result) ? [result.value] : [] +} + +const runHydrationCommand = ( + command: HydrationCommand, +): Effect.Effect | undefined, never, Pages> => + Effect.flatMap(Pages, (handle) => + Match.value(command).pipe( + Match.tagsExhaustive({ + Save: () => + Effect.gen(function*() { + const entries = Atom.Hydration.dehydrate(handle.savedPage, { encodeInitialAs: 'deferred' }) + yield* Ref.set(handle.saved, Option.some(entries)) + return [entries.length] + }), + Reload: () => + Effect.gen(function*() { + const entries = Option.getOrThrow(yield* Ref.get(handle.saved)) + const pending = Atom.Hydration.hydrate(handle.reloadedPage, entries) + yield* Ref.set(handle.applied, Option.some(pending)) + return undefined + }), + Resolve: () => + Effect.gen(function*() { + yield* Deferred.succeed(handle.source, 42) + const pending = Option.getOrThrow(yield* Ref.get(handle.applied)) + yield* Fiber.join(pending) + return shownBy(handle) + }), + Read: () => Effect.sync(() => shownBy(handle)), + }), + )) + +const hydrationCheck = ( + subject: Layer.Layer, + spec: { readonly sequences: number; readonly operations: number }, +) => + Conformance.sequential(subject, { + commands: HydrationCommand, + model: hydrationModel, + run: runHydrationCommand, + sequences: spec.sequences, + operations: spec.operations, + }) + +Feature('Saving a still-loading value and filling it in on a reloaded page', { timeout: 0 }) + .withLayer(Layer.empty) + .live('each scenario drives the simulation kernel itself, and a conformance check cannot run inside a kernel run') + .body(({ scenario }) => { + scenario( + 'A page saved while a value is still loading shows it loading, then fills it in once it finishes', + Gherkin.Do.pipe( + Given('a page holding a value that is still loading, and a page to reload it into')( + 'subject', + () => Effect.succeed(pagesLayer), + ), + When('fifty rounds of saving, reloading, finishing the value, and reading it are replayed')( + 'report', + (s) => hydrationCheck(s.subject, { sequences: HYDRATION_ROUNDS, operations: 8 }), + ), + Then('the reloaded page shows the value loading until it finishes, then shows the finished value')(( + s, + expect, + ) => + expect(s.report, Conformance.render(s.report)).toMatchObject({ _tag: 'Pass', histories: HYDRATION_ROUNDS }) + ), + ), + ) + }) diff --git a/packages/atom/effect-atom/tests/registry-batching.differential.test.ts b/packages/atom/effect-atom/tests/registry-batching.differential.test.ts new file mode 100644 index 0000000..db20571 --- /dev/null +++ b/packages/atom/effect-atom/tests/registry-batching.differential.test.ts @@ -0,0 +1,33 @@ +import { Metamorphic } from '@systemfsoftware/differential-spec' +import { observed, programs, unbatched } from './__fixtures__/registry-program.fixture.js' + +const mirrorRead = 'read 6:' + +const heardLines = (log: ReadonlyArray): ReadonlyArray => + log.filter((line) => line.startsWith('heard ')) + +/** + * Values that only writes decide. The mirror is written by a listener, and a + * listener runs after the batch that woke it, so a mirror read inside a batch + * rightly differs from the same read in the split program. + */ +const writeDecidedReads = (log: ReadonlyArray): ReadonlyArray => + log.filter((line) => line.startsWith('read ') && !line.startsWith(mirrorRead)) + +Metamorphic.on({ name: 'a batched program decides writes the same as the split program', system: observed }) + .relation({ + transformInput: unbatched, + assertOutput: (batched, split) => { + const expected = writeDecidedReads(batched) + const actual = writeDecidedReads(split) + return expected.length === actual.length && expected.every((line, index) => line === actual[index]) + }, + }) + .on(programs, { runBudget: 500 }) + +Metamorphic.on({ name: 'batching never lets a listener hear more than the split program', system: observed }) + .relation({ + transformInput: unbatched, + assertOutput: (batched, split) => heardLines(batched).length <= heardLines(split).length, + }) + .on(programs, { runBudget: 500 }) diff --git a/packages/atom/effect-atom/tests/registry-model.differential.test.ts b/packages/atom/effect-atom/tests/registry-model.differential.test.ts new file mode 100644 index 0000000..3f3ac5d --- /dev/null +++ b/packages/atom/effect-atom/tests/registry-model.differential.test.ts @@ -0,0 +1,10 @@ +import { Differential } from '@systemfsoftware/differential-spec' +import { observed, programs, specified } from './__fixtures__/registry-program.fixture.js' + +Differential.compare({ + name: 'the registry matches its consumer model over generated programs', + reference: specified, + candidate: observed, +}) + .on(programs, { runBudget: 500 }) + .assert((expected, actual) => expected.length === actual.length && expected.every((line, i) => line === actual[i])) diff --git a/packages/atom/effect-atom/tests/registry.conformance.test.ts b/packages/atom/effect-atom/tests/registry.conformance.test.ts new file mode 100644 index 0000000..aa6ffe8 --- /dev/null +++ b/packages/atom/effect-atom/tests/registry.conformance.test.ts @@ -0,0 +1,758 @@ +import { Conformance } from '@systemfsoftware/conformance-spec' +import { Atom } from '@systemfsoftware/effect-atom' +import { Gherkin, Given, it, makeFeature, Then, When } from '@systemfsoftware/effect-gherkin-spec' +import { + Clock, + Context, + Effect, + Exit, + Fiber, + Layer, + Match, + Option, + Ref, + Scheduler, + Schema, + Scope, + Stream, +} from 'effect' + +import { + ContextStreamCommand, + contextStreamModel, + DerivedCommand, + derivedModel, + doubled, + LifetimeCommand, + lifetimeModel, + RegistryCommand, + registryModel, + StreamCommand, + streamModel, + SubscriptionCommand, + subscriptionModel, +} from './__fixtures__/registry.model.js' + +const Feature = makeFeature({ it }) + +interface KernelPorts { + readonly now: () => number + readonly scheduleTask: (task: () => void) => () => void + readonly scheduleTimer: (task: () => void, delayMillis: number) => () => void +} + +const cancelledTask = (): void => {} + +const sleptTask = (clock: Clock.Clock, task: () => void, delayMillis: number) => + Effect.provideService( + Effect.andThen(Effect.sleep(delayMillis), Effect.sync(task)), + Clock.Clock, + clock, + ) + +const timerFor = ( + clock: Clock.Clock, + scheduler: Scheduler.Scheduler, + task: () => void, + delayMillis: number, +): () => void => { + const fiber = Effect.runFork(sleptTask(clock, task, delayMillis), { scheduler }) + return () => { + Effect.runFork(Fiber.interrupt(fiber), { scheduler }) + } +} + +const kernelPorts = (clock: Clock.Clock, scheduler: Scheduler.Scheduler): KernelPorts => { + const dispatcher = scheduler.makeDispatcher() + return { + now: () => clock.currentTimeMillisUnsafe(), + scheduleTask: (task) => { + dispatcher.scheduleTask(task, 0) + return cancelledTask + }, + scheduleTimer: (task, delayMillis) => timerFor(clock, scheduler, task, delayMillis), + } +} + +const portsFromRun = Effect.gen(function*() { + const clock = yield* Clock.Clock + const scheduler = yield* Scheduler.Scheduler + return kernelPorts(clock, scheduler) +}) + +interface RegistryGraph { + readonly registry: Atom.Registry.Registry + readonly source: Atom.Writable + readonly derived: Atom.Atom +} + +class Graph + extends Context.Service()('@systemfsoftware/effect-atom/tests/registry.conformance.test/Graph') +{} + +const freshGraph = (ports: KernelPorts): RegistryGraph => { + const source = Atom.keepAlive(Atom.make(0)) + const derived = Atom.readable((get) => doubled(get(source))) + return { registry: Atom.Registry.make(ports), source, derived } +} + +const scopedRegistry = ( + keeper: Context.Key, + acquire: Effect.Effect, never, never>, +): Effect.Effect, never, Scope.Scope> => + Effect.acquireRelease( + acquire, + (context) => Effect.sync(() => Atom.Registry.dispose(Context.get(context, keeper).registry)), + ) + +const graphLayer: Layer.Layer = Layer.unwrap( + Effect.map( + portsFromRun, + (ports) => Layer.effectContext(scopedRegistry(Graph, Effect.sync(() => Context.make(Graph, freshGraph(ports))))), + ), +) + +const increasedBy = (value: number): [number, number] => [value, value + 1] + +const writtenThrough = (graph: RegistryGraph, command: RegistryCommand): number | undefined => + Match.value(command).pipe( + Match.tagsExhaustive({ + GetSource: () => Atom.Registry.get(graph.registry, graph.source), + GetDerived: () => Atom.Registry.get(graph.registry, graph.derived), + SetSource: (set) => { + Atom.Registry.set(graph.registry, graph.source, set.value) + return undefined + }, + UpdateSource: (update) => { + Atom.Registry.update(graph.registry, graph.source, (value) => value + update.by) + return undefined + }, + ModifySource: () => Atom.Registry.modify(graph.registry, graph.source, increasedBy), + RefreshDerived: () => { + Atom.Registry.refresh(graph.registry, graph.derived) + return undefined + }, + Reset: () => { + Atom.Registry.reset(graph.registry) + return undefined + }, + }), + ) + +const runRegistryCommand = (command: RegistryCommand): Effect.Effect => + Effect.flatMap(Graph, (graph) => Effect.sync(() => writtenThrough(graph, command))) + +const CONCURRENCY_BOUND = { + preemptions: 2, + maxSchedules: 200_000, +} as const + +const registryCheck = ( + subject: Layer.Layer, + spec: { readonly fibers: number; readonly operations: number }, +) => + Conformance.linearizable(subject, { + commands: RegistryCommand, + model: registryModel, + run: runRegistryCommand, + fibers: spec.fibers, + operations: spec.operations, + ...CONCURRENCY_BOUND, + }) + +const derivedCheck = ( + subject: Layer.Layer, + spec: { readonly fibers: number; readonly operations: number }, +) => + Conformance.linearizable(subject, { + commands: DerivedCommand, + model: derivedModel, + run: runRegistryCommand, + fibers: spec.fibers, + operations: spec.operations, + ...CONCURRENCY_BOUND, + }) + +interface SubscriptionHandle { + readonly registry: Atom.Registry.Registry + readonly source: Atom.Writable + readonly delivered: Array + readonly subscribe: () => ReadonlyArray + readonly setSource: (value: number) => ReadonlyArray +} + +class Subscriptions extends Context.Service()( + '@systemfsoftware/effect-atom/tests/registry.conformance.test/Subscriptions', +) {} + +const freshSubscription = (ports: KernelPorts): SubscriptionHandle => { + const source = Atom.make(0) + const registry = Atom.Registry.make(ports) + const delivered: Array = [] + return { + registry, + source, + delivered, + subscribe: () => { + Atom.Registry.subscribe(registry, source, (value) => { + delivered.push(value) + }) + return [...delivered] + }, + setSource: (value) => { + Atom.Registry.set(registry, source, value) + return [...delivered] + }, + } +} + +const subscriptionLayer: Layer.Layer = Layer.unwrap( + Effect.map( + portsFromRun, + (ports) => + Layer.effectContext( + scopedRegistry(Subscriptions, Effect.sync(() => Context.make(Subscriptions, freshSubscription(ports)))), + ), + ), +) + +const notifiedThrough = (handle: SubscriptionHandle, command: SubscriptionCommand): ReadonlyArray => + Match.value(command).pipe( + Match.tagsExhaustive({ + Subscribe: () => handle.subscribe(), + SetSource: (set) => handle.setSource(set.value), + }), + ) + +const runSubscriptionCommand = ( + command: SubscriptionCommand, +): Effect.Effect, never, Subscriptions> => + Effect.flatMap(Subscriptions, (handle) => Effect.sync(() => notifiedThrough(handle, command))) + +const subscriptionCheck = ( + subject: Layer.Layer, + spec: { readonly sequences: number; readonly operations: number }, +) => + Conformance.sequential(subject, { + commands: SubscriptionCommand, + model: subscriptionModel, + run: runSubscriptionCommand, + sequences: spec.sequences, + operations: spec.operations, + }) + +interface LifetimeHandle { + readonly registry: Atom.Registry.Registry + readonly atom: Atom.Atom + readonly mount: () => void + readonly unmount: () => void + readonly observe: () => boolean +} + +class Lifetimes extends Context.Service()( + '@systemfsoftware/effect-atom/tests/registry.conformance.test/Lifetimes', +) {} + +const IDLE_MILLIS = 10 +const RESOLUTION_MILLIS = 10 +const DEADLINE_MILLIS = 1000 + +const freshLifetime = (ports: KernelPorts): LifetimeHandle => { + const atom = Atom.setIdleTTL(IDLE_MILLIS)(Atom.make(0)) + const registry = Atom.Registry.make({ ...ports, timeoutResolution: RESOLUTION_MILLIS }) + let release: (() => void) | undefined = undefined + return { + registry, + atom, + mount: () => { + release = Atom.Registry.subscribe(registry, atom, () => {}, { immediate: true }) + }, + unmount: () => { + if (release !== undefined) { + release() + } + }, + observe: () => Option.isSome(Atom.Registry.getRaw(registry, atom)), + } +} + +const lifetimeLayer: Layer.Layer = Layer.unwrap( + Effect.map( + portsFromRun, + (ports) => + Layer.effectContext( + scopedRegistry(Lifetimes, Effect.sync(() => Context.make(Lifetimes, freshLifetime(ports)))), + ), + ), +) + +const livedThrough = ( + handle: LifetimeHandle, + command: LifetimeCommand, +): Effect.Effect => + Match.value(command).pipe( + Match.tagsExhaustive({ + Mount: () => Effect.sync(() => handle.mount()), + Unmount: () => Effect.sync(() => handle.unmount()), + AwaitDeadline: () => Effect.sleep(DEADLINE_MILLIS), + Observe: () => Effect.sync(() => handle.observe()), + }), + ) + +const runLifetimeCommand = ( + command: LifetimeCommand, +): Effect.Effect => + Effect.flatMap(Lifetimes, (handle) => livedThrough(handle, command)) +const lifetimeCheck = ( + subject: Layer.Layer, + spec: { readonly sequences: number; readonly operations: number }, +) => + Conformance.sequential(subject, { + commands: LifetimeCommand, + model: lifetimeModel, + run: runLifetimeCommand, + sequences: spec.sequences, + operations: spec.operations, + }) + +interface StreamHandle { + readonly registry: Atom.Registry.Registry + readonly value: Atom.Writable +} + +class Streams extends Context.Service()( + '@systemfsoftware/effect-atom/tests/registry.conformance.test/Streams', +) {} + +const freshStream = (ports: KernelPorts): StreamHandle => ({ + registry: Atom.Registry.make(ports), + value: Atom.keepAlive(Atom.make(1)), +}) + +const streamLayer: Layer.Layer = Layer.unwrap( + Effect.map( + portsFromRun, + (ports) => + Layer.effectContext(scopedRegistry(Streams, Effect.sync(() => Context.make(Streams, freshStream(ports))))), + ), +) + +const readCurrent = (handle: StreamHandle): Effect.Effect> => + Effect.scoped( + Stream.runCollect(Atom.Registry.toStream(handle.registry, handle.value).pipe(Stream.take(1))), + ) + +const runStreamCommand = ( + command: StreamCommand, +): Effect.Effect | undefined, never, Streams> => + Effect.flatMap(Streams, (handle) => + Match.value(command).pipe( + Match.tagsExhaustive({ + SetSource: (set) => + Effect.as(Effect.sync(() => Atom.Registry.set(handle.registry, handle.value, set.value)), undefined), + ReadCurrent: () => readCurrent(handle), + }), + )) + +const streamCheck = ( + subject: Layer.Layer, + spec: { readonly sequences: number; readonly operations: number }, +) => + Conformance.sequential(subject, { + commands: StreamCommand, + model: streamModel, + run: runStreamCommand, + sequences: spec.sequences, + operations: spec.operations, + }) + +interface ContextStreamHandle { + readonly registry: Atom.Registry.Registry + readonly result: Atom.Writable> + readonly stream: Atom.Atom> +} + +class ContextStreams extends Context.Service()( + '@systemfsoftware/effect-atom/tests/registry.conformance.test/ContextStreams', +) {} + +const freshContextStream = (ports: KernelPorts): ContextStreamHandle => { + const result = Atom.keepAlive(Atom.make>(Atom.AsyncResult.success(3))) + const stream = Atom.keepAlive( + Atom.readable((get) => { + get(result) + return get.streamResult(result) + }), + ) + return { registry: Atom.Registry.make(ports), result, stream } +} + +const contextStreamLayer: Layer.Layer = Layer.unwrap( + Effect.map( + portsFromRun, + (ports) => + Layer.effectContext( + scopedRegistry(ContextStreams, Effect.sync(() => Context.make(ContextStreams, freshContextStream(ports)))), + ), + ), +) + +const readSettled = (handle: ContextStreamHandle): Effect.Effect> => + Effect.scoped(Stream.runCollect(Atom.Registry.get(handle.registry, handle.stream).pipe(Stream.take(1)))) + +const runContextStreamCommand = ( + command: ContextStreamCommand, +): Effect.Effect | undefined, never, ContextStreams> => + Effect.flatMap(ContextStreams, (handle) => + Match.value(command).pipe( + Match.tagsExhaustive({ + Settle: (settle) => + Effect.as( + Effect.sync(() => + Atom.Registry.set(handle.registry, handle.result, Atom.AsyncResult.success(settle.value)) + ), + undefined, + ), + ReadSettled: () => readSettled(handle), + }), + )) + +const contextStreamCheck = ( + subject: Layer.Layer, + spec: { readonly sequences: number; readonly operations: number }, +) => + Conformance.sequential(subject, { + commands: ContextStreamCommand, + model: contextStreamModel, + run: runContextStreamCommand, + sequences: spec.sequences, + operations: spec.operations, + }) + +const listenersOn = (registry: Atom.Registry.Registry, atom: Atom.Atom): number => + Atom.Registry.getNodes(registry).get(atom)?.listeners.size ?? 0 + +const stillSubscribed = ( + captured: Ref.Ref>, + atom: Atom.Atom, + what: string, +): Effect.Effect => + Effect.flatMap(Ref.get(captured), (held) => + Option.match(held, { + onNone: () => Effect.void, + onSome: (registry) => + listenersOn(registry, atom) > 0 + ? Effect.die(new Error(`a reader is still subscribed to ${what} after letting go`)) + : Effect.void, + })) + +const streamedOnce = ( + captured: Ref.Ref>, + value: Atom.Atom, +): Effect.Effect => + Effect.gen(function*() { + const registry = Atom.Registry.make() + yield* Ref.set(captured, Option.some(registry)) + yield* Effect.scoped(Stream.runDrain(Atom.Registry.toStream(registry, value).pipe(Stream.take(1)))) + }) + +const mountedOnce = ( + captured: Ref.Ref>, + value: Atom.Atom, +): Effect.Effect => + Effect.gen(function*() { + const registry = Atom.Registry.make() + yield* Ref.set(captured, Option.some(registry)) + yield* Effect.scoped(Atom.Registry.mount(registry, value)) + }) + +class Provided extends Context.Service()( + '@systemfsoftware/effect-atom/tests/registry.conformance.test/Provided', +) {} + +const providedOnce = (captured: Ref.Ref>): Effect.Effect => + Effect.provide( + Effect.gen(function*() { + const registry = yield* Provided + yield* Ref.set(captured, Option.some(registry)) + }), + Atom.Registry.layer(Provided), + ) + +const registryDisposed = (captured: Ref.Ref>): Effect.Effect => + Effect.flatMap(Ref.get(captured), (held) => + Option.match(held, { + onNone: () => Effect.void, + onSome: (registry) => + Effect.flatMap( + Effect.exit(Effect.try(() => Atom.Registry.get(registry, Atom.make(0)))), + (exit) => + Exit.isFailure(exit) + ? Effect.void + : Effect.die(new Error('a provided registry still answers after its scope closed')), + ), + })) + +const runningRun = ( + live: Ref.Ref, + started: Ref.Ref, + input: number, +): Effect.Effect => + Effect.acquireRelease( + Effect.andThen( + Ref.update(started, (count) => count + 1), + Ref.update(live, (count) => count + 1), + ), + () => Ref.update(live, (count) => count - 1), + ).pipe(Effect.andThen(Effect.never), Effect.as(input)) + +interface RunHandle { + readonly live: Ref.Ref + readonly started: Ref.Ref +} + +const freshRun = (): RunHandle => ({ live: Ref.makeUnsafe(0), started: Ref.makeUnsafe(0) }) + +const forkedRuns = (handle: RunHandle): Effect.Effect => + Effect.acquireRelease( + Effect.sync(() => Atom.Registry.make()), + (registry) => Effect.sync(() => Atom.Registry.dispose(registry)), + ).pipe( + Effect.flatMap((registry) => + Effect.gen(function*() { + const task = Atom.fn((input: number) => runningRun(handle.live, handle.started, input), { concurrent: true }) + const release = Atom.Registry.subscribe(registry, task, () => {}, { immediate: true }) + yield* Effect.sync(() => { + Atom.Registry.set(registry, task, 1) + Atom.Registry.set(registry, task, 2) + Atom.Registry.set(registry, task, 3) + }) + yield* Effect.yieldNow + yield* Effect.sync(() => release()) + }) + ), + ) + +const runsReleasedTogether = (handle: RunHandle): Effect.Effect => + Effect.gen(function*() { + const started = yield* Ref.get(handle.started) + if (started < 1) { + return yield* Effect.die(new Error('no run ever started, so the release proves nothing')) + } + const live = yield* Ref.get(handle.live) + if (live !== 0) { + return yield* Effect.die(new Error(`expected no run left running, but ${live} still ran`)) + } + }) + +/** The budget the sequential check is given: every one of these histories is checked. */ +const SUBSCRIPTION_ROUNDS = 50 +const LIFETIME_ROUNDS = 60 +const STREAM_ROUNDS = 50 +const CONTEXT_STREAM_ROUNDS = 50 + +Feature('A registry that keeps readers, writers, listeners, and idle entries consistent', { timeout: 0 }) + .withLayer(Layer.empty) + .live('each scenario drives the simulation kernel itself, and a conformance check cannot run inside a kernel run') + .body(({ scenario, scenarioOutline }) => { + scenarioOutline( + ' reading and writing one counter always match them taking turns one after the other', + [ + { callers: 2, operations: 4, writers: 'Ada and Bo' }, + { callers: 3, operations: 1, writers: 'Ada, Bo, and Cy' }, + ] as const, + (row) => + Gherkin.Do.pipe( + Given('a counter starting at 0 and a reader showing double the counter')( + 'subject', + () => Effect.succeed(graphLayer), + ), + When(`${row.writers} read the counter and write new values`)( + 'report', + (s) => registryCheck(s.subject, { fibers: row.callers, operations: row.operations }), + ), + Then(`every interleaving matches ${row.writers} taking turns one after the other`)((s, expect) => + expect(s.report, Conformance.render(s.report)).toMatchObject({ _tag: 'Pass' }) + ), + ), + ) + + scenario( + 'A doubling reader only ever shows 0, 2, or 4 while Ada and Bo write', + Gherkin.Do.pipe( + Given('a counter starting at 0 and a reader showing double the counter')( + 'subject', + () => Effect.succeed(graphLayer), + ), + When('Ada writes 1 and Bo writes 2 while the doubled counter is read')( + 'report', + (s) => derivedCheck(s.subject, { fibers: 2, operations: 2 }), + ), + Then( + 'the reader only ever shows 0, 2, or 4, and every interleaving matches Ada and Bo taking turns one after the other', + )( + (s, expect) => expect(s.report, Conformance.render(s.report)).toMatchObject({ _tag: 'Pass' }), + ), + ), + ) + + scenario( + 'A listener subscribed before a write hears the write exactly once', + Gherkin.Do.pipe( + Given('a counter starting at 0 with a listener ready to subscribe')( + 'subject', + () => Effect.succeed(subscriptionLayer), + ), + When('the listener subscribes and fifty rounds of writes arrive')( + 'report', + (s) => subscriptionCheck(s.subject, { sequences: SUBSCRIPTION_ROUNDS, operations: 8 }), + ), + Then('every write is heard exactly once, in the order it happened')((s, expect) => + expect(s.report, Conformance.render(s.report)).toMatchObject({ _tag: 'Pass', histories: SUBSCRIPTION_ROUNDS }) + ), + ), + ) + + scenario( + 'An entry kept for a while after its last reader leaves is gone once the wait passes', + Gherkin.Do.pipe( + Given('an entry mounted with a reader holding it')( + 'subject', + () => Effect.succeed(lifetimeLayer), + ), + When( + 'sixty rounds of mounting, letting the reader leave, waiting past the keep-alive, and checking the entry are run', + )( + 'report', + (s) => lifetimeCheck(s.subject, { sequences: LIFETIME_ROUNDS, operations: 12 }), + ), + Then('a released entry is gone once the wait passes, and a held entry is never gone')( + (s, expect) => + expect(s.report, Conformance.render(s.report)).toMatchObject({ _tag: 'Pass', histories: LIFETIME_ROUNDS }), + ), + ), + ) + + scenario( + 'A reader that follows a value as a stream starts at the value the registry currently holds', + Gherkin.Do.pipe( + Given('a value starting at 1 that a registry holds')( + 'subject', + () => Effect.succeed(streamLayer), + ), + When('fifty rounds of setting the value and reading it as a stream are replayed')( + 'report', + (s) => streamCheck(s.subject, { sequences: CONTEXT_STREAM_ROUNDS, operations: 10 }), + ), + Then('every read starts at the value the registry currently holds')((s, expect) => + expect(s.report, Conformance.render(s.report)).toMatchObject({ _tag: 'Pass', histories: STREAM_ROUNDS }) + ), + ), + ) + + scenario( + 'A reader that follows a settled result as a stream hears it right away', + Gherkin.Do.pipe( + Given('a settled result a registry exposes as a stream')( + 'subject', + () => Effect.succeed(contextStreamLayer), + ), + When('fifty rounds of settling the result and reading the stream are replayed')( + 'report', + (s) => contextStreamCheck(s.subject, { sequences: STREAM_ROUNDS, operations: 8 }), + ), + Then('the reader hears the settled value each time')((s, expect) => + expect(s.report, Conformance.render(s.report)).toMatchObject({ + _tag: 'Pass', + histories: CONTEXT_STREAM_ROUNDS, + }) + ), + ), + ) + + scenario( + 'A reader that follows a value as a stream stops listening once it lets go', + Gherkin.Do.pipe( + Given('a value a registry can stream')('held', () => + Effect.map( + Ref.make(Option.none()), + (captured) => ({ captured, value: Atom.keepAlive(Atom.make(1)) }), + )), + When('a stream of the value is read and the reader lets go, stopped at each step')( + 'checked', + (s) => + Conformance.released(streamedOnce(s.held.captured, s.held.value), { + probe: stillSubscribed(s.held.captured, s.held.value, 'a streamed value'), + }), + ), + Then('nobody is left subscribed to the value')((s, expect) => + expect(s.checked, Conformance.render(s.checked)).toMatchObject({ _tag: 'Pass' }) + ), + ), + ) + + scenario( + 'A value held open for a scope stops being held once the scope closes', + Gherkin.Do.pipe( + Given('a value a registry holds')('held', () => + Effect.map( + Ref.make(Option.none()), + (captured) => ({ captured, value: Atom.keepAlive(Atom.make(1)) }), + )), + When('the value is held open for a scope that is closed at each step')( + 'checked', + (s) => + Conformance.released(mountedOnce(s.held.captured, s.held.value), { + probe: stillSubscribed(s.held.captured, s.held.value, 'a held value'), + }), + ), + Then('nobody is left subscribed to the value')((s, expect) => + expect(s.checked, Conformance.render(s.checked)).toMatchObject({ _tag: 'Pass' }) + ), + ), + ) + + scenario( + 'A registry provided for a name is thrown away once its scope closes', + Gherkin.Do.pipe( + Given('somewhere to remember a provided registry')( + 'provided', + () => Effect.map(Ref.make(Option.none()), (captured) => ({ captured })), + ), + When('a registry is provided for a name and its scope is closed at each step')( + 'checked', + (s) => + Conformance.released(providedOnce(s.provided.captured), { + probe: registryDisposed(s.provided.captured), + }), + ), + Then('the provided registry no longer answers')((s, expect) => + expect(s.checked, Conformance.render(s.checked)).toMatchObject({ _tag: 'Pass' }) + ), + ), + ) + + scenario( + 'A computation still running when its owner lets go is left with nothing running', + Gherkin.Do.pipe( + Given('counters for runs that are running and runs that have started')('runs', () => Effect.sync(freshRun)), + When('the computation is asked to run three times at once and then let go, stopped at each step')( + 'checked', + (s) => Conformance.released(forkedRuns(s.runs), { probe: runsReleasedTogether(s.runs) }), + ), + Then( + 'nothing is left running once the owner lets go, and at least one run started so the release proves something', + )( + (s, expect) => + Effect.map( + Ref.get(s.runs.started), + (started) => + expect({ report: s.checked, started }, Conformance.render(s.checked)).toMatchObject({ + report: { _tag: 'Pass' }, + started: expect.schemaMatching(Schema.Int.pipe(Schema.check(Schema.isGreaterThan(0)))), + }), + ), + ), + ), + ) + }) diff --git a/packages/atom/effect-atom/tsconfig.api.json b/packages/atom/effect-atom/tsconfig.api.json new file mode 100644 index 0000000..3fa8bb8 --- /dev/null +++ b/packages/atom/effect-atom/tsconfig.api.json @@ -0,0 +1,9 @@ +{ + "extends": "@systemfsoftware/tsconfig/tsc/dom/library-monorepo", + "include": [ + "dist/mod.d.ts" + ], + "compilerOptions": { + "customConditions": [] + } +} diff --git a/packages/atom/effect-atom/tsconfig.app.json b/packages/atom/effect-atom/tsconfig.app.json new file mode 100644 index 0000000..dec646c --- /dev/null +++ b/packages/atom/effect-atom/tsconfig.app.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": [ + "@systemfsoftware/tsconfig/tsc/dom/library-monorepo", + "@systemfsoftware/tsconfig/effect" + ], + "compilerOptions": { + "composite": true, + "tsBuildInfoFile": "node_modules/.cache/tsconfig.app.tsbuildinfo", + "types": [], + "customConditions": [ + "@systemfsoftware/source" + ], + "emitDeclarationOnly": true, + "outDir": "node_modules/.cache/tsc/app" + }, + "include": ["src"] +} diff --git a/packages/atom/effect-atom/tsconfig.build.json b/packages/atom/effect-atom/tsconfig.build.json new file mode 100644 index 0000000..38f293d --- /dev/null +++ b/packages/atom/effect-atom/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.app.json", + "compilerOptions": { + "declarationMap": false, + "sourceMap": false, + "stripInternal": true + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts", "src/**/*.spec.ts"] +} diff --git a/packages/atom/effect-atom/tsconfig.dts.json b/packages/atom/effect-atom/tsconfig.dts.json new file mode 100644 index 0000000..760bc07 --- /dev/null +++ b/packages/atom/effect-atom/tsconfig.dts.json @@ -0,0 +1,5 @@ +{ + "extends": "@systemfsoftware/tsconfig/tsc/dom/library-monorepo", + "compilerOptions": { "skipLibCheck": false, "types": ["node"] }, + "include": ["dist/*.d.ts"] +} diff --git a/packages/atom/effect-atom/tsconfig.json b/packages/atom/effect-atom/tsconfig.json new file mode 100644 index 0000000..38412bc --- /dev/null +++ b/packages/atom/effect-atom/tsconfig.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "files": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.test.json" + }, + { + "path": "./tsconfig.tstyche.json" + }, + { + "path": "./tsconfig.node.json" + } + ] +} diff --git a/packages/atom/effect-atom/tsconfig.node.json b/packages/atom/effect-atom/tsconfig.node.json new file mode 100644 index 0000000..cc4af84 --- /dev/null +++ b/packages/atom/effect-atom/tsconfig.node.json @@ -0,0 +1,15 @@ +{ + "extends": "@systemfsoftware/tsconfig/node", + "compilerOptions": { + "composite": true, + "noEmit": true, + "tsBuildInfoFile": "node_modules/.cache/tsconfig.node.tsbuildinfo" + }, + "include": [ + "oxlint.config.ts", + "stryker.config.ts", + "tsdown.config.ts", + "vitest.config.ts", + "vitest.node.config.ts" + ] +} diff --git a/packages/atom/effect-atom/tsconfig.test.json b/packages/atom/effect-atom/tsconfig.test.json new file mode 100644 index 0000000..902c939 --- /dev/null +++ b/packages/atom/effect-atom/tsconfig.test.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": [ + "@systemfsoftware/tsconfig/tsc/dom/library-monorepo", + "@systemfsoftware/tsconfig/effect/entrypoint" + ], + "compilerOptions": { + "composite": true, + "tsBuildInfoFile": "node_modules/.cache/tsconfig.test.tsbuildinfo", + "customConditions": [ + "@systemfsoftware/source" + ], + "emitDeclarationOnly": true, + "outDir": "node_modules/.cache/tsc/test" + }, + "include": ["tests"], + "references": [ + { + "path": "./tsconfig.app.json" + } + ] +} diff --git a/packages/atom/effect-atom/tsconfig.tstyche.json b/packages/atom/effect-atom/tsconfig.tstyche.json new file mode 100644 index 0000000..750eb5d --- /dev/null +++ b/packages/atom/effect-atom/tsconfig.tstyche.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": [ + "@systemfsoftware/tsconfig/tsc/dom/library-monorepo", + "@systemfsoftware/tsconfig/effect/entrypoint" + ], + "compilerOptions": { + "composite": true, + "noEmit": true, + "tsBuildInfoFile": "node_modules/.cache/tsconfig.tstyche.tsbuildinfo", + "customConditions": [ + "@systemfsoftware/source" + ] + }, + "include": [ + "test-types" + ], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.test.json" + } + ] +} diff --git a/packages/atom/effect-atom/tsdown.config.ts b/packages/atom/effect-atom/tsdown.config.ts new file mode 100644 index 0000000..35dd759 --- /dev/null +++ b/packages/atom/effect-atom/tsdown.config.ts @@ -0,0 +1,41 @@ +import { quietBuild } from '@systemfsoftware/tsdown-config/quiet-build' +import { defineConfig } from 'tsdown' + +type ExportEntry = string | Record + +const typesMap: Record = { + '.': './dist/mod.d.ts', +} + +const injectTypes = (exports: Record): Record => { + for (const [subpath, types] of Object.entries(typesMap)) { + const entry = exports[subpath] + if (typeof entry === 'string') { + exports[subpath] = { types, default: entry } + } else if (typeof entry === 'object' && Boolean(entry)) { + const { default: defaultEntry, types: _existingTypes, ...rest } = entry + let withDefault: Record = {} + if (typeof defaultEntry === 'string') { + withDefault = { default: defaultEntry } + } + exports[subpath] = { ...rest, types, ...withDefault } + } + } + return exports +} + +export default defineConfig({ + ...quietBuild, + entry: { + mod: './src/mod.ts', + }, + format: 'esm', + dts: true, + exports: { + devExports: '@systemfsoftware/source', + customExports: injectTypes, + }, + outExtensions: () => ({ js: '.mjs', dts: '.d.ts' }), + deps: { onlyBundle: false }, + tsconfig: './tsconfig.build.json', +}) diff --git a/packages/atom/effect-atom/tstyche.json b/packages/atom/effect-atom/tstyche.json new file mode 100644 index 0000000..54cdbda --- /dev/null +++ b/packages/atom/effect-atom/tstyche.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/tstyche/schemas/config.json", + "rejectAnyType": true, + "rejectNeverType": true, + "testFileMatch": [ + "test-types/**/*.tst.ts" + ] +} diff --git a/packages/atom/effect-atom/vitest.config.ts b/packages/atom/effect-atom/vitest.config.ts new file mode 100644 index 0000000..cc4f03e --- /dev/null +++ b/packages/atom/effect-atom/vitest.config.ts @@ -0,0 +1,34 @@ +import { inlineSchemaTests } from '@systemfsoftware/effect-schema-vite' +import { defineConfig, sharedConfig } from '@systemfsoftware/vitest-config' +import { playwright } from '@vitest/browser-playwright' +import { nodeTest } from './vitest.node.config.js' + +export default defineConfig({ + ...sharedConfig, + plugins: [inlineSchemaTests()], + test: { + ...sharedConfig.test, + projects: [ + { extends: true, test: nodeTest }, + { + extends: true, + test: { + name: 'browser', + include: ['./tests/browser/**/*.test.ts'], + browser: { + enabled: true, + provider: playwright(), + headless: true, + instances: [{ browser: 'chromium' }], + }, + }, + }, + ], + coverage: { + ...sharedConfig.test?.coverage, + provider: 'istanbul', + include: ['src/**/*.ts'], + exclude: ['src/**/*.test.ts'], + }, + }, +}) diff --git a/packages/atom/effect-atom/vitest.node.config.ts b/packages/atom/effect-atom/vitest.node.config.ts new file mode 100644 index 0000000..9ed742a --- /dev/null +++ b/packages/atom/effect-atom/vitest.node.config.ts @@ -0,0 +1,30 @@ +import { inlineSchemaTests } from '@systemfsoftware/effect-schema-vite' +import { defineConfig, sharedConfig } from '@systemfsoftware/vitest-config' + +/** + * The node test project. `vitest.config.ts` runs it beside the browser project; + * Stryker runs it alone, because the decision modules it mutates are specified + * by node tests and the CI mutation job installs no browser. + */ +export const nodeTest: { + readonly name: string + readonly include: string[] + readonly pool: 'forks' + readonly environment: 'node' +} = { + name: 'node', + include: ['./tests/**/*.test.ts', './src/**/*.test.ts', '!./tests/browser/**'], + pool: 'forks', + environment: 'node', +} + +export default defineConfig({ + ...sharedConfig, + plugins: [inlineSchemaTests()], + test: { + ...sharedConfig.test, + ...nodeTest, + // Run alone, the node tests are the package's one project, listed as `unit`. + name: 'unit', + }, +}) diff --git a/packages/gherkin/storybook-gherkin/.attw.json b/packages/gherkin/storybook-gherkin/.attw.json new file mode 100644 index 0000000..76f3418 --- /dev/null +++ b/packages/gherkin/storybook-gherkin/.attw.json @@ -0,0 +1 @@ +{ "ignoreRules": ["no-resolution", "cjs-resolves-to-esm"] } diff --git a/packages/gherkin/storybook-gherkin/.storybook/main.ts b/packages/gherkin/storybook-gherkin/.storybook/main.ts new file mode 100644 index 0000000..79ae9e3 --- /dev/null +++ b/packages/gherkin/storybook-gherkin/.storybook/main.ts @@ -0,0 +1,19 @@ +import type { StorybookConfig } from '@storybook/react-vite' + +const config: StorybookConfig = { + stories: ['../test/browser/**/*.stories.ts'], + addons: [], + framework: { + name: '@storybook/react-vite', + options: {}, + }, + viteFinal: (viteConfig) => ({ + ...viteConfig, + resolve: { + ...viteConfig.resolve, + conditions: [...(viteConfig.resolve?.conditions ?? []), '@systemfsoftware/source'], + }, + }), +} + +export default config diff --git a/packages/gherkin/storybook-gherkin/.storybook/preview.ts b/packages/gherkin/storybook-gherkin/.storybook/preview.ts new file mode 100644 index 0000000..0290d3a --- /dev/null +++ b/packages/gherkin/storybook-gherkin/.storybook/preview.ts @@ -0,0 +1,3 @@ +import { definePreview } from '@storybook/react-vite' + +export default definePreview({ addons: [] }) diff --git a/packages/gherkin/storybook-gherkin/AGENTS.md b/packages/gherkin/storybook-gherkin/AGENTS.md new file mode 100644 index 0000000..5556cbf --- /dev/null +++ b/packages/gherkin/storybook-gherkin/AGENTS.md @@ -0,0 +1,13 @@ +# AGENTS.md — `@systemfsoftware/storybook-gherkin` + +Root `AGENTS.md` governs. This package is library code, not Effect cell code: it keeps its own minimal `oxlint.config.ts`, does not extend `@systemfsoftware/oxlint-config`, carries no api-extractor (tsdown + `dts:check` only), and has no `src/**/*.workflow.ts` file so is not enrolled in mutation testing. + +| ID | Rule | Gate | +| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **SG2** | The story suite is the browser test (`test/browser/spec.stories.ts`): no auxiliary browser suites and no raw `test()` in the stories file. The one node lane is `tests/story-leave.conformance.test.ts`. | `pnpm --filter @systemfsoftware/storybook-gherkin test:browser` (run `pnpm --filter @systemfsoftware/storybook-gherkin exec playwright install chromium` once) and `… test` | +| **SG3** | Scenario-outline rows become stories through direct const exports (`export const X = Outline['row name']`); never export the `.examples()` record itself — the CSF indexer turns it into a story with no play, a silent false green. | `review` — wrong: `export const Rows = Outline.examples({...})`; right: `export const Empty = Outline['empty cart']` | +| **SG5** | The embedded spec is the only representation: a story using this DSL is the `.feature`. Never produce or require a parallel `.feature` file. | `git ls-files 'packages/gherkin/storybook-gherkin/**/*.feature'` prints nothing | +| **SG6** | User code runs exactly once, at the play edge (`interpretPlay` via `Runtime.runPromiseExit` on the consumer's runtime). The `runStep` bridge's `Effect.runPromiseExit` runs only the pure `Deferred.await` signal; never add a second run site for user code. | `review` — wrong: `runStep` calling `Effect.runPromise(handler(ctx))`; right: `runStep` resolves the `Deferred` the play edge awaits | +| **SG7** | A step handler's rejection is a defect and is never wrapped in a tagged error: Storybook's Interactions panel matches `error.name === 'AssertionError'` and `expect(` prefixes to render matcher diffs. | `review` — wrong: `Effect.mapError(e => new StepFailed({ cause: e }))` around a handler; right: the rejection propagates as a defect | +| **SG9** | `storybook` (`>=10.0.0`) and `effect` stay peerDependencies although `storybook/test` is imported at runtime — the consumer's install provides them. `type-fest` stays a regular dependency because its types appear in the shipped dts. | `review` — wrong: `storybook` moved to `dependencies`; right: it stays under `peerDependencies` | +| **SG10** | `ScenarioOptions` is exact — `with` only, no options generic, no runtime spread of unknown keys. Storybook 10's CSF transformer discovers `tags`/`parameters`/`decorators`/`globals` only in exported object literals; anything copied onto a spec inside a call is invisible and yields zero stories. | `review` — wrong: `feature.scenario('x', { with: {}, tags: ['smoke'] }, …)`; right: `tags` on the exported story object literal | diff --git a/packages/gherkin/storybook-gherkin/LICENSE b/packages/gherkin/storybook-gherkin/LICENSE new file mode 100644 index 0000000..896c374 --- /dev/null +++ b/packages/gherkin/storybook-gherkin/LICENSE @@ -0,0 +1,203 @@ +Copyright (c) 2026 Ryan Lee (systemfsoftware) + +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Ryan Lee (systemfsoftware) + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/gherkin/storybook-gherkin/README.md b/packages/gherkin/storybook-gherkin/README.md new file mode 100644 index 0000000..041e9fe --- /dev/null +++ b/packages/gherkin/storybook-gherkin/README.md @@ -0,0 +1,23 @@ +# @systemfsoftware/storybook-gherkin + +Feature specs as Storybook stories — Gherkin Given/When/Then embedded in CSF with typed captures and scenario outlines, executing under Storybook's browser-mode play functions. + +## Install + +```sh +pnpm add @systemfsoftware/storybook-gherkin 'effect@^4' 'storybook@>=10.0.0' +``` + +Those are peer dependencies: this package declares them but does not install them, so one copy is shared with the rest of your project. + +## Entry points + +- `@systemfsoftware/storybook-gherkin` + +## API + +The public surface is generated from the source and versioned with the package: [`etc/storybook-gherkin.api.md`](./etc/storybook-gherkin.api.md). + +## License + +Apache-2.0. Part of [systemfsoftware](https://github.com/systemfsoftware/systemfsoftware/tree/main/packages/gherkin/storybook-gherkin#readme). diff --git a/packages/gherkin/storybook-gherkin/api-extractor.json b/packages/gherkin/storybook-gherkin/api-extractor.json new file mode 100644 index 0000000..808055a --- /dev/null +++ b/packages/gherkin/storybook-gherkin/api-extractor.json @@ -0,0 +1,55 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + "projectFolder": ".", + "mainEntryPointFilePath": "/dist/index.d.ts", + "bundledPackages": [], + "compiler": { + "tsconfigFilePath": "/tsconfig.api.json" + }, + "apiReport": { + "enabled": true, + "reportFolder": "/etc/", + "reportTempFolder": "/temp/main/", + "reportFileName": "storybook-gherkin.api.md" + }, + "docModel": { + "enabled": false + }, + "dtsRollup": { + "enabled": true, + "untrimmedFilePath": "/dist/index.d.ts" + }, + "tsdocMetadata": { + "enabled": false + }, + "messages": { + "extractorMessageReporting": { + "default": { + "logLevel": "warning" + }, + "ae-forgotten-export": { + "logLevel": "error" + }, + "ae-missing-release-tag": { + "logLevel": "none" + }, + "ae-internal-missing-underscore": { + "logLevel": "none" + } + }, + "tsdocMessageReporting": { + "default": { + "logLevel": "warning" + }, + "tsdoc-undefined-tag": { + "logLevel": "none" + }, + "tsdoc-unsupported-tag": { + "logLevel": "none" + }, + "tsdoc-param-tag-missing-hyphen": { + "logLevel": "none" + } + } + } +} diff --git a/packages/gherkin/storybook-gherkin/etc/storybook-gherkin.api.md b/packages/gherkin/storybook-gherkin/etc/storybook-gherkin.api.md new file mode 100644 index 0000000..18781f0 --- /dev/null +++ b/packages/gherkin/storybook-gherkin/etc/storybook-gherkin.api.md @@ -0,0 +1,378 @@ +## API Report File for "@systemfsoftware/storybook-gherkin" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { Assertion } from 'vitest'; +import { Context } from 'effect'; +import { Effect } from 'effect'; +import { ExpectStatic } from 'vitest'; +import { Schema } from 'effect'; +import { screen as screen_2 } from 'storybook/test'; +import { Simplify } from 'type-fest'; +import { UnionToIntersection } from 'type-fest'; +import { UserEventObject } from 'storybook/test'; +import { YieldableError } from 'effect/Cause'; + +// @public (undocumented) +export const And: StepCtor; + +// Warning: (ae-forgotten-export) The symbol "BackgroundNotGiven_base" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +export class BackgroundNotGiven extends BackgroundNotGiven_base { + // (undocumented) + get message(): string; +} + +// @public (undocumented) +export const But: StepCtor; + +// @public (undocumented) +export type Canvas = typeof screen_2; + +// @public (undocumented) +export type CapsOf = THoles extends readonly [] ? {} : Simplify ? { [P in N]: A; } : {}; }[number]>>; + +// Warning: (ae-forgotten-export) The symbol "CaptureTag" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +export interface Capture extends CaptureTag { + // (undocumented) + readonly default: string | undefined; + // (undocumented) + readonly name: Name; + // Warning: (ae-forgotten-export) The symbol "AnyConstraintDecoder$1" needs to be exported by the entry point index.d.ts + // + // (undocumented) + readonly schema: AnyConstraintDecoder$1 | undefined; +} + +// @public (undocumented) +export const capture: { + (options: { + readonly schema: Schema.Codec; + readonly default?: string; + }): (name: Name) => Capture; + (options?: { + readonly default?: string; + }): (name: Name) => Capture; + (name: Name, options: { + readonly schema: Schema.Codec; + readonly default?: string; + }): Capture; + (name: Name, options?: { + readonly default?: string; + }): Capture; +}; + +// Warning: (ae-forgotten-export) The symbol "CaptureDecodeFailed_base" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +export class CaptureDecodeFailed extends CaptureDecodeFailed_base { + // (undocumented) + get message(): string; +} + +// @public (undocumented) +export interface CaptureModel { + // (undocumented) + readonly default: string | undefined; + // (undocumented) + readonly name: string; + // Warning: (ae-forgotten-export) The symbol "AnyConstraintDecoder" needs to be exported by the entry point index.d.ts + readonly schema: AnyConstraintDecoder | undefined; +} + +// @public (undocumented) +export type ConcreteKeyword = 'Given' | 'When' | 'Then'; + +// Warning: (ae-forgotten-export) The symbol "DuplicateCapture_base" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +export class DuplicateCapture extends DuplicateCapture_base { + // (undocumented) + get message(): string; +} + +// Warning: (ae-forgotten-export) The symbol "EmptyScenario_base" needs to be exported by the entry point index.d.ts +// +// @public +export class EmptyScenario extends EmptyScenario_base { + // (undocumented) + get message(): string; +} + +// @public (undocumented) +export type ExampleRow = { + readonly name: string; +} & Readonly>; + +// @public (undocumented) +export interface Feature { + // (undocumented) + readonly background: (...steps: readonly Step[]) => void; + // (undocumented) + readonly meta: M; + // (undocumented) + readonly rule: (name: string) => RuleScope; + // (undocumented) + readonly scenario: ScenarioFn; + // (undocumented) + readonly scenarioOutline: OutlineFn; + // (undocumented) + readonly type: () => Feature; +} + +// @public +export const feature: { + (options: FeatureOptions): (meta: M) => Feature; + (meta: M, options: FeatureOptions): Feature; +}; + +// @public (undocumented) +export interface FeatureOptions { + readonly context?: Context.Context; +} + +// Warning: (ae-forgotten-export) The symbol "StoryWithPlay" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +export const From: (story: StoryWithPlay) => Step[]; + +// @public (undocumented) +export const Given: StepCtor; + +// @public (undocumented) +export type Hole = Capture | string | number; + +// @public (undocumented) +export type Keyword = 'Given' | 'When' | 'Then' | 'And' | 'But' | 'Star'; + +// Warning: (ae-forgotten-export) The symbol "MissingThen_base" needs to be exported by the entry point index.d.ts +// +// @public +export class MissingThen extends MissingThen_base { + // (undocumented) + get message(): string; +} + +// @public (undocumented) +export interface OutlineBuilder { + // (undocumented) + readonly examples: (rows: readonly ExampleRow[]) => Record>; +} + +// Warning: (ae-forgotten-export) The symbol "OutlineDuplicateRowName_base" needs to be exported by the entry point index.d.ts +// +// @public +export class OutlineDuplicateRowName extends OutlineDuplicateRowName_base { + // (undocumented) + get message(): string; +} + +// Warning: (ae-forgotten-export) The symbol "OutlineEmpty_base" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +export class OutlineEmpty extends OutlineEmpty_base { + // (undocumented) + get message(): string; +} + +// @public (undocumented) +export interface OutlineFn { + // Warning: (ae-forgotten-export) The symbol "ScenarioTitle" needs to be exported by the entry point index.d.ts + // + // (undocumented) + (name: ScenarioTitle, ...steps: readonly StepArg[]): OutlineBuilder; + // (undocumented) + (name: ScenarioTitle, options: ScenarioOptions, ...steps: readonly StepArg[]): OutlineBuilder; +} + +// Warning: (ae-forgotten-export) The symbol "OutlineInconsistentKeys_base" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +export class OutlineInconsistentKeys extends OutlineInconsistentKeys_base { + // (undocumented) + get message(): string; +} + +// Warning: (ae-forgotten-export) The symbol "OutlineMissingCapture_base" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +export class OutlineMissingCapture extends OutlineMissingCapture_base { + // (undocumented) + get message(): string; +} + +// @public (undocumented) +export interface PlayContext { + // (undocumented) + readonly abortSignal: AbortSignal; + // (undocumented) + readonly args: TArgs; + // (undocumented) + readonly canvas: Canvas; + // (undocumented) + readonly canvasElement: HTMLElement; + // Warning: (ae-forgotten-export) The symbol "AnyValue" needs to be exported by the entry point index.d.ts + // + // (undocumented) + readonly globals: Record; + // (undocumented) + readonly loaded: Record; + // (undocumented) + readonly parameters: Record; + // (undocumented) + readonly reporting: ReportingAPI; + // (undocumented) + readonly step: StepFn; + // (undocumented) + readonly userEvent: UserEventObject; +} + +// @public (undocumented) +interface Report_2 { + // (undocumented) + readonly result: Result; + // (undocumented) + readonly status: 'failed' | 'passed' | 'warning'; + // (undocumented) + readonly type: string; + // (undocumented) + readonly version?: number; +} +export { Report_2 as Report } + +// @public (undocumented) +export interface ReportingAPI { + // (undocumented) + readonly addReport: (report: Report_2) => void; + // (undocumented) + readonly reports: Report_2[]; +} + +// @public (undocumented) +export interface RuleScope { + // (undocumented) + readonly scenario: ScenarioFn; + // (undocumented) + readonly scenarioOutline: OutlineFn; +} + +// @public (undocumented) +export interface ScenarioFn { + // (undocumented) + (name: ScenarioTitle, ...steps: readonly StepArg[]): StorySpec; + // (undocumented) + (name: ScenarioTitle, options: ScenarioOptions, ...steps: readonly StepArg[]): StorySpec; +} + +// @public (undocumented) +export interface ScenarioOptions { + readonly with?: Readonly>; +} + +// @public (undocumented) +export const Star: StepCtor; + +// Warning: (ae-forgotten-export) The symbol "StepTag" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +export interface Step extends StepTag { + // (undocumented) + readonly model: StepModel; + // (undocumented) + readonly run: (values: Readonly>, ctx: StepContext) => Effect.Effect; +} + +// @public +export type StepArg = Step | readonly Step[]; + +// @public (undocumented) +export interface StepBuilder { + // (undocumented) + (handler: StepHandler, TArgs>): Step; +} + +// @public (undocumented) +export interface StepContext { + readonly abortSignal: AbortSignal; + // (undocumented) + readonly args: TArgs; + // (undocumented) + readonly canvas: Canvas; + // (undocumented) + readonly canvasElement: HTMLElement; + // (undocumented) + readonly context: PlayContext; + // (undocumented) + readonly expect: Omit & { + (actual: T, message?: string): Assertion, T>; + }; + // (undocumented) + readonly globals: Record; + // (undocumented) + readonly loaded: Record; + // (undocumented) + readonly parameters: Record; + // (undocumented) + readonly reporting: ReportingAPI; + // (undocumented) + readonly screen: typeof screen_2; + // (undocumented) + readonly step: StepFn; + // (undocumented) + readonly userEvent: UserEventObject; +} + +// @public (undocumented) +export type StepCtor = { + (statics: TemplateStringsArray, ...holes: THoles): StepBuilder; + (statics: TemplateStringsArray, ...holes: THoles): StepBuilder; +}; + +// @public (undocumented) +export type StepFn = (label: string, fn: () => Promise) => Promise | void; + +// @public (undocumented) +export type StepHandler = (ctx: StepContext, caps: TCaps) => void | Promise; + +// @public (undocumented) +export interface StepModel { + // (undocumented) + readonly captures: readonly CaptureModel[]; + // (undocumented) + readonly keyword: Keyword; + readonly parts: readonly string[]; +} + +// @public (undocumented) +export const Steps: (...steps: readonly Step[]) => Step[]; + +// @public (undocumented) +export interface StorySpec { + // (undocumented) + readonly name: string; + // (undocumented) + readonly play: (context: PlayContext) => Promise; +} + +// @public (undocumented) +export const Then: StepCtor; + +// Warning: (ae-forgotten-export) The symbol "UnresolvedCapture_base" needs to be exported by the entry point index.d.ts +// +// @public +export class UnresolvedCapture extends UnresolvedCapture_base { + // (undocumented) + get message(): string; +} + +// @public (undocumented) +export const When: StepCtor; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/gherkin/storybook-gherkin/oxlint.config.ts b/packages/gherkin/storybook-gherkin/oxlint.config.ts new file mode 100644 index 0000000..b6f1225 --- /dev/null +++ b/packages/gherkin/storybook-gherkin/oxlint.config.ts @@ -0,0 +1,4 @@ +import recommended from '@systemfsoftware/oxlint-config-frontend' +import { defineConfig } from 'oxlint' + +export default defineConfig({ extends: [recommended] }) diff --git a/packages/gherkin/storybook-gherkin/package.json b/packages/gherkin/storybook-gherkin/package.json new file mode 100644 index 0000000..8f71a1f --- /dev/null +++ b/packages/gherkin/storybook-gherkin/package.json @@ -0,0 +1,109 @@ +{ + "name": "@systemfsoftware/storybook-gherkin", + "license": "Apache-2.0", + "version": "5.0.0", + "author": "TODO ", + "repository": { + "type": "git", + "url": "git+https://github.com/systemfsoftware/frontend.git", + "directory": "packages/gherkin/storybook-gherkin" + }, + "homepage": "https://github.com/systemfsoftware/frontend/tree/main/packages/gherkin/storybook-gherkin#readme", + "bugs": "https://github.com/systemfsoftware/frontend/issues", + "description": "Feature specs as Storybook stories — Gherkin Given/When/Then embedded in CSF with typed captures and scenario outlines, executing under Storybook's browser-mode play functions.", + "keywords": [ + "effect", + "effect-ts", + "storybook", + "gherkin", + "bdd", + "cucumber", + "given-when-then", + "scenario-outline", + "stories", + "csf", + "acceptance-testing" + ], + "type": "module", + "exports": { + ".": { + "@systemfsoftware/source": "./src/index.ts", + "types": "./dist/index.d.ts", + "default": "./dist/index.mjs" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist" + ], + "scripts": { + "clean": "rimraf dist", + "build": "tsdown -l warn && pnpm dts:check && pnpm api:check", + "typecheck": "tsc -b", + "lint": "f=${OXLINT_FORMAT:-${AGENT:+agent}}; oxlint . --config oxlint.config.ts --format=${f:-default}", + "lint:tsgo": "effect-tsgo diagnostics --project tsconfig.app.json --format ${TSGO_FORMAT:-text} && effect-tsgo diagnostics --project tsconfig.test.json --format ${TSGO_FORMAT:-text}", + "attw": "attw --pack .", + "dts:check": "node scripts/check-dts.mjs", + "storybook": "storybook dev -p 6006 --no-open", + "test": "vitest run --project conformance", + "test:browser": "pnpm build && vitest run", + "api:check": "tsdown -l warn && api-extractor-quiet run", + "api:update": "api-extractor run --local" + }, + "dependencies": { + "type-fest": "catalog:" + }, + "peerDependencies": { + "effect": "catalog:peers", + "storybook": ">=10.0.0", + "vitest": "catalog:" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } + }, + "devDependencies": { + "@microsoft/api-extractor": "catalog:", + "@storybook/addon-vitest": "catalog:", + "@storybook/react-vite": "catalog:", + "@systemfsoftware/arethetypeswrong-cli": "catalog:", + "@systemfsoftware/conformance-spec": "^1.0.0", + "@systemfsoftware/effect-gherkin-spec": "^7.0.1", + "@systemfsoftware/effect-sim-kernel": "^1.0.0", + "@systemfsoftware/effect-spec-runtime": "^1.0.1", + "@systemfsoftware/oxlint-config-frontend": "workspace:^", + "@systemfsoftware/tsconfig": "^2.0.1", + "@systemfsoftware/tsdown-config": "^0.1.0", + "@systemfsoftware/vitest": "^2.0.0", + "@systemfsoftware/vitest-config": "^0.1.0", + "@types/node": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitest/browser": "catalog:", + "@vitest/browser-playwright": "catalog:", + "effect": "catalog:", + "jsdom": "30.1.2", + "oxlint": "catalog:oxlint", + "playwright": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "rimraf": "catalog:", + "storybook": "catalog:", + "tsdown": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + "vitest": "catalog:" + }, + "publishConfig": { + "access": "public", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.mjs" + }, + "./package.json": "./package.json" + }, + "provenance": true + } +} diff --git a/packages/gherkin/storybook-gherkin/scripts/check-dts.mjs b/packages/gherkin/storybook-gherkin/scripts/check-dts.mjs new file mode 100755 index 0000000..a5bd9e2 --- /dev/null +++ b/packages/gherkin/storybook-gherkin/scripts/check-dts.mjs @@ -0,0 +1,53 @@ +#!/usr/bin/env node +/// +// dts:check for this package: fail only on errors in the declarations WE ship. +// +// The strict check (skipLibCheck: false) is a strict consumer of dist/*.d.ts, +// and that program drags third-party declaration files in with it. The +// storybook/test type surface flows through @vitest/expect, whose declarations +// reference an ambient `Chai` namespace they never declare (their own +// @types/chai dependency is installed but not ambient-visible). Policing that +// file would mean declaring a @types/chai devDependency we do not use — cope. +// This script scopes the verdict instead: errors in files under dist/ fail the +// build, errors in node_modules are third-party noise by construction, and any +// other tsc output (crash, config error, usage) still fails. Our own dts is +// checked exactly as strictly as before; the manifest stays honest. +import { spawnSync } from 'node:child_process' +import { dirname, join, sep } from 'node:path' +import { fileURLToPath } from 'node:url' + +const pkgDir = join(dirname(fileURLToPath(import.meta.url)), '..') +const distDir = join(pkgDir, 'dist') +const tscBin = join(pkgDir, 'node_modules', '.bin', 'tsc') + +const result = spawnSync(tscBin, ['--noEmit', '-p', 'tsconfig.dts.json'], { + cwd: pkgDir, + encoding: 'utf8', +}) +const lines = `${result.stdout ?? ''}${result.stderr ?? ''}`.split('\n').filter(Boolean) + +/** @param {string} line @returns {boolean} */ +const isTypeError = (line) => /: error TS\d+:/u.test(line) +/** @param {string} line @returns {string} */ +const fileOf = (line) => line.slice(0, line.indexOf('(')) + +const ours = lines.filter((l) => isTypeError(l) && fileOf(l).startsWith(`${distDir}${sep}`)) +const thirdParty = lines.filter((l) => isTypeError(l) && fileOf(l).includes(`${sep}node_modules${sep}`)) +const unexpected = lines.filter((l) => !isTypeError(l) || (!ours.includes(l) && !thirdParty.includes(l))) + +if (result.status === null) { + console.error('dts:check: tsc failed to run') + console.error(lines.join('\n')) + process.exit(1) +} + +if (result.status === 0) process.exit(0) + +// Nonzero. Pass only when every diagnostic is a third-party type error; the +// point of the gate is OUR dts, and a strict check that cannot avoid dragging +// in node_modules must not be held hostage by their files. +if (ours.length === 0 && unexpected.length === 0 && thirdParty.length > 0) process.exit(0) + +const group = [...ours, ...unexpected] +console.error(group.length > 0 ? group.join('\n') : 'dts:check: tsc exited nonzero without attributable diagnostics') +process.exit(1) diff --git a/packages/gherkin/storybook-gherkin/src/Capture.ts b/packages/gherkin/storybook-gherkin/src/Capture.ts new file mode 100644 index 0000000..e761411 --- /dev/null +++ b/packages/gherkin/storybook-gherkin/src/Capture.ts @@ -0,0 +1,42 @@ +import type { Schema } from 'effect' +import { dual } from 'effect/Function' + +const CaptureTag = { _tag: 'Capture' } as const +export type CaptureTag = typeof CaptureTag + +type AnyConstraintDecoder = Schema.ConstraintDecoder + +export interface Capture extends CaptureTag { + readonly name: Name + readonly schema: AnyConstraintDecoder | undefined + readonly default: string | undefined +} + +function captureImpl( + name: Name, + options?: { readonly schema?: Schema.Codec; readonly default?: string }, +): Capture { + const resolved = options ?? {} + return { + ...CaptureTag, + name, + schema: resolved.schema, + default: resolved.default, + } +} + +export const capture: { + (options: { + readonly schema: Schema.Codec + readonly default?: string + }): (name: Name) => Capture + (options?: { readonly default?: string }): (name: Name) => Capture + (name: Name, options: { + readonly schema: Schema.Codec + readonly default?: string + }): Capture + (name: Name, options?: { readonly default?: string }): Capture +} = dual( + (args: IArguments) => typeof args[0] === 'string', + captureImpl, +) diff --git a/packages/gherkin/storybook-gherkin/src/Errors.schema.ts b/packages/gherkin/storybook-gherkin/src/Errors.schema.ts new file mode 100644 index 0000000..a927747 --- /dev/null +++ b/packages/gherkin/storybook-gherkin/src/Errors.schema.ts @@ -0,0 +1,119 @@ +import { Schema as S } from 'effect' + +const ConcreteKeywordSchema = S.Literals(['Given', 'When', 'Then']) + +/** + * Two channels: + * - Declaration time: every violation except `CaptureDecodeFailed` is thrown + * during module evaluation of the story file, so a malformed spec fails the + * Storybook import before any story runs. + * - Run time: `CaptureDecodeFailed` travels the typed error channel of + * `Step.run` and surfaces when the play edge interprets the scenario. + */ + +export class EmptyScenario extends S.TaggedError()('EmptyScenario', { + scenario: S.String, +}) { + override get message(): string { + return `Scenario "${this.scenario}" has no steps` + } +} + +/** Without a Then-phase step nothing is asserted — the scenario is not a spec. */ +export class MissingThen extends S.TaggedError()('MissingThen', { + scenario: S.String, +}) { + override get message(): string { + return `Scenario "${this.scenario}" has no Then step` + } +} + +export class BackgroundNotGiven extends S.TaggedError()('BackgroundNotGiven', { + step: S.String, + resolved: ConcreteKeywordSchema, +}) { + override get message(): string { + return `The background step "${this.step}" is written as ${this.resolved}; a background must be Given` + } +} + +export class DuplicateCapture extends S.TaggedError()('DuplicateCapture', { + step: S.String, + name: S.String, +}) { + override get message(): string { + return `Step "${this.step}" binds capture "${this.name}" more than once` + } +} + +/** Capture values come from a literal hole, `with`, or an outline row — none matched. */ +export class UnresolvedCapture extends S.TaggedError()('UnresolvedCapture', { + scenario: S.String, + step: S.String, + capture: S.String, +}) { + override get message(): string { + return `Capture "${this.capture}" in step "${this.step}" of scenario "${this.scenario}" resolved to no value` + } +} + +export class OutlineEmpty extends S.TaggedError()('OutlineEmpty', { + outline: S.String, +}) { + override get message(): string { + return `Outline "${this.outline}" has no rows` + } +} + +export class OutlineInconsistentKeys extends S.TaggedError()( + 'OutlineInconsistentKeys', + { + outline: S.String, + row: S.String, + expected: S.Array(S.String), + actual: S.Array(S.String), + }, +) { + override get message(): string { + return `Outline "${this.outline}" row "${this.row}" provides [${ + this.actual.join(', ') + }] where the outline declares [${this.expected.join(', ')}]` + } +} + +/** Row names become the exported story names, so duplicates would collide. */ +export class OutlineDuplicateRowName extends S.TaggedError()( + 'OutlineDuplicateRowName', + { + outline: S.String, + name: S.String, + }, +) { + override get message(): string { + return `Outline "${this.outline}" has more than one row named "${this.name}"` + } +} + +export class OutlineMissingCapture extends S.TaggedError()( + 'OutlineMissingCapture', + { + outline: S.String, + row: S.String, + capture: S.String, + }, +) { + override get message(): string { + return `Outline "${this.outline}" row "${this.row}" has no value for capture "${this.capture}"` + } +} + +export class CaptureDecodeFailed extends S.TaggedError()('CaptureDecodeFailed', { + step: S.String, + capture: S.String, + value: S.String, + cause: S.Unknown, +}) { + override get message(): string { + return `Capture "${this.capture}" of step "${this.step}" did not decode: ${this.value}` + } +} diff --git a/packages/gherkin/storybook-gherkin/src/Feature.ts b/packages/gherkin/storybook-gherkin/src/Feature.ts new file mode 100644 index 0000000..abbc931 --- /dev/null +++ b/packages/gherkin/storybook-gherkin/src/Feature.ts @@ -0,0 +1,652 @@ +import { Cause, Context, Deferred, Effect, Exit, Fiber } from 'effect' +import { dual } from 'effect/Function' +import { expect as storybookExpect, screen } from 'storybook/test' +import type { TestContext } from 'vitest' + +import { + BackgroundNotGiven, + CaptureDecodeFailed, + EmptyScenario, + MissingThen, + OutlineDuplicateRowName, + OutlineEmpty, + OutlineInconsistentKeys, + OutlineMissingCapture, + UnresolvedCapture, +} from './Errors.schema.js' +import type { ConcreteKeyword, ExampleRow, PlayContext, Step, StepContext, StepModel } from './Steps.js' +import { displayPattern, isStep, renderStepText, resolveKeywords, StepTag } from './Steps.js' + +export interface StorySpec { + readonly name: string + readonly play: (context: PlayContext) => Promise +} + +export interface ScenarioOptions { + /** Capture values for a plain (non-outline) scenario, keyed by capture name. */ + readonly with?: Readonly> +} + +export interface FeatureOptions { + /** + * Context that interprets a scenario's step program at the play edge, + * defaulting to `Context.empty()`. Supply a context carrying services + * or a custom scheduler to make them available to the run. + */ + readonly context?: Context.Context +} + +/** + * Anything positional after the scenario name: a step, a step group from + * `Steps(...)` / `From(...)`, or — in first position only — an options object. + */ +export type StepArg = Step | readonly Step[] + +/** + * A scenario title names a concrete situation in natural-language prose. Two + * shape checks keep DAMP `Should_[Behavior]_When_[Condition]` unit-test names — + * and every concatenated-token title shaped like one — out of the call site: + * 1. the literal must not start with `Should`; + * 2. the literal must contain at least one ASCII space (a single-word title is + * a test name, not prose). + * Either check failing maps to `ScenarioTitleRejected`, so the call fails to + * type-check with the rule in the diagnostic. Non-literal titles (widened + * `string`) pass through untouched — a runtime guard would catch those, but + * the brand is the contract this skill ships. + */ +export type ScenarioTitleRejected = + | `Scenario titles are natural-language prose of a concrete situation, not DAMP Should_[Behavior]_When_[Condition] unit-test names. Got: ${T}` + | `Scenario title must be natural-language prose (at least one space separates words); got: ${T}` + +export type ScenarioTitle = T extends `Should${string}` ? ScenarioTitleRejected + : T extends `${string} ${string}` ? T + : ScenarioTitleRejected + +export interface ScenarioFn { + (name: ScenarioTitle, ...steps: readonly StepArg[]): StorySpec + ( + name: ScenarioTitle, + options: ScenarioOptions, + ...steps: readonly StepArg[] + ): StorySpec +} + +export interface OutlineBuilder { + readonly examples: ( + rows: readonly ExampleRow[], + ) => Record> +} + +export interface OutlineFn { + (name: ScenarioTitle, ...steps: readonly StepArg[]): OutlineBuilder + ( + name: ScenarioTitle, + options: ScenarioOptions, + ...steps: readonly StepArg[] + ): OutlineBuilder +} + +export interface RuleScope { + readonly scenario: ScenarioFn + readonly scenarioOutline: OutlineFn +} + +export interface Feature { + readonly meta: M + readonly type: () => Feature + readonly background: (...steps: readonly Step[]) => void + readonly scenario: ScenarioFn + readonly scenarioOutline: OutlineFn + readonly rule: (name: string) => RuleScope +} + +const displayKeyword = (model: StepModel): string => { + if (model.keyword === 'Star') return '*' + return model.keyword +} + +type StepExpect = StepContext['expect'] + +const vitestBrowserMode = (): boolean => Reflect.get(globalThis, '__vitest_browser__') === true + +interface VitestWorkerGlobal { + readonly current?: { readonly context?: TestContext } +} + +const isVitestWorker = (value: unknown): value is VitestWorkerGlobal => typeof value === 'object' && value !== null + +const vitestWorkerOf = (value: A): VitestWorkerGlobal | undefined => + isVitestWorker(value) ? value : undefined + +const vitestBrowserWorker = (): VitestWorkerGlobal | undefined => { + if (!vitestBrowserMode()) return undefined + return vitestWorkerOf(Reflect.get(globalThis, '__vitest_worker__')) +} + +const currentContext = (worker: VitestWorkerGlobal): TestContext | undefined => { + const current = worker.current + if (current === undefined) return undefined + return current.context +} + +const contextExpect = (context: TestContext | undefined): TestContext['expect'] | undefined => context?.expect + +const perTestExpect = (): TestContext['expect'] | undefined => { + const worker = vitestBrowserWorker() + if (worker === undefined) return undefined + return contextExpect(currentContext(worker)) +} + +const isStepExpect = (value: unknown): value is StepExpect => typeof value === 'function' + +const vitestExpect = (): StepExpect | undefined => { + const fromVitest = perTestExpect() + if (!isStepExpect(fromVitest)) return undefined + return fromVitest +} + +const storybookExpectStatic = (): StepExpect => { + if (!isStepExpect(storybookExpect)) throw new Error('storybook/test no longer exports a callable expect') + return storybookExpect +} + +const resolveExpect = (): StepExpect => vitestExpect() ?? storybookExpectStatic() + +const buildStepContext = (ctx: PlayContext, expect: StepExpect): StepContext => ({ + canvas: ctx.canvas, + screen, + expect, + userEvent: ctx.userEvent, + step: ctx.step, + args: ctx.args, + globals: ctx.globals, + parameters: ctx.parameters, + loaded: ctx.loaded, + canvasElement: ctx.canvasElement, + abortSignal: ctx.abortSignal, + reporting: ctx.reporting, + context: ctx, +}) + +type AnyExit = Exit.Exit + +const squashExit = (exit: AnyExit): A | undefined => + Exit.match(exit, { + onSuccess: (value) => value, + onFailure: (cause) => { + if (Exit.hasInterrupts(exit)) return undefined + throw Cause.squash(cause) + }, + }) + +const runStep = ( + step: Step, + values: Readonly>, + stepCtx: StepContext, +): Effect.Effect => { + const label = `${displayKeyword(step.model)} ${renderStepText(step.model, values)}` + return Deferred.make>().pipe( + Effect.flatMap((done) => { + const bridge = (): Promise => + Effect.runPromiseExit( + // An Exit is itself an Effect in v4 — yielding it re-raises its cause. + Effect.flatten(Deferred.await(done)), + ).then(squashExit) + return Effect.forkChild( + step.run(values, stepCtx).pipe( + Effect.exit, + Effect.flatMap((exit) => Deferred.succeed(done, exit)), + Effect.ensuring(Deferred.interrupt(done).pipe(Effect.asVoid)), + ), + ).pipe( + Effect.flatMap((fiber) => + Effect.promise(() => Promise.resolve(stepCtx.step(label, bridge))).pipe( + Effect.flatMap(() => Fiber.join(fiber)), + Effect.ensuring(Fiber.interrupt(fiber).pipe(Effect.asVoid)), + ) + ), + Effect.ensuring(Deferred.interrupt(done).pipe(Effect.asVoid)), + ) + }), + ) +} + +const executeSteps = ( + ordered: readonly Step[], + values: Readonly>, + ctx: PlayContext, +): Effect.Effect => { + const stepCtx = buildStepContext(ctx, resolveExpect()) + return Effect.forEach(ordered, (s) => runStep(s, values, stepCtx), { discard: true }) +} + +const interpretPlay = ( + context: Context.Context, + program: Effect.Effect, + ctx: { readonly abortSignal: AbortSignal }, +): Promise => Effect.runPromiseExitWith(context)(program, { signal: ctx.abortSignal }).then(squashExit) + +const rowValuesFor = (row: ExampleRow): Readonly> => + Object.fromEntries(Object.entries(row).filter(([k]) => k !== 'name')) + +const sortKeys = (keys: readonly string[]): readonly string[] => { + const sorted = keys.slice() + sorted.sort() + return sorted +} + +const assertNonEmptyScenario = (fullName: string, models: readonly StepModel[]): void => { + if (models.length === 0) { + throw EmptyScenario.make({ scenario: fullName }) + } +} + +const isThen = (r: { readonly resolved: ConcreteKeyword }): boolean => r.resolved === 'Then' + +const assertHasThen = (fullName: string, models: readonly StepModel[]): void => { + if (!resolveKeywords(models).some(isThen)) { + throw MissingThen.make({ scenario: fullName }) + } +} + +const captureIsBound = ( + cap: { readonly default: string | undefined; readonly name: string }, + withRecord: Readonly>, +): boolean => { + if (cap.default !== undefined) return true + return Object.prototype.hasOwnProperty.call(withRecord, cap.name) +} + +const assertCaptureBound = ( + fullName: string, + stepModel: StepModel, + cap: { readonly default: string | undefined; readonly name: string }, + withRecord: Readonly>, +): void => { + if (captureIsBound(cap, withRecord)) return + throw UnresolvedCapture.make({ + scenario: fullName, + step: displayPattern(stepModel), + capture: cap.name, + }) +} + +const assertCapturesBound = ( + fullName: string, + stepModel: StepModel, + withRecord: Readonly>, +): void => { + for (const cap of stepModel.captures) { + assertCaptureBound(fullName, stepModel, cap, withRecord) + } +} + +const validateScenarioSteps = ( + fullName: string, + models: readonly StepModel[], + withRecord: Readonly>, +): void => { + assertNonEmptyScenario(fullName, models) + assertHasThen(fullName, models) + for (const stepModel of models) { + assertCapturesBound(fullName, stepModel, withRecord) + } +} + +const isScenarioOptions = (value: StepArg | ScenarioOptions): value is ScenarioOptions => + !isStep(value) && !Array.isArray(value) + +const optionsIfPresent = ( + firstArg: StepArg | ScenarioOptions, +): ScenarioOptions | undefined => { + if (!isScenarioOptions(firstArg)) return undefined + return firstArg +} + +const readOptions = ( + rest: readonly (StepArg | ScenarioOptions)[], +): ScenarioOptions | undefined => { + const firstArg = rest[0] + if (firstArg === undefined) return undefined + return optionsIfPresent(firstArg) +} + +const bodyAfterOptions = ( + rest: readonly (StepArg | ScenarioOptions)[], + options: ScenarioOptions | undefined, +): readonly (StepArg | ScenarioOptions)[] => { + if (options === undefined) return rest + return rest.slice(1) +} + +const pushInnerStep = (steps: Step[], inner: I): void => { + if (!isStep(inner)) { + throw new TypeError(`Steps group contains a non-step value of type ${typeof inner}`) + } + steps.push(inner) +} + +const pushStepGroup = (steps: Step[], item: readonly I[]): void => { + for (const inner of item) { + pushInnerStep(steps, inner) + } +} + +const pushIfGroup = ( + steps: Step[], + item: StepArg | ScenarioOptions, +): void => { + if (Array.isArray(item)) { + pushStepGroup(steps, item) + return + } + throw new TypeError(`Scenario arguments must be steps or step groups; got type ${typeof item}`) +} + +const pushScenarioItem = ( + steps: Step[], + item: StepArg | ScenarioOptions, +): void => { + if (isStep(item)) { + steps.push(item) + return + } + pushIfGroup(steps, item) +} + +const parseScenarioArgs = ( + rest: readonly (StepArg | ScenarioOptions)[], +): { readonly options: ScenarioOptions | undefined; readonly steps: readonly Step[] } => { + const options = readOptions(rest) + const body = bodyAfterOptions(rest, options) + const steps: Step[] = [] + for (const item of body) { + pushScenarioItem(steps, item) + } + return { options, steps } +} + +const qualifyName = (prefix: string, name: string): string => { + if (prefix === '') return name + return `${prefix}: ${name}` +} + +const recordOrEmpty = ( + value: Readonly> | undefined, +): Readonly> => { + if (value === undefined) return {} + return value +} + +const withRecordOf = (options: ScenarioOptions | undefined): Readonly> => { + if (options === undefined) return {} + return recordOrEmpty(options.with) +} + +const makeScenario = ( + background: readonly Step[], + prefix: string, + context: Context.Context, +): ScenarioFn => { + function scenario( + name: string, + ...steps: readonly StepArg[] + ): StorySpec + function scenario( + name: string, + options: ScenarioOptions, + ...steps: readonly StepArg[] + ): StorySpec + function scenario( + name: string, + ...rest: readonly (StepArg | ScenarioOptions)[] + ): StorySpec { + const { options, steps } = parseScenarioArgs(rest) + const fullName = qualifyName(prefix, name) + const withRecord = withRecordOf(options) + validateScenarioSteps(fullName, steps.map((s) => s.model), withRecord) + return { + name: fullName, + play: (ctx: PlayContext) => + interpretPlay(context, executeSteps([...background, ...steps], withRecord, ctx), ctx), + } + } + return scenario +} + +const assertOutlineNonEmpty = (rows: readonly ExampleRow[], fullName: string): void => { + if (rows.length === 0) throw OutlineEmpty.make({ outline: fullName }) +} + +const isNotName = (k: string): boolean => k !== 'name' + +const keysExceptName = (row: object): readonly string[] => sortKeys(Object.keys(row).filter(isNotName)) + +const firstRowKeys = (rows: readonly ExampleRow[]): readonly string[] => { + const first = rows[0] + if (first === undefined) return [] + return keysExceptName(first) +} + +const rememberRowName = (seen: Set, row: ExampleRow, fullName: string): void => { + if (seen.has(row.name)) { + throw OutlineDuplicateRowName.make({ outline: fullName, name: row.name }) + } + seen.add(row.name) +} + +const keyAtMatches = (actual: readonly string[], expected: readonly string[], i: number): boolean => + actual[i] === expected[i] + +const keysMatch = (actual: readonly string[], expected: readonly string[]): boolean => { + if (actual.length !== expected.length) return false + return actual.every((_, i) => keyAtMatches(actual, expected, i)) +} + +const assertKeysConsistent = (row: ExampleRow, firstKeys: readonly string[], fullName: string): void => { + const actual = keysExceptName(row) + if (keysMatch(actual, firstKeys)) return + throw OutlineInconsistentKeys.make({ + outline: fullName, + row: row.name, + expected: [...firstKeys], + actual: [...actual], + }) +} + +const assertRowHasCapture = (row: ExampleRow, cap: string, fullName: string): void => { + if (Object.prototype.hasOwnProperty.call(row, cap)) return + throw OutlineMissingCapture.make({ + outline: fullName, + row: row.name, + capture: cap, + }) +} + +const assertRowCaptures = ( + row: ExampleRow, + captureNames: ReadonlySet, + fullName: string, +): void => { + for (const cap of captureNames) { + assertRowHasCapture(row, cap, fullName) + } +} + +const validateOneRow = ( + seenRowNames: Set, + row: ExampleRow, + firstKeys: readonly string[], + captureNames: ReadonlySet, + fullName: string, +): void => { + rememberRowName(seenRowNames, row, fullName) + assertKeysConsistent(row, firstKeys, fullName) + assertRowCaptures(row, captureNames, fullName) +} + +const validateOutlineRows = ( + rows: readonly ExampleRow[], + captureNames: ReadonlySet, + fullName: string, +): void => { + assertOutlineNonEmpty(rows, fullName) + const seenRowNames = new Set() + const firstKeys = firstRowKeys(rows) + for (const row of rows) { + validateOneRow(seenRowNames, row, firstKeys, captureNames, fullName) + } +} + +const addModelCaptures = (captureNames: Set, m: StepModel): void => { + for (const c of m.captures) { + captureNames.add(c.name) + } +} + +const collectCaptureNames = (models: readonly StepModel[]): Set => { + const captureNames = new Set() + for (const m of models) { + addModelCaptures(captureNames, m) + } + return captureNames +} + +const makeOutline = ( + background: readonly Step[], + prefix: string, + context: Context.Context, +): OutlineFn => { + function outline( + name: string, + ...steps: readonly StepArg[] + ): OutlineBuilder + function outline( + name: string, + options: ScenarioOptions, + ...steps: readonly StepArg[] + ): OutlineBuilder + function outline( + name: string, + ...rest: readonly (StepArg | ScenarioOptions)[] + ): OutlineBuilder { + const { options, steps } = parseScenarioArgs(rest) + const fullName = qualifyName(prefix, name) + const withRecord = withRecordOf(options) + const models = steps.map((s) => s.model) + assertNonEmptyScenario(fullName, models) + assertHasThen(fullName, models) + const captureNames = collectCaptureNames(models) + + const buildRowSpec = (row: ExampleRow): StorySpec => { + const values = { ...withRecord, ...rowValuesFor(row) } + return { + name: `${fullName} — ${row.name}`, + play: (ctx: PlayContext) => + interpretPlay( + context, + executeSteps([...background, ...steps], values, ctx), + ctx, + ), + } + } + + const examples = ( + rows: readonly ExampleRow[], + ): Record> => { + validateOutlineRows(rows, captureNames, fullName) + const out: Record> = {} + for (const row of rows) out[row.name] = buildRowSpec(row) + return out + } + + const builder: OutlineBuilder = { examples } + return builder + } + return outline +} + +const assertResolvedGiven = (step: Step, resolved: ConcreteKeyword): void => { + if (resolved === 'Given') return + throw BackgroundNotGiven.make({ + step: displayPattern(step.model), + resolved, + }) +} + +const assertBackgroundResolved = ( + step: Step, + resolvedEntry: { readonly resolved: ConcreteKeyword } | undefined, +): void => { + if (resolvedEntry === undefined) return + assertResolvedGiven(step, resolvedEntry.resolved) +} + +const assertBackgroundStep = ( + step: Step | undefined, + resolvedEntry: { readonly resolved: ConcreteKeyword } | undefined, +): void => { + if (step === undefined) return + assertBackgroundResolved(step, resolvedEntry) +} + +const makeBackground = (background: Step[]) => (...steps: readonly Step[]): void => { + const resolvedKeywords = resolveKeywords(steps.map((s) => s.model)) + for (let i = 0; i < steps.length; i++) { + assertBackgroundStep(steps[i], resolvedKeywords[i]) + } + background.push(...steps) +} + +const makeFeature = ( + meta: M, + context: Context.Context, +): Feature => { + const background: Step[] = [] + const feature: Feature = { + meta, + type: (): Feature => makeFeature(meta, context), + background: makeBackground(background), + scenario: makeScenario(background, '', context), + scenarioOutline: makeOutline(background, '', context), + rule: (ruleName: string): RuleScope => ({ + scenario: makeScenario(background, ruleName, context), + scenarioOutline: makeOutline(background, ruleName, context), + }), + } + return feature +} + +const contextOf = (options: FeatureOptions): Context.Context => { + if (options.context === undefined) return Context.empty() + return options.context +} + +const featureImpl = (meta: M, options: FeatureOptions): Feature => makeFeature(meta, contextOf(options)) + +/** + * Declare a feature: a story set whose scenarios execute as CSF `play` + * functions. `options.context` (default `Context.empty()`) is the Effect + * context interpreting each scenario's composed step program exactly once, + * at the play edge. + */ +export const feature: { + (options: FeatureOptions): (meta: M) => Feature + (meta: M, options: FeatureOptions): Feature +} = dual(2, featureImpl) + +interface StoryWithPlay { + readonly play?: (context: PlayContext) => Promise | void +} + +export const From = (story: StoryWithPlay): Step[] => { + const play = story.play + if (play === undefined) return [] + const step: Step = { + ...StepTag, + model: { keyword: 'Given', parts: ['the prior scenario completed'], captures: [] }, + run: (_values: Readonly>, ctx: StepContext) => + Effect.promise(() => Promise.resolve(play(ctx.context))), + } + return [step] +} diff --git a/packages/gherkin/storybook-gherkin/src/Steps.ts b/packages/gherkin/storybook-gherkin/src/Steps.ts new file mode 100644 index 0000000..ed1d41c --- /dev/null +++ b/packages/gherkin/storybook-gherkin/src/Steps.ts @@ -0,0 +1,337 @@ +import { Array as Arr, Effect, Match, Schema } from 'effect' +import { dual } from 'effect/Function' +import type { screen, UserEventObject } from 'storybook/test' +import type { Simplify, UnionToIntersection } from 'type-fest' +import type { Assertion, ExpectStatic } from 'vitest' + +import type { Capture } from './Capture.js' +import { CaptureDecodeFailed, DuplicateCapture } from './Errors.schema.js' + +export type Keyword = 'Given' | 'When' | 'Then' | 'And' | 'But' | 'Star' + +export type ConcreteKeyword = 'Given' | 'When' | 'Then' + +type AnyConstraintDecoder = Schema.ConstraintDecoder +type AnyValue = V +type AnyEffect = Effect.Effect + +export interface CaptureModel { + readonly name: string + /** Service-free decode view — the v4 counterpart of the removed no-context alias. */ + readonly schema: AnyConstraintDecoder | undefined + readonly default: string | undefined +} + +export interface StepModel { + readonly keyword: Keyword + /** Static segments around the holes — length is always captures.length + 1. */ + readonly parts: readonly string[] + readonly captures: readonly CaptureModel[] +} + +export type ExampleRow = { readonly name: string } & Readonly> + +const emptyIfMissing = (part: string | undefined): string => { + if (part === undefined) return '' + return part +} + +const joinStep = ( + step: StepModel, + renderHole: (cap: CaptureModel) => string, +): string => + [ + emptyIfMissing(step.parts[0]), + ...step.captures.flatMap((cap, i) => [renderHole(cap), emptyIfMissing(step.parts[i + 1])]), + ].join('') + +export const displayPattern = (step: StepModel): string => joinStep(step, (cap) => `{${cap.name}}`) + +const firstString = (left: string | undefined, right: string): string => { + if (left !== undefined) return left + return right +} + +const holeText = (cap: CaptureModel, values: Readonly>): string => { + const fromValues = values[cap.name] + if (fromValues !== undefined) return fromValues + return firstString(cap.default, `{${cap.name}}`) +} + +const renderStepTextImpl = (step: StepModel, values: Readonly>): string => + joinStep(step, (cap) => holeText(cap, values)) + +export const renderStepText: { + (values: Readonly>): (step: StepModel) => string + (step: StepModel, values: Readonly>): string +} = dual(2, renderStepTextImpl) + +const resolveKeyword = (keyword: Keyword, previous: ConcreteKeyword): ConcreteKeyword => + Match.value(keyword).pipe( + Match.when('Given', () => 'Given' as const), + Match.when('When', () => 'When' as const), + Match.when('Then', () => 'Then' as const), + Match.when('And', () => previous), + Match.when('But', () => previous), + Match.when('Star', () => previous), + Match.exhaustive, + ) + +export const resolveKeywords = ( + steps: readonly S[], +): readonly (S & { readonly resolved: ConcreteKeyword })[] => { + const initial: ConcreteKeyword = 'Given' + return Arr.mapAccum(steps, initial, (previous: ConcreteKeyword, s) => { + const resolved = resolveKeyword(s.keyword, previous) + return [resolved, { ...s, resolved }] + })[1] +} + +export type Canvas = typeof screen + +export type StepFn = (label: string, fn: () => Promise) => Promise | void + +export interface Report { + readonly type: string + readonly version?: number + readonly result: Result + readonly status: 'failed' | 'passed' | 'warning' +} + +export interface ReportingAPI { + readonly reports: Report[] + readonly addReport: (report: Report) => void +} + +export interface PlayContext { + readonly canvas: Canvas + readonly canvasElement: HTMLElement + readonly step: StepFn + readonly userEvent: UserEventObject + readonly args: TArgs + readonly globals: Record + readonly parameters: Record + readonly loaded: Record + readonly abortSignal: AbortSignal + readonly reporting: ReportingAPI +} + +export interface StepContext { + readonly canvas: Canvas + readonly screen: typeof screen + readonly expect: Omit & { + (actual: T, message?: string): Assertion, T> + } + readonly userEvent: UserEventObject + readonly step: StepFn + readonly args: TArgs + readonly globals: Record + readonly parameters: Record + readonly loaded: Record + readonly canvasElement: HTMLElement + /** + * Fires on story teardown (remount, navigation, HMR). Since the 2026-08-09 + * Effect-composition refactor, the play edge interrupts the in-flight step + * on abort — steps no longer run to completion after teardown. The step + * handler's own promise is abandoned, not cancelled: observe this signal + * for cleanup or to race long-running work. + */ + readonly abortSignal: AbortSignal + readonly reporting: ReportingAPI + readonly context: PlayContext +} + +export type Hole = Capture | string | number + +export type CapsOf = THoles extends readonly [] ? {} + : Simplify< + UnionToIntersection< + { [K in keyof THoles]: THoles[K] extends Capture ? { [P in N]: A } : {} }[number] + > + > + +export type StepHandler = ( + ctx: StepContext, + caps: TCaps, +) => void | Promise + +const STEP_TAG = 'Step' +export const StepTag = { _tag: STEP_TAG } as const +export type StepTag = typeof StepTag + +export interface Step extends StepTag { + readonly model: StepModel + readonly run: ( + values: Readonly>, + ctx: StepContext, + ) => Effect.Effect +} + +export interface StepBuilder { + (handler: StepHandler, TArgs>): Step +} + +export type StepCtor = { + ( + statics: TemplateStringsArray, + ...holes: THoles + ): StepBuilder + ( + statics: TemplateStringsArray, + ...holes: THoles + ): StepBuilder +} + +const partAt = (statics: TemplateStringsArray, index: number): string => emptyIfMissing(statics[index]) + +const appendLiteral = (current: string, literal: string, trailing: string): string => current + literal + trailing + +const consumeNonStringHole = ( + current: string, + hole: Exclude, + trailing: string, + parts: string[], + captures: CaptureModel[], +): string => { + if (typeof hole === 'number') return appendLiteral(current, String(hole), trailing) + parts.push(current) + captures.push({ name: hole.name, schema: hole.schema, default: hole.default }) + return trailing +} + +const consumeHole = ( + current: string, + hole: Hole, + trailing: string, + parts: string[], + captures: CaptureModel[], +): string => { + if (typeof hole === 'string') return appendLiteral(current, hole, trailing) + return consumeNonStringHole(current, hole, trailing, parts, captures) +} + +const rememberCapture = (model: StepModel, seen: Set, cap: CaptureModel): void => { + if (seen.has(cap.name)) { + throw DuplicateCapture.make({ step: displayPattern(model), name: cap.name }) + } + seen.add(cap.name) +} + +const assertUniqueCaptures = (model: StepModel, captures: readonly CaptureModel[]): void => { + const seen = new Set() + for (const cap of captures) { + rememberCapture(model, seen, cap) + } +} + +const buildModel = ( + keyword: Keyword, + statics: TemplateStringsArray, + holes: readonly Hole[], +): StepModel => { + const parts: string[] = [] + const captures: CaptureModel[] = [] + let current = partAt(statics, 0) + for (const [i, hole] of holes.entries()) { + current = consumeHole(current, hole, partAt(statics, i + 1), parts, captures) + } + parts.push(current) + const model: StepModel = { keyword, parts, captures } + assertUniqueCaptures(model, captures) + return model +} + +const captureRaw = ( + cap: CaptureModel, + values: Readonly>, +): string | undefined => { + const fromValues = values[cap.name] + if (fromValues !== undefined) return fromValues + return cap.default +} + +const rawOrEmpty = (raw: string | undefined): string => { + if (raw === undefined) return '' + return raw +} + +const decodeCapture = ( + cap: CaptureModel, + values: Readonly>, + model: StepModel, +): AnyEffect => { + const raw = captureRaw(cap, values) + if (cap.schema === undefined) return Effect.succeed(raw) + return Schema.decodeEffect(cap.schema)(raw).pipe( + Effect.mapError((error) => + CaptureDecodeFailed.make({ + step: displayPattern(model), + capture: cap.name, + value: rawOrEmpty(raw), + cause: error, + }) + ), + ) +} + +const makeStepCtor = (keyword: Keyword): StepCtor => { + function ctor( + statics: TemplateStringsArray, + ...holes: THoles + ): StepBuilder + function ctor( + statics: TemplateStringsArray, + ...holes: THoles + ): StepBuilder + function ctor( + statics: TemplateStringsArray, + ...holes: readonly Hole[] + ): AnyValue { + return (handler: StepHandler, TArgs>): Step => { + const model = buildModel(keyword, statics, holes) + const _step: Step = { + ...StepTag, + model, + run: (values: Readonly>, ctx: StepContext) => + Effect.forEach(model.captures, (cap) => decodeCapture(cap, values, model)).pipe( + Effect.flatMap((decoded) => { + const caps: Record = {} + for (const [cap, value] of Arr.zip(model.captures, decoded)) caps[cap.name] = value + return Effect.promise(() => Promise.resolve(handler(ctx, caps))) + }), + ), + } + return _step + } + } + return ctor +} + +export const Given: StepCtor = makeStepCtor('Given') +export const When: StepCtor = makeStepCtor('When') +export const Then: StepCtor = makeStepCtor('Then') +export const And: StepCtor = makeStepCtor('And') +export const But: StepCtor = makeStepCtor('But') +export const Star: StepCtor = makeStepCtor('Star') + +export const Steps = (...steps: readonly Step[]): Step[] => [...steps] + +const isNonNullObject = (value: unknown): value is object => { + if (typeof value !== 'object') return false + return value !== null +} + +const hasModelAndRun = (value: object): boolean => { + if (!('model' in value)) return false + return 'run' in value +} + +const hasStepFields = (value: object): boolean => { + if (Reflect.get(value, '_tag') !== STEP_TAG) return false + return hasModelAndRun(value) +} + +export const isStep = (value: unknown): value is Step => { + if (!isNonNullObject(value)) return false + return hasStepFields(value) +} diff --git a/packages/gherkin/storybook-gherkin/src/index.ts b/packages/gherkin/storybook-gherkin/src/index.ts new file mode 100644 index 0000000..6c14c9e --- /dev/null +++ b/packages/gherkin/storybook-gherkin/src/index.ts @@ -0,0 +1,35 @@ +export { capture } from './Capture.js' +export type { Capture } from './Capture.js' +export * from './Errors.schema.js' +export { feature, From } from './Feature.js' +export type { + Feature, + FeatureOptions, + OutlineBuilder, + OutlineFn, + RuleScope, + ScenarioFn, + ScenarioOptions, + StepArg, + StorySpec, +} from './Feature.js' +export { And, But, Given, Star, Steps, Then, When } from './Steps.js' +export type { + Canvas, + CapsOf, + CaptureModel, + ConcreteKeyword, + ExampleRow, + Hole, + Keyword, + PlayContext, + Report, + ReportingAPI, + Step, + StepBuilder, + StepContext, + StepCtor, + StepFn, + StepHandler, + StepModel, +} from './Steps.js' diff --git a/packages/gherkin/storybook-gherkin/test/browser/scenario-title.deny.ts b/packages/gherkin/storybook-gherkin/test/browser/scenario-title.deny.ts new file mode 100644 index 0000000..6625e5c --- /dev/null +++ b/packages/gherkin/storybook-gherkin/test/browser/scenario-title.deny.ts @@ -0,0 +1,42 @@ +import type { Feature } from '../../src/Feature.js' + +declare const feature: Feature + +// Compile-time denial: a DAMP title must not typecheck. The functions are never +// invoked — the @ts-expect-error directives are verified by `pnpm typecheck`. +const denyDampTitle = (): void => { + // @ts-expect-error scenario titles are prose, never DAMP Should_[Behavior]_When_[Condition] + feature.scenario('Should_ReportARegression_When_ACheckStartsFailing', []) +} + +const denyDampTitleWithOptions = (): void => { + // @ts-expect-error scenario titles are prose, never DAMP Should_[Behavior]_When_[Condition] + feature.scenario('Should_ReportARegression_When_ACheckStartsFailing', {}, []) +} + +const denyConcatenatedDampTitle = (): void => { + // @ts-expect-error scenario titles are prose, never a concatenated-token DAMP shape + feature.scenario('Reports_A_Regression_When_Check_Fails', []) +} + +const denyCamelCaseDampTitle = (): void => { + // @ts-expect-error scenario titles are prose, never a CamelCase DAMP shape + feature.scenario('ACheckThatFailsIsReportedAsARegression', []) +} + +const denySnakeCaseDampTitle = (): void => { + // @ts-expect-error scenario titles are prose, never a snake_case DAMP shape + feature.scenario('reports_a_regression_when_check_fails', []) +} + +const denySingleWordTitle = (): void => { + // @ts-expect-error scenario titles are prose, at least one space separates words + feature.scenario('Login', []) +} + +void denyDampTitle +void denyDampTitleWithOptions +void denyConcatenatedDampTitle +void denyCamelCaseDampTitle +void denySnakeCaseDampTitle +void denySingleWordTitle diff --git a/packages/gherkin/storybook-gherkin/test/browser/spec.stories.ts b/packages/gherkin/storybook-gherkin/test/browser/spec.stories.ts new file mode 100644 index 0000000..7608a5c --- /dev/null +++ b/packages/gherkin/storybook-gherkin/test/browser/spec.stories.ts @@ -0,0 +1,57 @@ +import { capture, feature, Given, Then, When } from '@systemfsoftware/storybook-gherkin' +import '@vitest/browser/matchers' +import { expect } from 'storybook/test' + +const f = feature({}, {}) + +export default { + ...f, + title: 'StorybookGherkin/Greeting', + render: () => null, +} + +export const AliceReceivesAGreeting = f.scenario( + 'Alice receives a greeting that names her', + { with: { user: 'alice', greeting: 'Hello, alice' } }, + Given`the user ${capture('user')} has an account`(() => {}), + When`the system greets ${capture('user')}`(() => {}), + Then`the greeting mentions ${capture('user')} and reads "${capture('greeting')}"`( + (_ctx, caps) => { + void expect(caps.greeting).toContain(caps.user) + }, + ), +) + +const GreetingsByName = f + .scenarioOutline( + 'a greeting names the user it addresses', + Given`the user ${capture('user')} has an account`(() => {}), + When`the system greets ${capture('user')}`(() => {}), + Then`the greeting mentions ${capture('user')} and reads "${capture('greeting')}"`( + (_ctx, caps) => { + void expect(caps.greeting).toContain(caps.user) + }, + ), + ) + .examples([ + { name: 'Alice is greeted by name', user: 'alice', greeting: 'Hello, alice' }, + { name: 'Bob is greeted by name', user: 'bob', greeting: 'Welcome back, bob' }, + ]) + +export const AliceIsGreetedByName = GreetingsByName['Alice is greeted by name'] +export const BobIsGreetedByName = GreetingsByName['Bob is greeted by name'] + +export const GreetingIsAnnounced = f.scenario( + 'the greeting is announced to the user', + { with: { greeting: 'Hello, alice' } }, + Given`the page announces "${capture('greeting')}"`((ctx, caps) => { + const announcement = document.createElement('p') + announcement.setAttribute('role', 'status') + announcement.textContent = caps.greeting + ctx.canvasElement.appendChild(announcement) + }), + Then`the announcement is visible`((ctx) => ctx.expect(ctx.canvas.getByRole('status')).toBeVisible()), + Then`the announcement reads "${capture('greeting')}"`((ctx, caps) => + ctx.expect(ctx.canvas.getByRole('status')).toMatchAriaInlineSnapshot(`- status: ${caps.greeting}`) + ), +) diff --git a/packages/gherkin/storybook-gherkin/tests/__fixtures__/failure-corpus/failing-story.ts b/packages/gherkin/storybook-gherkin/tests/__fixtures__/failure-corpus/failing-story.ts new file mode 100644 index 0000000..583c116 --- /dev/null +++ b/packages/gherkin/storybook-gherkin/tests/__fixtures__/failure-corpus/failing-story.ts @@ -0,0 +1,41 @@ +import { capture, feature, Given, Then, When } from '@systemfsoftware/storybook-gherkin' +import type { PlayContext } from '@systemfsoftware/storybook-gherkin' +import { Effect } from 'effect' +import { screen, userEvent } from 'storybook/test' +import type { CorpusFixture } from './record.js' +import { StepMishap } from './step-mishap.schema.js' + +export const defectFile = 'packages/gherkin/storybook-gherkin/tests/__fixtures__/failure-corpus/failing-story.ts' + +const STEP = 'Then the greeting mentions {greeting}' + +const greetingStory = () => + feature({}, {}).scenario( + 'Alice receives a greeting that names her', + { with: { user: 'alice', greeting: 'Hello, alice' } }, + Given`the user ${capture('user')} has an account`(() => undefined), + When`the system greets ${capture('user')}`(() => undefined), + Then`the greeting mentions ${capture('greeting')}`(() => { + throw StepMishap.make({ step: STEP, detail: 'the greeting never arrived' }) + }), + ) + +const playContext = (): PlayContext => ({ + canvas: screen, + canvasElement: document.createElement('div'), + step: (_label, promised) => promised(), + userEvent: userEvent.setup(), + args: {}, + globals: {}, + parameters: {}, + loaded: {}, + abortSignal: new AbortController().signal, + reporting: { reports: [], addReport: () => undefined }, +}) + +export const failingStory: CorpusFixture = { + name: 'a story step failure', + defectFile, + raisingFile: defectFile, + program: () => Effect.promise(() => greetingStory().play(playContext())), +} diff --git a/packages/gherkin/storybook-gherkin/tests/__fixtures__/failure-corpus/record.ts b/packages/gherkin/storybook-gherkin/tests/__fixtures__/failure-corpus/record.ts new file mode 100644 index 0000000..529487b --- /dev/null +++ b/packages/gherkin/storybook-gherkin/tests/__fixtures__/failure-corpus/record.ts @@ -0,0 +1,42 @@ +import type { FailureRecord, RecordedRun } from '@systemfsoftware/vitest/failure' +import { Option } from 'effect' + +export const FIRST_LOCATION = /(?:[\w.@-]+\/)*[\w.@-]+\.[cm]?[jt]sx?:\d+/u + +export interface CorpusFixture { + readonly name: string + readonly defectFile: string + readonly raisingFile: string | undefined + readonly program: RecordedRun +} + +export interface VerdictInput { + readonly record: FailureRecord | undefined + readonly fixture: CorpusFixture +} + +export interface CorpusVerdict { + readonly name: string + readonly namesDefectFile: boolean + readonly hasHeadline: boolean + readonly firstLocation: string | undefined + readonly firstLocationFile: string | undefined + readonly breaches: ReadonlyArray +} + +const fileOf = (location: string | undefined): string | undefined => location?.replace(/:\d+$/u, '') + +const verdictOf = (record: FailureRecord, fixture: CorpusFixture): CorpusVerdict => { + const first = FIRST_LOCATION.exec(record.record)?.[0] + return { + name: record.name, + namesDefectFile: record.record.includes(fixture.defectFile), + hasHeadline: record.name.length > 0 && record.record.startsWith(`${record.name}: `), + firstLocation: first, + firstLocationFile: fileOf(first), + breaches: record.breaches, + } +} + +export const verdictOrNull = ({ record, fixture }: VerdictInput): CorpusVerdict | null => + Option.getOrNull(Option.map(Option.fromNullishOr(record), (present) => verdictOf(present, fixture))) diff --git a/packages/gherkin/storybook-gherkin/tests/__fixtures__/failure-corpus/step-mishap.schema.ts b/packages/gherkin/storybook-gherkin/tests/__fixtures__/failure-corpus/step-mishap.schema.ts new file mode 100644 index 0000000..82860d7 --- /dev/null +++ b/packages/gherkin/storybook-gherkin/tests/__fixtures__/failure-corpus/step-mishap.schema.ts @@ -0,0 +1,10 @@ +import { Schema } from 'effect' + +export class StepMishap extends Schema.TaggedError()('StepMishap', { + step: Schema.String, + detail: Schema.String, +}) { + override get message(): string { + return `The step "${this.step}" failed: ${this.detail}` + } +} diff --git a/packages/gherkin/storybook-gherkin/tests/__fixtures__/greeting-story.model.ts b/packages/gherkin/storybook-gherkin/tests/__fixtures__/greeting-story.model.ts new file mode 100644 index 0000000..3cc7d1f --- /dev/null +++ b/packages/gherkin/storybook-gherkin/tests/__fixtures__/greeting-story.model.ts @@ -0,0 +1,9 @@ +export interface StoryPlan { + readonly user: string + readonly greeting: string +} + +export const aliceGreeting: StoryPlan = { + user: 'alice', + greeting: 'Hello, alice', +} diff --git a/packages/gherkin/storybook-gherkin/tests/__fixtures__/story-leave.fixture.ts b/packages/gherkin/storybook-gherkin/tests/__fixtures__/story-leave.fixture.ts new file mode 100644 index 0000000..8173101 --- /dev/null +++ b/packages/gherkin/storybook-gherkin/tests/__fixtures__/story-leave.fixture.ts @@ -0,0 +1,77 @@ +import { capture, feature, Given, Then, When } from '@systemfsoftware/storybook-gherkin' +import type { PlayContext, StepFn } from '@systemfsoftware/storybook-gherkin' +import { Effect } from 'effect' +import type * as Scope from 'effect/Scope' +import { screen, userEvent } from 'storybook/test' +import { aliceGreeting } from './greeting-story.model.js' + +/** + * One visit to one story, staged for a single play: every promise the play + * handed Storybook's step function, and the signal Storybook aborts when the + * visit moves on. + */ +export interface StagedVisit { + readonly handedToStorybook: Array> + readonly movingOn: AbortController +} + +export const freshVisit = (): StagedVisit => ({ handedToStorybook: [], movingOn: new AbortController() }) + +const greetingStory = () => + feature({}, {}).scenario( + 'Alice receives a greeting that names her', + { with: { user: aliceGreeting.user, greeting: aliceGreeting.greeting } }, + Given`the user ${capture('user')} has an account`(() => undefined), + When`the system greets ${capture('user')}`(() => undefined), + Then`the greeting mentions ${capture('user')} and reads "${capture('greeting')}"`(() => undefined), + ) + +/** + * Storybook's step function as the browser runner calls it: it calls the + * promise the play hands it and awaits that promise. Every promise it is + * handed is recorded, so the check can wait on exactly those promises. + */ +const recordingStep = (staged: StagedVisit): StepFn => (_label, promised) => { + const handed = Promise.resolve(promised()) + staged.handedToStorybook.push(handed) + return handed +} + +/** + * The context Storybook supplies to a play. The canvas, the user-event helper + * and the session data are Storybook's own; the element is a real one from the + * conformance project's DOM environment, which is the only member the lane + * cannot take from Storybook itself. + */ +const playContextFor = (staged: StagedVisit): PlayContext => ({ + canvas: screen, + canvasElement: document.createElement('div'), + step: recordingStep(staged), + userEvent: userEvent.setup(), + args: {}, + globals: {}, + parameters: {}, + loaded: {}, + abortSignal: staged.movingOn.signal, + reporting: { reports: [], addReport: () => undefined }, +}) + +/** + * One play of the story, played once. The play runs with the visit's signal, + * and closing the scope aborts that signal — the path Storybook's teardown + * takes to the play. + */ +export const playedOnce = (staged: StagedVisit): Effect.Effect => + Effect.acquireRelease( + Effect.sync(() => staged.movingOn), + (movingOn) => Effect.sync(() => movingOn.abort()), + ).pipe(Effect.andThen(Effect.promise(() => greetingStory().play(playContextFor(staged))))) + +/** + * Every promise the play handed Storybook's step function, once every one of + * them has settled. This waits on the promises themselves: nothing here counts + * steps or sleeps, so a promise that never settles leaves the check waiting on + * it with nothing else able to run. + */ +export const everyStepSettled = (staged: StagedVisit): Effect.Effect => + Effect.asVoid(Effect.promise(() => Promise.allSettled(staged.handedToStorybook))) diff --git a/packages/gherkin/storybook-gherkin/tests/failure-corpus.storybook.integration.test.ts b/packages/gherkin/storybook-gherkin/tests/failure-corpus.storybook.integration.test.ts new file mode 100644 index 0000000..1433692 --- /dev/null +++ b/packages/gherkin/storybook-gherkin/tests/failure-corpus.storybook.integration.test.ts @@ -0,0 +1,31 @@ +import { Gherkin, Given, it, makeFeature, Then } from '@systemfsoftware/effect-gherkin-spec' +import { recordOfRun } from '@systemfsoftware/vitest/failure' +import { Effect, Layer } from 'effect' +import { failingStory } from './__fixtures__/failure-corpus/failing-story.js' +import { verdictOrNull } from './__fixtures__/failure-corpus/record.js' + +const Feature = makeFeature({ it }) + +Feature('The storybook gherkin family prints a failure record that names the defect') + .withLayer(Layer.empty) + .live('the corpus plays a story through the play edge in the conformance project') + .body(({ scenario }) => { + scenario( + 'A failing story step yields a record naming its defect file and starting at the raising frame', + Gherkin.Do.pipe( + Given('a defective story whose step failure the corpus renders')( + 'record', + () => Effect.promise(() => recordOfRun(failingStory.program)), + ), + Then('the record names the defect file and starts at the raising frame')((s, expect) => + expect(verdictOrNull({ record: s.record, fixture: failingStory })).toMatchObject({ + name: 'StepMishap', + namesDefectFile: true, + hasHeadline: true, + firstLocationFile: failingStory.raisingFile, + breaches: [], + }) + ), + ), + ) + }) diff --git a/packages/gherkin/storybook-gherkin/tests/story-leave.conformance.test.ts b/packages/gherkin/storybook-gherkin/tests/story-leave.conformance.test.ts new file mode 100644 index 0000000..846b916 --- /dev/null +++ b/packages/gherkin/storybook-gherkin/tests/story-leave.conformance.test.ts @@ -0,0 +1,29 @@ +import { Conformance } from '@systemfsoftware/conformance-spec' +import { Gherkin, Given, it, makeFeature, Then, When } from '@systemfsoftware/effect-gherkin-spec' +import { Effect, Layer, Schema } from 'effect' + +import { everyStepSettled, freshVisit, playedOnce } from './__fixtures__/story-leave.fixture.js' + +const Feature = makeFeature({ it }) + +Feature('Leaving a story with nothing hanging when the visit moves on', { timeout: 120_000 }) + .withLayer(Layer.empty) + .live('each scenario drives the simulation kernel itself, and a conformance check cannot run inside a kernel run') + .body(({ scenario }) => { + scenario( + 'A story the visit walks away from mid-step leaves no step waiting for it', + Gherkin.Do.pipe( + Given('a visit to a story that greets Alice by name in three steps')('visit', () => Effect.sync(freshVisit)), + When('the visit moves on at every step of the story')( + 'checked', + (s) => Conformance.released(playedOnce(s.visit), { probe: everyStepSettled(s.visit) }), + ), + Then('no step is left hanging and the visit moved on at least once')((s, expect) => + expect(s.checked, Conformance.render(s.checked)).toMatchObject({ + _tag: 'Pass', + histories: expect.schemaMatching(Schema.Int.pipe(Schema.check(Schema.isGreaterThan(0)))), + }) + ), + ), + ) + }) diff --git a/packages/gherkin/storybook-gherkin/tsconfig.api.json b/packages/gherkin/storybook-gherkin/tsconfig.api.json new file mode 100644 index 0000000..02a0563 --- /dev/null +++ b/packages/gherkin/storybook-gherkin/tsconfig.api.json @@ -0,0 +1,6 @@ +{ + "extends": "@systemfsoftware/tsconfig/tsc/dom/library-monorepo", + "compilerOptions": { + "customConditions": [] + } +} diff --git a/packages/gherkin/storybook-gherkin/tsconfig.app.json b/packages/gherkin/storybook-gherkin/tsconfig.app.json new file mode 100644 index 0000000..8871499 --- /dev/null +++ b/packages/gherkin/storybook-gherkin/tsconfig.app.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": [ + "@systemfsoftware/tsconfig/tsc/dom/library-monorepo", + "@systemfsoftware/tsconfig/effect/entrypoint" + ], + "compilerOptions": { + "composite": true, + "noEmit": false, + "emitDeclarationOnly": true, + "outDir": "node_modules/.cache/tsc/app", + "tsBuildInfoFile": "node_modules/.cache/tsconfig.app.tsbuildinfo", + "customConditions": [ + "@systemfsoftware/source" + ], + "types": [] + }, + "include": [ + "src" + ] +} diff --git a/packages/gherkin/storybook-gherkin/tsconfig.build.json b/packages/gherkin/storybook-gherkin/tsconfig.build.json new file mode 100644 index 0000000..38f293d --- /dev/null +++ b/packages/gherkin/storybook-gherkin/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.app.json", + "compilerOptions": { + "declarationMap": false, + "sourceMap": false, + "stripInternal": true + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts", "src/**/*.spec.ts"] +} diff --git a/packages/gherkin/storybook-gherkin/tsconfig.dts.json b/packages/gherkin/storybook-gherkin/tsconfig.dts.json new file mode 100644 index 0000000..3edcb44 --- /dev/null +++ b/packages/gherkin/storybook-gherkin/tsconfig.dts.json @@ -0,0 +1,9 @@ +{ + "extends": "@systemfsoftware/tsconfig/tsc/dom/library-monorepo", + "compilerOptions": { + "composite": false, + "skipLibCheck": false, + "types": ["node"] + }, + "include": ["dist/*.d.ts"] +} diff --git a/packages/gherkin/storybook-gherkin/tsconfig.json b/packages/gherkin/storybook-gherkin/tsconfig.json new file mode 100644 index 0000000..16302a3 --- /dev/null +++ b/packages/gherkin/storybook-gherkin/tsconfig.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "files": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.test.json" + }, + { + "path": "./tsconfig.node.json" + } + ] +} diff --git a/packages/gherkin/storybook-gherkin/tsconfig.node.json b/packages/gherkin/storybook-gherkin/tsconfig.node.json new file mode 100644 index 0000000..33175be --- /dev/null +++ b/packages/gherkin/storybook-gherkin/tsconfig.node.json @@ -0,0 +1,16 @@ +{ + "extends": "@systemfsoftware/tsconfig/node", + "compilerOptions": { + "composite": true, + "noEmit": true, + "tsBuildInfoFile": "node_modules/.cache/tsconfig.node.tsbuildinfo" + }, + "include": [ + ".storybook/main.ts", + ".storybook/preview.ts", + "oxlint.config.ts", + "scripts/check-dts.mjs", + "tsdown.config.ts", + "vitest.config.ts" + ] +} diff --git a/packages/gherkin/storybook-gherkin/tsconfig.test.json b/packages/gherkin/storybook-gherkin/tsconfig.test.json new file mode 100644 index 0000000..46ab60e --- /dev/null +++ b/packages/gherkin/storybook-gherkin/tsconfig.test.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": [ + "@systemfsoftware/tsconfig/tsc/dom/library-monorepo", + "@systemfsoftware/tsconfig/effect/entrypoint" + ], + "compilerOptions": { + "composite": true, + "noEmit": false, + "emitDeclarationOnly": true, + "outDir": "node_modules/.cache/tsc/test", + "tsBuildInfoFile": "node_modules/.cache/tsconfig.test.tsbuildinfo", + "customConditions": [ + "@systemfsoftware/source" + ], + "types": [] + }, + "include": [ + "test/browser", + "tests" + ], + "references": [ + { + "path": "./tsconfig.app.json" + } + ] +} diff --git a/packages/gherkin/storybook-gherkin/tsdown.config.ts b/packages/gherkin/storybook-gherkin/tsdown.config.ts new file mode 100644 index 0000000..0e115de --- /dev/null +++ b/packages/gherkin/storybook-gherkin/tsdown.config.ts @@ -0,0 +1,30 @@ +import { quietBuild } from '@systemfsoftware/tsdown-config/quiet-build' +import { defineConfig } from 'tsdown' + +type ExportEntry = string | Record + +const injectTypes = (exports: Record): Record => { + const entry = exports['.'] + if (typeof entry === 'string') { + exports['.'] = { types: './dist/index.d.ts', default: entry } + } else if (typeof entry === 'object' && Boolean(entry)) { + const { default: defaultEntry, types: _existingTypes, ...rest } = entry + let withDefault: Record = {} + if (typeof defaultEntry === 'string') { + withDefault = { default: defaultEntry } + } + exports['.'] = { ...rest, types: './dist/index.d.ts', ...withDefault } + } + return exports +} + +export default defineConfig({ + ...quietBuild, + entry: { index: './src/index.ts' }, + format: 'esm', + dts: true, + exports: { devExports: '@systemfsoftware/source', customExports: injectTypes }, + tsconfig: './tsconfig.build.json', + outExtensions: () => ({ js: '.mjs', dts: '.d.ts' }), + deps: { onlyBundle: false }, +}) diff --git a/packages/gherkin/storybook-gherkin/vitest.config.ts b/packages/gherkin/storybook-gherkin/vitest.config.ts new file mode 100644 index 0000000..4739705 --- /dev/null +++ b/packages/gherkin/storybook-gherkin/vitest.config.ts @@ -0,0 +1,43 @@ +import { storybookTest } from '@storybook/addon-vitest/vitest-plugin' +import { defineConfig, sourceResolveConditions } from '@systemfsoftware/vitest-config' +import { playwright } from '@vitest/browser-playwright' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const dirname = path.dirname(fileURLToPath(import.meta.url)) + +export default defineConfig({ + ...sourceResolveConditions, + test: { + passWithNoTests: true, + projects: [ + { + extends: true, + plugins: [ + storybookTest({ + configDir: path.join(dirname, '.storybook'), + storybookScript: 'pnpm storybook -- --ci', + }), + ], + test: { + name: 'storybook', + browser: { + enabled: true, + provider: playwright({}), + headless: true, + instances: [{ browser: 'chromium' }], + }, + }, + }, + { + extends: true, + test: { + name: 'conformance', + environment: 'jsdom', + globals: true, + include: ['tests/**/*.test.ts'], + }, + }, + ], + }, +}) diff --git a/packages/oxlint-presets/oxlint-config-frontend/.attw.json b/packages/oxlint-presets/oxlint-config-frontend/.attw.json new file mode 100644 index 0000000..0a440a4 --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/.attw.json @@ -0,0 +1,6 @@ +{ + "ignoreRules": [ + "no-resolution", + "cjs-resolves-to-esm" + ] +} diff --git a/packages/oxlint-presets/oxlint-config-frontend/LICENSE b/packages/oxlint-presets/oxlint-config-frontend/LICENSE new file mode 100644 index 0000000..896c374 --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/LICENSE @@ -0,0 +1,203 @@ +Copyright (c) 2026 Ryan Lee (systemfsoftware) + +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Ryan Lee (systemfsoftware) + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/oxlint-presets/oxlint-config-frontend/oxlint.config.ts b/packages/oxlint-presets/oxlint-config-frontend/oxlint.config.ts new file mode 100644 index 0000000..a4a8ce1 --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/oxlint.config.ts @@ -0,0 +1,7 @@ +import ruleAuthoring from '@systemfsoftware/oxlint-config-rule-authoring' +import { defineConfig } from 'oxlint' + +export default defineConfig({ + extends: [ruleAuthoring], + ignorePatterns: ['ui-state-fixture/**'], +}) diff --git a/packages/oxlint-presets/oxlint-config-frontend/package.json b/packages/oxlint-presets/oxlint-config-frontend/package.json new file mode 100644 index 0000000..c00a6aa --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/package.json @@ -0,0 +1,82 @@ +{ + "name": "@systemfsoftware/oxlint-config-frontend", + "license": "Apache-2.0", + "version": "0.1.0", + "author": "TODO ", + "repository": { + "type": "git", + "url": "git+https://github.com/systemfsoftware/frontend.git", + "directory": "packages/oxlint-presets/oxlint-config-frontend" + }, + "homepage": "https://github.com/systemfsoftware/frontend/tree/main/packages/oxlint-presets/oxlint-config-frontend#readme", + "bugs": "https://github.com/systemfsoftware/frontend/issues", + "description": "Frontend Oxlint configuration: the systemfsoftware recommended preset plus the React rules and the ui-state and storybook-testing bans.", + "keywords": [ + "oxlint", + "oxlint-config", + "preset", + "react", + "effect-atom", + "storybook", + "typescript" + ], + "type": "module", + "files": [ + "dist" + ], + "exports": { + ".": { + "@systemfsoftware/source": "./src/index.ts", + "types": "./dist/index.d.ts", + "default": "./dist/index.mjs" + }, + "./package.json": "./package.json" + }, + "scripts": { + "clean": "rimraf dist", + "build": "tsdown -l warn", + "typecheck": "tsc -b", + "lint": "f=${OXLINT_FORMAT:-${AGENT:+agent}}; oxlint . --format=${f:-default}", + "attw": "attw --pack .", + "test": "vitest run" + }, + "dependencies": { + "@oxlint/plugins": "catalog:oxlint", + "@systemfsoftware/oxlint-config-recommended": "4.0.0", + "@systemfsoftware/oxlint-plugin-effect-platform": "2.0.0", + "oxlint": "catalog:oxlint", + "oxlint-tsgolint": "catalog:oxlint" + }, + "devDependencies": { + "@systemfsoftware/arethetypeswrong-cli": "catalog:", + "@systemfsoftware/oxlint-config-rule-authoring": "^1.1.0", + "@systemfsoftware/tsconfig": "^2.0.1", + "@systemfsoftware/tsdown-config": "^0.1.0", + "@systemfsoftware/vitest-config": "^0.1.0", + "@types/node": "catalog:", + "effect": "catalog:", + "oxlint": "catalog:oxlint", + "oxlint-tsgolint": "catalog:oxlint", + "rimraf": "catalog:", + "tsdown": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + }, + "peerDependencies": { + "effect": "catalog:peers", + "oxlint": "catalog:oxlint", + "oxlint-tsgolint": "catalog:oxlint", + "typescript": ">=5.0.0" + }, + "publishConfig": { + "access": "public", + "provenance": true, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.mjs" + }, + "./package.json": "./package.json" + } + } +} diff --git a/packages/oxlint-presets/oxlint-config-frontend/src/index.ts b/packages/oxlint-presets/oxlint-config-frontend/src/index.ts new file mode 100644 index 0000000..8b9759b --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/src/index.ts @@ -0,0 +1,180 @@ +import generic from '@systemfsoftware/oxlint-config-recommended' +import { noNodeBuiltinImports } from '@systemfsoftware/oxlint-plugin-effect-platform' +import type { OxlintConfig, OxlintOverride } from 'oxlint' + +import { REACT_IMPORT_BAN_NAMES } from './rules/no-react-effect.config.js' + +export interface ImportPattern { + readonly group?: string[] + readonly regex?: string + readonly importNames?: string[] + readonly message: string +} + +export type PatternImportBan = ['error', { readonly patterns: ImportPattern[] }] + +const serverStateLibraries = [ + '@tanstack/react-query', + '@tanstack/react-query-devtools', + '@tanstack/react-query-persist-client', + '@tanstack/react-query-next-experimental', + 'react-query', + 'swr', + 'urql', + '@apollo/client/react', + '@reduxjs/toolkit/query/react', + '@trpc/react-query', + '@trpc/tanstack-react-query', +] + +const serverStatePatterns: ImportPattern[] = [ + { + group: serverStateLibraries.flatMap((name) => [name, `${name}/**`]), + message: + 'ui-state/server-data-lives-in-atoms: React server-state libraries are forbidden — read server data through effect-atom runtime atoms (appRuntime.atom, useAtomValue) over Effect services.', + }, +] + +const reactEffectPatterns: ImportPattern[] = [ + { + regex: '^react$', + importNames: [...REACT_IMPORT_BAN_NAMES], + message: + 'ui-state/react-runs-no-effect: import react by name, and run no React Effect — read through an effect-atom runtime atom with useAtomValue, dispatch a command with useAtomSet.', + }, +] + +const uiStatePatterns: ImportPattern[] = [...serverStatePatterns, ...reactEffectPatterns] + +const UI_STATE_LAW = 'ui-state/' + +export const uiStateImports: PatternImportBan = ['error', { patterns: uiStatePatterns }] + +type ImportRuleValue = NonNullable['no-restricted-imports'] + +const bansByPattern = (value: ImportRuleValue): value is ['error', { readonly patterns: ImportPattern[] }] => + Array.isArray(value) && typeof value[1] === 'object' && 'patterns' in value[1] && Array.isArray(value[1].patterns) + +const withoutGenericUiStatePatterns = (value: ImportRuleValue): ImportRuleValue => + bansByPattern(value) + ? ['error', { + ...value[1], + patterns: value[1].patterns.filter((pattern) => !pattern.message.startsWith(UI_STATE_LAW)), + }] + : value + +const withUiState = (value: ImportRuleValue): PatternImportBan => { + if (value === 'off') return uiStateImports + if (bansByPattern(value)) return ['error', { ...value[1], patterns: [...value[1].patterns, ...uiStatePatterns] }] + throw new Error( + `no-restricted-imports value ${ + JSON.stringify(value) + } is neither 'off' nor a patterns ban; compose the ui-state ban with it by hand`, + ) +} + +export const nodeBuiltinAndUiStateImports: PatternImportBan = withUiState(noNodeBuiltinImports) + +const withUiStateOverride = (override: OxlintOverride): OxlintOverride => + override.rules?.['no-restricted-imports'] === undefined + ? override + : { + ...override, + rules: { + ...override.rules, + 'no-restricted-imports': withUiState(withoutGenericUiStatePatterns(override.rules['no-restricted-imports'])), + }, + } + +// react/rules-of-hooks judges a hook by its name alone, so it reports Effect's `Service.use(...)` +// member calls; those live in .ts modules (atom recipes, service wiring), never in components. It stays +// an error where components live, in `**/*.tsx`, and `react/hooks` checks the Rules of Hooks in every file. +const reactRules: NonNullable = { + 'react/rules-of-hooks': 'error', + 'react/hooks': 'error', + 'react/capitalized-calls': 'error', + 'react/rule-suppression': 'error', + 'react/unsupported-syntax': 'error', + 'react/no-unstable-nested-components': 'error', + 'react/no-array-index-key': 'error', + 'react/no-clone-element': 'error', + 'react/no-react-children': 'error', + 'react/prefer-function-component': 'error', + '@systemfsoftware/ui-state/no-react-effect': 'error', +} + +const fetchInComponentsMessage = + 'ui-state/components-perform-no-io: a .tsx module performs no I/O — reach the server through an Effect service in a .ts module, read through an effect-atom runtime atom.' + +export interface RestrictedGlobal { + readonly name: string + readonly message: string +} + +export type RestrictedGlobalsBan = ['error', ...RestrictedGlobal[]] + +export interface RestrictedProperty { + readonly object: string + readonly property: string + readonly message: string +} + +export type RestrictedPropertiesBan = ['error', ...RestrictedProperty[]] + +export const fetchInComponentsGlobals: RestrictedGlobalsBan = ['error', { + name: 'fetch', + message: fetchInComponentsMessage, +}] + +const fetchInComponentsEntries: RestrictedProperty[] = ['globalThis', 'window', 'self'].map((object) => ({ + object, + property: 'fetch', + message: fetchInComponentsMessage, +})) + +export const fetchInComponentsProperties: RestrictedPropertiesBan = ['error', ...fetchInComponentsEntries] + +const fetchInComponentsOverride: OxlintOverride = { + files: ['**/*.tsx'], + rules: { + 'no-restricted-globals': fetchInComponentsGlobals, + 'no-restricted-properties': fetchInComponentsProperties, + }, +} + +const storyScreenshotEntries: RestrictedProperty[] = [{ + object: 'page', + property: 'screenshot', + message: + 'storybook-testing/visual-evidence-comes-from-the-runner: a story file never calls page.screenshot — the runner captures each story through one hook run for every story.', +}] + +export const storyScreenshotProperties: RestrictedPropertiesBan = ['error', ...storyScreenshotEntries] + +export const fetchAndStoryScreenshotProperties: RestrictedPropertiesBan = [ + 'error', + ...fetchInComponentsEntries, + ...storyScreenshotEntries, +] + +const storyScreenshotOverrides: OxlintOverride[] = [ + { files: ['**/*.stories.ts'], rules: { 'no-restricted-properties': storyScreenshotProperties } }, + { files: ['**/*.stories.tsx'], rules: { 'no-restricted-properties': fetchAndStoryScreenshotProperties } }, +] + +const uiStatePlugin = new URL('./plugin.mjs', import.meta.url).href + +const frontendConfig: OxlintConfig = { + ...generic, + plugins: [...(generic.plugins ?? []), 'react'], + jsPlugins: [...(generic.jsPlugins ?? []), uiStatePlugin], + rules: { ...generic.rules, ...reactRules, 'no-restricted-imports': uiStateImports }, + overrides: [ + ...(generic.overrides ?? []).map(withUiStateOverride), + { files: ['**/*.ts'], rules: { 'react/rules-of-hooks': 'off' } }, + fetchInComponentsOverride, + ...storyScreenshotOverrides, + ], +} + +export default frontendConfig diff --git a/packages/oxlint-presets/oxlint-config-frontend/src/plugin.ts b/packages/oxlint-presets/oxlint-config-frontend/src/plugin.ts new file mode 100644 index 0000000..cb2ae0a --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/src/plugin.ts @@ -0,0 +1,10 @@ +import { noReactEffect } from './rules/no-react-effect.js' + +export default { + meta: { + name: '@systemfsoftware/ui-state', + }, + rules: { + 'no-react-effect': noReactEffect, + }, +} diff --git a/packages/oxlint-presets/oxlint-config-frontend/src/rules/__tests__/no-react-effect.test.ts b/packages/oxlint-presets/oxlint-config-frontend/src/rules/__tests__/no-react-effect.test.ts new file mode 100644 index 0000000..83a8e24 --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/src/rules/__tests__/no-react-effect.test.ts @@ -0,0 +1,144 @@ +import { RuleTester } from 'oxlint/plugins-dev' +import * as vitest from 'vitest' + +import { effectBanDirectiveVerdict, REACT_EFFECT_EXPECTED, reactModuleLoadVerdict } from '../no-react-effect.config.js' +import { noReactEffect } from '../no-react-effect.js' + +RuleTester.it = vitest.it +RuleTester.itOnly = vitest.it.only +RuleTester.describe = vitest.describe + +const ruleTester = new RuleTester({ + languageOptions: { + parserOptions: { + lang: 'ts', + }, + }, +}) + +const verdictError = (verdict: { readonly name: string; readonly actual: string; readonly fix: string }) => ({ + messageId: 'reactEffect' as const, + data: { ...verdict, expected: REACT_EFFECT_EXPECTED }, +}) + +const loadError = (name: string) => verdictError(reactModuleLoadVerdict(name)) +const directiveError = (form: string, line: number) => verdictError(effectBanDirectiveVerdict(form, line)) + +const REACT_EFFECT_IMPORT = "import { useEffect } from 'react'\n" +const RULE_ID = '@systemfsoftware/ui-state/no-react-effect' + +ruleTester.run('no-react-effect', noReactEffect, { + valid: [ + { + name: 'Should_Pass_When_RequireIsALocalFunction', + code: "const require = (id: string): string => id\nrequire('react')", + filename: 'src/feature.ts', + }, + { + name: 'Should_Pass_When_ADynamicImportLoadsAnotherModule', + code: "import('./react')", + filename: 'src/feature.ts', + }, + { + name: 'Should_Pass_When_ADirectiveNamesAnotherRule', + code: "import { useMemo } from 'react'\n// oxlint-disable-next-line no-console\nuseMemo(() => 1, [])", + filename: 'src/feature.ts', + }, + { + name: 'Should_Pass_When_AnImportBanDirectiveCoversANonReactImport', + code: "// oxlint-disable-next-line no-restricted-imports\nimport { readFileSync } from 'node:fs'", + filename: 'src/feature.ts', + }, + { + name: 'Should_Pass_When_AnUnnamedDirectiveCoversOnlyTheEffectCall', + code: `${REACT_EFFECT_IMPORT}// oxlint-disable-next-line\nuseEffect(f, [f])`, + filename: 'src/feature.ts', + }, + { + name: 'Should_Pass_When_AnImportBanBlockDirectiveIsClosedBeforeTheReactImport', + code: + "/* oxlint-disable no-restricted-imports */\nimport { readFileSync } from 'node:fs'\n/* oxlint-enable no-restricted-imports */\nimport { useEffect } from 'react'", + filename: 'src/feature.ts', + }, + ], + invalid: [ + { + name: 'Should_Report_When_ReactIsLoadedByDynamicImport', + code: "import('react')", + filename: 'src/feature.ts', + errors: [loadError("import('react')")], + }, + { + name: 'Should_Report_When_ReactIsLoadedByTemplateDynamicImport', + code: 'import(`react`)', + filename: 'src/feature.ts', + errors: [loadError("import('react')")], + }, + { + name: 'Should_Report_When_ReactIsLoadedByGlobalRequire', + code: "require('react')", + filename: 'src/feature.ts', + errors: [loadError("require('react')")], + }, + { + name: 'Should_Report_When_ANextLineDirectiveNamesTheRule', + code: `${REACT_EFFECT_IMPORT}// oxlint-disable-next-line ${RULE_ID} -- reason\nuseEffect(f, [f])`, + filename: 'src/feature.ts', + errors: [directiveError('oxlint-disable-next-line', 2)], + }, + { + name: 'Should_Report_When_AFileDirectiveNamesTheRule', + code: `/* oxlint-disable ${RULE_ID} */\n${REACT_EFFECT_IMPORT}useEffect(f, [f])`, + filename: 'src/feature.ts', + errors: [directiveError('oxlint-disable', 1)], + }, + { + name: 'Should_Report_When_AnEslintDirectiveNamesTheRule', + code: `${REACT_EFFECT_IMPORT}useEffect(f, [f]) // eslint-disable-line ${RULE_ID}`, + filename: 'src/feature.ts', + errors: [directiveError('eslint-disable-line', 2)], + }, + { + name: 'Should_Report_When_ADirectiveNamesTheRuleWithNoReachToCover', + code: `// oxlint-disable-next-line ${RULE_ID}\nconst a = 1`, + filename: 'src/feature.ts', + errors: [directiveError('oxlint-disable-next-line', 1)], + }, + { + name: 'Should_Report_When_AnImportBanDirectiveCoversTheUseEffectImport', + code: `// oxlint-disable-next-line no-restricted-imports\n${REACT_EFFECT_IMPORT}`, + filename: 'src/feature.ts', + errors: [directiveError('oxlint-disable-next-line', 1)], + }, + { + name: 'Should_Report_When_AnImportBanDirectiveCoversARefusedReactImport', + code: "// oxlint-disable-next-line no-restricted-imports\nimport { useLayoutEffect } from 'react'", + filename: 'src/feature.ts', + errors: [directiveError('oxlint-disable-next-line', 1)], + }, + { + name: 'Should_Report_When_AnUnnamedDirectiveCoversTheUseEffectReexport', + code: "// oxlint-disable-next-line\nexport { useEffect } from 'react'", + filename: 'src/feature.ts', + errors: [directiveError('oxlint-disable-next-line', 1)], + }, + { + name: 'Should_Report_When_AnUnnamedFileDirectiveCoversANamespaceReactImport', + code: "/* eslint-disable */\nimport * as React from 'react'", + filename: 'src/feature.ts', + errors: [directiveError('eslint-disable', 1)], + }, + { + name: 'Should_Report_When_AnImportBanBlockDirectiveCoversExportAllFromReact', + code: "/* oxlint-disable eslint/no-restricted-imports */\nexport * from 'react'", + filename: 'src/feature.ts', + errors: [directiveError('oxlint-disable', 1)], + }, + { + name: 'Should_Report_When_AnUnnamedDirectiveCoversAReactLoad', + code: "// oxlint-disable-next-line\nrequire('react')", + filename: 'src/feature.ts', + errors: [directiveError('oxlint-disable-next-line', 1), loadError("require('react')")], + }, + ], +}) diff --git a/packages/oxlint-presets/oxlint-config-frontend/src/rules/no-react-effect.config.ts b/packages/oxlint-presets/oxlint-config-frontend/src/rules/no-react-effect.config.ts new file mode 100644 index 0000000..140b171 --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/src/rules/no-react-effect.config.ts @@ -0,0 +1,55 @@ +export const RULE_ID = 'ui-state/react-runs-no-effect' as const + +export const REACT_MODULE = 'react' as const + +export const REACT_IMPORT_BAN_NAMES: readonly string[] = [ + 'useEffect', + 'useLayoutEffect', + 'useInsertionEffect', + 'useEffectEvent', + 'default', +] + +export const REACT_EFFECT_DIRECTIVE_ID = '@systemfsoftware/ui-state/no-react-effect' as const +export const IMPORT_BAN_DIRECTIVE_IDS: readonly string[] = ['no-restricted-imports', 'eslint/no-restricted-imports'] + +export const REACT_EFFECT_MESSAGE = + '{{name}} is forbidden. Expected: {{expected}}. Actual: {{actual}}. Fix: {{fix}}.' as const + +export const REACT_EFFECT_EXPECTED = + `${RULE_ID} — React code runs no Effect: reads go through an effect-atom runtime atom with \`useAtomValue\`, commands through \`useAtomSet\`` as const + +export const REACT_MODULE_FIX = "import each React API by name: `import { useMemo } from 'react'`" as const + +export const DIRECTIVE_FIX = + 'delete the `oxlint-disable` or `eslint-disable` comment and the `react` import or load it covers; the ban admits no inline exception' as const + +export interface ReactEffectVerdict { + readonly name: string + readonly actual: string + readonly fix: string +} + +export const reactModuleLoadVerdict = (name: string): ReactEffectVerdict => ({ + name, + actual: `a load of the whole ${REACT_MODULE} module, which reaches its Effect hooks as members`, + fix: REACT_MODULE_FIX, +}) + +export const effectBanDirectiveVerdict = (form: string, line: number): ReactEffectVerdict => ({ + name: form, + actual: `a directive at line ${String(line)} that turns the React Effect ban off`, + fix: DIRECTIVE_FIX, +}) + +export const meta = { + type: 'problem', + docs: { + description: + "Refuses every `import('react')` and global `require('react')` with a literal specifier, and every disable directive that turns the React Effect ban off: one naming this rule, one naming no rule that covers a `react` import the preset's import ban refuses or a load this rule reports, and one naming `no-restricted-imports` that covers a refused `react` import. Law: ui-state/react-runs-no-effect. `useEffect`, `useLayoutEffect`, `useInsertionEffect`, `useEffectEvent` and the default and namespace imports of `react` are refused by the preset's import ban, with no exception.", + }, + schema: [], + messages: { + reactEffect: REACT_EFFECT_MESSAGE, + }, +} as const diff --git a/packages/oxlint-presets/oxlint-config-frontend/src/rules/no-react-effect.ts b/packages/oxlint-presets/oxlint-config-frontend/src/rules/no-react-effect.ts new file mode 100644 index 0000000..27d8206 --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/src/rules/no-react-effect.ts @@ -0,0 +1,197 @@ +import { defineRule } from '@oxlint/plugins' +import type { Comment, Context, ESTree } from '@oxlint/plugins' + +import { + effectBanDirectiveVerdict, + IMPORT_BAN_DIRECTIVE_IDS, + meta, + REACT_EFFECT_DIRECTIVE_ID, + REACT_EFFECT_EXPECTED, + REACT_IMPORT_BAN_NAMES, + REACT_MODULE, + reactModuleLoadVerdict, +} from './no-react-effect.config.js' +import type { ReactEffectVerdict } from './no-react-effect.config.js' + +export type MessageIds = 'reactEffect' + +type GetScope = (node: ESTree.Node) => unknown + +type IdentifierNode = ESTree.Node & { readonly type: 'Identifier'; readonly name: string } + +interface DisableDirective { + readonly type: 'disable' | 'enable' | 'disable-line' | 'disable-next-line' + readonly value: string + readonly node: Comment +} + +const REFUSED_REACT_NAMES: Record = Object.fromEntries( + REACT_IMPORT_BAN_NAMES.map((name) => [name, true] as const), +) + +const isIdentifierNode = (node: ESTree.Node): node is IdentifierNode => node.type === 'Identifier' + +const staticStringOf = (node: ESTree.Node | undefined): string | null => { + if (node === undefined) return null + if (node.type === 'Literal') return typeof node.value === 'string' ? node.value : null + if (node.type === 'TemplateLiteral' && node.expressions.length === 0) return node.quasis[0]?.value.cooked ?? null + return null +} + +interface VariableLike { + readonly name: string +} + +interface BindingScopeLike { + readonly upper: BindingScopeLike | null + readonly variables: readonly VariableLike[] +} + +const isBindingScopeLike = (value: unknown): value is BindingScopeLike => + typeof value === 'object' && value !== null && 'variables' in value && 'upper' in value + +const isLocallyBound = (node: IdentifierNode, getScope: GetScope): boolean => { + const scopeValue: unknown = getScope(node) + if (!isBindingScopeLike(scopeValue)) return false + for (let scope: BindingScopeLike | null = scopeValue; scope !== null; scope = scope.upper) { + if (scope.variables.some((variable) => variable.name === node.name)) return true + } + return false +} + +const isGlobalRequire = (callee: ESTree.Node, getScope: GetScope): boolean => + isIdentifierNode(callee) && callee.name === 'require' && !isLocallyBound(callee, getScope) + +const moduleExportNameOf = (node: ESTree.Node): string | null => { + if (node.type === 'Identifier') return node.name + if (node.type === 'Literal' && typeof node.value === 'string') return node.value + return null +} + +const isRefusedName = (node: ESTree.Node): boolean => { + const name = moduleExportNameOf(node) + return name !== null && REFUSED_REACT_NAMES[name] === true +} + +const isRefusedReactSpecifier = (specifier: ESTree.Node): boolean => { + if (specifier.type === 'ImportDefaultSpecifier' || specifier.type === 'ImportNamespaceSpecifier') return true + if (specifier.type === 'ImportSpecifier') return isRefusedName(specifier.imported) + if (specifier.type === 'ExportSpecifier') return isRefusedName(specifier.local) + return false +} + +const isRefusedReactDeclaration = (statement: ESTree.Node): boolean => { + if (statement.type === 'ExportAllDeclaration') return statement.source.value === REACT_MODULE + if (statement.type === 'ImportDeclaration') { + return statement.source.value === REACT_MODULE && statement.specifiers.some(isRefusedReactSpecifier) + } + if (statement.type === 'ExportNamedDeclaration') { + return statement.source !== null && statement.source.value === REACT_MODULE && + statement.specifiers.some(isRefusedReactSpecifier) + } + return false +} + +interface LineRange { + readonly first: number + readonly last: number +} + +const coversAny = (range: LineRange, lines: ReadonlySet): boolean => { + for (const line of lines) { + if (line >= range.first && line <= range.last) return true + } + return false +} + +const namedRulesOf = (value: string): readonly string[] => + value.split(',').map((name) => name.trim()).filter((name) => name.length > 0) + +const directiveFormOf = (directive: DisableDirective): string => + `${/^\s*eslint-/.test(directive.node.value) ? 'eslint' : 'oxlint'}-${directive.type}` + +const enables = (directive: DisableDirective, name: string | null): boolean => { + if (directive.type !== 'enable') return false + const names = namedRulesOf(directive.value) + return names.length === 0 || (name !== null && names.includes(name)) +} + +const rangeOf = (directive: DisableDirective, all: readonly DisableDirective[], name: string | null): LineRange => { + const loc = directive.node.loc + if (directive.type === 'disable-line') return { first: loc.start.line, last: loc.end.line } + if (directive.type === 'disable-next-line') return { first: loc.end.line + 1, last: loc.end.line + 1 } + const closing = all.find((other) => other.node.start > directive.node.end && enables(other, name)) + return { first: loc.start.line, last: closing === undefined ? Number.POSITIVE_INFINITY : closing.node.loc.start.line } +} + +const turnsBanOff = ( + directive: DisableDirective, + all: readonly DisableDirective[], + loadLines: ReadonlySet, + importBanLines: ReadonlySet, +): boolean => { + if (directive.type === 'enable') return false + const names = namedRulesOf(directive.value) + if (names.includes(REACT_EFFECT_DIRECTIVE_ID)) return true + if (names.length === 0) { + const range = rangeOf(directive, all, null) + return coversAny(range, loadLines) || coversAny(range, importBanLines) + } + return IMPORT_BAN_DIRECTIVE_IDS.some((id) => + names.includes(id) && coversAny(rangeOf(directive, all, id), importBanLines) + ) +} + +export const noReactEffect = defineRule({ + meta, + create(context: Context) { + const getScope: GetScope = context.sourceCode.getScope + + const loadLines = new Set() + + const report = (verdict: ReactEffectVerdict, node: ESTree.Node): void => { + loadLines.add(node.loc.start.line) + context.report({ + node, + messageId: 'reactEffect', + data: { ...verdict, expected: REACT_EFFECT_EXPECTED }, + }) + } + + const reportDirective = (directive: DisableDirective): void => { + context.report({ + loc: { line: 1, column: 0 }, + messageId: 'reactEffect', + data: { + ...effectBanDirectiveVerdict(directiveFormOf(directive), directive.node.loc.start.line), + expected: REACT_EFFECT_EXPECTED, + }, + }) + } + + return { + 'Program:exit'(program: ESTree.Program) { + const importBanLines = new Set() + for (const statement of program.body) { + if (!isRefusedReactDeclaration(statement)) continue + for (let line = statement.loc.start.line; line <= statement.loc.end.line; line++) importBanLines.add(line) + } + const { directives } = context.sourceCode.getDisableDirectives() + for (const directive of directives) { + if (turnsBanOff(directive, directives, loadLines, importBanLines)) reportDirective(directive) + } + }, + + ImportExpression(node: ESTree.ImportExpression) { + if (staticStringOf(node.source) !== REACT_MODULE) return + report(reactModuleLoadVerdict(`import('${REACT_MODULE}')`), node) + }, + + CallExpression(node: ESTree.CallExpression) { + if (!isGlobalRequire(node.callee, getScope)) return + if (staticStringOf(node.arguments[0]) !== REACT_MODULE) return + report(reactModuleLoadVerdict(`require('${REACT_MODULE}')`), node) + }, + } + }, +}) diff --git a/packages/oxlint-presets/oxlint-config-frontend/tests/__fixtures__/oxlint-diagnostics.ts b/packages/oxlint-presets/oxlint-config-frontend/tests/__fixtures__/oxlint-diagnostics.ts new file mode 100644 index 0000000..d459324 --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/tests/__fixtures__/oxlint-diagnostics.ts @@ -0,0 +1,45 @@ +import { execFile } from 'node:child_process' +import { fileURLToPath } from 'node:url' + +const PACKAGE = new URL('../../', import.meta.url) +const OXLINT = fileURLToPath(new URL('node_modules/.bin/oxlint', PACKAGE)) + +export interface OxlintRun { + readonly fixture: string + readonly config: string + readonly format: 'json' | 'unix' + readonly path: string +} + +export interface OxlintOutput { + readonly stdout: string + readonly stderr: string +} + +export const runOxlint = ({ fixture, config, format, path }: OxlintRun): Promise => { + const { promise, resolve } = Promise.withResolvers() + const cwd = fileURLToPath(new URL(fixture, PACKAGE)) + execFile(OXLINT, ['-c', config, `--format=${format}`, path], { cwd }, (_error, stdout, stderr) => { + resolve({ stdout, stderr }) + }) + return promise +} + +export interface Diagnostic { + readonly code: string + readonly severity: string + readonly message: string + readonly help?: string +} + +interface Report { + readonly diagnostics: readonly Diagnostic[] +} + +export const lintDiagnostics = async (config: string, path: string): Promise => { + const { stdout, stderr } = await runOxlint({ fixture: 'ui-state-fixture/', config, format: 'json', path }) + const report: Report = await Promise.try(() => JSON.parse(stdout)).catch(() => { + throw new Error(`oxlint printed no JSON report for ${path} with ${config}:\n${stdout}${stderr}`) + }) + return report.diagnostics +} diff --git a/packages/oxlint-presets/oxlint-config-frontend/tests/fetch-in-tsx.test.ts b/packages/oxlint-presets/oxlint-config-frontend/tests/fetch-in-tsx.test.ts new file mode 100644 index 0000000..f89017a --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/tests/fetch-in-tsx.test.ts @@ -0,0 +1,57 @@ +import { expect, it } from 'vitest' + +import { lintDiagnostics } from './__fixtures__/oxlint-diagnostics.js' + +const PACK_RULE = 'ui-state/components-perform-no-io' +const FETCH_BAN_CODES: Record = { + 'eslint(no-restricted-globals)': true, + 'eslint(no-restricted-properties)': true, +} + +interface FetchReport { + readonly severity: string + readonly namesPackRule: boolean +} + +const fetchReports = async (path: string, config = 'recommended.config.ts'): Promise => + (await lintDiagnostics(config, path)) + .filter((diagnostic) => FETCH_BAN_CODES[diagnostic.code] === true) + .map((diagnostic) => ({ + severity: diagnostic.severity, + namesPackRule: `${diagnostic.message} ${diagnostic.help ?? ''}`.includes(PACK_RULE), + })) + +const ONE_ERROR_NAMING_THE_RULE: readonly FetchReport[] = [{ severity: 'error', namesPackRule: true }] + +it.each([ + 'src/fetch/bare.tsx', + 'src/fetch/alias.tsx', + 'src/fetch/type-of.tsx', + 'src/fetch/global-this.tsx', + 'src/fetch/window.tsx', + 'src/fetch/self.tsx', + 'src/Page.stories.tsx', + 'src/Page.tsx', + 'outside-src.tsx', +])('reports the fetch in %s once as an error, naming the pack rule', async (path) => { + expect(await fetchReports(path)).toEqual(ONE_ERROR_NAMING_THE_RULE) +}, 60_000) + +it.each([ + 'src/demand.service.ts', + 'src/fetch/shadowed.tsx', +])('reports nothing for %s', async (path) => { + expect(await fetchReports(path)).toEqual([]) +}, 60_000) + +const CONSUMER_OWN_ERROR: FetchReport = { severity: 'error', namesPackRule: false } + +it('keeps the fetch ban beside the entries of a consumer that spreads the exports into its own .tsx bans', async () => { + const reports = await Promise.all( + ['src/Page.tsx', 'src/fetch/window.tsx'].map((path) => fetchReports(path, 'consumer-spread.config.ts')), + ) + expect(reports).toEqual([ + [...ONE_ERROR_NAMING_THE_RULE, CONSUMER_OWN_ERROR], + [...ONE_ERROR_NAMING_THE_RULE, CONSUMER_OWN_ERROR], + ]) +}, 60_000) diff --git a/packages/oxlint-presets/oxlint-config-frontend/tests/react-effect.test.ts b/packages/oxlint-presets/oxlint-config-frontend/tests/react-effect.test.ts new file mode 100644 index 0000000..e205243 --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/tests/react-effect.test.ts @@ -0,0 +1,90 @@ +import { expect, it } from 'vitest' + +import { lintDiagnostics } from './__fixtures__/oxlint-diagnostics.js' + +// Written out from compound-packs/ui-state/react-runs-no-effect.md, not read from the preset. +const PACK_RULE = 'ui-state/react-runs-no-effect' +const EFFECT_BAN_CODES: Record = { + 'eslint(no-restricted-imports)': true, + '@systemfsoftware/ui-state(no-react-effect)': true, +} + +interface EffectReport { + readonly severity: string + readonly namesPackRule: boolean + readonly namesEffectAtom: boolean + readonly kind: 'directive' | 'reach' +} + +const reportText = (diagnostic: { readonly message: string; readonly help?: string }): string => + `${diagnostic.message} ${diagnostic.help ?? ''}` + +const effectReports = async (path: string): Promise => + (await lintDiagnostics('recommended.config.ts', path)) + .filter((diagnostic) => EFFECT_BAN_CODES[diagnostic.code] === true) + .map((diagnostic) => ({ + severity: diagnostic.severity, + namesPackRule: reportText(diagnostic).includes(PACK_RULE), + namesEffectAtom: reportText(diagnostic).includes('useAtomValue') && reportText(diagnostic).includes('useAtomSet'), + kind: reportText(diagnostic).includes('turns the React Effect ban off') ? 'directive' : 'reach', + })) + +const oneError = (kind: EffectReport['kind']): readonly EffectReport[] => [ + { severity: 'error', namesPackRule: true, namesEffectAtom: true, kind }, +] + +const byKind = (reports: readonly EffectReport[]): readonly EffectReport[] => + [...reports].toSorted((left, right) => left.kind.localeCompare(right.kind)) + +it.each([ + 'src/react-effect/named.ts', + 'src/react-effect/aliased.ts', + 'src/react-effect/value.ts', + 'src/react-effect/reexport.ts', + 'src/react-effect/export-all.ts', + 'src/react-effect/layout.ts', + 'src/react-effect/insertion.ts', + 'src/react-effect/effect-event.ts', + 'src/react-effect/namespace.ts', + 'src/react-effect/type-namespace.ts', + 'src/react-effect/default.ts', + 'src/react-effect/hydration.ts', + 'src/react-effect/dynamic-import.ts', + 'src/react-effect/require.ts', + 'src/react-effect/directive-config-key.ts', + 'src/react-effect/directive-closed-before.ts', + 'src/react-effect/directive-unnamed.ts', + 'outside-src-effect.tsx', +])('reports the React Effect reach in %s once as an error naming the pack rule and effect-atom', async (path) => { + expect(await effectReports(path)).toEqual(oneError('reach')) +}, 60_000) + +it.each([ + 'src/react-effect/directive-unnamed-file.ts', + 'src/react-effect/directive-import-ban.ts', +])('reports the directive in %s that turns the ban off once, as an error naming the pack rule', async (path) => { + expect(await effectReports(path)).toEqual(oneError('directive')) +}, 60_000) + +it.each([ + 'src/react-effect/directive-next-line.ts', + 'src/react-effect/directive-whole-file.ts', + 'src/react-effect/directive-eslint.ts', + 'src/react-effect/directive-same-line.ts', +])('reports the directive in %s that names the rule, and the useEffect import it does not cover', async (path) => { + expect(byKind(await effectReports(path))).toEqual([...oneError('directive'), ...oneError('reach')]) +}, 60_000) + +it.each([ + 'src/react-effect/clean.ts', + 'src/react-effect/directive-other-import.ts', +])('reports nothing for %s', async (path) => { + expect(await effectReports(path)).toEqual([]) +}, 60_000) + +it('names importing react by name in the report for a namespace import used only for useState', async () => { + const helps = (await lintDiagnostics('recommended.config.ts', 'src/react-effect/namespace.ts')) + .filter((diagnostic) => diagnostic.code === 'eslint(no-restricted-imports)') + .map((diagnostic) => diagnostic.help ?? '') + expect(helps.map((help) => help.includes('import react by name'))).toEqual([true]) +}, 60_000) diff --git a/packages/oxlint-presets/oxlint-config-frontend/tests/react-rules.test.ts b/packages/oxlint-presets/oxlint-config-frontend/tests/react-rules.test.ts new file mode 100644 index 0000000..2d3eebb --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/tests/react-rules.test.ts @@ -0,0 +1,34 @@ +import { expect, it } from 'vitest' + +import { lintDiagnostics } from './__fixtures__/oxlint-diagnostics.js' + +const reactCodes = async (path: string): Promise => + (await lintDiagnostics('recommended.config.ts', path)) + .map((diagnostic) => diagnostic.code) + .filter((code) => code.startsWith('react')) + .toSorted() + +it.each([ + ['src/react-rules/conditional-hook.tsx', ['react(hooks)', 'react-hooks(rules-of-hooks)']], + ['src/react-rules/ref-in-render.tsx', ['react(refs)']], + ['src/react-rules/index-key.tsx', ['react(no-array-index-key)']], +])('reports %s under %s', async (path, codes) => { + expect(await reactCodes(path)).toEqual(codes) +}, 60_000) + +it( + 'reports a real hooks violation in a consumer .tsx under react/rules-of-hooks', + async () => { + const codes = await reactCodes('src/react-rules/conditional-hook.tsx') + expect(codes).toContain('react-hooks(rules-of-hooks)') + }, + 60_000, +) + +it( + 'reports no React rule for Effect service code that calls use and a use-prefixed function outside React', + async () => { + expect(await reactCodes('src/react-rules/effect-service.ts')).toEqual([]) + }, + 60_000, +) diff --git a/packages/oxlint-presets/oxlint-config-frontend/tests/server-state-imports.test.ts b/packages/oxlint-presets/oxlint-config-frontend/tests/server-state-imports.test.ts new file mode 100644 index 0000000..afaa015 --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/tests/server-state-imports.test.ts @@ -0,0 +1,158 @@ +import { matchesGlob } from 'node:path' + +import effectPlatform from '@systemfsoftware/oxlint-plugin-effect-platform' +import { expect, it } from 'vitest' + +import { lintDiagnostics } from './__fixtures__/oxlint-diagnostics.js' + +// R5 of compound-packs/ui-state/server-data-lives-in-atoms.md, written out from the pack rule, not read from the preset. +const SERVER_STATE = [ + '@tanstack/react-query', + '@tanstack/react-query-devtools', + '@tanstack/react-query-persist-client', + '@tanstack/react-query-next-experimental', + 'react-query', + 'swr', + 'urql', + '@apollo/client/react', + '@reduxjs/toolkit/query/react', + '@trpc/react-query', + '@trpc/tanstack-react-query', +] as const + +const PACK_RULE = 'ui-state/server-data-lives-in-atoms' + +const NODE_BUILTIN = 'node:fs' +const SERVER_STATE_IMPORT = '@tanstack/react-query' +const REACT_EFFECT_IMPORT = 'useLayoutEffect' +const CONSUMER_OWN = '@consumer/legacy-client' +const DRIFT_FILES = [ + 'src/Page.tsx', + 'src/__fixtures__/both.ts', + 'fixtures/both.ts', + 'testResources/both.ts', + 'both.test.ts', +] as const + +interface RestrictedImport { + readonly specifier: string + readonly severity: string + readonly help: string +} + +const restrictedImports = async ( + path: string, + config = 'recommended.config.ts', +): Promise => + (await lintDiagnostics(config, path)) + .filter((diagnostic) => diagnostic.code === 'eslint(no-restricted-imports)') + .map((diagnostic) => ({ + specifier: /^'(.+)' import/.exec(diagnostic.message)?.[1] ?? diagnostic.message, + severity: diagnostic.severity, + help: diagnostic.help ?? '', + })) + +const specifiers = async (path: string, config?: string): Promise => + (await restrictedImports(path, config)).map((report) => report.specifier).toSorted() + +const libraryOf = (specifier: string): string => + SERVER_STATE.find((name) => specifier.startsWith(`${name}/`)) ?? specifier + +it('reports an import of every server-state library the pack rule names', async () => { + expect(await specifiers('src/server-state/names.ts')).toEqual([...SERVER_STATE].toSorted()) +}, 60_000) + +it('reports an import of a subpath of every server-state library the pack rule names', async () => { + const libraries = (await specifiers('src/server-state/subpaths.ts')).map(libraryOf) + expect(libraries.toSorted()).toEqual([...SERVER_STATE].toSorted()) +}, 60_000) + +it('reports type-only imports, re-exports and dynamic import() of a server-state library', async () => { + expect(await specifiers('src/server-state/forms.ts')).toEqual(['@tanstack/react-query', 'swr', 'urql']) +}, 60_000) + +it('reports a local module that re-exports a server-state hook', async () => { + expect(await specifiers('src/shell.ts')).toEqual(['@tanstack/react-query']) +}, 60_000) + +it('leaves server-state cores that also serve non-React code alone', async () => { + expect(await specifiers('src/server-state/allowed.ts')).toEqual([]) +}, 60_000) + +it('names the pack rule in every server-state report', async () => { + const helps = (await restrictedImports('src/server-state/names.ts')).map((report) => report.help.includes(PACK_RULE)) + expect(helps).toEqual(SERVER_STATE.map(() => true)) +}, 60_000) + +it('reports the node builtin, the server-state and the React Effect import in a source file', async () => { + expect(await specifiers('src/Page.tsx')).toEqual([NODE_BUILTIN, SERVER_STATE_IMPORT, REACT_EFFECT_IMPORT].toSorted()) +}, 60_000) + +it('reports every server-state, node-builtin and React Effect import as an error', async () => { + const reports = await Promise.all( + ['src/server-state/names.ts', 'src/Page.tsx'].map((path) => restrictedImports(path)), + ) + expect(reports.flat().map((report) => report.severity)).toEqual( + [...SERVER_STATE, NODE_BUILTIN, SERVER_STATE_IMPORT, REACT_EFFECT_IMPORT].map(() => 'error'), + ) +}, 60_000) + +it( + 'reports the server-state and React Effect imports, not the node builtin, in a fixtures directory and in a script outside src', + async () => { + expect(await Promise.all([specifiers('src/__fixtures__/both.ts'), specifiers('scripts/both.ts')])).toEqual([ + [SERVER_STATE_IMPORT, REACT_EFFECT_IMPORT].toSorted(), + [SERVER_STATE_IMPORT, REACT_EFFECT_IMPORT].toSorted(), + ]) + }, + 60_000, +) + +// The drift guard takes its file groups from effect-platform's own exported overrides, so a glob it adds or moves is +// linted here. Within a group the last override matching a file decides its no-restricted-imports value. +const importGroups = effectPlatform.configs.recommended.overrides.filter( + (override) => override.rules?.['no-restricted-imports'] !== undefined, +) + +const decidingGroup = (file: string): number => + importGroups.findLastIndex((group) => group.files.some((glob) => matchesGlob(file, glob))) + +const groupGlobs = importGroups.flatMap((group, index) => + group.files.map((glob) => ({ + glob, + bansNodeBuiltins: group.rules?.['no-restricted-imports'] !== 'off', + file: DRIFT_FILES.find((file) => matchesGlob(file, glob) && decidingGroup(file) === index), + })) +) + +const driftCases = groupGlobs.flatMap(({ glob, bansNodeBuiltins, file }) => + file === undefined + ? [] + : [{ + glob, + file, + expected: bansNodeBuiltins + ? [NODE_BUILTIN, SERVER_STATE_IMPORT, REACT_EFFECT_IMPORT] + : [SERVER_STATE_IMPORT, REACT_EFFECT_IMPORT], + }] +) + +it('has a drift fixture file for every glob effect-platform sets no-restricted-imports on', () => { + expect(groupGlobs.filter(({ file }) => file === undefined).map(({ glob }) => glob)).toEqual([]) +}) + +it.each(driftCases)( + 'keeps every import ban under the preset in effect-platform group $glob ($file)', + async ({ file, expected }) => { + expect(await specifiers(file)).toEqual(expected.toSorted()) + }, + 60_000, +) + +it.each(driftCases)( + 'keeps every import ban and the consumer pattern when a consumer spreads the exports, in group $glob ($file)', + async ({ file, expected }) => { + expect(await specifiers(file, 'consumer-spread.config.ts')).toEqual([...expected, CONSUMER_OWN].toSorted()) + }, + 60_000, +) diff --git a/packages/oxlint-presets/oxlint-config-frontend/tests/story-screenshot.test.ts b/packages/oxlint-presets/oxlint-config-frontend/tests/story-screenshot.test.ts new file mode 100644 index 0000000..65fbad6 --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/tests/story-screenshot.test.ts @@ -0,0 +1,84 @@ +import { expect, it } from 'vitest' + +import { lintDiagnostics } from './__fixtures__/oxlint-diagnostics.js' + +const CAPTURE_RULE = 'storybook-testing/visual-evidence-comes-from-the-runner' +const FETCH_RULE = 'ui-state/components-perform-no-io' +const CONSUMER_STORAGE_MESSAGE = 'Persist through the KeyValueStore service.' + +interface Report { + readonly severity: string + readonly rule: string +} + +const propertyBans = async (path: string, config: string) => + (await lintDiagnostics(config, path)).filter((diagnostic) => diagnostic.code === 'eslint(no-restricted-properties)') + +const reportsNaming = async (path: string, config = 'recommended.config.ts'): Promise => + (await propertyBans(path, config)).flatMap((diagnostic) => + [CAPTURE_RULE, FETCH_RULE] + .filter((rule) => (diagnostic.help ?? '').startsWith(`${rule}:`)) + .map((rule) => ({ severity: diagnostic.severity, rule })) + ) + +const CAPTURE_ERROR: Report = { severity: 'error', rule: CAPTURE_RULE } +const FETCH_ERROR: Report = { severity: 'error', rule: FETCH_RULE } + +it.each([ + 'src/screenshot/direct-component.stories.tsx', + 'src/screenshot/direct.stories.ts', + 'src/screenshot/destructured-component.stories.tsx', + 'src/screenshot/destructured.stories.ts', +])('reports the page.screenshot in %s once as an error whose message begins with the pack rule', async (path) => { + expect(await reportsNaming(path)).toEqual([CAPTURE_ERROR]) +}, 60_000) + +it.each([ + 'src/screenshot/canvas.stories.tsx', + 'src/screenshot/fetch.stories.ts', + 'src/screenshot/capture.ts', + 'src/screenshot/alias.stories.ts', +])('reports nothing for %s', async (path) => { + expect(await reportsNaming(path)).toEqual([]) +}, 60_000) + +it('passes every preset rule on the capture registration the pack rule prescribes', async () => { + expect(await lintDiagnostics('recommended.config.ts', '.storybook/preview.ts')).toEqual([]) +}, 60_000) + +it('keeps the .tsx fetch ban beside the capture ban in a .stories.tsx file', async () => { + expect(await reportsNaming('src/screenshot/fetch-and-capture.stories.tsx')).toEqual([FETCH_ERROR, CAPTURE_ERROR]) +}, 60_000) + +it.each( + [ + ['src/screenshot/fetch-and-capture.stories.tsx', [FETCH_ERROR, CAPTURE_ERROR]], + ['src/screenshot/direct.stories.ts', [CAPTURE_ERROR]], + ] as const, +)('keeps the preset bans on %s when a consumer spreads the exports into its own story override', async ( + path, + expected, +) => { + expect(await reportsNaming(path, 'consumer-spread.config.ts')).toEqual(expected) +}, 60_000) + +it.each([ + 'src/screenshot/direct-component.stories.tsx', + 'src/screenshot/direct.stories.ts', +])( + 'drops the capture ban on %s when a consumer property override matches story files without the story spread', + async (path) => { + expect(await reportsNaming(path, 'consumer-own-only.config.ts')).toEqual([]) + }, + 60_000, +) + +it.each([ + 'src/screenshot/storage-component.stories.tsx', + 'src/screenshot/storage.stories.ts', +])('keeps the consumer own property ban on %s beside the spread exports', async (path) => { + const own = (await propertyBans(path, 'consumer-spread.config.ts')) + .filter((diagnostic) => diagnostic.help === CONSUMER_STORAGE_MESSAGE) + .map((diagnostic) => diagnostic.severity) + expect(own).toEqual(['error']) +}, 60_000) diff --git a/packages/oxlint-presets/oxlint-config-frontend/tsconfig.app.json b/packages/oxlint-presets/oxlint-config-frontend/tsconfig.app.json new file mode 100644 index 0000000..e70962a --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/tsconfig.app.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": [ + "@systemfsoftware/tsconfig/tsc/dom/library-monorepo", + "@systemfsoftware/tsconfig/effect" + ], + "compilerOptions": { + "composite": true, + "tsBuildInfoFile": "node_modules/.cache/tsconfig.app.tsbuildinfo", + "customConditions": [ + "@systemfsoftware/source" + ], + "emitDeclarationOnly": true, + "outDir": "node_modules/.cache/tsc/app" + }, + "include": [ + "src" + ], + "exclude": [ + "src/**/__tests__/**", + "src/**/*.test.ts" + ] +} diff --git a/packages/oxlint-presets/oxlint-config-frontend/tsconfig.build.json b/packages/oxlint-presets/oxlint-config-frontend/tsconfig.build.json new file mode 100644 index 0000000..8f94fd6 --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.app.json", + "compilerOptions": { + "incremental": true, + "declarationMap": false, + "sourceMap": false + } +} diff --git a/packages/oxlint-presets/oxlint-config-frontend/tsconfig.json b/packages/oxlint-presets/oxlint-config-frontend/tsconfig.json new file mode 100644 index 0000000..4677fb3 --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/tsconfig.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "files": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.test.json" + }, + { + "path": "./tsconfig.node.json" + }, + { + "path": "./ui-state-fixture" + } + ] +} diff --git a/packages/oxlint-presets/oxlint-config-frontend/tsconfig.node.json b/packages/oxlint-presets/oxlint-config-frontend/tsconfig.node.json new file mode 100644 index 0000000..32e5a44 --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/tsconfig.node.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@systemfsoftware/tsconfig/node", + "compilerOptions": { + "composite": true, + "noEmit": true, + "tsBuildInfoFile": "node_modules/.cache/tsconfig.node.tsbuildinfo" + }, + "include": [ + "oxlint.config.ts", + "tsdown.config.ts", + "vitest.config.ts" + ] +} diff --git a/packages/oxlint-presets/oxlint-config-frontend/tsconfig.test.json b/packages/oxlint-presets/oxlint-config-frontend/tsconfig.test.json new file mode 100644 index 0000000..ce045c5 --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/tsconfig.test.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": ["@systemfsoftware/tsconfig/tsc/dom/library-monorepo", "@systemfsoftware/tsconfig/effect/entrypoint"], + "compilerOptions": { + "composite": true, + "noEmit": false, + "emitDeclarationOnly": true, + "outDir": "node_modules/.cache/tsc/test", + "tsBuildInfoFile": "node_modules/.cache/tsconfig.test.tsbuildinfo", + "types": ["node"] + }, + "include": ["tests", "src/**/__tests__"], + "references": [{ "path": "./tsconfig.app.json" }] +} diff --git a/packages/oxlint-presets/oxlint-config-frontend/tsdown.config.ts b/packages/oxlint-presets/oxlint-config-frontend/tsdown.config.ts new file mode 100644 index 0000000..68b3080 --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/tsdown.config.ts @@ -0,0 +1,36 @@ +import { quietBuild } from '@systemfsoftware/tsdown-config/quiet-build' +import { defineConfig } from 'tsdown' + +type ExportEntry = string | Record + +const exportPresetEntryOnly = (exports: Record): Record => { + delete exports['./plugin'] + const entry = exports['.'] + if (typeof entry === 'string') { + exports['.'] = { types: './dist/index.d.ts', default: entry } + } else if (typeof entry === 'object') { + const { default: defaultEntry, types: _existingTypes, ...rest } = entry + const withDefault = typeof defaultEntry === 'string' ? { default: defaultEntry } : {} + exports['.'] = { ...rest, types: './dist/index.d.ts', ...withDefault } + } + return exports +} + +export default defineConfig({ + ...quietBuild, + entry: { + index: './src/index.ts', + plugin: './src/plugin.ts', + }, + format: 'esm', + dts: true, + tsconfig: './tsconfig.build.json', + outExtensions: () => ({ js: '.mjs', dts: '.d.ts' }), + exports: { + devExports: '@systemfsoftware/source', + customExports: exportPresetEntryOnly, + }, + deps: { + onlyBundle: false, + }, +}) diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/.storybook/preview.ts b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/.storybook/preview.ts new file mode 100644 index 0000000..15f0af0 --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/.storybook/preview.ts @@ -0,0 +1,13 @@ +const captureEachStory = async (): Promise => { + if (import.meta.env.MODE !== 'test') return + const [{ onTestFinished }, { page }, { screenshot }] = await Promise.all([ + import('@systemfsoftware/vitest'), + import('vitest/browser'), + import('@storycap-testrun/browser'), + ]) + onTestFinished(async (context) => { + await screenshot(page, context) + }) +} + +export default { beforeEach: captureEachStory } diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/allowed-modules.d.ts b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/allowed-modules.d.ts new file mode 100644 index 0000000..86cb0e9 --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/allowed-modules.d.ts @@ -0,0 +1,5 @@ +// Server-state cores that also serve non-React code; ui-state/server-data-lives-in-atoms does not ban them. +declare module '@tanstack/query-core' +declare module '@apollo/client' +declare module '@urql/core' +declare module '@reduxjs/toolkit/query' diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/banned-modules.d.ts b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/banned-modules.d.ts new file mode 100644 index 0000000..548f7d6 --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/banned-modules.d.ts @@ -0,0 +1,26 @@ +// The React server-state libraries R5 of ui-state/server-data-lives-in-atoms bans, and one specifier a +// consumer bans for itself. None is installed: these declarations let the planted imports typecheck while +// oxlint still sees the real specifiers. +declare module '@tanstack/react-query' +declare module '@tanstack/react-query/*' +declare module '@tanstack/react-query-devtools' +declare module '@tanstack/react-query-devtools/*' +declare module '@tanstack/react-query-persist-client' +declare module '@tanstack/react-query-persist-client/*' +declare module '@tanstack/react-query-next-experimental' +declare module '@tanstack/react-query-next-experimental/*' +declare module 'react-query' +declare module 'react-query/*' +declare module 'swr' +declare module 'swr/*' +declare module 'urql' +declare module 'urql/*' +declare module '@apollo/client/react' +declare module '@apollo/client/react/*' +declare module '@reduxjs/toolkit/query/react' +declare module '@reduxjs/toolkit/query/react/*' +declare module '@trpc/react-query' +declare module '@trpc/react-query/*' +declare module '@trpc/tanstack-react-query' +declare module '@trpc/tanstack-react-query/*' +declare module '@consumer/legacy-client' diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/both.test.ts b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/both.test.ts new file mode 100644 index 0000000..29b344a --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/both.test.ts @@ -0,0 +1,6 @@ +import { legacyClient } from '@consumer/legacy-client' +import { useQuery } from '@tanstack/react-query' +import { readFileSync } from 'node:fs' +import { useLayoutEffect } from 'react' + +export const used: readonly unknown[] = [readFileSync, useQuery, legacyClient, useLayoutEffect] diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/capture-modules.d.ts b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/capture-modules.d.ts new file mode 100644 index 0000000..05a4efa --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/capture-modules.d.ts @@ -0,0 +1,15 @@ +declare module '@storycap-testrun/browser' { + export const screenshot: (page: unknown, context: unknown) => Promise +} + +declare module '@systemfsoftware/vitest' { + export const onTestFinished: (handler: (context: unknown) => Promise) => void +} + +interface ImportMetaEnv { + readonly MODE: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/consumer-own-only.config.ts b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/consumer-own-only.config.ts new file mode 100644 index 0000000..a08a73a --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/consumer-own-only.config.ts @@ -0,0 +1,12 @@ +import recommended from '@systemfsoftware/oxlint-config-frontend' +import { defineConfig } from 'oxlint' + +const storage = { object: 'window', property: 'localStorage', message: 'Persist through the KeyValueStore service.' } + +export default defineConfig({ + extends: [recommended], + overrides: [ + { files: ['**/*.ts'], rules: { 'no-restricted-properties': ['error', storage] } }, + { files: ['**/*.tsx'], rules: { 'no-restricted-properties': ['error', storage] } }, + ], +}) diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/consumer-spread.config.ts b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/consumer-spread.config.ts new file mode 100644 index 0000000..da5318f --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/consumer-spread.config.ts @@ -0,0 +1,74 @@ +import recommended, { + fetchAndStoryScreenshotProperties, + fetchInComponentsGlobals, + fetchInComponentsProperties, + nodeBuiltinAndUiStateImports, + type PatternImportBan, + storyScreenshotProperties, + uiStateImports, +} from '@systemfsoftware/oxlint-config-frontend' +import { defineConfig } from 'oxlint' + +const legacyClient = { group: ['@consumer/legacy-client'], message: 'Reach the legacy API through its Effect service.' } + +const withLegacyClient = ([severity, options]: PatternImportBan): PatternImportBan => [ + severity, + { ...options, patterns: [...options.patterns, legacyClient] }, +] + +const storageMessage = 'Persist through the KeyValueStore service.' + +export default defineConfig({ + extends: [recommended], + rules: { + 'no-restricted-imports': withLegacyClient(uiStateImports), + }, + overrides: [ + { + files: ['**/src/**', '**/*.test.ts'], + rules: { + 'no-restricted-imports': withLegacyClient(nodeBuiltinAndUiStateImports), + }, + }, + { + files: ['**/__fixtures__/**', '**/fixtures/**', '**/testResources/**'], + rules: { + 'no-restricted-imports': withLegacyClient(uiStateImports), + }, + }, + { + files: ['**/*.tsx'], + rules: { + 'no-restricted-globals': [...fetchInComponentsGlobals, { name: 'localStorage', message: storageMessage }], + 'no-restricted-properties': [ + ...fetchInComponentsProperties, + { object: 'window', property: 'localStorage', message: storageMessage }, + ], + }, + }, + { + files: ['**/*.stories.tsx'], + rules: { + 'no-restricted-properties': [ + ...fetchAndStoryScreenshotProperties, + { object: 'window', property: 'localStorage', message: storageMessage }, + ], + }, + }, + { + files: ['**/*.ts'], + rules: { + 'no-restricted-properties': ['error', { object: 'window', property: 'localStorage', message: storageMessage }], + }, + }, + { + files: ['**/*.stories.ts'], + rules: { + 'no-restricted-properties': [ + ...storyScreenshotProperties, + { object: 'window', property: 'localStorage', message: storageMessage }, + ], + }, + }, + ], +}) diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/fixtures/both.ts b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/fixtures/both.ts new file mode 100644 index 0000000..29b344a --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/fixtures/both.ts @@ -0,0 +1,6 @@ +import { legacyClient } from '@consumer/legacy-client' +import { useQuery } from '@tanstack/react-query' +import { readFileSync } from 'node:fs' +import { useLayoutEffect } from 'react' + +export const used: readonly unknown[] = [readFileSync, useQuery, legacyClient, useLayoutEffect] diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/outside-src-effect.tsx b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/outside-src-effect.tsx new file mode 100644 index 0000000..b1efd85 --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/outside-src-effect.tsx @@ -0,0 +1,8 @@ +import { useEffect } from 'react' + +export function Title({ text }: { readonly text: string }) { + useEffect(() => { + globalThis.document.title = text + }, [text]) + return

{text}

+} diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/outside-src.tsx b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/outside-src.tsx new file mode 100644 index 0000000..90f1101 --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/outside-src.tsx @@ -0,0 +1 @@ +export const load = async (url: string): Promise => fetch(url) diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/react-modules.d.ts b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/react-modules.d.ts new file mode 100644 index 0000000..86533d9 --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/react-modules.d.ts @@ -0,0 +1,10 @@ +declare module 'react' +declare module 'react/jsx-runtime' +declare module '@systemfsoftware/effect-atom' + +declare namespace JSX { + type Element = unknown + interface IntrinsicElements { + readonly [element: string]: unknown + } +} diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/recommended.config.ts b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/recommended.config.ts new file mode 100644 index 0000000..b6f1225 --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/recommended.config.ts @@ -0,0 +1,4 @@ +import recommended from '@systemfsoftware/oxlint-config-frontend' +import { defineConfig } from 'oxlint' + +export default defineConfig({ extends: [recommended] }) diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/scripts/both.ts b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/scripts/both.ts new file mode 100644 index 0000000..29b344a --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/scripts/both.ts @@ -0,0 +1,6 @@ +import { legacyClient } from '@consumer/legacy-client' +import { useQuery } from '@tanstack/react-query' +import { readFileSync } from 'node:fs' +import { useLayoutEffect } from 'react' + +export const used: readonly unknown[] = [readFileSync, useQuery, legacyClient, useLayoutEffect] diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/Page.stories.tsx b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/Page.stories.tsx new file mode 100644 index 0000000..90f1101 --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/Page.stories.tsx @@ -0,0 +1 @@ +export const load = async (url: string): Promise => fetch(url) diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/Page.tsx b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/Page.tsx new file mode 100644 index 0000000..4c8e2f5 --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/Page.tsx @@ -0,0 +1,10 @@ +import { legacyClient } from '@consumer/legacy-client' +import { useQuery } from '@tanstack/react-query' +import { readFileSync } from 'node:fs' +import { useLayoutEffect } from 'react' + +export const used: readonly unknown[] = [readFileSync, useQuery, legacyClient, useLayoutEffect] + +export const load = (): Promise => fetch('/demand') + +export const density = (): string | null => localStorage.getItem('density') diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/__fixtures__/both.ts b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/__fixtures__/both.ts new file mode 100644 index 0000000..29b344a --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/__fixtures__/both.ts @@ -0,0 +1,6 @@ +import { legacyClient } from '@consumer/legacy-client' +import { useQuery } from '@tanstack/react-query' +import { readFileSync } from 'node:fs' +import { useLayoutEffect } from 'react' + +export const used: readonly unknown[] = [readFileSync, useQuery, legacyClient, useLayoutEffect] diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/demand.service.ts b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/demand.service.ts new file mode 100644 index 0000000..90f1101 --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/demand.service.ts @@ -0,0 +1 @@ +export const load = async (url: string): Promise => fetch(url) diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/fetch/alias.tsx b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/fetch/alias.tsx new file mode 100644 index 0000000..80633d9 --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/fetch/alias.tsx @@ -0,0 +1,3 @@ +const request = fetch + +export const load = async (url: string): Promise => request(url) diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/fetch/bare.tsx b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/fetch/bare.tsx new file mode 100644 index 0000000..90f1101 --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/fetch/bare.tsx @@ -0,0 +1 @@ +export const load = async (url: string): Promise => fetch(url) diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/fetch/global-this.tsx b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/fetch/global-this.tsx new file mode 100644 index 0000000..b98e48b --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/fetch/global-this.tsx @@ -0,0 +1 @@ +export const load = async (url: string): Promise => globalThis.fetch(url) diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/fetch/self.tsx b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/fetch/self.tsx new file mode 100644 index 0000000..b03d5ff --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/fetch/self.tsx @@ -0,0 +1 @@ +export const load = async (url: string): Promise => self.fetch(url) diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/fetch/shadowed.tsx b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/fetch/shadowed.tsx new file mode 100644 index 0000000..2fad76b --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/fetch/shadowed.tsx @@ -0,0 +1 @@ +export const load = async (fetch: (url: string) => Promise, url: string): Promise => fetch(url) diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/fetch/type-of.tsx b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/fetch/type-of.tsx new file mode 100644 index 0000000..9cf0bac --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/fetch/type-of.tsx @@ -0,0 +1 @@ +export type Request = typeof fetch diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/fetch/window.tsx b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/fetch/window.tsx new file mode 100644 index 0000000..82440e6 --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/fetch/window.tsx @@ -0,0 +1,3 @@ +export const load = async (url: string): Promise => window.fetch(url) + +export const saved = (): string | null => window.localStorage.getItem('density') diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/aliased.ts b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/aliased.ts new file mode 100644 index 0000000..8020168 --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/aliased.ts @@ -0,0 +1,7 @@ +import { useEffect as afterCommit } from 'react' + +export const usePersist = (key: string, value: string): void => { + afterCommit(() => { + globalThis.localStorage.setItem(key, value) + }, [key, value]) +} diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/clean.ts b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/clean.ts new file mode 100644 index 0000000..7f34985 --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/clean.ts @@ -0,0 +1,6 @@ +import { useMemo, useSyncExternalStore } from 'react' + +export const useReading = (subscribe: (onChange: () => void) => () => void, read: () => number): number => { + const stableSubscribe = useMemo(() => subscribe, [subscribe]) + return useSyncExternalStore(stableSubscribe, read, read) +} diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/default.ts b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/default.ts new file mode 100644 index 0000000..a46f3cc --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/default.ts @@ -0,0 +1,3 @@ +import React from 'react' + +export const version: string = React.version diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/directive-closed-before.ts b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/directive-closed-before.ts new file mode 100644 index 0000000..f1b4183 --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/directive-closed-before.ts @@ -0,0 +1,9 @@ +/* oxlint-disable no-restricted-imports */ +import { readFileSync } from 'node:fs' +/* oxlint-enable no-restricted-imports */ +import { useEffect } from 'react' + +export const read: unknown = readFileSync +export const useTicker = (tick: () => void): void => { + useEffect(tick, [tick]) +} diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/directive-config-key.ts b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/directive-config-key.ts new file mode 100644 index 0000000..d094870 --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/directive-config-key.ts @@ -0,0 +1,6 @@ +import { useEffect } from 'react' + +export const useTicker = (tick: () => void): void => { + // oxlint-disable-next-line @systemfsoftware/oxlint-plugin-effect-platform/no-react-effect + useEffect(tick, [tick]) +} diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/directive-eslint.ts b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/directive-eslint.ts new file mode 100644 index 0000000..399e365 --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/directive-eslint.ts @@ -0,0 +1,6 @@ +import { useEffect } from 'react' + +export const useTicker = (tick: () => void): void => { + // eslint-disable-next-line @systemfsoftware/ui-state/no-react-effect + useEffect(tick, [tick]) +} diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/directive-import-ban.ts b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/directive-import-ban.ts new file mode 100644 index 0000000..48c066b --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/directive-import-ban.ts @@ -0,0 +1,4 @@ +// oxlint-disable-next-line no-restricted-imports +import { useLayoutEffect } from 'react' + +export const used: unknown = useLayoutEffect diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/directive-next-line.ts b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/directive-next-line.ts new file mode 100644 index 0000000..6f7c5a2 --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/directive-next-line.ts @@ -0,0 +1,6 @@ +import { useEffect } from 'react' + +export const useTicker = (tick: () => void): void => { + // oxlint-disable-next-line @systemfsoftware/ui-state/no-react-effect -- the ticker needs a timer + useEffect(tick, [tick]) +} diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/directive-other-import.ts b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/directive-other-import.ts new file mode 100644 index 0000000..7c0ffd7 --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/directive-other-import.ts @@ -0,0 +1,4 @@ +// oxlint-disable-next-line no-restricted-imports +import { readFileSync } from 'node:fs' + +export const used: unknown = readFileSync diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/directive-same-line.ts b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/directive-same-line.ts new file mode 100644 index 0000000..104eadd --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/directive-same-line.ts @@ -0,0 +1,2 @@ +export const useTicker = (tick: () => void): void => useEffect(tick, [tick]) // oxlint-disable-line @systemfsoftware/ui-state/no-react-effect +import { useEffect } from 'react' diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/directive-unnamed-file.ts b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/directive-unnamed-file.ts new file mode 100644 index 0000000..1878ead --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/directive-unnamed-file.ts @@ -0,0 +1,6 @@ +/* eslint-disable */ +import { useEffect } from 'react' + +export const useTicker = (tick: () => void): void => { + useEffect(tick, [tick]) +} diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/directive-unnamed.ts b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/directive-unnamed.ts new file mode 100644 index 0000000..84323b2 --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/directive-unnamed.ts @@ -0,0 +1,6 @@ +import { useEffect } from 'react' + +export const useTicker = (tick: () => void): void => { + // oxlint-disable-next-line + useEffect(tick, [tick]) +} diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/directive-whole-file.ts b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/directive-whole-file.ts new file mode 100644 index 0000000..2f2e466 --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/directive-whole-file.ts @@ -0,0 +1,6 @@ +/* oxlint-disable @systemfsoftware/ui-state/no-react-effect */ +import { useEffect } from 'react' + +export const useTicker = (tick: () => void): void => { + useEffect(tick, [tick]) +} diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/dynamic-import.ts b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/dynamic-import.ts new file mode 100644 index 0000000..11c646e --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/dynamic-import.ts @@ -0,0 +1 @@ +export const loadReact = (): Promise => import('react') diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/effect-event.ts b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/effect-event.ts new file mode 100644 index 0000000..0c5ffe6 --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/effect-event.ts @@ -0,0 +1,3 @@ +import { useEffectEvent } from 'react' + +export const useLatest = (f: () => void): () => void => useEffectEvent(f) diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/export-all.ts b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/export-all.ts new file mode 100644 index 0000000..4ae4b6c --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/export-all.ts @@ -0,0 +1 @@ +export * from 'react' diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/hydration.ts b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/hydration.ts new file mode 100644 index 0000000..20f390b --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/hydration.ts @@ -0,0 +1,8 @@ +import { Atom } from '@systemfsoftware/effect-atom' +import { useEffect } from 'react' + +export const useHydrateAfterCommit = (registry: unknown, queue: readonly unknown[]): void => { + useEffect(() => { + Atom.Hydration.hydrate(registry, queue) + }, [registry, queue]) +} diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/insertion.ts b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/insertion.ts new file mode 100644 index 0000000..1eabebc --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/insertion.ts @@ -0,0 +1,5 @@ +import { useInsertionEffect } from 'react' + +export const useStyle = (inject: () => void): void => { + useInsertionEffect(inject, [inject]) +} diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/layout.ts b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/layout.ts new file mode 100644 index 0000000..e3e85d2 --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/layout.ts @@ -0,0 +1,5 @@ +import { useLayoutEffect } from 'react' + +export const useMeasure = (measure: () => void): void => { + useLayoutEffect(measure, [measure]) +} diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/named.ts b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/named.ts new file mode 100644 index 0000000..c5c79c6 --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/named.ts @@ -0,0 +1,8 @@ +import { useEffect } from 'react' + +export const useClock = (tick: () => void): void => { + useEffect(() => { + const timer = setInterval(tick, 30_000) + return () => clearInterval(timer) + }, [tick]) +} diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/namespace.ts b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/namespace.ts new file mode 100644 index 0000000..5612adb --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/namespace.ts @@ -0,0 +1,3 @@ +import * as React from 'react' + +export const useCount = (): number => React.useState(0)[0] diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/reexport.ts b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/reexport.ts new file mode 100644 index 0000000..5327580 --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/reexport.ts @@ -0,0 +1 @@ +export { useEffect } from 'react' diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/require.ts b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/require.ts new file mode 100644 index 0000000..08a69ea --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/require.ts @@ -0,0 +1 @@ +export const react: unknown = require('react') diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/type-namespace.ts b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/type-namespace.ts new file mode 100644 index 0000000..fe2083a --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/type-namespace.ts @@ -0,0 +1,3 @@ +import type * as React from 'react' + +export type ReactModule = typeof React diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/value.ts b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/value.ts new file mode 100644 index 0000000..33dc329 --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-effect/value.ts @@ -0,0 +1,3 @@ +import { useEffect } from 'react' + +export const runAfterCommit = useEffect diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-rules/conditional-hook.tsx b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-rules/conditional-hook.tsx new file mode 100644 index 0000000..16b2e2c --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-rules/conditional-hook.tsx @@ -0,0 +1,9 @@ +import { useState } from 'react' + +export function Toggle({ open }: { readonly open: boolean }) { + if (open) { + const [count] = useState(0) + return {count} + } + return null +} diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-rules/effect-service.ts b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-rules/effect-service.ts new file mode 100644 index 0000000..d8dd1ce --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-rules/effect-service.ts @@ -0,0 +1,9 @@ +interface MicroVM { + readonly start: string +} + +declare const MicroVM: { readonly use: (f: (vm: MicroVM) => A) => A } +declare const useSync: (value: A) => A + +export const started = MicroVM.use((vm) => vm.start) +export const synced = useSync(1) diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-rules/index-key.tsx b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-rules/index-key.tsx new file mode 100644 index 0000000..a0fc10c --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-rules/index-key.tsx @@ -0,0 +1,3 @@ +export function Names({ names }: { readonly names: readonly string[] }) { + return
    {names.map((name, index) =>
  • {name}
  • )}
+} diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-rules/ref-in-render.tsx b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-rules/ref-in-render.tsx new file mode 100644 index 0000000..e925bfb --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/react-rules/ref-in-render.tsx @@ -0,0 +1,6 @@ +import { useRef } from 'react' + +export function Latest({ value }: { readonly value: number }) { + const latest = useRef(value) + return {latest.current} +} diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/screenshot/alias.stories.ts b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/screenshot/alias.stories.ts new file mode 100644 index 0000000..bd00195 --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/screenshot/alias.stories.ts @@ -0,0 +1,3 @@ +import { page as browserPage } from 'vitest/browser' + +export const capture = async (): Promise => browserPage.screenshot() diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/screenshot/canvas.stories.tsx b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/screenshot/canvas.stories.tsx new file mode 100644 index 0000000..81b46e8 --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/screenshot/canvas.stories.tsx @@ -0,0 +1,9 @@ +interface Canvas { + readonly getByRole: (role: string) => unknown +} + +export const Primary = { + play: ({ canvas }: { readonly canvas: Canvas }): void => { + canvas.getByRole('button') + }, +} diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/screenshot/capture.ts b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/screenshot/capture.ts new file mode 100644 index 0000000..b629771 --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/screenshot/capture.ts @@ -0,0 +1,3 @@ +import { page } from 'vitest/browser' + +export const capture = async (): Promise => page.screenshot() diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/screenshot/destructured-component.stories.tsx b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/screenshot/destructured-component.stories.tsx new file mode 100644 index 0000000..dd22482 --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/screenshot/destructured-component.stories.tsx @@ -0,0 +1,9 @@ +import { page } from 'vitest/browser' + +const { screenshot } = page + +export const Primary = { + play: async (): Promise => { + await screenshot() + }, +} diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/screenshot/destructured.stories.ts b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/screenshot/destructured.stories.ts new file mode 100644 index 0000000..dd22482 --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/screenshot/destructured.stories.ts @@ -0,0 +1,9 @@ +import { page } from 'vitest/browser' + +const { screenshot } = page + +export const Primary = { + play: async (): Promise => { + await screenshot() + }, +} diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/screenshot/direct-component.stories.tsx b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/screenshot/direct-component.stories.tsx new file mode 100644 index 0000000..c6cb726 --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/screenshot/direct-component.stories.tsx @@ -0,0 +1,7 @@ +import { page } from 'vitest/browser' + +export const Primary = { + play: async (): Promise => { + await page.screenshot({ path: 'primary.png' }) + }, +} diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/screenshot/direct.stories.ts b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/screenshot/direct.stories.ts new file mode 100644 index 0000000..c6cb726 --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/screenshot/direct.stories.ts @@ -0,0 +1,7 @@ +import { page } from 'vitest/browser' + +export const Primary = { + play: async (): Promise => { + await page.screenshot({ path: 'primary.png' }) + }, +} diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/screenshot/fetch-and-capture.stories.tsx b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/screenshot/fetch-and-capture.stories.tsx new file mode 100644 index 0000000..d791494 --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/screenshot/fetch-and-capture.stories.tsx @@ -0,0 +1,8 @@ +import { page } from 'vitest/browser' + +export const Primary = { + play: async (): Promise => { + await window.fetch('/demand') + await page.screenshot() + }, +} diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/screenshot/fetch.stories.ts b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/screenshot/fetch.stories.ts new file mode 100644 index 0000000..c4f8b57 --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/screenshot/fetch.stories.ts @@ -0,0 +1,5 @@ +export const Primary = { + play: async (): Promise => { + await window.fetch('/demand') + }, +} diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/screenshot/storage-component.stories.tsx b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/screenshot/storage-component.stories.tsx new file mode 100644 index 0000000..396e746 --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/screenshot/storage-component.stories.tsx @@ -0,0 +1 @@ +export const remember = (value: string): void => window.localStorage.setItem('draft', value) diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/screenshot/storage.stories.ts b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/screenshot/storage.stories.ts new file mode 100644 index 0000000..396e746 --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/screenshot/storage.stories.ts @@ -0,0 +1 @@ +export const remember = (value: string): void => window.localStorage.setItem('draft', value) diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/server-state/allowed.ts b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/server-state/allowed.ts new file mode 100644 index 0000000..d8225e9 --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/server-state/allowed.ts @@ -0,0 +1,6 @@ +import * as apolloClient from '@apollo/client' +import * as reduxToolkitQuery from '@reduxjs/toolkit/query' +import * as tanstackQueryCore from '@tanstack/query-core' +import * as urqlCore from '@urql/core' + +export const modules: readonly unknown[] = [tanstackQueryCore, apolloClient, urqlCore, reduxToolkitQuery] diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/server-state/forms.ts b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/server-state/forms.ts new file mode 100644 index 0000000..c2a3f9a --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/server-state/forms.ts @@ -0,0 +1,5 @@ +import type * as ReactQuery from '@tanstack/react-query' +export { default as useSWR } from 'swr' +export const loadUrql = async (): Promise => import('urql') + +export type ReactQueryModule = typeof ReactQuery diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/server-state/names.ts b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/server-state/names.ts new file mode 100644 index 0000000..f8dd668 --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/server-state/names.ts @@ -0,0 +1,25 @@ +import * as apolloClientReact from '@apollo/client/react' +import * as reduxToolkitQueryReact from '@reduxjs/toolkit/query/react' +import * as tanstackReactQuery from '@tanstack/react-query' +import * as tanstackReactQueryDevtools from '@tanstack/react-query-devtools' +import * as tanstackReactQueryNextExperimental from '@tanstack/react-query-next-experimental' +import * as tanstackReactQueryPersistClient from '@tanstack/react-query-persist-client' +import * as trpcReactQuery from '@trpc/react-query' +import * as trpcTanstackReactQuery from '@trpc/tanstack-react-query' +import * as reactQuery from 'react-query' +import * as swr from 'swr' +import * as urql from 'urql' + +export const modules: readonly unknown[] = [ + tanstackReactQuery, + tanstackReactQueryDevtools, + tanstackReactQueryPersistClient, + tanstackReactQueryNextExperimental, + reactQuery, + swr, + urql, + apolloClientReact, + reduxToolkitQueryReact, + trpcReactQuery, + trpcTanstackReactQuery, +] diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/server-state/subpaths.ts b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/server-state/subpaths.ts new file mode 100644 index 0000000..ceffe7f --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/server-state/subpaths.ts @@ -0,0 +1,25 @@ +import * as apolloClientReact from '@apollo/client/react/ssr' +import * as reduxToolkitQueryReact from '@reduxjs/toolkit/query/react/dist/index' +import * as tanstackReactQueryDevtools from '@tanstack/react-query-devtools/production' +import * as tanstackReactQueryNextExperimental from '@tanstack/react-query-next-experimental/build/modern' +import * as tanstackReactQueryPersistClient from '@tanstack/react-query-persist-client/build/modern' +import * as tanstackReactQuery from '@tanstack/react-query/build/modern' +import * as trpcReactQuery from '@trpc/react-query/rsc' +import * as trpcTanstackReactQuery from '@trpc/tanstack-react-query/dist/index' +import * as reactQuery from 'react-query/devtools' +import * as swr from 'swr/infinite' +import * as urql from 'urql/dist/urql.mjs' + +export const modules: readonly unknown[] = [ + tanstackReactQuery, + tanstackReactQueryDevtools, + tanstackReactQueryPersistClient, + tanstackReactQueryNextExperimental, + reactQuery, + swr, + urql, + apolloClientReact, + reduxToolkitQueryReact, + trpcReactQuery, + trpcTanstackReactQuery, +] diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/shell.ts b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/shell.ts new file mode 100644 index 0000000..10fc7ef --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/src/shell.ts @@ -0,0 +1 @@ +export { useQuery } from '@tanstack/react-query' diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/testResources/both.ts b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/testResources/both.ts new file mode 100644 index 0000000..29b344a --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/testResources/both.ts @@ -0,0 +1,6 @@ +import { legacyClient } from '@consumer/legacy-client' +import { useQuery } from '@tanstack/react-query' +import { readFileSync } from 'node:fs' +import { useLayoutEffect } from 'react' + +export const used: readonly unknown[] = [readFileSync, useQuery, legacyClient, useLayoutEffect] diff --git a/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/tsconfig.json b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/tsconfig.json new file mode 100644 index 0000000..ecb4f1b --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/ui-state-fixture/tsconfig.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@systemfsoftware/tsconfig/tsc/dom/library-monorepo", + "compilerOptions": { + "noEmit": true, + "jsx": "react-jsx", + "tsBuildInfoFile": "../node_modules/.cache/ui-state-fixture.tsbuildinfo", + "types": [ + "node" + ] + }, + "include": [ + ".", + ".storybook/preview.ts" + ] +} diff --git a/packages/oxlint-presets/oxlint-config-frontend/vitest.config.ts b/packages/oxlint-presets/oxlint-config-frontend/vitest.config.ts new file mode 100644 index 0000000..5d9c8e9 --- /dev/null +++ b/packages/oxlint-presets/oxlint-config-frontend/vitest.config.ts @@ -0,0 +1,10 @@ +import { sourceResolveConditions } from '@systemfsoftware/vitest-config' +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + ...sourceResolveConditions, + test: { + environment: 'node', + include: ['tests/**/*.test.ts', 'src/**/*.test.ts'], + }, +}) diff --git a/patches/rolldown-plugin-dts@0.28.6.patch b/patches/rolldown-plugin-dts@0.28.6.patch new file mode 100644 index 0000000..0b0a26e --- /dev/null +++ b/patches/rolldown-plugin-dts@0.28.6.patch @@ -0,0 +1,39 @@ +diff --git a/dist/index.mjs b/dist/index.mjs +index cb3854eccb1c8208bdd18bde6d9026a6261dff61..202e6eae0e94881ab90d5820dc1a44a57a5fa834 100644 +--- a/dist/index.mjs ++++ b/dist/index.mjs +@@ -1427,6 +1427,12 @@ function createFakeJsPlugin({ sourcemap, cjsDefault, sideEffects }) { + source: null, + attributes: [] + })); ++ if (program.body.some(isLocalDeclaration) && !program.body.some(isExportListStatement) && !program.body.some((node) => node.type === "TSExportAssignment")) program.body.push(b.ExportNamedDeclaration({ ++ declaration: null, ++ specifiers: [], ++ source: null, ++ attributes: [] ++ })); + const comments = /* @__PURE__ */ new Set(); + const commentsValue = /* @__PURE__ */ new Set(); + for (const id of chunk.moduleIds) { +@@ -1480,6 +1486,21 @@ function isModuleStatement(node) { + "TSExportAssignment" + ]) || node.type === "TSImportEqualsDeclaration" && node.moduleReference.type === "TSExternalModuleReference"; + } ++function isExportListStatement(node) { ++ return node.type === "ExportNamedDeclaration" && !node.declaration; ++} ++function isLocalDeclaration(node) { ++ if (is.oneOf(node, [ ++ "TSInterfaceDeclaration", ++ "TSTypeAliasDeclaration", ++ "TSEnumDeclaration", ++ "ClassDeclaration", ++ "FunctionDeclaration", ++ "VariableDeclaration", ++ "TSDeclareFunction" ++ ])) return true; ++ return node.type === "TSModuleDeclaration" && node.kind === "namespace" && node.id.type === "Identifier" && node.id.name !== "global"; ++} + //#endregion + //#region src/tsc/vue.ts + const debug$3 = createDebug("rolldown-plugin-dts:vue"); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 954465c..82ebf9e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -186,11 +186,20 @@ catalogs: specifier: 21.2.3 version: 21.2.3 '@effect/tsgo': - specifier: 0.51.0 - version: 0.51.0 + specifier: 0.51.1 + version: 0.51.1 + '@microsoft/api-extractor': + specifier: 7.59.4 + version: 7.59.4 '@rolldown/plugin-babel': specifier: 0.2.4 version: 0.2.4 + '@storybook/addon-vitest': + specifier: 10.6.2 + version: 10.6.2 + '@storybook/react-vite': + specifier: 10.6.2 + version: 10.6.2 '@tanstack/react-router': specifier: 1.170.41 version: 1.170.41 @@ -200,6 +209,12 @@ catalogs: '@tanstack/router-cli': specifier: 1.167.40 version: 1.167.40 + '@testing-library/dom': + specifier: 10.4.2 + version: 10.4.2 + '@testing-library/react': + specifier: 16.3.3 + version: 16.3.3 '@types/node': specifier: 24.19.1 version: 24.19.1 @@ -209,9 +224,21 @@ catalogs: '@types/react-dom': specifier: 19.3.0 version: 19.3.0 + '@types/scheduler': + specifier: 0.28.0 + version: 0.28.0 '@vitejs/plugin-react': specifier: 6.1.2 version: 6.1.2 + '@vitest/browser': + specifier: 5.0.5 + version: 5.0.5 + '@vitest/browser-playwright': + specifier: 5.0.5 + version: 5.0.5 + '@vitest/coverage-istanbul': + specifier: 5.0.5 + version: 5.0.5 alchemy: specifier: 2.0.0-beta.80 version: 2.0.0-beta.80 @@ -219,43 +246,80 @@ catalogs: specifier: 1.0.0 version: 1.0.0 effect: - specifier: 4.0.1 - version: 4.0.1 + specifier: 4.0.5 + version: 4.0.5 + fast-check: + specifier: 4.10.2 + version: 4.10.2 lint-staged: specifier: 17.6.0 version: 17.6.0 oxlint: - specifier: 1.82.0 - version: 1.82.0 + specifier: 1.87.0 + version: 1.87.0 oxlint-tsgolint: - specifier: 7.0.2001 - version: 7.0.2001 + specifier: 7.0.2003 + version: 7.0.2003 playwright: specifier: 1.63.0 version: 1.63.0 react: - specifier: 19.3.0 + specifier: ^19.3.0 version: 19.3.0 react-dom: - specifier: 19.3.0 + specifier: ^19.3.0 version: 19.3.0 + react-error-boundary: + specifier: 6.1.6 + version: 6.1.6 + rimraf: + specifier: 6.1.3 + version: 6.1.3 + scheduler: + specifier: 0.28.0 + version: 0.28.0 + storybook: + specifier: 10.6.2 + version: 10.6.2 + tsdown: + specifier: 0.23.0 + version: 0.23.0 + tstyche: + specifier: 7.2.5 + version: 7.2.5 turbo: specifier: 2.11.7 version: 2.11.7 + type-fest: + specifier: ^5.10.0 + version: 5.10.0 typescript: specifier: 7.0.2 version: 7.0.2 vite: - specifier: 8.3.2 - version: 8.3.2 + specifier: 8.3.4 + version: 8.3.4 vitest: - specifier: 5.0.3 - version: 5.0.3 + specifier: ^5.0.5 + version: 5.0.5 wrangler: specifier: 4.147.0 version: 4.147.0 + oxlint: + '@oxlint/plugins': + specifier: 1.87.0 + version: 1.87.0 + oxlint: + specifier: 1.87.0 + version: 1.87.0 + oxlint-tsgolint: + specifier: 7.0.2003 + version: 7.0.2003 overrides: + '@systemfsoftware/arethetypeswrong-cli': file:.sfs-deps/arethetypeswrong-cli-4.2.0.tgz + '@systemfsoftware/conformance-spec': file:.sfs-deps/conformance-spec-1.0.0.tgz + '@systemfsoftware/differential-spec': file:.sfs-deps/differential-spec-1.0.1.tgz '@systemfsoftware/effect-cell-types': file:.sfs-deps/effect-cell-types-12.0.0.tgz '@systemfsoftware/effect-gherkin-spec': file:.sfs-deps/effect-gherkin-spec-7.0.1.tgz '@systemfsoftware/effect-schema-discovery': file:.sfs-deps/effect-schema-discovery-2.0.0.tgz @@ -267,6 +331,7 @@ overrides: '@systemfsoftware/oxlint-config-cell-architecture': file:.sfs-deps/oxlint-config-cell-architecture-4.0.0.tgz '@systemfsoftware/oxlint-config-dmmf': file:.sfs-deps/oxlint-config-dmmf-2.0.0.tgz '@systemfsoftware/oxlint-config-recommended': file:.sfs-deps/oxlint-config-recommended-4.0.0.tgz + '@systemfsoftware/oxlint-config-rule-authoring': file:.sfs-deps/oxlint-config-rule-authoring-1.1.0.tgz '@systemfsoftware/oxlint-ignorer-config': file:.sfs-deps/oxlint-ignorer-config-0.1.1.tgz '@systemfsoftware/oxlint-plugin-cell-architecture': file:.sfs-deps/oxlint-plugin-cell-architecture-5.0.0.tgz '@systemfsoftware/oxlint-plugin-dmmf-workflow': file:.sfs-deps/oxlint-plugin-dmmf-workflow-7.0.0.tgz @@ -290,11 +355,19 @@ overrides: '@systemfsoftware/stryker-js-typescript-checker': file:.sfs-deps/stryker-js-typescript-checker-8.2.1.tgz '@systemfsoftware/stryker-js-vitest-runner': file:.sfs-deps/stryker-js-vitest-runner-8.1.1.tgz '@systemfsoftware/stryker-js-vm-runner': file:.sfs-deps/stryker-js-vitest-runner-8.1.1.tgz + '@systemfsoftware/stryker-config': file:.sfs-deps/stryker-config-0.1.0.tgz '@systemfsoftware/tsconfig': file:.sfs-deps/tsconfig-2.0.1.tgz + '@systemfsoftware/tsdown-config': file:.sfs-deps/tsdown-config-0.1.0.tgz '@systemfsoftware/vitest': file:.sfs-deps/vitest-2.0.0.tgz + '@systemfsoftware/vitest-config': file:.sfs-deps/vitest-config-0.1.0.tgz '@systemfsoftware/xstate': file:.sfs-deps/xstate-6.0.0-alpha.64.tgz + esbuild: ^0.28.2 + rolldown-plugin-dts: 0.28.6 workerd: 1.20261005.1 +patchedDependencies: + rolldown-plugin-dts@0.28.6: c4d02f422daffea3f211c29c3c2b926625c17774e763cc9a3643bf730fe57eba + importers: .: @@ -310,16 +383,16 @@ importers: version: 21.2.3 '@effect/tsgo': specifier: 'catalog:' - version: 0.51.0 + version: 0.51.1 '@systemfsoftware/oxlint-config-recommended': specifier: file:.sfs-deps/oxlint-config-recommended-4.0.0.tgz - version: file:.sfs-deps/oxlint-config-recommended-4.0.0.tgz(effect@4.0.1)(oxlint-tsgolint@7.0.2001)(oxlint@1.82.0(oxlint-tsgolint@7.0.2001))(typescript@7.0.2) + version: file:.sfs-deps/oxlint-config-recommended-4.0.0.tgz(effect@4.0.5)(oxlint-tsgolint@7.0.2003)(oxlint@1.87.0(oxlint-tsgolint@7.0.2003))(typescript@7.0.2) '@systemfsoftware/oxlint-plugin-test-discipline': specifier: file:.sfs-deps/oxlint-plugin-test-discipline-5.0.0.tgz - version: file:.sfs-deps/oxlint-plugin-test-discipline-5.0.0.tgz(effect@4.0.1)(typescript@7.0.2) + version: file:.sfs-deps/oxlint-plugin-test-discipline-5.0.0.tgz(effect@4.0.5)(typescript@7.0.2) '@systemfsoftware/stryker-js': specifier: file:.sfs-deps/stryker-js-17.0.2.tgz - version: file:.sfs-deps/stryker-js-17.0.2.tgz(@opentelemetry/api-logs@0.222.0)(@opentelemetry/resources@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(effect@4.0.1)(redis@6.2.1(@opentelemetry/api@1.9.1))(supports-color@10.2.2)(vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.19.1)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))) + version: file:.sfs-deps/stryker-js-17.0.2.tgz(@opentelemetry/api-logs@0.222.0)(@opentelemetry/resources@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(effect@4.0.5)(redis@6.2.1(@opentelemetry/api@1.9.1))(supports-color@10.2.2)(vitest@5.0.5) '@systemfsoftware/tsconfig': specifier: file:.sfs-deps/tsconfig-2.0.1.tgz version: file:.sfs-deps/tsconfig-2.0.1.tgz @@ -328,16 +401,19 @@ importers: version: 24.19.1 effect: specifier: 'catalog:' - version: 4.0.1 + version: 4.0.5 lint-staged: specifier: 'catalog:' version: 17.6.0 oxlint: specifier: 'catalog:' - version: 1.82.0(oxlint-tsgolint@7.0.2001) + version: 1.87.0(oxlint-tsgolint@7.0.2003) oxlint-tsgolint: specifier: 'catalog:' - version: 7.0.2001 + version: 7.0.2003 + tstyche-typescript: + specifier: npm:typescript@6.0.3 + version: typescript@6.0.3 turbo: specifier: 'catalog:' version: 2.11.7 @@ -346,13 +422,13 @@ importers: version: 7.0.2 vitest: specifier: 'catalog:' - version: 5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.19.1)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) + version: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@24.19.1)(@vitest/browser-playwright@5.0.5)(@vitest/coverage-istanbul@5.0.5)(@vitest/ui@5.0.5)(jsdom@30.1.2(@noble/hashes@2.4.0))(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) apps/site: dependencies: '@systemfsoftware/effect-cell-types': specifier: file:../../.sfs-deps/effect-cell-types-12.0.0.tgz - version: file:.sfs-deps/effect-cell-types-12.0.0.tgz(effect@4.0.1) + version: file:.sfs-deps/effect-cell-types-12.0.0.tgz(effect@4.0.5) '@systemfsoftware/xstate': specifier: file:../../.sfs-deps/xstate-6.0.0-alpha.64.tgz version: file:.sfs-deps/xstate-6.0.0-alpha.64.tgz @@ -361,10 +437,10 @@ importers: version: 1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@tanstack/react-start': specifier: 'catalog:' - version: 1.168.60(esbuild@0.28.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(rolldown@1.2.12)(supports-color@10.2.2)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) + version: 1.168.60(esbuild@0.28.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(rolldown@1.2.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) effect: specifier: 'catalog:' - version: 4.0.1 + version: 4.0.5 react: specifier: 'catalog:' version: 19.3.0 @@ -374,40 +450,40 @@ importers: devDependencies: '@alchemy.run/frontend-frameworks': specifier: 'catalog:' - version: 2.0.0-beta.80(@types/node@24.19.1)(@vitejs/plugin-react@6.1.2(@rolldown/plugin-babel@0.2.4(@babel/core@8.0.6)(rolldown@1.2.12)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)))(effect@4.0.1)(hono@4.11.4)(redis@6.2.1(@opentelemetry/api@1.9.1))(typescript@7.0.2)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) + version: 2.0.0-beta.80(@types/node@24.19.1)(@vitejs/plugin-react@6.1.2(@rolldown/plugin-babel@0.2.4(@babel/core@8.0.6)(@babel/runtime@7.29.10)(rolldown@1.2.12)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)))(effect@4.0.5)(hono@4.11.4)(redis@6.2.1(@opentelemetry/api@1.9.1))(typescript@7.0.2)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) '@babel/core': specifier: 'catalog:' version: 8.0.6 '@cloudflare/vite-plugin': specifier: 'catalog:' - version: 1.62.5(@types/node@24.19.1)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))(wrangler@4.147.0(@cloudflare/workers-types@5.20261005.1)(@types/node@24.19.1)) + version: 1.62.5(@types/node@24.19.1)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))(wrangler@4.147.0(@cloudflare/workers-types@5.20261005.1)(@types/node@24.19.1)) '@cloudflare/workers-types': specifier: 'catalog:' version: 5.20261005.1 '@rolldown/plugin-babel': specifier: 'catalog:' - version: 0.2.4(@babel/core@8.0.6)(rolldown@1.2.12)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) + version: 0.2.4(@babel/core@8.0.6)(@babel/runtime@7.29.10)(rolldown@1.2.12)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) '@systemfsoftware/oxlint-config-recommended': specifier: file:../../.sfs-deps/oxlint-config-recommended-4.0.0.tgz - version: file:.sfs-deps/oxlint-config-recommended-4.0.0.tgz(effect@4.0.1)(oxlint-tsgolint@7.0.2001)(oxlint@1.82.0(oxlint-tsgolint@7.0.2001))(typescript@7.0.2) + version: file:.sfs-deps/oxlint-config-recommended-4.0.0.tgz(effect@4.0.5)(oxlint-tsgolint@7.0.2003)(oxlint@1.87.0(oxlint-tsgolint@7.0.2003))(typescript@7.0.2) '@systemfsoftware/stryker-ignorer-effect-schema-declarations': specifier: file:../../.sfs-deps/stryker-ignorer-effect-schema-declarations-0.2.0.tgz version: file:.sfs-deps/stryker-ignorer-effect-schema-declarations-0.2.0.tgz(rolldown@1.2.12) '@systemfsoftware/stryker-js': specifier: file:../../.sfs-deps/stryker-js-17.0.2.tgz - version: file:.sfs-deps/stryker-js-17.0.2.tgz(@opentelemetry/api-logs@0.222.0)(@opentelemetry/resources@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(effect@4.0.1)(redis@6.2.1(@opentelemetry/api@1.9.1))(supports-color@10.2.2)(vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.19.1)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))) + version: file:.sfs-deps/stryker-js-17.0.2.tgz(@opentelemetry/api-logs@0.222.0)(@opentelemetry/resources@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(effect@4.0.5)(redis@6.2.1(@opentelemetry/api@1.9.1))(supports-color@10.2.2)(vitest@5.0.5) '@systemfsoftware/stryker-js-typescript-checker': specifier: file:../../.sfs-deps/stryker-js-typescript-checker-8.2.1.tgz version: file:.sfs-deps/stryker-js-typescript-checker-8.2.1.tgz(@opentelemetry/api-logs@0.222.0)(@opentelemetry/api@1.9.1)(@opentelemetry/resources@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(rolldown@1.2.12)(supports-color@10.2.2) '@systemfsoftware/stryker-js-vitest-runner': specifier: file:../../.sfs-deps/stryker-js-vitest-runner-8.1.1.tgz - version: file:.sfs-deps/stryker-js-vitest-runner-8.1.1.tgz(@opentelemetry/api-logs@0.222.0)(@opentelemetry/api@1.9.1)(@opentelemetry/resources@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(effect@4.0.1)(supports-color@10.2.2)(vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.19.1)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))) + version: file:.sfs-deps/stryker-js-vitest-runner-8.1.1.tgz(@opentelemetry/api-logs@0.222.0)(@opentelemetry/api@1.9.1)(@opentelemetry/resources@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(effect@4.0.5)(supports-color@10.2.2)(vitest@5.0.5) '@systemfsoftware/tsconfig': specifier: file:../../.sfs-deps/tsconfig-2.0.1.tgz version: file:.sfs-deps/tsconfig-2.0.1.tgz '@systemfsoftware/vitest': specifier: file:../../.sfs-deps/vitest-2.0.0.tgz - version: file:.sfs-deps/vitest-2.0.0.tgz(effect@4.0.1)(redis@6.2.1(@opentelemetry/api@1.9.1))(vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.19.1)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))) + version: file:.sfs-deps/vitest-2.0.0.tgz(effect@4.0.5)(redis@6.2.1(@opentelemetry/api@1.9.1))(vitest@5.0.5) '@tanstack/router-cli': specifier: 'catalog:' version: 1.167.40(supports-color@10.2.2) @@ -422,25 +498,25 @@ importers: version: 19.3.0(@types/react@19.3.0) '@vitejs/plugin-react': specifier: 'catalog:' - version: 6.1.2(@rolldown/plugin-babel@0.2.4(@babel/core@8.0.6)(rolldown@1.2.12)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) + version: 6.1.2(@rolldown/plugin-babel@0.2.4(@babel/core@8.0.6)(@babel/runtime@7.29.10)(rolldown@1.2.12)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) alchemy: specifier: 'catalog:' - version: 2.0.0-beta.80(@alchemy.run/frontend-frameworks@2.0.0-beta.80(@types/node@24.19.1)(@vitejs/plugin-react@6.1.2(@rolldown/plugin-babel@0.2.4(@babel/core@8.0.6)(rolldown@1.2.12)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)))(effect@4.0.1)(hono@4.11.4)(redis@6.2.1(@opentelemetry/api@1.9.1))(typescript@7.0.2)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)))(@effect/platform-node@4.0.1(effect@4.0.1)(redis@6.2.1(@opentelemetry/api@1.9.1)))(@types/node@24.19.1)(@types/react@19.3.0)(effect@4.0.1)(hono@4.11.4)(typescript@7.0.2)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))(ws@8.22.0) + version: 2.0.0-beta.80(@alchemy.run/frontend-frameworks@2.0.0-beta.80(@types/node@24.19.1)(@vitejs/plugin-react@6.1.2(@rolldown/plugin-babel@0.2.4(@babel/core@8.0.6)(@babel/runtime@7.29.10)(rolldown@1.2.12)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)))(effect@4.0.5)(hono@4.11.4)(redis@6.2.1(@opentelemetry/api@1.9.1))(typescript@7.0.2)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)))(@effect/platform-node@4.0.1(effect@4.0.5)(redis@6.2.1(@opentelemetry/api@1.9.1)))(@types/node@24.19.1)(@types/react@19.3.0)(effect@4.0.5)(hono@4.11.4)(typescript@7.0.2)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))(ws@8.22.0) babel-plugin-react-compiler: specifier: 'catalog:' version: 1.0.0 oxlint: specifier: 'catalog:' - version: 1.82.0(oxlint-tsgolint@7.0.2001) + version: 1.87.0(oxlint-tsgolint@7.0.2003) typescript: specifier: 'catalog:' version: 7.0.2 vite: specifier: 'catalog:' - version: 8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1) + version: 8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1) vitest: specifier: 'catalog:' - version: 5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.19.1)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) + version: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@24.19.1)(@vitest/browser-playwright@5.0.5)(@vitest/coverage-istanbul@5.0.5)(@vitest/ui@5.0.5)(jsdom@30.1.2(@noble/hashes@2.4.0))(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) wrangler: specifier: 'catalog:' version: 4.147.0(@cloudflare/workers-types@5.20261005.1)(@types/node@24.19.1) @@ -449,40 +525,416 @@ importers: devDependencies: '@systemfsoftware/effect-gherkin-spec': specifier: file:../../.sfs-deps/effect-gherkin-spec-7.0.1.tgz - version: file:.sfs-deps/effect-gherkin-spec-7.0.1.tgz(@systemfsoftware/vitest@file:.sfs-deps/vitest-2.0.0.tgz(effect@4.0.1)(redis@6.2.1(@opentelemetry/api@1.9.1))(vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.19.1)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))))(effect@4.0.1)(vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.19.1)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))) + version: file:.sfs-deps/effect-gherkin-spec-7.0.1.tgz(@systemfsoftware/vitest@file:.sfs-deps/vitest-2.0.0.tgz(effect@4.0.5)(redis@6.2.1(@opentelemetry/api@1.9.1))(vitest@5.0.5))(effect@4.0.5)(vitest@5.0.5) '@systemfsoftware/oxlint-config-recommended': specifier: file:../../.sfs-deps/oxlint-config-recommended-4.0.0.tgz - version: file:.sfs-deps/oxlint-config-recommended-4.0.0.tgz(effect@4.0.1)(oxlint-tsgolint@7.0.2001)(oxlint@1.82.0(oxlint-tsgolint@7.0.2001))(typescript@7.0.2) + version: file:.sfs-deps/oxlint-config-recommended-4.0.0.tgz(effect@4.0.5)(oxlint-tsgolint@7.0.2003)(oxlint@1.87.0(oxlint-tsgolint@7.0.2003))(typescript@7.0.2) '@systemfsoftware/tsconfig': specifier: file:../../.sfs-deps/tsconfig-2.0.1.tgz version: file:.sfs-deps/tsconfig-2.0.1.tgz '@systemfsoftware/vitest': specifier: file:../../.sfs-deps/vitest-2.0.0.tgz - version: file:.sfs-deps/vitest-2.0.0.tgz(effect@4.0.1)(redis@6.2.1(@opentelemetry/api@1.9.1))(vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.19.1)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))) + version: file:.sfs-deps/vitest-2.0.0.tgz(effect@4.0.5)(redis@6.2.1(@opentelemetry/api@1.9.1))(vitest@5.0.5) + '@types/node': + specifier: 'catalog:' + version: 24.19.1 + effect: + specifier: 'catalog:' + version: 4.0.5 + oxlint: + specifier: 'catalog:' + version: 1.87.0(oxlint-tsgolint@7.0.2003) + playwright: + specifier: 'catalog:' + version: 1.63.0 + typescript: + specifier: 'catalog:' + version: 7.0.2 + vite: + specifier: 'catalog:' + version: 8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1) + vitest: + specifier: 'catalog:' + version: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@24.19.1)(@vitest/browser-playwright@5.0.5)(@vitest/coverage-istanbul@5.0.5)(@vitest/ui@5.0.5)(jsdom@30.1.2(@noble/hashes@2.4.0))(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) + + packages/atom/effect-atom: + dependencies: + '@systemfsoftware/effect-cell-types': + specifier: file:../../../.sfs-deps/effect-cell-types-12.0.0.tgz + version: file:.sfs-deps/effect-cell-types-12.0.0.tgz(effect@4.0.5) + devDependencies: + '@microsoft/api-extractor': + specifier: 'catalog:' + version: 7.59.4(@types/node@24.19.1) + '@systemfsoftware/arethetypeswrong-cli': + specifier: file:../../../.sfs-deps/arethetypeswrong-cli-4.2.0.tgz + version: file:.sfs-deps/arethetypeswrong-cli-4.2.0.tgz + '@systemfsoftware/conformance-spec': + specifier: file:../../../.sfs-deps/conformance-spec-1.0.0.tgz + version: file:.sfs-deps/conformance-spec-1.0.0.tgz(effect@4.0.5) + '@systemfsoftware/differential-spec': + specifier: file:../../../.sfs-deps/differential-spec-1.0.1.tgz + version: file:.sfs-deps/differential-spec-1.0.1.tgz(redis@6.2.1(@opentelemetry/api@1.9.1))(vitest@5.0.5) + '@systemfsoftware/effect-gherkin-spec': + specifier: file:../../../.sfs-deps/effect-gherkin-spec-7.0.1.tgz + version: file:.sfs-deps/effect-gherkin-spec-7.0.1.tgz(@systemfsoftware/vitest@file:.sfs-deps/vitest-2.0.0.tgz(effect@4.0.5)(redis@6.2.1(@opentelemetry/api@1.9.1))(vitest@5.0.5))(effect@4.0.5)(vitest@5.0.5) + '@systemfsoftware/effect-schema-law': + specifier: file:../../../.sfs-deps/effect-schema-law-5.0.1.tgz + version: file:.sfs-deps/effect-schema-law-5.0.1.tgz(@systemfsoftware/vitest@file:.sfs-deps/vitest-2.0.0.tgz(effect@4.0.5)(redis@6.2.1(@opentelemetry/api@1.9.1))(vitest@5.0.5))(effect@4.0.5)(vitest@5.0.5) + '@systemfsoftware/effect-schema-vite': + specifier: file:../../../.sfs-deps/effect-schema-vite-3.0.1.tgz + version: file:.sfs-deps/effect-schema-vite-3.0.1.tgz(@systemfsoftware/effect-schema-law@file:.sfs-deps/effect-schema-law-5.0.1.tgz(@systemfsoftware/vitest@file:.sfs-deps/vitest-2.0.0.tgz(effect@4.0.5)(redis@6.2.1(@opentelemetry/api@1.9.1))(vitest@5.0.5))(effect@4.0.5)(vitest@5.0.5))(effect@4.0.5)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))(vitest@5.0.5) + '@systemfsoftware/oxlint-config-frontend': + specifier: workspace:^ + version: link:../../oxlint-presets/oxlint-config-frontend + '@systemfsoftware/stryker-config': + specifier: file:../../../.sfs-deps/stryker-config-0.1.0.tgz + version: file:.sfs-deps/stryker-config-0.1.0.tgz(@systemfsoftware/stryker-js@file:.sfs-deps/stryker-js-17.0.2.tgz(@opentelemetry/api-logs@0.222.0)(@opentelemetry/resources@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(effect@4.0.5)(redis@6.2.1(@opentelemetry/api@1.9.1))(supports-color@10.2.2)(vitest@5.0.5)) + '@systemfsoftware/stryker-ignorer-effect-schema-declarations': + specifier: file:../../../.sfs-deps/stryker-ignorer-effect-schema-declarations-0.2.0.tgz + version: file:.sfs-deps/stryker-ignorer-effect-schema-declarations-0.2.0.tgz(rolldown@1.2.12) + '@systemfsoftware/stryker-ignorer-in-source-vitest-block': + specifier: file:../../../.sfs-deps/stryker-ignorer-in-source-vitest-block-0.1.1.tgz + version: file:.sfs-deps/stryker-ignorer-in-source-vitest-block-0.1.1.tgz(rolldown@1.2.12) + '@systemfsoftware/stryker-js': + specifier: file:../../../.sfs-deps/stryker-js-17.0.2.tgz + version: file:.sfs-deps/stryker-js-17.0.2.tgz(@opentelemetry/api-logs@0.222.0)(@opentelemetry/resources@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(effect@4.0.5)(redis@6.2.1(@opentelemetry/api@1.9.1))(supports-color@10.2.2)(vitest@5.0.5) + '@systemfsoftware/stryker-js-typescript-checker': + specifier: file:../../../.sfs-deps/stryker-js-typescript-checker-8.2.1.tgz + version: file:.sfs-deps/stryker-js-typescript-checker-8.2.1.tgz(@opentelemetry/api-logs@0.222.0)(@opentelemetry/api@1.9.1)(@opentelemetry/resources@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(rolldown@1.2.12)(supports-color@10.2.2) + '@systemfsoftware/stryker-js-vitest-runner': + specifier: file:../../../.sfs-deps/stryker-js-vitest-runner-8.1.1.tgz + version: file:.sfs-deps/stryker-js-vitest-runner-8.1.1.tgz(@opentelemetry/api-logs@0.222.0)(@opentelemetry/api@1.9.1)(@opentelemetry/resources@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(effect@4.0.5)(supports-color@10.2.2)(vitest@5.0.5) + '@systemfsoftware/tsconfig': + specifier: file:../../../.sfs-deps/tsconfig-2.0.1.tgz + version: file:.sfs-deps/tsconfig-2.0.1.tgz + '@systemfsoftware/tsdown-config': + specifier: file:../../../.sfs-deps/tsdown-config-0.1.0.tgz + version: file:.sfs-deps/tsdown-config-0.1.0.tgz(@microsoft/api-extractor@7.59.4(@types/node@24.19.1))(tsdown@0.23.0(oxc-resolver@11.21.2)(typescript@7.0.2)) + '@systemfsoftware/vitest': + specifier: file:../../../.sfs-deps/vitest-2.0.0.tgz + version: file:.sfs-deps/vitest-2.0.0.tgz(effect@4.0.5)(redis@6.2.1(@opentelemetry/api@1.9.1))(vitest@5.0.5) + '@systemfsoftware/vitest-config': + specifier: file:../../../.sfs-deps/vitest-config-0.1.0.tgz + version: file:.sfs-deps/vitest-config-0.1.0.tgz(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))(vitest@5.0.5) + '@types/node': + specifier: 'catalog:' + version: 24.19.1 + '@vitest/browser': + specifier: 'catalog:' + version: 5.0.5(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))(vitest@5.0.5) + '@vitest/browser-playwright': + specifier: 'catalog:' + version: 5.0.5(playwright@1.63.0)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))(vitest@5.0.5) + '@vitest/coverage-istanbul': + specifier: 'catalog:' + version: 5.0.5(vitest@5.0.5) + effect: + specifier: 'catalog:' + version: 4.0.5 + fast-check: + specifier: 'catalog:' + version: 4.10.2 + oxlint: + specifier: catalog:oxlint + version: 1.87.0(oxlint-tsgolint@7.0.2003) + playwright: + specifier: 'catalog:' + version: 1.63.0 + rimraf: + specifier: 'catalog:' + version: 6.1.3 + tsdown: + specifier: 'catalog:' + version: 0.23.0(oxc-resolver@11.21.2)(typescript@7.0.2) + tstyche: + specifier: 'catalog:' + version: 7.2.5(typescript@7.0.2) + type-fest: + specifier: 'catalog:' + version: 5.10.0 + typescript: + specifier: 'catalog:' + version: 7.0.2 + vitest: + specifier: 'catalog:' + version: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@24.19.1)(@vitest/browser-playwright@5.0.5)(@vitest/coverage-istanbul@5.0.5)(@vitest/ui@5.0.5)(jsdom@30.1.2(@noble/hashes@2.4.0))(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) + + packages/atom/effect-atom-react: + dependencies: + '@systemfsoftware/effect-atom': + specifier: workspace:^ + version: link:../effect-atom + '@systemfsoftware/effect-cell-types': + specifier: file:../../../.sfs-deps/effect-cell-types-12.0.0.tgz + version: file:.sfs-deps/effect-cell-types-12.0.0.tgz(effect@4.0.5) + devDependencies: + '@microsoft/api-extractor': + specifier: 'catalog:' + version: 7.59.4(@types/node@24.19.1) + '@systemfsoftware/arethetypeswrong-cli': + specifier: file:../../../.sfs-deps/arethetypeswrong-cli-4.2.0.tgz + version: file:.sfs-deps/arethetypeswrong-cli-4.2.0.tgz + '@systemfsoftware/effect-gherkin-spec': + specifier: file:../../../.sfs-deps/effect-gherkin-spec-7.0.1.tgz + version: file:.sfs-deps/effect-gherkin-spec-7.0.1.tgz(@systemfsoftware/vitest@file:.sfs-deps/vitest-2.0.0.tgz(effect@4.0.5)(redis@6.2.1(@opentelemetry/api@1.9.1))(vitest@5.0.5))(effect@4.0.5)(vitest@5.0.5) + '@systemfsoftware/effect-schema-law': + specifier: file:../../../.sfs-deps/effect-schema-law-5.0.1.tgz + version: file:.sfs-deps/effect-schema-law-5.0.1.tgz(@systemfsoftware/vitest@file:.sfs-deps/vitest-2.0.0.tgz(effect@4.0.5)(redis@6.2.1(@opentelemetry/api@1.9.1))(vitest@5.0.5))(effect@4.0.5)(vitest@5.0.5) + '@systemfsoftware/effect-schema-vite': + specifier: file:../../../.sfs-deps/effect-schema-vite-3.0.1.tgz + version: file:.sfs-deps/effect-schema-vite-3.0.1.tgz(@systemfsoftware/effect-schema-law@file:.sfs-deps/effect-schema-law-5.0.1.tgz(@systemfsoftware/vitest@file:.sfs-deps/vitest-2.0.0.tgz(effect@4.0.5)(redis@6.2.1(@opentelemetry/api@1.9.1))(vitest@5.0.5))(effect@4.0.5)(vitest@5.0.5))(effect@4.0.5)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))(vitest@5.0.5) + '@systemfsoftware/oxlint-config-frontend': + specifier: workspace:^ + version: link:../../oxlint-presets/oxlint-config-frontend + '@systemfsoftware/stryker-config': + specifier: file:../../../.sfs-deps/stryker-config-0.1.0.tgz + version: file:.sfs-deps/stryker-config-0.1.0.tgz(@systemfsoftware/stryker-js@file:.sfs-deps/stryker-js-17.0.2.tgz(@opentelemetry/api-logs@0.222.0)(@opentelemetry/resources@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(effect@4.0.5)(redis@6.2.1(@opentelemetry/api@1.9.1))(supports-color@10.2.2)(vitest@5.0.5)) + '@systemfsoftware/stryker-ignorer-effect-schema-declarations': + specifier: file:../../../.sfs-deps/stryker-ignorer-effect-schema-declarations-0.2.0.tgz + version: file:.sfs-deps/stryker-ignorer-effect-schema-declarations-0.2.0.tgz(rolldown@1.2.12) + '@systemfsoftware/stryker-ignorer-in-source-vitest-block': + specifier: file:../../../.sfs-deps/stryker-ignorer-in-source-vitest-block-0.1.1.tgz + version: file:.sfs-deps/stryker-ignorer-in-source-vitest-block-0.1.1.tgz(rolldown@1.2.12) + '@systemfsoftware/stryker-js': + specifier: file:../../../.sfs-deps/stryker-js-17.0.2.tgz + version: file:.sfs-deps/stryker-js-17.0.2.tgz(@opentelemetry/api-logs@0.222.0)(@opentelemetry/resources@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(effect@4.0.5)(redis@6.2.1(@opentelemetry/api@1.9.1))(supports-color@10.2.2)(vitest@5.0.5) + '@systemfsoftware/stryker-js-typescript-checker': + specifier: file:../../../.sfs-deps/stryker-js-typescript-checker-8.2.1.tgz + version: file:.sfs-deps/stryker-js-typescript-checker-8.2.1.tgz(@opentelemetry/api-logs@0.222.0)(@opentelemetry/api@1.9.1)(@opentelemetry/resources@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(rolldown@1.2.12)(supports-color@10.2.2) + '@systemfsoftware/stryker-js-vitest-runner': + specifier: file:../../../.sfs-deps/stryker-js-vitest-runner-8.1.1.tgz + version: file:.sfs-deps/stryker-js-vitest-runner-8.1.1.tgz(@opentelemetry/api-logs@0.222.0)(@opentelemetry/api@1.9.1)(@opentelemetry/resources@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(effect@4.0.5)(supports-color@10.2.2)(vitest@5.0.5) + '@systemfsoftware/tsconfig': + specifier: file:../../../.sfs-deps/tsconfig-2.0.1.tgz + version: file:.sfs-deps/tsconfig-2.0.1.tgz + '@systemfsoftware/tsdown-config': + specifier: file:../../../.sfs-deps/tsdown-config-0.1.0.tgz + version: file:.sfs-deps/tsdown-config-0.1.0.tgz(@microsoft/api-extractor@7.59.4(@types/node@24.19.1))(tsdown@0.23.0(oxc-resolver@11.21.2)(typescript@7.0.2)) + '@systemfsoftware/vitest': + specifier: file:../../../.sfs-deps/vitest-2.0.0.tgz + version: file:.sfs-deps/vitest-2.0.0.tgz(effect@4.0.5)(redis@6.2.1(@opentelemetry/api@1.9.1))(vitest@5.0.5) + '@systemfsoftware/vitest-config': + specifier: file:../../../.sfs-deps/vitest-config-0.1.0.tgz + version: file:.sfs-deps/vitest-config-0.1.0.tgz(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))(vitest@5.0.5) + '@testing-library/dom': + specifier: 'catalog:' + version: 10.4.2 + '@testing-library/react': + specifier: 'catalog:' + version: 16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@types/node': specifier: 'catalog:' version: 24.19.1 + '@types/react': + specifier: 'catalog:' + version: 19.3.0 + '@types/react-dom': + specifier: 'catalog:' + version: 19.3.0(@types/react@19.3.0) + '@types/scheduler': + specifier: 'catalog:' + version: 0.28.0 + '@vitest/browser': + specifier: 'catalog:' + version: 5.0.5(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))(vitest@5.0.5) + '@vitest/browser-playwright': + specifier: 'catalog:' + version: 5.0.5(playwright@1.63.0)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))(vitest@5.0.5) + '@vitest/coverage-istanbul': + specifier: 'catalog:' + version: 5.0.5(vitest@5.0.5) effect: specifier: 'catalog:' - version: 4.0.1 + version: 4.0.5 oxlint: + specifier: catalog:oxlint + version: 1.87.0(oxlint-tsgolint@7.0.2003) + playwright: + specifier: 'catalog:' + version: 1.63.0 + react: + specifier: 'catalog:' + version: 19.3.0 + react-dom: + specifier: 'catalog:' + version: 19.3.0(react@19.3.0) + react-error-boundary: + specifier: 'catalog:' + version: 6.1.6(@types/react@19.3.0)(react@19.3.0) + rimraf: + specifier: 'catalog:' + version: 6.1.3 + scheduler: + specifier: 'catalog:' + version: 0.28.0 + tsdown: + specifier: 'catalog:' + version: 0.23.0(oxc-resolver@11.21.2)(typescript@7.0.2) + tstyche: + specifier: 'catalog:' + version: 7.2.5(typescript@7.0.2) + typescript: + specifier: 'catalog:' + version: 7.0.2 + vitest: + specifier: 'catalog:' + version: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@24.19.1)(@vitest/browser-playwright@5.0.5)(@vitest/coverage-istanbul@5.0.5)(@vitest/ui@5.0.5)(jsdom@30.1.2(@noble/hashes@2.4.0))(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) + + packages/gherkin/storybook-gherkin: + dependencies: + type-fest: + specifier: 'catalog:' + version: 5.10.0 + devDependencies: + '@microsoft/api-extractor': + specifier: 'catalog:' + version: 7.59.4(@types/node@24.19.1) + '@storybook/addon-vitest': + specifier: 'catalog:' + version: 10.6.2(@vitest/browser-playwright@5.0.5)(@vitest/browser@5.0.5(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))(vitest@5.0.5))(react@19.3.0)(storybook@10.6.2(@types/react@19.3.0)(prettier@3.9.9)(react@19.3.0))(vitest@5.0.5) + '@storybook/react-vite': + specifier: 'catalog:' + version: 10.6.2(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(storybook@10.6.2(@types/react@19.3.0)(prettier@3.9.9)(react@19.3.0))(supports-color@10.2.2)(typescript@7.0.2)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) + '@systemfsoftware/arethetypeswrong-cli': + specifier: file:../../../.sfs-deps/arethetypeswrong-cli-4.2.0.tgz + version: file:.sfs-deps/arethetypeswrong-cli-4.2.0.tgz + '@systemfsoftware/conformance-spec': + specifier: file:../../../.sfs-deps/conformance-spec-1.0.0.tgz + version: file:.sfs-deps/conformance-spec-1.0.0.tgz(effect@4.0.5) + '@systemfsoftware/effect-gherkin-spec': + specifier: file:../../../.sfs-deps/effect-gherkin-spec-7.0.1.tgz + version: file:.sfs-deps/effect-gherkin-spec-7.0.1.tgz(@systemfsoftware/vitest@file:.sfs-deps/vitest-2.0.0.tgz(effect@4.0.5)(redis@6.2.1(@opentelemetry/api@1.9.1))(vitest@5.0.5))(effect@4.0.5)(vitest@5.0.5) + '@systemfsoftware/effect-sim-kernel': + specifier: file:../../../.sfs-deps/effect-sim-kernel-1.0.0.tgz + version: file:.sfs-deps/effect-sim-kernel-1.0.0.tgz(effect@4.0.5) + '@systemfsoftware/effect-spec-runtime': + specifier: file:../../../.sfs-deps/effect-spec-runtime-1.0.1.tgz + version: file:.sfs-deps/effect-spec-runtime-1.0.1.tgz(@systemfsoftware/vitest@file:.sfs-deps/vitest-2.0.0.tgz(effect@4.0.5)(redis@6.2.1(@opentelemetry/api@1.9.1))(vitest@5.0.5))(effect@4.0.5)(vitest@5.0.5) + '@systemfsoftware/oxlint-config-frontend': + specifier: workspace:^ + version: link:../../oxlint-presets/oxlint-config-frontend + '@systemfsoftware/tsconfig': + specifier: file:../../../.sfs-deps/tsconfig-2.0.1.tgz + version: file:.sfs-deps/tsconfig-2.0.1.tgz + '@systemfsoftware/tsdown-config': + specifier: file:../../../.sfs-deps/tsdown-config-0.1.0.tgz + version: file:.sfs-deps/tsdown-config-0.1.0.tgz(@microsoft/api-extractor@7.59.4(@types/node@24.19.1))(tsdown@0.23.0(oxc-resolver@11.21.2)(typescript@7.0.2)) + '@systemfsoftware/vitest': + specifier: file:../../../.sfs-deps/vitest-2.0.0.tgz + version: file:.sfs-deps/vitest-2.0.0.tgz(effect@4.0.5)(redis@6.2.1(@opentelemetry/api@1.9.1))(vitest@5.0.5) + '@systemfsoftware/vitest-config': + specifier: file:../../../.sfs-deps/vitest-config-0.1.0.tgz + version: file:.sfs-deps/vitest-config-0.1.0.tgz(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))(vitest@5.0.5) + '@types/node': + specifier: 'catalog:' + version: 24.19.1 + '@types/react': + specifier: 'catalog:' + version: 19.3.0 + '@types/react-dom': + specifier: 'catalog:' + version: 19.3.0(@types/react@19.3.0) + '@vitest/browser': + specifier: 'catalog:' + version: 5.0.5(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))(vitest@5.0.5) + '@vitest/browser-playwright': + specifier: 'catalog:' + version: 5.0.5(playwright@1.63.0)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))(vitest@5.0.5) + effect: specifier: 'catalog:' - version: 1.82.0(oxlint-tsgolint@7.0.2001) + version: 4.0.5 + jsdom: + specifier: 30.1.2 + version: 30.1.2(@noble/hashes@2.4.0) + oxlint: + specifier: catalog:oxlint + version: 1.87.0(oxlint-tsgolint@7.0.2003) playwright: specifier: 'catalog:' version: 1.63.0 + react: + specifier: 'catalog:' + version: 19.3.0 + react-dom: + specifier: 'catalog:' + version: 19.3.0(react@19.3.0) + rimraf: + specifier: 'catalog:' + version: 6.1.3 + storybook: + specifier: 'catalog:' + version: 10.6.2(@types/react@19.3.0)(prettier@3.9.9)(react@19.3.0) + tsdown: + specifier: 'catalog:' + version: 0.23.0(oxc-resolver@11.21.2)(typescript@7.0.2) typescript: specifier: 'catalog:' version: 7.0.2 vite: specifier: 'catalog:' - version: 8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1) + version: 8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1) + vitest: + specifier: 'catalog:' + version: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@24.19.1)(@vitest/browser-playwright@5.0.5)(@vitest/coverage-istanbul@5.0.5)(@vitest/ui@5.0.5)(jsdom@30.1.2(@noble/hashes@2.4.0))(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) + + packages/oxlint-presets/oxlint-config-frontend: + dependencies: + '@oxlint/plugins': + specifier: catalog:oxlint + version: 1.87.0 + '@systemfsoftware/oxlint-config-recommended': + specifier: file:../../../.sfs-deps/oxlint-config-recommended-4.0.0.tgz + version: file:.sfs-deps/oxlint-config-recommended-4.0.0.tgz(effect@4.0.5)(oxlint-tsgolint@7.0.2003)(oxlint@1.87.0(oxlint-tsgolint@7.0.2003))(typescript@7.0.2) + '@systemfsoftware/oxlint-plugin-effect-platform': + specifier: file:../../../.sfs-deps/oxlint-plugin-effect-platform-2.0.0.tgz + version: file:.sfs-deps/oxlint-plugin-effect-platform-2.0.0.tgz(effect@4.0.5)(oxlint-tsgolint@7.0.2003)(typescript@7.0.2) + oxlint: + specifier: catalog:oxlint + version: 1.87.0(oxlint-tsgolint@7.0.2003) + oxlint-tsgolint: + specifier: catalog:oxlint + version: 7.0.2003 + devDependencies: + '@systemfsoftware/arethetypeswrong-cli': + specifier: file:../../../.sfs-deps/arethetypeswrong-cli-4.2.0.tgz + version: file:.sfs-deps/arethetypeswrong-cli-4.2.0.tgz + '@systemfsoftware/oxlint-config-rule-authoring': + specifier: file:../../../.sfs-deps/oxlint-config-rule-authoring-1.1.0.tgz + version: file:.sfs-deps/oxlint-config-rule-authoring-1.1.0.tgz(oxlint@1.87.0(oxlint-tsgolint@7.0.2003))(typescript@7.0.2) + '@systemfsoftware/tsconfig': + specifier: file:../../../.sfs-deps/tsconfig-2.0.1.tgz + version: file:.sfs-deps/tsconfig-2.0.1.tgz + '@systemfsoftware/tsdown-config': + specifier: file:../../../.sfs-deps/tsdown-config-0.1.0.tgz + version: file:.sfs-deps/tsdown-config-0.1.0.tgz(@microsoft/api-extractor@7.59.4(@types/node@24.19.1))(tsdown@0.23.0(oxc-resolver@11.21.2)(typescript@7.0.2)) + '@systemfsoftware/vitest-config': + specifier: file:../../../.sfs-deps/vitest-config-0.1.0.tgz + version: file:.sfs-deps/vitest-config-0.1.0.tgz(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))(vitest@5.0.5) + '@types/node': + specifier: 'catalog:' + version: 24.19.1 + effect: + specifier: 'catalog:' + version: 4.0.5 + rimraf: + specifier: 'catalog:' + version: 6.1.3 + tsdown: + specifier: 'catalog:' + version: 0.23.0(oxc-resolver@11.21.2)(typescript@7.0.2) + typescript: + specifier: 'catalog:' + version: 7.0.2 vitest: specifier: 'catalog:' - version: 5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.19.1)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) + version: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@24.19.1)(@vitest/browser-playwright@5.0.5)(@vitest/coverage-istanbul@5.0.5)(@vitest/ui@5.0.5)(jsdom@30.1.2(@noble/hashes@2.4.0))(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) packages: + '@adobe/css-tools@4.5.0': + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + '@alchemy.run/cloudflare-runtime@2.0.0-beta.80': resolution: {integrity: sha512-vrMDVAQ+LlzFGkgfIbi/65iwskdSxxWYhJX03sFL72mvkGxvb8RQSKrbxLGHhMz0QSZh796dOff1z9zOwxsIbA==} peerDependencies: @@ -561,6 +1013,14 @@ packages: react-devtools-core: optional: true + '@asamuzakjp/css-color@7.1.7': + resolution: {integrity: sha512-Q7ioeear07HwixdIitIn9KS1LZi97CEDFCSjPNm9v+y8KNbsu9q4Lrd4zUQW4faED+E7qxvfeVnbNRW46wFKuw==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + '@asamuzakjp/dom-selector@9.2.4': + resolution: {integrity: sha512-YnVzxDxLaqL0t7Q4wfcgHZjg55Wm6y5EaO7Bhu3q9J7wq/8iw3PhsnWiOQhYekx4VexnbkgWpTT6n+QIV7kczw==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -661,6 +1121,10 @@ packages: engines: {node: ^22.18.0 || >=24.11.0} hasBin: true + '@babel/runtime@7.29.10': + resolution: {integrity: sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==} + engines: {node: '>=6.9.0'} + '@babel/template@7.29.7': resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} @@ -685,6 +1149,13 @@ packages: resolution: {integrity: sha512-c+xgSWboV2pdwXP67BUWDVRHi2BO5Q3KwxyFVs7taVTY+fsK47/L51ZXQoV9rozHJDNHvJ4v4WEsI/IlxV2l2A==} engines: {node: ^22.18.0 || >=24.11.0} + '@blazediff/core@1.10.0': + resolution: {integrity: sha512-AOQff0zgR7cGsZL+4E7hVkmujoPUpm0J9xzWGWZj5wCjd3gmxESXAPfKyuzs93VdpQNFhHlBhfOjrcZ+XTERtQ==} + + '@bramus/specificity@2.4.3': + resolution: {integrity: sha512-0GEz8qJWokkUVYQYRukmkSHIFNoBH8QTreeHrAFbvz1XFYc3Y+puhMs8PQ5x8GNugiC2l5jvDBXKLbBIByeSBw==} + hasBin: true + '@chevrotain/cst-dts-gen@10.5.0': resolution: {integrity: sha512-lhmC/FyqQ2o7pGK4Om+hzuDrm9rhFYIJ/AXoQBeongmn870Xeb0L6oGEiuR8nohFNL5sMaQEJWCxr1oIVIVXrw==} @@ -839,6 +1310,42 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@csstools/color-helpers@6.1.2': + resolution: {integrity: sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.4.3': + resolution: {integrity: sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.2 + '@csstools/css-tokenizer': ^4.0.2 + + '@csstools/css-color-parser@4.2.6': + resolution: {integrity: sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.2 + '@csstools/css-tokenizer': ^4.0.2 + + '@csstools/css-parser-algorithms@4.0.2': + resolution: {integrity: sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.2 + + '@csstools/css-syntax-patches-for-csstree@1.1.15': + resolution: {integrity: sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.2': + resolution: {integrity: sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==} + engines: {node: '>=20.19.0'} + '@distilled.cloud/acme@1.0.0-rc.13': resolution: {integrity: sha512-3rmzH125jQxk0zpCvXGNEybSaqpAolyCX+8LWDDrXf+wIKs67GflkJyQLjOOh0lKARe29hdUZGgqEbbvpp0LNA==} peerDependencies: @@ -957,6 +1464,12 @@ packages: peerDependencies: effect: ^4.0.1 + '@effect/platform-node-shared@4.0.5': + resolution: {integrity: sha512-a8rCdEkfZBGe9C6bsIjxVZuRp73svIyK5IHwHdDAVsvZXjrbkxusfI+rZ8WlX31RP1mKizwI/E72GQTNfdMWHw==} + engines: {node: '>=18.0.0'} + peerDependencies: + effect: ^4.0.5 + '@effect/platform-node@4.0.1': resolution: {integrity: sha512-FT0Fu5LO0/XP9zBpj0HqrtJNLluK4StNKqf6TSulUsjp8FPBsDVXGJiDuyoN5J2yVMT7tgvhxSwaDcObWVRIaQ==} engines: {node: '>=18.0.0'} @@ -964,6 +1477,13 @@ packages: effect: ^4.0.1 redis: '>=5.0.0 <7.0.0' + '@effect/platform-node@4.0.5': + resolution: {integrity: sha512-v4KD5fWvEUa4QmWFpZFbBsRhmfnTP6ETCME2r9ifyXVDPjhfpxpTZl41prjgQFMqZBHdWWCl7YC5ImbXv5ui5g==} + engines: {node: '>=18.0.0'} + peerDependencies: + effect: ^4.0.5 + redis: '>=5.0.0 <7.0.0' + '@effect/sql-d1@4.0.1': resolution: {integrity: sha512-//VXATQFso9/y9JFDIfxxctMEwDlZq6RdlKA+q8nXE1N3q24FdBM9NF8BaBHT1G2NY+CB0U0PjqUASgsmcOvXw==} peerDependencies: @@ -974,82 +1494,43 @@ packages: peerDependencies: effect: ^4.0.1 - '@effect/tsgo-darwin-arm64@0.50.0': - resolution: {integrity: sha512-CtpXnQbf2BXavXeY3g/LZinyXWEsMftVcbla0zbwe4HiGqueUaacCqI1HTGZ2JUFYo/3M5MsnFFe2VCWW0wj4w==} + '@effect/tsgo-darwin-arm64@0.51.1': + resolution: {integrity: sha512-Jx+1RB3pz1B2zYs1EmxybwDOq9KRpfdu/rn1XTULNMZmqUJZuvTQU9CRqB0g/ZgIa6MeNBZEYvmAu4Dq2/QVWA==} cpu: [arm64] os: [darwin] - '@effect/tsgo-darwin-arm64@0.51.0': - resolution: {integrity: sha512-IsVT2Z7hNGT6RCLbNP/lyLyyjC9arVblfoXbr3qt3cF0A364BigXnoKx+NGm6ZNfg5NozW2NGKW2AD9btcy13Q==} - cpu: [arm64] - os: [darwin] - - '@effect/tsgo-darwin-x64@0.50.0': - resolution: {integrity: sha512-SC3Tk1nDtjUA+UYg3qyyKUz62/mhpPOdhL9HmBBI+Cksg3XXjYw8cenmDT24yJKiwFxO3AbYQ0siXy3QrYaOdQ==} - cpu: [x64] - os: [darwin] - - '@effect/tsgo-darwin-x64@0.51.0': - resolution: {integrity: sha512-S0k97HUL7vS9a1CZFcZEUeOK/6fYuev06li7+canKpn9w1PAp+YtxrET3r37ZdHeZP7w/WYJtT0yxxLZO7Mprw==} + '@effect/tsgo-darwin-x64@0.51.1': + resolution: {integrity: sha512-eRz7eHOOb2eJPUj66m89Zhzu924vGvNLDAXzcEAIaXVsWc4Zw29RVUsRnp/ozTmCbDVCvMqPSh8i9bkdAmt8mA==} cpu: [x64] os: [darwin] - '@effect/tsgo-linux-arm64@0.50.0': - resolution: {integrity: sha512-u6oBSeK9bL7Ta5OJX/v92AErjl+9YQXZuHPuWGsajia6cV2IpyR5UwbOX2ekk4V2QhVpSzmSIYVIEkq9jOL6Pg==} - cpu: [arm64] - os: [linux] - - '@effect/tsgo-linux-arm64@0.51.0': - resolution: {integrity: sha512-2KSJiPO3foagMgBuKhUBhO9X+JLHeJ/+2FzrwxQhexhR6V3LUnJ2L2hJS3KGs/sMn4TJjjtgczO8mAmIrFaffw==} + '@effect/tsgo-linux-arm64@0.51.1': + resolution: {integrity: sha512-LqZKkEEgJcq7dOho+h0esioU/idlB/+VhPuMR7l9B5Y7mk17Eivs8TiJQHtxLVfrh/nizbvZih7O5GujRNG0bw==} cpu: [arm64] os: [linux] - '@effect/tsgo-linux-arm@0.50.0': - resolution: {integrity: sha512-qLJdYvGw4mrMrjonhqzSkwByR4X5SlUhG2Pg0LIlKVSy21B0RxLQ2Sg1Re90XpwQCcFCV1byoqW4QLT6A1YDcQ==} - cpu: [arm] - os: [linux] - - '@effect/tsgo-linux-arm@0.51.0': - resolution: {integrity: sha512-4FUwHI8G1Fbpa3nPBfR9143mK0ziLjfa+/RVzbr9VIfSF0FWEYHJzD7A3RGNEh0EXZ9ZQG+UKmfs9kKRhWOvyw==} + '@effect/tsgo-linux-arm@0.51.1': + resolution: {integrity: sha512-vub6MCJIbczHp+Im1GTEUh5YyS+6YHcvH0ZmBdSjdvqzzBKbRnl5xUbO52tRJlty74f+3ey+uSh0nIO99NnggA==} cpu: [arm] os: [linux] - '@effect/tsgo-linux-x64@0.50.0': - resolution: {integrity: sha512-x7Xz7IPP/4CKHaWk58tm+LpgMPAwNXGQcLXZAkWUoarjcXzrDG3QCoEjnIpCRSzL7O8ykShApgGcYv0Pd2tJkA==} + '@effect/tsgo-linux-x64@0.51.1': + resolution: {integrity: sha512-zyKKcdL2Pr4C//9raggaEuxhsSUR7jT6517vHvPwykbBPjPnnmGtM1Kq7NKEIZnvXWzXcaK+4wAK/0FqSqMOmw==} cpu: [x64] os: [linux] - '@effect/tsgo-linux-x64@0.51.0': - resolution: {integrity: sha512-Psolf3poNm3PVHm/Q2Q66cXQ17ZIBLhtPJoKMvEIrlbAJmHMWfQyKeUrtRfum6V+yDTy6ZTeO6ksUfk0hg2YwA==} - cpu: [x64] - os: [linux] - - '@effect/tsgo-win32-arm64@0.50.0': - resolution: {integrity: sha512-VK3PTybFLmwKI50FMcNeX1LpPv9m55coKv+PKLj7gkb91YZIUooQ7hQJdSUhiuCTaUU3kikHQpnW7UbN9q4zDw==} - cpu: [arm64] - os: [win32] - - '@effect/tsgo-win32-arm64@0.51.0': - resolution: {integrity: sha512-V+BiYnc75s30F62CQO2NtTFiaG0tYpBcVbIIvoiRBVuHUy68RDCXVQWqSNG6JkohTIabObPmvk4bznCLbcGvPQ==} + '@effect/tsgo-win32-arm64@0.51.1': + resolution: {integrity: sha512-yIF88Za3Bs793Mb8OShet8gc3kj/NNty6+ZoTdgiPXWlY6xh84bZZCOETqE7VcCmxXFT3NZSYQuDrU35/CTvRQ==} cpu: [arm64] os: [win32] - '@effect/tsgo-win32-x64@0.50.0': - resolution: {integrity: sha512-ANbPqHKT/owJ9vIjyOWai+vO0IZAKZd3nN6QlPBBJyHe5BWMjz+MpntNwGxSvvp2PTMb4ZPBLQ2ixh4c9IMOMg==} - cpu: [x64] - os: [win32] - - '@effect/tsgo-win32-x64@0.51.0': - resolution: {integrity: sha512-sO2Ngbdxn6DrDdSCItXu/J5ha7PgFGyY7U1qJ4VzMyovazguiVJfkExskZ174Qa+ltPWZIGHFY/QdVZwHepOTw==} + '@effect/tsgo-win32-x64@0.51.1': + resolution: {integrity: sha512-5HO1TJkWMlErIXbfKKWEMzg0Jj0MPQAzs0g1k7wA3B1ZxNDoDY9iRu5eGM+G1a4HmKl3zA7hQZd0ch2Dh4MohQ==} cpu: [x64] os: [win32] - '@effect/tsgo@0.50.0': - resolution: {integrity: sha512-BCRwWFBVfQGhFPXmLlNCbYhohTPoIzCL+0iSrDkSpSN2TRtGbbxCB1Mi4oBPr1jRk5VgMB0jOFEvXmPt3SqCDg==} - hasBin: true - - '@effect/tsgo@0.51.0': - resolution: {integrity: sha512-USDPT8AJCq1xc8+C8gyroohI0aJ9SZmmnSQRUEKzEMiBMJFpVuadXKqkHQ1DGOwZDeu2NDz/tLXxDD/vtel1bA==} + '@effect/tsgo@0.51.1': + resolution: {integrity: sha512-i6mA6cNah+0mkPdu8qZILCO3Pnd9kWo03/s82Ju+1DFhdL28HFagi+1PRGxqgq1uMaHziWQ+ciKvOmIoks2RdA==} hasBin: true '@electric-sql/pglite-socket@0.0.20': @@ -1066,14 +1547,26 @@ packages: '@electric-sql/pglite@0.3.15': resolution: {integrity: sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ==} + '@emnapi/core@1.11.0': + resolution: {integrity: sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q==} + + '@emnapi/core@1.9.2': + resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==} + + '@emnapi/runtime@1.11.0': + resolution: {integrity: sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==} + '@emnapi/runtime@1.11.3': resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} - '@esbuild/aix-ppc64@0.28.1': - resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] + '@emnapi/runtime@1.9.2': + resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} '@esbuild/aix-ppc64@0.28.2': resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} @@ -1081,300 +1574,150 @@ packages: cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.28.1': - resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - '@esbuild/android-arm64@0.28.2': resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.28.1': - resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - '@esbuild/android-arm@0.28.2': resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.28.1': - resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - '@esbuild/android-x64@0.28.2': resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.28.1': - resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - '@esbuild/darwin-arm64@0.28.2': resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.28.1': - resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - '@esbuild/darwin-x64@0.28.2': resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.28.1': - resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - '@esbuild/freebsd-arm64@0.28.2': resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.28.1': - resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - '@esbuild/freebsd-x64@0.28.2': resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.28.1': - resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - '@esbuild/linux-arm64@0.28.2': resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.28.1': - resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - '@esbuild/linux-arm@0.28.2': resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.28.1': - resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - '@esbuild/linux-ia32@0.28.2': resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.28.1': - resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - '@esbuild/linux-loong64@0.28.2': resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.28.1': - resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - '@esbuild/linux-mips64el@0.28.2': resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.28.1': - resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - '@esbuild/linux-ppc64@0.28.2': resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.28.1': - resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - '@esbuild/linux-riscv64@0.28.2': resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.28.1': - resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - '@esbuild/linux-s390x@0.28.2': resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.28.1': - resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - '@esbuild/linux-x64@0.28.2': resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.28.1': - resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - '@esbuild/netbsd-arm64@0.28.2': resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.28.1': - resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - '@esbuild/netbsd-x64@0.28.2': resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.28.1': - resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - '@esbuild/openbsd-arm64@0.28.2': resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.28.1': - resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - '@esbuild/openbsd-x64@0.28.2': resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.28.1': - resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - '@esbuild/openharmony-arm64@0.28.2': resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.28.1': - resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - '@esbuild/sunos-x64@0.28.2': resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.28.1': - resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - '@esbuild/win32-arm64@0.28.2': resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.28.1': - resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - '@esbuild/win32-ia32@0.28.2': resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.28.1': - resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - '@esbuild/win32-x64@0.28.2': resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} engines: {node: '>=18'} @@ -1385,6 +1728,15 @@ packages: resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + '@exodus/bytes@1.16.0': + resolution: {integrity: sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@grpc/grpc-js@1.14.5': resolution: {integrity: sha512-7VZM+SVdEcUUqSQeNI3zM8Qs/BhQKZndPo2h5VkYkAM8Iz0wJIa8mKV5ekQGqG8UUsnkQ0NMxIxwkIHYvj0qOw==} engines: {node: '>=12.10.0'} @@ -1720,6 +2072,19 @@ packages: cpu: [x64] os: [win32] + '@istanbuljs/schema@0.1.6': + resolution: {integrity: sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==} + engines: {node: '>=8'} + + '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0': + resolution: {integrity: sha512-qvsTEwEFefhdirGOPnu9Wp6ChfIwy2dBCRuETU3uE+4cC+PFoxMSiiEhxk4lOluA34eARHA0OxqsEUYDqRMgeQ==} + peerDependencies: + typescript: '>= 4.3.x' + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + typescript: + optional: true + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -1802,10 +2167,32 @@ packages: cpu: [x64] os: [win32] + '@microsoft/api-extractor-model@7.33.15': + resolution: {integrity: sha512-UpCnItIJwu03iNTWtJVKv8Qbf0MXN7WLQZUail9cpAQmnZiIX36YPRRoRxz0pocwytzUpmJhstsLkyW5tsaHBA==} + engines: {node: '>=20.9.0'} + + '@microsoft/api-extractor@7.59.4': + resolution: {integrity: sha512-XqseDKIw736qlhQwS9SOpbY4veOiP2Nc7YiWmgXXBmeA3kY9qGXw+Tu7DFpU9FvXywE1z8SvLJKUVxFpcX0kig==} + engines: {node: '>=20.9.0'} + hasBin: true + + '@microsoft/tsdoc-config@0.18.3': + resolution: {integrity: sha512-0I0rGXo1eJziw3EEErkDS9p/0RzBcyOiV+zbSDqOZTeURmhtut+WIvpih0LM5g+ZpZ9SyFxDfkhAcl/r8e2zGw==} + + '@microsoft/tsdoc@0.17.1': + resolution: {integrity: sha512-VHyYyyoH63jFoO3shpw9Zfijk5v/9p/VrClW35z0/q6ooK7sSmxpzOkS4iZPxs3vO4XtBW4H38HRAKkAMIK7DA==} + '@mrleebo/prisma-ast@0.13.1': resolution: {integrity: sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==} engines: {node: '>=16'} + '@napi-rs/wasm-runtime@1.2.5': + resolution: {integrity: sha512-HshSvXzmBxNzqejd3k/KemgQ3hdREUYFRRrgxOZ+gPljDIsO2SDZrVZMd4Z/HsxudQbE62OVyVFE8z9BCurfjA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + peerDependencies: + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 + '@neon-rs/load@0.0.4': resolution: {integrity: sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==} @@ -2032,6 +2419,7 @@ packages: '@opentelemetry/propagator-jaeger@2.11.0': resolution: {integrity: sha512-RUOqgoIqXOPQTOhpBrs07Y8sBlxVoLY5Fka6ueZ0prW+k6fWQsba/nHDY4gm+gmfuwtRqbX5Y9YKhDvktjbIqQ==} engines: {node: ^18.19.0 || >=20.6.0} + deprecated: Package no longer supported. See https://github.com/open-telemetry/opentelemetry-js/pull/7077 peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' @@ -2062,12 +2450,14 @@ packages: '@opentelemetry/sdk-trace-base@2.11.0': resolution: {integrity: sha512-H19x/TX/LZdqiYOjM7fqtSxwlplC5pgelavqbQdHbhdq0q/AI/TGkM2dfGuuynTXmJPeF2HoZVoPDu+TGoW78A==} engines: {node: ^18.19.0 || >=20.6.0} + deprecated: Use @opentelemetry/sdk-trace package peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' '@opentelemetry/sdk-trace-node@2.11.0': resolution: {integrity: sha512-CuvCMJmZxswhNLlM2LfuLOW3h3fZujA4hsG4B+Sz4dX2zvaXO8Ng74cnDHWD64gLszTlhiG3c0iNUjj4g+0/sA==} engines: {node: ^18.19.0 || >=20.6.0} + deprecated: Use @opentelemetry/sdk-trace package peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' @@ -2081,48 +2471,139 @@ packages: resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} engines: {node: '>=14'} + '@oxc-parser/binding-android-arm-eabi@0.127.0': + resolution: {integrity: sha512-0LC7ye4hvqbIKxAzThzvswgHLFu2AURKzYLeSVvLdu2TBOYWQDmHnTqPLeA597BcUCxiLqLsS4CJ5uoI5WYWCQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + '@oxc-parser/binding-android-arm-eabi@0.150.0': resolution: {integrity: sha512-oQef2Zu4Prz1KLKznz3HqZzU9uVoA5PMoDZuuLmqms7hKmKSAPzlaMnLllJq3t+rgKmfJJ2siPrpZfFrW06btw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] + '@oxc-parser/binding-android-arm-eabi@0.153.0': + resolution: {integrity: sha512-BcWu25dMwMjHpaDCEidmpAi7rGg1qZoihJeUUH9bD55pVZ0z4Wa5CQgqSTo0R41H81oozisQKW3WqvAN2m77IQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxc-parser/binding-android-arm64@0.127.0': + resolution: {integrity: sha512-b5jtVTH6AU5CJXHNdj7Jj9IEiR9yVjjnwHzPJhGyHGPdcsZSzBCkS9GBbV33niRMvKthDwQRFRJfI4a+k4PvYg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + '@oxc-parser/binding-android-arm64@0.150.0': resolution: {integrity: sha512-B6ofpoFiAUwIZ0MJ2IgHPvZK8FAtL4qzSZRwOkAKIfxEobE1mQC8nDdiFZj7pUaJiVUVCsfkMsBJKedzO8rZvA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] + '@oxc-parser/binding-android-arm64@0.153.0': + resolution: {integrity: sha512-9l5rdp2DhRqVriBHsNpu5cEf+hizZ4B58rt93HzyQEBUgZiC6qe2oFpaAAnXGCxHGnQExI1s7S2G0QhFacUZ6w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxc-parser/binding-darwin-arm64@0.127.0': + resolution: {integrity: sha512-obCE8B7ISKkJidjlhv9xRGJPOSDG2Yu6PRga9Ruaz35uintHxbp1Ki/Yc71wx4rj3Edrm0a1kzG1TAwit0wFpg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + '@oxc-parser/binding-darwin-arm64@0.150.0': resolution: {integrity: sha512-J+9IHKzx/bSz1JetOfD4zKXSK9sOm4/a7+0qomJODcTL6JsRXGeV/ZdkAPSIDydfFmWzYCraMlQEosSZEyBYkQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] + '@oxc-parser/binding-darwin-arm64@0.153.0': + resolution: {integrity: sha512-EcpsyRXHhENDpekpG4QNaSZj2F/BAz4Gh4o89zZwNss86R8KyRSQ6sXbAj74Xyeb4z3j/yBZZ6joQZhraIWyfw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxc-parser/binding-darwin-x64@0.127.0': + resolution: {integrity: sha512-JL6Xb5IwPQT8rUzlpsX7E+AgfcdNklXNPFp8pjCQQ5MQOQo5rtEB2ui+3Hgg9Sn7Y9Egj6YOLLiHhLpdAe12Aw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + '@oxc-parser/binding-darwin-x64@0.150.0': resolution: {integrity: sha512-v6IPfcAcSYrBWXV5Tce1DmxDMXLhEYpIIWiRFPJopgCVscZdLaV4MRQOUxvL3usQlw+yrwYOjBFB9IwBx+jUiQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] + '@oxc-parser/binding-darwin-x64@0.153.0': + resolution: {integrity: sha512-hlj80HUQy07/2lYp6rmxH+3u+H2ik3rjLR52CUdPApxmZCNxASDUf/+ykTzAHS/fehftmVMATKvPaWmuLHuasg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxc-parser/binding-freebsd-x64@0.127.0': + resolution: {integrity: sha512-SDQ/3MQFw58fqQz3Z1PhSKFF3JoCF4gmlNjziDm8X02tTahCw0qJbd7FGPDKw1i4VTBZene9JPyC3mHtSvi+wA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + '@oxc-parser/binding-freebsd-x64@0.150.0': resolution: {integrity: sha512-AoR/4jD02HET0KO0yNT4nbTE+XJUxiO9jB1X/ycEjUE3WrIJ3IjV/Vf+gskhWLcqqeXfvNnkDtBR7epllAm88A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] + '@oxc-parser/binding-freebsd-x64@0.153.0': + resolution: {integrity: sha512-fWRcv2pMSh1qtez46MOu8vuUJ5fiaQZPHSphbWulbL7SHBMKfRgZl2PzjXZYQJbq7NEXyXbgIuIuUm+/Rmkv8w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxc-parser/binding-linux-arm-gnueabihf@0.127.0': + resolution: {integrity: sha512-Av+D1MIqzV0YMGPT9we2SIZaMKD7Cxs4CvXSx/yxaWHewZjYEjScpOf5igc8IILASViw4WTnjlwUdI1KzVtDHQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + '@oxc-parser/binding-linux-arm-gnueabihf@0.150.0': resolution: {integrity: sha512-A/hycCFjLUrCmLoL5O/vBp40ohlaXO1Ta3v5hqYZxicYFs129wZmgZJggcW4mYGYbZebQLhup+N8JjeQl8qwyw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] + '@oxc-parser/binding-linux-arm-gnueabihf@0.153.0': + resolution: {integrity: sha512-paZeUK1GXShl/bOJQET1/8AQxayFeuN4SIWAkgTRxE7ZQwv9uYSB2n0648smygP22N7jcI3zxAm0Z+ALNiIKxg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm-musleabihf@0.127.0': + resolution: {integrity: sha512-Cs2fdJ8cPpFdeebj6p4dag8A4+56hPvZ0AhQQzlaLswGz1tz7bXt1nETLeorrM9+AMcWFFkqxcXwDGfTVidY8g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + '@oxc-parser/binding-linux-arm-musleabihf@0.150.0': resolution: {integrity: sha512-pbqahg1Pkz7J4RKmXm30s/iQu99hv64ayXCu8P4p75HcEH5azOdR4eGqiB6lFGkFR3mMpzaYsSKLkS8oJbjmeQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] + '@oxc-parser/binding-linux-arm-musleabihf@0.153.0': + resolution: {integrity: sha512-qFPp5NwMf4K1C0O+DCVGmcbOaYyYAq98Rvv9p4VSfb777iUwlJCqMUxf6QG9N5f5ls4TEX6LitOZHi+6GL6rYQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm64-gnu@0.127.0': + resolution: {integrity: sha512-qdOfTcT6SY8gsJrrV92uyEUyjqMGPpIB5JZUG6QN5dukYd+7/j0kX6MwK1DgQj39jtUYixxPiaRUiEN1+0CXgQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@oxc-parser/binding-linux-arm64-gnu@0.150.0': resolution: {integrity: sha512-HV11aRbBQGwqv8bEo+K/6qr88uB4fEe1mhXncMhlVCrj+WpBSraxrUzHaPVmeQRU6x6x8v/3DuCbbo93rpp+fw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2130,6 +2611,20 @@ packages: os: [linux] libc: [glibc] + '@oxc-parser/binding-linux-arm64-gnu@0.153.0': + resolution: {integrity: sha512-oufEhZCX7saeo27Logra8cvBR1jQ/yw2zUI2hHmWw3qA/XYqEW31FOvLQBv7DWN32NjVdlcaP11WBBZTJo4k9g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-arm64-musl@0.127.0': + resolution: {integrity: sha512-EoTCZneNFU/P2qrpEM+RHmQwt+CvDkyGESG6qhr7KaegXLZwePfbrkCDfAk8/rhxbDUVGsZILX+2tqPzFtoFWA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + '@oxc-parser/binding-linux-arm64-musl@0.150.0': resolution: {integrity: sha512-k6pVkJqALwtEuP4zukVhGRhdIy4+ofChUIUUsAHHyqDZlNsknmel3JjbggrJiWGaes6h/dIcWmEXsKW4SmK9oQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2137,6 +2632,20 @@ packages: os: [linux] libc: [musl] + '@oxc-parser/binding-linux-arm64-musl@0.153.0': + resolution: {integrity: sha512-zXYzuAqng85h6+LI3iRm0zeMLX1/K2z41ZAMWxVRgt0D10VvZUaV5q+TEB46KDhoKBpdexoTLC6QoB/EiP2oqQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-linux-ppc64-gnu@0.127.0': + resolution: {integrity: sha512-zALjmZYgxFLHjXeudcDF0xFGNydTAtkAeXAr2EuC17ywCyFxcmQra4w0BMde0Yi/re4Bi4iwEoEXtYN7l6eBLQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@oxc-parser/binding-linux-ppc64-gnu@0.150.0': resolution: {integrity: sha512-tEFg39mw/rHO5n8GK2DR4ZMFfsPQZtnQIPbsIpjoQRomJc/2tRfsCSmCT6gNit+NZGoeJG5aH9ATDH5bf0WnoQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2144,6 +2653,20 @@ packages: os: [linux] libc: [glibc] + '@oxc-parser/binding-linux-ppc64-gnu@0.153.0': + resolution: {integrity: sha512-J7+NLwLEzdkKM9cKyPHH/AjXXstWOs98KYgLrPPnO4N9MO8EZRBfPNnBcS3S67uMOBkRpUmCl2FhRnWdQ5CbBA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-riscv64-gnu@0.127.0': + resolution: {integrity: sha512-fPP8M6zQLS7Jz7o9d5ArUSuAuSK3e+WCYVrCpdzeCOejidtZExJ9tjhDrAd3HEPqARBCPmdpqxESPFqy44vkBQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + '@oxc-parser/binding-linux-riscv64-gnu@0.150.0': resolution: {integrity: sha512-0JO7IFkoek6HqV589Menx3hJySNXi6quRhO4tq2P7kpEHKEXoDATnoPVUpn5B7gDH2KLkdo751N0uIrGJFCTCw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2151,6 +2674,20 @@ packages: os: [linux] libc: [glibc] + '@oxc-parser/binding-linux-riscv64-gnu@0.153.0': + resolution: {integrity: sha512-sD0K11GQTLWylln54nPMMTtgmBbQrR5It/vMpZpy/fogiw8O2AvIE+9LuWUXddrWk9zvFIHGRcSgFn4IHOfoYg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-riscv64-musl@0.127.0': + resolution: {integrity: sha512-7IcC4Ao02oGpfnjt+X/oF4U2mllo2qoSkw5xxiXNKL9MCTsTiAC6616beOuehdxGcnz1bRoPC1RQ2f1GQDdN+g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + '@oxc-parser/binding-linux-riscv64-musl@0.150.0': resolution: {integrity: sha512-7XPzREnyAS5wHU9aB+49Uaqgoo1gVCklHc3DRlc3fJy2tXD/eAJFN1QFz/crj+Ulup5P1+axNREF+/RGaB/IRA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2158,6 +2695,20 @@ packages: os: [linux] libc: [musl] + '@oxc-parser/binding-linux-riscv64-musl@0.153.0': + resolution: {integrity: sha512-7L66uql2BhvSz51LwfExjOdn6et/ydTiQxl23VApdKzJhTc3TczA1Qt6VR/UcvlQrL2ZbytIJb447vh7p5wmUw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-linux-s390x-gnu@0.127.0': + resolution: {integrity: sha512-pbXIhiNFHoqWeqDNLiJ9JkpHz1IM9k4DXa66x+1GTWMG7iLxtkXgE53iiuKSXwmk3zIYmaPVfBvgcAhS583K4Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@oxc-parser/binding-linux-s390x-gnu@0.150.0': resolution: {integrity: sha512-7n7ZxcbRWDFaTdyj8M8p0O9OfzoMKm8O6syBAIgcSutbOALq0Bb9G52Me+XH0anMLZV+NgXc726gqgG2q39vcQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2165,6 +2716,20 @@ packages: os: [linux] libc: [glibc] + '@oxc-parser/binding-linux-s390x-gnu@0.153.0': + resolution: {integrity: sha512-hXV3T7mNyrPR19rbPYkglkQJtoM/UNo/xwbZeeqdAxEg8PXgb4LXXuxPpGgI4bzRRCWwLUxdDSS58xIyC2u1TA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-x64-gnu@0.127.0': + resolution: {integrity: sha512-MYCguB9RvBvlSd6gbuNI7QwiLoCCAlGnlRJFPrzLI6U1/9wkC/WK6LtBAUln55H1Ctqw45PWmqrobKoMhsYQzQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + '@oxc-parser/binding-linux-x64-gnu@0.150.0': resolution: {integrity: sha512-Vx0GSA9ZCRTUiczoEQnIIyXkMvtEY8uc6IiwLQtMe5ci2sXPqjrry0Ek5fsPP1k2kCzkML3ow9UOOEgnEDi7Bw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2172,6 +2737,20 @@ packages: os: [linux] libc: [glibc] + '@oxc-parser/binding-linux-x64-gnu@0.153.0': + resolution: {integrity: sha512-Da8Vqs1xdtoDYtEjmozD7wGgJeLBQkMbR0WUBECOSxodyX5DXy8ySlvJWbukyO2Q0+TM9EK+4LT6tbq0jCg4hQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-x64-musl@0.127.0': + resolution: {integrity: sha512-5eY0B/bxf1xIUxb4NOTvOI3KWtBQfPWYyKAzgcrCt0mDibSZygVpO1Pz8bkeiSZ5Jj9+M09dkggG3H8I5d0Uyg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + '@oxc-parser/binding-linux-x64-musl@0.150.0': resolution: {integrity: sha512-ii4/9m3viDLssMnfXU7/Pni/3nYplApuGayceX4qsVGaqqQJCx3tHIH9M/HWIeSty5i8LjS21zPYT6lVIkwLxw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2179,31 +2758,94 @@ packages: os: [linux] libc: [musl] + '@oxc-parser/binding-linux-x64-musl@0.153.0': + resolution: {integrity: sha512-zGAPxNTIIw6X5WyTRscIFE/oDjpdNtrrl32ing6NOXdGjzRucadnxBilexLdeYQ9YxixF93GujpXyQ0jw7ddSg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-openharmony-arm64@0.127.0': + resolution: {integrity: sha512-Gld0ajrFTUXNtdw20fVBuTQx66FA75nIVg+//pPfR3sXkuABB4mTBhl3r9JNzrJpgW//qiwxf0nWXUWGJSL3UQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + '@oxc-parser/binding-openharmony-arm64@0.150.0': resolution: {integrity: sha512-ZtoxX5rez36ZWkwtiEhjkCPnlTPoQ2I7lIL0IYZ1yjxMYohbvoOjBmUIZzrf9W/HouONq0omIfTeCWEY+R380w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] + '@oxc-parser/binding-openharmony-arm64@0.153.0': + resolution: {integrity: sha512-GoHzpqP5XyPIp9DHokoEdCp+K4UPl/h1neFj1uXnUB3pJYyOQ7NOhFVOttNUgCGcj2eywAvu5cRivTOSq4S10g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxc-parser/binding-wasm32-wasi@0.127.0': + resolution: {integrity: sha512-T6KVD7rhLzFlwGRXMnxUFfkCZD8FHnb968wVXW1mXzgRFc5RNXOBY2mPPDZ77x5Ln76ltLMgtPg0cOkU1NSrEQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@oxc-parser/binding-win32-arm64-msvc@0.127.0': + resolution: {integrity: sha512-Ujvw4X+LD1CCGULcsQcvb4YNVoBGqt+JHgNNzGGaCImELiZLk477ifUH53gIbE7EKd933NdTi25JWEr9K2HwXw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + '@oxc-parser/binding-win32-arm64-msvc@0.150.0': resolution: {integrity: sha512-VqeRb5JX/bKYBrff7TUDvJyKY0914UzuCkuXIGxJS4FUmvMNfqkCbc7Sq90iMz9DlmAtlggwaBYQ9eg3h3ltiw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] + '@oxc-parser/binding-win32-arm64-msvc@0.153.0': + resolution: {integrity: sha512-xGcy12tO9k7zFbeyQzEuilfWfQpyRq23/fLPfN3wjSWuWv58k46fQT1hsO1KFNykZuVGW9xSJE/z592TmAoSDA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxc-parser/binding-win32-ia32-msvc@0.127.0': + resolution: {integrity: sha512-0cwxKO7KHQQQfo4Uf4B2SQrhgm+cJaP9OvFFhx52Tkg4bezsacu83GB2/In5bC415Ueeym+kXdnge/57rbSfTw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + '@oxc-parser/binding-win32-ia32-msvc@0.150.0': resolution: {integrity: sha512-EPqJfeZ4Pgg2BJSsCV8GozxV0FDltRzpVtGVa1r2noJu1iu+opOw3gtBWXwIo9odCT/MdFfyHxdy0/CRqs3OqA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxc-parser/binding-win32-x64-msvc@0.150.0': - resolution: {integrity: sha512-n5YMzbqwPQqozbkrexi0lNZrUz5cnPHalYpFWe6Dau7AmKIWNo+y24IwzDuZEQtEdUHuKhXmMMih/MPjOI+CMw==} + '@oxc-parser/binding-win32-ia32-msvc@0.153.0': + resolution: {integrity: sha512-o1ykUjWncvXX6N5a4KebgLjtD8zRui+f1llMhSGoSC9oP2KOqapRpdwmZ3NcNG0axFouwVKmhGBjd+IhA8BksA==} engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] + cpu: [ia32] os: [win32] - '@oxc-project/types@0.146.0': + '@oxc-parser/binding-win32-x64-msvc@0.127.0': + resolution: {integrity: sha512-rOrnSQSCbhI2kowr9XxE7m9a8oQXnBHjnS6j95LxxAnEZ0+Fz20WlRXG4ondQb+ejjt2KOsa65sE6++L6kUd+w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxc-parser/binding-win32-x64-msvc@0.150.0': + resolution: {integrity: sha512-n5YMzbqwPQqozbkrexi0lNZrUz5cnPHalYpFWe6Dau7AmKIWNo+y24IwzDuZEQtEdUHuKhXmMMih/MPjOI+CMw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxc-parser/binding-win32-x64-msvc@0.153.0': + resolution: {integrity: sha512-gxQz7G96Up136fd+2zWrP+o6FYGs9NO4Dyv55IoeJFJUDwLFHcLYY11hhA5wQKs0HoM57Q8esQ3nEVhnCdsjAQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxc-project/types@0.127.0': + resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==} + + '@oxc-project/types@0.146.0': resolution: {integrity: sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==} '@oxc-project/types@0.150.0': @@ -2212,162 +2854,367 @@ packages: '@oxc-project/types@0.152.0': resolution: {integrity: sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==} - '@oxlint-tsgolint/darwin-arm64@7.0.2001': - resolution: {integrity: sha512-CUJEdbSZ54+Xy9OXqOhWLTKZKV0BBiV7C2i/ygyVmXtkUNXx5YCzN8DpSSshTAKktoL7S+tnQ/ftFG/i7X896w==} + '@oxc-project/types@0.153.0': + resolution: {integrity: sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==} + + '@oxc-resolver/binding-android-arm-eabi@11.21.2': + resolution: {integrity: sha512-xQoCRv+gKax9KTdwdaQNnAFOai8neay7g3jExDIORzhbrejwGSJaZNTdOJHR5ziLg2joMxOCMOFMo4zuxza2uQ==} + cpu: [arm] + os: [android] + + '@oxc-resolver/binding-android-arm64@11.21.2': + resolution: {integrity: sha512-HF5oiE2L05yInPYCFD/4uxSrEZW4SuIfn99Y6L1xnJnzl066JR+MJs2rIdstw8A2MPlAKH+13dpFPNycjqzvGg==} + cpu: [arm64] + os: [android] + + '@oxc-resolver/binding-darwin-arm64@11.21.2': + resolution: {integrity: sha512-UX4u49CVCAD8QZNELaW8eMGgMAGwFWYEPbvNsh+3r/gs4NX3KfpiACMVwRQT0EuH3uat9hM5Zl+Ppm9pJD8tgg==} + cpu: [arm64] + os: [darwin] + + '@oxc-resolver/binding-darwin-x64@11.21.2': + resolution: {integrity: sha512-J9xPx7YBkrRmJ+xl561ztnMWEc1aOyjEIxBiGX1dVb3u7bGSnfObfcZk+Pd+uM0HZAPNsQ1xvD8j52A/uOSqNQ==} + cpu: [x64] + os: [darwin] + + '@oxc-resolver/binding-freebsd-x64@11.21.2': + resolution: {integrity: sha512-fRlt7OvSaQkWj6+EDTVxawVxOlqJB2QSnBfkeCyK4RTvsGctbw3BiH2Tb7DzMs7bikc4BRBpvWP5zF9K8b54Zg==} + cpu: [x64] + os: [freebsd] + + '@oxc-resolver/binding-linux-arm-gnueabihf@11.21.2': + resolution: {integrity: sha512-gK+vPUcPQITkGwBKpZGrcDHSlU6eDGl7AQacxS2CEKAZIBHWkOVFeJwLZ4tYnA1acJqRM5lt7yYwPCVqGHIJ7A==} + cpu: [arm] + os: [linux] + + '@oxc-resolver/binding-linux-arm-musleabihf@11.21.2': + resolution: {integrity: sha512-L/Rgas7SrOKy/z7IH+HxSFRqVO4PuLDKLEGKvnhoCBBq3UJ0YzGBou3qMzaAGgkJwsIvX2pBF+7ojzfUhLiZxQ==} + cpu: [arm] + os: [linux] + + '@oxc-resolver/binding-linux-arm64-gnu@11.21.2': + resolution: {integrity: sha512-CUEYvlX1Fk7E9kUMzuswru1J9HLxMwnpDeQGjQuI4ZH+iNCoa2X9T+pvyzrbsgl7WnIeTFTlNlHsRfVdf5g9/g==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-arm64-musl@11.21.2': + resolution: {integrity: sha512-ViN1ZibQyxwC67GpoP33oo+S9UyUnkog13vzQb9+v9bCNvrVzJuk0MRdacjtv/9xfRMVF/eqHFyl8YOeykI10Q==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxc-resolver/binding-linux-ppc64-gnu@11.21.2': + resolution: {integrity: sha512-CU1sCqWnhGqYD5I1HedHk5pujrn7ssDkNB/AEQd/pd3D/EojVSgJUlpbafwIbyhia3PgIfkvdFpRPWSALzVumA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-riscv64-gnu@11.21.2': + resolution: {integrity: sha512-jWVyZtIHca4Gb96x7dag+y69vlei7ffjrsveLkmf2ZhqEAz6ZSBnY1GWvgZUaZlwZP62A3xD092BW97Q5VGc+g==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-riscv64-musl@11.21.2': + resolution: {integrity: sha512-LF29obFqNgBUgDX7rmUK7M4D0JQG5LxhYzn3xXmECcHU9aQAdWG7NiY052qybtesEdwHQXKNTWYQ7mTsybNvWg==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxc-resolver/binding-linux-s390x-gnu@11.21.2': + resolution: {integrity: sha512-+NYcm+cCHBbtdQQ3A4phQTSuVRYnNHz7wrl9XRAPEovcdoqi0mb1K5ZOl+jN54ZD+q1zz3V0vltbFJmzecJKmw==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-x64-gnu@11.21.2': + resolution: {integrity: sha512-UQqZDdG2r2HhAOsZEgufkIWHPQ886IUyuJQkoZByvzhW8j51R4UNzGBJFkTiTnLhQnggwJRdJgFWK4uY6ZVIMw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-x64-musl@11.21.2': + resolution: {integrity: sha512-3Q9PMRjalWkT6NZ4jfujuqTCFwoWErg3y3BnOgb544B8IMw4PktiwWOigMfOHNRLMghZeJ7hpfpZf4CP7rV7Og==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxc-resolver/binding-openharmony-arm64@11.21.2': + resolution: {integrity: sha512-Eljeq3ndtyKhM+Es8LITi4Zl2htzuRZcrPMF3kMCsrILztvU6AjZ3FuEhHHKobPUB9rMpzLpJP5bDqXI7r+iog==} + cpu: [arm64] + os: [openharmony] + + '@oxc-resolver/binding-wasm32-wasi@11.21.2': + resolution: {integrity: sha512-HGDbNsIywqc4LxU38+CTJNnB/6BF7rheWOJ259b4eE0aEnelYblC8x+1tEd63bp31fYne63RQz9Jb4yVnC7Yig==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@oxc-resolver/binding-win32-arm64-msvc@11.21.2': + resolution: {integrity: sha512-iWx25CBEgH49iE9q5coEGI/jb1jl5kkCY9z6U5Og67xCkQ/WFMDc2J5U78+AE91SUxM2NqSLhJC8/PLfWnImww==} + cpu: [arm64] + os: [win32] + + '@oxc-resolver/binding-win32-x64-msvc@11.21.2': + resolution: {integrity: sha512-VPoCAhKvCQTlG7vxqaBXcmuvbh77BfnGXj8g0pbvVXpm1F/R8rDVwqIWcEbMzrI1JvJlm8v7T9uMVQb6UctMRg==} + cpu: [x64] + os: [win32] + + '@oxlint-tsgolint/darwin-arm64@7.0.2003': + resolution: {integrity: sha512-TgV33rXr6ueXBwvc+0nssUkTBSXHJxv77I8p4RCjDjCnvexHtmoPiudVRDfj3S3puMDdeQdHW6jdUgUPy3nr/w==} cpu: [arm64] os: [darwin] - '@oxlint-tsgolint/darwin-x64@7.0.2001': - resolution: {integrity: sha512-pXfBb5BqONCcgrXQNUZWXgiYmRSWJzd97S8i41VVOh6ut0tyo+cJ5FKFpczDHxiVNfj/3e7c9B4MtztNdpIVCw==} + '@oxlint-tsgolint/darwin-x64@7.0.2003': + resolution: {integrity: sha512-hY3FMAjIaPDdK3FNxyRXRfHPOFeoBn6dmnLyKZgQ2IbTTD1adXhl6PfCIqHhCPvurigv7nf7mYuWZZ19MmJzmg==} cpu: [x64] os: [darwin] - '@oxlint-tsgolint/linux-arm64@7.0.2001': - resolution: {integrity: sha512-roP7zujb/QDPzDwEKsFFpzNHHy91/Y7oX9vQXk78ekyZtcQj1QXDIMH33gjDdHBfRl4K9pZ36xhRgrP4Zr+R8A==} + '@oxlint-tsgolint/linux-arm64@7.0.2003': + resolution: {integrity: sha512-eAET4JpyfBbg8SO0K74o4R55tEjVC292pIyxGOw2XGf8x/HrPNyxwQ478jMYOM54FIVOHc8sJ6VRHxluy6lucw==} cpu: [arm64] os: [linux] - '@oxlint-tsgolint/linux-x64@7.0.2001': - resolution: {integrity: sha512-UDezNqdECVmngu2TPnjaS1YoAmcTaBoI5lV9vk3VahBxoi+I5r9k3iJTT7qZoYWOXTD/7T7bNcwRgrocR6BscQ==} + '@oxlint-tsgolint/linux-x64@7.0.2003': + resolution: {integrity: sha512-GXdyO/XyqDJ3s/llR/oOktLsYNjZtWSQBy0JMc+/0gsNPvTcseKPhn9c6KcFyWmnr6H4vljYALbujonqSzzEGw==} cpu: [x64] os: [linux] - '@oxlint-tsgolint/win32-arm64@7.0.2001': - resolution: {integrity: sha512-uJZhqB6pdXLuN+AD1F5082byyQti/NPmJA77GtcFlmT2HzRelqbNls3SaIqxpjdFgvSBF9g0yOKGBkGFg7kX8Q==} + '@oxlint-tsgolint/win32-arm64@7.0.2003': + resolution: {integrity: sha512-TWauXnPfet0VgpmrstoAK58eJ4gbuwPUuUTQmYQAzE/RG1CpnxXtrKUJULf6lyerPE4KN3gM+DflIA1hOH+38Q==} cpu: [arm64] os: [win32] - '@oxlint-tsgolint/win32-x64@7.0.2001': - resolution: {integrity: sha512-FkDRm8hx9OwzGQqyWG1tO5QrTLRApff9DzSgpz9QZau37BR8d1VYKOxMLGf6shPZntJFoTwIIJYT68VndYDCog==} + '@oxlint-tsgolint/win32-x64@7.0.2003': + resolution: {integrity: sha512-DoRmfe7j8VqNlukp8liRVW45GQDhzRccNenjD/pdzelgtffW47pCMd1xbJLkPaPbKnTwID3onn3VZlL7JbebEA==} cpu: [x64] os: [win32] - '@oxlint/binding-android-arm-eabi@1.82.0': - resolution: {integrity: sha512-a3LB+C5Dsj5b/qtmG/mv5WrzuiXEpg1KF5nXWcEvaoN5TYAqkIvxPOwTPp3Jy/FoGpRo8zsTFhMElMXfeoOEzA==} + '@oxlint/binding-android-arm-eabi@1.87.0': + resolution: {integrity: sha512-22tVIHlB9898OYsBKmYB6He1MCSqdTmK1BVWuSNUTVrpS0KWNege+U9uEYqGJ+KatQag0r/oU44i3OOs46z7HQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxlint/binding-android-arm64@1.82.0': - resolution: {integrity: sha512-OBlhRgNqFblGpGenno/aqOfJLOkQ2B8Ig3iDAalfn0H8hJGZKXPeexCRTDm6uwv6YUjSA9Xnwt1y/Bgj5ZH8uw==} + '@oxlint/binding-android-arm64@1.87.0': + resolution: {integrity: sha512-JfZQ3MWf74n8ov0DojZpkOduIubYJvZL5tbTMMhDutsal6u1xH1QuNpN+lK+EQwF8AeAfgwTR2fgh5U2br27XA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxlint/binding-darwin-arm64@1.82.0': - resolution: {integrity: sha512-dsopxqtY5ZdyT9uLHyGt1SyiLop6hi7hWI3PKpePodkRQOkLaCm+OE4fR9CAz9qdfjiFO8531tX/QDyP/psjFg==} + '@oxlint/binding-darwin-arm64@1.87.0': + resolution: {integrity: sha512-rkx6Xvvw93z2GImVOyvbZwAZcoY0Z6MJW177XNsSB+i/CBsH7ANyelTp01VZyIkM3iSXtqKVb4+ptBgPHap5pQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxlint/binding-darwin-x64@1.82.0': - resolution: {integrity: sha512-94Lu0SgTClKColU66g1VDuigV3HkcbkJBnTtZjGYfE8UPugaWDgKrm2icjC6HJVUYler2OXaHP/X0TBy8+CowQ==} + '@oxlint/binding-darwin-x64@1.87.0': + resolution: {integrity: sha512-cw5nYw7L/pQ2SgGBgOcg/Qo89llQre3vIc/kuBLhSICzu1r5s8L76H3ug6AIFsD98WpROj8/z5JVzkf4daxHaQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxlint/binding-freebsd-x64@1.82.0': - resolution: {integrity: sha512-hne/V06ewhh1i0w8+l7GDNROAGCGPmyFuOwiP7YTRu0JycyStJ4785dmF8xU5p0uUwt2emvIF9vc7Xjis+cJ0g==} + '@oxlint/binding-freebsd-x64@1.87.0': + resolution: {integrity: sha512-x/SuznE6loRZK9gz9AS4ZmzNzSu+5KdeoCS+sVHhnrE8HM6AD8UyTjwldQTTqlXGvkwHASgB0NiTNOc3o3PdAg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxlint/binding-linux-arm-gnueabihf@1.82.0': - resolution: {integrity: sha512-aWY2xtbZf1LneW9Qsv/n2Sp8gOu74JrlQzEtj4coHX2SHFrCfhmAumaU+sI/A5nr+yoTRTSmI/pL2s6ADlNSkw==} + '@oxlint/binding-linux-arm-gnueabihf@1.87.0': + resolution: {integrity: sha512-+JEDmCAXiiFmKhAjZhgAOwaHWY6WENOQ5QA+lFuY59tgsUCDOTRi46B+lENK6CWvtxsXpwkZXNOgpjAQcpIpnA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm-musleabihf@1.82.0': - resolution: {integrity: sha512-Fe+TtXCXMh/5f7kWlZ2VAwsMumZWtraFlKVk1NJlL52/beGwfDE7ov+/8gVirHzWokzGu7X65hSPq0ucPDskWQ==} + '@oxlint/binding-linux-arm-musleabihf@1.87.0': + resolution: {integrity: sha512-/uC9XqXLAC9AHFLNycwmSQEvKqGrOCgU+uXALI7gNW0DG81TJ/3T2eHnKdRvcb5R1gFEfjxDwI4e4zIa29rICQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm64-gnu@1.82.0': - resolution: {integrity: sha512-6azCZ6OJudlvipNttXCCQcyeFfcJ/NvUZdSN1z8elo73kCHtyQC7WTiUcSjWYvJ1jaq9KDUyMAoAS/vNzhBomA==} + '@oxlint/binding-linux-arm64-gnu@1.87.0': + resolution: {integrity: sha512-FTRYPsBy/M728vnUv7l+KAwSAa3Qi4BQbhVvy7NYuMTZ0sUa/OWsbMx79HVFw6u/5KWcaYayDsoOiv7xbevzDg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-arm64-musl@1.82.0': - resolution: {integrity: sha512-PLEaSD8IAIIlwW4dwOd9YaxuxeOpwiXL4J24rcnE4iNtyM5j9Q9/3+gti08oXpx0u2ygNjRDx9xjWWpQonuJEw==} + '@oxlint/binding-linux-arm64-musl@1.87.0': + resolution: {integrity: sha512-6sC+N7IY0h1egd8SrYUGNN7CUyKJZ4bIZakKa7V7hfinf4jfi8Kq4+klJvFp5DqXcwMn44I5MeBwmJluO1odFg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxlint/binding-linux-ppc64-gnu@1.82.0': - resolution: {integrity: sha512-D94em/BwknNTn4vqxjHh5wb2oL566eFhArabqKIr0cNZMHOJuiraFp1A8tXpH05bbE5tqwEfLXTI0MWEGtn3Dw==} + '@oxlint/binding-linux-ppc64-gnu@1.87.0': + resolution: {integrity: sha512-d5Mnn+ICbhoJPi37Ccjy+86T5qfH2+mSTrTMBXNPfB+wqSWOayBBLLIrCXdUy1YssluCVdGkl+DO9w5r5ODmUA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-gnu@1.82.0': - resolution: {integrity: sha512-MOprxBaoYU2D4VgxXCl3ghydThWtx7Um1lL51kGYNeQ5Al7WzsH7/tqGdNtbLrIWnjq3bsm13+nz/gRIxjrOXw==} + '@oxlint/binding-linux-riscv64-gnu@1.87.0': + resolution: {integrity: sha512-sf5YWuUo1Dx9loqWCgF8P278MycfcD4JHjDC/LV5j11L3Lqa4PFiwCjD6B8kP4AjsgZZkLn3xbpJEtZBaccs4Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-musl@1.82.0': - resolution: {integrity: sha512-5h55QsfJ/luDXZzC20k6SNOY1Az+dCP9WvntKtcUWh2JhckAdwApY2ZusaBTwLENnReXU+A2fJtSrYvZJNKNPg==} + '@oxlint/binding-linux-riscv64-musl@1.87.0': + resolution: {integrity: sha512-+aCraYbmK3uU3P0rFojjeaK9pGY6JvBDXh/0CM+gPyWKmNmXmnFe0qj4qj02U4ibFpbd9Pa9CmDvIdjJUd29rA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxlint/binding-linux-s390x-gnu@1.82.0': - resolution: {integrity: sha512-IE8NJNLlHr0CaXyGJPGVn0eTkUyoj1I2UfA8x7I4PSOYKsQ/6btVC7Pywrj5onk0cMH25r6Z38SoN3AvE5Zuog==} + '@oxlint/binding-linux-s390x-gnu@1.87.0': + resolution: {integrity: sha512-7rzbZQIljo6VLWVPqUyLa2P/WV3VFsJB4M52R4NCUJn1A50yjtUsHxiMHFL9opEKGFIiTbZ3VQzmtcJOPUq4NQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-gnu@1.82.0': - resolution: {integrity: sha512-XUUUxaBo9XKl+J1B9EmP1cTGQPddzeURvoGkfwh/94PGnbW+hBprDljneoI2M1jzC1bzrIV3ihc7iM9UXl8+tg==} + '@oxlint/binding-linux-x64-gnu@1.87.0': + resolution: {integrity: sha512-Dry46Fa+AXE/xt9J/YriujU1pU9mzfNWHGQwtOYibAFH8HLrKWCi93AhIGnuUMiwn2JbuBkRyz51f50Ly4r4ow==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-musl@1.82.0': - resolution: {integrity: sha512-SWLSFulX9TDuH6yvbPYp4+VNn6jkkIvvI+KiujDM5rWBRHEfkesCC/pCneIIUr6ovkxZ5fRtpi2v5Cz5FrMJZg==} + '@oxlint/binding-linux-x64-musl@1.87.0': + resolution: {integrity: sha512-f/HMZez74gJvnRpGz+b5sdGyWrEgRLmFTXg9sNoqjPWogGxYHUhXKTKyMiYsu9rUGR2ciYqL3fk+Ua4JhMJbRA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxlint/binding-openharmony-arm64@1.82.0': - resolution: {integrity: sha512-BQy35f6ZUdNr9a6c7B7orxQTcLjByGT2z3WAgmRovpRwmPYAaJ+NTplmMzhdjdJ4qSchfMNZy/Ukg+qRg6zseQ==} + '@oxlint/binding-openharmony-arm64@1.87.0': + resolution: {integrity: sha512-lEC6ZfPZm/07JlB7FsOWX9o1RiHlXiBW594LSUGuXmekC8LZmvUPP9XzE2WZcNxJi5cHA/Fw56o02c3JxgMA5g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxlint/binding-win32-arm64-msvc@1.82.0': - resolution: {integrity: sha512-V4QhSTg5gctZue8RJjsGi7NpQPThr/p1/HfmiMC5kfe1KFEup9SQRVub4A6kijQjdHfxj7bLL1KO3QO7/5bwMQ==} + '@oxlint/binding-win32-arm64-msvc@1.87.0': + resolution: {integrity: sha512-afBWKOD36dUiA9AenaiWpCg8xfyUVPYznyAAVXu413brK4RA5UI/Xoc4yfeGIT8G1a44MV+JR7hioryerBDzGQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxlint/binding-win32-ia32-msvc@1.82.0': - resolution: {integrity: sha512-TUSCLaKB2yktpFAJ/r3HAUYsaV/3DT7JS4iNKyoh3a9YNwD0UG7Ezh4D8m23654vQcU6P/RQrCAjRPKe4peP/A==} + '@oxlint/binding-win32-ia32-msvc@1.87.0': + resolution: {integrity: sha512-daptNsEOuru4/nJTxV4DBHNJ5GepzmaBVQf+jjgKQGi9NRlZKik+RFq4hUL7ewsF1pwjKM+SkYaMbF82UwFGnw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxlint/binding-win32-x64-msvc@1.82.0': - resolution: {integrity: sha512-VTVoRIWJTb+wvUX8EYoPArfFH02whuR10goFXE/LHRRX33ajRrFgqbcONXZMiF4C5rnattfkm87HqYn8jb8hmQ==} + '@oxlint/binding-win32-x64-msvc@1.87.0': + resolution: {integrity: sha512-k+m/EShyXE29J/r1fjk0HlAzhN5JhIvj044oSJkXTEkCLrWZQgXb/3r4q5LTw/eaKW/4GuMXG2xmiJs3s5rPNw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@oxlint/plugins@1.82.0': - resolution: {integrity: sha512-IDF4UXBNOaCeLoEpVxf5Bg85KOWdeuBEkbo1rGzS2vXcbD+GT1asBSeeTC+5BgW6NFTHVByd6N5giHsFPGtwAA==} + '@oxlint/plugins@1.87.0': + resolution: {integrity: sha512-OtSZ6NxNkS7bkVy7zk9zGnajz2hVIu0uRPP/dJvJesGRaXtimpA4lpxap7FaIJndG2UIoKhnBU4DxyDQIWv6yg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@pnpm/cli.meta@1100.1.2': + resolution: {integrity: sha512-1JuLRrTXap/eoRi+V1IlqgWQFFFlkkHzTCHHMC3bbieQqnANu/8HG4eWvRN0lytfqx0FVD9MaJOyAPjmCsdxOw==} + engines: {node: '>=22.13'} + + '@pnpm/cli.utils@1101.0.32': + resolution: {integrity: sha512-6EJUJBXu+Dw9gStZe4g1j/fqDgW6a+LxrSRNDd6UOqyBWE5KkpaLAwGUxkGU9IlG/2lSBA9PMZUSVGWKUA1XKA==} + engines: {node: '>=22.13'} + peerDependencies: + '@pnpm/logger': ^1100.0.0 + + '@pnpm/config.package-is-installable@1100.2.3': + resolution: {integrity: sha512-fYICmcP6OfMLY4orkAq0zErqVZyA5CNdIAg1kakUWgKVSidYXKzmhFoulBh+2hjsl6KNC5Fp+ihAe41eAX9hUw==} + engines: {node: '>=22.13'} + peerDependencies: + '@pnpm/logger': ^1100.0.0 + + '@pnpm/constants@1102.0.1': + resolution: {integrity: sha512-vLulGZ3iRt6kHcwbGYckrUF2Rd18nfGtpFjyRgbKmEyxLcZmFTBOWKFZSEyS3VVnxFEI7bTPNOGmRC0lD4U1qg==} + engines: {node: '>=22.13'} + + '@pnpm/core-loggers@1101.0.2': + resolution: {integrity: sha512-QfIZ68PuWfC58rJk+Cu9fQ9pndhyCHZxmzPo8PRleWfnoQOfiXkJW2vnEO1BT1UFmSPLrCjh5C6lV0VaWpGl/Q==} + engines: {node: '>=22.13'} + peerDependencies: + '@pnpm/logger': ^1100.0.0 + + '@pnpm/deps.peer-range@1100.1.2': + resolution: {integrity: sha512-PFGNtptMRhqQd7FogcaCsfMpCkvDhFQVuO3+BnMw0PByAUPjXpE7jJMIGduRUSETb2E/zDr2PZ13cXO2OQiAeQ==} + engines: {node: '>=22.13'} + + '@pnpm/engine.runtime.system-version@1100.0.13': + resolution: {integrity: sha512-9ipnlbCqx5wfNbzdAlmrnAgXZcqiabwK7E8ypx3tobUs8WMjdoDVcRoWd/GdVB+V1wOVogcgJxK09tqu/ZzcJg==} + engines: {node: '>=22.13'} + + '@pnpm/error@1100.2.2': + resolution: {integrity: sha512-OVUBUOtFou4xLyrnoETrplOX1FszsAVSfiq/H0DaN+g4nvqOlLPe12pLCoR2/P2Va9yEsInjgsXyrY29Ujgi7g==} + engines: {node: '>=22.13'} + + '@pnpm/fs.graceful-fs@1100.2.5': + resolution: {integrity: sha512-yvGmy0mxhZRjJJy5NPLfpDTSoPkjuAQnZq4mTv/isuStN3+jKST/gyWmUzgwWke2DFFB8/WyYwFi2wU7Uc78gQ==} + engines: {node: '>=22.13'} + + '@pnpm/logger@1100.0.0': + resolution: {integrity: sha512-A3bRMTIZ5ptDPAE/KsDeY1J5UqqOiyo66+k/mUvaVjx++OJdMbp9If5kisEq9ljAV5r5TRpkxsJbSdrdJ5WtzQ==} + engines: {node: '>=22.13'} + + '@pnpm/pkg-manifest.utils@1100.4.9': + resolution: {integrity: sha512-3UdxZVSGZnXB7th4BlsPTrx2inskr6U7RqxzSKuYQQOiPxAj1JMPOLxPVvbLNrfefLyYTCqvFOb4yQDanK2SHA==} + engines: {node: '>=22.13'} + peerDependencies: + '@pnpm/logger': ^1100.0.0 + + '@pnpm/text.comments-parser@1100.0.1': + resolution: {integrity: sha512-10rRwfC9ffkuS4RugetHD+AiNZLdg9ZVv0rkbr/8o14EW6Vh6ZwFwAfTOlkp3Eyse8518jVFvXXPKQKyeh9kNw==} + engines: {node: '>=22.13'} + + '@pnpm/text.ordinal-comparator@1100.0.0': + resolution: {integrity: sha512-1mtSzx973BzS4c0QGW8NMweIG3PXq8LFRjff+JMpXDYyqZEn+1u6brAnaWDH8mgx7pgiUyjTmXNJa+MrSVjXCA==} + engines: {node: '>=22.13'} + + '@pnpm/types@1102.1.1': + resolution: {integrity: sha512-rPauWzpgjzxE/l5OS8gGdeW0K1oF3PPjraGzNg4CA4bbaTDdE2BwmUWJHaADbSF0/EqI0WufrZkSavVTzKFBEg==} + engines: {node: '>=22.13'} + + '@pnpm/workspace.package-patterns@1100.0.2': + resolution: {integrity: sha512-pJUo7FOteC8me1LY/kfbydiUynLXeqZBeHWHSvRP5eYrOi8gHPuq5cpi/NJjO507mUYWIs9D4cvrg2uxGcADDA==} + engines: {node: '>=22.13'} + + '@pnpm/workspace.project-manifest-reader@1100.1.3': + resolution: {integrity: sha512-TFXyT2PqK1RptblHTj2tuSsLztmGxomXl0Uh2o5atyW3PJphutK4bPtHtQ9hrj7NSI1IBDclj5Kpu9PIh+WPJg==} + engines: {node: '>=22.13'} + peerDependencies: + '@pnpm/logger': ^1100.0.0 + + '@pnpm/workspace.project-manifest-writer@1100.0.20': + resolution: {integrity: sha512-30u5S5s303KWCi2w8LObEDH9DaVFkEupth0R//0ZAnb/yyQ7WRuJXwalO0E8XesksojupKAhqCTTOcr/HD9z7A==} + engines: {node: '>=22.13'} + + '@pnpm/workspace.projects-reader@1101.1.4': + resolution: {integrity: sha512-KSPuJhIwzz9BL4WJs0Nxv8ICowMCOS7o2nbOPJY2s1DwEvuVwnm6hn9VC8M/RVxaxW+Y5JiIkYeCK/ds/A0kpw==} + engines: {node: '>=22.13'} + peerDependencies: + '@pnpm/logger': ^1100.0.0 + + '@pnpm/workspace.root-finder@1100.1.3': + resolution: {integrity: sha512-yP++DGZFD3LKyncI4ZL4UEIQKVPy59CS/UeKnv/kot07It3L5SCvbMnGnAZ3/Eopxo1RF4C9x4e9lN9q+t5btA==} + engines: {node: '>=22.13'} + + '@pnpm/workspace.workspace-manifest-reader@1100.2.2': + resolution: {integrity: sha512-QjcmWgnlmaleH83I1jGncehU7n0B1mmBehQbRxnaS640kH8JaNVHNhSjvKJoK4xXesevh6JwJMyy1HfXB1+EpQ==} + engines: {node: '>=22.13'} + + '@pnpm/yaml.document-sync@1100.0.2': + resolution: {integrity: sha512-DXohTVqVxIDf30cPlBczibqDnSzsAB1N9PPBMGf7EkBgiOaJNvsWkFA6QeI90XVMAkLCRjV4S6LdnhVpB8U80w==} + engines: {node: '>=22.13'} + + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@poppinss/colors@4.1.6': resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} @@ -2429,6 +3276,9 @@ packages: yauzl: optional: true + '@quansync/fs@1.1.0': + resolution: {integrity: sha512-qAPG/t3HqML1TlN7sY/pTbEjzFVAKsMjNNMGheyDosro+kT4iw2KCUoHcVdmliwWjorm4elZbgNQyU2eD97eDg==} + '@redis/bloom@6.2.1': resolution: {integrity: sha512-huQgNLaCIZfQ9SeLn4q9124uOUd8HbZDYHwwUzNcRgHqCHiHKl2dDxMqJCeWh8cMqZAoWuHR8XnWbDMIf+o7ag==} engines: {node: '>= 20.0.0'} @@ -2677,6 +3527,52 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@rollup/pluginutils@5.4.0': + resolution: {integrity: sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rushstack/node-core-library@5.25.1': + resolution: {integrity: sha512-ozoRDwIx3jStOYpx86rFyXH4SaUR+DVT6HubQ59avLplJkWXSm5InztYH7RrOzzq9PJXTdq9/+lKX06SfkLVsA==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + + '@rushstack/problem-matcher@0.2.1': + resolution: {integrity: sha512-gulfhBs6n+I5b7DvjKRfhMGyUejtSgOHTclF/eONr8hcgF1APEDjhxIsfdUYYMzC3rvLwGluqLjbwCFZ8nxrog==} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + + '@rushstack/rig-package@0.7.4': + resolution: {integrity: sha512-SHZdzc9b2ptUb5uvRWALtAceD/QKZ4UAnZjcaKviTMPmHBnW14gs7XDocn6sDv2EqgnxxFPFkpV7/I3cUijCbw==} + engines: {node: '>=20.9.0'} + + '@rushstack/terminal@0.24.7': + resolution: {integrity: sha512-o8J9nHOIwfYb0s0eG2WYhx7ed3YIvBXQHVFxYdXCmVdBOkTkdArJcgVzRS8ewUepmlic4nGgJPjKIVaNKSqEnA==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + + '@rushstack/ts-command-line@5.3.17': + resolution: {integrity: sha512-pIwhWawZXAT0nTXwvcE/InQ7uaVAgSeIzILMb2mBrI01Hz1yEvxrhV2+DE2Fzux8aZY9OJTdLsI+sk9kZDuAJw==} + engines: {node: '>=20.9.0'} + + '@sec-ant/readable-stream@0.4.1': + resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + '@simple-libs/child-process-utils@2.0.0': resolution: {integrity: sha512-dvNoRKLijXnD0XoJAz94pbNuB5GQgDr55UhpSPhffDkTT0Cmcqh9jSCOtwfT2d4H6MI9E7c4SgtMuJXZ6F3c6A==} engines: {node: '>=22'} @@ -2689,6 +3585,10 @@ packages: resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} engines: {node: '>=18'} + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + '@smithy/types@4.19.0': resolution: {integrity: sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q==} engines: {node: '>=18.0.0'} @@ -2696,96 +3596,230 @@ packages: '@speed-highlight/core@1.2.24': resolution: {integrity: sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==} + '@storybook/addon-vitest@10.6.2': + resolution: {integrity: sha512-gyyBzncvUyEgWQR3LinnGuI4UsWIUYLyw1u8i244aTxmY4uxCYjsDmUNkaSr8xQzNz8nh2Ckh3G4bO4SvJ6nJQ==} + peerDependencies: + '@vitest/browser': ^3.0.0 || ^4.0.0 || ^5.0.0 + '@vitest/browser-playwright': ^4.0.0 || ^5.0.0 + '@vitest/runner': ^3.0.0 || ^4.0.0 || ^5.0.0 + storybook: ^10.6.2 + vitest: ^3.0.0 || ^4.0.0 || ^5.0.0 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/runner': + optional: true + vitest: + optional: true + + '@storybook/builder-vite@10.6.2': + resolution: {integrity: sha512-SzeQEGjvybdhP2coF4aIX6JX/7rZTrQCpRU3vAEO/R98Vr3xM8jdg7a3nvt91TeF/P8iWqljLIpaDjTdk2r/sQ==} + peerDependencies: + storybook: ^10.6.2 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + + '@storybook/global@5.0.0': + resolution: {integrity: sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ==} + + '@storybook/icons@2.2.0': + resolution: {integrity: sha512-FpUB/xizIrV1ILhCIYFo+TtQNN9YEoLjfcnAW0JtOXL+BcM2aisSysI0B54JMWDnljriXySBHgN6ps4s9lb75A==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@storybook/react-dom-shim@10.6.2': + resolution: {integrity: sha512-k0262Vqxjk3wHeg3g/QO/ENDrkC7kkbuwsFSXOdYr7Sl888tsF1DAS7uQ/gr+s/BRz1FLYRX7wBY3smEUV4MJg==} + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@types/react-dom': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + storybook: ^10.6.2 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@storybook/react-vite@10.6.2': + resolution: {integrity: sha512-RfYSW9eefxRXk7IXKBTBkBYAspHKaxNVBR7mlXQAt+Z2bJZzj+S21mv6kBcpFIfAIT9wSIq8xMg0RbeR7CneVQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + storybook: ^10.6.2 + typescript: '>= 4.9.x' + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@storybook/react@10.6.2': + resolution: {integrity: sha512-kNWUU6Q8TrLmEqAzYmNYhC9A3i3kbjFxqIcLL3eu+o4gDu0afp1Of6Sy6jIF2qPGH05A3r6FWMzD03y4Mm5xRw==} + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@types/react-dom': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + storybook: ^10.6.2 + typescript: '>= 4.9.x' + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + typescript: + optional: true + + '@systemfsoftware/arethetypeswrong-cli@file:.sfs-deps/arethetypeswrong-cli-4.2.0.tgz': + resolution: {integrity: sha512-Otl/hFbZCAwkXIaVkyFQeppoKjL1IjSgurfEgM3JfwPdOv5gs4Y2Ns0La7lzehctqDxW9BVmdC0/6jwI+gnebg==, tarball: file:.sfs-deps/arethetypeswrong-cli-4.2.0.tgz} + version: 4.2.0 + engines: {node: '>=24'} + hasBin: true + + '@systemfsoftware/conformance-spec@file:.sfs-deps/conformance-spec-1.0.0.tgz': + resolution: {integrity: sha512-f1vjP48xEgH7Jvt7BDs4RPnnp9NOQJbUoVrcH/XTShxubAVs676rHYQ0/b0E/x6vfHqAyRwwR8DTxOlrlRwmAg==, tarball: file:.sfs-deps/conformance-spec-1.0.0.tgz} + version: 1.0.0 + peerDependencies: + effect: ^4 + + '@systemfsoftware/differential-spec@file:.sfs-deps/differential-spec-1.0.1.tgz': + resolution: {integrity: sha512-oyUqpJYt6WKrmnZznwNr1PaPgkYnUW0PXaMi4gAAGcBsuy1D9IRYdGhCjjRnrjkUO+UHizQ6fsVlfvLhoDmOGQ==, tarball: file:.sfs-deps/differential-spec-1.0.1.tgz} + version: 1.0.1 + '@systemfsoftware/effect-cell-types@file:.sfs-deps/effect-cell-types-12.0.0.tgz': - resolution: {integrity: sha512-3pmbmzGrUe//NOacyRToZZtv/ow+DREFxL05LiS7Z8ZhPriIKwpvCBACWQ8kPFzkcSAIVPZV+XMx56LkU266EQ==, tarball: file:.sfs-deps/effect-cell-types-12.0.0.tgz} + resolution: {integrity: sha512-MewiGEO5DMlC0zGOcGJo05ATNKvakCHHtmoRHGXiB4wfAIvSBYx12B+wyxtWN+y3KEkqOcSlFG+exPMo1+Iq8w==, tarball: file:.sfs-deps/effect-cell-types-12.0.0.tgz} version: 12.0.0 peerDependencies: effect: ^4 '@systemfsoftware/effect-gherkin-spec@file:.sfs-deps/effect-gherkin-spec-7.0.1.tgz': - resolution: {integrity: sha512-RmyM6qdT88AzjcCdZVHXFP/32ky3zEy0pOABYocCIYKYhkr86EL07Mnxgwxm+UFhbgbgc65KfsA5alTuAlLR3w==, tarball: file:.sfs-deps/effect-gherkin-spec-7.0.1.tgz} + resolution: {integrity: sha512-ATePKdi79QYKz1O0IIGdFJ2gqw4X3MR4wrZrIc6CRSzP0ZqV8kZq/ThQ8K3Sz5q7L8CqG9uCC0K2yAYydRmpSA==, tarball: file:.sfs-deps/effect-gherkin-spec-7.0.1.tgz} version: 7.0.1 peerDependencies: '@systemfsoftware/vitest': ^2.0.0 effect: ^4 - vitest: ^5 + vitest: ^5.0.5 + + '@systemfsoftware/effect-schema-discovery@file:.sfs-deps/effect-schema-discovery-2.0.0.tgz': + resolution: {integrity: sha512-Ef67cur4LwkUhhFcb78C5QcTFuAM9ZIHsXRx1hlQ6oaqi5y/cODyGv7P8dgcc3CLdQl31K8EvEGd7YbPTMnVsw==, tarball: file:.sfs-deps/effect-schema-discovery-2.0.0.tgz} + version: 2.0.0 + + '@systemfsoftware/effect-schema-law@file:.sfs-deps/effect-schema-law-5.0.1.tgz': + resolution: {integrity: sha512-rlJy9KPBTGsv86stGWq7mNGQAUf13HOcyB37qya/MciqYq6hbY3B6KyFLAHMhjFu+JCHEde9BmKi18u/rluakA==, tarball: file:.sfs-deps/effect-schema-law-5.0.1.tgz} + version: 5.0.1 + peerDependencies: + '@systemfsoftware/vitest': ^2.0.0 + effect: ^4 + vitest: ^5.0.5 + + '@systemfsoftware/effect-schema-recursion-budget@file:.sfs-deps/effect-schema-recursion-budget-2.0.0.tgz': + resolution: {integrity: sha512-LzK3wNBDEjdgL3Clc7upvDvSZMK28pdYGWXfLfF6f4b3WzDljt7nt1422C4Sx1X0qL9lSDGtFpI5BZ+ku+S36g==, tarball: file:.sfs-deps/effect-schema-recursion-budget-2.0.0.tgz} + version: 2.0.0 + peerDependencies: + effect: ^4 + + '@systemfsoftware/effect-schema-vite@file:.sfs-deps/effect-schema-vite-3.0.1.tgz': + resolution: {integrity: sha512-spx7L+vtvFAKQYy3ko76Lr/8u1OkOWinc6oNe+84rxctPe2nhzPJ6WjB0mojkYtF2cfXIxYDAqBoax/8mGrLjw==, tarball: file:.sfs-deps/effect-schema-vite-3.0.1.tgz} + version: 3.0.1 + peerDependencies: + '@systemfsoftware/effect-schema-law': 5.0.1 + effect: ^4 + vite: '>5.0.0' + vitest: ^5.0.5 '@systemfsoftware/effect-sim-kernel@file:.sfs-deps/effect-sim-kernel-1.0.0.tgz': - resolution: {integrity: sha512-B4z4wxtI8EIpFIg3K3VIBqfhIELg8JNX1j21htt0c6H8KagiAE9nY90fgzrSK52Ot8nR3wfNHXLNVyk63j7F8g==, tarball: file:.sfs-deps/effect-sim-kernel-1.0.0.tgz} + resolution: {integrity: sha512-4YBesLhEcrYOVvM88COGnP211BqkRqv/R9D5s+ZnHZP7jVVxqiqqNGyTp4Pcg9Yx1hTg50A0oK38jKKmP+bR+w==, tarball: file:.sfs-deps/effect-sim-kernel-1.0.0.tgz} version: 1.0.0 peerDependencies: effect: ^4 '@systemfsoftware/effect-spec-runtime@file:.sfs-deps/effect-spec-runtime-1.0.1.tgz': - resolution: {integrity: sha512-U8/9ZN9i82idvFwrM3QD+G8zs0kx96QfGqpNTNBRtg0iPk+RbDPH2WjSZWXP6fQdMw7NTo1YfV/GBdsnytVfMA==, tarball: file:.sfs-deps/effect-spec-runtime-1.0.1.tgz} + resolution: {integrity: sha512-8RhZPPNmMaObZMaburvmzLYrWQASfu7/xZrf8vUs8bDEtGdAQulhyFpZo40XYdILajvN/oMzkt1fyZsqIWIwdg==, tarball: file:.sfs-deps/effect-spec-runtime-1.0.1.tgz} version: 1.0.1 peerDependencies: '@systemfsoftware/vitest': ^2.0.0 effect: ^4 - vitest: ^5 + vitest: ^5.0.5 '@systemfsoftware/oxlint-config-cell-architecture@file:.sfs-deps/oxlint-config-cell-architecture-4.0.0.tgz': - resolution: {integrity: sha512-Uye24d15thfQ/Z4MJC7bFBYTMMc914SjLNjsVpyo/KxI8c/9ocAtAw4JvSNQcuNZSG9/+LTeQipwfkd/N8gRvQ==, tarball: file:.sfs-deps/oxlint-config-cell-architecture-4.0.0.tgz} + resolution: {integrity: sha512-CUhWMNrMxcdk7d0H76UzPYrd82Wtu/UYfoGpO6cFvGbhdGvZTYfHZMXDMmDIe7fuNjU0S9Vs6lpUaTfAmXxn0Q==, tarball: file:.sfs-deps/oxlint-config-cell-architecture-4.0.0.tgz} version: 4.0.0 peerDependencies: effect: ^4 - oxlint: ~1.82.0 + oxlint: ~1.87.0 typescript: '>=5.0.0' '@systemfsoftware/oxlint-config-dmmf@file:.sfs-deps/oxlint-config-dmmf-2.0.0.tgz': - resolution: {integrity: sha512-IuShg+/z4sKNQkjWi7DvTOizE/Olt+OCrplfv6KNcRUJOsMYciM65lXErzP2pV0lDFtvXVLMbl60PK06xmGR5Q==, tarball: file:.sfs-deps/oxlint-config-dmmf-2.0.0.tgz} + resolution: {integrity: sha512-w2EGm3P+aWFh6Wt9KVUWe5sxW8XMaaiWBUKf0euEtNz03AIjKJowgSBiG9H71WNOwgz2OjeN6ebjj9cyie+bbQ==, tarball: file:.sfs-deps/oxlint-config-dmmf-2.0.0.tgz} version: 2.0.0 peerDependencies: effect: ^4 - oxlint: ~1.82.0 - oxlint-tsgolint: 7.0.2001 + oxlint: ~1.87.0 + oxlint-tsgolint: 7.0.2003 typescript: '>=5.0.0' '@systemfsoftware/oxlint-config-recommended@file:.sfs-deps/oxlint-config-recommended-4.0.0.tgz': - resolution: {integrity: sha512-3YWMP3r9noWcppx4EHSYNV8F+J0V+up8Msmb1HLrTmQtsnBkC0I7qVJP1QJ9uqDEERyKQZM6h+1FNZ2X4XFuhg==, tarball: file:.sfs-deps/oxlint-config-recommended-4.0.0.tgz} + resolution: {integrity: sha512-MRpNEezWWUA8O4tKMvf/ETYDK5D/Gx2Mf6FLxUgXkiWfDhJ20Q9bMMzXBrNp6sYmpmno05JLqz3LpMrYBf24ow==, tarball: file:.sfs-deps/oxlint-config-recommended-4.0.0.tgz} version: 4.0.0 peerDependencies: effect: ^4 - oxlint: ~1.82.0 - oxlint-tsgolint: 7.0.2001 + oxlint: ~1.87.0 + oxlint-tsgolint: 7.0.2003 + typescript: '>=5.0.0' + + '@systemfsoftware/oxlint-config-rule-authoring@file:.sfs-deps/oxlint-config-rule-authoring-1.1.0.tgz': + resolution: {integrity: sha512-kaEBvNaxmCeJu4RtOzZ2vq57bEdaTOG9hW5K5t1b2cQxvZdGb9dQXGVJKOsUAevEVaFcTARH465lyZ+t7vXczg==, tarball: file:.sfs-deps/oxlint-config-rule-authoring-1.1.0.tgz} + version: 1.1.0 + peerDependencies: + oxlint: ~1.87.0 typescript: '>=5.0.0' '@systemfsoftware/oxlint-plugin-cell-architecture@file:.sfs-deps/oxlint-plugin-cell-architecture-5.0.0.tgz': - resolution: {integrity: sha512-wMPvGXt1MTXU0JtNtUgGpgm+EoXLOZaXx02tiq533VKdPb6sVjWkPBks+19xkRfYKdZS6V+zVAQlwIV30HcdLg==, tarball: file:.sfs-deps/oxlint-plugin-cell-architecture-5.0.0.tgz} + resolution: {integrity: sha512-Rqq9ntfhIMAwXzGTHb7b9b9aOjPK2ClehaTThNUz49/VyAmUznlKFkSLXvcuYO74ekfbdEQ6Pv96VsIQ3P6Tbw==, tarball: file:.sfs-deps/oxlint-plugin-cell-architecture-5.0.0.tgz} version: 5.0.0 peerDependencies: effect: ^4 typescript: '>=5.0.0' '@systemfsoftware/oxlint-plugin-dmmf-workflow@file:.sfs-deps/oxlint-plugin-dmmf-workflow-7.0.0.tgz': - resolution: {integrity: sha512-W3szjCYcMiYsUoJ3h6gO/HgZMqB5qTt/MduFjRjNW2fkUn6KglX/+PO5YM0Y6f+OxIMzs14sDGLNOWxP0KPhSQ==, tarball: file:.sfs-deps/oxlint-plugin-dmmf-workflow-7.0.0.tgz} + resolution: {integrity: sha512-5a/w2r5oXEBRdZq6du2ITb+gyTyAJLYFGtaODj8ukJWiGaikSvb03U1g573g3QfRlOzn4ZwgzcqMI0MkcrmtIw==, tarball: file:.sfs-deps/oxlint-plugin-dmmf-workflow-7.0.0.tgz} version: 7.0.0 peerDependencies: effect: ^4 typescript: '>=5.0.0' '@systemfsoftware/oxlint-plugin-effect-platform@file:.sfs-deps/oxlint-plugin-effect-platform-2.0.0.tgz': - resolution: {integrity: sha512-WP7AAYw6haNmJ9lu89MWyZ0bmfT6fiGO9KaZgmvhj91xcX0C4vE5MnwuqgK+jALYI3giieLXW2xrm2aMh3ANPg==, tarball: file:.sfs-deps/oxlint-plugin-effect-platform-2.0.0.tgz} + resolution: {integrity: sha512-27EThTmZMsatbpjYZegrU7atSMnyuXELfyZTqCefTTJ7ObOw23n2WhP+8xRkNIhM1gR+XPVed31C9Nz0VGrylA==, tarball: file:.sfs-deps/oxlint-plugin-effect-platform-2.0.0.tgz} version: 2.0.0 peerDependencies: effect: ^4 typescript: '>=5.0.0' '@systemfsoftware/oxlint-plugin-effect-schema@file:.sfs-deps/oxlint-plugin-effect-schema-6.0.0.tgz': - resolution: {integrity: sha512-Gu0Lm6Enzk6lBO29PU0AQCo6kwGZJSUbak75MAykcpTLzttzr6Pbpo6frAqcXS8LCzOGrFHwZXEHlKuRckrdjQ==, tarball: file:.sfs-deps/oxlint-plugin-effect-schema-6.0.0.tgz} + resolution: {integrity: sha512-20abuSP5rvBCH1SF4tIJNvzEH242k6S7hhlVhn7LgRt7StKJ6lDfJlGfG74dt1lX1JudkiPrJ45TGxO1rk5Weg==, tarball: file:.sfs-deps/oxlint-plugin-effect-schema-6.0.0.tgz} version: 6.0.0 peerDependencies: effect: ^4 typescript: '>=5.0.0' '@systemfsoftware/oxlint-plugin-test-discipline@file:.sfs-deps/oxlint-plugin-test-discipline-5.0.0.tgz': - resolution: {integrity: sha512-wJvbLIGuuhqeQQ17F7dXHs4uCKGtZG7bBn3nGN/XI4wQh9GrRVZzlajARwv07pZ+dVSwsjAnkuSc9uX/dG0PEQ==, tarball: file:.sfs-deps/oxlint-plugin-test-discipline-5.0.0.tgz} + resolution: {integrity: sha512-l0X2xHrjkF4n7mtP1ntbtgSFBZuNV2UqQCddchgWn6Kmf785DRyhz2eNygyEw93r8vM3rN5OKdaMu/UXaFb/GA==, tarball: file:.sfs-deps/oxlint-plugin-test-discipline-5.0.0.tgz} version: 5.0.0 hasBin: true peerDependencies: effect: ^4 typescript: '>=5.0.0' + '@systemfsoftware/stryker-config@file:.sfs-deps/stryker-config-0.1.0.tgz': + resolution: {integrity: sha512-oRcCYqFKaDXrcgrNyiIe7MlJXBBhjMCcZ93s/7ZR7ipqv8rSzRcpQ6KcuGUvxgo5SOCbnb25hNe8mky4iN4q5w==, tarball: file:.sfs-deps/stryker-config-0.1.0.tgz} + version: 0.1.0 + peerDependencies: + '@systemfsoftware/stryker-js': ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@systemfsoftware/stryker-js': + optional: true + '@systemfsoftware/stryker-framework-interface@file:.sfs-deps/stryker-framework-interface-0.0.0.tgz': resolution: {integrity: sha512-L4WKCC7S/aDMqm10V2ystvmaqTm8+fTF3Fxh+HbGSL4ZlFEdHvVxQuBpok0pPoFnThm3lVFbugSTJrzzLIIQIw==, tarball: file:.sfs-deps/stryker-framework-interface-0.0.0.tgz} version: 0.0.0 @@ -2794,6 +3828,10 @@ packages: resolution: {integrity: sha512-dgbE4gWZXIsrHAfcWhcE7iXDyLOmC7nw75Qfh/jRILte0E6QjwXU/47MNx09jx0YPETYjxM0g6b5giJmaepGhA==, tarball: file:.sfs-deps/stryker-ignorer-effect-schema-declarations-0.2.0.tgz} version: 0.2.0 + '@systemfsoftware/stryker-ignorer-in-source-vitest-block@file:.sfs-deps/stryker-ignorer-in-source-vitest-block-0.1.1.tgz': + resolution: {integrity: sha512-iGwcw4KIpHbgrTXkUFOhYXrtO0HVmevsauUS7/zZFjWi9OXclEM+ItD3Gf7lLmEXMY5sErOdc2YBHcGQbG4g+w==, tarball: file:.sfs-deps/stryker-ignorer-in-source-vitest-block-0.1.1.tgz} + version: 0.1.1 + '@systemfsoftware/stryker-ignorer-interface@file:.sfs-deps/stryker-ignorer-interface-0.1.1.tgz': resolution: {integrity: sha512-8viKISqk3vtuS6fp9qKzPbl3VNnLLdXcupiM8u8DLXMMn7v0Y3E/KprYe48z7pBW8mBsf+lJmlNn7oHMV+j8Ew==, tarball: file:.sfs-deps/stryker-ignorer-interface-0.1.1.tgz} version: 0.1.1 @@ -2842,15 +3880,35 @@ packages: effect: ^4.0.0 '@systemfsoftware/tsconfig@file:.sfs-deps/tsconfig-2.0.1.tgz': - resolution: {integrity: sha512-kEBWLZmCzgRyf5I1ntSxKobHyDoxFJLoWUGW7AxQFmICzxC2qVo63x/TP8buVCGBj/dKZx/zIwjz9974TZxdUg==, tarball: file:.sfs-deps/tsconfig-2.0.1.tgz} + resolution: {integrity: sha512-DOBlgfypDiU1IdMAkWTio/xV42eoZj5v+4vTMpRzQu0GvLqK1RdeEtsTE7PrtF3YqbPYyaoTlx8GdgOiMeoXsg==, tarball: file:.sfs-deps/tsconfig-2.0.1.tgz} version: 2.0.1 + '@systemfsoftware/tsdown-config@file:.sfs-deps/tsdown-config-0.1.0.tgz': + resolution: {integrity: sha512-msXkyImTojddLKFJ0kqCwUAvnOaY54p5ZANCSedTTAuf9GLBcTkk4QHi7K5JX3izXh9ueOwV0bZw3zYOahHEaQ==, tarball: file:.sfs-deps/tsdown-config-0.1.0.tgz} + version: 0.1.0 + hasBin: true + peerDependencies: + '@microsoft/api-extractor': ^7.59.4 + tsdown: ^0.23.0 + peerDependenciesMeta: + '@microsoft/api-extractor': + optional: true + tsdown: + optional: true + + '@systemfsoftware/vitest-config@file:.sfs-deps/vitest-config-0.1.0.tgz': + resolution: {integrity: sha512-iSCdHGzfVYSyN9DHE6UlrfbY9Uf916lziBdfyZcGRPgJtOnpGkbJK7TOuAKDJQPlqtj5ImoFHPGadzQVek2mNg==, tarball: file:.sfs-deps/vitest-config-0.1.0.tgz} + version: 0.1.0 + peerDependencies: + vite: ^8.3.4 + vitest: ^5.0.5 + '@systemfsoftware/vitest@file:.sfs-deps/vitest-2.0.0.tgz': - resolution: {integrity: sha512-hdDeELLf1st7/2oPHWj06gFzC3FbyrCVhMt5u8lDm6qinTzYfXv3s8uZbOYZl06VzBsRY/o0bpfsNuTg+Vx2oA==, tarball: file:.sfs-deps/vitest-2.0.0.tgz} + resolution: {integrity: sha512-Qop/XEvBCmTdJJCRU/CVQ8lrOM04DWJbvU/spyIxL7S63RRdf66FuFI1OKaxSJmGJTq7F+Cj2frV1Jw3gL4SDg==, tarball: file:.sfs-deps/vitest-2.0.0.tgz} version: 2.0.0 peerDependencies: effect: ^4 - vitest: ^5 + vitest: ^5.0.5 '@systemfsoftware/xstate@file:.sfs-deps/xstate-6.0.0-alpha.64.tgz': resolution: {integrity: sha512-NsN1kippHN4OJ5BR2wijgo3Qn04PTL1L5wwwoWUFgDGTmpZLoaq1RQuuXV4UjGTTDAJpTUew+6U+Lfq6YwUd4Q==, tarball: file:.sfs-deps/xstate-6.0.0-alpha.64.tgz} @@ -2994,6 +4052,35 @@ packages: resolution: {integrity: sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA==} engines: {node: '>=20.19'} + '@testing-library/dom@10.4.2': + resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.3.3': + resolution: {integrity: sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@testing-library/user-event@14.6.8': + resolution: {integrity: sha512-vgSSqnANMnkcYR/vUNDbwS7aM3OErRLNUG8vCVVlj4oc6cKffUrccX7UAS5AmJQQNZTSibi9z5GvvRZ0aYgUCQ==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + '@turbo/darwin-64@2.11.7': resolution: {integrity: sha512-Z2LFOxwbiJueD1miByXJS/BrW21vv6YaQXZustty5jEX4vQYw6zyh101Sz1QO7WG0mqBzKagZ/DoAeeujI0/Sw==} cpu: [x64] @@ -3024,15 +4111,39 @@ packages: cpu: [arm64] os: [win32] + '@tybys/wasm-util@0.10.4': + resolution: {integrity: sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==} + + '@types/argparse@1.0.38': + resolution: {integrity: sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA==} + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/aws-lambda@8.10.164': resolution: {integrity: sha512-XOnrazWcOd6yWPnR7DxqCBPPYciDjHq+NN8LPwwqBCJNXLyPJgKo47fZpJ113+oyAn9Zti0Cf6H4k6yPcE3WFg==} + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/doctrine@0.0.9': + resolution: {integrity: sha512-eOIHzCUSH7SMfonMG1LsC2f8vxBFtho6NGBznK41R84YzPuvSBzrhEps33IsQiOW9+VL6NQ9DbjQJznk/S4uRA==} + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} @@ -3053,9 +4164,18 @@ packages: '@types/react@19.3.0': resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==} + '@types/resolve@1.20.6': + resolution: {integrity: sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ==} + + '@types/scheduler@0.28.0': + resolution: {integrity: sha512-WuK7EQ0YBvNbYwanHo+x3kixrg10+7Ak4rmYmy1dYo3l4FCh0XWeoq/SdqQamKA5wShXse8WRhh0Tt1czTAuTQ==} + '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@types/ws@8.18.2': + resolution: {integrity: sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==} + '@typescript/typescript-aix-ppc64@7.0.2': resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} engines: {node: '>=16.20.0'} @@ -3192,19 +4312,255 @@ packages: oxc-transform-react: optional: true - '@vitest/mocker@5.0.3': - resolution: {integrity: sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==} + '@vitest/browser-playwright@5.0.5': + resolution: {integrity: sha512-lcdThFUUcMV9mGGV1SUiZXPxfjgW4CssA7o3STxzh8WK0tT2r526kc12tZIBRZfoLMwkhxjLmMXkWXONzdsDQw==} peerDependencies: - msw: ^2.4.9 - vite: ^6.0.0 || ^7.0.0 || ^8.0.0 - peerDependenciesMeta: - msw: - optional: true - vite: - optional: true + playwright: '*' + vitest: 5.0.5 - '@vitest/spy@5.0.3': - resolution: {integrity: sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==} + '@vitest/browser@5.0.5': + resolution: {integrity: sha512-IDMoZX/iZ8rkilw7weaSgN8tJ7iZ9hUv0wYoKXyiyjWjWFwLSWAY707DcVTgxTYOudWC1E3HA9LYn7kVa9a7uQ==} + peerDependencies: + vitest: 5.0.5 + + '@vitest/coverage-istanbul@5.0.5': + resolution: {integrity: sha512-nmbE0+e1JSH9kYEoHGp/iKCQq6+TqwvQ4EcH2zKPULOIrY7rPHxDHsipYGGr+0xMDdzCy46F8zj3edz8dsqvdg==} + peerDependencies: + vitest: 5.0.5 + + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + + '@vitest/istanbul-lib-coverage@1.0.2': + resolution: {integrity: sha512-9J/JMwOf9AoJhAywhrn7ScKTL38hsWQP/qPG60OtaAFcQ5OXPwKsxZFlbnuCKmZ61m8/lGgHYnFpdyQZUvG/iA==} + engines: {node: '>=22'} + + '@vitest/istanbul-lib-instrument@1.0.2': + resolution: {integrity: sha512-KFlvK0uYxYjdNoQJR+6F1B45u0ZOmYgOZndqfwLiGBjYHACc8q1Wyuw/ZQeGP3UKc7XsSZ51NKQDKt0IIbGMQg==} + engines: {node: '>=22'} + + '@vitest/istanbul-lib-report@1.0.2': + resolution: {integrity: sha512-gUsfXZJbzPamoIY5TvHFiMMoXESBrUMo+xqaj+rYrWI69+EnvRlYBlP96ZnHPY4vX8kyUpgAnFUCg5wZG/HkDQ==} + engines: {node: '>=22'} + + '@vitest/istanbul-lib-source-maps@1.0.2': + resolution: {integrity: sha512-Ia3+9WDB5UKN940ztJ5LyhqDaS/It05Uh2VQ70KIPDWlOed2ZsJTJTyMb7x6OIW4eAwWSBFknpqJafJetTTXLw==} + engines: {node: '>=22'} + + '@vitest/mocker@5.0.5': + resolution: {integrity: sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==} + peerDependencies: + msw: ^2.4.9 || ^3.0.0 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + + '@vitest/pretty-format@5.0.5': + resolution: {integrity: sha512-24sSdIlMA9DVPXnYiB9siwU0bTd887Y9jX3+vabztCSsvHwo7ipvJ/a2L3Rljw8+2rK1f5fCj3h5U9I/Jsqgbw==} + + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + + '@vitest/spy@5.0.5': + resolution: {integrity: sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==} + + '@vitest/ui@5.0.5': + resolution: {integrity: sha512-dCoGUOdgmCaSB2g+WQ/spPCBkytAWdNUkFSXY4z+eYp2d8JEOY8v0uDRaJTG9qf/kTlTVZ4CuYMYMz/f1HS/qQ==} + peerDependencies: + vitest: 5.0.5 + + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + + '@vitest/utils@5.0.5': + resolution: {integrity: sha512-/rBu7gzLDQI/SC79Tn98b5DwOgaHU/Gt4I9OVeP3YofzNaYtbImRCOJQmT1S6vOJg9gEIlWC+r3QxKkjuSa1nQ==} + + '@webcontainer/env@1.1.1': + resolution: {integrity: sha512-6aN99yL695Hi9SuIk1oC88l9o0gmxL1nGWWQ/kNy81HigJ0FoaoTXpytCj6ItzgyCEwA9kF1wixsTuv5cjsgng==} + + '@yuku-codegen/binding-android-arm64@0.10.2': + resolution: {integrity: sha512-Oc2KInVkPfUjEB4PeV6X0NvIoyYTzDe/WmqFrTwBqKs6YKnz4A7XwWQvJTb7M0rLz9a6WgpUUiiS5QKaCi4O/g==} + cpu: [arm64] + os: [android] + deprecated: yuku-codegen is pure JavaScript since 0.14 + + '@yuku-codegen/binding-darwin-arm64@0.10.2': + resolution: {integrity: sha512-3H7eNPIHJndUJXZ4QUGBPq1zC6RGcUZfm7bymJes+/N7wTVWUn1bxZ9JcozYHpkWQNm9f23G8YWbaHD9aYH72A==} + cpu: [arm64] + os: [darwin] + deprecated: yuku-codegen is pure JavaScript since 0.14 + + '@yuku-codegen/binding-darwin-x64@0.10.2': + resolution: {integrity: sha512-AJOUpR2s3LF9AWiiub/Uyc/UoXNTWcq8hK3cVI6fUSXtwh34dG21J6hRuNlO6JVJFTo4o3ScVvZOrh/YFfUEAA==} + cpu: [x64] + os: [darwin] + deprecated: yuku-codegen is pure JavaScript since 0.14 + + '@yuku-codegen/binding-freebsd-x64@0.10.2': + resolution: {integrity: sha512-ms7DcZu87u5CiK/wMwvpKAvAmtaqKjCQIXNweMLypG6qbuiaOMQf1jpbB5+j46xBcgCovo8TQMcv0bCQLKIL9w==} + cpu: [x64] + os: [freebsd] + deprecated: yuku-codegen is pure JavaScript since 0.14 + + '@yuku-codegen/binding-linux-arm-gnu@0.10.2': + resolution: {integrity: sha512-xJDpYAsKV5+eaiBhTYl05fvT6sst4PsCeuIFMRu9b0/WCSrNQPfCDtAQ5/MS6xNpsdujdZEPR+gmfWk3ZG5i3A==} + cpu: [arm] + os: [linux] + libc: [glibc] + deprecated: yuku-codegen is pure JavaScript since 0.14 + + '@yuku-codegen/binding-linux-arm-musl@0.10.2': + resolution: {integrity: sha512-qfTkgd7AEx61l4K9VtXWCGTxc/fmXJ4ZheDz3i+/AAJUtg4sa0bPrc0VBxPggB/rayR8Z3+JfrBItuyanBJJ9Q==} + cpu: [arm] + os: [linux] + libc: [musl] + deprecated: yuku-codegen is pure JavaScript since 0.14 + + '@yuku-codegen/binding-linux-arm64-gnu@0.10.2': + resolution: {integrity: sha512-4e6Mifm/4UdjtU3D8mATIrvgP+xEiH8xtQOjd0zpd72XKWT7ug3sdyhq2utkkZFP6BvYp7SgZrI+aR4VeIOHdw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + deprecated: yuku-codegen is pure JavaScript since 0.14 + + '@yuku-codegen/binding-linux-arm64-musl@0.10.2': + resolution: {integrity: sha512-RdsJrUfDYFVV3JOmWFUWwSu31fa1APn12xDeIKxgl/YWxrabwtxsDBNjF2561c7tmcbiewdCUvngqRs8AyGVLA==} + cpu: [arm64] + os: [linux] + libc: [musl] + deprecated: yuku-codegen is pure JavaScript since 0.14 + + '@yuku-codegen/binding-linux-x64-gnu@0.10.2': + resolution: {integrity: sha512-mVzWimEPPreaPyXIUvxsHoJzvO7ckZ5j0LgQFHz04zQIRwt9A9T2hA7K5/jYjJKkMxWG/U2VjhGNwVhtyU+uqg==} + cpu: [x64] + os: [linux] + libc: [glibc] + deprecated: yuku-codegen is pure JavaScript since 0.14 + + '@yuku-codegen/binding-linux-x64-musl@0.10.2': + resolution: {integrity: sha512-Y77fyISurmr2mvO1yeq3u+x/WJbACgPR4w+lzEVx30ViCxZzIGrZPRN1yEdZ9xUNPNsIO5thBHIdRNG+UGUG+Q==} + cpu: [x64] + os: [linux] + libc: [musl] + deprecated: yuku-codegen is pure JavaScript since 0.14 + + '@yuku-codegen/binding-win32-arm64@0.10.2': + resolution: {integrity: sha512-1+tGLyG0u5YYy0lev4ck7/0sd8xJ9SZde0dLut7qLDPZV5WVTV8x6OxNXUne/PGuHZyO1vK+txPQzzHOyXHGSw==} + cpu: [arm64] + os: [win32] + deprecated: yuku-codegen is pure JavaScript since 0.14 + + '@yuku-codegen/binding-win32-x64@0.10.2': + resolution: {integrity: sha512-8a2SExRohRbwC0MY4VpOF0/RSckrJ2ASZqAor5/RYSJJaeadR93n10gZzcKT6pY9ukiSOIZCRKVRD2tH73T0qA==} + cpu: [x64] + os: [win32] + deprecated: yuku-codegen is pure JavaScript since 0.14 + + '@yuku-parser/binding-android-arm64@0.10.2': + resolution: {integrity: sha512-2VPBU9fRGRAQ2xPAvghnec3oou5Nrxm2Bezhf+13UMspAxQSkF/32r+ySKXSwIPA5/RivumDJDxAxc301Sgicw==} + cpu: [arm64] + os: [android] + deprecated: yuku-parser runs on yuku-core since 0.14 + + '@yuku-parser/binding-darwin-arm64@0.10.2': + resolution: {integrity: sha512-LD+PMZE51tCYTOss5HBkm3/AE39MvcMDBfWJx7A4yDcjfNbAQDnHZKtzSOuqrswx+TQHY0ws5xn6+fWOwtmfBA==} + cpu: [arm64] + os: [darwin] + deprecated: yuku-parser runs on yuku-core since 0.14 + + '@yuku-parser/binding-darwin-x64@0.10.2': + resolution: {integrity: sha512-mmZ8cND+AoIIMRERyMinlg5ApHxP23B9jH2B5wT7T+dliPa9rubLxneB/SUjFwyUjGaFnB5G7t4YvpfbO5zbkQ==} + cpu: [x64] + os: [darwin] + deprecated: yuku-parser runs on yuku-core since 0.14 + + '@yuku-parser/binding-freebsd-x64@0.10.2': + resolution: {integrity: sha512-gVIjaaIddbRfAhHlC8N809wQWml7mxfSVnzaLtzGXObTeFTPAg/YVnQXt1UOhPM1ah5eG/8Y0RUlNpB4GVe7eQ==} + cpu: [x64] + os: [freebsd] + deprecated: yuku-parser runs on yuku-core since 0.14 + + '@yuku-parser/binding-linux-arm-gnu@0.10.2': + resolution: {integrity: sha512-q/XPPQQAPdlw05aPj30ygBhekmQryGOwxVraBgApjKK8yY1kNQgqq6XCYLF1WHSee+lhxclrTO7W0/bt7dR1GA==} + cpu: [arm] + os: [linux] + libc: [glibc] + deprecated: yuku-parser runs on yuku-core since 0.14 + + '@yuku-parser/binding-linux-arm-musl@0.10.2': + resolution: {integrity: sha512-A+Cb0I1hFF4wilTQpWs79k1aNnj4B2tkrHx3zsuUNF9BtVY2zPZ4yeQEM/zjXrS/qJlNMZVK9NrWJjwdpnTrfg==} + cpu: [arm] + os: [linux] + libc: [musl] + deprecated: yuku-parser runs on yuku-core since 0.14 + + '@yuku-parser/binding-linux-arm64-gnu@0.10.2': + resolution: {integrity: sha512-aGNSzqIqqphFwAdIFwVvKIyXD1Iy3CxrEJVIZAT87Ecyi4vCmmDD2v2l9h+Gd3/wSy3JZlOI792LJbKAjyy9rQ==} + cpu: [arm64] + os: [linux] + libc: [glibc] + deprecated: yuku-parser runs on yuku-core since 0.14 + + '@yuku-parser/binding-linux-arm64-musl@0.10.2': + resolution: {integrity: sha512-Ddl1sF0rtuCXyHGSOV6dcmdx+ETv1iD+IVFqQuOjzkJZWclxJxdBWX6ZJMRtTiGqGUTbqLEKiTXxpR/Bvqk+2A==} + cpu: [arm64] + os: [linux] + libc: [musl] + deprecated: yuku-parser runs on yuku-core since 0.14 + + '@yuku-parser/binding-linux-x64-gnu@0.10.2': + resolution: {integrity: sha512-/nlcpR6IF5U+0m5L9wvAIXeQa2DT+BXuvqKDsTcOTlcc0B4dHNyqE5hTXsS4hAyimpy2ZK8A1zMNF5hTrjg2cg==} + cpu: [x64] + os: [linux] + libc: [glibc] + deprecated: yuku-parser runs on yuku-core since 0.14 + + '@yuku-parser/binding-linux-x64-musl@0.10.2': + resolution: {integrity: sha512-GX80dxTQD/M/OryyuxJcFzFENzry3cDFPvCFTyWogNWQH3fSHfMrNfpwpl1YzMos1eV9NygK5GSDG7VArQlb4w==} + cpu: [x64] + os: [linux] + libc: [musl] + deprecated: yuku-parser runs on yuku-core since 0.14 + + '@yuku-parser/binding-win32-arm64@0.10.2': + resolution: {integrity: sha512-agePQBV4VHewiGU0ACSjscZ/hJd283f9JfAF19Gf1rI5+wy5tTgx4GS36i0b3xim15AQ4RCpDRosEvhLZ4zAOw==} + cpu: [arm64] + os: [win32] + deprecated: yuku-parser runs on yuku-core since 0.14 + + '@yuku-parser/binding-win32-x64@0.10.2': + resolution: {integrity: sha512-s8//CMpgL5+y1lvDCdyh1rwGI5+ytDJywFJRe1vnhI7n0j+caxshNucurikYLLVeQ2SYFex6aPrzgQxkabBQRA==} + cpu: [x64] + os: [win32] + deprecated: yuku-parser runs on yuku-core since 0.14 + + '@yuku-toolchain/types@0.10.2': + resolution: {integrity: sha512-sSeo4SSSToiS+sSD+bwn/s94EEcaLJ7tG5LCp8gFYC1G5VzxzX7fqB6m9RU1yMD8KTpu6zdU/I1NlQf8hVBJ9Q==} + + '@zkochan/which@2.0.3': + resolution: {integrity: sha512-C1ReN7vt2/2O0fyTsx5xnbQuxBrmG5NMSbcIkPKCCfCTJgpZBsuRYzFXHj3nVq8vTfK7vxHUmzfCpSHgO7j4rg==} + engines: {node: '>= 8'} + hasBin: true + + ajv-draft-04@1.0.0: + resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} + peerDependencies: + ajv: ^8.5.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} @@ -3279,6 +4635,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + ansi-styles@6.2.3: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} @@ -3287,6 +4647,9 @@ packages: resolution: {integrity: sha512-9k3v7xcHwgdO/DruxGIg4HtjvlAZlcnsX/mzqUb1t3NkYnl9kK2UJ+Gq0io+vQf7iT//BD/HB/NBkUR1LWxoeA==} engines: {node: '>=14'} + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -3294,16 +4657,31 @@ packages: resolution: {integrity: sha512-VipTB0gXgGIFO2Rg9yEVN5wLt2AurJcZqDbgmYSwPwsykhLrQhs240/bfceev4w68lI8JshoOJIk9uW7tFzROw==} engines: {node: '>=22'} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-types@0.16.3: + resolution: {integrity: sha512-FvWoWYfSCM6kRxCSH+MGLHIKKGRL6A6AW7Zek2O32REPQRdg131428uRTKMBYAeRd3XXAaHDS60Wpri7CdKDrA==} + engines: {node: '>=4'} + babel-dead-code-elimination@1.0.12: resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==} babel-plugin-react-compiler@1.0.0: resolution: {integrity: sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + baseline-browser-mapping@2.11.27: resolution: {integrity: sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ==} engines: {node: '>=6.0.0'} @@ -3312,14 +4690,36 @@ packages: before-after-hook@4.0.0: resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} + bidi-js@1.1.0: + resolution: {integrity: sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==} + blake3-wasm@2.1.5: resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + bole@5.0.29: + resolution: {integrity: sha512-eYR9i2ubLv5/4TFGyZsQ1cVH4jF9+qLJA72Aow+E7ZZQfqHqQNUZeX3w+pVWF76PQyjl5eDKf2xylyOOX76ozA==} + + brace-expansion@5.0.12: + resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} + engines: {node: 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + browserslist@4.29.3: resolution: {integrity: sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + bundle-name@4.1.1: + resolution: {integrity: sha512-DdH81/zPLVS11EUgWq3tEu/xn+EzljlMYooDNdzWEnFha3R3NBMpMV1UqYIpjYHV/SgpFKMIX1Oh7o06SjM/oA==} + engines: {node: '>=18'} + + cac@7.1.0: + resolution: {integrity: sha512-LMDU/U2yA4IPIY8KT0NsNPWEYZvAMkbgyspxJ6btJaYV+WMDOHAZCmB0lsTObiHaRS1V/ztKAl7+TSkTqXyeoA==} + engines: {node: '>=20.19.0'} + callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} @@ -3339,10 +4739,22 @@ packages: capnweb@0.12.0: resolution: {integrity: sha512-jgZ/LMtMVTi+RVlooIslH78Gg23XbDTdvcLghWx3oz17D6u5GuJARt+uhZk/wcTo+f81eeabfnow4d3zLBj+JQ==} - chai@6.2.2: - resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + chai@6.3.0: + resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} engines: {node: '>=18'} + chalk@6.0.1: + resolution: {integrity: sha512-/Ce6KNm3vIbWdMlNna6RVIZ/ICQxnJxCicet5LBKK9ZffBkqzDw0xh9EiKSljdRtiIQ1S1z4YgcscUUGzNCWrA==} + engines: {node: '>=22'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + chevrotain@10.5.0: resolution: {integrity: sha512-Pkv5rBY3+CsHOYfV5g/Vs5JY9WTHHDEKOlohI2XeygaZhUeqhAlldZ8Hz9cRmxu709bvS08YzxHdTPHhffc13A==} @@ -3420,9 +4832,20 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-urls@8.0.0: + resolution: {integrity: sha512-JQUCCK+/CAGbGGx618PlFZMXH81Jtj7t73CHv9dd/zVWvxgGT2zLFRjnKb+Ng46GIwcy+f7f88qd4duqllK+WQ==} + engines: {node: ^22.14.0 || >=24.0.0} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -3432,6 +4855,36 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.5.1: + resolution: {integrity: sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==} + engines: {node: '>=18'} + + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-indent@7.0.2: + resolution: {integrity: sha512-y+8xyqdGLL+6sh0tVeHcfP/QDd8gUgbasolJJpY7NgeQGSZ739bDtSiaiDgtoicy+mtYB81dKLxO9xRhCyIB3A==} + engines: {node: '>=12.20'} + detect-libc@2.0.2: resolution: {integrity: sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==} engines: {node: '>=8'} @@ -3444,8 +4897,27 @@ packages: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} - effect@4.0.1: - resolution: {integrity: sha512-b1VlQG9g8fwxE5QnIZuPoOP/0MsqHJSRKxUijjoM80E5q95FXrX5yWtY5XI8G+GJsBQAVSDbLS458z9a++uVvw==} + doctrine@3.0.0: + resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} + engines: {node: '>=6.0.0'} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + + dts-resolver@3.0.0: + resolution: {integrity: sha512-1T1f+z+4tl9XD+m+0HBgWoL/nm0bOIffyWaUuUSBlFg/86IWvfx+wjNaO/ybU0AJzG9/Mi5hBUgGV6zCmWEN7Q==} + engines: {node: ^22.18.0 || >=24.0.0} + peerDependencies: + oxc-resolver: '>=11.0.0' + peerDependenciesMeta: + oxc-resolver: + optional: true + + effect@4.0.5: + resolution: {integrity: sha512-BY4/28NlpOd99yzmV741xbVGmp+1U7tks4oB7F/Png64bTa5tm5eCA6W0RiXsnuLDDoJpjfVSo4Dnq4BZb/89Q==} electron-to-chromium@1.5.444: resolution: {integrity: sha512-5ss/uJfoDYDHT0lfJzT6FbcskIzROIOPf0BbbFkGcvDzoJU7i//9GDrwwIHQVmIsrAGiF3ihpADBRIsrEFt1rQ==} @@ -3460,6 +4932,14 @@ packages: resolution: {integrity: sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==} engines: {node: '>=14'} + empathic@2.1.0: + resolution: {integrity: sha512-AnfC1ATldl49/cvZdLPDjBfrRNwbDO05aibiOtzQu3qtlbJtomNLhF30HEtn/7iBz50dlMECqATo3fG0LrdEgw==} + engines: {node: '>=14'} + + entities@8.1.0: + resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==} + engines: {node: '>=20.19.0'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -3470,6 +4950,10 @@ packages: error-stack-parser-es@1.0.5: resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + es-module-lexer@2.3.2: resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} @@ -3479,11 +4963,6 @@ packages: es-toolkit@1.52.0: resolution: {integrity: sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==} - esbuild@0.28.1: - resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} - engines: {node: '>=18'} - hasBin: true - esbuild@0.28.2: resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} engines: {node: '>=18'} @@ -3493,9 +4972,25 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + execa@9.6.1: + resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} + engines: {node: ^18.19.0 || >=20.5.0} + expect-type@1.4.0: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} @@ -3503,9 +4998,16 @@ packages: exsolve@1.1.3: resolution: {integrity: sha512-kuzL9zherLQoqDeQaS55AiiIg1kb4KYXaSusTtgZTPlvKP2gGCI82LNmBNh5RjDVUXIcmgx+/Mx7vPwRzBbhJA==} + fast-check@4.10.2: + resolution: {integrity: sha512-iK2f+YrcmoeGqk6fA0ea2bptcu/itMIm4NfEozq6N25+aG6h7s5HZbB/k1aV7b5w5sFLMCbbtRUsTVR+BgC3xw==} + engines: {node: '>=12.17.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + fast-uri@3.1.7: resolution: {integrity: sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==} @@ -3524,6 +5026,17 @@ packages: fflate@0.8.3: resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + figures@6.1.0: + resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} + engines: {node: '>=18'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} + flru@1.0.2: resolution: {integrity: sha512-kWyh8ADvHBFz6ua5xYOPnUroZTT/bwWfrCeL0Wj1dzG4/YOmOcfJ99W8dOVyyynJN35rZ9aCOtHChqQovV7yog==} engines: {node: '>=6'} @@ -3532,11 +5045,18 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + fs-extra@11.3.6: + resolution: {integrity: sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==} + engines: {node: '>=14.14'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -3552,6 +5072,18 @@ packages: get-port-please@3.2.0: resolution: {integrity: sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==} + get-stream@9.0.1: + resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} + engines: {node: '>=18'} + + get-tsconfig@5.0.0-beta.6: + resolution: {integrity: sha512-X6fBC0pmImC70gvX2zm56go9hx0MyoGVdG0tUCkg/D+Xnh5TJsOZ7iDbOdI3PvmtrDxnu1YdDufpK2QJX1Meqw==} + engines: {node: '>=20.20.0'} + + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + global-directory@5.0.0: resolution: {integrity: sha512-1pgFdhK3J2LeM+dVf2Pd424yHx2ou338lC0ErNP2hPx4j8eW1Sp0XqSjNxtk6Tc4Kr5wlWtSvz8cn2yb7/SG/w==} engines: {node: '>=20'} @@ -3579,13 +5111,32 @@ packages: crossws: optional: true + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + hono@4.11.4: resolution: {integrity: sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==} engines: {node: '>=16.9.0'} + hookable@6.1.2: + resolution: {integrity: sha512-+abwxtiEA52GCVIsQqut3S/uKTbUwYIp4Pe/vv+6py5XiXBCqMZHg6pA6Y5qhgLSEys0/cYuPbO0z1QFj5ZCmg==} + + html-encoding-sniffer@7.0.0: + resolution: {integrity: sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==} + engines: {node: ^22.13.0 || >=24.0.0} + http-status-codes@2.3.0: resolution: {integrity: sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==} + human-signals@8.0.1: + resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} + engines: {node: '>=18.18.0'} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -3594,9 +5145,28 @@ packages: resolution: {integrity: sha512-mPKuL8bPQzecui2KK6Gb+M8JvJoHnhS1FeYGa22QopBmlevF5F0FE6ued/B5EgHDeIoMTONIpDWWFKUPOG0DBQ==} engines: {node: '>=18'} + import-lazy@4.0.0: + resolution: {integrity: sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==} + engines: {node: '>=8'} + import-meta-resolve@4.2.0: resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + import-without-cache@0.4.1: + resolution: {integrity: sha512-vXoV9PjKHEednCUu01e98TkImxy67e3BJXbTIOmIq4Hyzw3IAwYRcuPkfeTzJzwyyts4kjuA73x+pWJLc4f86A==} + engines: {node: ^22.18.0 || >=24.0.0} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + index-to-position@1.2.0: + resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==} + engines: {node: '>=18'} + + individual@3.0.0: + resolution: {integrity: sha512-rUY5vtT748NMRbEMrTNiFfy29BgGZwGXUi2NFUVMWQrogSLzlJvQV9eeMWi+g1aVaQ53tpyLAQtd5x/JH0Nh1g==} + ini@6.0.0: resolution: {integrity: sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==} engines: {node: ^20.17.0 || >=22.9.0} @@ -3604,14 +5174,51 @@ packages: is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + is-core-module@2.17.0: + resolution: {integrity: sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==} + engines: {node: '>= 0.4'} + + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + is-plain-obj@4.1.0: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + is-stream@4.0.1: + resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} + engines: {node: '>=18'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + + is-windows@1.0.2: + resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} + engines: {node: '>=0.10.0'} + + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + isbot@5.2.2: resolution: {integrity: sha512-iQcBXcd+Rv/pkubRyGh2utW2j1oPG5hZY6TUhVPpqK4G+o3IbxpJNx04hgksjc/N7GK5pEorUxDeg31cFgEk/w==} engines: {node: '>=18'} @@ -3627,6 +5234,9 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + jju@1.4.0: + resolution: {integrity: sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==} + js-base64@3.9.4: resolution: {integrity: sha512-PtOMXpEGuP0RRiRXsjzHzl44dMHxSu2CPvAhinupR1tBa88me+1DPqsobl7eupn5UukjLkln1ZnAOAOXOrYG0Q==} @@ -3640,6 +5250,15 @@ packages: resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} hasBin: true + jsdom@30.1.2: + resolution: {integrity: sha512-0FFE/jE1rppmVfUrJUgxqXjcwolZYIGtAgj1pTussMhNZxIxi7W/PvfWaMNroGi2B36X1IKHbexpZ9DhkoOPiQ==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + peerDependencies: + canvas: ^3.2.3 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -3659,6 +5278,12 @@ packages: engines: {node: '>=6'} hasBin: true + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + + jsonfile@6.2.1: + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + kleur@4.1.5: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} @@ -3760,6 +5385,10 @@ packages: engines: {node: '>=22.22.1'} hasBin: true + load-json-file@7.0.1: + resolution: {integrity: sha512-Gnxj3ev3mB5TkVBGad0JM6dmLiQL+o0t23JPBZ9sd+yvSLk05mFoqKBw5N8gbbkU4TNXyqCgIrl/VM17OgUIgQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + lodash.camelcase@4.3.0: resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} @@ -3769,24 +5398,75 @@ packages: long@5.3.2: resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + lru-cache@11.5.3: + resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} magic-string@1.4.2: resolution: {integrity: sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==} + magic-string@1.4.3: + resolution: {integrity: sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==} + + magicast@0.5.5: + resolution: {integrity: sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + memoize@11.0.0: + resolution: {integrity: sha512-cjsfZaC9b1clqPeIVMbb5dLHSXgdgGWGxdAU3oTUUkHiwWTKTBNnSmcqWJncNjYtBi3S8Rp0c5GIiyGztR8TRA==} + engines: {node: '>=22'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + mime@4.1.0: resolution: {integrity: sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==} engines: {node: '>=16'} hasBin: true + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + miniflare@5.20261001.0-alpha: resolution: {integrity: sha512-GaimS5mSIOMyvd16ga+e1/QkI8cmp3z35QPHFex0DktqNQf2RVZQwMPQXdyoUreUiFP/0Gpe2MoQInTyMAD5xA==} engines: {node: '>=22.0.0'} + minimatch@10.2.3: + resolution: {integrity: sha512-Rwi3pnapEqirPSbWbrZaa6N3nmqq4Xer/2XooiOKyV3q12ML06f7MOuc5DVH8ONZIFhwIYQ3yzPH4nt7iWHaTg==} + engines: {node: 18 || 20 || >=22} + + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + modern-tar@0.8.5: resolution: {integrity: sha512-snEhs+6G5Tjd4I7tLCDOaoln2RgE0bD19RzEKgvgK2hZ5VKy3MpLhLTZ2fWpXSTg4K2cyPwp+VHATFJhxfnOeA==} engines: {node: '>=18.0.0'} @@ -3794,11 +5474,15 @@ packages: module-details-from-path@1.0.4: resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - nanoid@3.3.18: - resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + nanoid@3.3.20: + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -3806,14 +5490,37 @@ packages: resolution: {integrity: sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==} engines: {node: '>=18'} + npm-run-path@6.0.0: + resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} + engines: {node: '>=18'} + obug@2.2.1: resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} engines: {node: '>=12.20.0'} + obug@3.0.0: + resolution: {integrity: sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==} + engines: {node: '>=12.20.0'} + + open@10.2.0: + resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} + engines: {node: '>=18'} + + oxc-parser@0.127.0: + resolution: {integrity: sha512-bkgD4qHlN7WxLdX8bLXdaU54TtQtAIg/ZBAfm0aje/mo3MRDo3P0hZSgr4U7O3xfX+fQmR5AP04JS/TGcZLcFA==} + engines: {node: ^20.19.0 || >=22.12.0} + oxc-parser@0.150.0: resolution: {integrity: sha512-zwajKw1GUa57IOdafIS7/yOObGF2NihwIfZfs5yJTRprMSkiwhWOc/k+n8bwnUPbHr2/XsjfBO8KyTPaW3VpWg==} engines: {node: ^20.19.0 || >=22.12.0} + oxc-parser@0.153.0: + resolution: {integrity: sha512-cEnpyyV1OJRXv++SFNw0z8i+2o/cr88rVLaOYJafELDoHM5yHvCAbwGRLvxuany6SNLcbblSMUyZaGzH+quSYg==} + engines: {node: ^20.19.0 || >=22.12.0} + + oxc-resolver@11.21.2: + resolution: {integrity: sha512-w5tLwYN3Zo24w5EeWJjJWZOwhYqTtC8PS2B1tIt7BZUuqTIcU07sQValbDw+rq7+AuAGzOHklgK+ifsy4lpXfw==} + oxc-walker@1.1.1: resolution: {integrity: sha512-Hwd7dq28zu/Er99+bpWp16CGwFrhyalJh0W3JOB7XpPvgWo3MqMi2ksWGKuzzA9zt50mJ2pIUwR5lpAdZ9ACNQ==} peerDependencies: @@ -3828,16 +5535,16 @@ packages: rolldown: optional: true - oxlint-tsgolint@7.0.2001: - resolution: {integrity: sha512-KjK/XLcXr1DSyonKhsuFqJRiuKqcyG9j3LJ8nkOsrLzGvodBPqzHOKauy10asLMDI0sUpvb+1sxlzff3udZvfg==} + oxlint-tsgolint@7.0.2003: + resolution: {integrity: sha512-VnK4zlqgmgq/7ZcjzCk/WpN8kKFsYGcB85Io9qT3wL4K8Un3RKmJkp793crNETieMusPMKOA4a+Mw2wbteI6TQ==} hasBin: true - oxlint@1.82.0: - resolution: {integrity: sha512-+iFM1BGw1ntYJt3QngbJmjbrGxPaKMUADOXOijpWGnYcBPq8YZnQftSS1C+pVcDYy9YxqDVJKQqQkTazTQMboQ==} + oxlint@1.87.0: + resolution: {integrity: sha512-drxVlaW7Oqq7wSy1CN25znRu00aRq8n57Q2wniJRxCmtYVz07ufCm7EVDPQFZURrTgQIjzKrVEHAD/HcvJXObg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: - oxlint-tsgolint: '>=7.0.2001' + oxlint-tsgolint: '>=7.0.2003' vite-plus: '*' peerDependenciesMeta: oxlint-tsgolint: @@ -3845,6 +5552,21 @@ packages: vite-plus: optional: true + p-filter@4.1.0: + resolution: {integrity: sha512-37/tPdZ3oJwHaS3gNJdenCDB3Tz26i9sjhnguBtvN0vYlRIiDNnvTWkuh+0hETV9rLPdJ3rlL3yVOYPIAnM8rw==} + engines: {node: '>=18'} + + p-limit@7.3.3: + resolution: {integrity: sha512-SKgVvDU5TNIxQ4r30U75BNLC6a+6GOEjngrk/ysM5+Zu3oJuk1SC5ApzIzY/7PjCEjoVPMWJ0zho/4QUiEB7TQ==} + engines: {node: '>=20'} + + p-map@7.1.1: + resolution: {integrity: sha512-6Lh3jQPLthYujSrlw6ccWXBGZTkoTCz27Hd92g79LH/kjAbgoEu0t442cCCNbegPSJsA0lwDX8z0bcKtkpA32g==} + engines: {node: '>=18'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -3853,22 +5575,55 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} + parse-json@8.3.0: + resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==} + engines: {node: '>=18'} + + parse-ms@4.0.0: + resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} + engines: {node: '>=18'} + + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + + path-name@1.0.0: + resolution: {integrity: sha512-/dcAb5vMXH0f51yvMuSUqFpxUcA8JelbRmE5mW/p4CUJxrNgK24IkstnV7ENtg2IDGBOu6izKTG6eilbnbNKWQ==} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + pend@1.2.0: resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + picomatch@4.0.7: resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} @@ -3883,8 +5638,12 @@ packages: engines: {node: '>=20'} hasBin: true - postcss@8.5.28: - resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + pngjs@7.0.0: + resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} + engines: {node: '>=14.19.0'} + + postcss@8.5.31: + resolution: {integrity: sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==} engines: {node: ^10 || ^12 || >=14} prettier@3.9.9: @@ -3892,6 +5651,14 @@ packages: engines: {node: '>=14'} hasBin: true + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + pretty-ms@9.3.1: + resolution: {integrity: sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA==} + engines: {node: '>=18'} + promise-limit@2.7.0: resolution: {integrity: sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==} @@ -3902,19 +5669,62 @@ packages: resolution: {integrity: sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==} engines: {node: '>=12.0.0'} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + pure-rand@8.4.2: + resolution: {integrity: sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==} + + quansync@1.0.0: + resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} + + react-docgen-typescript@2.4.0: + resolution: {integrity: sha512-ZtAp5XTO5HRzQctjPU0ybY0RRCQO19X/8fxn3w7y2VVTUbGHDKULPTL4ky3vB05euSgG5NpALhEhDPvQ56wvXg==} + peerDependencies: + typescript: '>= 4.3.x' + + react-docgen@8.0.4: + resolution: {integrity: sha512-gIYjtVTU4NCWZnb/LXVSWfClZXnjzX3+xxi69Tkfu4YsmYuqiOk8giLkKt/WwxWqh+U66CTh8Kqbha1uPv8/qQ==} + engines: {node: ^20.9.0 || >=22} + react-dom@19.3.0: resolution: {integrity: sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==} peerDependencies: react: ^19.3.0 + react-error-boundary@6.1.6: + resolution: {integrity: sha512-CDXPnXDGyFIbkwaaJ6u+xgsRmJhSi6YdgUDW1vnyKHfXp1a9pfAlM+ZET2CDu80/A+8iRcmXN0NSY9BCCoWP5A==} + peerDependencies: + '@types/react': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react@19.3.0: resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==} engines: {node: '>=0.10.0'} + read-yaml-file@3.0.0: + resolution: {integrity: sha512-7urDNDyx3oJt9NGkymzT3qudju76BoJhT6ICWclx6274zxMeSUOhyiceHscSBk4Lfbs0IYpjOjUwLkaOzrsJdQ==} + engines: {node: '>=22.13'} + readdirp@5.1.1: resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==} engines: {node: '>= 20.19.0'} + recast@0.23.21: + resolution: {integrity: sha512-mFAyJq9vUbSTARLZUvAEf1z3YxlvAwswbmxMx2mPA/MSm4KmpwvwvhsH/NIrZhyOuwD60Lzyw2qh83uCbgTPYw==} + engines: {node: '>= 4'} + + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + redis@6.2.1: resolution: {integrity: sha512-Z9VHtgYs48PiQC77X9O2Er8Hj4T+5BtFjT91/vi5Is1D04N72cA946ZslM1ImJw8ZctFBZWAVjM7S5wJNeHMpg==} engines: {node: '>= 20.0.0'} @@ -3945,10 +5755,45 @@ packages: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + resolve@1.22.13: + resolution: {integrity: sha512-Dj/cW9zBV2aRj6XzCxuGIs3ESZKv02jixne3q6jFZYZ3/Tg09gHHviSMDL4kObh9pqSahdRHEnDoaP2gn8YLhA==} + engines: {node: '>= 0.4'} + hasBin: true + retry@0.12.0: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} + rimraf@6.1.3: + resolution: {integrity: sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==} + engines: {node: 20 || >=22} + hasBin: true + + rolldown-plugin-dts@0.28.6: + resolution: {integrity: sha512-qKrFtBfRfR2hP233m7Ic9zf3wv6MSYdZghWKMdEO6dRYZGlNfINJAa1P5ZVvyHh3mrcd7IJQvIY8L9vnm+v5rQ==} + engines: {node: ^22.18.0 || ^24.11.0 || >=26.0.0} + peerDependencies: + '@typescript/native-preview': '*' + '@volar/typescript': ~2.4.0 + '@vue/language-core': ~3.2.0 || ~3.3.0 + rolldown: ^1.2.0 + typescript: ^5.0.0 || ^6.0.0 || ~7.0.0 + vue-tsc: ~3.2.0 || ~3.3.0 + peerDependenciesMeta: + '@typescript/native-preview': + optional: true + '@volar/typescript': + optional: true + '@vue/language-core': + optional: true + typescript: + optional: true + vue-tsc: + optional: true + rolldown@1.2.12: resolution: {integrity: sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3962,13 +5807,33 @@ packages: rou3@0.8.1: resolution: {integrity: sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA==} + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + + safe-execa@0.3.0: + resolution: {integrity: sha512-LMzgcWXnnL60sDuAzlmGehx7OHa4ajy0R/3djFOfnZwj4aksZUcdHfZgIIWLNcqverOAQJ2qOe7BDFm7Qpxd6Q==} + engines: {node: '>=22.13'} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.28.0: resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==} + semver-utils@1.1.4: + resolution: {integrity: sha512-EjnoLE5OGmDAVV/8YDoN5KiajNadjzIp9BAHOhYeQHt7j0UWxjmgsx4YD48wp4Ue1Qogq38F1GNUJNqF1kKKxA==} + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} @@ -4017,14 +5882,33 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} + engines: {node: '>=0.10.0'} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + source-map@0.7.6: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + srvx@0.11.22: resolution: {integrity: sha512-LqZxxBDMKuMAZzFzJnDCkFOrs9MZQZr0LvHiO/SuSZVdQaXD7xQ5UWTUxheJrQPve1qk9MG2B/yttUvJxw8egQ==} engines: {node: '>=20.16.0'} @@ -4038,8 +5922,23 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} - std-env@4.2.0: - resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + std-env@4.3.0: + resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} + + storybook@10.6.2: + resolution: {integrity: sha512-qHDINMiapxFUxYb+xC3j8u7Y+85X3u+/PlytE/wmnyg+bU0agCW3jUmWP4vIh2MioO2jTK/2B3Lcw6IpRFzQBA==} + hasBin: true + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + prettier: ^2 || ^3 + vite-plus: ^0.1.15 || ^0.2.0 + peerDependenciesMeta: + '@types/react': + optional: true + prettier: + optional: true + vite-plus: + optional: true string-argv@0.3.2: resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} @@ -4065,12 +5964,50 @@ packages: resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} engines: {node: '>=12'} + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-bom@5.0.0: + resolution: {integrity: sha512-p+byADHF7SzEcVnLvc/r3uognM1hUhObuHXxJcgLCfD194XAkaLbjq3Wzb0N5G2tgIjH0dgT708Z51QxMeu60A==} + engines: {node: '>=12'} + + strip-comments-strings@1.2.0: + resolution: {integrity: sha512-zwF4bmnyEjZwRhaak9jUWNxc0DoeKBJ7lwSN/LEc8dQXZcUFG6auaaTQJokQWXopLdM3iTx01nQT8E4aL29DAQ==} + + strip-final-newline@4.0.0: + resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} + engines: {node: '>=18'} + + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + + strip-indent@4.1.1: + resolution: {integrity: sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==} + engines: {node: '>=12'} + supports-color@10.2.2: resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} engines: {node: '>=18'} - tinybench@6.2.0: - resolution: {integrity: sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==} + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + + tinybench@6.2.2: + resolution: {integrity: sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==} engines: {node: '>=20.0.0'} tinyexec@1.3.1: @@ -4081,13 +6018,122 @@ packages: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyrainbow@3.2.0: + resolution: {integrity: sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.6: + resolution: {integrity: sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==} + engines: {node: '>=14.0.0'} + + tldts-core@7.4.18: + resolution: {integrity: sha512-KI2PKvCmvQI/xD6ypPqkU0i7oxNXR/O58RLRaHR4svsgVUQ2JE/XC8BtMbG6PSehD27I8sZJRcC1ASnCy6lp3g==} + + tldts@7.4.18: + resolution: {integrity: sha512-somxA8xKAUzlAxl0TX/jMSYIbRNFIVGnI96pVGJPeMc5BsHmfVXn6mOJT6U++ELnl04eGfzpfcEuZe8WiASkXg==} + hasBin: true + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + + tr46@7.0.0: + resolution: {integrity: sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg==} + engines: {node: ^22.14.0 || >=24.0.0} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + ts-dedent@2.3.0: + resolution: {integrity: sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==} + engines: {node: '>=6.10'} + + tsconfig-paths@4.2.0: + resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} + engines: {node: '>=6'} + + tsdown@0.23.0: + resolution: {integrity: sha512-BaT+ep1xnj5hdyICLd5r5SYYE+TXnI1ATePLykv9vcKpHpFTWUWRH+x1AF/E2qcu3YB6+vI8IdyxIIIffEqw5Q==} + engines: {node: ^22.18.0 || ^24.11.0 || >=26.0.0} + hasBin: true + peerDependencies: + '@arethetypeswrong/core': ^0.18.1 + '@tsdown/css': 0.23.0 + '@tsdown/exe': 0.23.0 + '@vitejs/devtools': '*' + publint: ^0.3.8 + tsx: '*' + typescript: ^5.0.0 || ^6.0.0 || ^7.0.0 + unplugin-unused: '>=0.5.0' + unrun: '*' + peerDependenciesMeta: + '@arethetypeswrong/core': + optional: true + '@tsdown/css': + optional: true + '@tsdown/exe': + optional: true + '@vitejs/devtools': + optional: true + publint: + optional: true + tsx: + optional: true + typescript: + optional: true + unplugin-unused: + optional: true + unrun: + optional: true + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tstyche@7.2.5: + resolution: {integrity: sha512-FlC7XYKNfvti6345d5Bpf/U6zZ98y3btlwbtrzeiIotVYeaO4ysq0s4pnkF9cLkWfjnP4Hm+Fq+TXNsfF8EVaA==} + engines: {node: '>=22'} + hasBin: true + peerDependencies: + typescript: '>=5.4' + peerDependenciesMeta: + typescript: + optional: true + turbo@2.11.7: resolution: {integrity: sha512-RhXHbqfvY6o3xt/zSHCwSNwLrZlXiqHzewi4eAj9PtZ1T1CqgMJ2yIDb7SKPTG9IikTm925mfzL6hldQVdyNWw==} hasBin: true + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + + type-fest@5.10.0: + resolution: {integrity: sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA==} + engines: {node: '>=20'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + typescript@7.0.2: resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} engines: {node: '>=16.20.0'} @@ -4096,6 +6142,9 @@ packages: ufo@1.6.4: resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + unconfig-core@7.5.0: + resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==} + undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} @@ -4110,9 +6159,17 @@ packages: unenv@2.0.0-rc.24: resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + unicorn-magic@0.3.0: + resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} + engines: {node: '>=18'} + universal-user-agent@7.0.3: resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==} + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + unplugin@3.4.0: resolution: {integrity: sha512-9skdIFlCsPdFV7wUfZxNsFInlW+7nJmGu2gkTu0OUhF56aXGsHab9x52/QhdJ4lC7ZDPWTxbiC4ANqVlsuaW3w==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4121,7 +6178,7 @@ packages: '@rsbuild/core': '*' '@rspack/core': '*' bun-types-no-globals: '*' - esbuild: '*' + esbuild: ^0.28.2 rolldown: '*' rollup: '*' unloader: '*' @@ -4172,14 +6229,18 @@ packages: resolution: {integrity: sha512-zj/ob3UsvJGN0whEAKFp53REA5X66hvffVqoCtVQAakJKnKlH+/PcOfMoFwIG/o4rElqLv/ycAFlx8ZlXUorCg==} engines: {node: '>=18.12.0'} - vite@8.3.2: - resolution: {integrity: sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==} + verkit@0.4.1: + resolution: {integrity: sha512-mMVzj0TXExtVdlDEq+Mzp0eyOuyznNpFobNM3uAqe3HVm/Ps2c+u17BligMkZjZCA6EiXpoon8iRTer3iu40SQ==} + engines: {node: '>=18.12.0'} + + vite@8.3.4: + resolution: {integrity: sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: '@types/node': ^20.19.0 || >=22.12.0 '@vitejs/devtools': ^0.7.1 - esbuild: ^0.27.0 || ^0.28.0 + esbuild: ^0.28.2 jiti: '>=1.21.0' less: ^4.0.0 sass: ^1.70.0 @@ -4223,20 +6284,20 @@ packages: vite: optional: true - vitest@5.0.3: - resolution: {integrity: sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==} + vitest@5.0.5: + resolution: {integrity: sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==} engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 5.0.3 - '@vitest/browser-preview': 5.0.3 + '@vitest/browser-playwright': 5.0.5 + '@vitest/browser-preview': 5.0.5 '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 - '@vitest/coverage-istanbul': 5.0.3 - '@vitest/coverage-v8': 5.0.3 - '@vitest/ui': 5.0.3 + '@vitest/coverage-istanbul': 5.0.5 + '@vitest/coverage-v8': 5.0.5 + '@vitest/ui': 5.0.5 happy-dom: '*' jsdom: '*' vite: ^6.4.0 || ^7.0.0 || ^8.0.0 @@ -4264,9 +6325,25 @@ packages: jsdom: optional: true + w3c-xmlserializer@6.0.0: + resolution: {integrity: sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@17.3.0: + resolution: {integrity: sha512-KwOAJrpf+GTgBvcBQJcP38sMF0DsQKdwtG+TxM2pW44P6E5k0hDbWrDmyuJDIF0Uef0aQq0ZoJs+5oI+k8ddhg==} + engines: {node: ^22.14.0 || >=24.0.0} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -4300,6 +6377,10 @@ packages: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} + write-file-atomic@7.0.1: + resolution: {integrity: sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg==} + engines: {node: ^20.17.0 || >=22.9.0} + ws@8.21.0: resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} engines: {node: '>=10.0.0'} @@ -4324,10 +6405,21 @@ packages: utf-8-validate: optional: true + wsl-utils@0.1.0: + resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} + engines: {node: '>=18'} + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + xmlbuilder2@4.0.3: resolution: {integrity: sha512-bx8Q1STctnNaaDymWnkfQLKofs0mGNN7rLLapJlGuV3VlvegD7Ls4ggMjE3aUSWItCCzU0PEv45lI87iSigiCA==} engines: {node: '>=20.0'} + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -4360,12 +6452,29 @@ packages: resolution: {integrity: sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==} engines: {node: '>=12'} + yocto-queue@1.2.2: + resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} + engines: {node: '>=12.20'} + + yoctocolors@2.2.0: + resolution: {integrity: sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==} + engines: {node: '>=18'} + youch-core@0.3.3: resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} youch@4.1.0-beta.10: resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} + yuku-ast@0.10.2: + resolution: {integrity: sha512-UnG9mA6giglCvSErft2/40TVFX750Sj1xgwddPLpG7J7rlr/P1wPADg9G2RK+d/1tNLtRVoLdMMOMVBB9561TQ==} + + yuku-codegen@0.10.2: + resolution: {integrity: sha512-hentl2dtrF6cPjiAirtkfxfFZBA6Hdm61UqRJ7cA0qrlDNMk9PZGOjw5w1mx3E0hu/6pHcJF4dJW+D0SXHPZOA==} + + yuku-parser@0.10.2: + resolution: {integrity: sha512-CgaU0/PPjCAIEZ3WQroosOxTY3eeKldAN3h+vk8pMNz4+jl1CZzBR4pW+K/rRPF112VooCL5FdjJoiMNjDrL2A==} + zeptomatch@2.1.0: resolution: {integrity: sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==} @@ -4374,14 +6483,16 @@ packages: snapshots: - '@alchemy.run/cloudflare-runtime@2.0.0-beta.80(@distilled.cloud/cloudflare@1.0.0-rc.13(effect@4.0.1))(@effect/platform-node@4.0.1(effect@4.0.1)(redis@6.2.1(@opentelemetry/api@1.9.1)))(@types/node@24.19.1)(effect@4.0.1)(rolldown@1.2.5)(typescript@7.0.2)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))': + '@adobe/css-tools@4.5.0': {} + + '@alchemy.run/cloudflare-runtime@2.0.0-beta.80(@distilled.cloud/cloudflare@1.0.0-rc.13(effect@4.0.5))(@effect/platform-node@4.0.1(effect@4.0.5)(redis@6.2.1(@opentelemetry/api@1.9.1)))(@types/node@24.19.1)(effect@4.0.5)(rolldown@1.2.5)(typescript@7.0.2)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))': dependencies: '@alchemy.run/node-utils': 2.0.0-beta.80 '@cloudflare/unenv-preset': 2.16.2(unenv@2.0.0-rc.24)(workerd@1.20261005.1) - '@distilled.cloud/cloudflare': 1.0.0-rc.13(effect@4.0.1) + '@distilled.cloud/cloudflare': 1.0.0-rc.13(effect@4.0.5) '@puppeteer/browsers': 3.2.3(yauzl@3.4.0) capnp-es: 0.0.16(typescript@7.0.2) - effect: 4.0.1 + effect: 4.0.5 magic-string: 0.30.21 mime: 4.1.0 sharp: 0.35.5(@types/node@24.19.1) @@ -4389,33 +6500,33 @@ snapshots: workerd: 1.20261005.1 yauzl: 3.4.0 optionalDependencies: - '@effect/platform-node': 4.0.1(effect@4.0.1)(redis@6.2.1(@opentelemetry/api@1.9.1)) + '@effect/platform-node': 4.0.1(effect@4.0.5)(redis@6.2.1(@opentelemetry/api@1.9.1)) rolldown: 1.2.5 - vite: 8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1) + vite: 8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1) transitivePeerDependencies: - '@types/node' - proxy-agent - typescript - '@alchemy.run/floci@2.0.0-beta.80(effect@4.0.1)': + '@alchemy.run/floci@2.0.0-beta.80(effect@4.0.5)': dependencies: - effect: 4.0.1 + effect: 4.0.5 - '@alchemy.run/frontend-frameworks@2.0.0-beta.80(@types/node@24.19.1)(@vitejs/plugin-react@6.1.2(@rolldown/plugin-babel@0.2.4(@babel/core@8.0.6)(rolldown@1.2.12)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)))(effect@4.0.1)(hono@4.11.4)(redis@6.2.1(@opentelemetry/api@1.9.1))(typescript@7.0.2)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))': + '@alchemy.run/frontend-frameworks@2.0.0-beta.80(@types/node@24.19.1)(@vitejs/plugin-react@6.1.2(@rolldown/plugin-babel@0.2.4(@babel/core@8.0.6)(@babel/runtime@7.29.10)(rolldown@1.2.12)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)))(effect@4.0.5)(hono@4.11.4)(redis@6.2.1(@opentelemetry/api@1.9.1))(typescript@7.0.2)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))': dependencies: - '@alchemy.run/cloudflare-runtime': 2.0.0-beta.80(@distilled.cloud/cloudflare@1.0.0-rc.13(effect@4.0.1))(@effect/platform-node@4.0.1(effect@4.0.1)(redis@6.2.1(@opentelemetry/api@1.9.1)))(@types/node@24.19.1)(effect@4.0.1)(rolldown@1.2.5)(typescript@7.0.2)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) - '@distilled.cloud/aws': 1.0.0-rc.13(effect@4.0.1) - '@distilled.cloud/cloudflare': 1.0.0-rc.13(effect@4.0.1) - '@effect/platform-node': 4.0.1(effect@4.0.1)(redis@6.2.1(@opentelemetry/api@1.9.1)) - effect: 4.0.1 + '@alchemy.run/cloudflare-runtime': 2.0.0-beta.80(@distilled.cloud/cloudflare@1.0.0-rc.13(effect@4.0.5))(@effect/platform-node@4.0.1(effect@4.0.5)(redis@6.2.1(@opentelemetry/api@1.9.1)))(@types/node@24.19.1)(effect@4.0.5)(rolldown@1.2.5)(typescript@7.0.2)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) + '@distilled.cloud/aws': 1.0.0-rc.13(effect@4.0.5) + '@distilled.cloud/cloudflare': 1.0.0-rc.13(effect@4.0.5) + '@effect/platform-node': 4.0.1(effect@4.0.5)(redis@6.2.1(@opentelemetry/api@1.9.1)) + effect: 4.0.5 esbuild: 0.28.2 rolldown: 1.2.5 srvx: 1.0.5 tinyglobby: 0.2.17 optionalDependencies: - '@vitejs/plugin-react': 6.1.2(@rolldown/plugin-babel@0.2.4(@babel/core@8.0.6)(rolldown@1.2.12)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) + '@vitejs/plugin-react': 6.1.2(@rolldown/plugin-babel@0.2.4(@babel/core@8.0.6)(@babel/runtime@7.29.10)(rolldown@1.2.12)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) hono: 4.11.4 - vite: 8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1) + vite: 8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1) transitivePeerDependencies: - '@types/node' - bufferutil @@ -4433,6 +6544,21 @@ snapshots: optionalDependencies: '@types/react': 19.3.0 + '@asamuzakjp/css-color@7.1.7': + dependencies: + '@csstools/css-calc': 3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) + '@csstools/css-color-parser': 4.2.6(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) + '@csstools/css-parser-algorithms': 4.0.2(@csstools/css-tokenizer@4.0.2) + '@csstools/css-tokenizer': 4.0.2 + lru-cache: 11.5.3 + + '@asamuzakjp/dom-selector@9.2.4': + dependencies: + bidi-js: 1.1.0 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.3 + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -4570,6 +6696,8 @@ snapshots: dependencies: '@babel/types': 8.0.6 + '@babel/runtime@7.29.10': {} + '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 @@ -4614,6 +6742,12 @@ snapshots: '@babel/helper-string-parser': 8.0.6 '@babel/helper-validator-identifier': 8.0.6 + '@blazediff/core@1.10.0': {} + + '@bramus/specificity@2.4.3': + dependencies: + css-tree: 3.2.1 + '@chevrotain/cst-dts-gen@10.5.0': dependencies: '@chevrotain/gast': 10.5.0 @@ -4637,12 +6771,12 @@ snapshots: optionalDependencies: workerd: 1.20261005.1 - '@cloudflare/vite-plugin@1.62.5(@types/node@24.19.1)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))(wrangler@4.147.0(@cloudflare/workers-types@5.20261005.1)(@types/node@24.19.1))': + '@cloudflare/vite-plugin@1.62.5(@types/node@24.19.1)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))(wrangler@4.147.0(@cloudflare/workers-types@5.20261005.1)(@types/node@24.19.1))': dependencies: '@cloudflare/unenv-preset': 2.16.2(unenv@2.0.0-rc.24)(workerd@1.20261005.1) miniflare: 5.20261001.0-alpha(@types/node@24.19.1) unenv: 2.0.0-rc.24 - vite: 8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1) + vite: 8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1) workerd: 1.20261005.1 wrangler: 4.147.0(@cloudflare/workers-types@5.20261005.1)(@types/node@24.19.1) ws: 8.21.0 @@ -4791,91 +6925,115 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 - '@distilled.cloud/acme@1.0.0-rc.13(effect@4.0.1)': + '@csstools/color-helpers@6.1.2': {} + + '@csstools/css-calc@3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.2(@csstools/css-tokenizer@4.0.2) + '@csstools/css-tokenizer': 4.0.2 + + '@csstools/css-color-parser@4.2.6(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2)': + dependencies: + '@csstools/color-helpers': 6.1.2 + '@csstools/css-calc': 3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) + '@csstools/css-parser-algorithms': 4.0.2(@csstools/css-tokenizer@4.0.2) + '@csstools/css-tokenizer': 4.0.2 + + '@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2)': + dependencies: + '@csstools/css-tokenizer': 4.0.2 + + '@csstools/css-syntax-patches-for-csstree@1.1.15(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.2': {} + + '@distilled.cloud/acme@1.0.0-rc.13(effect@4.0.5)': dependencies: - '@distilled.cloud/core': 1.0.0-rc.13(effect@4.0.1) - effect: 4.0.1 + '@distilled.cloud/core': 1.0.0-rc.13(effect@4.0.5) + effect: 4.0.5 - '@distilled.cloud/aws@1.0.0-rc.13(effect@4.0.1)': + '@distilled.cloud/aws@1.0.0-rc.13(effect@4.0.5)': dependencies: - '@distilled.cloud/core': 1.0.0-rc.13(effect@4.0.1) + '@distilled.cloud/core': 1.0.0-rc.13(effect@4.0.5) '@smithy/types': 4.19.0 - effect: 4.0.1 + effect: 4.0.5 - '@distilled.cloud/axiom@1.0.0-rc.13(effect@4.0.1)': + '@distilled.cloud/axiom@1.0.0-rc.13(effect@4.0.5)': dependencies: - '@distilled.cloud/core': 1.0.0-rc.13(effect@4.0.1) - effect: 4.0.1 + '@distilled.cloud/core': 1.0.0-rc.13(effect@4.0.5) + effect: 4.0.5 - '@distilled.cloud/cloudflare@1.0.0-rc.13(effect@4.0.1)': + '@distilled.cloud/cloudflare@1.0.0-rc.13(effect@4.0.5)': dependencies: - '@distilled.cloud/core': 1.0.0-rc.13(effect@4.0.1) - effect: 4.0.1 + '@distilled.cloud/core': 1.0.0-rc.13(effect@4.0.5) + effect: 4.0.5 - '@distilled.cloud/core@1.0.0-rc.13(effect@4.0.1)': + '@distilled.cloud/core@1.0.0-rc.13(effect@4.0.5)': dependencies: - effect: 4.0.1 + effect: 4.0.5 graphql: 16.11.0 - '@distilled.cloud/doppler@1.0.0-rc.13(effect@4.0.1)': + '@distilled.cloud/doppler@1.0.0-rc.13(effect@4.0.5)': dependencies: - '@distilled.cloud/core': 1.0.0-rc.13(effect@4.0.1) - effect: 4.0.1 + '@distilled.cloud/core': 1.0.0-rc.13(effect@4.0.5) + effect: 4.0.5 - '@distilled.cloud/fly-io@1.0.0-rc.13(effect@4.0.1)': + '@distilled.cloud/fly-io@1.0.0-rc.13(effect@4.0.5)': dependencies: - '@distilled.cloud/core': 1.0.0-rc.13(effect@4.0.1) - effect: 4.0.1 + '@distilled.cloud/core': 1.0.0-rc.13(effect@4.0.5) + effect: 4.0.5 - '@distilled.cloud/gcp@1.0.0-rc.13(effect@4.0.1)': + '@distilled.cloud/gcp@1.0.0-rc.13(effect@4.0.5)': dependencies: - '@distilled.cloud/core': 1.0.0-rc.13(effect@4.0.1) - effect: 4.0.1 + '@distilled.cloud/core': 1.0.0-rc.13(effect@4.0.5) + effect: 4.0.5 - '@distilled.cloud/hetzner@1.0.0-rc.13(effect@4.0.1)': + '@distilled.cloud/hetzner@1.0.0-rc.13(effect@4.0.5)': dependencies: - '@distilled.cloud/core': 1.0.0-rc.13(effect@4.0.1) - effect: 4.0.1 + '@distilled.cloud/core': 1.0.0-rc.13(effect@4.0.5) + effect: 4.0.5 - '@distilled.cloud/infisical@1.0.0-rc.13(effect@4.0.1)': + '@distilled.cloud/infisical@1.0.0-rc.13(effect@4.0.5)': dependencies: - '@distilled.cloud/core': 1.0.0-rc.13(effect@4.0.1) - effect: 4.0.1 + '@distilled.cloud/core': 1.0.0-rc.13(effect@4.0.5) + effect: 4.0.5 - '@distilled.cloud/neon@1.0.0-rc.13(effect@4.0.1)': + '@distilled.cloud/neon@1.0.0-rc.13(effect@4.0.5)': dependencies: - '@distilled.cloud/core': 1.0.0-rc.13(effect@4.0.1) - effect: 4.0.1 + '@distilled.cloud/core': 1.0.0-rc.13(effect@4.0.5) + effect: 4.0.5 - '@distilled.cloud/planetscale@1.0.0-rc.13(effect@4.0.1)': + '@distilled.cloud/planetscale@1.0.0-rc.13(effect@4.0.5)': dependencies: - '@distilled.cloud/core': 1.0.0-rc.13(effect@4.0.1) - effect: 4.0.1 + '@distilled.cloud/core': 1.0.0-rc.13(effect@4.0.5) + effect: 4.0.5 - '@distilled.cloud/prisma@1.0.0-rc.13(effect@4.0.1)': + '@distilled.cloud/prisma@1.0.0-rc.13(effect@4.0.5)': dependencies: - '@distilled.cloud/core': 1.0.0-rc.13(effect@4.0.1) - effect: 4.0.1 + '@distilled.cloud/core': 1.0.0-rc.13(effect@4.0.5) + effect: 4.0.5 - '@distilled.cloud/railway@1.0.0-rc.13(effect@4.0.1)': + '@distilled.cloud/railway@1.0.0-rc.13(effect@4.0.5)': dependencies: - '@distilled.cloud/core': 1.0.0-rc.13(effect@4.0.1) - effect: 4.0.1 + '@distilled.cloud/core': 1.0.0-rc.13(effect@4.0.5) + effect: 4.0.5 - '@distilled.cloud/stripe@1.0.0-rc.13(effect@4.0.1)': + '@distilled.cloud/stripe@1.0.0-rc.13(effect@4.0.5)': dependencies: - '@distilled.cloud/core': 1.0.0-rc.13(effect@4.0.1) - effect: 4.0.1 + '@distilled.cloud/core': 1.0.0-rc.13(effect@4.0.5) + effect: 4.0.5 - '@distilled.cloud/zerossl@1.0.0-rc.13(effect@4.0.1)': + '@distilled.cloud/zerossl@1.0.0-rc.13(effect@4.0.5)': dependencies: - '@distilled.cloud/core': 1.0.0-rc.13(effect@4.0.1) - effect: 4.0.1 + '@distilled.cloud/core': 1.0.0-rc.13(effect@4.0.5) + effect: 4.0.5 - '@effect/opentelemetry@4.0.1(@opentelemetry/api-logs@0.222.0)(@opentelemetry/api@1.9.1)(@opentelemetry/resources@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(effect@4.0.1)': + '@effect/opentelemetry@4.0.1(@opentelemetry/api-logs@0.222.0)(@opentelemetry/api@1.9.1)(@opentelemetry/resources@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(effect@4.0.5)': dependencies: '@opentelemetry/semantic-conventions': 1.43.0 - effect: 4.0.1 + effect: 4.0.5 optionalDependencies: '@opentelemetry/api': 1.9.1 '@opentelemetry/api-logs': 0.222.0 @@ -4885,95 +7043,83 @@ snapshots: '@opentelemetry/sdk-trace-base': 2.11.0(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-trace-node': 2.11.0(@opentelemetry/api@1.9.1) - '@effect/platform-node-shared@4.0.1(effect@4.0.1)': + '@effect/platform-node-shared@4.0.1(effect@4.0.5)': dependencies: '@types/ws': 8.18.1 - effect: 4.0.1 + effect: 4.0.5 + ws: 8.22.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@effect/platform-node-shared@4.0.5(effect@4.0.5)': + dependencies: + '@types/ws': 8.18.2 + effect: 4.0.5 ws: 8.22.0 transitivePeerDependencies: - bufferutil - utf-8-validate - '@effect/platform-node@4.0.1(effect@4.0.1)(redis@6.2.1(@opentelemetry/api@1.9.1))': + '@effect/platform-node@4.0.1(effect@4.0.5)(redis@6.2.1(@opentelemetry/api@1.9.1))': dependencies: - '@effect/platform-node-shared': 4.0.1(effect@4.0.1) - effect: 4.0.1 + '@effect/platform-node-shared': 4.0.1(effect@4.0.5) + effect: 4.0.5 redis: 6.2.1(@opentelemetry/api@1.9.1) undici: 8.11.2 transitivePeerDependencies: - bufferutil - utf-8-validate - '@effect/sql-d1@4.0.1(effect@4.0.1)': + '@effect/platform-node@4.0.5(effect@4.0.5)(redis@6.2.1(@opentelemetry/api@1.9.1))': dependencies: - '@cloudflare/workers-types': 5.20261005.1 - effect: 4.0.1 + '@effect/platform-node-shared': 4.0.5(effect@4.0.5) + effect: 4.0.5 + redis: 6.2.1(@opentelemetry/api@1.9.1) + undici: 8.11.2 + transitivePeerDependencies: + - bufferutil + - utf-8-validate - '@effect/sql-sqlite-do@4.0.1(effect@4.0.1)': + '@effect/sql-d1@4.0.1(effect@4.0.5)': dependencies: - effect: 4.0.1 - - '@effect/tsgo-darwin-arm64@0.50.0': - optional: true - - '@effect/tsgo-darwin-arm64@0.51.0': - optional: true - - '@effect/tsgo-darwin-x64@0.50.0': - optional: true - - '@effect/tsgo-darwin-x64@0.51.0': - optional: true - - '@effect/tsgo-linux-arm64@0.50.0': - optional: true - - '@effect/tsgo-linux-arm64@0.51.0': - optional: true + '@cloudflare/workers-types': 5.20261005.1 + effect: 4.0.5 - '@effect/tsgo-linux-arm@0.50.0': - optional: true + '@effect/sql-sqlite-do@4.0.1(effect@4.0.5)': + dependencies: + effect: 4.0.5 - '@effect/tsgo-linux-arm@0.51.0': + '@effect/tsgo-darwin-arm64@0.51.1': optional: true - '@effect/tsgo-linux-x64@0.50.0': + '@effect/tsgo-darwin-x64@0.51.1': optional: true - '@effect/tsgo-linux-x64@0.51.0': + '@effect/tsgo-linux-arm64@0.51.1': optional: true - '@effect/tsgo-win32-arm64@0.50.0': + '@effect/tsgo-linux-arm@0.51.1': optional: true - '@effect/tsgo-win32-arm64@0.51.0': + '@effect/tsgo-linux-x64@0.51.1': optional: true - '@effect/tsgo-win32-x64@0.50.0': + '@effect/tsgo-win32-arm64@0.51.1': optional: true - '@effect/tsgo-win32-x64@0.51.0': + '@effect/tsgo-win32-x64@0.51.1': optional: true - '@effect/tsgo@0.50.0': + '@effect/tsgo@0.51.1': optionalDependencies: - '@effect/tsgo-darwin-arm64': 0.50.0 - '@effect/tsgo-darwin-x64': 0.50.0 - '@effect/tsgo-linux-arm': 0.50.0 - '@effect/tsgo-linux-arm64': 0.50.0 - '@effect/tsgo-linux-x64': 0.50.0 - '@effect/tsgo-win32-arm64': 0.50.0 - '@effect/tsgo-win32-x64': 0.50.0 - - '@effect/tsgo@0.51.0': - optionalDependencies: - '@effect/tsgo-darwin-arm64': 0.51.0 - '@effect/tsgo-darwin-x64': 0.51.0 - '@effect/tsgo-linux-arm': 0.51.0 - '@effect/tsgo-linux-arm64': 0.51.0 - '@effect/tsgo-linux-x64': 0.51.0 - '@effect/tsgo-win32-arm64': 0.51.0 - '@effect/tsgo-win32-x64': 0.51.0 + '@effect/tsgo-darwin-arm64': 0.51.1 + '@effect/tsgo-darwin-x64': 0.51.1 + '@effect/tsgo-linux-arm': 0.51.1 + '@effect/tsgo-linux-arm64': 0.51.1 + '@effect/tsgo-linux-x64': 0.51.1 + '@effect/tsgo-win32-arm64': 0.51.1 + '@effect/tsgo-win32-x64': 0.51.1 '@electric-sql/pglite-socket@0.0.20(@electric-sql/pglite@0.3.15)': dependencies: @@ -4985,169 +7131,127 @@ snapshots: '@electric-sql/pglite@0.3.15': {} - '@emnapi/runtime@1.11.3': + '@emnapi/core@1.11.0': dependencies: + '@emnapi/wasi-threads': 1.2.2 tslib: 2.8.1 optional: true - '@esbuild/aix-ppc64@0.28.1': + '@emnapi/core@1.9.2': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 optional: true - '@esbuild/aix-ppc64@0.28.2': + '@emnapi/runtime@1.11.0': + dependencies: + tslib: 2.8.1 optional: true - '@esbuild/android-arm64@0.28.1': + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 optional: true - '@esbuild/android-arm64@0.28.2': + '@emnapi/runtime@1.9.2': + dependencies: + tslib: 2.8.1 optional: true - '@esbuild/android-arm@0.28.1': + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 optional: true - '@esbuild/android-arm@0.28.2': + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 optional: true - '@esbuild/android-x64@0.28.1': + '@esbuild/aix-ppc64@0.28.2': optional: true - '@esbuild/android-x64@0.28.2': + '@esbuild/android-arm64@0.28.2': optional: true - '@esbuild/darwin-arm64@0.28.1': + '@esbuild/android-arm@0.28.2': optional: true - '@esbuild/darwin-arm64@0.28.2': + '@esbuild/android-x64@0.28.2': optional: true - '@esbuild/darwin-x64@0.28.1': + '@esbuild/darwin-arm64@0.28.2': optional: true '@esbuild/darwin-x64@0.28.2': optional: true - '@esbuild/freebsd-arm64@0.28.1': - optional: true - '@esbuild/freebsd-arm64@0.28.2': optional: true - '@esbuild/freebsd-x64@0.28.1': - optional: true - '@esbuild/freebsd-x64@0.28.2': optional: true - '@esbuild/linux-arm64@0.28.1': - optional: true - '@esbuild/linux-arm64@0.28.2': optional: true - '@esbuild/linux-arm@0.28.1': - optional: true - '@esbuild/linux-arm@0.28.2': optional: true - '@esbuild/linux-ia32@0.28.1': - optional: true - '@esbuild/linux-ia32@0.28.2': optional: true - '@esbuild/linux-loong64@0.28.1': - optional: true - '@esbuild/linux-loong64@0.28.2': optional: true - '@esbuild/linux-mips64el@0.28.1': - optional: true - '@esbuild/linux-mips64el@0.28.2': optional: true - '@esbuild/linux-ppc64@0.28.1': - optional: true - '@esbuild/linux-ppc64@0.28.2': optional: true - '@esbuild/linux-riscv64@0.28.1': - optional: true - '@esbuild/linux-riscv64@0.28.2': optional: true - '@esbuild/linux-s390x@0.28.1': - optional: true - '@esbuild/linux-s390x@0.28.2': optional: true - '@esbuild/linux-x64@0.28.1': - optional: true - '@esbuild/linux-x64@0.28.2': optional: true - '@esbuild/netbsd-arm64@0.28.1': - optional: true - '@esbuild/netbsd-arm64@0.28.2': optional: true - '@esbuild/netbsd-x64@0.28.1': - optional: true - '@esbuild/netbsd-x64@0.28.2': optional: true - '@esbuild/openbsd-arm64@0.28.1': - optional: true - '@esbuild/openbsd-arm64@0.28.2': optional: true - '@esbuild/openbsd-x64@0.28.1': - optional: true - '@esbuild/openbsd-x64@0.28.2': optional: true - '@esbuild/openharmony-arm64@0.28.1': - optional: true - '@esbuild/openharmony-arm64@0.28.2': optional: true - '@esbuild/sunos-x64@0.28.1': - optional: true - '@esbuild/sunos-x64@0.28.2': optional: true - '@esbuild/win32-arm64@0.28.1': - optional: true - '@esbuild/win32-arm64@0.28.2': optional: true - '@esbuild/win32-ia32@0.28.1': - optional: true - '@esbuild/win32-ia32@0.28.2': optional: true - '@esbuild/win32-x64@0.28.1': - optional: true - '@esbuild/win32-x64@0.28.2': optional: true '@eslint-community/regexpp@4.12.2': {} + '@exodus/bytes@1.16.0(@noble/hashes@2.4.0)': + optionalDependencies: + '@noble/hashes': 2.4.0 + '@grpc/grpc-js@1.14.5': dependencies: '@grpc/proto-loader': 0.8.1 @@ -5374,6 +7478,16 @@ snapshots: '@img/sharp-win32-x64@0.35.5': optional: true + '@istanbuljs/schema@0.1.6': {} + + '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@7.0.2)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))': + dependencies: + glob: 13.0.6 + react-docgen-typescript: 2.4.0(typescript@7.0.2) + vite: 8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1) + optionalDependencies: + typescript: 7.0.2 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.6.0 @@ -5463,11 +7577,60 @@ snapshots: '@libsql/win32-x64-msvc@0.5.29': optional: true + '@microsoft/api-extractor-model@7.33.15(@types/node@24.19.1)': + dependencies: + '@microsoft/tsdoc': 0.17.1 + '@microsoft/tsdoc-config': 0.18.3 + '@rushstack/node-core-library': 5.25.1(@types/node@24.19.1) + transitivePeerDependencies: + - '@types/node' + + '@microsoft/api-extractor@7.59.4(@types/node@24.19.1)': + dependencies: + '@microsoft/api-extractor-model': 7.33.15(@types/node@24.19.1) + '@microsoft/tsdoc': 0.17.1 + '@microsoft/tsdoc-config': 0.18.3 + '@rushstack/node-core-library': 5.25.1(@types/node@24.19.1) + '@rushstack/rig-package': 0.7.4 + '@rushstack/terminal': 0.24.7(@types/node@24.19.1) + '@rushstack/ts-command-line': 5.3.17(@types/node@24.19.1) + diff: 8.0.4 + minimatch: 10.2.3 + resolve: 1.22.13 + semver: 7.7.4 + source-map: 0.6.1 + typescript: 5.9.3 + transitivePeerDependencies: + - '@types/node' + + '@microsoft/tsdoc-config@0.18.3': + dependencies: + '@microsoft/tsdoc': 0.17.1 + ajv: 8.18.0 + jju: 1.4.0 + resolve: 1.22.13 + + '@microsoft/tsdoc@0.17.1': {} + '@mrleebo/prisma-ast@0.13.1': dependencies: chevrotain: 10.5.0 lilconfig: 2.1.0 + '@napi-rs/wasm-runtime@1.2.5(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)': + dependencies: + '@emnapi/core': 1.11.0 + '@emnapi/runtime': 1.11.0 + '@tybys/wasm-util': 0.10.4 + optional: true + + '@napi-rs/wasm-runtime@1.2.5(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': + dependencies: + '@emnapi/core': 1.9.2 + '@emnapi/runtime': 1.9.2 + '@tybys/wasm-util': 0.10.4 + optional: true + '@neon-rs/load@0.0.4': {} '@neon/functions@0.11.0(hono@4.11.4)': @@ -5796,145 +7959,478 @@ snapshots: '@opentelemetry/semantic-conventions@1.43.0': {} + '@oxc-parser/binding-android-arm-eabi@0.127.0': + optional: true + '@oxc-parser/binding-android-arm-eabi@0.150.0': optional: true + '@oxc-parser/binding-android-arm-eabi@0.153.0': + optional: true + + '@oxc-parser/binding-android-arm64@0.127.0': + optional: true + '@oxc-parser/binding-android-arm64@0.150.0': optional: true + '@oxc-parser/binding-android-arm64@0.153.0': + optional: true + + '@oxc-parser/binding-darwin-arm64@0.127.0': + optional: true + '@oxc-parser/binding-darwin-arm64@0.150.0': optional: true + '@oxc-parser/binding-darwin-arm64@0.153.0': + optional: true + + '@oxc-parser/binding-darwin-x64@0.127.0': + optional: true + '@oxc-parser/binding-darwin-x64@0.150.0': optional: true + '@oxc-parser/binding-darwin-x64@0.153.0': + optional: true + + '@oxc-parser/binding-freebsd-x64@0.127.0': + optional: true + '@oxc-parser/binding-freebsd-x64@0.150.0': optional: true + '@oxc-parser/binding-freebsd-x64@0.153.0': + optional: true + + '@oxc-parser/binding-linux-arm-gnueabihf@0.127.0': + optional: true + '@oxc-parser/binding-linux-arm-gnueabihf@0.150.0': optional: true + '@oxc-parser/binding-linux-arm-gnueabihf@0.153.0': + optional: true + + '@oxc-parser/binding-linux-arm-musleabihf@0.127.0': + optional: true + '@oxc-parser/binding-linux-arm-musleabihf@0.150.0': optional: true + '@oxc-parser/binding-linux-arm-musleabihf@0.153.0': + optional: true + + '@oxc-parser/binding-linux-arm64-gnu@0.127.0': + optional: true + '@oxc-parser/binding-linux-arm64-gnu@0.150.0': optional: true + '@oxc-parser/binding-linux-arm64-gnu@0.153.0': + optional: true + + '@oxc-parser/binding-linux-arm64-musl@0.127.0': + optional: true + '@oxc-parser/binding-linux-arm64-musl@0.150.0': optional: true + '@oxc-parser/binding-linux-arm64-musl@0.153.0': + optional: true + + '@oxc-parser/binding-linux-ppc64-gnu@0.127.0': + optional: true + '@oxc-parser/binding-linux-ppc64-gnu@0.150.0': optional: true + '@oxc-parser/binding-linux-ppc64-gnu@0.153.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-gnu@0.127.0': + optional: true + '@oxc-parser/binding-linux-riscv64-gnu@0.150.0': optional: true + '@oxc-parser/binding-linux-riscv64-gnu@0.153.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-musl@0.127.0': + optional: true + '@oxc-parser/binding-linux-riscv64-musl@0.150.0': optional: true + '@oxc-parser/binding-linux-riscv64-musl@0.153.0': + optional: true + + '@oxc-parser/binding-linux-s390x-gnu@0.127.0': + optional: true + '@oxc-parser/binding-linux-s390x-gnu@0.150.0': optional: true + '@oxc-parser/binding-linux-s390x-gnu@0.153.0': + optional: true + + '@oxc-parser/binding-linux-x64-gnu@0.127.0': + optional: true + '@oxc-parser/binding-linux-x64-gnu@0.150.0': optional: true + '@oxc-parser/binding-linux-x64-gnu@0.153.0': + optional: true + + '@oxc-parser/binding-linux-x64-musl@0.127.0': + optional: true + '@oxc-parser/binding-linux-x64-musl@0.150.0': optional: true + '@oxc-parser/binding-linux-x64-musl@0.153.0': + optional: true + + '@oxc-parser/binding-openharmony-arm64@0.127.0': + optional: true + '@oxc-parser/binding-openharmony-arm64@0.150.0': optional: true + '@oxc-parser/binding-openharmony-arm64@0.153.0': + optional: true + + '@oxc-parser/binding-wasm32-wasi@0.127.0': + dependencies: + '@emnapi/core': 1.9.2 + '@emnapi/runtime': 1.9.2 + '@napi-rs/wasm-runtime': 1.2.5(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + optional: true + + '@oxc-parser/binding-win32-arm64-msvc@0.127.0': + optional: true + '@oxc-parser/binding-win32-arm64-msvc@0.150.0': optional: true + '@oxc-parser/binding-win32-arm64-msvc@0.153.0': + optional: true + + '@oxc-parser/binding-win32-ia32-msvc@0.127.0': + optional: true + '@oxc-parser/binding-win32-ia32-msvc@0.150.0': optional: true + '@oxc-parser/binding-win32-ia32-msvc@0.153.0': + optional: true + + '@oxc-parser/binding-win32-x64-msvc@0.127.0': + optional: true + '@oxc-parser/binding-win32-x64-msvc@0.150.0': optional: true + '@oxc-parser/binding-win32-x64-msvc@0.153.0': + optional: true + + '@oxc-project/types@0.127.0': {} + '@oxc-project/types@0.146.0': {} '@oxc-project/types@0.150.0': {} '@oxc-project/types@0.152.0': {} - '@oxlint-tsgolint/darwin-arm64@7.0.2001': + '@oxc-project/types@0.153.0': {} + + '@oxc-resolver/binding-android-arm-eabi@11.21.2': + optional: true + + '@oxc-resolver/binding-android-arm64@11.21.2': + optional: true + + '@oxc-resolver/binding-darwin-arm64@11.21.2': + optional: true + + '@oxc-resolver/binding-darwin-x64@11.21.2': + optional: true + + '@oxc-resolver/binding-freebsd-x64@11.21.2': optional: true - '@oxlint-tsgolint/darwin-x64@7.0.2001': + '@oxc-resolver/binding-linux-arm-gnueabihf@11.21.2': optional: true - '@oxlint-tsgolint/linux-arm64@7.0.2001': + '@oxc-resolver/binding-linux-arm-musleabihf@11.21.2': optional: true - '@oxlint-tsgolint/linux-x64@7.0.2001': + '@oxc-resolver/binding-linux-arm64-gnu@11.21.2': optional: true - '@oxlint-tsgolint/win32-arm64@7.0.2001': + '@oxc-resolver/binding-linux-arm64-musl@11.21.2': optional: true - '@oxlint-tsgolint/win32-x64@7.0.2001': + '@oxc-resolver/binding-linux-ppc64-gnu@11.21.2': optional: true - '@oxlint/binding-android-arm-eabi@1.82.0': + '@oxc-resolver/binding-linux-riscv64-gnu@11.21.2': optional: true - '@oxlint/binding-android-arm64@1.82.0': + '@oxc-resolver/binding-linux-riscv64-musl@11.21.2': optional: true - '@oxlint/binding-darwin-arm64@1.82.0': + '@oxc-resolver/binding-linux-s390x-gnu@11.21.2': optional: true - '@oxlint/binding-darwin-x64@1.82.0': + '@oxc-resolver/binding-linux-x64-gnu@11.21.2': optional: true - '@oxlint/binding-freebsd-x64@1.82.0': + '@oxc-resolver/binding-linux-x64-musl@11.21.2': optional: true - '@oxlint/binding-linux-arm-gnueabihf@1.82.0': + '@oxc-resolver/binding-openharmony-arm64@11.21.2': optional: true - '@oxlint/binding-linux-arm-musleabihf@1.82.0': + '@oxc-resolver/binding-wasm32-wasi@11.21.2': + dependencies: + '@emnapi/core': 1.11.0 + '@emnapi/runtime': 1.11.0 + '@napi-rs/wasm-runtime': 1.2.5(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0) + optional: true + + '@oxc-resolver/binding-win32-arm64-msvc@11.21.2': + optional: true + + '@oxc-resolver/binding-win32-x64-msvc@11.21.2': + optional: true + + '@oxlint-tsgolint/darwin-arm64@7.0.2003': + optional: true + + '@oxlint-tsgolint/darwin-x64@7.0.2003': + optional: true + + '@oxlint-tsgolint/linux-arm64@7.0.2003': + optional: true + + '@oxlint-tsgolint/linux-x64@7.0.2003': + optional: true + + '@oxlint-tsgolint/win32-arm64@7.0.2003': + optional: true + + '@oxlint-tsgolint/win32-x64@7.0.2003': + optional: true + + '@oxlint/binding-android-arm-eabi@1.87.0': + optional: true + + '@oxlint/binding-android-arm64@1.87.0': + optional: true + + '@oxlint/binding-darwin-arm64@1.87.0': + optional: true + + '@oxlint/binding-darwin-x64@1.87.0': + optional: true + + '@oxlint/binding-freebsd-x64@1.87.0': + optional: true + + '@oxlint/binding-linux-arm-gnueabihf@1.87.0': + optional: true + + '@oxlint/binding-linux-arm-musleabihf@1.87.0': optional: true - '@oxlint/binding-linux-arm64-gnu@1.82.0': + '@oxlint/binding-linux-arm64-gnu@1.87.0': optional: true - '@oxlint/binding-linux-arm64-musl@1.82.0': + '@oxlint/binding-linux-arm64-musl@1.87.0': optional: true - '@oxlint/binding-linux-ppc64-gnu@1.82.0': + '@oxlint/binding-linux-ppc64-gnu@1.87.0': optional: true - '@oxlint/binding-linux-riscv64-gnu@1.82.0': + '@oxlint/binding-linux-riscv64-gnu@1.87.0': optional: true - '@oxlint/binding-linux-riscv64-musl@1.82.0': + '@oxlint/binding-linux-riscv64-musl@1.87.0': optional: true - '@oxlint/binding-linux-s390x-gnu@1.82.0': + '@oxlint/binding-linux-s390x-gnu@1.87.0': optional: true - '@oxlint/binding-linux-x64-gnu@1.82.0': + '@oxlint/binding-linux-x64-gnu@1.87.0': optional: true - '@oxlint/binding-linux-x64-musl@1.82.0': + '@oxlint/binding-linux-x64-musl@1.87.0': optional: true - '@oxlint/binding-openharmony-arm64@1.82.0': + '@oxlint/binding-openharmony-arm64@1.87.0': optional: true - '@oxlint/binding-win32-arm64-msvc@1.82.0': + '@oxlint/binding-win32-arm64-msvc@1.87.0': optional: true - '@oxlint/binding-win32-ia32-msvc@1.82.0': + '@oxlint/binding-win32-ia32-msvc@1.87.0': optional: true - '@oxlint/binding-win32-x64-msvc@1.82.0': + '@oxlint/binding-win32-x64-msvc@1.87.0': optional: true - '@oxlint/plugins@1.82.0': {} + '@oxlint/plugins@1.87.0': {} + + '@pnpm/cli.meta@1100.1.2': + dependencies: + '@pnpm/types': 1102.1.1 + empathic: 2.1.0 + load-json-file: 7.0.1 + + '@pnpm/cli.utils@1101.0.32(@pnpm/logger@1100.0.0)': + dependencies: + '@pnpm/cli.meta': 1100.1.2 + '@pnpm/config.package-is-installable': 1100.2.3(@pnpm/logger@1100.0.0) + '@pnpm/error': 1100.2.2 + '@pnpm/logger': 1100.0.0 + '@pnpm/pkg-manifest.utils': 1100.4.9(@pnpm/logger@1100.0.0) + '@pnpm/types': 1102.1.1 + '@pnpm/workspace.project-manifest-reader': 1100.1.3(@pnpm/logger@1100.0.0) + chalk: 6.0.1 + load-json-file: 7.0.1 + + '@pnpm/config.package-is-installable@1100.2.3(@pnpm/logger@1100.0.0)': + dependencies: + '@pnpm/cli.meta': 1100.1.2 + '@pnpm/core-loggers': 1101.0.2(@pnpm/logger@1100.0.0) + '@pnpm/engine.runtime.system-version': 1100.0.13 + '@pnpm/error': 1100.2.2 + '@pnpm/logger': 1100.0.0 + '@pnpm/types': 1102.1.1 + detect-libc: 2.1.2 + execa: safe-execa@0.3.0 + memoize: 11.0.0 + semver: 7.8.5 + + '@pnpm/constants@1102.0.1': {} + + '@pnpm/core-loggers@1101.0.2(@pnpm/logger@1100.0.0)': + dependencies: + '@pnpm/logger': 1100.0.0 + '@pnpm/types': 1102.1.1 + + '@pnpm/deps.peer-range@1100.1.2': + dependencies: + semver: 7.8.5 + + '@pnpm/engine.runtime.system-version@1100.0.13': + dependencies: + '@pnpm/cli.meta': 1100.1.2 + '@pnpm/types': 1102.1.1 + execa: safe-execa@0.3.0 + memoize: 11.0.0 + + '@pnpm/error@1100.2.2': + dependencies: + '@pnpm/constants': 1102.0.1 + + '@pnpm/fs.graceful-fs@1100.2.5': + dependencies: + '@pnpm/error': 1100.2.2 + graceful-fs: 4.2.11 + + '@pnpm/logger@1100.0.0': + dependencies: + bole: 5.0.29 + split2: 4.2.0 + + '@pnpm/pkg-manifest.utils@1100.4.9(@pnpm/logger@1100.0.0)': + dependencies: + '@pnpm/core-loggers': 1101.0.2(@pnpm/logger@1100.0.0) + '@pnpm/deps.peer-range': 1100.1.2 + '@pnpm/error': 1100.2.2 + '@pnpm/logger': 1100.0.0 + '@pnpm/types': 1102.1.1 + semver: 7.8.5 + semver-utils: 1.1.4 + + '@pnpm/text.comments-parser@1100.0.1': + dependencies: + strip-comments-strings: 1.2.0 + + '@pnpm/text.ordinal-comparator@1100.0.0': {} + + '@pnpm/types@1102.1.1': {} + + '@pnpm/workspace.package-patterns@1100.0.2': + dependencies: + micromatch: 4.0.8 + + '@pnpm/workspace.project-manifest-reader@1100.1.3(@pnpm/logger@1100.0.0)': + dependencies: + '@pnpm/error': 1100.2.2 + '@pnpm/fs.graceful-fs': 1100.2.5 + '@pnpm/logger': 1100.0.0 + '@pnpm/pkg-manifest.utils': 1100.4.9(@pnpm/logger@1100.0.0) + '@pnpm/text.comments-parser': 1100.0.1 + '@pnpm/types': 1102.1.1 + '@pnpm/workspace.project-manifest-writer': 1100.0.20 + detect-indent: 7.0.2 + fast-deep-equal: 3.1.3 + is-windows: 1.0.2 + json5: 2.2.3 + p-limit: 7.3.3 + parse-json: 8.3.0 + read-yaml-file: 3.0.0 + strip-bom: 5.0.0 + + '@pnpm/workspace.project-manifest-writer@1100.0.20': + dependencies: + '@pnpm/error': 1100.2.2 + '@pnpm/text.comments-parser': 1100.0.1 + '@pnpm/types': 1102.1.1 + '@pnpm/yaml.document-sync': 1100.0.2 + json5: 2.2.3 + write-file-atomic: 7.0.1 + yaml: 2.9.1 + + '@pnpm/workspace.projects-reader@1101.1.4(@pnpm/logger@1100.0.0)': + dependencies: + '@pnpm/cli.utils': 1101.0.32(@pnpm/logger@1100.0.0) + '@pnpm/constants': 1102.0.1 + '@pnpm/error': 1100.2.2 + '@pnpm/logger': 1100.0.0 + '@pnpm/text.ordinal-comparator': 1100.0.0 + '@pnpm/types': 1102.1.1 + '@pnpm/workspace.package-patterns': 1100.0.2 + '@pnpm/workspace.project-manifest-reader': 1100.1.3(@pnpm/logger@1100.0.0) + p-filter: 4.1.0 + tinyglobby: 0.2.17 + + '@pnpm/workspace.root-finder@1100.1.3': + dependencies: + '@pnpm/constants': 1102.0.1 + '@pnpm/error': 1100.2.2 + '@pnpm/workspace.package-patterns': 1100.0.2 + '@pnpm/workspace.workspace-manifest-reader': 1100.2.2 + empathic: 2.1.0 + + '@pnpm/workspace.workspace-manifest-reader@1100.2.2': + dependencies: + '@pnpm/constants': 1102.0.1 + '@pnpm/error': 1100.2.2 + '@pnpm/types': 1102.1.1 + read-yaml-file: 3.0.0 + + '@pnpm/yaml.document-sync@1100.0.2': + dependencies: + yaml: 2.9.1 + + '@polka/url@1.0.0-next.29': {} '@poppinss/colors@4.1.6': dependencies: @@ -6005,6 +8501,10 @@ snapshots: optionalDependencies: yauzl: 3.4.0 + '@quansync/fs@1.1.0': + dependencies: + quansync: 1.0.0 + '@redis/bloom@6.2.1(@redis/client@6.2.1(@opentelemetry/api@1.9.1))': dependencies: '@redis/client': 6.2.1(@opentelemetry/api@1.9.1) @@ -6117,128 +8617,314 @@ snapshots: '@rolldown/binding-win32-x64-msvc@1.2.5': optional: true - '@rolldown/plugin-babel@0.2.4(@babel/core@8.0.6)(rolldown@1.2.12)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))': + '@rolldown/plugin-babel@0.2.4(@babel/core@8.0.6)(@babel/runtime@7.29.10)(rolldown@1.2.12)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))': dependencies: '@babel/core': 8.0.6 picomatch: 4.0.7 rolldown: 1.2.12 optionalDependencies: - vite: 8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1) + '@babel/runtime': 7.29.10 + vite: 8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1) + + '@rolldown/pluginutils@1.0.1': {} + + '@rollup/pluginutils@5.4.0': + dependencies: + '@types/estree': 1.0.9 + estree-walker: 2.0.2 + picomatch: 4.0.7 + + '@rushstack/node-core-library@5.25.1(@types/node@24.19.1)': + dependencies: + ajv: 8.20.0 + ajv-draft-04: 1.0.0(ajv@8.20.0) + ajv-formats: 3.0.1 + fs-extra: 11.3.6 + import-lazy: 4.0.0 + jju: 1.4.0 + resolve: 1.22.13 + semver: 7.7.4 + optionalDependencies: + '@types/node': 24.19.1 + + '@rushstack/problem-matcher@0.2.1(@types/node@24.19.1)': + optionalDependencies: + '@types/node': 24.19.1 + + '@rushstack/rig-package@0.7.4': + dependencies: + jju: 1.4.0 + resolve: 1.22.13 + + '@rushstack/terminal@0.24.7(@types/node@24.19.1)': + dependencies: + '@rushstack/node-core-library': 5.25.1(@types/node@24.19.1) + '@rushstack/problem-matcher': 0.2.1(@types/node@24.19.1) + supports-color: 8.1.1 + optionalDependencies: + '@types/node': 24.19.1 + + '@rushstack/ts-command-line@5.3.17(@types/node@24.19.1)': + dependencies: + '@rushstack/terminal': 0.24.7(@types/node@24.19.1) + '@types/argparse': 1.0.38 + argparse: 1.0.10 + string-argv: 0.3.2 + transitivePeerDependencies: + - '@types/node' + + '@sec-ant/readable-stream@0.4.1': {} + + '@simple-libs/child-process-utils@2.0.0': + dependencies: + '@simple-libs/stream-utils': 2.0.0 + + '@simple-libs/stream-utils@2.0.0': {} + + '@sindresorhus/is@7.2.0': {} + + '@sindresorhus/merge-streams@4.0.0': {} + + '@smithy/types@4.19.0': + dependencies: + tslib: 2.8.1 + + '@speed-highlight/core@1.2.24': {} + + '@storybook/addon-vitest@10.6.2(@vitest/browser-playwright@5.0.5)(@vitest/browser@5.0.5(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))(vitest@5.0.5))(react@19.3.0)(storybook@10.6.2(@types/react@19.3.0)(prettier@3.9.9)(react@19.3.0))(vitest@5.0.5)': + dependencies: + '@storybook/global': 5.0.0 + '@storybook/icons': 2.2.0(react@19.3.0) + storybook: 10.6.2(@types/react@19.3.0)(prettier@3.9.9)(react@19.3.0) + optionalDependencies: + '@vitest/browser': 5.0.5(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))(vitest@5.0.5) + '@vitest/browser-playwright': 5.0.5(playwright@1.63.0)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))(vitest@5.0.5) + vitest: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@24.19.1)(@vitest/browser-playwright@5.0.5)(@vitest/coverage-istanbul@5.0.5)(@vitest/ui@5.0.5)(jsdom@30.1.2(@noble/hashes@2.4.0))(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) + transitivePeerDependencies: + - react + + '@storybook/builder-vite@10.6.2(storybook@10.6.2(@types/react@19.3.0)(prettier@3.9.9)(react@19.3.0))(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))': + dependencies: + storybook: 10.6.2(@types/react@19.3.0)(prettier@3.9.9)(react@19.3.0) + ts-dedent: 2.3.0 + vite: 8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1) + + '@storybook/global@5.0.0': {} + + '@storybook/icons@2.2.0(react@19.3.0)': + dependencies: + react: 19.3.0 + + '@storybook/react-dom-shim@10.6.2(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(storybook@10.6.2(@types/react@19.3.0)(prettier@3.9.9)(react@19.3.0))': + dependencies: + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + storybook: 10.6.2(@types/react@19.3.0)(prettier@3.9.9)(react@19.3.0) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@storybook/react-vite@10.6.2(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(storybook@10.6.2(@types/react@19.3.0)(prettier@3.9.9)(react@19.3.0))(supports-color@10.2.2)(typescript@7.0.2)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))': + dependencies: + '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@7.0.2)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) + '@rollup/pluginutils': 5.4.0 + '@storybook/builder-vite': 10.6.2(storybook@10.6.2(@types/react@19.3.0)(prettier@3.9.9)(react@19.3.0))(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) + '@storybook/react': 10.6.2(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(storybook@10.6.2(@types/react@19.3.0)(prettier@3.9.9)(react@19.3.0))(supports-color@10.2.2)(typescript@7.0.2) + empathic: 2.0.1 + magic-string: 1.4.2 + react: 19.3.0 + react-docgen: 8.0.4(supports-color@10.2.2) + react-dom: 19.3.0(react@19.3.0) + resolve: 1.22.13 + storybook: 10.6.2(@types/react@19.3.0)(prettier@3.9.9)(react@19.3.0) + tsconfig-paths: 4.2.0 + vite: 8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1) + optionalDependencies: + typescript: 7.0.2 + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + - rollup + - supports-color + + '@storybook/react@10.6.2(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(storybook@10.6.2(@types/react@19.3.0)(prettier@3.9.9)(react@19.3.0))(supports-color@10.2.2)(typescript@7.0.2)': + dependencies: + '@storybook/global': 5.0.0 + '@storybook/react-dom-shim': 10.6.2(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(storybook@10.6.2(@types/react@19.3.0)(prettier@3.9.9)(react@19.3.0)) + react: 19.3.0 + react-docgen: 8.0.4(supports-color@10.2.2) + react-docgen-typescript: 2.4.0(typescript@7.0.2) + react-dom: 19.3.0(react@19.3.0) + storybook: 10.6.2(@types/react@19.3.0)(prettier@3.9.9)(react@19.3.0) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + typescript: 7.0.2 + transitivePeerDependencies: + - supports-color - '@rolldown/pluginutils@1.0.1': {} + '@systemfsoftware/arethetypeswrong-cli@file:.sfs-deps/arethetypeswrong-cli-4.2.0.tgz': {} - '@simple-libs/child-process-utils@2.0.0': + '@systemfsoftware/conformance-spec@file:.sfs-deps/conformance-spec-1.0.0.tgz(effect@4.0.5)': dependencies: - '@simple-libs/stream-utils': 2.0.0 + '@systemfsoftware/effect-sim-kernel': file:.sfs-deps/effect-sim-kernel-1.0.0.tgz(effect@4.0.5) + effect: 4.0.5 - '@simple-libs/stream-utils@2.0.0': {} + '@systemfsoftware/differential-spec@file:.sfs-deps/differential-spec-1.0.1.tgz(redis@6.2.1(@opentelemetry/api@1.9.1))(vitest@5.0.5)': + dependencies: + '@systemfsoftware/effect-cell-types': file:.sfs-deps/effect-cell-types-12.0.0.tgz(effect@4.0.5) + '@systemfsoftware/effect-sim-kernel': file:.sfs-deps/effect-sim-kernel-1.0.0.tgz(effect@4.0.5) + '@systemfsoftware/vitest': file:.sfs-deps/vitest-2.0.0.tgz(effect@4.0.5)(redis@6.2.1(@opentelemetry/api@1.9.1))(vitest@5.0.5) + effect: 4.0.5 + fast-check: 4.10.2 + transitivePeerDependencies: + - bufferutil + - redis + - utf-8-validate + - vitest - '@sindresorhus/is@7.2.0': {} + '@systemfsoftware/effect-cell-types@file:.sfs-deps/effect-cell-types-12.0.0.tgz(effect@4.0.5)': + dependencies: + effect: 4.0.5 - '@smithy/types@4.19.0': + '@systemfsoftware/effect-gherkin-spec@file:.sfs-deps/effect-gherkin-spec-7.0.1.tgz(@systemfsoftware/vitest@file:.sfs-deps/vitest-2.0.0.tgz(effect@4.0.5)(redis@6.2.1(@opentelemetry/api@1.9.1))(vitest@5.0.5))(effect@4.0.5)(vitest@5.0.5)': dependencies: - tslib: 2.8.1 + '@systemfsoftware/effect-spec-runtime': file:.sfs-deps/effect-spec-runtime-1.0.1.tgz(@systemfsoftware/vitest@file:.sfs-deps/vitest-2.0.0.tgz(effect@4.0.5)(redis@6.2.1(@opentelemetry/api@1.9.1))(vitest@5.0.5))(effect@4.0.5)(vitest@5.0.5) + '@systemfsoftware/vitest': file:.sfs-deps/vitest-2.0.0.tgz(effect@4.0.5)(redis@6.2.1(@opentelemetry/api@1.9.1))(vitest@5.0.5) + effect: 4.0.5 + vitest: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@24.19.1)(@vitest/browser-playwright@5.0.5)(@vitest/coverage-istanbul@5.0.5)(@vitest/ui@5.0.5)(jsdom@30.1.2(@noble/hashes@2.4.0))(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) - '@speed-highlight/core@1.2.24': {} + '@systemfsoftware/effect-schema-discovery@file:.sfs-deps/effect-schema-discovery-2.0.0.tgz': + dependencies: + '@oxc-project/types': 0.153.0 + oxc-parser: 0.153.0 + + '@systemfsoftware/effect-schema-law@file:.sfs-deps/effect-schema-law-5.0.1.tgz(@systemfsoftware/vitest@file:.sfs-deps/vitest-2.0.0.tgz(effect@4.0.5)(redis@6.2.1(@opentelemetry/api@1.9.1))(vitest@5.0.5))(effect@4.0.5)(vitest@5.0.5)': + dependencies: + '@systemfsoftware/vitest': file:.sfs-deps/vitest-2.0.0.tgz(effect@4.0.5)(redis@6.2.1(@opentelemetry/api@1.9.1))(vitest@5.0.5) + effect: 4.0.5 + vitest: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@24.19.1)(@vitest/browser-playwright@5.0.5)(@vitest/coverage-istanbul@5.0.5)(@vitest/ui@5.0.5)(jsdom@30.1.2(@noble/hashes@2.4.0))(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) - '@systemfsoftware/effect-cell-types@file:.sfs-deps/effect-cell-types-12.0.0.tgz(effect@4.0.1)': + '@systemfsoftware/effect-schema-recursion-budget@file:.sfs-deps/effect-schema-recursion-budget-2.0.0.tgz(effect@4.0.5)': dependencies: - effect: 4.0.1 + '@oxc-project/types': 0.153.0 + effect: 4.0.5 + oxc-parser: 0.153.0 - '@systemfsoftware/effect-gherkin-spec@file:.sfs-deps/effect-gherkin-spec-7.0.1.tgz(@systemfsoftware/vitest@file:.sfs-deps/vitest-2.0.0.tgz(effect@4.0.1)(redis@6.2.1(@opentelemetry/api@1.9.1))(vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.19.1)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))))(effect@4.0.1)(vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.19.1)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)))': + '@systemfsoftware/effect-schema-vite@file:.sfs-deps/effect-schema-vite-3.0.1.tgz(@systemfsoftware/effect-schema-law@file:.sfs-deps/effect-schema-law-5.0.1.tgz(@systemfsoftware/vitest@file:.sfs-deps/vitest-2.0.0.tgz(effect@4.0.5)(redis@6.2.1(@opentelemetry/api@1.9.1))(vitest@5.0.5))(effect@4.0.5)(vitest@5.0.5))(effect@4.0.5)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))(vitest@5.0.5)': dependencies: - '@systemfsoftware/effect-spec-runtime': file:.sfs-deps/effect-spec-runtime-1.0.1.tgz(@systemfsoftware/vitest@file:.sfs-deps/vitest-2.0.0.tgz(effect@4.0.1)(redis@6.2.1(@opentelemetry/api@1.9.1))(vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.19.1)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))))(effect@4.0.1)(vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.19.1)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))) - '@systemfsoftware/vitest': file:.sfs-deps/vitest-2.0.0.tgz(effect@4.0.1)(redis@6.2.1(@opentelemetry/api@1.9.1))(vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.19.1)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))) - effect: 4.0.1 - vitest: 5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.19.1)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) + '@oxc-project/types': 0.153.0 + '@systemfsoftware/effect-schema-discovery': file:.sfs-deps/effect-schema-discovery-2.0.0.tgz + '@systemfsoftware/effect-schema-law': file:.sfs-deps/effect-schema-law-5.0.1.tgz(@systemfsoftware/vitest@file:.sfs-deps/vitest-2.0.0.tgz(effect@4.0.5)(redis@6.2.1(@opentelemetry/api@1.9.1))(vitest@5.0.5))(effect@4.0.5)(vitest@5.0.5) + '@systemfsoftware/effect-schema-recursion-budget': file:.sfs-deps/effect-schema-recursion-budget-2.0.0.tgz(effect@4.0.5) + effect: 4.0.5 + oxc-parser: 0.153.0 + vite: 8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1) + vitest: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@24.19.1)(@vitest/browser-playwright@5.0.5)(@vitest/coverage-istanbul@5.0.5)(@vitest/ui@5.0.5)(jsdom@30.1.2(@noble/hashes@2.4.0))(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) - '@systemfsoftware/effect-sim-kernel@file:.sfs-deps/effect-sim-kernel-1.0.0.tgz(effect@4.0.1)': + '@systemfsoftware/effect-sim-kernel@file:.sfs-deps/effect-sim-kernel-1.0.0.tgz(effect@4.0.5)': dependencies: - effect: 4.0.1 + effect: 4.0.5 - '@systemfsoftware/effect-spec-runtime@file:.sfs-deps/effect-spec-runtime-1.0.1.tgz(@systemfsoftware/vitest@file:.sfs-deps/vitest-2.0.0.tgz(effect@4.0.1)(redis@6.2.1(@opentelemetry/api@1.9.1))(vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.19.1)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))))(effect@4.0.1)(vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.19.1)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)))': + '@systemfsoftware/effect-spec-runtime@file:.sfs-deps/effect-spec-runtime-1.0.1.tgz(@systemfsoftware/vitest@file:.sfs-deps/vitest-2.0.0.tgz(effect@4.0.5)(redis@6.2.1(@opentelemetry/api@1.9.1))(vitest@5.0.5))(effect@4.0.5)(vitest@5.0.5)': dependencies: - '@systemfsoftware/effect-sim-kernel': file:.sfs-deps/effect-sim-kernel-1.0.0.tgz(effect@4.0.1) - '@systemfsoftware/vitest': file:.sfs-deps/vitest-2.0.0.tgz(effect@4.0.1)(redis@6.2.1(@opentelemetry/api@1.9.1))(vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.19.1)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))) - effect: 4.0.1 - vitest: 5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.19.1)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) + '@systemfsoftware/effect-sim-kernel': file:.sfs-deps/effect-sim-kernel-1.0.0.tgz(effect@4.0.5) + '@systemfsoftware/vitest': file:.sfs-deps/vitest-2.0.0.tgz(effect@4.0.5)(redis@6.2.1(@opentelemetry/api@1.9.1))(vitest@5.0.5) + effect: 4.0.5 + vitest: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@24.19.1)(@vitest/browser-playwright@5.0.5)(@vitest/coverage-istanbul@5.0.5)(@vitest/ui@5.0.5)(jsdom@30.1.2(@noble/hashes@2.4.0))(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) - '@systemfsoftware/oxlint-config-cell-architecture@file:.sfs-deps/oxlint-config-cell-architecture-4.0.0.tgz(effect@4.0.1)(oxlint@1.82.0(oxlint-tsgolint@7.0.2001))(typescript@7.0.2)': + '@systemfsoftware/oxlint-config-cell-architecture@file:.sfs-deps/oxlint-config-cell-architecture-4.0.0.tgz(effect@4.0.5)(oxlint@1.87.0(oxlint-tsgolint@7.0.2003))(typescript@7.0.2)': dependencies: - '@systemfsoftware/oxlint-plugin-cell-architecture': file:.sfs-deps/oxlint-plugin-cell-architecture-5.0.0.tgz(effect@4.0.1)(typescript@7.0.2) - effect: 4.0.1 - oxlint: 1.82.0(oxlint-tsgolint@7.0.2001) + '@systemfsoftware/oxlint-config-rule-authoring': file:.sfs-deps/oxlint-config-rule-authoring-1.1.0.tgz(oxlint@1.87.0(oxlint-tsgolint@7.0.2003))(typescript@7.0.2) + '@systemfsoftware/oxlint-plugin-cell-architecture': file:.sfs-deps/oxlint-plugin-cell-architecture-5.0.0.tgz(effect@4.0.5)(typescript@7.0.2) + effect: 4.0.5 + oxlint: 1.87.0(oxlint-tsgolint@7.0.2003) typescript: 7.0.2 - '@systemfsoftware/oxlint-config-dmmf@file:.sfs-deps/oxlint-config-dmmf-2.0.0.tgz(effect@4.0.1)(oxlint-tsgolint@7.0.2001)(oxlint@1.82.0(oxlint-tsgolint@7.0.2001))(typescript@7.0.2)': + '@systemfsoftware/oxlint-config-dmmf@file:.sfs-deps/oxlint-config-dmmf-2.0.0.tgz(effect@4.0.5)(oxlint-tsgolint@7.0.2003)(oxlint@1.87.0(oxlint-tsgolint@7.0.2003))(typescript@7.0.2)': dependencies: - '@systemfsoftware/oxlint-plugin-dmmf-workflow': file:.sfs-deps/oxlint-plugin-dmmf-workflow-7.0.0.tgz(effect@4.0.1)(oxlint-tsgolint@7.0.2001)(typescript@7.0.2) - '@systemfsoftware/oxlint-plugin-effect-schema': file:.sfs-deps/oxlint-plugin-effect-schema-6.0.0.tgz(effect@4.0.1)(typescript@7.0.2) - effect: 4.0.1 - oxlint: 1.82.0(oxlint-tsgolint@7.0.2001) - oxlint-tsgolint: 7.0.2001 + '@systemfsoftware/oxlint-config-rule-authoring': file:.sfs-deps/oxlint-config-rule-authoring-1.1.0.tgz(oxlint@1.87.0(oxlint-tsgolint@7.0.2003))(typescript@7.0.2) + '@systemfsoftware/oxlint-plugin-dmmf-workflow': file:.sfs-deps/oxlint-plugin-dmmf-workflow-7.0.0.tgz(effect@4.0.5)(oxlint-tsgolint@7.0.2003)(typescript@7.0.2) + '@systemfsoftware/oxlint-plugin-effect-schema': file:.sfs-deps/oxlint-plugin-effect-schema-6.0.0.tgz(effect@4.0.5)(typescript@7.0.2) + effect: 4.0.5 + oxlint: 1.87.0(oxlint-tsgolint@7.0.2003) + oxlint-tsgolint: 7.0.2003 typescript: 7.0.2 transitivePeerDependencies: - vite-plus - '@systemfsoftware/oxlint-config-recommended@file:.sfs-deps/oxlint-config-recommended-4.0.0.tgz(effect@4.0.1)(oxlint-tsgolint@7.0.2001)(oxlint@1.82.0(oxlint-tsgolint@7.0.2001))(typescript@7.0.2)': - dependencies: - '@effect/tsgo': 0.50.0 - '@systemfsoftware/oxlint-config-cell-architecture': file:.sfs-deps/oxlint-config-cell-architecture-4.0.0.tgz(effect@4.0.1)(oxlint@1.82.0(oxlint-tsgolint@7.0.2001))(typescript@7.0.2) - '@systemfsoftware/oxlint-config-dmmf': file:.sfs-deps/oxlint-config-dmmf-2.0.0.tgz(effect@4.0.1)(oxlint-tsgolint@7.0.2001)(oxlint@1.82.0(oxlint-tsgolint@7.0.2001))(typescript@7.0.2) - '@systemfsoftware/oxlint-plugin-effect-platform': file:.sfs-deps/oxlint-plugin-effect-platform-2.0.0.tgz(effect@4.0.1)(oxlint-tsgolint@7.0.2001)(typescript@7.0.2) - '@systemfsoftware/oxlint-plugin-test-discipline': file:.sfs-deps/oxlint-plugin-test-discipline-5.0.0.tgz(effect@4.0.1)(typescript@7.0.2) - effect: 4.0.1 - oxlint: 1.82.0(oxlint-tsgolint@7.0.2001) - oxlint-tsgolint: 7.0.2001 + '@systemfsoftware/oxlint-config-recommended@file:.sfs-deps/oxlint-config-recommended-4.0.0.tgz(effect@4.0.5)(oxlint-tsgolint@7.0.2003)(oxlint@1.87.0(oxlint-tsgolint@7.0.2003))(typescript@7.0.2)': + dependencies: + '@effect/tsgo': 0.51.1 + '@systemfsoftware/oxlint-config-cell-architecture': file:.sfs-deps/oxlint-config-cell-architecture-4.0.0.tgz(effect@4.0.5)(oxlint@1.87.0(oxlint-tsgolint@7.0.2003))(typescript@7.0.2) + '@systemfsoftware/oxlint-config-dmmf': file:.sfs-deps/oxlint-config-dmmf-2.0.0.tgz(effect@4.0.5)(oxlint-tsgolint@7.0.2003)(oxlint@1.87.0(oxlint-tsgolint@7.0.2003))(typescript@7.0.2) + '@systemfsoftware/oxlint-config-rule-authoring': file:.sfs-deps/oxlint-config-rule-authoring-1.1.0.tgz(oxlint@1.87.0(oxlint-tsgolint@7.0.2003))(typescript@7.0.2) + '@systemfsoftware/oxlint-plugin-effect-platform': file:.sfs-deps/oxlint-plugin-effect-platform-2.0.0.tgz(effect@4.0.5)(oxlint-tsgolint@7.0.2003)(typescript@7.0.2) + '@systemfsoftware/oxlint-plugin-test-discipline': file:.sfs-deps/oxlint-plugin-test-discipline-5.0.0.tgz(effect@4.0.5)(typescript@7.0.2) + effect: 4.0.5 + oxlint: 1.87.0(oxlint-tsgolint@7.0.2003) + oxlint-tsgolint: 7.0.2003 typescript: 7.0.2 transitivePeerDependencies: - vite-plus - '@systemfsoftware/oxlint-plugin-cell-architecture@file:.sfs-deps/oxlint-plugin-cell-architecture-5.0.0.tgz(effect@4.0.1)(typescript@7.0.2)': + '@systemfsoftware/oxlint-config-rule-authoring@file:.sfs-deps/oxlint-config-rule-authoring-1.1.0.tgz(oxlint@1.87.0(oxlint-tsgolint@7.0.2003))(typescript@7.0.2)': dependencies: - '@oxc-project/types': 0.150.0 - '@oxlint/plugins': 1.82.0 - effect: 4.0.1 + oxlint: 1.87.0(oxlint-tsgolint@7.0.2003) typescript: 7.0.2 - '@systemfsoftware/oxlint-plugin-dmmf-workflow@file:.sfs-deps/oxlint-plugin-dmmf-workflow-7.0.0.tgz(effect@4.0.1)(oxlint-tsgolint@7.0.2001)(typescript@7.0.2)': + '@systemfsoftware/oxlint-plugin-cell-architecture@file:.sfs-deps/oxlint-plugin-cell-architecture-5.0.0.tgz(effect@4.0.5)(typescript@7.0.2)': dependencies: - '@oxc-project/types': 0.150.0 - '@oxlint/plugins': 1.82.0 - effect: 4.0.1 - oxlint: 1.82.0(oxlint-tsgolint@7.0.2001) + '@oxc-project/types': 0.153.0 + '@oxlint/plugins': 1.87.0 + effect: 4.0.5 + typescript: 7.0.2 + + '@systemfsoftware/oxlint-plugin-dmmf-workflow@file:.sfs-deps/oxlint-plugin-dmmf-workflow-7.0.0.tgz(effect@4.0.5)(oxlint-tsgolint@7.0.2003)(typescript@7.0.2)': + dependencies: + '@oxc-project/types': 0.153.0 + '@oxlint/plugins': 1.87.0 + effect: 4.0.5 + oxlint: 1.87.0(oxlint-tsgolint@7.0.2003) typescript: 7.0.2 transitivePeerDependencies: - oxlint-tsgolint - vite-plus - '@systemfsoftware/oxlint-plugin-effect-platform@file:.sfs-deps/oxlint-plugin-effect-platform-2.0.0.tgz(effect@4.0.1)(oxlint-tsgolint@7.0.2001)(typescript@7.0.2)': + '@systemfsoftware/oxlint-plugin-effect-platform@file:.sfs-deps/oxlint-plugin-effect-platform-2.0.0.tgz(effect@4.0.5)(oxlint-tsgolint@7.0.2003)(typescript@7.0.2)': dependencies: - '@oxc-project/types': 0.150.0 - '@oxlint/plugins': 1.82.0 - effect: 4.0.1 - oxlint: 1.82.0(oxlint-tsgolint@7.0.2001) + '@oxc-project/types': 0.153.0 + '@oxlint/plugins': 1.87.0 + effect: 4.0.5 + oxlint: 1.87.0(oxlint-tsgolint@7.0.2003) typescript: 7.0.2 transitivePeerDependencies: - oxlint-tsgolint - vite-plus - '@systemfsoftware/oxlint-plugin-effect-schema@file:.sfs-deps/oxlint-plugin-effect-schema-6.0.0.tgz(effect@4.0.1)(typescript@7.0.2)': + '@systemfsoftware/oxlint-plugin-effect-schema@file:.sfs-deps/oxlint-plugin-effect-schema-6.0.0.tgz(effect@4.0.5)(typescript@7.0.2)': dependencies: - '@oxc-project/types': 0.150.0 - '@oxlint/plugins': 1.82.0 - effect: 4.0.1 + '@oxc-project/types': 0.153.0 + '@oxlint/plugins': 1.87.0 + '@pnpm/logger': 1100.0.0 + '@pnpm/workspace.projects-reader': 1101.1.4(@pnpm/logger@1100.0.0) + '@pnpm/workspace.root-finder': 1100.1.3 + '@pnpm/workspace.workspace-manifest-reader': 1100.2.2 + effect: 4.0.5 typescript: 7.0.2 - '@systemfsoftware/oxlint-plugin-test-discipline@file:.sfs-deps/oxlint-plugin-test-discipline-5.0.0.tgz(effect@4.0.1)(typescript@7.0.2)': + '@systemfsoftware/oxlint-plugin-test-discipline@file:.sfs-deps/oxlint-plugin-test-discipline-5.0.0.tgz(effect@4.0.5)(typescript@7.0.2)': dependencies: - '@oxc-project/types': 0.150.0 - '@oxlint/plugins': 1.82.0 - effect: 4.0.1 - oxc-parser: 0.150.0 + '@oxc-project/types': 0.153.0 + '@oxlint/plugins': 1.87.0 + effect: 4.0.5 + oxc-parser: 0.153.0 typescript: 7.0.2 + '@systemfsoftware/stryker-config@file:.sfs-deps/stryker-config-0.1.0.tgz(@systemfsoftware/stryker-js@file:.sfs-deps/stryker-js-17.0.2.tgz(@opentelemetry/api-logs@0.222.0)(@opentelemetry/resources@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(effect@4.0.5)(redis@6.2.1(@opentelemetry/api@1.9.1))(supports-color@10.2.2)(vitest@5.0.5))': + optionalDependencies: + '@systemfsoftware/stryker-js': file:.sfs-deps/stryker-js-17.0.2.tgz(@opentelemetry/api-logs@0.222.0)(@opentelemetry/resources@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(effect@4.0.5)(redis@6.2.1(@opentelemetry/api@1.9.1))(supports-color@10.2.2)(vitest@5.0.5) + '@systemfsoftware/stryker-framework-interface@file:.sfs-deps/stryker-framework-interface-0.0.0.tgz': dependencies: '@systemfsoftware/stryker-ignorer-interface': file:.sfs-deps/stryker-ignorer-interface-0.1.1.tgz @@ -6250,6 +8936,13 @@ snapshots: transitivePeerDependencies: - rolldown + '@systemfsoftware/stryker-ignorer-in-source-vitest-block@file:.sfs-deps/stryker-ignorer-in-source-vitest-block-0.1.1.tgz(rolldown@1.2.12)': + dependencies: + '@systemfsoftware/stryker-ignorer-interface': file:.sfs-deps/stryker-ignorer-interface-0.1.1.tgz + '@systemfsoftware/stryker-ignorer-kit': file:.sfs-deps/stryker-ignorer-kit-0.1.1.tgz(rolldown@1.2.12) + transitivePeerDependencies: + - rolldown + '@systemfsoftware/stryker-ignorer-interface@file:.sfs-deps/stryker-ignorer-interface-0.1.1.tgz': {} '@systemfsoftware/stryker-ignorer-kit@file:.sfs-deps/stryker-ignorer-kit-0.1.1.tgz(rolldown@1.2.12)': @@ -6264,18 +8957,18 @@ snapshots: '@systemfsoftware/stryker-js-cli-contract@file:.sfs-deps/stryker-js-cli-contract-0.3.1.tgz': dependencies: '@systemfsoftware/stryker-js-plugin-interface': file:.sfs-deps/stryker-js-plugin-interface-14.0.0.tgz - effect: 4.0.1 + effect: 4.0.5 '@systemfsoftware/stryker-js-instrumenter@file:.sfs-deps/stryker-js-instrumenter-12.1.1.tgz(rolldown@1.2.12)': dependencies: '@eslint-community/regexpp': 4.12.2 '@noble/hashes': 2.4.0 '@oxc-project/types': 0.150.0 - '@systemfsoftware/effect-cell-types': file:.sfs-deps/effect-cell-types-12.0.0.tgz(effect@4.0.1) + '@systemfsoftware/effect-cell-types': file:.sfs-deps/effect-cell-types-12.0.0.tgz(effect@4.0.5) '@systemfsoftware/stryker-framework-interface': file:.sfs-deps/stryker-framework-interface-0.0.0.tgz '@systemfsoftware/stryker-js-cli-contract': file:.sfs-deps/stryker-js-cli-contract-0.3.1.tgz '@systemfsoftware/stryker-js-plugin-interface': file:.sfs-deps/stryker-js-plugin-interface-14.0.0.tgz - effect: 4.0.1 + effect: 4.0.5 oxc-parser: 0.150.0 oxc-walker: 1.1.1(@oxc-project/types@0.150.0)(oxc-parser@0.150.0)(rolldown@1.2.12) transitivePeerDependencies: @@ -6284,29 +8977,29 @@ snapshots: '@systemfsoftware/stryker-js-plugin-interface@file:.sfs-deps/stryker-js-plugin-interface-14.0.0.tgz': dependencies: '@systemfsoftware/stryker-ignorer-interface': file:.sfs-deps/stryker-ignorer-interface-0.1.1.tgz - effect: 4.0.1 + effect: 4.0.5 '@systemfsoftware/stryker-js-plugin-runtime@file:.sfs-deps/stryker-js-plugin-runtime-7.0.2.tgz': dependencies: '@opentelemetry/api': 1.9.1 - '@systemfsoftware/effect-cell-types': file:.sfs-deps/effect-cell-types-12.0.0.tgz(effect@4.0.1) + '@systemfsoftware/effect-cell-types': file:.sfs-deps/effect-cell-types-12.0.0.tgz(effect@4.0.5) '@systemfsoftware/stryker-js-cli-contract': file:.sfs-deps/stryker-js-cli-contract-0.3.1.tgz '@systemfsoftware/stryker-js-plugin-interface': file:.sfs-deps/stryker-js-plugin-interface-14.0.0.tgz - effect: 4.0.1 + effect: 4.0.5 '@systemfsoftware/stryker-js-typescript-checker@file:.sfs-deps/stryker-js-typescript-checker-8.2.1.tgz(@opentelemetry/api-logs@0.222.0)(@opentelemetry/api@1.9.1)(@opentelemetry/resources@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(rolldown@1.2.12)(supports-color@10.2.2)': dependencies: - '@effect/opentelemetry': 4.0.1(@opentelemetry/api-logs@0.222.0)(@opentelemetry/api@1.9.1)(@opentelemetry/resources@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(effect@4.0.1) + '@effect/opentelemetry': 4.0.1(@opentelemetry/api-logs@0.222.0)(@opentelemetry/api@1.9.1)(@opentelemetry/resources@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(effect@4.0.5) '@noble/hashes': 2.4.0 '@opentelemetry/exporter-trace-otlp-http': 0.222.0(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-node': 0.222.0(@opentelemetry/api@1.9.1)(supports-color@10.2.2) '@opentelemetry/sdk-trace-base': 2.11.0(@opentelemetry/api@1.9.1) - '@systemfsoftware/effect-cell-types': file:.sfs-deps/effect-cell-types-12.0.0.tgz(effect@4.0.1) + '@systemfsoftware/effect-cell-types': file:.sfs-deps/effect-cell-types-12.0.0.tgz(effect@4.0.5) '@systemfsoftware/stryker-js-cli-contract': file:.sfs-deps/stryker-js-cli-contract-0.3.1.tgz '@systemfsoftware/stryker-js-instrumenter': file:.sfs-deps/stryker-js-instrumenter-12.1.1.tgz(rolldown@1.2.12) '@systemfsoftware/stryker-js-plugin-interface': file:.sfs-deps/stryker-js-plugin-interface-14.0.0.tgz '@systemfsoftware/stryker-js-plugin-runtime': file:.sfs-deps/stryker-js-plugin-runtime-7.0.2.tgz - effect: 4.0.1 + effect: 4.0.5 typescript: 7.0.2 transitivePeerDependencies: - '@opentelemetry/api' @@ -6320,14 +9013,14 @@ snapshots: - rolldown - supports-color - '@systemfsoftware/stryker-js-vitest-runner@file:.sfs-deps/stryker-js-vitest-runner-8.1.1.tgz(@opentelemetry/api-logs@0.222.0)(@opentelemetry/api@1.9.1)(@opentelemetry/resources@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(effect@4.0.1)(supports-color@10.2.2)(vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.19.1)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)))': + '@systemfsoftware/stryker-js-vitest-runner@file:.sfs-deps/stryker-js-vitest-runner-8.1.1.tgz(@opentelemetry/api-logs@0.222.0)(@opentelemetry/api@1.9.1)(@opentelemetry/resources@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(effect@4.0.5)(supports-color@10.2.2)(vitest@5.0.5)': dependencies: - '@effect/opentelemetry': 4.0.1(@opentelemetry/api-logs@0.222.0)(@opentelemetry/api@1.9.1)(@opentelemetry/resources@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(effect@4.0.1) + '@effect/opentelemetry': 4.0.1(@opentelemetry/api-logs@0.222.0)(@opentelemetry/api@1.9.1)(@opentelemetry/resources@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(effect@4.0.5) '@opentelemetry/exporter-trace-otlp-http': 0.222.0(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-node': 0.222.0(@opentelemetry/api@1.9.1)(supports-color@10.2.2) '@opentelemetry/sdk-trace-base': 2.11.0(@opentelemetry/api@1.9.1) - '@systemfsoftware/effect-cell-types': file:.sfs-deps/effect-cell-types-12.0.0.tgz(effect@4.0.1) - vitest: 5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.19.1)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) + '@systemfsoftware/effect-cell-types': file:.sfs-deps/effect-cell-types-12.0.0.tgz(effect@4.0.5) + vitest: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@24.19.1)(@vitest/browser-playwright@5.0.5)(@vitest/coverage-istanbul@5.0.5)(@vitest/ui@5.0.5)(jsdom@30.1.2(@noble/hashes@2.4.0))(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) transitivePeerDependencies: - '@opentelemetry/api' - '@opentelemetry/api-logs' @@ -6340,20 +9033,20 @@ snapshots: - effect - supports-color - '@systemfsoftware/stryker-js@file:.sfs-deps/stryker-js-17.0.2.tgz(@opentelemetry/api-logs@0.222.0)(@opentelemetry/resources@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(effect@4.0.1)(redis@6.2.1(@opentelemetry/api@1.9.1))(supports-color@10.2.2)(vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.19.1)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)))': + '@systemfsoftware/stryker-js@file:.sfs-deps/stryker-js-17.0.2.tgz(@opentelemetry/api-logs@0.222.0)(@opentelemetry/resources@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(effect@4.0.5)(redis@6.2.1(@opentelemetry/api@1.9.1))(supports-color@10.2.2)(vitest@5.0.5)': dependencies: - '@effect/opentelemetry': 4.0.1(@opentelemetry/api-logs@0.222.0)(@opentelemetry/api@1.9.1)(@opentelemetry/resources@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(effect@4.0.1) - '@effect/platform-node': 4.0.1(effect@4.0.1)(redis@6.2.1(@opentelemetry/api@1.9.1)) - '@effect/platform-node-shared': 4.0.1(effect@4.0.1) + '@effect/opentelemetry': 4.0.1(@opentelemetry/api-logs@0.222.0)(@opentelemetry/api@1.9.1)(@opentelemetry/resources@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(effect@4.0.5) + '@effect/platform-node': 4.0.1(effect@4.0.5)(redis@6.2.1(@opentelemetry/api@1.9.1)) + '@effect/platform-node-shared': 4.0.1(effect@4.0.5) '@noble/hashes': 2.4.0 '@opentelemetry/api': 1.9.1 '@opentelemetry/exporter-metrics-otlp-http': 0.222.0(@opentelemetry/api@1.9.1) '@opentelemetry/exporter-trace-otlp-http': 0.222.0(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-metrics': 2.11.0(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-trace-base': 2.11.0(@opentelemetry/api@1.9.1) - '@systemfsoftware/effect-cell-types': file:.sfs-deps/effect-cell-types-12.0.0.tgz(effect@4.0.1) - '@systemfsoftware/stryker-js-vm-runner': '@systemfsoftware/stryker-js-vitest-runner@file:.sfs-deps/stryker-js-vitest-runner-8.1.1.tgz(@opentelemetry/api-logs@0.222.0)(@opentelemetry/api@1.9.1)(@opentelemetry/resources@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(effect@4.0.1)(supports-color@10.2.2)(vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.19.1)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)))' - effect: 4.0.1 + '@systemfsoftware/effect-cell-types': file:.sfs-deps/effect-cell-types-12.0.0.tgz(effect@4.0.5) + '@systemfsoftware/stryker-js-vm-runner': '@systemfsoftware/stryker-js-vitest-runner@file:.sfs-deps/stryker-js-vitest-runner-8.1.1.tgz(@opentelemetry/api-logs@0.222.0)(@opentelemetry/api@1.9.1)(@opentelemetry/resources@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(effect@4.0.5)(supports-color@10.2.2)(vitest@5.0.5)' + effect: 4.0.5 transitivePeerDependencies: - '@opentelemetry/api-logs' - '@opentelemetry/resources' @@ -6369,11 +9062,21 @@ snapshots: '@systemfsoftware/tsconfig@file:.sfs-deps/tsconfig-2.0.1.tgz': {} - '@systemfsoftware/vitest@file:.sfs-deps/vitest-2.0.0.tgz(effect@4.0.1)(redis@6.2.1(@opentelemetry/api@1.9.1))(vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.19.1)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)))': + '@systemfsoftware/tsdown-config@file:.sfs-deps/tsdown-config-0.1.0.tgz(@microsoft/api-extractor@7.59.4(@types/node@24.19.1))(tsdown@0.23.0(oxc-resolver@11.21.2)(typescript@7.0.2))': + optionalDependencies: + '@microsoft/api-extractor': 7.59.4(@types/node@24.19.1) + tsdown: 0.23.0(oxc-resolver@11.21.2)(typescript@7.0.2) + + '@systemfsoftware/vitest-config@file:.sfs-deps/vitest-config-0.1.0.tgz(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))(vitest@5.0.5)': + dependencies: + vite: 8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1) + vitest: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@24.19.1)(@vitest/browser-playwright@5.0.5)(@vitest/coverage-istanbul@5.0.5)(@vitest/ui@5.0.5)(jsdom@30.1.2(@noble/hashes@2.4.0))(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) + + '@systemfsoftware/vitest@file:.sfs-deps/vitest-2.0.0.tgz(effect@4.0.5)(redis@6.2.1(@opentelemetry/api@1.9.1))(vitest@5.0.5)': dependencies: - '@effect/platform-node': 4.0.1(effect@4.0.1)(redis@6.2.1(@opentelemetry/api@1.9.1)) - effect: 4.0.1 - vitest: 5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.19.1)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) + '@effect/platform-node': 4.0.5(effect@4.0.5)(redis@6.2.1(@opentelemetry/api@1.9.1)) + effect: 4.0.5 + vitest: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@24.19.1)(@vitest/browser-playwright@5.0.5)(@vitest/coverage-istanbul@5.0.5)(@vitest/ui@5.0.5)(jsdom@30.1.2(@noble/hashes@2.4.0))(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) transitivePeerDependencies: - bufferutil - redis @@ -6400,14 +9103,14 @@ snapshots: react: 19.3.0 react-dom: 19.3.0(react@19.3.0) - '@tanstack/react-start-rsc@0.1.59(esbuild@0.28.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(rolldown@1.2.12)(supports-color@10.2.2)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))': + '@tanstack/react-start-rsc@0.1.59(esbuild@0.28.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(rolldown@1.2.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))': dependencies: '@tanstack/react-router': 1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@tanstack/router-core': 1.171.34 '@tanstack/router-utils': 1.162.3(supports-color@10.2.2) '@tanstack/start-client-core': 1.170.34 '@tanstack/start-fn-stubs': 1.162.0 - '@tanstack/start-plugin-core': 1.171.49(@tanstack/react-router@1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(esbuild@0.28.2)(rolldown@1.2.12)(supports-color@10.2.2)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) + '@tanstack/start-plugin-core': 1.171.49(@tanstack/react-router@1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(esbuild@0.28.2)(rolldown@1.2.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) '@tanstack/start-storage-context': 1.167.36 pathe: 2.0.3 react: 19.3.0 @@ -6436,21 +9139,21 @@ snapshots: transitivePeerDependencies: - crossws - '@tanstack/react-start@1.168.60(esbuild@0.28.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(rolldown@1.2.12)(supports-color@10.2.2)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))': + '@tanstack/react-start@1.168.60(esbuild@0.28.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(rolldown@1.2.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))': dependencies: '@tanstack/react-router': 1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@tanstack/react-start-client': 1.168.39(react-dom@19.3.0(react@19.3.0))(react@19.3.0) - '@tanstack/react-start-rsc': 0.1.59(esbuild@0.28.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(rolldown@1.2.12)(supports-color@10.2.2)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) + '@tanstack/react-start-rsc': 0.1.59(esbuild@0.28.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(rolldown@1.2.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) '@tanstack/react-start-server': 1.167.46(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@tanstack/router-utils': 1.162.3(supports-color@10.2.2) '@tanstack/start-client-core': 1.170.34 - '@tanstack/start-plugin-core': 1.171.49(@tanstack/react-router@1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(esbuild@0.28.2)(rolldown@1.2.12)(supports-color@10.2.2)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) + '@tanstack/start-plugin-core': 1.171.49(@tanstack/react-router@1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(esbuild@0.28.2)(rolldown@1.2.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) '@tanstack/start-server-core': 1.169.39 pathe: 2.0.3 react: 19.3.0 react-dom: 19.3.0(react@19.3.0) optionalDependencies: - vite: 8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1) + vite: 8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1) transitivePeerDependencies: - '@farmfe/core' - '@rspack/core' @@ -6500,7 +9203,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.168.42(@tanstack/react-router@1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(esbuild@0.28.2)(rolldown@1.2.12)(supports-color@10.2.2)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))': + '@tanstack/router-plugin@1.168.42(@tanstack/react-router@1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(esbuild@0.28.2)(rolldown@1.2.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))': dependencies: '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/template': 7.29.7 @@ -6509,11 +9212,11 @@ snapshots: '@tanstack/router-generator': 1.167.40(supports-color@10.2.2) '@tanstack/router-utils': 1.162.3(supports-color@10.2.2) chokidar: 5.0.0 - unplugin: 3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) + unplugin: 3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) zod: 4.6.5 optionalDependencies: '@tanstack/react-router': 1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0) - vite: 8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1) + vite: 8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1) transitivePeerDependencies: - '@farmfe/core' - '@rspack/core' @@ -6546,7 +9249,7 @@ snapshots: '@tanstack/start-fn-stubs@1.162.0': {} - '@tanstack/start-plugin-core@1.171.49(@tanstack/react-router@1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(esbuild@0.28.2)(rolldown@1.2.12)(supports-color@10.2.2)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))': + '@tanstack/start-plugin-core@1.171.49(@tanstack/react-router@1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(esbuild@0.28.2)(rolldown@1.2.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))': dependencies: '@babel/code-frame': 7.29.7 '@babel/core': 7.29.7(supports-color@10.2.2) @@ -6554,7 +9257,7 @@ snapshots: '@jridgewell/remapping': 2.3.5 '@tanstack/router-core': 1.171.34 '@tanstack/router-generator': 1.167.40(supports-color@10.2.2) - '@tanstack/router-plugin': 1.168.42(@tanstack/react-router@1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(esbuild@0.28.2)(rolldown@1.2.12)(supports-color@10.2.2)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) + '@tanstack/router-plugin': 1.168.42(@tanstack/react-router@1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(esbuild@0.28.2)(rolldown@1.2.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) '@tanstack/router-utils': 1.162.3(supports-color@10.2.2) '@tanstack/start-server-core': 1.169.39 exsolve: 1.1.3 @@ -6566,11 +9269,11 @@ snapshots: srvx: 0.11.22 tinyglobby: 0.2.17 ufo: 1.6.4 - vitefu: 1.1.3(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) + vitefu: 1.1.3(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) xmlbuilder2: 4.0.3 zod: 4.6.5 optionalDependencies: - vite: 8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1) + vite: 8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1) transitivePeerDependencies: - '@farmfe/core' - '@rspack/core' @@ -6605,6 +9308,40 @@ snapshots: '@tanstack/virtual-file-routes@1.162.0': {} + '@testing-library/dom@10.4.2': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.10 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.5.0 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@babel/runtime': 7.29.10 + '@testing-library/dom': 10.4.2 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@testing-library/user-event@14.6.8(@testing-library/dom@10.4.2)': + dependencies: + '@testing-library/dom': 10.4.2 + '@turbo/darwin-64@2.11.7': optional: true @@ -6623,8 +9360,38 @@ snapshots: '@turbo/windows-arm64@2.11.7': optional: true + '@tybys/wasm-util@0.10.4': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/argparse@1.0.38': {} + + '@types/aria-query@5.0.4': {} + '@types/aws-lambda@8.10.164': {} + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.8 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.8 + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -6632,6 +9399,8 @@ snapshots: '@types/deep-eql@4.0.2': {} + '@types/doctrine@0.0.9': {} + '@types/estree@1.0.9': {} '@types/gensync@1.0.5': {} @@ -6650,10 +9419,18 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/resolve@1.20.6': {} + + '@types/scheduler@0.28.0': {} + '@types/ws@8.18.1': dependencies: '@types/node': 24.19.1 + '@types/ws@8.18.2': + dependencies: + '@types/node': 24.19.1 + '@typescript/typescript-aix-ppc64@7.0.2': optional: true @@ -6714,24 +9491,225 @@ snapshots: '@typescript/typescript-win32-x64@7.0.2': optional: true - '@vitejs/plugin-react@6.1.2(@rolldown/plugin-babel@0.2.4(@babel/core@8.0.6)(rolldown@1.2.12)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))': + '@vitejs/plugin-react@6.1.2(@rolldown/plugin-babel@0.2.4(@babel/core@8.0.6)(@babel/runtime@7.29.10)(rolldown@1.2.12)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1) + vite: 8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1) optionalDependencies: - '@rolldown/plugin-babel': 0.2.4(@babel/core@8.0.6)(rolldown@1.2.12)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) + '@rolldown/plugin-babel': 0.2.4(@babel/core@8.0.6)(@babel/runtime@7.29.10)(rolldown@1.2.12)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) babel-plugin-react-compiler: 1.0.0 - '@vitest/mocker@5.0.3(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))': + '@vitest/browser-playwright@5.0.5(playwright@1.63.0)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))(vitest@5.0.5)': + dependencies: + '@vitest/browser': 5.0.5(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))(vitest@5.0.5) + '@vitest/mocker': 5.0.5(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) + playwright: 1.63.0 + tinyrainbow: 3.2.0 + vitest: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@24.19.1)(@vitest/browser-playwright@5.0.5)(@vitest/coverage-istanbul@5.0.5)(@vitest/ui@5.0.5)(jsdom@30.1.2(@noble/hashes@2.4.0))(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/browser@5.0.5(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))(vitest@5.0.5)': + dependencies: + '@blazediff/core': 1.10.0 + '@vitest/mocker': 5.0.5(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) + '@vitest/ui': 5.0.5(vitest@5.0.5) + '@vitest/utils': 5.0.5 + magic-string: 1.4.3 + pngjs: 7.0.0 + sirv: 3.0.2 + tinyrainbow: 3.2.0 + vitest: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@24.19.1)(@vitest/browser-playwright@5.0.5)(@vitest/coverage-istanbul@5.0.5)(@vitest/ui@5.0.5)(jsdom@30.1.2(@noble/hashes@2.4.0))(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) + ws: 8.22.0 + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/coverage-istanbul@5.0.5(vitest@5.0.5)': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/istanbul-lib-coverage': 1.0.2 + '@vitest/istanbul-lib-instrument': 1.0.2 + '@vitest/istanbul-lib-report': 1.0.2 + '@vitest/istanbul-lib-source-maps': 1.0.2 + magicast: 0.5.5 + obug: 3.0.0 + tinyrainbow: 3.2.0 + vitest: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@24.19.1)(@vitest/browser-playwright@5.0.5)(@vitest/coverage-istanbul@5.0.5)(@vitest/ui@5.0.5)(jsdom@30.1.2(@noble/hashes@2.4.0))(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) + + '@vitest/expect@3.2.4': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/istanbul-lib-coverage@1.0.2': {} + + '@vitest/istanbul-lib-instrument@1.0.2': + dependencies: + '@babel/core': 8.0.6 + '@babel/parser': 8.0.6 + '@istanbuljs/schema': 0.1.6 + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/istanbul-lib-coverage': 1.0.2 + + '@vitest/istanbul-lib-report@1.0.2': + dependencies: + '@vitest/istanbul-lib-coverage': 1.0.2 + + '@vitest/istanbul-lib-source-maps@1.0.2': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/istanbul-lib-coverage': 1.0.2 + obug: 2.2.1 + + '@vitest/mocker@5.0.5(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))': dependencies: '@jridgewell/trace-mapping': 0.3.31 - '@vitest/spy': 5.0.3 + '@vitest/spy': 5.0.5 estree-walker: 3.0.3 - magic-string: 1.4.2 + magic-string: 1.4.3 + optionalDependencies: + vite: 8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1) + + '@vitest/pretty-format@3.2.4': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/pretty-format@5.0.5': + dependencies: + tinyrainbow: 3.2.0 + + '@vitest/spy@3.2.4': + dependencies: + tinyspy: 4.0.6 + + '@vitest/spy@5.0.5': {} + + '@vitest/ui@5.0.5(vitest@5.0.5)': + dependencies: + '@vitest/utils': 5.0.5 + fflate: 0.8.3 + flatted: 3.4.4 + pathe: 2.0.3 + sirv: 3.0.2 + tinyrainbow: 3.2.0 + vitest: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@24.19.1)(@vitest/browser-playwright@5.0.5)(@vitest/coverage-istanbul@5.0.5)(@vitest/ui@5.0.5)(jsdom@30.1.2(@noble/hashes@2.4.0))(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) + + '@vitest/utils@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + '@vitest/utils@5.0.5': + dependencies: + '@vitest/pretty-format': 5.0.5 + convert-source-map: 2.0.0 + tinyrainbow: 3.2.0 + + '@webcontainer/env@1.1.1': {} + + '@yuku-codegen/binding-android-arm64@0.10.2': + optional: true + + '@yuku-codegen/binding-darwin-arm64@0.10.2': + optional: true + + '@yuku-codegen/binding-darwin-x64@0.10.2': + optional: true + + '@yuku-codegen/binding-freebsd-x64@0.10.2': + optional: true + + '@yuku-codegen/binding-linux-arm-gnu@0.10.2': + optional: true + + '@yuku-codegen/binding-linux-arm-musl@0.10.2': + optional: true + + '@yuku-codegen/binding-linux-arm64-gnu@0.10.2': + optional: true + + '@yuku-codegen/binding-linux-arm64-musl@0.10.2': + optional: true + + '@yuku-codegen/binding-linux-x64-gnu@0.10.2': + optional: true + + '@yuku-codegen/binding-linux-x64-musl@0.10.2': + optional: true + + '@yuku-codegen/binding-win32-arm64@0.10.2': + optional: true + + '@yuku-codegen/binding-win32-x64@0.10.2': + optional: true + + '@yuku-parser/binding-android-arm64@0.10.2': + optional: true + + '@yuku-parser/binding-darwin-arm64@0.10.2': + optional: true + + '@yuku-parser/binding-darwin-x64@0.10.2': + optional: true + + '@yuku-parser/binding-freebsd-x64@0.10.2': + optional: true + + '@yuku-parser/binding-linux-arm-gnu@0.10.2': + optional: true + + '@yuku-parser/binding-linux-arm-musl@0.10.2': + optional: true + + '@yuku-parser/binding-linux-arm64-gnu@0.10.2': + optional: true + + '@yuku-parser/binding-linux-arm64-musl@0.10.2': + optional: true + + '@yuku-parser/binding-linux-x64-gnu@0.10.2': + optional: true + + '@yuku-parser/binding-linux-x64-musl@0.10.2': + optional: true + + '@yuku-parser/binding-win32-arm64@0.10.2': + optional: true + + '@yuku-parser/binding-win32-x64@0.10.2': + optional: true + + '@yuku-toolchain/types@0.10.2': {} + + '@zkochan/which@2.0.3': + dependencies: + isexe: 2.0.0 + + ajv-draft-04@1.0.0(ajv@8.20.0): optionalDependencies: - vite: 8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1) + ajv: 8.20.0 - '@vitest/spy@5.0.3': {} + ajv-formats@3.0.1: + dependencies: + ajv: 8.20.0 + + ajv@8.18.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.7 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 ajv@8.20.0: dependencies: @@ -6740,30 +9718,30 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - alchemy@2.0.0-beta.80(@alchemy.run/frontend-frameworks@2.0.0-beta.80(@types/node@24.19.1)(@vitejs/plugin-react@6.1.2(@rolldown/plugin-babel@0.2.4(@babel/core@8.0.6)(rolldown@1.2.12)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)))(effect@4.0.1)(hono@4.11.4)(redis@6.2.1(@opentelemetry/api@1.9.1))(typescript@7.0.2)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)))(@effect/platform-node@4.0.1(effect@4.0.1)(redis@6.2.1(@opentelemetry/api@1.9.1)))(@types/node@24.19.1)(@types/react@19.3.0)(effect@4.0.1)(hono@4.11.4)(typescript@7.0.2)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))(ws@8.22.0): + alchemy@2.0.0-beta.80(@alchemy.run/frontend-frameworks@2.0.0-beta.80(@types/node@24.19.1)(@vitejs/plugin-react@6.1.2(@rolldown/plugin-babel@0.2.4(@babel/core@8.0.6)(@babel/runtime@7.29.10)(rolldown@1.2.12)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)))(effect@4.0.5)(hono@4.11.4)(redis@6.2.1(@opentelemetry/api@1.9.1))(typescript@7.0.2)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)))(@effect/platform-node@4.0.1(effect@4.0.5)(redis@6.2.1(@opentelemetry/api@1.9.1)))(@types/node@24.19.1)(@types/react@19.3.0)(effect@4.0.5)(hono@4.11.4)(typescript@7.0.2)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))(ws@8.22.0): dependencies: - '@alchemy.run/cloudflare-runtime': 2.0.0-beta.80(@distilled.cloud/cloudflare@1.0.0-rc.13(effect@4.0.1))(@effect/platform-node@4.0.1(effect@4.0.1)(redis@6.2.1(@opentelemetry/api@1.9.1)))(@types/node@24.19.1)(effect@4.0.1)(rolldown@1.2.5)(typescript@7.0.2)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) - '@alchemy.run/floci': 2.0.0-beta.80(effect@4.0.1) + '@alchemy.run/cloudflare-runtime': 2.0.0-beta.80(@distilled.cloud/cloudflare@1.0.0-rc.13(effect@4.0.5))(@effect/platform-node@4.0.1(effect@4.0.5)(redis@6.2.1(@opentelemetry/api@1.9.1)))(@types/node@24.19.1)(effect@4.0.5)(rolldown@1.2.5)(typescript@7.0.2)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) + '@alchemy.run/floci': 2.0.0-beta.80(effect@4.0.5) '@alchemy.run/node-utils': 2.0.0-beta.80 '@alchemy.run/sigil': 0.1.0-alpha.1(@types/react@19.3.0) - '@distilled.cloud/acme': 1.0.0-rc.13(effect@4.0.1) - '@distilled.cloud/aws': 1.0.0-rc.13(effect@4.0.1) - '@distilled.cloud/axiom': 1.0.0-rc.13(effect@4.0.1) - '@distilled.cloud/cloudflare': 1.0.0-rc.13(effect@4.0.1) - '@distilled.cloud/core': 1.0.0-rc.13(effect@4.0.1) - '@distilled.cloud/doppler': 1.0.0-rc.13(effect@4.0.1) - '@distilled.cloud/fly-io': 1.0.0-rc.13(effect@4.0.1) - '@distilled.cloud/gcp': 1.0.0-rc.13(effect@4.0.1) - '@distilled.cloud/hetzner': 1.0.0-rc.13(effect@4.0.1) - '@distilled.cloud/infisical': 1.0.0-rc.13(effect@4.0.1) - '@distilled.cloud/neon': 1.0.0-rc.13(effect@4.0.1) - '@distilled.cloud/planetscale': 1.0.0-rc.13(effect@4.0.1) - '@distilled.cloud/prisma': 1.0.0-rc.13(effect@4.0.1) - '@distilled.cloud/railway': 1.0.0-rc.13(effect@4.0.1) - '@distilled.cloud/stripe': 1.0.0-rc.13(effect@4.0.1) - '@distilled.cloud/zerossl': 1.0.0-rc.13(effect@4.0.1) - '@effect/sql-d1': 4.0.1(effect@4.0.1) - '@effect/sql-sqlite-do': 4.0.1(effect@4.0.1) + '@distilled.cloud/acme': 1.0.0-rc.13(effect@4.0.5) + '@distilled.cloud/aws': 1.0.0-rc.13(effect@4.0.5) + '@distilled.cloud/axiom': 1.0.0-rc.13(effect@4.0.5) + '@distilled.cloud/cloudflare': 1.0.0-rc.13(effect@4.0.5) + '@distilled.cloud/core': 1.0.0-rc.13(effect@4.0.5) + '@distilled.cloud/doppler': 1.0.0-rc.13(effect@4.0.5) + '@distilled.cloud/fly-io': 1.0.0-rc.13(effect@4.0.5) + '@distilled.cloud/gcp': 1.0.0-rc.13(effect@4.0.5) + '@distilled.cloud/hetzner': 1.0.0-rc.13(effect@4.0.5) + '@distilled.cloud/infisical': 1.0.0-rc.13(effect@4.0.5) + '@distilled.cloud/neon': 1.0.0-rc.13(effect@4.0.5) + '@distilled.cloud/planetscale': 1.0.0-rc.13(effect@4.0.5) + '@distilled.cloud/prisma': 1.0.0-rc.13(effect@4.0.5) + '@distilled.cloud/railway': 1.0.0-rc.13(effect@4.0.5) + '@distilled.cloud/stripe': 1.0.0-rc.13(effect@4.0.5) + '@distilled.cloud/zerossl': 1.0.0-rc.13(effect@4.0.5) + '@effect/sql-d1': 4.0.1(effect@4.0.5) + '@effect/sql-sqlite-do': 4.0.1(effect@4.0.5) '@libsql/client': 0.17.4 '@neon/functions': 0.11.0(hono@4.11.4) '@octokit/rest': 22.0.1 @@ -6771,7 +9749,7 @@ snapshots: '@prisma/dev': 0.20.0(typescript@7.0.2) '@types/aws-lambda': 8.10.164 capnweb: 0.12.0 - effect: 4.0.1 + effect: 4.0.5 fflate: 0.8.3 libsodium-wrappers: 0.8.4 pathe: 2.0.3 @@ -6780,9 +9758,9 @@ snapshots: tinyglobby: 0.2.17 yaml: 2.9.1 optionalDependencies: - '@alchemy.run/frontend-frameworks': 2.0.0-beta.80(@types/node@24.19.1)(@vitejs/plugin-react@6.1.2(@rolldown/plugin-babel@0.2.4(@babel/core@8.0.6)(rolldown@1.2.12)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)))(effect@4.0.1)(hono@4.11.4)(redis@6.2.1(@opentelemetry/api@1.9.1))(typescript@7.0.2)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) - '@effect/platform-node': 4.0.1(effect@4.0.1)(redis@6.2.1(@opentelemetry/api@1.9.1)) - vite: 8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1) + '@alchemy.run/frontend-frameworks': 2.0.0-beta.80(@types/node@24.19.1)(@vitejs/plugin-react@6.1.2(@rolldown/plugin-babel@0.2.4(@babel/core@8.0.6)(@babel/runtime@7.29.10)(rolldown@1.2.12)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)))(effect@4.0.5)(hono@4.11.4)(redis@6.2.1(@opentelemetry/api@1.9.1))(typescript@7.0.2)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) + '@effect/platform-node': 4.0.1(effect@4.0.5)(redis@6.2.1(@opentelemetry/api@1.9.1)) + vite: 8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1) ws: 8.22.0 transitivePeerDependencies: - '@types/node' @@ -6802,16 +9780,32 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} ansis@4.4.0: {} + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + argparse@2.0.1: {} argue-cli@3.2.0: {} + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + assertion-error@2.0.1: {} + ast-types@0.16.3: + dependencies: + tslib: 2.8.1 + babel-dead-code-elimination@1.0.12(supports-color@10.2.2): dependencies: '@babel/core': 7.29.7(supports-color@10.2.2) @@ -6825,12 +9819,31 @@ snapshots: dependencies: '@babel/types': 7.29.8 + balanced-match@4.0.4: {} + baseline-browser-mapping@2.11.27: {} before-after-hook@4.0.0: {} + bidi-js@1.1.0: + dependencies: + require-from-string: 2.0.2 + blake3-wasm@2.1.5: {} + bole@5.0.29: + dependencies: + fast-safe-stringify: 2.1.1 + individual: 3.0.0 + + brace-expansion@5.0.12: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + browserslist@4.29.3: dependencies: baseline-browser-mapping: 2.11.27 @@ -6839,6 +9852,12 @@ snapshots: node-releases: 2.0.57 update-browserslist-db: 1.3.3(browserslist@4.29.3) + bundle-name@4.1.1: + dependencies: + run-applescript: 7.1.0 + + cac@7.1.0: {} + callsites@3.1.0: {} caniuse-lite@1.0.30001814: {} @@ -6849,7 +9868,19 @@ snapshots: capnweb@0.12.0: {} - chai@6.2.2: {} + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + chai@6.3.0: {} + + chalk@6.0.1: {} + + check-error@2.1.3: {} chevrotain@10.5.0: dependencies: @@ -6929,21 +9960,66 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + css.escape@1.5.1: {} + csstype@3.2.3: {} + data-urls@8.0.0(@noble/hashes@2.4.0): + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 17.3.0(@noble/hashes@2.4.0) + transitivePeerDependencies: + - '@noble/hashes' + debug@4.4.3(supports-color@10.2.2): dependencies: ms: 2.1.3 optionalDependencies: supports-color: 10.2.2 + decimal.js@10.6.0: {} + + deep-eql@5.0.2: {} + + default-browser-id@5.0.1: {} + + default-browser@5.5.1: + dependencies: + bundle-name: 4.1.1 + default-browser-id: 5.0.1 + + define-lazy-prop@3.0.0: {} + + defu@6.1.7: {} + + dequal@2.0.3: {} + + detect-indent@7.0.2: {} + detect-libc@2.0.2: {} detect-libc@2.1.2: {} diff@8.0.4: {} - effect@4.0.1: {} + doctrine@3.0.0: + dependencies: + esutils: 2.0.3 + + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + + dts-resolver@3.0.0(oxc-resolver@11.21.2): + optionalDependencies: + oxc-resolver: 11.21.2 + + effect@4.0.5: {} electron-to-chromium@1.5.444: {} @@ -6953,6 +10029,10 @@ snapshots: empathic@2.0.1: {} + empathic@2.1.0: {} + + entities@8.1.0: {} + env-paths@2.2.1: {} error-ex@1.3.4: @@ -6961,41 +10041,14 @@ snapshots: error-stack-parser-es@1.0.5: {} + es-errors@1.3.0: {} + es-module-lexer@2.3.2: {} es-module-lexer@3.0.2: {} es-toolkit@1.52.0: {} - esbuild@0.28.1: - optionalDependencies: - '@esbuild/aix-ppc64': 0.28.1 - '@esbuild/android-arm': 0.28.1 - '@esbuild/android-arm64': 0.28.1 - '@esbuild/android-x64': 0.28.1 - '@esbuild/darwin-arm64': 0.28.1 - '@esbuild/darwin-x64': 0.28.1 - '@esbuild/freebsd-arm64': 0.28.1 - '@esbuild/freebsd-x64': 0.28.1 - '@esbuild/linux-arm': 0.28.1 - '@esbuild/linux-arm64': 0.28.1 - '@esbuild/linux-ia32': 0.28.1 - '@esbuild/linux-loong64': 0.28.1 - '@esbuild/linux-mips64el': 0.28.1 - '@esbuild/linux-ppc64': 0.28.1 - '@esbuild/linux-riscv64': 0.28.1 - '@esbuild/linux-s390x': 0.28.1 - '@esbuild/linux-x64': 0.28.1 - '@esbuild/netbsd-arm64': 0.28.1 - '@esbuild/netbsd-x64': 0.28.1 - '@esbuild/openbsd-arm64': 0.28.1 - '@esbuild/openbsd-x64': 0.28.1 - '@esbuild/openharmony-arm64': 0.28.1 - '@esbuild/sunos-x64': 0.28.1 - '@esbuild/win32-arm64': 0.28.1 - '@esbuild/win32-ia32': 0.28.1 - '@esbuild/win32-x64': 0.28.1 - esbuild@0.28.2: optionalDependencies: '@esbuild/aix-ppc64': 0.28.2 @@ -7027,16 +10080,43 @@ snapshots: escalade@3.2.0: {} + esprima@4.0.1: {} + + estree-walker@2.0.2: {} + estree-walker@3.0.3: dependencies: - '@types/estree': 1.0.9 + '@types/estree': 1.0.9 + + esutils@2.0.3: {} + + execa@9.6.1: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + cross-spawn: 7.0.6 + figures: 6.1.0 + get-stream: 9.0.1 + human-signals: 8.0.1 + is-plain-obj: 4.1.0 + is-stream: 4.0.1 + npm-run-path: 6.0.0 + pretty-ms: 9.3.1 + signal-exit: 4.1.0 + strip-final-newline: 4.0.0 + yoctocolors: 2.2.0 expect-type@1.4.0: {} exsolve@1.1.3: {} + fast-check@4.10.2: + dependencies: + pure-rand: 8.4.2 + fast-deep-equal@3.1.3: {} + fast-safe-stringify@2.1.1: {} + fast-uri@3.1.7: {} fdir@6.5.0(picomatch@4.0.7): @@ -7047,6 +10127,16 @@ snapshots: fflate@0.8.3: {} + figures@6.1.0: + dependencies: + is-unicode-supported: 2.1.0 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + flatted@3.4.4: {} + flru@1.0.2: {} foreground-child@3.3.1: @@ -7054,9 +10144,17 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + fs-extra@11.3.6: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + fsevents@2.3.3: optional: true + function-bind@1.1.2: {} + gensync@1.0.0-beta.2: {} get-caller-file@2.0.5: {} @@ -7065,6 +10163,21 @@ snapshots: get-port-please@3.2.0: {} + get-stream@9.0.1: + dependencies: + '@sec-ant/readable-stream': 0.4.1 + is-stream: 4.0.1 + + get-tsconfig@5.0.0-beta.6: + dependencies: + resolve-pkg-maps: 1.0.0 + + glob@13.0.6: + dependencies: + minimatch: 10.2.6 + minipass: 7.1.3 + path-scurry: 2.0.2 + global-directory@5.0.0: dependencies: ini: 6.0.0 @@ -7082,10 +10195,26 @@ snapshots: rou3: 0.8.1 srvx: 0.11.22 + has-flag@4.0.0: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + hono@4.11.4: {} + hookable@6.1.2: {} + + html-encoding-sniffer@7.0.0(@noble/hashes@2.4.0): + dependencies: + '@exodus/bytes': 1.16.0(@noble/hashes@2.4.0) + transitivePeerDependencies: + - '@noble/hashes' + http-status-codes@2.3.0: {} + human-signals@8.0.1: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -7097,16 +10226,50 @@ snapshots: es-module-lexer: 3.0.2 module-details-from-path: 1.0.4 + import-lazy@4.0.0: {} + import-meta-resolve@4.2.0: {} + import-without-cache@0.4.1: {} + + indent-string@4.0.0: {} + + index-to-position@1.2.0: {} + + individual@3.0.0: {} + ini@6.0.0: {} is-arrayish@0.2.1: {} + is-core-module@2.17.0: + dependencies: + hasown: 2.0.4 + + is-docker@3.0.0: {} + is-fullwidth-code-point@3.0.0: {} + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + + is-number@7.0.0: {} + is-plain-obj@4.1.0: {} + is-potential-custom-element-name@1.0.1: {} + + is-stream@4.0.1: {} + + is-unicode-supported@2.1.0: {} + + is-windows@1.0.2: {} + + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + isbot@5.2.2: {} isexe@2.0.0: {} @@ -7115,6 +10278,8 @@ snapshots: jiti@2.7.0: {} + jju@1.4.0: {} + js-base64@3.9.4: {} js-tokens@10.0.0: {} @@ -7125,6 +10290,31 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@30.1.2(@noble/hashes@2.4.0): + dependencies: + '@asamuzakjp/css-color': 7.1.7 + '@asamuzakjp/dom-selector': 9.2.4 + '@bramus/specificity': 2.4.3 + '@csstools/css-syntax-patches-for-csstree': 1.1.15(css-tree@3.2.1) + '@exodus/bytes': 1.16.0(@noble/hashes@2.4.0) + css-tree: 3.2.1 + data-urls: 8.0.0(@noble/hashes@2.4.0) + decimal.js: 10.6.0 + html-encoding-sniffer: 7.0.0(@noble/hashes@2.4.0) + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.3 + parse5: 8.0.1 + saxes: 6.0.0 + tough-cookie: 6.0.2 + undici: 8.11.2 + w3c-xmlserializer: 6.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 17.3.0(@noble/hashes@2.4.0) + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + jsesc@3.1.0: {} json-parse-even-better-errors@2.3.1: {} @@ -7135,6 +10325,14 @@ snapshots: json5@2.2.3: {} + jsonc-parser@3.3.1: {} + + jsonfile@6.2.1: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + kleur@4.1.5: {} libsodium-wrappers@0.8.4: @@ -7219,16 +10417,24 @@ snapshots: optionalDependencies: yaml: 2.9.1 + load-json-file@7.0.1: {} + lodash.camelcase@4.3.0: {} lodash@4.17.21: {} long@5.3.2: {} + loupe@3.2.1: {} + + lru-cache@11.5.3: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 @@ -7237,8 +10443,33 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 + magic-string@1.4.3: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + magicast@0.5.5: + dependencies: + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 + source-map-js: 1.2.1 + + mdn-data@2.27.1: {} + + memoize@11.0.0: + dependencies: + mimic-function: 5.0.1 + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + mime@4.1.0: {} + mimic-function@5.0.1: {} + + min-indent@1.0.1: {} + miniflare@5.20261001.0-alpha(@types/node@24.19.1): dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -7252,18 +10483,71 @@ snapshots: - bufferutil - utf-8-validate + minimatch@10.2.3: + dependencies: + brace-expansion: 5.0.12 + + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.12 + + minimist@1.2.8: {} + + minipass@7.1.3: {} + modern-tar@0.8.5: {} module-details-from-path@1.0.4: {} + mrmime@2.0.1: {} + ms@2.1.3: {} - nanoid@3.3.18: {} + nanoid@3.3.20: {} node-releases@2.0.57: {} + npm-run-path@6.0.0: + dependencies: + path-key: 4.0.0 + unicorn-magic: 0.3.0 + obug@2.2.1: {} + obug@3.0.0: {} + + open@10.2.0: + dependencies: + default-browser: 5.5.1 + define-lazy-prop: 3.0.0 + is-inside-container: 1.0.0 + wsl-utils: 0.1.0 + + oxc-parser@0.127.0: + dependencies: + '@oxc-project/types': 0.127.0 + optionalDependencies: + '@oxc-parser/binding-android-arm-eabi': 0.127.0 + '@oxc-parser/binding-android-arm64': 0.127.0 + '@oxc-parser/binding-darwin-arm64': 0.127.0 + '@oxc-parser/binding-darwin-x64': 0.127.0 + '@oxc-parser/binding-freebsd-x64': 0.127.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.127.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.127.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.127.0 + '@oxc-parser/binding-linux-arm64-musl': 0.127.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.127.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.127.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.127.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.127.0 + '@oxc-parser/binding-linux-x64-gnu': 0.127.0 + '@oxc-parser/binding-linux-x64-musl': 0.127.0 + '@oxc-parser/binding-openharmony-arm64': 0.127.0 + '@oxc-parser/binding-wasm32-wasi': 0.127.0 + '@oxc-parser/binding-win32-arm64-msvc': 0.127.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.127.0 + '@oxc-parser/binding-win32-x64-msvc': 0.127.0 + oxc-parser@0.150.0: dependencies: '@oxc-project/types': 0.150.0 @@ -7288,43 +10572,101 @@ snapshots: '@oxc-parser/binding-win32-ia32-msvc': 0.150.0 '@oxc-parser/binding-win32-x64-msvc': 0.150.0 + oxc-parser@0.153.0: + dependencies: + '@oxc-project/types': 0.153.0 + optionalDependencies: + '@oxc-parser/binding-android-arm-eabi': 0.153.0 + '@oxc-parser/binding-android-arm64': 0.153.0 + '@oxc-parser/binding-darwin-arm64': 0.153.0 + '@oxc-parser/binding-darwin-x64': 0.153.0 + '@oxc-parser/binding-freebsd-x64': 0.153.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.153.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.153.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.153.0 + '@oxc-parser/binding-linux-arm64-musl': 0.153.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.153.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.153.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.153.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.153.0 + '@oxc-parser/binding-linux-x64-gnu': 0.153.0 + '@oxc-parser/binding-linux-x64-musl': 0.153.0 + '@oxc-parser/binding-openharmony-arm64': 0.153.0 + '@oxc-parser/binding-win32-arm64-msvc': 0.153.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.153.0 + '@oxc-parser/binding-win32-x64-msvc': 0.153.0 + + oxc-resolver@11.21.2: + optionalDependencies: + '@oxc-resolver/binding-android-arm-eabi': 11.21.2 + '@oxc-resolver/binding-android-arm64': 11.21.2 + '@oxc-resolver/binding-darwin-arm64': 11.21.2 + '@oxc-resolver/binding-darwin-x64': 11.21.2 + '@oxc-resolver/binding-freebsd-x64': 11.21.2 + '@oxc-resolver/binding-linux-arm-gnueabihf': 11.21.2 + '@oxc-resolver/binding-linux-arm-musleabihf': 11.21.2 + '@oxc-resolver/binding-linux-arm64-gnu': 11.21.2 + '@oxc-resolver/binding-linux-arm64-musl': 11.21.2 + '@oxc-resolver/binding-linux-ppc64-gnu': 11.21.2 + '@oxc-resolver/binding-linux-riscv64-gnu': 11.21.2 + '@oxc-resolver/binding-linux-riscv64-musl': 11.21.2 + '@oxc-resolver/binding-linux-s390x-gnu': 11.21.2 + '@oxc-resolver/binding-linux-x64-gnu': 11.21.2 + '@oxc-resolver/binding-linux-x64-musl': 11.21.2 + '@oxc-resolver/binding-openharmony-arm64': 11.21.2 + '@oxc-resolver/binding-wasm32-wasi': 11.21.2 + '@oxc-resolver/binding-win32-arm64-msvc': 11.21.2 + '@oxc-resolver/binding-win32-x64-msvc': 11.21.2 + oxc-walker@1.1.1(@oxc-project/types@0.150.0)(oxc-parser@0.150.0)(rolldown@1.2.12): optionalDependencies: '@oxc-project/types': 0.150.0 oxc-parser: 0.150.0 rolldown: 1.2.12 - oxlint-tsgolint@7.0.2001: + oxlint-tsgolint@7.0.2003: optionalDependencies: - '@oxlint-tsgolint/darwin-arm64': 7.0.2001 - '@oxlint-tsgolint/darwin-x64': 7.0.2001 - '@oxlint-tsgolint/linux-arm64': 7.0.2001 - '@oxlint-tsgolint/linux-x64': 7.0.2001 - '@oxlint-tsgolint/win32-arm64': 7.0.2001 - '@oxlint-tsgolint/win32-x64': 7.0.2001 - - oxlint@1.82.0(oxlint-tsgolint@7.0.2001): + '@oxlint-tsgolint/darwin-arm64': 7.0.2003 + '@oxlint-tsgolint/darwin-x64': 7.0.2003 + '@oxlint-tsgolint/linux-arm64': 7.0.2003 + '@oxlint-tsgolint/linux-x64': 7.0.2003 + '@oxlint-tsgolint/win32-arm64': 7.0.2003 + '@oxlint-tsgolint/win32-x64': 7.0.2003 + + oxlint@1.87.0(oxlint-tsgolint@7.0.2003): optionalDependencies: - '@oxlint/binding-android-arm-eabi': 1.82.0 - '@oxlint/binding-android-arm64': 1.82.0 - '@oxlint/binding-darwin-arm64': 1.82.0 - '@oxlint/binding-darwin-x64': 1.82.0 - '@oxlint/binding-freebsd-x64': 1.82.0 - '@oxlint/binding-linux-arm-gnueabihf': 1.82.0 - '@oxlint/binding-linux-arm-musleabihf': 1.82.0 - '@oxlint/binding-linux-arm64-gnu': 1.82.0 - '@oxlint/binding-linux-arm64-musl': 1.82.0 - '@oxlint/binding-linux-ppc64-gnu': 1.82.0 - '@oxlint/binding-linux-riscv64-gnu': 1.82.0 - '@oxlint/binding-linux-riscv64-musl': 1.82.0 - '@oxlint/binding-linux-s390x-gnu': 1.82.0 - '@oxlint/binding-linux-x64-gnu': 1.82.0 - '@oxlint/binding-linux-x64-musl': 1.82.0 - '@oxlint/binding-openharmony-arm64': 1.82.0 - '@oxlint/binding-win32-arm64-msvc': 1.82.0 - '@oxlint/binding-win32-ia32-msvc': 1.82.0 - '@oxlint/binding-win32-x64-msvc': 1.82.0 - oxlint-tsgolint: 7.0.2001 + '@oxlint/binding-android-arm-eabi': 1.87.0 + '@oxlint/binding-android-arm64': 1.87.0 + '@oxlint/binding-darwin-arm64': 1.87.0 + '@oxlint/binding-darwin-x64': 1.87.0 + '@oxlint/binding-freebsd-x64': 1.87.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.87.0 + '@oxlint/binding-linux-arm-musleabihf': 1.87.0 + '@oxlint/binding-linux-arm64-gnu': 1.87.0 + '@oxlint/binding-linux-arm64-musl': 1.87.0 + '@oxlint/binding-linux-ppc64-gnu': 1.87.0 + '@oxlint/binding-linux-riscv64-gnu': 1.87.0 + '@oxlint/binding-linux-riscv64-musl': 1.87.0 + '@oxlint/binding-linux-s390x-gnu': 1.87.0 + '@oxlint/binding-linux-x64-gnu': 1.87.0 + '@oxlint/binding-linux-x64-musl': 1.87.0 + '@oxlint/binding-openharmony-arm64': 1.87.0 + '@oxlint/binding-win32-arm64-msvc': 1.87.0 + '@oxlint/binding-win32-ia32-msvc': 1.87.0 + '@oxlint/binding-win32-x64-msvc': 1.87.0 + oxlint-tsgolint: 7.0.2003 + + p-filter@4.1.0: + dependencies: + p-map: 7.1.1 + + p-limit@7.3.3: + dependencies: + yocto-queue: 1.2.2 + + p-map@7.1.1: {} + + package-json-from-dist@1.0.1: {} parent-module@1.0.1: dependencies: @@ -7337,16 +10679,43 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 + parse-json@8.3.0: + dependencies: + '@babel/code-frame': 7.29.7 + index-to-position: 1.2.0 + type-fest: 4.41.0 + + parse-ms@4.0.0: {} + + parse5@8.0.1: + dependencies: + entities: 8.1.0 + path-key@3.1.1: {} + path-key@4.0.0: {} + + path-name@1.0.0: {} + + path-parse@1.0.7: {} + + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.3 + minipass: 7.1.3 + path-to-regexp@6.3.0: {} pathe@2.0.3: {} + pathval@2.0.1: {} + pend@1.2.0: {} picocolors@1.1.1: {} + picomatch@2.3.2: {} + picomatch@4.0.7: {} playwright-core@1.63.0: {} @@ -7355,14 +10724,26 @@ snapshots: dependencies: playwright-core: 1.63.0 - postcss@8.5.28: + pngjs@7.0.0: {} + + postcss@8.5.31: dependencies: - nanoid: 3.3.18 + nanoid: 3.3.20 picocolors: 1.1.1 - source-map-js: 1.2.1 + source-map-js: 1.2.2 prettier@3.9.9: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + pretty-ms@9.3.1: + dependencies: + parse-ms: 4.0.0 + promise-limit@2.7.0: {} proper-lockfile@4.1.2: @@ -7385,15 +10766,66 @@ snapshots: '@types/node': 24.19.1 long: 5.3.2 + punycode@2.3.1: {} + + pure-rand@8.4.2: {} + + quansync@1.0.0: {} + + react-docgen-typescript@2.4.0(typescript@7.0.2): + dependencies: + typescript: 7.0.2 + + react-docgen@8.0.4(supports-color@10.2.2): + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/traverse': 7.29.8(supports-color@10.2.2) + '@babel/types': 7.29.8 + '@types/babel__core': 7.20.5 + '@types/babel__traverse': 7.28.0 + '@types/doctrine': 0.0.9 + '@types/resolve': 1.20.6 + doctrine: 3.0.0 + resolve: 1.22.13 + strip-indent: 4.1.1 + transitivePeerDependencies: + - supports-color + react-dom@19.3.0(react@19.3.0): dependencies: react: 19.3.0 scheduler: 0.28.0 + react-error-boundary@6.1.6(@types/react@19.3.0)(react@19.3.0): + dependencies: + react: 19.3.0 + optionalDependencies: + '@types/react': 19.3.0 + + react-is@17.0.2: {} + react@19.3.0: {} + read-yaml-file@3.0.0: + dependencies: + js-yaml: 4.3.2 + strip-bom: 5.0.0 + readdirp@5.1.1: {} + recast@0.23.21: + dependencies: + ast-types: 0.16.3 + esprima: 4.0.1 + source-map: 0.6.1 + tiny-invariant: 1.3.3 + tslib: 2.8.1 + + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + redis@6.2.1(@opentelemetry/api@1.9.1): dependencies: '@redis/bloom': 6.2.1(@redis/client@6.2.1(@opentelemetry/api@1.9.1)) @@ -7424,8 +10856,36 @@ snapshots: resolve-from@5.0.0: {} + resolve-pkg-maps@1.0.0: {} + + resolve@1.22.13: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.17.0 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + retry@0.12.0: {} + rimraf@6.1.3: + dependencies: + glob: 13.0.6 + package-json-from-dist: 1.0.1 + + rolldown-plugin-dts@0.28.6(patch_hash=c4d02f422daffea3f211c29c3c2b926625c17774e763cc9a3643bf730fe57eba)(oxc-resolver@11.21.2)(rolldown@1.2.12)(typescript@7.0.2): + dependencies: + dts-resolver: 3.0.0(oxc-resolver@11.21.2) + get-tsconfig: 5.0.0-beta.6 + obug: 3.0.0 + rolldown: 1.2.12 + yuku-ast: 0.10.2 + yuku-codegen: 0.10.2 + yuku-parser: 0.10.2 + optionalDependencies: + typescript: 7.0.2 + transitivePeerDependencies: + - oxc-resolver + rolldown@1.2.12: dependencies: '@oxc-project/types': 0.152.0 @@ -7470,10 +10930,26 @@ snapshots: rou3@0.8.1: {} + run-applescript@7.1.0: {} + + safe-execa@0.3.0: + dependencies: + '@zkochan/which': 2.0.3 + execa: 9.6.1 + path-name: 1.0.0 + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.28.0: {} + semver-utils@1.1.4: {} + semver@6.3.1: {} + semver@7.7.4: {} + semver@7.8.5: {} seroval-plugins@1.6.8(seroval@1.6.8): @@ -7558,17 +11034,58 @@ snapshots: signal-exit@4.1.0: {} + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + source-map-js@1.2.1: {} + source-map-js@1.2.2: {} + + source-map@0.6.1: {} + source-map@0.7.6: {} + split2@4.2.0: {} + + sprintf-js@1.0.3: {} + srvx@0.11.22: {} srvx@1.0.5: {} std-env@3.10.0: {} - std-env@4.2.0: {} + std-env@4.3.0: {} + + storybook@10.6.2(@types/react@19.3.0)(prettier@3.9.9)(react@19.3.0): + dependencies: + '@storybook/global': 5.0.0 + '@storybook/icons': 2.2.0(react@19.3.0) + '@testing-library/dom': 10.4.2 + '@testing-library/jest-dom': 6.9.1 + '@testing-library/user-event': 14.6.8(@testing-library/dom@10.4.2) + '@vitest/expect': 3.2.4 + '@vitest/spy': 3.2.4 + '@webcontainer/env': 1.1.1 + esbuild: 0.28.2 + jsonc-parser: 3.3.1 + open: 10.2.0 + oxc-parser: 0.127.0 + oxc-resolver: 11.21.2 + recast: 0.23.21 + semver: 7.8.5 + use-sync-external-store: 1.7.0(react@19.3.0) + ws: 8.22.0 + optionalDependencies: + '@types/react': 19.3.0 + prettier: 3.9.9 + transitivePeerDependencies: + - bufferutil + - react + - utf-8-validate string-argv@0.3.2: {} @@ -7597,9 +11114,33 @@ snapshots: dependencies: ansi-regex: 6.3.0 + strip-bom@3.0.0: {} + + strip-bom@5.0.0: {} + + strip-comments-strings@1.2.0: {} + + strip-final-newline@4.0.0: {} + + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + + strip-indent@4.1.1: {} + supports-color@10.2.2: {} - tinybench@6.2.0: {} + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + tagged-tag@1.0.0: {} + + tiny-invariant@1.3.3: {} + + tinybench@6.2.2: {} tinyexec@1.3.1: {} @@ -7608,8 +11149,73 @@ snapshots: fdir: 6.5.0(picomatch@4.0.7) picomatch: 4.0.7 + tinyrainbow@2.0.0: {} + + tinyrainbow@3.2.0: {} + + tinyspy@4.0.6: {} + + tldts-core@7.4.18: {} + + tldts@7.4.18: + dependencies: + tldts-core: 7.4.18 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + totalist@3.0.1: {} + + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.18 + + tr46@7.0.0: + dependencies: + punycode: 2.3.1 + + tree-kill@1.2.2: {} + + ts-dedent@2.3.0: {} + + tsconfig-paths@4.2.0: + dependencies: + json5: 2.2.3 + minimist: 1.2.8 + strip-bom: 3.0.0 + + tsdown@0.23.0(oxc-resolver@11.21.2)(typescript@7.0.2): + dependencies: + cac: 7.1.0 + defu: 6.1.7 + empathic: 2.0.1 + hookable: 6.1.2 + import-without-cache: 0.4.1 + obug: 2.2.1 + picomatch: 4.0.7 + rolldown: 1.2.12 + rolldown-plugin-dts: 0.28.6(patch_hash=c4d02f422daffea3f211c29c3c2b926625c17774e763cc9a3643bf730fe57eba)(oxc-resolver@11.21.2)(rolldown@1.2.12)(typescript@7.0.2) + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + tree-kill: 1.2.2 + unconfig-core: 7.5.0 + verkit: 0.4.1 + optionalDependencies: + typescript: 7.0.2 + transitivePeerDependencies: + - '@typescript/native-preview' + - '@volar/typescript' + - '@vue/language-core' + - oxc-resolver + - vue-tsc + tslib@2.8.1: {} + tstyche@7.2.5(typescript@7.0.2): + optionalDependencies: + typescript: 7.0.2 + turbo@2.11.7: optionalDependencies: '@turbo/darwin-64': 2.11.7 @@ -7619,6 +11225,16 @@ snapshots: '@turbo/windows-64': 2.11.7 '@turbo/windows-arm64': 2.11.7 + type-fest@4.41.0: {} + + type-fest@5.10.0: + dependencies: + tagged-tag: 1.0.0 + + typescript@5.9.3: {} + + typescript@6.0.3: {} + typescript@7.0.2: optionalDependencies: '@typescript/typescript-aix-ppc64': 7.0.2 @@ -7644,6 +11260,11 @@ snapshots: ufo@1.6.4: {} + unconfig-core@7.5.0: + dependencies: + '@quansync/fs': 1.1.0 + quansync: 1.0.0 + undici-types@7.24.6: {} undici@7.29.1: {} @@ -7654,9 +11275,13 @@ snapshots: dependencies: pathe: 2.0.3 + unicorn-magic@0.3.0: {} + universal-user-agent@7.0.3: {} - unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)): + universalify@2.0.1: {} + + unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)): dependencies: '@jridgewell/remapping': 2.3.5 picomatch: 4.0.7 @@ -7664,7 +11289,7 @@ snapshots: optionalDependencies: esbuild: 0.28.2 rolldown: 1.2.12 - vite: 8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1) + vite: 8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1) update-browserslist-db@1.3.3(browserslist@4.29.3): dependencies: @@ -7682,11 +11307,13 @@ snapshots: verkit@0.3.2: {} - vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1): + verkit@0.4.1: {} + + vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 - postcss: 8.5.28 + postcss: 8.5.31 rolldown: 1.2.12 tinyglobby: 0.2.17 optionalDependencies: @@ -7696,34 +11323,54 @@ snapshots: jiti: 2.6.1 yaml: 2.9.1 - vitefu@1.1.3(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)): + vitefu@1.1.3(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)): optionalDependencies: - vite: 8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1) + vite: 8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1) - vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.19.1)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)): + vitest@5.0.5(@opentelemetry/api@1.9.1)(@types/node@24.19.1)(@vitest/browser-playwright@5.0.5)(@vitest/coverage-istanbul@5.0.5)(@vitest/ui@5.0.5)(jsdom@30.1.2(@noble/hashes@2.4.0))(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)): dependencies: '@types/chai': 5.2.3 - '@vitest/mocker': 5.0.3(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) - chai: 6.2.2 + '@vitest/mocker': 5.0.5(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1)) + chai: 6.3.0 es-module-lexer: 2.3.2 expect-type: 1.4.0 - magic-string: 1.4.2 - obug: 2.2.1 + magic-string: 1.4.3 + obug: 3.0.0 picomatch: 4.0.7 - std-env: 4.2.0 - tinybench: 6.2.0 + std-env: 4.3.0 + tinybench: 6.2.2 tinyexec: 1.3.1 tinyglobby: 0.2.17 - vite: 8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1) + vite: 8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1) why-is-node-running: 3.2.1 optionalDependencies: '@opentelemetry/api': 1.9.1 '@types/node': 24.19.1 + '@vitest/browser-playwright': 5.0.5(playwright@1.63.0)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.1))(vitest@5.0.5) + '@vitest/coverage-istanbul': 5.0.5(vitest@5.0.5) + '@vitest/ui': 5.0.5(vitest@5.0.5) + jsdom: 30.1.2(@noble/hashes@2.4.0) transitivePeerDependencies: - msw + w3c-xmlserializer@6.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} + webpack-virtual-modules@0.6.2: {} + whatwg-mimetype@5.0.0: {} + + whatwg-url@17.3.0(@noble/hashes@2.4.0): + dependencies: + '@exodus/bytes': 1.16.0(@noble/hashes@2.4.0) + tr46: 7.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + which@2.0.2: dependencies: isexe: 2.0.0 @@ -7743,7 +11390,7 @@ snapshots: '@cloudflare/kv-asset-handler': 0.5.0 '@cloudflare/unenv-preset': 2.16.2(unenv@2.0.0-rc.24)(workerd@1.20261005.1) blake3-wasm: 2.1.5 - esbuild: 0.28.1 + esbuild: 0.28.2 miniflare: 5.20261001.0-alpha(@types/node@24.19.1) path-to-regexp: 6.3.0 unenv: 2.0.0-rc.24 @@ -7768,10 +11415,20 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.2.0 + write-file-atomic@7.0.1: + dependencies: + signal-exit: 4.1.0 + ws@8.21.0: {} ws@8.22.0: {} + wsl-utils@0.1.0: + dependencies: + is-wsl: 3.1.1 + + xml-name-validator@5.0.0: {} + xmlbuilder2@4.0.3: dependencies: '@oozcitak/dom': 2.0.2 @@ -7779,6 +11436,8 @@ snapshots: '@oozcitak/util': 10.0.0 js-yaml: 4.3.2 + xmlchars@2.2.0: {} + y18n@5.0.8: {} yallist@3.1.1: {} @@ -7812,6 +11471,10 @@ snapshots: dependencies: pend: 1.2.0 + yocto-queue@1.2.2: {} + + yoctocolors@2.2.0: {} + youch-core@0.3.3: dependencies: '@poppinss/exception': 1.2.3 @@ -7825,6 +11488,45 @@ snapshots: cookie: 1.1.1 youch-core: 0.3.3 + yuku-ast@0.10.2: + dependencies: + '@yuku-toolchain/types': 0.10.2 + + yuku-codegen@0.10.2: + dependencies: + '@yuku-toolchain/types': 0.10.2 + optionalDependencies: + '@yuku-codegen/binding-android-arm64': 0.10.2 + '@yuku-codegen/binding-darwin-arm64': 0.10.2 + '@yuku-codegen/binding-darwin-x64': 0.10.2 + '@yuku-codegen/binding-freebsd-x64': 0.10.2 + '@yuku-codegen/binding-linux-arm-gnu': 0.10.2 + '@yuku-codegen/binding-linux-arm-musl': 0.10.2 + '@yuku-codegen/binding-linux-arm64-gnu': 0.10.2 + '@yuku-codegen/binding-linux-arm64-musl': 0.10.2 + '@yuku-codegen/binding-linux-x64-gnu': 0.10.2 + '@yuku-codegen/binding-linux-x64-musl': 0.10.2 + '@yuku-codegen/binding-win32-arm64': 0.10.2 + '@yuku-codegen/binding-win32-x64': 0.10.2 + + yuku-parser@0.10.2: + dependencies: + '@yuku-toolchain/types': 0.10.2 + yuku-ast: 0.10.2 + optionalDependencies: + '@yuku-parser/binding-android-arm64': 0.10.2 + '@yuku-parser/binding-darwin-arm64': 0.10.2 + '@yuku-parser/binding-darwin-x64': 0.10.2 + '@yuku-parser/binding-freebsd-x64': 0.10.2 + '@yuku-parser/binding-linux-arm-gnu': 0.10.2 + '@yuku-parser/binding-linux-arm-musl': 0.10.2 + '@yuku-parser/binding-linux-arm64-gnu': 0.10.2 + '@yuku-parser/binding-linux-arm64-musl': 0.10.2 + '@yuku-parser/binding-linux-x64-gnu': 0.10.2 + '@yuku-parser/binding-linux-x64-musl': 0.10.2 + '@yuku-parser/binding-win32-arm64': 0.10.2 + '@yuku-parser/binding-win32-x64': 0.10.2 + zeptomatch@2.1.0: dependencies: grammex: 3.1.13 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c9ed1f5..720fb73 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -13,8 +13,14 @@ catalog: "@commitlint/cli": 21.2.3 "@commitlint/config-conventional": 21.2.3 "@commitlint/types": 21.2.3 - "@effect/tsgo": 0.51.0 + "@effect/tsgo": 0.51.1 + "@microsoft/api-extractor": 7.59.4 "@rolldown/plugin-babel": 0.2.4 + "@storybook/addon-vitest": 10.6.2 + "@storybook/react-vite": 10.6.2 + "@systemfsoftware/arethetypeswrong-cli": file:.sfs-deps/arethetypeswrong-cli-4.2.0.tgz + "@systemfsoftware/conformance-spec": file:.sfs-deps/conformance-spec-1.0.0.tgz + "@systemfsoftware/differential-spec": file:.sfs-deps/differential-spec-1.0.1.tgz "@systemfsoftware/effect-cell-types": file:.sfs-deps/effect-cell-types-12.0.0.tgz "@systemfsoftware/effect-gherkin-spec": file:.sfs-deps/effect-gherkin-spec-7.0.1.tgz "@systemfsoftware/effect-schema-discovery": file:.sfs-deps/effect-schema-discovery-2.0.0.tgz @@ -26,6 +32,7 @@ catalog: "@systemfsoftware/oxlint-config-cell-architecture": file:.sfs-deps/oxlint-config-cell-architecture-4.0.0.tgz "@systemfsoftware/oxlint-config-dmmf": file:.sfs-deps/oxlint-config-dmmf-2.0.0.tgz "@systemfsoftware/oxlint-config-recommended": file:.sfs-deps/oxlint-config-recommended-4.0.0.tgz + "@systemfsoftware/oxlint-config-rule-authoring": file:.sfs-deps/oxlint-config-rule-authoring-1.1.0.tgz "@systemfsoftware/oxlint-ignorer-config": file:.sfs-deps/oxlint-ignorer-config-0.1.1.tgz "@systemfsoftware/oxlint-plugin-cell-architecture": file:.sfs-deps/oxlint-plugin-cell-architecture-5.0.0.tgz "@systemfsoftware/oxlint-plugin-dmmf-workflow": file:.sfs-deps/oxlint-plugin-dmmf-workflow-7.0.0.tgz @@ -49,30 +56,60 @@ catalog: "@systemfsoftware/stryker-js-typescript-checker": file:.sfs-deps/stryker-js-typescript-checker-8.2.1.tgz "@systemfsoftware/stryker-js-vitest-runner": file:.sfs-deps/stryker-js-vitest-runner-8.1.1.tgz "@systemfsoftware/stryker-js-vm-runner": file:.sfs-deps/stryker-js-vitest-runner-8.1.1.tgz + "@systemfsoftware/stryker-config": file:.sfs-deps/stryker-config-0.1.0.tgz "@systemfsoftware/tsconfig": file:.sfs-deps/tsconfig-2.0.1.tgz + "@systemfsoftware/tsdown-config": file:.sfs-deps/tsdown-config-0.1.0.tgz "@systemfsoftware/vitest": file:.sfs-deps/vitest-2.0.0.tgz + "@systemfsoftware/vitest-config": file:.sfs-deps/vitest-config-0.1.0.tgz "@systemfsoftware/xstate": file:.sfs-deps/xstate-6.0.0-alpha.64.tgz "@tanstack/react-router": 1.170.41 "@tanstack/react-start": 1.168.60 "@tanstack/router-cli": 1.167.40 + "@testing-library/dom": 10.4.2 + "@testing-library/react": 16.3.3 "@types/node": 24.19.1 "@types/react": 19.3.0 "@types/react-dom": 19.3.0 + "@types/scheduler": 0.28.0 + "@vitest/browser": 5.0.5 + "@vitest/browser-playwright": 5.0.5 + "@vitest/coverage-istanbul": 5.0.5 "@vitejs/plugin-react": 6.1.2 alchemy: 2.0.0-beta.80 babel-plugin-react-compiler: 1.0.0 - effect: 4.0.1 + effect: 4.0.5 + fast-check: 4.10.2 lint-staged: 17.6.0 - oxlint: 1.82.0 - oxlint-tsgolint: 7.0.2001 + oxlint: 1.87.0 + oxlint-tsgolint: 7.0.2003 playwright: 1.63.0 - react: 19.3.0 - react-dom: 19.3.0 + react: ^19.3.0 + react-dom: ^19.3.0 + react-error-boundary: 6.1.6 + rimraf: 6.1.3 + scheduler: 0.28.0 + storybook: 10.6.2 + tsdown: 0.23.0 + tstyche: 7.2.5 turbo: 2.11.7 + type-fest: ^5.10.0 typescript: 7.0.2 - vite: 8.3.2 - vitest: 5.0.3 + vite: 8.3.4 + vitest: ^5.0.5 wrangler: 4.147.0 +catalogs: + oxlint: + "@oxlint/plugins": 1.87.0 + oxlint: 1.87.0 + oxlint-tsgolint: 7.0.2003 + peers: + effect: ^4 + stryker: + "@systemfsoftware/stryker-ignorer-effect-schema-declarations": file:.sfs-deps/stryker-ignorer-effect-schema-declarations-0.2.0.tgz + "@systemfsoftware/stryker-ignorer-in-source-vitest-block": file:.sfs-deps/stryker-ignorer-in-source-vitest-block-0.1.1.tgz + "@systemfsoftware/stryker-js": file:.sfs-deps/stryker-js-17.0.2.tgz + "@systemfsoftware/stryker-js-typescript-checker": file:.sfs-deps/stryker-js-typescript-checker-8.2.1.tgz + "@systemfsoftware/stryker-js-vitest-runner": file:.sfs-deps/stryker-js-vitest-runner-8.1.1.tgz catalogMode: prefer ignoreScripts: true minimumReleaseAge: 1440 @@ -81,6 +118,9 @@ minimumReleaseAgeExclude: - "@effect/*" verifyDepsBeforeRun: warn overrides: + "@systemfsoftware/arethetypeswrong-cli": "catalog:" + "@systemfsoftware/conformance-spec": "catalog:" + "@systemfsoftware/differential-spec": "catalog:" "@systemfsoftware/effect-cell-types": "catalog:" "@systemfsoftware/effect-gherkin-spec": "catalog:" "@systemfsoftware/effect-schema-discovery": "catalog:" @@ -92,6 +132,7 @@ overrides: "@systemfsoftware/oxlint-config-cell-architecture": "catalog:" "@systemfsoftware/oxlint-config-dmmf": "catalog:" "@systemfsoftware/oxlint-config-recommended": "catalog:" + "@systemfsoftware/oxlint-config-rule-authoring": "catalog:" "@systemfsoftware/oxlint-ignorer-config": "catalog:" "@systemfsoftware/oxlint-plugin-cell-architecture": "catalog:" "@systemfsoftware/oxlint-plugin-dmmf-workflow": "catalog:" @@ -115,13 +156,23 @@ overrides: "@systemfsoftware/stryker-js-typescript-checker": "catalog:" "@systemfsoftware/stryker-js-vitest-runner": "catalog:" "@systemfsoftware/stryker-js-vm-runner": "catalog:" + "@systemfsoftware/stryker-config": "catalog:" "@systemfsoftware/tsconfig": "catalog:" + "@systemfsoftware/tsdown-config": "catalog:" "@systemfsoftware/vitest": "catalog:" + "@systemfsoftware/vitest-config": "catalog:" "@systemfsoftware/xstate": "catalog:" + esbuild: ^0.28.2 + rolldown-plugin-dts: 0.28.6 workerd: 1.20261005.1 packages: - apps/* - packages/* + - packages/atom/* + - packages/gherkin/* + - packages/oxlint-presets/* +patchedDependencies: + rolldown-plugin-dts@0.28.6: patches/rolldown-plugin-dts@0.28.6.patch supportedArchitectures: os: [linux] cpu: [x64, arm64] diff --git a/turbo.json b/turbo.json index 4b86ee0..1c70a3d 100644 --- a/turbo.json +++ b/turbo.json @@ -31,7 +31,10 @@ "tsconfig.json", "tsconfig.*.json", "package.json", - "tsdown.config.*" + "tsdown.config.*", + "api-extractor.json", + "api-extractor.*.json", + "etc/*.api.md" ], "outputs": [ "dist/**" @@ -51,6 +54,7 @@ "outputs": [], "dependsOn": [ "^build", + "build", "generate" ], "cache": true @@ -106,6 +110,58 @@ "CI", "AGENT" ], + "passThroughEnv": [ + "PLAYWRIGHT_BROWSERS_PATH", + "FONTCONFIG_FILE" + ], + "cache": true + }, + "test:browser": { + "outputLogs": "errors-only", + "dependsOn": [ + "^build", + "build" + ], + "outputs": [], + "env": [ + "NODE_ENV", + "CI", + "AGENT" + ], + "passThroughEnv": [ + "PLAYWRIGHT_BROWSERS_PATH", + "FONTCONFIG_FILE" + ], + "cache": true + }, + "test:types": { + "inputs": [ + "$TURBO_DEFAULT$", + "tstyche.json", + "test-types/**", + "tsconfig.json", + "tsconfig.*.json", + "!**/*.md" + ], + "outputLogs": "errors-only", + "dependsOn": [ + "^build", + "build" + ], + "outputs": [], + "cache": true + }, + "lint:tsgo": { + "inputs": [ + "$TURBO_DEFAULT$", + "tsconfig.json", + "tsconfig.*.json", + "!**/*.md" + ], + "outputs": [], + "env": [ + "TSGO_FORMAT" + ], "cache": true }, "mutation": {