From 474ec4c9be28dbc4a68d2bb91ebdff856b510d8e Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Fri, 9 Oct 2026 21:40:02 +0200 Subject: [PATCH 01/97] fix: enforce strict quarantine in isolated and frozen installs --- .github/workflows/ci.yml | 3 + .github/workflows/release.yml | 2 + AGENTS.md | 4 + apps/docs/src/content/docs/examples.md | 4 + .../docs/getting-started/installation.md | 4 + ...-new-dependency-releases-for-seven-days.md | 16 +- docs/agents/install-policy.md | 39 +++++ pnpm-workspace.yaml | 1 + scripts/install-policy.mts | 30 ++++ scripts/verify-install-policy.mts | 163 ++++++++++++++++++ scripts/verify-packed-consumer.mts | 2 + vite.config.ts | 12 +- 12 files changed, 275 insertions(+), 5 deletions(-) create mode 100644 docs/agents/install-policy.md create mode 100644 scripts/install-policy.mts create mode 100644 scripts/verify-install-policy.mts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0dd8acd..8a9d044 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,9 @@ jobs: - name: Verify formatting run: vp run format + - name: Verify dependency quarantine + run: vp run install-policy + - name: Construct package prerequisite run: vp run pack diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 361580b..3a7deec 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,6 +9,8 @@ on: - pnpm-lock.yaml - pnpm-workspace.yaml - scripts/verify-packed-consumer.mts + - scripts/install-policy.mts + - scripts/verify-install-policy.mts - vite.config.ts push: branches: diff --git a/AGENTS.md b/AGENTS.md index 9767767..66df0fb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,10 @@ Before exploring the codebase, read [the domain documentation guide](docs/agents Check `package.json` and `vite.config.ts` first, and run `vp run ` when the project defines a script or task with that name. +### Dependency installs + +Before installing or changing dependencies, read [the install policy](docs/agents/install-policy.md). Every registry dependency must satisfy pnpm's strict seven-day quarantine, including isolated consumers and frozen or cached installs. No exceptions or bypasses. + ### Testing Use compile-time fixtures to verify the published type contract. Reserve runtime tests for behavior that can fail at runtime; avoid testing guarantees already enforced by the type system. diff --git a/apps/docs/src/content/docs/examples.md b/apps/docs/src/content/docs/examples.md index 8b3f9cd..6e49a82 100644 --- a/apps/docs/src/content/docs/examples.md +++ b/apps/docs/src/content/docs/examples.md @@ -29,6 +29,10 @@ the workspace dependencies: pnpm install --frozen-lockfile ``` +The workspace enforces a strict seven-day quarantine for every registry dependency. Frozen +lockfiles and cached packages remain subject to the same check, and missing publication times +fail installation. Wait for rejected versions to become eligible; do not approve an exception. + The workspace includes Vite+. If `vp` is not on your shell's PATH, prefix the commands below with `pnpm exec`, for example `pnpm exec vp run vite-react-dev`. diff --git a/apps/docs/src/content/docs/getting-started/installation.md b/apps/docs/src/content/docs/getting-started/installation.md index f2f8f63..b7d4e67 100644 --- a/apps/docs/src/content/docs/getting-started/installation.md +++ b/apps/docs/src/content/docs/getting-started/installation.md @@ -9,6 +9,10 @@ Install the package and TanStack Query Core: pnpm add effect-api-query @tanstack/query-core ``` +This repository uses a strict seven-day dependency quarantine. A newly published package version +becomes eligible for registry installation seven days after publication; this includes the initial +release. Use an eligible version and preserve your application's release-age policy. + With pnpm's default peer installation enabled, this also installs the exact Effect release required by the package. If your application already pins Effect or disables automatic peer installation, install the Effect version from the package's `peerDependencies` and resolve any peer mismatch diff --git a/docs/adr/0017-quarantine-new-dependency-releases-for-seven-days.md b/docs/adr/0017-quarantine-new-dependency-releases-for-seven-days.md index d171663..9524ca6 100644 --- a/docs/adr/0017-quarantine-new-dependency-releases-for-seven-days.md +++ b/docs/adr/0017-quarantine-new-dependency-releases-for-seven-days.md @@ -9,10 +9,18 @@ Immediate dependency upgrades provide too little time to observe supply-chain in ## Decision pnpm enforces a strict seven-day minimum release age, frozen lockfiles in CI, and explicit -lifecycle-script permissions. +lifecycle-script permissions. Missing registry publication times fail closed. The same policy +applies to workspace packages, examples, CI, and isolated consumers. Lockfile verification stays +enabled so frozen and cached installations also reject ineligible versions. ## Consequences -A fresh Effect release candidate or another intentionally evaluated tool may receive one reviewed, -exact-version exception after its release, provenance, scripts, and lockfile changes are checked. -Package-wide exemptions remain forbidden. Each exception expires with the quarantine. +No release-age exceptions, exclusions, configuration overrides, alternate installers, or missing-time +bypasses are allowed. An ineligible version remains blocked until its registry publication is seven +days old. Record its next eligibility time and continue independent work with eligible dependencies. +Existing version-resolution overrides remain valid when they preserve this policy. + +Locally built project tarballs can be verified before publication; their registry dependencies still +obey the quarantine. External installation of a newly published version waits for that version's +own seven-day eligibility. See the [install policy](../agents/install-policy.md) for consumer setup +and verification. diff --git a/docs/agents/install-policy.md b/docs/agents/install-policy.md new file mode 100644 index 0000000..70aff91 --- /dev/null +++ b/docs/agents/install-policy.md @@ -0,0 +1,39 @@ +# Dependency installation + +Use the pnpm version pinned in `package.json`. Keep these workspace settings: + +```yaml +minimumReleaseAge: 10080 +minimumReleaseAgeStrict: true +minimumReleaseAgeIgnoreMissingTime: false +trustLockfile: false +``` + +Every registry version, including transitive dependencies, must have a known publication time +at least seven days old. Do not approve pnpm's interactive exception prompt, add an exclusion, +override these settings through flags or environment variables, trust the lockfile without +verification, use an alternate installer, or replace an ineligible registry package with a direct +URL or copied artifact. This also applies to package-manager and tool upgrades: verify their +publication dates before updating their exact pins. + +Run workspace installs from the root. CI and the Dev Container retain `--frozen-lockfile` and the +existing `allowBuilds` lifecycle permissions. Frozen lockfiles and cached artifacts are rechecked +against registry publication metadata; neither supplies an age exemption. + +For a temporary consumer, call `writeConsumerWorkspace` from `scripts/install-policy.mts` before +its pnpm install. It copies the full workspace policy, including catalogs and lifecycle permissions, +and rejects weakened quarantine settings. Preserve `--ignore-scripts` for packed consumers. +Locally built project tarballs are repository artifacts; their registry dependencies still need +to satisfy the quarantine. + +When an install fails, record the rejected version, registry publication time, and earliest +eligibility time. A missing publication time remains a blocking condition. Continue independent +work using already-installed dependencies read-only. Do not weaken the policy to unblock work. + +Run `vp run install-policy` after changes to installation policy or consumer setup. This invokes +the real pnpm CLI against an isolated fixture registry and checks fresh resolution plus frozen +installs with cached artifacts, including under-age and missing-time rejection. The normal packed +consumer checks verify real eligible registry dependencies with the same copied policy. + +After publishing a new package version, registry installation waits for that version's own +seven-day eligibility. Metadata and provenance checks and local-tarball acceptance may run sooner. diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d68773d..ce165b2 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -26,6 +26,7 @@ catalog: minimumReleaseAge: 10080 minimumReleaseAgeStrict: true minimumReleaseAgeIgnoreMissingTime: false +trustLockfile: false allowBuilds: 'esbuild@0.28.2': true diff --git a/scripts/install-policy.mts b/scripts/install-policy.mts new file mode 100644 index 0000000..3bb37ed --- /dev/null +++ b/scripts/install-policy.mts @@ -0,0 +1,30 @@ +// fallow-ignore-file unused-file +// Used by Vite+'s dynamically registered packed-package and install-policy verifiers. +import { readFileSync, writeFileSync } from 'node:fs' +import nodePath from 'node:path' + +const repositoryRoot = nodePath.resolve(import.meta.dirname, '..') + +/** Copy the workspace's quarantine and lifecycle policy into an isolated consumer. */ +export const writeConsumerWorkspace = (directory: string): void => { + const workspace = readFileSync(nodePath.join(repositoryRoot, 'pnpm-workspace.yaml'), 'utf-8') + for (const setting of [ + 'minimumReleaseAge: 10080', + 'minimumReleaseAgeStrict: true', + 'minimumReleaseAgeIgnoreMissingTime: false', + 'trustLockfile: false', + ]) { + if (!workspace.split('\n').includes(setting)) { + throw new Error(`The install policy requires ${setting}`) + } + } + if (/minimumReleaseAgeExclude\s*:/u.test(workspace)) { + throw new Error('Release-age exclusions are forbidden') + } + // Keep catalogs, version-resolution overrides and lifecycle permissions unchanged. + // Every isolated fixture is its own workspace, never a member of the source tree. + writeFileSync( + nodePath.join(directory, 'pnpm-workspace.yaml'), + workspace.replace(/^packages:\n(?:[ \t]+[^\n]*\n)+/mu, 'packages:\n - .\n'), + ) +} diff --git a/scripts/verify-install-policy.mts b/scripts/verify-install-policy.mts new file mode 100644 index 0000000..2618725 --- /dev/null +++ b/scripts/verify-install-policy.mts @@ -0,0 +1,163 @@ +// fallow-ignore-file unused-file +// Vite+ invokes this verifier through the install-policy task. +import { equal, match, ok } from 'node:assert/strict' +import { execFileSync, spawn } from 'node:child_process' +import { createHash } from 'node:crypto' +import { once } from 'node:events' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { createServer } from 'node:http' +import { tmpdir } from 'node:os' +import nodePath from 'node:path' + +import { writeConsumerWorkspace } from './install-policy.mts' + +const root = mkdtempSync(nodePath.join(tmpdir(), 'effect-api-query-install-policy-')) +const packageName = 'effect-api-query-install-policy-fixture' +const version = '1.0.0' +let publishedAt: string | undefined = new Date(Date.now() - 10 * 24 * 60 * 60_000).toISOString() +let tarball: Buffer +let registry: string +const server = createServer((request, response) => { + if (request.url === '/fixture.tgz') { + response.end(tarball) + return + } + if (request.url !== `/${packageName}`) { + response.writeHead(404).end() + return + } + response.setHeader('content-type', 'application/json') + response.end( + JSON.stringify({ + name: packageName, + 'dist-tags': { latest: version }, + versions: { + [version]: { + name: packageName, + version, + dist: { + tarball: `${registry}/fixture.tgz`, + integrity: `sha512-${createHash('sha512').update(tarball).digest('base64')}`, + }, + }, + }, + time: publishedAt === undefined ? undefined : { [version]: publishedAt }, + }), + ) +}) + +const install = async ( + directory: string, + frozen: boolean, + cache: string, +): Promise<{ code: number | null; output: string }> => { + const child = spawn( + process.env['EFFECT_API_QUERY_PNPM'] ?? 'pnpm', + [ + 'install', + '--ignore-scripts', + '--prefer-offline', + `--registry=${registry}`, + `--store-dir=${nodePath.join(root, 'store')}`, + `--state-dir=${nodePath.join(root, cache)}`, + ...(frozen ? ['--frozen-lockfile'] : ['--no-frozen-lockfile']), + ], + { + cwd: directory, + env: { ...process.env, CI: 'true', XDG_CACHE_HOME: nodePath.join(root, cache) }, + }, + ) + let output = '' + let timedOut = false + const timeout = setTimeout(() => { + timedOut = true + child.kill('SIGKILL') + }, 30_000) + child.stdout.on('data', (data: Buffer) => { + output += data.toString() + }) + child.stderr.on('data', (data: Buffer) => { + output += data.toString() + }) + try { + await once(child, 'close') + if (timedOut) { + throw new Error(`pnpm install timed out: ${output}`) + } + return { code: child.exitCode, output } + } finally { + clearTimeout(timeout) + } +} + +const consumer = (name: string): string => { + const directory = nodePath.join(root, name) + mkdirSync(directory) + writeConsumerWorkspace(directory) + writeFileSync( + nodePath.join(directory, 'package.json'), + JSON.stringify({ name, private: true, dependencies: { [packageName]: version } }), + ) + return directory +} + +try { + writeConsumerWorkspace(root) + const config = readFileSync(nodePath.join(root, 'pnpm-workspace.yaml'), 'utf-8') + match(config, /^minimumReleaseAge: 10080$/mu) + match(config, /^minimumReleaseAgeStrict: true$/mu) + match(config, /^minimumReleaseAgeIgnoreMissingTime: false$/mu) + match(config, /^trustLockfile: false$/mu) + equal(/minimumReleaseAgeExclude:/u.test(config), false) + console.log('Install policy applies to isolated consumers') + + const source = nodePath.join(root, 'package') + mkdirSync(source) + writeFileSync( + nodePath.join(source, 'package.json'), + JSON.stringify({ name: packageName, version }), + ) + const archive = nodePath.join(root, 'fixture.tgz') + execFileSync('tar', ['-czf', archive, '-C', root, 'package']) + tarball = readFileSync(archive) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + const address = server.address() + // Node's address() contract distinguishes a TCP address from a Unix pipe path. + // oxlint-disable-next-line anti-slop/no-runtime-typeof + ok(address !== null && typeof address !== 'string') + registry = `http://127.0.0.1:${address.port}` + + const eligible = consumer('eligible') + let result = await install(eligible, false, 'eligible-cache') + equal(result.code, 0, result.output) + result = await install(eligible, true, 'eligible-frozen-cache') + equal(result.code, 0, result.output) + console.log('PASS eligible fresh and frozen cached-artifact installs') + + // The already-resolved lockfile and populated store stay intact. Only the external + // registry metadata changes; no age exception or alternate installer creates them. + publishedAt = new Date().toISOString() + result = await install(consumer('young-fresh'), false, 'young-fresh-cache') + ok(result.code !== 0, result.output) + match(result.output, /ERR_PNPM_NO_MATURE_MATCHING_VERSION/u) + console.log('PASS under-age fresh resolution rejection') + result = await install(eligible, true, 'young-frozen-cache') + ok(result.code !== 0, result.output) + match(result.output, /ERR_PNPM_MINIMUM_RELEASE_AGE/u) + console.log('PASS under-age already-resolved frozen cached-artifact rejection') + + publishedAt = undefined + result = await install(consumer('undated-fresh'), false, 'undated-fresh-cache') + ok(result.code !== 0, result.output) + match(result.output, /ERR_PNPM_MISSING_TIME/u) + console.log('PASS missing publication-time fresh rejection') + result = await install(eligible, true, 'undated-frozen-cache') + ok(result.code !== 0, result.output) + match(result.output, /ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION/u) + match(result.output, /could not be checked/u) + console.log('PASS missing publication-time already-resolved frozen cached-artifact rejection') +} finally { + server.close() + rmSync(root, { recursive: true, force: true }) +} diff --git a/scripts/verify-packed-consumer.mts b/scripts/verify-packed-consumer.mts index 5d26fc2..9a55197 100644 --- a/scripts/verify-packed-consumer.mts +++ b/scripts/verify-packed-consumer.mts @@ -20,6 +20,7 @@ import nodePath from 'node:path' import ts from 'typescript-5.9' import repositoryManifest from '../package.json' with { type: 'json' } +import { writeConsumerWorkspace } from './install-policy.mts' const repositoryRoot = nodePath.resolve(import.meta.dirname, '..') const artifactDirectory = nodePath.join(repositoryRoot, '.artifacts') @@ -448,6 +449,7 @@ const verifyConsumer = (peer: (typeof peerCases)[number]): void => { // Prefer cached artifacts, but allow a fresh machine to fetch exact pinned versions. // The temporary project must resolve every peer from its own node_modules. + writeConsumerWorkspace(consumerDirectory) execFileSync('pnpm', ['install', '--ignore-scripts', '--prefer-offline'], { cwd: consumerDirectory, stdio: 'inherit', diff --git a/vite.config.ts b/vite.config.ts index f0257cf..cc75c93 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -298,6 +298,10 @@ export default defineConfig({ command: 'node scripts/verify-packed-consumer.mts', cache: false, }, + 'install-policy': { + command: 'node scripts/verify-install-policy.mts', + cache: false, + }, 'skills-check': { command: 'intent validate skills --check', cache: { @@ -339,7 +343,13 @@ export default defineConfig({ cache: false, }, quality: { - command: ['vp run check', 'vp run effect-check', 'vp run fallow', 'vp run test'], + command: [ + 'vp run install-policy', + 'vp run check', + 'vp run effect-check', + 'vp run fallow', + 'vp run test', + ], }, server: { command: 'vp run --filter @effect-api-query/server dev', From d6b08d1380b0e10462461c7bbd7f12a73017e238 Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Fri, 9 Oct 2026 21:41:05 +0200 Subject: [PATCH 02/97] test: prove public server-local RPC ready clients --- ...ed-message-clients-for-server-local-rpc.md | 47 ++++ ...reate-rpc-query-utils-server-local.test.ts | 252 ++++++++++++++++++ tests/fixtures/server-local-rpc.ts | 23 ++ tests/fixtures/tsconfig.server-local.json | 4 + tests/types/server-local-client-contract.ts | 35 +++ tsconfig.json | 1 + vite.config.ts | 11 +- 7 files changed, 372 insertions(+), 1 deletion(-) create mode 100644 docs/adr/0025-use-public-decoded-message-clients-for-server-local-rpc.md create mode 100644 tests/create-rpc-query-utils-server-local.test.ts create mode 100644 tests/fixtures/server-local-rpc.ts create mode 100644 tests/fixtures/tsconfig.server-local.json create mode 100644 tests/types/server-local-client-contract.ts diff --git a/docs/adr/0025-use-public-decoded-message-clients-for-server-local-rpc.md b/docs/adr/0025-use-public-decoded-message-clients-for-server-local-rpc.md new file mode 100644 index 0000000..5c14519 --- /dev/null +++ b/docs/adr/0025-use-public-decoded-message-clients-for-server-local-rpc.md @@ -0,0 +1,47 @@ +# Use public decoded-message clients for server-local RPC + +Status: Accepted. Extends [ADR 0007](0007-keep-the-core-factory-lifecycle-neutral.md) +and [ADR 0019](0019-host-effect-rpc-inside-tanstack-start.md). + +Effect 4.0.0 exposes `RpcServer.makeNoSerialization` and +`RpcClient.makeNoSerialization` as public constructors for decoded message +channels. An application may connect their write callbacks within its own +Scope, supply the server handler and middleware Context, and pass the resulting +flat ready client to `createRpcQueryUtils`. Server-local execution therefore +needs no loopback request or library-owned client factory. The browser keeps +its independently scoped HTTP or socket client. + +Use a separate connection and QueryClient for each request identity. The +server-owned handler Context determines services and authority; validate +request headers in middleware. Do not substitute arbitrary caller Context for +the server Context. Keep stream acknowledgements enabled, and set the server's +`disableFatalDefects: true` to preserve complete failed Exits rather than +collapsing fatal defect Causes. Cancel queries before closing the caller-owned +Scope. + +This route constructs payloads and carries decoded values, middleware headers, +Exits, acknowledgements, and interrupts. It deliberately performs no transport +Schema encoding or decoding. Native call types retain residual Schema service +requirements, so the adapter still requires the application's matching runner; +this does not prove that codec effects execute on a decoded channel. Applications +that require wire validation or codec side effects should keep the schema-aware +`RpcClient.make` protocol. An application-owned `FetchHttpClient.Fetch` can +dispatch that protocol to its existing Web handler without a network request; +that separate recipe must verify its own transport and resource semantics. + +The bounded executable proof is +[`tests/create-rpc-query-utils-server-local.test.ts`](../../tests/create-rpc-query-utils-server-local.test.ts), +with its application recipe in +[`tests/fixtures/server-local-rpc.ts`](../../tests/fixtures/server-local-rpc.ts) +and native service/type evidence in +[`tests/types/server-local-client-contract.ts`](../../tests/types/server-local-client-contract.ts). +It uses the public constructors directly, with the network fetch boundary +disabled. It verifies decoded class behavior, payload defaults, authenticated +request isolation, complete Causes, cancellation, finalization, and independent +Scope disposal. `RpcTest` and `HttpApiTest` remain test support rather than +production construction guarantees. The unstable upstream constructors remain +qualified by the package's exact Effect peer. + +`vp run test` includes the native constructor contract through +`vp run server-local-types`, using the repository compiler and TypeScript 5.9. +Published adapter contracts remain covered by the existing packed consumers. diff --git a/tests/create-rpc-query-utils-server-local.test.ts b/tests/create-rpc-query-utils-server-local.test.ts new file mode 100644 index 0000000..3d02a3f --- /dev/null +++ b/tests/create-rpc-query-utils-server-local.test.ts @@ -0,0 +1,252 @@ +import { CancelledError, QueryClient } from '@tanstack/query-core' +import { Cause, Context, Deferred, Effect, Exit, Layer, Schema, Scope } from 'effect' +import { Rpc, RpcClient, RpcGroup, RpcMiddleware } from 'effect/rpc' +import { describe, expect, it, vi } from 'vite-plus/test' + +import { createRpcQueryUtils, isEffectRpcQueryError } from '#effect-api-query' + +import { captureFailure } from './fixtures/async.ts' +import { makeServerLocalRpcClient } from './fixtures/server-local-rpc.ts' + +describe('server-local ready-client construction', () => { + it('executes a production decoded-message client without calling the network fetch boundary', async () => { + const networkFetch = vi + .spyOn(globalThis, 'fetch') + .mockRejectedValue(new Error('Network disabled')) + const scope = Scope.makeUnsafe() + try { + class Profile extends Schema.Class('ServerLocalProfile')({ + name: Schema.String, + }) { + greeting(): string { + return `Hello, ${this.name}` + } + } + const Read = Rpc.make('profiles.read', { + payload: { + name: Schema.String.pipe(Schema.withConstructorDefault(Effect.succeed('Ada'))), + }, + success: Profile, + }) + const group = RpcGroup.make(Read) + const handlers = group.toLayer({ + 'profiles.read': ({ name }) => Effect.succeed(new Profile({ name })), + }) + const client = await Effect.runPromise( + makeServerLocalRpcClient(group).pipe(Effect.provide(handlers), Scope.provide(scope)), + ) + const queryClient = new QueryClient() + const utils = createRpcQueryUtils(group, { client, keyPrefix: ['local'] }) + const profile = await queryClient.query(utils.profiles.read.queryOptions({ input: {} })) + expect(profile.greeting()).toBe('Hello, Ada') + expect(networkFetch).not.toHaveBeenCalled() + } finally { + await Effect.runPromise(Scope.close(scope, Exit.void)) + networkFetch.mockRestore() + } + }) + + it('isolates server-owned services and authenticated headers between concurrent local request scopes', async () => { + class RequestOwner extends Context.Service()( + 'effect-api-query/tests/ServerLocalRequestOwner', + ) {} + class Authorization extends RpcMiddleware.Service()( + 'effect-api-query/tests/ServerLocalAuthorization', + { error: Schema.Literal('unauthorized') }, + ) {} + const Read = Rpc.make('owner.read', { success: Schema.String }).middleware(Authorization) + const group = RpcGroup.make(Read) + const handlers = group.toLayer({ + 'owner.read': Effect.fnUntraced(function* () { + return (yield* RequestOwner).name + }), + }) + const authorization = Layer.effect( + Authorization, + Effect.gen(function* () { + const owner = yield* RequestOwner + return Authorization.of((effect, { headers }) => + headers['x-owner'] === owner.name ? effect : Effect.fail('unauthorized' as const), + ) + }), + ) + const firstScope = Scope.makeUnsafe() + const secondScope = Scope.makeUnsafe() + try { + const first = await Effect.runPromise( + makeServerLocalRpcClient(group).pipe( + Effect.provide(Layer.mergeAll(handlers, authorization)), + Effect.provideService(RequestOwner, { name: 'first-owner' }), + Scope.provide(firstScope), + ), + ) + const second = await Effect.runPromise( + makeServerLocalRpcClient(group).pipe( + Effect.provide(Layer.mergeAll(handlers, authorization)), + Effect.provideService(RequestOwner, { name: 'second-owner' }), + Scope.provide(secondScope), + ), + ) + const firstQueryClient = new QueryClient() + const secondQueryClient = new QueryClient() + const firstUtils = createRpcQueryUtils(group, { + client: first, + keyPrefix: ['local', 'first-owner'], + runPromiseExit: async (effect, options) => + Effect.runPromiseExit( + RpcClient.withHeaders(effect, { 'x-owner': 'first-owner' }), + options, + ), + }) + const secondUtils = createRpcQueryUtils(group, { + client: second, + keyPrefix: ['local', 'second-owner'], + runPromiseExit: async (effect, options) => + Effect.runPromiseExit( + RpcClient.withHeaders(effect, { 'x-owner': 'second-owner' }), + options, + ), + }) + await expect( + Promise.all([ + firstQueryClient.query(firstUtils.owner.read.queryOptions()), + secondQueryClient.query(secondUtils.owner.read.queryOptions()), + ]), + ).resolves.toStrictEqual(['first-owner', 'second-owner']) + expect( + secondQueryClient.getQueryData(firstUtils.owner.read.queryOptions().queryKey), + ).toBeUndefined() + await expect(Effect.runPromise(Effect.flip(first('owner.read', undefined)))).resolves.toBe( + 'unauthorized', + ) + await expect( + Effect.runPromise( + Effect.flip(first('owner.read', undefined, { headers: { 'x-owner': 'second-owner' } })), + ), + ).resolves.toBe('unauthorized') + + await Effect.runPromise(Scope.close(firstScope, Exit.void)) + const disposed = await Effect.runPromiseExit( + first('owner.read', undefined, { headers: { 'x-owner': 'first-owner' } }), + ) + expect(Exit.isFailure(disposed) && Cause.hasInterrupts(disposed.cause)).toBe(true) + await expect( + Effect.runPromise( + second('owner.read', undefined, { headers: { 'x-owner': 'second-owner' } }), + ), + ).resolves.toBe('second-owner') + } finally { + await Promise.all([ + Effect.runPromise(Scope.close(firstScope, Exit.void)), + Effect.runPromise(Scope.close(secondScope, Exit.void)), + ]) + } + }) + + it('preserves complete declared failure, defect, and interruption Causes through the local ready client', async () => { + const Fail = Rpc.make('diagnostics.fail', { + success: Schema.String, + error: Schema.Literal('declared-failure'), + }) + const group = RpcGroup.make(Fail) + const cause = Cause.combine( + Cause.fail('declared-failure' as const), + Cause.combine(Cause.die(new Error('local defect')), Cause.interrupt(123)), + ) + let failureCause = cause + const scope = Scope.makeUnsafe() + try { + const client = await Effect.runPromise( + makeServerLocalRpcClient(group).pipe( + Effect.provide( + group.toLayer({ 'diagnostics.fail': () => Effect.failCause(failureCause) }), + ), + Scope.provide(scope), + ), + ) + const direct = await Effect.runPromiseExit(client('diagnostics.fail', undefined)) + if (Exit.isSuccess(direct)) { + throw new Error('Expected the local client to fail') + } + expect(direct.cause.reasons.map((reason) => reason._tag)).toStrictEqual([ + 'Fail', + 'Die', + 'Interrupt', + ]) + expect(Cause.interruptors(direct.cause)).toStrictEqual(new Set([123])) + let queryCause: Cause.Cause | undefined + const utils = createRpcQueryUtils(group, { + client, + keyPrefix: ['local'], + runPromiseExit: async (effect, options) => { + const exit = await Effect.runPromiseExit(effect, options) + if (Exit.isFailure(exit)) { + queryCause = exit.cause + } + return exit + }, + }) + const error = await captureFailure( + new QueryClient().query(utils.diagnostics.fail.queryOptions()), + ) + if (!isEffectRpcQueryError(error)) { + throw new Error('Expected a query execution error') + } + expect(queryCause).toBeDefined() + expect(error.cause).toBe(queryCause) + expect( + error.cause.reasons.filter(Cause.isFailReason).map((reason) => reason.error), + ).toStrictEqual(['declared-failure']) + // The default server policy collapses fatal defects; the local recipe keeps all reasons. + failureCause = Cause.combine( + Cause.fail('declared-failure' as const), + Cause.die('fatal defect'), + ) + const fatal = await captureFailure( + new QueryClient().query(utils.diagnostics.fail.queryOptions()), + ) + if (!isEffectRpcQueryError(fatal)) { + throw new Error('Expected the fatal local query to return its failed Exit') + } + expect(fatal.cause).toBe(queryCause) + expect(fatal.cause.reasons.map((reason) => reason._tag)).toStrictEqual(['Fail', 'Die']) + } finally { + await Effect.runPromise(Scope.close(scope, Exit.void)) + } + }) + + it('delivers query cancellation to a local handler and releases its request-owned resource', async () => { + const started = Deferred.makeUnsafe() + const released = Deferred.makeUnsafe() + const Wait = Rpc.make('diagnostics.wait', { success: Schema.String }) + const group = RpcGroup.make(Wait) + const scope = Scope.makeUnsafe() + try { + const client = await Effect.runPromise( + makeServerLocalRpcClient(group).pipe( + Effect.provide( + group.toLayer({ + 'diagnostics.wait': Effect.fnUntraced(function* () { + yield* Effect.acquireRelease(Deferred.succeed(started, undefined), () => + Deferred.succeed(released, undefined), + ) + return yield* Effect.never + }), + }), + ), + Scope.provide(scope), + ), + ) + const queryClient = new QueryClient() + const utils = createRpcQueryUtils(group, { client, keyPrefix: ['local'] }) + const options = utils.diagnostics.wait.queryOptions() + const failure = captureFailure(queryClient.query(options)) + await Effect.runPromise(Deferred.await(started)) + await queryClient.cancelQueries({ queryKey: options.queryKey, exact: true }) + await expect(failure).resolves.toBeInstanceOf(CancelledError) + await Effect.runPromise(Deferred.await(released)) + } finally { + await Effect.runPromise(Scope.close(scope, Exit.void)) + } + }) +}) diff --git a/tests/fixtures/server-local-rpc.ts b/tests/fixtures/server-local-rpc.ts new file mode 100644 index 0000000..759e5fb --- /dev/null +++ b/tests/fixtures/server-local-rpc.ts @@ -0,0 +1,23 @@ +import { Deferred, Effect } from 'effect' +import type { Rpc, RpcGroup, RpcMessage } from 'effect/rpc' +import { RpcClient, RpcServer } from 'effect/rpc' + +/** Bounded application recipe, outside the published library contract. */ +export const makeServerLocalRpcClient = Effect.fnUntraced(function* ( + group: RpcGroup.RpcGroup, +) { + const reply = + yield* Deferred.make<(message: RpcMessage.FromServer) => Effect.Effect>() + const server = yield* RpcServer.makeNoSerialization(group, { + disableFatalDefects: true, + onFromServer: (response) => + Deferred.await(reply).pipe(Effect.flatMap((write) => write(response))), + }) + const { client, write } = yield* RpcClient.makeNoSerialization(group, { + flatten: true, + supportsAck: true, + onFromClient: ({ message }) => server.write(0, message), + }) + yield* Deferred.succeed(reply, write) + return client +}) diff --git a/tests/fixtures/tsconfig.server-local.json b/tests/fixtures/tsconfig.server-local.json new file mode 100644 index 0000000..d801642 --- /dev/null +++ b/tests/fixtures/tsconfig.server-local.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["server-local-rpc.ts", "../types/server-local-client-contract.ts"] +} diff --git a/tests/types/server-local-client-contract.ts b/tests/types/server-local-client-contract.ts new file mode 100644 index 0000000..76731b6 --- /dev/null +++ b/tests/types/server-local-client-contract.ts @@ -0,0 +1,35 @@ +// This upstream design proof adds no library export or published adapter contract. +import { Context, Effect, Schema } from 'effect' +import { Rpc, RpcGroup } from 'effect/rpc' + +import { makeServerLocalRpcClient } from '../fixtures/server-local-rpc.ts' + +class CodecService extends Context.Service()( + 'effect-api-query/tests/ServerLocalCodecService', +) {} + +const Payload = Schema.Struct({ name: Schema.String }).pipe( + Schema.middlewareEncoding((encoding) => Effect.flatMap(CodecService, () => encoding)), +) +const Read = Rpc.make('profiles.read', { + payload: Payload, + success: Schema.String, + error: Schema.Literal('not-found'), +}) +const group = RpcGroup.make(Read) +const construction = makeServerLocalRpcClient(group) +declare const client: Effect.Success +const call = client('profiles.read', { + name: 'Ada', +}) +type Equal = + (() => Value extends Left ? 1 : 2) extends () => Value extends Right ? 1 : 2 + ? true + : false +type Assert = Condition +true satisfies Assert, string>> +true satisfies Assert, 'not-found'>> +true satisfies Assert, CodecService>> +// @ts-expect-error The declaration-derived client does not accept a numeric profile name. +const invalidCall = client('profiles.read', { name: 1 }) +void [call, invalidCall] diff --git a/tsconfig.json b/tsconfig.json index 7cd6ac4..251567e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -47,6 +47,7 @@ "tests/create-rpc-query-utils*.test.ts", "tests/http-*.test.ts", "tests/fixtures/effect-rpc.ts", + "tests/fixtures/server-local-rpc.ts", "tests/fixtures/http-client.ts", "tests/types/**/*.ts" ], diff --git a/vite.config.ts b/vite.config.ts index f0257cf..9a025ee 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -317,7 +317,16 @@ export default defineConfig({ }, test: { command: 'vp test', - dependsOn: ['pack'], + dependsOn: ['pack', 'server-local-types'], + cache: { + output: [], + }, + }, + 'server-local-types': { + command: [ + 'tsc --project tests/fixtures/tsconfig.server-local.json', + 'node node_modules/typescript-5.9/bin/tsc --project tests/fixtures/tsconfig.server-local.json', + ], cache: { output: [], }, From 92bb086f51408f3487036b8598e148e04e2a78e9 Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Fri, 9 Oct 2026 21:41:31 +0200 Subject: [PATCH 03/97] test: prove HTTP SSE and metadata view contracts --- GLOSSARY.md | 10 +- ...ate-accumulated-and-live-stream-queries.md | 3 +- ...-http-utilities-with-separate-key-roots.md | 5 +- ...xpose-buffered-multipart-http-mutations.md | 2 +- ...ject-http-streams-and-buffered-metadata.md | 63 +++ docs/design/http-cached-views.md | 120 ++++++ tests/http-view-proof.test.ts | 368 ++++++++++++++++++ tests/types/http-view-proof.ts | 112 ++++++ 8 files changed, 676 insertions(+), 7 deletions(-) create mode 100644 docs/adr/0024-project-http-streams-and-buffered-metadata.md create mode 100644 docs/design/http-cached-views.md create mode 100644 tests/http-view-proof.test.ts create mode 100644 tests/types/http-view-proof.ts diff --git a/GLOSSARY.md b/GLOSSARY.md index e2cdf9d..2282d3b 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -9,7 +9,7 @@ A nested object derived from literal Effect RPC tags. Branches represent RPC nam _Avoid_: Router, generated client **HTTP utility tree**: -A nested object derived from an Effect HttpApi's literal group and endpoint identifiers. Supported buffered endpoints provide typed key and option builders, with multipart endpoints limited to mutations; endpoints in top-level groups appear at the root. +A nested object derived from an Effect HttpApi's literal group and endpoint identifiers. Supported buffered and SSE endpoints provide typed key and option builders, with multipart endpoints limited to buffered mutations; endpoints in top-level groups appear at the root. _Avoid_: RPC utility tree, generated client **Query data**: @@ -17,13 +17,17 @@ The successful decoded RPC or HTTP value presented to TanStack Query. Buffered q _Avoid_: RPC success value when it is `undefined` **Accumulated streamed query**: -A streaming RPC view that caches emitted values in order, optionally retaining only a bounded recent history. Refetches may reset, append to, or replace the cached sequence. +A streaming RPC or HTTP SSE view that caches emitted values in order, optionally retaining only a bounded recent history. Refetches may reset, append to, or replace the cached sequence. _Avoid_: Live query, infinite query **Live query**: -A streaming RPC view that caches only the latest emitted value. Stream completion preserves that value; completion before the first emission produces a package error. +A streaming RPC or HTTP SSE view that caches only the latest emitted value. Stream completion preserves that value; completion before the first emission produces a package error. _Avoid_: Accumulated streamed query, subscription +**HTTP metadata view**: +A buffered HTTP query view containing decoded query data and an immutable snapshot of the response status and raw string headers. Declared decoded header wrappers remain part of its data. +_Avoid_: Raw response, header-wrapped success + ## Payloads and keys **HTTP request input**: diff --git a/docs/adr/0020-generate-accumulated-and-live-stream-queries.md b/docs/adr/0020-generate-accumulated-and-live-stream-queries.md index 4c44484..0d44c31 100644 --- a/docs/adr/0020-generate-accumulated-and-live-stream-queries.md +++ b/docs/adr/0020-generate-accumulated-and-live-stream-queries.md @@ -1,6 +1,7 @@ # Generate accumulated and live stream queries -Status: Accepted. Partially supersedes ADR 0002 and ADR 0003. +Status: Accepted. Partially supersedes ADR 0002 and ADR 0003. Extended to HTTP SSE views by +[ADR 0024](0024-project-http-streams-and-buffered-metadata.md). Streaming RPC leaves expose `streamedKey`/`streamedOptions` for ordered history with `reset`, `append`, and `replace` refetch modes, and `liveKey`/`liveOptions` for the latest value. A live stream diff --git a/docs/adr/0022-add-buffered-http-utilities-with-separate-key-roots.md b/docs/adr/0022-add-buffered-http-utilities-with-separate-key-roots.md index d2de168..2f3cf69 100644 --- a/docs/adr/0022-add-buffered-http-utilities-with-separate-key-roots.md +++ b/docs/adr/0022-add-buffered-http-utilities-with-separate-key-roots.md @@ -1,14 +1,15 @@ # Add buffered HTTP utilities with separate key roots -Status: Accepted. Amends ADR 0001, ADR 0003, ADR 0005 through ADR 0008, ADR 0013, and ADR 0021. Multipart omission amended by [ADR 0023](0023-expose-buffered-multipart-http-mutations.md). +Status: Accepted. Amends ADR 0001, ADR 0003, ADR 0005 through ADR 0008, ADR 0013, and ADR 0021. Multipart omission amended by [ADR 0023](0023-expose-buffered-multipart-http-mutations.md). HTTP SSE projection and opt-in buffered metadata amended by [ADR 0024](0024-project-http-streams-and-buffered-metadata.md). Publish one `effect-api-query` package at the workspace root. Export `createHttpApiQueryUtils` beside `createRpcQueryUtils` through one package root. Private adapters share utility construction but retain their distinct request and projection contracts. Use `src/core`, `src/http`, and `src/rpc` with direct imports and explicit root exports. -HTTP mirrors literal group/endpoint names, including dots and top-level projection. Omit an entire +HTTP mirrors literal group/endpoint names, including dots and top-level projection. The original buffered projection omitted an entire endpoint if any success alternative streams (including header-wrapped streams); omit empty groups. +ADR 0024 permits an unambiguous single SSE success while retaining omission for mixed buffered/streamed successes and raw byte streams. The original decision also omitted any multipart payload alternative. ADR 0023 permits buffered multipart mutation builders while preserving omission for streaming multipart payloads. This prevents falsely buffered result types. Unsafe paths, collisions, and contradictory multipart metadata fail atomically. diff --git a/docs/adr/0023-expose-buffered-multipart-http-mutations.md b/docs/adr/0023-expose-buffered-multipart-http-mutations.md index d56a09f..eed0991 100644 --- a/docs/adr/0023-expose-buffered-multipart-http-mutations.md +++ b/docs/adr/0023-expose-buffered-multipart-http-mutations.md @@ -1,6 +1,6 @@ # Expose buffered multipart HTTP mutations -Status: Accepted. Amends [ADR 0022](0022-add-buffered-http-utilities-with-separate-key-roots.md) and extends [ADR 0021](0021-share-utility-construction-through-private-modules.md). +Status: Accepted. Amends [ADR 0022](0022-add-buffered-http-utilities-with-separate-key-roots.md) and extends [ADR 0021](0021-share-utility-construction-through-private-modules.md). Extended HTTP views in [ADR 0024](0024-project-http-streams-and-buffered-metadata.md) retain this multipart capability limit. Buffered multipart uploads need mutation execution without query cache identity for files or `FormData`. Expose only `key()`, `mutationKey()`, and `mutationOptions()` when an endpoint has any diff --git a/docs/adr/0024-project-http-streams-and-buffered-metadata.md b/docs/adr/0024-project-http-streams-and-buffered-metadata.md new file mode 100644 index 0000000..1f96d84 --- /dev/null +++ b/docs/adr/0024-project-http-streams-and-buffered-metadata.md @@ -0,0 +1,63 @@ +# Project HTTP streams and buffered metadata into separate cached views + +Status: Accepted design for the review programme. Implementation follows in issues #104–#106. +Amends [ADR 0020](0020-generate-accumulated-and-live-stream-queries.md), +[ADR 0022](0022-add-buffered-http-utilities-with-separate-key-roots.md), and +[ADR 0023](0023-expose-buffered-multipart-http-mutations.md). + +## Decision + +An endpoint with exactly one success alternative, whose body is `HttpApiSchema.StreamSse`, +exposes accumulated and live query builders. A declared `WithHeaders` wrapper projects onto each +emitted chunk: `HttpApiSchema.withHeaders({ body: chunk, headers: decodedHeaders })`. Accumulated +query data is an array of those values; live query data is the latest value. Neither view caches a +Stream or a response object. An unwrapped successful live `undefined` becomes `null`; an accumulated +`undefined` element or a declared wrapper containing it keeps its original representation. + +Keep the existing `streamedKey`/`streamedOptions` and `liveKey`/`liveOptions` vocabulary. Accept +`sseOptions` beside `input` in option builders and beside the retention policy in concrete key +builders. Decoded request input and custom key-encoder input contain only declared request parts. +Normalize the installed decoder's `maxEventSize` default to 10 MiB of string code units. Its +effective value belongs in both concrete view keys because it changes successful consumption into +a failure for sufficiently large pending events. Accumulated identity also includes effective +`maxChunks` and `refetchMode`. Key-prefix invalidation remains independent of those concrete policies. + +Expose `metadataKey`/`metadataOptions` on query-enabled buffered endpoints. Execute the ready +client with `responseMode: 'decoded-and-response'` and return a readonly, frozen outer record +`{ data, status, headers }`, with copied, frozen plain string headers. Normalize decoded `undefined` +to `null` in `data`; preserve any declared decoded `WithHeaders` value intact. Give this view its +own `metadata` operation identity. Ordinary query, infinite-query, and mutation behavior stays as +specified by the existing buffered contracts. Header copying reads raw string entries rather than +Effect's redacting `Headers.toJSON` representation. + +The supplied ready client and runner own encoding, middleware, residual services, and transport. +Acquire the SSE value and consume it under the caller's runner Context; forward Query's abort +signal and close the iterator on cancellation. Both acquisition and consumption failures retain +the complete Cause in `EffectHttpApiQueryError`, with `streamed`, `live`, or `metadata` operation +metadata. An independent interruption is a failure; signal cancellation follows native Query +reversion. Empty accumulated completion succeeds with the appropriate retained history. Empty +live completion raises `EffectHttpApiQueryEmptyStreamError` with declaration identity. Runner +rejections pass through unchanged. + +## Supported limits + +Effect 4.0.0 permits only one streaming success alternative, but it can combine that alternative +with buffered successes selected by status/content type. Such a ready client can return either +buffered data or a Stream. Keep the whole mixed endpoint omitted: assigning a stream view or a +buffered view would misrepresent one successful outcome. Revisit only with an explicitly proved +discriminated contract. Streaming multipart request payloads remain omitted. Buffered multipart +requests remain mutation-only when their successes are buffered; they do not gain SSE query views. + +Keep raw `StreamUint8Array` successes omitted, including wrappers and mixed alternatives. Transport +chunks partition the same bytes differently, and `maxChunks` counts transport chunks rather than +bytes or application records. A latest-chunk cache is not a download snapshot. Applications can +consume the native ready client into their own sink or declare an explicitly buffered byte result; +this programme adds no generic download accumulator or sink ownership. + +SSE event declarations preserve their declared event fields and IDs; data declarations emit only +decoded data. The adapter does not reconstruct dropped event IDs, reconnect, retry a parser +directive, or invent resume semantics. `Sse.Retry`, its `lastEventId`, and all other failures remain +available in the Cause for an application-owned retry/resume policy. + +The [support matrix and proof](../design/http-cached-views.md) record the exact upstream seam, +service channels, resource behavior, and executable evidence behind this decision. diff --git a/docs/design/http-cached-views.md b/docs/design/http-cached-views.md new file mode 100644 index 0000000..2129e4e --- /dev/null +++ b/docs/design/http-cached-views.md @@ -0,0 +1,120 @@ +# HTTP cached-view contract and design proof + +This is the implementation contract for issues #104–#106 under spec #95. The retained proof uses +the already installed Effect 4.0.0 public exports. It does not install dependencies or add shipped +features. [ADR 0024](../adr/0024-project-http-streams-and-buffered-metadata.md) records the decision. + +## Input and output support matrix + +The request and success rows compose: a request must permit a particular view and its success +must have the corresponding representation. HTTP method does not determine cache capability. + +| Declared request | Ordinary buffered views | SSE query views | Key-encoder input | +| ------------------------------------------------------------------ | ---------------------------------------------- | ---------------------------------------------- | --------------------------------------------------------- | +| No params/query/headers/payload | Query, infinite, mutation, metadata | Accumulated, live | None | +| Decoded params/query/headers and JSON/text/form/URL payload | Query, infinite, mutation, metadata | Accumulated, live | Complete decoded request containers | +| Serviceful, redacted, binary, or multiple payload alternatives | Same views after required safe custom encoding | Same views after required safe custom encoding | Complete decoded request; never response/decoder controls | +| Buffered multipart, including a mixed buffered payload alternative | Mutation only | Omitted | None; FormData is not query identity | +| Streaming multipart payload alternative | Omitted | Omitted | None | + +Request values captured by builders must remain immutable. Identity and execution must describe the +same request. Response mode belongs to the adapter, and `sseOptions` belongs to the view builder. +Native transport headers declared as request input, including an application resume cursor, remain +request identity input. Middleware-owned identity must be partitioned in the caller's key prefix. + +| Declared success alternatives | Generated view | Cached query data | +| ---------------------------------------------------- | ----------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | +| All buffered JSON/text/bytes/no-content alternatives | Existing ordinary query/infinite/mutation; opt-in metadata for query-enabled inputs | Existing decoded value; metadata `{ data, status, headers }` | +| Buffered `WithHeaders(Body, Headers)` | Same buffered views | Declared decoded wrapper remains intact; metadata adds raw header/status snapshot outside it | +| Exactly one `StreamSse({ data })` | Accumulated and live only | Decoded data elements; latest decoded data for live | +| Exactly one `StreamSse({ events })` | Accumulated and live only | Declared decoded event elements, including declared IDs/event names | +| Exactly one `WithHeaders(StreamSse(...), Headers)` | Accumulated and live only | Per-emission `withHeaders({ body: decodedChunk, headers: decodedHeaders })` | +| SSE plus any buffered alternative | Whole endpoint omitted | No truthful single query-data shape has been selected | +| Multiple streaming alternatives | Rejected by installed Effect endpoint construction | No endpoint | +| Raw `StreamUint8Array`, wrapped or mixed | Whole endpoint omitted | Application consumes native bytes into its own sink | + +For ordinary query data and live values, successful top-level `undefined` becomes `null`. +Accumulated arrays preserve `undefined` elements. Metadata normalizes its decoded `data` field; +it leaves a decoded wrapper and its fields unchanged. Live completion preserves the latest value; +completion without any emission is a distinct package empty-stream error. + +## Concrete interface + +Streaming leaves provide `key()`, `streamedKey(...)`, `streamedOptions(...)`, `liveKey(...)`, and +`liveOptions(...)`. Query-enabled buffered leaves additionally provide `metadataKey(...)` and +`metadataOptions(...)`. The metadata result shape is +`{ readonly data: QueryData; readonly status: number; readonly headers: Readonly> }`. +Metadata gets the normal unary options' native selection, initial-data, skip-token, and error +inference with this result shape. It does not create metadata mutation or infinite-query methods. + +Option builders accept `sseOptions?: Sse.DecodeOptions` alongside `input`, with accumulated +`maxChunks` and `refetchMode` alongside it. Concrete key builders accept the same policy object +after decoded input, or as their only argument for inputless endpoints. Live concrete key policy +contains only decoder controls. Prefix keys continue to select every policy for that endpoint. + +The installed decoder has one control, `maxEventSize`. Absent options, an empty options record, +an undefined field, and the explicit 10 MiB default must have identical normalized identity. A +non-default effective limit splits both SSE views. Require a positive safe integer and reject +invalid values synchronously with a configuration error. This protects finite, JSON-safe view +identity. The same normalized value must be passed to the ready client. + +`maxEventSize` limits pending parser text, measured in JavaScript string code units. It is neither +a network byte limit nor a maximum decoded payload size: in installed Effect, a complete event +that is parsed within one input chunk can clear pending state before that limit check. The proof +deliberately splits a pending event before its terminator to demonstrate the control. No adapter +parser replacement or stronger size guarantee is added. + +## Failures, services, and resource lifetime + +| Phase | Public upstream result/channel | Adapter contract | +| ------------------------------------------------------------------- | --------------------------------------------------------- | ---------------------------------------------------------------------------- | +| Request encoding, middleware, HTTP status, response-header decoding | Ready-client Effect failure/defect/interruption | HTTP execution error with untouched Cause and declaration/operation metadata | +| SSE parsing, event decoding, reserved declared failure event | Stream failure/defect/interruption | Same HTTP execution error; acquisition alone is not successful consumption | +| `retry:` parser directive | `Sse.Retry` failure, including duration and last event ID | Preserve Cause; application selects retry/resume behavior | +| Empty SSE body that completes successfully | Empty upstream Stream | Accumulated empty/history result; live empty-stream error | +| Query abort | Interrupted acquisition or iterator consumption | Native Query cancellation/reversion; finalize iterator, no late cache writes | +| Injected runner rejects | Rejected Promise | Preserve rejection unchanged | + +The generated client acquisition Effect requires declared request encoding and response decoding +services, declared error/middleware services, and any additional client requirements. A serviceful +SSE event decoder is captured in the acquisition Context by public `HttpApiClient`; its resulting +Stream advertises `never` residual services. The retained compile fixture proves those channels, +including a caller's additional error/service types and declared stream errors. A caller-supplied +ready client must satisfy that public interface; the adapter does not erase requirements to make +construction compile. + +Public `Stream.toAsyncIterable` closes its scope and interrupts pending pulls on `return()`. +The proof observes one Web body cancellation and an aborted transport signal both when returning +after the first event and when aborting consumption. These observations do not transfer ownership +of the acquired client, runtime, or application Scope to the adapter. Upstream transport remains +responsible for resources when acquisition fails before a usable Stream is returned. + +Buffered metadata uses `decoded-and-response` so normal buffering/decoding finishes before the +metadata record is returned. Copy raw enumerable string entries into an ordinary frozen record; +never cache `HttpClientResponse`, Web `Response`, Effect `Headers`, cookies, or an open body. Do not +use `Headers.toJSON`, which applies redaction rather than copying raw strings. Decoded data retains +its declared representation and ownership; freeze only the new metadata envelope and plain header +snapshot. Applications choose whether those headers may be persisted or dehydrated. + +Raw-byte proof observations show `[1, 2, 3]` returned as one transport chunk and as two chunks +`[1, 2]`, `[3]`. Retention by chunk count therefore changes which bytes remain despite identical +full-body content. Native byte Streams also retain transport/body lifetime until consumed or +closed. The programme deliberately limits generated streaming views to schema-decoded SSE +application records. Existing explicitly buffered byte schemas keep their existing behavior. + +## Executable evidence + +- [`tests/http-view-proof.test.ts`](../../tests/http-view-proof.test.ts) uses `HttpApiTest.groups` + with real handlers for finite SSE/event/header/error round trips, and public + `HttpApiClient.makeWith`/`HttpClient.make` for controlled wire and body-lifecycle observations. +- [`tests/types/http-view-proof.ts`](../../tests/types/http-view-proof.ts) compiles upstream + acquisition, stream, decoded-wrapper, additional-client, and separated-request contracts. It + passes with installed TypeScript 5.9.3 and 7.0.2 without a registry installation. +- `vp test tests/http-view-proof.test.ts` runs the focused runtime proof. Implementation tickets + must additionally verify their shipped builders through the agreed installed-tarball seam. + +Upstream evidence is available in the installed public `effect/http-api`, `effect/http`, +`effect/encoding`, and `effect` exports. Source inspection used `HttpApiClient`'s stream decoding +and captured Context, `HttpApiEndpoint`'s success validation/client channels, `HttpApiSchema`'s +stream/wrapper declarations, `Sse`'s decoder controls, and the public iterator/HTTP finalization +contracts. Re-run this evidence when the coordinated Effect patch changes. diff --git a/tests/http-view-proof.test.ts b/tests/http-view-proof.test.ts new file mode 100644 index 0000000..83e4ae1 --- /dev/null +++ b/tests/http-view-proof.test.ts @@ -0,0 +1,368 @@ +import { it } from '@effect/vitest' +import { + Cause, + Context, + Deferred, + Effect, + Exit, + Layer, + Schema, + SchemaTransformation, + Stream, +} from 'effect' +import { HttpClient, HttpClientResponse, HttpServer } from 'effect/http' +import { + HttpApi, + HttpApiBuilder, + HttpApiClient, + HttpApiEndpoint, + HttpApiGroup, + HttpApiSchema, + HttpApiTest, +} from 'effect/http-api' +import { describe, expect } from 'vite-plus/test' + +class Scale extends Context.Service()('HttpViewProof/Scale') {} +const Scaled = Schema.Finite.pipe( + Schema.decodeTo( + Schema.Finite, + SchemaTransformation.transformEffect({ + decode: (value) => Scale.pipe(Effect.map(({ factor }) => value * factor)), + encode: (value) => Effect.succeed(value), + }), + ), +) +const Expired = Schema.TaggedStruct('Expired', { reason: Schema.String }) +const Event = Schema.Struct({ + id: Schema.optional(Schema.String), + event: Schema.Literal('changed'), + data: Schema.fromJsonString(Schema.Finite), +}) + +const Api = HttpApi.make('http-view-proof').add( + HttpApiGroup.make('views').add( + HttpApiEndpoint.get('wrapped', '/wrapped', { + success: HttpApiSchema.WithHeaders(HttpApiSchema.StreamSse({ data: Schema.Finite }), { + 'x-version': Schema.FiniteFromString, + }), + }), + HttpApiEndpoint.get('buffered', '/buffered', { + success: HttpApiSchema.WithHeaders(Schema.FiniteFromString, { + 'x-version': Schema.FiniteFromString, + }).pipe(HttpApiSchema.status(203)), + }), + HttpApiEndpoint.get('plain', '/plain', { + success: HttpApiSchema.StreamSse({ data: Schema.Finite }), + }), + HttpApiEndpoint.get('events', '/events', { + success: HttpApiSchema.StreamSse({ events: Event }), + }), + HttpApiEndpoint.get('serviceful', '/serviceful', { + success: HttpApiSchema.StreamSse({ data: Scaled }), + }), + HttpApiEndpoint.get('failure', '/failure', { + success: HttpApiSchema.StreamSse({ data: Schema.Finite, error: Expired }), + }), + HttpApiEndpoint.get('bytes', '/bytes', { success: HttpApiSchema.StreamUint8Array() }), + ), +) + +const Handlers = HttpApiBuilder.group(Api, 'views', (handlers) => + handlers + .handle('wrapped', () => + Effect.succeed( + HttpApiSchema.withHeaders({ body: Stream.make(3, 7), headers: { 'x-version': 4 } }), + ), + ) + .handle('buffered', () => + Effect.succeed(HttpApiSchema.withHeaders({ body: 42, headers: { 'x-version': 4 } })), + ) + .handle('plain', () => Effect.succeed(Stream.make(3, 7))) + .handle('events', () => Effect.succeed(Stream.make({ id: 'v3', event: 'changed', data: 3 }))) + .handle('serviceful', () => Effect.succeed(Stream.make(3, 7))) + .handle('failure', () => + Effect.succeed( + Stream.concat( + Stream.succeed(3), + Stream.fail({ _tag: 'Expired', reason: 'resume required' }), + ), + ), + ) + .handle('bytes', () => + Effect.succeed(Stream.make(new Uint8Array([1, 2]), new Uint8Array([3]))), + ), +) +const TestLayer = Layer.mergeAll(Handlers, HttpServer.layerServices) +const makeWireClient = (response: () => Response) => + HttpApiClient.makeWith(Api, { + baseUrl: 'https://example.test', + httpClient: HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, response())), + ), + }) + +const sseResponse = (body: string) => + new Response(body, { headers: { 'content-type': 'text/event-stream' } }) +const sseSegments = (...parts: readonly string[]) => + new Response( + new ReadableStream({ + start(controller) { + for (const part of parts) { + controller.enqueue(new TextEncoder().encode(part)) + } + controller.close() + }, + }), + { headers: { 'content-type': 'text/event-stream' } }, + ) + +describe('HTTP view design proof through public Effect clients', () => { + it.effect( + 'projects decoded response headers onto every emitted value without caching a Stream', + () => + Effect.gen(function* () { + const client = yield* HttpApiTest.groups(Api, ['views']) + const response = yield* client.views.wrapped({ responseMode: 'decoded-only' }) + const values = yield* response.body.pipe( + Stream.map((body) => HttpApiSchema.withHeaders({ body, headers: response.headers })), + Stream.runCollect, + ) + expect(values).toStrictEqual([ + HttpApiSchema.withHeaders({ body: 3, headers: { 'x-version': 4 } }), + HttpApiSchema.withHeaders({ body: 7, headers: { 'x-version': 4 } }), + ]) + }).pipe(Effect.provide(TestLayer), Effect.scoped), + ) + + it.effect('buffers metadata while preserving the declared decoded WithHeaders value', () => + Effect.gen(function* () { + const client = yield* HttpApiClient.makeWith(Api, { + baseUrl: 'https://example.test', + httpClient: HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json('42', { + status: 203, + headers: { 'x-version': '4', etag: 'revision-4' }, + }), + ), + ), + ), + }) + const [data, response] = yield* client.views.buffered({ + responseMode: 'decoded-and-response', + }) + const view = Object.freeze({ + data, + status: response.status, + headers: Object.freeze({ ...response.headers }), + }) + expect(view.data).toStrictEqual( + HttpApiSchema.withHeaders({ body: 42, headers: { 'x-version': 4 } }), + ) + expect(view.status).toBe(203) + expect(view.headers).toMatchObject({ etag: 'revision-4', 'x-version': '4' }) + expect(Object.getPrototypeOf(view.headers)).toBe(Object.prototype) + expect(Object.isFrozen(view.headers)).toBe(true) + }), + ) + + it.effect( + 'preserves declared event fields while data-mode declarations emit only decoded data', + () => + Effect.gen(function* () { + const client = yield* HttpApiTest.groups(Api, ['views']) + const plain = yield* client.views.plain({ responseMode: 'decoded-only' }) + const events = yield* client.views.events({ responseMode: 'decoded-only' }) + expect(yield* Stream.runCollect(plain)).toStrictEqual([3, 7]) + expect(yield* Stream.runCollect(events)).toStrictEqual([ + { id: 'v3', event: 'changed', data: 3 }, + ]) + }).pipe(Effect.provide(TestLayer), Effect.scoped), + ) + + it.effect( + 'captures decoding services during acquisition for later service-free consumption', + () => + Effect.gen(function* () { + const client = yield* makeWireClient(() => sseResponse('data: 3\n\n')) + const stream = yield* client.views + .serviceful({ responseMode: 'decoded-only' }) + .pipe(Effect.provideService(Scale, { factor: 2 })) + expect(yield* Stream.runCollect(stream)).toStrictEqual([6]) + }), + ) + + it.effect('limits pending SSE event text during consumption without changing acquisition', () => + Effect.gen(function* () { + const client = yield* makeWireClient(() => sseSegments('data: 12345', '\n\n')) + const accepted = yield* client.views.plain({ responseMode: 'decoded-only' }) + expect(yield* Stream.runCollect(accepted)).toStrictEqual([12_345]) + const limited = yield* client.views.plain({ + responseMode: 'decoded-only', + sseOptions: { maxEventSize: 3 }, + }) + const failure = yield* Effect.exit(Stream.runCollect(limited)) + expect(failure).toMatchObject({ + cause: { + reasons: [ + { + _tag: 'Fail', + error: { _tag: 'SseError', reason: { _tag: 'EventTooLarge', maxEventSize: 3 } }, + }, + ], + }, + }) + }), + ) + + it.effect( + 'fails malformed decoded headers at acquisition and malformed SSE data at consumption', + () => + Effect.gen(function* () { + const client = yield* makeWireClient( + () => + new Response('data: "invalid"\n\n', { + headers: { 'content-type': 'text/event-stream', 'x-version': 'invalid' }, + }), + ) + const headersFailure = yield* Effect.exit( + client.views.wrapped({ responseMode: 'decoded-only' }), + ) + expect(headersFailure).toMatchObject({ + cause: { reasons: [{ _tag: 'Fail', error: { _tag: 'SchemaError' } }] }, + }) + const acquired = yield* client.views.plain({ responseMode: 'decoded-only' }) + const streamFailure = yield* Effect.exit(Stream.runCollect(acquired)) + expect(streamFailure).toMatchObject({ + cause: { reasons: [{ _tag: 'Fail', error: { _tag: 'SchemaError' } }] }, + }) + }), + ) + + it.effect('preserves successful chunks before a declared reserved-event stream failure', () => + Effect.gen(function* () { + const client = yield* HttpApiTest.groups(Api, ['views']) + const stream = yield* client.views.failure({ responseMode: 'decoded-only' }) + const values: number[] = [] + const failure = yield* Effect.exit( + stream.pipe(Stream.runForEach((value) => Effect.sync(() => values.push(value)))), + ) + expect(values).toStrictEqual([3]) + expect(failure).toMatchObject({ + cause: { + reasons: [{ _tag: 'Fail', error: { _tag: 'Expired', reason: 'resume required' } }], + }, + }) + }).pipe(Effect.provide(TestLayer), Effect.scoped), + ) + + it.effect( + 'returns retry directives and resume cursors as failures for caller-owned reconnection', + () => + Effect.gen(function* () { + const client = yield* makeWireClient(() => sseResponse('id: v3\ndata: 3\n\nretry: 25\n\n')) + const stream = yield* client.views.plain({ responseMode: 'decoded-only' }) + const values: number[] = [] + const failure = yield* Effect.exit( + stream.pipe(Stream.runForEach((value) => Effect.sync(() => values.push(value)))), + ) + expect(values).toStrictEqual([3]) + expect(failure).toMatchObject({ + cause: { reasons: [{ _tag: 'Fail', error: { _tag: 'Retry', lastEventId: 'v3' } }] }, + }) + }), + ) + + it.each(['iterator return', 'abort'] as const)( + 'finalizes an acquired SSE body on %s', + async (mode) => { + let closed = 0 + let transportSignal: AbortSignal | undefined + const client = await Effect.runPromise( + HttpApiClient.makeWith(Api, { + baseUrl: 'https://example.test', + httpClient: HttpClient.make((request, _url, signal) => { + transportSignal = signal + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('data: 3\n\n')) + }, + cancel() { + closed += 1 + }, + }), + { headers: { 'content-type': 'text/event-stream' } }, + ), + ), + ) + }), + }), + ) + const stream = await Effect.runPromise(client.views.plain({ responseMode: 'decoded-only' })) + let firstValue: number | undefined + let interrupted = false + if (mode === 'iterator return') { + const iterator = Stream.toAsyncIterable(stream)[Symbol.asyncIterator]() + try { + const first = await iterator.next() + firstValue = first.done === true ? undefined : first.value + } finally { + await iterator.return?.() + } + } else { + const emitted = Deferred.makeUnsafe() + const controller = new AbortController() + const pending = Effect.runPromiseExit( + stream.pipe( + Stream.runForEach((value) => + Effect.sync(() => { + firstValue = value + }).pipe(Effect.andThen(Deferred.succeed(emitted, undefined))), + ), + ), + { signal: controller.signal }, + ) + await Effect.runPromise(Deferred.await(emitted)) + controller.abort() + const exit = await pending + interrupted = Exit.isFailure(exit) && Cause.hasInterrupts(exit.cause) + } + expect(firstValue).toBe(3) + expect(interrupted).toBe(mode === 'abort') + expect(transportSignal?.aborted).toBe(true) + expect(closed).toBe(1) + }, + ) + + it.effect('distinguishes empty SSE completion from missing chunks and transport failures', () => + Effect.gen(function* () { + const client = yield* makeWireClient(() => sseResponse('')) + const stream = yield* client.views.plain({ responseMode: 'decoded-only' }) + expect(yield* Stream.runCollect(stream)).toStrictEqual([]) + }), + ) + + it.effect('exposes transport byte partitions rather than stable cache records', () => + Effect.gen(function* () { + const whole = yield* makeWireClient( + () => + new Response(new Uint8Array([1, 2, 3]), { + headers: { 'content-type': 'application/octet-stream' }, + }), + ) + const divided = yield* HttpApiTest.groups(Api, ['views']) + const wholeStream = yield* whole.views.bytes({ responseMode: 'decoded-only' }) + const dividedStream = yield* divided.views.bytes({ responseMode: 'decoded-only' }) + const wholeChunks = yield* Stream.runCollect(wholeStream) + const dividedChunks = yield* Stream.runCollect(dividedStream) + expect(wholeChunks).toStrictEqual([new Uint8Array([1, 2, 3])]) + expect(dividedChunks).toStrictEqual([new Uint8Array([1, 2]), new Uint8Array([3])]) + }).pipe(Effect.provide(TestLayer), Effect.scoped), + ) +}) diff --git a/tests/types/http-view-proof.ts b/tests/types/http-view-proof.ts new file mode 100644 index 0000000..71895db --- /dev/null +++ b/tests/types/http-view-proof.ts @@ -0,0 +1,112 @@ +import { Context, Effect, Schema, SchemaTransformation } from 'effect' +import type { Stream } from 'effect' +import type { Sse } from 'effect/encoding' +import type { HttpClientError } from 'effect/http' +import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from 'effect/http-api' +import type { HttpApiClient } from 'effect/http-api' + +type Equal = + (() => Value extends Left ? 1 : 2) extends () => Value extends Right ? 1 : 2 + ? true + : false +type Assert = Value + +class Scale extends Context.Service()('HttpViewProof/Scale') {} +const Value = Schema.Finite.pipe( + Schema.decodeTo( + Schema.Finite, + SchemaTransformation.transformEffect({ + decode: (value) => Scale.pipe(Effect.map(({ factor }) => value * factor)), + encode: Effect.succeed, + }), + ), +) +const Expired = Schema.TaggedStruct('Expired', { reason: Schema.String }) +const Plain = HttpApiEndpoint.get('plain', '/plain', { + params: { id: Schema.FiniteFromString }, + success: HttpApiSchema.StreamSse({ data: Value, error: Expired }), +}) +const Wrapped = HttpApiEndpoint.get('wrapped', '/wrapped', { + success: HttpApiSchema.WithHeaders(HttpApiSchema.StreamSse({ data: Value }), { + 'x-version': Schema.FiniteFromString, + }), +}) +const Buffered = HttpApiEndpoint.get('buffered', '/buffered', { + success: HttpApiSchema.WithHeaders(Schema.FiniteFromString, { + 'x-version': Schema.FiniteFromString, + }), +}) +const Api = HttpApi.make('proof').add(HttpApiGroup.make('views').add(Plain, Wrapped, Buffered)) +declare const client: HttpApiClient.ForApi + +const plain = client.views.plain({ params: { id: 1 }, responseMode: 'decoded-only' }) +type PlainStream = Effect.Success +true satisfies Assert, number>> +true satisfies Assert, never>> +true satisfies Assert< + Equal< + Stream.Error, + | typeof Expired.Type + | HttpClientError.HttpClientError + | Schema.SchemaError + | Sse.Retry + | Sse.SseError + > +> +true satisfies Assert, Scale | 'client-service'>> +true satisfies Assert< + Equal< + Effect.Error, + 'client-error' | HttpClientError.HttpClientError | Schema.SchemaError + > +> + +const wrapped = client.views.wrapped({ responseMode: 'decoded-only' }) +type WrappedSuccess = Effect.Success +true satisfies Assert, number>> +true satisfies Assert> +true satisfies Assert, Scale | 'client-service'>> + +/** Proof-only projection: each cached emission retains its decoded declaration wrapper. */ +type Chunk = + Value extends HttpApiSchema.withHeaders + ? HttpApiSchema.withHeaders, Headers> + : Value extends Stream.Stream + ? Data + : never +true satisfies Assert< + Equal, HttpApiSchema.withHeaders> +> + +/** Decoder controls never enter decoded request or custom key-encoder input. */ +type Request = Omit< + Exclude< + HttpApiEndpoint.ClientRequest< + Endpoint['~Params'], + Endpoint['~Query'], + Endpoint['~Payload'], + Endpoint['~Headers'], + 'decoded-only' + >, + void + >, + 'responseMode' | 'sseOptions' +> & { readonly responseMode?: never; readonly sseOptions?: never } +declare const encodeKey: (request: Request) => string +encodeKey({ params: { id: 1 } }) +// @ts-expect-error SSE controls are separate from decoded request identity input. +encodeKey({ params: { id: 1 }, sseOptions: { maxEventSize: 1024 } }) +// @ts-expect-error An adapter owns the response mode for each concrete view. +encodeKey({ params: { id: 1 }, responseMode: 'response-only' }) +const requestWithControls = { params: { id: 1 }, sseOptions: { maxEventSize: 1024 } } +// @ts-expect-error Captured request values cannot carry decoder controls either. +encodeKey(requestWithControls) + +const buffered = client.views.buffered({ responseMode: 'decoded-and-response' }) +true satisfies Assert< + Equal< + Effect.Success[0], + HttpApiSchema.withHeaders + > +> +true satisfies Assert, 'client-service'>> From 82ef51f1ee621a63e22ff18634937d5f9ac079ea Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Fri, 9 Oct 2026 21:43:37 +0200 Subject: [PATCH 04/97] fix: register install policy verifier for static audits --- .fallowrc.jsonc | 7 ++++++- scripts/install-policy.mts | 2 -- scripts/verify-install-policy.mts | 2 -- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 477875d..217e5cf 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -3,7 +3,12 @@ // Astro copies this browser entry from public/ and loads it through the // Starlight head configuration, which static import analysis cannot follow. - "entry": ["apps/docs/public/package-version.js", "playwright.docs.config.ts"], + "entry": [ + "apps/docs/public/package-version.js", + "playwright.docs.config.ts", + // Vite+ invokes this verifier through its dynamically declared install-policy task. + "scripts/verify-install-policy.mts" + ], "ignorePatterns": ["examples/tanstack-start/src/routeTree.gen.ts"], diff --git a/scripts/install-policy.mts b/scripts/install-policy.mts index 3bb37ed..a3e4055 100644 --- a/scripts/install-policy.mts +++ b/scripts/install-policy.mts @@ -1,5 +1,3 @@ -// fallow-ignore-file unused-file -// Used by Vite+'s dynamically registered packed-package and install-policy verifiers. import { readFileSync, writeFileSync } from 'node:fs' import nodePath from 'node:path' diff --git a/scripts/verify-install-policy.mts b/scripts/verify-install-policy.mts index 2618725..e8fa32f 100644 --- a/scripts/verify-install-policy.mts +++ b/scripts/verify-install-policy.mts @@ -1,5 +1,3 @@ -// fallow-ignore-file unused-file -// Vite+ invokes this verifier through the install-policy task. import { equal, match, ok } from 'node:assert/strict' import { execFileSync, spawn } from 'node:child_process' import { createHash } from 'node:crypto' From df8e6363760aefb4490461d2496df20787cbdb0b Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Fri, 9 Oct 2026 21:52:00 +0200 Subject: [PATCH 05/97] docs: measure independent RPC transport overhead (#123) --- ...keep-the-core-factory-lifecycle-neutral.md | 5 + docs/rpc-transport-batching.md | 101 +++++++ .../tests/rpc-transport-overhead.test.ts | 260 ++++++++++++++++++ tsconfig.json | 1 + vite.config.ts | 1 + 5 files changed, 368 insertions(+) create mode 100644 docs/rpc-transport-batching.md create mode 100644 examples/server/tests/rpc-transport-overhead.test.ts diff --git a/docs/adr/0007-keep-the-core-factory-lifecycle-neutral.md b/docs/adr/0007-keep-the-core-factory-lifecycle-neutral.md index cdd8974..f660fbd 100644 --- a/docs/adr/0007-keep-the-core-factory-lifecycle-neutral.md +++ b/docs/adr/0007-keep-the-core-factory-lifecycle-neutral.md @@ -17,3 +17,8 @@ an injected runner with the same `effect, { signal? }` shape. The caller owns protocol, middleware, lifetime, and resources. Queries forward TanStack's abort signal, mutations omit it, and runner rejections propagate untouched. + +The [transport batching measurement](../rpc-transport-batching.md) confirms that shared WebSocket +connections reduce repeated HTTP overhead while retaining independent request envelopes. Transport +aggregation and backend RequestResolver batching remain application choices; the utility tree does +not automatically collect independent calls. diff --git a/docs/rpc-transport-batching.md b/docs/rpc-transport-batching.md new file mode 100644 index 0000000..88375fa --- /dev/null +++ b/docs/rpc-transport-batching.md @@ -0,0 +1,101 @@ +# RPC transport batching decision + +Keep transport policy in Effect and the application. The installed Effect 4.0.0 HTTP and socket +protocols send one request envelope per independent call. A shared WebSocket removes repeated HTTP +request headers and shares one connection, but it still sends each envelope separately. This +measurement supports an application-owned multiplexed transport when request overhead matters; +it does not justify an automatic batching engine in the RPC utility tree. + +## Reproduce the measurement + +Run the retained [protocol fixture](../examples/server/tests/rpc-transport-overhead.test.ts): + +```sh +RPC_TRANSPORT_MEASURE=1 vp run --no-cache test examples/server/tests/rpc-transport-overhead.test.ts +``` + +The fixture uses the public package factory, a real QueryClient, public Effect clients/servers, +and the existing server workspace's Node platform adapter. It runs 1, 8, and 32 concurrent unary +queries with distinct integer payloads and empty caches. Each call returns its own integer; Query +retries are disabled. Fixed request IDs and disabled RPC tracing make envelope sizes reproducible. +No dependencies are installed. + +The October 9, 2026 baseline used Node 24.5.0, Effect and Node platform 4.0.0, Query Core 5.104.0, +and Vite+ 1.0.0. Each row starts a fresh loopback host and ready client. HTTP uses FetchHttpClient +with JSON serialization; WebSocket uses the installed Node WebSocket constructor with the same +serialization. HTTP connection counts describe this concurrent HTTP/1.1 workload and the default +fetch connection pool, rather than a requirement of the RPC protocol. + +| Calls | Transport | POSTs / WebSocket sends | Connections | RPC envelope bytes | HTTP request / upgrade bytes | Client → server bytes | +| ----: | --------- | ----------------------: | ----------: | -----------------: | ---------------------------: | --------------------: | +| 1 | HTTP | 1 | 1 | 77 | 371 | 448 | +| 1 | WebSocket | 1 | 1 | 77 | 227 | 310 | +| 8 | HTTP | 8 | 8 | 616 | 2,968 | 3,584 | +| 8 | WebSocket | 8 | 1 | 616 | 227 | 891 | +| 32 | HTTP | 32 | 32 | 2,508 | 11,872 | 14,380 | +| 32 | WebSocket | 32 | 1 | 2,508 | 227 | 2,927 | + +For eight calls, multiplexing reduces measured client-to-server bytes by about 75%; at 32 calls, +the reduction is about 80%. Envelope counts and envelope bytes remain equal. All calls produce +separate successful cache entries. + +Byte counts come from encoded HTTP bodies, the actual request line and raw headers, actual +WebSocket sends, and the server TCP sockets' `bytesRead`. WebSocket totals include one upgrade +request and the six-byte masked frame overhead of each small request in this fixture. Counts end +after responses arrive and before disposal. They exclude response traffic, TCP/IP packet headers, +TLS, connection setup packets, and later keepalives. They measure bytes and calls, not latency, +throughput, or CPU cost. Browser, HTTP/2, TLS, compression, warm connections, larger payloads, and +remote backends require their own application measurement. Exact header bytes may change with the +Node/fetch version or application headers. + +## Configure the layer that owns the cost + +| Concern | Owner and control | Effect on independent RPC requests | +| --------------------------------------------------------- | --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| Connection and repeated HTTP overhead | Application selects a shared socket protocol | One request envelope and WebSocket send per call; one shared connection | +| Several logical operations in one HTTP request | Application declares a bulk RPC or owns a collecting custom protocol | A bulk RPC can change the contract to one request; a custom protocol must preserve per-call response routing, failures, interruption, and lifetime | +| Backend/database lookup overhead | Server shares a RequestResolver and uses the backend's bulk operation | Fewer backend calls can serve separate RPC requests; network requests remain independent | +| Query cache publication or observer notification overhead | Application/Query consumption and notification policy | Changes update frequency or callback scheduling; it does not change transport request count | + +Effect's HTTP client protocol encodes a single request and performs a single POST in +`node_modules/effect/src/rpc/RpcClient.ts`, `makeProtocolHttp` (lines 937–949). The socket protocol +shares a writer and routes responses by request ID, then encodes and writes each message separately +in `makeProtocolSocket` (lines 1068–1072, 1103–1110, 1195–1204). The public WebSocket adapter maps +each write to one `send` in `node_modules/effect/src/socket/Socket.ts` (lines 1128–1137). + +`node_modules/effect/src/rpc/RpcSerialization.ts` accepts JSON arrays and provides NDJSON/JSON-RPC +framing. The HTTP server also accepts multiple decoded messages in one request +(`RpcServer.ts`, lines 1160–1168). Those parsing capabilities do not collect independent calls in +the stock ready client. No stock-client aggregation option is supported by this exact version. + +For backend batching, construct one shared `RequestResolver.make` in the server service, optionally +set its collection delay with `RequestResolver.setDelay`, invoke a backend bulk operation for the +collected IDs, and complete every entry with its individual Exit. Handlers use +`Effect.request(request, resolver)`. Sharing the resolver is essential: its instance and batch key +own collection, and a delay adds latency before backend execution. The installed primary-source +recipe is `node_modules/effect/ai-docs/src/05_batching/10_request-resolver.ts` (lines 39–73); resolver +collection lives in `node_modules/effect/src/internal/request.ts` (lines 82–98, 116–165). + +Query Core's `node_modules/@tanstack/query-core/src/notifyManager.ts` batches notification +callbacks (lines 32–73, 100–113). This does not aggregate requests or replace individual cache +writes. A policy that batches streamed Query data publication addresses another cost and must +define its own delivery and cancellation behavior. + +## Keep the ready client alive + +The fixture demonstrates the supported socket ownership recipe: acquire the Socket, build +`RpcClient.layerProtocolSocket()` with serialization into the application's client Scope using +`Layer.buildWithScope`, and acquire `RpcClient.make(..., { flatten: true })` with that Context and +Scope. Pass the resulting ready RPC client to `createRpcQueryUtils`. Finish/cancel its queries and +close the client Scope before closing the host. The package acquires or disposes none of these +resources, as required by [ADR 0007](adr/0007-keep-the-core-factory-lifecycle-neutral.md). + +Providing a socket protocol Layer only around the acquisition Effect closes its receive-loop +resources when that Effect returns. Keep the Layer's Scope alive for all later calls. The fixture +closes its explicitly owned client Scope and loopback listener on every run. + +The bounded decision is to retain ordinary independent Query execution. Applications can choose +the proven shared WebSocket transport when repeated HTTP overhead matters, declare an explicit +bulk operation when their domain supports one, or batch backend work with RequestResolver. A +generic HTTP collection window has no stock-client support here and would introduce latency, +failure, cancellation, and retry policy that belongs to the application. diff --git a/examples/server/tests/rpc-transport-overhead.test.ts b/examples/server/tests/rpc-transport-overhead.test.ts new file mode 100644 index 0000000..4e4322b --- /dev/null +++ b/examples/server/tests/rpc-transport-overhead.test.ts @@ -0,0 +1,260 @@ +import { NodeHttpServer, NodeSocket } from '@effect/platform-node' +import { describe, expect, it } from '@effect/vitest' +import { QueryClient } from '@tanstack/query-core' +import { Effect, Exit, Layer, Schema, Scope } from 'effect' +import { FetchHttpClient, HttpClient } from 'effect/http' +import { Rpc, RpcClient, RpcGroup, RpcMessage, RpcSerialization, RpcServer } from 'effect/rpc' +import { Socket } from 'effect/socket' +import { createServer } from 'node:http' +import type { IncomingMessage } from 'node:http' +import type { Socket as TcpSocket } from 'node:net' + +import { createRpcQueryUtils } from '../../../src/index.ts' + +const group = RpcGroup.make( + Rpc.make('values.read', { payload: { id: Schema.Int }, success: Schema.Int }), +) +const handlers = group.toLayer({ 'values.read': ({ id }) => Effect.succeed(id) }) + +const headerBytes = (request: IncomingMessage) => { + let headers = `${request.method ?? ''} ${request.url ?? ''} HTTP/${request.httpVersion}\r\n` + for (let index = 0; index < request.rawHeaders.length; index += 2) { + headers += `${request.rawHeaders[index]}: ${request.rawHeaders[index + 1]}\r\n` + } + return Buffer.byteLength(`${headers}\r\n`) +} + +const measureHttpCalls = Effect.fn('TransportOverhead.measureHttpCalls')(function* (count: number) { + let requests = 0 + let requestBodyBytes = 0 + let requestHeaderBytes = 0 + let nextRequestId = 0 + const connections = new Set() + const nodeServer = createServer() + nodeServer.on('connection', (socket) => { + connections.add(socket) + }) + nodeServer.on('request', (request) => { + requests += 1 + requestHeaderBytes += headerBytes(request) + }) + const server = yield* NodeHttpServer.make(() => nodeServer, { + host: '127.0.0.1', + port: 0, + disablePreemptiveShutdown: true, + }) + if (server.address._tag === 'UnixPathAddress') { + return yield* Effect.die(new Error('Expected TCP address')) + } + const app = yield* RpcServer.toHttpEffect(group).pipe( + Effect.provide(Layer.merge(handlers, RpcSerialization.layerJson)), + ) + yield* server.serve(app) + const client = yield* RpcClient.make(group, { + flatten: true, + disableTracing: true, + generateRequestId: () => { + const id = RpcMessage.RequestId(nextRequestId) + nextRequestId += 1 + return id + }, + }).pipe( + Effect.provide( + RpcClient.layerProtocolHttp({ + url: `http://127.0.0.1:${String(server.address.port)}/rpc`, + transformClient: (httpClient) => + HttpClient.mapRequest(httpClient, (request) => { + if (request.body._tag !== 'Uint8Array') { + throw new Error('Expected encoded RPC byte body') + } + requestBodyBytes += request.body.body.byteLength + return request + }), + }).pipe(Layer.provide(RpcSerialization.layerJson), Layer.provide(FetchHttpClient.layer)), + ), + ) + const queryClient = new QueryClient() + yield* Effect.addFinalizer(() => + Effect.sync(() => { + queryClient.clear() + nodeServer.closeAllConnections() + }), + ) + const utils = createRpcQueryUtils(group, { client, keyPrefix: ['overhead'] }) + const values = yield* Effect.promise(async () => + Promise.all( + Array.from({ length: count }, async (_unused, id) => + queryClient.query(utils.values.read.queryOptions({ input: { id }, retry: false })), + ), + ), + ) + return { + values, + requests, + requestBodyBytes, + requestHeaderBytes, + connections: connections.size, + clientToServerBytes: [...connections].reduce((total, socket) => total + socket.bytesRead, 0), + cacheEntries: queryClient.getQueryCache().getAll().length, + } +}) + +const measureWebSocketCalls = Effect.fn('TransportOverhead.measureWebSocketCalls')(function* ( + count: number, +) { + let writes = 0 + let requestBodyBytes = 0 + let connections = 0 + let nextRequestId = 0 + let upgradeHeaderBytes = 0 + let connection: TcpSocket | undefined + const nodeServer = createServer() + nodeServer.on('upgrade', (request) => { + connections += 1 + upgradeHeaderBytes += headerBytes(request) + connection = request.socket + }) + const server = yield* NodeHttpServer.make(() => nodeServer, { + host: '127.0.0.1', + port: 0, + disablePreemptiveShutdown: true, + }) + if (server.address._tag === 'UnixPathAddress') { + return yield* Effect.die(new Error('Expected TCP address')) + } + const app = yield* RpcServer.toHttpEffectWebsocket(group).pipe( + Effect.provide(Layer.merge(handlers, RpcSerialization.layerJson)), + ) + yield* server.serve(app) + const makeWebSocket = yield* Socket.WebSocketConstructor.pipe( + Effect.provide(NodeSocket.layerWebSocketConstructorWS), + ) + const socket = yield* Socket.makeWebSocket( + `ws://127.0.0.1:${String(server.address.port)}/rpc`, + ).pipe( + Effect.provideService(Socket.WebSocketConstructor, (url, options) => { + const webSocket = makeWebSocket(url, options) + return { + get readyState() { + return webSocket.readyState + }, + addEventListener: (...args) => { + webSocket.addEventListener(...args) + }, + removeEventListener: (...args) => { + webSocket.removeEventListener(...args) + }, + close: (...args) => { + webSocket.close(...args) + }, + send: (data) => { + writes += 1 + requestBodyBytes += Buffer.byteLength(data) + webSocket.send(data) + }, + } + }), + ) + const clientScope = yield* Scope.fork(yield* Effect.scope, 'sequential') + const protocolContext = yield* Layer.buildWithScope( + RpcClient.layerProtocolSocket().pipe( + Layer.provide(RpcSerialization.layerJson), + Layer.provide(Layer.succeed(Socket.Socket, socket)), + ), + clientScope, + ) + const client = yield* RpcClient.make(group, { + flatten: true, + disableTracing: true, + generateRequestId: () => { + const id = RpcMessage.RequestId(nextRequestId) + nextRequestId += 1 + return id + }, + }).pipe(Effect.provide(protocolContext), Scope.provide(clientScope)) + const queryClient = new QueryClient() + yield* Effect.addFinalizer(() => + Effect.sync(() => { + queryClient.clear() + }), + ) + const utils = createRpcQueryUtils(group, { client, keyPrefix: ['overhead'] }) + const values = yield* Effect.promise(async () => + Promise.all( + Array.from({ length: count }, async (_unused, id) => + queryClient.query(utils.values.read.queryOptions({ input: { id }, retry: false })), + ), + ), + ) + const measurement = { + values, + writes, + requestBodyBytes, + upgradeHeaderBytes, + clientToServerBytes: connection?.bytesRead ?? 0, + connections, + cacheEntries: queryClient.getQueryCache().getAll().length, + } + yield* Scope.close(clientScope, Exit.void) + return measurement +}) + +const reportMeasurement = ( + transport: string, + count: number, + measurement: + | Effect.Success> + | Effect.Success>, +) => { + if (process.env['RPC_TRANSPORT_MEASURE'] === '1') { + process.stdout.write(`${JSON.stringify({ transport, count, ...measurement })}\n`) + } +} + +describe('independent RPC request overhead', () => { + it.live('keeps eight independent queries as eight HTTP requests', () => + Effect.gen(function* () { + const measurement = yield* measureHttpCalls(8) + expect(measurement.values).toStrictEqual([0, 1, 2, 3, 4, 5, 6, 7]) + expect(measurement.requests).toBe(8) + expect(measurement.cacheEntries).toBe(8) + expect(measurement.requestHeaderBytes).toBeGreaterThan(measurement.requestBodyBytes) + expect(measurement.clientToServerBytes).toBe( + measurement.requestHeaderBytes + measurement.requestBodyBytes, + ) + reportMeasurement('http', 8, measurement) + }), + ) + + it.live( + 'multiplexes eight queries on one WebSocket without combining their request messages', + () => + Effect.gen(function* () { + const measurement = yield* measureWebSocketCalls(8) + expect(measurement.values).toStrictEqual([0, 1, 2, 3, 4, 5, 6, 7]) + expect(measurement.writes).toBe(8) + expect(measurement.connections).toBe(1) + expect(measurement.cacheEntries).toBe(8) + expect(measurement.clientToServerBytes).toBe( + measurement.upgradeHeaderBytes + measurement.requestBodyBytes + 8 * 6, + ) + reportMeasurement('websocket', 8, measurement) + }), + ) + + it.live.each([1, 32])('measures %i independent calls through both transports', (count) => + Effect.gen(function* () { + const http = yield* measureHttpCalls(count).pipe(Effect.scoped) + const websocket = yield* measureWebSocketCalls(count).pipe(Effect.scoped) + expect(http.requests).toBe(count) + expect(websocket.writes).toBe(count) + expect(websocket.connections).toBe(1) + expect(http.cacheEntries).toBe(count) + expect(websocket.cacheEntries).toBe(count) + expect(http.values).toStrictEqual(websocket.values) + expect(http.requestBodyBytes).toBe(websocket.requestBodyBytes) + reportMeasurement('http', count, http) + reportMeasurement('websocket', count, websocket) + }), + ) +}) diff --git a/tsconfig.json b/tsconfig.json index 7cd6ac4..a6f9655 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -38,6 +38,7 @@ "examples/server/src/web-handler.ts", "examples/server/tests/http-api.test.ts", "examples/server/tests/server.test.ts", + "examples/server/tests/rpc-transport-overhead.test.ts", "examples/tanstack-start/src/lib/application.ts", "examples/tanstack-start/tests/server-render.test.tsx", "examples/vite-react/src/lib/application.ts", diff --git a/vite.config.ts b/vite.config.ts index cc75c93..72d00d8 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -323,6 +323,7 @@ export default defineConfig({ command: 'vp test', dependsOn: ['pack'], cache: { + env: ['RPC_TRANSPORT_MEASURE'], output: [], }, }, From b49e8b73999ab9e557c48ba7f4e9d91e5eba7807 Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Fri, 9 Oct 2026 21:52:36 +0200 Subject: [PATCH 06/97] fix: normalize live values and require fixed Query Core --- .changeset/ticket-97-live-values.md | 5 + GLOSSARY.md | 4 +- .../docs/concepts/data-normalization.md | 9 +- .../reference/compatibility-and-limits.md | 5 +- .../docs/reference/generated-builders.md | 4 + ...010-reuse-tanstack-query-core-as-a-peer.md | 6 +- ...rmalize-undefined-query-success-to-null.md | 10 +- ...ate-accumulated-and-live-stream-queries.md | 4 + package.json | 2 +- scripts/verify-packed-consumer.mts | 2 +- skills/effect-api-query/SKILL.md | 6 +- .../references/query-patterns.md | 5 +- src/core/types.ts | 4 +- src/rpc/streamed-query.ts | 4 +- src/rpc/types.ts | 8 +- tests/packed-consumer/runtime.mts | 134 +++++++++++++++++- tests/types/public-contract.ts | 50 +++++++ 17 files changed, 234 insertions(+), 28 deletions(-) create mode 100644 .changeset/ticket-97-live-values.md diff --git a/.changeset/ticket-97-live-values.md b/.changeset/ticket-97-live-values.md new file mode 100644 index 0000000..017c0d6 --- /dev/null +++ b/.changeset/ticket-97-live-values.md @@ -0,0 +1,5 @@ +--- +'effect-api-query': minor +--- + +Normalize live query undefined emissions to null in cached values, keys, selectors, and initial-data types. Preserve accumulated undefined elements, mutation results, and empty-live errors. Require Query Core 5.103.1 or later in v5 for correct null live values. diff --git a/GLOSSARY.md b/GLOSSARY.md index e2cdf9d..a25aed0 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -13,7 +13,7 @@ A nested object derived from an Effect HttpApi's literal group and endpoint iden _Avoid_: RPC utility tree, generated client **Query data**: -The successful decoded RPC or HTTP value presented to TanStack Query. Buffered queries convert successful `undefined` to `null`; streamed values and mutation data remain unchanged. +The successful decoded RPC or HTTP value presented to TanStack Query. Buffered queries and live queries convert successful `undefined` to `null`; accumulated stream elements and mutation data remain unchanged. _Avoid_: RPC success value when it is `undefined` **Accumulated streamed query**: @@ -21,7 +21,7 @@ A streaming RPC view that caches emitted values in order, optionally retaining o _Avoid_: Live query, infinite query **Live query**: -A streaming RPC view that caches only the latest emitted value. Stream completion preserves that value; completion before the first emission produces a package error. +A streaming RPC view that caches only the latest emitted value, converting `undefined` to `null`. Stream completion preserves that value; completion before the first emission produces a package error. _Avoid_: Accumulated streamed query, subscription ## Payloads and keys diff --git a/apps/docs/src/content/docs/concepts/data-normalization.md b/apps/docs/src/content/docs/concepts/data-normalization.md index d777c60..c5a9395 100644 --- a/apps/docs/src/content/docs/concepts/data-normalization.md +++ b/apps/docs/src/content/docs/concepts/data-normalization.md @@ -34,8 +34,13 @@ An HTTP endpoint using `HttpApiSchema.NoContent` decodes its successful 204 resp Mutations keep the original success type. A mutation that succeeds with `undefined` still resolves to `undefined`. -Accumulated streamed queries and live queries retain emitted values as supplied; they do not -apply buffered query normalization. +Live queries also convert each emitted `undefined` to `null`. A stream emitting a value followed +by `undefined` ends with `null` as its latest query data. Live keys, selectors, and `initialData` +use the same normalized type. The first emission makes an open live query successful while it +remains fetching; completion without any emission raises `EffectRpcQueryEmptyStreamError`. + +Accumulated streamed queries retain emitted values as supplied, including `undefined` elements +inside their cached arrays. The [packed RPC consumer](https://github.com/ueberBrot/effect-api-query/blob/main/tests/packed-consumer/runtime.mts) and [packed HTTP consumer](https://github.com/ueberBrot/effect-api-query/blob/main/tests/packed-consumer/http-runtime.mts) exercise these normalization rules. diff --git a/apps/docs/src/content/docs/reference/compatibility-and-limits.md b/apps/docs/src/content/docs/reference/compatibility-and-limits.md index 88f05ea..f304c41 100644 --- a/apps/docs/src/content/docs/reference/compatibility-and-limits.md +++ b/apps/docs/src/content/docs/reference/compatibility-and-limits.md @@ -5,7 +5,7 @@ description: Supported versions, RPC and HTTP operations, cache identity, and ru ## Supported integrations -The package targets Effect 4 and TanStack Query Core 5, with React Query and TanStack Start +The package targets Effect 4 and TanStack Query Core 5.103.1 or later in v5, with React Query and TanStack Start integrations checked in this repository. It is ESM-only and targets ES2022. Use TypeScript with `strict: true`. @@ -68,7 +68,8 @@ calls that do not need TanStack Query. - Mutation cancellation is outside the generated API. - Mutations do not invalidate queries automatically. Applications choose the affected key prefix. - RPC and HTTP query successes that may be `undefined` become `null` because TanStack Query rejects - `undefined` query data. Mutation results keep their original success type. + `undefined` query data. Live emissions follow this rule; accumulated stream elements and mutation + results keep their original success types. - Cache identity must be strict JSON. Encoding services, redacted values, and multiple HTTP payload alternatives require safe custom encoders for endpoints with query support. Binary query input needs a JSON-safe projection. Multipart mutation-only endpoints require no encoder and reject encoder entries. diff --git a/apps/docs/src/content/docs/reference/generated-builders.md b/apps/docs/src/content/docs/reference/generated-builders.md index 575e894..70283b5 100644 --- a/apps/docs/src/content/docs/reference/generated-builders.md +++ b/apps/docs/src/content/docs/reference/generated-builders.md @@ -148,6 +148,10 @@ Both option builders publish the query as successful after its first value and k `fetchStatus: 'fetching'` until the stream ends. An empty accumulated stream resolves to `[]`; an empty live stream fails with `EffectRpcQueryEmptyStreamError`. +Live queries convert emitted `undefined` to `null`; explicit `null` stays `null`. Their keys, +selectors, and initial-data options describe this normalized value. Accumulated arrays preserve +`undefined` elements. + Accumulated streams accept TanStack's `refetchMode` option: - `reset` clears cached values and returns the query to pending before refetching. This is the diff --git a/docs/adr/0010-reuse-tanstack-query-core-as-a-peer.md b/docs/adr/0010-reuse-tanstack-query-core-as-a-peer.md index dadd937..f19ea8a 100644 --- a/docs/adr/0010-reuse-tanstack-query-core-as-a-peer.md +++ b/docs/adr/0010-reuse-tanstack-query-core-as-a-peer.md @@ -8,8 +8,10 @@ Generated objects and declarations expose TanStack Query Core runtime values and ## Decision -`@tanstack/query-core` is an external peer. The package supports compatible v5 releases and tests -its declared lower bound and development version. Because the package imports Query Core's +`@tanstack/query-core` is an external peer. The package supports v5 releases from 5.103.1 and tests +its declared lower bound and development version. This minimum preserves `null` data in the +streamed-query helper. Packed consumers exercise undefined-only, null-only, value-then-undefined, +empty, and open live streams with both peers. Because the package imports Query Core's experimental streamed-query interface, each development update must pass packed-consumer type and runtime checks. The package re-exports only primitives required by its own interface, including the exact `skipToken` and `SkipToken` bindings. diff --git a/docs/adr/0012-normalize-undefined-query-success-to-null.md b/docs/adr/0012-normalize-undefined-query-success-to-null.md index 9ffaa24..9fb9ed8 100644 --- a/docs/adr/0012-normalize-undefined-query-success-to-null.md +++ b/docs/adr/0012-normalize-undefined-query-success-to-null.md @@ -9,10 +9,14 @@ runtime `undefined` for success. ## Decision -Generated query functions map successful `undefined` to cacheable `null`. Their query-data type also -replaces `undefined` with `null`. Mutations preserve `undefined`. +Generated buffered query functions and live-stream reducers map successful `undefined` to cacheable +`null`. Their query-data types replace `undefined` with `null`, including key DataTags, selectors, +and initial data. Explicit `null` stays `null`. Accumulated stream elements and mutation results +preserve `undefined`: an array containing `undefined` remains cacheable query data. ## Consequences -Every unary RPC remains usable as a query without disguising an upstream failure. This is the +Every unary RPC remains usable as a query without disguising an upstream failure. Every live +emission becomes the latest query data, including an undefined emission after a defined value. +An undefined emission counts as the first value; an empty live stream still fails. This is the adapter's only success-value normalization. diff --git a/docs/adr/0020-generate-accumulated-and-live-stream-queries.md b/docs/adr/0020-generate-accumulated-and-live-stream-queries.md index 4c44484..ea73e87 100644 --- a/docs/adr/0020-generate-accumulated-and-live-stream-queries.md +++ b/docs/adr/0020-generate-accumulated-and-live-stream-queries.md @@ -8,6 +8,10 @@ that completes without emitting a value raises `EffectRpcQueryEmptyStreamError`. and `live` key segments prevent collisions with other query shapes and mutations; generated prefixes support invalidation. +Live emissions normalize `undefined` to `null` under ADR 0012; accumulated elements remain +unchanged. On an initial fetch, the first emission makes either view successful while the open +stream remains fetching. Live completion preserves the latest normalized value. + The ready RPC client remains the execution seam, including direct execution outside TanStack. Client construction and Effect middleware remain interception seams. Stream functions forward TanStack's abort signal, close the AsyncIterator on cancellation, and preserve the complete Cause. diff --git a/package.json b/package.json index aeef267..2f1d6ca 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,7 @@ "vitest": "catalog:" }, "peerDependencies": { - "@tanstack/query-core": ">=5.102.0 <6", + "@tanstack/query-core": ">=5.103.1 <6", "effect": "catalog:" }, "packageManager": "pnpm@12.8.2" diff --git a/scripts/verify-packed-consumer.mts b/scripts/verify-packed-consumer.mts index 9a55197..02e684c 100644 --- a/scripts/verify-packed-consumer.mts +++ b/scripts/verify-packed-consumer.mts @@ -270,7 +270,7 @@ deepStrictEqual(declarationNames, [ ]) const queryCoreCompatibilityCases = () => { - const supportedPeerRange = '>=5.102.0 <6' + const supportedPeerRange = '>=5.103.1 <6' deepStrictEqual(packedManifest.peerDependencies, { '@tanstack/query-core': supportedPeerRange, effect: testedVersion('effect'), diff --git a/skills/effect-api-query/SKILL.md b/skills/effect-api-query/SKILL.md index 32c59f9..b03d9a0 100644 --- a/skills/effect-api-query/SKILL.md +++ b/skills/effect-api-query/SKILL.md @@ -95,9 +95,9 @@ work starts. Interrupting a client call does not undo a completed write. ## Interpret results and failures -Buffered queries normalize successful `undefined` to `null`; mutations preserve -`undefined`. Streamed values follow the stream contract described in the query -patterns reference. +Buffered queries and live emissions normalize successful `undefined` to `null`. +Accumulated stream elements and mutation results preserve `undefined`. See the +query patterns reference for stream behavior. Configuration and key-generation errors are synchronous: an options builder may fail before a hook or query function runs. Failed execution Exits become diff --git a/skills/effect-api-query/references/query-patterns.md b/skills/effect-api-query/references/query-patterns.md index 1956fb0..52a5180 100644 --- a/skills/effect-api-query/references/query-patterns.md +++ b/skills/effect-api-query/references/query-patterns.md @@ -131,8 +131,9 @@ Live queries cache only the latest value and accept neither history option. With no cached data, an empty accumulated stream resolves to `[]`. An empty append refetch preserves existing history; empty reset and replace refetches finish with `[]`. Empty live completion raises `EffectRpcQueryEmptyStreamError`. -Emitted chunks are retained as supplied, unlike buffered query `undefined` -normalization. +Live queries normalize each emitted `undefined` to `null`, including an emission +after a defined value. Live keys, selectors, and initial data use that normalized +type. Accumulated chunks retain their original values, including `undefined`. On an initial fetch, both views become successful after the first emission. They keep fetching until completion. Canceling closes the iterator and interrupts its diff --git a/src/core/types.ts b/src/core/types.ts index bc4206a..ac4c8ee 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -20,10 +20,10 @@ export type JsonPrimitive = boolean | null | number | string export type JsonValue = JsonPrimitive | { readonly [key: string]: JsonValue } | readonly JsonValue[] /** - * The value cached for a successful buffered query. + * The value cached for a successful buffered query or live stream emission. * * TanStack rejects `undefined` query data, so possible `undefined` values become - * `null`. Mutation results keep the success type unchanged. + * `null`. Accumulated stream elements and mutation results keep their success types unchanged. */ export type QueryData = undefined extends A ? Exclude | null : A diff --git a/src/rpc/streamed-query.ts b/src/rpc/streamed-query.ts index 27a9dec..0fb1218 100644 --- a/src/rpc/streamed-query.ts +++ b/src/rpc/streamed-query.ts @@ -131,8 +131,8 @@ const makeStreamQuery = ({ if (policy._tag === 'Live') { return experimental_streamedQuery({ - initialValue: undefined, - reducer: (_latest: unknown, value: unknown) => value, + initialValue: null, + reducer: (_latest: unknown, value: unknown) => (value === undefined ? null : value), streamFn, }) } diff --git a/src/rpc/types.ts b/src/rpc/types.ts index 70f3044..f1b4ea0 100644 --- a/src/rpc/types.ts +++ b/src/rpc/types.ts @@ -218,7 +218,7 @@ export type ConcreteLiveKey< void extends Rpc.PayloadConstructor ? LiveOperationKey : readonly [...LiveOperationKey, JsonValue], - Rpc.SuccessChunk, + QueryData>, RpcLiveError > @@ -267,7 +267,7 @@ export type LiveOptionsBuilder< ClientError, > = StreamingQueryBuilder< Rpc.PayloadConstructor, - Rpc.SuccessChunk, + QueryData>, RpcLiveError, ConcreteLiveKey, LiveOperationKey @@ -289,8 +289,8 @@ export interface RpcStreamLeaf< readonly liveKey: StreamKeyBuilder> /** - * Builds latest-value query options. A stream that completes before its first value fails with - * {@link EffectRpcQueryEmptyStreamError}. + * Builds latest-value query options, normalizing emitted `undefined` to `null`. A stream that + * completes before its first value fails with {@link EffectRpcQueryEmptyStreamError}. */ readonly liveOptions: LiveOptionsBuilder diff --git a/tests/packed-consumer/runtime.mts b/tests/packed-consumer/runtime.mts index 9e67e4a..5e320a4 100644 --- a/tests/packed-consumer/runtime.mts +++ b/tests/packed-consumer/runtime.mts @@ -1,4 +1,4 @@ -import { QueryClient, skipToken } from '@tanstack/query-core' +import { MutationObserver, QueryClient, QueryObserver, skipToken } from '@tanstack/query-core' import { skipToken as reactQuerySkipToken } from '@tanstack/react-query' import { Effect, Schema, Stream } from 'effect' import * as rpcQuery from 'effect-api-query' @@ -99,11 +99,40 @@ const Page = Rpc.make('compatibility.page', { success: Schema.Int, }) const Watch = Rpc.make('compatibility.watch', { success: Schema.String, stream: true }) -const group = RpcGroup.make(Read, Page, Watch) +const UndefinedWatch = Rpc.make('compatibility.undefined', { + success: Schema.Undefined, + stream: true, +}) +const NullWatch = Rpc.make('compatibility.null', { success: Schema.Null, stream: true }) +const OptionalWatch = Rpc.make('compatibility.optional', { + success: Schema.UndefinedOr(Schema.String), + stream: true, +}) +const OpenWatch = Rpc.make('compatibility.open', { success: Schema.Undefined, stream: true }) +const EmptyWatch = Rpc.make('compatibility.empty', { success: Schema.Undefined, stream: true }) +const WriteUndefined = Rpc.make('compatibility.write', { success: Schema.Undefined }) +const group = RpcGroup.make( + Read, + Page, + Watch, + UndefinedWatch, + NullWatch, + OptionalWatch, + OpenWatch, + EmptyWatch, + WriteUndefined, +) const handlers = group.of({ + 'compatibility.empty': () => Stream.empty, + 'compatibility.null': () => Stream.succeed(null), + 'compatibility.open': () => + Stream.succeed(undefined).pipe(Stream.concat(Stream.fromEffect(Effect.never))), + 'compatibility.optional': () => Stream.make('value', undefined), 'compatibility.page': Effect.fn('CompatibilityRpc.page')(({ cursor }) => Effect.succeed(cursor)), 'compatibility.read': Effect.fn('CompatibilityRpc.read')(() => Effect.succeed('ordinary')), + 'compatibility.undefined': () => Stream.succeed(undefined), 'compatibility.watch': () => Stream.make('first', 'second'), + 'compatibility.write': () => Effect.succeed(undefined), }) await Effect.runPromise( @@ -148,6 +177,107 @@ await Effect.runPromise( ), 'second', ) + equal( + yield* Effect.promise(() => + queryClient.query(rpcUtilityTree.compatibility.undefined.liveOptions()), + ), + null, + 'An undefined emission must become the live value', + ) + equal(queryClient.getQueryData(rpcUtilityTree.compatibility.undefined.liveKey()), null) + equal( + yield* Effect.promise(() => + queryClient.query(rpcUtilityTree.compatibility.null.liveOptions()), + ), + null, + 'An explicit null emission must remain null', + ) + equal(queryClient.getQueryData(rpcUtilityTree.compatibility.null.liveKey()), null) + + const optionalOptions = rpcUtilityTree.compatibility.optional.liveOptions() + const latestValues: unknown[] = [] + const stopRecording = queryClient.getQueryCache().subscribe((event) => { + if ( + event.type === 'updated' && + event.action.type === 'success' && + event.query === + queryClient.getQueryCache().find({ queryKey: optionalOptions.queryKey, exact: true }) + ) { + latestValues.push(event.query.state.data) + } + }) + try { + equal(yield* Effect.promise(() => queryClient.query(optionalOptions)), null) + deepStrictEqual(latestValues.slice(0, 2), ['value', null]) + equal(queryClient.getQueryData(optionalOptions.queryKey), null) + } finally { + stopRecording() + } + + deepStrictEqual( + yield* Effect.promise(() => + queryClient.query(rpcUtilityTree.compatibility.undefined.streamedOptions()), + ), + [undefined], + 'Accumulated undefined elements must be preserved', + ) + deepStrictEqual( + yield* Effect.promise(() => + queryClient.query(rpcUtilityTree.compatibility.optional.streamedOptions()), + ), + ['value', undefined], + ) + const mutation = new MutationObserver( + queryClient, + rpcUtilityTree.compatibility.write.mutationOptions(), + ) + equal(yield* Effect.promise(() => mutation.mutate(undefined)), undefined) + + yield* Effect.promise(() => + rejects( + queryClient.query(rpcUtilityTree.compatibility.empty.liveOptions({ retry: false })), + (error: unknown) => { + ok(error instanceof rpcQuery.EffectRpcQueryEmptyStreamError) + equal(error.rpcTag, 'compatibility.empty') + return true + }, + ), + ) + equal(queryClient.getQueryData(rpcUtilityTree.compatibility.empty.liveKey()), undefined) + + const openOptions = rpcUtilityTree.compatibility.open.liveOptions({ retry: false }) + const observer = new QueryObserver(queryClient, openOptions) + let unsubscribe = () => {} + let timeout: ReturnType | undefined + try { + const firstSnapshot = yield* Effect.promise( + () => + new Promise<{ readonly data: null; readonly fetchStatus: string }>( + (resolve, reject) => { + timeout = setTimeout( + () => reject(new Error('Live first emission timed out')), + 10_000, + ) + unsubscribe = observer.subscribe((result) => { + if (result.status === 'success') { + resolve(result) + } else if (result.status === 'error') { + reject(result.error) + } + }) + }, + ), + ) + equal(firstSnapshot.data, null) + equal(firstSnapshot.fetchStatus, 'fetching') + equal(queryClient.getQueryData(openOptions.queryKey), null) + } finally { + clearTimeout(timeout) + unsubscribe() + yield* Effect.promise(() => queryClient.cancelQueries({ queryKey: openOptions.queryKey })) + observer.destroy() + queryClient.clear() + } }), ), ) diff --git a/tests/types/public-contract.ts b/tests/types/public-contract.ts index e3835d1..c872e17 100644 --- a/tests/types/public-contract.ts +++ b/tests/types/public-contract.ts @@ -364,6 +364,56 @@ const initializedLive = useQuery( ) initializedLive.data satisfies number +const normalizedLiveGroup = RpcGroup.make( + Rpc.make('values.undefined', { success: Schema.Undefined, stream: true }), + Rpc.make('values.void', { success: Schema.Void, stream: true }), + Rpc.make('values.null', { success: Schema.Null, stream: true }), + Rpc.make('values.optional', { success: Schema.UndefinedOr(Schema.String), stream: true }), +) +declare const normalizedLiveClient: RpcClient.RpcClient.Flat< + RpcGroup.Rpcs +> +const normalizedLiveUtils = createRpcQueryUtils(normalizedLiveGroup, { + client: normalizedLiveClient, + keyPrefix, +}) +true satisfies Assert, string | null>> +true satisfies Assert, null>> +const undefinedLiveResult = queryClient.query(normalizedLiveUtils.values.undefined.liveOptions()) +true satisfies Assert>> +const voidLiveResult = queryClient.query(normalizedLiveUtils.values.void.liveOptions()) +true satisfies Assert>> +const nullLiveResult = queryClient.query(normalizedLiveUtils.values.null.liveOptions()) +true satisfies Assert>> +const optionalLiveKey = normalizedLiveUtils.values.optional.liveKey() +const cachedOptionalLive = queryClient.getQueryData(optionalLiveKey) +true satisfies Assert> +const cachedUndefinedLive = queryClient.getQueryData(normalizedLiveUtils.values.undefined.liveKey()) +true satisfies Assert> +queryClient.setQueryData(optionalLiveKey, null) +const initializedNormalizedLive = useQuery( + normalizedLiveUtils.values.optional.liveOptions({ + initialData: null, + select: (value) => { + true satisfies Assert> + return value === null ? 0 : value.length + }, + }), +) +true satisfies Assert> +const initializedUndefinedLive = useQuery( + normalizedLiveUtils.values.undefined.liveOptions({ initialData: () => null }), +) +true satisfies Assert> +const optionalNormalizedInitial = useQuery( + normalizedLiveUtils.values.optional.liveOptions({ initialData: () => undefined }), +) +true satisfies Assert> +const accumulatedUndefined = queryClient.query( + normalizedLiveUtils.values.undefined.streamedOptions({ initialData: [undefined] }), +) +true satisfies Assert>> + declare const clientFailureClient: RpcClient.RpcClient.Flat const clientFailureUtils = createRpcQueryUtils( group, From 0089789f2e7e332282e0d5085887c21f857acfc3 Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Fri, 9 Oct 2026 21:57:32 +0200 Subject: [PATCH 07/97] fix: align transport proof ownership and ADR decision --- ...keep-the-core-factory-lifecycle-neutral.md | 6 +- ...ransport-aggregation-in-the-application.md | 80 ++++++++++++++ docs/rpc-transport-batching.md | 101 ------------------ .../tests/rpc-transport-overhead.test.ts | 45 ++------ tests/fixtures/rpc-transport-queries.ts | 34 ++++++ tsconfig.json | 1 + vite.config.ts | 2 +- 7 files changed, 128 insertions(+), 141 deletions(-) create mode 100644 docs/adr/0026-keep-rpc-transport-aggregation-in-the-application.md delete mode 100644 docs/rpc-transport-batching.md create mode 100644 tests/fixtures/rpc-transport-queries.ts diff --git a/docs/adr/0007-keep-the-core-factory-lifecycle-neutral.md b/docs/adr/0007-keep-the-core-factory-lifecycle-neutral.md index f660fbd..63b391f 100644 --- a/docs/adr/0007-keep-the-core-factory-lifecycle-neutral.md +++ b/docs/adr/0007-keep-the-core-factory-lifecycle-neutral.md @@ -18,7 +18,5 @@ an injected runner with the same `effect, { signal? }` shape. The caller owns protocol, middleware, lifetime, and resources. Queries forward TanStack's abort signal, mutations omit it, and runner rejections propagate untouched. -The [transport batching measurement](../rpc-transport-batching.md) confirms that shared WebSocket -connections reduce repeated HTTP overhead while retaining independent request envelopes. Transport -aggregation and backend RequestResolver batching remain application choices; the utility tree does -not automatically collect independent calls. +[ADR 0026](0026-keep-rpc-transport-aggregation-in-the-application.md) records the transport overhead +measurement and preserves application ownership of aggregation. diff --git a/docs/adr/0026-keep-rpc-transport-aggregation-in-the-application.md b/docs/adr/0026-keep-rpc-transport-aggregation-in-the-application.md new file mode 100644 index 0000000..c808fa6 --- /dev/null +++ b/docs/adr/0026-keep-rpc-transport-aggregation-in-the-application.md @@ -0,0 +1,80 @@ +# Keep RPC transport aggregation in the application + +Status: Accepted. Extends [ADR 0007](0007-keep-the-core-factory-lifecycle-neutral.md). + +## Context + +Independent ready RPC client calls can incur repeated HTTP headers and connection overhead. A +shared WebSocket can reduce that cost while preserving individual request envelopes, responses, +failures, and cancellation. + +## Decision + +The application selects and owns the transport. Keep independent Query execution in the RPC +utility tree. Use a shared WebSocket when repeated HTTP overhead matters, or declare an explicit +bulk RPC when the domain supports one. A collecting custom protocol must own its collection delay, +response routing, retries, failures, interruption, and lifetime. + +Transport aggregation, backend RequestResolver batching, and Query publication address separate +costs. A shared server RequestResolver can combine backend lookups while frontend RPC requests +remain independent. Query notification batching changes callback scheduling; streamed publication +batching changes cache update frequency. Neither reduces transport request count. + +Effect 4.0.0's stock HTTP protocol performs one POST per Request in +`node_modules/effect/src/rpc/RpcClient.ts`, `makeProtocolHttp`. Its `makeProtocolSocket` shares a +writer and routes responses by request ID, then writes each encoded message separately. The +WebSocket adapter's writer maps each write to one send in `node_modules/effect/src/socket/Socket.ts`. +JSON array/framing support in `RpcSerialization.ts` and batch input parsing in `RpcServer.ts` +provide no stock-client collection window. + +## Measurement + +The [protocol fixture](../../examples/server/tests/rpc-transport-overhead.test.ts) uses real loopback +Effect HTTP/WebSocket servers, ready clients, the public package factory, and a real QueryClient. +Each row starts a fresh host, client, and cache, then runs concurrent unary queries with distinct +integer payloads. JSON serialization, fixed request IDs, disabled RPC tracing, and disabled Query +retries keep the request sizes comparable. + +```sh +RPC_TRANSPORT_MEASURE=1 vp run --no-cache test examples/server/tests/rpc-transport-overhead.test.ts +``` + +The October 9, 2026 measurement used Node 24.5.0, Effect and Node platform 4.0.0, Query Core 5.104.0, +and Vite+ 1.0.0. + +| Calls | Transport | POSTs / sends | Connections | Envelope bytes | Header / upgrade bytes | Client → server bytes | +| ----: | --------- | ------------: | ----------: | -------------: | ---------------------: | --------------------: | +| 1 | HTTP | 1 | 1 | 77 | 371 | 448 | +| 1 | WebSocket | 1 | 1 | 77 | 227 | 310 | +| 8 | HTTP | 8 | 8 | 616 | 2,968 | 3,584 | +| 8 | WebSocket | 8 | 1 | 616 | 227 | 891 | +| 32 | HTTP | 32 | 32 | 2,508 | 11,872 | 14,380 | +| 32 | WebSocket | 32 | 1 | 2,508 | 227 | 2,927 | + +At eight calls, WebSocket multiplexing reduces outbound bytes by about 75% while retaining eight +envelopes and eight sends. At 32 calls the reduction is about 80%. Each query returns its individual +value and creates a separate successful cache entry. + +Counts use encoded HTTP bodies, actual request lines/raw headers, WebSocket sends, and server TCP +`bytesRead`. WebSocket totals include one upgrade and six masked-frame bytes per small request. +Counts end after responses arrive and before disposal. Responses, TCP/IP headers, TLS, connection +setup packets, and later keepalives are excluded. This measures bytes and requests, not latency, +throughput, or CPU cost. HTTP connection counts reflect this cold concurrent HTTP/1.1 fetch pool; +warm connections, HTTP/2, browsers, compression, larger payloads, and remote backends need their own +measurement. Header sizes depend on the Node/fetch version and application headers. + +## Consequences + +Acquire the socket, build its protocol Layer into an application-owned client Scope with +`Layer.buildWithScope`, and acquire the flat client with that Context and Scope. Keep the Scope +alive while the utility tree uses the ready RPC client. Close the client Scope before its host; +providing the socket protocol Layer only around acquisition closes its receive loop too early. + +For backend batching, share one `RequestResolver.make` instance in the server service, optionally +set a collection delay with `RequestResolver.setDelay`, run the backend bulk operation, and complete +each entry with its individual Exit. Handlers call `Effect.request(request, resolver)`. The installed +recipe is `node_modules/effect/ai-docs/src/05_batching/10_request-resolver.ts`; collection belongs to +the shared resolver instance and batch key. A collection delay adds latency. + +The package retains its lifecycle-neutral ready-client boundary and adds no automatic transport +batching API. diff --git a/docs/rpc-transport-batching.md b/docs/rpc-transport-batching.md deleted file mode 100644 index 88375fa..0000000 --- a/docs/rpc-transport-batching.md +++ /dev/null @@ -1,101 +0,0 @@ -# RPC transport batching decision - -Keep transport policy in Effect and the application. The installed Effect 4.0.0 HTTP and socket -protocols send one request envelope per independent call. A shared WebSocket removes repeated HTTP -request headers and shares one connection, but it still sends each envelope separately. This -measurement supports an application-owned multiplexed transport when request overhead matters; -it does not justify an automatic batching engine in the RPC utility tree. - -## Reproduce the measurement - -Run the retained [protocol fixture](../examples/server/tests/rpc-transport-overhead.test.ts): - -```sh -RPC_TRANSPORT_MEASURE=1 vp run --no-cache test examples/server/tests/rpc-transport-overhead.test.ts -``` - -The fixture uses the public package factory, a real QueryClient, public Effect clients/servers, -and the existing server workspace's Node platform adapter. It runs 1, 8, and 32 concurrent unary -queries with distinct integer payloads and empty caches. Each call returns its own integer; Query -retries are disabled. Fixed request IDs and disabled RPC tracing make envelope sizes reproducible. -No dependencies are installed. - -The October 9, 2026 baseline used Node 24.5.0, Effect and Node platform 4.0.0, Query Core 5.104.0, -and Vite+ 1.0.0. Each row starts a fresh loopback host and ready client. HTTP uses FetchHttpClient -with JSON serialization; WebSocket uses the installed Node WebSocket constructor with the same -serialization. HTTP connection counts describe this concurrent HTTP/1.1 workload and the default -fetch connection pool, rather than a requirement of the RPC protocol. - -| Calls | Transport | POSTs / WebSocket sends | Connections | RPC envelope bytes | HTTP request / upgrade bytes | Client → server bytes | -| ----: | --------- | ----------------------: | ----------: | -----------------: | ---------------------------: | --------------------: | -| 1 | HTTP | 1 | 1 | 77 | 371 | 448 | -| 1 | WebSocket | 1 | 1 | 77 | 227 | 310 | -| 8 | HTTP | 8 | 8 | 616 | 2,968 | 3,584 | -| 8 | WebSocket | 8 | 1 | 616 | 227 | 891 | -| 32 | HTTP | 32 | 32 | 2,508 | 11,872 | 14,380 | -| 32 | WebSocket | 32 | 1 | 2,508 | 227 | 2,927 | - -For eight calls, multiplexing reduces measured client-to-server bytes by about 75%; at 32 calls, -the reduction is about 80%. Envelope counts and envelope bytes remain equal. All calls produce -separate successful cache entries. - -Byte counts come from encoded HTTP bodies, the actual request line and raw headers, actual -WebSocket sends, and the server TCP sockets' `bytesRead`. WebSocket totals include one upgrade -request and the six-byte masked frame overhead of each small request in this fixture. Counts end -after responses arrive and before disposal. They exclude response traffic, TCP/IP packet headers, -TLS, connection setup packets, and later keepalives. They measure bytes and calls, not latency, -throughput, or CPU cost. Browser, HTTP/2, TLS, compression, warm connections, larger payloads, and -remote backends require their own application measurement. Exact header bytes may change with the -Node/fetch version or application headers. - -## Configure the layer that owns the cost - -| Concern | Owner and control | Effect on independent RPC requests | -| --------------------------------------------------------- | --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | -| Connection and repeated HTTP overhead | Application selects a shared socket protocol | One request envelope and WebSocket send per call; one shared connection | -| Several logical operations in one HTTP request | Application declares a bulk RPC or owns a collecting custom protocol | A bulk RPC can change the contract to one request; a custom protocol must preserve per-call response routing, failures, interruption, and lifetime | -| Backend/database lookup overhead | Server shares a RequestResolver and uses the backend's bulk operation | Fewer backend calls can serve separate RPC requests; network requests remain independent | -| Query cache publication or observer notification overhead | Application/Query consumption and notification policy | Changes update frequency or callback scheduling; it does not change transport request count | - -Effect's HTTP client protocol encodes a single request and performs a single POST in -`node_modules/effect/src/rpc/RpcClient.ts`, `makeProtocolHttp` (lines 937–949). The socket protocol -shares a writer and routes responses by request ID, then encodes and writes each message separately -in `makeProtocolSocket` (lines 1068–1072, 1103–1110, 1195–1204). The public WebSocket adapter maps -each write to one `send` in `node_modules/effect/src/socket/Socket.ts` (lines 1128–1137). - -`node_modules/effect/src/rpc/RpcSerialization.ts` accepts JSON arrays and provides NDJSON/JSON-RPC -framing. The HTTP server also accepts multiple decoded messages in one request -(`RpcServer.ts`, lines 1160–1168). Those parsing capabilities do not collect independent calls in -the stock ready client. No stock-client aggregation option is supported by this exact version. - -For backend batching, construct one shared `RequestResolver.make` in the server service, optionally -set its collection delay with `RequestResolver.setDelay`, invoke a backend bulk operation for the -collected IDs, and complete every entry with its individual Exit. Handlers use -`Effect.request(request, resolver)`. Sharing the resolver is essential: its instance and batch key -own collection, and a delay adds latency before backend execution. The installed primary-source -recipe is `node_modules/effect/ai-docs/src/05_batching/10_request-resolver.ts` (lines 39–73); resolver -collection lives in `node_modules/effect/src/internal/request.ts` (lines 82–98, 116–165). - -Query Core's `node_modules/@tanstack/query-core/src/notifyManager.ts` batches notification -callbacks (lines 32–73, 100–113). This does not aggregate requests or replace individual cache -writes. A policy that batches streamed Query data publication addresses another cost and must -define its own delivery and cancellation behavior. - -## Keep the ready client alive - -The fixture demonstrates the supported socket ownership recipe: acquire the Socket, build -`RpcClient.layerProtocolSocket()` with serialization into the application's client Scope using -`Layer.buildWithScope`, and acquire `RpcClient.make(..., { flatten: true })` with that Context and -Scope. Pass the resulting ready RPC client to `createRpcQueryUtils`. Finish/cancel its queries and -close the client Scope before closing the host. The package acquires or disposes none of these -resources, as required by [ADR 0007](adr/0007-keep-the-core-factory-lifecycle-neutral.md). - -Providing a socket protocol Layer only around the acquisition Effect closes its receive-loop -resources when that Effect returns. Keep the Layer's Scope alive for all later calls. The fixture -closes its explicitly owned client Scope and loopback listener on every run. - -The bounded decision is to retain ordinary independent Query execution. Applications can choose -the proven shared WebSocket transport when repeated HTTP overhead matters, declare an explicit -bulk operation when their domain supports one, or batch backend work with RequestResolver. A -generic HTTP collection window has no stock-client support here and would introduce latency, -failure, cancellation, and retry policy that belongs to the application. diff --git a/examples/server/tests/rpc-transport-overhead.test.ts b/examples/server/tests/rpc-transport-overhead.test.ts index 4e4322b..02d666f 100644 --- a/examples/server/tests/rpc-transport-overhead.test.ts +++ b/examples/server/tests/rpc-transport-overhead.test.ts @@ -1,19 +1,18 @@ import { NodeHttpServer, NodeSocket } from '@effect/platform-node' import { describe, expect, it } from '@effect/vitest' -import { QueryClient } from '@tanstack/query-core' -import { Effect, Exit, Layer, Schema, Scope } from 'effect' +import { Effect, Exit, Layer, Scope } from 'effect' import { FetchHttpClient, HttpClient } from 'effect/http' -import { Rpc, RpcClient, RpcGroup, RpcMessage, RpcSerialization, RpcServer } from 'effect/rpc' +import { RpcClient, RpcMessage, RpcSerialization, RpcServer } from 'effect/rpc' import { Socket } from 'effect/socket' import { createServer } from 'node:http' import type { IncomingMessage } from 'node:http' import type { Socket as TcpSocket } from 'node:net' -import { createRpcQueryUtils } from '../../../src/index.ts' +import { + queryTransportCalls, + transportGroup as group, +} from '../../../tests/fixtures/rpc-transport-queries.ts' -const group = RpcGroup.make( - Rpc.make('values.read', { payload: { id: Schema.Int }, success: Schema.Int }), -) const handlers = group.toLayer({ 'values.read': ({ id }) => Effect.succeed(id) }) const headerBytes = (request: IncomingMessage) => { @@ -73,29 +72,19 @@ const measureHttpCalls = Effect.fn('TransportOverhead.measureHttpCalls')(functio }).pipe(Layer.provide(RpcSerialization.layerJson), Layer.provide(FetchHttpClient.layer)), ), ) - const queryClient = new QueryClient() yield* Effect.addFinalizer(() => Effect.sync(() => { - queryClient.clear() nodeServer.closeAllConnections() }), ) - const utils = createRpcQueryUtils(group, { client, keyPrefix: ['overhead'] }) - const values = yield* Effect.promise(async () => - Promise.all( - Array.from({ length: count }, async (_unused, id) => - queryClient.query(utils.values.read.queryOptions({ input: { id }, retry: false })), - ), - ), - ) + const queries = yield* queryTransportCalls(client, count) return { - values, + ...queries, requests, requestBodyBytes, requestHeaderBytes, connections: connections.size, clientToServerBytes: [...connections].reduce((total, socket) => total + socket.bytesRead, 0), - cacheEntries: queryClient.getQueryCache().getAll().length, } }) @@ -172,28 +161,14 @@ const measureWebSocketCalls = Effect.fn('TransportOverhead.measureWebSocketCalls return id }, }).pipe(Effect.provide(protocolContext), Scope.provide(clientScope)) - const queryClient = new QueryClient() - yield* Effect.addFinalizer(() => - Effect.sync(() => { - queryClient.clear() - }), - ) - const utils = createRpcQueryUtils(group, { client, keyPrefix: ['overhead'] }) - const values = yield* Effect.promise(async () => - Promise.all( - Array.from({ length: count }, async (_unused, id) => - queryClient.query(utils.values.read.queryOptions({ input: { id }, retry: false })), - ), - ), - ) + const queries = yield* queryTransportCalls(client, count) const measurement = { - values, + ...queries, writes, requestBodyBytes, upgradeHeaderBytes, clientToServerBytes: connection?.bytesRead ?? 0, connections, - cacheEntries: queryClient.getQueryCache().getAll().length, } yield* Scope.close(clientScope, Exit.void) return measurement diff --git a/tests/fixtures/rpc-transport-queries.ts b/tests/fixtures/rpc-transport-queries.ts new file mode 100644 index 0000000..6d49d0f --- /dev/null +++ b/tests/fixtures/rpc-transport-queries.ts @@ -0,0 +1,34 @@ +import { QueryClient } from '@tanstack/query-core' +import { Effect, Schema } from 'effect' +import { Rpc, RpcGroup } from 'effect/rpc' +import type { RpcClient, RpcClientError } from 'effect/rpc' + +import { createRpcQueryUtils } from '../../src/index.ts' + +export const transportGroup = RpcGroup.make( + Rpc.make('values.read', { payload: { id: Schema.Int }, success: Schema.Int }), +) + +export const queryTransportCalls = Effect.fnUntraced(function* ( + client: RpcClient.RpcClient.Flat< + RpcGroup.Rpcs, + RpcClientError.RpcClientError + >, + count: number, +) { + const queryClient = new QueryClient() + yield* Effect.addFinalizer(() => + Effect.sync(() => { + queryClient.clear() + }), + ) + const utils = createRpcQueryUtils(transportGroup, { client, keyPrefix: ['overhead'] }) + const values = yield* Effect.promise(async () => + Promise.all( + Array.from({ length: count }, async (_unused, id) => + queryClient.query(utils.values.read.queryOptions({ input: { id }, retry: false })), + ), + ), + ) + return { values, cacheEntries: queryClient.getQueryCache().getAll().length } +}) diff --git a/tsconfig.json b/tsconfig.json index 81e0739..53d39ac 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -49,6 +49,7 @@ "tests/http-*.test.ts", "tests/fixtures/effect-rpc.ts", "tests/fixtures/server-local-rpc.ts", + "tests/fixtures/rpc-transport-queries.ts", "tests/fixtures/http-client.ts", "tests/types/**/*.ts" ], diff --git a/vite.config.ts b/vite.config.ts index 7ce7eff..20bda79 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -323,6 +323,7 @@ export default defineConfig({ command: 'vp test', dependsOn: ['pack', 'server-local-types'], cache: { + env: ['RPC_TRANSPORT_MEASURE'], output: [], }, }, @@ -332,7 +333,6 @@ export default defineConfig({ 'node node_modules/typescript-5.9/bin/tsc --project tests/fixtures/tsconfig.server-local.json', ], cache: { - env: ['RPC_TRANSPORT_MEASURE'], output: [], }, }, From 085508c02160cc30bfc7aafece504f7b5fcb7a84 Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Fri, 9 Oct 2026 22:01:44 +0200 Subject: [PATCH 08/97] chore: keep native quarantine and consolidate cached-view decisions --- .fallowrc.jsonc | 7 +- .github/workflows/ci.yml | 3 - .github/workflows/release.yml | 2 - AGENTS.md | 4 - apps/docs/src/content/docs/examples.md | 4 - .../docs/getting-started/installation.md | 4 - .../0016-automate-releases-with-changesets.md | 10 +- ...-new-dependency-releases-for-seven-days.md | 18 +- ...ject-http-streams-and-buffered-metadata.md | 192 +++++++++++++----- docs/agents/install-policy.md | 39 ---- docs/design/http-cached-views.md | 120 ----------- scripts/install-policy.mts | 28 --- scripts/verify-install-policy.mts | 161 --------------- scripts/verify-packed-consumer.mts | 3 +- ...reate-rpc-query-utils-server-local.test.ts | 1 - tests/fixtures/server-local-rpc.ts | 1 - tests/types/http-view-proof.ts | 2 - tests/types/server-local-client-contract.ts | 1 - vite.config.ts | 12 +- 19 files changed, 151 insertions(+), 461 deletions(-) delete mode 100644 docs/agents/install-policy.md delete mode 100644 docs/design/http-cached-views.md delete mode 100644 scripts/install-policy.mts delete mode 100644 scripts/verify-install-policy.mts diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 217e5cf..477875d 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -3,12 +3,7 @@ // Astro copies this browser entry from public/ and loads it through the // Starlight head configuration, which static import analysis cannot follow. - "entry": [ - "apps/docs/public/package-version.js", - "playwright.docs.config.ts", - // Vite+ invokes this verifier through its dynamically declared install-policy task. - "scripts/verify-install-policy.mts" - ], + "entry": ["apps/docs/public/package-version.js", "playwright.docs.config.ts"], "ignorePatterns": ["examples/tanstack-start/src/routeTree.gen.ts"], diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8a9d044..0dd8acd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,9 +38,6 @@ jobs: - name: Verify formatting run: vp run format - - name: Verify dependency quarantine - run: vp run install-policy - - name: Construct package prerequisite run: vp run pack diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3a7deec..361580b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,8 +9,6 @@ on: - pnpm-lock.yaml - pnpm-workspace.yaml - scripts/verify-packed-consumer.mts - - scripts/install-policy.mts - - scripts/verify-install-policy.mts - vite.config.ts push: branches: diff --git a/AGENTS.md b/AGENTS.md index 66df0fb..9767767 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,10 +16,6 @@ Before exploring the codebase, read [the domain documentation guide](docs/agents Check `package.json` and `vite.config.ts` first, and run `vp run ` when the project defines a script or task with that name. -### Dependency installs - -Before installing or changing dependencies, read [the install policy](docs/agents/install-policy.md). Every registry dependency must satisfy pnpm's strict seven-day quarantine, including isolated consumers and frozen or cached installs. No exceptions or bypasses. - ### Testing Use compile-time fixtures to verify the published type contract. Reserve runtime tests for behavior that can fail at runtime; avoid testing guarantees already enforced by the type system. diff --git a/apps/docs/src/content/docs/examples.md b/apps/docs/src/content/docs/examples.md index 6e49a82..8b3f9cd 100644 --- a/apps/docs/src/content/docs/examples.md +++ b/apps/docs/src/content/docs/examples.md @@ -29,10 +29,6 @@ the workspace dependencies: pnpm install --frozen-lockfile ``` -The workspace enforces a strict seven-day quarantine for every registry dependency. Frozen -lockfiles and cached packages remain subject to the same check, and missing publication times -fail installation. Wait for rejected versions to become eligible; do not approve an exception. - The workspace includes Vite+. If `vp` is not on your shell's PATH, prefix the commands below with `pnpm exec`, for example `pnpm exec vp run vite-react-dev`. diff --git a/apps/docs/src/content/docs/getting-started/installation.md b/apps/docs/src/content/docs/getting-started/installation.md index b7d4e67..f2f8f63 100644 --- a/apps/docs/src/content/docs/getting-started/installation.md +++ b/apps/docs/src/content/docs/getting-started/installation.md @@ -9,10 +9,6 @@ Install the package and TanStack Query Core: pnpm add effect-api-query @tanstack/query-core ``` -This repository uses a strict seven-day dependency quarantine. A newly published package version -becomes eligible for registry installation seven days after publication; this includes the initial -release. Use an eligible version and preserve your application's release-age policy. - With pnpm's default peer installation enabled, this also installs the exact Effect release required by the package. If your application already pins Effect or disables automatic peer installation, install the Effect version from the package's `peerDependencies` and resolve any peer mismatch diff --git a/docs/adr/0016-automate-releases-with-changesets.md b/docs/adr/0016-automate-releases-with-changesets.md index 2747a67..a59be82 100644 --- a/docs/adr/0016-automate-releases-with-changesets.md +++ b/docs/adr/0016-automate-releases-with-changesets.md @@ -14,7 +14,9 @@ commit links. Private examples remain outside versioning, and releases run only ## Consequences -Changes to published behavior, types, exports, dependency ranges, or installation metadata require -a non-empty changeset. Documentation, examples, tests, internal tooling, and CI require none. Empty -changesets do not serve as process markers. Any bootstrap token is removed after trusted publishing -is configured. +While the package is unpublished at `0.0.0`, changes accumulate without Changesets. Prepare a single +initial release candidate when publication is authorized. + +After initial publication, changes to published behavior, types, exports, dependency ranges, or +installation metadata require a non-empty changeset. Documentation, examples, tests, internal +tooling, and CI require none. Any bootstrap token is removed after trusted publishing is configured. diff --git a/docs/adr/0017-quarantine-new-dependency-releases-for-seven-days.md b/docs/adr/0017-quarantine-new-dependency-releases-for-seven-days.md index 9524ca6..19d2215 100644 --- a/docs/adr/0017-quarantine-new-dependency-releases-for-seven-days.md +++ b/docs/adr/0017-quarantine-new-dependency-releases-for-seven-days.md @@ -9,18 +9,12 @@ Immediate dependency upgrades provide too little time to observe supply-chain in ## Decision pnpm enforces a strict seven-day minimum release age, frozen lockfiles in CI, and explicit -lifecycle-script permissions. Missing registry publication times fail closed. The same policy -applies to workspace packages, examples, CI, and isolated consumers. Lockfile verification stays -enabled so frozen and cached installations also reject ineligible versions. +lifecycle-script permissions. Missing registry publication times fail closed. The same native +workspace settings apply to isolated consumers; frozen and cached installs remain subject to +registry publication metadata. ## Consequences -No release-age exceptions, exclusions, configuration overrides, alternate installers, or missing-time -bypasses are allowed. An ineligible version remains blocked until its registry publication is seven -days old. Record its next eligibility time and continue independent work with eligible dependencies. -Existing version-resolution overrides remain valid when they preserve this policy. - -Locally built project tarballs can be verified before publication; their registry dependencies still -obey the quarantine. External installation of a newly published version waits for that version's -own seven-day eligibility. See the [install policy](../agents/install-policy.md) for consumer setup -and verification. +Every registry version becomes eligible seven days after publication. Release-age exceptions, +exclusions, configuration overrides, alternate installers, and missing-time bypasses are forbidden. +Already-installed dependencies may be reused read-only while an ineligible version remains blocked. diff --git a/docs/adr/0024-project-http-streams-and-buffered-metadata.md b/docs/adr/0024-project-http-streams-and-buffered-metadata.md index 1f96d84..9cd1654 100644 --- a/docs/adr/0024-project-http-streams-and-buffered-metadata.md +++ b/docs/adr/0024-project-http-streams-and-buffered-metadata.md @@ -1,63 +1,143 @@ # Project HTTP streams and buffered metadata into separate cached views -Status: Accepted design for the review programme. Implementation follows in issues #104–#106. -Amends [ADR 0020](0020-generate-accumulated-and-live-stream-queries.md), +Status: Accepted. Amends [ADR 0020](0020-generate-accumulated-and-live-stream-queries.md), [ADR 0022](0022-add-buffered-http-utilities-with-separate-key-roots.md), and [ADR 0023](0023-expose-buffered-multipart-http-mutations.md). +## Context + +A query cache needs decoded application values with stable identity. SSE declarations provide +such values; raw transport chunks do not. Buffered responses can also expose status and headers +without retaining a response object or its resources. + ## Decision -An endpoint with exactly one success alternative, whose body is `HttpApiSchema.StreamSse`, -exposes accumulated and live query builders. A declared `WithHeaders` wrapper projects onto each -emitted chunk: `HttpApiSchema.withHeaders({ body: chunk, headers: decodedHeaders })`. Accumulated -query data is an array of those values; live query data is the latest value. Neither view caches a -Stream or a response object. An unwrapped successful live `undefined` becomes `null`; an accumulated -`undefined` element or a declared wrapper containing it keeps its original representation. - -Keep the existing `streamedKey`/`streamedOptions` and `liveKey`/`liveOptions` vocabulary. Accept -`sseOptions` beside `input` in option builders and beside the retention policy in concrete key -builders. Decoded request input and custom key-encoder input contain only declared request parts. -Normalize the installed decoder's `maxEventSize` default to 10 MiB of string code units. Its -effective value belongs in both concrete view keys because it changes successful consumption into -a failure for sufficiently large pending events. Accumulated identity also includes effective -`maxChunks` and `refetchMode`. Key-prefix invalidation remains independent of those concrete policies. - -Expose `metadataKey`/`metadataOptions` on query-enabled buffered endpoints. Execute the ready -client with `responseMode: 'decoded-and-response'` and return a readonly, frozen outer record -`{ data, status, headers }`, with copied, frozen plain string headers. Normalize decoded `undefined` -to `null` in `data`; preserve any declared decoded `WithHeaders` value intact. Give this view its -own `metadata` operation identity. Ordinary query, infinite-query, and mutation behavior stays as -specified by the existing buffered contracts. Header copying reads raw string entries rather than -Effect's redacting `Headers.toJSON` representation. - -The supplied ready client and runner own encoding, middleware, residual services, and transport. -Acquire the SSE value and consume it under the caller's runner Context; forward Query's abort -signal and close the iterator on cancellation. Both acquisition and consumption failures retain -the complete Cause in `EffectHttpApiQueryError`, with `streamed`, `live`, or `metadata` operation -metadata. An independent interruption is a failure; signal cancellation follows native Query -reversion. Empty accumulated completion succeeds with the appropriate retained history. Empty -live completion raises `EffectHttpApiQueryEmptyStreamError` with declaration identity. Runner -rejections pass through unchanged. - -## Supported limits - -Effect 4.0.0 permits only one streaming success alternative, but it can combine that alternative -with buffered successes selected by status/content type. Such a ready client can return either -buffered data or a Stream. Keep the whole mixed endpoint omitted: assigning a stream view or a -buffered view would misrepresent one successful outcome. Revisit only with an explicitly proved -discriminated contract. Streaming multipart request payloads remain omitted. Buffered multipart -requests remain mutation-only when their successes are buffered; they do not gain SSE query views. - -Keep raw `StreamUint8Array` successes omitted, including wrappers and mixed alternatives. Transport -chunks partition the same bytes differently, and `maxChunks` counts transport chunks rather than -bytes or application records. A latest-chunk cache is not a download snapshot. Applications can -consume the native ready client into their own sink or declare an explicitly buffered byte result; -this programme adds no generic download accumulator or sink ownership. - -SSE event declarations preserve their declared event fields and IDs; data declarations emit only -decoded data. The adapter does not reconstruct dropped event IDs, reconnect, retry a parser -directive, or invent resume semantics. `Sse.Retry`, its `lastEventId`, and all other failures remain -available in the Cause for an application-owned retry/resume policy. - -The [support matrix and proof](../design/http-cached-views.md) record the exact upstream seam, -service channels, resource behavior, and executable evidence behind this decision. +Generate accumulated and live query builders for endpoints with exactly one SSE success +alternative. A declared `WithHeaders` wrapper projects onto each emission as +`HttpApiSchema.withHeaders({ body: chunk, headers: decodedHeaders })`. Accumulated data is an array +of emissions; live data is the latest emission. Data declarations emit decoded data, while event +declarations preserve their declared fields, event names, and IDs. + +Expose opt-in `metadataKey` and `metadataOptions` on query-enabled buffered endpoints. Execute the +ready client with `responseMode: 'decoded-and-response'` and return a frozen readonly envelope: +`{ readonly data: QueryData; readonly status: number; readonly headers: Readonly> }`. +Copy raw enumerable string headers into an ordinary frozen record. Preserve declared decoded +`WithHeaders` values inside `data`; normalize only a top-level successful `undefined` to `null`. +Metadata uses its own operation identity and the ordinary query options' selection, initial-data, +skip-token, and error inference. It adds no metadata mutation or infinite-query methods. + +### Supported declarations + +Request and success rows compose: the request must permit the view, and the success must have +that representation. HTTP method does not determine cache capability. + +| Declared request | Buffered views | SSE views | Key-encoder input | +| ------------------------------------------------------------------ | ---------------------------------------- | ---------------------------------------- | ----------------------------------------------- | +| No params/query/headers/payload | Query, infinite, mutation, metadata | Accumulated, live | None | +| Decoded params/query/headers and JSON/text/form/URL payload | Query, infinite, mutation, metadata | Accumulated, live | Complete decoded request containers | +| Serviceful, redacted, binary, or multiple payload alternatives | Same views with required custom encoding | Same views with required custom encoding | Complete decoded request, without view controls | +| Buffered multipart, including a mixed buffered payload alternative | Mutation only | Omitted | None | +| Streaming multipart payload alternative | Omitted | Omitted | None | + +| Declared success alternatives | Generated views | Cached data | +| ---------------------------------------------------- | ------------------------------------------------- | ------------------------------------------------------- | +| All buffered JSON/text/bytes/no-content alternatives | Ordinary query/infinite/mutation; opt-in metadata | Decoded value; metadata adds status and raw headers | +| Buffered `WithHeaders(Body, Headers)` | Same buffered views | Decoded wrapper remains intact | +| Exactly one `StreamSse({ data })` | Accumulated and live | Decoded data elements | +| Exactly one `StreamSse({ events })` | Accumulated and live | Decoded event elements with declared fields | +| Exactly one `WithHeaders(StreamSse(...), Headers)` | Accumulated and live | Per-emission decoded body and decoded header wrapper | +| SSE plus any buffered alternative | Whole endpoint omitted | A single view would misrepresent one successful outcome | +| Multiple streaming alternatives | Rejected by Effect endpoint construction | No endpoint | +| Raw `StreamUint8Array`, wrapped or mixed | Whole endpoint omitted | Application consumes native bytes into its own sink | + +Effect 4.0.0 permits one streaming success alternative with additional buffered alternatives +selected by status/content type. Keep such mixed endpoints omitted until a discriminated contract +can represent every successful outcome. + +For ordinary queries and unwrapped live values, successful top-level `undefined` becomes `null`. +Accumulated arrays preserve `undefined` elements. Decoded wrappers and their fields keep their +original representation. Live completion preserves the latest value; completion without an +emission raises `EffectHttpApiQueryEmptyStreamError` with declaration identity. Empty accumulated +completion succeeds with the appropriate retained history. + +### View identity + +Streaming leaves provide `key`, `streamedKey`, `streamedOptions`, `liveKey`, and `liveOptions`. +Option builders accept `sseOptions?: Sse.DecodeOptions` beside `input`; accumulated builders also +accept `maxChunks` and `refetchMode`. Concrete key builders accept the same policy after decoded +input, or as their only argument for inputless endpoints. Live key policy contains decoder controls +only. Endpoint prefixes select every concrete policy. + +Decoded request input and custom key-encoder input contain only declared request parts. Response +mode belongs to the adapter; decoder controls belong to the view. Declared transport headers, +including an application resume cursor, remain request identity input. Middleware-owned identity +belongs in the caller's key prefix. Captured request values remain immutable so identity and +execution describe the same request. + +The decoder's `maxEventSize` default is 10 MiB of JavaScript string code units. Absent options, +an empty record, an undefined field, and an explicit default share normalized identity. A +non-default effective limit splits both SSE view keys. Require a positive safe integer and reject +invalid values synchronously with a configuration error. Pass the same normalized value to the +ready client. Accumulated identity also includes effective `maxChunks` and `refetchMode`. + +`maxEventSize` limits pending parser text, rather than network bytes or decoded payload size. +A complete event parsed within one input chunk can clear pending state before Effect checks the +limit. A pending event split before its terminator demonstrates the actual control; the adapter +retains that upstream guarantee. + +### Failures and resource lifetime + +| Phase | Upstream result | Adapter behavior | +| -------------------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------- | +| Request encoding, middleware, status, response-header decoding | Acquisition failure/defect/interruption | HTTP execution error with complete Cause and operation identity | +| SSE parsing, event decoding, reserved declared failure event | Stream failure/defect/interruption | Same HTTP execution error | +| `retry:` parser directive | `Sse.Retry`, with duration and event ID | Preserve Cause for application retry/resume policy | +| Empty successful SSE completion | Empty Stream | Accumulated empty/history result; live empty-stream error | +| Query abort | Interrupted acquisition or consumption | Native Query reversion, iterator cleanup, no late cache writes | +| Injected runner rejects | Rejected Promise | Preserve rejection unchanged | + +Acquisition requires declared encoding, response decoding, error, and middleware services, plus +additional client requirements. Public `HttpApiClient` captures an SSE decoder's services in the +acquisition Context; the resulting Stream advertises `never` residual services. A ready client +must satisfy that public interface, including additional error and service channels. + +The supplied ready client and runner own transport, middleware, runtime, and Scope. Acquire and +consume SSE under the caller's runner Context, forward Query's abort signal, and close the iterator +on cancellation. Acquisition and consumption failures preserve the complete Cause in +`EffectHttpApiQueryError` with `streamed`, `live`, or `metadata` operation identity. Independent +interruption is a failure; signal cancellation follows native Query reversion. + +Public `Stream.toAsyncIterable` closes its scope and interrupts pending pulls on `return()`. +Returning after the first event and aborting consumption each cancel the Web body once and abort +the transport signal. Upstream transport retains responsibility when acquisition fails before a +usable Stream is returned. + +Buffered metadata finishes buffering and decoding before returning its envelope. Cache only that +envelope and header snapshot, never `HttpClientResponse`, Web `Response`, Effect `Headers`, a cookie +collection, or an open body. `Headers.toJSON` applies redaction, so it cannot produce the required +raw snapshot. Decoded data retains its declared representation and ownership. Applications choose +whether raw headers may be persisted or dehydrated. + +## Consequences + +Raw byte partitions depend on transport chunking: `[1, 2, 3]` can arrive as one chunk or as +`[1, 2]`, `[3]`. Retention by chunk count therefore changes retained bytes despite identical body +content, and native byte Streams retain body lifetime until consumed or closed. Applications own +download accumulation and sinks; explicitly buffered byte schemas keep their existing behavior. + +The adapter preserves declared event fields and IDs. Application code owns reconnection, +`Sse.Retry` handling, and resume semantics, including use of its `lastEventId`. + +## Evidence + +- [`tests/http-view-proof.test.ts`](../../tests/http-view-proof.test.ts) uses public + `HttpApiTest.groups`, `HttpApiClient.makeWith`, and `HttpClient.make` for decoded SSE, headers, + reserved failures, retry directives, parser limits, raw byte partitions, and body finalization. +- [`tests/types/http-view-proof.ts`](../../tests/types/http-view-proof.ts) verifies acquisition and + stream channels, decoded wrappers, additional client requirements, and separated request controls + with TypeScript 5.9 and the repository compiler. + +The upstream contracts come from `HttpApiClient` stream decoding and captured Context, +`HttpApiEndpoint` success validation and client channels, `HttpApiSchema` declarations, `Sse` +decoder controls, and public iterator and HTTP finalization. Reverify them when the exact Effect +peer changes. diff --git a/docs/agents/install-policy.md b/docs/agents/install-policy.md deleted file mode 100644 index 70aff91..0000000 --- a/docs/agents/install-policy.md +++ /dev/null @@ -1,39 +0,0 @@ -# Dependency installation - -Use the pnpm version pinned in `package.json`. Keep these workspace settings: - -```yaml -minimumReleaseAge: 10080 -minimumReleaseAgeStrict: true -minimumReleaseAgeIgnoreMissingTime: false -trustLockfile: false -``` - -Every registry version, including transitive dependencies, must have a known publication time -at least seven days old. Do not approve pnpm's interactive exception prompt, add an exclusion, -override these settings through flags or environment variables, trust the lockfile without -verification, use an alternate installer, or replace an ineligible registry package with a direct -URL or copied artifact. This also applies to package-manager and tool upgrades: verify their -publication dates before updating their exact pins. - -Run workspace installs from the root. CI and the Dev Container retain `--frozen-lockfile` and the -existing `allowBuilds` lifecycle permissions. Frozen lockfiles and cached artifacts are rechecked -against registry publication metadata; neither supplies an age exemption. - -For a temporary consumer, call `writeConsumerWorkspace` from `scripts/install-policy.mts` before -its pnpm install. It copies the full workspace policy, including catalogs and lifecycle permissions, -and rejects weakened quarantine settings. Preserve `--ignore-scripts` for packed consumers. -Locally built project tarballs are repository artifacts; their registry dependencies still need -to satisfy the quarantine. - -When an install fails, record the rejected version, registry publication time, and earliest -eligibility time. A missing publication time remains a blocking condition. Continue independent -work using already-installed dependencies read-only. Do not weaken the policy to unblock work. - -Run `vp run install-policy` after changes to installation policy or consumer setup. This invokes -the real pnpm CLI against an isolated fixture registry and checks fresh resolution plus frozen -installs with cached artifacts, including under-age and missing-time rejection. The normal packed -consumer checks verify real eligible registry dependencies with the same copied policy. - -After publishing a new package version, registry installation waits for that version's own -seven-day eligibility. Metadata and provenance checks and local-tarball acceptance may run sooner. diff --git a/docs/design/http-cached-views.md b/docs/design/http-cached-views.md deleted file mode 100644 index 2129e4e..0000000 --- a/docs/design/http-cached-views.md +++ /dev/null @@ -1,120 +0,0 @@ -# HTTP cached-view contract and design proof - -This is the implementation contract for issues #104–#106 under spec #95. The retained proof uses -the already installed Effect 4.0.0 public exports. It does not install dependencies or add shipped -features. [ADR 0024](../adr/0024-project-http-streams-and-buffered-metadata.md) records the decision. - -## Input and output support matrix - -The request and success rows compose: a request must permit a particular view and its success -must have the corresponding representation. HTTP method does not determine cache capability. - -| Declared request | Ordinary buffered views | SSE query views | Key-encoder input | -| ------------------------------------------------------------------ | ---------------------------------------------- | ---------------------------------------------- | --------------------------------------------------------- | -| No params/query/headers/payload | Query, infinite, mutation, metadata | Accumulated, live | None | -| Decoded params/query/headers and JSON/text/form/URL payload | Query, infinite, mutation, metadata | Accumulated, live | Complete decoded request containers | -| Serviceful, redacted, binary, or multiple payload alternatives | Same views after required safe custom encoding | Same views after required safe custom encoding | Complete decoded request; never response/decoder controls | -| Buffered multipart, including a mixed buffered payload alternative | Mutation only | Omitted | None; FormData is not query identity | -| Streaming multipart payload alternative | Omitted | Omitted | None | - -Request values captured by builders must remain immutable. Identity and execution must describe the -same request. Response mode belongs to the adapter, and `sseOptions` belongs to the view builder. -Native transport headers declared as request input, including an application resume cursor, remain -request identity input. Middleware-owned identity must be partitioned in the caller's key prefix. - -| Declared success alternatives | Generated view | Cached query data | -| ---------------------------------------------------- | ----------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | -| All buffered JSON/text/bytes/no-content alternatives | Existing ordinary query/infinite/mutation; opt-in metadata for query-enabled inputs | Existing decoded value; metadata `{ data, status, headers }` | -| Buffered `WithHeaders(Body, Headers)` | Same buffered views | Declared decoded wrapper remains intact; metadata adds raw header/status snapshot outside it | -| Exactly one `StreamSse({ data })` | Accumulated and live only | Decoded data elements; latest decoded data for live | -| Exactly one `StreamSse({ events })` | Accumulated and live only | Declared decoded event elements, including declared IDs/event names | -| Exactly one `WithHeaders(StreamSse(...), Headers)` | Accumulated and live only | Per-emission `withHeaders({ body: decodedChunk, headers: decodedHeaders })` | -| SSE plus any buffered alternative | Whole endpoint omitted | No truthful single query-data shape has been selected | -| Multiple streaming alternatives | Rejected by installed Effect endpoint construction | No endpoint | -| Raw `StreamUint8Array`, wrapped or mixed | Whole endpoint omitted | Application consumes native bytes into its own sink | - -For ordinary query data and live values, successful top-level `undefined` becomes `null`. -Accumulated arrays preserve `undefined` elements. Metadata normalizes its decoded `data` field; -it leaves a decoded wrapper and its fields unchanged. Live completion preserves the latest value; -completion without any emission is a distinct package empty-stream error. - -## Concrete interface - -Streaming leaves provide `key()`, `streamedKey(...)`, `streamedOptions(...)`, `liveKey(...)`, and -`liveOptions(...)`. Query-enabled buffered leaves additionally provide `metadataKey(...)` and -`metadataOptions(...)`. The metadata result shape is -`{ readonly data: QueryData; readonly status: number; readonly headers: Readonly> }`. -Metadata gets the normal unary options' native selection, initial-data, skip-token, and error -inference with this result shape. It does not create metadata mutation or infinite-query methods. - -Option builders accept `sseOptions?: Sse.DecodeOptions` alongside `input`, with accumulated -`maxChunks` and `refetchMode` alongside it. Concrete key builders accept the same policy object -after decoded input, or as their only argument for inputless endpoints. Live concrete key policy -contains only decoder controls. Prefix keys continue to select every policy for that endpoint. - -The installed decoder has one control, `maxEventSize`. Absent options, an empty options record, -an undefined field, and the explicit 10 MiB default must have identical normalized identity. A -non-default effective limit splits both SSE views. Require a positive safe integer and reject -invalid values synchronously with a configuration error. This protects finite, JSON-safe view -identity. The same normalized value must be passed to the ready client. - -`maxEventSize` limits pending parser text, measured in JavaScript string code units. It is neither -a network byte limit nor a maximum decoded payload size: in installed Effect, a complete event -that is parsed within one input chunk can clear pending state before that limit check. The proof -deliberately splits a pending event before its terminator to demonstrate the control. No adapter -parser replacement or stronger size guarantee is added. - -## Failures, services, and resource lifetime - -| Phase | Public upstream result/channel | Adapter contract | -| ------------------------------------------------------------------- | --------------------------------------------------------- | ---------------------------------------------------------------------------- | -| Request encoding, middleware, HTTP status, response-header decoding | Ready-client Effect failure/defect/interruption | HTTP execution error with untouched Cause and declaration/operation metadata | -| SSE parsing, event decoding, reserved declared failure event | Stream failure/defect/interruption | Same HTTP execution error; acquisition alone is not successful consumption | -| `retry:` parser directive | `Sse.Retry` failure, including duration and last event ID | Preserve Cause; application selects retry/resume behavior | -| Empty SSE body that completes successfully | Empty upstream Stream | Accumulated empty/history result; live empty-stream error | -| Query abort | Interrupted acquisition or iterator consumption | Native Query cancellation/reversion; finalize iterator, no late cache writes | -| Injected runner rejects | Rejected Promise | Preserve rejection unchanged | - -The generated client acquisition Effect requires declared request encoding and response decoding -services, declared error/middleware services, and any additional client requirements. A serviceful -SSE event decoder is captured in the acquisition Context by public `HttpApiClient`; its resulting -Stream advertises `never` residual services. The retained compile fixture proves those channels, -including a caller's additional error/service types and declared stream errors. A caller-supplied -ready client must satisfy that public interface; the adapter does not erase requirements to make -construction compile. - -Public `Stream.toAsyncIterable` closes its scope and interrupts pending pulls on `return()`. -The proof observes one Web body cancellation and an aborted transport signal both when returning -after the first event and when aborting consumption. These observations do not transfer ownership -of the acquired client, runtime, or application Scope to the adapter. Upstream transport remains -responsible for resources when acquisition fails before a usable Stream is returned. - -Buffered metadata uses `decoded-and-response` so normal buffering/decoding finishes before the -metadata record is returned. Copy raw enumerable string entries into an ordinary frozen record; -never cache `HttpClientResponse`, Web `Response`, Effect `Headers`, cookies, or an open body. Do not -use `Headers.toJSON`, which applies redaction rather than copying raw strings. Decoded data retains -its declared representation and ownership; freeze only the new metadata envelope and plain header -snapshot. Applications choose whether those headers may be persisted or dehydrated. - -Raw-byte proof observations show `[1, 2, 3]` returned as one transport chunk and as two chunks -`[1, 2]`, `[3]`. Retention by chunk count therefore changes which bytes remain despite identical -full-body content. Native byte Streams also retain transport/body lifetime until consumed or -closed. The programme deliberately limits generated streaming views to schema-decoded SSE -application records. Existing explicitly buffered byte schemas keep their existing behavior. - -## Executable evidence - -- [`tests/http-view-proof.test.ts`](../../tests/http-view-proof.test.ts) uses `HttpApiTest.groups` - with real handlers for finite SSE/event/header/error round trips, and public - `HttpApiClient.makeWith`/`HttpClient.make` for controlled wire and body-lifecycle observations. -- [`tests/types/http-view-proof.ts`](../../tests/types/http-view-proof.ts) compiles upstream - acquisition, stream, decoded-wrapper, additional-client, and separated-request contracts. It - passes with installed TypeScript 5.9.3 and 7.0.2 without a registry installation. -- `vp test tests/http-view-proof.test.ts` runs the focused runtime proof. Implementation tickets - must additionally verify their shipped builders through the agreed installed-tarball seam. - -Upstream evidence is available in the installed public `effect/http-api`, `effect/http`, -`effect/encoding`, and `effect` exports. Source inspection used `HttpApiClient`'s stream decoding -and captured Context, `HttpApiEndpoint`'s success validation/client channels, `HttpApiSchema`'s -stream/wrapper declarations, `Sse`'s decoder controls, and the public iterator/HTTP finalization -contracts. Re-run this evidence when the coordinated Effect patch changes. diff --git a/scripts/install-policy.mts b/scripts/install-policy.mts deleted file mode 100644 index a3e4055..0000000 --- a/scripts/install-policy.mts +++ /dev/null @@ -1,28 +0,0 @@ -import { readFileSync, writeFileSync } from 'node:fs' -import nodePath from 'node:path' - -const repositoryRoot = nodePath.resolve(import.meta.dirname, '..') - -/** Copy the workspace's quarantine and lifecycle policy into an isolated consumer. */ -export const writeConsumerWorkspace = (directory: string): void => { - const workspace = readFileSync(nodePath.join(repositoryRoot, 'pnpm-workspace.yaml'), 'utf-8') - for (const setting of [ - 'minimumReleaseAge: 10080', - 'minimumReleaseAgeStrict: true', - 'minimumReleaseAgeIgnoreMissingTime: false', - 'trustLockfile: false', - ]) { - if (!workspace.split('\n').includes(setting)) { - throw new Error(`The install policy requires ${setting}`) - } - } - if (/minimumReleaseAgeExclude\s*:/u.test(workspace)) { - throw new Error('Release-age exclusions are forbidden') - } - // Keep catalogs, version-resolution overrides and lifecycle permissions unchanged. - // Every isolated fixture is its own workspace, never a member of the source tree. - writeFileSync( - nodePath.join(directory, 'pnpm-workspace.yaml'), - workspace.replace(/^packages:\n(?:[ \t]+[^\n]*\n)+/mu, 'packages:\n - .\n'), - ) -} diff --git a/scripts/verify-install-policy.mts b/scripts/verify-install-policy.mts deleted file mode 100644 index e8fa32f..0000000 --- a/scripts/verify-install-policy.mts +++ /dev/null @@ -1,161 +0,0 @@ -import { equal, match, ok } from 'node:assert/strict' -import { execFileSync, spawn } from 'node:child_process' -import { createHash } from 'node:crypto' -import { once } from 'node:events' -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { createServer } from 'node:http' -import { tmpdir } from 'node:os' -import nodePath from 'node:path' - -import { writeConsumerWorkspace } from './install-policy.mts' - -const root = mkdtempSync(nodePath.join(tmpdir(), 'effect-api-query-install-policy-')) -const packageName = 'effect-api-query-install-policy-fixture' -const version = '1.0.0' -let publishedAt: string | undefined = new Date(Date.now() - 10 * 24 * 60 * 60_000).toISOString() -let tarball: Buffer -let registry: string -const server = createServer((request, response) => { - if (request.url === '/fixture.tgz') { - response.end(tarball) - return - } - if (request.url !== `/${packageName}`) { - response.writeHead(404).end() - return - } - response.setHeader('content-type', 'application/json') - response.end( - JSON.stringify({ - name: packageName, - 'dist-tags': { latest: version }, - versions: { - [version]: { - name: packageName, - version, - dist: { - tarball: `${registry}/fixture.tgz`, - integrity: `sha512-${createHash('sha512').update(tarball).digest('base64')}`, - }, - }, - }, - time: publishedAt === undefined ? undefined : { [version]: publishedAt }, - }), - ) -}) - -const install = async ( - directory: string, - frozen: boolean, - cache: string, -): Promise<{ code: number | null; output: string }> => { - const child = spawn( - process.env['EFFECT_API_QUERY_PNPM'] ?? 'pnpm', - [ - 'install', - '--ignore-scripts', - '--prefer-offline', - `--registry=${registry}`, - `--store-dir=${nodePath.join(root, 'store')}`, - `--state-dir=${nodePath.join(root, cache)}`, - ...(frozen ? ['--frozen-lockfile'] : ['--no-frozen-lockfile']), - ], - { - cwd: directory, - env: { ...process.env, CI: 'true', XDG_CACHE_HOME: nodePath.join(root, cache) }, - }, - ) - let output = '' - let timedOut = false - const timeout = setTimeout(() => { - timedOut = true - child.kill('SIGKILL') - }, 30_000) - child.stdout.on('data', (data: Buffer) => { - output += data.toString() - }) - child.stderr.on('data', (data: Buffer) => { - output += data.toString() - }) - try { - await once(child, 'close') - if (timedOut) { - throw new Error(`pnpm install timed out: ${output}`) - } - return { code: child.exitCode, output } - } finally { - clearTimeout(timeout) - } -} - -const consumer = (name: string): string => { - const directory = nodePath.join(root, name) - mkdirSync(directory) - writeConsumerWorkspace(directory) - writeFileSync( - nodePath.join(directory, 'package.json'), - JSON.stringify({ name, private: true, dependencies: { [packageName]: version } }), - ) - return directory -} - -try { - writeConsumerWorkspace(root) - const config = readFileSync(nodePath.join(root, 'pnpm-workspace.yaml'), 'utf-8') - match(config, /^minimumReleaseAge: 10080$/mu) - match(config, /^minimumReleaseAgeStrict: true$/mu) - match(config, /^minimumReleaseAgeIgnoreMissingTime: false$/mu) - match(config, /^trustLockfile: false$/mu) - equal(/minimumReleaseAgeExclude:/u.test(config), false) - console.log('Install policy applies to isolated consumers') - - const source = nodePath.join(root, 'package') - mkdirSync(source) - writeFileSync( - nodePath.join(source, 'package.json'), - JSON.stringify({ name: packageName, version }), - ) - const archive = nodePath.join(root, 'fixture.tgz') - execFileSync('tar', ['-czf', archive, '-C', root, 'package']) - tarball = readFileSync(archive) - server.listen(0, '127.0.0.1') - await once(server, 'listening') - const address = server.address() - // Node's address() contract distinguishes a TCP address from a Unix pipe path. - // oxlint-disable-next-line anti-slop/no-runtime-typeof - ok(address !== null && typeof address !== 'string') - registry = `http://127.0.0.1:${address.port}` - - const eligible = consumer('eligible') - let result = await install(eligible, false, 'eligible-cache') - equal(result.code, 0, result.output) - result = await install(eligible, true, 'eligible-frozen-cache') - equal(result.code, 0, result.output) - console.log('PASS eligible fresh and frozen cached-artifact installs') - - // The already-resolved lockfile and populated store stay intact. Only the external - // registry metadata changes; no age exception or alternate installer creates them. - publishedAt = new Date().toISOString() - result = await install(consumer('young-fresh'), false, 'young-fresh-cache') - ok(result.code !== 0, result.output) - match(result.output, /ERR_PNPM_NO_MATURE_MATCHING_VERSION/u) - console.log('PASS under-age fresh resolution rejection') - result = await install(eligible, true, 'young-frozen-cache') - ok(result.code !== 0, result.output) - match(result.output, /ERR_PNPM_MINIMUM_RELEASE_AGE/u) - console.log('PASS under-age already-resolved frozen cached-artifact rejection') - - publishedAt = undefined - result = await install(consumer('undated-fresh'), false, 'undated-fresh-cache') - ok(result.code !== 0, result.output) - match(result.output, /ERR_PNPM_MISSING_TIME/u) - console.log('PASS missing publication-time fresh rejection') - result = await install(eligible, true, 'undated-frozen-cache') - ok(result.code !== 0, result.output) - match(result.output, /ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION/u) - match(result.output, /could not be checked/u) - console.log('PASS missing publication-time already-resolved frozen cached-artifact rejection') -} finally { - server.close() - rmSync(root, { recursive: true, force: true }) -} diff --git a/scripts/verify-packed-consumer.mts b/scripts/verify-packed-consumer.mts index 9a55197..2e01adf 100644 --- a/scripts/verify-packed-consumer.mts +++ b/scripts/verify-packed-consumer.mts @@ -20,7 +20,6 @@ import nodePath from 'node:path' import ts from 'typescript-5.9' import repositoryManifest from '../package.json' with { type: 'json' } -import { writeConsumerWorkspace } from './install-policy.mts' const repositoryRoot = nodePath.resolve(import.meta.dirname, '..') const artifactDirectory = nodePath.join(repositoryRoot, '.artifacts') @@ -449,7 +448,7 @@ const verifyConsumer = (peer: (typeof peerCases)[number]): void => { // Prefer cached artifacts, but allow a fresh machine to fetch exact pinned versions. // The temporary project must resolve every peer from its own node_modules. - writeConsumerWorkspace(consumerDirectory) + writeFileSync(nodePath.join(consumerDirectory, 'pnpm-workspace.yaml'), workspaceConfig) execFileSync('pnpm', ['install', '--ignore-scripts', '--prefer-offline'], { cwd: consumerDirectory, stdio: 'inherit', diff --git a/tests/create-rpc-query-utils-server-local.test.ts b/tests/create-rpc-query-utils-server-local.test.ts index ea0b50c..3954670 100644 --- a/tests/create-rpc-query-utils-server-local.test.ts +++ b/tests/create-rpc-query-utils-server-local.test.ts @@ -201,7 +201,6 @@ describe('server-local ready-client construction', () => { expect( error.cause.reasons.filter(Cause.isFailReason).map((reason) => reason.error), ).toStrictEqual(['declared-failure']) - // The default server policy collapses fatal defects; the local recipe keeps all reasons. failureCause = Cause.combine( Cause.fail('declared-failure' as const), Cause.die('fatal defect'), diff --git a/tests/fixtures/server-local-rpc.ts b/tests/fixtures/server-local-rpc.ts index 0c21c81..85d22b6 100644 --- a/tests/fixtures/server-local-rpc.ts +++ b/tests/fixtures/server-local-rpc.ts @@ -3,7 +3,6 @@ import type { Scope } from 'effect' import type { Rpc, RpcGroup, RpcMessage } from 'effect/rpc' import { RpcClient, RpcServer } from 'effect/rpc' -/** Bounded application recipe, outside the published library contract. */ export const makeServerLocalRpcClient = Effect.fnUntraced(function* ( group: RpcGroup.RpcGroup, ) { diff --git a/tests/types/http-view-proof.ts b/tests/types/http-view-proof.ts index 71895db..555e854 100644 --- a/tests/types/http-view-proof.ts +++ b/tests/types/http-view-proof.ts @@ -67,7 +67,6 @@ true satisfies Assert, number>> true satisfies Assert> true satisfies Assert, Scale | 'client-service'>> -/** Proof-only projection: each cached emission retains its decoded declaration wrapper. */ type Chunk = Value extends HttpApiSchema.withHeaders ? HttpApiSchema.withHeaders, Headers> @@ -78,7 +77,6 @@ true satisfies Assert< Equal, HttpApiSchema.withHeaders> > -/** Decoder controls never enter decoded request or custom key-encoder input. */ type Request = Omit< Exclude< HttpApiEndpoint.ClientRequest< diff --git a/tests/types/server-local-client-contract.ts b/tests/types/server-local-client-contract.ts index 76731b6..0c1d9fd 100644 --- a/tests/types/server-local-client-contract.ts +++ b/tests/types/server-local-client-contract.ts @@ -1,4 +1,3 @@ -// This upstream design proof adds no library export or published adapter contract. import { Context, Effect, Schema } from 'effect' import { Rpc, RpcGroup } from 'effect/rpc' diff --git a/vite.config.ts b/vite.config.ts index 20bda79..f029268 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -298,10 +298,6 @@ export default defineConfig({ command: 'node scripts/verify-packed-consumer.mts', cache: false, }, - 'install-policy': { - command: 'node scripts/verify-install-policy.mts', - cache: false, - }, 'skills-check': { command: 'intent validate skills --check', cache: { @@ -353,13 +349,7 @@ export default defineConfig({ cache: false, }, quality: { - command: [ - 'vp run install-policy', - 'vp run check', - 'vp run effect-check', - 'vp run fallow', - 'vp run test', - ], + command: ['vp run check', 'vp run effect-check', 'vp run fallow', 'vp run test'], }, server: { command: 'vp run --filter @effect-api-query/server dev', From 12de88f66f2f4fbe2c73be31056c6440ff991340 Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Fri, 9 Oct 2026 22:02:09 +0200 Subject: [PATCH 09/97] fix: preserve independent RPC stream interruption Causes (#98) --- .fallowrc.jsonc | 3 +- ...ate-accumulated-and-live-stream-queries.md | 5 +- scripts/verify-packed-consumer.mts | 2 +- src/rpc/streamed-query.ts | 4 +- .../create-rpc-query-utils-streaming.test.ts | 50 ++- .../packed-consumer/stream-cause-runtime.mts | 308 ++++++++++++++++++ tests/packed-consumer/tsconfig.json | 1 + 7 files changed, 367 insertions(+), 6 deletions(-) create mode 100644 tests/packed-consumer/stream-cause-runtime.mts diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 217e5cf..feb34ed 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -7,7 +7,8 @@ "apps/docs/public/package-version.js", "playwright.docs.config.ts", // Vite+ invokes this verifier through its dynamically declared install-policy task. - "scripts/verify-install-policy.mts" + "scripts/verify-install-policy.mts", + "tests/packed-consumer/stream-cause-runtime.mts" ], "ignorePatterns": ["examples/tanstack-start/src/routeTree.gen.ts"], diff --git a/docs/adr/0020-generate-accumulated-and-live-stream-queries.md b/docs/adr/0020-generate-accumulated-and-live-stream-queries.md index 0d44c31..bde0a34 100644 --- a/docs/adr/0020-generate-accumulated-and-live-stream-queries.md +++ b/docs/adr/0020-generate-accumulated-and-live-stream-queries.md @@ -11,7 +11,10 @@ support invalidation. The ready RPC client remains the execution seam, including direct execution outside TanStack. Client construction and Effect middleware remain interception seams. Stream functions forward -TanStack's abort signal, close the AsyncIterator on cancellation, and preserve the complete Cause. +TanStack's abort signal and close the AsyncIterator on cancellation. A stream failure preserves its +complete Cause in `EffectRpcQueryError`, including interruption-only and mixed interruption/defect +Causes, while that signal is not aborted. Requested cancellation retains TanStack's native result +and cache reversion, including superseded refetches and removal of the last observer. The application owns client/runtime resources, transport, middleware, Scope, QueryClient, and framework lifecycle. SSR dehydrates completed data normally; open streams require cancellation after the first successful snapshot. Mutations have no cancellation signal. diff --git a/scripts/verify-packed-consumer.mts b/scripts/verify-packed-consumer.mts index 9a55197..db6fe97 100644 --- a/scripts/verify-packed-consumer.mts +++ b/scripts/verify-packed-consumer.mts @@ -503,7 +503,7 @@ const verifyConsumer = (peer: (typeof peerCases)[number]): void => { ) } - for (const fixture of ['runtime.mts', 'http-runtime.mts']) { + for (const fixture of ['runtime.mts', 'http-runtime.mts', 'stream-cause-runtime.mts']) { execFileSync(process.execPath, ['--experimental-import-meta-resolve', fixture], { cwd: consumerDirectory, stdio: 'inherit', diff --git a/src/rpc/streamed-query.ts b/src/rpc/streamed-query.ts index 27a9dec..0b24303 100644 --- a/src/rpc/streamed-query.ts +++ b/src/rpc/streamed-query.ts @@ -1,6 +1,6 @@ import { experimental_streamedQuery } from '@tanstack/query-core' import type { QueryFunctionContext } from '@tanstack/query-core' -import { Cause, Exit, Predicate, Stream } from 'effect' +import { Exit, Predicate, Stream } from 'effect' import type { StreamingOperation } from '../core/operation' import type { RunPromiseExit } from '../core/types' @@ -115,7 +115,7 @@ const makeStreamQuery = ({ const streamFn = async ({ signal }: { readonly signal: AbortSignal }) => { const stream = rpc.invoke(input, rpcOptions).pipe( Stream.catchCauseIf( - (cause) => !Cause.hasInterruptsOnly(cause), + () => !signal.aborted, (cause) => Stream.fail(new EffectRpcQueryError(rpc.tag, operation, cause)), ), ) diff --git a/tests/create-rpc-query-utils-streaming.test.ts b/tests/create-rpc-query-utils-streaming.test.ts index d98dca5..d6da2fd 100644 --- a/tests/create-rpc-query-utils-streaming.test.ts +++ b/tests/create-rpc-query-utils-streaming.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from '@effect/vitest' import { QueryClient, QueryObserver, skipToken } from '@tanstack/query-core' -import { Deferred, Effect, Equal, Exit, Predicate, Schema, Stream } from 'effect' +import { Cause, Deferred, Effect, Equal, Exit, Predicate, Schema, Stream } from 'effect' import type { RpcClient } from 'effect/rpc' import { Rpc, RpcGroup } from 'effect/rpc' import { setTimeout } from 'node:timers/promises' @@ -393,6 +393,54 @@ describe('createRpcQueryUtils streaming execution', () => { }), ) + it.effect.each(['live', 'streamed'] as const)( + 'preserves independent interruption and mixed Causes from an official client in %s queries', + (operation) => + Effect.gen(function* () { + const Watch = Rpc.make('events.watch', { success: Schema.String, stream: true }) + const streamGroup = RpcGroup.make(Watch) + const defect = new Error('stream defect') + const causes = [ + Cause.interrupt(42), + Cause.combine(Cause.interrupt(42), Cause.die(defect)), + ] as const + let [currentCause] = causes + const client = yield* makeRpcTestClient(streamGroup, { + 'events.watch': () => Stream.failCause(currentCause), + }) + const utils = createRpcQueryUtils(streamGroup, { + client, + keyPrefix: ['interrupt', operation], + }) + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + try { + for (const cause of causes) { + currentCause = cause + const direct = yield* Effect.exit(Stream.runCollect(client('events.watch', undefined))) + if (Exit.isSuccess(direct)) { + throw new TypeError('Expected a failed client stream') + } + const error = yield* Effect.promise( + async () => + await captureFailure( + operation === 'live' + ? queryClient.query(utils.events.watch.liveOptions()) + : queryClient.query(utils.events.watch.streamedOptions()), + ), + ) + expect(error).toBeInstanceOf(EffectRpcQueryError) + expect(error).toMatchObject({ rpcTag: 'events.watch', operation }) + if (!isEffectRpcQueryError(error)) { + throw new TypeError('Expected an RPC execution error') + } + expect(error.cause).toStrictEqual(direct.cause) + } + } finally { + queryClient.clear() + } + }), + ) + it.effect('finalizes a stream after normal completion', () => Effect.gen(function* () { const Watch = Rpc.make('events.watch', { success: Schema.String, stream: true }) diff --git a/tests/packed-consumer/stream-cause-runtime.mts b/tests/packed-consumer/stream-cause-runtime.mts new file mode 100644 index 0000000..0b9d244 --- /dev/null +++ b/tests/packed-consumer/stream-cause-runtime.mts @@ -0,0 +1,308 @@ +import { isCancelledError, QueryClient, QueryObserver } from '@tanstack/query-core' +import type { QueryKey } from '@tanstack/query-core' +import { Cause, Deferred, Effect, Equal, Schema, Stream } from 'effect' +import { createRpcQueryUtils, isEffectRpcQueryError } from 'effect-api-query' +import type { RunPromiseExit } from 'effect-api-query' +import type { RpcClient } from 'effect/rpc' +import { Rpc, RpcGroup, RpcTest } from 'effect/rpc' +import { deepStrictEqual, equal, ok, rejects } from 'node:assert/strict' + +const Watch = Rpc.make('events.watch', { success: Schema.String, stream: true }) +const group = RpcGroup.make(Watch) + +const operations = ['streamed', 'live'] as const +const defect = new Error('stream defect') +const causes = [Cause.interrupt(42), Cause.combine(Cause.interrupt(42), Cause.die(defect))] + +for (const operation of operations) { + for (const cause of causes) { + for (const emitted of [false, true]) { + let finalized = false + const failure = Stream.failCause(cause) + const client = (() => + (emitted ? Stream.make('ready').pipe(Stream.concat(failure)) : failure).pipe( + Stream.ensuring( + Effect.sync(() => { + finalized = true + }), + ), + )) as RpcClient.RpcClient.Flat> + const utils = createRpcQueryUtils(group, { client, keyPrefix: ['interrupt', operation] }) + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + try { + const queryKey = + operation === 'live' ? utils.events.watch.liveKey() : utils.events.watch.streamedKey() + const query = + operation === 'live' + ? queryClient.query(utils.events.watch.liveOptions()) + : queryClient.query(utils.events.watch.streamedOptions()) + await rejects(query, (error: unknown) => { + ok(isEffectRpcQueryError(error)) + equal(error.rpcTag, 'events.watch') + equal(error.operation, operation) + equal( + Equal.equals(error.cause, cause), + true, + 'Preserve the entire independent stream Cause', + ) + equal(finalized, true) + equal(queryClient.getQueryState(queryKey)?.status, 'error') + equal(queryClient.getQueryState(queryKey)?.error, error) + deepStrictEqual( + queryClient.getQueryData(queryKey), + emitted ? (operation === 'live' ? 'ready' : ['ready']) : undefined, + ) + return true + }) + } finally { + queryClient.clear() + } + } + } +} + +for (const operation of operations) { + const rejection = new Error('runner rejection') + const runPromiseExit: RunPromiseExit = async () => await Promise.reject(rejection) + const client = (() => Stream.empty) as RpcClient.RpcClient.Flat> + const utils = createRpcQueryUtils(group, { + client, + keyPrefix: ['runner', operation], + runPromiseExit, + }) + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + try { + const query = + operation === 'live' + ? queryClient.query(utils.events.watch.liveOptions()) + : queryClient.query(utils.events.watch.streamedOptions()) + await rejects(query, (error: unknown) => { + equal(error, rejection) + return true + }) + } finally { + queryClient.clear() + } +} + +const wait = async (deferred: Deferred.Deferred) => + await Effect.runPromise(Deferred.await(deferred).pipe(Effect.timeout('5 seconds'))) + +const settle = async (promise: PromiseLike) => { + try { + return { status: 'success', data: await promise } as const + } catch (error) { + return { status: 'error', error } as const + } +} + +const waitForValue = async (queryClient: QueryClient, key: QueryKey, value: string) => { + const published = Deferred.makeUnsafe() + const check = () => { + const data = queryClient.getQueryData(key) + if (data === value || (Array.isArray(data) && data.at(-1) === value)) { + Effect.runSync(Deferred.succeed(published, undefined)) + } + } + const unsubscribe = queryClient.getQueryCache().subscribe(check) + try { + check() + await wait(published) + } finally { + unsubscribe() + } +} + +await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + for (const operation of operations) { + const finalized = Deferred.makeUnsafe() + const client = yield* RpcTest.makeClient(group, { flatten: true }).pipe( + Effect.provide( + group.toLayer({ + 'events.watch': () => + Stream.make('first', 'second').pipe( + Stream.ensuring(Deferred.succeed(finalized, undefined).pipe(Effect.asVoid)), + ), + }), + ), + ) + const utils = createRpcQueryUtils(group, { client, keyPrefix: ['completion', operation] }) + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + try { + const result = yield* Effect.promise( + async () => + await (operation === 'live' + ? queryClient.query(utils.events.watch.liveOptions()) + : queryClient.query(utils.events.watch.streamedOptions())), + ) + deepStrictEqual(result, operation === 'live' ? 'second' : ['first', 'second']) + yield* Effect.promise(() => wait(finalized)) + const key = + operation === 'live' ? utils.events.watch.liveKey() : utils.events.watch.streamedKey() + equal(queryClient.getQueryState(key)?.status, 'success') + equal(queryClient.getQueryState(key)?.fetchStatus, 'idle') + } finally { + queryClient.clear() + } + } + + for (const operation of operations) { + const started = Deferred.makeUnsafe() + const finalized = Deferred.makeUnsafe() + let signal: AbortSignal | undefined + const runPromiseExit: RunPromiseExit = async (effect, options) => { + signal = options?.signal + return await Effect.runPromiseExit(effect, options) + } + const client = yield* RpcTest.makeClient(group, { flatten: true }).pipe( + Effect.provide( + group.toLayer({ + 'events.watch': () => + Stream.fromEffect( + Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)), + ).pipe(Stream.ensuring(Deferred.succeed(finalized, undefined).pipe(Effect.asVoid))), + }), + ), + ) + const utils = createRpcQueryUtils(group, { + client, + keyPrefix: ['cancellation', operation], + runPromiseExit, + }) + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const query = + operation === 'live' + ? queryClient.query(utils.events.watch.liveOptions({ initialData: 'cached' })) + : queryClient.query(utils.events.watch.streamedOptions({ initialData: ['cached'] })) + const key = + operation === 'live' ? utils.events.watch.liveKey() : utils.events.watch.streamedKey() + try { + yield* Effect.promise(() => wait(started)) + equal(signal?.aborted, false) + yield* Effect.promise(() => queryClient.cancelQueries({ queryKey: key, exact: true })) + yield* Effect.promise(() => wait(finalized)) + equal(signal?.aborted, true) + deepStrictEqual( + yield* Effect.promise(async () => await query), + operation === 'live' ? 'cached' : ['cached'], + ) + deepStrictEqual( + queryClient.getQueryData(key), + operation === 'live' ? 'cached' : ['cached'], + ) + equal(queryClient.getQueryState(key)?.status, 'success') + equal(queryClient.getQueryState(key)?.fetchStatus, 'idle') + equal(queryClient.getQueryState(key)?.error, null) + } finally { + queryClient.clear() + } + } + + for (const operation of operations) { + const started = [Deferred.makeUnsafe(), Deferred.makeUnsafe()] + const finalized = [Deferred.makeUnsafe(), Deferred.makeUnsafe()] + let invocation = 0 + const client = yield* RpcTest.makeClient(group, { flatten: true }).pipe( + Effect.provide( + group.toLayer({ + 'events.watch': () => { + const index = invocation++ + const start = started[index]! + const finish = finalized[index]! + return Stream.make(`run-${index + 1}`).pipe( + Stream.concat( + Stream.fromEffect( + Deferred.succeed(start, undefined).pipe(Effect.andThen(Effect.never)), + ), + ), + Stream.ensuring(Deferred.succeed(finish, undefined).pipe(Effect.asVoid)), + ) + }, + }), + ), + ) + const utils = createRpcQueryUtils(group, { client, keyPrefix: ['refetch', operation] }) + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const key = + operation === 'live' ? utils.events.watch.liveKey() : utils.events.watch.streamedKey() + const first = settle( + operation === 'live' + ? queryClient.query(utils.events.watch.liveOptions()) + : queryClient.query(utils.events.watch.streamedOptions()), + ) + try { + yield* Effect.promise(() => wait(started[0]!)) + yield* Effect.promise(() => waitForValue(queryClient, key, 'run-1')) + const refetch = queryClient.refetchQueries({ queryKey: key, exact: true }) + yield* Effect.promise(() => wait(finalized[0]!)) + yield* Effect.promise(() => wait(started[1]!)) + yield* Effect.promise(() => waitForValue(queryClient, key, 'run-2')) + deepStrictEqual(queryClient.getQueryData(key), operation === 'live' ? 'run-2' : ['run-2']) + yield* Effect.promise(() => queryClient.cancelQueries({ queryKey: key, exact: true })) + yield* Effect.promise(() => wait(finalized[1]!)) + yield* Effect.promise(() => refetch) + const firstResult = yield* Effect.promise(() => first) + if (firstResult.status === 'error') { + ok(isCancelledError(firstResult.error)) + } else { + deepStrictEqual(firstResult.data, operation === 'live' ? 'run-2' : ['run-2']) + } + equal(invocation, 2) + equal(queryClient.getQueryState(key)?.error, null) + equal(queryClient.getQueryState(key)?.fetchStatus, 'idle') + } finally { + queryClient.clear() + } + } + + for (const operation of operations) { + const started = Deferred.makeUnsafe() + const finalized = Deferred.makeUnsafe() + const client = yield* RpcTest.makeClient(group, { flatten: true }).pipe( + Effect.provide( + group.toLayer({ + 'events.watch': () => + Stream.make('ready').pipe( + Stream.concat( + Stream.fromEffect( + Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)), + ), + ), + Stream.ensuring(Deferred.succeed(finalized, undefined).pipe(Effect.asVoid)), + ), + }), + ), + ) + const utils = createRpcQueryUtils(group, { client, keyPrefix: ['observer', operation] }) + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const observer = + operation === 'live' + ? new QueryObserver(queryClient, utils.events.watch.liveOptions()) + : new QueryObserver(queryClient, utils.events.watch.streamedOptions()) + const unsubscribe = observer.subscribe(() => {}) + try { + yield* Effect.promise(() => wait(started)) + const key = + operation === 'live' ? utils.events.watch.liveKey() : utils.events.watch.streamedKey() + yield* Effect.promise(() => waitForValue(queryClient, key, 'ready')) + equal(observer.getCurrentResult().status, 'success') + unsubscribe() + yield* Effect.promise(() => wait(finalized)) + equal( + queryClient.getQueryCache().find({ queryKey: key, exact: true })?.getObserversCount(), + 0, + ) + equal(queryClient.getQueryState(key)?.error, null) + equal(queryClient.getQueryState(key)?.fetchStatus, 'idle') + } finally { + unsubscribe() + queryClient.clear() + } + } + }), + ), +) + +console.log('Packed stream Causes, runner rejections, cancellation, and lifecycle verified') diff --git a/tests/packed-consumer/tsconfig.json b/tests/packed-consumer/tsconfig.json index ddd1c69..5fc7a66 100644 --- a/tests/packed-consumer/tsconfig.json +++ b/tests/packed-consumer/tsconfig.json @@ -13,6 +13,7 @@ }, "include": [ "runtime.mts", + "stream-cause-runtime.mts", "http-runtime.mts", "public-contract.ts", "http-contract.ts", From 9ac577b26c165dbe999ca776ce784b407fb27f98 Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Fri, 9 Oct 2026 22:05:03 +0200 Subject: [PATCH 10/97] chore: align live value changes with pre-release constraints --- .changeset/ticket-97-live-values.md | 5 ----- docs/adr/0010-reuse-tanstack-query-core-as-a-peer.md | 10 ++++++++-- ...020-generate-accumulated-and-live-stream-queries.md | 3 ++- src/core/types.ts | 6 ------ src/rpc/types.ts | 4 ---- 5 files changed, 10 insertions(+), 18 deletions(-) delete mode 100644 .changeset/ticket-97-live-values.md diff --git a/.changeset/ticket-97-live-values.md b/.changeset/ticket-97-live-values.md deleted file mode 100644 index 017c0d6..0000000 --- a/.changeset/ticket-97-live-values.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'effect-api-query': minor ---- - -Normalize live query undefined emissions to null in cached values, keys, selectors, and initial-data types. Preserve accumulated undefined elements, mutation results, and empty-live errors. Require Query Core 5.103.1 or later in v5 for correct null live values. diff --git a/docs/adr/0010-reuse-tanstack-query-core-as-a-peer.md b/docs/adr/0010-reuse-tanstack-query-core-as-a-peer.md index f19ea8a..7283efb 100644 --- a/docs/adr/0010-reuse-tanstack-query-core-as-a-peer.md +++ b/docs/adr/0010-reuse-tanstack-query-core-as-a-peer.md @@ -10,8 +10,7 @@ Generated objects and declarations expose TanStack Query Core runtime values and `@tanstack/query-core` is an external peer. The package supports v5 releases from 5.103.1 and tests its declared lower bound and development version. This minimum preserves `null` data in the -streamed-query helper. Packed consumers exercise undefined-only, null-only, value-then-undefined, -empty, and open live streams with both peers. Because the package imports Query Core's +streamed-query helper. Because the package imports Query Core's experimental streamed-query interface, each development update must pass packed-consumer type and runtime checks. The package re-exports only primitives required by its own interface, including the exact `skipToken` and `SkipToken` bindings. @@ -21,3 +20,10 @@ exact `skipToken` and `SkipToken` bindings. Consumers use the same bindings, or broader Query APIs, from their TanStack installation. `skipToken` applies only to payload-bearing query options. React, React Query, and TanStack Start remain outside the peer set until a public entry point imports them directly. + +## Evidence + +[`tests/packed-consumer/runtime.mts`](../../tests/packed-consumer/runtime.mts) exercises +undefined-only, null-only, value-then-undefined, empty, and open live streams with the minimum and +development peers. [`tests/types/public-contract.ts`](../../tests/types/public-contract.ts) verifies +normalized live data with TypeScript 5.9 and the repository compiler. diff --git a/docs/adr/0020-generate-accumulated-and-live-stream-queries.md b/docs/adr/0020-generate-accumulated-and-live-stream-queries.md index ac6d3a7..ec8ab18 100644 --- a/docs/adr/0020-generate-accumulated-and-live-stream-queries.md +++ b/docs/adr/0020-generate-accumulated-and-live-stream-queries.md @@ -9,7 +9,8 @@ that completes without emitting a value raises `EffectRpcQueryEmptyStreamError`. and `live` key segments prevent collisions with other query shapes and mutations; generated prefixes support invalidation. -Live emissions normalize `undefined` to `null` under ADR 0012; accumulated elements remain +Live emissions normalize `undefined` to `null` under +[ADR 0012](0012-normalize-undefined-query-success-to-null.md); accumulated elements remain unchanged. On an initial fetch, the first emission makes either view successful while the open stream remains fetching. Live completion preserves the latest normalized value. diff --git a/src/core/types.ts b/src/core/types.ts index ac4c8ee..0d94ac7 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -19,12 +19,6 @@ export type JsonPrimitive = boolean | null | number | string /** An immutable JSON value accepted in cache keys. */ export type JsonValue = JsonPrimitive | { readonly [key: string]: JsonValue } | readonly JsonValue[] -/** - * The value cached for a successful buffered query or live stream emission. - * - * TanStack rejects `undefined` query data, so possible `undefined` values become - * `null`. Accumulated stream elements and mutation results keep their success types unchanged. - */ export type QueryData = undefined extends A ? Exclude | null : A /** Runs an Effect and returns its Exit, optionally forwarding an abort signal. */ diff --git a/src/rpc/types.ts b/src/rpc/types.ts index f1b4ea0..c8bad8d 100644 --- a/src/rpc/types.ts +++ b/src/rpc/types.ts @@ -288,10 +288,6 @@ export interface RpcStreamLeaf< /** Builds a semantic key for the latest-value view of the stream. */ readonly liveKey: StreamKeyBuilder> - /** - * Builds latest-value query options, normalizing emitted `undefined` to `null`. A stream that - * completes before its first value fails with {@link EffectRpcQueryEmptyStreamError}. - */ readonly liveOptions: LiveOptionsBuilder /** Builds a semantic key for the accumulated view of the stream. */ From 7ae6483681c7a8ef99515797e4a01568e3f0fac9 Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Fri, 9 Oct 2026 22:05:05 +0200 Subject: [PATCH 11/97] test: preserve native stream cancellation before first emission (#98) --- .../packed-consumer/stream-cause-runtime.mts | 107 ++++++++++-------- 1 file changed, 61 insertions(+), 46 deletions(-) diff --git a/tests/packed-consumer/stream-cause-runtime.mts b/tests/packed-consumer/stream-cause-runtime.mts index 0b9d244..8c315b4 100644 --- a/tests/packed-consumer/stream-cause-runtime.mts +++ b/tests/packed-consumer/stream-cause-runtime.mts @@ -149,54 +149,69 @@ await Effect.runPromise( } for (const operation of operations) { - const started = Deferred.makeUnsafe() - const finalized = Deferred.makeUnsafe() - let signal: AbortSignal | undefined - const runPromiseExit: RunPromiseExit = async (effect, options) => { - signal = options?.signal - return await Effect.runPromiseExit(effect, options) - } - const client = yield* RpcTest.makeClient(group, { flatten: true }).pipe( - Effect.provide( - group.toLayer({ - 'events.watch': () => - Stream.fromEffect( - Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)), - ).pipe(Stream.ensuring(Deferred.succeed(finalized, undefined).pipe(Effect.asVoid))), - }), - ), - ) - const utils = createRpcQueryUtils(group, { - client, - keyPrefix: ['cancellation', operation], - runPromiseExit, - }) - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) - const query = - operation === 'live' - ? queryClient.query(utils.events.watch.liveOptions({ initialData: 'cached' })) - : queryClient.query(utils.events.watch.streamedOptions({ initialData: ['cached'] })) - const key = - operation === 'live' ? utils.events.watch.liveKey() : utils.events.watch.streamedKey() - try { - yield* Effect.promise(() => wait(started)) - equal(signal?.aborted, false) - yield* Effect.promise(() => queryClient.cancelQueries({ queryKey: key, exact: true })) - yield* Effect.promise(() => wait(finalized)) - equal(signal?.aborted, true) - deepStrictEqual( - yield* Effect.promise(async () => await query), - operation === 'live' ? 'cached' : ['cached'], + for (const cached of [false, true]) { + const started = Deferred.makeUnsafe() + const finalized = Deferred.makeUnsafe() + let signal: AbortSignal | undefined + const runPromiseExit: RunPromiseExit = async (effect, options) => { + signal = options?.signal + return await Effect.runPromiseExit(effect, options) + } + const client = yield* RpcTest.makeClient(group, { flatten: true }).pipe( + Effect.provide( + group.toLayer({ + 'events.watch': () => + Stream.fromEffect( + Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)), + ).pipe( + Stream.ensuring(Deferred.succeed(finalized, undefined).pipe(Effect.asVoid)), + ), + }), + ), ) - deepStrictEqual( - queryClient.getQueryData(key), - operation === 'live' ? 'cached' : ['cached'], + const utils = createRpcQueryUtils(group, { + client, + keyPrefix: ['cancellation', operation, cached], + runPromiseExit, + }) + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const query = settle( + operation === 'live' + ? queryClient.query( + utils.events.watch.liveOptions(cached ? { initialData: 'cached' } : {}), + ) + : queryClient.query( + utils.events.watch.streamedOptions(cached ? { initialData: ['cached'] } : {}), + ), ) - equal(queryClient.getQueryState(key)?.status, 'success') - equal(queryClient.getQueryState(key)?.fetchStatus, 'idle') - equal(queryClient.getQueryState(key)?.error, null) - } finally { - queryClient.clear() + const key = + operation === 'live' ? utils.events.watch.liveKey() : utils.events.watch.streamedKey() + try { + yield* Effect.promise(() => wait(started)) + equal(signal?.aborted, false) + yield* Effect.promise(() => queryClient.cancelQueries({ queryKey: key, exact: true })) + yield* Effect.promise(() => wait(finalized)) + equal(signal?.aborted, true) + const result = yield* Effect.promise(() => query) + if (cached) { + equal(result.status, 'success') + ok(result.status === 'success') + deepStrictEqual(result.data, operation === 'live' ? 'cached' : ['cached']) + } else { + equal(result.status, 'error') + ok(result.status === 'error') + ok(isCancelledError(result.error)) + } + deepStrictEqual( + queryClient.getQueryData(key), + cached ? (operation === 'live' ? 'cached' : ['cached']) : undefined, + ) + equal(queryClient.getQueryState(key)?.status, cached ? 'success' : 'pending') + equal(queryClient.getQueryState(key)?.fetchStatus, 'idle') + equal(queryClient.getQueryState(key)?.error, null) + } finally { + queryClient.clear() + } } } From b39236a046645c14167a0a98e900315f53ddff7c Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Fri, 9 Oct 2026 22:08:59 +0200 Subject: [PATCH 12/97] chore: centralize workspace TypeScript versions in catalogs --- apps/docs/package.json | 2 +- package.json | 2 +- pnpm-lock.yaml | 11 +++++++++-- pnpm-workspace.yaml | 5 +++++ 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/apps/docs/package.json b/apps/docs/package.json index 4f0fb9b..69c7feb 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -18,6 +18,6 @@ }, "devDependencies": { "@astrojs/check": "0.9.10", - "typescript": "6.0.3" + "typescript": "catalog:docs" } } diff --git a/package.json b/package.json index 2f1d6ca..7b9bf4a 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,7 @@ "react": "catalog:", "react-dom": "catalog:", "tsx": "catalog:", - "typescript": "7.0.2", + "typescript": "catalog:", "typescript-5.9": "npm:typescript@5.9.3", "ultracite": "7.12.2", "unplugin-unused": "0.6.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f60623a..14d2f19 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -209,6 +209,9 @@ catalogs: tsx: specifier: 4.23.15 version: 4.23.15 + typescript: + specifier: 7.0.2 + version: 7.0.2 vite: specifier: npm:@voidzero-dev/vite-plus-core@1.0.0 version: 1.0.0 @@ -218,6 +221,10 @@ catalogs: vitest: specifier: 5.0.1 version: 5.0.1 + docs: + typescript: + specifier: 6.0.3 + version: 6.0.3 overrides: vite@*: npm:@voidzero-dev/vite-plus-core@1.0.0 @@ -306,7 +313,7 @@ importers: specifier: 'catalog:' version: 4.23.15 typescript: - specifier: 7.0.2 + specifier: 'catalog:' version: 7.0.2 typescript-5.9: specifier: npm:typescript@5.9.3 @@ -349,7 +356,7 @@ importers: specifier: 0.9.10 version: 0.9.10(@emnapi/runtime@1.11.3)(prettier@3.9.9)(typescript@6.0.3) typescript: - specifier: 6.0.3 + specifier: catalog:docs version: 6.0.3 examples/contracts: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ce165b2..a1a6a35 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -19,10 +19,15 @@ catalog: react-dom: 19.3.0 tailwindcss: 4.3.3 tsx: 4.23.15 + typescript: 7.0.2 vite: npm:@voidzero-dev/vite-plus-core@1.0.0 vite-plus: 1.0.0 vitest: 5.0.1 +catalogs: + docs: + typescript: 6.0.3 + minimumReleaseAge: 10080 minimumReleaseAgeStrict: true minimumReleaseAgeIgnoreMissingTime: false From a42975a3b6cc413820487496ec0db4c825917571 Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Fri, 9 Oct 2026 22:09:04 +0200 Subject: [PATCH 13/97] docs: keep ADRs focused on architectural decisions --- ...keep-the-core-factory-lifecycle-neutral.md | 4 +- ...010-reuse-tanstack-query-core-as-a-peer.md | 7 - ...ject-http-streams-and-buffered-metadata.md | 153 ++---------------- ...ed-message-clients-for-server-local-rpc.md | 57 ++----- ...ransport-aggregation-in-the-application.md | 81 +--------- 5 files changed, 29 insertions(+), 273 deletions(-) diff --git a/docs/adr/0007-keep-the-core-factory-lifecycle-neutral.md b/docs/adr/0007-keep-the-core-factory-lifecycle-neutral.md index 63b391f..b75c49d 100644 --- a/docs/adr/0007-keep-the-core-factory-lifecycle-neutral.md +++ b/docs/adr/0007-keep-the-core-factory-lifecycle-neutral.md @@ -18,5 +18,5 @@ an injected runner with the same `effect, { signal? }` shape. The caller owns protocol, middleware, lifetime, and resources. Queries forward TanStack's abort signal, mutations omit it, and runner rejections propagate untouched. -[ADR 0026](0026-keep-rpc-transport-aggregation-in-the-application.md) records the transport overhead -measurement and preserves application ownership of aggregation. +[ADR 0026](0026-keep-rpc-transport-aggregation-in-the-application.md) preserves application ownership +of transport aggregation. diff --git a/docs/adr/0010-reuse-tanstack-query-core-as-a-peer.md b/docs/adr/0010-reuse-tanstack-query-core-as-a-peer.md index 7283efb..32fb6d5 100644 --- a/docs/adr/0010-reuse-tanstack-query-core-as-a-peer.md +++ b/docs/adr/0010-reuse-tanstack-query-core-as-a-peer.md @@ -20,10 +20,3 @@ exact `skipToken` and `SkipToken` bindings. Consumers use the same bindings, or broader Query APIs, from their TanStack installation. `skipToken` applies only to payload-bearing query options. React, React Query, and TanStack Start remain outside the peer set until a public entry point imports them directly. - -## Evidence - -[`tests/packed-consumer/runtime.mts`](../../tests/packed-consumer/runtime.mts) exercises -undefined-only, null-only, value-then-undefined, empty, and open live streams with the minimum and -development peers. [`tests/types/public-contract.ts`](../../tests/types/public-contract.ts) verifies -normalized live data with TypeScript 5.9 and the repository compiler. diff --git a/docs/adr/0024-project-http-streams-and-buffered-metadata.md b/docs/adr/0024-project-http-streams-and-buffered-metadata.md index 9cd1654..58f6f4a 100644 --- a/docs/adr/0024-project-http-streams-and-buffered-metadata.md +++ b/docs/adr/0024-project-http-streams-and-buffered-metadata.md @@ -1,143 +1,14 @@ # Project HTTP streams and buffered metadata into separate cached views -Status: Accepted. Amends [ADR 0020](0020-generate-accumulated-and-live-stream-queries.md), -[ADR 0022](0022-add-buffered-http-utilities-with-separate-key-roots.md), and -[ADR 0023](0023-expose-buffered-multipart-http-mutations.md). - -## Context - -A query cache needs decoded application values with stable identity. SSE declarations provide -such values; raw transport chunks do not. Buffered responses can also expose status and headers -without retaining a response object or its resources. - -## Decision - -Generate accumulated and live query builders for endpoints with exactly one SSE success -alternative. A declared `WithHeaders` wrapper projects onto each emission as -`HttpApiSchema.withHeaders({ body: chunk, headers: decodedHeaders })`. Accumulated data is an array -of emissions; live data is the latest emission. Data declarations emit decoded data, while event -declarations preserve their declared fields, event names, and IDs. - -Expose opt-in `metadataKey` and `metadataOptions` on query-enabled buffered endpoints. Execute the -ready client with `responseMode: 'decoded-and-response'` and return a frozen readonly envelope: -`{ readonly data: QueryData; readonly status: number; readonly headers: Readonly> }`. -Copy raw enumerable string headers into an ordinary frozen record. Preserve declared decoded -`WithHeaders` values inside `data`; normalize only a top-level successful `undefined` to `null`. -Metadata uses its own operation identity and the ordinary query options' selection, initial-data, -skip-token, and error inference. It adds no metadata mutation or infinite-query methods. - -### Supported declarations - -Request and success rows compose: the request must permit the view, and the success must have -that representation. HTTP method does not determine cache capability. - -| Declared request | Buffered views | SSE views | Key-encoder input | -| ------------------------------------------------------------------ | ---------------------------------------- | ---------------------------------------- | ----------------------------------------------- | -| No params/query/headers/payload | Query, infinite, mutation, metadata | Accumulated, live | None | -| Decoded params/query/headers and JSON/text/form/URL payload | Query, infinite, mutation, metadata | Accumulated, live | Complete decoded request containers | -| Serviceful, redacted, binary, or multiple payload alternatives | Same views with required custom encoding | Same views with required custom encoding | Complete decoded request, without view controls | -| Buffered multipart, including a mixed buffered payload alternative | Mutation only | Omitted | None | -| Streaming multipart payload alternative | Omitted | Omitted | None | - -| Declared success alternatives | Generated views | Cached data | -| ---------------------------------------------------- | ------------------------------------------------- | ------------------------------------------------------- | -| All buffered JSON/text/bytes/no-content alternatives | Ordinary query/infinite/mutation; opt-in metadata | Decoded value; metadata adds status and raw headers | -| Buffered `WithHeaders(Body, Headers)` | Same buffered views | Decoded wrapper remains intact | -| Exactly one `StreamSse({ data })` | Accumulated and live | Decoded data elements | -| Exactly one `StreamSse({ events })` | Accumulated and live | Decoded event elements with declared fields | -| Exactly one `WithHeaders(StreamSse(...), Headers)` | Accumulated and live | Per-emission decoded body and decoded header wrapper | -| SSE plus any buffered alternative | Whole endpoint omitted | A single view would misrepresent one successful outcome | -| Multiple streaming alternatives | Rejected by Effect endpoint construction | No endpoint | -| Raw `StreamUint8Array`, wrapped or mixed | Whole endpoint omitted | Application consumes native bytes into its own sink | - -Effect 4.0.0 permits one streaming success alternative with additional buffered alternatives -selected by status/content type. Keep such mixed endpoints omitted until a discriminated contract -can represent every successful outcome. - -For ordinary queries and unwrapped live values, successful top-level `undefined` becomes `null`. -Accumulated arrays preserve `undefined` elements. Decoded wrappers and their fields keep their -original representation. Live completion preserves the latest value; completion without an -emission raises `EffectHttpApiQueryEmptyStreamError` with declaration identity. Empty accumulated -completion succeeds with the appropriate retained history. - -### View identity - -Streaming leaves provide `key`, `streamedKey`, `streamedOptions`, `liveKey`, and `liveOptions`. -Option builders accept `sseOptions?: Sse.DecodeOptions` beside `input`; accumulated builders also -accept `maxChunks` and `refetchMode`. Concrete key builders accept the same policy after decoded -input, or as their only argument for inputless endpoints. Live key policy contains decoder controls -only. Endpoint prefixes select every concrete policy. - -Decoded request input and custom key-encoder input contain only declared request parts. Response -mode belongs to the adapter; decoder controls belong to the view. Declared transport headers, -including an application resume cursor, remain request identity input. Middleware-owned identity -belongs in the caller's key prefix. Captured request values remain immutable so identity and -execution describe the same request. - -The decoder's `maxEventSize` default is 10 MiB of JavaScript string code units. Absent options, -an empty record, an undefined field, and an explicit default share normalized identity. A -non-default effective limit splits both SSE view keys. Require a positive safe integer and reject -invalid values synchronously with a configuration error. Pass the same normalized value to the -ready client. Accumulated identity also includes effective `maxChunks` and `refetchMode`. - -`maxEventSize` limits pending parser text, rather than network bytes or decoded payload size. -A complete event parsed within one input chunk can clear pending state before Effect checks the -limit. A pending event split before its terminator demonstrates the actual control; the adapter -retains that upstream guarantee. - -### Failures and resource lifetime - -| Phase | Upstream result | Adapter behavior | -| -------------------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------- | -| Request encoding, middleware, status, response-header decoding | Acquisition failure/defect/interruption | HTTP execution error with complete Cause and operation identity | -| SSE parsing, event decoding, reserved declared failure event | Stream failure/defect/interruption | Same HTTP execution error | -| `retry:` parser directive | `Sse.Retry`, with duration and event ID | Preserve Cause for application retry/resume policy | -| Empty successful SSE completion | Empty Stream | Accumulated empty/history result; live empty-stream error | -| Query abort | Interrupted acquisition or consumption | Native Query reversion, iterator cleanup, no late cache writes | -| Injected runner rejects | Rejected Promise | Preserve rejection unchanged | - -Acquisition requires declared encoding, response decoding, error, and middleware services, plus -additional client requirements. Public `HttpApiClient` captures an SSE decoder's services in the -acquisition Context; the resulting Stream advertises `never` residual services. A ready client -must satisfy that public interface, including additional error and service channels. - -The supplied ready client and runner own transport, middleware, runtime, and Scope. Acquire and -consume SSE under the caller's runner Context, forward Query's abort signal, and close the iterator -on cancellation. Acquisition and consumption failures preserve the complete Cause in -`EffectHttpApiQueryError` with `streamed`, `live`, or `metadata` operation identity. Independent -interruption is a failure; signal cancellation follows native Query reversion. - -Public `Stream.toAsyncIterable` closes its scope and interrupts pending pulls on `return()`. -Returning after the first event and aborting consumption each cancel the Web body once and abort -the transport signal. Upstream transport retains responsibility when acquisition fails before a -usable Stream is returned. - -Buffered metadata finishes buffering and decoding before returning its envelope. Cache only that -envelope and header snapshot, never `HttpClientResponse`, Web `Response`, Effect `Headers`, a cookie -collection, or an open body. `Headers.toJSON` applies redaction, so it cannot produce the required -raw snapshot. Decoded data retains its declared representation and ownership. Applications choose -whether raw headers may be persisted or dehydrated. - -## Consequences - -Raw byte partitions depend on transport chunking: `[1, 2, 3]` can arrive as one chunk or as -`[1, 2]`, `[3]`. Retention by chunk count therefore changes retained bytes despite identical body -content, and native byte Streams retain body lifetime until consumed or closed. Applications own -download accumulation and sinks; explicitly buffered byte schemas keep their existing behavior. - -The adapter preserves declared event fields and IDs. Application code owns reconnection, -`Sse.Retry` handling, and resume semantics, including use of its `lastEventId`. - -## Evidence - -- [`tests/http-view-proof.test.ts`](../../tests/http-view-proof.test.ts) uses public - `HttpApiTest.groups`, `HttpApiClient.makeWith`, and `HttpClient.make` for decoded SSE, headers, - reserved failures, retry directives, parser limits, raw byte partitions, and body finalization. -- [`tests/types/http-view-proof.ts`](../../tests/types/http-view-proof.ts) verifies acquisition and - stream channels, decoded wrappers, additional client requirements, and separated request controls - with TypeScript 5.9 and the repository compiler. - -The upstream contracts come from `HttpApiClient` stream decoding and captured Context, -`HttpApiEndpoint` success validation and client channels, `HttpApiSchema` declarations, `Sse` -decoder controls, and public iterator and HTTP finalization. Reverify them when the exact Effect -peer changes. +Status: Accepted. Amends [ADR 0020](0020-generate-accumulated-and-live-stream-queries.md) +and [ADR 0022](0022-add-buffered-http-utilities-with-separate-key-roots.md). + +Query caches hold decoded application values, while HTTP responses and streams retain transport +resources. Generate accumulated and live views for unambiguous SSE successes, and an opt-in +buffered metadata view containing decoded data, status, and a plain header snapshot. Give each +representation separate cache identity so incompatible data shapes cannot share an entry. + +Preserve declared decoded header wrappers. Omit mixed buffered/streamed successes and raw byte +streams: the former lack one truthful view, and the latter have transport-dependent chunk +boundaries. Applications own download sinks, reconnection, resume policy, and client lifetime; +the adapter consumes their ready clients without caching transport resources. diff --git a/docs/adr/0025-use-public-decoded-message-clients-for-server-local-rpc.md b/docs/adr/0025-use-public-decoded-message-clients-for-server-local-rpc.md index 7e06db7..458daeb 100644 --- a/docs/adr/0025-use-public-decoded-message-clients-for-server-local-rpc.md +++ b/docs/adr/0025-use-public-decoded-message-clients-for-server-local-rpc.md @@ -3,51 +3,12 @@ Status: Accepted. Extends [ADR 0007](0007-keep-the-core-factory-lifecycle-neutral.md) and [ADR 0019](0019-host-effect-rpc-inside-tanstack-start.md). -Effect 4.0.0 exposes `RpcServer.makeNoSerialization` and -`RpcClient.makeNoSerialization` as public constructors for decoded message -channels. An application may connect their write callbacks within its own -Scope, supply the server handler and middleware Context, and pass the resulting -flat ready client to `createRpcQueryUtils`. Server-local execution therefore -needs no loopback request or library-owned client factory. The browser keeps -its independently scoped HTTP or socket client. - -Use a separate connection and QueryClient for each request identity. The -server-owned handler Context determines services and authority; validate -request headers in middleware. Capture the application's server Context during -construction and provide it to every `server.write` call. Native dispatch merges -the handler Context with its current fiber Context; dispatching directly from a -caller runner permits that runner's services to override handler-owned services. -The proof supplies conflicting runner identities to both concurrent clients and -preserves their server-owned identities. Caller Context and explicit native call -Context stay outside the authority-owning dispatch boundary. Keep stream -acknowledgements enabled, and set the server's -`disableFatalDefects: true` to preserve complete failed Exits rather than -collapsing fatal defect Causes. Cancel queries before closing the caller-owned -Scope. - -This route constructs payloads and carries decoded values, middleware headers, -Exits, acknowledgements, and interrupts. It deliberately performs no transport -Schema encoding or decoding. Native call types retain residual Schema service -requirements, so the adapter still requires the application's matching runner; -this does not prove that codec effects execute on a decoded channel. Applications -that require wire validation or codec side effects should keep the schema-aware -`RpcClient.make` protocol. An application-owned `FetchHttpClient.Fetch` can -dispatch that protocol to its existing Web handler without a network request; -that separate recipe must verify its own transport and resource semantics. - -The bounded executable proof is -[`tests/create-rpc-query-utils-server-local.test.ts`](../../tests/create-rpc-query-utils-server-local.test.ts), -with its application recipe in -[`tests/fixtures/server-local-rpc.ts`](../../tests/fixtures/server-local-rpc.ts) -and native service/type evidence in -[`tests/types/server-local-client-contract.ts`](../../tests/types/server-local-client-contract.ts). -It uses the public constructors directly, with the network fetch boundary -disabled. It verifies decoded class behavior, payload defaults, authenticated -request isolation, complete Causes, cancellation, finalization, and independent -Scope disposal. `RpcTest` and `HttpApiTest` remain test support rather than -production construction guarantees. The unstable upstream constructors remain -qualified by the package's exact Effect peer. - -`vp run test` includes the native constructor contract through -`vp run server-local-types`, using the repository compiler and TypeScript 5.9. -Published adapter contracts remain covered by the existing packed consumers. +Server rendering can execute local handlers without a network round trip. Applications may +connect Effect's public decoded-message client and server within a request-owned Scope and pass +the ready client to the adapter. This preserves the lifecycle-neutral boundary without adding a +package-owned client factory or relying on test constructors. + +Server-owned services determine request authority; caller execution services cannot override it. +Each request owns its connection and QueryClient, and browser execution owns separate resources. +Decoded-message execution skips transport Schema codecs, so applications requiring wire validation +or codec effects retain a schema-aware protocol. The exact Effect peer bounds this upstream API. diff --git a/docs/adr/0026-keep-rpc-transport-aggregation-in-the-application.md b/docs/adr/0026-keep-rpc-transport-aggregation-in-the-application.md index c808fa6..43a9f80 100644 --- a/docs/adr/0026-keep-rpc-transport-aggregation-in-the-application.md +++ b/docs/adr/0026-keep-rpc-transport-aggregation-in-the-application.md @@ -2,79 +2,10 @@ Status: Accepted. Extends [ADR 0007](0007-keep-the-core-factory-lifecycle-neutral.md). -## Context +Repeated RPC calls can incur substantial transport overhead. Keep transport selection and +aggregation in the application because they determine connection lifetime, latency, routing, +retries, and cancellation. Shared WebSocket transport reduces repeated headers while preserving +independent calls; explicit bulk RPCs remain a domain choice. -Independent ready RPC client calls can incur repeated HTTP headers and connection overhead. A -shared WebSocket can reduce that cost while preserving individual request envelopes, responses, -failures, and cancellation. - -## Decision - -The application selects and owns the transport. Keep independent Query execution in the RPC -utility tree. Use a shared WebSocket when repeated HTTP overhead matters, or declare an explicit -bulk RPC when the domain supports one. A collecting custom protocol must own its collection delay, -response routing, retries, failures, interruption, and lifetime. - -Transport aggregation, backend RequestResolver batching, and Query publication address separate -costs. A shared server RequestResolver can combine backend lookups while frontend RPC requests -remain independent. Query notification batching changes callback scheduling; streamed publication -batching changes cache update frequency. Neither reduces transport request count. - -Effect 4.0.0's stock HTTP protocol performs one POST per Request in -`node_modules/effect/src/rpc/RpcClient.ts`, `makeProtocolHttp`. Its `makeProtocolSocket` shares a -writer and routes responses by request ID, then writes each encoded message separately. The -WebSocket adapter's writer maps each write to one send in `node_modules/effect/src/socket/Socket.ts`. -JSON array/framing support in `RpcSerialization.ts` and batch input parsing in `RpcServer.ts` -provide no stock-client collection window. - -## Measurement - -The [protocol fixture](../../examples/server/tests/rpc-transport-overhead.test.ts) uses real loopback -Effect HTTP/WebSocket servers, ready clients, the public package factory, and a real QueryClient. -Each row starts a fresh host, client, and cache, then runs concurrent unary queries with distinct -integer payloads. JSON serialization, fixed request IDs, disabled RPC tracing, and disabled Query -retries keep the request sizes comparable. - -```sh -RPC_TRANSPORT_MEASURE=1 vp run --no-cache test examples/server/tests/rpc-transport-overhead.test.ts -``` - -The October 9, 2026 measurement used Node 24.5.0, Effect and Node platform 4.0.0, Query Core 5.104.0, -and Vite+ 1.0.0. - -| Calls | Transport | POSTs / sends | Connections | Envelope bytes | Header / upgrade bytes | Client → server bytes | -| ----: | --------- | ------------: | ----------: | -------------: | ---------------------: | --------------------: | -| 1 | HTTP | 1 | 1 | 77 | 371 | 448 | -| 1 | WebSocket | 1 | 1 | 77 | 227 | 310 | -| 8 | HTTP | 8 | 8 | 616 | 2,968 | 3,584 | -| 8 | WebSocket | 8 | 1 | 616 | 227 | 891 | -| 32 | HTTP | 32 | 32 | 2,508 | 11,872 | 14,380 | -| 32 | WebSocket | 32 | 1 | 2,508 | 227 | 2,927 | - -At eight calls, WebSocket multiplexing reduces outbound bytes by about 75% while retaining eight -envelopes and eight sends. At 32 calls the reduction is about 80%. Each query returns its individual -value and creates a separate successful cache entry. - -Counts use encoded HTTP bodies, actual request lines/raw headers, WebSocket sends, and server TCP -`bytesRead`. WebSocket totals include one upgrade and six masked-frame bytes per small request. -Counts end after responses arrive and before disposal. Responses, TCP/IP headers, TLS, connection -setup packets, and later keepalives are excluded. This measures bytes and requests, not latency, -throughput, or CPU cost. HTTP connection counts reflect this cold concurrent HTTP/1.1 fetch pool; -warm connections, HTTP/2, browsers, compression, larger payloads, and remote backends need their own -measurement. Header sizes depend on the Node/fetch version and application headers. - -## Consequences - -Acquire the socket, build its protocol Layer into an application-owned client Scope with -`Layer.buildWithScope`, and acquire the flat client with that Context and Scope. Keep the Scope -alive while the utility tree uses the ready RPC client. Close the client Scope before its host; -providing the socket protocol Layer only around acquisition closes its receive loop too early. - -For backend batching, share one `RequestResolver.make` instance in the server service, optionally -set a collection delay with `RequestResolver.setDelay`, run the backend bulk operation, and complete -each entry with its individual Exit. Handlers call `Effect.request(request, resolver)`. The installed -recipe is `node_modules/effect/ai-docs/src/05_batching/10_request-resolver.ts`; collection belongs to -the shared resolver instance and batch key. A collection delay adds latency. - -The package retains its lifecycle-neutral ready-client boundary and adds no automatic transport -batching API. +The utility tree adds no automatic transport batching. Backend request batching, transport +aggregation, and Query publication address different costs and retain separate ownership. From ba396ff2bcedcae4cf7d6392e19b851c6153ae7a Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Fri, 9 Oct 2026 22:16:11 +0200 Subject: [PATCH 14/97] fix: derive RPC payloadlessness from schema declarations --- .../docs/guides/custom-key-encoders.md | 4 + .../docs/reference/generated-builders.md | 7 + ...quire-query-stable-payload-construction.md | 2 + src/core/types.ts | 127 +++++-- src/rpc/types.ts | 48 ++- tests/packed-consumer/runtime.mts | 273 +++++++++++++- tests/types/public-contract.ts | 333 ++++++++++++++++++ 7 files changed, 745 insertions(+), 49 deletions(-) diff --git a/apps/docs/src/content/docs/guides/custom-key-encoders.md b/apps/docs/src/content/docs/guides/custom-key-encoders.md index 379f3c9..5cf906e 100644 --- a/apps/docs/src/content/docs/guides/custom-key-encoders.md +++ b/apps/docs/src/content/docs/guides/custom-key-encoders.md @@ -23,6 +23,10 @@ const rpcQuery = createRpcQueryUtils(rpcGroup, { An encoder receives the normalized payload and must return a strict `JsonValue` synchronously. It must not reveal secrets. Return a stable public identifier, digest, or other safe semantic identity. +Broad payload declarations such as `Schema.Unknown`, `Schema.Any`, and void-containing unions +accept encoders too. Their default keys still require strict JSON: an accepted RPC input such as +`undefined`, a bigint, or a class instance may require an encoder to supply JSON-safe identity. + Define encoders as own enumerable properties keyed by literal payload-bearing RPC tags. TypeScript requires entries for unsafe payloads; the factory also rejects missing or unknown entries at runtime. diff --git a/apps/docs/src/content/docs/reference/generated-builders.md b/apps/docs/src/content/docs/reference/generated-builders.md index 70283b5..c938df8 100644 --- a/apps/docs/src/content/docs/reference/generated-builders.md +++ b/apps/docs/src/content/docs/reference/generated-builders.md @@ -16,6 +16,12 @@ Each unary RPC leaf also exposes: | `mutationKey()` | Immutable operation key shared by mutations of this RPC. | | `mutationOptions(options?)` | Fresh mutation options with generated `mutationFn` and `mutationKey`. Pass variables when the mutation runs. | +Omitting `payload` or declaring `Schema.Void` makes an RPC payloadless. `Schema.Unknown`, +`Schema.Any`, and unions containing `Schema.Void` remain payload-bearing: key builders accept their +constructor input, query builders require an `input` field, and infinite queries require a page-input +mapper. Mutation variables and custom encoders retain the declaration's input and normalized payload +types, respectively. + Payloadless query builders take no input. Payload-bearing builders require an `input` field: ```ts @@ -42,6 +48,7 @@ Skipped options retain the exact sentinel, operation-level key, and package-owne `queryOptions`, `streamedOptions`, and `liveOptions` accept conditional `input` unions of payload constructor input and `skipToken`. Concrete inputs retain callable query functions and payload-specific keys. Conditional inputs retain the possible sentinel and both key shapes. +Inputs typed `unknown` or `any` can contain the sentinel and therefore return conditional options. `skipToken` is valid only for payload-bearing query options. It is not accepted by key or mutation builders, and skipped options are unsuitable for suspense and prefetch-only hooks. diff --git a/docs/adr/0014-require-query-stable-payload-construction.md b/docs/adr/0014-require-query-stable-payload-construction.md index 26b7e50..3bb0778 100644 --- a/docs/adr/0014-require-query-stable-payload-construction.md +++ b/docs/adr/0014-require-query-stable-payload-construction.md @@ -13,6 +13,8 @@ its own `Type` or preserves encoded meaning across repeated construction. Query use requires a query-stable payload Schema. Standard struct-shaped payloads and materialized defaults are the supported path. +Only a Void payload declaration is payloadless. Broader constructor types remain payload-bearing. + ## Consequences Constructor-sensitive Schemas remain available to mutations. Query callers instead expose a stable diff --git a/src/core/types.ts b/src/core/types.ts index 0d94ac7..b085c19 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -72,6 +72,21 @@ export type ConditionalQueryOptions QueryFunction | SkipToken > +export type InputQueryOptions< + Input, + Data, + Error, + Selected, + Key extends QueryKey, + SkippedKey extends QueryKey, +> = unknown extends Input + ? ConditionalQueryOptions + : [Input] extends [SkipToken] + ? QueryOptions + : SkipToken extends Input + ? ConditionalQueryOptions + : QueryOptions + /** Owns unary query inference; adapters supply their request, data, error, and key types. */ export type UnaryQueryBuilder< Input, @@ -80,7 +95,8 @@ export type UnaryQueryBuilder< Key extends QueryKey, SkippedKey extends QueryKey, AdapterOptions = unknown, -> = void extends Input + Inputless extends boolean = void extends Input ? true : false, +> = Inputless extends true ? { ( options: WithDefinedInitialData< @@ -96,32 +112,60 @@ export type UnaryQueryBuilder< ): QueryOptions } : { - ( + ( options: WithDefinedInitialData< - QueryInput, + QueryInput< + Data, + Error, + Selected, + InputQueryOptions['queryKey'], + AdapterOptions + >, Data - > & { readonly input: Input }, - ): WithDefinedInitialData, Data> - ( + > & { readonly input: ActualInput }, + ): WithDefinedInitialData< + InputQueryOptions, + Data + > + ( options: WithUndefinedInitialData< - QueryInput, + QueryInput< + Data, + Error, + Selected, + InputQueryOptions['queryKey'], + AdapterOptions + >, Data - > & { readonly input: Input }, - ): QueryOptions - ( + > & { readonly input: ActualInput }, + ): InputQueryOptions + ( options: WithDefinedInitialData< - Omit, OwnedQueryOption>, + QueryInput< + Data, + Error, + Selected, + InputQueryOptions['queryKey'], + AdapterOptions + >, Data - > & - AdapterOptions & { readonly input: SkipToken }, - ): WithDefinedInitialData, Data> - ( + > & { readonly input: ActualInput }, + ): WithDefinedInitialData< + InputQueryOptions, + Data + > + ( options: WithUndefinedInitialData< - Omit, OwnedQueryOption>, + QueryInput< + Data, + Error, + Selected, + InputQueryOptions['queryKey'], + AdapterOptions + >, Data - > & - AdapterOptions & { readonly input: SkipToken }, - ): QueryOptions + > & { readonly input: ActualInput }, + ): InputQueryOptions ( options: WithDefinedInitialData< Omit, OwnedQueryOption>, @@ -172,8 +216,9 @@ export type InfiniteQueryKey< Error, OperationKey extends readonly JsonValue[], PageParam = unknown, + Inputless extends boolean = void extends Input ? true : false, > = DataTag< - void extends Input ? OperationKey : readonly [...OperationKey, JsonValue], + Inputless extends true ? OperationKey : readonly [...OperationKey, JsonValue], InfiniteData, Error > @@ -186,15 +231,16 @@ export type MappedInfiniteInput< Selected, PageParam, AdapterOptions, + Inputless extends boolean, > = InfiniteInput< Data, Error, Selected, - InfiniteQueryKey, + InfiniteQueryKey, PageParam, AdapterOptions > & - (void extends Input ? unknown : { readonly input: (pageParam: PageParam) => Input }) + (Inputless extends true ? unknown : { readonly input: (pageParam: PageParam) => Input }) export type ConditionalInfiniteOptions< Data, @@ -218,10 +264,20 @@ export type InfiniteQueryBuilder< Error, OperationKey extends readonly JsonValue[], AdapterOptions = unknown, + Inputless extends boolean = void extends Input ? true : false, > = { >( options: WithDefinedInitialData< - MappedInfiniteInput, + MappedInfiniteInput< + Input, + Data, + Error, + OperationKey, + Selected, + PageParam, + AdapterOptions, + Inputless + >, InfiniteData >, ): WithDefinedInitialData< @@ -229,24 +285,33 @@ export type InfiniteQueryBuilder< Data, Error, Selected, - InfiniteQueryKey, + InfiniteQueryKey, PageParam >, InfiniteData > >( options: WithUndefinedInitialData< - MappedInfiniteInput, + MappedInfiniteInput< + Input, + Data, + Error, + OperationKey, + Selected, + PageParam, + AdapterOptions, + Inputless + >, InfiniteData >, ): InfiniteOptions< Data, Error, Selected, - InfiniteQueryKey, + InfiniteQueryKey, PageParam > -} & (void extends Input +} & (Inputless extends true ? unknown : { >( @@ -270,7 +335,7 @@ export type InfiniteQueryBuilder< Data, Error, Selected, - InfiniteQueryKey | OperationKey, + InfiniteQueryKey | OperationKey, PageParam, AdapterOptions >, @@ -281,7 +346,7 @@ export type InfiniteQueryBuilder< Data, Error, Selected, - InfiniteQueryKey | OperationKey, + InfiniteQueryKey | OperationKey, PageParam >, InfiniteData @@ -292,7 +357,7 @@ export type InfiniteQueryBuilder< Data, Error, Selected, - InfiniteQueryKey | OperationKey, + InfiniteQueryKey | OperationKey, PageParam, AdapterOptions >, @@ -302,7 +367,7 @@ export type InfiniteQueryBuilder< Data, Error, Selected, - InfiniteQueryKey | OperationKey, + InfiniteQueryKey | OperationKey, PageParam > }) diff --git a/src/rpc/types.ts b/src/rpc/types.ts index c8bad8d..fd24c1f 100644 --- a/src/rpc/types.ts +++ b/src/rpc/types.ts @@ -1,5 +1,5 @@ import type { DataTag, QueryKey } from '@tanstack/query-core' -import type { Context, Schema } from 'effect' +import type { Context, Schema, SchemaAST } from 'effect' import type { Headers } from 'effect/http' import type { Rpc, RpcClient, RpcGroup, RpcSchema } from 'effect/rpc' @@ -53,6 +53,10 @@ export type PayloadSchema = ? Payload : never +export type PayloadlessRpc = [PayloadSchema['ast']] extends [SchemaAST.Void] + ? true + : false + /** Retains only streaming RPC definitions. */ export type StreamingRpc = Rpc.SuccessSchema extends RpcSchema.Stream ? R : never @@ -61,7 +65,7 @@ export type StreamingRpc = export type PayloadBearingRpcs = RpcsOf extends infer R ? R extends Rpc.Any - ? void extends Rpc.PayloadConstructor + ? PayloadlessRpc extends true ? never : R : never @@ -126,7 +130,7 @@ export type ConcreteQueryKey< R extends Rpc.Any, ClientError, > = DataTag< - void extends Rpc.PayloadConstructor + PayloadlessRpc extends true ? QueryOperationKey : readonly [...QueryOperationKey, JsonValue], QueryData>, @@ -149,11 +153,12 @@ export type QueryOptionsBuilder< EffectRpcQueryError>, ConcreteQueryKey, QueryOperationKey, - RpcOptionsInput + RpcOptionsInput, + PayloadlessRpc > export type QueryKeyBuilder = - void extends Rpc.PayloadConstructor + PayloadlessRpc extends true ? () => ConcreteQueryKey : (input: Rpc.PayloadConstructor) => ConcreteQueryKey @@ -168,7 +173,8 @@ export type ConcreteInfiniteKey< QueryData>, EffectRpcQueryError>, InfiniteOperationKey, - PageParam + PageParam, + PayloadlessRpc > /** Supplies RPC payload and request-local options to the shared infinite query builder. */ @@ -181,7 +187,8 @@ export type InfiniteOptionsBuilder< QueryData>, EffectRpcQueryError>, InfiniteOperationKey, - RpcOptionsInput + RpcOptionsInput, + PayloadlessRpc > export type InfiniteKeyBuilder< @@ -189,7 +196,7 @@ export type InfiniteKeyBuilder< Prefix extends readonly JsonValue[], ClientError, > = - void extends Rpc.PayloadConstructor + PayloadlessRpc extends true ? () => ConcreteInfiniteKey : (input: Rpc.PayloadConstructor) => ConcreteInfiniteKey @@ -202,7 +209,7 @@ export type ConcreteStreamedKey< R extends Rpc.Any, ClientError, > = DataTag< - void extends Rpc.PayloadConstructor + PayloadlessRpc extends true ? StreamedOperationKey : readonly [...StreamedOperationKey, JsonValue], StreamedData, @@ -215,7 +222,7 @@ export type ConcreteLiveKey< R extends Rpc.Any, ClientError, > = DataTag< - void extends Rpc.PayloadConstructor + PayloadlessRpc extends true ? LiveOperationKey : readonly [...LiveOperationKey, JsonValue], QueryData>, @@ -239,13 +246,15 @@ export type StreamingQueryBuilder< Key extends QueryKey, SkippedKey extends QueryKey, Policy = unknown, + Inputless extends boolean = void extends Input ? true : false, > = UnaryQueryBuilder< Input, Data, Error, Key, SkippedKey, - Policy & RpcOptionsInput + Policy & RpcOptionsInput, + Inputless > export type StreamedOptionsBuilder< @@ -258,7 +267,8 @@ export type StreamedOptionsBuilder< EffectRpcQueryError>, ConcreteStreamedKey, StreamedOperationKey, - StreamedPolicyOptions + StreamedPolicyOptions, + PayloadlessRpc > export type LiveOptionsBuilder< @@ -270,11 +280,13 @@ export type LiveOptionsBuilder< QueryData>, RpcLiveError, ConcreteLiveKey, - LiveOperationKey + LiveOperationKey, + unknown, + PayloadlessRpc > export type StreamKeyBuilder = - void extends Rpc.PayloadConstructor ? () => Key : (input: Rpc.PayloadConstructor) => Key + PayloadlessRpc extends true ? () => Key : (input: Rpc.PayloadConstructor) => Key /** The key and option builders exposed at one streaming RPC path. */ export interface RpcStreamLeaf< @@ -380,9 +392,11 @@ export type RpcQueryUtils< export type NeedsKeyEncoder = [PayloadSchema['EncodingServices']] extends [ never, ] - ? true extends ContainsRedacted> - ? true - : false + ? PayloadSchema['ast'] extends SchemaAST.Any | SchemaAST.Unknown + ? false + : true extends ContainsRedacted> + ? true + : false : true /** Selects RPCs whose default key encoding is unsafe or cannot run synchronously. */ diff --git a/tests/packed-consumer/runtime.mts b/tests/packed-consumer/runtime.mts index 5e320a4..3650d8b 100644 --- a/tests/packed-consumer/runtime.mts +++ b/tests/packed-consumer/runtime.mts @@ -16,7 +16,7 @@ import type { import { Rpc, RpcGroup, RpcTest } from 'effect/rpc' // fallow-ignore-file unused-file // The packed-package verifier copies and executes this fixture in temporary consumers. -import { deepStrictEqual, equal, ok, rejects } from 'node:assert/strict' +import { deepStrictEqual, equal, ok, rejects, throws } from 'node:assert/strict' type PublicTypes = [ CreateRpcQueryUtilsOptions, @@ -281,3 +281,274 @@ await Effect.runPromise( }), ), ) + +const BroadRead = Rpc.make('payload.unknown', { payload: Schema.Unknown, success: Schema.String }) +const AnyRead = Rpc.make('payload.any', { payload: Schema.Any, success: Schema.String }) +const OptionalRead = Rpc.make('payload.optional', { + payload: Schema.Union([Schema.String, Schema.Void]), + success: Schema.String, +}) +const VoidRead = Rpc.make('payload.void', { payload: Schema.Void, success: Schema.String }) +const DefaultRead = Rpc.make('payload.defaults', { + payload: { + id: Schema.Int, + locale: Schema.String.pipe( + Schema.optionalKey, + Schema.withConstructorDefault(Effect.succeed('en')), + ), + }, + success: Schema.String, +}) +const BroadWatch = Rpc.make('payload.unknownWatch', { + payload: Schema.Unknown, + success: Schema.String, + stream: true, +}) +const AnyWatch = Rpc.make('payload.anyWatch', { + payload: Schema.Any, + success: Schema.String, + stream: true, +}) +const BroadOptionalWatch = Rpc.make('payload.optionalWatch', { + payload: Schema.Union([Schema.String, Schema.Void]), + success: Schema.String, + stream: true, +}) +const VoidWatch = Rpc.make('payload.voidWatch', { + payload: Schema.Void, + success: Schema.String, + stream: true, +}) +const broadGroup = RpcGroup.make( + BroadRead, + AnyRead, + OptionalRead, + VoidRead, + DefaultRead, + BroadWatch, + AnyWatch, + BroadOptionalWatch, + VoidWatch, +) +const broadHandlers = broadGroup.of({ + 'payload.unknown': (input) => Effect.succeed(JSON.stringify(input)), + 'payload.any': (input) => Effect.succeed(String(input)), + 'payload.optional': (input) => Effect.succeed(input ?? 'empty'), + 'payload.void': () => Effect.succeed('payloadless'), + 'payload.defaults': ({ id, locale }) => Effect.succeed(`${id}:${locale}`), + 'payload.unknownWatch': (input) => Stream.make(JSON.stringify(input), 'unknown-last'), + 'payload.anyWatch': (input) => Stream.make(String(input), 'any-last'), + 'payload.optionalWatch': (input) => Stream.make(input ?? 'empty', 'optional-last'), + 'payload.voidWatch': () => Stream.make('payloadless-first', 'payloadless-last'), +}) + +await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const client = yield* RpcTest.makeClient(broadGroup, { flatten: true }).pipe( + Effect.provide(broadGroup.toLayer(broadHandlers)), + ) + const queryClient = new QueryClient() + const defaults = rpcQuery.createRpcQueryUtils(broadGroup, { + client, + keyPrefix: ['payload'], + }) + const encoded = rpcQuery.createRpcQueryUtils(broadGroup, { + client, + keyPrefix: ['encoded'], + keyEncoders: { + 'payload.unknown': (input) => (input === undefined ? 'empty' : String(input)), + 'payload.any': (input) => String(input), + 'payload.optional': (input) => input ?? null, + 'payload.unknownWatch': (input) => String(input), + 'payload.anyWatch': (input) => String(input), + 'payload.optionalWatch': (input) => input ?? null, + }, + }) + + deepStrictEqual(defaults.payload.unknown.queryKey({ id: 7 }), [ + 'payload', + 'rpc', + 'payload', + 'unknown', + 'query', + { id: 7 }, + ]) + deepStrictEqual(defaults.payload.any.queryKey(7), [ + 'payload', + 'rpc', + 'payload', + 'any', + 'query', + 7, + ]) + deepStrictEqual(defaults.payload.optional.queryKey('input'), [ + 'payload', + 'rpc', + 'payload', + 'optional', + 'query', + 'input', + ]) + for (const invalid of [undefined, 1n, new Date(0)]) { + throws(() => defaults.payload.unknown.queryKey(invalid), rpcQuery.EffectRpcQueryKeyError) + throws(() => defaults.payload.any.queryKey(invalid), rpcQuery.EffectRpcQueryKeyError) + } + throws(() => defaults.payload.optional.queryKey(undefined), rpcQuery.EffectRpcQueryKeyError) + deepStrictEqual(encoded.payload.optional.queryKey(undefined), [ + 'encoded', + 'rpc', + 'payload', + 'optional', + 'query', + null, + ]) + deepStrictEqual( + defaults.payload.defaults.queryKey({ id: 7 }), + defaults.payload.defaults.queryKey({ id: 7, locale: 'en' }), + ) + equal( + yield* Effect.promise(() => + queryClient.query(defaults.payload.defaults.queryOptions({ input: { id: 7 } })), + ), + '7:en', + ) + equal( + yield* Effect.promise(() => + queryClient.query(defaults.payload.unknown.queryOptions({ input: { id: 7 } })), + ), + '{"id":7}', + ) + equal( + yield* Effect.promise(() => + queryClient.query(defaults.payload.any.queryOptions({ input: 7 })), + ), + '7', + ) + equal( + yield* Effect.promise(() => + queryClient.query(encoded.payload.optional.queryOptions({ input: undefined })), + ), + 'empty', + ) + equal( + yield* Effect.promise(() => + queryClient.query(encoded.payload.any.queryOptions({ input: 1n })), + ), + '1', + ) + equal( + yield* Effect.promise(() => + new MutationObserver(queryClient, defaults.payload.unknown.mutationOptions()).mutate({ + id: 8, + }), + ), + '{"id":8}', + ) + equal( + yield* Effect.promise(() => + new MutationObserver(queryClient, defaults.payload.any.mutationOptions()).mutate(2n), + ), + '2', + ) + equal( + yield* Effect.promise(() => + new MutationObserver(queryClient, defaults.payload.optional.mutationOptions()).mutate( + undefined, + ), + ), + 'empty', + ) + deepStrictEqual( + yield* Effect.promise(() => + queryClient.infiniteQuery( + defaults.payload.unknown.infiniteOptions({ + input: (page: number) => ({ page }), + initialPageParam: 0, + getNextPageParam: () => undefined, + }), + ), + ), + { pages: ['{"page":0}'], pageParams: [0] }, + ) + deepStrictEqual( + yield* Effect.promise(() => + queryClient.infiniteQuery( + defaults.payload.any.infiniteOptions({ + input: (page: number) => page, + initialPageParam: 0, + getNextPageParam: () => undefined, + }), + ), + ), + { pages: ['0'], pageParams: [0] }, + ) + deepStrictEqual( + yield* Effect.promise(() => + queryClient.infiniteQuery( + encoded.payload.optional.infiniteOptions({ + input: () => undefined, + initialPageParam: 0, + getNextPageParam: () => undefined, + }), + ), + ), + { pages: ['empty'], pageParams: [0] }, + ) + deepStrictEqual( + yield* Effect.promise(() => + queryClient.query(defaults.payload.unknownWatch.streamedOptions({ input: { id: 7 } })), + ), + ['{"id":7}', 'unknown-last'], + ) + deepStrictEqual( + yield* Effect.promise(() => + queryClient.query(defaults.payload.anyWatch.streamedOptions({ input: 7 })), + ), + ['7', 'any-last'], + ) + deepStrictEqual( + yield* Effect.promise(() => + queryClient.query(encoded.payload.optionalWatch.streamedOptions({ input: undefined })), + ), + ['empty', 'optional-last'], + ) + equal( + yield* Effect.promise(() => + queryClient.query(defaults.payload.unknownWatch.liveOptions({ input: { id: 7 } })), + ), + 'unknown-last', + ) + equal( + yield* Effect.promise(() => + queryClient.query(defaults.payload.anyWatch.liveOptions({ input: 7 })), + ), + 'any-last', + ) + equal( + yield* Effect.promise(() => + queryClient.query(encoded.payload.optionalWatch.liveOptions({ input: undefined })), + ), + 'optional-last', + ) + equal( + yield* Effect.promise(() => queryClient.query(defaults.payload.void.queryOptions())), + 'payloadless', + ) + equal( + yield* Effect.promise(() => defaults.payload.void.mutationOptions().mutationFn()), + 'payloadless', + ) + deepStrictEqual( + yield* Effect.promise(() => + queryClient.query(defaults.payload.voidWatch.streamedOptions()), + ), + ['payloadless-first', 'payloadless-last'], + ) + equal( + yield* Effect.promise(() => queryClient.query(defaults.payload.voidWatch.liveOptions())), + 'payloadless-last', + ) + }), + ), +) diff --git a/tests/types/public-contract.ts b/tests/types/public-contract.ts index c872e17..fd1e8d8 100644 --- a/tests/types/public-contract.ts +++ b/tests/types/public-contract.ts @@ -1474,3 +1474,336 @@ utils.users.get.mutationOptions({ rpcOptions: discardOptions }) // @ts-expect-error A caller hash cannot override generated RPC cache identity. utils.users.get.queryOptions({ input: skipToken, queryHash: 'shared' }) + +const UnknownRead = Rpc.make('payload.unknown', { + payload: Schema.Unknown, + success: Schema.String, +}) +const AnyRead = Rpc.make('payload.any', { payload: Schema.Any, success: Schema.String }) +const OptionalRead = Rpc.make('payload.optional', { + payload: Schema.Union([Schema.String, Schema.Void]), + success: Schema.String, +}) +const VoidRead = Rpc.make('payload.void', { + payload: Schema.Void.annotate({ title: 'Payloadless' }), + success: Schema.String, +}) +const UnknownWatch = Rpc.make('payload.unknownWatch', { + payload: Schema.Unknown, + success: Schema.String, + stream: true, +}) +const AnyWatch = Rpc.make('payload.anyWatch', { + payload: Schema.Any, + success: Schema.String, + stream: true, +}) +const OptionalWatch = Rpc.make('payload.optionalWatch', { + payload: Schema.Union([Schema.String, Schema.Void]), + success: Schema.String, + stream: true, +}) +const VoidWatch = Rpc.make('payload.voidWatch', { + payload: Schema.Void, + success: Schema.String, + stream: true, +}) +const broadPayloadGroup = RpcGroup.make( + UnknownRead, + AnyRead, + OptionalRead, + VoidRead, + UnknownWatch, + AnyWatch, + OptionalWatch, + VoidWatch, +) +declare const broadPayloadClient: RpcClient.RpcClient.Flat> +const broadPayloadUtils = createRpcQueryUtils(broadPayloadGroup, { + client: broadPayloadClient, + keyPrefix: ['payload'], +}) +createRpcQueryUtils(broadPayloadGroup, { + client: broadPayloadClient, + keyPrefix: ['payload'], + keyEncoders: { + 'payload.unknown': (payload) => { + true satisfies Assert> + return 'unknown' + }, + 'payload.any': (payload) => { + true satisfies Assert> + return 'any' + }, + 'payload.optional': (payload) => { + true satisfies Assert> + return payload ?? null + }, + 'payload.unknownWatch': (payload) => { + true satisfies Assert> + return 'unknown' + }, + 'payload.anyWatch': (payload) => { + true satisfies Assert> + return 'any' + }, + 'payload.optionalWatch': (payload) => { + true satisfies Assert> + return payload ?? null + }, + // @ts-expect-error Void declarations have no payload identity encoder + 'payload.void': () => null, + }, +}) +const unknownKey = broadPayloadUtils.payload.unknown.queryKey({ id: 1 }) +const anyKey = broadPayloadUtils.payload.any.queryKey({ id: 1 }) +const optionalKey = broadPayloadUtils.payload.optional.queryKey('input') +unknownKey satisfies readonly ['payload', 'rpc', 'payload', 'unknown', 'query', JsonValue] +anyKey satisfies readonly ['payload', 'rpc', 'payload', 'any', 'query', JsonValue] +optionalKey satisfies readonly ['payload', 'rpc', 'payload', 'optional', 'query', JsonValue] +queryClient.getQueryData(unknownKey) satisfies string | undefined +queryClient.getQueryData(anyKey) satisfies string | undefined +queryClient.getQueryData(optionalKey) satisfies string | undefined +broadPayloadUtils.payload.unknown.queryOptions({ input: { id: 1 } }) +broadPayloadUtils.payload.any.queryOptions({ input: { id: 1 } }) +broadPayloadUtils.payload.optional.queryOptions({ input: undefined }) +// @ts-expect-error Unknown declarations require an input field +broadPayloadUtils.payload.unknown.queryOptions() +// @ts-expect-error Any declarations require an input field +broadPayloadUtils.payload.any.queryOptions() +// @ts-expect-error void-containing declarations require an input field +broadPayloadUtils.payload.optional.queryOptions() +// @ts-expect-error Unknown declarations require constructor input for keys +broadPayloadUtils.payload.unknown.queryKey() +// @ts-expect-error Any declarations require constructor input for keys +broadPayloadUtils.payload.any.queryKey() +// @ts-expect-error void-containing unions retain their declared constructor type +broadPayloadUtils.payload.optional.queryKey(1) + +const unknownMutation = broadPayloadUtils.payload.unknown.mutationOptions({ + onMutate: (input) => { + true satisfies Assert> + return 'unknown' + }, +}) +const anyMutation = broadPayloadUtils.payload.any.mutationOptions({ + onMutate: (input) => { + true satisfies Assert> + return 'any' + }, +}) +const optionalMutation = broadPayloadUtils.payload.optional.mutationOptions({ + onMutate: (input) => { + true satisfies Assert> + return 'optional' + }, +}) +unknownMutation.mutationFn({ id: 1 }) satisfies Promise +anyMutation.mutationFn({ id: 1 }) satisfies Promise +optionalMutation.mutationFn('input') satisfies Promise +optionalMutation.mutationFn(undefined) satisfies Promise +// @ts-expect-error void-containing mutation variables retain their constructor type +optionalMutation.mutationFn(1) + +const broadUnknownPages = broadPayloadUtils.payload.unknown.infiniteOptions({ + input: (page: number) => ({ page }), + initialPageParam: 0, + getNextPageParam: () => undefined, +}) +const broadAnyPages = broadPayloadUtils.payload.any.infiniteOptions({ + input: (page: number) => ({ page }), + initialPageParam: 0, + getNextPageParam: () => undefined, +}) +const broadOptionalPages = broadPayloadUtils.payload.optional.infiniteOptions({ + input: (page: number) => String(page), + initialPageParam: 0, + getNextPageParam: () => undefined, +}) +queryClient.infiniteQuery(broadUnknownPages) satisfies Promise> +queryClient.infiniteQuery(broadAnyPages) satisfies Promise> +queryClient.infiniteQuery(broadOptionalPages) satisfies Promise> +broadPayloadUtils.payload.unknown.infiniteKey({ page: 0 }) satisfies readonly [ + 'payload', + 'rpc', + 'payload', + 'unknown', + 'infinite', + JsonValue, +] +broadPayloadUtils.payload.any.infiniteKey({ page: 0 }) satisfies readonly [ + 'payload', + 'rpc', + 'payload', + 'any', + 'infinite', + JsonValue, +] +broadPayloadUtils.payload.optional.infiniteKey('0') satisfies readonly [ + 'payload', + 'rpc', + 'payload', + 'optional', + 'infinite', + JsonValue, +] +// @ts-expect-error Unknown infinite queries require a page-input mapper +broadPayloadUtils.payload.unknown.infiniteOptions({ + initialPageParam: 0, + getNextPageParam: () => undefined, +}) +// @ts-expect-error Any infinite queries require a page-input mapper +broadPayloadUtils.payload.any.infiniteOptions({ + initialPageParam: 0, + getNextPageParam: () => undefined, +}) +// @ts-expect-error void-containing infinite queries require a page-input mapper +broadPayloadUtils.payload.optional.infiniteOptions({ + initialPageParam: 0, + getNextPageParam: () => undefined, +}) + +const unknownStreamedKey = broadPayloadUtils.payload.unknownWatch.streamedKey({ id: 1 }) +const anyStreamedKey = broadPayloadUtils.payload.anyWatch.streamedKey({ id: 1 }) +const optionalStreamedKey = broadPayloadUtils.payload.optionalWatch.streamedKey('input') +const unknownLiveKey = broadPayloadUtils.payload.unknownWatch.liveKey({ id: 1 }) +const anyLiveKey = broadPayloadUtils.payload.anyWatch.liveKey({ id: 1 }) +const broadOptionalLiveKey = broadPayloadUtils.payload.optionalWatch.liveKey('input') +unknownStreamedKey satisfies readonly [ + 'payload', + 'rpc', + 'payload', + 'unknownWatch', + 'streamed', + JsonValue, +] +anyStreamedKey satisfies readonly ['payload', 'rpc', 'payload', 'anyWatch', 'streamed', JsonValue] +optionalStreamedKey satisfies readonly [ + 'payload', + 'rpc', + 'payload', + 'optionalWatch', + 'streamed', + JsonValue, +] +unknownLiveKey satisfies readonly ['payload', 'rpc', 'payload', 'unknownWatch', 'live', JsonValue] +anyLiveKey satisfies readonly ['payload', 'rpc', 'payload', 'anyWatch', 'live', JsonValue] +broadOptionalLiveKey satisfies readonly [ + 'payload', + 'rpc', + 'payload', + 'optionalWatch', + 'live', + JsonValue, +] +queryClient.getQueryData(unknownStreamedKey) satisfies readonly string[] | undefined +queryClient.getQueryData(anyStreamedKey) satisfies readonly string[] | undefined +queryClient.getQueryData(optionalStreamedKey) satisfies readonly string[] | undefined +queryClient.getQueryData(unknownLiveKey) satisfies string | undefined +queryClient.getQueryData(anyLiveKey) satisfies string | undefined +queryClient.getQueryData(broadOptionalLiveKey) satisfies string | undefined +broadPayloadUtils.payload.unknownWatch.streamedOptions({ input: { id: 1 } }) +broadPayloadUtils.payload.anyWatch.streamedOptions({ input: { id: 1 } }) +broadPayloadUtils.payload.optionalWatch.streamedOptions({ input: undefined }) +broadPayloadUtils.payload.unknownWatch.liveOptions({ input: { id: 1 } }) +broadPayloadUtils.payload.anyWatch.liveOptions({ input: { id: 1 } }) +broadPayloadUtils.payload.optionalWatch.liveOptions({ input: undefined }) +// @ts-expect-error Unknown accumulated queries require an input field +broadPayloadUtils.payload.unknownWatch.streamedOptions() +// @ts-expect-error Any accumulated queries require an input field +broadPayloadUtils.payload.anyWatch.streamedOptions() +// @ts-expect-error void-containing accumulated queries require an input field +broadPayloadUtils.payload.optionalWatch.streamedOptions() +// @ts-expect-error Unknown live queries require an input field +broadPayloadUtils.payload.unknownWatch.liveOptions() +// @ts-expect-error Any live queries require an input field +broadPayloadUtils.payload.anyWatch.liveOptions() +// @ts-expect-error void-containing live queries require an input field +broadPayloadUtils.payload.optionalWatch.liveOptions() + +const unknownSkipped = broadPayloadUtils.payload.unknown.queryOptions({ input: skipToken }) +const anySkipped = broadPayloadUtils.payload.any.queryOptions({ input: skipToken }) +const optionalSkipped = broadPayloadUtils.payload.optional.queryOptions({ input: skipToken }) +const unknownStreamSkipped = broadPayloadUtils.payload.unknownWatch.streamedOptions({ + input: skipToken, +}) +const anyStreamSkipped = broadPayloadUtils.payload.anyWatch.streamedOptions({ input: skipToken }) +const optionalStreamSkipped = broadPayloadUtils.payload.optionalWatch.streamedOptions({ + input: skipToken, +}) +const unknownLiveSkipped = broadPayloadUtils.payload.unknownWatch.liveOptions({ input: skipToken }) +const anyLiveSkipped = broadPayloadUtils.payload.anyWatch.liveOptions({ input: skipToken }) +const optionalLiveSkipped = broadPayloadUtils.payload.optionalWatch.liveOptions({ + input: skipToken, +}) +true satisfies Assert> +true satisfies Assert> +true satisfies Assert> +true satisfies Assert> +true satisfies Assert> +true satisfies Assert> +true satisfies Assert> +true satisfies Assert> +true satisfies Assert> + +declare const broadUnknownInput: unknown +declare const broadAnyInput: any +const broadUnknownOptions = broadPayloadUtils.payload.unknown.queryOptions({ + input: broadUnknownInput, +}) +const broadAnyOptions = broadPayloadUtils.payload.any.queryOptions({ input: broadAnyInput }) +true satisfies Assert< + Equal< + typeof broadUnknownOptions.queryFn, + QueryFunction | SkipToken + > +> +true satisfies Assert< + Equal< + typeof broadAnyOptions.queryFn, + QueryFunction | SkipToken + > +> + +broadPayloadUtils.payload.void.queryKey() satisfies readonly [ + 'payload', + 'rpc', + 'payload', + 'void', + 'query', +] +broadPayloadUtils.payload.void.infiniteKey() satisfies readonly [ + 'payload', + 'rpc', + 'payload', + 'void', + 'infinite', +] +broadPayloadUtils.payload.void.queryOptions() +broadPayloadUtils.payload.void.infiniteOptions({ + initialPageParam: 0, + getNextPageParam: () => undefined, +}) +broadPayloadUtils.payload.void.mutationOptions().mutationFn() satisfies Promise +broadPayloadUtils.payload.voidWatch.streamedKey() satisfies readonly [ + 'payload', + 'rpc', + 'payload', + 'voidWatch', + 'streamed', +] +broadPayloadUtils.payload.voidWatch.liveKey() satisfies readonly [ + 'payload', + 'rpc', + 'payload', + 'voidWatch', + 'live', +] +broadPayloadUtils.payload.voidWatch.streamedOptions() +broadPayloadUtils.payload.voidWatch.liveOptions() +// @ts-expect-error Void query declarations omit the input field +broadPayloadUtils.payload.void.queryOptions({ input: undefined }) +// @ts-expect-error Void accumulated declarations omit the input field +broadPayloadUtils.payload.voidWatch.streamedOptions({ input: undefined }) +// @ts-expect-error Void live declarations omit the input field +broadPayloadUtils.payload.voidWatch.liveOptions({ input: undefined }) From 06c175a582e7acd69a9388824b18b5ee9392a0b1 Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Fri, 9 Oct 2026 22:19:45 +0200 Subject: [PATCH 15/97] docs: defer Changesets until initial publication --- docs/adr/0016-automate-releases-with-changesets.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/adr/0016-automate-releases-with-changesets.md b/docs/adr/0016-automate-releases-with-changesets.md index a59be82..816c8c7 100644 --- a/docs/adr/0016-automate-releases-with-changesets.md +++ b/docs/adr/0016-automate-releases-with-changesets.md @@ -14,8 +14,8 @@ commit links. Private examples remain outside versioning, and releases run only ## Consequences -While the package is unpublished at `0.0.0`, changes accumulate without Changesets. Prepare a single -initial release candidate when publication is authorized. +Before initial publication, changes accumulate without Changesets. Prepare a single initial release +candidate when publication is authorized. After initial publication, changes to published behavior, types, exports, dependency ranges, or installation metadata require a non-empty changeset. Documentation, examples, tests, internal From 6ca5957f449970daa999f88be91ff31655cea742 Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Fri, 9 Oct 2026 22:20:08 +0200 Subject: [PATCH 16/97] fix: respect QueryClient hashing defaults --- .fallowrc.jsonc | 6 +- .../content/docs/concepts/semantic-keys.md | 5 +- .../src/content/docs/guides/react-query.md | 7 +- .../docs/src/content/docs/reference/errors.md | 2 + .../docs/reference/generated-builders.md | 14 +- .../content/docs/reference/http-factory.md | 6 +- ...-shaped-options-and-certify-react-first.md | 6 +- scripts/verify-packed-consumer.mts | 2 +- skills/effect-api-query/SKILL.md | 6 +- src/core/operation.ts | 1 + src/core/types.ts | 3 - src/core/utility-tree.ts | 25 +- src/http/errors.ts | 1 + src/http/operation.ts | 6 + src/rpc/errors.ts | 1 + src/rpc/operation.ts | 6 + .../create-rpc-query-utils-execution.test.ts | 2 +- .../create-rpc-query-utils-streaming.test.ts | 4 +- tests/create-rpc-query-utils.test.ts | 10 +- tests/http-query-options.test.ts | 11 +- tests/packed-consumer/hashing-runtime.mts | 260 ++++++++++++++++++ tests/packed-consumer/tsconfig.json | 1 + tests/types/http-contract.ts | 23 +- tests/types/public-contract.ts | 44 ++- 24 files changed, 383 insertions(+), 69 deletions(-) create mode 100644 tests/packed-consumer/hashing-runtime.mts diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 477875d..d26db8d 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -3,7 +3,11 @@ // Astro copies this browser entry from public/ and loads it through the // Starlight head configuration, which static import analysis cannot follow. - "entry": ["apps/docs/public/package-version.js", "playwright.docs.config.ts"], + "entry": [ + "apps/docs/public/package-version.js", + "playwright.docs.config.ts", + "tests/packed-consumer/hashing-runtime.mts" + ], "ignorePatterns": ["examples/tanstack-start/src/routeTree.gen.ts"], diff --git a/apps/docs/src/content/docs/concepts/semantic-keys.md b/apps/docs/src/content/docs/concepts/semantic-keys.md index f6305cc..c672672 100644 --- a/apps/docs/src/content/docs/concepts/semantic-keys.md +++ b/apps/docs/src/content/docs/concepts/semantic-keys.md @@ -21,8 +21,9 @@ The default process is: 3. Canonicalize strict JSON by sorting object keys recursively. 4. Store the canonical key payload in a flat, prefix-matchable query key. -The generated `queryKeyHashFn` serializes the already-canonical key so TanStack Query uses the same -identity as the key builders. +QueryClient owns hashing through its global and prefix defaults. Generated options omit hash +fields so fetching, stream publications, and native `getQueryData`/`setQueryData` calls address the +same cache entry. Builders reject per-call `queryKeyHashFn` and `queryHash` overrides. Key objects cannot contain `__proto__` or `constructor` properties at any depth. This applies to prefixes, Schema output, and custom encoder output. diff --git a/apps/docs/src/content/docs/guides/react-query.md b/apps/docs/src/content/docs/guides/react-query.md index a79d954..58a0d27 100644 --- a/apps/docs/src/content/docs/guides/react-query.md +++ b/apps/docs/src/content/docs/guides/react-query.md @@ -56,9 +56,10 @@ const events = useQuery(rpcQuery.events.watch.streamedOptions()) const latestEvent = useQuery(rpcQuery.events.watch.liveOptions()) ``` -The package owns each generated `queryFn`, `queryKey`, `queryKeyHashFn`, `mutationFn`, and -`mutationKey`. Stream builders own the same query fields. Other TanStack Query options, including `select`, -`retry`, and lifecycle callbacks, retain their normal TanStack types. +The package owns each generated `queryFn`, `queryKey`, `mutationFn`, and `mutationKey`. +Stream builders own the same query fields. Configure custom hashing through QueryClient global +or prefix defaults; builders reject per-call `queryKeyHashFn` and `queryHash`. Other TanStack Query +options, including `select`, `retry`, and lifecycle callbacks, retain their normal TanStack types. ## Use an HTTP endpoint diff --git a/apps/docs/src/content/docs/reference/errors.md b/apps/docs/src/content/docs/reference/errors.md index fc56f86..e2d01c9 100644 --- a/apps/docs/src/content/docs/reference/errors.md +++ b/apps/docs/src/content/docs/reference/errors.md @@ -13,6 +13,7 @@ Thrown synchronously while configuring the utility tree or an option builder. It - `RpcPathCollision` - `MissingKeyEncoder` - `UnknownKeyEncoder` +- `UnsupportedQueryHash`: an option builder received `queryKeyHashFn` or `queryHash`. It can also expose `rpcTag`, `path`, and an underlying `cause`. @@ -52,6 +53,7 @@ Thrown synchronously while configuring HTTP utilities. Its `code` is one of: - `MissingKeyEncoder` - `UnknownKeyEncoder` - `UnsupportedEndpointMetadata` +- `UnsupportedQueryHash`: an option builder received `queryKeyHashFn` or `queryHash`. It exposes `apiId` and, when available, `groupId`, `endpoint`, `method`, `path`, and an underlying `cause`. diff --git a/apps/docs/src/content/docs/reference/generated-builders.md b/apps/docs/src/content/docs/reference/generated-builders.md index 70283b5..91c12e5 100644 --- a/apps/docs/src/content/docs/reference/generated-builders.md +++ b/apps/docs/src/content/docs/reference/generated-builders.md @@ -10,7 +10,7 @@ Each unary RPC leaf also exposes: | Builder | Result | | --------------------------- | ------------------------------------------------------------------------------------------------------------ | | `queryKey(input?)` | Payload-specific, data-tagged query key. Payload-bearing RPCs require constructor input. | -| `queryOptions(options?)` | Fresh Query Core options with generated `queryFn`, `queryKey`, and `queryKeyHashFn`. | +| `queryOptions(options?)` | Fresh Query Core options with generated `queryFn` and `queryKey`. | | `infiniteKey(input?)` | Data-tagged infinite-query key derived from the initial page's payload. | | `infiniteOptions(options)` | Fresh infinite-query options that map each `pageParam` to an RPC payload. | | `mutationKey()` | Immutable operation key shared by mutations of this RPC. | @@ -37,7 +37,7 @@ const options = rpcQuery.users.get.queryOptions({ `queryOptions`, `streamedOptions`, and `liveOptions` also accept the direct `skipToken` shorthand. The object form preserves applicable caller options. The builder consumes `input` and, for accumulated streams, `refetchMode` and `maxChunks`, removing them from the returned options. -Skipped options retain the exact sentinel, operation-level key, and package-owned hash function. +Skipped options retain the exact sentinel and operation-level key. `queryOptions`, `streamedOptions`, and `liveOptions` accept conditional `input` unions of payload constructor input and `skipToken`. Concrete inputs retain callable query functions and @@ -46,8 +46,8 @@ payload-specific keys. Conditional inputs retain the possible sentinel and both `skipToken` is valid only for payload-bearing query options. It is not accepted by key or mutation builders, and skipped options are unsuitable for suspense and prefetch-only hooks. -Generated query options also reserve `queryHash`: a caller-supplied hash cannot override generated -cache identity. +Configure custom hashing through QueryClient global or prefix defaults. Builders reject per-call +`queryKeyHashFn` and `queryHash` with `EffectRpcQueryConfigError` code `UnsupportedQueryHash`. ## Request-local RPC options @@ -129,9 +129,9 @@ A defined `initialData` value or factory remains required in the generated optio including conditional and skipped inputs. A factory that may return `undefined` keeps hook data possibly undefined. -The builder forwards applicable Query Core options but owns `queryFn`, `queryKey`, and -`queryKeyHashFn`. Each page uses the same Effect runner and cancellation signal as an ordinary -query. +The builder forwards applicable Query Core options and owns `queryFn` and `queryKey`. +QueryClient defaults own hashing. Each page uses the same Effect runner and cancellation signal +as an ordinary query. ## Stream values diff --git a/apps/docs/src/content/docs/reference/http-factory.md b/apps/docs/src/content/docs/reference/http-factory.md index 7578678..fb9c946 100644 --- a/apps/docs/src/content/docs/reference/http-factory.md +++ b/apps/docs/src/content/docs/reference/http-factory.md @@ -77,8 +77,10 @@ observer data; data-tagged keys retain the underlying cache data and error types `initialData`, whether defined or possibly undefined, preserves the corresponding TanStack hook overload. -Applicable TanStack options pass through. The package owns the key, function, and query hash -fields, including the precomputed `queryHash`. It removes `input` before returning options. +Applicable TanStack options pass through. The package owns the key and function and removes +`input` before returning options. QueryClient global and prefix defaults own hashing. Builders +reject per-call `queryKeyHashFn` and `queryHash` with `EffectHttpApiQueryConfigError` code +`UnsupportedQueryHash`. Input-bearing `queryOptions` accepts a complete request, `queryOptions(skipToken)`, or `queryOptions({ input: skipToken, ...options })`. Import `skipToken` from `effect-api-query` or diff --git a/docs/adr/0004-build-core-shaped-options-and-certify-react-first.md b/docs/adr/0004-build-core-shaped-options-and-certify-react-first.md index d56c5d5..8ef5d93 100644 --- a/docs/adr/0004-build-core-shaped-options-and-certify-react-first.md +++ b/docs/adr/0004-build-core-shaped-options-and-certify-react-first.md @@ -13,8 +13,10 @@ Generated options use static TanStack Query Core shapes. They pass unchanged to `QueryClient`-driven TanStack Router loaders, and TanStack Start. The package imports no framework adapter and certifies React Query and TanStack Start first. -The library owns generated keys, functions, and each query's `queryKeyHashFn`. Callers may provide -every other applicable Query Core option. +The library owns generated keys and functions. QueryClient global and prefix defaults own hashing +so generated fetches, stream publications, and native cache reads and writes share one entry. +Builders reject per-call hashing overrides because native key-only cache access cannot honor them. +Callers may provide every other applicable Query Core option. ## Consequences diff --git a/scripts/verify-packed-consumer.mts b/scripts/verify-packed-consumer.mts index f9f7039..39fb8fb 100644 --- a/scripts/verify-packed-consumer.mts +++ b/scripts/verify-packed-consumer.mts @@ -502,7 +502,7 @@ const verifyConsumer = (peer: (typeof peerCases)[number]): void => { ) } - for (const fixture of ['runtime.mts', 'http-runtime.mts']) { + for (const fixture of ['hashing-runtime.mts', 'runtime.mts', 'http-runtime.mts']) { execFileSync(process.execPath, ['--experimental-import-meta-resolve', fixture], { cwd: consumerDirectory, stdio: 'inherit', diff --git a/skills/effect-api-query/SKILL.md b/skills/effect-api-query/SKILL.md index b03d9a0..a4ac585 100644 --- a/skills/effect-api-query/SKILL.md +++ b/skills/effect-api-query/SKILL.md @@ -63,9 +63,9 @@ pagination, streaming, React Query, or server rendering. backend identifiers when they distinguish results. The factory cannot infer identity from a client, URL, middleware, or RPC request options. - Pass caller options such as `staleTime`, `select`, and callbacks into the - builder. Preserve its `queryFn`, `queryKey`, and `queryKeyHashFn`; the builder - removes caller-supplied `queryHash`. Mutation builders own `mutationFn` and - `mutationKey`. + builder. Preserve its `queryFn` and `queryKey`. Configure custom hashing through + QueryClient global or prefix defaults; builders reject per-call `queryKeyHashFn` + and `queryHash`. Mutation builders own `mutationFn` and `mutationKey`. - Treat captured inputs as immutable and build new options when they change. Keys are frozen snapshots; request inputs are not necessarily copied or frozen, so later mutation can make execution disagree with its key. diff --git a/src/core/operation.ts b/src/core/operation.ts index affd70e..b2accb0 100644 --- a/src/core/operation.ts +++ b/src/core/operation.ts @@ -23,6 +23,7 @@ export type OperationInput = export interface OperationIdentity { readonly id: string readonly path: readonly string[] + readonly unsupportedQueryHash: (option: 'queryKeyHashFn' | 'queryHash') => Error /** Removes adapter-owned fields from the fresh options copy. */ readonly takeOptions: (options: Record) => unknown } diff --git a/src/core/types.ts b/src/core/types.ts index 0d94ac7..cc8bdc1 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -7,7 +7,6 @@ import type { NonUndefinedGuard, QueryFunction, QueryKey, - QueryKeyHashFunction, QueryObserverOptions, SkipToken, } from '@tanstack/query-core' @@ -61,7 +60,6 @@ export type QueryOptions< > = QueryInput & { readonly queryFn: Fn readonly queryKey: Key - readonly queryKeyHashFn: QueryKeyHashFunction } export type ConditionalQueryOptions = QueryOptions< @@ -162,7 +160,6 @@ export type InfiniteOptions< > = InfiniteInput & { readonly queryFn: Fn readonly queryKey: Key - readonly queryKeyHashFn: QueryKeyHashFunction } /** Tags concrete infinite keys with their cached pages and inferred page parameter. */ diff --git a/src/core/utility-tree.ts b/src/core/utility-tree.ts index 048663f..660c4ab 100644 --- a/src/core/utility-tree.ts +++ b/src/core/utility-tree.ts @@ -1,5 +1,5 @@ import { skipToken } from '@tanstack/query-core' -import type { QueryFunction, QueryKey } from '@tanstack/query-core' +import type { QueryFunction } from '@tanstack/query-core' import { Effect, Exit, Predicate } from 'effect' import type { Cause } from 'effect' @@ -110,8 +110,6 @@ const canonicalize = (value: unknown, seen = new WeakSet()): JsonValue => { const freezeKey = (parts: readonly (JsonValue | string)[]) => Object.freeze([...parts]) -const hashCanonicalKey = (queryKey: QueryKey): string => JSON.stringify(queryKey) - const normalizePrefix = ( prefix: readonly [JsonValue, ...JsonValue[]], errors: TreeErrors, @@ -232,6 +230,11 @@ const prepareQueryOptions = (description: OperationDescription, argument: unknow argument === skipToken ? { input: skipToken } : { ...(Predicate.isObject(argument) ? argument : undefined) } + for (const option of ['queryKeyHashFn', 'queryHash'] as const) { + if (Object.hasOwn(options, option)) { + throw description.unsupportedQueryHash(option) + } + } const { input } = options delete options['input'] const requestOptions = description.takeOptions(options) @@ -242,17 +245,11 @@ const finalizeQueryOptions = ( options: Record, queryKey: readonly JsonValue[], queryFn: QueryFn, -) => { - // Query Core prefers an explicit hash over the generated hash function. - delete options['queryHash'] - return { - ...options, - // Owned fields follow user options so callers cannot replace keys or runners. - queryFn, - queryKey, - queryKeyHashFn: hashCanonicalKey, - } -} +) => ({ + ...options, + queryFn, + queryKey, +}) const createQueryBuilders = ( description: UnaryOperation | StreamingOperation, diff --git a/src/http/errors.ts b/src/http/errors.ts index 49ea267..730f1d0 100644 --- a/src/http/errors.ts +++ b/src/http/errors.ts @@ -19,6 +19,7 @@ export type EffectHttpApiQueryConfigErrorCode = | 'MissingKeyEncoder' | 'UnknownKeyEncoder' | 'UnsupportedEndpointMetadata' + | 'UnsupportedQueryHash' /** Stable codes for synchronous HTTP key failures. */ export type EffectHttpApiQueryKeyErrorCode = diff --git a/src/http/operation.ts b/src/http/operation.ts index 82ca3d3..91cbf9b 100644 --- a/src/http/operation.ts +++ b/src/http/operation.ts @@ -49,6 +49,12 @@ const extractHttpEndpoints = (api: HttpApi.Top, client: unknown): readonly HttpO identity, id: JSON.stringify([group.identifier, endpoint.identifier]), path: group.topLevel ? [endpoint.identifier] : [group.identifier, endpoint.identifier], + unsupportedQueryHash: (option) => + new EffectHttpApiQueryConfigError( + 'UnsupportedQueryHash', + `${option} must be configured through QueryClient defaults`, + identity, + ), takeOptions: () => { // HTTP has no adapter-owned options. }, diff --git a/src/rpc/errors.ts b/src/rpc/errors.ts index 5a3d337..d1b1357 100644 --- a/src/rpc/errors.ts +++ b/src/rpc/errors.ts @@ -8,6 +8,7 @@ export type EffectRpcQueryConfigErrorCode = | 'RpcPathCollision' | 'MissingKeyEncoder' | 'UnknownKeyEncoder' + | 'UnsupportedQueryHash' /** Stable codes for synchronous query-key preparation failures. */ export type EffectRpcQueryKeyErrorCode = diff --git a/src/rpc/operation.ts b/src/rpc/operation.ts index 840f537..8b73731 100644 --- a/src/rpc/operation.ts +++ b/src/rpc/operation.ts @@ -69,6 +69,12 @@ const describeRpc = ( id: rpcTag, path: rpcTag.split('.'), input: createRpcInput(definition), + unsupportedQueryHash: (option: 'queryKeyHashFn' | 'queryHash') => + new EffectRpcQueryConfigError( + 'UnsupportedQueryHash', + `${option} must be configured through QueryClient defaults`, + { rpcTag }, + ), takeOptions: takeRpcOptions, } if (!RpcSchema.isStreamSchema(definition.successSchema)) { diff --git a/tests/create-rpc-query-utils-execution.test.ts b/tests/create-rpc-query-utils-execution.test.ts index f5e05cb..c784388 100644 --- a/tests/create-rpc-query-utils-execution.test.ts +++ b/tests/create-rpc-query-utils-execution.test.ts @@ -261,7 +261,7 @@ describe('createRpcQueryUtils execution boundaries', () => { }) const options = utils.health.ping.queryOptions() - expect(Object.keys(options).sort()).toStrictEqual(['queryFn', 'queryKey', 'queryKeyHashFn']) + expect(Object.keys(options).sort()).toStrictEqual(['queryFn', 'queryKey']) expect(yield* Effect.promise(async () => await new QueryClient().query(options))).toBeNull() }), ) diff --git a/tests/create-rpc-query-utils-streaming.test.ts b/tests/create-rpc-query-utils-streaming.test.ts index d98dca5..349b7ad 100644 --- a/tests/create-rpc-query-utils-streaming.test.ts +++ b/tests/create-rpc-query-utils-streaming.test.ts @@ -639,9 +639,7 @@ describe('createRpcQueryUtils streaming execution', () => { }) expect(options).not.toHaveProperty('input') expect(options).not.toHaveProperty('refetchMode') - expect(options.queryKeyHashFn).toBe( - utils.events.watch.liveOptions(skipToken).queryKeyHashFn, - ) + expect(options).not.toHaveProperty('queryKeyHashFn') const unsubscribe = observer.subscribe(() => {}) yield* Effect.promise(async () => { await queryClient.invalidateQueries({ queryKey: utils.events.key() }) diff --git a/tests/create-rpc-query-utils.test.ts b/tests/create-rpc-query-utils.test.ts index eece78d..fa1fbe9 100644 --- a/tests/create-rpc-query-utils.test.ts +++ b/tests/create-rpc-query-utils.test.ts @@ -45,7 +45,6 @@ describe(createRpcQueryUtils, () => { retry: false, queryFn: queryCoreSkipToken, queryKey: ['app', 'rpc', 'users', 'get', 'query'], - queryKeyHashFn: utils.users.get.queryOptions(skipToken).queryKeyHashFn, }) const queryClient = new QueryClient() const observer = new QueryObserver(queryClient, options) @@ -101,7 +100,7 @@ describe(createRpcQueryUtils, () => { expect(Object.isFrozen(utils.users.get.queryKey({ id: 1 }))).toBe(true) expect(Object.isFrozen(utils.users.get.queryKey({ id: 1 }).at(-1))).toBe(true) const options = utils.users.get.queryOptions({ input: { id: 1 } }) - expect(options.queryKeyHashFn(options.queryKey)).toBe(JSON.stringify(options.queryKey)) + expect(options).not.toHaveProperty('queryKeyHashFn') }), ) @@ -161,7 +160,7 @@ describe(createRpcQueryUtils, () => { queryFn: queryCoreSkipToken, queryKey: ['app', 'rpc', 'users', 'get', 'query'], }) - expect(skipped.queryKeyHashFn(skipped.queryKey)).toBe(JSON.stringify(skipped.queryKey)) + expect(skipped).not.toHaveProperty('queryKeyHashFn') const infiniteCallerOptions = { getNextPageParam: (): undefined => {}, @@ -179,11 +178,8 @@ describe(createRpcQueryUtils, () => { ...infiniteCallerOptions, queryFn: queryCoreSkipToken, queryKey: ['app', 'rpc', 'users', 'get', 'infinite'], - queryKeyHashFn: skipped.queryKeyHashFn, }) - expect(skippedInfinite.queryKeyHashFn(skippedInfinite.queryKey)).toBe( - JSON.stringify(skippedInfinite.queryKey), - ) + expect(skippedInfinite).not.toHaveProperty('queryKeyHashFn') }), ) diff --git a/tests/http-query-options.test.ts b/tests/http-query-options.test.ts index 37456f6..0736b43 100644 --- a/tests/http-query-options.test.ts +++ b/tests/http-query-options.test.ts @@ -47,16 +47,12 @@ describe('HTTP query options', () => { ) const utils = createHttpApiQueryUtils(Api, { client, keyPrefix: ['test'] }) const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) - // SAFETY: Extra untyped caller fields deliberately verify that generated options retain ownership of query identity. - /* oxlint-disable anti-slop/no-known-value-widening */ const options = utils.users.list.infiniteOptions({ initialPageParam: 0, input: (cursor) => ({ query: { cursor, filter: 'active' } }), getNextPageParam: (lastPage) => lastPage.next, staleTime: Infinity, - ...({ queryHash: 'collision' } as object), }) - /* oxlint-enable anti-slop/no-known-value-widening */ const observer = new InfiniteQueryObserver(queryClient, options) const unsubscribe = observer.subscribe(() => {}) try { @@ -69,18 +65,14 @@ describe('HTTP query options', () => { ], pageParams: [0, 1], }) - // SAFETY: Extra untyped caller fields deliberately verify that generated options retain ownership of query identity. - /* oxlint-disable anti-slop/no-known-value-widening */ await expect( queryClient.query( utils.users.list.queryOptions({ input: { query: { cursor: 42, filter: 'active' } }, staleTime: Infinity, - ...({ queryHash: 'collision' } as object), }), ), ).resolves.toStrictEqual({ items: ['active:42'], next: null }) - /* oxlint-enable anti-slop/no-known-value-widening */ expect(options.queryKey).toStrictEqual([ 'test', 'http', @@ -271,12 +263,11 @@ describe('HTTP query options', () => { ...({ queryKey: ['replacement'], queryFn: () => 'replacement', - queryKeyHashFn: () => 'replacement', } as object), }) /* oxlint-enable anti-slop/no-known-value-widening */ expect(options.queryKey).toStrictEqual(utils.refresh.infiniteKey()) - expect(options.queryKeyHashFn(options.queryKey)).toBe(JSON.stringify(options.queryKey)) + expect(options).not.toHaveProperty('queryKeyHashFn') expect(options).toMatchObject({ staleTime: Infinity, meta: { label: 'refresh' } }) await expect(queryClient.infiniteQuery({ ...options, pages: 2 })).resolves.toStrictEqual({ pages: [null, null], diff --git a/tests/packed-consumer/hashing-runtime.mts b/tests/packed-consumer/hashing-runtime.mts new file mode 100644 index 0000000..b73e496 --- /dev/null +++ b/tests/packed-consumer/hashing-runtime.mts @@ -0,0 +1,260 @@ +import { QueryClient, QueryObserver } from '@tanstack/query-core' +import type { + InfiniteData, + InfiniteQueryObserverOptions, + QueryKey, + QueryObserverOptions, +} from '@tanstack/query-core' +import { Effect, Layer, Schema, Stream } from 'effect' +import { + EffectHttpApiQueryConfigError, + EffectRpcQueryConfigError, + createHttpApiQueryUtils, + createRpcQueryUtils, + skipToken, +} from 'effect-api-query' +import { HttpServer } from 'effect/http' +import { + HttpApi, + HttpApiBuilder, + HttpApiEndpoint, + HttpApiGroup, + HttpApiTest, +} from 'effect/http-api' +import { Rpc, RpcGroup, RpcTest } from 'effect/rpc' +import { deepStrictEqual, equal, ok, throws } from 'node:assert/strict' + +const group = RpcGroup.make( + Rpc.make('read', { payload: { id: Schema.Int }, success: Schema.String }), + Rpc.make('page', { payload: { cursor: Schema.Int }, success: Schema.Int }), + Rpc.make('watch', { payload: { id: Schema.Int }, success: Schema.String, stream: true }), +) +const api = HttpApi.make('hashing-api').add( + HttpApiGroup.make('items').add( + HttpApiEndpoint.get('read', '/items/:id', { + params: { id: Schema.FiniteFromString }, + success: Schema.String, + }), + HttpApiEndpoint.get('page', '/pages', { + query: { cursor: Schema.FiniteFromString }, + success: Schema.Int, + }), + ), +) +const httpHandlers = HttpApiBuilder.group(api, 'items', (handlers) => + handlers + .handle('read', ({ params }) => Effect.succeed(`http:${params.id}`)) + .handle('page', ({ query }) => Effect.succeed(query.cursor)), +) + +const assertInfinitePages = async ( + queryClient: QueryClient, + options: InfiniteQueryObserverOptions, Key, number>, +) => { + deepStrictEqual(await queryClient.infiniteQuery({ ...options, pages: 2 }), { + pages: [0, 1], + pageParams: [0, 1], + }) + deepStrictEqual(queryClient.getQueryData(options.queryKey), { + pages: [0, 1], + pageParams: [0, 1], + }) + queryClient.setQueryData>(options.queryKey, { + pages: [7], + pageParams: [0], + }) + deepStrictEqual(await queryClient.infiniteQuery(options), { + pages: [7], + pageParams: [0], + }) + ok(Object.isFrozen(options.queryKey)) +} + +const assertStreamSnapshot = async ( + queryClient: QueryClient, + options: QueryObserverOptions, + expected: Data, + updated: Data, +) => { + const observer = new QueryObserver(queryClient, options) + let unsubscribe = () => {} + let timeout: ReturnType | undefined + try { + const first = await new Promise<{ readonly data: Data; readonly fetchStatus: string }>( + (resolve, reject) => { + timeout = setTimeout(() => reject(new Error('First emission timed out')), 10_000) + unsubscribe = observer.subscribe((result) => { + if (result.status === 'success') resolve(result) + else if (result.status === 'error') reject(result.error) + }) + }, + ) + deepStrictEqual(first.data, expected) + equal(first.fetchStatus, 'fetching') + deepStrictEqual(queryClient.getQueryData(options.queryKey), expected) + queryClient.setQueryData(options.queryKey, updated) + deepStrictEqual(observer.getCurrentResult().data, updated) + deepStrictEqual(queryClient.getQueryData(options.queryKey), updated) + equal( + queryClient.getQueryCache().findAll({ queryKey: options.queryKey, exact: true }).length, + 1, + ) + ok(Object.isFrozen(options.queryKey)) + } finally { + clearTimeout(timeout) + await queryClient.cancelQueries({ queryKey: options.queryKey }) + unsubscribe() + observer.destroy() + } +} + +await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const client = yield* RpcTest.makeClient(group, { flatten: true }).pipe( + Effect.provide( + group.toLayer({ + read: ({ id }) => Effect.succeed(`rpc:${id}`), + page: ({ cursor }) => Effect.succeed(cursor), + watch: () => Stream.succeed('first').pipe(Stream.concat(Stream.never)), + }), + ), + ) + const utils = createRpcQueryUtils(group, { client, keyPrefix: ['hashing'] }) + const httpClient = yield* HttpApiTest.groups(api, ['items']).pipe( + Effect.provide(Layer.mergeAll(httpHandlers, HttpServer.layerServices)), + ) + const http = createHttpApiQueryUtils(api, { client: httpClient, keyPrefix: ['hashing'] }) + for (const [field, value] of [ + ['queryKeyHashFn', () => 'caller'], + ['queryHash', 'caller'], + ['queryKeyHashFn', undefined], + ['queryHash', undefined], + ] as const) { + const rpcError = (error: unknown) => { + ok(error instanceof EffectRpcQueryConfigError) + equal(error.code, 'UnsupportedQueryHash') + ok(error.rpcTag === 'read' || error.rpcTag === 'page' || error.rpcTag === 'watch') + return true + } + const httpError = (error: unknown) => { + ok(error instanceof EffectHttpApiQueryConfigError) + equal(error.code, 'UnsupportedQueryHash') + equal(error.apiId, 'hashing-api') + equal(error.groupId, 'items') + ok(error.endpoint === 'read' || error.endpoint === 'page') + return true + } + for (const input of [{ id: 1 }, skipToken] as const) { + const argument = Object.freeze({ input, [field]: value }) + throws(() => utils.read.queryOptions(argument), rpcError) + throws(() => utils.watch.liveOptions(argument), rpcError) + throws(() => utils.watch.streamedOptions(argument), rpcError) + ok(Object.hasOwn(argument, field)) + } + for (const input of [{ params: { id: 1 } }, skipToken] as const) { + throws(() => http.items.read.queryOptions({ input, [field]: value }), httpError) + } + throws( + () => + utils.page.infiniteOptions({ + input: (cursor: number) => ({ cursor }), + initialPageParam: 0, + getNextPageParam: () => undefined, + [field]: value, + }), + rpcError, + ) + throws( + () => + http.items.page.infiniteOptions({ + input: (cursor: number) => ({ query: { cursor } }), + initialPageParam: 0, + getNextPageParam: () => undefined, + [field]: value, + }), + httpError, + ) + } + for (const mode of ['global', 'prefix'] as const) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + gcTime: Infinity, + retry: false, + staleTime: Infinity, + queryKeyHashFn: (key) => `global:${JSON.stringify(key)}`, + }, + }, + }) + if (mode === 'prefix') { + queryClient.setQueryDefaults(utils.key(), { + queryKeyHashFn: (key) => `prefix:${JSON.stringify(key)}`, + }) + queryClient.setQueryDefaults(http.key(), { + queryKeyHashFn: (key) => `prefix:${JSON.stringify(key)}`, + }) + } + try { + const options = utils.read.queryOptions({ input: { id: 1 } }) + equal(yield* Effect.promise(() => queryClient.query(options)), 'rpc:1') + equal(queryClient.getQueryData(utils.read.queryKey({ id: 1 })), 'rpc:1') + queryClient.setQueryData(utils.read.queryKey({ id: 1 }), 'native') + equal(queryClient.getQueryData(options.queryKey), 'native') + equal(yield* Effect.promise(() => queryClient.query(options)), 'native') + equal(queryClient.getQueryCache().getAll().length, 1) + ok(Object.isFrozen(options.queryKey)) + ok(Object.isFrozen(options.queryKey.at(-1))) + + const httpOptions = http.items.read.queryOptions({ input: { params: { id: 1 } } }) + equal(yield* Effect.promise(() => queryClient.query(httpOptions)), 'http:1') + equal(queryClient.getQueryData(http.items.read.queryKey({ params: { id: 1 } })), 'http:1') + queryClient.setQueryData(http.items.read.queryKey({ params: { id: 1 } }), 'native-http') + equal(queryClient.getQueryData(httpOptions.queryKey), 'native-http') + equal(yield* Effect.promise(() => queryClient.query(httpOptions)), 'native-http') + ok(Object.isFrozen(httpOptions.queryKey)) + ok(Object.isFrozen(httpOptions.queryKey.at(-1))) + + const pages = utils.page.infiniteOptions({ + input: (cursor) => ({ cursor }), + initialPageParam: 0, + getNextPageParam: (_last, _pages, cursor) => (cursor < 1 ? cursor + 1 : undefined), + }) + const httpPages = http.items.page.infiniteOptions({ + input: (cursor) => ({ query: { cursor } }), + initialPageParam: 0, + getNextPageParam: (_last, _pages, cursor) => (cursor < 1 ? cursor + 1 : undefined), + }) + yield* Effect.promise(() => assertInfinitePages(queryClient, pages)) + yield* Effect.promise(() => assertInfinitePages(queryClient, httpPages)) + + yield* Effect.promise(() => + assertStreamSnapshot( + queryClient, + utils.watch.liveOptions({ input: { id: 1 } }), + 'first', + 'native-live', + ), + ) + yield* Effect.promise(() => + assertStreamSnapshot( + queryClient, + utils.watch.streamedOptions({ input: { id: 1 } }), + ['first'], + ['first', 'native-streamed'], + ), + ) + equal(queryClient.getQueryCache().getAll().length, 6) + ok( + queryClient + .getQueryCache() + .getAll() + .every((query) => query.queryHash.startsWith(`${mode}:`)), + ) + } finally { + queryClient.clear() + } + } + }), + ), +) diff --git a/tests/packed-consumer/tsconfig.json b/tests/packed-consumer/tsconfig.json index ddd1c69..7a45722 100644 --- a/tests/packed-consumer/tsconfig.json +++ b/tests/packed-consumer/tsconfig.json @@ -12,6 +12,7 @@ "types": ["node"] }, "include": [ + "hashing-runtime.mts", "runtime.mts", "http-runtime.mts", "public-contract.ts", diff --git a/tests/types/http-contract.ts b/tests/types/http-contract.ts index 70c120b..7841e35 100644 --- a/tests/types/http-contract.ts +++ b/tests/types/http-contract.ts @@ -1087,7 +1087,7 @@ queryClient.refetchQueries({ queryKey: getUser.key() }) nativeQuery.input // @ts-expect-error Query keys belong to the package. getUser.queryOptions({ input, queryKey: ['other'] }) -// @ts-expect-error Query hashing belongs to the package. +// @ts-expect-error QueryClient defaults own hashing. getUser.queryOptions({ input, queryKeyHashFn: () => 'other' }) const optionalInitial = (): typeof User.Type | undefined => undefined const maybeInitial = useQuery( @@ -1477,3 +1477,24 @@ getUser.infiniteOptions({ // @ts-expect-error A caller hash cannot merge ordinary and infinite caches. queryHash: 'shared', }) + +true satisfies Assert< + Equal< + Extract< + 'queryKeyHashFn' | 'queryHash', + | keyof typeof userPages + | keyof typeof skippedPages + | keyof typeof conditionalPages + | keyof ReturnType + >, + never + > +> + +getUser.infiniteOptions({ + initialPageParam: 0, + input: () => input, + getNextPageParam: () => undefined, + // @ts-expect-error QueryClient defaults own hashing. + queryKeyHashFn: JSON.stringify, +}) diff --git a/tests/types/public-contract.ts b/tests/types/public-contract.ts index c872e17..82cbae0 100644 --- a/tests/types/public-contract.ts +++ b/tests/types/public-contract.ts @@ -741,10 +741,10 @@ const queryOptions = utils.users.get.queryOptions({ staleTime: (query) => (query.state.data?.name === 'Ada' ? Infinity : 0), structuralSharing: false, }) -type ExactQueryHashInput = Assert< - Equal[0], typeof queryOptions.queryKey> +type ClientOwnedQueryHash = Assert< + Equal, never> > -const exactQueryHashInput: ExactQueryHashInput = true +const clientOwnedQueryHash: ClientOwnedQueryHash = true const queryHook = useQuery(queryOptions) const queryHookData: string | undefined = queryHook.data @@ -754,7 +754,7 @@ const suspenseData: string = suspenseHook.data suspenseHook.error satisfies EffectRpcQueryError<'not-found'> | null usePrefetchQuery(queryOptions) -void [exactQueryHashInput, queryHookData, suspenseData] +void [clientOwnedQueryHash, queryHookData, suspenseData] const possiblyInitialized = utils.users.get.queryOptions({ input: { id: 1, locale: 'de' }, @@ -828,11 +828,11 @@ const skipped = utils.users.get.queryOptions(skipToken) skipped.queryFn satisfies typeof queryCoreSkipToken const typedSkipToken: SkipToken = skipped.queryFn void typedSkipToken -type ExactSkippedQueryHashInput = Assert< - Equal[0], typeof skipped.queryKey> +type ClientOwnedSkippedQueryHash = Assert< + Equal, never> > -const exactSkippedQueryHashInput: ExactSkippedQueryHashInput = true -void exactSkippedQueryHashInput +const clientOwnedSkippedQueryHash: ClientOwnedSkippedQueryHash = true +void clientOwnedSkippedQueryHash const skippedHook = useQuery(skipped) const skippedData: { readonly id: number; readonly name: string } | undefined = skippedHook.data skippedHook.error satisfies EffectRpcQueryError<'not-found'> | null @@ -950,7 +950,7 @@ utils.users.get.queryOptions({ input: { id: 1 }, queryKey: ['custom'], }) -// @ts-expect-error the package owns the per-call query key hash function +// @ts-expect-error QueryClient defaults own hashing. utils.users.get.queryOptions({ input: { id: 1 }, queryKeyHashFn: JSON.stringify, @@ -1474,3 +1474,29 @@ utils.users.get.mutationOptions({ rpcOptions: discardOptions }) // @ts-expect-error A caller hash cannot override generated RPC cache identity. utils.users.get.queryOptions({ input: skipToken, queryHash: 'shared' }) + +true satisfies Assert< + Equal< + Extract< + 'queryKeyHashFn' | 'queryHash', + | keyof typeof infiniteOptions + | keyof typeof skippedInfinite + | keyof typeof conditionalInfiniteOptions + | keyof typeof streamedOptions + | keyof typeof liveOptions + >, + never + > +> + +// @ts-expect-error QueryClient defaults own hashing. +utils.events.watch.streamedOptions({ input: { channel: 'news' }, queryHash: 'shared' }) +// @ts-expect-error QueryClient defaults own hashing. +utils.events.watch.liveOptions({ input: { channel: 'news' }, queryKeyHashFn: JSON.stringify }) +utils.users.pages.infiniteOptions({ + initialPageParam: 0, + input: (cursor) => ({ cursor }), + getNextPageParam: () => undefined, + // @ts-expect-error QueryClient defaults own hashing. + queryKeyHashFn: JSON.stringify, +}) From 2e65e5c77c54db171e8b67388d29169448346fb7 Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Fri, 9 Oct 2026 22:24:06 +0200 Subject: [PATCH 17/97] Include accumulated stream policy in cache identity --- .fallowrc.jsonc | 3 +- .../content/docs/concepts/semantic-keys.md | 6 + apps/docs/src/content/docs/examples.md | 7 +- .../docs/reference/generated-builders.md | 38 ++- ...ate-accumulated-and-live-stream-queries.md | 6 + scripts/verify-packed-consumer.mts | 7 +- src/core/operation.ts | 1 + src/core/utility-tree.ts | 19 +- src/rpc/errors.ts | 1 + src/rpc/operation.ts | 3 +- src/rpc/streamed-query.ts | 65 ++-- src/rpc/types.ts | 15 +- .../create-rpc-query-utils-streaming.test.ts | 19 +- tests/create-rpc-query-utils.test.ts | 6 +- .../packed-consumer/stream-policy-runtime.mts | 309 ++++++++++++++++++ tests/packed-consumer/tsconfig.json | 1 + tests/types/public-contract.ts | 30 +- 17 files changed, 475 insertions(+), 61 deletions(-) create mode 100644 tests/packed-consumer/stream-policy-runtime.mts diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 3909097..1a0da78 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -6,7 +6,8 @@ "entry": [ "apps/docs/public/package-version.js", "playwright.docs.config.ts", - "tests/packed-consumer/stream-cause-runtime.mts" + "tests/packed-consumer/stream-cause-runtime.mts", + "tests/packed-consumer/stream-policy-runtime.mts" ], "ignorePatterns": ["examples/tanstack-start/src/routeTree.gen.ts"], diff --git a/apps/docs/src/content/docs/concepts/semantic-keys.md b/apps/docs/src/content/docs/concepts/semantic-keys.md index f6305cc..3c96424 100644 --- a/apps/docs/src/content/docs/concepts/semantic-keys.md +++ b/apps/docs/src/content/docs/concepts/semantic-keys.md @@ -35,6 +35,12 @@ Every RPC operation adds its own segment after the RPC path: `query`, `infinite` keep different cache shapes from colliding while root, branch, and RPC prefixes continue to match all descendant operations. +Accumulated-stream keys also include normalized retention and refetch policy. Unlimited/reset is +the default identity; different bounds or modes have separate cache entries. Supply the same policy +to `streamedKey` and `streamedOptions` for exact cache access. Broad prefixes continue to match all +views. Treat the concrete suffix as opaque and change your persistence version buster when migrating +from keys that omitted stream policy. + Unsupported values, failed construction, and failed encoding raise `EffectRpcQueryKeyError` before network execution. Payloads whose encoding needs services or that contain redacted values require a [custom key encoder](/effect-api-query/guides/custom-key-encoders/). diff --git a/apps/docs/src/content/docs/examples.md b/apps/docs/src/content/docs/examples.md index 8b3f9cd..09ac6c7 100644 --- a/apps/docs/src/content/docs/examples.md +++ b/apps/docs/src/content/docs/examples.md @@ -120,9 +120,10 @@ The accumulated history retains only "Workspace synchronized" and "Ready"; earli as new ones arrive. Choose **Replay full history** to retain all four states again. The live query continues to show only "Ready". -The bounded replay supplies `maxChunks: 2` to `streamedOptions`. Both controls reuse the generated -streamed key: the bound changes retention policy, not RPC identity. The application keeps the selected -policy for subsequent refetches. TanStack Start also demonstrates this after hydrating its server snapshot. +The bounded replay supplies `maxChunks: 2` to `streamedOptions`. Each policy has its own generated +streamed key, so a cached full history cannot satisfy the bounded view. The application keeps the +selected policy for subsequent refetches. TanStack Start also demonstrates this after hydrating its +server snapshot. ## Inspect request-local metadata diff --git a/apps/docs/src/content/docs/reference/generated-builders.md b/apps/docs/src/content/docs/reference/generated-builders.md index 70283b5..8ab6817 100644 --- a/apps/docs/src/content/docs/reference/generated-builders.md +++ b/apps/docs/src/content/docs/reference/generated-builders.md @@ -137,12 +137,12 @@ query. Each streaming RPC leaf exposes: -| Builder | Result | -| --------------------------- | --------------------------------------------------------------------------- | -| `streamedKey(input?)` | Data-tagged key for the accumulated sequence. | -| `streamedOptions(options?)` | Fresh Query Core options that append each emitted value to an array. | -| `liveKey(input?)` | Data-tagged key for the latest value. | -| `liveOptions(options?)` | Fresh Query Core options that replace the cached value after each emission. | +| Builder | Result | +| ----------------------------- | ------------------------------------------------------------------------------ | +| `streamedKey(input, policy?)` | Data-tagged key for the accumulated sequence and its retention/refetch policy. | +| `streamedOptions(options?)` | Fresh Query Core options that append each emitted value to an array. | +| `liveKey(input?)` | Data-tagged key for the latest value. | +| `liveOptions(options?)` | Fresh Query Core options that replace the cached value after each emission. | Both option builders publish the query as successful after its first value and keep `fetchStatus: 'fetching'` until the stream ends. An empty accumulated stream resolves to `[]`; an @@ -152,6 +152,16 @@ Live queries convert emitted `undefined` to `null`; explicit `null` stays `null` selectors, and initial-data options describe this normalized value. Accumulated arrays preserve `undefined` elements. +For an inputless RPC, call `streamedKey(policy?)`. Existing calls without a policy retain +unlimited history and reset refetches. Pass the same policy to the key and options builders when +reading, seeding, cancelling, or refetching an exact accumulated view: + +```ts +const policy = { maxChunks: 100, refetchMode: 'append' } as const +const key = utils.events.watch.streamedKey({ channel: 'news' }, policy) +const options = utils.events.watch.streamedOptions({ input: { channel: 'news' }, ...policy }) +``` + Accumulated streams accept TanStack's `refetchMode` option: - `reset` clears cached values and returns the query to pending before refetching. This is the @@ -182,9 +192,19 @@ array. The bound discards older history to limit the number of elements; it does not limit byte size. Without it, accumulation remains unbounded. Use `liveOptions` when only the latest value matters. -`maxChunks` configures accumulation, not key identity. Builders consume it before returning options, -including skipped options. Invalid bounds throw `EffectRpcQueryConfigError` with code -`InvalidMaxChunks` synchronously, even when `input` is `skipToken`. +`maxChunks` and `refetchMode` both contribute to concrete accumulated-stream identity. Omitted +values mean unlimited retention and `reset`; explicit `undefined` and explicit `reset` produce +the same default identity. Different policies have separate histories, including concurrent views +of the same RPC payload. Root, branch, RPC, and streamed-operation prefixes still match every policy. + +Builders consume both fields before returning options, including skipped options. Invalid bounds +throw `EffectRpcQueryConfigError` with code `InvalidMaxChunks`; invalid modes use +`InvalidRefetchMode`. Both fail synchronously, even when `input` is `skipToken`. + +Accumulated-stream keys have changed from earlier versions. Regenerate exact keys through the +builders and include the view's policy; treat their concrete suffix as opaque. Bump your persisted +cache's version buster or discard old accumulated histories before restoring data. Live, unary, +infinite, and mutation key identities retain their existing contracts. Live queries always replace the cached value and therefore expose no `refetchMode`. Cancelling, unmounting, or superseding either stream closes its iterator and interrupts its Effect resources. diff --git a/docs/adr/0020-generate-accumulated-and-live-stream-queries.md b/docs/adr/0020-generate-accumulated-and-live-stream-queries.md index 5eac73c..2f00a2e 100644 --- a/docs/adr/0020-generate-accumulated-and-live-stream-queries.md +++ b/docs/adr/0020-generate-accumulated-and-live-stream-queries.md @@ -9,6 +9,12 @@ that completes without emitting a value raises `EffectRpcQueryEmptyStreamError`. and `live` key segments prevent collisions with other query shapes and mutations; generated prefixes support invalidation. +Concrete accumulated-stream identity includes the normalized retention bound and refetch mode, +so incompatible histories occupy separate cache entries. Omitted bounds mean unlimited retention; +omitted refetch mode means reset. Exact key builders accept the same policy as options builders, +and explicit defaults share identity with omitted defaults. Broad prefixes remain stable; persisted +accumulated histories require a version change when adopting this identity. + Live emissions normalize `undefined` to `null` under [ADR 0012](0012-normalize-undefined-query-success-to-null.md); accumulated elements remain unchanged. On an initial fetch, the first emission makes either view successful while the open diff --git a/scripts/verify-packed-consumer.mts b/scripts/verify-packed-consumer.mts index 4f5ee56..672a66d 100644 --- a/scripts/verify-packed-consumer.mts +++ b/scripts/verify-packed-consumer.mts @@ -502,7 +502,12 @@ const verifyConsumer = (peer: (typeof peerCases)[number]): void => { ) } - for (const fixture of ['runtime.mts', 'http-runtime.mts', 'stream-cause-runtime.mts']) { + for (const fixture of [ + 'runtime.mts', + 'http-runtime.mts', + 'stream-cause-runtime.mts', + 'stream-policy-runtime.mts', + ]) { execFileSync(process.execPath, ['--experimental-import-meta-resolve', fixture], { cwd: consumerDirectory, stdio: 'inherit', diff --git a/src/core/operation.ts b/src/core/operation.ts index affd70e..901ebd0 100644 --- a/src/core/operation.ts +++ b/src/core/operation.ts @@ -44,6 +44,7 @@ export interface MutationOperation extends BufferedOperation { export interface StreamingOperation extends OperationIdentity { readonly kind: 'Streaming' readonly input: OperationInput + readonly streamedIdentity: (options: Record) => readonly JsonValue[] /** Consumes and validates stream policy, including for skipped requests. */ readonly prepareStream: ( options: Record, diff --git a/src/core/utility-tree.ts b/src/core/utility-tree.ts index 048663f..4d1e6b7 100644 --- a/src/core/utility-tree.ts +++ b/src/core/utility-tree.ts @@ -212,15 +212,19 @@ const prepareQuery = ( input: unknown, operationKey: readonly JsonValue[], keyEncoder: RuntimeKeyEncoder | undefined, + identity: readonly JsonValue[] = [], ): PreparedQuery => { if (description.input._tag === 'Inputless') { - return { input: undefined, key: operationKey } + return { + input: undefined, + key: identity.length === 0 ? operationKey : freezeKey([...operationKey, ...identity]), + } } const prepared = description.input.prepare(input, keyEncoder) try { return { input: prepared.input, - key: freezeKey([...operationKey, canonicalize(prepared.keyValue)]), + key: freezeKey([...operationKey, canonicalize(prepared.keyValue), ...identity]), } } catch (error) { throw description.input.invalidKey(error) @@ -262,16 +266,22 @@ const createQueryBuilders = ( options: Record, requestOptions: unknown, ) => (input: unknown) => QueryFunction, + prepareIdentity?: (options: Record) => readonly JsonValue[], ) => ({ - key: (input?: unknown) => prepareQuery(description, input, operationKey, keyEncoder).key, + key: (input?: unknown, policy?: Record) => { + const options = description.input._tag === 'Inputless' ? input : policy + const identity = prepareIdentity?.(Predicate.isObject(options) ? { ...options } : {}) ?? [] + return prepareQuery(description, input, operationKey, keyEncoder, identity).key + }, options: (argument?: unknown) => { const { input, options, requestOptions } = prepareQueryOptions(description, argument) + const identity = prepareIdentity?.(options) ?? [] // Stream policy is consumed and validated even when execution will be skipped. const makeQuery = prepareExecution(options, requestOptions) if (description.input._tag !== 'Inputless' && input === skipToken) { return finalizeQueryOptions(options, operationKey, skipToken) } - const prepared = prepareQuery(description, input, operationKey, keyEncoder) + const prepared = prepareQuery(description, input, operationKey, keyEncoder, identity) return finalizeQueryOptions(options, prepared.key, makeQuery(prepared.input)) }, }) @@ -419,6 +429,7 @@ const createStreamingLeaf = ( keyEncoder, (options, requestOptions) => description.prepareStream(options, 'streamed', runPromiseExit, requestOptions), + description.streamedIdentity, ) return Object.freeze({ diff --git a/src/rpc/errors.ts b/src/rpc/errors.ts index 5a3d337..0005488 100644 --- a/src/rpc/errors.ts +++ b/src/rpc/errors.ts @@ -3,6 +3,7 @@ import type { Cause } from 'effect' /** Stable codes for errors raised while configuring an RPC utility tree or its builders. */ export type EffectRpcQueryConfigErrorCode = | 'InvalidMaxChunks' + | 'InvalidRefetchMode' | 'InvalidKeyPrefix' | 'InvalidRpcPath' | 'RpcPathCollision' diff --git a/src/rpc/operation.ts b/src/rpc/operation.ts index 840f537..35c5e66 100644 --- a/src/rpc/operation.ts +++ b/src/rpc/operation.ts @@ -5,7 +5,7 @@ import { RpcSchema } from 'effect/rpc' import type { OperationDescription, OperationInput, TreeErrors } from '../core/operation' import { createSchemaKeyEncoding } from '../core/schema-key' import { EffectRpcQueryConfigError, EffectRpcQueryError, EffectRpcQueryKeyError } from './errors' -import { createStreamPreparation } from './streamed-query' +import { createStreamIdentity, createStreamPreparation } from './streamed-query' const createRpcInput = (definition: Rpc.AnyWithProps): OperationInput => { const { payloadSchema, _tag: rpcTag } = definition @@ -88,6 +88,7 @@ const describeRpc = ( return { ...identity, kind: 'Streaming', + streamedIdentity: createStreamIdentity(rpcTag), prepareStream: createStreamPreparation({ tag: rpcTag, invoke: (input, options) => diff --git a/src/rpc/streamed-query.ts b/src/rpc/streamed-query.ts index 157164e..b4998be 100644 --- a/src/rpc/streamed-query.ts +++ b/src/rpc/streamed-query.ts @@ -3,7 +3,7 @@ import type { QueryFunctionContext } from '@tanstack/query-core' import { Exit, Predicate, Stream } from 'effect' import type { StreamingOperation } from '../core/operation' -import type { RunPromiseExit } from '../core/types' +import type { JsonValue, RunPromiseExit } from '../core/types' import { EffectRpcQueryConfigError, EffectRpcQueryEmptyStreamError, @@ -13,9 +13,9 @@ import type { StreamRefetchMode, StreamingRpcOptions } from './types' type StreamQueryPolicy = | { - readonly maxChunks?: number + readonly maxChunks: number | undefined readonly _tag: 'Accumulated' - readonly refetchMode?: StreamRefetchMode + readonly refetchMode: StreamRefetchMode } | { readonly _tag: 'Live' } @@ -35,6 +35,39 @@ interface MakeStreamQueryOptions { readonly runPromiseExit: RunPromiseExit } +const accumulatedPolicy = ( + rpcTag: string, + options: Record, +): Extract => { + const { maxChunks, refetchMode = 'reset' } = options + if ( + maxChunks !== undefined && + (!Predicate.isNumber(maxChunks) || !Number.isSafeInteger(maxChunks) || maxChunks <= 0) + ) { + throw new EffectRpcQueryConfigError( + 'InvalidMaxChunks', + 'maxChunks must be a positive safe integer', + { rpcTag }, + ) + } + if (refetchMode !== 'reset' && refetchMode !== 'append' && refetchMode !== 'replace') { + throw new EffectRpcQueryConfigError( + 'InvalidRefetchMode', + 'refetchMode must be reset, append, or replace', + { rpcTag }, + ) + } + return { _tag: 'Accumulated' as const, maxChunks, refetchMode } +} + +export const createStreamIdentity = + (rpcTag: string): StreamingOperation['streamedIdentity'] => + (options) => { + const { maxChunks, refetchMode } = accumulatedPolicy(rpcTag, options) + const identity: JsonValue = Object.freeze({ maxChunks: maxChunks ?? null, refetchMode }) + return [identity] + } + const streamQueryIterable = ( source: AsyncIterable, signal: AbortSignal, @@ -137,7 +170,7 @@ const makeStreamQuery = ({ }) } - const { maxChunks, refetchMode = 'reset' } = policy + const { maxChunks, refetchMode } = policy if (maxChunks === undefined) { return experimental_streamedQuery({ refetchMode, streamFn }) } @@ -171,30 +204,10 @@ const makeStreamQuery = ({ export const createStreamPreparation = (rpc: RpcStreamInvocation): StreamingOperation['prepareStream'] => (options, operation, runPromiseExit, requestOptions) => { - // SAFETY: Public streamedOptions restricts this field to StreamRefetchMode; - // the options copy changes neither its value nor its contract. - // oxlint-disable-next-line typescript/no-unsafe-type-assertion - const refetchMode = options['refetchMode'] as StreamRefetchMode | undefined + const policy: StreamQueryPolicy = + operation === 'live' ? { _tag: 'Live' } : accumulatedPolicy(rpc.tag, options) delete options['refetchMode'] - const { maxChunks } = options delete options['maxChunks'] - // Preparation also runs for skipped queries; invalid policy fails synchronously. - if ( - maxChunks !== undefined && - (!Predicate.isNumber(maxChunks) || !Number.isSafeInteger(maxChunks) || maxChunks <= 0) - ) { - throw new EffectRpcQueryConfigError( - 'InvalidMaxChunks', - 'maxChunks must be a positive safe integer', - { rpcTag: rpc.tag }, - ) - } - const policy: StreamQueryPolicy = - operation === 'live' ? { _tag: 'Live' } : { _tag: 'Accumulated' } - if (policy._tag === 'Accumulated') { - Object.assign(policy, refetchMode === undefined ? undefined : { refetchMode }) - Object.assign(policy, maxChunks === undefined ? undefined : { maxChunks }) - } // SAFETY: takeRpcOptions extracted this unchanged from the typed public options. // oxlint-disable-next-line typescript/no-unsafe-type-assertion const rpcOptions = requestOptions as StreamingRpcOptions | undefined diff --git a/src/rpc/types.ts b/src/rpc/types.ts index c8bad8d..acf296b 100644 --- a/src/rpc/types.ts +++ b/src/rpc/types.ts @@ -202,9 +202,7 @@ export type ConcreteStreamedKey< R extends Rpc.Any, ClientError, > = DataTag< - void extends Rpc.PayloadConstructor - ? StreamedOperationKey - : readonly [...StreamedOperationKey, JsonValue], + readonly [...StreamedOperationKey, ...JsonValue[]], StreamedData, EffectRpcQueryError> > @@ -226,9 +224,9 @@ export type StreamRefetchMode = 'append' | 'replace' | 'reset' export type StreamedPolicyOptions = { /** Controls whether a refetch clears, appends to, or replaces accumulated data. */ - readonly refetchMode?: StreamRefetchMode + readonly refetchMode?: StreamRefetchMode | undefined /** Retains at most this many newest elements; must be a positive safe integer. */ - readonly maxChunks?: number + readonly maxChunks?: number | undefined } /** Shares query inference across accumulated and live queries, including conditional inputs. */ @@ -276,6 +274,11 @@ export type LiveOptionsBuilder< export type StreamKeyBuilder = void extends Rpc.PayloadConstructor ? () => Key : (input: Rpc.PayloadConstructor) => Key +export type StreamedKeyBuilder = + void extends Rpc.PayloadConstructor + ? (policy?: StreamedPolicyOptions) => Key + : (input: Rpc.PayloadConstructor, policy?: StreamedPolicyOptions) => Key + /** The key and option builders exposed at one streaming RPC path. */ export interface RpcStreamLeaf< R extends Rpc.Any, @@ -291,7 +294,7 @@ export interface RpcStreamLeaf< readonly liveOptions: LiveOptionsBuilder /** Builds a semantic key for the accumulated view of the stream. */ - readonly streamedKey: StreamKeyBuilder> + readonly streamedKey: StreamedKeyBuilder> /** Builds accumulated streamed-query options with Query Core refetch semantics. */ readonly streamedOptions: StreamedOptionsBuilder diff --git a/tests/create-rpc-query-utils-streaming.test.ts b/tests/create-rpc-query-utils-streaming.test.ts index d6da2fd..1efa5b3 100644 --- a/tests/create-rpc-query-utils-streaming.test.ts +++ b/tests/create-rpc-query-utils-streaming.test.ts @@ -127,7 +127,7 @@ describe('createRpcQueryUtils streaming execution', () => { const group = RpcGroup.make(Watch) const queryClient = new QueryClient() const snapshots: unknown[] = [] - const key = ['bounded', 'rpc', 'events', 'watch', 'streamed'] as const + let key: readonly unknown[] = [] const client = yield* makeRpcTestClient(group, { 'events.watch': () => Stream.fromAsyncIterable( @@ -143,6 +143,7 @@ describe('createRpcQueryUtils streaming execution', () => { }) const utils = createRpcQueryUtils(group, { client, keyPrefix: ['bounded'] }) const options = utils.events.watch.streamedOptions({ maxChunks: 2, refetchMode }) + key = options.queryKey queryClient.setQueryData(options.queryKey, [-2, -1, 0, 9]) const unsubscribe = queryClient.getQueryCache().subscribe((event) => { if (event.type === 'updated' && event.action.type === 'success') { @@ -313,19 +314,27 @@ describe('createRpcQueryUtils streaming execution', () => { 'run-1-second', ]) expect(yield* Effect.promise(async () => await fetch('append'))).toStrictEqual([ - 'run-1-first', - 'run-1-second', 'run-2-first', 'run-2-second', ]) - expect(yield* Effect.promise(async () => await fetch('replace'))).toStrictEqual([ + expect(yield* Effect.promise(async () => await fetch('append'))).toStrictEqual([ + 'run-2-first', + 'run-2-second', 'run-3-first', 'run-3-second', ]) - expect(yield* Effect.promise(async () => await fetch('reset'))).toStrictEqual([ + expect(yield* Effect.promise(async () => await fetch('replace'))).toStrictEqual([ 'run-4-first', 'run-4-second', ]) + expect(yield* Effect.promise(async () => await fetch('replace'))).toStrictEqual([ + 'run-5-first', + 'run-5-second', + ]) + expect(yield* Effect.promise(async () => await fetch('reset'))).toStrictEqual([ + 'run-6-first', + 'run-6-second', + ]) }), ) diff --git a/tests/create-rpc-query-utils.test.ts b/tests/create-rpc-query-utils.test.ts index eece78d..16a863d 100644 --- a/tests/create-rpc-query-utils.test.ts +++ b/tests/create-rpc-query-utils.test.ts @@ -88,7 +88,7 @@ describe(createRpcQueryUtils, () => { ]) expect(utils.toString.child.key()).toStrictEqual(['app', 'rpc', 'toString', 'child']) expect(utils.events.watch.key()).toStrictEqual(['app', 'rpc', 'events', 'watch']) - expect(utils.events.watch.streamedKey()).toStrictEqual([ + expect(utils.events.watch.streamedKey().slice(0, 5)).toStrictEqual([ 'app', 'rpc', 'events', @@ -300,7 +300,7 @@ describe(createRpcQueryUtils, () => { 'streamedKey', 'streamedOptions', ]) - expect(utils.events.audit.watch.streamedKey()).toStrictEqual([ + expect(utils.events.audit.watch.streamedKey().slice(0, 6)).toStrictEqual([ 'app', 'rpc', 'events', @@ -319,7 +319,7 @@ describe(createRpcQueryUtils, () => { expect(utils.events.audit.watch.streamedKey()).not.toStrictEqual( utils.events.audit.watch.liveKey(), ) - expect(utils.updates.watch.streamedKey()).toStrictEqual([ + expect(utils.updates.watch.streamedKey().slice(0, 5)).toStrictEqual([ 'app', 'rpc', 'updates', diff --git a/tests/packed-consumer/stream-policy-runtime.mts b/tests/packed-consumer/stream-policy-runtime.mts new file mode 100644 index 0000000..ff9c175 --- /dev/null +++ b/tests/packed-consumer/stream-policy-runtime.mts @@ -0,0 +1,309 @@ +import { QueryClient, QueryObserver, skipToken } from '@tanstack/query-core' +import type { QueryKey } from '@tanstack/query-core' +import { Deferred, Effect, Schema, Stream } from 'effect' +import { createRpcQueryUtils, EffectRpcQueryConfigError } from 'effect-api-query' +import { Rpc, RpcGroup, RpcTest } from 'effect/rpc' +import { deepStrictEqual, equal, notDeepStrictEqual, ok, throws } from 'node:assert/strict' + +const Watch = Rpc.make('events.watch', { + payload: { channel: Schema.String }, + success: Schema.Number, + stream: true, +}) +const Audit = Rpc.make('events.audit.watch', { success: Schema.Number, stream: true }) +const Other = Rpc.make('other.watch', { success: Schema.Number, stream: true }) +const group = RpcGroup.make(Watch, Audit, Other) + +const waitFor = async (queryClient: QueryClient, check: () => boolean) => { + const ready = Deferred.makeUnsafe() + const observe = () => { + if (check()) { + Effect.runSync(Deferred.succeed(ready, undefined)) + } + } + const unsubscribe = queryClient.getQueryCache().subscribe(observe) + try { + observe() + await Effect.runPromise(Deferred.await(ready).pipe(Effect.timeout('5 seconds'))) + } finally { + unsubscribe() + } +} + +await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const client = yield* RpcTest.makeClient(group, { flatten: true }).pipe( + Effect.provide( + group.toLayer({ + 'events.watch': () => Stream.make(1, 2, 3, 4), + 'events.audit.watch': () => Stream.make(1, 2, 3, 4), + 'other.watch': () => Stream.make(1, 2, 3, 4), + }), + ), + ) + const utils = createRpcQueryUtils(group, { client, keyPrefix: ['stream-policy'] }) + const input = { channel: 'news' } + deepStrictEqual( + utils.events.watch.streamedKey(input), + utils.events.watch.streamedKey(input, { maxChunks: undefined, refetchMode: 'reset' }), + ) + deepStrictEqual( + utils.events.watch.streamedOptions({ input }).queryKey, + utils.events.watch.streamedOptions({ input, maxChunks: undefined, refetchMode: undefined }) + .queryKey, + ) + deepStrictEqual( + utils.events.audit.watch.streamedKey(), + utils.events.audit.watch.streamedKey({ maxChunks: undefined, refetchMode: 'reset' }), + ) + for (const refetchMode of ['reset', 'append', 'replace'] as const) { + for (const maxChunks of [undefined, 2]) { + const policy = { maxChunks, refetchMode } + deepStrictEqual( + utils.events.watch.streamedKey(input, policy), + utils.events.watch.streamedOptions({ input, ...policy }).queryKey, + ) + deepStrictEqual( + utils.events.audit.watch.streamedKey(policy), + utils.events.audit.watch.streamedOptions(policy).queryKey, + ) + } + } + notDeepStrictEqual( + utils.events.watch.streamedKey(input, { refetchMode: 'append' }), + utils.events.watch.streamedKey(input, { refetchMode: 'replace' }), + ) + const capturedPolicy = { maxChunks: 2, refetchMode: 'append' as const } + const capturedKey = utils.events.watch.streamedKey(input, capturedPolicy) + capturedPolicy.maxChunks = 4 + deepStrictEqual( + capturedKey, + utils.events.watch.streamedKey(input, { maxChunks: 2, refetchMode: 'append' }), + ) + equal(Object.isFrozen(capturedKey), true) + for (const order of [ + [4, 2], + [2, 4], + ]) { + const queryClient = new QueryClient() + try { + for (const maxChunks of order) { + const options = utils.events.watch.streamedOptions({ + input: { channel: 'news' }, + maxChunks, + staleTime: Infinity, + }) + const result = yield* Effect.promise(() => queryClient.query(options)) + deepStrictEqual(result, maxChunks === 2 ? [3, 4] : [1, 2, 3, 4]) + } + equal(queryClient.getQueryCache().getAll().length, 2) + } finally { + queryClient.clear() + } + } + + const streamKeys: QueryKey[] = [] + for (const refetchMode of ['reset', 'append', 'replace'] as const) { + for (const maxChunks of [undefined, 2]) { + streamKeys.push(utils.events.watch.streamedKey(input, { maxChunks, refetchMode })) + } + } + const keys = [ + ...streamKeys, + utils.events.watch.liveKey(input), + utils.events.audit.watch.streamedKey(), + utils.other.watch.streamedKey(), + ['unrelated'], + ] + const prefixes = [ + { key: utils.key(), count: 9 }, + { key: utils.events.key(), count: 8 }, + { key: utils.events.watch.key(), count: 7 }, + { key: [...utils.events.watch.key(), 'streamed'], count: 6 }, + ] + for (const prefix of prefixes) { + const queryClient = new QueryClient() + try { + for (const key of keys) { + queryClient.setQueryData(key, [1]) + } + equal(queryClient.getQueriesData({ queryKey: prefix.key }).length, prefix.count) + yield* Effect.promise(() => queryClient.invalidateQueries({ queryKey: prefix.key })) + deepStrictEqual( + keys.map((key) => queryClient.getQueryState(key)?.isInvalidated), + [ + ...Array.from({ length: prefix.count }, () => true), + ...Array.from({ length: 10 - prefix.count }, () => false), + ], + ) + } finally { + queryClient.clear() + } + } + + for (const [policy, code] of [ + ...[0, -1, 0.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, '2', null].map( + (maxChunks) => [{ maxChunks }, 'InvalidMaxChunks'] as const, + ), + ...['unknown', null, 3].map( + (refetchMode) => [{ refetchMode }, 'InvalidRefetchMode'] as const, + ), + ]) { + const builders = [ + () => Reflect.apply(utils.events.watch.streamedKey, undefined, [input, policy]), + () => Reflect.apply(utils.events.audit.watch.streamedKey, undefined, [policy]), + ...[input, skipToken].map( + (request) => () => + Reflect.apply(utils.events.watch.streamedOptions, undefined, [ + { input: request, ...policy }, + ]), + ), + ] + for (const build of builders) { + throws(build, (error: unknown) => { + ok(error instanceof EffectRpcQueryConfigError) + equal(error.code, code) + return true + }) + } + } + }), + ), +) + +await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const release = Deferred.makeUnsafe() + const client = yield* RpcTest.makeClient(RpcGroup.make(Watch), { flatten: true }).pipe( + Effect.provide( + RpcGroup.make(Watch).toLayer({ + 'events.watch': () => + Stream.make(1, 2, 3, 4).pipe( + Stream.concat(Stream.fromEffect(Deferred.await(release).pipe(Effect.as(5)))), + ), + }), + ), + ) + const utils = createRpcQueryUtils(RpcGroup.make(Watch), { + client, + keyPrefix: ['concurrent-stream-policy'], + }) + const queryClient = new QueryClient() + const narrow = utils.events.watch.streamedOptions({ + input: { channel: 'news' }, + maxChunks: 2, + }) + const wide = utils.events.watch.streamedOptions({ input: { channel: 'news' }, maxChunks: 4 }) + const narrowObserver = new QueryObserver(queryClient, narrow) + const wideObserver = new QueryObserver(queryClient, wide) + const unsubscribeNarrow = narrowObserver.subscribe(() => undefined) + const unsubscribeWide = wideObserver.subscribe(() => undefined) + try { + yield* Effect.promise(() => + waitFor( + queryClient, + () => + narrowObserver.getCurrentResult().data?.at(-1) === 4 && + wideObserver.getCurrentResult().data?.at(-1) === 4, + ), + ) + deepStrictEqual(narrowObserver.getCurrentResult().data, [3, 4]) + deepStrictEqual(wideObserver.getCurrentResult().data, [1, 2, 3, 4]) + equal(narrowObserver.getCurrentResult().fetchStatus, 'fetching') + equal(wideObserver.getCurrentResult().fetchStatus, 'fetching') + yield* Deferred.succeed(release, undefined) + yield* Effect.promise(() => + waitFor( + queryClient, + () => + narrowObserver.getCurrentResult().fetchStatus === 'idle' && + wideObserver.getCurrentResult().fetchStatus === 'idle', + ), + ) + deepStrictEqual(narrowObserver.getCurrentResult().data, [4, 5]) + deepStrictEqual(wideObserver.getCurrentResult().data, [2, 3, 4, 5]) + equal(queryClient.getQueryCache().getAll().length, 2) + } finally { + unsubscribeNarrow() + unsubscribeWide() + queryClient.clear() + } + }), + ), +) + +for (const maxChunks of [undefined, 3]) { + for (const refetchMode of ['reset', 'append', 'replace'] as const) { + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + let values = [1, 2] + let release: Deferred.Deferred | undefined + const received = Deferred.makeUnsafe() + const watchGroup = RpcGroup.make(Watch) + const client = yield* RpcTest.makeClient(watchGroup, { flatten: true }).pipe( + Effect.provide( + watchGroup.toLayer({ + 'events.watch': () => { + const stream = Stream.fromIterable(values) + return release === undefined + ? stream + : stream.pipe( + Stream.concat( + Stream.fromEffect( + Deferred.succeed(received, undefined).pipe( + Effect.andThen(Deferred.await(release)), + ), + ).pipe(Stream.drain), + ), + ) + }, + }), + ), + ) + const utils = createRpcQueryUtils(watchGroup, { client, keyPrefix: ['refetch-policy'] }) + const queryClient = new QueryClient() + const options = utils.events.watch.streamedOptions({ + input: { channel: 'news' }, + maxChunks, + refetchMode, + }) + try { + deepStrictEqual(yield* Effect.promise(() => queryClient.query(options)), [1, 2]) + values = [3, 4] + release = Deferred.makeUnsafe() + const pending = queryClient.refetchQueries({ queryKey: options.queryKey, exact: true }) + yield* Deferred.await(received).pipe(Effect.timeout('5 seconds')) + const appended = maxChunks === 3 ? [2, 3, 4] : [1, 2, 3, 4] + if (refetchMode !== 'replace') { + yield* Effect.promise(() => + waitFor( + queryClient, + () => queryClient.getQueryData(options.queryKey)?.at(-1) === 4, + ), + ) + } + deepStrictEqual( + queryClient.getQueryData(options.queryKey), + refetchMode === 'replace' ? [1, 2] : refetchMode === 'append' ? appended : [3, 4], + ) + equal(queryClient.getQueryState(options.queryKey)?.fetchStatus, 'fetching') + yield* Deferred.succeed(release, undefined) + yield* Effect.promise(() => pending) + deepStrictEqual( + queryClient.getQueryData(options.queryKey), + refetchMode === 'append' ? appended : [3, 4], + ) + equal(queryClient.getQueryState(options.queryKey)?.fetchStatus, 'idle') + } finally { + queryClient.clear() + } + }), + ), + ) + } +} + +console.log('Packed stream policy identity, concurrent histories, refetches, and prefixes verified') diff --git a/tests/packed-consumer/tsconfig.json b/tests/packed-consumer/tsconfig.json index 5fc7a66..8d46eab 100644 --- a/tests/packed-consumer/tsconfig.json +++ b/tests/packed-consumer/tsconfig.json @@ -14,6 +14,7 @@ "include": [ "runtime.mts", "stream-cause-runtime.mts", + "stream-policy-runtime.mts", "http-runtime.mts", "public-contract.ts", "http-contract.ts", diff --git a/tests/types/public-contract.ts b/tests/types/public-contract.ts index c872e17..1223ffb 100644 --- a/tests/types/public-contract.ts +++ b/tests/types/public-contract.ts @@ -323,9 +323,35 @@ streamedHook.error satisfies EffectRpcQueryError< > | null const streamedResult: Promise = queryClient.query(streamedOptions) const streamedKey = utils.events.watch.streamedKey({ channel: 'news' }) -streamedKey satisfies readonly ['app', 'rpc', 'events', 'watch', 'streamed', JsonValue] +streamedKey satisfies readonly ['app', 'rpc', 'events', 'watch', 'streamed', ...JsonValue[]] const cachedStream = queryClient.getQueryData(utils.events.watch.streamedKey({ channel: 'news' })) cachedStream satisfies ReadonlyArray | undefined +const boundedStreamKey = utils.events.watch.streamedKey( + { channel: 'news' }, + { maxChunks: 2, refetchMode: 'append' }, +) +const boundedStreamData = queryClient.getQueryData(boundedStreamKey) +boundedStreamData satisfies ReadonlyArray | undefined +queryClient.setQueryData(boundedStreamKey, ['latest']) +queryClient.getQueryState(boundedStreamKey)?.error satisfies + | EffectRpcQueryError<'unauthorized' | 'watch-failure' | 'watch-rpc-failure'> + | null + | undefined +utils.events.watch.streamedKey({ channel: 'news' }, { maxChunks: undefined, refetchMode: 'reset' }) +utils.events.audit.watch.streamedKey() +utils.events.audit.watch.streamedKey({ maxChunks: 2, refetchMode: 'replace' }) +utils.events.audit.watch.streamedKey({ maxChunks: undefined, refetchMode: undefined }) +queryClient.getQueryData(utils.events.audit.watch.streamedKey({ maxChunks: 2 })) satisfies + | ReadonlyArray + | undefined +// @ts-expect-error refetch policy is one of the declared modes +utils.events.watch.streamedKey({ channel: 'news' }, { refetchMode: 'unknown' }) +// @ts-expect-error accumulation bounds are numeric +utils.events.audit.watch.streamedKey({ maxChunks: '2' }) +// @ts-expect-error a payload-bearing key still requires constructor input +utils.events.watch.streamedKey({ maxChunks: 2 }) +// @ts-expect-error live keys have no accumulation policy +utils.events.watch.liveKey({ channel: 'news' }, { maxChunks: 2 }) queryClient.invalidateQueries({ queryKey: streamedKey }) queryClient.refetchQueries({ queryKey: streamedKey }) void [streamedHookData, streamedResult] @@ -1218,7 +1244,7 @@ const conditionalStreamOptions = utils.events.watch.streamedOptions({ staleTime: (query) => { query.queryKey satisfies | readonly ['app', 'rpc', 'events', 'watch', 'streamed'] - | readonly ['app', 'rpc', 'events', 'watch', 'streamed', JsonValue] + | readonly ['app', 'rpc', 'events', 'watch', 'streamed', ...JsonValue[]] return 30_000 }, }) From f1351c0e30e101c158d8e45411aa3dcb7e2dbeb9 Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Fri, 9 Oct 2026 22:26:38 +0200 Subject: [PATCH 18/97] docs: prune obsolete HTTP implementation reference --- ...0022-add-buffered-http-utilities-with-separate-key-roots.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/docs/adr/0022-add-buffered-http-utilities-with-separate-key-roots.md b/docs/adr/0022-add-buffered-http-utilities-with-separate-key-roots.md index 2f3cf69..133cfbc 100644 --- a/docs/adr/0022-add-buffered-http-utilities-with-separate-key-roots.md +++ b/docs/adr/0022-add-buffered-http-utilities-with-separate-key-roots.md @@ -24,6 +24,3 @@ Keys append `rpc` plus tag segments, or `http` plus API identifier and projected caller prefix. Operation and canonical query input follow. Factory `key()` includes this generated root, preventing cross-adapter collisions while retaining prefix invalidation. Applications own safe client-identity partitions and deliberate cross-adapter invalidation through the caller prefix. - -See the [HTTP technical reference](../research/http-technical-spine.md) for upstream assumptions and -executable evidence. From c68e9f6dec2b0ac9316e59a403062c93099e63c8 Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Fri, 9 Oct 2026 22:31:30 +0200 Subject: [PATCH 19/97] docs: document invalid stream refetch modes --- apps/docs/src/content/docs/reference/errors.md | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/docs/src/content/docs/reference/errors.md b/apps/docs/src/content/docs/reference/errors.md index e2d01c9..afe2e88 100644 --- a/apps/docs/src/content/docs/reference/errors.md +++ b/apps/docs/src/content/docs/reference/errors.md @@ -8,6 +8,7 @@ description: Configuration, key-generation, and execution errors for RPC and HTT Thrown synchronously while configuring the utility tree or an option builder. Its `code` is one of: - `InvalidMaxChunks`: a streamed-query bound is not a positive safe integer. +- `InvalidRefetchMode`: a streamed-query mode is not `reset`, `append`, or `replace`. - `InvalidKeyPrefix` - `InvalidRpcPath` - `RpcPathCollision` From 389ec39f07e8887cfc880aeef90b10e55f2cd7ce Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Fri, 9 Oct 2026 22:35:10 +0200 Subject: [PATCH 20/97] Document typed retries and runner stream lifetimes --- .fallowrc.jsonc | 3 +- apps/docs/astro.config.mjs | 1 + .../content/docs/concepts/client-lifecycle.md | 31 ++ .../content/docs/guides/handle-failures.md | 2 + .../src/content/docs/guides/retry-queries.md | 189 +++++++ scripts/verify-docs-examples.mts | 9 +- scripts/verify-packed-consumer.mts | 7 +- tests/packed-consumer/docs-retry.ts | 113 ++++ tests/packed-consumer/retry-runtime.mts | 523 ++++++++++++++++++ tests/packed-consumer/tsconfig.json | 3 + tests/types/http-contract.ts | 15 + tests/types/public-contract.ts | 23 + 12 files changed, 913 insertions(+), 6 deletions(-) create mode 100644 apps/docs/src/content/docs/guides/retry-queries.md create mode 100644 tests/packed-consumer/docs-retry.ts create mode 100644 tests/packed-consumer/retry-runtime.mts diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 3909097..37be69c 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -6,7 +6,8 @@ "entry": [ "apps/docs/public/package-version.js", "playwright.docs.config.ts", - "tests/packed-consumer/stream-cause-runtime.mts" + "tests/packed-consumer/stream-cause-runtime.mts", + "tests/packed-consumer/retry-runtime.mts" ], "ignorePatterns": ["examples/tanstack-start/src/routeTree.gen.ts"], diff --git a/apps/docs/astro.config.mjs b/apps/docs/astro.config.mjs index cc5eb96..3b040ab 100644 --- a/apps/docs/astro.config.mjs +++ b/apps/docs/astro.config.mjs @@ -71,6 +71,7 @@ export default defineConfig({ { label: 'Cache Management', slug: 'guides/cache-management' }, { label: 'Cancellation', slug: 'guides/cancellation' }, { label: 'Handle Failures', slug: 'guides/handle-failures' }, + { label: 'Retry Queries', slug: 'guides/retry-queries' }, { label: 'Custom Key Encoders', slug: 'guides/custom-key-encoders' }, { label: 'Conditional Queries', slug: 'guides/conditional-queries' }, ], diff --git a/apps/docs/src/content/docs/concepts/client-lifecycle.md b/apps/docs/src/content/docs/concepts/client-lifecycle.md index a089842..8d5145a 100644 --- a/apps/docs/src/content/docs/concepts/client-lifecycle.md +++ b/apps/docs/src/content/docs/concepts/client-lifecycle.md @@ -42,3 +42,34 @@ processing; it is not a serialized server Context and does not replace the suppl Manage authentication, middleware, transport setup, runtime services, and `Scope` in your application client and runtime. + +## Stream creation and consumption + +For an accumulated streamed RPC or live RPC query, the runner executes +`Stream.toAsyncIterableEffect` to create an iterable and capture its Effect `Context`. The runner +returns a successful `Exit` before Query Core pulls values. Iterator pulls then use that captured +Context while the query remains fetching. Keep its services and the ready client's Scope alive +until consumption finishes. + +Apply transformations to the stream returned by the ready client, before it reaches the utility +tree. Use `Stream.map` for value changes, `Stream.mapEffect` for service-dependent work, +`Stream.tap` for per-emission instrumentation, and `Stream.ensuring` for completion or cancellation +cleanup. For RPC, wrap the streaming call at the ready-client boundary; preserve its payload, +request options, and declared success/error types. For HTTP, retain the native client's response +mode contract when applying a stream transformation. + +A timer, span, finalizer, or retry schedule around the runner's creation Effect ends with iterable +creation. It does not wrap the later pulls. Put stream-lifetime instrumentation and recovery on the +stream itself. A retry in generated Query options reruns the query function and creates another +iterable. Coordinate it with stream and transport schedules as described in +[retry queries](/effect-api-query/guides/retry-queries/#set-defaults-deliberately). + +At shutdown, cancel active queries, await application-owned stream finalization, clear the cache, +and then dispose the runtime or close the client Scope. `cancelQueries` restores Query's cache +state; iterator cleanup can finish asynchronously, so wait for your resource's completion signal +before disposal. Settle pending mutations separately because they have no query abort signal. + +The [packed runner fixture](https://github.com/ueberBrot/effect-api-query/blob/main/tests/packed-consumer/retry-runtime.mts) +checks both stream views: creation finishes before the first transformed emission, pulls and +finalizers retain the runner's provided Context, and cancellation finalizes the stream before +client disposal. diff --git a/apps/docs/src/content/docs/guides/handle-failures.md b/apps/docs/src/content/docs/guides/handle-failures.md index b5697cb..8e71a1f 100644 --- a/apps/docs/src/content/docs/guides/handle-failures.md +++ b/apps/docs/src/content/docs/guides/handle-failures.md @@ -28,6 +28,8 @@ These error classes distinguish failure stages: If a custom runner rejects instead of returning an `Exit`, its rejection passes through unchanged. See the [error reference](/effect-api-query/reference/errors/) for stable codes and metadata. +Use [retry queries](/effect-api-query/guides/retry-queries/) for typed `Cause`/`Result` decisions, +bounded attempt counts, and synchronous preparation errors. ## Inspect HTTP failures diff --git a/apps/docs/src/content/docs/guides/retry-queries.md b/apps/docs/src/content/docs/guides/retry-queries.md new file mode 100644 index 0000000..6e65bcb --- /dev/null +++ b/apps/docs/src/content/docs/guides/retry-queries.md @@ -0,0 +1,189 @@ +--- +title: Retry Queries +description: Choose bounded retries by inspecting typed Effect failures and native Query options. +--- + +Set an explicit retry policy for an idempotent read. The callbacks below allow two retries after +an initial failure: at most three client executions. They retry a declared `RetryLater` or an HTTP +transport failure, and stop for `NotFound`, `AuthenticationRequired`, Schema failures, defects, +interruptions, and rejected runners. + +## Inspect the typed failure + +Share the declarations with your server and pass your application's ready client to +`rpcReadOptions` or `httpReadOptions`. Both functions return native query options for `useQuery` +or `queryClient.query`. Adapt the allowed tags and transport reasons to your read's contract. + +```ts +import { Cause, Result, Schema } from 'effect' +import { + createHttpApiQueryUtils, + createRpcQueryUtils, + isEffectHttpApiQueryError, + isEffectRpcQueryError, +} from 'effect-api-query' +import { HttpClientError } from 'effect/http' +import { HttpApi, HttpApiClient, HttpApiEndpoint, HttpApiGroup } from 'effect/http-api' +import { Rpc, RpcClient, RpcClientError, RpcGroup } from 'effect/rpc' + +export class NotFound extends Schema.TaggedError()('NotFound', {}) {} +export class AuthenticationRequired extends Schema.TaggedError()( + 'AuthenticationRequired', + {}, +) {} +export class RetryLater extends Schema.TaggedError()('RetryLater', { + retryAfterMs: Schema.Number, +}) {} + +const ReadError = Schema.Union([NotFound, AuthenticationRequired, RetryLater]) +export const readGroup = RpcGroup.make( + Rpc.make('profile.read', { success: Schema.String, error: ReadError }), +) +export const readApi = HttpApi.make('profile-api').add( + HttpApiGroup.make('profile').add( + HttpApiEndpoint.get('read', '/profile', { success: Schema.String, error: ReadError }), + ), +) + +export const rpcReadOptions = ( + client: RpcClient.RpcClient.Flat, RpcClientError.RpcClientError>, +) => { + const rpc = createRpcQueryUtils< + typeof readGroup, + readonly ['profile-app'], + RpcClientError.RpcClientError + >(readGroup, { client, keyPrefix: ['profile-app'] }) + + return rpc.profile.read.queryOptions({ + retry: (failureCount, error) => { + if (!isEffectRpcQueryError(error) || failureCount >= 2) { + return false + } + if (Cause.hasDies(error.cause) || Cause.hasInterrupts(error.cause)) { + return false + } + const failure = Cause.findError(error.cause) + if (Result.isFailure(failure) || error.cause.reasons.length !== 1) { + return false + } + switch (failure.success._tag) { + case 'RetryLater': + return true + case 'RpcClientError': + return ( + failure.success.reason._tag === 'HttpError' && + failure.success.reason.kind === 'TransportError' + ) + default: + return false + } + }, + retryDelay: (attemptIndex, error) => { + if (isEffectRpcQueryError(error)) { + const failure = Cause.findError(error.cause) + if (Result.isSuccess(failure) && failure.success._tag === 'RetryLater') { + return Math.min(30_000, Math.max(0, failure.success.retryAfterMs)) + } + } + return Math.min(1_000 * 2 ** attemptIndex, 30_000) + }, + }) +} + +export const httpReadOptions = (client: HttpApiClient.ForApi) => { + const http = createHttpApiQueryUtils(readApi, { client, keyPrefix: ['profile-app'] }) + + return http.profile.read.queryOptions({ + retry: (failureCount, error) => { + if (!isEffectHttpApiQueryError(error) || failureCount >= 2) { + return false + } + if (Cause.hasDies(error.cause) || Cause.hasInterrupts(error.cause)) { + return false + } + const failure = Cause.findError(error.cause) + if (Result.isFailure(failure) || error.cause.reasons.length !== 1) { + return false + } + switch (failure.success._tag) { + case 'RetryLater': + return true + case 'HttpClientError': + return ( + HttpClientError.isHttpClientError(failure.success) && + failure.success.reason._tag === 'TransportError' + ) + default: + return false + } + }, + retryDelay: (attemptIndex, error) => { + if (isEffectHttpApiQueryError(error)) { + const failure = Cause.findError(error.cause) + if (Result.isSuccess(failure) && failure.success._tag === 'RetryLater') { + return Math.min(30_000, Math.max(0, failure.success.retryAfterMs)) + } + } + return Math.min(1_000 * 2 ** attemptIndex, 30_000) + }, + }) +} +``` + +`Cause.findError` returns a `Result`; after `Result.isSuccess`, `success` retains the declared +error union. It extracts the first typed failure. Check defects and interruptions first, and +inspect all `cause.reasons` when several failures are meaningful to your policy. This example +rejects every Cause with more than one reason, including a retryable failure combined with a defect, +interruption, or another typed failure. + +The runtime guard also rejects a custom runner's original rejection. A runner rejection produces +no failed Effect `Exit`, so it need not have the statically declared execution-error shape. Keep +this guard in retry and retry-delay callbacks that can receive custom runner failures. + +An HTTP `TransportError` is safe to retry here because this operation is an idempotent read. Other +`HttpClientError` reasons include encoding, decoding, and response failures. An RPC +`RpcClientError` can contain HTTP, socket, worker, or protocol failures; the RPC policy above +explicitly allows only its HTTP transport reason. Choose socket/worker reconnection policy in the +application that owns that transport. Handle expired authentication by restoring the application +session and invalidating affected queries, instead of repeatedly sending the same credentials. + +`retryDelay` receives the zero-based attempt index and the same typed error. It returns milliseconds, +not an Effect or Promise. Query Core can evaluate it even for a terminal failure, so keep it pure. +The example bounds server-provided delays and caps exponential backoff at 30 seconds. + +## Set defaults deliberately + +| Execution | Native retry default without application overrides | +| -------------------------------------------------------------- | -------------------------------------------------- | +| Browser query observer, such as `useQuery` | Three retries after the initial attempt | +| Server query observer | Zero retries | +| Imperative `queryClient.query` and `queryClient.infiniteQuery` | Retries disabled | +| Mutation | Retries disabled | + +Generated options preserve Query Core policy. QueryClient defaults, prefix defaults, and explicit +builder options can change these defaults. Set `retry` explicitly when attempt counts matter; +`retry: 2` also means at most three executions, while `retry: false` permits one. + +Apply the same decision in an infinite, accumulated-stream, or live builder only when restarting +that operation is safe. A stream retry starts a new stream invocation; its retained data and refetch +policy still govern cache publication. Mutation retries require an application idempotency contract. + +Choose one layer to own retries. If TanStack allows two retries and the underlying Effect or +transport schedule also allows two retries per invocation, one query can make nine client attempts. +Set `retry: false` in Query options when the ready client or runner owns a retry schedule. A retry +around the runner's iterable-creation Effect does not retry subsequent stream consumption; see +[client lifecycle](/effect-api-query/concepts/client-lifecycle/#stream-creation-and-consumption). + +## Handle preparation before execution + +Factory and option-builder configuration errors and key-generation errors are synchronous. Prepare +options inside a `try` block when input or custom encoders may fail, then pass the successful options +to Query Core. These errors occur before a query is registered and never enter execution retry. +For an infinite query, the later page-input mapper executes during fetching; errors it throws reach +TanStack unchanged and need an explicit non-retry decision. Mutations encode input inside the +ready client's Effect, so their encoding failures use the execution Cause. + +Use [failure handling](/effect-api-query/guides/handle-failures/) for error-stage guards and +[the error reference](/effect-api-query/reference/errors/) for stable metadata. The complete example +is compiled against the installed package in +[`tests/packed-consumer/docs-retry.ts`](https://github.com/ueberBrot/effect-api-query/blob/main/tests/packed-consumer/docs-retry.ts). diff --git a/scripts/verify-docs-examples.mts b/scripts/verify-docs-examples.mts index 410512c..590b42a 100644 --- a/scripts/verify-docs-examples.mts +++ b/scripts/verify-docs-examples.mts @@ -4,14 +4,15 @@ import { strictEqual } from 'node:assert' import { readFileSync } from 'node:fs' for (const [page, fixture] of [ - ['quick-start', 'docs-rpc-quick-start'], - ['http-quick-start', 'docs-http-quick-start'], + ['getting-started/quick-start', 'types/docs-rpc-quick-start'], + ['getting-started/http-quick-start', 'types/docs-http-quick-start'], + ['guides/retry-queries', 'packed-consumer/docs-retry'], ]) { const markdown = readFileSync( - new URL(`../apps/docs/src/content/docs/getting-started/${page}.md`, import.meta.url), + new URL(`../apps/docs/src/content/docs/${page}.md`, import.meta.url), 'utf-8', ) - const source = readFileSync(new URL(`../tests/types/${fixture}.ts`, import.meta.url), 'utf-8') + const source = readFileSync(new URL(`../tests/${fixture}.ts`, import.meta.url), 'utf-8') const samples = [...markdown.matchAll(/^```ts\n(?[\s\S]*?)^```/gmu)] strictEqual(samples.length, 1, `${page} must contain its complete TypeScript fixture`) strictEqual( diff --git a/scripts/verify-packed-consumer.mts b/scripts/verify-packed-consumer.mts index 4f5ee56..d196786 100644 --- a/scripts/verify-packed-consumer.mts +++ b/scripts/verify-packed-consumer.mts @@ -502,7 +502,12 @@ const verifyConsumer = (peer: (typeof peerCases)[number]): void => { ) } - for (const fixture of ['runtime.mts', 'http-runtime.mts', 'stream-cause-runtime.mts']) { + for (const fixture of [ + 'runtime.mts', + 'http-runtime.mts', + 'stream-cause-runtime.mts', + 'retry-runtime.mts', + ]) { execFileSync(process.execPath, ['--experimental-import-meta-resolve', fixture], { cwd: consumerDirectory, stdio: 'inherit', diff --git a/tests/packed-consumer/docs-retry.ts b/tests/packed-consumer/docs-retry.ts new file mode 100644 index 0000000..0331eac --- /dev/null +++ b/tests/packed-consumer/docs-retry.ts @@ -0,0 +1,113 @@ +import { Cause, Result, Schema } from 'effect' +import { + createHttpApiQueryUtils, + createRpcQueryUtils, + isEffectHttpApiQueryError, + isEffectRpcQueryError, +} from 'effect-api-query' +import { HttpClientError } from 'effect/http' +import { HttpApi, HttpApiClient, HttpApiEndpoint, HttpApiGroup } from 'effect/http-api' +import { Rpc, RpcClient, RpcClientError, RpcGroup } from 'effect/rpc' + +export class NotFound extends Schema.TaggedError()('NotFound', {}) {} +export class AuthenticationRequired extends Schema.TaggedError()( + 'AuthenticationRequired', + {}, +) {} +export class RetryLater extends Schema.TaggedError()('RetryLater', { + retryAfterMs: Schema.Number, +}) {} + +const ReadError = Schema.Union([NotFound, AuthenticationRequired, RetryLater]) +export const readGroup = RpcGroup.make( + Rpc.make('profile.read', { success: Schema.String, error: ReadError }), +) +export const readApi = HttpApi.make('profile-api').add( + HttpApiGroup.make('profile').add( + HttpApiEndpoint.get('read', '/profile', { success: Schema.String, error: ReadError }), + ), +) + +export const rpcReadOptions = ( + client: RpcClient.RpcClient.Flat, RpcClientError.RpcClientError>, +) => { + const rpc = createRpcQueryUtils< + typeof readGroup, + readonly ['profile-app'], + RpcClientError.RpcClientError + >(readGroup, { client, keyPrefix: ['profile-app'] }) + + return rpc.profile.read.queryOptions({ + retry: (failureCount, error) => { + if (!isEffectRpcQueryError(error) || failureCount >= 2) { + return false + } + if (Cause.hasDies(error.cause) || Cause.hasInterrupts(error.cause)) { + return false + } + const failure = Cause.findError(error.cause) + if (Result.isFailure(failure) || error.cause.reasons.length !== 1) { + return false + } + switch (failure.success._tag) { + case 'RetryLater': + return true + case 'RpcClientError': + return ( + failure.success.reason._tag === 'HttpError' && + failure.success.reason.kind === 'TransportError' + ) + default: + return false + } + }, + retryDelay: (attemptIndex, error) => { + if (isEffectRpcQueryError(error)) { + const failure = Cause.findError(error.cause) + if (Result.isSuccess(failure) && failure.success._tag === 'RetryLater') { + return Math.min(30_000, Math.max(0, failure.success.retryAfterMs)) + } + } + return Math.min(1_000 * 2 ** attemptIndex, 30_000) + }, + }) +} + +export const httpReadOptions = (client: HttpApiClient.ForApi) => { + const http = createHttpApiQueryUtils(readApi, { client, keyPrefix: ['profile-app'] }) + + return http.profile.read.queryOptions({ + retry: (failureCount, error) => { + if (!isEffectHttpApiQueryError(error) || failureCount >= 2) { + return false + } + if (Cause.hasDies(error.cause) || Cause.hasInterrupts(error.cause)) { + return false + } + const failure = Cause.findError(error.cause) + if (Result.isFailure(failure) || error.cause.reasons.length !== 1) { + return false + } + switch (failure.success._tag) { + case 'RetryLater': + return true + case 'HttpClientError': + return ( + HttpClientError.isHttpClientError(failure.success) && + failure.success.reason._tag === 'TransportError' + ) + default: + return false + } + }, + retryDelay: (attemptIndex, error) => { + if (isEffectHttpApiQueryError(error)) { + const failure = Cause.findError(error.cause) + if (Result.isSuccess(failure) && failure.success._tag === 'RetryLater') { + return Math.min(30_000, Math.max(0, failure.success.retryAfterMs)) + } + } + return Math.min(1_000 * 2 ** attemptIndex, 30_000) + }, + }) +} diff --git a/tests/packed-consumer/retry-runtime.mts b/tests/packed-consumer/retry-runtime.mts new file mode 100644 index 0000000..3bb5fde --- /dev/null +++ b/tests/packed-consumer/retry-runtime.mts @@ -0,0 +1,523 @@ +import { QueryClient } from '@tanstack/query-core' +import { + Cause, + Context, + Deferred, + Effect, + Exit, + Predicate, + Result, + Schema, + Scope, + Stream, +} from 'effect' +import { + createHttpApiQueryUtils, + createRpcQueryUtils, + EffectHttpApiQueryKeyError, + EffectRpcQueryKeyError, + isEffectHttpApiQueryError, + isEffectRpcQueryError, +} from 'effect-api-query' +import type { RunPromiseExit } from 'effect-api-query' +import { + HttpClient, + HttpClientError, + HttpClientRequest, + HttpClientResponse, + HttpServer, +} from 'effect/http' +import { + HttpApi, + HttpApiClient, + HttpApiBuilder, + HttpApiEndpoint, + HttpApiGroup, + HttpApiTest, +} from 'effect/http-api' +import { Rpc, RpcClient, RpcClientError, RpcGroup, RpcTest } from 'effect/rpc' +import { deepStrictEqual, equal, ok, rejects, throws } from 'node:assert/strict' + +import { + AuthenticationRequired, + httpReadOptions, + NotFound, + readApi, + readGroup, + RetryLater, + rpcReadOptions, +} from './docs-retry.ts' + +const domain = new NotFound({}) +const authentication = new AuthenticationRequired({}) +const retryLater = new RetryLater({ retryAfterMs: 0 }) +const defect = new Error('read defect') +const terminalCauses: readonly Cause.Cause[] = [ + Cause.fail(domain), + Cause.fail(authentication), + Cause.die(defect), + Cause.interrupt(42), + Cause.combine(Cause.fail(retryLater), Cause.die(defect)), + Cause.combine(Cause.fail(retryLater), Cause.interrupt(42)), + Cause.combine(Cause.fail(retryLater), Cause.fail(authentication)), +] + +await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + let attempts = 0 + let failure = Cause.fail(retryLater) + let successOnAttempt = Infinity + const client = yield* RpcTest.makeClient(readGroup, { flatten: true }).pipe( + Effect.provide( + readGroup.toLayer({ + 'profile.read': () => + Effect.suspend(() => { + attempts += 1 + return attempts === successOnAttempt + ? Effect.succeed('profile') + : Effect.failCause(failure) + }), + }), + ), + ) + const options = rpcReadOptions(client) + const queryClient = new QueryClient() + try { + for (const cause of terminalCauses) { + failure = cause + const direct = yield* Effect.exit(client('profile.read', undefined)) + ok(Exit.isFailure(direct)) + attempts = 0 + queryClient.clear() + yield* Effect.promise(() => + rejects(queryClient.query(options), (error: unknown) => { + ok(isEffectRpcQueryError(error)) + equal(attempts, 1) + equal(Cause.hasDies(error.cause), Cause.hasDies(direct.cause)) + equal(Cause.hasInterrupts(error.cause), Cause.hasInterrupts(direct.cause)) + const actual = Cause.findError(error.cause) + const expected = Cause.findError(direct.cause) + equal(Result.isSuccess(actual), Result.isSuccess(expected)) + if (Result.isSuccess(actual) && Result.isSuccess(expected)) { + equal(actual.success, expected.success) + } + return true + }), + ) + } + + failure = Cause.fail(retryLater) + attempts = 0 + queryClient.clear() + yield* Effect.promise(() => + rejects(queryClient.query(options), (error: unknown) => { + ok(isEffectRpcQueryError(error)) + equal(attempts, 3) + equal(Cause.findError(error.cause)._tag, 'Success') + return true + }), + ) + + attempts = 0 + successOnAttempt = 3 + queryClient.clear() + const delayIndexes: number[] = [] + const result = yield* Effect.promise(() => + queryClient.query({ + ...options, + retryDelay: (attemptIndex, error) => { + delayIndexes.push(attemptIndex) + ok(isEffectRpcQueryError(error)) + ok(typeof options.retryDelay === 'function') + return options.retryDelay(attemptIndex, error) + }, + }), + ) + equal(result, 'profile') + equal(attempts, 3) + deepStrictEqual(delayIndexes, [0, 1]) + + attempts = 0 + successOnAttempt = Infinity + queryClient.clear() + const rpc = createRpcQueryUtils(readGroup, { client, keyPrefix: ['defaults'] }) + yield* Effect.promise(() => rejects(queryClient.query(rpc.profile.read.queryOptions()))) + equal(attempts, 1) + } finally { + queryClient.clear() + } + + const transportError = new RpcClientError.RpcClientError({ + reason: HttpClientError.HttpClientErrorSchema.fromHttpClientError( + new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ + request: HttpClientRequest.get('https://profiles.test/rpc'), + }), + }), + ), + }) + let sent = 0 + const transport = yield* RpcClient.makeNoSerialization(readGroup, { + flatten: true, + onFromClient: ({ message }) => + message._tag === 'Request' + ? Effect.sync(() => { + sent += 1 + }).pipe(Effect.andThen(Effect.fail(transportError))) + : Effect.void, + }) + const transportQueryClient = new QueryClient() + try { + yield* Effect.promise(() => + rejects( + transportQueryClient.query(rpcReadOptions(transport.client)), + (error: unknown) => { + ok(isEffectRpcQueryError(error)) + const failure = Cause.findError(error.cause) + ok(Result.isSuccess(failure)) + equal(failure.success, transportError) + return true + }, + ), + ) + equal(sent, 3) + } finally { + transportQueryClient.clear() + } + + for (const error of [domain, authentication, retryLater]) { + const expectedTag = error._tag + let httpAttempts = 0 + const httpClient = yield* HttpApiTest.groups(readApi, ['profile']).pipe( + Effect.provide( + HttpApiBuilder.group(readApi, 'profile', (handlers) => + handlers.handle('read', () => + Effect.suspend(() => { + httpAttempts += 1 + return httpAttempts === 3 ? Effect.succeed('profile') : Effect.fail(error) + }), + ), + ), + ), + ) + const queryClient = new QueryClient() + try { + const query = queryClient.query(httpReadOptions(httpClient)) + if (error._tag === 'RetryLater') { + equal(yield* Effect.promise(() => query), 'profile') + equal(httpAttempts, 3) + } else { + yield* Effect.promise(() => + rejects(query, (error: unknown) => { + ok(isEffectHttpApiQueryError(error)) + const failure = Cause.findError(error.cause) + ok(Result.isSuccess(failure)) + ok(Predicate.isTagged(failure.success, expectedTag)) + return true + }), + ) + equal(httpAttempts, 1) + } + } finally { + queryClient.clear() + } + } + }), + ).pipe(Effect.provide(HttpServer.layerServices)), +) + +let transportAttempts = 0 +const httpClient = await Effect.runPromise( + HttpApiClient.makeWith(readApi, { + baseUrl: 'https://profiles.test', + httpClient: HttpClient.make((request) => + Effect.suspend(() => { + transportAttempts += 1 + return transportAttempts === 3 + ? Effect.succeed(HttpClientResponse.fromWeb(request, Response.json('profile'))) + : Effect.fail( + new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ request }), + }), + ) + }), + ), + }), +) +const queryClient = new QueryClient() +try { + const httpOptions = httpReadOptions(httpClient) + const delays: number[] = [] + equal( + await queryClient.query({ + ...httpOptions, + retryDelay: (attempt, error) => { + ok(isEffectHttpApiQueryError(error)) + ok(typeof httpOptions.retryDelay === 'function') + delays.push(httpOptions.retryDelay(attempt, error)) + return 0 + }, + }), + 'profile', + ) + equal(transportAttempts, 3) + deepStrictEqual(delays, [1_000, 2_000]) + + for (const adapter of ['rpc', 'http'] as const) { + const scope = Scope.makeUnsafe() + const rejection = new Error(`${adapter} runner rejected`) + let runnerAttempts = 0 + const runPromiseExit: RunPromiseExit = async () => { + runnerAttempts += 1 + return await Promise.reject(rejection) + } + try { + let query: Promise + if (adapter === 'rpc') { + const client = await Effect.runPromise( + RpcTest.makeClient(readGroup, { flatten: true }).pipe( + Effect.provide(readGroup.toLayer({ 'profile.read': () => Effect.succeed('profile') })), + Scope.provide(scope), + ), + ) + const rpc = createRpcQueryUtils(readGroup, { + client, + keyPrefix: ['runner'], + runPromiseExit, + }) + const policy = rpcReadOptions(client) + ok(typeof policy.retry === 'function') + ok(typeof policy.retryDelay === 'function') + query = queryClient.query( + rpc.profile.read.queryOptions({ retry: policy.retry, retryDelay: policy.retryDelay }), + ) + } else { + const http = createHttpApiQueryUtils(readApi, { + client: httpClient, + keyPrefix: ['runner'], + runPromiseExit, + }) + const policy = httpReadOptions(httpClient) + ok(typeof policy.retry === 'function') + ok(typeof policy.retryDelay === 'function') + query = queryClient.query( + http.profile.read.queryOptions({ retry: policy.retry, retryDelay: policy.retryDelay }), + ) + } + await rejects(query, (error: unknown) => { + equal(error, rejection) + return true + }) + equal(runnerAttempts, 1) + } finally { + await queryClient.cancelQueries() + queryClient.clear() + await Effect.runPromise(Scope.close(scope, Exit.void)) + } + } + + const preparationFailure = new Error('key encoder rejected') + const payloadApi = HttpApi.make('key-preparation').add( + HttpApiGroup.make('profile').add( + HttpApiEndpoint.get('read', '/profile/:id', { + params: { id: Schema.String }, + success: Schema.String, + }), + ), + ) + const payloadClient = await Effect.runPromise( + HttpApiClient.makeWith(payloadApi, { + baseUrl: 'https://profiles.test', + httpClient: HttpClient.make((request) => + Effect.sync(() => { + transportAttempts += 1 + return HttpClientResponse.fromWeb(request, Response.json('profile')) + }), + ), + }), + ) + const http = createHttpApiQueryUtils(payloadApi, { + client: payloadClient, + keyPrefix: ['preparation'], + keyEncoders: { + profile: { + read: () => { + throw preparationFailure + }, + }, + }, + }) + throws( + () => http.profile.read.queryOptions({ input: { params: { id: 'user' } }, retry: 2 }), + (error: unknown) => { + ok(error instanceof EffectHttpApiQueryKeyError) + equal(error.cause, preparationFailure) + return true + }, + ) + equal(transportAttempts, 3) + equal(queryClient.getQueryCache().getAll().length, 0) + const payloadGroup = RpcGroup.make( + Rpc.make('read', { payload: { id: Schema.String }, success: Schema.String }), + ) + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + let executions = 0 + const client = yield* RpcTest.makeClient(payloadGroup, { flatten: true }).pipe( + Effect.provide( + payloadGroup.toLayer({ read: () => Effect.sync(() => String(++executions)) }), + ), + ) + const rpc = createRpcQueryUtils(payloadGroup, { + client, + keyPrefix: ['preparation'], + keyEncoders: { + read: () => { + throw preparationFailure + }, + }, + }) + throws( + () => rpc.read.queryOptions({ input: { id: 'user' }, retry: 2 }), + (error: unknown) => { + ok(error instanceof EffectRpcQueryKeyError) + equal(error.cause, preparationFailure) + return true + }, + ) + equal(executions, 0) + equal(queryClient.getQueryCache().getAll().length, 0) + }), + ), + ) +} finally { + queryClient.clear() +} + +class StreamContext extends Context.Service()( + 'RetryRunner/StreamContext', +) {} + +const StreamValue = Schema.String.pipe( + Schema.middlewareDecoding((decoding) => Effect.flatMap(StreamContext, () => decoding)), +) +const watchGroup = RpcGroup.make(Rpc.make('events.watch', { success: StreamValue, stream: true })) +const wait = async (deferred: Deferred.Deferred) => + await Effect.runPromise(Deferred.await(deferred).pipe(Effect.timeout('5 seconds'))) + +for (const operation of ['streamed', 'live'] as const) { + const scope = Scope.makeUnsafe() + const queryClient = new QueryClient() + const events: string[] = [] + const published = Deferred.makeUnsafe() + const finalized = Deferred.makeUnsafe() + const serverFinalized = Deferred.makeUnsafe() + let signal: AbortSignal | undefined + const readyClient = await Effect.runPromise( + RpcTest.makeClient(watchGroup, { flatten: true }).pipe( + Effect.provide( + watchGroup.toLayer({ + 'events.watch': () => + Stream.succeed('ready').pipe( + Stream.concat(Stream.fromEffect(Effect.never)), + Stream.ensuring(Deferred.succeed(serverFinalized, undefined).pipe(Effect.asVoid)), + ), + }), + ), + Scope.provide(scope), + ), + ) + await Effect.runPromise( + Scope.addFinalizer( + scope, + Effect.sync(() => events.push('disposed')), + ), + ) + const client = (( + tag: 'events.watch', + input: void, + options?: { + readonly headers?: import('effect/http').Headers.Input | undefined + readonly context?: Context.Context | undefined + readonly streamBufferSize?: number | undefined + }, + ) => + readyClient(tag, input, options).pipe( + Stream.mapEffect((value) => + Effect.map(StreamContext, ({ prefix }) => { + events.push('consumed') + return `${prefix}:${value}` + }), + ), + Stream.ensuring( + Effect.flatMap(StreamContext, ({ prefix }) => + Effect.sync(() => { + events.push(`finalized:${prefix}`) + Effect.runSync(Deferred.succeed(finalized, undefined)) + }), + ), + ), + )) as typeof readyClient + const runPromiseExit: RunPromiseExit = async (effect, options) => { + events.push('creation-started') + signal = options?.signal + const exit = await Effect.runPromiseExit( + effect.pipe(Effect.provideService(StreamContext, { prefix: 'provided' })), + options, + ) + events.push('creation-completed') + return exit + } + const utils = createRpcQueryUtils(watchGroup, { + client, + keyPrefix: ['runner-lifetime', operation], + runPromiseExit, + }) + const options = + operation === 'live' + ? utils.events.watch.liveOptions({ retry: false }) + : utils.events.watch.streamedOptions({ retry: false }) + const unsubscribe = queryClient.getQueryCache().subscribe(() => { + const data = queryClient.getQueryData(options.queryKey) + if (data === 'provided:ready' || (Array.isArray(data) && data[0] === 'provided:ready')) { + Effect.runSync(Deferred.succeed(published, undefined)) + } + }) + const query = + operation === 'live' + ? queryClient.query(utils.events.watch.liveOptions({ retry: false })) + : queryClient.query(utils.events.watch.streamedOptions({ retry: false })) + const outcome = query.then( + (data) => ({ _tag: 'Success', data }) as const, + (error: unknown) => ({ _tag: 'Failure', error }) as const, + ) + try { + await wait(published) + deepStrictEqual(events, ['creation-started', 'creation-completed', 'consumed']) + equal(queryClient.getQueryState(options.queryKey)?.fetchStatus, 'fetching') + await queryClient.cancelQueries({ queryKey: options.queryKey, exact: true }) + await wait(finalized) + await wait(serverFinalized) + const result = await outcome + ok(result._tag === 'Success') + deepStrictEqual(result.data, operation === 'live' ? 'provided:ready' : ['provided:ready']) + equal(queryClient.getQueryState(options.queryKey)?.fetchStatus, 'idle') + equal(queryClient.getQueryState(options.queryKey)?.error, null) + equal(signal?.aborted, true) + deepStrictEqual(events, [ + 'creation-started', + 'creation-completed', + 'consumed', + 'finalized:provided', + ]) + } finally { + unsubscribe() + await queryClient.cancelQueries() + await wait(finalized) + queryClient.clear() + await Effect.runPromise(Scope.close(scope, Exit.void)) + } + equal(events.at(-1), 'disposed') +} diff --git a/tests/packed-consumer/tsconfig.json b/tests/packed-consumer/tsconfig.json index 5fc7a66..fe2a605 100644 --- a/tests/packed-consumer/tsconfig.json +++ b/tests/packed-consumer/tsconfig.json @@ -4,6 +4,7 @@ "module": "NodeNext", "moduleResolution": "NodeNext", "noEmit": true, + "allowImportingTsExtensions": true, "exactOptionalPropertyTypes": true, "noUncheckedIndexedAccess": true, "skipLibCheck": false, @@ -14,11 +15,13 @@ "include": [ "runtime.mts", "stream-cause-runtime.mts", + "retry-runtime.mts", "http-runtime.mts", "public-contract.ts", "http-contract.ts", "docs-rpc-quick-start.ts", "docs-http-quick-start.ts", + "docs-retry.ts", "skill-example-*.ts" ] } diff --git a/tests/types/http-contract.ts b/tests/types/http-contract.ts index 70c120b..d7c03ec 100644 --- a/tests/types/http-contract.ts +++ b/tests/types/http-contract.ts @@ -1057,6 +1057,11 @@ type GetFailure = EffectHttpApiQueryError< const getUser = utils['user.accounts']['get.user'] const nativeQuery = getUser.queryOptions({ input, + retryDelay: (attemptIndex, error) => { + attemptIndex satisfies number + error satisfies GetFailure + return 0 + }, select: (user) => user.name, retry: (_count, error) => { error satisfies GetFailure @@ -1192,6 +1197,11 @@ const userPages = getUser.infiniteOptions({ }, getPreviousPageParam: (_first, _pages, firstParam) => firstParam > 0 ? firstParam - 1 : undefined, + retryDelay: (attemptIndex, error) => { + attemptIndex satisfies number + error satisfies GetFailure + return 0 + }, retry: (_count, error) => { error satisfies GetFailure return false @@ -1336,6 +1346,11 @@ const inputlessPages = utils.ping.infiniteOptions({ useInfiniteQuery(inputlessPages).data satisfies InfiniteData | undefined const callbackMutation = getUser.mutationOptions({ + retryDelay: (attemptIndex, error) => { + attemptIndex satisfies number + error satisfies GetFailure + return 0 + }, onMutate: (request) => ({ previousId: request.params.id }), onSuccess: (user, request, result) => { user satisfies typeof User.Type diff --git a/tests/types/public-contract.ts b/tests/types/public-contract.ts index fd1e8d8..8524b53 100644 --- a/tests/types/public-contract.ts +++ b/tests/types/public-contract.ts @@ -310,6 +310,11 @@ const streamedOptions = utils.events.watch.streamedOptions({ meta: { source: 'fixture' }, networkMode: 'offlineFirst', refetchMode: 'append', + retryDelay: (attemptIndex, error) => { + attemptIndex satisfies number + error satisfies EffectRpcQueryError<'unauthorized' | 'watch-failure' | 'watch-rpc-failure'> + return 0 + }, retry: (_count, error) => { error satisfies EffectRpcQueryError<'unauthorized' | 'watch-failure' | 'watch-rpc-failure'> return false @@ -332,6 +337,13 @@ void [streamedHookData, streamedResult] const liveOptions = utils.events.watch.liveOptions({ input: { channel: 'news' }, + retryDelay: (attemptIndex, error) => { + attemptIndex satisfies number + error satisfies + | EffectRpcQueryEmptyStreamError + | EffectRpcQueryError<'unauthorized' | 'watch-failure' | 'watch-rpc-failure'> + return 0 + }, select: (value) => value.length, }) const liveHook = useQuery(liveOptions) @@ -484,6 +496,11 @@ const infiniteOptions = utils.users.pages.infiniteOptions({ error satisfies EffectRpcQueryError<'page-failure'> return false }, + retryDelay: (attemptIndex, error) => { + attemptIndex satisfies number + error satisfies EffectRpcQueryError<'page-failure'> + return 0 + }, select: (data) => data.pages.flatMap((page) => page.users.map((user) => user.name)), }) const infiniteHook = useInfiniteQuery(infiniteOptions) @@ -734,6 +751,7 @@ const queryOptions = utils.users.get.queryOptions({ return false }, retryDelay: (_attempt, error) => { + _attempt satisfies number error satisfies EffectRpcQueryError<'not-found'> return 0 }, @@ -854,6 +872,11 @@ const getMutationOptions = utils.users.get.mutationOptions<{ readonly previousName: string }>({ gcTime: 60_000, + retryDelay: (attemptIndex, error) => { + attemptIndex satisfies number + error satisfies EffectRpcQueryError<'not-found'> + return 0 + }, onError: (error, variables, onMutateResult) => { error satisfies EffectRpcQueryError<'not-found'> variables satisfies { readonly id: number; readonly locale?: string } From eca7cb1c66918c5415f62f2df1215724ffda03a3 Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Fri, 9 Oct 2026 22:37:51 +0200 Subject: [PATCH 21/97] docs: keep ADRs focused on architectural decisions --- ...010-reuse-tanstack-query-core-as-a-peer.md | 5 ++- ...rmalize-undefined-query-success-to-null.md | 13 +++---- ...quire-query-stable-payload-construction.md | 2 -- ...ate-accumulated-and-live-stream-queries.md | 36 ++++++------------- ...-http-utilities-with-separate-key-roots.md | 30 +++++----------- 5 files changed, 25 insertions(+), 61 deletions(-) diff --git a/docs/adr/0010-reuse-tanstack-query-core-as-a-peer.md b/docs/adr/0010-reuse-tanstack-query-core-as-a-peer.md index 32fb6d5..e35b37d 100644 --- a/docs/adr/0010-reuse-tanstack-query-core-as-a-peer.md +++ b/docs/adr/0010-reuse-tanstack-query-core-as-a-peer.md @@ -8,9 +8,8 @@ Generated objects and declarations expose TanStack Query Core runtime values and ## Decision -`@tanstack/query-core` is an external peer. The package supports v5 releases from 5.103.1 and tests -its declared lower bound and development version. This minimum preserves `null` data in the -streamed-query helper. Because the package imports Query Core's +`@tanstack/query-core` is an external peer. Its supported v5 range must preserve `null` stream +data, and both the declared lower bound and development version are tested. Because the package imports Query Core's experimental streamed-query interface, each development update must pass packed-consumer type and runtime checks. The package re-exports only primitives required by its own interface, including the exact `skipToken` and `SkipToken` bindings. diff --git a/docs/adr/0012-normalize-undefined-query-success-to-null.md b/docs/adr/0012-normalize-undefined-query-success-to-null.md index 9fb9ed8..b9d3788 100644 --- a/docs/adr/0012-normalize-undefined-query-success-to-null.md +++ b/docs/adr/0012-normalize-undefined-query-success-to-null.md @@ -9,14 +9,11 @@ runtime `undefined` for success. ## Decision -Generated buffered query functions and live-stream reducers map successful `undefined` to cacheable -`null`. Their query-data types replace `undefined` with `null`, including key DataTags, selectors, -and initial data. Explicit `null` stays `null`. Accumulated stream elements and mutation results -preserve `undefined`: an array containing `undefined` remains cacheable query data. +Buffered and live queries map successful `undefined` to cacheable `null` in both values and types. +Accumulated stream elements and mutation results preserve `undefined`, because neither needs this +normalization to produce valid query data. ## Consequences -Every unary RPC remains usable as a query without disguising an upstream failure. Every live -emission becomes the latest query data, including an undefined emission after a defined value. -An undefined emission counts as the first value; an empty live stream still fails. This is the -adapter's only success-value normalization. +Every unary RPC and live emission remains usable as query data without disguising a failure or an +empty stream. This is the adapter's only success-value normalization. diff --git a/docs/adr/0014-require-query-stable-payload-construction.md b/docs/adr/0014-require-query-stable-payload-construction.md index 3bb0778..26b7e50 100644 --- a/docs/adr/0014-require-query-stable-payload-construction.md +++ b/docs/adr/0014-require-query-stable-payload-construction.md @@ -13,8 +13,6 @@ its own `Type` or preserves encoded meaning across repeated construction. Query use requires a query-stable payload Schema. Standard struct-shaped payloads and materialized defaults are the supported path. -Only a Void payload declaration is payloadless. Broader constructor types remain payload-bearing. - ## Consequences Constructor-sensitive Schemas remain available to mutations. Query callers instead expose a stable diff --git a/docs/adr/0020-generate-accumulated-and-live-stream-queries.md b/docs/adr/0020-generate-accumulated-and-live-stream-queries.md index 2f00a2e..4173af2 100644 --- a/docs/adr/0020-generate-accumulated-and-live-stream-queries.md +++ b/docs/adr/0020-generate-accumulated-and-live-stream-queries.md @@ -3,29 +3,13 @@ Status: Accepted. Partially supersedes ADR 0002 and ADR 0003. Extended to HTTP SSE views by [ADR 0024](0024-project-http-streams-and-buffered-metadata.md). -Streaming RPC leaves expose `streamedKey`/`streamedOptions` for ordered history with `reset`, -`append`, and `replace` refetch modes, and `liveKey`/`liveOptions` for the latest value. A live stream -that completes without emitting a value raises `EffectRpcQueryEmptyStreamError`. Separate `streamed` -and `live` key segments prevent collisions with other query shapes and mutations; generated prefixes -support invalidation. - -Concrete accumulated-stream identity includes the normalized retention bound and refetch mode, -so incompatible histories occupy separate cache entries. Omitted bounds mean unlimited retention; -omitted refetch mode means reset. Exact key builders accept the same policy as options builders, -and explicit defaults share identity with omitted defaults. Broad prefixes remain stable; persisted -accumulated histories require a version change when adopting this identity. - -Live emissions normalize `undefined` to `null` under -[ADR 0012](0012-normalize-undefined-query-success-to-null.md); accumulated elements remain -unchanged. On an initial fetch, the first emission makes either view successful while the open -stream remains fetching. Live completion preserves the latest normalized value. - -The ready RPC client remains the execution seam, including direct execution outside TanStack. -Client construction and Effect middleware remain interception seams. Stream functions forward -TanStack's abort signal and close the AsyncIterator on cancellation. A stream failure preserves its -complete Cause in `EffectRpcQueryError`, including interruption-only and mixed interruption/defect -Causes, while that signal is not aborted. Requested cancellation retains TanStack's native result -and cache reversion, including superseded refetches and removal of the last observer. -The application owns client/runtime resources, transport, middleware, Scope, QueryClient, and -framework lifecycle. SSR dehydrates completed data normally; open streams require cancellation -after the first successful snapshot. Mutations have no cancellation signal. +Streams support two cached representations: ordered accumulated history and the latest live value. +Keep these representations separate, and include retention and refetch policies in accumulated +cache identity, because incompatible histories cannot safely share an entry. Broad key prefixes +still support invalidation across views and policies. + +The adapter consumes caller-owned ready clients and follows TanStack cancellation while preserving +complete Effect failures. Applications own transport, reconnection, and resource lifetime; server +rendering must settle and stop open work before disposing those resources. Live values follow +[ADR 0012](0012-normalize-undefined-query-success-to-null.md), while accumulated elements retain +their decoded representation. diff --git a/docs/adr/0022-add-buffered-http-utilities-with-separate-key-roots.md b/docs/adr/0022-add-buffered-http-utilities-with-separate-key-roots.md index 133cfbc..2048370 100644 --- a/docs/adr/0022-add-buffered-http-utilities-with-separate-key-roots.md +++ b/docs/adr/0022-add-buffered-http-utilities-with-separate-key-roots.md @@ -1,26 +1,12 @@ # Add buffered HTTP utilities with separate key roots -Status: Accepted. Amends ADR 0001, ADR 0003, ADR 0005 through ADR 0008, ADR 0013, and ADR 0021. Multipart omission amended by [ADR 0023](0023-expose-buffered-multipart-http-mutations.md). HTTP SSE projection and opt-in buffered metadata amended by [ADR 0024](0024-project-http-streams-and-buffered-metadata.md). +Status: Accepted. Multipart support amended by [ADR 0023](0023-expose-buffered-multipart-http-mutations.md). +Streaming and metadata views extended by [ADR 0024](0024-project-http-streams-and-buffered-metadata.md). -Publish one `effect-api-query` package at the workspace root. Export `createHttpApiQueryUtils` beside -`createRpcQueryUtils` through one package root. Private adapters share utility construction but retain -their distinct request and projection contracts. -Use `src/core`, `src/http`, and `src/rpc` with direct imports and explicit root exports. +Expose HTTP and RPC utilities through one package, sharing private construction while preserving +their distinct declaration and request contracts. HTTP utilities consume caller-owned ready clients +and project only response shapes they can represent truthfully. -HTTP mirrors literal group/endpoint names, including dots and top-level projection. The original buffered projection omitted an entire -endpoint if any success alternative streams (including header-wrapped streams); omit empty groups. -ADR 0024 permits an unambiguous single SSE success while retaining omission for mixed buffered/streamed successes and raw byte streams. -The original decision also omitted any multipart payload alternative. ADR 0023 permits buffered -multipart mutation builders while preserving omission for streaming multipart payloads. This prevents -falsely buffered result types. Unsafe paths, collisions, and contradictory multipart metadata fail atomically. - -Accept decoded request parts and force decoded-only responses. Preserve query normalization and -mutation data. Applications own ready-client resources and supply runners for encoding, decoding, -and residual client services; key encoders supply only cache identity. HTTP-specific errors retain -declaration metadata and untouched failed-Exit Causes, which may contain upstream request, response, -or Schema issue values. Runner rejections pass through. - -Keys append `rpc` plus tag segments, or `http` plus API identifier and projected path, after the -caller prefix. Operation and canonical query input follow. Factory `key()` includes this generated -root, preventing cross-adapter collisions while retaining prefix invalidation. Applications own -safe client-identity partitions and deliberate cross-adapter invalidation through the caller prefix. +HTTP cache roots include the adapter and API identity, so HTTP endpoints and RPC tags cannot collide +under a shared caller prefix. Applications still own identity partitions and deliberate invalidation +across adapters. Decoded header wrappers and complete Effect failures remain intact at the boundary. From f5ed05efa3bba9985575f2fe1eb4b12ea9ff3613 Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Fri, 9 Oct 2026 22:40:24 +0200 Subject: [PATCH 22/97] feat: add buffered HTTP metadata query views --- .fallowrc.jsonc | 2 + README.md | 27 +- .../docs/getting-started/feature-support.md | 4 + .../docs/guides/http-queries-and-mutations.md | 45 +++ .../docs/src/content/docs/reference/errors.md | 3 +- .../content/docs/reference/http-factory.md | 37 +- scripts/verify-packed-consumer.mts | 2 + src/core/operation.ts | 4 + src/core/utility-tree.ts | 20 +- src/http/errors.ts | 2 +- src/http/operation.ts | 62 +++- src/http/types.ts | 56 ++- tests/create-http-api-query-utils.test.ts | 2 + tests/packed-consumer/http-etag-recipe.mts | 88 +++++ .../packed-consumer/http-metadata-runtime.mts | 341 ++++++++++++++++++ tests/packed-consumer/http-runtime.mts | 48 ++- tests/packed-consumer/tsconfig.json | 2 + tests/types/http-contract.ts | 118 +++++- 18 files changed, 818 insertions(+), 45 deletions(-) create mode 100644 tests/packed-consumer/http-etag-recipe.mts create mode 100644 tests/packed-consumer/http-metadata-runtime.mts diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 2059071..22e34b5 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -7,6 +7,8 @@ "apps/docs/public/package-version.js", "playwright.docs.config.ts", "tests/packed-consumer/hashing-runtime.mts", + "tests/packed-consumer/http-metadata-runtime.mts", + "tests/packed-consumer/http-etag-recipe.mts", "tests/packed-consumer/stream-cause-runtime.mts" ], diff --git a/README.md b/README.md index 750d3ef..fbaf5e8 100644 --- a/README.md +++ b/README.md @@ -56,14 +56,29 @@ HTTP accepts decoded request parts; RPC accepts payload constructor input. Follo [HTTP quick start](https://ueberbrot.github.io/effect-api-query/getting-started/http-quick-start/) for a complete declaration, client, query, and mutation. +Use `metadataOptions` when a buffered query also needs response status or an ETag: + +```ts +const result = await queryClient.query( + http.users.get.metadataOptions({ input: { params: { id: 1 } } }), +) +const etag = result.headers['etag'] +const user = result.data +``` + +Metadata has a separate cache key and frozen status/header snapshot. Declared decoded header +wrappers stay inside `data`. Follow the +[ETag recipe](https://ueberbrot.github.io/effect-api-query/guides/http-queries-and-mutations/#update-with-an-etag) +for conditional writes and invalidation. + ## Choose an operation -| API definition | Available operations | -| ------------------------------------------- | --------------------------------------------- | -| Unary RPC | Queries, infinite queries, and mutations | -| Streaming RPC | Accumulated streamed queries and live queries | -| Buffered HttpApi endpoint without multipart | Queries, infinite queries, and mutations | -| Buffered HttpApi endpoint with multipart | Mutations with `FormData` | +| API definition | Available operations | +| ------------------------------------------- | -------------------------------------------------- | +| Unary RPC | Queries, infinite queries, and mutations | +| Streaming RPC | Accumulated streamed queries and live queries | +| Buffered HttpApi endpoint without multipart | Queries, metadata, infinite queries, and mutations | +| Buffered HttpApi endpoint with multipart | Mutations with `FormData` | Both factories generate keys for cache reads, writes, prefetching, and invalidation. Query functions forward cancellation to Effect. Mutations use TanStack's normal callbacks; invalidate affected diff --git a/apps/docs/src/content/docs/getting-started/feature-support.md b/apps/docs/src/content/docs/getting-started/feature-support.md index 6782212..9358f65 100644 --- a/apps/docs/src/content/docs/getting-started/feature-support.md +++ b/apps/docs/src/content/docs/getting-started/feature-support.md @@ -18,6 +18,10 @@ Unary RPCs and buffered HTTP endpoints without multipart provide all three build multipart HTTP endpoints provide mutations only. Writes refresh cached reads only when your application invalidates the affected keys. +Query-enabled buffered HTTP endpoints also expose `metadataOptions` for decoded data, status, and +raw response headers with separate cache identity. See +[Update with an ETag](/effect-api-query/guides/http-queries-and-mutations/#update-with-an-etag). + RPC streams also have `streamedOptions` to accumulate values and `liveOptions` to retain the latest value. Buffered multipart HTTP uploads accept explicit `FormData` through `mutationOptions`; see [upload a file](/effect-api-query/guides/http-queries-and-mutations/#upload-a-file). diff --git a/apps/docs/src/content/docs/guides/http-queries-and-mutations.md b/apps/docs/src/content/docs/guides/http-queries-and-mutations.md index 5af7775..a6a3c4b 100644 --- a/apps/docs/src/content/docs/guides/http-queries-and-mutations.md +++ b/apps/docs/src/content/docs/guides/http-queries-and-mutations.md @@ -51,6 +51,51 @@ React applications can pass `userOptions` directly to `useQuery`, `useSuspenseQu data type without changing the cached value. `queryClient.getQueryData(userOptions.queryKey)` still infers the decoded user type. +## Update with an ETag + +Use a metadata view to read decoded data, status, and raw response headers together. For this +recipe, `users.get` returns a user and its server supplies an ETag. The `users.update` endpoint +declares numeric `id` params, a string `if-match` header, a `{ name: string }` payload, and its +precondition failure as a declared error. + +```ts +const input = { params: { id: 1 } } +const metadataOptions = http.users.get.metadataOptions({ input }) +const current = await queryClient.query(metadataOptions) +const etag = current.headers['etag'] +if (etag === undefined) throw new Error('The server supplied no ETag') + +await queryClient.cancelQueries({ queryKey: http.users.get.key() }) +const update = new MutationObserver( + queryClient, + http.users.update.mutationOptions({ + onSuccess: () => queryClient.invalidateQueries({ queryKey: http.users.get.key() }), + }), +) +await update.mutate({ + params: input.params, + headers: { 'if-match': etag }, + payload: { name: 'Ada' }, +}) + +const refreshed = await queryClient.query(metadataOptions) +``` + +Send the ETag unchanged, including quotes. The server owns conditional-write enforcement; handle a +declared precondition failure through the mutation's HTTP execution error. Invalidation uses the +endpoint prefix so both ordinary data and metadata views refresh. Read the next ETag from the fresh +metadata result before another write. + +`current.data` retains the endpoint's decoded success, including any declared decoded header +wrapper. A top-level successful `undefined` becomes `null`. `current.status` is the response status; +`current.headers` is a copied, frozen plain string record. The outer result is frozen, while decoded +data keeps its declared representation. Raw headers can contain private values: choose which headers +your application may persist or dehydrate. + +`metadataKey(input)` provides a typed key for cache reads and writes. Metadata options support +selection, initial data, skipped input, native hashing defaults, and cancellation like ordinary +queries. Metadata has its own cache identity and no mutation or infinite-query builders. + ## Wait for request input Use `skipToken` until the complete request is available: diff --git a/apps/docs/src/content/docs/reference/errors.md b/apps/docs/src/content/docs/reference/errors.md index e2d01c9..2aea5c3 100644 --- a/apps/docs/src/content/docs/reference/errors.md +++ b/apps/docs/src/content/docs/reference/errors.md @@ -73,7 +73,8 @@ for each code's trigger. ## `EffectHttpApiQueryError` Thrown when the HTTP runner returns a failed `Exit`. It exposes `apiId`, `groupId`, `endpoint`, -`method`, `operation`, and the full `Cause.Cause`. The operation is `query`, `infinite`, or `mutation`. +`method`, `operation`, and the full `Cause.Cause`. The operation is `query`, `infinite`, `mutation`, +or `metadata`. Use `isEffectHttpApiQueryError(value)` to narrow errors within the same JavaScript realm. The Cause preserves endpoint, middleware, Schema, and HTTP client errors, including defects and diff --git a/apps/docs/src/content/docs/reference/http-factory.md b/apps/docs/src/content/docs/reference/http-factory.md index fb9c946..4ee7fc7 100644 --- a/apps/docs/src/content/docs/reference/http-factory.md +++ b/apps/docs/src/content/docs/reference/http-factory.md @@ -12,7 +12,8 @@ For a step-by-step example, see [HTTP Queries and Mutations](/effect-api-query/g Ordinary groups appear as `utils[groupIdentifier][endpointIdentifier]`. Top-level groups place their endpoints at `utils[endpointIdentifier]`. Identifiers containing dots remain literal properties. Every retained branch and endpoint has `key()`; buffered endpoints without multipart have -`queryKey`, `queryOptions`, `mutationKey`, `mutationOptions`, `infiniteKey`, and `infiniteOptions`. +`queryKey`, `queryOptions`, `metadataKey`, `metadataOptions`, `mutationKey`, `mutationOptions`, +`infiniteKey`, and `infiniteOptions`. An endpoint with any buffered multipart payload alternative exposes only `key`, `mutationKey`, and `mutationOptions`, even when it also accepts plain payload alternatives. This classification applies regardless of HTTP method; applications choose the builder for endpoints with query support. @@ -47,7 +48,7 @@ query filter still needs `input: { query: {} }`. A `Schema.FiniteFromString` fie which the ready client encodes as a string. Raw response controls are excluded from query input, mutation variables, and encoder input. -The adapter forces decoded-only responses. Queries cache a successful `undefined` as `null`; +Ordinary queries and mutations use decoded-only responses. Queries cache a successful `undefined` as `null`; mutations retain `undefined`. Buffered text stays a string, binary data stays a `Uint8Array`, and declared response-header wrappers retain their decoded body and headers. Applications own the serialization strategy for binary and other domain values; the package supplies no automatic SSR @@ -58,7 +59,8 @@ client errors, and additional ready-client errors in the wrapped Cause's type. Required services include those needed by request encoders, success and error decoders, and the ready client for exposed endpoints. Compatible custom clients retain the errors and remaining -service requirements of their decoded-only call signatures; raw-response overloads contribute neither. +service requirements of their decoded-only and decoded-and-response call signatures; +response-only overloads contribute neither. See [client lifecycle](/effect-api-query/concepts/client-lifecycle/#http-clients-and-execution-services) and [cancellation](/effect-api-query/guides/cancellation/#cancel-an-http-query) for runtime ownership. @@ -92,6 +94,35 @@ Inputless builders, key builders, and mutations reject `skipToken`. A skipped fu through manual refetch; supply valid input or use `enabled: false` with a complete request when manual execution is required. Native suspense and prefetch-only hooks reject skipped options. +## Buffered metadata + +Query-enabled buffered endpoints also provide `metadataKey(request)` and `metadataOptions({ input, +...options })`; inputless endpoints omit the request. The ready client runs in decoded-and-response +mode, and the cache contains: + +```ts +type Metadata = { + readonly data: QueryData + readonly status: number + readonly headers: Readonly> +} +``` + +The outer envelope and copied plain header record are frozen. `data` preserves the declared decoded +value and its ownership, including decoded `WithHeaders` wrappers. Only top-level successful +`undefined` becomes `null`. Raw headers retain their string values without Effect's inspection +redaction; applications decide which headers may be persisted or dehydrated. + +Metadata keys use a `metadata` discriminator and the ordinary request identity. Endpoint prefixes +match every view. Native `select`, `initialData`, skip-token inference, QueryClient hashing defaults, +and cancellation apply to metadata options. Execution failures identify operation `metadata` and +preserve that client mode's full error and service channels. Metadata adds no mutation or infinite +builders and is absent on multipart and streaming endpoints. + +The cache retains the decoded value and metadata snapshot, without a response object or open body. +See [Update with an ETag](/effect-api-query/guides/http-queries-and-mutations/#update-with-an-etag) +for conditional writes. + ## Pagination `infiniteOptions` requires `initialPageParam` and `getNextPageParam`. For an input-bearing endpoint, diff --git a/scripts/verify-packed-consumer.mts b/scripts/verify-packed-consumer.mts index 453b057..d4b81e3 100644 --- a/scripts/verify-packed-consumer.mts +++ b/scripts/verify-packed-consumer.mts @@ -506,6 +506,8 @@ const verifyConsumer = (peer: (typeof peerCases)[number]): void => { 'hashing-runtime.mts', 'runtime.mts', 'http-runtime.mts', + 'http-metadata-runtime.mts', + 'http-etag-recipe.mts', 'stream-cause-runtime.mts', ]) { execFileSync(process.execPath, ['--experimental-import-meta-resolve', fixture], { diff --git a/src/core/operation.ts b/src/core/operation.ts index b2accb0..dc45e3d 100644 --- a/src/core/operation.ts +++ b/src/core/operation.ts @@ -36,6 +36,10 @@ export interface BufferedOperation extends OperationIdentity { export interface UnaryOperation extends BufferedOperation { readonly kind: 'Unary' readonly input: OperationInput + readonly metadata?: { + readonly invoke: BufferedOperation['invoke'] + readonly executionError: (operation: 'metadata', cause: Cause.Cause) => Error + } } export interface MutationOperation extends BufferedOperation { diff --git a/src/core/utility-tree.ts b/src/core/utility-tree.ts index 660c4ab..08e5a5e 100644 --- a/src/core/utility-tree.ts +++ b/src/core/utility-tree.ts @@ -22,6 +22,8 @@ const reservedPathSegments = new Set([ 'key', 'liveKey', 'liveOptions', + 'metadataKey', + 'metadataOptions', 'mutationKey', 'mutationOptions', 'prototype', @@ -172,7 +174,7 @@ const planPaths = (operations: readonly OperationDescription[], errors: TreeErro return plan } -const execute = async ( +const execute = async ( description: { readonly invoke: UnaryOperation['invoke'] readonly executionError: (operation: Operation, cause: Cause.Cause) => Error @@ -384,6 +386,19 @@ const createUnaryLeaf = ( async ({ signal }: { readonly signal: AbortSignal }) => execute(description, 'query', input, runPromiseExit, requestOptions, signal), ) + const { metadata } = description + const metadataQuery = + metadata === undefined + ? undefined + : createQueryBuilders( + description, + freezeKey([...operationKey, 'metadata']), + keyEncoder, + (_options, requestOptions) => + (input) => + async ({ signal }: { readonly signal: AbortSignal }) => + execute(metadata, 'metadata', input, runPromiseExit, requestOptions, signal), + ) return Object.freeze({ ...createInfiniteBuilders(description, operationKey, keyEncoder, runPromiseExit), @@ -391,6 +406,9 @@ const createUnaryLeaf = ( ...createMutationBuilders(description, operationKey, runPromiseExit), queryKey: query.key, queryOptions: query.options, + ...(metadataQuery === undefined + ? undefined + : { metadataKey: metadataQuery.key, metadataOptions: metadataQuery.options }), }) } diff --git a/src/http/errors.ts b/src/http/errors.ts index 730f1d0..7eaf678 100644 --- a/src/http/errors.ts +++ b/src/http/errors.ts @@ -9,7 +9,7 @@ export interface HttpApiEndpointIdentity { } /** TanStack operations supported by buffered HTTP endpoints. */ -export type HttpApiOperation = 'query' | 'infinite' | 'mutation' +export type HttpApiOperation = 'query' | 'infinite' | 'mutation' | 'metadata' /** Stable codes for invalid HTTP utility configuration. */ export type EffectHttpApiQueryConfigErrorCode = diff --git a/src/http/operation.ts b/src/http/operation.ts index 91cbf9b..ba95054 100644 --- a/src/http/operation.ts +++ b/src/http/operation.ts @@ -1,5 +1,6 @@ -import { Predicate } from 'effect' -import type { Effect } from 'effect' +import { Effect, Predicate } from 'effect' +import type { Cause } from 'effect' +import type { HttpClientResponse } from 'effect/http' import type { HttpApi } from 'effect/http-api' import type { @@ -16,6 +17,16 @@ type HttpOperation = (UnaryOperation | MutationOperation) & { readonly identity: HttpApiEndpointIdentity } +type RuntimeHttpMethod = ( + request: Record & { readonly responseMode: Mode }, +) => Effect.Effect< + Mode extends 'decoded-and-response' + ? readonly [unknown, HttpClientResponse.HttpClientResponse] + : unknown, + unknown, + unknown +> + export interface CompiledHttpOperations { readonly operations: readonly (UnaryOperation | MutationOperation)[] readonly errors: TreeErrors @@ -42,8 +53,22 @@ const extractHttpEndpoints = (api: HttpApi.Top, client: unknown): readonly HttpO const target = // oxlint-disable-next-line typescript/no-unsafe-type-assertion (group.topLevel ? client : (client as Record)[group.identifier]) as Record< string, - (request: unknown) => Effect.Effect + RuntimeHttpMethod > + const invoke = ( + requestInput: unknown, + responseMode: Mode, + ) => { + const method = target[endpoint.identifier] + if (method === undefined) { + throw new TypeError(`Missing HTTP client endpoint ${endpoint.identifier}`) + } + // oxlint-disable-next-line effecttsgo/any-unknown-in-error-context + return method.bind(target)({ + ...(Predicate.isObject(requestInput) ? requestInput : undefined), + responseMode, + }) + } operations.push({ ...request, identity, @@ -58,18 +83,25 @@ const extractHttpEndpoints = (api: HttpApi.Top, client: unknown): readonly HttpO takeOptions: () => { // HTTP has no adapter-owned options. }, - invoke: (requestInput) => { - const invoke = target[endpoint.identifier] - if (invoke === undefined) { - throw new TypeError(`Missing HTTP client endpoint ${endpoint.identifier}`) - } - // The ready client preserves caller errors and service requirements. - // oxlint-disable-next-line effecttsgo/any-unknown-in-error-context - return invoke.call(target, { - ...(Predicate.isObject(requestInput) ? requestInput : undefined), - responseMode: 'decoded-only', - }) - }, + // oxlint-disable-next-line effecttsgo/any-unknown-in-error-context + invoke: (requestInput) => invoke(requestInput, 'decoded-only'), + ...(request.kind === 'Unary' + ? { + metadata: { + invoke: (requestInput: unknown) => + // oxlint-disable-next-line effecttsgo/any-unknown-in-error-context + Effect.map(invoke(requestInput, 'decoded-and-response'), ([data, response]) => + Object.freeze({ + data: data === undefined ? null : data, + status: response.status, + headers: Object.freeze(Object.fromEntries(Object.entries(response.headers))), + }), + ), + executionError: (operation: 'metadata', cause: Cause.Cause) => + new EffectHttpApiQueryError(identity, operation, cause), + }, + } + : undefined), executionError: (operation, cause) => new EffectHttpApiQueryError(identity, operation, cause), }) diff --git a/src/http/types.ts b/src/http/types.ts index 85c88e3..5732018 100644 --- a/src/http/types.ts +++ b/src/http/types.ts @@ -120,8 +120,9 @@ export type MethodSignatures< export type DecodedEffect< Method, Endpoint extends HttpApiEndpoint.ConstraintRequest, + Mode extends 'decoded-only' | 'decoded-and-response' = 'decoded-only', > = Method extends ( - request: RequestFields & { readonly responseMode: 'decoded-only' }, + request: RequestFields & { readonly responseMode: Mode }, ) => infer Result ? Result : never @@ -129,16 +130,22 @@ export type ClientEffect< Group, Endpoint extends HttpApiEndpoint.ConstraintRequest, Client, + Mode extends 'decoded-only' | 'decoded-and-response' = 'decoded-only', > = DecodedEffect< MethodSignatures, Endpoint['identifier']>>, - Endpoint + Endpoint, + Mode > export type ExposedEffects = SupportedGroups extends infer Group ? Group extends HttpApiGroup.Constraint ? Supported> extends infer Endpoint ? Endpoint extends HttpApiEndpoint.ConstraintRequest - ? ClientEffect + ? + | ClientEffect + | ([Queryable] extends [never] + ? never + : ClientEffect) : never : never : never @@ -180,6 +187,36 @@ export type QueryBuilder< readonly [...Key, 'query'] > +export type MetadataData = { + readonly data: QueryData> + readonly status: number + readonly headers: Readonly> +} + +export type ConcreteMetadataKey< + Endpoint extends HttpApiEndpoint.ConstraintRequest, + Key extends readonly JsonValue[], + ClientError, +> = DataTag< + void extends Request + ? readonly [...Key, 'metadata'] + : readonly [...Key, 'metadata', JsonValue], + MetadataData, + Failure +> + +export type MetadataBuilder< + Endpoint extends HttpApiEndpoint.ConstraintRequest, + Key extends readonly JsonValue[], + ClientError, +> = UnaryQueryBuilder< + Request, + MetadataData, + Failure, + ConcreteMetadataKey, + readonly [...Key, 'metadata'] +> + export type ConcreteInfiniteKey< Endpoint extends HttpApiEndpoint.ConstraintRequest, Key extends readonly JsonValue[], @@ -224,6 +261,7 @@ export type Leaf< Endpoint extends HttpApiEndpoint.ConstraintRequest, Key extends readonly JsonValue[], ClientError, + MetadataClientError = ClientError, > = MutationLeaf & ([Queryable] extends [never] ? unknown @@ -232,6 +270,10 @@ export type Leaf< ? () => ConcreteKey : (input: Request) => ConcreteKey readonly queryOptions: QueryBuilder + readonly metadataKey: void extends Request + ? () => ConcreteMetadataKey + : (input: Request) => ConcreteMetadataKey + readonly metadataOptions: MetadataBuilder readonly infiniteKey: void extends Request ? () => ConcreteInfiniteKey : (input: Request) => ConcreteInfiniteKey @@ -254,7 +296,8 @@ export type HttpApiQueryUtils< readonly [Endpoint in Supported> as Endpoint['identifier']]: Leaf< Endpoint, EndpointKey, - Effect.Error> + Effect.Error>, + Effect.Error> > } } & { @@ -265,7 +308,10 @@ export type HttpApiQueryUtils< ]: Leaf< Endpoint, readonly [...Root, Endpoint['identifier']], - Effect.Error> + Effect.Error>, + Effect.Error< + ClientEffect<{ readonly topLevel: true }, Endpoint, Client, 'decoded-and-response'> + > > } diff --git a/tests/create-http-api-query-utils.test.ts b/tests/create-http-api-query-utils.test.ts index 110ac86..8e57e92 100644 --- a/tests/create-http-api-query-utils.test.ts +++ b/tests/create-http-api-query-utils.test.ts @@ -306,6 +306,8 @@ describe(createHttpApiQueryUtils, () => { 'mutationOptions', 'queryKey', 'queryOptions', + 'metadataKey', + 'metadataOptions', ]) expect(Object.isFrozen(utils)).toBe(true) expect(Object.isFrozen(utils['users.v1'])).toBe(true) diff --git a/tests/packed-consumer/http-etag-recipe.mts b/tests/packed-consumer/http-etag-recipe.mts new file mode 100644 index 0000000..5b525a8 --- /dev/null +++ b/tests/packed-consumer/http-etag-recipe.mts @@ -0,0 +1,88 @@ +import { MutationObserver, QueryClient } from '@tanstack/query-core' +import { Effect, Layer, Schema } from 'effect' +import { createHttpApiQueryUtils } from 'effect-api-query' +import { HttpServer, HttpServerResponse } from 'effect/http' +import { + HttpApi, + HttpApiBuilder, + HttpApiEndpoint, + HttpApiGroup, + HttpApiSchema, + HttpApiTest, +} from 'effect/http-api' +import { equal } from 'node:assert/strict' + +const User = Schema.Struct({ id: Schema.Finite, name: Schema.String }) +const api = HttpApi.make('etag-recipe').add( + HttpApiGroup.make('users').add( + HttpApiEndpoint.get('get', '/users/:id', { + params: { id: Schema.FiniteFromString }, + success: User, + }), + HttpApiEndpoint.put('update', '/users/:id', { + params: { id: Schema.FiniteFromString }, + headers: { 'if-match': Schema.String }, + payload: Schema.Struct({ name: Schema.String }), + success: User, + error: Schema.Literal('precondition-failed').pipe(HttpApiSchema.status(412)), + }), + ), +) +let name = 'Grace' +let revision = 1 +const handlers = HttpApiBuilder.group(api, 'users', (group) => + group + .handleRaw('get', ({ params }) => + HttpServerResponse.json( + { id: params.id, name }, + { + headers: { etag: `"revision-${revision}"` }, + }, + ).pipe(Effect.orDie), + ) + .handle('update', ({ params, headers, payload }) => { + if (headers['if-match'] !== `"revision-${revision}"`) + return Effect.fail('precondition-failed') + name = payload.name + revision += 1 + return Effect.succeed({ id: params.id, name }) + }), +) +await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const client = yield* HttpApiTest.groups(api, ['users']) + const http = createHttpApiQueryUtils(api, { client, keyPrefix: ['etag'] }) + const queryClient = new QueryClient({ + defaultOptions: { queries: { staleTime: Infinity, retry: false } }, + }) + yield* Effect.addFinalizer(() => Effect.sync(() => queryClient.clear())) + yield* Effect.promise(async () => { + const input = { params: { id: 1 } } + const metadataOptions = http.users.get.metadataOptions({ input }) + const current = await queryClient.query(metadataOptions) + const etag = current.headers['etag'] + if (etag === undefined) throw new Error('The server supplied no ETag') + + await queryClient.cancelQueries({ queryKey: http.users.get.key() }) + const update = new MutationObserver( + queryClient, + http.users.update.mutationOptions({ + onSuccess: () => queryClient.invalidateQueries({ queryKey: http.users.get.key() }), + }), + ) + await update.mutate({ + params: input.params, + headers: { 'if-match': etag }, + payload: { name: 'Ada' }, + }) + + const refreshed = await queryClient.query(metadataOptions) + equal(current.data.name, 'Grace') + equal(refreshed.data.name, 'Ada') + equal(etag, '"revision-1"') + equal(refreshed.headers['etag'], '"revision-2"') + }) + }).pipe(Effect.provide(Layer.mergeAll(handlers, HttpServer.layerServices))), + ), +) diff --git a/tests/packed-consumer/http-metadata-runtime.mts b/tests/packed-consumer/http-metadata-runtime.mts new file mode 100644 index 0000000..48c2bbb --- /dev/null +++ b/tests/packed-consumer/http-metadata-runtime.mts @@ -0,0 +1,341 @@ +import { MutationObserver, QueryClient, QueryObserver } from '@tanstack/query-core' +import { Cause, Effect, Exit, Layer, Result, Schema } from 'effect' +import { + createHttpApiQueryUtils, + EffectHttpApiQueryConfigError, + isEffectHttpApiQueryError, + skipToken, + type RunPromiseExit, +} from 'effect-api-query' +import { HttpClient, HttpClientResponse, HttpServer } from 'effect/http' +import { + HttpApi, + HttpApiBuilder, + HttpApiClient, + HttpApiEndpoint, + HttpApiGroup, + HttpApiSchema, + HttpApiTest, +} from 'effect/http-api' +import { deepStrictEqual, equal, notDeepStrictEqual, ok, rejects, throws } from 'node:assert/strict' + +const Conflict = Schema.TaggedStruct('Conflict', {}).pipe(HttpApiSchema.status(412)) + +const api = HttpApi.make('packed-metadata').add( + HttpApiGroup.make('documents').add( + HttpApiEndpoint.post('read', '/documents/:id', { + params: { id: Schema.FiniteFromString }, + query: { factor: Schema.FiniteFromString }, + headers: { 'x-request-version': Schema.FiniteFromString }, + payload: Schema.Struct({ amount: Schema.FiniteFromString }), + success: HttpApiSchema.WithHeaders(Schema.FiniteFromString, { + 'x-version': Schema.FiniteFromString, + etag: Schema.String, + }).pipe(HttpApiSchema.status(203)), + error: Schema.Literal('missing'), + }), + HttpApiEndpoint.put('update', '/documents/:id', { + params: { id: Schema.FiniteFromString }, + headers: { 'if-match': Schema.String }, + payload: Schema.Struct({ value: Schema.FiniteFromString }), + success: Schema.FiniteFromString, + error: Conflict, + }), + ), + HttpApiGroup.make('system', { topLevel: true }).add( + HttpApiEndpoint.get('empty', '/empty', { success: HttpApiSchema.NoContent }), + HttpApiEndpoint.get('wrappedEmpty', '/wrapped-empty', { + success: HttpApiSchema.WithHeaders(HttpApiSchema.NoContent, { etag: Schema.String }), + }), + ), +) + +const requests: unknown[] = [] +let revision = 4 +let savedValue: number | undefined +const handlers = Layer.mergeAll( + HttpApiBuilder.group(api, 'documents', (group) => + group + .handle('read', ({ params, query, headers, payload }) => { + requests.push({ params, query, headers, payload }) + if (params.id < 0) return Effect.fail('missing') + return Effect.succeed( + HttpApiSchema.withHeaders({ + body: + savedValue ?? + params.id + query.factor + headers['x-request-version'] + payload.amount, + headers: { 'x-version': revision, etag: `"revision-${revision}"` }, + }), + ) + }) + .handle('update', ({ headers, payload }) => { + if (headers['if-match'] !== `"revision-${revision}"`) { + return Effect.fail({ _tag: 'Conflict' as const }) + } + savedValue = payload.value + revision += 1 + return Effect.succeed(savedValue) + }), + ), + HttpApiBuilder.group(api, 'system', (group) => + group + .handle('empty', () => Effect.void) + .handle('wrappedEmpty', () => + Effect.succeed(HttpApiSchema.withHeaders({ body: undefined, headers: { etag: 'empty' } })), + ), + ), +) + +await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const client = yield* HttpApiTest.groups(api, ['documents', 'system']) + const http = createHttpApiQueryUtils(api, { client, keyPrefix: ['metadata'] }) + const queryClient = new QueryClient({ + defaultOptions: { queries: { staleTime: Infinity, retry: false } }, + }) + yield* Effect.addFinalizer(() => Effect.sync(() => queryClient.clear())) + const input = { + params: { id: 1 }, + query: { factor: 2 }, + headers: { 'x-request-version': 3 }, + payload: { amount: 7 }, + } + const options = http.documents.read.metadataOptions({ input }) + const metadata = yield* Effect.promise(() => queryClient.query(options)) + deepStrictEqual( + metadata.data, + HttpApiSchema.withHeaders({ + body: 13, + headers: { 'x-version': 4, etag: '"revision-4"' }, + }), + ) + equal(metadata.status, 203) + equal(metadata.headers['x-version'], '4') + equal(metadata.headers['etag'], '"revision-4"') + equal(Object.getPrototypeOf(metadata), Object.prototype) + equal(Object.getPrototypeOf(metadata.headers), Object.prototype) + equal(Object.isFrozen(metadata), true) + equal(Object.isFrozen(metadata.headers), true) + deepStrictEqual(Object.keys(metadata).sort(), ['data', 'headers', 'status']) + deepStrictEqual(requests, [input]) + deepStrictEqual(http.documents.read.metadataKey(input), options.queryKey) + notDeepStrictEqual(options.queryKey, http.documents.read.queryKey(input)) + const data = yield* Effect.promise(() => + queryClient.query(http.documents.read.queryOptions({ input })), + ) + deepStrictEqual(data, metadata.data) + equal(queryClient.getQueryCache().findAll({ queryKey: http.documents.read.key() }).length, 2) + const selected = new QueryObserver( + queryClient, + http.documents.read.metadataOptions({ + input, + select: (view) => view.data.body, + }), + ) + equal(selected.getCurrentResult().data, 13) + equal(queryClient.getQueryData(options.queryKey), metadata) + const empty = yield* Effect.promise(() => queryClient.query(http.empty.metadataOptions())) + equal(empty.data, null) + equal(empty.status, 204) + const wrappedEmpty = yield* Effect.promise(() => + queryClient.query(http.wrappedEmpty.metadataOptions()), + ) + deepStrictEqual( + wrappedEmpty.data, + HttpApiSchema.withHeaders({ body: undefined, headers: { etag: 'empty' } }), + ) + + const etag = metadata.headers['etag'] + ok(etag !== undefined) + yield* Effect.promise(() => + queryClient.cancelQueries({ queryKey: http.documents.read.key() }), + ) + const update = new MutationObserver( + queryClient, + http.documents.update.mutationOptions({ + onSuccess: () => queryClient.invalidateQueries({ queryKey: http.documents.read.key() }), + }), + ) + equal( + yield* Effect.promise(() => + update.mutate({ + params: { id: 1 }, + headers: { 'if-match': etag }, + payload: { value: 29 }, + }), + ), + 29, + ) + equal(queryClient.getQueryState(options.queryKey)?.isInvalidated, true) + equal(queryClient.getQueryState(http.documents.read.queryKey(input))?.isInvalidated, true) + const refreshed = yield* Effect.promise(() => queryClient.query(options)) + equal(refreshed.data.body, 29) + equal(refreshed.headers['etag'], '"revision-5"') + equal(metadata.headers['etag'], '"revision-4"') + yield* Effect.promise(() => + rejects( + update.mutate({ + params: { id: 1 }, + headers: { 'if-match': etag }, + payload: { value: 30 }, + }), + (error: unknown) => { + ok(isEffectHttpApiQueryError(error)) + equal(error.operation, 'mutation') + const failure = Cause.findError(error.cause) + ok(Result.isSuccess(failure)) + deepStrictEqual(failure.success, { _tag: 'Conflict' }) + return true + }, + ), + ) + + const skipped = http.documents.read.metadataOptions({ input: skipToken, staleTime: 123 }) + equal(skipped.queryFn, skipToken) + equal(skipped.staleTime, 123) + deepStrictEqual(skipped.queryKey, [...http.documents.read.key(), 'metadata']) + for (const option of ['queryKeyHashFn', 'queryHash']) { + throws( + () => http.documents.read.metadataOptions({ input: skipToken, [option]: undefined }), + (error: unknown) => + error instanceof EffectHttpApiQueryConfigError && error.code === 'UnsupportedQueryHash', + ) + } + + let completeCause: Cause.Cause | undefined + const runPromiseExit: RunPromiseExit = async (effect, runnerOptions) => { + const exit = await Effect.runPromiseExit(effect, runnerOptions) + if (Exit.isSuccess(exit)) return exit + const cause = Cause.combine( + exit.cause, + Cause.combine(Cause.die('defect'), Cause.interrupt(123)), + ) + completeCause = cause + return Exit.failCause(cause) + } + const failing = createHttpApiQueryUtils(api, { + client, + keyPrefix: ['failing'], + runPromiseExit, + }) + yield* Effect.promise(() => + rejects( + queryClient.query( + failing.documents.read.metadataOptions({ + input: { ...input, params: { id: -1 } }, + }), + ), + (error: unknown) => { + ok(isEffectHttpApiQueryError(error)) + equal(error.apiId, 'packed-metadata') + equal(error.groupId, 'documents') + equal(error.endpoint, 'read') + equal(error.method, 'POST') + equal(error.operation, 'metadata') + equal(error.cause, completeCause) + deepStrictEqual( + error.cause.reasons.map((reason) => reason._tag), + ['Fail', 'Die', 'Interrupt'], + ) + return true + }, + ), + ) + const rejected = new Error('runner rejected') + const rejectingRunner: RunPromiseExit = () => Promise.reject(rejected) + const rejecting = createHttpApiQueryUtils(api, { + client, + keyPrefix: ['rejection'], + runPromiseExit: rejectingRunner, + }) + yield* Effect.promise(() => + rejects( + queryClient.query(rejecting.empty.metadataOptions()), + (error: unknown) => error === rejected, + ), + ) + }).pipe(Effect.provide(Layer.mergeAll(handlers, HttpServer.layerServices))), + ), +) + +const wireApi = HttpApi.make('metadata-wire').add( + HttpApiGroup.make('documents').add( + HttpApiEndpoint.get('read', '/read', { + success: HttpApiSchema.WithHeaders(Schema.FiniteFromString, { + 'x-version': Schema.FiniteFromString, + }), + }), + ), +) +let response: HttpClientResponse.HttpClientResponse | undefined +let web: Response | undefined +let responseReady: () => void = () => undefined +let endBody: () => void = () => undefined +const client = await Effect.runPromise( + HttpApiClient.makeWith(wireApi, { + baseUrl: 'https://metadata.test', + httpClient: HttpClient.make((request) => { + web = new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('"42"')) + endBody = () => controller.close() + }, + }), + { + headers: { + 'content-type': 'application/json', + 'x-version': '4', + authorization: 'raw-header-value', + }, + }, + ) + response = HttpClientResponse.fromWeb(request, web) + responseReady() + return Effect.succeed(response) + }), + }), +) +for (const mode of ['global', 'prefix'] as const) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: Infinity, + retry: false, + ...(mode === 'global' ? { queryKeyHashFn: (key) => `global:${JSON.stringify(key)}` } : {}), + }, + }, + }) + try { + const http = createHttpApiQueryUtils(wireApi, { client, keyPrefix: ['wire', mode] }) + if (mode === 'prefix') + queryClient.setQueryDefaults(http.key(), { + queryKeyHashFn: (key) => `prefix:${JSON.stringify(key)}`, + }) + const received = new Promise((resolve) => { + responseReady = resolve + }) + const pending = queryClient.query(http.documents.read.metadataOptions()) + await received + equal(queryClient.getQueryData(http.documents.read.metadataKey()), undefined) + equal(queryClient.isFetching(), 1) + endBody() + const view = await pending + equal(view.data.body, 42) + equal(view.headers['authorization'], 'raw-header-value') + equal(view.headers['x-version'], '4') + equal(web?.bodyUsed, true) + notDeepStrictEqual( + Object.getPrototypeOf(view.headers), + Object.getPrototypeOf(response?.headers), + ) + ok(view.headers !== response?.headers) + equal(queryClient.getQueryData(http.documents.read.metadataKey()), view) + queryClient.setQueryData(http.documents.read.metadataKey(), { ...view, status: 203 }) + equal(queryClient.getQueryData(http.documents.read.metadataKey())?.status, 203) + equal(queryClient.getQueryCache().findAll({ queryKey: http.documents.read.key() }).length, 1) + } finally { + queryClient.clear() + } +} diff --git a/tests/packed-consumer/http-runtime.mts b/tests/packed-consumer/http-runtime.mts index cca3a0f..96cac2c 100644 --- a/tests/packed-consumer/http-runtime.mts +++ b/tests/packed-consumer/http-runtime.mts @@ -86,7 +86,7 @@ const cancellationApi = HttpApi.make('packed-cancellation').add( }), ), ) -for (const mode of ['query', 'later page'] as const) { +for (const mode of ['query', 'metadata', 'metadata body', 'later page'] as const) { let received!: () => void let disconnected!: () => void const requestReceived = new Promise((resolve) => { @@ -104,6 +104,10 @@ for (const mode of ['query', 'later page'] as const) { return } response.on('close', disconnected) + if (mode === 'metadata body') { + response.setHeader('content-type', 'application/json') + response.write('"pending') + } received() }) const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) @@ -140,23 +144,45 @@ for (const mode of ['query', 'later page'] as const) { const pending = mode === 'query' ? queryClient.query(utils.pages.read.queryOptions({ input: { params: { page: 1 } } })) - : queryClient.infiniteQuery({ - ...utils.pages.read.infiniteOptions({ - initialPageParam: 0, - input: (page) => ({ params: { page } }), - getNextPageParam: (_last, _pages, page) => page + 1, - }), - pages: 2, - }) + : mode === 'metadata' || mode === 'metadata body' + ? queryClient.query( + utils.pages.read.metadataOptions({ + input: { params: { page: 1 } }, + initialData: { data: 'cached', status: 200, headers: { etag: 'cached' } }, + }), + ) + : queryClient.infiniteQuery({ + ...utils.pages.read.infiniteOptions({ + initialPageParam: 0, + input: (page) => ({ params: { page } }), + getNextPageParam: (_last, _pages, page) => page + 1, + }), + pages: 2, + }) const result = pending.catch((error: unknown) => error) await Promise.race([requestReceived, deadline]) equal(requestSignal?.aborted, false) await queryClient.cancelQueries({ queryKey: utils.pages.read.key() }) - ok(isCancelledError(await result)) + const settled = await result + if (mode === 'metadata' || mode === 'metadata body') { + deepStrictEqual(settled, { data: 'cached', status: 200, headers: { etag: 'cached' } }) + } else { + ok(isCancelledError(settled)) + } equal(requestSignal?.aborted, true) ok(Cause.hasInterrupts(await Promise.race([interruption, deadline]))) await Promise.race([requestDisconnected, deadline]) - deepStrictEqual(paths, mode === 'query' ? ['/pages/1'] : ['/pages/0', '/pages/1']) + deepStrictEqual(paths, mode === 'later page' ? ['/pages/0', '/pages/1'] : ['/pages/1']) + if (mode === 'metadata' || mode === 'metadata body') { + deepStrictEqual( + queryClient.getQueryData(utils.pages.read.metadataKey({ params: { page: 1 } })), + { + data: 'cached', + status: 200, + headers: { etag: 'cached' }, + }, + ) + } equal(queryClient.isFetching(), 0) } finally { clearTimeout(timeout) diff --git a/tests/packed-consumer/tsconfig.json b/tests/packed-consumer/tsconfig.json index 1c52a04..70022ab 100644 --- a/tests/packed-consumer/tsconfig.json +++ b/tests/packed-consumer/tsconfig.json @@ -16,6 +16,8 @@ "runtime.mts", "stream-cause-runtime.mts", "http-runtime.mts", + "http-metadata-runtime.mts", + "http-etag-recipe.mts", "public-contract.ts", "http-contract.ts", "docs-rpc-quick-start.ts", diff --git a/tests/types/http-contract.ts b/tests/types/http-contract.ts index 7841e35..9c1ab42 100644 --- a/tests/types/http-contract.ts +++ b/tests/types/http-contract.ts @@ -169,6 +169,8 @@ true satisfies Assert< | 'mutationOptions' | 'infiniteKey' | 'infiniteOptions' + | 'metadataKey' + | 'metadataOptions' > > true satisfies Assert, []>> @@ -970,6 +972,9 @@ declare const overloadedServiceMethod: typeof serviceClient.work.serviceful & RawResponseMethod & SpecificRawResponseMethod const overloadedServiceClient = { work: { serviceful: overloadedServiceMethod } } +declare const metadataRunner: RunPromiseExit< + EncodeRequest | DecodeSuccess | DecodeError | ExtraClientService | RawResponseService +> // @ts-expect-error A trailing raw-response overload cannot hide decoded execution services. createHttpApiQueryUtils(serviceApi, { client: overloadedServiceClient, @@ -980,14 +985,14 @@ const overloadedServiceUtils = createHttpApiQueryUtils(serviceApi, { client: overloadedServiceClient, keyPrefix: ['app'], keyEncoders: { work: { serviceful: encoder } }, - runPromiseExit: runner, + runPromiseExit: metadataRunner, }) type OverloadedRequirements = CreateHttpApiQueryUtilsOptions< typeof serviceApi, readonly ['app'], typeof overloadedServiceClient >['runPromiseExit'] -true satisfies Assert> +true satisfies Assert> const overloadedServiceState = queryClient.getQueryState( overloadedServiceUtils.work.serviceful.queryKey({ payload: { id: 1, name: 'Ada' } }), ) @@ -1055,6 +1060,115 @@ type GetFailure = EffectHttpApiQueryError< 'not-found' | HttpClientError.HttpClientError | Schema.SchemaError > const getUser = utils['user.accounts']['get.user'] +const metadata = getUser.metadataOptions({ + input, + select: (view) => view.data.name, + retry: (_count, error) => { + error satisfies GetFailure + return false + }, + staleTime: (query) => { + query.state.data?.data satisfies typeof User.Type | undefined + query.queryKey satisfies ReturnType + return 1_000 + }, +}) +useQuery(metadata).data satisfies string | undefined +useSuspenseQuery(metadata).data satisfies string +usePrefetchQuery(metadata) +type UserMetadata = { + readonly data: typeof User.Type + readonly status: number + readonly headers: Readonly> +} +true satisfies Assert[5], 'metadata'>> +const metadataCache = queryClient.getQueryData(getUser.metadataKey(input)) +true satisfies Assert> +const initialMetadata = { data: { id: 1, name: 'Ada' }, status: 200, headers: { etag: 'v1' } } +const definedMetadata = useQuery( + getUser.metadataOptions({ + input, + initialData: initialMetadata, + select: (view) => view.data.name, + }), +) +true satisfies Assert> +declare const optionalMetadata: UserMetadata | undefined +const optionalMetadataHook = useQuery( + getUser.metadataOptions({ + input, + initialData: optionalMetadata, + select: (view) => view.data.name, + }), +) +true satisfies Assert> +const skippedMetadata = getUser.metadataOptions({ input: skipToken, initialData: initialMetadata }) +skippedMetadata.queryFn satisfies SkipToken +skippedMetadata.queryKey satisfies readonly [ + 'app', + 'http', + 'account.api', + 'user.accounts', + 'get.user', + 'metadata', +] +const skippedMetadataHook = useQuery(skippedMetadata) +true satisfies Assert> +getUser.metadataOptions(skipToken).queryFn satisfies SkipToken +const conditionalMetadata = getUser.metadataOptions({ + input: hasUser ? input : skipToken, + select: (view) => view.status, +}) +useQuery(conditionalMetadata).data satisfies number | undefined +const emptyMetadata = queryClient.getQueryData(utils.ping.metadataKey()) +true satisfies Assert['data'], null>> +const wrappedMetadata = queryClient.getQueryData( + utils['user.accounts'].wrappedBuffered.metadataKey(), +) +true satisfies Assert< + Equal< + NonNullable['data'], + HttpApiSchema.withHeaders + > +> +// @ts-expect-error Metadata snapshots have readonly status. +metadataCache!.status = 201 +// @ts-expect-error Metadata headers form a readonly string snapshot. +metadataCache!.headers['etag'] = 'other' +// @ts-expect-error Metadata is available only for query-enabled buffered requests. +utils.upload.metadataOptions() +// @ts-expect-error Metadata adds no infinite-query builder. +getUser.metadataInfiniteOptions() +// @ts-expect-error Metadata adds no mutation builder. +getUser.metadataMutationOptions() +// @ts-expect-error Metadata views retain complete decoded request containers. +getUser.metadataOptions({ input: { params: { id: 1 } } }) +// @ts-expect-error Response controls belong to the metadata view. +getUser.metadataKey({ ...input, responseMode: 'response-only' }) +// @ts-expect-error Inputless metadata builders cannot be skipped. +utils.ping.metadataOptions(skipToken) +// @ts-expect-error Metadata hash defaults belong to QueryClient. +getUser.metadataOptions({ input: skipToken, queryHash: 'other' }) +// @ts-expect-error Metadata hash functions belong to QueryClient. +getUser.metadataOptions({ input, queryKeyHashFn: JSON.stringify }) +// @ts-expect-error Skipped metadata cannot guarantee suspense execution. +useSuspenseQuery(skippedMetadata) +true satisfies Assert, never>> +const metadataServiceState = queryClient.getQueryState( + overloadedServiceUtils.work.serviceful.metadataKey({ payload: { id: 1, name: 'Ada' } }), +) +true satisfies Assert< + Equal< + NonNullable['error'], + EffectHttpApiQueryError< + | 'service-error' + | 'extra-client-error' + | 'tuple-error' + | HttpClientError.HttpClientError + | Schema.SchemaError + > | null + > +> const nativeQuery = getUser.queryOptions({ input, select: (user) => user.name, From 42b4f9826048c1d687544ba1510312a56f2f48f1 Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Fri, 9 Oct 2026 22:47:37 +0200 Subject: [PATCH 23/97] feat: add accumulated HTTP SSE queries --- .fallowrc.jsonc | 1 + .../docs/concepts/http-utility-tree.md | 15 +- .../docs/guides/http-queries-and-mutations.md | 48 +- .../content/docs/reference/http-factory.md | 32 +- scripts/verify-packed-consumer.mts | 2 + src/core/operation.ts | 1 + src/core/streamed-query.ts | 146 +++++ src/core/utility-tree.ts | 16 +- src/http/create-utils.ts | 1 - src/http/errors.ts | 6 +- src/http/operation.ts | 64 +- src/http/request.ts | 38 +- src/http/streamed-query.ts | 109 ++++ src/http/types.ts | 102 ++- src/rpc/streamed-query.ts | 164 +---- tests/packed-consumer/http-stream-runtime.mts | 583 ++++++++++++++++++ .../packed-consumer/stream-policy-runtime.mts | 13 +- tests/packed-consumer/tsconfig.json | 2 + tests/types/http-stream-contract.ts | 190 ++++++ vite.config.ts | 6 + 20 files changed, 1313 insertions(+), 226 deletions(-) create mode 100644 src/core/streamed-query.ts create mode 100644 src/http/streamed-query.ts create mode 100644 tests/packed-consumer/http-stream-runtime.mts create mode 100644 tests/types/http-stream-contract.ts diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 4a3faaf..0697bd3 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -7,6 +7,7 @@ "apps/docs/public/package-version.js", "playwright.docs.config.ts", "tests/packed-consumer/hashing-runtime.mts", + "tests/packed-consumer/http-stream-runtime.mts", "tests/packed-consumer/stream-cause-runtime.mts", "tests/packed-consumer/stream-policy-runtime.mts" ], diff --git a/apps/docs/src/content/docs/concepts/http-utility-tree.md b/apps/docs/src/content/docs/concepts/http-utility-tree.md index ad6f26b..337d847 100644 --- a/apps/docs/src/content/docs/concepts/http-utility-tree.md +++ b/apps/docs/src/content/docs/concepts/http-utility-tree.md @@ -6,6 +6,7 @@ description: Understand literal HTTP identifiers, top-level groups, and omitted `createHttpApiQueryUtils` builds and freezes an HTTP utility tree when you call it with a literal Effect HttpApi declaration. Buffered endpoints without multipart have ordinary query, infinite-query, and mutation builders. Buffered multipart endpoints have mutation builders only, regardless of HTTP method. +Endpoints with one SSE success expose accumulated streamed-query builders. | Declaration | Generated path | | ------------------------------------------ | ----------------------------------- | @@ -21,11 +22,15 @@ Every retained branch and endpoint has `key()`. Top-level groups have no branch custom key encoders still use their declaration group identifier. The factory rejects unsafe names, collisions, and invalid encoder configuration before returning any utilities. -The factory omits an entire endpoint if any success alternative streams, including a stream -wrapped with response headers, or any request alternative uses streaming multipart. It also omits groups -containing only omitted endpoints. It never keeps just the buffered alternatives of a partially -supported endpoint. Contradictory multipart metadata causes factory construction to fail. HTTP -streams and streaming multipart remain deferred; use the ready client directly when you need them. +The factory retains a single SSE success, including a decoded response-header wrapper. Its leaf +exposes `key()`, `streamedKey()`, and `streamedOptions()`. Cache entries hold ordered decoded events; +header wrappers surround each event. Decoder, retention, and refetch policies contribute to concrete +identity while endpoint prefixes continue to select every view policy. + +Raw byte streams, mixed buffered/SSE successes, SSE endpoints with multipart payloads, and streaming +multipart requests omit the complete endpoint. Groups containing only omitted endpoints disappear. +Contradictory multipart metadata causes factory construction to fail. Use the ready client directly +for unsupported streams; applications own byte sinks, reconnection, and resume policy. Any buffered multipart payload alternative makes the endpoint mutation-only, including an endpoint that also accepts ordinary payloads. Its leaf exposes `key()`, `mutationKey()`, and `mutationOptions()`. diff --git a/apps/docs/src/content/docs/guides/http-queries-and-mutations.md b/apps/docs/src/content/docs/guides/http-queries-and-mutations.md index 5af7775..78a6b9d 100644 --- a/apps/docs/src/content/docs/guides/http-queries-and-mutations.md +++ b/apps/docs/src/content/docs/guides/http-queries-and-mutations.md @@ -177,6 +177,50 @@ plain payloads. Mixed alternatives retain Effect's client request union. Leave t `keyEncoders`: mutation keys contain no request variables, and encoder entries are rejected. Any streaming success or streaming multipart alternative omits the whole endpoint. +## Retain SSE events + +An endpoint with one `HttpApiSchema.StreamSse` success exposes `streamedKey` and +`streamedOptions`. For a `watch` endpoint whose query declares `channel: Schema.String`, +retain its latest 100 decoded events: + +```ts +const events = http.events.watch.streamedOptions({ + input: { query: { channel: 'news' } }, + maxChunks: 100, + refetchMode: 'reset', + sseOptions: { maxEventSize: 1024 * 1024 }, +}) + +const observer = new QueryObserver(queryClient, events) +const unsubscribe = observer.subscribe((result) => { + renderEvents(result.data ?? []) +}) +``` + +Import `QueryObserver` from `@tanstack/query-core`, or pass the options to your framework's +query hook. Replace `renderEvents` with your application's rendering callback. The first event +makes the query successful while it remains fetching. Unsubscribe and cancel the owning queries +before disposing the ready client and runtime. + +The cache holds decoded events in order. Omit `maxChunks` for unlimited history; use a positive +safe integer to bound retention. `reset` starts a fresh history on refetch, `append` extends the +existing history, and `replace` keeps the previous history visible until the refetch completes. +Empty completion succeeds with an empty or retained history according to the refetch mode. +Accumulated arrays preserve decoded `undefined` elements. A declared `WithHeaders` success wraps +each event with the response's decoded headers. + +Keep `sseOptions` beside `input`. Its `maxEventSize` limits pending SSE parser text in JavaScript +string code units and defaults to 10 MiB. The same request with different effective decoder, +retention, or refetch policies has a different cache key. To read that exact entry, pass the +same policy to `streamedKey(input, policy)`; inputless endpoints accept policy alone. +`key()` remains a prefix matching every policy. Input-bearing builders also support `skipToken`. + +Declared request headers, such as a resume cursor, remain inside `input.headers` and contribute +to request identity. The ready client handles SSE decoding and failures; the adapter preserves +complete Causes, including `Sse.Retry`. Your application owns reconnection and resume policy. +Raw byte streams, mixed buffered/SSE successes, and SSE endpoints with multipart payloads are +omitted. Consume unsupported streams directly through the ready client. + ## Keep cache entries separate Use generated keys for individual queries or whole branches: @@ -194,8 +238,8 @@ need encoding services, contain redacted values, or allow multiple payload alter Buffered HTTP endpoints without multipart expose ordinary query, infinite query, and mutation builders regardless of HTTP method. Choose the builder for the operation you intend. Buffered -multipart endpoints expose mutations only. Streaming responses and streaming multipart requests -are omitted; see the [HTTP factory reference](/effect-api-query/reference/http-factory/) +multipart endpoints expose mutations only. A single SSE success exposes accumulated streamed +queries; raw byte streams, mixed buffered/SSE successes, and streaming multipart requests are omitted; see the [HTTP factory reference](/effect-api-query/reference/http-factory/) for supported request formats and the complete builder contract. When your application or server request ends, cancel its active queries and clear its QueryClient diff --git a/apps/docs/src/content/docs/reference/http-factory.md b/apps/docs/src/content/docs/reference/http-factory.md index fb9c946..e576cd0 100644 --- a/apps/docs/src/content/docs/reference/http-factory.md +++ b/apps/docs/src/content/docs/reference/http-factory.md @@ -16,6 +16,8 @@ properties. Every retained branch and endpoint has `key()`; buffered endpoints w An endpoint with any buffered multipart payload alternative exposes only `key`, `mutationKey`, and `mutationOptions`, even when it also accepts plain payload alternatives. This classification applies regardless of HTTP method; applications choose the builder for endpoints with query support. +An endpoint with exactly one SSE success and no multipart payload exposes `key`, `streamedKey`, +and `streamedOptions`. ## Factory options @@ -62,8 +64,10 @@ service requirements of their decoded-only call signatures; raw-response overloa See [client lifecycle](/effect-api-query/concepts/client-lifecycle/#http-clients-and-execution-services) and [cancellation](/effect-api-query/guides/cancellation/#cancel-an-http-query) for runtime ownership. -Any streaming success alternative, including a header-wrapped stream, omits the complete endpoint. -Any streaming multipart request alternative does the same. Buffered multipart alternatives retain +A single SSE success, including `WithHeaders(StreamSse(...), ...)`, supports accumulated queries. +Raw byte streams and mixed buffered/SSE success alternatives omit the complete endpoint. +Any streaming multipart request alternative does the same. SSE endpoints with buffered multipart +payload alternatives are also omitted. Buffered multipart alternatives retain the endpoint's mutation builders. Groups containing only omitted endpoints disappear. Factory construction rejects unsafe names, path collisions, and contradictory multipart metadata before returning a tree. Preserve literal declaration types so the inferred tree omits the same @@ -92,6 +96,30 @@ Inputless builders, key builders, and mutations reject `skipToken`. A skipped fu through manual refetch; supply valid input or use `enabled: false` with a complete request when manual execution is required. Native suspense and prefetch-only hooks reject skipped options. +## Accumulated SSE options + +`streamedOptions` accepts native query options, decoded `input` when declared, and: + +| Option | Contract | +| ------------------------- | ------------------------------------------------------------------------------------------------------- | +| `maxChunks` | Positive safe integer limiting retained events; omitted means unlimited. | +| `refetchMode` | `reset` (default), `append`, or `replace`. | +| `sseOptions.maxEventSize` | Positive safe integer limiting pending parser text; defaults to 10 MiB of JavaScript string code units. | + +Decoded data is an ordered readonly array. Accumulated elements keep `undefined`; decoded header +wrappers surround each emitted body. Empty completion succeeds. Acquisition and consumption errors +use `EffectHttpApiQueryError` with operation `streamed`, preserving declared event failures, +`Sse.Retry`, `Sse.SseError`, HTTP and Schema errors, and additional client channels. +The caller's runner supplies acquisition and consumption services and receives Query's abort signal. +Cancellation closes the iterator and follows native Query cache reversion. + +`streamedKey(input, policy)` accepts the same decoder, retention, and refetch policy; inputless +endpoints use `streamedKey(policy)`. Equivalent defaults have equal keys. Different effective +policies occupy separate entries. Endpoint `key()` selects them all. Decoder controls are excluded +from decoded request input and custom encoders. Declared resume headers remain request identity. + +See [Retain SSE events](/effect-api-query/guides/http-queries-and-mutations/#retain-sse-events). + ## Pagination `infiniteOptions` requires `initialPageParam` and `getNextPageParam`. For an input-bearing endpoint, diff --git a/scripts/verify-packed-consumer.mts b/scripts/verify-packed-consumer.mts index ddf3b41..c1462a4 100644 --- a/scripts/verify-packed-consumer.mts +++ b/scripts/verify-packed-consumer.mts @@ -407,6 +407,7 @@ const verifyConsumer = (peer: (typeof peerCases)[number]): void => { 'public-contract.ts', 'tanstack-start-contract.ts', 'http-contract.ts', + 'http-stream-contract.ts', 'type-scale.ts', 'http-type-scale.ts', 'docs-rpc-quick-start.ts', @@ -506,6 +507,7 @@ const verifyConsumer = (peer: (typeof peerCases)[number]): void => { 'hashing-runtime.mts', 'runtime.mts', 'http-runtime.mts', + 'http-stream-runtime.mts', 'stream-cause-runtime.mts', 'stream-policy-runtime.mts', ]) { diff --git a/src/core/operation.ts b/src/core/operation.ts index 295e3ed..5649097 100644 --- a/src/core/operation.ts +++ b/src/core/operation.ts @@ -45,6 +45,7 @@ export interface MutationOperation extends BufferedOperation { export interface StreamingOperation extends OperationIdentity { readonly kind: 'Streaming' readonly input: OperationInput + readonly supportsLive?: boolean readonly streamedIdentity: (options: Record) => readonly JsonValue[] /** Consumes and validates stream policy, including for skipped requests. */ readonly prepareStream: ( diff --git a/src/core/streamed-query.ts b/src/core/streamed-query.ts new file mode 100644 index 0000000..b4f5f92 --- /dev/null +++ b/src/core/streamed-query.ts @@ -0,0 +1,146 @@ +import { experimental_streamedQuery } from '@tanstack/query-core' +import type { QueryFunctionContext } from '@tanstack/query-core' +import { Exit, Stream } from 'effect' +import type { Cause } from 'effect' + +import type { RunPromiseExit } from './types' + +export type StreamQueryPolicy = + | { + readonly maxChunks: number | undefined + readonly _tag: 'Accumulated' + readonly refetchMode: 'reset' | 'append' | 'replace' + } + | { readonly _tag: 'Live' } + +export interface MakeStreamQueryOptions { + readonly policy: StreamQueryPolicy + readonly source: Stream.Stream + readonly executionError: (cause: Cause.Cause) => Error + readonly emptyError?: (() => Error) | undefined + readonly runPromiseExit: RunPromiseExit +} + +const streamQueryIterable = ( + source: AsyncIterable, + signal: AbortSignal, + emptyError: (() => Error) | undefined, +): AsyncIterable => ({ + [Symbol.asyncIterator]() { + const iterator = source[Symbol.asyncIterator]() + let closePromise: Promise> | undefined + let emitted = false + const detach = () => { + // oxlint-disable-next-line eslint/no-use-before-define + signal.removeEventListener('abort', onAbort) + } + const close = async () => { + closePromise ??= (async () => { + try { + return (await iterator.return?.()) ?? { done: true, value: undefined } + } finally { + detach() + } + })() + return closePromise + } + const onAbort = () => { + // oxlint-disable-next-line promise/prefer-await-to-then + void close().catch(detach) + } + signal.addEventListener('abort', onAbort, { once: true }) + + return { + async next() { + let result: IteratorResult + if (signal.aborted) { + result = await close() + } else { + try { + result = await iterator.next() + } catch (error) { + detach() + throw error + } + } + if (result.done === true) { + detach() + if (!emitted && emptyError !== undefined) { + throw emptyError() + } + } + emitted = true + return result + }, + return: close, + async throw(cause?: unknown) { + detach() + if (iterator.throw !== undefined) { + return await iterator.throw(cause) + } + await close() + throw cause + }, + } + }, +}) + +export const makeStreamQuery = ({ + policy, + source, + executionError, + emptyError, + runPromiseExit, +}: MakeStreamQueryOptions) => { + const streamFn = async ({ signal }: { readonly signal: AbortSignal }) => { + const stream = source.pipe( + Stream.catchCauseIf( + () => !signal.aborted, + (cause) => Stream.fail(executionError(cause)), + ), + ) + // oxlint-disable-next-line effecttsgo/any-unknown-in-error-context + const exit = await runPromiseExit(Stream.toAsyncIterableEffect(stream), { signal }) + if (Exit.isFailure(exit)) { + throw executionError(exit.cause) + } + return streamQueryIterable(exit.value, signal, policy._tag === 'Live' ? emptyError : undefined) + } + + if (policy._tag === 'Live') { + return experimental_streamedQuery({ + initialValue: null, + reducer: (_latest: unknown, value: unknown) => (value === undefined ? null : value), + streamFn, + }) + } + + const { maxChunks, refetchMode } = policy + return async (context: QueryFunctionContext) => { + const reset = + refetchMode === 'reset' && + context.client + .getQueryCache() + .find({ queryKey: context.queryKey, exact: true }) + ?.isFetched() === true + let emitted = false + const initialValue: unknown[] = [] + const queryFn = experimental_streamedQuery({ + initialValue, + reducer: (values: unknown[], value: unknown) => { + const history = reset && !emitted ? [] : values + emitted = true + return [ + ...(maxChunks === undefined + ? history + : history.slice(Math.max(0, history.length + 1 - maxChunks))), + value, + ] + }, + refetchMode, + streamFn, + }) + const result = await queryFn(context) + return reset && !emitted ? [] : result + } +} diff --git a/src/core/utility-tree.ts b/src/core/utility-tree.ts index ac03c0d..c4ca7ab 100644 --- a/src/core/utility-tree.ts +++ b/src/core/utility-tree.ts @@ -413,13 +413,12 @@ const createStreamingLeaf = ( const operationKey = freezeKey(keyParts) const liveOperationKey = freezeKey([...operationKey, 'live']) const streamedOperationKey = freezeKey([...operationKey, 'streamed']) - const live = createQueryBuilders( - description, - liveOperationKey, - keyEncoder, - (options, requestOptions) => - description.prepareStream(options, 'live', runPromiseExit, requestOptions), - ) + const live = + description.supportsLive === false + ? undefined + : createQueryBuilders(description, liveOperationKey, keyEncoder, (options, requestOptions) => + description.prepareStream(options, 'live', runPromiseExit, requestOptions), + ) const streamed = createQueryBuilders( description, streamedOperationKey, @@ -431,8 +430,7 @@ const createStreamingLeaf = ( return Object.freeze({ key: () => operationKey, - liveKey: live.key, - liveOptions: live.options, + ...(live === undefined ? undefined : { liveKey: live.key, liveOptions: live.options }), streamedKey: streamed.key, streamedOptions: streamed.options, }) diff --git a/src/http/create-utils.ts b/src/http/create-utils.ts index ef1e3ee..9ca4ee1 100644 --- a/src/http/create-utils.ts +++ b/src/http/create-utils.ts @@ -5,7 +5,6 @@ import { createUtilityTree } from '../core/utility-tree' import { compileHttpOperations } from './operation' import type { CreateHttpApiQueryUtilsOptions, HttpApiQueryUtils } from './types' -/** Derives a frozen utility tree from buffered endpoints and a caller-owned ready HTTP client. */ export const createHttpApiQueryUtils = < const Api extends HttpApi.Constraint, const Prefix extends readonly [JsonValue, ...JsonValue[]], diff --git a/src/http/errors.ts b/src/http/errors.ts index 730f1d0..3739644 100644 --- a/src/http/errors.ts +++ b/src/http/errors.ts @@ -8,8 +8,7 @@ export interface HttpApiEndpointIdentity { readonly method: string } -/** TanStack operations supported by buffered HTTP endpoints. */ -export type HttpApiOperation = 'query' | 'infinite' | 'mutation' +export type HttpApiOperation = 'query' | 'infinite' | 'mutation' | 'streamed' /** Stable codes for invalid HTTP utility configuration. */ export type EffectHttpApiQueryConfigErrorCode = @@ -20,6 +19,9 @@ export type EffectHttpApiQueryConfigErrorCode = | 'UnknownKeyEncoder' | 'UnsupportedEndpointMetadata' | 'UnsupportedQueryHash' + | 'InvalidMaxChunks' + | 'InvalidRefetchMode' + | 'InvalidMaxEventSize' /** Stable codes for synchronous HTTP key failures. */ export type EffectHttpApiQueryKeyErrorCode = diff --git a/src/http/operation.ts b/src/http/operation.ts index 91cbf9b..b79d767 100644 --- a/src/http/operation.ts +++ b/src/http/operation.ts @@ -1,9 +1,11 @@ import { Predicate } from 'effect' import type { Effect } from 'effect' +import type { Sse } from 'effect/encoding' import type { HttpApi } from 'effect/http-api' import type { MutationOperation, + StreamingOperation, RuntimeKeyEncoder, TreeErrors, UnaryOperation, @@ -11,13 +13,14 @@ import type { import { EffectHttpApiQueryConfigError, EffectHttpApiQueryError } from './errors' import type { HttpApiEndpointIdentity } from './errors' import { createHttpRequest } from './request' +import { createHttpStreamIdentity, createHttpStreamPreparation } from './streamed-query' -type HttpOperation = (UnaryOperation | MutationOperation) & { +type HttpOperation = (UnaryOperation | MutationOperation | StreamingOperation) & { readonly identity: HttpApiEndpointIdentity } export interface CompiledHttpOperations { - readonly operations: readonly (UnaryOperation | MutationOperation)[] + readonly operations: readonly (UnaryOperation | MutationOperation | StreamingOperation)[] readonly errors: TreeErrors readonly keyEncoders: ReadonlyMap } @@ -44,35 +47,50 @@ const extractHttpEndpoints = (api: HttpApi.Top, client: unknown): readonly HttpO string, (request: unknown) => Effect.Effect > - operations.push({ - ...request, + const common = { identity, id: JSON.stringify([group.identifier, endpoint.identifier]), path: group.topLevel ? [endpoint.identifier] : [group.identifier, endpoint.identifier], - unsupportedQueryHash: (option) => + unsupportedQueryHash: (option: 'queryKeyHashFn' | 'queryHash') => new EffectHttpApiQueryConfigError( 'UnsupportedQueryHash', `${option} must be configured through QueryClient defaults`, identity, ), - takeOptions: () => { - // HTTP has no adapter-owned options. - }, - invoke: (requestInput) => { - const invoke = target[endpoint.identifier] - if (invoke === undefined) { - throw new TypeError(`Missing HTTP client endpoint ${endpoint.identifier}`) - } - // The ready client preserves caller errors and service requirements. + takeOptions: () => null, + } + const invoke = (requestInput: unknown, sseOptions?: Sse.DecodeOptions) => { + const method = target[endpoint.identifier] + if (method === undefined) { + throw new TypeError(`Missing HTTP client endpoint ${endpoint.identifier}`) + } + // The ready client preserves caller errors and service requirements. + // oxlint-disable-next-line effecttsgo/any-unknown-in-error-context + return method.call(target, { + ...(Predicate.isObject(requestInput) ? requestInput : undefined), + responseMode: 'decoded-only', + ...(sseOptions === undefined ? undefined : { sseOptions }), + }) + } + if (request.kind === 'Streaming') { + operations.push({ + ...common, + ...request, + kind: 'Streaming', + supportsLive: false, + streamedIdentity: createHttpStreamIdentity(identity), + prepareStream: createHttpStreamPreparation(identity, invoke), + }) + } else { + operations.push({ + ...common, + ...request, // oxlint-disable-next-line effecttsgo/any-unknown-in-error-context - return invoke.call(target, { - ...(Predicate.isObject(requestInput) ? requestInput : undefined), - responseMode: 'decoded-only', - }) - }, - executionError: (operation, cause) => - new EffectHttpApiQueryError(identity, operation, cause), - }) + invoke: (requestInput) => invoke(requestInput), + executionError: (operation, cause) => + new EffectHttpApiQueryError(identity, operation, cause), + }) + } } } return operations @@ -127,7 +145,7 @@ export const compileHttpOperations = ( const errors = httpTreeErrors(api, operations) const encoderGroups = new Set( operations - .filter((operation) => operation.kind === 'Unary' && operation.input._tag === 'Input') + .filter((operation) => operation.kind !== 'Mutation' && operation.input._tag === 'Input') .map((operation) => operation.identity.groupId), ) const keyEncoders = new Map() diff --git a/src/http/request.ts b/src/http/request.ts index 1b27fab..cf0b357 100644 --- a/src/http/request.ts +++ b/src/http/request.ts @@ -28,13 +28,14 @@ const multipartBrands = (schema: Schema.Top): ReadonlySet => { export type HttpRequestDescription = | { readonly kind: 'Unary'; readonly input: OperationInput } + | { readonly kind: 'Streaming'; readonly input: OperationInput } | { readonly kind: 'Mutation' } const classifyEndpoint = ( endpoint: HttpApiEndpoint.Top, identity: HttpApiEndpointIdentity, ): - | { readonly kind: 'Unary'; readonly schemas: readonly Schema.Top[] } + | { readonly kind: 'Unary' | 'Streaming'; readonly schemas: readonly Schema.Top[] } | { readonly kind: 'Mutation' } | undefined => { const payloads: Schema.Top[] = [] @@ -60,17 +61,29 @@ const classifyEndpoint = ( payloads.push(schema) } } - if ( - multipartStream || - [...endpoint.success].some((schema) => - Predicate.hasProperty( - HttpApiSchema.isWithHeaders(schema) ? schema.schema : schema, - '~effect/http-api/HttpApiSchema/Stream', - ), - ) - ) { + if (multipartStream) { return undefined } + const successes = [...endpoint.success] + const streams = successes.filter((schema) => + Predicate.hasProperty( + HttpApiSchema.isWithHeaders(schema) ? schema.schema : schema, + '~effect/http-api/HttpApiSchema/Stream', + ), + ) + if (streams.length > 0) { + const [success] = successes + const body = HttpApiSchema.isWithHeaders(success) ? success.schema : success + if ( + multipart || + successes.length !== 1 || + !Predicate.hasProperty(body, '_tag') || + body._tag !== 'StreamSse' + ) { + return undefined + } + return { kind: 'Streaming', schemas: payloads } + } return multipart ? { kind: 'Mutation' } : { kind: 'Unary', schemas: payloads } } @@ -124,7 +137,6 @@ const normalizeRequestKey = (value: unknown): unknown => { return request } -/** Retains buffered multipart mutations and omits streaming requests or responses. */ export const createHttpRequest = ( endpoint: HttpApiEndpoint.Top, identity: HttpApiEndpointIdentity, @@ -148,7 +160,7 @@ export const createHttpRequest = ( fields['payload'] = Schema.Union(payloads) } if (Object.keys(fields).length === 0) { - return { kind: 'Unary', input: { _tag: 'Inputless' } } + return { kind: classified.kind, input: { _tag: 'Inputless' } } } const schema = Schema.Struct(fields) const keyEncoding = createSchemaKeyEncoding(schema) @@ -160,7 +172,7 @@ export const createHttpRequest = ( cause, ) return { - kind: 'Unary', + kind: classified.kind, input: { _tag: 'Input', requiresEncoder: payloads.length > 1 || keyEncoding.requiresEncoder, diff --git a/src/http/streamed-query.ts b/src/http/streamed-query.ts new file mode 100644 index 0000000..5f16d6f --- /dev/null +++ b/src/http/streamed-query.ts @@ -0,0 +1,109 @@ +import { Effect, Predicate, Stream } from 'effect' +import type { Sse } from 'effect/encoding' +import { HttpApiSchema } from 'effect/http-api' + +import type { StreamingOperation } from '../core/operation' +import { makeStreamQuery } from '../core/streamed-query' +import type { StreamQueryPolicy } from '../core/streamed-query' +import type { JsonValue } from '../core/types' +import { EffectHttpApiQueryConfigError, EffectHttpApiQueryError } from './errors' +import type { HttpApiEndpointIdentity } from './errors' + +const decoderOptions = ( + identity: HttpApiEndpointIdentity, + options: Record, +): Sse.DecodeOptions & { readonly maxEventSize: number } => { + const { sseOptions } = options + const maxEventSize = Predicate.isObject(sseOptions) ? sseOptions['maxEventSize'] : undefined + const limit = maxEventSize === undefined ? 10 * 1024 * 1024 : maxEventSize + if (!Predicate.isNumber(limit) || !Number.isSafeInteger(limit) || limit <= 0) { + throw new EffectHttpApiQueryConfigError( + 'InvalidMaxEventSize', + 'sseOptions.maxEventSize must be a positive safe integer', + identity, + ) + } + return Object.freeze({ maxEventSize: limit }) +} + +const accumulatedPolicy = ( + identity: HttpApiEndpointIdentity, + options: Record, +): Extract => { + const { maxChunks, refetchMode = 'reset' } = options + if ( + maxChunks !== undefined && + (!Predicate.isNumber(maxChunks) || !Number.isSafeInteger(maxChunks) || maxChunks <= 0) + ) { + throw new EffectHttpApiQueryConfigError( + 'InvalidMaxChunks', + 'maxChunks must be a positive safe integer', + identity, + ) + } + if (refetchMode !== 'reset' && refetchMode !== 'append' && refetchMode !== 'replace') { + throw new EffectHttpApiQueryConfigError( + 'InvalidRefetchMode', + 'refetchMode must be reset, append, or replace', + identity, + ) + } + return { _tag: 'Accumulated', maxChunks, refetchMode } +} + +export const createHttpStreamIdentity = + (identity: HttpApiEndpointIdentity): StreamingOperation['streamedIdentity'] => + (options) => { + const { maxChunks, refetchMode } = accumulatedPolicy(identity, options) + const { maxEventSize } = decoderOptions(identity, options) + const policy: JsonValue = Object.freeze({ + maxChunks: maxChunks ?? null, + refetchMode, + maxEventSize, + }) + return [policy] + } + +export const createHttpStreamPreparation = + ( + identity: HttpApiEndpointIdentity, + invoke: ( + input: unknown, + sseOptions: Sse.DecodeOptions, + ) => Effect.Effect, + ): StreamingOperation['prepareStream'] => + (options, _operation, runPromiseExit) => { + const policy = accumulatedPolicy(identity, options) + const sseOptions = decoderOptions(identity, options) + delete options['refetchMode'] + delete options['maxChunks'] + delete options['sseOptions'] + return (input) => + makeStreamQuery({ + policy, + source: Stream.unwrap( + // oxlint-disable-next-line effecttsgo/any-unknown-in-error-context + Effect.suspend(() => invoke(input, sseOptions)).pipe( + Effect.map((response) => { + if ( + Predicate.hasProperty(response, 'body') && + Predicate.hasProperty(response, 'headers') && + Stream.isStream(response.body) + ) { + return response.body.pipe( + Stream.map((body) => + HttpApiSchema.withHeaders({ body, headers: response.headers }), + ), + ) + } + if (!Stream.isStream(response)) { + throw new TypeError('HTTP SSE clients must return a Stream') + } + return response + }), + ), + ), + executionError: (cause) => new EffectHttpApiQueryError(identity, 'streamed', cause), + runPromiseExit, + }) + } diff --git a/src/http/types.ts b/src/http/types.ts index 85c88e3..cdbadfa 100644 --- a/src/http/types.ts +++ b/src/http/types.ts @@ -1,5 +1,6 @@ import type { DataTag } from '@tanstack/query-core' -import type { Brand, Effect, Schema } from 'effect' +import type { Brand, Effect, Schema, Stream } from 'effect' +import type { Sse } from 'effect/encoding' import type { HttpApi, HttpApiClient, @@ -33,14 +34,23 @@ export type MultipartPayload = | Brand.Brand | Brand.Brand -/** Omits the complete endpoint when any success or multipart payload alternative streams. */ +export type SseEndpoint = Endpoint extends HttpApiEndpoint.ConstraintRequest + ? [ResponseBody] extends [{ readonly _tag: 'StreamSse' }] + ? Endpoint + : never + : never + export type Supported = Endpoint extends HttpApiEndpoint.ConstraintRequest - ? [Extract, HttpApiSchema.StreamSchema>] extends [never] - ? [ - Extract>, - ] extends [never] + ? [ + Extract>, + ] extends [never] + ? [Extract, HttpApiSchema.StreamSchema>] extends [never] ? Endpoint - : never + : [SseEndpoint] extends [never] + ? never + : [Extract] extends [never] + ? Endpoint + : never : never : never @@ -220,7 +230,7 @@ export type MutationLeaf< > } -export type Leaf< +export type BufferedLeaf< Endpoint extends HttpApiEndpoint.ConstraintRequest, Key extends readonly JsonValue[], ClientError, @@ -238,6 +248,68 @@ export type Leaf< readonly infiniteOptions: InfiniteBuilder }) +export type StreamChunk = + Value extends HttpApiSchema.withHeaders + ? Body extends Stream.Stream + ? HttpApiSchema.withHeaders + : never + : Value extends Stream.Stream + ? Chunk + : never + +export type StreamFailure = + Value extends HttpApiSchema.withHeaders + ? StreamFailure + : Value extends Stream.Stream + ? Error + : never + +export type HttpStreamPolicy = { + readonly sseOptions?: Sse.DecodeOptions | undefined + readonly maxChunks?: number | undefined + readonly refetchMode?: 'reset' | 'append' | 'replace' | undefined +} + +export type StreamedKey< + Endpoint extends HttpApiEndpoint.ConstraintRequest, + Key extends readonly JsonValue[], + ClientError, +> = DataTag< + readonly [...Key, 'streamed', ...JsonValue[]], + readonly StreamChunk>[], + Failure>> +> + +export type StreamedLeaf< + Endpoint extends HttpApiEndpoint.ConstraintRequest, + Key extends readonly JsonValue[], + ClientError, +> = { + readonly key: () => Key + readonly streamedKey: void extends Request + ? (policy?: HttpStreamPolicy) => StreamedKey + : ( + input: Request, + policy?: HttpStreamPolicy, + ) => StreamedKey + readonly streamedOptions: UnaryQueryBuilder< + Request, + readonly StreamChunk>[], + Failure>>, + StreamedKey, + readonly [...Key, 'streamed'], + HttpStreamPolicy + > +} + +export type Leaf< + Endpoint extends HttpApiEndpoint.ConstraintRequest, + Key extends readonly JsonValue[], + ClientError, +> = [SseEndpoint] extends [never] + ? BufferedLeaf + : StreamedLeaf + /** An eager utility tree mirroring the ready HTTP client's literal properties. */ export type HttpApiQueryUtils< Api extends HttpApi.Constraint, @@ -254,7 +326,8 @@ export type HttpApiQueryUtils< readonly [Endpoint in Supported> as Endpoint['identifier']]: Leaf< Endpoint, EndpointKey, - Effect.Error> + | Effect.Error> + | StreamFailure>> > } } & { @@ -265,7 +338,8 @@ export type HttpApiQueryUtils< ]: Leaf< Endpoint, readonly [...Root, Endpoint['identifier']], - Effect.Error> + | Effect.Error> + | StreamFailure>> > } @@ -344,10 +418,18 @@ export type EncoderOption = [EncoderGroups] : [RequiredEncoderGroups] extends [never] ? { readonly keyEncoders?: Encoders } : { readonly keyEncoders: Encoders } +export type StreamServices = + Value extends HttpApiSchema.withHeaders + ? StreamServices + : Value extends Stream.Stream + ? Services + : never + export type ClientServices = | HttpApiEndpoint.ClientServices>>> | HttpApiEndpoint.ErrorServicesDecode>>> | Effect.Services> + | StreamServices>> export type RunnerOption = [ ClientServices, ] extends [never] diff --git a/src/rpc/streamed-query.ts b/src/rpc/streamed-query.ts index b4998be..0615fd9 100644 --- a/src/rpc/streamed-query.ts +++ b/src/rpc/streamed-query.ts @@ -1,23 +1,15 @@ -import { experimental_streamedQuery } from '@tanstack/query-core' -import type { QueryFunctionContext } from '@tanstack/query-core' -import { Exit, Predicate, Stream } from 'effect' +import { Predicate, Stream } from 'effect' import type { StreamingOperation } from '../core/operation' -import type { JsonValue, RunPromiseExit } from '../core/types' +import { makeStreamQuery } from '../core/streamed-query' +import type { StreamQueryPolicy } from '../core/streamed-query' +import type { JsonValue } from '../core/types' import { EffectRpcQueryConfigError, EffectRpcQueryEmptyStreamError, EffectRpcQueryError, } from './errors' -import type { StreamRefetchMode, StreamingRpcOptions } from './types' - -type StreamQueryPolicy = - | { - readonly maxChunks: number | undefined - readonly _tag: 'Accumulated' - readonly refetchMode: StreamRefetchMode - } - | { readonly _tag: 'Live' } +import type { StreamingRpcOptions } from './types' export interface RpcStreamInvocation { readonly tag: string @@ -27,14 +19,6 @@ export interface RpcStreamInvocation { ) => Stream.Stream } -interface MakeStreamQueryOptions { - readonly rpcOptions: StreamingRpcOptions | undefined - readonly input: unknown - readonly policy: StreamQueryPolicy - readonly rpc: RpcStreamInvocation - readonly runPromiseExit: RunPromiseExit -} - const accumulatedPolicy = ( rpcTag: string, options: Record, @@ -68,138 +52,6 @@ export const createStreamIdentity = return [identity] } -const streamQueryIterable = ( - source: AsyncIterable, - signal: AbortSignal, - liveRpcTag: string | undefined, -): AsyncIterable => ({ - [Symbol.asyncIterator]() { - const iterator = source[Symbol.asyncIterator]() - let closePromise: Promise> | undefined - let emitted = false - // This callback runs after the listener has been initialized below. - const detach = () => { - // oxlint-disable-next-line eslint/no-use-before-define - signal.removeEventListener('abort', onAbort) - } - const close = async () => { - closePromise ??= (async () => { - try { - return (await iterator.return?.()) ?? { done: true, value: undefined } - } finally { - detach() - } - })() - return closePromise - } - const onAbort = () => { - // Abort listeners are synchronous; attach cleanup rejection handling immediately. - // oxlint-disable-next-line promise/prefer-await-to-then - void close().catch(() => { - // The pending iterator pull or explicit return reports failures to Query Core. - }) - } - signal.addEventListener('abort', onAbort, { once: true }) - - return { - async next() { - let result: IteratorResult - if (signal.aborted) { - result = await close() - } else { - try { - result = await iterator.next() - } catch (error) { - detach() - throw error - } - } - if (result.done === true) { - detach() - if (!emitted && liveRpcTag !== undefined) { - throw new EffectRpcQueryEmptyStreamError(liveRpcTag) - } - } - emitted = true - return result - }, - return: close, - async throw(cause?: unknown) { - detach() - if (iterator.throw !== undefined) { - return await iterator.throw(cause) - } - await close() - throw cause - }, - } - }, -}) - -/** Adapts one Effect stream invocation to an accumulated or latest-value Query Core function. */ -const makeStreamQuery = ({ - input, - policy, - rpc, - rpcOptions, - runPromiseExit, -}: MakeStreamQueryOptions) => { - const operation = policy._tag === 'Live' ? 'live' : 'streamed' - const streamFn = async ({ signal }: { readonly signal: AbortSignal }) => { - const stream = rpc.invoke(input, rpcOptions).pipe( - Stream.catchCauseIf( - () => !signal.aborted, - (cause) => Stream.fail(new EffectRpcQueryError(rpc.tag, operation, cause)), - ), - ) - // Capture the runner's Context so iterator pulls use the caller-owned runtime. - // The injected runner provides the erased caller-owned service requirements. - // oxlint-disable-next-line effecttsgo/any-unknown-in-error-context - const exit = await runPromiseExit(Stream.toAsyncIterableEffect(stream), { signal }) - if (Exit.isFailure(exit)) { - throw new EffectRpcQueryError(rpc.tag, operation, exit.cause) - } - return streamQueryIterable(exit.value, signal, policy._tag === 'Live' ? rpc.tag : undefined) - } - - if (policy._tag === 'Live') { - return experimental_streamedQuery({ - initialValue: null, - reducer: (_latest: unknown, value: unknown) => (value === undefined ? null : value), - streamFn, - }) - } - - const { maxChunks, refetchMode } = policy - if (maxChunks === undefined) { - return experimental_streamedQuery({ refetchMode, streamFn }) - } - - return async (context: QueryFunctionContext) => { - const reset = - refetchMode === 'reset' && - context.client - .getQueryCache() - .find({ queryKey: context.queryKey, exact: true }) - ?.isFetched() === true - let emitted = false - const initialValue: unknown[] = [] - const queryFn = experimental_streamedQuery({ - initialValue, - reducer: (values: unknown[], value: unknown) => { - // Query Core restores initialData on reset; a refetch starts a fresh accumulation. - const history = reset && !emitted ? [] : values - emitted = true - return [...history.slice(Math.max(0, history.length + 1 - maxChunks)), value] - }, - refetchMode, - streamFn, - }) - const result = await queryFn(context) - return reset && !emitted ? [] : result - } -} - /** Owns policy validation and execution for both accumulated and live queries. */ export const createStreamPreparation = (rpc: RpcStreamInvocation): StreamingOperation['prepareStream'] => @@ -213,10 +65,10 @@ export const createStreamPreparation = const rpcOptions = requestOptions as StreamingRpcOptions | undefined return (input) => makeStreamQuery({ - input, - rpcOptions, policy, - rpc, + source: Stream.suspend(() => rpc.invoke(input, rpcOptions)), + executionError: (cause) => new EffectRpcQueryError(rpc.tag, operation, cause), + emptyError: () => new EffectRpcQueryEmptyStreamError(rpc.tag), runPromiseExit, }) } diff --git a/tests/packed-consumer/http-stream-runtime.mts b/tests/packed-consumer/http-stream-runtime.mts new file mode 100644 index 0000000..60a0749 --- /dev/null +++ b/tests/packed-consumer/http-stream-runtime.mts @@ -0,0 +1,583 @@ +import { QueryClient, QueryObserver, isCancelledError } from '@tanstack/query-core' +import { + Cause, + Context, + Deferred, + Effect, + Exit, + Layer, + Schema, + SchemaTransformation, + Stream, +} from 'effect' +import { + createHttpApiQueryUtils, + EffectHttpApiQueryConfigError, + EffectHttpApiQueryError, + skipToken, +} from 'effect-api-query' +import { HttpClient, HttpClientResponse, HttpServer } from 'effect/http' +import { + HttpApi, + HttpApiBuilder, + HttpApiClient, + HttpApiEndpoint, + HttpApiGroup, + HttpApiSchema, + HttpApiTest, +} from 'effect/http-api' +import { deepStrictEqual, equal, notDeepStrictEqual, ok, rejects, throws } from 'node:assert/strict' + +const UndefinedValue = Schema.Null.pipe( + Schema.decodeTo( + Schema.Void, + SchemaTransformation.transform({ + decode: (): void => undefined, + encode: (_value: void) => null, + }), + ), +) +const Expired = Schema.TaggedStruct('Expired', { reason: Schema.String }) +const api = HttpApi.make('http-streams').add( + HttpApiGroup.make('events').add( + HttpApiEndpoint.get('watch', '/watch', { + query: { channel: Schema.String }, + success: HttpApiSchema.StreamSse({ data: Schema.Finite, error: Expired }), + }), + HttpApiEndpoint.get('cursor', '/cursor', { + headers: { 'last-event-id': Schema.String }, + success: HttpApiSchema.StreamSse({ data: Schema.String }), + }), + HttpApiEndpoint.get('wrapped', '/wrapped', { + success: HttpApiSchema.WithHeaders(HttpApiSchema.StreamSse({ data: Schema.Finite }), { + 'x-version': Schema.FiniteFromString, + }), + }), + HttpApiEndpoint.get('undefined', '/undefined', { + success: HttpApiSchema.StreamSse({ data: UndefinedValue }), + }), + HttpApiEndpoint.get('plain', '/plain', { + success: HttpApiSchema.StreamSse({ data: Schema.Finite }), + }), + HttpApiEndpoint.get('records', '/records', { + success: HttpApiSchema.StreamSse({ + events: Schema.Struct({ + id: Schema.String, + event: Schema.Literal('changed'), + data: Schema.String, + }), + }), + }), + ), +) +const handlers = HttpApiBuilder.group(api, 'events', (group) => + group + .handle('watch', ({ query }) => + Effect.succeed( + query.channel === 'failure' + ? Stream.concat( + Stream.succeed(1), + Stream.fail({ _tag: 'Expired' as const, reason: 'resume' }), + ) + : query.channel === 'interruption' + ? Stream.failCause(Cause.interrupt(123)) + : query.channel === 'mixed' + ? Stream.failCause( + Cause.combine(Cause.interrupt(123), Cause.die(new Error('defect'))), + ) + : Stream.make(1, 2, 3, 4), + ), + ) + .handle('cursor', ({ headers }) => Effect.succeed(Stream.succeed(headers['last-event-id']))) + .handle('undefined', () => Effect.succeed(Stream.make(undefined, undefined))) + .handle('wrapped', () => + Effect.succeed( + HttpApiSchema.withHeaders({ body: Stream.make(3, 7), headers: { 'x-version': 4 } }), + ), + ) + .handle('plain', () => Effect.succeed(Stream.empty)) + .handle('records', () => + Effect.succeed(Stream.make({ id: 'v1', event: 'changed', data: 'first' })), + ), +) +const waitFor = async (client: QueryClient, check: () => boolean) => { + const ready = Deferred.makeUnsafe() + const observe = () => { + if (check()) Effect.runSync(Deferred.succeed(ready, undefined)) + } + const unsubscribe = client.getQueryCache().subscribe(observe) + try { + observe() + await Effect.runPromise(Deferred.await(ready).pipe(Effect.timeout('5 seconds'))) + } finally { + unsubscribe() + } +} +const makeClient = (response: () => Response) => + Effect.runPromise( + HttpApiClient.makeWith(api, { + baseUrl: 'https://example.test', + httpClient: HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, response())), + ), + }), + ) +const sse = (body: string, headers: Record = {}) => + new Response(body, { headers: { 'content-type': 'text/event-stream', ...headers } }) +const expectFailure = + (endpoint: string, check: (cause: Cause.Cause) => void) => (error: unknown) => { + ok(error instanceof EffectHttpApiQueryError) + equal(error.apiId, 'http-streams') + equal(error.groupId, 'events') + equal(error.endpoint, endpoint) + equal(error.method, 'GET') + equal(error.operation, 'streamed') + check(error.cause) + return true + } + +await Effect.runPromise( + Effect.gen(function* () { + const client = yield* HttpApiTest.groups(api, ['events']) + const utils = createHttpApiQueryUtils(api, { client, keyPrefix: ['consumer'] }) + const input = { query: { channel: 'news' } } + const queryClient = new QueryClient() + try { + const options = utils.events.watch.streamedOptions({ input }) + deepStrictEqual(yield* Effect.promise(() => queryClient.query(options)), [1, 2, 3, 4]) + deepStrictEqual(queryClient.getQueryData(options.queryKey), [1, 2, 3, 4]) + deepStrictEqual( + yield* Effect.promise(() => + queryClient.query(utils.events.watch.streamedOptions({ input, maxChunks: 2 })), + ), + [3, 4], + ) + equal(queryClient.getQueryCache().getAll().length, 2) + for (const cursor of ['v1', 'v2']) { + const cursorOptions = utils.events.cursor.streamedOptions({ + input: { headers: { 'last-event-id': cursor } }, + sseOptions: { maxEventSize: 1024 }, + }) + deepStrictEqual(yield* Effect.promise(() => queryClient.query(cursorOptions)), [cursor]) + deepStrictEqual( + queryClient.getQueryData( + utils.events.cursor.streamedKey( + { headers: { 'last-event-id': cursor } }, + { sseOptions: { maxEventSize: 1024 } }, + ), + ), + [cursor], + ) + } + deepStrictEqual( + yield* Effect.promise(() => queryClient.query(utils.events.wrapped.streamedOptions())), + [ + HttpApiSchema.withHeaders({ body: 3, headers: { 'x-version': 4 } }), + HttpApiSchema.withHeaders({ body: 7, headers: { 'x-version': 4 } }), + ], + ) + deepStrictEqual( + yield* Effect.promise(() => queryClient.query(utils.events.records.streamedOptions())), + [{ id: 'v1', event: 'changed', data: 'first' }], + ) + deepStrictEqual( + yield* Effect.promise(() => queryClient.query(utils.events.plain.streamedOptions())), + [], + ) + const defaultKey = utils.events.watch.streamedKey(input) + for (const sseOptions of [ + undefined, + {}, + { maxEventSize: undefined }, + { maxEventSize: 10 * 1024 * 1024 }, + ]) { + deepStrictEqual( + defaultKey, + utils.events.watch.streamedKey(input, { + sseOptions, + maxChunks: undefined, + refetchMode: 'reset', + }), + ) + deepStrictEqual( + defaultKey, + utils.events.watch.streamedOptions({ input, sseOptions }).queryKey, + ) + } + notDeepStrictEqual( + defaultKey, + utils.events.watch.streamedKey(input, { sseOptions: { maxEventSize: 1024 } }), + ) + notDeepStrictEqual( + defaultKey, + utils.events.watch.streamedKey(input, { refetchMode: 'append' }), + ) + equal(Object.isFrozen(defaultKey), true) + equal(Object.isFrozen(defaultKey.at(-1)), true) + for (const refetchMode of ['reset', 'append', 'replace'] as const) { + for (const maxChunks of [undefined, 2]) { + deepStrictEqual( + utils.events.watch.streamedKey(input, { refetchMode, maxChunks }), + utils.events.watch.streamedOptions({ input, refetchMode, maxChunks }).queryKey, + ) + deepStrictEqual( + utils.events.plain.streamedKey({ refetchMode, maxChunks }), + utils.events.plain.streamedOptions({ refetchMode, maxChunks }).queryKey, + ) + } + } + const skipped = utils.events.watch.streamedOptions({ + input: skipToken, + maxChunks: 2, + sseOptions: { maxEventSize: 1024 }, + staleTime: 123, + }) + equal(skipped.queryFn, skipToken) + equal(skipped.staleTime, 123) + deepStrictEqual(skipped.queryKey, [...utils.events.watch.key(), 'streamed']) + for (const [policy, code] of [ + ...[0, -1, 0.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, '2', null].map( + (maxChunks) => [{ maxChunks }, 'InvalidMaxChunks'] as const, + ), + ...['unknown', null, 3].map( + (refetchMode) => [{ refetchMode }, 'InvalidRefetchMode'] as const, + ), + ...[0, -1, 0.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, '2', null].map( + (maxEventSize) => [{ sseOptions: { maxEventSize } }, 'InvalidMaxEventSize'] as const, + ), + ]) { + for (const build of [ + () => Reflect.apply(utils.events.watch.streamedKey, undefined, [input, policy]), + () => Reflect.apply(utils.events.plain.streamedKey, undefined, [policy]), + ...[input, skipToken].map( + (request) => () => + Reflect.apply(utils.events.watch.streamedOptions, undefined, [ + { input: request, ...policy }, + ]), + ), + ]) { + throws(build, (error: unknown) => { + ok(error instanceof EffectHttpApiQueryConfigError) + equal(error.code, code) + equal(error.apiId, 'http-streams') + return true + }) + } + } + equal(queryClient.getQueriesData({ queryKey: utils.events.watch.key() }).length, 2) + yield* Effect.promise(() => + queryClient.invalidateQueries({ queryKey: utils.events.watch.key() }), + ) + equal(queryClient.getQueryState(options.queryKey)?.isInvalidated, true) + equal(queryClient.getQueryState(utils.events.wrapped.streamedKey())?.isInvalidated, false) + } finally { + queryClient.clear() + } + }).pipe(Effect.provide(Layer.mergeAll(handlers, HttpServer.layerServices)), Effect.scoped), +) + +for (const refetchMode of ['reset', 'append', 'replace'] as const) { + for (const maxChunks of [undefined, 2]) { + let body = 'data: 1\n\ndata: 2\n\ndata: 3\n\n' + const client = await makeClient(() => sse(body)) + const utils = createHttpApiQueryUtils(api, { client, keyPrefix: ['refetch'] }) + const queryClient = new QueryClient() + const options = utils.events.plain.streamedOptions({ + refetchMode, + maxChunks, + initialData: [99], + }) + try { + deepStrictEqual(await queryClient.query(options), maxChunks === 2 ? [2, 3] : [99, 1, 2, 3]) + body = 'data: 4\n\ndata: 5\n\n' + deepStrictEqual( + await queryClient.query(options), + refetchMode === 'append' && maxChunks === undefined ? [99, 1, 2, 3, 4, 5] : [4, 5], + ) + body = '' + deepStrictEqual( + await queryClient.query(options), + refetchMode === 'append' ? (maxChunks === undefined ? [99, 1, 2, 3, 4, 5] : [4, 5]) : [], + ) + } finally { + queryClient.clear() + } + } +} + +for (const [body, endpoint, headers, failureTag] of [ + ['data: "bad"\n\n', 'plain', {}, 'SchemaError'], + ['data: 1\n\n', 'wrapped', { 'x-version': 'bad' }, 'SchemaError'], + ['id: v3\ndata: 1\n\nretry: 25\n\n', 'plain', {}, 'Retry'], +] as const) { + const client = await makeClient(() => sse(body, headers)) + const utils = createHttpApiQueryUtils(api, { client, keyPrefix: ['failures'] }) + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + try { + const options = + endpoint === 'wrapped' + ? utils.events.wrapped.streamedOptions() + : utils.events.plain.streamedOptions() + const pending = + endpoint === 'wrapped' + ? queryClient.query(utils.events.wrapped.streamedOptions()) + : queryClient.query(utils.events.plain.streamedOptions()) + await rejects( + pending, + expectFailure(endpoint, (cause) => + deepStrictEqual( + cause.reasons.map((reason) => + reason._tag === 'Fail' ? Reflect.get(reason.error as object, '_tag') : reason._tag, + ), + [failureTag], + ), + ), + ) + if (failureTag === 'Retry') deepStrictEqual(queryClient.getQueryData(options.queryKey), [1]) + } finally { + queryClient.clear() + } +} + +{ + const client = await makeClient( + () => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('data: 12345')) + controller.enqueue(new TextEncoder().encode('\n\n')) + controller.close() + }, + }), + { headers: { 'content-type': 'text/event-stream' } }, + ), + ) + const utils = createHttpApiQueryUtils(api, { client, keyPrefix: ['decoder'] }) + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + try { + deepStrictEqual(await queryClient.query(utils.events.plain.streamedOptions()), [12345]) + await rejects( + queryClient.query(utils.events.plain.streamedOptions({ sseOptions: { maxEventSize: 3 } })), + expectFailure('plain', (cause) => + equal( + cause.reasons[0]?._tag === 'Fail' && + Reflect.get(cause.reasons[0].error as object, '_tag'), + 'SseError', + ), + ), + ) + } finally { + queryClient.clear() + } +} + +for (const phase of ['acquisition', 'consumption'] as const) { + const cause = Cause.combine(Cause.interrupt(123), Cause.die(new Error('defect'))) + const client = await Effect.runPromise( + HttpApiClient.makeWith(api, { + baseUrl: 'https://example.test', + httpClient: HttpClient.make((request) => + phase === 'acquisition' + ? Effect.failCause(cause) + : Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response( + new ReadableStream({ + start(controller) { + controller.error(new Error('transport')) + }, + }), + { headers: { 'content-type': 'text/event-stream' } }, + ), + ), + ), + ), + }), + ) + const utils = createHttpApiQueryUtils(api, { client, keyPrefix: ['cause'] }) + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + try { + await rejects( + queryClient.query(utils.events.plain.streamedOptions()), + expectFailure('plain', (actual) => + phase === 'acquisition' + ? deepStrictEqual( + actual.reasons.map((reason) => reason._tag), + ['Interrupt', 'Die'], + ) + : ok(Cause.hasFails(actual)), + ), + ) + } finally { + queryClient.clear() + } +} + +for (const cancelPhase of ['acquisition', 'consumption'] as const) { + let closed = 0 + let signal: AbortSignal | undefined + const started = Deferred.makeUnsafe() + const finalized = Deferred.makeUnsafe() + const client = await Effect.runPromise( + HttpApiClient.makeWith(api, { + baseUrl: 'https://example.test', + httpClient: HttpClient.make((request, _url, transportSignal) => { + signal = transportSignal + if (cancelPhase === 'acquisition') + return Deferred.succeed(started, undefined).pipe( + Effect.andThen(Effect.never), + Effect.ensuring(Deferred.succeed(finalized, undefined)), + ) + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('data: 1\n\n')) + }, + cancel() { + closed += 1 + Effect.runSync(Deferred.succeed(finalized, undefined)) + }, + }), + { headers: { 'content-type': 'text/event-stream' } }, + ), + ), + ) + }), + }), + ) + const utils = createHttpApiQueryUtils(api, { client, keyPrefix: ['cancel'] }) + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const options = utils.events.plain.streamedOptions() + const pending = queryClient.query(options) + const outcome = pending.catch((error: unknown) => error) + try { + if (cancelPhase === 'acquisition') + await Effect.runPromise(Deferred.await(started).pipe(Effect.timeout('5 seconds'))) + else await waitFor(queryClient, () => queryClient.getQueryData(options.queryKey)?.length === 1) + await queryClient.cancelQueries({ queryKey: options.queryKey, exact: true }) + const result = await outcome + if (cancelPhase === 'acquisition') ok(isCancelledError(result)) + else deepStrictEqual(result, [1]) + await Effect.runPromise(Deferred.await(finalized).pipe(Effect.timeout('5 seconds'))) + equal(signal?.aborted, true) + equal(closed, cancelPhase === 'consumption' ? 1 : 0) + deepStrictEqual( + queryClient.getQueryData(options.queryKey), + cancelPhase === 'acquisition' ? undefined : [1], + ) + } finally { + queryClient.clear() + } +} + +{ + let closed = 0 + const finalized = Deferred.makeUnsafe() + const client = await makeClient( + () => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('data: 1\n\n')) + }, + cancel() { + closed += 1 + Effect.runSync(Deferred.succeed(finalized, undefined)) + }, + }), + { headers: { 'content-type': 'text/event-stream' } }, + ), + ) + const utils = createHttpApiQueryUtils(api, { client, keyPrefix: ['observer'] }) + const queryClient = new QueryClient({ + defaultOptions: { + queries: { queryKeyHashFn: (key) => `custom:${JSON.stringify(key)}`, retry: false }, + }, + }) + const observer = new QueryObserver(queryClient, utils.events.plain.streamedOptions()) + const unsubscribe = observer.subscribe(() => undefined) + try { + await waitFor(queryClient, () => observer.getCurrentResult().data?.length === 1) + equal(observer.getCurrentResult().status, 'success') + equal(observer.getCurrentResult().fetchStatus, 'fetching') + deepStrictEqual(queryClient.getQueryData(utils.events.plain.streamedKey()), [1]) + equal(queryClient.getQueryCache().getAll().length, 1) + unsubscribe() + await Effect.runPromise(Deferred.await(finalized).pipe(Effect.timeout('5 seconds'))) + equal(closed, 1) + } finally { + unsubscribe() + queryClient.clear() + } +} + +class Scale extends Context.Service()('HttpStream/Scale') {} +const Scaled = Schema.Finite.pipe( + Schema.decodeTo( + Schema.Finite, + SchemaTransformation.transformEffect({ + decode: (value) => Scale.pipe(Effect.map(({ factor }) => value * factor)), + encode: Effect.succeed, + }), + ), +) +const serviceApi = HttpApi.make('services').add( + HttpApiGroup.make('events').add( + HttpApiEndpoint.get('watch', '/watch', { success: HttpApiSchema.StreamSse({ data: Scaled }) }), + ), +) +const serviceClient = await Effect.runPromise( + HttpApiClient.makeWith(serviceApi, { + baseUrl: 'https://example.test', + httpClient: HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, sse('data: 3\n\n'))), + ), + }), +) +const serviceUtils = createHttpApiQueryUtils(serviceApi, { + client: serviceClient, + keyPrefix: ['services'], + runPromiseExit: (effect, options) => + Effect.runPromiseExit(effect.pipe(Effect.provideService(Scale, { factor: 2 })), options), +}) +const serviceQueryClient = new QueryClient() +try { + deepStrictEqual(await serviceQueryClient.query(serviceUtils.events.watch.streamedOptions()), [6]) +} finally { + serviceQueryClient.clear() +} + +{ + const client = await makeClient(() => sse('data: 1\n\n')) + const cause = Cause.combine(Cause.interrupt(123), Cause.die(new Error('defect'))) + const failedExit = Exit.failCause(cause) + const failed = createHttpApiQueryUtils(api, { + client, + keyPrefix: ['runner-cause'], + runPromiseExit: () => Promise.resolve(failedExit), + }) + const rejection = new Error('runner rejected') + const utils = createHttpApiQueryUtils(api, { + client, + keyPrefix: ['runner'], + runPromiseExit: () => Promise.reject(rejection), + }) + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + try { + await rejects( + queryClient.query(failed.events.plain.streamedOptions()), + expectFailure('plain', (actual) => equal(actual, cause)), + ) + await rejects( + queryClient.query(utils.events.plain.streamedOptions()), + (error) => error === rejection, + ) + } finally { + queryClient.clear() + } +} diff --git a/tests/packed-consumer/stream-policy-runtime.mts b/tests/packed-consumer/stream-policy-runtime.mts index ff9c175..d61e569 100644 --- a/tests/packed-consumer/stream-policy-runtime.mts +++ b/tests/packed-consumer/stream-policy-runtime.mts @@ -269,14 +269,15 @@ for (const maxChunks of [undefined, 3]) { input: { channel: 'news' }, maxChunks, refetchMode, + initialData: [99], }) try { - deepStrictEqual(yield* Effect.promise(() => queryClient.query(options)), [1, 2]) + deepStrictEqual(yield* Effect.promise(() => queryClient.query(options)), [99, 1, 2]) values = [3, 4] release = Deferred.makeUnsafe() const pending = queryClient.refetchQueries({ queryKey: options.queryKey, exact: true }) yield* Deferred.await(received).pipe(Effect.timeout('5 seconds')) - const appended = maxChunks === 3 ? [2, 3, 4] : [1, 2, 3, 4] + const appended = maxChunks === 3 ? [2, 3, 4] : [99, 1, 2, 3, 4] if (refetchMode !== 'replace') { yield* Effect.promise(() => waitFor( @@ -287,7 +288,7 @@ for (const maxChunks of [undefined, 3]) { } deepStrictEqual( queryClient.getQueryData(options.queryKey), - refetchMode === 'replace' ? [1, 2] : refetchMode === 'append' ? appended : [3, 4], + refetchMode === 'replace' ? [99, 1, 2] : refetchMode === 'append' ? appended : [3, 4], ) equal(queryClient.getQueryState(options.queryKey)?.fetchStatus, 'fetching') yield* Deferred.succeed(release, undefined) @@ -297,6 +298,12 @@ for (const maxChunks of [undefined, 3]) { refetchMode === 'append' ? appended : [3, 4], ) equal(queryClient.getQueryState(options.queryKey)?.fetchStatus, 'idle') + values = [] + release = undefined + deepStrictEqual( + yield* Effect.promise(() => queryClient.query(options)), + refetchMode === 'append' ? appended : [], + ) } finally { queryClient.clear() } diff --git a/tests/packed-consumer/tsconfig.json b/tests/packed-consumer/tsconfig.json index 3687a12..e95d705 100644 --- a/tests/packed-consumer/tsconfig.json +++ b/tests/packed-consumer/tsconfig.json @@ -17,6 +17,8 @@ "stream-cause-runtime.mts", "stream-policy-runtime.mts", "http-runtime.mts", + "http-stream-runtime.mts", + "http-stream-contract.ts", "public-contract.ts", "http-contract.ts", "docs-rpc-quick-start.ts", diff --git a/tests/types/http-stream-contract.ts b/tests/types/http-stream-contract.ts new file mode 100644 index 0000000..0997bd9 --- /dev/null +++ b/tests/types/http-stream-contract.ts @@ -0,0 +1,190 @@ +import { QueryClient, QueryObserver, skipToken } from '@tanstack/query-core' +import { Context, Effect, Schema, SchemaTransformation } from 'effect' +import type { Stream } from 'effect' +import { createHttpApiQueryUtils } from 'effect-api-query' +import type { EffectHttpApiQueryError, RunPromiseExit } from 'effect-api-query' +import type { Sse } from 'effect/encoding' +import type { HttpClientError } from 'effect/http' +import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from 'effect/http-api' +import type { HttpApiClient } from 'effect/http-api' + +type Equal = + (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false +type Assert = T + +class Scale extends Context.Service()('HttpStream/Scale') {} +const Scaled = Schema.Finite.pipe( + Schema.decodeTo( + Schema.Finite, + SchemaTransformation.transformEffect({ + decode: (value) => Scale.pipe(Effect.map(({ factor }) => value * factor)), + encode: Effect.succeed, + }), + ), +) +const Expired = Schema.TaggedStruct('Expired', { reason: Schema.String }) +const Plain = HttpApiEndpoint.get('plain', '/plain/:id', { + params: { id: Schema.FiniteFromString }, + success: HttpApiSchema.StreamSse({ data: Scaled, error: Expired }), +}) +const Wrapped = HttpApiEndpoint.get('wrapped', '/wrapped', { + success: HttpApiSchema.WithHeaders(HttpApiSchema.StreamSse({ data: Schema.Finite }), { + 'x-version': Schema.FiniteFromString, + }), +}) +const Mixed = HttpApiEndpoint.get('mixed', '/mixed', { + success: [ + HttpApiSchema.StreamSse({ data: Schema.Finite }), + Schema.String.pipe(HttpApiSchema.status(201)), + ], +}) +const Bytes = HttpApiEndpoint.get('bytes', '/bytes', { success: HttpApiSchema.StreamUint8Array() }) +const WrappedBytes = HttpApiEndpoint.get('wrappedBytes', '/wrapped-bytes', { + success: HttpApiSchema.WithHeaders(HttpApiSchema.StreamUint8Array(), { etag: Schema.String }), +}) +const Multipart = HttpApiEndpoint.post('multipart', '/multipart', { + payload: Schema.Struct({ name: Schema.String }).pipe(HttpApiSchema.asMultipart()), + success: HttpApiSchema.StreamSse({ data: Schema.Finite }), +}) +const Events = HttpApiEndpoint.get('events', '/events', { + success: HttpApiSchema.StreamSse({ + events: Schema.Struct({ + id: Schema.String, + event: Schema.Literal('changed'), + data: Schema.String, + }), + }), +}) +const api = HttpApi.make('stream-contract').add( + HttpApiGroup.make('views').add(Plain, Wrapped, Mixed, Bytes, WrappedBytes, Multipart), + HttpApiGroup.make('system', { topLevel: true }).add(Events), + HttpApiGroup.make('omitted').add(Bytes), +) +declare const client: HttpApiClient.ForApi +declare const runPromiseExit: RunPromiseExit +const utils = createHttpApiQueryUtils(api, { client, keyPrefix: ['consumer'], runPromiseExit }) +true satisfies Assert> +true satisfies Assert> +true satisfies Assert< + Equal +> +const queryClient = new QueryClient() +const input = { params: { id: 1 } } +const options = utils.views.plain.streamedOptions({ + input, + maxChunks: 3, + refetchMode: 'append', + sseOptions: { maxEventSize: 1024 }, + select: (values) => values.length, + staleTime: Infinity, +}) +const data = queryClient.getQueryData(options.queryKey) +true satisfies Assert> +const state = queryClient.getQueryState(options.queryKey) +type Error = EffectHttpApiQueryError< + | 'client-error' + | typeof Expired.Type + | HttpClientError.HttpClientError + | Schema.SchemaError + | Sse.Retry + | Sse.SseError +> +true satisfies Assert['error'], Error | null>> +const observer = new QueryObserver(queryClient, options) +true satisfies Assert< + Equal['data'], number | undefined> +> +const key = utils.views.plain.streamedKey(input, { + maxChunks: 3, + sseOptions: { maxEventSize: 1024 }, +}) +queryClient.setQueryData(key, [1, 2]) +const skipped = utils.views.plain.streamedOptions({ + input: skipToken, + sseOptions: { maxEventSize: 1024 }, + maxChunks: 2, +}) +skipped.queryFn satisfies typeof skipToken +const conditional = utils.views.plain.streamedOptions({ + input: Math.random() > 0.5 ? input : skipToken, +}) +new QueryObserver(queryClient, conditional) +const initial = utils.views.plain.streamedOptions({ + input, + initialData: [1], + select: (values) => values.length, +}) +const wrapped = queryClient.getQueryData( + utils.views.wrapped.streamedKey({ refetchMode: 'replace' }), +) +true satisfies Assert< + Equal< + typeof wrapped, + readonly HttpApiSchema.withHeaders[] | undefined + > +> +const events = queryClient.getQueryData(utils.events.streamedKey()) +true satisfies Assert< + Equal< + typeof events, + readonly { readonly id: string; readonly event: 'changed'; readonly data: string }[] | undefined + > +> +const captured = { params: { id: 1 }, sseOptions: { maxEventSize: 1024 } } +// @ts-expect-error Decoder controls remain separate from request input. +utils.views.plain.streamedKey(captured) +// @ts-expect-error The adapter owns response mode. +utils.views.plain.streamedOptions({ input: { ...input, responseMode: 'response-only' } }) +// @ts-expect-error SSE endpoints expose accumulated query builders. +utils.views.plain.queryOptions({ input }) +// @ts-expect-error SSE endpoints expose accumulated query builders. +utils.views.plain.mutationOptions() +// @ts-expect-error Inputless endpoints do not accept skipToken. +utils.views.wrapped.streamedOptions({ input: skipToken }) +// @ts-expect-error Caller hashes belong in QueryClient defaults. +utils.views.plain.streamedOptions({ input, queryHash: 'shared' }) +// @ts-expect-error The acquisition decoding services require a runner. +createHttpApiQueryUtils(api, { client, keyPrefix: ['missing'] }) +const custom = createHttpApiQueryUtils(api, { + client, + keyPrefix: ['custom'], + runPromiseExit, + keyEncoders: { views: { plain: (request) => request.params.id } }, +}) +custom.views.plain.streamedKey(input) +initial.queryKey satisfies typeof key + +declare const extraStream: Stream.Stream +declare const extraClient: HttpApiClient.ForApi & { + readonly views: { + readonly plain: (request: { + readonly params: { readonly id: number } + readonly responseMode: 'decoded-only' + }) => Effect.Effect + } +} +declare const extraRunner: RunPromiseExit +const extra = createHttpApiQueryUtils(api, { + client: extraClient, + keyPrefix: ['extra'], + runPromiseExit: extraRunner, +}) +queryClient.getQueryData(extra.views.plain.streamedKey(input)) satisfies + | readonly number[] + | undefined + +const extraState = queryClient.getQueryState(extra.views.plain.streamedKey(input)) +type ExtraFailure = NonNullable['error'] extends EffectHttpApiQueryError< + infer E +> | null + ? E + : never +true satisfies Assert< + Equal, 'extra-error' | 'stream-error'> +> +createHttpApiQueryUtils(api, { + client: extraClient, + keyPrefix: ['extra'], + // @ts-expect-error Additional acquisition and consumption requirements remain caller-owned. + runPromiseExit: Effect.runPromiseExit, +}) diff --git a/vite.config.ts b/vite.config.ts index f029268..4dc1bfd 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -172,6 +172,10 @@ export default defineConfig({ 'eslint/max-classes-per-file': ['error', 4], }, }, + { + files: ['src/http/types.ts'], + rules: { 'typescript/no-redundant-type-constituents': 'off' }, + }, { files: ['src/{core,http,rpc}/types.ts'], rules: { @@ -186,9 +190,11 @@ export default defineConfig({ files: [ 'src/core/operation.ts', 'src/core/schema-key.ts', + 'src/core/streamed-query.ts', 'src/core/utility-tree.ts', 'src/http/operation.ts', 'src/http/request.ts', + 'src/http/streamed-query.ts', 'src/rpc/operation.ts', 'src/rpc/streamed-query.ts', ], From 18b596bc2769c41bf1b6071fef79c29eecc76faa Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Fri, 9 Oct 2026 22:55:19 +0200 Subject: [PATCH 24/97] test: complete HTTP stream contract verification --- .../docs/src/content/docs/reference/errors.md | 9 +++- tests/packed-consumer/http-stream-runtime.mts | 44 +++++++++++++++++++ tests/types/http-stream-contract.ts | 19 +++++--- 3 files changed, 63 insertions(+), 9 deletions(-) diff --git a/apps/docs/src/content/docs/reference/errors.md b/apps/docs/src/content/docs/reference/errors.md index afe2e88..c2d01e8 100644 --- a/apps/docs/src/content/docs/reference/errors.md +++ b/apps/docs/src/content/docs/reference/errors.md @@ -48,6 +48,9 @@ Thrown when a live query's stream completes before emitting a value. It exposes Thrown synchronously while configuring HTTP utilities. Its `code` is one of: +- `InvalidMaxChunks`: a streamed-query bound is not a positive safe integer. +- `InvalidRefetchMode`: a streamed-query mode is not `reset`, `append`, or `replace`. +- `InvalidMaxEventSize`: an SSE parser limit is not a positive safe integer. - `InvalidKeyPrefix` - `InvalidEndpointPath` - `EndpointPathCollision` @@ -74,9 +77,11 @@ for each code's trigger. ## `EffectHttpApiQueryError` Thrown when the HTTP runner returns a failed `Exit`. It exposes `apiId`, `groupId`, `endpoint`, -`method`, `operation`, and the full `Cause.Cause`. The operation is `query`, `infinite`, or `mutation`. +`method`, `operation`, and the full `Cause.Cause`. The operation is `query`, `infinite`, `mutation`, +or `streamed`. Use `isEffectHttpApiQueryError(value)` to narrow errors within the same JavaScript realm. The Cause preserves endpoint, middleware, Schema, and HTTP client errors, including defects and -interruption. It can contain upstream requests, responses, or sensitive input values; inspect those +interruption. SSE failures also preserve declared event errors, `Sse.Retry`, and `Sse.SseError`. +It can contain upstream requests, responses, or sensitive input values; inspect those values before logging or exposing them. Runner rejections pass through unchanged. diff --git a/tests/packed-consumer/http-stream-runtime.mts b/tests/packed-consumer/http-stream-runtime.mts index 60a0749..4fb842e 100644 --- a/tests/packed-consumer/http-stream-runtime.mts +++ b/tests/packed-consumer/http-stream-runtime.mts @@ -184,6 +184,10 @@ await Effect.runPromise( yield* Effect.promise(() => queryClient.query(utils.events.plain.streamedOptions())), [], ) + deepStrictEqual( + yield* Effect.promise(() => queryClient.query(utils.events.undefined.streamedOptions())), + [undefined, undefined], + ) const defaultKey = utils.events.watch.streamedKey(input) for (const sseOptions of [ undefined, @@ -270,6 +274,46 @@ await Effect.runPromise( ) equal(queryClient.getQueryState(options.queryKey)?.isInvalidated, true) equal(queryClient.getQueryState(utils.events.wrapped.streamedKey())?.isInvalidated, false) + for (const [channel, tags] of [ + ['failure', ['Fail']], + ['interruption', ['Interrupt']], + ['mixed', ['Interrupt', 'Die']], + ] as const) { + const stream = yield* client.events.watch({ + query: { channel }, + responseMode: 'decoded-only', + }) + const native = yield* Effect.exit(Stream.runCollect(stream)) + ok(Exit.isFailure(native)) + deepStrictEqual( + native.cause.reasons.map((reason) => reason._tag), + tags, + ) + const failedOptions = utils.events.watch.streamedOptions({ + input: { query: { channel } }, + retry: false, + }) + yield* Effect.promise(() => + rejects( + queryClient.query(failedOptions), + expectFailure('watch', (cause) => { + deepStrictEqual( + cause.reasons.map((reason) => reason._tag), + tags, + ) + if (channel === 'failure') { + deepStrictEqual(cause.reasons[0]?._tag === 'Fail' && cause.reasons[0].error, { + _tag: 'Expired', + reason: 'resume', + }) + } + }), + ), + ) + if (channel === 'failure') { + deepStrictEqual(queryClient.getQueryData(failedOptions.queryKey), [1]) + } + } } finally { queryClient.clear() } diff --git a/tests/types/http-stream-contract.ts b/tests/types/http-stream-contract.ts index 0997bd9..1cd3e87 100644 --- a/tests/types/http-stream-contract.ts +++ b/tests/types/http-stream-contract.ts @@ -2,7 +2,11 @@ import { QueryClient, QueryObserver, skipToken } from '@tanstack/query-core' import { Context, Effect, Schema, SchemaTransformation } from 'effect' import type { Stream } from 'effect' import { createHttpApiQueryUtils } from 'effect-api-query' -import type { EffectHttpApiQueryError, RunPromiseExit } from 'effect-api-query' +import type { + CreateHttpApiQueryUtilsOptions, + EffectHttpApiQueryError, + RunPromiseExit, +} from 'effect-api-query' import type { Sse } from 'effect/encoding' import type { HttpClientError } from 'effect/http' import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from 'effect/http-api' @@ -182,9 +186,10 @@ type ExtraFailure = NonNullable['error'] extends EffectHttpAp true satisfies Assert< Equal, 'extra-error' | 'stream-error'> > -createHttpApiQueryUtils(api, { - client: extraClient, - keyPrefix: ['extra'], - // @ts-expect-error Additional acquisition and consumption requirements remain caller-owned. - runPromiseExit: Effect.runPromiseExit, -}) +type ExtraRequirements = CreateHttpApiQueryUtilsOptions< + typeof api, + readonly ['extra'], + typeof extraClient +>['runPromiseExit'] +true satisfies Assert> +true satisfies Assert From 9c779bd07d08c97f8f59d833e48cd2ef6c376cff Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Fri, 9 Oct 2026 22:55:54 +0200 Subject: [PATCH 25/97] fix: preserve metadata snapshot ownership through cache sharing --- .../docs/guides/http-queries-and-mutations.md | 4 + .../content/docs/reference/http-factory.md | 5 + src/core/operation.ts | 1 + src/core/utility-tree.ts | 72 +++++++++- src/http/operation.ts | 13 ++ .../packed-consumer/http-metadata-runtime.mts | 134 +++++++++++++++++- 6 files changed, 221 insertions(+), 8 deletions(-) diff --git a/apps/docs/src/content/docs/guides/http-queries-and-mutations.md b/apps/docs/src/content/docs/guides/http-queries-and-mutations.md index a6a3c4b..9c2bd6a 100644 --- a/apps/docs/src/content/docs/guides/http-queries-and-mutations.md +++ b/apps/docs/src/content/docs/guides/http-queries-and-mutations.md @@ -92,6 +92,10 @@ wrapper. A top-level successful `undefined` becomes `null`. `current.status` is data keeps its declared representation. Raw headers can contain private values: choose which headers your application may persist or dehydrate. +Fetched snapshots stay frozen after native structural sharing. Your global, prefix, and per-call +sharing policies still apply; decoded data is not frozen by the adapter. Values supplied through +`initialData`, hydration, or manual cache writes remain application-owned. + `metadataKey(input)` provides a typed key for cache reads and writes. Metadata options support selection, initial data, skipped input, native hashing defaults, and cancellation like ordinary queries. Metadata has its own cache identity and no mutation or infinite-query builders. diff --git a/apps/docs/src/content/docs/reference/http-factory.md b/apps/docs/src/content/docs/reference/http-factory.md index 4ee7fc7..b4b3020 100644 --- a/apps/docs/src/content/docs/reference/http-factory.md +++ b/apps/docs/src/content/docs/reference/http-factory.md @@ -113,6 +113,11 @@ value and its ownership, including decoded `WithHeaders` wrappers. Only top-leve `undefined` becomes `null`. Raw headers retain their string values without Effect's inspection redaction; applications decide which headers may be persisted or dehydrated. +Fetched snapshots remain frozen after native structural sharing, while preserving global, prefix, +and per-call sharing policies. Mutable envelopes or header records selected by sharing are copied +before freezing, retaining the selected decoded data reference. The adapter does not freeze +decoded data or caller-supplied `initialData`, hydrated values, or manual cache writes. + Metadata keys use a `metadata` discriminator and the ordinary request identity. Endpoint prefixes match every view. Native `select`, `initialData`, skip-token inference, QueryClient hashing defaults, and cancellation apply to metadata options. Execution failures identify operation `metadata` and diff --git a/src/core/operation.ts b/src/core/operation.ts index 926d628..0a69f17 100644 --- a/src/core/operation.ts +++ b/src/core/operation.ts @@ -39,6 +39,7 @@ export interface UnaryOperation extends BufferedOperation { readonly metadata?: { readonly invoke: BufferedOperation['invoke'] readonly executionError: (operation: 'metadata', cause: Cause.Cause) => Error + readonly finalizeData: (data: unknown) => unknown } } diff --git a/src/core/utility-tree.ts b/src/core/utility-tree.ts index 3b92312..b9f5ed2 100644 --- a/src/core/utility-tree.ts +++ b/src/core/utility-tree.ts @@ -1,5 +1,5 @@ -import { skipToken } from '@tanstack/query-core' -import type { QueryFunction } from '@tanstack/query-core' +import { replaceEqualDeep, skipToken } from '@tanstack/query-core' +import type { QueryFunction, QueryFunctionContext } from '@tanstack/query-core' import { Effect, Exit, Predicate } from 'effect' import type { Cause } from 'effect' @@ -264,6 +264,7 @@ const createQueryBuilders = ( prepareExecution: ( options: Record, requestOptions: unknown, + input: unknown, ) => (input: unknown) => QueryFunction, prepareIdentity?: (options: Record) => readonly JsonValue[], ) => ({ @@ -276,7 +277,7 @@ const createQueryBuilders = ( const { input, options, requestOptions } = prepareQueryOptions(description, argument) const identity = prepareIdentity?.(options) ?? [] // Stream policy is consumed and validated even when execution will be skipped. - const makeQuery = prepareExecution(options, requestOptions) + const makeQuery = prepareExecution(options, requestOptions, input) if (description.input._tag !== 'Inputless' && input === skipToken) { return finalizeQueryOptions(options, operationKey, skipToken) } @@ -379,6 +380,59 @@ const createMutationLeaf = ( }) } +type RuntimeStructuralSharing = (previous: unknown, data: unknown) => unknown + +const isStructuralSharing = (policy: unknown): policy is RuntimeStructuralSharing => + Predicate.isFunction(policy) + +const shareQueryData = (previous: unknown, data: unknown, policy: unknown) => { + if (policy === false) { + return data + } + if (isStructuralSharing(policy)) { + return policy(previous, data) + } + return replaceEqualDeep(previous, data) +} + +const prepareMetadataExecution = ( + description: NonNullable, + options: Record, + runPromiseExit: RunPromiseExit, + requestOptions: unknown, + skipped: boolean, +) => { + const hasLocalPolicy = Object.hasOwn(options, 'structuralSharing') + const localPolicy = options['structuralSharing'] + const policies = new WeakMap() + if (!skipped) { + options['structuralSharing'] = (previous: unknown, data: unknown) => { + const produced = Predicate.isObject(data) && policies.has(data) + const policy = produced ? policies.get(data) : localPolicy + const shared = shareQueryData(previous, data, policy) + return produced ? description.finalizeData(shared) : shared + } + } + return (input: unknown) => + async ({ client, queryKey, signal }: QueryFunctionContext) => { + const policy = hasLocalPolicy + ? localPolicy + : client.defaultQueryOptions({ queryKey }).structuralSharing + const data = await execute( + description, + 'metadata', + input, + runPromiseExit, + requestOptions, + signal, + ) + if (Predicate.isObject(data)) { + policies.set(data, policy) + } + return data + } +} + const createUnaryLeaf = ( description: UnaryOperation, keyParts: readonly (JsonValue | string)[], @@ -404,10 +458,14 @@ const createUnaryLeaf = ( description, freezeKey([...operationKey, 'metadata']), keyEncoder, - (_options, requestOptions) => - (input) => - async ({ signal }: { readonly signal: AbortSignal }) => - execute(metadata, 'metadata', input, runPromiseExit, requestOptions, signal), + (options, requestOptions, input) => + prepareMetadataExecution( + metadata, + options, + runPromiseExit, + requestOptions, + description.input._tag !== 'Inputless' && input === skipToken, + ), ) return Object.freeze({ diff --git a/src/http/operation.ts b/src/http/operation.ts index ba95054..ab37428 100644 --- a/src/http/operation.ts +++ b/src/http/operation.ts @@ -99,6 +99,19 @@ const extractHttpEndpoints = (api: HttpApi.Top, client: unknown): readonly HttpO ), executionError: (operation: 'metadata', cause: Cause.Cause) => new EffectHttpApiQueryError(identity, operation, cause), + finalizeData: (data: unknown) => { + if (!Predicate.isObject(data)) { + return data + } + const { headers } = data + const snapshot = + Predicate.isObject(headers) && !Object.isFrozen(headers) + ? Object.freeze({ ...headers }) + : headers + return Object.isFrozen(data) && snapshot === headers + ? data + : Object.freeze({ ...data, headers: snapshot }) + }, }, } : undefined), diff --git a/tests/packed-consumer/http-metadata-runtime.mts b/tests/packed-consumer/http-metadata-runtime.mts index 48c2bbb..dbc9a5d 100644 --- a/tests/packed-consumer/http-metadata-runtime.mts +++ b/tests/packed-consumer/http-metadata-runtime.mts @@ -1,5 +1,5 @@ import { MutationObserver, QueryClient, QueryObserver } from '@tanstack/query-core' -import { Cause, Effect, Exit, Layer, Result, Schema } from 'effect' +import { Cause, Effect, Exit, Layer, Predicate, Result, Schema } from 'effect' import { createHttpApiQueryUtils, EffectHttpApiQueryConfigError, @@ -170,9 +170,59 @@ await Effect.runPromise( equal(queryClient.getQueryState(options.queryKey)?.isInvalidated, true) equal(queryClient.getQueryState(http.documents.read.queryKey(input))?.isInvalidated, true) const refreshed = yield* Effect.promise(() => queryClient.query(options)) + equal(Object.isFrozen(refreshed), true) + equal(Object.isFrozen(refreshed.headers), true) + equal(Object.isFrozen(queryClient.getQueryData(options.queryKey)), true) + equal(Object.isFrozen(queryClient.getQueryData(options.queryKey)?.headers), true) + equal(Object.isFrozen(refreshed.data), false) + equal(Object.isFrozen(refreshed.data.headers), false) equal(refreshed.data.body, 29) equal(refreshed.headers['etag'], '"revision-5"') equal(metadata.headers['etag'], '"revision-4"') + const sharedMetadata = queryClient.getQueryData(options.queryKey) + yield* Effect.promise(() => + queryClient.invalidateQueries({ queryKey: options.queryKey, exact: true }), + ) + yield* Effect.promise(() => queryClient.query(options)) + equal(queryClient.getQueryData(options.queryKey), sharedMetadata) + yield* Effect.promise(async () => { + const owned = { ...refreshed, headers: { ...refreshed.headers } } + const initialClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const customClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + try { + const initialOptions = http.documents.read.metadataOptions({ input, initialData: owned }) + await initialClient.query(initialOptions) + const initialSnapshot = initialClient.getQueryData(initialOptions.queryKey) + ok(initialSnapshot !== owned) + ok(initialSnapshot?.headers !== owned.headers) + equal(initialSnapshot?.data, owned.data) + equal(Object.isFrozen(initialSnapshot), true) + equal(Object.isFrozen(initialSnapshot?.headers), true) + equal(Object.isFrozen(owned), false) + equal(Object.isFrozen(owned.headers), false) + const customOptions = http.documents.read.metadataOptions({ + input, + structuralSharing: () => owned, + }) + await customClient.query(customOptions) + const customSnapshot = customClient.getQueryData(customOptions.queryKey) + ok(customSnapshot !== owned) + ok(customSnapshot?.headers !== owned.headers) + equal(customSnapshot?.data, owned.data) + equal(Object.isFrozen(customSnapshot), true) + equal(Object.isFrozen(customSnapshot?.headers), true) + owned.status = 299 + owned.headers['etag'] = 'application-owned' + equal(initialSnapshot?.status, 203) + equal(customSnapshot?.status, 203) + equal(initialSnapshot?.headers['etag'], '"revision-5"') + equal(customSnapshot?.headers['etag'], '"revision-5"') + equal(Object.isFrozen(owned.data), false) + } finally { + initialClient.clear() + customClient.clear() + } + }) yield* Effect.promise(() => rejects( update.mutate({ @@ -255,6 +305,88 @@ await Effect.runPromise( (error: unknown) => error === rejected, ), ) + yield* Effect.promise(async () => { + let globalCalls = 0 + let prefixCalls = 0 + let localCalls = 0 + const left = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + structuralSharing: (_previous, data) => { + globalCalls += 1 + return { ...(Predicate.isObject(data) ? data : undefined), status: 211 } + }, + }, + }, + }) + const right = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + structuralSharing: false, + }, + }, + }) + right.setQueryDefaults(http.empty.key(), { + structuralSharing: (_previous, data) => { + prefixCalls += 1 + return { ...(Predicate.isObject(data) ? data : undefined), status: 212 } + }, + }) + try { + const sharedOptions = http.empty.metadataOptions() + await Promise.all([left.query(sharedOptions), right.query(sharedOptions)]) + equal(left.getQueryData(sharedOptions.queryKey)?.status, 211) + equal(right.getQueryData(sharedOptions.queryKey)?.status, 212) + equal(globalCalls, 1) + equal(prefixCalls, 1) + equal(Object.isFrozen(left.getQueryData(sharedOptions.queryKey)), true) + equal(Object.isFrozen(right.getQueryData(sharedOptions.queryKey)?.headers), true) + await Promise.all([left.query(sharedOptions), right.query(sharedOptions)]) + equal(globalCalls, 2) + equal(prefixCalls, 2) + equal(left.getQueryData(sharedOptions.queryKey)?.status, 211) + equal(right.getQueryData(sharedOptions.queryKey)?.status, 212) + const localOptions = http.empty.metadataOptions({ + structuralSharing: (_previous, data) => { + localCalls += 1 + return { ...(Predicate.isObject(data) ? data : undefined), status: 213 } + }, + }) + await Promise.all([left.query(localOptions), right.query(localOptions)]) + equal(globalCalls, 2) + equal(prefixCalls, 2) + equal(localCalls, 2) + equal(left.getQueryData(localOptions.queryKey)?.status, 213) + equal(right.getQueryData(localOptions.queryKey)?.status, 213) + const inheritedFalse = http.empty.metadataOptions() + left.setDefaultOptions({ queries: { retry: false, structuralSharing: false } }) + right.setQueryDefaults(http.empty.key(), { structuralSharing: false }) + const [leftInherited, rightInherited] = await Promise.all([ + left.query(inheritedFalse), + right.query(inheritedFalse), + ]) + equal(left.getQueryData(inheritedFalse.queryKey), leftInherited) + equal(right.getQueryData(inheritedFalse.queryKey), rightInherited) + equal(globalCalls, 2) + equal(prefixCalls, 2) + const noSharing = http.empty.metadataOptions({ structuralSharing: false }) + const [leftValue, rightValue] = await Promise.all([ + left.query(noSharing), + right.query(noSharing), + ]) + equal(left.getQueryData(noSharing.queryKey), leftValue) + equal(right.getQueryData(noSharing.queryKey), rightValue) + equal(Object.isFrozen(leftValue), true) + equal(globalCalls, 2) + equal(prefixCalls, 2) + equal(localCalls, 2) + } finally { + left.clear() + right.clear() + } + }) }).pipe(Effect.provide(Layer.mergeAll(handlers, HttpServer.layerServices))), ), ) From 17eefc2327ba49b2ecc95a0136c70a3c4c47f52e Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Fri, 9 Oct 2026 22:56:23 +0200 Subject: [PATCH 26/97] docs: add stable unary hydration recipes --- .fallowrc.jsonc | 4 +- README.md | 1 + apps/docs/astro.config.mjs | 1 + .../content/docs/guides/hydrate-unary-data.md | 257 ++++++++++++++ .../src/content/docs/guides/tanstack-start.md | 7 +- examples/tanstack-start/src/lib/query-ssr.ts | 2 - scripts/verify-docs-examples.mts | 34 +- scripts/verify-packed-consumer.mts | 1 + tests/packed-consumer/docs-hydration-async.ts | 52 +++ tests/packed-consumer/docs-hydration-dto.ts | 47 +++ tests/packed-consumer/docs-hydration-rich.ts | 56 +++ tests/packed-consumer/hydration-contract.ts | 68 ++++ tests/packed-consumer/hydration-runtime.mts | 332 ++++++++++++++++++ tests/packed-consumer/tsconfig.json | 3 + 14 files changed, 848 insertions(+), 17 deletions(-) create mode 100644 apps/docs/src/content/docs/guides/hydrate-unary-data.md create mode 100644 tests/packed-consumer/docs-hydration-async.ts create mode 100644 tests/packed-consumer/docs-hydration-dto.ts create mode 100644 tests/packed-consumer/docs-hydration-rich.ts create mode 100644 tests/packed-consumer/hydration-contract.ts create mode 100644 tests/packed-consumer/hydration-runtime.mts diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index f70d52d..c5b0186 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -9,7 +9,9 @@ "tests/packed-consumer/hashing-runtime.mts", "tests/packed-consumer/stream-cause-runtime.mts", "tests/packed-consumer/stream-policy-runtime.mts", - "tests/packed-consumer/retry-runtime.mts" + "tests/packed-consumer/retry-runtime.mts", + "tests/packed-consumer/hydration-runtime.mts", + "tests/packed-consumer/hydration-contract.ts" ], "ignorePatterns": ["examples/tanstack-start/src/routeTree.gen.ts"], diff --git a/README.md b/README.md index 750d3ef..3704c4f 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,7 @@ Streaming HTTP responses and streaming multipart requests remain omitted. - [React Query](https://ueberbrot.github.io/effect-api-query/guides/react-query/): use generated options with hooks. - [TanStack Start](https://ueberbrot.github.io/effect-api-query/guides/tanstack-start/): share RPC and HTTP options across loaders, server rendering, and hydration. +- [Hydrate Unary Data](https://ueberbrot.github.io/effect-api-query/guides/hydrate-unary-data/): keep DTOs or reconstructed Schema values consistent across JSON hydration and refetch. - [Run the examples](https://ueberbrot.github.io/effect-api-query/examples/): try complete Vite React and TanStack Start applications. - [Feature support](https://ueberbrot.github.io/effect-api-query/getting-started/feature-support/): check available operations and integration limits. - [API reference](https://ueberbrot.github.io/effect-api-query/reference/public-exports/): look up factories, builders, and errors. diff --git a/apps/docs/astro.config.mjs b/apps/docs/astro.config.mjs index 3b040ab..d782e7a 100644 --- a/apps/docs/astro.config.mjs +++ b/apps/docs/astro.config.mjs @@ -72,6 +72,7 @@ export default defineConfig({ { label: 'Cancellation', slug: 'guides/cancellation' }, { label: 'Handle Failures', slug: 'guides/handle-failures' }, { label: 'Retry Queries', slug: 'guides/retry-queries' }, + { label: 'Hydrate Unary Data', slug: 'guides/hydrate-unary-data' }, { label: 'Custom Key Encoders', slug: 'guides/custom-key-encoders' }, { label: 'Conditional Queries', slug: 'guides/conditional-queries' }, ], diff --git a/apps/docs/src/content/docs/guides/hydrate-unary-data.md b/apps/docs/src/content/docs/guides/hydrate-unary-data.md new file mode 100644 index 0000000..7ccb9f1 --- /dev/null +++ b/apps/docs/src/content/docs/guides/hydrate-unary-data.md @@ -0,0 +1,257 @@ +--- +title: Hydrate Unary Data +description: Keep DTOs or rich Schema values consistent across JSON hydration and client refetch. +--- + +Choose the cache representation before connecting server rendering to TanStack Query. The server +and browser must use the same contract, key prefix, and inputs. Each owns its Query Client, ready +Effect client, and runtime. Cancel outstanding queries before disposing a runtime. + +These recipes cover unary profile reads through either factory. They keep only successful profile +queries and omit mutations. Use an explicit strategy for other cached shapes, including infinite +pages and stream snapshots. + +## Keep DTOs in both caches + +Use a JSON-safe success schema when components need plain data. The contract below keeps credits +as decimal strings and binary data as base64 strings. Return this DTO from the server operation; +the ready RPC or HTTP client produces the same DTO on the server and browser. + +Save the following as `docs-hydration-dto.ts`: + +```ts +import { defaultShouldDehydrateQuery, dehydrate, hydrate } from '@tanstack/query-core' +import type { QueryClient } from '@tanstack/query-core' +import { Schema } from 'effect' +import { createHttpApiQueryUtils, createRpcQueryUtils } from 'effect-api-query' +import { HttpApi, HttpApiClient, HttpApiEndpoint, HttpApiGroup } from 'effect/http-api' +import { Rpc, RpcClient, RpcGroup } from 'effect/rpc' + +export const ProfileDto = Schema.Struct({ + name: Schema.String, + credits: Schema.String, + avatar: Schema.String.check(Schema.isBase64()), +}) + +export const dtoGroup = RpcGroup.make(Rpc.make('profile.read', { success: ProfileDto })) +export const dtoApi = HttpApi.make('dto').add( + HttpApiGroup.make('profile').add( + HttpApiEndpoint.get('read', '/profile', { success: ProfileDto }), + ), +) + +export function dtoRpcOptions( + client: RpcClient.RpcClient.Flat, E>, +) { + return createRpcQueryUtils(dtoGroup, { + client, + keyPrefix: ['dto', 'rpc'], + }).profile.read.queryOptions({ retry: false, staleTime: 60_000 }) +} + +export function dtoHttpOptions(client: HttpApiClient.ForApi) { + return createHttpApiQueryUtils(dtoApi, { + client, + keyPrefix: ['dto', 'http'], + }).profile.read.queryOptions({ retry: false, staleTime: 60_000 }) +} + +export const snapshotDto = (queryClient: QueryClient): string => + JSON.stringify( + dehydrate(queryClient, { + shouldDehydrateMutation: () => false, + shouldDehydrateQuery: (query) => + defaultShouldDehydrateQuery(query) && query.queryKey[0] === 'dto', + }), + ) + +export const hydrateDto = (queryClient: QueryClient, json: string): void => + hydrate(queryClient, JSON.parse(json)) +``` + +Prefetch with `queryClient.query(dtoRpcOptions(rpcClient))` or the HTTP equivalent, then publish +`snapshotDto(queryClient)` through your framework's JSON transport. Call `hydrateDto` on the +browser's Query Client before its first query. The 60-second freshness window avoids an immediate +duplicate read; subsequent invalidation and refetch still produce DTOs. + +If your domain layer uses classes, convert them to this DTO in the operation or ready-client +adapter on both sides. TanStack's `select` changes an observer's result; it does not change cached +query data. + +## Reconstruct Schema classes with paired codecs + +Keep decoded domain objects in the cache when components need methods or rich values. Encode at +the snapshot boundary and decode during hydration. This `Profile` encodes bigint credits as a +string and its `Uint8Array` avatar as base64. Decoding reconstructs the class and its `summary` +method. + +Save the following as `docs-hydration-rich.ts`: + +```ts +import { defaultShouldDehydrateQuery, dehydrate, hydrate } from '@tanstack/query-core' +import type { QueryClient } from '@tanstack/query-core' +import { Schema } from 'effect' +import { createHttpApiQueryUtils, createRpcQueryUtils } from 'effect-api-query' +import { HttpApi, HttpApiClient, HttpApiEndpoint, HttpApiGroup } from 'effect/http-api' +import { Rpc, RpcClient, RpcGroup } from 'effect/rpc' + +export class Profile extends Schema.Class('Profile')({ + name: Schema.String, + credits: Schema.BigIntFromString, + avatar: Schema.Uint8ArrayFromBase64, +}) { + summary(): string { + return `${this.name}: ${this.credits} credits` + } +} + +export const profileGroup = RpcGroup.make( + Rpc.make('profile.read', { success: Profile, error: Schema.String }), +) +export const profileApi = HttpApi.make('rich').add( + HttpApiGroup.make('profile').add( + HttpApiEndpoint.get('read', '/profile', { success: Profile, error: Schema.String }), + ), +) + +export function profileRpcOptions( + client: RpcClient.RpcClient.Flat, E>, +) { + return createRpcQueryUtils(profileGroup, { + client, + keyPrefix: ['profile', 'rpc'], + }).profile.read.queryOptions({ retry: false, staleTime: 60_000 }) +} + +export function profileHttpOptions(client: HttpApiClient.ForApi) { + return createHttpApiQueryUtils(profileApi, { + client, + keyPrefix: ['profile', 'http'], + }).profile.read.queryOptions({ retry: false, staleTime: 60_000 }) +} + +export const snapshotProfile = (queryClient: QueryClient): string => + JSON.stringify( + dehydrate(queryClient, { + shouldDehydrateMutation: () => false, + shouldDehydrateQuery: (query) => + defaultShouldDehydrateQuery(query) && query.queryKey[0] === 'profile', + serializeData: Schema.encodeUnknownSync(Profile), + }), + ) + +export const hydrateProfile = (queryClient: QueryClient, json: string): void => + hydrate(queryClient, JSON.parse(json), { + defaultOptions: { deserializeData: Schema.decodeUnknownSync(Profile) }, + }) +``` + +Prefetch with `profileRpcOptions` or `profileHttpOptions`, publish `snapshotProfile`, then call +`hydrateProfile` on the browser's Query Client. The hydrated value and a later browser refetch +both have the `Profile` prototype, bigint credits, and byte array. Only the snapshot contains the +encoded strings. The `'profile'` prefix in this recipe is reserved for unary `Profile` data; +other query shapes need their own codec selection. + +`serializeData` and `deserializeData` are synchronous callbacks. The synchronous Schema functions +must be able to finish without asynchronous work or required services. A codec failure throws; +handle it through your rendering or hydration error path. + +`structuredClone` is not a paired JSON codec. It copies class properties without restoring the +class prototype or methods. It preserves bigint and typed arrays in memory, but JSON still +requires an encoding for those values. Cloning a server class alone also leaves hydration and a +later class-valued refetch with different representations. + +## Finish effectful preparation before publishing or hydrating + +When a codec needs services or asynchronous work, prepare the entire snapshot outside TanStack's +synchronous callbacks. This example adds a caller-owned preparation service to the same Profile +codec. Its hooks can await application work before encoding or decoding. + +Save the following alongside `docs-hydration-rich.ts`: + +```ts +import { defaultShouldDehydrateQuery, dehydrate, hydrate } from '@tanstack/query-core' +import type { DehydratedState, QueryClient } from '@tanstack/query-core' +import { Context, Effect, Schema } from 'effect' + +import { Profile } from './docs-hydration-rich.ts' + +export class HydrationPreparation extends Context.Service< + HydrationPreparation, + { + readonly beforeEncode: Effect.Effect + readonly beforeDecode: Effect.Effect + } +>()('HydrationPreparation') {} + +const PreparedProfile = Profile.pipe( + Schema.middlewareEncoding((encoding) => + Effect.flatMap(HydrationPreparation, ({ beforeEncode }) => + Effect.andThen(beforeEncode, encoding), + ), + ), + Schema.middlewareDecoding((decoding) => + Effect.flatMap(HydrationPreparation, ({ beforeDecode }) => + Effect.andThen(beforeDecode, decoding), + ), + ), +) + +export const prepareProfileSnapshot = (queryClient: QueryClient) => + Effect.gen(function* () { + const snapshot = dehydrate(queryClient, { + shouldDehydrateMutation: () => false, + shouldDehydrateQuery: (query) => + defaultShouldDehydrateQuery(query) && query.queryKey[0] === 'profile', + }) + const queries = yield* Effect.forEach(snapshot.queries, (query) => + Schema.encodeUnknownEffect(PreparedProfile)(query.state.data).pipe( + Effect.map((data) => ({ ...query, state: { ...query.state, data } })), + ), + ) + return JSON.stringify({ ...snapshot, queries }) + }) + +export const prepareProfileHydration = (queryClient: QueryClient, json: string) => + Effect.gen(function* () { + const snapshot: DehydratedState = JSON.parse(json) + const queries = yield* Effect.forEach(snapshot.queries, (query) => + Schema.decodeUnknownEffect(PreparedProfile)(query.state.data).pipe( + Effect.map((data) => ({ ...query, state: { ...query.state, data } })), + ), + ) + yield* Effect.sync(() => hydrate(queryClient, { ...snapshot, queries })) + }) +``` + +Provide `HydrationPreparation` through your application Context or ManagedRuntime. Await the +result of `prepareProfileSnapshot` before publishing its JSON. On the browser, await +`prepareProfileHydration` before allowing queries or components to read the hydrated cache. The +latter decodes every entry before calling `hydrate`, so decoding failure leaves the destination +cache untouched. Keep the preparation runtime alive until the Effect completes, and dispose it +through the application's lifecycle. + +Neither preparation function puts an Effect or Promise into query data. The server cache keeps +`Profile` values, the published snapshot contains encoded data, and the browser cache receives +fully decoded `Profile` values. The Schema codec validates each profile; the surrounding snapshot +must come from the matching application encoder. Query keys and metadata must also satisfy your +JSON transport. + +## Omit failed queries by default + +Retain `defaultShouldDehydrateQuery` so failed queries stay out of the snapshot. The browser can +refetch them with its own client and receive a fresh `EffectRpcQueryError` or +`EffectHttpApiQueryError` and Cause. The recipes above preserve this policy even when selecting a +profile prefix. + +If you deliberately transfer failed queries, define a safe application error representation. +Encode and decode both `state.error` and `state.fetchFailureReason`; `serializeData` and +`deserializeData` transform query data, not those error fields. Allowlist public failure tags and +fields, omit private messages, defects, and transport details, and reconstruct the error behavior +your UI requires. JSON serialization of an Error or Cause does not supply that policy. A safe +error DTO will not automatically become either package error class or pass its type guard. + +For normal retry and error handling, keep failures local to each client and follow +[Handle Failures](/effect-api-query/guides/handle-failures/) and +[Retry Queries](/effect-api-query/guides/retry-queries/). For router ownership and cleanup, see +[TanStack Start](/effect-api-query/guides/tanstack-start/). diff --git a/apps/docs/src/content/docs/guides/tanstack-start.md b/apps/docs/src/content/docs/guides/tanstack-start.md index 5889727..fd20352 100644 --- a/apps/docs/src/content/docs/guides/tanstack-start.md +++ b/apps/docs/src/content/docs/guides/tanstack-start.md @@ -100,6 +100,8 @@ runtime alive for the browser application's lifetime. Successful query data must satisfy your serializer's contract. If an endpoint returns decoded Schema class instances, decide whether the browser needs plain data or reconstructed instances. The package does not serialize query data for you. +Use [Hydrate Unary Data](/effect-api-query/guides/hydrate-unary-data/) to keep a DTO representation +or pair Schema encoding and decoding across hydration and later browser refetch. ## Let the browser refetch failed queries @@ -137,8 +139,9 @@ See [Executable Examples](/effect-api-query/examples/) for commands and controls The example serves RPC at `/rpc` and HTTP at `/api/$`. Both handlers share a demonstration user directory, so writes invalidate both sets of query keys. The authorization header contains a public demonstration value. The ownership tests use separate identities to verify that caches -and resource disposal stay isolated. Its SSR setup converts decoded Schema class values to plain -data with `structuredClone`. +and resource disposal stay isolated. Its SSR setup clones class properties for field-only views. +`structuredClone` does not reconstruct class methods or define a JSON encoding for rich values; +use a paired codec when the browser needs the decoded domain representation. The example also disables Vite preview compression for its API routes. The pinned middleware delays response-close listeners until the first write, preventing a pending buffered request from diff --git a/examples/tanstack-start/src/lib/query-ssr.ts b/examples/tanstack-start/src/lib/query-ssr.ts index fc3b5de..76a80ef 100644 --- a/examples/tanstack-start/src/lib/query-ssr.ts +++ b/examples/tanstack-start/src/lib/query-ssr.ts @@ -36,11 +36,9 @@ export const fetchStreamSnapshot = async { setupRouterSsrQueryIntegration({ dehydrateOptions: { - // Query cache data is opaque; structuredClone preserves the serialization boundary. // oxlint-disable-next-line anti-slop/no-unknown-parameters serializeData: (data: unknown) => structuredClone(data), shouldDehydrateQuery: defaultShouldDehydrateQuery, diff --git a/scripts/verify-docs-examples.mts b/scripts/verify-docs-examples.mts index 590b42a..85becd1 100644 --- a/scripts/verify-docs-examples.mts +++ b/scripts/verify-docs-examples.mts @@ -3,21 +3,31 @@ import { strictEqual } from 'node:assert' import { readFileSync } from 'node:fs' -for (const [page, fixture] of [ - ['getting-started/quick-start', 'types/docs-rpc-quick-start'], - ['getting-started/http-quick-start', 'types/docs-http-quick-start'], - ['guides/retry-queries', 'packed-consumer/docs-retry'], -]) { +for (const [page, fixtures] of [ + ['getting-started/quick-start', ['types/docs-rpc-quick-start']], + ['getting-started/http-quick-start', ['types/docs-http-quick-start']], + ['guides/retry-queries', ['packed-consumer/docs-retry']], + [ + 'guides/hydrate-unary-data', + [ + 'packed-consumer/docs-hydration-dto', + 'packed-consumer/docs-hydration-rich', + 'packed-consumer/docs-hydration-async', + ], + ], +] as const) { const markdown = readFileSync( new URL(`../apps/docs/src/content/docs/${page}.md`, import.meta.url), 'utf-8', ) - const source = readFileSync(new URL(`../tests/${fixture}.ts`, import.meta.url), 'utf-8') const samples = [...markdown.matchAll(/^```ts\n(?[\s\S]*?)^```/gmu)] - strictEqual(samples.length, 1, `${page} must contain its complete TypeScript fixture`) - strictEqual( - samples[0]?.groups?.['source']?.trimEnd(), - source.trimEnd(), - `${page} differs from ${fixture}.ts`, - ) + strictEqual(samples.length, fixtures.length, `${page} must contain its TypeScript fixtures`) + for (const [index, fixture] of fixtures.entries()) { + const source = readFileSync(new URL(`../tests/${fixture}.ts`, import.meta.url), 'utf-8') + strictEqual( + samples[index]?.groups?.['source']?.trimEnd(), + source.trimEnd(), + `${page} differs from ${fixture}.ts`, + ) + } } diff --git a/scripts/verify-packed-consumer.mts b/scripts/verify-packed-consumer.mts index 0c106ea..4e41cff 100644 --- a/scripts/verify-packed-consumer.mts +++ b/scripts/verify-packed-consumer.mts @@ -509,6 +509,7 @@ const verifyConsumer = (peer: (typeof peerCases)[number]): void => { 'stream-cause-runtime.mts', 'stream-policy-runtime.mts', 'retry-runtime.mts', + 'hydration-runtime.mts', ]) { execFileSync(process.execPath, ['--experimental-import-meta-resolve', fixture], { cwd: consumerDirectory, diff --git a/tests/packed-consumer/docs-hydration-async.ts b/tests/packed-consumer/docs-hydration-async.ts new file mode 100644 index 0000000..4d10f9d --- /dev/null +++ b/tests/packed-consumer/docs-hydration-async.ts @@ -0,0 +1,52 @@ +import { defaultShouldDehydrateQuery, dehydrate, hydrate } from '@tanstack/query-core' +import type { DehydratedState, QueryClient } from '@tanstack/query-core' +import { Context, Effect, Schema } from 'effect' + +import { Profile } from './docs-hydration-rich.ts' + +export class HydrationPreparation extends Context.Service< + HydrationPreparation, + { + readonly beforeEncode: Effect.Effect + readonly beforeDecode: Effect.Effect + } +>()('HydrationPreparation') {} + +const PreparedProfile = Profile.pipe( + Schema.middlewareEncoding((encoding) => + Effect.flatMap(HydrationPreparation, ({ beforeEncode }) => + Effect.andThen(beforeEncode, encoding), + ), + ), + Schema.middlewareDecoding((decoding) => + Effect.flatMap(HydrationPreparation, ({ beforeDecode }) => + Effect.andThen(beforeDecode, decoding), + ), + ), +) + +export const prepareProfileSnapshot = (queryClient: QueryClient) => + Effect.gen(function* () { + const snapshot = dehydrate(queryClient, { + shouldDehydrateMutation: () => false, + shouldDehydrateQuery: (query) => + defaultShouldDehydrateQuery(query) && query.queryKey[0] === 'profile', + }) + const queries = yield* Effect.forEach(snapshot.queries, (query) => + Schema.encodeUnknownEffect(PreparedProfile)(query.state.data).pipe( + Effect.map((data) => ({ ...query, state: { ...query.state, data } })), + ), + ) + return JSON.stringify({ ...snapshot, queries }) + }) + +export const prepareProfileHydration = (queryClient: QueryClient, json: string) => + Effect.gen(function* () { + const snapshot: DehydratedState = JSON.parse(json) + const queries = yield* Effect.forEach(snapshot.queries, (query) => + Schema.decodeUnknownEffect(PreparedProfile)(query.state.data).pipe( + Effect.map((data) => ({ ...query, state: { ...query.state, data } })), + ), + ) + yield* Effect.sync(() => hydrate(queryClient, { ...snapshot, queries })) + }) diff --git a/tests/packed-consumer/docs-hydration-dto.ts b/tests/packed-consumer/docs-hydration-dto.ts new file mode 100644 index 0000000..eb43b48 --- /dev/null +++ b/tests/packed-consumer/docs-hydration-dto.ts @@ -0,0 +1,47 @@ +import { defaultShouldDehydrateQuery, dehydrate, hydrate } from '@tanstack/query-core' +import type { QueryClient } from '@tanstack/query-core' +import { Schema } from 'effect' +import { createHttpApiQueryUtils, createRpcQueryUtils } from 'effect-api-query' +import { HttpApi, HttpApiClient, HttpApiEndpoint, HttpApiGroup } from 'effect/http-api' +import { Rpc, RpcClient, RpcGroup } from 'effect/rpc' + +export const ProfileDto = Schema.Struct({ + name: Schema.String, + credits: Schema.String, + avatar: Schema.String.check(Schema.isBase64()), +}) + +export const dtoGroup = RpcGroup.make(Rpc.make('profile.read', { success: ProfileDto })) +export const dtoApi = HttpApi.make('dto').add( + HttpApiGroup.make('profile').add( + HttpApiEndpoint.get('read', '/profile', { success: ProfileDto }), + ), +) + +export function dtoRpcOptions( + client: RpcClient.RpcClient.Flat, E>, +) { + return createRpcQueryUtils(dtoGroup, { + client, + keyPrefix: ['dto', 'rpc'], + }).profile.read.queryOptions({ retry: false, staleTime: 60_000 }) +} + +export function dtoHttpOptions(client: HttpApiClient.ForApi) { + return createHttpApiQueryUtils(dtoApi, { + client, + keyPrefix: ['dto', 'http'], + }).profile.read.queryOptions({ retry: false, staleTime: 60_000 }) +} + +export const snapshotDto = (queryClient: QueryClient): string => + JSON.stringify( + dehydrate(queryClient, { + shouldDehydrateMutation: () => false, + shouldDehydrateQuery: (query) => + defaultShouldDehydrateQuery(query) && query.queryKey[0] === 'dto', + }), + ) + +export const hydrateDto = (queryClient: QueryClient, json: string): void => + hydrate(queryClient, JSON.parse(json)) diff --git a/tests/packed-consumer/docs-hydration-rich.ts b/tests/packed-consumer/docs-hydration-rich.ts new file mode 100644 index 0000000..bf9fa33 --- /dev/null +++ b/tests/packed-consumer/docs-hydration-rich.ts @@ -0,0 +1,56 @@ +import { defaultShouldDehydrateQuery, dehydrate, hydrate } from '@tanstack/query-core' +import type { QueryClient } from '@tanstack/query-core' +import { Schema } from 'effect' +import { createHttpApiQueryUtils, createRpcQueryUtils } from 'effect-api-query' +import { HttpApi, HttpApiClient, HttpApiEndpoint, HttpApiGroup } from 'effect/http-api' +import { Rpc, RpcClient, RpcGroup } from 'effect/rpc' + +export class Profile extends Schema.Class('Profile')({ + name: Schema.String, + credits: Schema.BigIntFromString, + avatar: Schema.Uint8ArrayFromBase64, +}) { + summary(): string { + return `${this.name}: ${this.credits} credits` + } +} + +export const profileGroup = RpcGroup.make( + Rpc.make('profile.read', { success: Profile, error: Schema.String }), +) +export const profileApi = HttpApi.make('rich').add( + HttpApiGroup.make('profile').add( + HttpApiEndpoint.get('read', '/profile', { success: Profile, error: Schema.String }), + ), +) + +export function profileRpcOptions( + client: RpcClient.RpcClient.Flat, E>, +) { + return createRpcQueryUtils(profileGroup, { + client, + keyPrefix: ['profile', 'rpc'], + }).profile.read.queryOptions({ retry: false, staleTime: 60_000 }) +} + +export function profileHttpOptions(client: HttpApiClient.ForApi) { + return createHttpApiQueryUtils(profileApi, { + client, + keyPrefix: ['profile', 'http'], + }).profile.read.queryOptions({ retry: false, staleTime: 60_000 }) +} + +export const snapshotProfile = (queryClient: QueryClient): string => + JSON.stringify( + dehydrate(queryClient, { + shouldDehydrateMutation: () => false, + shouldDehydrateQuery: (query) => + defaultShouldDehydrateQuery(query) && query.queryKey[0] === 'profile', + serializeData: Schema.encodeUnknownSync(Profile), + }), + ) + +export const hydrateProfile = (queryClient: QueryClient, json: string): void => + hydrate(queryClient, JSON.parse(json), { + defaultOptions: { deserializeData: Schema.decodeUnknownSync(Profile) }, + }) diff --git a/tests/packed-consumer/hydration-contract.ts b/tests/packed-consumer/hydration-contract.ts new file mode 100644 index 0000000..f13af8d --- /dev/null +++ b/tests/packed-consumer/hydration-contract.ts @@ -0,0 +1,68 @@ +import type { QueryClient } from '@tanstack/query-core' +import { Effect, Schema } from 'effect' +import type { HttpApiClient } from 'effect/http-api' +import type { RpcClient, RpcGroup } from 'effect/rpc' + +import { + HydrationPreparation, + prepareProfileHydration, + prepareProfileSnapshot, +} from './docs-hydration-async.ts' +import { + dtoApi, + dtoGroup, + dtoHttpOptions, + dtoRpcOptions, + ProfileDto, +} from './docs-hydration-dto.ts' +import { + Profile, + profileApi, + profileGroup, + profileHttpOptions, + profileRpcOptions, +} from './docs-hydration-rich.ts' + +type Equal = + (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false +type Assert = T +type Success = T extends Promise ? A : never +type Services = T extends Effect.Effect ? R : never + +declare const queryClient: QueryClient +declare const rpcClient: RpcClient.RpcClient.Flat> +declare const httpClient: HttpApiClient.ForApi +declare const dtoRpcClient: RpcClient.RpcClient.Flat> +declare const dtoHttpClient: HttpApiClient.ForApi + +const rpcData = queryClient.query(profileRpcOptions(rpcClient)) +const httpData = queryClient.query(profileHttpOptions(httpClient)) +const rpcDto = queryClient.query(dtoRpcOptions(dtoRpcClient)) +const httpDto = queryClient.query(dtoHttpOptions(dtoHttpClient)) + +type Contract = [ + Assert, Profile>>, + Assert, Profile>>, + Assert, typeof ProfileDto.Type>>, + Assert, typeof ProfileDto.Type>>, + Assert>, HydrationPreparation>>, + Assert>, HydrationPreparation>>, +] + +prepareProfileSnapshot(queryClient) satisfies Effect.Effect< + string, + Schema.SchemaError, + HydrationPreparation +> +prepareProfileHydration(queryClient, '{}') satisfies Effect.Effect< + void, + Schema.SchemaError, + HydrationPreparation +> +// @ts-expect-error The caller must provide the hydration preparation service +Effect.runPromise(prepareProfileSnapshot(queryClient)) +// @ts-expect-error The caller must provide the hydration preparation service +Effect.runPromise(prepareProfileHydration(queryClient, '{}')) + +declare const contract: Contract +contract satisfies [true, true, true, true, true, true] diff --git a/tests/packed-consumer/hydration-runtime.mts b/tests/packed-consumer/hydration-runtime.mts new file mode 100644 index 0000000..f7819ae --- /dev/null +++ b/tests/packed-consumer/hydration-runtime.mts @@ -0,0 +1,332 @@ +import { dehydrate, QueryClient } from '@tanstack/query-core' +import { Deferred, Effect, Exit, Scope } from 'effect' +import { HttpServer } from 'effect/http' +import { HttpApiBuilder, HttpApiTest } from 'effect/http-api' +import { RpcTest } from 'effect/rpc' +import { deepStrictEqual, equal, ok, rejects, throws } from 'node:assert/strict' + +import { + HydrationPreparation, + prepareProfileHydration, + prepareProfileSnapshot, +} from './docs-hydration-async.ts' +import { + dtoApi, + dtoGroup, + dtoHttpOptions, + dtoRpcOptions, + hydrateDto, + snapshotDto, +} from './docs-hydration-dto.ts' +import { + hydrateProfile, + Profile, + profileApi, + profileGroup, + profileHttpOptions, + profileRpcOptions, + snapshotProfile, +} from './docs-hydration-rich.ts' + +const serverDto = { name: 'Ada', credits: '9007199254740993', avatar: 'AAH/' } +const browserDto = { name: 'Grace', credits: '9007199254740995', avatar: '/wEC' } + +await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + for (const transport of ['rpc', 'http'] as const) { + let serverReads = 0 + let browserReads = 0 + const serverScope = yield* Scope.make() + const serverClient = new QueryClient() + const browserClient = new QueryClient() + try { + const serverRpc = yield* RpcTest.makeClient(dtoGroup, { flatten: true }).pipe( + Effect.provide( + dtoGroup.toLayer({ + 'profile.read': () => + Effect.sync(() => { + serverReads += 1 + return serverDto + }), + }), + ), + Effect.provideService(Scope.Scope, serverScope), + ) + const browserRpc = yield* RpcTest.makeClient(dtoGroup, { flatten: true }).pipe( + Effect.provide( + dtoGroup.toLayer({ + 'profile.read': () => + Effect.sync(() => { + browserReads += 1 + return browserDto + }), + }), + ), + ) + const serverHttp = yield* HttpApiTest.groups(dtoApi, ['profile']).pipe( + Effect.provide( + HttpApiBuilder.group(dtoApi, 'profile', (handlers) => + handlers.handle('read', () => + Effect.sync(() => { + serverReads += 1 + return serverDto + }), + ), + ), + ), + Effect.provideService(Scope.Scope, serverScope), + ) + const browserHttp = yield* HttpApiTest.groups(dtoApi, ['profile']).pipe( + Effect.provide( + HttpApiBuilder.group(dtoApi, 'profile', (handlers) => + handlers.handle('read', () => + Effect.sync(() => { + browserReads += 1 + return browserDto + }), + ), + ), + ), + ) + const serverQuery = + transport === 'rpc' + ? () => serverClient.query(dtoRpcOptions(serverRpc)) + : () => serverClient.query(dtoHttpOptions(serverHttp)) + const browserQuery = + transport === 'rpc' + ? () => browserClient.query(dtoRpcOptions(browserRpc)) + : () => browserClient.query(dtoHttpOptions(browserHttp)) + const browserKey = + transport === 'rpc' + ? dtoRpcOptions(browserRpc).queryKey + : dtoHttpOptions(browserHttp).queryKey + deepStrictEqual(yield* Effect.promise(serverQuery), serverDto) + const json = snapshotDto(serverClient) + equal(typeof json, 'string') + yield* Effect.promise(() => serverClient.cancelQueries()) + serverClient.clear() + yield* Scope.close(serverScope, Exit.void) + hydrateDto(browserClient, json) + deepStrictEqual(yield* Effect.promise(browserQuery), serverDto) + equal(browserReads, 0) + yield* Effect.promise(() => browserClient.invalidateQueries({ queryKey: browserKey })) + deepStrictEqual(yield* Effect.promise(browserQuery), browserDto) + equal(serverReads, 1) + equal(browserReads, 1) + } finally { + yield* Effect.promise(() => browserClient.cancelQueries()) + browserClient.clear() + serverClient.clear() + yield* Scope.close(serverScope, Exit.void) + } + } + }), + ).pipe(Effect.provide(HttpServer.layerServices)), +) + +const serverProfile = new Profile({ + name: 'Ada', + credits: 9007199254740993n, + avatar: new Uint8Array([0, 1, 255]), +}) +const browserProfile = new Profile({ + name: 'Grace', + credits: 9007199254740995n, + avatar: new Uint8Array([255, 1, 2]), +}) +const cloned = structuredClone(serverProfile) +equal(cloned instanceof Profile, false) +equal('summary' in cloned, false) +equal(cloned.credits, 9007199254740993n) +ok(cloned.avatar instanceof Uint8Array) +throws(() => JSON.stringify(cloned), TypeError) + +await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + for (const transport of ['rpc', 'http'] as const) { + let serverReads = 0 + let browserReads = 0 + let failed = false + const serverScope = yield* Scope.make() + const serverClient = new QueryClient() + const browserClient = new QueryClient() + const makeOptions = (profile: Profile, onRead: () => void, scope: Scope.Scope) => + Effect.gen(function* () { + const read = () => + Effect.suspend(() => { + onRead() + return failed ? Effect.fail('private upstream detail') : Effect.succeed(profile) + }) + if (transport === 'rpc') { + const client = yield* RpcTest.makeClient(profileGroup, { flatten: true }).pipe( + Effect.provide(profileGroup.toLayer({ 'profile.read': read })), + Effect.provideService(Scope.Scope, scope), + ) + const options = profileRpcOptions(client) + return { + query: (cache: QueryClient) => cache.query(options), + queryKey: options.queryKey, + } + } + const client = yield* HttpApiTest.groups(profileApi, ['profile']).pipe( + Effect.provide( + HttpApiBuilder.group(profileApi, 'profile', (handlers) => + handlers.handle('read', read), + ), + ), + Effect.provideService(Scope.Scope, scope), + ) + const options = profileHttpOptions(client) + return { + query: (cache: QueryClient) => cache.query(options), + queryKey: options.queryKey, + } + }) + try { + const browserScope = yield* Scope.Scope + const serverOptions = yield* makeOptions( + serverProfile, + () => { + serverReads += 1 + }, + serverScope, + ) + const browserOptions = yield* makeOptions( + browserProfile, + () => { + browserReads += 1 + }, + browserScope, + ) + const initial = yield* Effect.promise(() => serverOptions.query(serverClient)) + ok(initial instanceof Profile) + equal(initial.summary(), 'Ada: 9007199254740993 credits') + const json = snapshotProfile(serverClient) + const wire = JSON.parse(json) + equal(wire.queries[0].state.data.credits, '9007199254740993') + equal(wire.queries[0].state.data.avatar, 'AAH/') + serverClient.clear() + failed = true + yield* Effect.promise(() => rejects(serverOptions.query(serverClient))) + equal(dehydrate(serverClient).queries.length, 0) + const failedJson = snapshotProfile(serverClient) + equal(JSON.parse(failedJson).queries.length, 0) + equal(failedJson.includes('private upstream detail'), false) + failed = false + yield* Effect.promise(() => serverClient.cancelQueries()) + serverClient.clear() + yield* Scope.close(serverScope, Exit.void) + hydrateProfile(browserClient, json) + const restored = yield* Effect.promise(() => browserOptions.query(browserClient)) + ok(restored instanceof Profile) + equal(restored.summary(), 'Ada: 9007199254740993 credits') + equal(restored.credits, 9007199254740993n) + deepStrictEqual(restored.avatar, new Uint8Array([0, 1, 255])) + equal(browserReads, 0) + yield* Effect.promise(() => + browserClient.invalidateQueries({ queryKey: browserOptions.queryKey }), + ) + const refetched = yield* Effect.promise(() => browserOptions.query(browserClient)) + ok(refetched instanceof Profile) + equal(refetched.summary(), 'Grace: 9007199254740995 credits') + equal(refetched.credits, 9007199254740995n) + deepStrictEqual(refetched.avatar, new Uint8Array([255, 1, 2])) + equal(serverReads, 2) + equal(browserReads, 1) + browserClient.clear() + hydrateProfile(browserClient, failedJson) + const afterOmittedFailure = yield* Effect.promise(() => + browserOptions.query(browserClient), + ) + ok(afterOmittedFailure instanceof Profile) + equal(afterOmittedFailure.summary(), 'Grace: 9007199254740995 credits') + equal(browserReads, 2) + } finally { + yield* Effect.promise(() => browserClient.cancelQueries()) + browserClient.clear() + serverClient.clear() + yield* Scope.close(serverScope, Exit.void) + } + } + }), + ).pipe(Effect.provide(HttpServer.layerServices)), +) + +await Effect.runPromise( + Effect.gen(function* () { + const encodingStarted = yield* Deferred.make() + const encodingAllowed = yield* Deferred.make() + const decodingStarted = yield* Deferred.make() + const decodingAllowed = yield* Deferred.make() + const preparation = { + beforeEncode: Deferred.succeed(encodingStarted, undefined).pipe( + Effect.andThen(Deferred.await(encodingAllowed)), + ), + beforeDecode: Deferred.succeed(decodingStarted, undefined).pipe( + Effect.andThen(Deferred.await(decodingAllowed)), + ), + } + const serverClient = new QueryClient() + const browserClient = new QueryClient() + const key = ['profile', 'prepared'] as const + serverClient.setQueryData(key, serverProfile) + let published: string | undefined + const encoding = Effect.runPromise( + prepareProfileSnapshot(serverClient).pipe( + Effect.provideService(HydrationPreparation, preparation), + ), + ).then((json) => { + published = json + return json + }) + yield* Deferred.await(encodingStarted) + equal(published, undefined) + equal(serverClient.getQueryData(key), serverProfile) + yield* Deferred.succeed(encodingAllowed, undefined) + const json = yield* Effect.promise(() => encoding) + equal(JSON.parse(json).queries[0].state.data.credits, '9007199254740993') + const decoding = Effect.runPromise( + prepareProfileHydration(browserClient, json).pipe( + Effect.provideService(HydrationPreparation, preparation), + ), + ) + yield* Deferred.await(decodingStarted) + equal(browserClient.getQueryData(key), undefined) + yield* Deferred.succeed(decodingAllowed, undefined) + yield* Effect.promise(() => decoding) + const hydrated = browserClient.getQueryData(key) + ok(hydrated instanceof Profile) + equal(hydrated.summary(), 'Ada: 9007199254740993 credits') + equal(hydrated.credits, 9007199254740993n) + deepStrictEqual(hydrated.avatar, new Uint8Array([0, 1, 255])) + const invalid = JSON.parse(json) + invalid.queries.push({ + ...invalid.queries[0], + queryHash: 'invalid', + queryKey: ['profile', 'invalid'], + state: { + ...invalid.queries[0].state, + data: { name: 'invalid', credits: 'oops', avatar: 'AAH/' }, + }, + }) + const untouched = new QueryClient() + yield* Effect.promise(() => + rejects( + Effect.runPromise( + prepareProfileHydration(untouched, JSON.stringify(invalid)).pipe( + Effect.provideService(HydrationPreparation, { + beforeEncode: Effect.void, + beforeDecode: Effect.void, + }), + ), + ), + ), + ) + equal(untouched.getQueryCache().getAll().length, 0) + untouched.clear() + serverClient.clear() + browserClient.clear() + }), +) diff --git a/tests/packed-consumer/tsconfig.json b/tests/packed-consumer/tsconfig.json index 6f4e53c..860dd7c 100644 --- a/tests/packed-consumer/tsconfig.json +++ b/tests/packed-consumer/tsconfig.json @@ -18,12 +18,15 @@ "stream-cause-runtime.mts", "stream-policy-runtime.mts", "retry-runtime.mts", + "hydration-runtime.mts", "http-runtime.mts", "public-contract.ts", "http-contract.ts", "docs-rpc-quick-start.ts", "docs-http-quick-start.ts", "docs-retry.ts", + "docs-hydration-*.ts", + "hydration-contract.ts", "skill-example-*.ts" ] } From 771e174306818d35de456000536f45e07cf4e264 Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Fri, 9 Oct 2026 23:07:24 +0200 Subject: [PATCH 27/97] feat: add HTTP live SSE query views --- .fallowrc.jsonc | 1 + .../docs/concepts/http-utility-tree.md | 10 +- .../docs/guides/http-queries-and-mutations.md | 42 +- .../docs/src/content/docs/reference/errors.md | 9 +- .../content/docs/reference/http-factory.md | 22 +- scripts/verify-packed-consumer.mts | 2 + skills/effect-api-query/references/http.md | 13 +- src/core/operation.ts | 2 +- src/core/utility-tree.ts | 17 +- src/http/errors.ts | 22 +- src/http/operation.ts | 8 +- src/http/streamed-query.ts | 21 +- src/http/types.ts | 40 +- src/index.ts | 1 + tests/http-live-query.test.ts | 50 ++ tests/packed-consumer/http-live-runtime.mts | 601 ++++++++++++++++++ tests/packed-consumer/runtime.mts | 1 + tests/packed-consumer/tsconfig.json | 1 + tests/types/http-stream-contract.ts | 152 ++++- 19 files changed, 983 insertions(+), 32 deletions(-) create mode 100644 tests/http-live-query.test.ts create mode 100644 tests/packed-consumer/http-live-runtime.mts diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index f7c62ea..36bc24f 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -8,6 +8,7 @@ "playwright.docs.config.ts", "tests/packed-consumer/hashing-runtime.mts", "tests/packed-consumer/http-stream-runtime.mts", + "tests/packed-consumer/http-live-runtime.mts", "tests/packed-consumer/stream-cause-runtime.mts", "tests/packed-consumer/stream-policy-runtime.mts", "tests/packed-consumer/retry-runtime.mts" diff --git a/apps/docs/src/content/docs/concepts/http-utility-tree.md b/apps/docs/src/content/docs/concepts/http-utility-tree.md index 337d847..0b1b953 100644 --- a/apps/docs/src/content/docs/concepts/http-utility-tree.md +++ b/apps/docs/src/content/docs/concepts/http-utility-tree.md @@ -6,7 +6,7 @@ description: Understand literal HTTP identifiers, top-level groups, and omitted `createHttpApiQueryUtils` builds and freezes an HTTP utility tree when you call it with a literal Effect HttpApi declaration. Buffered endpoints without multipart have ordinary query, infinite-query, and mutation builders. Buffered multipart endpoints have mutation builders only, regardless of HTTP method. -Endpoints with one SSE success expose accumulated streamed-query builders. +Endpoints with one SSE success expose accumulated and live streamed-query builders. | Declaration | Generated path | | ------------------------------------------ | ----------------------------------- | @@ -23,9 +23,11 @@ custom key encoders still use their declaration group identifier. The factory re names, collisions, and invalid encoder configuration before returning any utilities. The factory retains a single SSE success, including a decoded response-header wrapper. Its leaf -exposes `key()`, `streamedKey()`, and `streamedOptions()`. Cache entries hold ordered decoded events; -header wrappers surround each event. Decoder, retention, and refetch policies contribute to concrete -identity while endpoint prefixes continue to select every view policy. +exposes `key()`, `streamedKey()`, `streamedOptions()`, `liveKey()`, and `liveOptions()`. Accumulated cache entries hold +ordered decoded events; live entries hold the latest value. +Decoded header wrappers surround each event. Decoder policy contributes to both views' concrete +identity; accumulated identity also includes retention and refetch policies. Endpoint prefixes +select every view policy. Raw byte streams, mixed buffered/SSE successes, SSE endpoints with multipart payloads, and streaming multipart requests omit the complete endpoint. Groups containing only omitted endpoints disappear. diff --git a/apps/docs/src/content/docs/guides/http-queries-and-mutations.md b/apps/docs/src/content/docs/guides/http-queries-and-mutations.md index 78a6b9d..4d948f5 100644 --- a/apps/docs/src/content/docs/guides/http-queries-and-mutations.md +++ b/apps/docs/src/content/docs/guides/http-queries-and-mutations.md @@ -179,8 +179,7 @@ streaming success or streaming multipart alternative omits the whole endpoint. ## Retain SSE events -An endpoint with one `HttpApiSchema.StreamSse` success exposes `streamedKey` and -`streamedOptions`. For a `watch` endpoint whose query declares `channel: Schema.String`, +An endpoint with one `HttpApiSchema.StreamSse` success exposes accumulated and live query builders. For a `watch` endpoint whose query declares `channel: Schema.String`, retain its latest 100 decoded events: ```ts @@ -221,6 +220,43 @@ complete Causes, including `Sse.Retry`. Your application owns reconnection and r Raw byte streams, mixed buffered/SSE successes, and SSE endpoints with multipart payloads are omitted. Consume unsupported streams directly through the ready client. +## Keep the latest SSE value + +Use `liveOptions` when a stream emits snapshots and only the latest value belongs in the cache: + +```ts +const latest = http.events.watch.liveOptions({ + input: { query: { channel: 'news' } }, + sseOptions: { maxEventSize: 1024 * 1024 }, +}) + +const observer = new QueryObserver(queryClient, latest) +const unsubscribe = observer.subscribe((result) => { + if (result.status === 'success') renderLatest(result.data) +}) +``` + +Replace `renderLatest` with your application's rendering callback, or pass `latest` to your +framework's query hook. The first emission makes the query successful while it remains fetching. +Each later emission replaces the cached value; completion preserves the last value. A top-level +`undefined` emission becomes `null`, including after a defined value. Decoded header wrappers keep +their body unchanged, so a wrapped `undefined` body remains `undefined`. + +Completion before any emission raises `EffectHttpApiQueryEmptyStreamError`. Transport, decoding, +declared event, and independent interruption failures retain the complete Cause in +`EffectHttpApiQueryError` with operation `live`. Query cancellation closes consumption and follows +native cache reversion; unsubscribe and cancel active queries before disposing client resources. + +`liveKey(input, { sseOptions })` builds the matching exact key. Inputless endpoints accept decoder +policy alone. Live and accumulated views use separate cache entries; the endpoint's `key()` +selects both views and every decoder policy. Live queries retain one value and accept no history +bound or accumulated refetch mode. Input-bearing builders support `skipToken`. + +The ready client owns the SSE connection. Handle `Sse.Retry` and its resume cursor through your +application's retry policy, and supply a declared cursor in `input.headers` when reconnecting. +New cursor values produce new request identity. Raw bytes, mixed buffered/SSE successes, and +multipart SSE endpoints remain omitted. + ## Keep cache entries separate Use generated keys for individual queries or whole branches: @@ -238,7 +274,7 @@ need encoding services, contain redacted values, or allow multiple payload alter Buffered HTTP endpoints without multipart expose ordinary query, infinite query, and mutation builders regardless of HTTP method. Choose the builder for the operation you intend. Buffered -multipart endpoints expose mutations only. A single SSE success exposes accumulated streamed +multipart endpoints expose mutations only. A single SSE success exposes accumulated and live streamed queries; raw byte streams, mixed buffered/SSE successes, and streaming multipart requests are omitted; see the [HTTP factory reference](/effect-api-query/reference/http-factory/) for supported request formats and the complete builder contract. diff --git a/apps/docs/src/content/docs/reference/errors.md b/apps/docs/src/content/docs/reference/errors.md index c2d01e8..aaf2f33 100644 --- a/apps/docs/src/content/docs/reference/errors.md +++ b/apps/docs/src/content/docs/reference/errors.md @@ -78,10 +78,17 @@ for each code's trigger. Thrown when the HTTP runner returns a failed `Exit`. It exposes `apiId`, `groupId`, `endpoint`, `method`, `operation`, and the full `Cause.Cause`. The operation is `query`, `infinite`, `mutation`, -or `streamed`. +`streamed`, or `live`. Use `isEffectHttpApiQueryError(value)` to narrow errors within the same JavaScript realm. The Cause preserves endpoint, middleware, Schema, and HTTP client errors, including defects and interruption. SSE failures also preserve declared event errors, `Sse.Retry`, and `Sse.SseError`. It can contain upstream requests, responses, or sensitive input values; inspect those values before logging or exposing them. Runner rejections pass through unchanged. + +## `EffectHttpApiQueryEmptyStreamError` + +Thrown when an HTTP live SSE query completes before emitting a value. It exposes `apiId`, +`groupId`, `endpoint`, `method`, and operation `live`. A decoded `undefined` emission counts as a +value and becomes `null`; a declared header wrapper preserves its body. Accumulated streams +complete with an empty or retained history according to their refetch mode. diff --git a/apps/docs/src/content/docs/reference/http-factory.md b/apps/docs/src/content/docs/reference/http-factory.md index e576cd0..6a7b9f3 100644 --- a/apps/docs/src/content/docs/reference/http-factory.md +++ b/apps/docs/src/content/docs/reference/http-factory.md @@ -17,7 +17,7 @@ An endpoint with any buffered multipart payload alternative exposes only `key`, `mutationOptions`, even when it also accepts plain payload alternatives. This classification applies regardless of HTTP method; applications choose the builder for endpoints with query support. An endpoint with exactly one SSE success and no multipart payload exposes `key`, `streamedKey`, -and `streamedOptions`. +`streamedOptions`, `liveKey`, and `liveOptions`. ## Factory options @@ -64,7 +64,7 @@ service requirements of their decoded-only call signatures; raw-response overloa See [client lifecycle](/effect-api-query/concepts/client-lifecycle/#http-clients-and-execution-services) and [cancellation](/effect-api-query/guides/cancellation/#cancel-an-http-query) for runtime ownership. -A single SSE success, including `WithHeaders(StreamSse(...), ...)`, supports accumulated queries. +A single SSE success, including `WithHeaders(StreamSse(...), ...)`, supports accumulated and live queries. Raw byte streams and mixed buffered/SSE success alternatives omit the complete endpoint. Any streaming multipart request alternative does the same. SSE endpoints with buffered multipart payload alternatives are also omitted. Buffered multipart alternatives retain @@ -120,6 +120,24 @@ from decoded request input and custom encoders. Declared resume headers remain r See [Retain SSE events](/effect-api-query/guides/http-queries-and-mutations/#retain-sse-events). +## Live SSE options + +`liveOptions` accepts native query options, declared decoded `input`, and `sseOptions`. +`liveKey(input, { sseOptions })`, or `liveKey({ sseOptions })` for inputless endpoints, includes +normalized decoder policy in its separate `live` identity. Its keys carry the latest decoded value +and the execution error union, including `EffectHttpApiQueryEmptyStreamError`. Live builders +preserve selection, initial-data, and skipped-input inference. + +The first emission makes the query successful. Later emissions replace the cached value, and +completion preserves the latest value. Only a top-level `undefined` becomes `null`; declared +header wrappers retain their decoded body and headers. An empty live completion fails with +declaration metadata. Live queries accept no `maxChunks` or accumulated `refetchMode`. + +The acquisition and consumption error/service channels, decoder limit validation, iterator cleanup, +and caller-owned reconnection contract match accumulated SSE queries. Input-bearing builders +validate decoder policy even when skipped. Endpoint prefixes match both representations and every +concrete decoder policy. See [Keep the latest SSE value](/effect-api-query/guides/http-queries-and-mutations/#keep-the-latest-sse-value). + ## Pagination `infiniteOptions` requires `initialPageParam` and `getNextPageParam`. For an input-bearing endpoint, diff --git a/scripts/verify-packed-consumer.mts b/scripts/verify-packed-consumer.mts index d09646f..e6379d1 100644 --- a/scripts/verify-packed-consumer.mts +++ b/scripts/verify-packed-consumer.mts @@ -242,6 +242,7 @@ deepStrictEqual(declarationNames, [ 'CreateRpcQueryUtilsOptions', 'EffectHttpApiQueryConfigError', 'EffectHttpApiQueryConfigErrorCode', + 'EffectHttpApiQueryEmptyStreamError', 'EffectHttpApiQueryError', 'EffectHttpApiQueryKeyError', 'EffectHttpApiQueryKeyErrorCode', @@ -508,6 +509,7 @@ const verifyConsumer = (peer: (typeof peerCases)[number]): void => { 'runtime.mts', 'http-runtime.mts', 'http-stream-runtime.mts', + 'http-live-runtime.mts', 'stream-cause-runtime.mts', 'stream-policy-runtime.mts', 'retry-runtime.mts', diff --git a/skills/effect-api-query/references/http.md b/skills/effect-api-query/references/http.md index af38476..e55615c 100644 --- a/skills/effect-api-query/references/http.md +++ b/skills/effect-api-query/references/http.md @@ -103,9 +103,16 @@ mutation-only, including mixed plain and multipart alternatives. Those leaves ex only `key()`, `mutationKey()`, and `mutationOptions()`, regardless of HTTP method. Mixed alternatives preserve Effect's client request union. -The adapter omits an endpoint entirely if it has **any streaming success alternative** -or **any streaming multipart request alternative**. Empty groups disappear. Use the underlying -Effect client directly for these endpoints. +An endpoint with exactly one SSE success exposes `streamedKey` / `streamedOptions` for +ordered history and `liveKey` / `liveOptions` for the latest decoded value. Declared +response-header wrappers surround each emission. Keep `sseOptions` beside decoded +`input`; live keys include decoder policy, while accumulated keys also include retention +and refetch policy. Applications own reconnect and resume controls. Empty live completion +raises `EffectHttpApiQueryEmptyStreamError`; a top-level `undefined` emission becomes `null`. + +Raw byte streams, mixed buffered/SSE successes, multipart SSE payloads, and streaming +multipart request alternatives omit the whole endpoint. Empty groups disappear. Consume +unsupported streams through the underlying Effect client. ## Multipart mutations diff --git a/src/core/operation.ts b/src/core/operation.ts index 5649097..59bf1c1 100644 --- a/src/core/operation.ts +++ b/src/core/operation.ts @@ -45,7 +45,7 @@ export interface MutationOperation extends BufferedOperation { export interface StreamingOperation extends OperationIdentity { readonly kind: 'Streaming' readonly input: OperationInput - readonly supportsLive?: boolean + readonly liveIdentity?: (options: Record) => readonly JsonValue[] readonly streamedIdentity: (options: Record) => readonly JsonValue[] /** Consumes and validates stream policy, including for skipped requests. */ readonly prepareStream: ( diff --git a/src/core/utility-tree.ts b/src/core/utility-tree.ts index c4ca7ab..417be7b 100644 --- a/src/core/utility-tree.ts +++ b/src/core/utility-tree.ts @@ -413,12 +413,14 @@ const createStreamingLeaf = ( const operationKey = freezeKey(keyParts) const liveOperationKey = freezeKey([...operationKey, 'live']) const streamedOperationKey = freezeKey([...operationKey, 'streamed']) - const live = - description.supportsLive === false - ? undefined - : createQueryBuilders(description, liveOperationKey, keyEncoder, (options, requestOptions) => - description.prepareStream(options, 'live', runPromiseExit, requestOptions), - ) + const live = createQueryBuilders( + description, + liveOperationKey, + keyEncoder, + (options, requestOptions) => + description.prepareStream(options, 'live', runPromiseExit, requestOptions), + description.liveIdentity, + ) const streamed = createQueryBuilders( description, streamedOperationKey, @@ -430,7 +432,8 @@ const createStreamingLeaf = ( return Object.freeze({ key: () => operationKey, - ...(live === undefined ? undefined : { liveKey: live.key, liveOptions: live.options }), + liveKey: live.key, + liveOptions: live.options, streamedKey: streamed.key, streamedOptions: streamed.options, }) diff --git a/src/http/errors.ts b/src/http/errors.ts index 3739644..6060cc9 100644 --- a/src/http/errors.ts +++ b/src/http/errors.ts @@ -8,7 +8,7 @@ export interface HttpApiEndpointIdentity { readonly method: string } -export type HttpApiOperation = 'query' | 'infinite' | 'mutation' | 'streamed' +export type HttpApiOperation = 'query' | 'infinite' | 'mutation' | 'streamed' | 'live' /** Stable codes for invalid HTTP utility configuration. */ export type EffectHttpApiQueryConfigErrorCode = @@ -57,6 +57,26 @@ export class EffectHttpApiQueryError extends Error implements HttpApiEndpoint } } +export class EffectHttpApiQueryEmptyStreamError extends Error implements HttpApiEndpointIdentity { + readonly _tag = 'EffectHttpApiQueryEmptyStreamError' + readonly apiId: string + readonly groupId: string + readonly endpoint: string + readonly method: string + readonly operation = 'live' + + constructor(identity: HttpApiEndpointIdentity) { + super( + `HTTP ${identity.method} ${identity.groupId}/${identity.endpoint} live stream completed without a value`, + ) + this.name = 'EffectHttpApiQueryEmptyStreamError' + this.apiId = identity.apiId + this.groupId = identity.groupId + this.endpoint = identity.endpoint + this.method = identity.method + } +} + /** Reports invalid HTTP projection, key configuration, or endpoint metadata. */ export class EffectHttpApiQueryConfigError extends Error { readonly _tag = 'EffectHttpApiQueryConfigError' diff --git a/src/http/operation.ts b/src/http/operation.ts index b79d767..0889e03 100644 --- a/src/http/operation.ts +++ b/src/http/operation.ts @@ -13,7 +13,11 @@ import type { import { EffectHttpApiQueryConfigError, EffectHttpApiQueryError } from './errors' import type { HttpApiEndpointIdentity } from './errors' import { createHttpRequest } from './request' -import { createHttpStreamIdentity, createHttpStreamPreparation } from './streamed-query' +import { + createHttpLiveIdentity, + createHttpStreamIdentity, + createHttpStreamPreparation, +} from './streamed-query' type HttpOperation = (UnaryOperation | MutationOperation | StreamingOperation) & { readonly identity: HttpApiEndpointIdentity @@ -77,7 +81,7 @@ const extractHttpEndpoints = (api: HttpApi.Top, client: unknown): readonly HttpO ...common, ...request, kind: 'Streaming', - supportsLive: false, + liveIdentity: createHttpLiveIdentity(identity), streamedIdentity: createHttpStreamIdentity(identity), prepareStream: createHttpStreamPreparation(identity, invoke), }) diff --git a/src/http/streamed-query.ts b/src/http/streamed-query.ts index 5f16d6f..e9677ac 100644 --- a/src/http/streamed-query.ts +++ b/src/http/streamed-query.ts @@ -6,7 +6,11 @@ import type { StreamingOperation } from '../core/operation' import { makeStreamQuery } from '../core/streamed-query' import type { StreamQueryPolicy } from '../core/streamed-query' import type { JsonValue } from '../core/types' -import { EffectHttpApiQueryConfigError, EffectHttpApiQueryError } from './errors' +import { + EffectHttpApiQueryConfigError, + EffectHttpApiQueryEmptyStreamError, + EffectHttpApiQueryError, +} from './errors' import type { HttpApiEndpointIdentity } from './errors' const decoderOptions = ( @@ -64,6 +68,13 @@ export const createHttpStreamIdentity = return [policy] } +export const createHttpLiveIdentity = + (identity: HttpApiEndpointIdentity): NonNullable => + (options) => { + const { maxEventSize } = decoderOptions(identity, options) + return [Object.freeze({ maxEventSize })] + } + export const createHttpStreamPreparation = ( identity: HttpApiEndpointIdentity, @@ -72,8 +83,9 @@ export const createHttpStreamPreparation = sseOptions: Sse.DecodeOptions, ) => Effect.Effect, ): StreamingOperation['prepareStream'] => - (options, _operation, runPromiseExit) => { - const policy = accumulatedPolicy(identity, options) + (options, operation, runPromiseExit) => { + const policy: StreamQueryPolicy = + operation === 'live' ? { _tag: 'Live' } : accumulatedPolicy(identity, options) const sseOptions = decoderOptions(identity, options) delete options['refetchMode'] delete options['maxChunks'] @@ -103,7 +115,8 @@ export const createHttpStreamPreparation = }), ), ), - executionError: (cause) => new EffectHttpApiQueryError(identity, 'streamed', cause), + executionError: (cause) => new EffectHttpApiQueryError(identity, operation, cause), + emptyError: () => new EffectHttpApiQueryEmptyStreamError(identity), runPromiseExit, }) } diff --git a/src/http/types.ts b/src/http/types.ts index cdbadfa..03eb688 100644 --- a/src/http/types.ts +++ b/src/http/types.ts @@ -19,7 +19,7 @@ import type { QueryData, RunPromiseExit, } from '../core/types' -import type { EffectHttpApiQueryError } from './errors' +import type { EffectHttpApiQueryEmptyStreamError, EffectHttpApiQueryError } from './errors' export type Groups = Api extends HttpApi.HttpApi ? Group : never @@ -302,13 +302,49 @@ export type StreamedLeaf< > } +export type HttpLivePolicy = { + readonly sseOptions?: Sse.DecodeOptions | undefined +} + +export type LiveFailure = + | Failure>> + | EffectHttpApiQueryEmptyStreamError + +export type LiveKey< + Endpoint extends HttpApiEndpoint.ConstraintRequest, + Key extends readonly JsonValue[], + ClientError, +> = DataTag< + readonly [...Key, 'live', ...JsonValue[]], + QueryData>>, + LiveFailure +> + +export type LiveLeaf< + Endpoint extends HttpApiEndpoint.ConstraintRequest, + Key extends readonly JsonValue[], + ClientError, +> = { + readonly liveKey: void extends Request + ? (policy?: HttpLivePolicy) => LiveKey + : (input: Request, policy?: HttpLivePolicy) => LiveKey + readonly liveOptions: UnaryQueryBuilder< + Request, + QueryData>>, + LiveFailure, + LiveKey, + readonly [...Key, 'live'], + HttpLivePolicy + > +} + export type Leaf< Endpoint extends HttpApiEndpoint.ConstraintRequest, Key extends readonly JsonValue[], ClientError, > = [SseEndpoint] extends [never] ? BufferedLeaf - : StreamedLeaf + : StreamedLeaf & LiveLeaf /** An eager utility tree mirroring the ready HTTP client's literal properties. */ export type HttpApiQueryUtils< diff --git a/src/index.ts b/src/index.ts index 3c4358b..98ddf0e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ export { createRpcQueryUtils } from './rpc/create-utils' export { createHttpApiQueryUtils } from './http/create-utils' export { EffectHttpApiQueryConfigError, + EffectHttpApiQueryEmptyStreamError, EffectHttpApiQueryError, EffectHttpApiQueryKeyError, isEffectHttpApiQueryError, diff --git a/tests/http-live-query.test.ts b/tests/http-live-query.test.ts new file mode 100644 index 0000000..ebb91c8 --- /dev/null +++ b/tests/http-live-query.test.ts @@ -0,0 +1,50 @@ +import { it } from '@effect/vitest' +import { QueryClient } from '@tanstack/query-core' +import { Effect, Layer, Schema, Stream } from 'effect' +import { HttpServer } from 'effect/http' +import { + HttpApi, + HttpApiBuilder, + HttpApiEndpoint, + HttpApiGroup, + HttpApiSchema, + HttpApiTest, +} from 'effect/http-api' +import { expect } from 'vite-plus/test' + +import { createHttpApiQueryUtils, EffectHttpApiQueryEmptyStreamError } from '#effect-api-query' + +const api = HttpApi.make('empty-live').add( + HttpApiGroup.make('events').add( + HttpApiEndpoint.get('watch', '/watch', { + success: HttpApiSchema.StreamSse({ data: Schema.Finite }), + }), + ), +) +const handlers = HttpApiBuilder.group(api, 'events', (group) => + group.handle('watch', () => Effect.succeed(Stream.empty)), +) + +it.effect('reports declaration identity when an HTTP live stream completes without an event', () => + Effect.gen(function* () { + const client = yield* HttpApiTest.groups(api, ['events']) + const http = createHttpApiQueryUtils(api, { client, keyPrefix: ['test'] }) + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + try { + yield* Effect.promise(async () => { + const pending = queryClient.query(http.events.watch.liveOptions()) + await expect(pending).rejects.toBeInstanceOf(EffectHttpApiQueryEmptyStreamError) + await expect(pending).rejects.toMatchObject({ + _tag: 'EffectHttpApiQueryEmptyStreamError', + apiId: 'empty-live', + groupId: 'events', + endpoint: 'watch', + method: 'GET', + operation: 'live', + }) + }) + } finally { + queryClient.clear() + } + }).pipe(Effect.provide(Layer.mergeAll(handlers, HttpServer.layerServices)), Effect.scoped), +) diff --git a/tests/packed-consumer/http-live-runtime.mts b/tests/packed-consumer/http-live-runtime.mts new file mode 100644 index 0000000..0c880c1 --- /dev/null +++ b/tests/packed-consumer/http-live-runtime.mts @@ -0,0 +1,601 @@ +import { QueryClient, QueryObserver, isCancelledError } from '@tanstack/query-core' +import { + Cause, + Context, + Deferred, + Effect, + Exit, + Layer, + Schema, + SchemaTransformation, + Stream, +} from 'effect' +import { + createHttpApiQueryUtils, + EffectHttpApiQueryConfigError, + EffectHttpApiQueryEmptyStreamError, + EffectHttpApiQueryError, + skipToken, +} from 'effect-api-query' +import { HttpClient, HttpClientResponse, HttpServer } from 'effect/http' +import { + HttpApi, + HttpApiBuilder, + HttpApiClient, + HttpApiEndpoint, + HttpApiGroup, + HttpApiSchema, + HttpApiTest, +} from 'effect/http-api' +import { deepStrictEqual, equal, notDeepStrictEqual, ok, rejects, throws } from 'node:assert/strict' + +const UndefinedValue = Schema.Null.pipe( + Schema.decodeTo( + Schema.Void, + SchemaTransformation.transform({ + decode: (): void => undefined, + encode: (_value: void) => null, + }), + ), +) +const Expired = Schema.TaggedStruct('Expired', { reason: Schema.String }) +const api = HttpApi.make('http-live').add( + HttpApiGroup.make('events').add( + HttpApiEndpoint.get('watch', '/watch', { + query: { channel: Schema.String }, + success: HttpApiSchema.StreamSse({ data: Schema.Finite, error: Expired }), + }), + HttpApiEndpoint.get('nullable', '/nullable', { + success: HttpApiSchema.StreamSse({ data: Schema.NullOr(Schema.Finite) }), + }), + HttpApiEndpoint.get('undefined', '/undefined', { + success: HttpApiSchema.StreamSse({ data: UndefinedValue }), + }), + HttpApiEndpoint.get('mixedUndefined', '/mixed-undefined', { + success: HttpApiSchema.StreamSse({ data: Schema.Union([Schema.Finite, UndefinedValue]) }), + }), + HttpApiEndpoint.get('wrapped', '/wrapped', { + success: HttpApiSchema.WithHeaders(HttpApiSchema.StreamSse({ data: Schema.Finite }), { + 'x-version': Schema.FiniteFromString, + }), + }), + HttpApiEndpoint.get('wrappedUndefined', '/wrapped-undefined', { + success: HttpApiSchema.WithHeaders(HttpApiSchema.StreamSse({ data: UndefinedValue }), { + 'x-version': Schema.FiniteFromString, + }), + }), + HttpApiEndpoint.get('empty', '/empty', { + success: HttpApiSchema.StreamSse({ data: Schema.Finite }), + }), + HttpApiEndpoint.get('cursor', '/cursor', { + headers: { 'last-event-id': Schema.String }, + success: HttpApiSchema.StreamSse({ + events: Schema.Struct({ + id: Schema.String, + event: Schema.Literal('changed'), + data: Schema.String, + }), + }), + }), + ), +) +const handlers = HttpApiBuilder.group(api, 'events', (group) => + group + .handle('watch', ({ query }) => + Effect.succeed( + query.channel === 'failure' + ? Stream.concat( + Stream.succeed(1), + Stream.fail({ _tag: 'Expired' as const, reason: 'resume' }), + ) + : query.channel === 'interruption' + ? Stream.failCause(Cause.interrupt(123)) + : query.channel === 'mixed' + ? Stream.failCause( + Cause.combine(Cause.interrupt(123), Cause.die(new Error('defect'))), + ) + : Stream.make(1, 2, 3), + ), + ) + .handle('nullable', () => Effect.succeed(Stream.make(1, null))) + .handle('undefined', () => Effect.succeed(Stream.succeed(undefined))) + .handle('mixedUndefined', () => Effect.succeed(Stream.make(1, undefined))) + .handle('wrapped', () => + Effect.succeed( + HttpApiSchema.withHeaders({ body: Stream.make(3, 7), headers: { 'x-version': 4 } }), + ), + ) + .handle('wrappedUndefined', () => + Effect.succeed( + HttpApiSchema.withHeaders({ body: Stream.succeed(undefined), headers: { 'x-version': 4 } }), + ), + ) + .handle('empty', () => Effect.succeed(Stream.empty)) + .handle('cursor', ({ headers }) => + Effect.succeed( + Stream.succeed({ id: headers['last-event-id'], event: 'changed' as const, data: 'next' }), + ), + ), +) +const waitFor = async (client: QueryClient, check: () => boolean) => { + const ready = Deferred.makeUnsafe() + const observe = () => { + if (check()) Effect.runSync(Deferred.succeed(ready, undefined)) + } + const unsubscribe = client.getQueryCache().subscribe(observe) + try { + observe() + await Effect.runPromise(Deferred.await(ready).pipe(Effect.timeout('5 seconds'))) + } finally { + unsubscribe() + } +} +const sse = (body: string, headers: Record = {}) => + new Response(body, { headers: { 'content-type': 'text/event-stream', ...headers } }) +const makeClient = (response: () => Response) => + Effect.runPromise( + HttpApiClient.makeWith(api, { + baseUrl: 'https://example.test', + httpClient: HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, response())), + ), + }), + ) +const expectFailure = + (endpoint: string, check: (cause: Cause.Cause) => void) => (error: unknown) => { + ok(error instanceof EffectHttpApiQueryError) + equal(error.apiId, 'http-live') + equal(error.groupId, 'events') + equal(error.endpoint, endpoint) + equal(error.method, 'GET') + equal(error.operation, 'live') + check(error.cause) + return true + } + +await Effect.runPromise( + Effect.gen(function* () { + const client = yield* HttpApiTest.groups(api, ['events']) + const http = createHttpApiQueryUtils(api, { client, keyPrefix: ['consumer'] }) + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const input = { query: { channel: 'news' } } + try { + equal( + yield* Effect.promise(() => queryClient.query(http.events.watch.liveOptions({ input }))), + 3, + ) + equal(queryClient.getQueryData(http.events.watch.liveKey(input)), 3) + equal( + yield* Effect.promise(() => queryClient.query(http.events.nullable.liveOptions())), + null, + ) + equal( + yield* Effect.promise(() => queryClient.query(http.events.undefined.liveOptions())), + null, + ) + equal( + yield* Effect.promise(() => queryClient.query(http.events.mixedUndefined.liveOptions())), + null, + ) + deepStrictEqual( + yield* Effect.promise(() => queryClient.query(http.events.wrapped.liveOptions())), + HttpApiSchema.withHeaders({ body: 7, headers: { 'x-version': 4 } }), + ) + deepStrictEqual( + yield* Effect.promise(() => queryClient.query(http.events.wrappedUndefined.liveOptions())), + HttpApiSchema.withHeaders({ body: undefined, headers: { 'x-version': 4 } }), + ) + yield* Effect.promise(() => + rejects(queryClient.query(http.events.empty.liveOptions()), (error: unknown) => { + ok(error instanceof EffectHttpApiQueryEmptyStreamError) + equal(error._tag, 'EffectHttpApiQueryEmptyStreamError') + equal(error.apiId, 'http-live') + equal(error.groupId, 'events') + equal(error.endpoint, 'empty') + equal(error.method, 'GET') + equal(error.operation, 'live') + return true + }), + ) + for (const channel of ['failure', 'interruption', 'mixed']) { + const options = http.events.watch.liveOptions({ input: { query: { channel } } }) + yield* Effect.promise(() => + rejects( + queryClient.query(options), + expectFailure('watch', (cause) => { + if (channel === 'failure') + deepStrictEqual( + cause.reasons.map((reason) => + reason._tag === 'Fail' ? reason.error : reason._tag, + ), + [{ _tag: 'Expired', reason: 'resume' }], + ) + else ok(Cause.hasInterrupts(cause)) + if (channel === 'mixed') ok(Cause.hasDies(cause)) + }), + ), + ) + if (channel === 'failure') equal(queryClient.getQueryData(options.queryKey), 1) + } + for (const cursor of ['v1', 'v2']) { + const options = http.events.cursor.liveOptions({ + input: { headers: { 'last-event-id': cursor } }, + sseOptions: { maxEventSize: 1024 }, + }) + deepStrictEqual(yield* Effect.promise(() => queryClient.query(options)), { + id: cursor, + event: 'changed', + data: 'next', + }) + deepStrictEqual(queryClient.getQueryData(options.queryKey), { + id: cursor, + event: 'changed', + data: 'next', + }) + } + const defaultKey = http.events.watch.liveKey(input) + for (const sseOptions of [ + undefined, + {}, + { maxEventSize: undefined }, + { maxEventSize: 10 * 1024 * 1024 }, + ]) { + deepStrictEqual(defaultKey, http.events.watch.liveKey(input, { sseOptions })) + deepStrictEqual(defaultKey, http.events.watch.liveOptions({ input, sseOptions }).queryKey) + deepStrictEqual( + http.events.undefined.liveKey(), + http.events.undefined.liveOptions({ sseOptions }).queryKey, + ) + } + notDeepStrictEqual( + defaultKey, + http.events.watch.liveKey(input, { sseOptions: { maxEventSize: 1024 } }), + ) + notDeepStrictEqual(defaultKey, http.events.watch.streamedKey(input)) + equal(Object.isFrozen(defaultKey.at(-1)), true) + const skipped = http.events.watch.liveOptions({ + input: skipToken, + sseOptions: { maxEventSize: 1024 }, + staleTime: 123, + }) + equal(http.events.watch.liveOptions(skipToken).queryFn, skipToken) + equal(skipped.queryFn, skipToken) + equal(skipped.staleTime, 123) + deepStrictEqual(skipped.queryKey, [...http.events.watch.key(), 'live']) + for (const maxEventSize of [ + 0, + -1, + 0.5, + NaN, + Infinity, + Number.MAX_SAFE_INTEGER + 1, + null, + '2', + ]) { + const policy = { sseOptions: { maxEventSize } } + for (const build of [ + () => Reflect.apply(http.events.watch.liveKey, undefined, [input, policy]), + () => Reflect.apply(http.events.undefined.liveKey, undefined, [policy]), + ...[input, skipToken].map( + (request) => () => + Reflect.apply(http.events.watch.liveOptions, undefined, [ + { input: request, ...policy }, + ]), + ), + ]) { + throws(build, (error: unknown) => { + ok(error instanceof EffectHttpApiQueryConfigError) + equal(error.code, 'InvalidMaxEventSize') + return true + }) + } + } + queryClient.setQueryData( + http.events.watch.liveKey(input, { sseOptions: { maxEventSize: 1024 } }), + 4, + ) + queryClient.setQueryData(http.events.watch.streamedKey(input), [3]) + equal(queryClient.getQueriesData({ queryKey: http.events.watch.key() }).length, 6) + yield* Effect.promise(() => + queryClient.invalidateQueries({ queryKey: http.events.watch.key() }), + ) + equal(queryClient.getQueryState(defaultKey)?.isInvalidated, true) + equal(queryClient.getQueryState(http.events.watch.streamedKey(input))?.isInvalidated, true) + equal(queryClient.getQueryState(http.events.wrapped.liveKey())?.isInvalidated, false) + } finally { + queryClient.clear() + } + }).pipe(Effect.provide(Layer.mergeAll(handlers, HttpServer.layerServices)), Effect.scoped), +) + +for (const [endpoint, firstChunk, lastChunk, firstValue, lastValue] of [ + ['nullable', 'null', '5', null, 5], + ['nullable', '1', 'null', 1, null], + ['mixedUndefined', '1', 'null', 1, null], + ['mixedUndefined', 'null', '5', null, 5], +] as const) { + let finish!: () => void + let closed = 0 + const client = await makeClient( + () => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(`data: ${firstChunk}\n\n`)) + finish = () => { + controller.enqueue(new TextEncoder().encode(`data: ${lastChunk}\n\n`)) + controller.close() + } + }, + cancel() { + closed += 1 + }, + }), + { headers: { 'content-type': 'text/event-stream' } }, + ), + ) + const http = createHttpApiQueryUtils(api, { + client, + keyPrefix: ['visibility', endpoint, firstChunk], + }) + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, queryKeyHashFn: (key) => `custom:${JSON.stringify(key)}` }, + }, + }) + const options = + endpoint === 'nullable' + ? http.events.nullable.liveOptions() + : http.events.mixedUndefined.liveOptions() + const observer = + endpoint === 'nullable' + ? new QueryObserver(queryClient, http.events.nullable.liveOptions()) + : new QueryObserver(queryClient, http.events.mixedUndefined.liveOptions()) + const unsubscribe = observer.subscribe(() => undefined) + try { + await waitFor( + queryClient, + () => + observer.getCurrentResult().status === 'success' && + observer.getCurrentResult().data === firstValue, + ) + equal(observer.getCurrentResult().fetchStatus, 'fetching') + equal(queryClient.getQueryData(options.queryKey), firstValue) + equal(queryClient.getQueryCache().getAll().length, 1) + finish() + await waitFor(queryClient, () => observer.getCurrentResult().fetchStatus === 'idle') + equal(observer.getCurrentResult().data, lastValue) + equal(queryClient.getQueryData(options.queryKey), lastValue) + equal(closed, 0) + } finally { + unsubscribe() + queryClient.clear() + } +} + +for (const [body, endpoint, headers, failureTag] of [ + ['data: "bad"\n\n', 'nullable', {}, 'SchemaError'], + ['data: 1\n\n', 'wrapped', { 'x-version': 'bad' }, 'SchemaError'], + ['id: v3\ndata: 1\n\nretry: 25\n\n', 'nullable', {}, 'Retry'], +] as const) { + const client = await makeClient(() => sse(body, headers)) + const http = createHttpApiQueryUtils(api, { client, keyPrefix: ['failure'] }) + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + try { + const pending = + endpoint === 'wrapped' + ? queryClient.query(http.events.wrapped.liveOptions()) + : queryClient.query(http.events.nullable.liveOptions()) + await rejects( + pending, + expectFailure(endpoint, (cause) => { + deepStrictEqual( + cause.reasons.map((reason) => + reason._tag === 'Fail' ? Reflect.get(reason.error as object, '_tag') : reason._tag, + ), + [failureTag], + ) + if (failureTag === 'Retry') { + const reason = cause.reasons[0] + ok(reason?._tag === 'Fail') + equal(Reflect.get(reason.error as object, 'lastEventId'), 'v3') + } + }), + ) + if (failureTag === 'Retry') equal(queryClient.getQueryData(http.events.nullable.liveKey()), 1) + } finally { + queryClient.clear() + } +} + +{ + const client = await makeClient( + () => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('data: 12345')) + controller.enqueue(new TextEncoder().encode('\n\n')) + controller.close() + }, + }), + { headers: { 'content-type': 'text/event-stream' } }, + ), + ) + const http = createHttpApiQueryUtils(api, { client, keyPrefix: ['decoder'] }) + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + try { + equal(await queryClient.query(http.events.nullable.liveOptions()), 12345) + await rejects( + queryClient.query(http.events.nullable.liveOptions({ sseOptions: { maxEventSize: 3 } })), + expectFailure('nullable', (cause) => { + const reason = cause.reasons[0] + ok(reason?._tag === 'Fail') + equal(Reflect.get(reason.error as object, '_tag'), 'SseError') + }), + ) + } finally { + queryClient.clear() + } +} + +for (const phase of ['acquisition', 'consumption', 'unsubscribe'] as const) { + const started = Deferred.makeUnsafe() + const finalized = Deferred.makeUnsafe() + let closed = 0 + let signal: AbortSignal | undefined + const client = await Effect.runPromise( + HttpApiClient.makeWith(api, { + baseUrl: 'https://example.test', + httpClient: HttpClient.make((request, _url, transportSignal) => { + signal = transportSignal + if (phase === 'acquisition') + return Deferred.succeed(started, undefined).pipe( + Effect.andThen(Effect.never), + Effect.ensuring(Deferred.succeed(finalized, undefined)), + ) + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('data: 1\n\n')) + }, + cancel() { + closed += 1 + Effect.runSync(Deferred.succeed(finalized, undefined)) + }, + }), + { headers: { 'content-type': 'text/event-stream' } }, + ), + ), + ) + }), + }), + ) + const http = createHttpApiQueryUtils(api, { client, keyPrefix: ['cancel', phase] }) + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const options = http.events.nullable.liveOptions() + const observer = new QueryObserver(queryClient, options) + const unsubscribe = + phase === 'unsubscribe' ? observer.subscribe(() => undefined) : () => undefined + const pending = phase === 'unsubscribe' ? undefined : queryClient.query(options) + const outcome = pending?.catch((error: unknown) => error) + try { + if (phase === 'acquisition') + await Effect.runPromise(Deferred.await(started).pipe(Effect.timeout('5 seconds'))) + else await waitFor(queryClient, () => queryClient.getQueryData(options.queryKey) === 1) + if (phase === 'unsubscribe') unsubscribe() + else await queryClient.cancelQueries({ queryKey: options.queryKey, exact: true }) + await Effect.runPromise(Deferred.await(finalized).pipe(Effect.timeout('5 seconds'))) + equal(signal?.aborted, true) + equal(closed, phase === 'acquisition' ? 0 : 1) + const result = await outcome + if (phase === 'acquisition') ok(isCancelledError(result)) + else if (phase === 'consumption') equal(result, 1) + equal(queryClient.getQueryData(options.queryKey), phase === 'acquisition' ? undefined : 1) + equal(queryClient.getQueryState(options.queryKey)?.fetchStatus, 'idle') + equal(queryClient.getQueryState(options.queryKey)?.error, null) + } finally { + unsubscribe() + queryClient.clear() + } +} + +{ + const cause = Cause.combine(Cause.interrupt(123), Cause.die(new Error('acquisition defect'))) + const client = await Effect.runPromise( + HttpApiClient.makeWith(api, { + baseUrl: 'https://example.test', + httpClient: HttpClient.make(() => Effect.failCause(cause)), + }), + ) + const http = createHttpApiQueryUtils(api, { client, keyPrefix: ['acquisition-failure'] }) + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + try { + await rejects( + queryClient.query(http.events.nullable.liveOptions()), + expectFailure('nullable', (actual) => { + deepStrictEqual( + actual.reasons.map((reason) => reason._tag), + ['Interrupt', 'Die'], + ) + const defect = actual.reasons[1] + ok(defect?._tag === 'Die') + const original = cause.reasons[1] + ok(original?._tag === 'Die') + equal(defect.defect, original.defect) + }), + ) + } finally { + queryClient.clear() + } +} + +class Scale extends Context.Service()('HttpLive/Scale') {} +const Scaled = Schema.Finite.pipe( + Schema.decodeTo( + Schema.Finite, + SchemaTransformation.transformEffect({ + decode: (value) => Scale.pipe(Effect.map(({ factor }) => value * factor)), + encode: Effect.succeed, + }), + ), +) +const serviceApi = HttpApi.make('live-services').add( + HttpApiGroup.make('events').add( + HttpApiEndpoint.get('watch', '/watch', { success: HttpApiSchema.StreamSse({ data: Scaled }) }), + ), +) +const serviceClient = await Effect.runPromise( + HttpApiClient.makeWith(serviceApi, { + baseUrl: 'https://example.test', + httpClient: HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, sse('data: 3\n\n'))), + ), + }), +) +const serviceHttp = createHttpApiQueryUtils(serviceApi, { + client: serviceClient, + keyPrefix: ['services'], + runPromiseExit: (effect, options) => + Effect.runPromiseExit(effect.pipe(Effect.provideService(Scale, { factor: 2 })), options), +}) +const serviceQueryClient = new QueryClient() +try { + equal(await serviceQueryClient.query(serviceHttp.events.watch.liveOptions()), 6) +} finally { + serviceQueryClient.clear() +} + +{ + const client = await makeClient(() => sse('data: 1\n\n')) + const cause = Cause.combine(Cause.interrupt(123), Cause.die(new Error('defect'))) + const failedExit = Exit.failCause(cause) + const failed = createHttpApiQueryUtils(api, { + client, + keyPrefix: ['runner-cause'], + runPromiseExit: () => Promise.resolve(failedExit), + }) + const rejection = new Error('runner rejected') + const http = createHttpApiQueryUtils(api, { + client, + keyPrefix: ['runner-rejection'], + runPromiseExit: () => Promise.reject(rejection), + }) + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + try { + await rejects( + queryClient.query(failed.events.nullable.liveOptions()), + expectFailure('nullable', (actual) => equal(actual, cause)), + ) + await rejects( + queryClient.query(http.events.nullable.liveOptions()), + (error) => error === rejection, + ) + } finally { + queryClient.clear() + } +} +console.log('Packed HTTP live values, policies, failures, services, and resource cleanup executed') diff --git a/tests/packed-consumer/runtime.mts b/tests/packed-consumer/runtime.mts index 3650d8b..b2fb977 100644 --- a/tests/packed-consumer/runtime.mts +++ b/tests/packed-consumer/runtime.mts @@ -31,6 +31,7 @@ type PublicTypes = [ const expectedExports = [ 'EffectHttpApiQueryConfigError', + 'EffectHttpApiQueryEmptyStreamError', 'EffectHttpApiQueryError', 'EffectHttpApiQueryKeyError', 'EffectRpcQueryConfigError', diff --git a/tests/packed-consumer/tsconfig.json b/tests/packed-consumer/tsconfig.json index 27c5028..907e8d2 100644 --- a/tests/packed-consumer/tsconfig.json +++ b/tests/packed-consumer/tsconfig.json @@ -20,6 +20,7 @@ "retry-runtime.mts", "http-runtime.mts", "http-stream-runtime.mts", + "http-live-runtime.mts", "http-stream-contract.ts", "public-contract.ts", "http-contract.ts", diff --git a/tests/types/http-stream-contract.ts b/tests/types/http-stream-contract.ts index 1cd3e87..2b52c9e 100644 --- a/tests/types/http-stream-contract.ts +++ b/tests/types/http-stream-contract.ts @@ -1,7 +1,8 @@ import { QueryClient, QueryObserver, skipToken } from '@tanstack/query-core' +import { useQuery, useSuspenseQuery } from '@tanstack/react-query' import { Context, Effect, Schema, SchemaTransformation } from 'effect' import type { Stream } from 'effect' -import { createHttpApiQueryUtils } from 'effect-api-query' +import { createHttpApiQueryUtils, EffectHttpApiQueryEmptyStreamError } from 'effect-api-query' import type { CreateHttpApiQueryUtilsOptions, EffectHttpApiQueryError, @@ -70,7 +71,10 @@ const utils = createHttpApiQueryUtils(api, { client, keyPrefix: ['consumer'], ru true satisfies Assert> true satisfies Assert> true satisfies Assert< - Equal + Equal< + keyof typeof utils.views.plain, + 'key' | 'streamedKey' | 'streamedOptions' | 'liveKey' | 'liveOptions' + > > const queryClient = new QueryClient() const input = { params: { id: 1 } } @@ -193,3 +197,147 @@ type ExtraRequirements = CreateHttpApiQueryUtilsOptions< >['runPromiseExit'] true satisfies Assert> true satisfies Assert + +const liveOptions = utils.views.plain.liveOptions({ + input, + sseOptions: { maxEventSize: 1024 }, + select: (value) => value.toFixed(2), + staleTime: Infinity, +}) +const liveKey = utils.views.plain.liveKey(input, { sseOptions: { maxEventSize: 1024 } }) +const liveData = queryClient.getQueryData(liveKey) +true satisfies Assert> +const liveState = queryClient.getQueryState(liveOptions.queryKey) +true satisfies Assert< + Equal['error'], Error | EffectHttpApiQueryEmptyStreamError | null> +> +const liveObserver = new QueryObserver(queryClient, liveOptions) +true satisfies Assert< + Equal['data'], string | undefined> +> +const liveSkipped = utils.views.plain.liveOptions({ + input: skipToken, + sseOptions: { maxEventSize: 1024 }, + initialData: 1, +}) +liveSkipped.queryFn satisfies typeof skipToken +utils.views.plain.liveOptions(skipToken).queryFn satisfies typeof skipToken +const liveConditional = utils.views.plain.liveOptions({ + input: Math.random() > 0.5 ? input : skipToken, + initialData: () => 1, + select: (value) => value.toString(), +}) +const liveDefined = useQuery( + utils.views.plain.liveOptions({ input, initialData: 1, select: (value) => value.toString() }), +) +true satisfies Assert> +const optionalNumber: number | undefined = Math.random() > 0.5 ? 1 : undefined +const liveOptional = useQuery( + utils.views.plain.liveOptions({ + input, + initialData: optionalNumber, + select: (value) => value.toString(), + }), +) +true satisfies Assert> +const liveSkippedResult = useQuery(liveSkipped) +true satisfies Assert> +useQuery(liveConditional) +// @ts-expect-error Skipped live queries cannot guarantee suspense execution. +useSuspenseQuery(liveSkipped) +const liveWrapped = queryClient.getQueryData(utils.views.wrapped.liveKey()) +true satisfies Assert< + Equal< + typeof liveWrapped, + HttpApiSchema.withHeaders | undefined + > +> +const liveEvents = queryClient.getQueryData(utils.events.liveKey()) +true satisfies Assert< + Equal< + typeof liveEvents, + { readonly id: string; readonly event: 'changed'; readonly data: string } | undefined + > +> +const extraLiveState = queryClient.getQueryState(extra.views.plain.liveKey(input)) +type ExtraLiveFailure = + Exclude< + NonNullable['error'], + EffectHttpApiQueryEmptyStreamError | null + > extends EffectHttpApiQueryError + ? E + : never +true satisfies Assert< + Equal, 'extra-error' | 'stream-error'> +> +// @ts-expect-error Decoder controls remain separate from captured requests. +utils.views.plain.liveKey(captured) +// @ts-expect-error The adapter owns response mode. +utils.views.plain.liveOptions({ input: { ...input, responseMode: 'response-only' } }) +// @ts-expect-error Live queries retain one value and expose no history bound. +utils.views.plain.liveOptions({ input, maxChunks: 2 }) +// @ts-expect-error Live queries expose no accumulated refetch mode. +utils.views.plain.liveKey(input, { refetchMode: 'append' }) +// @ts-expect-error Caller hashes belong in QueryClient defaults. +utils.views.plain.liveOptions({ input, queryKeyHashFn: JSON.stringify }) +// @ts-expect-error Inputless live endpoints reject skipToken. +utils.events.liveOptions({ input: skipToken }) +custom.views.plain.liveKey(input) + +const UndefinedValue = Schema.Null.pipe( + Schema.decodeTo( + Schema.Void, + SchemaTransformation.transform({ + decode: (): void => undefined, + encode: (_value: void) => null, + }), + ), +) +const normalizationApi = HttpApi.make('live-normalization').add( + HttpApiGroup.make('values').add( + HttpApiEndpoint.get('plain', '/plain', { + success: HttpApiSchema.StreamSse({ data: UndefinedValue }), + }), + HttpApiEndpoint.get('wrapped', '/wrapped', { + success: HttpApiSchema.WithHeaders(HttpApiSchema.StreamSse({ data: UndefinedValue }), { + etag: Schema.String, + }), + }), + HttpApiEndpoint.get('union', '/union', { + success: HttpApiSchema.StreamSse({ data: Schema.Union([Schema.Finite, UndefinedValue]) }), + }), + ), +) +declare const normalizationClient: HttpApiClient.ForApi +const normalized = createHttpApiQueryUtils(normalizationApi, { + client: normalizationClient, + keyPrefix: ['normalized'], +}) +const undefinedData = queryClient.getQueryData(normalized.values.plain.liveKey()) +true satisfies Assert> +const unionData = queryClient.getQueryData(normalized.values.union.liveKey()) +true satisfies Assert> +const wrappedUndefinedData = queryClient.getQueryData(normalized.values.wrapped.liveKey()) +true satisfies Assert< + Equal< + typeof wrappedUndefinedData, + HttpApiSchema.withHeaders | undefined + > +> +const undefinedLive = useQuery( + normalized.values.plain.liveOptions({ + initialData: null, + select: (value) => { + value satisfies null + return 'ready' as const + }, + }), +) +true satisfies Assert> +const emptyError: EffectHttpApiQueryEmptyStreamError = new EffectHttpApiQueryEmptyStreamError({ + apiId: 'api', + groupId: 'group', + endpoint: 'empty', + method: 'GET', +}) +emptyError.operation satisfies 'live' From 923b354d8ab6295bb9d1e8b41be6dc20cbbfd546 Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Fri, 9 Oct 2026 23:10:15 +0200 Subject: [PATCH 28/97] test: verify metadata ownership and hydration recipes --- README.md | 5 +- .../docs/guides/http-queries-and-mutations.md | 8 ++- .../content/docs/guides/hydrate-unary-data.md | 47 ++++++++-------- .../content/docs/reference/http-factory.md | 9 ++- tests/packed-consumer/docs-hydration-async.ts | 47 ++++++++-------- .../packed-consumer/http-metadata-runtime.mts | 21 ++++++- tests/packed-consumer/hydration-runtime.mts | 55 ++++++++++--------- 7 files changed, 113 insertions(+), 79 deletions(-) diff --git a/README.md b/README.md index 4095499..423ce21 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ for conditional writes and invalidation. | Streaming RPC | Accumulated streamed queries and live queries | | Buffered HttpApi endpoint without multipart | Queries, metadata, infinite queries, and mutations | | Buffered HttpApi endpoint with multipart | Mutations with `FormData` | +| Unambiguous HttpApi SSE endpoint | Accumulated streamed queries | Both factories generate keys for cache reads, writes, prefetching, and invalidation. Query functions forward cancellation to Effect. Mutations use TanStack's normal callbacks; invalidate affected @@ -86,7 +87,9 @@ queries in your application. For uploads, build `FormData` explicitly and pass it as mutation `payload`. See the [HTTP upload guide](https://ueberbrot.github.io/effect-api-query/guides/http-queries-and-mutations/#upload-a-file). -Streaming HTTP responses and streaming multipart requests remain omitted. +Mixed HTTP success modes, raw byte streams, and streaming multipart requests remain omitted. See +the [SSE guide](https://ueberbrot.github.io/effect-api-query/guides/http-queries-and-mutations/#retain-sse-events) +for accumulated HTTP event queries. ## Integrate with your application diff --git a/apps/docs/src/content/docs/guides/http-queries-and-mutations.md b/apps/docs/src/content/docs/guides/http-queries-and-mutations.md index 23ab5d6..8514bba 100644 --- a/apps/docs/src/content/docs/guides/http-queries-and-mutations.md +++ b/apps/docs/src/content/docs/guides/http-queries-and-mutations.md @@ -92,9 +92,11 @@ wrapper. A top-level successful `undefined` becomes `null`. `current.status` is data keeps its declared representation. Raw headers can contain private values: choose which headers your application may persist or dehydrate. -Fetched snapshots stay frozen after native structural sharing. Your global, prefix, and per-call -sharing policies still apply; decoded data is not frozen by the adapter. Values supplied through -`initialData`, hydration, or manual cache writes remain application-owned. +Fetched snapshots stay frozen after structural sharing, using your global, prefix, or per-call +policy. Decoded data and values supplied through `initialData`, hydration, or manual cache writes +remain application-owned. After execution, manual writes use the policy explicitly passed to +`metadataOptions`, or standard deep sharing if omitted; they do not inherit global or prefix sharing +policies. See [Buffered metadata](/effect-api-query/reference/http-factory/#buffered-metadata). `metadataKey(input)` provides a typed key for cache reads and writes. Metadata options support selection, initial data, skipped input, native hashing defaults, and cancellation like ordinary diff --git a/apps/docs/src/content/docs/guides/hydrate-unary-data.md b/apps/docs/src/content/docs/guides/hydrate-unary-data.md index 7ccb9f1..047e754 100644 --- a/apps/docs/src/content/docs/guides/hydrate-unary-data.md +++ b/apps/docs/src/content/docs/guides/hydrate-unary-data.md @@ -197,31 +197,32 @@ const PreparedProfile = Profile.pipe( ), ) -export const prepareProfileSnapshot = (queryClient: QueryClient) => - Effect.gen(function* () { - const snapshot = dehydrate(queryClient, { - shouldDehydrateMutation: () => false, - shouldDehydrateQuery: (query) => - defaultShouldDehydrateQuery(query) && query.queryKey[0] === 'profile', - }) - const queries = yield* Effect.forEach(snapshot.queries, (query) => - Schema.encodeUnknownEffect(PreparedProfile)(query.state.data).pipe( - Effect.map((data) => ({ ...query, state: { ...query.state, data } })), - ), - ) - return JSON.stringify({ ...snapshot, queries }) +export const prepareProfileSnapshot = Effect.fnUntraced(function* (queryClient: QueryClient) { + const snapshot = dehydrate(queryClient, { + shouldDehydrateMutation: () => false, + shouldDehydrateQuery: (query) => + defaultShouldDehydrateQuery(query) && query.queryKey[0] === 'profile', }) + const queries = yield* Effect.forEach(snapshot.queries, (query) => + Schema.encodeUnknownEffect(PreparedProfile)(query.state.data).pipe( + Effect.map((data) => ({ ...query, state: { ...query.state, data } })), + ), + ) + return JSON.stringify({ ...snapshot, queries }) +}) -export const prepareProfileHydration = (queryClient: QueryClient, json: string) => - Effect.gen(function* () { - const snapshot: DehydratedState = JSON.parse(json) - const queries = yield* Effect.forEach(snapshot.queries, (query) => - Schema.decodeUnknownEffect(PreparedProfile)(query.state.data).pipe( - Effect.map((data) => ({ ...query, state: { ...query.state, data } })), - ), - ) - yield* Effect.sync(() => hydrate(queryClient, { ...snapshot, queries })) - }) +export const prepareProfileHydration = Effect.fnUntraced(function* ( + queryClient: QueryClient, + json: string, +) { + const snapshot: DehydratedState = JSON.parse(json) + const queries = yield* Effect.forEach(snapshot.queries, (query) => + Schema.decodeUnknownEffect(PreparedProfile)(query.state.data).pipe( + Effect.map((data) => ({ ...query, state: { ...query.state, data } })), + ), + ) + yield* Effect.sync(() => hydrate(queryClient, { ...snapshot, queries })) +}) ``` Provide `HydrationPreparation` through your application Context or ManagedRuntime. Await the diff --git a/apps/docs/src/content/docs/reference/http-factory.md b/apps/docs/src/content/docs/reference/http-factory.md index bf6c97a..561af87 100644 --- a/apps/docs/src/content/docs/reference/http-factory.md +++ b/apps/docs/src/content/docs/reference/http-factory.md @@ -117,11 +117,16 @@ value and its ownership, including decoded `WithHeaders` wrappers. Only top-leve `undefined` becomes `null`. Raw headers retain their string values without Effect's inspection redaction; applications decide which headers may be persisted or dehydrated. -Fetched snapshots remain frozen after native structural sharing, while preserving global, prefix, -and per-call sharing policies. Mutable envelopes or header records selected by sharing are copied +Fetched snapshots remain frozen after structural sharing. Global and prefix sharing policies apply +to fetched data; a policy passed to `metadataOptions` overrides them. Mutable envelopes or header +records selected by sharing are copied before freezing, retaining the selected decoded data reference. The adapter does not freeze decoded data or caller-supplied `initialData`, hydrated values, or manual cache writes. +After execution, manual cache writes use the sharing policy explicitly passed to `metadataOptions`, +or standard deep sharing when it is absent. Inherited global and prefix sharing policies do not +apply to those manual writes. + Metadata keys use a `metadata` discriminator and the ordinary request identity. Endpoint prefixes match every view. Native `select`, `initialData`, skip-token inference, QueryClient hashing defaults, and cancellation apply to metadata options. Execution failures identify operation `metadata` and diff --git a/tests/packed-consumer/docs-hydration-async.ts b/tests/packed-consumer/docs-hydration-async.ts index 4d10f9d..4dbb590 100644 --- a/tests/packed-consumer/docs-hydration-async.ts +++ b/tests/packed-consumer/docs-hydration-async.ts @@ -25,28 +25,29 @@ const PreparedProfile = Profile.pipe( ), ) -export const prepareProfileSnapshot = (queryClient: QueryClient) => - Effect.gen(function* () { - const snapshot = dehydrate(queryClient, { - shouldDehydrateMutation: () => false, - shouldDehydrateQuery: (query) => - defaultShouldDehydrateQuery(query) && query.queryKey[0] === 'profile', - }) - const queries = yield* Effect.forEach(snapshot.queries, (query) => - Schema.encodeUnknownEffect(PreparedProfile)(query.state.data).pipe( - Effect.map((data) => ({ ...query, state: { ...query.state, data } })), - ), - ) - return JSON.stringify({ ...snapshot, queries }) +export const prepareProfileSnapshot = Effect.fnUntraced(function* (queryClient: QueryClient) { + const snapshot = dehydrate(queryClient, { + shouldDehydrateMutation: () => false, + shouldDehydrateQuery: (query) => + defaultShouldDehydrateQuery(query) && query.queryKey[0] === 'profile', }) + const queries = yield* Effect.forEach(snapshot.queries, (query) => + Schema.encodeUnknownEffect(PreparedProfile)(query.state.data).pipe( + Effect.map((data) => ({ ...query, state: { ...query.state, data } })), + ), + ) + return JSON.stringify({ ...snapshot, queries }) +}) -export const prepareProfileHydration = (queryClient: QueryClient, json: string) => - Effect.gen(function* () { - const snapshot: DehydratedState = JSON.parse(json) - const queries = yield* Effect.forEach(snapshot.queries, (query) => - Schema.decodeUnknownEffect(PreparedProfile)(query.state.data).pipe( - Effect.map((data) => ({ ...query, state: { ...query.state, data } })), - ), - ) - yield* Effect.sync(() => hydrate(queryClient, { ...snapshot, queries })) - }) +export const prepareProfileHydration = Effect.fnUntraced(function* ( + queryClient: QueryClient, + json: string, +) { + const snapshot: DehydratedState = JSON.parse(json) + const queries = yield* Effect.forEach(snapshot.queries, (query) => + Schema.decodeUnknownEffect(PreparedProfile)(query.state.data).pipe( + Effect.map((data) => ({ ...query, state: { ...query.state, data } })), + ), + ) + yield* Effect.sync(() => hydrate(queryClient, { ...snapshot, queries })) +}) diff --git a/tests/packed-consumer/http-metadata-runtime.mts b/tests/packed-consumer/http-metadata-runtime.mts index dbc9a5d..373cf09 100644 --- a/tests/packed-consumer/http-metadata-runtime.mts +++ b/tests/packed-consumer/http-metadata-runtime.mts @@ -360,6 +360,20 @@ await Effect.runPromise( equal(localCalls, 2) equal(left.getQueryData(localOptions.queryKey)?.status, 213) equal(right.getQueryData(localOptions.queryKey)?.status, 213) + const customManual = { data: null, status: 218, headers: { etag: 'manual-custom' } } + const leftManual = left.setQueryData(localOptions.queryKey, customManual) + const rightManual = right.setQueryData(localOptions.queryKey, customManual) + equal(localCalls, 4) + equal(globalCalls, 2) + equal(prefixCalls, 2) + equal(leftManual?.status, 213) + equal(rightManual?.status, 213) + equal(leftManual?.headers, customManual.headers) + equal(rightManual?.headers, customManual.headers) + equal(Object.isFrozen(leftManual), false) + equal(Object.isFrozen(rightManual), false) + equal(Object.isFrozen(customManual), false) + equal(Object.isFrozen(customManual.headers), false) const inheritedFalse = http.empty.metadataOptions() left.setDefaultOptions({ queries: { retry: false, structuralSharing: false } }) right.setQueryDefaults(http.empty.key(), { structuralSharing: false }) @@ -379,9 +393,14 @@ await Effect.runPromise( equal(left.getQueryData(noSharing.queryKey), leftValue) equal(right.getQueryData(noSharing.queryKey), rightValue) equal(Object.isFrozen(leftValue), true) + const falseManual = { data: null, status: 219, headers: { etag: 'manual-false' } } + equal(left.setQueryData(noSharing.queryKey, falseManual), falseManual) + equal(right.setQueryData(noSharing.queryKey, falseManual), falseManual) + equal(Object.isFrozen(falseManual), false) + equal(Object.isFrozen(falseManual.headers), false) equal(globalCalls, 2) equal(prefixCalls, 2) - equal(localCalls, 2) + equal(localCalls, 4) } finally { left.clear() right.clear() diff --git a/tests/packed-consumer/hydration-runtime.mts b/tests/packed-consumer/hydration-runtime.mts index f7819ae..1228553 100644 --- a/tests/packed-consumer/hydration-runtime.mts +++ b/tests/packed-consumer/hydration-runtime.mts @@ -152,38 +152,41 @@ await Effect.runPromise( const serverScope = yield* Scope.make() const serverClient = new QueryClient() const browserClient = new QueryClient() - const makeOptions = (profile: Profile, onRead: () => void, scope: Scope.Scope) => - Effect.gen(function* () { - const read = () => - Effect.suspend(() => { - onRead() - return failed ? Effect.fail('private upstream detail') : Effect.succeed(profile) - }) - if (transport === 'rpc') { - const client = yield* RpcTest.makeClient(profileGroup, { flatten: true }).pipe( - Effect.provide(profileGroup.toLayer({ 'profile.read': read })), - Effect.provideService(Scope.Scope, scope), - ) - const options = profileRpcOptions(client) - return { - query: (cache: QueryClient) => cache.query(options), - queryKey: options.queryKey, - } - } - const client = yield* HttpApiTest.groups(profileApi, ['profile']).pipe( - Effect.provide( - HttpApiBuilder.group(profileApi, 'profile', (handlers) => - handlers.handle('read', read), - ), - ), + const makeOptions = Effect.fnUntraced(function* ( + profile: Profile, + onRead: () => void, + scope: Scope.Scope, + ) { + const read = () => + Effect.suspend(() => { + onRead() + return failed ? Effect.fail('private upstream detail') : Effect.succeed(profile) + }) + if (transport === 'rpc') { + const client = yield* RpcTest.makeClient(profileGroup, { flatten: true }).pipe( + Effect.provide(profileGroup.toLayer({ 'profile.read': read })), Effect.provideService(Scope.Scope, scope), ) - const options = profileHttpOptions(client) + const options = profileRpcOptions(client) return { query: (cache: QueryClient) => cache.query(options), queryKey: options.queryKey, } - }) + } + const client = yield* HttpApiTest.groups(profileApi, ['profile']).pipe( + Effect.provide( + HttpApiBuilder.group(profileApi, 'profile', (handlers) => + handlers.handle('read', read), + ), + ), + Effect.provideService(Scope.Scope, scope), + ) + const options = profileHttpOptions(client) + return { + query: (cache: QueryClient) => cache.query(options), + queryKey: options.queryKey, + } + }) try { const browserScope = yield* Scope.Scope const serverOptions = yield* makeOptions( From 160122982090cf77904c2462187265dcd0e4a1d3 Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Fri, 9 Oct 2026 23:11:48 +0200 Subject: [PATCH 29/97] test: document metadata selection sharing boundary --- .../docs/guides/http-queries-and-mutations.md | 7 ++++--- .../content/docs/reference/http-factory.md | 6 +++--- .../packed-consumer/http-metadata-runtime.mts | 21 +++++++++++++++++++ 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/apps/docs/src/content/docs/guides/http-queries-and-mutations.md b/apps/docs/src/content/docs/guides/http-queries-and-mutations.md index 8514bba..199a724 100644 --- a/apps/docs/src/content/docs/guides/http-queries-and-mutations.md +++ b/apps/docs/src/content/docs/guides/http-queries-and-mutations.md @@ -94,9 +94,10 @@ your application may persist or dehydrate. Fetched snapshots stay frozen after structural sharing, using your global, prefix, or per-call policy. Decoded data and values supplied through `initialData`, hydration, or manual cache writes -remain application-owned. After execution, manual writes use the policy explicitly passed to -`metadataOptions`, or standard deep sharing if omitted; they do not inherit global or prefix sharing -policies. See [Buffered metadata](/effect-api-query/reference/http-factory/#buffered-metadata). +remain application-owned. Selected observer results and subsequent manual writes use the policy +explicitly passed to `metadataOptions`, or standard deep sharing if omitted; they do not inherit +global or prefix sharing policies. See +[Buffered metadata](/effect-api-query/reference/http-factory/#buffered-metadata). `metadataKey(input)` provides a typed key for cache reads and writes. Metadata options support selection, initial data, skipped input, native hashing defaults, and cancellation like ordinary diff --git a/apps/docs/src/content/docs/reference/http-factory.md b/apps/docs/src/content/docs/reference/http-factory.md index 561af87..10a4e8b 100644 --- a/apps/docs/src/content/docs/reference/http-factory.md +++ b/apps/docs/src/content/docs/reference/http-factory.md @@ -123,9 +123,9 @@ records selected by sharing are copied before freezing, retaining the selected decoded data reference. The adapter does not freeze decoded data or caller-supplied `initialData`, hydrated values, or manual cache writes. -After execution, manual cache writes use the sharing policy explicitly passed to `metadataOptions`, -or standard deep sharing when it is absent. Inherited global and prefix sharing policies do not -apply to those manual writes. +Selected observer results and, after execution, manual cache writes use the sharing policy explicitly +passed to `metadataOptions`, or standard deep sharing when it is absent. Inherited global and prefix +sharing policies apply only to fetched metadata snapshots. Metadata keys use a `metadata` discriminator and the ordinary request identity. Endpoint prefixes match every view. Native `select`, `initialData`, skip-token inference, QueryClient hashing defaults, diff --git a/tests/packed-consumer/http-metadata-runtime.mts b/tests/packed-consumer/http-metadata-runtime.mts index 373cf09..6cb222f 100644 --- a/tests/packed-consumer/http-metadata-runtime.mts +++ b/tests/packed-consumer/http-metadata-runtime.mts @@ -398,6 +398,27 @@ await Effect.runPromise( equal(right.setQueryData(noSharing.queryKey, falseManual), falseManual) equal(Object.isFrozen(falseManual), false) equal(Object.isFrozen(falseManual.headers), false) + const selected = new QueryObserver( + left, + http.empty.metadataOptions({ select: (view) => ({ status: view.status }) }), + ) + await selected.refetch() + const inheritedSelection = selected.getCurrentResult().data + await selected.refetch() + equal(selected.getCurrentResult().data, inheritedSelection) + equal(Object.isFrozen(inheritedSelection), false) + selected.setOptions( + http.empty.metadataOptions({ + select: (view) => ({ status: view.status }), + structuralSharing: false, + }), + ) + await selected.refetch() + const explicitSelection = selected.getCurrentResult().data + await selected.refetch() + ok(selected.getCurrentResult().data !== explicitSelection) + equal(Object.isFrozen(selected.getCurrentResult().data), false) + selected.destroy() equal(globalCalls, 2) equal(prefixCalls, 2) equal(localCalls, 4) From 3790342a0d9e0872a124fdf9493f5787acf0f3fc Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Fri, 9 Oct 2026 23:15:49 +0200 Subject: [PATCH 30/97] docs: align HTTP capability references with cached views --- README.md | 2 +- .../src/content/docs/concepts/http-utility-tree.md | 5 +++-- .../content/docs/getting-started/feature-support.md | 11 ++++++----- .../docs/reference/compatibility-and-limits.md | 4 ++-- .../docs/src/content/docs/reference/public-exports.md | 3 ++- 5 files changed, 14 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 423ce21..cf622ce 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ for conditional writes and invalidation. | Streaming RPC | Accumulated streamed queries and live queries | | Buffered HttpApi endpoint without multipart | Queries, metadata, infinite queries, and mutations | | Buffered HttpApi endpoint with multipart | Mutations with `FormData` | -| Unambiguous HttpApi SSE endpoint | Accumulated streamed queries | +| Unambiguous HttpApi SSE endpoint | Accumulated streamed queries and live queries | Both factories generate keys for cache reads, writes, prefetching, and invalidation. Query functions forward cancellation to Effect. Mutations use TanStack's normal callbacks; invalidate affected diff --git a/apps/docs/src/content/docs/concepts/http-utility-tree.md b/apps/docs/src/content/docs/concepts/http-utility-tree.md index 0b1b953..c45de3b 100644 --- a/apps/docs/src/content/docs/concepts/http-utility-tree.md +++ b/apps/docs/src/content/docs/concepts/http-utility-tree.md @@ -39,8 +39,9 @@ that also accepts ordinary payloads. Its leaf exposes `key()`, `mutationKey()`, Files and other mutation variables do not become cache identity, so these leaves require no key encoder and reject encoder entries. See [upload a file](/effect-api-query/guides/http-queries-and-mutations/#upload-a-file). -Raw-response modes are also deferred. Generated calls force decoded-only responses and exclude -response controls from their input. See the [HTTP factory](/effect-api-query/reference/http-factory/) +Raw response objects remain omitted. Ordinary queries, mutations, and SSE views use decoded-only +responses; buffered metadata views copy status and headers alongside decoded data. Response +controls are excluded from request input. See the [HTTP factory](/effect-api-query/reference/http-factory/) for request, response, and builder contracts, and [Semantic Keys](/effect-api-query/concepts/semantic-keys/) for the `http` namespace. diff --git a/apps/docs/src/content/docs/getting-started/feature-support.md b/apps/docs/src/content/docs/getting-started/feature-support.md index 9358f65..231005c 100644 --- a/apps/docs/src/content/docs/getting-started/feature-support.md +++ b/apps/docs/src/content/docs/getting-started/feature-support.md @@ -22,12 +22,13 @@ Query-enabled buffered HTTP endpoints also expose `metadataOptions` for decoded raw response headers with separate cache identity. See [Update with an ETag](/effect-api-query/guides/http-queries-and-mutations/#update-with-an-etag). -RPC streams also have `streamedOptions` to accumulate values and `liveOptions` to retain the latest -value. Buffered multipart HTTP uploads accept explicit `FormData` through `mutationOptions`; +RPC streams and unambiguous HTTP SSE endpoints have `streamedOptions` to accumulate values and +`liveOptions` to retain the latest value. Buffered multipart HTTP uploads accept explicit `FormData` +through `mutationOptions`; see [upload a file](/effect-api-query/guides/http-queries-and-mutations/#upload-a-file). -HTTP streams, streaming multipart, and raw-response modes are deferred. The factory omits an -HTTP endpoint from the generated utility tree if it has any streaming success or streaming multipart -request alternative. +Raw byte streams, mixed buffered/SSE successes, streaming multipart, and raw-response modes remain +omitted. SSE endpoints with buffered multipart payloads are also omitted. See +[HTTP streams](/effect-api-query/guides/http-queries-and-mutations/#retain-sse-events). ## Plan application ownership diff --git a/apps/docs/src/content/docs/reference/compatibility-and-limits.md b/apps/docs/src/content/docs/reference/compatibility-and-limits.md index f304c41..b93bd13 100644 --- a/apps/docs/src/content/docs/reference/compatibility-and-limits.md +++ b/apps/docs/src/content/docs/reference/compatibility-and-limits.md @@ -32,10 +32,10 @@ supplies the client, policy, or lifecycle. **Deferred** means the adapter does n | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | | Ordinary queries and mutations | Generated for unary RPCs | Generated for buffered endpoints without multipart, regardless of method | | Pagination | Generated `infiniteOptions`; pages map to payloads | Generated for buffered endpoints without multipart; pages map to decoded requests | -| Accumulated streams and live queries | Generated for streaming RPCs; tested cancellation and SSR snapshots | Deferred; any streaming success alternative omits the endpoint | +| Accumulated streams and live queries | Generated for streaming RPCs; tested cancellation and SSR snapshots | Generated for unambiguous SSE successes without multipart; tested failures and cleanup | | Buffered multipart uploads | Application-owned transport and payload contract | Generated mutation-only builders; application supplies `FormData` | | Streaming multipart | Application-owned transport and payload contract | Deferred; any streaming multipart request alternative omits the endpoint | -| Raw HTTP response modes | Outside the RPC contract | Deferred; generated calls force decoded-only responses | +| Raw HTTP response modes | Outside the RPC contract | Raw responses omitted; metadata views retain decoded data and plain status/header snapshots | | Conditional queries | Generated `skipToken` support for input-bearing queries | Generated `skipToken` support for input-bearing queries | | Cache keys and invalidation prefixes | Generated `rpc` namespace and dotted tag paths | Generated `http` namespace, API identifier, and literal projected paths | | Failure inspection | Generated wrapper preserves the failed Exit Cause | Generated wrapper preserves the failed Exit Cause and declaration identity | diff --git a/apps/docs/src/content/docs/reference/public-exports.md b/apps/docs/src/content/docs/reference/public-exports.md index b34e9ce..773484a 100644 --- a/apps/docs/src/content/docs/reference/public-exports.md +++ b/apps/docs/src/content/docs/reference/public-exports.md @@ -26,7 +26,8 @@ All supported imports come from `effect-api-query`. The [HTTP factory](/effect-api-query/reference/http-factory/) adds `createHttpApiQueryUtils`, `HttpApiQueryUtils`, `CreateHttpApiQueryUtilsOptions`, and `HttpApiKeyEncoder`. Its errors are -`EffectHttpApiQueryError`, `EffectHttpApiQueryConfigError`, and `EffectHttpApiQueryKeyError`, with +`EffectHttpApiQueryError`, `EffectHttpApiQueryEmptyStreamError`, `EffectHttpApiQueryConfigError`, and +`EffectHttpApiQueryKeyError`, with `isEffectHttpApiQueryError`, `EffectHttpApiQueryConfigErrorCode`, and `EffectHttpApiQueryKeyErrorCode` for narrowing and stable codes. From fb541b8f1626158abe7032044d804b12187d8f0d Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Fri, 9 Oct 2026 23:17:41 +0200 Subject: [PATCH 31/97] test: checkpoint native QueryClient defaults recipes --- tests/packed-consumer/defaults-runtime.mts | 219 ++++++++++++++++++++ tests/packed-consumer/docs-defaults.ts | 42 ++++ tests/packed-consumer/docs-http-defaults.ts | 34 +++ 3 files changed, 295 insertions(+) create mode 100644 tests/packed-consumer/defaults-runtime.mts create mode 100644 tests/packed-consumer/docs-defaults.ts create mode 100644 tests/packed-consumer/docs-http-defaults.ts diff --git a/tests/packed-consumer/defaults-runtime.mts b/tests/packed-consumer/defaults-runtime.mts new file mode 100644 index 0000000..97982b3 --- /dev/null +++ b/tests/packed-consumer/defaults-runtime.mts @@ -0,0 +1,219 @@ +import { QueryClient, QueryObserver } from '@tanstack/query-core' +import { Effect, Layer } from 'effect' +import { isEffectRpcQueryError } from 'effect-api-query' +import { HttpServer, HttpServerResponse } from 'effect/http' +import { HttpApiBuilder, HttpApiTest } from 'effect/http-api' +import { RpcTest } from 'effect/rpc' +import { deepStrictEqual, equal, ok, rejects } from 'node:assert/strict' + +import { createApplicationQueries, usersRpc } from './docs-defaults.ts' +import { createHttpApplicationQueries, usersApi } from './docs-http-defaults.ts' + +await Effect.runPromise( + Effect.gen(function* () { + const attempts = new Map() + const client = yield* RpcTest.makeClient(usersRpc, { flatten: true }).pipe( + Effect.provide( + usersRpc.toLayer({ + 'users.get': ({ id }) => { + const attempt = (attempts.get(id) ?? 0) + 1 + attempts.set(id, attempt) + return id === 2 || attempt < 3 + ? Effect.fail({ _tag: 'RetryLater' as const }) + : Effect.succeed({ id, name: 'Ada' }) + }, + }), + ), + ) + const { queryClient, rpc, user, refreshUser } = createApplicationQueries(client) + const observer = new QueryObserver(queryClient, user) + let unsubscribe: () => void = () => undefined + try { + equal(observer.options.staleTime, 120_000) + equal(observer.options.gcTime, 600_000) + equal(observer.options.retry, 2) + unsubscribe = observer.subscribe(() => undefined) + equal(yield* Effect.promise(() => queryClient.query(user)), 'Ada') + equal(attempts.get(1), 3) + equal(observer.getCurrentResult().data, 'Ada') + equal(observer.getCurrentResult().isStale, false) + deepStrictEqual(queryClient.getQueryData(rpc.users.get.queryKey({ id: 1 })), { + id: 1, + name: 'Ada', + }) + queryClient.setQueryData(rpc.users.get.queryKey({ id: 1 }), { id: 1, name: 'Grace' }) + equal(observer.getCurrentResult().data, 'Grace') + equal(yield* Effect.promise(() => queryClient.query(user)), 'Grace') + equal(attempts.get(1), 3) + deepStrictEqual(yield* Effect.promise(() => queryClient.query(refreshUser)), { + id: 1, + name: 'Ada', + }) + equal(attempts.get(1), 4) + const refreshObserver = new QueryObserver(queryClient, refreshUser) + equal(refreshObserver.options.staleTime, 0) + equal(refreshObserver.options.retry, false) + refreshObserver.destroy() + deepStrictEqual( + yield* Effect.promise(() => + queryClient.query(rpc.users.get.queryOptions({ input: { id: 1 }, staleTime: undefined })), + ), + { id: 1, name: 'Ada' }, + ) + equal(attempts.get(1), 5) + yield* Effect.promise(() => + rejects( + queryClient.query(rpc.users.get.queryOptions({ input: { id: 2 }, retry: false })), + (error: unknown) => isEffectRpcQueryError(error), + ), + ) + equal(attempts.get(2), 1) + yield* Effect.promise(() => + rejects( + queryClient.query(rpc.users.get.queryOptions({ input: { id: 2 }, retryDelay: 0 })), + (error: unknown) => isEffectRpcQueryError(error), + ), + ) + equal(attempts.get(2), 4) + } finally { + unsubscribe() + observer.destroy() + queryClient.clear() + } + for (const mode of ['global', 'prefix'] as const) { + let fallbackCalls = 0 + const hashClient = new QueryClient({ + defaultOptions: { + queries: { + gcTime: Infinity, + staleTime: Infinity, + retry: false, + queryKeyHashFn: (key) => `global:${JSON.stringify(key)}`, + }, + }, + }) + hashClient.setQueryDefaults(rpc.key(), { + queryFn: () => { + fallbackCalls += 1 + return Promise.resolve({ id: -1, name: 'fallback' }) + }, + }) + if (mode === 'prefix') + hashClient.setQueryDefaults(rpc.users.key(), { + queryKeyHashFn: (key) => `prefix:${JSON.stringify(key)}`, + }) + const options = rpc.users.get.queryOptions({ input: { id: 1 } }) + const selected = new QueryObserver( + hashClient, + rpc.users.get.queryOptions({ input: { id: 1 }, select: (value) => value.name }), + ) + let stop: () => void = () => undefined + try { + deepStrictEqual(yield* Effect.promise(() => hashClient.query(options)), { + id: 1, + name: 'Ada', + }) + const key = rpc.users.get.queryKey({ id: 1 }) + deepStrictEqual(hashClient.getQueryData(key), { id: 1, name: 'Ada' }) + stop = selected.subscribe(() => undefined) + hashClient.setQueryData(key, { id: 1, name: 'native' }) + equal(selected.getCurrentResult().data, 'native') + deepStrictEqual(yield* Effect.promise(() => hashClient.query(options)), { + id: 1, + name: 'native', + }) + equal(fallbackCalls, 0) + equal(hashClient.getQueryCache().getAll().length, 1) + equal( + hashClient.getQueryCache().getAll()[0]?.queryHash, + `${mode}:["users-app","rpc","users","get","query",{"id":1}]`, + ) + ok(Object.isFrozen(key)) + } finally { + stop() + selected.destroy() + hashClient.clear() + } + } + }).pipe(Effect.scoped), +) + +let httpAttempts = 0 +const httpHandlers = HttpApiBuilder.group(usersApi, 'users', (group) => + group.handleRaw('get', ({ params }) => { + httpAttempts += 1 + return HttpServerResponse.json( + httpAttempts === 1 ? { _tag: 'RetryLater' } : { id: params.id, name: 'Ada' }, + { status: httpAttempts === 1 ? 503 : 200, headers: { etag: '"v1"' } }, + ).pipe(Effect.orDie) + }), +) +await Effect.runPromise( + Effect.gen(function* () { + const client = yield* HttpApiTest.groups(usersApi, ['users']) + const { queryClient, http, user, metadata } = createHttpApplicationQueries(client) + const userObserver = new QueryObserver(queryClient, user) + const metadataObserver = new QueryObserver(queryClient, metadata) + let unsubscribe: () => void = () => undefined + try { + equal(userObserver.options.staleTime, 30_000) + equal(userObserver.options.retry, 1) + equal(metadataObserver.options.staleTime, 5_000) + equal(metadataObserver.options.retry, 1) + deepStrictEqual(yield* Effect.promise(() => queryClient.query(user)), { + id: 1, + name: 'Ada', + }) + equal(httpAttempts, 2) + const snapshot = yield* Effect.promise(() => queryClient.query(metadata)) + deepStrictEqual(snapshot.data, { id: 1, name: 'Ada' }) + equal(snapshot.status, 200) + equal(snapshot.headers['etag'], '"v1"') + equal(httpAttempts, 3) + ok(Object.isFrozen(snapshot)) + ok(Object.isFrozen(snapshot.headers)) + equal(queryClient.getQueryData(http.users.get.metadataKey({ params: { id: 1 } })), snapshot) + unsubscribe = userObserver.subscribe(() => undefined) + queryClient.setQueryData(http.users.get.queryKey({ params: { id: 1 } }), { + id: 1, + name: 'Grace', + }) + deepStrictEqual(userObserver.getCurrentResult().data, { id: 1, name: 'Grace' }) + deepStrictEqual(yield* Effect.promise(() => queryClient.query(user)), { + id: 1, + name: 'Grace', + }) + equal(yield* Effect.promise(() => queryClient.query(metadata)), snapshot) + equal(httpAttempts, 3) + const fresh = http.users.get.queryOptions({ + input: { params: { id: 1 } }, + staleTime: 0, + retry: false, + }) + deepStrictEqual(yield* Effect.promise(() => queryClient.query(fresh)), { + id: 1, + name: 'Ada', + }) + equal(httpAttempts, 4) + equal(queryClient.getQueryCache().getAll().length, 2) + deepStrictEqual( + queryClient + .getQueryCache() + .getAll() + .map((query) => query.queryHash), + [ + 'users:["users-app","http","users","users","get","query",{"params":{"id":"1"}}]', + 'users:["users-app","http","users","users","get","metadata",{"params":{"id":"1"}}]', + ], + ) + } finally { + unsubscribe() + userObserver.destroy() + metadataObserver.destroy() + queryClient.clear() + } + }).pipe(Effect.provide(Layer.mergeAll(httpHandlers, HttpServer.layerServices)), Effect.scoped), +) +console.log( + 'Packed RPC/HTTP defaults, precedence, observed values, retries and native hashing executed', +) diff --git a/tests/packed-consumer/docs-defaults.ts b/tests/packed-consumer/docs-defaults.ts new file mode 100644 index 0000000..9132b09 --- /dev/null +++ b/tests/packed-consumer/docs-defaults.ts @@ -0,0 +1,42 @@ +import { QueryClient } from '@tanstack/query-core' +import { Schema } from 'effect' +import { createRpcQueryUtils } from 'effect-api-query' +import { Rpc, RpcGroup } from 'effect/rpc' +import type { RpcClient } from 'effect/rpc' + +export const usersRpc = RpcGroup.make( + Rpc.make('users.get', { + payload: { id: Schema.Int }, + success: Schema.Struct({ id: Schema.Int, name: Schema.String }), + error: Schema.TaggedStruct('RetryLater', {}), + }), +) + +export const createApplicationQueries = ( + client: RpcClient.RpcClient.Flat, ClientError>, +) => { + const rpc = createRpcQueryUtils(usersRpc, { + client, + keyPrefix: ['users-app'], + }) + const queryClient = new QueryClient({ + defaultOptions: { + queries: { staleTime: 30_000, gcTime: 600_000, retry: false, retryDelay: 250 }, + mutations: { retry: false }, + }, + }) + queryClient.setQueryDefaults(rpc.key(), { staleTime: 60_000 }) + queryClient.setQueryDefaults(rpc.users.key(), { retry: 2 }) + queryClient.setQueryDefaults([...rpc.users.get.key(), 'query'], { staleTime: 120_000 }) + + const user = rpc.users.get.queryOptions({ + input: { id: 1 }, + select: (value) => value.name, + }) + const refreshUser = rpc.users.get.queryOptions({ + input: { id: 1 }, + staleTime: 0, + retry: false, + }) + return { queryClient, rpc, user, refreshUser } +} diff --git a/tests/packed-consumer/docs-http-defaults.ts b/tests/packed-consumer/docs-http-defaults.ts new file mode 100644 index 0000000..c44c50a --- /dev/null +++ b/tests/packed-consumer/docs-http-defaults.ts @@ -0,0 +1,34 @@ +import { QueryClient } from '@tanstack/query-core' +import { Schema } from 'effect' +import { createHttpApiQueryUtils } from 'effect-api-query' +import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from 'effect/http-api' +import type { HttpApiClient } from 'effect/http-api' + +export const usersApi = HttpApi.make('users').add( + HttpApiGroup.make('users').add( + HttpApiEndpoint.get('get', '/users/:id', { + params: { id: Schema.Int }, + success: Schema.Struct({ id: Schema.Int, name: Schema.String }), + error: Schema.TaggedStruct('RetryLater', {}).pipe(HttpApiSchema.status(503)), + }), + ), +) + +export const createHttpApplicationQueries = (client: HttpApiClient.ForApi) => { + const http = createHttpApiQueryUtils(usersApi, { client, keyPrefix: ['users-app'] }) + const queryClient = new QueryClient({ + defaultOptions: { + queries: { staleTime: 30_000, retry: false, retryDelay: 250 }, + }, + }) + queryClient.setQueryDefaults(http.key(), { + queryKeyHashFn: (key) => `users:${JSON.stringify(key)}`, + }) + queryClient.setQueryDefaults(http.users.key(), { retry: 1 }) + queryClient.setQueryDefaults([...http.users.get.key(), 'metadata'], { staleTime: 5_000 }) + + const input = { params: { id: 1 } } + const user = http.users.get.queryOptions({ input }) + const metadata = http.users.get.metadataOptions({ input }) + return { queryClient, http, user, metadata } +} From a3774e80b266308cb6b8a224c48e19ddda70a710 Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Fri, 9 Oct 2026 23:19:08 +0200 Subject: [PATCH 32/97] wip: save stream snapshot pause checkpoint --- .fallowrc.jsonc | 4 +- examples/tanstack-start/src/lib/query-ssr.ts | 35 +- scripts/verify-packed-consumer.mts | 1 + src/core/stream-snapshot.ts | 119 +++++++ src/core/streamed-query.ts | 2 +- src/index.ts | 2 + tests/packed-consumer/docs-stream-snapshot.ts | 27 ++ tests/packed-consumer/runtime.mts | 1 + tests/packed-consumer/snapshot-contract.ts | 42 +++ tests/packed-consumer/snapshot-runtime.mts | 308 ++++++++++++++++++ tests/packed-consumer/tsconfig.json | 3 + vite.config.ts | 4 + 12 files changed, 513 insertions(+), 35 deletions(-) create mode 100644 src/core/stream-snapshot.ts create mode 100644 tests/packed-consumer/docs-stream-snapshot.ts create mode 100644 tests/packed-consumer/snapshot-contract.ts create mode 100644 tests/packed-consumer/snapshot-runtime.mts diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index f7c62ea..ed9cd7c 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -10,7 +10,9 @@ "tests/packed-consumer/http-stream-runtime.mts", "tests/packed-consumer/stream-cause-runtime.mts", "tests/packed-consumer/stream-policy-runtime.mts", - "tests/packed-consumer/retry-runtime.mts" + "tests/packed-consumer/retry-runtime.mts", + "tests/packed-consumer/snapshot-runtime.mts", + "tests/packed-consumer/snapshot-contract.ts" ], "ignorePatterns": ["examples/tanstack-start/src/routeTree.gen.ts"], diff --git a/examples/tanstack-start/src/lib/query-ssr.ts b/examples/tanstack-start/src/lib/query-ssr.ts index fc3b5de..13a4e18 100644 --- a/examples/tanstack-start/src/lib/query-ssr.ts +++ b/examples/tanstack-start/src/lib/query-ssr.ts @@ -1,40 +1,9 @@ import { defaultShouldDehydrateQuery } from '@tanstack/react-query' -import type { QueryExecuteOptions, QueryClient, QueryKey } from '@tanstack/react-query' +import type { QueryClient } from '@tanstack/react-query' import type { AnyRouter } from '@tanstack/react-router' import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query' -/** - * Captures the first successful cache snapshot from an open stream, then interrupts its server - * subscription so route loading and dehydration can finish. - */ -export const fetchStreamSnapshot = async ( - queryClient: QueryClient, - options: QueryExecuteOptions, -): Promise => { - // Start first so an earlier cached error or reset snapshot cannot settle this request. - const fetching = queryClient.query(options) - let stopWatching: (() => void) | undefined - // The callback/event API needs a Promise bridge in the ES2022 target. - // oxlint-disable-next-line promise/avoid-new - const snapshotReady = new Promise((resolve) => { - const inspect = () => { - const state = queryClient.getQueryState(options.queryKey) - if (state?.status === 'success') { - resolve() - } - } - stopWatching = queryClient.getQueryCache().subscribe(inspect) - inspect() - }) - try { - // Cancellation can restore pending state; the fetch promise owns terminal failures. - await Promise.race([snapshotReady, fetching]) - await queryClient.cancelQueries({ exact: true, queryKey: options.queryKey }) - return await fetching - } finally { - stopWatching?.() - } -} +export { fetchStreamSnapshot } from 'effect-api-query' /** Configures QueryClient hydration, including Effect Schema class serialization. */ export const setupQuerySsr = (router: AnyRouter, queryClient: QueryClient): void => { diff --git a/scripts/verify-packed-consumer.mts b/scripts/verify-packed-consumer.mts index d09646f..e8a3476 100644 --- a/scripts/verify-packed-consumer.mts +++ b/scripts/verify-packed-consumer.mts @@ -511,6 +511,7 @@ const verifyConsumer = (peer: (typeof peerCases)[number]): void => { 'stream-cause-runtime.mts', 'stream-policy-runtime.mts', 'retry-runtime.mts', + 'snapshot-runtime.mts', ]) { execFileSync(process.execPath, ['--experimental-import-meta-resolve', fixture], { cwd: consumerDirectory, diff --git a/src/core/stream-snapshot.ts b/src/core/stream-snapshot.ts new file mode 100644 index 0000000..1c58ea1 --- /dev/null +++ b/src/core/stream-snapshot.ts @@ -0,0 +1,119 @@ +import { CancelledError } from '@tanstack/query-core' +import type { + DefaultError, + QueryClient, + QueryExecuteOptions, + QueryFunction, + QueryKey, +} from '@tanstack/query-core' + +export interface StreamSnapshotOptions { + readonly mode?: 'fresh' | 'cached' + readonly signal?: AbortSignal + readonly timeoutMs?: number +} + +export const fetchStreamSnapshot = async < + TQueryFnData, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +>( + queryClient: QueryClient, + options: QueryExecuteOptions & { + readonly queryFn: QueryFunction + }, + { mode = 'fresh', signal, timeoutMs = 10_000 }: StreamSnapshotOptions = {}, +): Promise => { + signal?.throwIfAborted() + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647) { + throw new RangeError('timeoutMs must be positive and at most 2147483647') + } + const cache = queryClient.getQueryCache() + const existing = cache.find({ exact: true, queryKey: options.queryKey }) + if (mode === 'cached' && existing?.state.status === 'success') { + return await queryClient.query({ ...options, staleTime: 'static' }) + } + if ( + existing !== undefined && + (existing.state.fetchStatus !== 'idle' || existing.getObserversCount() > 0) + ) { + throw new Error('Stream snapshots require an idle query without observers') + } + const queryHash = queryClient.defaultQueryOptions(options).queryHash + let published = false + let started = false + let executionSignal: AbortSignal | undefined + let failed = false + let failure: unknown + let cleanup = Promise.resolve() + let cancellation: Promise | undefined + const cancel = () => { + cancellation ??= queryClient.cancelQueries({ exact: true, queryKey: options.queryKey }) + return cancellation + } + let ready!: () => void + let reject!: (error: unknown) => void + const publication = new Promise((resolve, rejectPublication) => { + ready = resolve + reject = rejectPublication + }) + const stop = cache.subscribe((event) => { + if ( + event.query.queryHash === queryHash && + event.type === 'updated' && + event.action.type === 'success' && + event.action.manual === true + ) { + published = true + ready() + void cancel() + } + }) + const onAbort = () => reject(signal?.reason) + signal?.addEventListener('abort', onAbort, { once: true }) + const timer = setTimeout(() => { + reject(new DOMException('Stream snapshot timed out', 'TimeoutError')) + }, timeoutMs) + let result: TData + try { + const fetching = queryClient.query({ + ...options, + staleTime: 0, + queryFn: async (context) => { + started = true + failed = false + failure = undefined + executionSignal = context.signal + let finished!: () => void + cleanup = new Promise((resolve) => { + finished = resolve + }) + try { + return await options.queryFn(context) + } catch (error) { + failed = true + failure = error + throw error + } finally { + finished() + } + }, + }) + await Promise.race([publication, fetching]) + result = await fetching + if (!published && (!started || executionSignal?.aborted === true)) { + throw new CancelledError({ revert: true }) + } + } finally { + stop() + clearTimeout(timer) + signal?.removeEventListener('abort', onAbort) + await cancel() + await cleanup + } + if (failed) { + throw failure + } + return result +} diff --git a/src/core/streamed-query.ts b/src/core/streamed-query.ts index b4f5f92..9c8f265 100644 --- a/src/core/streamed-query.ts +++ b/src/core/streamed-query.ts @@ -46,7 +46,7 @@ const streamQueryIterable = ( } const onAbort = () => { // oxlint-disable-next-line promise/prefer-await-to-then - void close().catch(detach) + void Promise.resolve().then(close).catch(detach) } signal.addEventListener('abort', onAbort, { once: true }) diff --git a/src/index.ts b/src/index.ts index 3c4358b..e98a066 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,8 @@ export { skipToken } from '@tanstack/query-core' export { createRpcQueryUtils } from './rpc/create-utils' /** Creates the public HTTP utility tree. @api public */ export { createHttpApiQueryUtils } from './http/create-utils' +export { fetchStreamSnapshot } from './core/stream-snapshot' +export type { StreamSnapshotOptions } from './core/stream-snapshot' export { EffectHttpApiQueryConfigError, EffectHttpApiQueryError, diff --git a/tests/packed-consumer/docs-stream-snapshot.ts b/tests/packed-consumer/docs-stream-snapshot.ts new file mode 100644 index 0000000..6aa856a --- /dev/null +++ b/tests/packed-consumer/docs-stream-snapshot.ts @@ -0,0 +1,27 @@ +import type { QueryClient } from '@tanstack/query-core' +import { Schema } from 'effect' +import { createRpcQueryUtils, fetchStreamSnapshot } from 'effect-api-query' +import { Rpc, RpcGroup } from 'effect/rpc' +import type { RpcClient } from 'effect/rpc' + +export const updates = RpcGroup.make(Rpc.make('updates.watch', { + payload: { channel: Schema.String }, success: Schema.String, stream: true, +})) + +export async function captureUpdates( + queryClient: QueryClient, + client: RpcClient.RpcClient.Flat, E>, + signal: AbortSignal, +) { + const rpc = createRpcQueryUtils(updates, { + client, keyPrefix: ['updates'], + }) + const input = { channel: 'announcements' } + const history = await fetchStreamSnapshot(queryClient, rpc.updates.watch.streamedOptions({ + input, retry: false, maxChunks: 20, + }), { signal, timeoutMs: 5_000 }) + const latest = await fetchStreamSnapshot(queryClient, rpc.updates.watch.liveOptions({ + input, retry: false, + }), { signal, timeoutMs: 5_000 }) + return { history, latest } +} diff --git a/tests/packed-consumer/runtime.mts b/tests/packed-consumer/runtime.mts index 3650d8b..c8efb4b 100644 --- a/tests/packed-consumer/runtime.mts +++ b/tests/packed-consumer/runtime.mts @@ -39,6 +39,7 @@ const expectedExports = [ 'EffectRpcQueryKeyError', 'createHttpApiQueryUtils', 'createRpcQueryUtils', + 'fetchStreamSnapshot', 'isEffectHttpApiQueryError', 'isEffectRpcQueryError', 'skipToken', diff --git a/tests/packed-consumer/snapshot-contract.ts b/tests/packed-consumer/snapshot-contract.ts new file mode 100644 index 0000000..b8cf533 --- /dev/null +++ b/tests/packed-consumer/snapshot-contract.ts @@ -0,0 +1,42 @@ +import type { QueryClient, QueryExecuteOptions } from '@tanstack/query-core' +import { Schema } from 'effect' +import { createHttpApiQueryUtils, createRpcQueryUtils, fetchStreamSnapshot, skipToken } from 'effect-api-query' +import type { StreamSnapshotOptions } from 'effect-api-query' +import { Rpc, RpcGroup } from 'effect/rpc' +import type { RpcClient } from 'effect/rpc' +import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from 'effect/http-api' +import type { HttpApiClient } from 'effect/http-api' + +const group = RpcGroup.make( + Rpc.make('watch', { payload: { channel: Schema.String }, success: Schema.UndefinedOr(Schema.Int), stream: true }), +) +declare const client: RpcClient.RpcClient.Flat> +declare const queryClient: QueryClient +const utils = createRpcQueryUtils(group, { client, keyPrefix: ['snapshot-types'] }) +const streamed = utils.watch.streamedOptions({ input: { channel: 'owned' } }) +const live = utils.watch.liveOptions({ input: { channel: 'owned' } }) + +fetchStreamSnapshot(queryClient, streamed) satisfies Promise +fetchStreamSnapshot(queryClient, live) satisfies Promise +fetchStreamSnapshot(queryClient, { ...streamed, select: (values) => values.length }) satisfies Promise +fetchStreamSnapshot(queryClient, { ...live, select: (value) => value === null ? 'empty' : `${value}` }) satisfies Promise +fetchStreamSnapshot(queryClient, streamed, { mode: 'cached', signal: new AbortController().signal, timeoutMs: 500 }) satisfies Promise +const controls = { mode: 'fresh', timeoutMs: 1_000 } as const satisfies StreamSnapshotOptions +fetchStreamSnapshot(queryClient, live, controls) + +declare const native: QueryExecuteOptions & { queryFn: () => Promise } +fetchStreamSnapshot(queryClient, native) satisfies Promise +const api = HttpApi.make('snapshot-types').add(HttpApiGroup.make('events').add( + HttpApiEndpoint.get('watch', '/watch', { success: HttpApiSchema.StreamSse({ data: Schema.Int }) }), +)) +declare const httpClient: HttpApiClient.ForApi +const http = createHttpApiQueryUtils(api, { client: httpClient, keyPrefix: ['snapshot-types'] }) +fetchStreamSnapshot(queryClient, http.events.watch.streamedOptions()) satisfies Promise +// @ts-expect-error Skipped options cannot own a callable query function +fetchStreamSnapshot(queryClient, utils.watch.liveOptions({ input: skipToken })) +// @ts-expect-error A snapshot requires a supplied callable query function +fetchStreamSnapshot(queryClient, { queryKey: ['missing'] }) +// @ts-expect-error Infinite page execution uses a different cache shape and lifecycle +fetchStreamSnapshot(queryClient, { ...streamed, initialPageParam: 0 }) +// @ts-expect-error Snapshot mode is explicit +fetchStreamSnapshot(queryClient, live, { mode: 'join' }) diff --git a/tests/packed-consumer/snapshot-runtime.mts b/tests/packed-consumer/snapshot-runtime.mts new file mode 100644 index 0000000..6404bc0 --- /dev/null +++ b/tests/packed-consumer/snapshot-runtime.mts @@ -0,0 +1,308 @@ +import { dehydrate, hydrate, QueryClient, QueryObserver, isCancelledError } from '@tanstack/query-core' +import { Deferred, Effect, Exit, Schema, Scope, Stream } from 'effect' +import { createHttpApiQueryUtils, createRpcQueryUtils, fetchStreamSnapshot, isEffectRpcQueryError } from 'effect-api-query' +import type { RunPromiseExit } from 'effect-api-query' +import { Rpc, RpcGroup, RpcTest } from 'effect/rpc' +import { HttpServer } from 'effect/http' +import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, HttpApiTest } from 'effect/http-api' +import { deepStrictEqual, equal, ok, rejects } from 'node:assert/strict' +import { getEventListeners } from 'node:events' + +const group = RpcGroup.make(Rpc.make('watch', { + payload: { channel: Schema.String }, success: Schema.Int, error: Schema.String, stream: true, +})) +const api = HttpApi.make('snapshot-http').add(HttpApiGroup.make('events').add( + HttpApiEndpoint.get('watch', '/watch', { success: HttpApiSchema.StreamSse({ data: Schema.Int }) }), +)) + +await Effect.runPromise(Effect.scoped(Effect.gen(function* () { + const client = yield* HttpApiTest.groups(api, ['events']).pipe( + Effect.provide(HttpApiBuilder.group(api, 'events', (handlers) => + handlers.handle('watch', () => Effect.succeed(Stream.succeed(1).pipe(Stream.concat(Stream.fromEffect(Effect.never))))), + )), + ) + const utils = createHttpApiQueryUtils(api, { client, keyPrefix: ['snapshot-http'] }) + const queryClient = new QueryClient() + deepStrictEqual(yield* Effect.promise(() => fetchStreamSnapshot(queryClient, utils.events.watch.streamedOptions({ retry: false }))), [1]) + equal(queryClient.isFetching(), 0) + equal(queryClient.getQueryCache().hasListeners(), false) + queryClient.clear() +})).pipe(Effect.provide(HttpServer.layerServices))) + +await Effect.runPromise(Effect.scoped(Effect.gen(function* () { + for (const view of ['streamed', 'live'] as const) { + for (const path of ['success', 'timeout', 'abort', 'external cancel', 'failure', 'empty', 'complete'] as const) { + const entered = yield* Deferred.make() + const closing = yield* Deferred.make() + const release = yield* Deferred.make() + let finalized = false + const client = yield* RpcTest.makeClient(group, { flatten: true }).pipe( + Effect.provide(group.toLayer({ watch: () => Stream.succeed(1) })), + ) + const source = path === 'failure' ? Stream.fail('unavailable') + : path === 'empty' ? Stream.empty + : path === 'complete' ? Stream.make(1, 2) + : path === 'success' ? Stream.succeed(1).pipe(Stream.concat(Stream.fromEffect(Effect.never))) + : Stream.fromEffect(Deferred.succeed(entered, undefined).pipe(Effect.andThen(Effect.never))) + const ready: typeof client = (...args) => client(...args).pipe( + Stream.flatMap(() => source), + Stream.ensuring(Deferred.succeed(closing, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.andThen(Effect.sync(() => { finalized = true })), + )), + ) + const options = createRpcQueryUtils(group, { client: ready, keyPrefix: ['snapshot', path] }).watch + const queryClient = new QueryClient() + const abort = new AbortController() + const reason = new Error('page aborted') + let settled = false + const nativeOptions = view === 'streamed' + ? options.streamedOptions({ input: { channel: 'owned' }, retry: false }) + : options.liveOptions({ input: { channel: 'owned' }, retry: false }) + const queryKey = nativeOptions.queryKey + const snapshot = view === 'streamed' + ? fetchStreamSnapshot(queryClient, options.streamedOptions({ input: { channel: 'owned' }, retry: false }), { signal: abort.signal, timeoutMs: path === 'timeout' ? 10 : 1_000 }) + : fetchStreamSnapshot(queryClient, options.liveOptions({ input: { channel: 'owned' }, retry: false }), { signal: abort.signal, timeoutMs: path === 'timeout' ? 10 : 1_000 }) + const outcome = snapshot.then( + (data) => { settled = true; return { status: 'success' as const, data } }, + (error: unknown) => { settled = true; return { status: 'failure' as const, error } }, + ) + if (path === 'abort' || path === 'external cancel') { + yield* Deferred.await(entered) + if (path === 'abort') abort.abort(reason) + else yield* Effect.promise(() => queryClient.cancelQueries({ exact: true, queryKey })) + } + yield* Deferred.await(closing) + equal(settled, false) + equal(finalized, false) + yield* Deferred.succeed(release, undefined) + const result = yield* Effect.promise(() => outcome) + equal(finalized, true) + equal(queryClient.getQueryCache().hasListeners(), false) + equal(queryClient.isFetching(), 0) + equal(getEventListeners(abort.signal, 'abort').length, 0) + if (path === 'timeout') { + equal(result.status, 'failure') + if (result.status === 'failure') { + ok(result.error instanceof DOMException) + equal(result.error.name, 'TimeoutError') + } + } else if (path === 'abort') { + equal(result.status, 'failure') + if (result.status === 'failure') equal(result.error, reason) + } else if (path === 'external cancel') { + equal(result.status, 'failure') + if (result.status === 'failure') ok(isCancelledError(result.error)) + } else if (path === 'failure' || (path === 'empty' && view === 'live')) { + equal(result.status, 'failure') + if (result.status === 'failure') { + if (path === 'failure') ok(isEffectRpcQueryError(result.error)) + else ok(result.error instanceof Error && result.error.name === 'EffectRpcQueryEmptyStreamError') + } + } else { + equal(result.status, 'success') + if (result.status === 'success') deepStrictEqual(result.data, view === 'streamed' ? path === 'empty' ? [] : [1] : 1) + } + abort.abort(new Error('late abort')) + equal(queryClient.getQueryCache().hasListeners(), false) + queryClient.clear() + } + } +}))) + +await Effect.runPromise(Effect.scoped(Effect.gen(function* () { + const acquiring = yield* Deferred.make() + const allow = yield* Deferred.make() + let acquisitionFinalized = false + let reads = 0 + const client = yield* RpcTest.makeClient(group, { flatten: true }).pipe( + Effect.provide(group.toLayer({ watch: () => Stream.fromEffect(Effect.sync(() => { reads += 1; return 1 })) })), + ) + const runPromiseExit: RunPromiseExit = (effect, options) => Effect.runPromiseExit( + Deferred.succeed(acquiring, undefined).pipe( + Effect.andThen(Deferred.await(allow)), + Effect.andThen(effect), + Effect.ensuring(Effect.sync(() => { acquisitionFinalized = true })), + ), options, + ) + const options = createRpcQueryUtils(group, { client, keyPrefix: ['acquisition'], runPromiseExit }).watch.liveOptions({ input: { channel: 'owned' }, retry: false }) + const queryClient = new QueryClient() + const before = AbortSignal.abort(new Error('already aborted')) + yield* Effect.promise(() => rejects(fetchStreamSnapshot(queryClient, options, { signal: before }), (error: unknown) => error === before.reason)) + equal(queryClient.getQueryCache().getAll().length, 0) + const abort = new AbortController() + const reason = new Error('abort acquisition') + const outcome = rejects(fetchStreamSnapshot(queryClient, options, { signal: abort.signal }), (error: unknown) => error === reason) + yield* Deferred.await(acquiring) + abort.abort(reason) + yield* Effect.promise(() => outcome) + equal(acquisitionFinalized, true) + equal(reads, 0) + equal(getEventListeners(abort.signal, 'abort').length, 0) + equal(queryClient.getQueryCache().hasListeners(), false) + queryClient.clear() +}))) + +await Effect.runPromise(Effect.scoped(Effect.gen(function* () { + let ownedClosed = false + let otherClosed = false + const started = yield* Deferred.make() + const client = yield* RpcTest.makeClient(group, { flatten: true }).pipe( + Effect.provide(group.toLayer({ watch: ({ channel }) => Stream.succeed(channel === 'owned' ? 1 : 2).pipe(Stream.concat(Stream.fromEffect(Effect.never))) })), + ) + const ready: typeof client = (...args) => client(...args).pipe( + Stream.ensuring(Effect.sync(() => { if (args[1].channel === 'owned') ownedClosed = true; else otherClosed = true })), + ) + const utils = createRpcQueryUtils(group, { client: ready, keyPrefix: ['exact'] }) + const queryClient = new QueryClient() + const other = utils.watch.liveOptions({ input: { channel: 'other' }, retry: false }) + const stop = queryClient.getQueryCache().subscribe(() => { + if (queryClient.getQueryData(other.queryKey) === 2) Effect.runFork(Deferred.succeed(started, undefined)) + }) + const fetching = queryClient.query(other) + yield* Deferred.await(started) + stop() + const owned = utils.watch.liveOptions({ input: { channel: 'owned' }, retry: false }) + equal(yield* Effect.promise(() => fetchStreamSnapshot(queryClient, owned)), 1) + equal(ownedClosed, true) + equal(otherClosed, false) + equal(queryClient.getQueryState(other.queryKey)?.fetchStatus, 'fetching') + equal(queryClient.getQueryData(other.queryKey), 2) + yield* Effect.promise(() => queryClient.cancelQueries({ exact: true, queryKey: other.queryKey })) + yield* Effect.promise(() => fetching) + equal(queryClient.getQueryCache().hasListeners(), false) + queryClient.clear() +}))) + +await Effect.runPromise(Effect.scoped(Effect.gen(function* () { + const serverScope = yield* Scope.make() + let serverClosed = false + let browserClosed = false + let browserReads = 0 + const server = yield* RpcTest.makeClient(group, { flatten: true }).pipe( + Effect.provide(group.toLayer({ watch: () => Stream.succeed(1).pipe(Stream.concat(Stream.fromEffect(Effect.never))) })), + Effect.provideService(Scope.Scope, serverScope), + ) + const browser = yield* RpcTest.makeClient(group, { flatten: true }).pipe( + Effect.provide(group.toLayer({ watch: () => Stream.fromEffect(Effect.sync(() => { browserReads += 1; return 2 })).pipe(Stream.concat(Stream.fromEffect(Effect.never))) })), + ) + const serverReady: typeof server = (...args) => server(...args).pipe(Stream.ensuring(Effect.sync(() => { serverClosed = true }))) + const browserReady: typeof browser = (...args) => browser(...args).pipe(Stream.ensuring(Effect.sync(() => { browserClosed = true }))) + const serverOptions = createRpcQueryUtils(group, { client: serverReady, keyPrefix: ['hydrate'] }).watch.streamedOptions({ input: { channel: 'owned' }, retry: false }) + const browserOptions = createRpcQueryUtils(group, { client: browserReady, keyPrefix: ['hydrate'] }).watch.streamedOptions({ input: { channel: 'owned' }, retry: false }) + const serverQueryClient = new QueryClient() + const browserQueryClient = new QueryClient() + deepStrictEqual(yield* Effect.promise(() => fetchStreamSnapshot(serverQueryClient, serverOptions)), [1]) + equal(serverClosed, true) + const json = JSON.stringify(dehydrate(serverQueryClient)) + serverQueryClient.clear() + yield* Scope.close(serverScope, Exit.void) + hydrate(browserQueryClient, JSON.parse(json)) + deepStrictEqual(yield* Effect.promise(() => fetchStreamSnapshot(browserQueryClient, browserOptions, { mode: 'cached' })), [1]) + equal(browserReads, 0) + deepStrictEqual(yield* Effect.promise(() => fetchStreamSnapshot(browserQueryClient, browserOptions)), [2]) + equal(browserReads, 1) + equal(browserClosed, true) + browserQueryClient.clear() +}))) + +await Effect.runPromise(Effect.scoped(Effect.gen(function* () { + let attempts = 0 + const client = yield* RpcTest.makeClient(group, { flatten: true }).pipe( + Effect.provide(group.toLayer({ watch: () => Stream.suspend(() => { + attempts += 1 + return attempts === 1 ? Stream.fail('try again') : Stream.make(1) + }) })), + ) + const options = createRpcQueryUtils(group, { client, keyPrefix: ['retry-snapshot'] }).watch.liveOptions({ input: { channel: 'owned' }, retry: 1, retryDelay: 0 }) + const queryClient = new QueryClient() + equal(yield* Effect.promise(() => fetchStreamSnapshot(queryClient, options)), 1) + equal(attempts, 2) + queryClient.clear() +}))) + +await Effect.runPromise(Effect.scoped(Effect.gen(function* () { + for (const policy of ['reset', 'append', 'replace', 'live'] as const) { + const entered = yield* Deferred.make() + const emit = yield* Deferred.make() + const emitted = yield* Deferred.make() + let finalized = false + const client = yield* RpcTest.makeClient(group, { flatten: true }).pipe( + Effect.provide(group.toLayer({ watch: () => Stream.succeed(1) })), + ) + const ready: typeof client = (...args) => client(...args).pipe( + Stream.flatMap(() => Stream.fromEffect( + Deferred.succeed(entered, undefined).pipe( + Effect.andThen(Deferred.await(emit)), + Effect.andThen(Deferred.succeed(emitted, undefined)), + Effect.as(1), + ), + )), + Stream.concat(Stream.fromEffect(Effect.never)), + Stream.ensuring(Effect.sync(() => { finalized = true })), + ) + const utils = createRpcQueryUtils(group, { client: ready, keyPrefix: ['cached', policy] }) + const queryClient = new QueryClient() + let settled = false + const input = { channel: 'owned' } + const streamed = utils.watch.streamedOptions({ input, refetchMode: policy === 'live' ? 'reset' : policy, retry: false, staleTime: 60_000 }) + const live = utils.watch.liveOptions({ input, retry: false, staleTime: 60_000 }) + const queryKey = policy === 'live' ? live.queryKey : streamed.queryKey + if (policy === 'live') queryClient.setQueryData(live.queryKey, 1) + else queryClient.setQueryData(streamed.queryKey, [0]) + const fetching = policy === 'live' + ? fetchStreamSnapshot(queryClient, live, { timeoutMs: 100 }) + : fetchStreamSnapshot(queryClient, streamed, { timeoutMs: 100 }) + const outcome = fetching.then( + (value) => { settled = true; return { status: 'success' as const, value } }, + (error: unknown) => { settled = true; return { status: 'failure' as const, error } }, + ) + yield* Deferred.await(entered) + equal(settled, false) + yield* Effect.promise(() => rejects( + policy === 'live' ? fetchStreamSnapshot(queryClient, live) : fetchStreamSnapshot(queryClient, streamed), + /idle query without observers/, + )) + if (policy === 'append' || policy === 'replace') { + deepStrictEqual(yield* Effect.promise(() => fetchStreamSnapshot(queryClient, streamed, { mode: 'cached' })), [0]) + equal(finalized, false) + } + yield* Deferred.succeed(emit, undefined) + yield* Deferred.await(emitted) + const result = yield* Effect.promise(() => outcome) + equal(finalized, true) + equal(queryClient.getQueryCache().hasListeners(), false) + if (policy === 'replace') { + equal(result.status, 'failure') + if (result.status === 'failure') ok(result.error instanceof DOMException && result.error.name === 'TimeoutError') + deepStrictEqual(queryClient.getQueryData(queryKey), [0]) + } else { + equal(result.status, 'success') + if (result.status === 'success') deepStrictEqual(result.value, policy === 'live' ? 1 : policy === 'append' ? [0, 1] : [1]) + } + queryClient.clear() + } +}))) + +await Effect.runPromise(Effect.scoped(Effect.gen(function* () { + const client = yield* RpcTest.makeClient(group, { flatten: true }).pipe( + Effect.provide(group.toLayer({ watch: () => Stream.make(1, 2) })), + ) + const options = createRpcQueryUtils(group, { client, keyPrefix: ['completed'] }).watch.streamedOptions({ + input: { channel: 'owned' }, refetchMode: 'replace', retry: false, + }) + const queryClient = new QueryClient() + queryClient.setQueryData(options.queryKey, [0]) + deepStrictEqual(yield* Effect.promise(() => fetchStreamSnapshot(queryClient, options)), [1, 2]) + queryClient.setQueryDefaults(options.queryKey, { select: (data) => `items:${Array.isArray(data) ? data.length : 0}` }) + equal(yield* Effect.promise(() => fetchStreamSnapshot(queryClient, options, { mode: 'cached' })), 'items:2') + equal(yield* Effect.promise(() => fetchStreamSnapshot(queryClient, { ...options, select: (values) => values.length })), 2) + deepStrictEqual(queryClient.getQueryData(options.queryKey), [1, 2]) + const observer = new QueryObserver(queryClient, { ...options, enabled: false }) + const unsubscribe = observer.subscribe(() => {}) + yield* Effect.promise(() => rejects(fetchStreamSnapshot(queryClient, options), /idle query without observers/)) + equal(yield* Effect.promise(() => fetchStreamSnapshot(queryClient, { ...options, select: (values) => values.length }, { mode: 'cached' })), 2) + unsubscribe() + equal(queryClient.getQueryCache().hasListeners(), false) + queryClient.clear() +}))) diff --git a/tests/packed-consumer/tsconfig.json b/tests/packed-consumer/tsconfig.json index 27c5028..2a5d828 100644 --- a/tests/packed-consumer/tsconfig.json +++ b/tests/packed-consumer/tsconfig.json @@ -18,6 +18,8 @@ "stream-cause-runtime.mts", "stream-policy-runtime.mts", "retry-runtime.mts", + "snapshot-runtime.mts", + "snapshot-contract.ts", "http-runtime.mts", "http-stream-runtime.mts", "http-stream-contract.ts", @@ -26,6 +28,7 @@ "docs-rpc-quick-start.ts", "docs-http-quick-start.ts", "docs-retry.ts", + "docs-stream-snapshot.ts", "skill-example-*.ts" ] } diff --git a/vite.config.ts b/vite.config.ts index 4dc1bfd..3693136 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -124,6 +124,10 @@ export default defineConfig({ 'effecttsgo/unstable-api-usage': 'error', }, overrides: [ + { + files: ['src/core/stream-snapshot.ts'], + rules: { 'promise/avoid-new': 'off' }, + }, { files: ['examples/**/*.{ts,tsx}'], ...ultraciteReact, From f2f8108c693252901d757a72e41541d23d69aa5f Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Sat, 10 Oct 2026 11:08:50 +0200 Subject: [PATCH 33/97] docs: complete native QueryClient defaults recipes --- .fallowrc.jsonc | 2 + apps/docs/astro.config.mjs | 1 + .../src/content/docs/guides/query-defaults.md | 148 ++++++++++++++++++ scripts/verify-docs-examples.mts | 4 + scripts/verify-packed-consumer.mts | 1 + tests/packed-consumer/defaults-contract.ts | 116 ++++++++++++++ tests/packed-consumer/defaults-runtime.mts | 7 - tests/packed-consumer/tsconfig.json | 4 + 8 files changed, 276 insertions(+), 7 deletions(-) create mode 100644 apps/docs/src/content/docs/guides/query-defaults.md create mode 100644 tests/packed-consumer/defaults-contract.ts diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 14fb801..57fb920 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -14,6 +14,8 @@ "tests/packed-consumer/stream-cause-runtime.mts", "tests/packed-consumer/stream-policy-runtime.mts", "tests/packed-consumer/retry-runtime.mts", + "tests/packed-consumer/defaults-runtime.mts", + "tests/packed-consumer/defaults-contract.ts", "tests/packed-consumer/hydration-runtime.mts", "tests/packed-consumer/hydration-contract.ts" ], diff --git a/apps/docs/astro.config.mjs b/apps/docs/astro.config.mjs index d782e7a..0138f5c 100644 --- a/apps/docs/astro.config.mjs +++ b/apps/docs/astro.config.mjs @@ -72,6 +72,7 @@ export default defineConfig({ { label: 'Cancellation', slug: 'guides/cancellation' }, { label: 'Handle Failures', slug: 'guides/handle-failures' }, { label: 'Retry Queries', slug: 'guides/retry-queries' }, + { label: 'Query Defaults', slug: 'guides/query-defaults' }, { label: 'Hydrate Unary Data', slug: 'guides/hydrate-unary-data' }, { label: 'Custom Key Encoders', slug: 'guides/custom-key-encoders' }, { label: 'Conditional Queries', slug: 'guides/conditional-queries' }, diff --git a/apps/docs/src/content/docs/guides/query-defaults.md b/apps/docs/src/content/docs/guides/query-defaults.md new file mode 100644 index 0000000..aca4252 --- /dev/null +++ b/apps/docs/src/content/docs/guides/query-defaults.md @@ -0,0 +1,148 @@ +--- +title: Query Defaults +description: Reuse QueryClient policies with generated prefixes and typed call-site options. +--- + +Configure common freshness and retry policies on your application-owned `QueryClient`. Use +generated prefixes to specialize those policies, then pass call-site options to the generated +builders. The utility tree owns keys and execution functions; QueryClient owns hashing. + +Create the utility tree and QueryClient once for each cache owner. Keep the ready client's Scope +alive while its queries run. Use a request-local QueryClient during SSR and dispose application +resources through their owning runtime. + +## Register RPC policies from broad to specific + +This example disables retries globally, retries reads in the `users` branch twice, and keeps the +`users.get` query view fresh for two minutes. The call-site `refreshUser` options request an +immediate fetch without retries. `user` selects a name while the cache retains the full user. + +```ts +import { QueryClient } from '@tanstack/query-core' +import { Schema } from 'effect' +import { createRpcQueryUtils } from 'effect-api-query' +import { Rpc, RpcGroup } from 'effect/rpc' +import type { RpcClient } from 'effect/rpc' + +export const usersRpc = RpcGroup.make( + Rpc.make('users.get', { + payload: { id: Schema.Int }, + success: Schema.Struct({ id: Schema.Int, name: Schema.String }), + error: Schema.TaggedStruct('RetryLater', {}), + }), +) + +export const createApplicationQueries = ( + client: RpcClient.RpcClient.Flat, ClientError>, +) => { + const rpc = createRpcQueryUtils(usersRpc, { + client, + keyPrefix: ['users-app'], + }) + const queryClient = new QueryClient({ + defaultOptions: { + queries: { staleTime: 30_000, gcTime: 600_000, retry: false, retryDelay: 250 }, + mutations: { retry: false }, + }, + }) + queryClient.setQueryDefaults(rpc.key(), { staleTime: 60_000 }) + queryClient.setQueryDefaults(rpc.users.key(), { retry: 2 }) + queryClient.setQueryDefaults([...rpc.users.get.key(), 'query'], { staleTime: 120_000 }) + + const user = rpc.users.get.queryOptions({ + input: { id: 1 }, + select: (value) => value.name, + }) + const refreshUser = rpc.users.get.queryOptions({ + input: { id: 1 }, + staleTime: 0, + retry: false, + }) + return { queryClient, rpc, user, refreshUser } +} +``` + +TanStack merges the QueryClient's global query defaults, every matching registered prefix in +registration order, and the explicit options passed to the builder. Register broader prefixes +first: TanStack does not sort them by specificity. Here the query inherits `gcTime: 600_000`, +`retry: 2`, and `staleTime: 120_000`. `refreshUser` overrides freshness and retries for that call. + +`rpc.key()` covers the utility tree, `rpc.users.key()` covers its branch, and +`rpc.users.get.key()` covers the leaf. Appending `'query'` targets that leaf's ordinary query view +without applying its stale policy to an infinite or stream view. An operation prefix also covers +all inputs of that view. Mutation policies use native `setMutationDefaults` separately. + +Omit an option to inherit its defaults. Use `staleTime: 0` to override an inherited freshness +policy with immediate staleness. QueryClient imperative fetches and observers can have different +built-in retry defaults, so set retry policy explicitly when attempts matter. `retry: 2` allows at +most three executions. For typed decisions +based on domain failures or transport errors, pass `retry` to the builder as shown in +[Retry Queries](/effect-api-query/guides/retry-queries/). + +## Apply HTTP policies to separate views + +HTTP prefixes use the same native APIs. This example retries the `users` group once, applies a +five-second stale time only to the metadata view, and hashes every view through QueryClient's +root-prefix policy. The ordinary query retains the global thirty-second stale time. + +```ts +import { QueryClient } from '@tanstack/query-core' +import { Schema } from 'effect' +import { createHttpApiQueryUtils } from 'effect-api-query' +import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from 'effect/http-api' +import type { HttpApiClient } from 'effect/http-api' + +export const usersApi = HttpApi.make('users').add( + HttpApiGroup.make('users').add( + HttpApiEndpoint.get('get', '/users/:id', { + params: { id: Schema.Int }, + success: Schema.Struct({ id: Schema.Int, name: Schema.String }), + error: Schema.TaggedStruct('RetryLater', {}).pipe(HttpApiSchema.status(503)), + }), + ), +) + +export const createHttpApplicationQueries = (client: HttpApiClient.ForApi) => { + const http = createHttpApiQueryUtils(usersApi, { client, keyPrefix: ['users-app'] }) + const queryClient = new QueryClient({ + defaultOptions: { + queries: { staleTime: 30_000, retry: false, retryDelay: 250 }, + }, + }) + queryClient.setQueryDefaults(http.key(), { + queryKeyHashFn: (key) => `users:${JSON.stringify(key)}`, + }) + queryClient.setQueryDefaults(http.users.key(), { retry: 1 }) + queryClient.setQueryDefaults([...http.users.get.key(), 'metadata'], { staleTime: 5_000 }) + + const input = { params: { id: 1 } } + const user = http.users.get.queryOptions({ input }) + const metadata = http.users.get.metadataOptions({ input }) + return { queryClient, http, user, metadata } +} +``` + +The ordinary and metadata views have separate keys. Configure global or prefix `queryKeyHashFn` +on QueryClient before using the cache, and keep it stable for the cache owner's lifetime. Generated +options retain their own query function even if a prefix registers another one. Builders reject +per-call `queryHash` and `queryKeyHashFn` because native key-only reads and writes must resolve the +same entry. + +Use the generated exact `queryKey(input)` or `metadataKey(input)` with native `getQueryData` and +`setQueryData`. Their data tags preserve the unselected query data type: selecting a name changes +the observer result, while a native write still supplies the complete user. A fresh native write +is visible to observers and subsequent imperative reads without another client execution. + +Fetched metadata snapshots inherit QueryClient's global or prefix `structuralSharing` policy. +Selected metadata results and later manual writes use an explicit per-call `metadataOptions` +policy, or standard deep sharing if omitted. Initial, hydrated, manually written, and selected +objects remain application-owned. Decoded data is not deep-frozen. See +[Buffered metadata](/effect-api-query/reference/http-factory/#buffered-metadata) for that boundary. + +## Keep defaults in native Query APIs + +A per-operation defaults map would replace the repeated `setQueryDefaults(prefix, policy)` calls +with a map and a registration loop. These recipes need only prefix selection and native policy +merging, both already provided by TanStack. The map would add configuration without useful +behavior, so the package provides no defaults helper. Keep shared policies on QueryClient and +operation-specific selection, input, and typed error callbacks on the generated builders. diff --git a/scripts/verify-docs-examples.mts b/scripts/verify-docs-examples.mts index 85becd1..aaf9daf 100644 --- a/scripts/verify-docs-examples.mts +++ b/scripts/verify-docs-examples.mts @@ -7,6 +7,10 @@ for (const [page, fixtures] of [ ['getting-started/quick-start', ['types/docs-rpc-quick-start']], ['getting-started/http-quick-start', ['types/docs-http-quick-start']], ['guides/retry-queries', ['packed-consumer/docs-retry']], + [ + 'guides/query-defaults', + ['packed-consumer/docs-defaults', 'packed-consumer/docs-http-defaults'], + ], [ 'guides/hydrate-unary-data', [ diff --git a/scripts/verify-packed-consumer.mts b/scripts/verify-packed-consumer.mts index 2288c74..ba35b1c 100644 --- a/scripts/verify-packed-consumer.mts +++ b/scripts/verify-packed-consumer.mts @@ -515,6 +515,7 @@ const verifyConsumer = (peer: (typeof peerCases)[number]): void => { 'stream-cause-runtime.mts', 'stream-policy-runtime.mts', 'retry-runtime.mts', + 'defaults-runtime.mts', 'hydration-runtime.mts', ]) { execFileSync(process.execPath, ['--experimental-import-meta-resolve', fixture], { diff --git a/tests/packed-consumer/defaults-contract.ts b/tests/packed-consumer/defaults-contract.ts new file mode 100644 index 0000000..91ed2a9 --- /dev/null +++ b/tests/packed-consumer/defaults-contract.ts @@ -0,0 +1,116 @@ +import { QueryObserver } from '@tanstack/query-core' +import type { Schema } from 'effect' +import type { EffectHttpApiQueryError, EffectRpcQueryError } from 'effect-api-query' +import type { HttpClientError } from 'effect/http' +import type { HttpApiClient } from 'effect/http-api' +import type { RpcClient, RpcClientError, RpcGroup } from 'effect/rpc' + +import { createApplicationQueries, usersRpc } from './docs-defaults.ts' +import { createHttpApplicationQueries, usersApi } from './docs-http-defaults.ts' + +type Equal = + (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false +type Assert = T +type User = { readonly id: number; readonly name: string } +type RetryLater = { readonly _tag: 'RetryLater' } + +declare const rpcClient: RpcClient.RpcClient.Flat< + RpcGroup.Rpcs, + RpcClientError.RpcClientError +> +declare const httpClient: HttpApiClient.ForApi + +const rpcSetup = createApplicationQueries(rpcClient) +const httpSetup = createHttpApplicationQueries(httpClient) +const rpcSelected = new QueryObserver(rpcSetup.queryClient, rpcSetup.user).getCurrentResult() +const httpObserved = new QueryObserver(httpSetup.queryClient, httpSetup.user).getCurrentResult() +const rpcFetched = rpcSetup.queryClient.query(rpcSetup.user) +const rpcRefreshed = rpcSetup.queryClient.query(rpcSetup.refreshUser) +const rpcCached = rpcSetup.queryClient.getQueryData(rpcSetup.user.queryKey) +const httpFetched = httpSetup.queryClient.query(httpSetup.user) +const httpCached = httpSetup.queryClient.getQueryData(httpSetup.user.queryKey) +const metadataFetched = httpSetup.queryClient.query(httpSetup.metadata) +const metadataCached = httpSetup.queryClient.getQueryData(httpSetup.metadata.queryKey) + +type Metadata = { + readonly data: User + readonly status: number + readonly headers: Readonly> +} + +type Contract = [ + Assert>, + Assert< + Equal< + typeof rpcSelected.error, + EffectRpcQueryError | null + > + >, + Assert>>, + Assert>>, + Assert>, + Assert>, + Assert>>, + Assert>, + Assert>>, + Assert>, + Assert, never>>, + Assert, never>>, + Assert, never>>, +] + +rpcSetup.rpc.users.get.queryOptions({ + input: { id: 1 }, + retry: (_count, error) => { + error satisfies EffectRpcQueryError + return false + }, +}) +httpSetup.http.users.get.queryOptions({ + input: { params: { id: 1 } }, + retry: (_count, error) => { + error satisfies EffectHttpApiQueryError< + RetryLater | HttpClientError.HttpClientError | Schema.SchemaError + > + return false + }, +}) +httpSetup.http.users.get.metadataOptions({ + input: { params: { id: 1 } }, + select: (snapshot) => snapshot.data.name, + retry: (_count, error) => { + error satisfies EffectHttpApiQueryError< + RetryLater | HttpClientError.HttpClientError | Schema.SchemaError + > + return false + }, +}) + +rpcSetup.queryClient.setQueryData(rpcSetup.user.queryKey, { id: 1, name: 'Grace' }) +httpSetup.queryClient.setQueryData(httpSetup.user.queryKey, { id: 1, name: 'Grace' }) +httpSetup.queryClient.setQueryData(httpSetup.metadata.queryKey, { + data: { id: 1, name: 'Grace' }, + status: 200, + headers: { etag: '"v2"' }, +}) + +// @ts-expect-error A selected string is not the cached RPC query data. +rpcSetup.queryClient.setQueryData(rpcSetup.user.queryKey, 'Grace') +// @ts-expect-error HTTP cache writes preserve the declared decoded data type. +httpSetup.queryClient.setQueryData(httpSetup.user.queryKey, { id: '1', name: 'Grace' }) +// @ts-expect-error A metadata key requires the complete metadata view. +httpSetup.queryClient.setQueryData(httpSetup.metadata.queryKey, { id: 1, name: 'Grace' }) +// @ts-expect-error Generated builders own query keys. +rpcSetup.rpc.users.get.queryOptions({ input: { id: 1 }, queryKey: ['replacement'] }) +// @ts-expect-error Generated builders own execution functions. +httpSetup.http.users.get.queryOptions({ + input: { params: { id: 1 } }, + queryFn: async () => ({ id: 1, name: 'replacement' }), +}) +// @ts-expect-error Hashing belongs to QueryClient defaults. +rpcSetup.rpc.users.get.queryOptions({ input: { id: 1 }, queryKeyHashFn: JSON.stringify }) +// @ts-expect-error Hashing belongs to QueryClient defaults. +httpSetup.http.users.get.metadataOptions({ input: { params: { id: 1 } }, queryHash: 'replacement' }) + +declare const contract: Contract +contract satisfies [true, true, true, true, true, true, true, true, true, true, true, true, true] diff --git a/tests/packed-consumer/defaults-runtime.mts b/tests/packed-consumer/defaults-runtime.mts index 97982b3..9f0b757 100644 --- a/tests/packed-consumer/defaults-runtime.mts +++ b/tests/packed-consumer/defaults-runtime.mts @@ -54,13 +54,6 @@ await Effect.runPromise( equal(refreshObserver.options.staleTime, 0) equal(refreshObserver.options.retry, false) refreshObserver.destroy() - deepStrictEqual( - yield* Effect.promise(() => - queryClient.query(rpc.users.get.queryOptions({ input: { id: 1 }, staleTime: undefined })), - ), - { id: 1, name: 'Ada' }, - ) - equal(attempts.get(1), 5) yield* Effect.promise(() => rejects( queryClient.query(rpc.users.get.queryOptions({ input: { id: 2 }, retry: false })), diff --git a/tests/packed-consumer/tsconfig.json b/tests/packed-consumer/tsconfig.json index 475cd05..e1a3d75 100644 --- a/tests/packed-consumer/tsconfig.json +++ b/tests/packed-consumer/tsconfig.json @@ -18,6 +18,10 @@ "stream-cause-runtime.mts", "stream-policy-runtime.mts", "retry-runtime.mts", + "defaults-runtime.mts", + "defaults-contract.ts", + "docs-defaults.ts", + "docs-http-defaults.ts", "hydration-runtime.mts", "http-runtime.mts", "http-metadata-runtime.mts", From c74eb26fd54c14d7216795494b063852a4593262 Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Sat, 10 Oct 2026 11:11:51 +0200 Subject: [PATCH 34/97] docs: define native cache filter recipes --- .../src/content/docs/guides/cache-filters.md | 195 +++++++++ .../content/docs/guides/cache-management.md | 3 + scripts/verify-docs-examples.mts | 4 + scripts/verify-packed-consumer.mts | 1 + .../docs-cache-filters-http.ts | 54 +++ .../packed-consumer/docs-cache-filters-rpc.ts | 40 ++ tests/packed-consumer/filter-contract.ts | 101 +++++ tests/packed-consumer/filter-runtime.mts | 373 ++++++++++++++++++ tests/packed-consumer/tsconfig.json | 3 + 9 files changed, 774 insertions(+) create mode 100644 apps/docs/src/content/docs/guides/cache-filters.md create mode 100644 tests/packed-consumer/docs-cache-filters-http.ts create mode 100644 tests/packed-consumer/docs-cache-filters-rpc.ts create mode 100644 tests/packed-consumer/filter-contract.ts create mode 100644 tests/packed-consumer/filter-runtime.mts diff --git a/apps/docs/src/content/docs/guides/cache-filters.md b/apps/docs/src/content/docs/guides/cache-filters.md new file mode 100644 index 0000000..6c471d6 --- /dev/null +++ b/apps/docs/src/content/docs/guides/cache-filters.md @@ -0,0 +1,195 @@ +--- +title: Cache Filters +description: Compose native filters for operations, encoded partial inputs, and exact cache entries. +--- + +Pass generated prefixes or concrete keys to TanStack's native filters. Choose the scope before +adding `type`, `stale`, `fetchStatus`, or `predicate` conditions: + +| Scope | Key | Matches | +| --------------------- | ------------------------------------------------------ | ---------------------------------------------------------------------- | +| Root | `rpc.key()` or `http.key()` | All cached query views in that utility tree | +| Branch | `rpc.users.key()` | Every descendant leaf and query view | +| Leaf | `rpc.users.get.key()` | Ordinary and infinite queries; HTTP leaves also include metadata views | +| Operation | `[...rpc.users.get.key(), 'query']` | Ordinary queries for that leaf | +| Encoded partial input | Operation prefix followed by an encoded partial object | Entries containing those encoded fields | +| Exact entry | Generated concrete key with `exact: true` | One complete cache identity, including its stream policy | + +A streaming leaf prefix includes accumulated and live views. Append `streamed` or `live` to select +one representation. HTTP buffered metadata uses `metadata`; pagination uses `infinite`. +Mutation keys belong to TanStack's separate mutation cache, and query invalidation does not affect +pending mutations. + +## Filter RPC queries + +This recipe accepts an application-owned ready client. Its `forId` filter matches every locale for +one user, while `exact` retains the generated DataTag for one complete payload: + +```ts +import { Effect, Schema } from 'effect' +import { createRpcQueryUtils } from 'effect-api-query' +import { Rpc, RpcClient, RpcGroup } from 'effect/rpc' + +const User = Schema.Struct({ id: Schema.Finite, name: Schema.String }) +const payload = { + id: Schema.FiniteFromString, + locale: Schema.String.pipe(Schema.withConstructorDefault(Effect.succeed('en'))), +} +export const usersRpc = RpcGroup.make( + Rpc.make('users.get', { payload, success: User }), + Rpc.make('users.watch', { payload, success: User, stream: true }), + Rpc.make('users.list', { success: Schema.Array(User) }), + Rpc.make('health.ping', { success: Schema.String }), +) + +export const userCacheFilters = ( + client: RpcClient.RpcClient.Flat>, +) => { + const rpc = createRpcQueryUtils(usersRpc, { client, keyPrefix: ['users-app'] }) + const queryPrefix = [...rpc.users.get.key(), 'query'] as const + const historyPrefix = [...rpc.users.watch.key(), 'streamed'] as const + + return { + rpc, + root: { queryKey: rpc.key() }, + branch: { queryKey: rpc.users.key() }, + leaf: { queryKey: rpc.users.get.key() }, + query: { queryKey: queryPrefix }, + forId: (id: number) => ({ queryKey: [...queryPrefix, { id: String(id) }] as const }), + history: { queryKey: historyPrefix }, + historyForId: (id: number) => ({ + queryKey: [...historyPrefix, { id: String(id) }] as const, + }), + exact: (id: number, locale = 'en') => ({ + queryKey: rpc.users.get.queryKey({ id, locale }), + exact: true as const, + }), + } +} +``` + +Create the filters with `userCacheFilters(client)`, then pass a filter to +`queryClient.invalidateQueries(filters.forId(1))`. Combine native conditions by spreading the filter, +for example `{ ...filters.forId(1), type: 'inactive', stale: false }`. Use +`queryClient.getQueryData(filters.exact(1).queryKey)` for a typed user read. + +Partial matching compares stored canonical key payloads. Here `FiniteFromString` encodes the numeric +constructor input `1` as `"1"`, so the partial object must use `id: "1"`. A raw `id: 1` matches no +entry. An omitted field places no constraint; specifying `locale: "en"` selects that locale. +Nested objects match recursively, arrays match by position, and scalar values must match exactly. +A scalar string filter does not perform substring matching. + +Use the concrete builder for complete constructor input. Calling `queryKey({ id: 1 })` materializes +the default `locale: "en"`; its key therefore cannot express every locale for user 1. Passing only +`{ locale: "en" }` rejects the missing required `id`. Do not cast partial constructor input into a +key builder or run it through the full payload constructor to derive a broad filter. + +`historyForId(1)` stops before the stream-policy suffix and matches every retention bound and +refetch mode for that encoded user. For one history, use +`rpc.users.watch.streamedKey({ id: 1 }, { maxChunks: 2, refetchMode: 'append' })` with `exact: true`. +The omitted policy and explicit unlimited/reset policy share the same normalized identity. Keep the +remaining concrete suffix opaque; root, branch, leaf, and operation filters survive policy changes. + +## Filter HTTP queries + +HTTP partial objects retain their labelled request containers. This recipe selects a path parameter +or normalized header without constraining the query or payload: + +```ts +import { Schema } from 'effect' +import { createHttpApiQueryUtils } from 'effect-api-query' +import { + HttpApi, + HttpApiClient, + HttpApiEndpoint, + HttpApiGroup, + HttpApiSchema, +} from 'effect/http-api' + +export const usersApi = HttpApi.make('users-api').add( + HttpApiGroup.make('users').add( + HttpApiEndpoint.post('get', '/users/:id', { + params: { id: Schema.FiniteFromString }, + query: { id: Schema.FiniteFromString, filter: Schema.optional(Schema.String) }, + headers: Schema.Record(Schema.String, Schema.optional(Schema.String)), + payload: Schema.Struct({ id: Schema.FiniteFromString }), + success: Schema.String, + }), + HttpApiEndpoint.get('watch', '/users/events', { + query: { id: Schema.FiniteFromString }, + success: HttpApiSchema.StreamSse({ data: Schema.String }), + }), + ), + HttpApiGroup.make('system', { topLevel: true }).add( + HttpApiEndpoint.get('ping', '/health', { success: Schema.String }), + ), +) + +export const httpCacheFilters = (client: HttpApiClient.ForApi) => { + const http = createHttpApiQueryUtils(usersApi, { client, keyPrefix: ['users-app'] }) + const queryPrefix = [...http.users.get.key(), 'query'] as const + + return { + http, + root: { queryKey: http.key() }, + branch: { queryKey: http.users.key() }, + leaf: { queryKey: http.users.get.key() }, + query: { queryKey: queryPrefix }, + forId: (id: number) => ({ + queryKey: [...queryPrefix, { params: { id: String(id) } }] as const, + }), + forLocale: (locale: string) => ({ + queryKey: [...queryPrefix, { headers: { 'x-locale': locale } }] as const, + }), + historyForId: (id: number) => ({ + queryKey: [...http.users.watch.key(), 'streamed', { query: { id: String(id) } }] as const, + }), + exact: (input: Parameters[0]) => ({ + queryKey: http.users.get.queryKey(input), + exact: true as const, + }), + } +} +``` + +Pass `httpCacheFilters(client).forId(1)` to `invalidateQueries` to refresh ordinary queries for path +parameter 1. Use the leaf filter to include infinite and metadata views too. `historyForId(1)` +selects every accumulated retention policy for the SSE request with query parameter 1. + +Default HTTP keys Schema-encode each request part, omit encoded `undefined` object members, and +lowercase header names. In this contract, `params: { id: "1" }` is a meaningful partial object; +`params: { id: 1 }` is not. Filter `headers: { 'x-locale': 'en' }` even when the decoded request uses +`'X-Locale'`. Preserve `params`, `query`, `headers`, and `payload`: `{ query: { id: "7" } }` and +`{ payload: { id: "7" } }` select different requests. An unlabelled `{ id: "7" }` matches neither. +HTTP request input does not use RPC payload construction. + +## Bound partial filters to the encoded shape + +A custom encoder replaces the default encoded shape. If it returns +`{ entity: { identifier: "1" }, language: "en" }`, filter by +`{ entity: { identifier: "1" } }`, using the same operation prefix. `{ id: "1" }` no longer matches. +If it returns the scalar `"1:en"`, native partial object matching cannot recover the original `id` or +locale. HTTP custom encoders also replace request labels and header normalization. + +Applications that need partial matching can define an explicit JSON object projection in their +encoder and build filters against that projection. Preserve every safe value that distinguishes +results. Treat projection changes as cache-identity changes, including persisted-cache version +busters. For opaque or noninvertible projections, use generated broad prefixes or a generated exact +key. A custom predicate can inspect only the information retained in the key or other +application-owned query metadata; it cannot reconstruct omitted input. + +The package keeps filter composition native. A generic partial-constructor helper cannot promise +meaningful matching across defaults, Schema transformations, and arbitrary encoder output. The +small operation-prefix expressions already compose with every native condition, so an additional +public helper would add no proven capability. This preserves the +[filter-composition decision](https://github.com/ueberBrot/effect-api-query/blob/main/docs/adr/0008-use-flat-prefix-matchable-key-tuples.md). + +Concrete builders retain DataTags for typed `getQueryData` and `setQueryData`. Manually composed +prefixes and partial keys carry no query-data tag, and native `getQueriesData` does not infer a +homogeneous result from its filter. Keep broad reads as `unknown` until the application establishes +the matched representation; an explicit generic or cast does not validate cached values. + +The [packed filter consumer](https://github.com/ueberBrot/effect-api-query/blob/main/tests/packed-consumer/filter-runtime.mts) +executes these recipes with real QueryClient caches and official Effect test clients. Its +[compiled contract](https://github.com/ueberBrot/effect-api-query/blob/main/tests/packed-consumer/filter-contract.ts) +checks exact DataTags and the limits of partial-key inference. diff --git a/apps/docs/src/content/docs/guides/cache-management.md b/apps/docs/src/content/docs/guides/cache-management.md index 2f1d1b0..193f7f6 100644 --- a/apps/docs/src/content/docs/guides/cache-management.md +++ b/apps/docs/src/content/docs/guides/cache-management.md @@ -10,6 +10,9 @@ await queryClient.invalidateQueries({ queryKey: rpcQuery.users.key() }) await queryClient.invalidateQueries({ queryKey: rpcQuery.users.get.key() }) ``` +For operation prefixes and partial encoded-input matching, use the complete +[Cache Filters recipe](/effect-api-query/guides/cache-filters/). + Use `queryKey(input)` for one payload-specific query: ```ts diff --git a/scripts/verify-docs-examples.mts b/scripts/verify-docs-examples.mts index 85becd1..000aa0a 100644 --- a/scripts/verify-docs-examples.mts +++ b/scripts/verify-docs-examples.mts @@ -7,6 +7,10 @@ for (const [page, fixtures] of [ ['getting-started/quick-start', ['types/docs-rpc-quick-start']], ['getting-started/http-quick-start', ['types/docs-http-quick-start']], ['guides/retry-queries', ['packed-consumer/docs-retry']], + [ + 'guides/cache-filters', + ['packed-consumer/docs-cache-filters-rpc', 'packed-consumer/docs-cache-filters-http'], + ], [ 'guides/hydrate-unary-data', [ diff --git a/scripts/verify-packed-consumer.mts b/scripts/verify-packed-consumer.mts index 2288c74..07fc112 100644 --- a/scripts/verify-packed-consumer.mts +++ b/scripts/verify-packed-consumer.mts @@ -506,6 +506,7 @@ const verifyConsumer = (peer: (typeof peerCases)[number]): void => { for (const fixture of [ 'hashing-runtime.mts', + 'filter-runtime.mts', 'runtime.mts', 'http-runtime.mts', 'http-metadata-runtime.mts', diff --git a/tests/packed-consumer/docs-cache-filters-http.ts b/tests/packed-consumer/docs-cache-filters-http.ts new file mode 100644 index 0000000..d9d87cd --- /dev/null +++ b/tests/packed-consumer/docs-cache-filters-http.ts @@ -0,0 +1,54 @@ +import { Schema } from 'effect' +import { createHttpApiQueryUtils } from 'effect-api-query' +import { + HttpApi, + HttpApiClient, + HttpApiEndpoint, + HttpApiGroup, + HttpApiSchema, +} from 'effect/http-api' + +export const usersApi = HttpApi.make('users-api').add( + HttpApiGroup.make('users').add( + HttpApiEndpoint.post('get', '/users/:id', { + params: { id: Schema.FiniteFromString }, + query: { id: Schema.FiniteFromString, filter: Schema.optional(Schema.String) }, + headers: Schema.Record(Schema.String, Schema.optional(Schema.String)), + payload: Schema.Struct({ id: Schema.FiniteFromString }), + success: Schema.String, + }), + HttpApiEndpoint.get('watch', '/users/events', { + query: { id: Schema.FiniteFromString }, + success: HttpApiSchema.StreamSse({ data: Schema.String }), + }), + ), + HttpApiGroup.make('system', { topLevel: true }).add( + HttpApiEndpoint.get('ping', '/health', { success: Schema.String }), + ), +) + +export const httpCacheFilters = (client: HttpApiClient.ForApi) => { + const http = createHttpApiQueryUtils(usersApi, { client, keyPrefix: ['users-app'] }) + const queryPrefix = [...http.users.get.key(), 'query'] as const + + return { + http, + root: { queryKey: http.key() }, + branch: { queryKey: http.users.key() }, + leaf: { queryKey: http.users.get.key() }, + query: { queryKey: queryPrefix }, + forId: (id: number) => ({ + queryKey: [...queryPrefix, { params: { id: String(id) } }] as const, + }), + forLocale: (locale: string) => ({ + queryKey: [...queryPrefix, { headers: { 'x-locale': locale } }] as const, + }), + historyForId: (id: number) => ({ + queryKey: [...http.users.watch.key(), 'streamed', { query: { id: String(id) } }] as const, + }), + exact: (input: Parameters[0]) => ({ + queryKey: http.users.get.queryKey(input), + exact: true as const, + }), + } +} diff --git a/tests/packed-consumer/docs-cache-filters-rpc.ts b/tests/packed-consumer/docs-cache-filters-rpc.ts new file mode 100644 index 0000000..d44ecfd --- /dev/null +++ b/tests/packed-consumer/docs-cache-filters-rpc.ts @@ -0,0 +1,40 @@ +import { Effect, Schema } from 'effect' +import { createRpcQueryUtils } from 'effect-api-query' +import { Rpc, RpcClient, RpcGroup } from 'effect/rpc' + +const User = Schema.Struct({ id: Schema.Finite, name: Schema.String }) +const payload = { + id: Schema.FiniteFromString, + locale: Schema.String.pipe(Schema.withConstructorDefault(Effect.succeed('en'))), +} +export const usersRpc = RpcGroup.make( + Rpc.make('users.get', { payload, success: User }), + Rpc.make('users.watch', { payload, success: User, stream: true }), + Rpc.make('users.list', { success: Schema.Array(User) }), + Rpc.make('health.ping', { success: Schema.String }), +) + +export const userCacheFilters = ( + client: RpcClient.RpcClient.Flat>, +) => { + const rpc = createRpcQueryUtils(usersRpc, { client, keyPrefix: ['users-app'] }) + const queryPrefix = [...rpc.users.get.key(), 'query'] as const + const historyPrefix = [...rpc.users.watch.key(), 'streamed'] as const + + return { + rpc, + root: { queryKey: rpc.key() }, + branch: { queryKey: rpc.users.key() }, + leaf: { queryKey: rpc.users.get.key() }, + query: { queryKey: queryPrefix }, + forId: (id: number) => ({ queryKey: [...queryPrefix, { id: String(id) }] as const }), + history: { queryKey: historyPrefix }, + historyForId: (id: number) => ({ + queryKey: [...historyPrefix, { id: String(id) }] as const, + }), + exact: (id: number, locale = 'en') => ({ + queryKey: rpc.users.get.queryKey({ id, locale }), + exact: true as const, + }), + } +} diff --git a/tests/packed-consumer/filter-contract.ts b/tests/packed-consumer/filter-contract.ts new file mode 100644 index 0000000..0e2e9f1 --- /dev/null +++ b/tests/packed-consumer/filter-contract.ts @@ -0,0 +1,101 @@ +import { QueryClient } from '@tanstack/query-core' +import type { QueryFilters } from '@tanstack/query-core' +import { HttpApiClient } from 'effect/http-api' +import { RpcClient, RpcGroup } from 'effect/rpc' + +import { httpCacheFilters, usersApi } from './docs-cache-filters-http.ts' +import { userCacheFilters, usersRpc } from './docs-cache-filters-rpc.ts' + +type Equal = + (() => Value extends Left ? 1 : 2) extends () => Value extends Right ? 1 : 2 + ? true + : false +type Assert = Value + +declare const rpcClient: RpcClient.RpcClient.Flat> +declare const httpClient: HttpApiClient.ForApi +const queryClient = new QueryClient() +const rpcFilters = userCacheFilters(rpcClient) +const httpFilters = httpCacheFilters(httpClient) + +rpcFilters.root.queryKey satisfies readonly ['users-app', 'rpc'] +rpcFilters.branch.queryKey satisfies readonly ['users-app', 'rpc', 'users'] +rpcFilters.leaf.queryKey satisfies readonly ['users-app', 'rpc', 'users', 'get'] +rpcFilters.query.queryKey satisfies readonly ['users-app', 'rpc', 'users', 'get', 'query'] +rpcFilters.forId(1) satisfies QueryFilters +rpcFilters.historyForId(1) satisfies QueryFilters +rpcFilters.exact(1) satisfies QueryFilters +httpFilters.root.queryKey satisfies readonly ['users-app', 'http', 'users-api'] +httpFilters.branch.queryKey satisfies readonly ['users-app', 'http', 'users-api', 'users'] +httpFilters.query.queryKey satisfies readonly [ + 'users-app', + 'http', + 'users-api', + 'users', + 'get', + 'query', +] +httpFilters.forId(1) satisfies QueryFilters +httpFilters.forLocale('en') satisfies QueryFilters +httpFilters.historyForId(1) satisfies QueryFilters + +const exactUser = queryClient.getQueryData(rpcFilters.exact(1).queryKey) +true satisfies Assert< + Equal +> +queryClient.setQueryData(rpcFilters.exact(1).queryKey, { id: 1, name: 'Ada' }) +// @ts-expect-error A concrete DataTag retains its query-data contract +queryClient.setQueryData(rpcFilters.exact(1).queryKey, 'Ada') +// @ts-expect-error Required constructor input cannot be omitted to build a partial filter +rpcFilters.rpc.users.get.queryKey({ locale: 'en' }) + +const partialUser = queryClient.getQueryData(rpcFilters.forId(1).queryKey) +true satisfies Assert> +const matchingUsers = queryClient.getQueriesData(rpcFilters.forId(1)) +true satisfies Assert> +const history = queryClient.getQueryData( + rpcFilters.rpc.users.watch.streamedKey({ id: 1 }, { maxChunks: 2, refetchMode: 'append' }), +) +true satisfies Assert< + Equal +> +const liveUser = queryClient.getQueryData(rpcFilters.rpc.users.watch.liveKey({ id: 1 })) +true satisfies Assert< + Equal +> + +const input = { + params: { id: 1 }, + query: { id: 7 }, + headers: { 'X-Locale': 'en' }, + payload: { id: 9 }, +} +const exactHttp = httpFilters.exact(input) +exactHttp satisfies QueryFilters +const httpData = queryClient.getQueryData(exactHttp.queryKey) +true satisfies Assert> +queryClient.setQueryData(exactHttp.queryKey, 'Ada') +// @ts-expect-error HTTP concrete DataTags retain their own data representation +queryClient.setQueryData(exactHttp.queryKey, { id: 1, name: 'Ada' }) +const partialHttp = queryClient.getQueryData(httpFilters.forId(1).queryKey) +true satisfies Assert> +const httpHistory = queryClient.getQueryData( + httpFilters.http.users.watch.streamedKey({ query: { id: 1 } }, { maxChunks: 2 }), +) +true satisfies Assert> +const httpLive = queryClient.getQueryData( + httpFilters.http.users.watch.liveKey({ query: { id: 1 } }), +) +true satisfies Assert> +const metadata = queryClient.getQueryData(httpFilters.http.users.get.metadataKey(input)) +metadata?.data satisfies string | undefined +metadata?.status satisfies number | undefined +metadata?.headers satisfies Readonly> | undefined + +queryClient.invalidateQueries({ ...rpcFilters.forId(1), type: 'inactive', stale: false }) +queryClient.invalidateQueries(httpFilters.historyForId(1)) +queryClient.invalidateQueries(exactHttp) +queryClient.setQueriesData(rpcFilters.query, (previous) => { + true satisfies Assert> + return previous +}) diff --git a/tests/packed-consumer/filter-runtime.mts b/tests/packed-consumer/filter-runtime.mts new file mode 100644 index 0000000..e8628a2 --- /dev/null +++ b/tests/packed-consumer/filter-runtime.mts @@ -0,0 +1,373 @@ +import { QueryClient } from '@tanstack/query-core' +import type { QueryFilters, QueryKey } from '@tanstack/query-core' +import { Effect, Layer, Stream } from 'effect' +import { + createHttpApiQueryUtils, + createRpcQueryUtils, + EffectRpcQueryKeyError, +} from 'effect-api-query' +import { HttpServer } from 'effect/http' +import { HttpApiBuilder, HttpApiTest } from 'effect/http-api' +import { RpcTest } from 'effect/rpc' +import { deepStrictEqual, equal, throws } from 'node:assert/strict' + +import { httpCacheFilters, usersApi } from './docs-cache-filters-http.ts' +import { userCacheFilters, usersRpc } from './docs-cache-filters-rpc.ts' + +await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const client = yield* RpcTest.makeClient(usersRpc, { flatten: true }).pipe( + Effect.provide( + usersRpc.toLayer({ + 'users.get': ({ id, locale }) => Effect.succeed({ id, name: locale }), + 'users.watch': ({ id, locale }) => Stream.succeed({ id, name: locale }), + 'users.list': () => Effect.succeed([]), + 'health.ping': () => Effect.succeed('ok'), + }), + ), + ) + const filters = userCacheFilters(client) + const queryClient = new QueryClient() + try { + for (const input of [{ id: 1 }, { id: 1, locale: 'de' }, { id: 2 }]) { + yield* Effect.promise(() => + queryClient.query(filters.rpc.users.get.queryOptions({ input })), + ) + } + equal(queryClient.getQueriesData(filters.forId(1)).length, 2) + deepStrictEqual(queryClient.getQueryData(filters.exact(1).queryKey), { + id: 1, + name: 'en', + }) + deepStrictEqual( + filters.rpc.users.get.queryKey({ id: 1 }), + filters.rpc.users.get.queryKey({ id: 1, locale: 'en' }), + ) + equal( + queryClient.getQueriesData({ + queryKey: [...filters.query.queryKey, { id: 1 }], + }).length, + 0, + ) + equal( + queryClient.getQueriesData({ queryKey: filters.rpc.users.get.queryKey({ id: 1 }) }) + .length, + 1, + ) + throws( + () => Reflect.apply(filters.rpc.users.get.queryKey, undefined, [{ locale: 'en' }]), + EffectRpcQueryKeyError, + ) + equal( + queryClient.getQueriesData({ ...filters.forId(1), type: 'inactive', stale: false }) + .length, + 2, + ) + + const infinite = filters.rpc.users.get.infiniteKey({ id: 1 }) + queryClient.setQueryData(infinite, { + pages: [{ id: 1, name: 'page' }], + pageParams: [0], + }) + const histories = [] + for (const refetchMode of ['reset', 'append', 'replace'] as const) { + for (const maxChunks of [undefined, 2]) { + const key = filters.rpc.users.watch.streamedKey({ id: 1 }, { maxChunks, refetchMode }) + queryClient.setQueryData(key, [{ id: 1, name: refetchMode }]) + histories.push(key) + } + } + const otherHistory = filters.rpc.users.watch.streamedKey({ id: 2 }, { maxChunks: 2 }) + queryClient.setQueryData(otherHistory, [{ id: 2, name: 'other' }]) + const live = filters.rpc.users.watch.liveKey({ id: 1 }) + queryClient.setQueryData(live, { id: 1, name: 'live' }) + const list = filters.rpc.users.list.queryKey() + queryClient.setQueryData(list, []) + const ping = filters.rpc.health.ping.queryKey() + queryClient.setQueryData(ping, 'ok') + queryClient.setQueryData(['unrelated'], 'other') + + const snapshots = queryClient.getQueriesData({}) + const unaryKeys = [ + filters.rpc.users.get.queryKey({ id: 1 }), + filters.rpc.users.get.queryKey({ id: 1, locale: 'de' }), + filters.rpc.users.get.queryKey({ id: 2 }), + ] + const userKeys = [...unaryKeys, infinite, ...histories, otherHistory, live, list] + const cases: readonly [QueryFilters, readonly QueryKey[]][] = [ + [filters.root, [...userKeys, ping]], + [filters.branch, userKeys], + [filters.leaf, [...unaryKeys, infinite]], + [filters.query, unaryKeys], + [filters.forId(1), unaryKeys.slice(0, 2)], + [filters.exact(1), [filters.exact(1).queryKey]], + [filters.history, [...histories, otherHistory]], + [filters.historyForId(1), histories], + [ + { + queryKey: filters.rpc.users.watch.streamedKey({ id: 1 }, { maxChunks: 2 }), + exact: true, + }, + [filters.rpc.users.watch.streamedKey({ id: 1 }, { maxChunks: 2 })], + ], + [ + { + queryKey: filters.rpc.users.watch.streamedKey( + { id: 1 }, + { maxChunks: undefined, refetchMode: 'reset' }, + ), + exact: true, + }, + [filters.rpc.users.watch.streamedKey({ id: 1 })], + ], + ] + for (const [filter, expectedKeys] of cases) { + const owner = new QueryClient() + try { + for (const [key, data] of snapshots) owner.setQueryData(key, data) + deepStrictEqual( + owner.getQueriesData(filter).map(([key]) => key), + expectedKeys, + ) + yield* Effect.promise(() => owner.invalidateQueries(filter)) + deepStrictEqual( + snapshots.map(([key]) => owner.getQueryState(key)?.isInvalidated), + snapshots.map(([key]) => + expectedKeys.some((expected) => JSON.stringify(expected) === JSON.stringify(key)), + ), + ) + } finally { + owner.clear() + } + } + + const projected = createRpcQueryUtils(usersRpc, { + client, + keyPrefix: ['projected'], + keyEncoders: { + 'users.get': ({ id, locale }) => ({ + entity: { identifier: String(id) }, + language: locale, + }), + }, + }) + const scalar = createRpcQueryUtils(usersRpc, { + client, + keyPrefix: ['scalar'], + keyEncoders: { 'users.get': ({ id, locale }) => `${id}:${locale}` }, + }) + for (const input of [{ id: 1 }, { id: 1, locale: 'de' }, { id: 2 }]) { + queryClient.setQueryData(projected.users.get.queryKey(input), { + id: input.id, + name: 'custom', + }) + queryClient.setQueryData(scalar.users.get.queryKey(input), { + id: input.id, + name: 'scalar', + }) + } + equal( + queryClient.getQueriesData({ + queryKey: [...projected.users.get.key(), 'query', { id: '1' }], + }).length, + 0, + ) + equal( + queryClient.getQueriesData({ + queryKey: [...projected.users.get.key(), 'query', { entity: { identifier: '1' } }], + }).length, + 2, + ) + equal(queryClient.getQueriesData({ queryKey: scalar.users.get.key() }).length, 3) + equal( + queryClient.getQueriesData({ queryKey: [...scalar.users.get.key(), 'query', '1'] }) + .length, + 0, + ) + equal( + queryClient.getQueriesData({ + queryKey: scalar.users.get.queryKey({ id: 1 }), + exact: true, + }).length, + 1, + ) + deepStrictEqual(queryClient.getQueryData(projected.users.get.queryKey({ id: 1 })), { + id: 1, + name: 'custom', + }) + } finally { + queryClient.clear() + } + }), + ), +) + +console.log( + 'Packed RPC native prefixes, encoded partial filters, exact keys and stream policies verified', +) + +await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const handlers = HttpApiBuilder.group(usersApi, 'users', (group) => + group + .handle('get', ({ params, query, payload, headers }) => + Effect.succeed(`${params.id}:${query.id}:${payload.id}:${headers['x-locale']}`), + ) + .handle('watch', ({ query }) => Effect.succeed(Stream.succeed(String(query.id)))), + ) + const system = HttpApiBuilder.group(usersApi, 'system', (group) => + group.handle('ping', () => Effect.succeed('ok')), + ) + const client = yield* HttpApiTest.groups(usersApi, ['users', 'system']).pipe( + Effect.provide(Layer.mergeAll(handlers, system, HttpServer.layerServices)), + ) + const filters = httpCacheFilters(client) + const queryClient = new QueryClient() + const input = { + params: { id: 1 }, + query: { id: 7, filter: undefined }, + headers: { 'X-Locale': 'en', ignored: undefined }, + payload: { id: 9 }, + } + const germanInput = { ...input, headers: { 'x-locale': 'de' } } + const otherInput = { ...input, params: { id: 2 } } + const swappedInput = { ...input, query: { id: 9, filter: undefined }, payload: { id: 7 } } + const inputs = [input, germanInput, otherInput, swappedInput] + try { + for (const request of inputs) { + yield* Effect.promise(() => + queryClient.query(filters.http.users.get.queryOptions({ input: request })), + ) + } + deepStrictEqual(queryClient.getQueryData(filters.exact(input).queryKey), '1:7:9:en') + deepStrictEqual( + filters.exact(input).queryKey, + filters.exact({ ...input, query: { id: 7 }, headers: { 'x-locale': 'en' } }).queryKey, + ) + equal(queryClient.getQueriesData(filters.forId(1)).length, 3) + equal(queryClient.getQueriesData(filters.forLocale('en')).length, 3) + for (const partial of [{ params: { id: 1 } }, { headers: { 'X-Locale': 'en' } }]) { + equal( + queryClient.getQueriesData({ queryKey: [...filters.query.queryKey, partial] }).length, + 0, + ) + } + for (const [partial, count] of [ + [{ query: { id: '7' } }, 3], + [{ payload: { id: '7' } }, 1], + [{ id: '7' }, 0], + ] as const) { + equal( + queryClient.getQueriesData({ queryKey: [...filters.query.queryKey, partial] }).length, + count, + ) + } + const metadata = filters.http.users.get.metadataKey(input) + queryClient.setQueryData(metadata, { data: 'metadata', status: 200, headers: {} }) + const infinite = filters.http.users.get.infiniteKey(input) + queryClient.setQueryData(infinite, { pages: ['page'], pageParams: [0] }) + const histories = [] + for (const refetchMode of ['reset', 'append', 'replace'] as const) { + for (const maxChunks of [undefined, 2]) { + const key = filters.http.users.watch.streamedKey( + { query: { id: 1 } }, + { maxChunks, refetchMode }, + ) + queryClient.setQueryData(key, ['history']) + histories.push(key) + } + } + const otherHistory = filters.http.users.watch.streamedKey({ query: { id: 2 } }) + queryClient.setQueryData(otherHistory, ['other']) + const live = filters.http.users.watch.liveKey({ query: { id: 1 } }) + queryClient.setQueryData(live, 'live') + const ping = filters.http.ping.queryKey() + queryClient.setQueryData(ping, 'ok') + queryClient.setQueryData(['unrelated'], 'other') + const snapshots = queryClient.getQueriesData({}) + const unaryKeys = inputs.map((request) => filters.exact(request).queryKey) + const userKeys = [...unaryKeys, metadata, infinite, ...histories, otherHistory, live] + const bounded = filters.http.users.watch.streamedKey({ query: { id: 1 } }, { maxChunks: 2 }) + const cases: readonly [QueryFilters, readonly QueryKey[]][] = [ + [filters.root, [...userKeys, ping]], + [filters.branch, userKeys], + [filters.leaf, [...unaryKeys, metadata, infinite]], + [filters.query, unaryKeys], + [ + filters.forId(1), + [input, germanInput, swappedInput].map((request) => filters.exact(request).queryKey), + ], + [filters.exact(input), [filters.exact(input).queryKey]], + [filters.historyForId(1), histories], + [ + { queryKey: [...filters.http.users.watch.key(), 'streamed'] }, + [...histories, otherHistory], + ], + [{ queryKey: bounded, exact: true }, [bounded]], + ] + for (const [filter, expectedKeys] of cases) { + const owner = new QueryClient() + try { + for (const [key, data] of snapshots) owner.setQueryData(key, data) + deepStrictEqual( + owner.getQueriesData(filter).map(([key]) => key), + expectedKeys, + ) + yield* Effect.promise(() => owner.invalidateQueries(filter)) + deepStrictEqual( + snapshots.map(([key]) => owner.getQueryState(key)?.isInvalidated), + snapshots.map(([key]) => + expectedKeys.some((expected) => JSON.stringify(expected) === JSON.stringify(key)), + ), + ) + } finally { + owner.clear() + } + } + + const projected = createHttpApiQueryUtils(usersApi, { + client, + keyPrefix: ['http-project'], + keyEncoders: { + users: { + get: ({ params, query, payload, headers }) => ({ + routeId: params.id, + queryId: query.id, + bodyId: payload.id, + language: headers['X-Locale'] ?? headers['x-locale'] ?? null, + }), + }, + }, + }) + for (const request of inputs) { + queryClient.setQueryData(projected.users.get.queryKey(request), 'projected') + } + equal( + queryClient.getQueriesData({ + queryKey: [...projected.users.get.key(), 'query', { params: { id: '1' } }], + }).length, + 0, + ) + equal( + queryClient.getQueriesData({ + queryKey: [...projected.users.get.key(), 'query', { routeId: 1 }], + }).length, + 3, + ) + equal( + queryClient.getQueriesData({ queryKey: projected.users.get.queryKey(input), exact: true }) + .length, + 1, + ) + } finally { + queryClient.clear() + } + }), + ), +) + +console.log( + 'Packed HTTP native filters, request labels, header normalization and policies verified', +) diff --git a/tests/packed-consumer/tsconfig.json b/tests/packed-consumer/tsconfig.json index 475cd05..1e935ad 100644 --- a/tests/packed-consumer/tsconfig.json +++ b/tests/packed-consumer/tsconfig.json @@ -14,6 +14,9 @@ }, "include": [ "hashing-runtime.mts", + "filter-runtime.mts", + "filter-contract.ts", + "docs-cache-filters-*.ts", "runtime.mts", "stream-cause-runtime.mts", "stream-policy-runtime.mts", From 937ba8dabf20b8c1de6d9af97a247aca94687692 Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Sat, 10 Oct 2026 11:16:15 +0200 Subject: [PATCH 35/97] feat: complete owned stream snapshots and cleanup proofs --- apps/docs/astro.config.mjs | 1 + .../content/docs/guides/stream-snapshots.md | 100 ++ .../src/content/docs/guides/tanstack-start.md | 17 +- .../content/docs/reference/public-exports.md | 72 +- ...ate-accumulated-and-live-stream-queries.md | 6 + scripts/verify-docs-examples.mts | 1 + scripts/verify-packed-consumer.mts | 2 + src/core/stream-snapshot.ts | 45 +- tests/packed-consumer/docs-stream-snapshot.ts | 36 +- tests/packed-consumer/snapshot-contract.ts | 60 +- tests/packed-consumer/snapshot-runtime.mts | 1056 ++++++++++++----- 11 files changed, 1060 insertions(+), 336 deletions(-) create mode 100644 apps/docs/src/content/docs/guides/stream-snapshots.md diff --git a/apps/docs/astro.config.mjs b/apps/docs/astro.config.mjs index d782e7a..4a75000 100644 --- a/apps/docs/astro.config.mjs +++ b/apps/docs/astro.config.mjs @@ -68,6 +68,7 @@ export default defineConfig({ { label: 'React Query', slug: 'guides/react-query' }, { label: 'HTTP Queries and Mutations', slug: 'guides/http-queries-and-mutations' }, { label: 'TanStack Start', slug: 'guides/tanstack-start' }, + { label: 'Stream Snapshots', slug: 'guides/stream-snapshots' }, { label: 'Cache Management', slug: 'guides/cache-management' }, { label: 'Cancellation', slug: 'guides/cancellation' }, { label: 'Handle Failures', slug: 'guides/handle-failures' }, diff --git a/apps/docs/src/content/docs/guides/stream-snapshots.md b/apps/docs/src/content/docs/guides/stream-snapshots.md new file mode 100644 index 0000000..c58d630 --- /dev/null +++ b/apps/docs/src/content/docs/guides/stream-snapshots.md @@ -0,0 +1,100 @@ +--- +title: Stream Snapshots +description: Capture an accumulated or live stream for server rendering and hydrate an independent browser cache. +--- + +Use `fetchStreamSnapshot` when server rendering needs data from an open RPC or HTTP SSE query. +It waits for the first new cache publication or normal completion, cancels the exact query, and +awaits its local iterator cleanup. Ordinary `queryClient.query` waits for an open stream to finish. + +## Capture data before dehydration + +Pass generated accumulated or live options to the helper. Keep the ready client and runner alive +until the returned Promise settles. This example captures separate history and latest-value views: + +```ts +import type { QueryClient } from '@tanstack/query-core' +import { Schema } from 'effect' +import { createRpcQueryUtils, fetchStreamSnapshot } from 'effect-api-query' +import { Rpc, RpcGroup } from 'effect/rpc' +import type { RpcClient } from 'effect/rpc' + +export const updates = RpcGroup.make( + Rpc.make('updates.watch', { + payload: { channel: Schema.String }, + success: Schema.String, + stream: true, + }), +) + +export async function captureUpdates( + queryClient: QueryClient, + client: RpcClient.RpcClient.Flat, E>, + signal: AbortSignal, +) { + const rpc = createRpcQueryUtils(updates, { + client, + keyPrefix: ['updates'], + }) + const input = { channel: 'announcements' } + const history = await fetchStreamSnapshot( + queryClient, + rpc.updates.watch.streamedOptions({ + input, + retry: false, + maxChunks: 20, + }), + { signal, timeoutMs: 5_000 }, + ) + const latest = await fetchStreamSnapshot( + queryClient, + rpc.updates.watch.liveOptions({ + input, + retry: false, + }), + { signal, timeoutMs: 5_000 }, + ) + return { history, latest } +} +``` + +Call `captureUpdates` with a request-local QueryClient, ready client, and request abort signal. +Then dehydrate successful data with TanStack's normal policy and your application's serializer. +For HTTP SSE, pass `streamedOptions` or `liveOptions` from the HTTP utility tree in the same way. +Use [Hydrate Unary Data](/effect-api-query/guides/hydrate-unary-data/) for paired Schema codecs, +including accumulated arrays and live values. + +The default `fresh` mode starts a new attempt even when cached or initial data has a future +timestamp. Old data cannot settle that attempt. Native stream policies still apply: `append` +includes existing history, and `replace` on a previously fetched query publishes only at completion. +An open replacement stream therefore needs to complete or reach the timeout. + +Choose `mode: 'cached'` to return an existing successful value without starting or cancelling work. +The helper applies native `select`, including QueryClient defaults. When no successful cache entry +exists, cached mode starts a fresh capture. + +## Keep capture ownership exclusive + +Use a request-local QueryClient and reserve the exact key throughout capture. A fresh capture +rejects a key that is fetching, paused, or observed. Other keys keep running. Avoid same-key writes, +observers, or concurrent fetches until capture settles: an external `setQueryData` publication can +otherwise be mistaken for the stream's first value. Configure hashing through QueryClient global +or prefix defaults; per-call `queryHash` and `queryKeyHashFn` are rejected. + +The helper removes its cache listener, request abort listener, and timeout on every outcome. It +waits for the supplied query function to finish local cleanup before returning or rejecting. +Dispose the server's ready client, Scope, or runtime after that settlement. This does not await a +remote server's cancellation acknowledgement. A finalizer that never finishes also prevents +settlement; `timeoutMs` limits the wait for data, not the duration of finalization. + +## Hydrate and reconnect in the browser + +Give the browser its own QueryClient, ready client, runner, and Scope. Hydrate the server snapshot +using the same safe key prefix and input, then observe the generated stream options with your +usual refetch policy. A new browser fetch reconnects with browser-owned resources; disposing the +server's resources cannot dispose those resources. + +The [TanStack Start example](/effect-api-query/guides/tanstack-start/) captures server snapshots +and reconnects accumulated and live views after hydration. The +[helper reference](/effect-api-query/reference/public-exports/#fetchstreamsnapshot) defines timeout, +abort, failure, empty-stream, and completion outcomes. diff --git a/apps/docs/src/content/docs/guides/tanstack-start.md b/apps/docs/src/content/docs/guides/tanstack-start.md index fd20352..a709dce 100644 --- a/apps/docs/src/content/docs/guides/tanstack-start.md +++ b/apps/docs/src/content/docs/guides/tanstack-start.md @@ -115,17 +115,16 @@ When the page should render despite a loader failure, catch the loader rejection to reach your route's error handling. See [Handle Failures](/effect-api-query/guides/handle-failures/) for inspecting typed failures. -## Capture an RPC stream snapshot +## Capture a stream snapshot -Completed stream data uses TanStack's normal dehydration contract. An open RPC stream remains -in `fetchStatus: 'fetching'`. To render its first successful value, start the generated query, -wait for a successful cache snapshot, then cancel the query before dehydration completes. -Cancellation closes the iterator and releases its Effect resources. The browser hydrates the -snapshot and may refetch according to your TanStack policies. +Use the package's `fetchStreamSnapshot` with generated accumulated or live options when an open +RPC or HTTP SSE stream must provide data for SSR. It captures a new publication, cancels the exact +query, and waits for local iterator cleanup before settling. Dehydrate the resulting successful +cache, then dispose request-owned resources. The browser hydrates its own cache and reconnects +through its own ready client and runner. -The example's -[`fetchStreamSnapshot`](https://github.com/ueberBrot/effect-api-query/blob/main/examples/tanstack-start/src/lib/query-ssr.ts) -shows the cache subscription, cancellation, and cleanup needed for this pattern. +[Stream Snapshots](/effect-api-query/guides/stream-snapshots/) covers fresh and cached modes, +timeouts, aborts, stream policies, and exclusive query ownership. ## Explore the executable example diff --git a/apps/docs/src/content/docs/reference/public-exports.md b/apps/docs/src/content/docs/reference/public-exports.md index 773484a..1cef21e 100644 --- a/apps/docs/src/content/docs/reference/public-exports.md +++ b/apps/docs/src/content/docs/reference/public-exports.md @@ -5,24 +5,26 @@ description: Package-root values and types. All supported imports come from `effect-api-query`. -| Export | Purpose | -| -------------------------------- | --------------------------------------------------------------------- | -| `createRpcQueryUtils` | Build the RPC utility tree. | -| `skipToken` | Query Core's exact sentinel for disabling queries that require input. | -| `EffectRpcQueryConfigError` | Invalid factory or builder configuration. | -| `EffectRpcQueryKeyError` | Payload or key preparation failure. | -| `EffectRpcQueryError` | Failed RPC `Exit` with its Effect `Cause`. | -| `EffectRpcQueryEmptyStreamError` | Live stream completed without a value. | -| `isEffectRpcQueryError` | Runtime guard for RPC execution errors. | -| `CreateRpcQueryUtilsOptions` | Factory option type. | -| `RpcQueryUtils` | Generated utility-tree type. | -| `RunPromiseExit` | Runner adapter type with optional abort signal. | -| `KeyEncoder` | Synchronous semantic key-encoder type. | -| `JsonValue` | Immutable strict-JSON key value. | -| `QueryData` | Query success type with possible `undefined` normalized to `null`. | -| `SkipToken` | Type of the exported skip sentinel. | -| `EffectRpcQueryConfigErrorCode` | Stable configuration error-code union. | -| `EffectRpcQueryKeyErrorCode` | Stable key error-code union. | +| Export | Purpose | +| -------------------------------- | ------------------------------------------------------------------------- | +| `createRpcQueryUtils` | Build the RPC utility tree. | +| `fetchStreamSnapshot` | Capture stream data and await exact-query cancellation and local cleanup. | +| `StreamSnapshotOptions` | Fresh/cached mode, request abort signal, and publication timeout. | +| `skipToken` | Query Core's exact sentinel for disabling queries that require input. | +| `EffectRpcQueryConfigError` | Invalid factory or builder configuration. | +| `EffectRpcQueryKeyError` | Payload or key preparation failure. | +| `EffectRpcQueryError` | Failed RPC `Exit` with its Effect `Cause`. | +| `EffectRpcQueryEmptyStreamError` | Live stream completed without a value. | +| `isEffectRpcQueryError` | Runtime guard for RPC execution errors. | +| `CreateRpcQueryUtilsOptions` | Factory option type. | +| `RpcQueryUtils` | Generated utility-tree type. | +| `RunPromiseExit` | Runner adapter type with optional abort signal. | +| `KeyEncoder` | Synchronous semantic key-encoder type. | +| `JsonValue` | Immutable strict-JSON key value. | +| `QueryData` | Query success type with possible `undefined` normalized to `null`. | +| `SkipToken` | Type of the exported skip sentinel. | +| `EffectRpcQueryConfigErrorCode` | Stable configuration error-code union. | +| `EffectRpcQueryKeyErrorCode` | Stable key error-code union. | The [HTTP factory](/effect-api-query/reference/http-factory/) adds `createHttpApiQueryUtils`, `HttpApiQueryUtils`, `CreateHttpApiQueryUtilsOptions`, and `HttpApiKeyEncoder`. Its errors are @@ -34,3 +36,37 @@ The [HTTP factory](/effect-api-query/reference/http-factory/) adds `createHttpAp `UnaryRpcOptions` describes request-local headers and Context for unary builders. `StreamingRpcOptions` also accepts the Effect client's stream buffer size. See [Generated Builders](/effect-api-query/reference/generated-builders/#request-local-rpc-options) for their behavior. + +## fetchStreamSnapshot + +`fetchStreamSnapshot(queryClient, options, controls?)` returns `Promise`, preserving native +`select` and its result type. Supply a callable query function, usually through generated +`streamedOptions` or `liveOptions`. Skipped queries and infinite-query options are excluded. + +| Control | Default | Meaning | +| ----------- | --------- | ----------------------------------------------------------------------------------------------- | +| `mode` | `'fresh'` | Start an owned attempt; `'cached'` returns an existing successful value without acquiring work. | +| `signal` | Omitted | Reject on request abort using `signal.reason`. An already aborted signal acquires no work. | +| `timeoutMs` | `10_000` | Wait for publication or completion; a finite positive value at most `2_147_483_647`. | + +Fresh capture requires an idle exact query without observers and starts despite cached freshness +or future timestamps. `cached` preserves native selection, even when another owner is fetching +or observing that successful entry. If no successful entry exists, it uses fresh capture. +QueryClient owns hashing; helper options reject `queryHash` and `queryKeyHashFn`. + +| Outcome | Result | +| ---------------------------------------- | --------------------------------------------------------------------------------------- | +| First new publication | Return selected data after cancelling and finalizing the exact query. | +| Successful completion | Return the completed selected data. | +| Empty accumulated stream | Return the native empty history, subject to its refetch policy. | +| Empty live stream | Reject with the adapter's empty-stream error. | +| Terminal query failure | Reject with the original failure after native retry policy and local cleanup. | +| Timeout | Reject with a `DOMException` named `TimeoutError` after cancellation and local cleanup. | +| Request abort | Reject with the abort reason after cancellation and local cleanup. | +| External cancellation before publication | Reject with Query Core's `CancelledError` after local cleanup. | + +All outcomes detach listeners and the timer. Local cleanup must cooperate with cancellation and +finish before the Promise settles; the timeout does not bound finalizers or acknowledge remote +server cleanup. Keep the exact key exclusively owned throughout capture, including manual writes. +See [Stream Snapshots](/effect-api-query/guides/stream-snapshots/) for request disposal and independent +browser reconnection. diff --git a/docs/adr/0020-generate-accumulated-and-live-stream-queries.md b/docs/adr/0020-generate-accumulated-and-live-stream-queries.md index 4173af2..cb06ea3 100644 --- a/docs/adr/0020-generate-accumulated-and-live-stream-queries.md +++ b/docs/adr/0020-generate-accumulated-and-live-stream-queries.md @@ -13,3 +13,9 @@ complete Effect failures. Applications own transport, reconnection, and resource rendering must settle and stop open work before disposing those resources. Live values follow [ADR 0012](0012-normalize-undefined-query-success-to-null.md), while accumulated elements retain their decoded representation. + +Expose a framework-neutral snapshot helper because native query cancellation can settle before +local iterator finalizers finish. A fresh capture exclusively owns an idle, unobserved exact query; +explicit cached capture can reuse successful data without acquiring work. Applications retain +client and runtime ownership and dispose them only after local cleanup settles. Browser +reconnection uses independently owned resources. diff --git a/scripts/verify-docs-examples.mts b/scripts/verify-docs-examples.mts index 85becd1..3309831 100644 --- a/scripts/verify-docs-examples.mts +++ b/scripts/verify-docs-examples.mts @@ -7,6 +7,7 @@ for (const [page, fixtures] of [ ['getting-started/quick-start', ['types/docs-rpc-quick-start']], ['getting-started/http-quick-start', ['types/docs-http-quick-start']], ['guides/retry-queries', ['packed-consumer/docs-retry']], + ['guides/stream-snapshots', ['packed-consumer/docs-stream-snapshot']], [ 'guides/hydrate-unary-data', [ diff --git a/scripts/verify-packed-consumer.mts b/scripts/verify-packed-consumer.mts index 2a6fa46..f4f6590 100644 --- a/scripts/verify-packed-consumer.mts +++ b/scripts/verify-packed-consumer.mts @@ -260,10 +260,12 @@ deepStrictEqual(declarationNames, [ 'RpcQueryUtils', 'RunPromiseExit', 'SkipToken', + 'StreamSnapshotOptions', 'StreamingRpcOptions', 'UnaryRpcOptions', 'createHttpApiQueryUtils', 'createRpcQueryUtils', + 'fetchStreamSnapshot', 'isEffectHttpApiQueryError', 'isEffectRpcQueryError', 'skipToken', diff --git a/src/core/stream-snapshot.ts b/src/core/stream-snapshot.ts index 1c58ea1..5d32772 100644 --- a/src/core/stream-snapshot.ts +++ b/src/core/stream-snapshot.ts @@ -6,6 +6,7 @@ import type { QueryFunction, QueryKey, } from '@tanstack/query-core' +import { Predicate } from 'effect' export interface StreamSnapshotOptions { readonly mode?: 'fresh' | 'cached' @@ -13,6 +14,18 @@ export interface StreamSnapshotOptions { readonly timeoutMs?: number } +const validateSnapshotControls = ( + options: Pick, + timeoutMs: number, +): void => { + if (Object.hasOwn(options, 'queryHash') || Object.hasOwn(options, 'queryKeyHashFn')) { + throw new TypeError('Stream snapshots inherit hashing from QueryClient defaults') + } + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647) { + throw new RangeError('timeoutMs must be positive and at most 2147483647') + } +} + export const fetchStreamSnapshot = async < TQueryFnData, TError = DefaultError, @@ -22,12 +35,15 @@ export const fetchStreamSnapshot = async < queryClient: QueryClient, options: QueryExecuteOptions & { readonly queryFn: QueryFunction + readonly queryHash?: never + readonly queryKeyHashFn?: never }, { mode = 'fresh', signal, timeoutMs = 10_000 }: StreamSnapshotOptions = {}, ): Promise => { signal?.throwIfAborted() - if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647) { - throw new RangeError('timeoutMs must be positive and at most 2147483647') + validateSnapshotControls(options, timeoutMs) + if (!Predicate.isFunction(options.queryFn)) { + throw new TypeError('Stream snapshots require a callable query function') } const cache = queryClient.getQueryCache() const existing = cache.find({ exact: true, queryKey: options.queryKey }) @@ -40,7 +56,7 @@ export const fetchStreamSnapshot = async < ) { throw new Error('Stream snapshots require an idle query without observers') } - const queryHash = queryClient.defaultQueryOptions(options).queryHash + const { queryHash } = queryClient.defaultQueryOptions(options) let published = false let started = false let executionSignal: AbortSignal | undefined @@ -48,15 +64,15 @@ export const fetchStreamSnapshot = async < let failure: unknown let cleanup = Promise.resolve() let cancellation: Promise | undefined - const cancel = () => { + const cancel = async () => { cancellation ??= queryClient.cancelQueries({ exact: true, queryKey: options.queryKey }) - return cancellation + await cancellation } let ready!: () => void - let reject!: (error: unknown) => void - const publication = new Promise((resolve, rejectPublication) => { + let rejectPublication!: (reason: AbortSignal['reason']) => void + const publication = new Promise((resolve, reject) => { ready = resolve - reject = rejectPublication + rejectPublication = reject }) const stop = cache.subscribe((event) => { if ( @@ -70,16 +86,25 @@ export const fetchStreamSnapshot = async < void cancel() } }) - const onAbort = () => reject(signal?.reason) + const onAbort = () => { + rejectPublication(signal?.reason) + } signal?.addEventListener('abort', onAbort, { once: true }) const timer = setTimeout(() => { - reject(new DOMException('Stream snapshot timed out', 'TimeoutError')) + rejectPublication(new DOMException('Stream snapshot timed out', 'TimeoutError')) }, timeoutMs) let result: TData try { + void queryClient.invalidateQueries({ + exact: true, + queryKey: options.queryKey, + refetchType: 'none', + }) + signal?.throwIfAborted() const fetching = queryClient.query({ ...options, staleTime: 0, + initialDataUpdatedAt: 0, queryFn: async (context) => { started = true failed = false diff --git a/tests/packed-consumer/docs-stream-snapshot.ts b/tests/packed-consumer/docs-stream-snapshot.ts index 6aa856a..fb79670 100644 --- a/tests/packed-consumer/docs-stream-snapshot.ts +++ b/tests/packed-consumer/docs-stream-snapshot.ts @@ -4,9 +4,13 @@ import { createRpcQueryUtils, fetchStreamSnapshot } from 'effect-api-query' import { Rpc, RpcGroup } from 'effect/rpc' import type { RpcClient } from 'effect/rpc' -export const updates = RpcGroup.make(Rpc.make('updates.watch', { - payload: { channel: Schema.String }, success: Schema.String, stream: true, -})) +export const updates = RpcGroup.make( + Rpc.make('updates.watch', { + payload: { channel: Schema.String }, + success: Schema.String, + stream: true, + }), +) export async function captureUpdates( queryClient: QueryClient, @@ -14,14 +18,26 @@ export async function captureUpdates( signal: AbortSignal, ) { const rpc = createRpcQueryUtils(updates, { - client, keyPrefix: ['updates'], + client, + keyPrefix: ['updates'], }) const input = { channel: 'announcements' } - const history = await fetchStreamSnapshot(queryClient, rpc.updates.watch.streamedOptions({ - input, retry: false, maxChunks: 20, - }), { signal, timeoutMs: 5_000 }) - const latest = await fetchStreamSnapshot(queryClient, rpc.updates.watch.liveOptions({ - input, retry: false, - }), { signal, timeoutMs: 5_000 }) + const history = await fetchStreamSnapshot( + queryClient, + rpc.updates.watch.streamedOptions({ + input, + retry: false, + maxChunks: 20, + }), + { signal, timeoutMs: 5_000 }, + ) + const latest = await fetchStreamSnapshot( + queryClient, + rpc.updates.watch.liveOptions({ + input, + retry: false, + }), + { signal, timeoutMs: 5_000 }, + ) return { history, latest } } diff --git a/tests/packed-consumer/snapshot-contract.ts b/tests/packed-consumer/snapshot-contract.ts index b8cf533..e5012cd 100644 --- a/tests/packed-consumer/snapshot-contract.ts +++ b/tests/packed-consumer/snapshot-contract.ts @@ -1,14 +1,23 @@ import type { QueryClient, QueryExecuteOptions } from '@tanstack/query-core' import { Schema } from 'effect' -import { createHttpApiQueryUtils, createRpcQueryUtils, fetchStreamSnapshot, skipToken } from 'effect-api-query' +import { + createHttpApiQueryUtils, + createRpcQueryUtils, + fetchStreamSnapshot, + skipToken, +} from 'effect-api-query' import type { StreamSnapshotOptions } from 'effect-api-query' -import { Rpc, RpcGroup } from 'effect/rpc' -import type { RpcClient } from 'effect/rpc' import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from 'effect/http-api' import type { HttpApiClient } from 'effect/http-api' +import { Rpc, RpcGroup } from 'effect/rpc' +import type { RpcClient } from 'effect/rpc' const group = RpcGroup.make( - Rpc.make('watch', { payload: { channel: Schema.String }, success: Schema.UndefinedOr(Schema.Int), stream: true }), + Rpc.make('watch', { + payload: { channel: Schema.String }, + success: Schema.UndefinedOr(Schema.Int), + stream: true, + }), ) declare const client: RpcClient.RpcClient.Flat> declare const queryClient: QueryClient @@ -18,20 +27,42 @@ const live = utils.watch.liveOptions({ input: { channel: 'owned' } }) fetchStreamSnapshot(queryClient, streamed) satisfies Promise fetchStreamSnapshot(queryClient, live) satisfies Promise -fetchStreamSnapshot(queryClient, { ...streamed, select: (values) => values.length }) satisfies Promise -fetchStreamSnapshot(queryClient, { ...live, select: (value) => value === null ? 'empty' : `${value}` }) satisfies Promise -fetchStreamSnapshot(queryClient, streamed, { mode: 'cached', signal: new AbortController().signal, timeoutMs: 500 }) satisfies Promise +fetchStreamSnapshot(queryClient, { + ...streamed, + select: (values) => values.length, +}) satisfies Promise +fetchStreamSnapshot(queryClient, { + ...live, + select: (value) => (value === null ? 'empty' : `${value}`), +}) satisfies Promise +fetchStreamSnapshot(queryClient, streamed, { + mode: 'cached', + signal: new AbortController().signal, + timeoutMs: 500, +}) satisfies Promise const controls = { mode: 'fresh', timeoutMs: 1_000 } as const satisfies StreamSnapshotOptions fetchStreamSnapshot(queryClient, live, controls) -declare const native: QueryExecuteOptions & { queryFn: () => Promise } +declare const native: Omit< + QueryExecuteOptions, + 'queryHash' | 'queryKeyHashFn' +> & { + queryFn: () => Promise +} fetchStreamSnapshot(queryClient, native) satisfies Promise -const api = HttpApi.make('snapshot-types').add(HttpApiGroup.make('events').add( - HttpApiEndpoint.get('watch', '/watch', { success: HttpApiSchema.StreamSse({ data: Schema.Int }) }), -)) +const api = HttpApi.make('snapshot-types').add( + HttpApiGroup.make('events').add( + HttpApiEndpoint.get('watch', '/watch', { + success: HttpApiSchema.StreamSse({ data: Schema.Int }), + }), + ), +) declare const httpClient: HttpApiClient.ForApi const http = createHttpApiQueryUtils(api, { client: httpClient, keyPrefix: ['snapshot-types'] }) -fetchStreamSnapshot(queryClient, http.events.watch.streamedOptions()) satisfies Promise +fetchStreamSnapshot(queryClient, http.events.watch.streamedOptions()) satisfies Promise< + readonly number[] +> +fetchStreamSnapshot(queryClient, http.events.watch.liveOptions()) satisfies Promise // @ts-expect-error Skipped options cannot own a callable query function fetchStreamSnapshot(queryClient, utils.watch.liveOptions({ input: skipToken })) // @ts-expect-error A snapshot requires a supplied callable query function @@ -40,3 +71,8 @@ fetchStreamSnapshot(queryClient, { queryKey: ['missing'] }) fetchStreamSnapshot(queryClient, { ...streamed, initialPageParam: 0 }) // @ts-expect-error Snapshot mode is explicit fetchStreamSnapshot(queryClient, live, { mode: 'join' }) + +// @ts-expect-error QueryClient owns hashing for snapshots +fetchStreamSnapshot(queryClient, { ...live, queryHash: 'custom' }) +// @ts-expect-error Configure custom hashing through QueryClient defaults +fetchStreamSnapshot(queryClient, { ...live, queryKeyHashFn: () => 'custom' }) diff --git a/tests/packed-consumer/snapshot-runtime.mts b/tests/packed-consumer/snapshot-runtime.mts index 6404bc0..f497f26 100644 --- a/tests/packed-consumer/snapshot-runtime.mts +++ b/tests/packed-consumer/snapshot-runtime.mts @@ -1,308 +1,810 @@ -import { dehydrate, hydrate, QueryClient, QueryObserver, isCancelledError } from '@tanstack/query-core' +import { + dehydrate, + hydrate, + QueryClient, + QueryObserver, + isCancelledError, + onlineManager, + skipToken, +} from '@tanstack/query-core' import { Deferred, Effect, Exit, Schema, Scope, Stream } from 'effect' -import { createHttpApiQueryUtils, createRpcQueryUtils, fetchStreamSnapshot, isEffectRpcQueryError } from 'effect-api-query' +import { + createHttpApiQueryUtils, + createRpcQueryUtils, + fetchStreamSnapshot, + isEffectRpcQueryError, +} from 'effect-api-query' import type { RunPromiseExit } from 'effect-api-query' -import { Rpc, RpcGroup, RpcTest } from 'effect/rpc' import { HttpServer } from 'effect/http' -import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, HttpApiTest } from 'effect/http-api' +import { + HttpApi, + HttpApiBuilder, + HttpApiEndpoint, + HttpApiGroup, + HttpApiSchema, + HttpApiTest, +} from 'effect/http-api' +import { Rpc, RpcGroup, RpcTest } from 'effect/rpc' import { deepStrictEqual, equal, ok, rejects } from 'node:assert/strict' import { getEventListeners } from 'node:events' -const group = RpcGroup.make(Rpc.make('watch', { - payload: { channel: Schema.String }, success: Schema.Int, error: Schema.String, stream: true, -})) -const api = HttpApi.make('snapshot-http').add(HttpApiGroup.make('events').add( - HttpApiEndpoint.get('watch', '/watch', { success: HttpApiSchema.StreamSse({ data: Schema.Int }) }), -)) +import { captureUpdates, updates } from './docs-stream-snapshot.ts' -await Effect.runPromise(Effect.scoped(Effect.gen(function* () { - const client = yield* HttpApiTest.groups(api, ['events']).pipe( - Effect.provide(HttpApiBuilder.group(api, 'events', (handlers) => - handlers.handle('watch', () => Effect.succeed(Stream.succeed(1).pipe(Stream.concat(Stream.fromEffect(Effect.never))))), - )), - ) - const utils = createHttpApiQueryUtils(api, { client, keyPrefix: ['snapshot-http'] }) - const queryClient = new QueryClient() - deepStrictEqual(yield* Effect.promise(() => fetchStreamSnapshot(queryClient, utils.events.watch.streamedOptions({ retry: false }))), [1]) - equal(queryClient.isFetching(), 0) - equal(queryClient.getQueryCache().hasListeners(), false) - queryClient.clear() -})).pipe(Effect.provide(HttpServer.layerServices))) +const group = RpcGroup.make( + Rpc.make('watch', { + payload: { channel: Schema.String }, + success: Schema.Int, + error: Schema.String, + stream: true, + }), +) +const api = HttpApi.make('snapshot-http').add( + HttpApiGroup.make('events').add( + HttpApiEndpoint.get('watch', '/watch', { + success: HttpApiSchema.StreamSse({ data: Schema.Int }), + }), + ), +) -await Effect.runPromise(Effect.scoped(Effect.gen(function* () { - for (const view of ['streamed', 'live'] as const) { - for (const path of ['success', 'timeout', 'abort', 'external cancel', 'failure', 'empty', 'complete'] as const) { - const entered = yield* Deferred.make() - const closing = yield* Deferred.make() - const release = yield* Deferred.make() - let finalized = false - const client = yield* RpcTest.makeClient(group, { flatten: true }).pipe( - Effect.provide(group.toLayer({ watch: () => Stream.succeed(1) })), +await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const client = yield* HttpApiTest.groups(api, ['events']).pipe( + Effect.provide( + HttpApiBuilder.group(api, 'events', (handlers) => + handlers.handle('watch', () => + Effect.succeed( + Stream.succeed(1).pipe(Stream.concat(Stream.fromEffect(Effect.never))), + ), + ), + ), + ), ) - const source = path === 'failure' ? Stream.fail('unavailable') - : path === 'empty' ? Stream.empty - : path === 'complete' ? Stream.make(1, 2) - : path === 'success' ? Stream.succeed(1).pipe(Stream.concat(Stream.fromEffect(Effect.never))) - : Stream.fromEffect(Deferred.succeed(entered, undefined).pipe(Effect.andThen(Effect.never))) - const ready: typeof client = (...args) => client(...args).pipe( - Stream.flatMap(() => source), - Stream.ensuring(Deferred.succeed(closing, undefined).pipe( - Effect.andThen(Deferred.await(release)), - Effect.andThen(Effect.sync(() => { finalized = true })), - )), + const utils = createHttpApiQueryUtils(api, { client, keyPrefix: ['snapshot-http'] }) + const queryClient = new QueryClient() + deepStrictEqual( + yield* Effect.promise(() => + fetchStreamSnapshot(queryClient, utils.events.watch.streamedOptions({ retry: false })), + ), + [1], + ) + equal( + yield* Effect.promise(() => + fetchStreamSnapshot(queryClient, utils.events.watch.liveOptions({ retry: false })), + ), + 1, ) - const options = createRpcQueryUtils(group, { client: ready, keyPrefix: ['snapshot', path] }).watch + equal(queryClient.isFetching(), 0) + equal(queryClient.getQueryCache().hasListeners(), false) + queryClient.clear() + }), + ).pipe(Effect.provide(HttpServer.layerServices)), +) + +await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + for (const view of ['streamed', 'live'] as const) { + for (const path of [ + 'success', + 'timeout', + 'abort', + 'external cancel', + 'failure', + 'empty', + 'complete', + ] as const) { + const entered = yield* Deferred.make() + const closing = yield* Deferred.make() + const release = yield* Deferred.make() + let finalized = false + const client = yield* RpcTest.makeClient(group, { flatten: true }).pipe( + Effect.provide(group.toLayer({ watch: () => Stream.succeed(1) })), + ) + const source = + path === 'failure' + ? Stream.fail('unavailable') + : path === 'empty' + ? Stream.empty + : path === 'complete' + ? Stream.make(1, 2) + : path === 'success' + ? Stream.succeed(1).pipe(Stream.concat(Stream.fromEffect(Effect.never))) + : Stream.fromEffect( + Deferred.succeed(entered, undefined).pipe(Effect.andThen(Effect.never)), + ) + const ready = ((...args: Parameters) => + client(args[0], args[1], { ...args[2], asQueue: false }).pipe( + Stream.flatMap(() => source), + Stream.ensuring( + Deferred.succeed(closing, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.andThen( + Effect.sync(() => { + finalized = true + }), + ), + ), + ), + )) as typeof client + const options = createRpcQueryUtils(group, { + client: ready, + keyPrefix: ['snapshot', path], + }).watch + const queryClient = new QueryClient() + const abort = new AbortController() + const reason = new Error('page aborted') + let settled = false + const nativeOptions = + view === 'streamed' + ? options.streamedOptions({ input: { channel: 'owned' }, retry: false }) + : options.liveOptions({ input: { channel: 'owned' }, retry: false }) + const queryKey = nativeOptions.queryKey + const snapshot = + view === 'streamed' + ? fetchStreamSnapshot( + queryClient, + options.streamedOptions({ input: { channel: 'owned' }, retry: false }), + { signal: abort.signal, timeoutMs: path === 'timeout' ? 10 : 1_000 }, + ) + : fetchStreamSnapshot( + queryClient, + options.liveOptions({ input: { channel: 'owned' }, retry: false }), + { signal: abort.signal, timeoutMs: path === 'timeout' ? 10 : 1_000 }, + ) + const outcome = snapshot.then( + (data) => { + settled = true + return { status: 'success' as const, data } + }, + (error: unknown) => { + settled = true + return { status: 'failure' as const, error } + }, + ) + if (path === 'abort' || path === 'external cancel') { + yield* Deferred.await(entered) + if (path === 'abort') abort.abort(reason) + else yield* Effect.promise(() => queryClient.cancelQueries({ exact: true, queryKey })) + } + yield* Deferred.await(closing) + yield* Effect.sleep('1 millis') + equal(settled, false) + equal(finalized, false) + yield* Deferred.succeed(release, undefined) + const result = yield* Effect.promise(() => outcome) + equal(finalized, true) + equal(queryClient.getQueryCache().hasListeners(), false) + equal(queryClient.isFetching(), 0) + equal(getEventListeners(abort.signal, 'abort').length, 0) + if (path === 'timeout') { + equal(result.status, 'failure') + if (result.status === 'failure') { + ok(result.error instanceof DOMException) + equal(result.error.name, 'TimeoutError') + } + } else if (path === 'abort') { + equal(result.status, 'failure') + if (result.status === 'failure') equal(result.error, reason) + } else if (path === 'external cancel') { + equal(result.status, 'failure') + if (result.status === 'failure') ok(isCancelledError(result.error)) + } else if (path === 'failure' || (path === 'empty' && view === 'live')) { + equal(result.status, 'failure') + if (result.status === 'failure') { + if (path === 'failure') ok(isEffectRpcQueryError(result.error)) + else + ok( + result.error instanceof Error && + result.error.name === 'EffectRpcQueryEmptyStreamError', + ) + } + } else { + equal(result.status, 'success') + if (result.status === 'success') + deepStrictEqual(result.data, view === 'streamed' ? (path === 'empty' ? [] : [1]) : 1) + } + abort.abort(new Error('late abort')) + equal(queryClient.getQueryCache().hasListeners(), false) + queryClient.clear() + } + } + }), + ), +) + +await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const acquiring = yield* Deferred.make() + const allow = yield* Deferred.make() + let acquisitionFinalized = false + let reads = 0 + const client = yield* RpcTest.makeClient(group, { flatten: true }).pipe( + Effect.provide( + group.toLayer({ + watch: () => + Stream.fromEffect( + Effect.sync(() => { + reads += 1 + return 1 + }), + ), + }), + ), + ) + const runPromiseExit: RunPromiseExit = (effect, options) => + Effect.runPromiseExit( + Deferred.succeed(acquiring, undefined).pipe( + Effect.andThen(Deferred.await(allow)), + Effect.andThen(effect), + Effect.ensuring( + Effect.sync(() => { + acquisitionFinalized = true + }), + ), + ), + options, + ) + const options = createRpcQueryUtils(group, { + client, + keyPrefix: ['acquisition'], + runPromiseExit, + }).watch.liveOptions({ input: { channel: 'owned' }, retry: false }) const queryClient = new QueryClient() + const before = AbortSignal.abort(new Error('already aborted')) + yield* Effect.promise(() => + rejects( + fetchStreamSnapshot(queryClient, options, { signal: before }), + (error: unknown) => error === before.reason, + ), + ) + equal(queryClient.getQueryCache().getAll().length, 0) const abort = new AbortController() - const reason = new Error('page aborted') - let settled = false - const nativeOptions = view === 'streamed' - ? options.streamedOptions({ input: { channel: 'owned' }, retry: false }) - : options.liveOptions({ input: { channel: 'owned' }, retry: false }) - const queryKey = nativeOptions.queryKey - const snapshot = view === 'streamed' - ? fetchStreamSnapshot(queryClient, options.streamedOptions({ input: { channel: 'owned' }, retry: false }), { signal: abort.signal, timeoutMs: path === 'timeout' ? 10 : 1_000 }) - : fetchStreamSnapshot(queryClient, options.liveOptions({ input: { channel: 'owned' }, retry: false }), { signal: abort.signal, timeoutMs: path === 'timeout' ? 10 : 1_000 }) - const outcome = snapshot.then( - (data) => { settled = true; return { status: 'success' as const, data } }, - (error: unknown) => { settled = true; return { status: 'failure' as const, error } }, + const reason = new Error('abort acquisition') + const outcome = rejects( + fetchStreamSnapshot(queryClient, options, { signal: abort.signal }), + (error: unknown) => error === reason, ) - if (path === 'abort' || path === 'external cancel') { - yield* Deferred.await(entered) - if (path === 'abort') abort.abort(reason) - else yield* Effect.promise(() => queryClient.cancelQueries({ exact: true, queryKey })) - } - yield* Deferred.await(closing) - equal(settled, false) - equal(finalized, false) - yield* Deferred.succeed(release, undefined) - const result = yield* Effect.promise(() => outcome) - equal(finalized, true) - equal(queryClient.getQueryCache().hasListeners(), false) - equal(queryClient.isFetching(), 0) + yield* Deferred.await(acquiring) + abort.abort(reason) + yield* Effect.promise(() => outcome) + equal(acquisitionFinalized, true) + equal(reads, 0) equal(getEventListeners(abort.signal, 'abort').length, 0) - if (path === 'timeout') { - equal(result.status, 'failure') - if (result.status === 'failure') { - ok(result.error instanceof DOMException) - equal(result.error.name, 'TimeoutError') + equal(queryClient.getQueryCache().hasListeners(), false) + queryClient.clear() + }), + ), +) + +await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + let ownedClosed = false + let otherClosed = false + const started = yield* Deferred.make() + const client = yield* RpcTest.makeClient(group, { flatten: true }).pipe( + Effect.provide( + group.toLayer({ + watch: ({ channel }) => + Stream.succeed(channel === 'owned' ? 1 : 2).pipe( + Stream.concat(Stream.fromEffect(Effect.never)), + ), + }), + ), + ) + const ready = ((...args: Parameters) => + client(args[0], args[1], { ...args[2], asQueue: false }).pipe( + Stream.ensuring( + Effect.sync(() => { + if (args[1].channel === 'owned') ownedClosed = true + else otherClosed = true + }), + ), + )) as typeof client + const utils = createRpcQueryUtils(group, { client: ready, keyPrefix: ['exact'] }) + const queryClient = new QueryClient() + const other = utils.watch.liveOptions({ input: { channel: 'other' }, retry: false }) + const stop = queryClient.getQueryCache().subscribe(() => { + if (queryClient.getQueryData(other.queryKey) === 2) + Effect.runFork(Deferred.succeed(started, undefined)) + }) + const fetching = queryClient.query(other) + yield* Deferred.await(started) + stop() + const owned = utils.watch.liveOptions({ input: { channel: 'owned' }, retry: false }) + equal(yield* Effect.promise(() => fetchStreamSnapshot(queryClient, owned)), 1) + equal(ownedClosed, true) + equal(otherClosed, false) + equal(queryClient.getQueryState(other.queryKey)?.fetchStatus, 'fetching') + equal(queryClient.getQueryData(other.queryKey), 2) + yield* Effect.promise(() => + queryClient.cancelQueries({ exact: true, queryKey: other.queryKey }), + ) + yield* Effect.promise(() => fetching) + equal(queryClient.getQueryCache().hasListeners(), false) + queryClient.clear() + }), + ), +) + +await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + for (const view of ['streamed', 'live'] as const) { + const serverScope = yield* Scope.make() + let serverClosed = false + let browserClosed = false + let browserReads = 0 + const server = yield* RpcTest.makeClient(group, { flatten: true }).pipe( + Effect.provide( + group.toLayer({ + watch: () => Stream.succeed(1).pipe(Stream.concat(Stream.fromEffect(Effect.never))), + }), + ), + Effect.provideService(Scope.Scope, serverScope), + ) + const browser = yield* RpcTest.makeClient(group, { flatten: true }).pipe( + Effect.provide( + group.toLayer({ + watch: () => + Stream.fromEffect( + Effect.sync(() => { + browserReads += 1 + return 2 + }), + ).pipe(Stream.concat(Stream.fromEffect(Effect.never))), + }), + ), + ) + const serverReady = ((...args: Parameters) => + server(args[0], args[1], { ...args[2], asQueue: false }).pipe( + Stream.ensuring( + Effect.sync(() => { + serverClosed = true + }), + ), + )) as typeof server + const browserReady = ((...args: Parameters) => + browser(args[0], args[1], { ...args[2], asQueue: false }).pipe( + Stream.ensuring( + Effect.sync(() => { + browserClosed = true + }), + ), + )) as typeof browser + const serverUtils = createRpcQueryUtils(group, { + client: serverReady, + keyPrefix: ['hydrate'], + }) + const browserUtils = createRpcQueryUtils(group, { + client: browserReady, + keyPrefix: ['hydrate'], + }) + const input = { channel: 'owned' } + const serverOptions = serverUtils.watch.streamedOptions({ input, retry: false }) + const serverLive = serverUtils.watch.liveOptions({ input, retry: false }) + const browserOptions = browserUtils.watch.streamedOptions({ input, retry: false }) + const browserLive = browserUtils.watch.liveOptions({ input, retry: false }) + const serverQueryClient = new QueryClient() + const browserQueryClient = new QueryClient() + deepStrictEqual( + view === 'streamed' + ? yield* Effect.promise(() => fetchStreamSnapshot(serverQueryClient, serverOptions)) + : yield* Effect.promise(() => fetchStreamSnapshot(serverQueryClient, serverLive)), + view === 'streamed' ? [1] : 1, + ) + equal(serverClosed, true) + const json = JSON.stringify(dehydrate(serverQueryClient)) + serverQueryClient.clear() + yield* Scope.close(serverScope, Exit.void) + hydrate(browserQueryClient, JSON.parse(json)) + deepStrictEqual( + view === 'streamed' + ? yield* Effect.promise(() => + fetchStreamSnapshot(browserQueryClient, browserOptions, { mode: 'cached' }), + ) + : yield* Effect.promise(() => + fetchStreamSnapshot(browserQueryClient, browserLive, { mode: 'cached' }), + ), + view === 'streamed' ? [1] : 1, + ) + equal(browserReads, 0) + deepStrictEqual( + view === 'streamed' + ? yield* Effect.promise(() => fetchStreamSnapshot(browserQueryClient, browserOptions)) + : yield* Effect.promise(() => fetchStreamSnapshot(browserQueryClient, browserLive)), + view === 'streamed' ? [2] : 2, + ) + equal(browserReads, 1) + equal(browserClosed, true) + browserQueryClient.clear() + } + }), + ), +) + +await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + let attempts = 0 + const client = yield* RpcTest.makeClient(group, { flatten: true }).pipe( + Effect.provide( + group.toLayer({ + watch: () => + Stream.suspend(() => { + attempts += 1 + return attempts === 1 ? Stream.fail('try again') : Stream.make(1) + }), + }), + ), + ) + const options = createRpcQueryUtils(group, { + client, + keyPrefix: ['retry-snapshot'], + }).watch.liveOptions({ input: { channel: 'owned' }, retry: 1, retryDelay: 0 }) + const queryClient = new QueryClient() + equal(yield* Effect.promise(() => fetchStreamSnapshot(queryClient, options)), 1) + equal(attempts, 2) + queryClient.clear() + }), + ), +) + +await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + for (const policy of ['reset', 'append', 'replace', 'live'] as const) { + const entered = yield* Deferred.make() + const emit = yield* Deferred.make() + const emitted = yield* Deferred.make() + let finalized = false + const client = yield* RpcTest.makeClient(group, { flatten: true }).pipe( + Effect.provide(group.toLayer({ watch: () => Stream.succeed(1) })), + ) + const ready = ((...args: Parameters) => + client(args[0], args[1], { ...args[2], asQueue: false }).pipe( + Stream.flatMap(() => + Stream.fromEffect( + Deferred.succeed(entered, undefined).pipe( + Effect.andThen(Deferred.await(emit)), + Effect.andThen(Deferred.succeed(emitted, undefined)), + Effect.as(1), + ), + ), + ), + Stream.concat(Stream.fromEffect(Effect.never)), + Stream.ensuring( + Effect.sync(() => { + finalized = true + }), + ), + )) as typeof client + const utils = createRpcQueryUtils(group, { client: ready, keyPrefix: ['cached', policy] }) + const queryClient = new QueryClient() + let settled = false + const input = { channel: 'owned' } + const streamed = utils.watch.streamedOptions({ + input, + refetchMode: policy === 'live' ? 'reset' : policy, + retry: false, + staleTime: 60_000, + }) + const live = utils.watch.liveOptions({ input, retry: false, staleTime: 60_000 }) + const queryKey = policy === 'live' ? live.queryKey : streamed.queryKey + if (policy === 'live') queryClient.setQueryData(live.queryKey, 1) + else queryClient.setQueryData(streamed.queryKey, [0]) + const fetching = + policy === 'live' + ? fetchStreamSnapshot(queryClient, live, { timeoutMs: 100 }) + : fetchStreamSnapshot(queryClient, streamed, { timeoutMs: 100 }) + const outcome = fetching.then( + (value) => { + settled = true + return { status: 'success' as const, value } + }, + (error: unknown) => { + settled = true + return { status: 'failure' as const, error } + }, + ) + yield* Deferred.await(entered) + equal(settled, false) + yield* Effect.promise(() => + rejects( + policy === 'live' + ? fetchStreamSnapshot(queryClient, live) + : fetchStreamSnapshot(queryClient, streamed), + /idle query without observers/, + ), + ) + if (policy === 'append' || policy === 'replace') { + deepStrictEqual( + yield* Effect.promise(() => + fetchStreamSnapshot(queryClient, streamed, { mode: 'cached' }), + ), + [0], + ) + equal(finalized, false) } - } else if (path === 'abort') { - equal(result.status, 'failure') - if (result.status === 'failure') equal(result.error, reason) - } else if (path === 'external cancel') { - equal(result.status, 'failure') - if (result.status === 'failure') ok(isCancelledError(result.error)) - } else if (path === 'failure' || (path === 'empty' && view === 'live')) { - equal(result.status, 'failure') - if (result.status === 'failure') { - if (path === 'failure') ok(isEffectRpcQueryError(result.error)) - else ok(result.error instanceof Error && result.error.name === 'EffectRpcQueryEmptyStreamError') + yield* Deferred.succeed(emit, undefined) + yield* Deferred.await(emitted) + const result = yield* Effect.promise(() => outcome) + equal(finalized, true) + equal(queryClient.getQueryCache().hasListeners(), false) + if (policy === 'replace') { + equal(result.status, 'failure') + if (result.status === 'failure') + ok(result.error instanceof DOMException && result.error.name === 'TimeoutError') + deepStrictEqual(queryClient.getQueryData(queryKey), [0]) + } else { + equal(result.status, 'success') + if (result.status === 'success') + deepStrictEqual( + result.value, + policy === 'live' ? 1 : policy === 'append' ? [0, 1] : [1], + ) } - } else { - equal(result.status, 'success') - if (result.status === 'success') deepStrictEqual(result.data, view === 'streamed' ? path === 'empty' ? [] : [1] : 1) + queryClient.clear() } - abort.abort(new Error('late abort')) + }), + ), +) + +await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const client = yield* RpcTest.makeClient(group, { flatten: true }).pipe( + Effect.provide(group.toLayer({ watch: () => Stream.make(1, 2) })), + ) + const options = createRpcQueryUtils(group, { + client, + keyPrefix: ['completed'], + }).watch.streamedOptions({ + input: { channel: 'owned' }, + refetchMode: 'replace', + retry: false, + }) + const queryClient = new QueryClient() + queryClient.setQueryData(options.queryKey, [0]) + deepStrictEqual( + yield* Effect.promise(() => fetchStreamSnapshot(queryClient, options)), + [1, 2], + ) + queryClient.setQueryDefaults(options.queryKey, { + select: (data) => `items:${Array.isArray(data) ? data.length : 0}`, + }) + equal( + yield* Effect.promise(() => fetchStreamSnapshot(queryClient, options, { mode: 'cached' })), + 'items:2', + ) + equal( + yield* Effect.promise(() => + fetchStreamSnapshot(queryClient, { ...options, select: (values) => values.length }), + ), + 2, + ) + deepStrictEqual(queryClient.getQueryData(options.queryKey), [1, 2]) + const observer = new QueryObserver(queryClient, { ...options, enabled: false }) + const unsubscribe = observer.subscribe(() => {}) + yield* Effect.promise(() => + rejects(fetchStreamSnapshot(queryClient, options), /idle query without observers/), + ) + equal( + yield* Effect.promise(() => + fetchStreamSnapshot( + queryClient, + { ...options, select: (values) => values.length }, + { mode: 'cached' }, + ), + ), + 2, + ) + unsubscribe() equal(queryClient.getQueryCache().hasListeners(), false) queryClient.clear() - } - } -}))) + }), + ), +) -await Effect.runPromise(Effect.scoped(Effect.gen(function* () { - const acquiring = yield* Deferred.make() - const allow = yield* Deferred.make() - let acquisitionFinalized = false - let reads = 0 - const client = yield* RpcTest.makeClient(group, { flatten: true }).pipe( - Effect.provide(group.toLayer({ watch: () => Stream.fromEffect(Effect.sync(() => { reads += 1; return 1 })) })), - ) - const runPromiseExit: RunPromiseExit = (effect, options) => Effect.runPromiseExit( - Deferred.succeed(acquiring, undefined).pipe( - Effect.andThen(Deferred.await(allow)), - Effect.andThen(effect), - Effect.ensuring(Effect.sync(() => { acquisitionFinalized = true })), - ), options, - ) - const options = createRpcQueryUtils(group, { client, keyPrefix: ['acquisition'], runPromiseExit }).watch.liveOptions({ input: { channel: 'owned' }, retry: false }) +await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const client = yield* RpcTest.makeClient(group, { flatten: true }).pipe( + Effect.provide( + group.toLayer({ + watch: () => Stream.succeed(1).pipe(Stream.concat(Stream.fromEffect(Effect.never))), + }), + ), + ) + for (const cached of [true, false]) { + for (const view of ['streamed', 'live'] as const) { + const utils = createRpcQueryUtils(group, { + client, + keyPrefix: ['clock-skew', cached, view], + }) + const queryClient = new QueryClient() + const input = { channel: 'owned' } + const future = Date.now() + 60_000 + const streamed = utils.watch.streamedOptions({ + input, + initialData: [0], + initialDataUpdatedAt: future, + retry: false, + }) + const live = utils.watch.liveOptions({ + input, + initialData: 0, + initialDataUpdatedAt: future, + retry: false, + }) + if (cached) { + if (view === 'streamed') + queryClient.setQueryData(streamed.queryKey, [0], { updatedAt: future }) + else queryClient.setQueryData(live.queryKey, 0, { updatedAt: future }) + const previous = + view === 'streamed' + ? yield* Effect.promise(() => + fetchStreamSnapshot(queryClient, streamed, { mode: 'cached' }), + ) + : yield* Effect.promise(() => + fetchStreamSnapshot(queryClient, live, { mode: 'cached' }), + ) + deepStrictEqual(previous, view === 'streamed' ? [0] : 0) + } + const result = + view === 'streamed' + ? yield* Effect.promise(() => fetchStreamSnapshot(queryClient, streamed)) + : yield* Effect.promise(() => fetchStreamSnapshot(queryClient, live)) + deepStrictEqual(result, view === 'streamed' ? (cached ? [1] : [0, 1]) : 1) + equal(queryClient.isFetching(), 0) + equal(queryClient.getQueryCache().hasListeners(), false) + queryClient.clear() + } + } + for (const prefix of [true, false]) { + const utils = createRpcQueryUtils(group, { client, keyPrefix: ['snapshot-hash', prefix] }) + const queryClient = new QueryClient({ + defaultOptions: { queries: { queryKeyHashFn: (key) => `global:${JSON.stringify(key)}` } }, + }) + if (prefix) + queryClient.setQueryDefaults(utils.watch.key(), { + queryKeyHashFn: (key) => `prefix:${JSON.stringify(key)}`, + }) + const options = utils.watch.liveOptions({ input: { channel: 'owned' }, retry: false }) + equal(yield* Effect.promise(() => fetchStreamSnapshot(queryClient, options)), 1) + equal(queryClient.getQueryData(options.queryKey), 1) + equal(queryClient.getQueryCache().getAll().length, 1) + equal(queryClient.getQueryCache().hasListeners(), false) + queryClient.clear() + } + }), + ), +) + +await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const undefinedGroup = RpcGroup.make( + Rpc.make('watch', { success: Schema.Undefined, stream: true }), + ) + const client = yield* RpcTest.makeClient(undefinedGroup, { flatten: true }).pipe( + Effect.provide( + undefinedGroup.toLayer({ + watch: () => + Stream.succeed(undefined).pipe(Stream.concat(Stream.fromEffect(Effect.never))), + }), + ), + ) + const options = createRpcQueryUtils(undefinedGroup, { + client, + keyPrefix: ['snapshot-null'], + }).watch.liveOptions({ retry: false }) + const queryClient = new QueryClient() + equal(yield* Effect.promise(() => fetchStreamSnapshot(queryClient, options)), null) + equal(queryClient.getQueryData(options.queryKey), null) + equal(queryClient.isFetching(), 0) + queryClient.clear() + }), + ), +) + +for (const extra of [{ queryHash: 'custom' }, { queryKeyHashFn: () => 'custom' }]) { const queryClient = new QueryClient() - const before = AbortSignal.abort(new Error('already aborted')) - yield* Effect.promise(() => rejects(fetchStreamSnapshot(queryClient, options, { signal: before }), (error: unknown) => error === before.reason)) + await rejects( + Reflect.apply(fetchStreamSnapshot, undefined, [ + queryClient, + { + queryKey: ['per-call-hash'], + queryFn: () => Promise.resolve(1), + ...extra, + }, + ]), + TypeError, + ) equal(queryClient.getQueryCache().getAll().length, 0) - const abort = new AbortController() - const reason = new Error('abort acquisition') - const outcome = rejects(fetchStreamSnapshot(queryClient, options, { signal: abort.signal }), (error: unknown) => error === reason) - yield* Deferred.await(acquiring) - abort.abort(reason) - yield* Effect.promise(() => outcome) - equal(acquisitionFinalized, true) - equal(reads, 0) - equal(getEventListeners(abort.signal, 'abort').length, 0) equal(queryClient.getQueryCache().hasListeners(), false) - queryClient.clear() -}))) +} -await Effect.runPromise(Effect.scoped(Effect.gen(function* () { - let ownedClosed = false - let otherClosed = false - const started = yield* Deferred.make() - const client = yield* RpcTest.makeClient(group, { flatten: true }).pipe( - Effect.provide(group.toLayer({ watch: ({ channel }) => Stream.succeed(channel === 'owned' ? 1 : 2).pipe(Stream.concat(Stream.fromEffect(Effect.never))) })), - ) - const ready: typeof client = (...args) => client(...args).pipe( - Stream.ensuring(Effect.sync(() => { if (args[1].channel === 'owned') ownedClosed = true; else otherClosed = true })), - ) - const utils = createRpcQueryUtils(group, { client: ready, keyPrefix: ['exact'] }) +for (const timeoutMs of [0, -1, Number.POSITIVE_INFINITY, Number.NaN, 2_147_483_648]) { const queryClient = new QueryClient() - const other = utils.watch.liveOptions({ input: { channel: 'other' }, retry: false }) - const stop = queryClient.getQueryCache().subscribe(() => { - if (queryClient.getQueryData(other.queryKey) === 2) Effect.runFork(Deferred.succeed(started, undefined)) - }) - const fetching = queryClient.query(other) - yield* Deferred.await(started) - stop() - const owned = utils.watch.liveOptions({ input: { channel: 'owned' }, retry: false }) - equal(yield* Effect.promise(() => fetchStreamSnapshot(queryClient, owned)), 1) - equal(ownedClosed, true) - equal(otherClosed, false) - equal(queryClient.getQueryState(other.queryKey)?.fetchStatus, 'fetching') - equal(queryClient.getQueryData(other.queryKey), 2) - yield* Effect.promise(() => queryClient.cancelQueries({ exact: true, queryKey: other.queryKey })) - yield* Effect.promise(() => fetching) + await rejects( + fetchStreamSnapshot( + queryClient, + { + queryKey: ['timeout'], + queryFn: () => Promise.resolve(1), + }, + { timeoutMs }, + ), + RangeError, + ) + equal(queryClient.getQueryCache().getAll().length, 0) equal(queryClient.getQueryCache().hasListeners(), false) - queryClient.clear() -}))) +} -await Effect.runPromise(Effect.scoped(Effect.gen(function* () { - const serverScope = yield* Scope.make() - let serverClosed = false - let browserClosed = false - let browserReads = 0 - const server = yield* RpcTest.makeClient(group, { flatten: true }).pipe( - Effect.provide(group.toLayer({ watch: () => Stream.succeed(1).pipe(Stream.concat(Stream.fromEffect(Effect.never))) })), - Effect.provideService(Scope.Scope, serverScope), - ) - const browser = yield* RpcTest.makeClient(group, { flatten: true }).pipe( - Effect.provide(group.toLayer({ watch: () => Stream.fromEffect(Effect.sync(() => { browserReads += 1; return 2 })).pipe(Stream.concat(Stream.fromEffect(Effect.never))) })), - ) - const serverReady: typeof server = (...args) => server(...args).pipe(Stream.ensuring(Effect.sync(() => { serverClosed = true }))) - const browserReady: typeof browser = (...args) => browser(...args).pipe(Stream.ensuring(Effect.sync(() => { browserClosed = true }))) - const serverOptions = createRpcQueryUtils(group, { client: serverReady, keyPrefix: ['hydrate'] }).watch.streamedOptions({ input: { channel: 'owned' }, retry: false }) - const browserOptions = createRpcQueryUtils(group, { client: browserReady, keyPrefix: ['hydrate'] }).watch.streamedOptions({ input: { channel: 'owned' }, retry: false }) - const serverQueryClient = new QueryClient() - const browserQueryClient = new QueryClient() - deepStrictEqual(yield* Effect.promise(() => fetchStreamSnapshot(serverQueryClient, serverOptions)), [1]) - equal(serverClosed, true) - const json = JSON.stringify(dehydrate(serverQueryClient)) - serverQueryClient.clear() - yield* Scope.close(serverScope, Exit.void) - hydrate(browserQueryClient, JSON.parse(json)) - deepStrictEqual(yield* Effect.promise(() => fetchStreamSnapshot(browserQueryClient, browserOptions, { mode: 'cached' })), [1]) - equal(browserReads, 0) - deepStrictEqual(yield* Effect.promise(() => fetchStreamSnapshot(browserQueryClient, browserOptions)), [2]) - equal(browserReads, 1) - equal(browserClosed, true) - browserQueryClient.clear() -}))) +const previousOnline = onlineManager.isOnline() +const pausedClient = new QueryClient() +try { + onlineManager.setOnline(false) + const options = { queryKey: ['paused'], queryFn: () => Promise.resolve(1) } + const paused = pausedClient.query(options).catch((error: unknown) => error) + equal(pausedClient.getQueryState(options.queryKey)?.fetchStatus, 'paused') + await rejects(fetchStreamSnapshot(pausedClient, options), /idle query without observers/) + equal(pausedClient.getQueryState(options.queryKey)?.fetchStatus, 'paused') + equal(pausedClient.getQueryCache().hasListeners(), false) + await pausedClient.cancelQueries({ exact: true, queryKey: options.queryKey }) + ok(isCancelledError(await paused)) +} finally { + onlineManager.setOnline(previousOnline) + pausedClient.clear() +} -await Effect.runPromise(Effect.scoped(Effect.gen(function* () { - let attempts = 0 - const client = yield* RpcTest.makeClient(group, { flatten: true }).pipe( - Effect.provide(group.toLayer({ watch: () => Stream.suspend(() => { - attempts += 1 - return attempts === 1 ? Stream.fail('try again') : Stream.make(1) - }) })), - ) - const options = createRpcQueryUtils(group, { client, keyPrefix: ['retry-snapshot'] }).watch.liveOptions({ input: { channel: 'owned' }, retry: 1, retryDelay: 0 }) +for (const queryFn of [undefined, skipToken, 'skipToken']) { const queryClient = new QueryClient() - equal(yield* Effect.promise(() => fetchStreamSnapshot(queryClient, options)), 1) - equal(attempts, 2) - queryClient.clear() -}))) - -await Effect.runPromise(Effect.scoped(Effect.gen(function* () { - for (const policy of ['reset', 'append', 'replace', 'live'] as const) { - const entered = yield* Deferred.make() - const emit = yield* Deferred.make() - const emitted = yield* Deferred.make() - let finalized = false - const client = yield* RpcTest.makeClient(group, { flatten: true }).pipe( - Effect.provide(group.toLayer({ watch: () => Stream.succeed(1) })), - ) - const ready: typeof client = (...args) => client(...args).pipe( - Stream.flatMap(() => Stream.fromEffect( - Deferred.succeed(entered, undefined).pipe( - Effect.andThen(Deferred.await(emit)), - Effect.andThen(Deferred.succeed(emitted, undefined)), - Effect.as(1), - ), - )), - Stream.concat(Stream.fromEffect(Effect.never)), - Stream.ensuring(Effect.sync(() => { finalized = true })), - ) - const utils = createRpcQueryUtils(group, { client: ready, keyPrefix: ['cached', policy] }) - const queryClient = new QueryClient() - let settled = false - const input = { channel: 'owned' } - const streamed = utils.watch.streamedOptions({ input, refetchMode: policy === 'live' ? 'reset' : policy, retry: false, staleTime: 60_000 }) - const live = utils.watch.liveOptions({ input, retry: false, staleTime: 60_000 }) - const queryKey = policy === 'live' ? live.queryKey : streamed.queryKey - if (policy === 'live') queryClient.setQueryData(live.queryKey, 1) - else queryClient.setQueryData(streamed.queryKey, [0]) - const fetching = policy === 'live' - ? fetchStreamSnapshot(queryClient, live, { timeoutMs: 100 }) - : fetchStreamSnapshot(queryClient, streamed, { timeoutMs: 100 }) - const outcome = fetching.then( - (value) => { settled = true; return { status: 'success' as const, value } }, - (error: unknown) => { settled = true; return { status: 'failure' as const, error } }, - ) - yield* Deferred.await(entered) - equal(settled, false) - yield* Effect.promise(() => rejects( - policy === 'live' ? fetchStreamSnapshot(queryClient, live) : fetchStreamSnapshot(queryClient, streamed), - /idle query without observers/, - )) - if (policy === 'append' || policy === 'replace') { - deepStrictEqual(yield* Effect.promise(() => fetchStreamSnapshot(queryClient, streamed, { mode: 'cached' })), [0]) - equal(finalized, false) - } - yield* Deferred.succeed(emit, undefined) - yield* Deferred.await(emitted) - const result = yield* Effect.promise(() => outcome) - equal(finalized, true) - equal(queryClient.getQueryCache().hasListeners(), false) - if (policy === 'replace') { - equal(result.status, 'failure') - if (result.status === 'failure') ok(result.error instanceof DOMException && result.error.name === 'TimeoutError') - deepStrictEqual(queryClient.getQueryData(queryKey), [0]) - } else { - equal(result.status, 'success') - if (result.status === 'success') deepStrictEqual(result.value, policy === 'live' ? 1 : policy === 'append' ? [0, 1] : [1]) - } - queryClient.clear() - } -}))) - -await Effect.runPromise(Effect.scoped(Effect.gen(function* () { - const client = yield* RpcTest.makeClient(group, { flatten: true }).pipe( - Effect.provide(group.toLayer({ watch: () => Stream.make(1, 2) })), + await rejects( + Reflect.apply(fetchStreamSnapshot, undefined, [ + queryClient, + { + queryKey: ['invalid-query-function'], + queryFn, + }, + ]), + TypeError, ) - const options = createRpcQueryUtils(group, { client, keyPrefix: ['completed'] }).watch.streamedOptions({ - input: { channel: 'owned' }, refetchMode: 'replace', retry: false, - }) - const queryClient = new QueryClient() - queryClient.setQueryData(options.queryKey, [0]) - deepStrictEqual(yield* Effect.promise(() => fetchStreamSnapshot(queryClient, options)), [1, 2]) - queryClient.setQueryDefaults(options.queryKey, { select: (data) => `items:${Array.isArray(data) ? data.length : 0}` }) - equal(yield* Effect.promise(() => fetchStreamSnapshot(queryClient, options, { mode: 'cached' })), 'items:2') - equal(yield* Effect.promise(() => fetchStreamSnapshot(queryClient, { ...options, select: (values) => values.length })), 2) - deepStrictEqual(queryClient.getQueryData(options.queryKey), [1, 2]) - const observer = new QueryObserver(queryClient, { ...options, enabled: false }) - const unsubscribe = observer.subscribe(() => {}) - yield* Effect.promise(() => rejects(fetchStreamSnapshot(queryClient, options), /idle query without observers/)) - equal(yield* Effect.promise(() => fetchStreamSnapshot(queryClient, { ...options, select: (values) => values.length }, { mode: 'cached' })), 2) - unsubscribe() + equal(queryClient.getQueryCache().getAll().length, 0) equal(queryClient.getQueryCache().hasListeners(), false) - queryClient.clear() -}))) +} + +console.log( + 'Packed stream snapshots, local cleanup, fresh/cached modes, ownership, hashing, and reconnection executed', +) + +await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const client = yield* RpcTest.makeClient(updates, { flatten: true }).pipe( + Effect.provide( + updates.toLayer({ + 'updates.watch': () => + Stream.succeed('announcement').pipe(Stream.concat(Stream.fromEffect(Effect.never))), + }), + ), + ) + const queryClient = new QueryClient() + deepStrictEqual( + yield* Effect.promise(() => + captureUpdates(queryClient, client, new AbortController().signal), + ), + { + history: ['announcement'], + latest: 'announcement', + }, + ) + equal(queryClient.isFetching(), 0) + equal(queryClient.getQueryCache().hasListeners(), false) + queryClient.clear() + }), + ), +) From ae64a00ae96f660e0f2c572a016b4586b80b03f0 Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Sat, 10 Oct 2026 11:17:25 +0200 Subject: [PATCH 36/97] docs: keep cache filter guidance focused on usage --- apps/docs/src/content/docs/guides/cache-filters.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/apps/docs/src/content/docs/guides/cache-filters.md b/apps/docs/src/content/docs/guides/cache-filters.md index 6c471d6..3942e11 100644 --- a/apps/docs/src/content/docs/guides/cache-filters.md +++ b/apps/docs/src/content/docs/guides/cache-filters.md @@ -188,8 +188,3 @@ Concrete builders retain DataTags for typed `getQueryData` and `setQueryData`. M prefixes and partial keys carry no query-data tag, and native `getQueriesData` does not infer a homogeneous result from its filter. Keep broad reads as `unknown` until the application establishes the matched representation; an explicit generic or cast does not validate cached values. - -The [packed filter consumer](https://github.com/ueberBrot/effect-api-query/blob/main/tests/packed-consumer/filter-runtime.mts) -executes these recipes with real QueryClient caches and official Effect test clients. Its -[compiled contract](https://github.com/ueberBrot/effect-api-query/blob/main/tests/packed-consumer/filter-contract.ts) -checks exact DataTags and the limits of partial-key inference. From dc6efae8de40b7b766641d74bb4c70841e294858 Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Sat, 10 Oct 2026 11:18:54 +0200 Subject: [PATCH 37/97] docs: retain transport error types in cache filter recipe --- apps/docs/src/content/docs/guides/cache-filters.md | 9 ++++++--- tests/packed-consumer/docs-cache-filters-rpc.ts | 9 ++++++--- tests/packed-consumer/filter-contract.ts | 11 +++++++++++ 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/apps/docs/src/content/docs/guides/cache-filters.md b/apps/docs/src/content/docs/guides/cache-filters.md index 6c471d6..01f4e28 100644 --- a/apps/docs/src/content/docs/guides/cache-filters.md +++ b/apps/docs/src/content/docs/guides/cache-filters.md @@ -42,10 +42,13 @@ export const usersRpc = RpcGroup.make( Rpc.make('health.ping', { success: Schema.String }), ) -export const userCacheFilters = ( - client: RpcClient.RpcClient.Flat>, +export const userCacheFilters = ( + client: RpcClient.RpcClient.Flat, ClientError>, ) => { - const rpc = createRpcQueryUtils(usersRpc, { client, keyPrefix: ['users-app'] }) + const rpc = createRpcQueryUtils(usersRpc, { + client, + keyPrefix: ['users-app'], + }) const queryPrefix = [...rpc.users.get.key(), 'query'] as const const historyPrefix = [...rpc.users.watch.key(), 'streamed'] as const diff --git a/tests/packed-consumer/docs-cache-filters-rpc.ts b/tests/packed-consumer/docs-cache-filters-rpc.ts index d44ecfd..d9f4a16 100644 --- a/tests/packed-consumer/docs-cache-filters-rpc.ts +++ b/tests/packed-consumer/docs-cache-filters-rpc.ts @@ -14,10 +14,13 @@ export const usersRpc = RpcGroup.make( Rpc.make('health.ping', { success: Schema.String }), ) -export const userCacheFilters = ( - client: RpcClient.RpcClient.Flat>, +export const userCacheFilters = ( + client: RpcClient.RpcClient.Flat, ClientError>, ) => { - const rpc = createRpcQueryUtils(usersRpc, { client, keyPrefix: ['users-app'] }) + const rpc = createRpcQueryUtils(usersRpc, { + client, + keyPrefix: ['users-app'], + }) const queryPrefix = [...rpc.users.get.key(), 'query'] as const const historyPrefix = [...rpc.users.watch.key(), 'streamed'] as const diff --git a/tests/packed-consumer/filter-contract.ts b/tests/packed-consumer/filter-contract.ts index 0e2e9f1..12b2c9b 100644 --- a/tests/packed-consumer/filter-contract.ts +++ b/tests/packed-consumer/filter-contract.ts @@ -1,5 +1,6 @@ import { QueryClient } from '@tanstack/query-core' import type { QueryFilters } from '@tanstack/query-core' +import type { EffectRpcQueryError } from 'effect-api-query' import { HttpApiClient } from 'effect/http-api' import { RpcClient, RpcGroup } from 'effect/rpc' @@ -14,9 +15,19 @@ type Assert = Value declare const rpcClient: RpcClient.RpcClient.Flat> declare const httpClient: HttpApiClient.ForApi +declare const transportClient: RpcClient.RpcClient.Flat< + RpcGroup.Rpcs, + { readonly _tag: 'TransportUnavailable' } +> const queryClient = new QueryClient() const rpcFilters = userCacheFilters(rpcClient) const httpFilters = httpCacheFilters(httpClient) +const transportFilters = userCacheFilters(transportClient) +const transportState = queryClient.getQueryState(transportFilters.exact(1).queryKey) +transportState?.error satisfies + | EffectRpcQueryError<{ readonly _tag: 'TransportUnavailable' }> + | null + | undefined rpcFilters.root.queryKey satisfies readonly ['users-app', 'rpc'] rpcFilters.branch.queryKey satisfies readonly ['users-app', 'rpc', 'users'] From 1d7d78db91c12b41733825ff8f0a53fbb0fc23e9 Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Sat, 10 Oct 2026 11:29:14 +0200 Subject: [PATCH 38/97] test: exercise snapshot controls through the source barrel --- examples/tanstack-start/tests/server-render.test.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/examples/tanstack-start/tests/server-render.test.tsx b/examples/tanstack-start/tests/server-render.test.tsx index 9f6c180..eb88503 100644 --- a/examples/tanstack-start/tests/server-render.test.tsx +++ b/examples/tanstack-start/tests/server-render.test.tsx @@ -8,6 +8,8 @@ import type { RpcClient } from 'effect/rpc' import { renderToString } from 'react-dom/server' import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test' +import { fetchStreamSnapshot as captureStreamSnapshot } from '../../../src/index.ts' +import type { StreamSnapshotOptions } from '../../../src/index.ts' import { fetchStreamSnapshot } from '../src/lib/query-ssr.ts' import { createTanStackStartRouter } from '../src/router.tsx' @@ -140,7 +142,8 @@ describe('TanStack Start server rendering', () => { queryClient.setQueryData(options.queryKey, ['previous']) } - const snapshot = await fetchStreamSnapshot(queryClient, options) + const controls: StreamSnapshotOptions = { mode: 'fresh', timeoutMs: 1000 } + const snapshot = await captureStreamSnapshot(queryClient, options, controls) expect(snapshot).toStrictEqual(['snapshot']) expect(queryClient.getQueryState(options.queryKey)?.fetchStatus).toBe('idle') From 159387cc0503866a96b82e6af789b597c74828ae Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Sat, 10 Oct 2026 11:42:07 +0200 Subject: [PATCH 39/97] Isolate application cache owners and persisted directories --- .fallowrc.jsonc | 2 + apps/docs/astro.config.mjs | 1 + .../content/docs/guides/cache-management.md | 3 + .../docs/guides/switch-cache-owners.md | 177 +++++++++++ examples/vite-react/src/app.tsx | 2 +- .../components/sections/commands-section.tsx | 13 +- .../sections/diagnostics-section.tsx | 8 +- .../src/components/sections/http-section.tsx | 4 +- .../components/sections/mutations-section.tsx | 6 +- .../src/hooks/use-slow-query-cancellation.ts | 13 +- examples/vite-react/src/lib/application.ts | 71 ++++- examples/vite-react/src/lib/owner-cache.ts | 175 +++++++++++ examples/vite-react/src/main.tsx | 3 +- .../vite-react/tests/owner-boundary.test.tsx | 60 ++++ examples/vite-react/tests/owner-cache.test.ts | 219 ++++++++++++++ .../vite-react/tests/owner-switch.test.ts | 111 +++++++ scripts/verify-docs-examples.mts | 1 + scripts/verify-packed-consumer.mts | 6 + tests/packed-consumer/docs-owner-cache.ts | 98 ++++++ tests/packed-consumer/owner-cache-contract.ts | 57 ++++ tests/packed-consumer/owner-cache-runtime.mts | 286 ++++++++++++++++++ tests/packed-consumer/owner-cache.ts | 5 + tests/packed-consumer/tsconfig.json | 4 + 23 files changed, 1301 insertions(+), 24 deletions(-) create mode 100644 apps/docs/src/content/docs/guides/switch-cache-owners.md create mode 100644 examples/vite-react/src/lib/owner-cache.ts create mode 100644 examples/vite-react/tests/owner-boundary.test.tsx create mode 100644 examples/vite-react/tests/owner-cache.test.ts create mode 100644 examples/vite-react/tests/owner-switch.test.ts create mode 100644 tests/packed-consumer/docs-owner-cache.ts create mode 100644 tests/packed-consumer/owner-cache-contract.ts create mode 100644 tests/packed-consumer/owner-cache-runtime.mts create mode 100644 tests/packed-consumer/owner-cache.ts diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 52d0538..dfc3018 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -4,6 +4,8 @@ // Astro copies this browser entry from public/ and loads it through the // Starlight head configuration, which static import analysis cannot follow. "entry": [ + "tests/packed-consumer/owner-cache-runtime.mts", + "tests/packed-consumer/owner-cache-contract.ts", "apps/docs/public/package-version.js", "playwright.docs.config.ts", "tests/packed-consumer/hashing-runtime.mts", diff --git a/apps/docs/astro.config.mjs b/apps/docs/astro.config.mjs index 1cc95ef..b6f81e1 100644 --- a/apps/docs/astro.config.mjs +++ b/apps/docs/astro.config.mjs @@ -70,6 +70,7 @@ export default defineConfig({ { label: 'TanStack Start', slug: 'guides/tanstack-start' }, { label: 'Cache Management', slug: 'guides/cache-management' }, { label: 'Cache Filters', slug: 'guides/cache-filters' }, + { label: 'Switch Cache Owners', slug: 'guides/switch-cache-owners' }, { label: 'Cancellation', slug: 'guides/cancellation' }, { label: 'Handle Failures', slug: 'guides/handle-failures' }, { label: 'Retry Queries', slug: 'guides/retry-queries' }, diff --git a/apps/docs/src/content/docs/guides/cache-management.md b/apps/docs/src/content/docs/guides/cache-management.md index 193f7f6..981f77a 100644 --- a/apps/docs/src/content/docs/guides/cache-management.md +++ b/apps/docs/src/content/docs/guides/cache-management.md @@ -38,6 +38,9 @@ Query keys have this flat shape: Mutation keys end in `'mutation'` and never include variables. See [Semantic Keys](/effect-api-query/concepts/semantic-keys/) for normalization and hashing rules. +For private caches across user, tenant, session, or permission changes, follow +[Switch Cache Owners](/effect-api-query/guides/switch-cache-owners/). + ## Manage HTTP caches HTTP utilities also expose branch prefixes and keys for individual queries. Supply the endpoint's diff --git a/apps/docs/src/content/docs/guides/switch-cache-owners.md b/apps/docs/src/content/docs/guides/switch-cache-owners.md new file mode 100644 index 0000000..0cc7283 --- /dev/null +++ b/apps/docs/src/content/docs/guides/switch-cache-owners.md @@ -0,0 +1,177 @@ +--- +title: Switch Cache Owners +description: Isolate private queries, pending writes, and persisted directory data when an owner changes. +--- + +Include every safe identity that changes a result in the key prefix: tenant, user, session +generation, and permission generation. Use application identifiers and numeric generations; +bearer credentials never belong in keys or storage names. A permission change needs a new generation +even when the user and tenant stay the same. These values partition caches; the server still +owns authorization. + +## Capture one owner + +Copy [the existing Vite application's owner cache](https://github.com/ueberBrot/effect-api-query/blob/main/examples/vite-react/src/lib/owner-cache.ts) +into `owner-cache.ts`. The following focused recipe accepts already acquired clients and captures +one QueryClient, identity, and set of generated utilities. Keep their owning Scopes and runtimes +alive until this owner's work finishes. + +```ts +import { QueryClient } from '@tanstack/react-query' +import { Effect, Schema } from 'effect' +import { createHttpApiQueryUtils, createRpcQueryUtils } from 'effect-api-query' +import type { RunPromiseExit } from 'effect-api-query' +import { HttpApi, HttpApiEndpoint, HttpApiGroup } from 'effect/http-api' +import type { HttpApiClient } from 'effect/http-api' +import { Rpc, RpcGroup } from 'effect/rpc' +import type { RpcClient } from 'effect/rpc' + +import { makeOwnerCache, ownerKeyPrefix } from './owner-cache.ts' +import type { ApplicationOwnerIdentity, DirectoryStorage } from './owner-cache.ts' + +export const DirectoryUser = Schema.Struct({ + id: Schema.Int, + name: Schema.String, + locale: Schema.String, +}) + +export const ownerGroup = RpcGroup.make( + Rpc.make('users.list', { success: Schema.Array(DirectoryUser) }), + Rpc.make('users.get', { payload: { id: Schema.Int }, success: DirectoryUser }), + Rpc.make('users.create', { + payload: { name: Schema.String }, + success: DirectoryUser, + }), + Rpc.make('users.watch', { success: Schema.String, stream: true }), +) + +export const ownerApi = HttpApi.make('owner-directory').add( + HttpApiGroup.make('users').add( + HttpApiEndpoint.get('list', '/users', { success: Schema.Array(DirectoryUser) }), + HttpApiEndpoint.get('get', '/users/:id', { + params: { id: Schema.Int }, + success: DirectoryUser, + }), + HttpApiEndpoint.post('create', '/users', { + payload: { name: Schema.String }, + success: DirectoryUser, + }), + ), +) + +export const makeOwnerQueries = ({ + rpcClient, + httpClient, + identity, + storage, + runPromiseExit = Effect.runPromiseExit, +}: { + readonly rpcClient: RpcClient.RpcClient.Flat, RpcError> + readonly httpClient: HttpApiClient.ForApi + readonly identity: ApplicationOwnerIdentity + readonly storage?: DirectoryStorage + readonly runPromiseExit?: RunPromiseExit +}) => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false, networkMode: 'always' }, + }, + }) + const keyPrefix = ownerKeyPrefix(identity) + const rpc = createRpcQueryUtils(ownerGroup, { + client: rpcClient, + keyPrefix, + runPromiseExit, + }) + const http = createHttpApiQueryUtils(ownerApi, { client: httpClient, keyPrefix, runPromiseExit }) + const owner = makeOwnerCache({ + identity, + queryClient, + directoryKeys: { rpc: rpc.users.list.queryKey(), http: http.users.list.queryKey() }, + storage, + }) + owner.restoreDirectory() + const invalidateUsers = async () => { + if (!owner.isActive()) { + return + } + await Promise.all([ + queryClient.invalidateQueries({ queryKey: rpc.users.key() }), + queryClient.invalidateQueries({ queryKey: http.users.key() }), + ]) + } + const createUser = owner.trackMutationOptions( + rpc.users.create.mutationOptions({ + onSuccess: async (user) => { + if (!owner.isActive()) { + return + } + queryClient.setQueryData(rpc.users.get.queryKey({ id: user.id }), user) + await invalidateUsers() + }, + onSettled: invalidateUsers, + }), + ) + return { owner, queryClient, rpc, http, createUser, invalidateUsers } +} +``` + +The success callback captures its original owner. It checks that owner before seeding a concrete +query, and settlement invalidation uses the same guard. An old callback never looks up a current +application variable to choose where to write. Apply the guard to rollback, optimistic writes, +manual cache updates, and any later work after an `await` too. + +## Replace the consumer boundary + +The [existing Vite application](https://github.com/ueberBrot/effect-api-query/blob/main/examples/vite-react/src/lib/application.ts) +provides `switchViteReactApplication(previous, options)`. Capture `previous`, unmount its consumer +tree, then await the handoff and render the returned application. The helper retires the old owner +before constructing the new clients, QueryClient, and utilities. A failed acquisition leaves the +old owner inactive; handle that failure in the surrounding application. + +[ViteReactExample](https://github.com/ueberBrot/effect-api-query/blob/main/examples/vite-react/src/app.tsx) +keys its QueryClientProvider by the immutable owner identity. A changed identity or generation +therefore remounts query and mutation consumers. Passing a new QueryClient through an existing +provider alone does not replace the QueryClient captured by existing hooks. Reassigning options +on a pending MutationObserver can also replace its callbacks. Capture originating ownership in +mutation context and replace observers at the handoff; arbitrary observer reassignment is outside +this recipe. + +Retirement immediately marks the captured owner inactive and removes its persisted snapshot. +It cancels old queries and streams, clears both native caches, and waits for accepted mutation +requests to settle. The application then releases its HTTP runtime and RPC client Scope. Complete +local stream cleanup before acquiring the next owner. Native `cancelQueries()` settlement alone +is not a finalizer acknowledgement, and local disposal is not a remote server acknowledgement. + +The example attempts mutations immediately with `networkMode: 'always'` and `retry: false`. +It does not keep an offline mutation queue. `runMutation` tracks requests that have started; +new requests are rejected after retirement. Requests already accepted may complete on the server, +and switching owners does not undo them. Their `onSuccess` or `onSettled` callbacks may finish +after the request drain, so the ownership guard remains necessary. A hanging request or local +finalizer can delay the handoff; this policy supplies no completion deadline. Do not introduce +serialized mutation queues without an explicit ownership and shutdown policy. + +## Restore a directory snapshot + +Pass application-approved Storage to the Vite application through `directoryStorage`. The browser +entry uses sessionStorage. `persistDirectory()` writes only the RPC and HTTP `users.list` DTOs: +arrays of `{ id: number, name: string, locale: string }`. It stores no mutations, errors, arbitrary +query keys, classes, streams, infinite pages, or bearer credentials. A pending mutation removes +the stored snapshot so an optimistic directory cannot be reused after reload. + +Storage names include the full safe owner tuple. Each envelope repeats that identity and includes +`schemaVersion: 1` and `keyVersion: 1`. Restoration validates the complete envelope and all DTO +entries, checks both busters and all owner fields, then writes through keys derived by the current +utilities. Foreign or incompatible data is deleted before any cache write. A different owner's +storage slot is never read. Bump the schema buster when DTO meaning changes and the key buster +when the persisted view's key contract changes. + +Restored DTOs are plain data with `dataUpdatedAt: 0`, so ordinary queries refetch them. Class +constructors and methods are not restored by this recipe; use a paired codec from +[Hydrate Unary Data](/effect-api-query/guides/hydrate-unary-data/) when the domain requires them. + +Default `dispose()` and owner switches erase persistence. The browser's pagehide cleanup explicitly +uses `dispose({ discardPersistence: false })` to snapshot a settled directory for a reload while +retiring the old in-memory owner and releasing its clients. Old-owner persistence calls become +no-ops. Storage failures propagate while query, cache, and client cleanup still run. diff --git a/examples/vite-react/src/app.tsx b/examples/vite-react/src/app.tsx index 243fa1b..ff6aa97 100644 --- a/examples/vite-react/src/app.tsx +++ b/examples/vite-react/src/app.tsx @@ -34,7 +34,7 @@ export const ViteReactExample = ({ }: { readonly application: ViteReactApplication }) => ( - + ) diff --git a/examples/vite-react/src/components/sections/commands-section.tsx b/examples/vite-react/src/components/sections/commands-section.tsx index 05afc20..717aa8a 100644 --- a/examples/vite-react/src/components/sections/commands-section.tsx +++ b/examples/vite-react/src/components/sections/commands-section.tsx @@ -15,13 +15,20 @@ const Command = ({ // This command keeps its idempotency key for the component lifetime. // oxlint-disable-next-line react/hook-use-state const [operationId] = useState(() => globalThis.crypto.randomUUID()) - const { rpcQuery, queryClient } = application + const { rpcQuery, queryClient, trackMutationOptions } = application const input = { operationId } const reconcile = async () => { + if (!application.isActive()) { + return + } await queryClient.invalidateQueries({ queryKey: rpcQuery.commands.status.queryKey(input) }) } - const start = useMutation(rpcQuery.commands.start.mutationOptions({ onSettled: reconcile })) - const cancel = useMutation(rpcQuery.commands.cancel.mutationOptions({ onSettled: reconcile })) + const start = useMutation( + trackMutationOptions(rpcQuery.commands.start.mutationOptions({ onSettled: reconcile })), + ) + const cancel = useMutation( + trackMutationOptions(rpcQuery.commands.cancel.mutationOptions({ onSettled: reconcile })), + ) const status = useQuery( rpcQuery.commands.status.queryOptions({ input, diff --git a/examples/vite-react/src/components/sections/diagnostics-section.tsx b/examples/vite-react/src/components/sections/diagnostics-section.tsx index c7e3e08..dd0303d 100644 --- a/examples/vite-react/src/components/sections/diagnostics-section.tsx +++ b/examples/vite-react/src/components/sections/diagnostics-section.tsx @@ -14,9 +14,11 @@ export const DiagnosticsSection = ({ readonly application: ViteReactApplication }) => { const declaredFailure = useMutation( - application.rpcQuery.diagnostics.fail.mutationOptions({ - rpcOptions: { headers: { 'x-request-source': 'diagnostics-panel' } }, - }), + application.trackMutationOptions( + application.rpcQuery.diagnostics.fail.mutationOptions({ + rpcOptions: { headers: { 'x-request-source': 'diagnostics-panel' } }, + }), + ), ) const slowQuery = useSlowQueryCancellation(application) const message = describeSlowQueryCancellation(slowQuery.state) diff --git a/examples/vite-react/src/components/sections/http-section.tsx b/examples/vite-react/src/components/sections/http-section.tsx index 4c4ac5b..978c864 100644 --- a/examples/vite-react/src/components/sections/http-section.tsx +++ b/examples/vite-react/src/components/sections/http-section.tsx @@ -67,7 +67,9 @@ export const HttpSection = ({ application }: { readonly application: ViteReactAp }, }), ) - const failure = useMutation(httpQuery.diagnostics.fail.mutationOptions()) + const failure = useMutation( + application.trackMutationOptions(httpQuery.diagnostics.fail.mutationOptions()), + ) const slowQuery = useSlowQueryCancellation(application, 'http') const slowMessage = describeSlowQueryCancellation(slowQuery.state) const submit = (event: SubmitEvent) => { diff --git a/examples/vite-react/src/components/sections/mutations-section.tsx b/examples/vite-react/src/components/sections/mutations-section.tsx index 26ea833..122617a 100644 --- a/examples/vite-react/src/components/sections/mutations-section.tsx +++ b/examples/vite-react/src/components/sections/mutations-section.tsx @@ -10,7 +10,7 @@ export const MutationsSection = ({ }: { readonly application: ViteReactApplication }) => { - const { invalidateUsers, rpcQuery } = application + const { invalidateUsers, rpcQuery, trackMutationOptions } = application const voidQuery = useQuery( rpcQuery.diagnostics.cancel.queryOptions({ enabled: false, @@ -18,10 +18,10 @@ export const MutationsSection = ({ }), ) const resetUsers = useMutation( - rpcQuery.testing.reset.mutationOptions({ onSuccess: invalidateUsers }), + trackMutationOptions(rpcQuery.testing.reset.mutationOptions({ onSuccess: invalidateUsers })), ) const seedUsers = useMutation( - rpcQuery.testing.seed.mutationOptions({ onSuccess: invalidateUsers }), + trackMutationOptions(rpcQuery.testing.seed.mutationOptions({ onSuccess: invalidateUsers })), ) return ( diff --git a/examples/vite-react/src/hooks/use-slow-query-cancellation.ts b/examples/vite-react/src/hooks/use-slow-query-cancellation.ts index 2da308e..79314b3 100644 --- a/examples/vite-react/src/hooks/use-slow-query-cancellation.ts +++ b/examples/vite-react/src/hooks/use-slow-query-cancellation.ts @@ -49,7 +49,7 @@ const delay = async (milliseconds: number) => /** Coordinates typed diagnostic operations as one cancellation demonstration. */ export const useSlowQueryCancellation = ( - { queryClient, rpcQuery, httpQuery }: ViteReactApplication, + { queryClient, rpcQuery, httpQuery, isActive }: ViteReactApplication, transport: 'rpc' | 'http' = 'rpc', ) => { const [state, setState] = useState({ _tag: 'Idle' }) @@ -93,6 +93,9 @@ export const useSlowQueryCancellation = ( predicate: (status: DiagnosticStatus) => boolean, ): Promise => { for (let attempt = 0; attempt < 100; attempt += 1) { + if (!isActive()) { + throw new Error('Owner is inactive') + } // Each poll depends on the preceding result and must finish before the next attempt. // oxlint-disable-next-line eslint/no-await-in-loop const status = await adapter.readStatus() @@ -115,9 +118,15 @@ export const useSlowQueryCancellation = ( } const start = async () => { + if (!isActive()) { + return + } setState({ _tag: 'Starting' }) try { const before = await adapter.readStatus() + if (!isActive()) { + return + } baseline.current = before void runQuery() await waitForStatus(({ started }) => started > before.started) @@ -129,7 +138,7 @@ export const useSlowQueryCancellation = ( const cancel = async () => { const before = baseline.current - if (before === undefined) { + if (before === undefined || !isActive()) { return } diff --git a/examples/vite-react/src/lib/application.ts b/examples/vite-react/src/lib/application.ts index 6cd4ddb..b13bfaf 100644 --- a/examples/vite-react/src/lib/application.ts +++ b/examples/vite-react/src/lib/application.ts @@ -8,10 +8,17 @@ import type { RunPromiseExit } from 'effect-api-query' import { FetchHttpClient, HttpClient, HttpClientRequest } from 'effect/http' import { HttpApiClient } from 'effect/http-api' -const makeExampleRpcQueryUtils = (client: ExampleRpcClient, runPromiseExit: RunPromiseExit) => +import { makeOwnerCache, ownerKeyPrefix } from './owner-cache.ts' +import type { ApplicationOwnerIdentity, DirectoryStorage } from './owner-cache.ts' + +const makeExampleRpcQueryUtils = ( + client: ExampleRpcClient, + runPromiseExit: RunPromiseExit, + keyPrefix: ReturnType, +) => createRpcQueryUtils(exampleRpcGroup, { client, - keyPrefix: ['vite-react'] as const, + keyPrefix, runPromiseExit, }) @@ -20,12 +27,18 @@ export type ExampleRpcQueryUtils = ReturnType const makeExampleHttpQueryUtils = ( client: HttpApiClient.ForApi, runPromiseExit: RunPromiseExit, -) => createHttpApiQueryUtils(exampleHttpApi, { client, keyPrefix: ['vite-react'], runPromiseExit }) + keyPrefix: ReturnType, +) => createHttpApiQueryUtils(exampleHttpApi, { client, keyPrefix, runPromiseExit }) export interface ViteReactApplication { readonly httpQuery: ReturnType readonly invalidateUsers: () => Promise - readonly dispose: () => Promise + readonly dispose: (options?: { readonly discardPersistence?: boolean }) => Promise + readonly identity: ApplicationOwnerIdentity + readonly isActive: () => boolean + readonly persistDirectory: () => void + readonly runMutation: (execute: () => Promise) => Promise + readonly trackMutationOptions: ReturnType['trackMutationOptions'] readonly queryClient: QueryClient readonly rpcQuery: ExampleRpcQueryUtils } @@ -33,27 +46,38 @@ export interface ViteReactApplication { export interface StartViteReactApplicationOptions { readonly rpcUrl: string readonly httpBaseUrl?: string + readonly identity?: ApplicationOwnerIdentity + readonly directoryStorage?: DirectoryStorage } /** Creates clients, query utilities, and cleanup together so the example can be copied as a whole. */ export const startViteReactApplication = async ({ rpcUrl, httpBaseUrl = new URL(rpcUrl, globalThis.location?.href).origin, + identity = { + tenantId: 'example-team', + userId: 'example-user', + sessionGeneration: 1, + permissionGeneration: 1, + }, + directoryStorage, }: StartViteReactApplicationOptions): Promise => { + const keyPrefix = ownerKeyPrefix(identity) const rpcClient = await startExampleRpcClient(rpcUrl) const httpRuntime = ManagedRuntime.make(FetchHttpClient.layer) const queryClient = new QueryClient({ defaultOptions: { - mutations: { retry: false }, + mutations: { retry: false, networkMode: 'always' }, queries: { retry: false }, }, }) + let owner: ReturnType | undefined let disposal: Promise | undefined - const dispose = async () => { - // Stop queries before releasing the ready clients they execute through. + const dispose = async (options?: { readonly discardPersistence?: boolean }) => { + const retirement = owner === undefined ? queryClient.cancelQueries() : owner.retire(options) disposal ??= (async () => { try { - await queryClient.cancelQueries() + await retirement } finally { queryClient.clear() try { @@ -75,11 +99,30 @@ export const startViteReactApplication = async ({ ), }), ) - const httpQuery = makeExampleHttpQueryUtils(httpClient, httpRuntime.runPromiseExit) - const rpcQuery = makeExampleRpcQueryUtils(rpcClient.client, rpcClient.runPromiseExit) + const httpQuery = makeExampleHttpQueryUtils(httpClient, httpRuntime.runPromiseExit, keyPrefix) + const rpcQuery = makeExampleRpcQueryUtils(rpcClient.client, rpcClient.runPromiseExit, keyPrefix) + owner = makeOwnerCache({ + identity, + queryClient, + directoryKeys: { + rpc: rpcQuery.users.list.queryKey(), + http: httpQuery.users.list.queryKey(), + }, + storage: directoryStorage, + }) + owner.restoreDirectory() + const capturedOwner = owner return { httpQuery, + identity: capturedOwner.identity, + isActive: capturedOwner.isActive, + persistDirectory: capturedOwner.persistDirectory, + runMutation: capturedOwner.runMutation, + trackMutationOptions: capturedOwner.trackMutationOptions, invalidateUsers: async () => { + if (!capturedOwner.isActive()) { + return + } await Promise.all([ queryClient.invalidateQueries({ queryKey: rpcQuery.users.key() }), queryClient.invalidateQueries({ queryKey: httpQuery.users.key() }), @@ -100,3 +143,11 @@ export const startViteReactApplication = async ({ throw error } } + +export const switchViteReactApplication = async ( + previous: ViteReactApplication, + options: StartViteReactApplicationOptions, +): Promise => { + await previous.dispose() + return startViteReactApplication(options) +} diff --git a/examples/vite-react/src/lib/owner-cache.ts b/examples/vite-react/src/lib/owner-cache.ts new file mode 100644 index 0000000..7c219b6 --- /dev/null +++ b/examples/vite-react/src/lib/owner-cache.ts @@ -0,0 +1,175 @@ +import type { + MutationFunction, + MutationOptions, + QueryClient, + QueryKey, +} from '@tanstack/react-query' +import { Schema } from 'effect' + +const OwnerIdentity = Schema.Struct({ + tenantId: Schema.NonEmptyString, + userId: Schema.NonEmptyString, + sessionGeneration: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), + permissionGeneration: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), +}) + +export type ApplicationOwnerIdentity = typeof OwnerIdentity.Type +export type DirectoryStorage = Pick + +const Directory = Schema.Array( + Schema.Struct({ id: Schema.Int, name: Schema.String, locale: Schema.String }), +) + +const PersistedDirectory = Schema.fromJsonString( + Schema.Struct({ + identity: OwnerIdentity, + schemaVersion: Schema.Literal(1), + keyVersion: Schema.Literal(1), + directories: Schema.Array( + Schema.Struct({ adapter: Schema.Literals(['rpc', 'http']), data: Directory }), + ), + }), +) + +export const ownerKeyPrefix = (identity: ApplicationOwnerIdentity) => { + const owner = Schema.decodeSync(OwnerIdentity)(identity) + return [ + 'vite-react', + owner.tenantId, + owner.userId, + owner.sessionGeneration, + owner.permissionGeneration, + ] as const +} + +export const makeOwnerCache = ({ + identity, + queryClient, + directoryKeys, + storage, +}: { + readonly identity: ApplicationOwnerIdentity + readonly queryClient: QueryClient + readonly directoryKeys: Readonly> + readonly storage?: DirectoryStorage | undefined +}) => { + const owner = Object.freeze(Schema.decodeSync(OwnerIdentity)(identity)) + const storageKey = `effect-api-query:directory:${JSON.stringify(ownerKeyPrefix(owner))}` + const pendingMutations = new Set>() + let active = true + let retirement: Promise | undefined + const isActive = () => active + const runMutation = async (execute: () => Promise): Promise => { + if (!active) { + throw new Error('Owner is inactive') + } + const result = (async () => { + await Promise.resolve() + return execute() + })() + pendingMutations.add(result) + try { + return await result + } finally { + pendingMutations.delete(result) + } + } + const trackMutationOptions = ( + options: MutationOptions & { + readonly mutationFn: MutationFunction + }, + ) => ({ + ...options, + networkMode: 'always' as const, + retry: false as const, + mutationFn: async (...args: Parameters>) => + await runMutation(async () => await options.mutationFn(...args)), + }) + const saveDirectory = () => { + if (storage === undefined) { + return + } + if (pendingMutations.size > 0 || queryClient.isMutating() > 0) { + storage.removeItem(storageKey) + return + } + const directories = (['rpc', 'http'] as const).flatMap((adapter) => { + const data = queryClient.getQueryData(directoryKeys[adapter]) + return data === undefined + ? [] + : [{ adapter, data: Schema.decodeUnknownSync(Directory)(data) }] + }) + storage.setItem( + storageKey, + JSON.stringify({ identity: owner, schemaVersion: 1, keyVersion: 1, directories }), + ) + } + const persistDirectory = () => { + if (active) { + saveDirectory() + } + } + const restoreDirectory = () => { + if (!active || storage === undefined) { + return false + } + const json = storage.getItem(storageKey) + if (json === null) { + return false + } + try { + const snapshot = Schema.decodeSync(PersistedDirectory)(json) + if ( + JSON.stringify(ownerKeyPrefix(snapshot.identity)) !== JSON.stringify(ownerKeyPrefix(owner)) + ) { + storage.removeItem(storageKey) + return false + } + for (const { adapter, data } of snapshot.directories) { + queryClient.setQueryData(directoryKeys[adapter], data, { updatedAt: 0 }) + } + return true + } catch { + storage.removeItem(storageKey) + return false + } + } + const retire = async ({ + discardPersistence = true, + }: { readonly discardPersistence?: boolean } = {}) => { + const wasActive = active + active = false + if (retirement !== undefined) { + if (discardPersistence) { + storage?.removeItem(storageKey) + } + return retirement + } + retirement = (async () => { + try { + if (discardPersistence) { + storage?.removeItem(storageKey) + } else if (wasActive) { + saveDirectory() + } + } finally { + try { + await queryClient.cancelQueries() + } finally { + queryClient.clear() + await Promise.allSettled(pendingMutations) + } + } + })() + return retirement + } + return { + identity: owner, + isActive, + runMutation, + trackMutationOptions, + persistDirectory, + restoreDirectory, + retire, + } +} diff --git a/examples/vite-react/src/main.tsx b/examples/vite-react/src/main.tsx index 5a3073b..792c01b 100644 --- a/examples/vite-react/src/main.tsx +++ b/examples/vite-react/src/main.tsx @@ -16,6 +16,7 @@ if (container === null) { const application = await startViteReactApplication({ rpcUrl, httpBaseUrl: import.meta.env.VITE_HTTP_BASE_URL ?? globalThis.location.origin, + directoryStorage: globalThis.sessionStorage, }) const root = createRoot(container) root.render( @@ -31,7 +32,7 @@ const dispose = () => { } disposed = true root.unmount() - void application.dispose() + void application.dispose({ discardPersistence: false }) } globalThis.addEventListener('pagehide', dispose, { once: true }) diff --git a/examples/vite-react/tests/owner-boundary.test.tsx b/examples/vite-react/tests/owner-boundary.test.tsx new file mode 100644 index 0000000..f4574d0 --- /dev/null +++ b/examples/vite-react/tests/owner-boundary.test.tsx @@ -0,0 +1,60 @@ +// @vitest-environment jsdom + +import type { CommandStatus } from '@effect-api-query/contracts' +import { startExampleRpcServer } from '@effect-api-query/server' +import { cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import { Effect, Exit, Scope } from 'effect' +import { describe, expect, it, vi } from 'vite-plus/test' + +import { ViteReactExample } from '../src/app.tsx' +import { startViteReactApplication } from '../src/lib/application.ts' +import type { ViteReactApplication } from '../src/lib/application.ts' + +describe('React consumers at the application owner boundary', () => { + it('remounts a pending mutation observer when the permission generation changes', async () => { + const serverScope = await Effect.runPromise(Scope.make()) + let previous: ViteReactApplication | undefined + let current: ViteReactApplication | undefined + try { + const server = await Effect.runPromise( + startExampleRpcServer().pipe(Scope.provide(serverScope)), + ) + previous = await startViteReactApplication({ rpcUrl: server.rpcUrl }) + const view = render() + const first = screen.getByRole('region', { name: 'Command 1' }) + fireEvent.click(within(first).getByRole('button', { name: 'Start command' })) + await expect(within(first).findByText('Server state: running')).resolves.toBeDefined() + const [status] = previous.queryClient + .getQueriesData({ queryKey: previous.rpcQuery.commands.status.key() }) + .map(([, data]) => data) + expect(status?.operationId).toBeDefined() + if (status === undefined || status === null) { + throw new Error('Expected the started command status') + } + current = await startViteReactApplication({ + rpcUrl: server.rpcUrl, + identity: { ...previous.identity, permissionGeneration: 2 }, + }) + view.rerender() + expect(screen.getAllByText('Start mutation: idle')).toHaveLength(2) + expect(screen.getAllByText('Server state: not started')).toHaveLength(2) + const capturedCurrent = current + await vi.waitFor(() => { + expect( + capturedCurrent.queryClient.getQueryData(capturedCurrent.rpcQuery.users.list.queryKey()), + ).toBeDefined() + }) + await current.rpcQuery.commands.cancel + .mutationOptions() + .mutationFn({ operationId: status.operationId }) + await previous.dispose() + expect(previous.queryClient.getQueryCache().getAll()).toHaveLength(0) + expect(current.queryClient.getMutationCache().getAll()).toHaveLength(0) + } finally { + cleanup() + await previous?.dispose() + await current?.dispose() + await Effect.runPromise(Scope.close(serverScope, Exit.void)) + } + }) +}) diff --git a/examples/vite-react/tests/owner-cache.test.ts b/examples/vite-react/tests/owner-cache.test.ts new file mode 100644 index 0000000..3b30e20 --- /dev/null +++ b/examples/vite-react/tests/owner-cache.test.ts @@ -0,0 +1,219 @@ +import { MutationObserver, onlineManager, QueryClient } from '@tanstack/react-query' +import { Deferred, Effect } from 'effect' +import { describe, expect, it, vi } from 'vite-plus/test' + +import { makeOwnerCache, ownerKeyPrefix } from '../src/lib/owner-cache.ts' + +const identity = { + tenantId: 'team-a', + userId: 'user-a', + sessionGeneration: 3, + permissionGeneration: 7, +} + +const makeStorage = () => { + const values = new Map() + return { + getItem: (key: string) => values.get(key) ?? null, + removeItem: (key: string) => { + values.delete(key) + }, + setItem: (key: string, value: string) => { + values.set(key, value) + }, + values, + } +} + +const makeCache = ( + queryClient: QueryClient, + storage: ReturnType, + owner = identity, +) => { + const prefix = ownerKeyPrefix(owner) + return makeOwnerCache({ + identity: owner, + queryClient, + directoryKeys: { + rpc: [...prefix, 'rpc', 'users', 'list', 'query'], + http: [...prefix, 'http', 'users', 'list', 'query'], + }, + storage, + }) +} + +describe('application private cache owner', () => { + it('attempts accepted native writes without leaving an offline queue on owner handoff', async () => { + const queryClient = new QueryClient() + const owner = makeCache(queryClient, makeStorage()) + const mutation = new MutationObserver( + queryClient, + owner.trackMutationOptions({ + mutationFn: async () => await Promise.resolve(4), + }), + ) + onlineManager.setOnline(false) + const result = mutation.mutate() + try { + await vi.waitFor(() => { + expect(mutation.getCurrentResult().status).toBe('success') + }) + await expect(result).resolves.toBe(4) + await owner.retire() + expect(queryClient.getMutationCache().getAll()).toHaveLength(0) + } finally { + onlineManager.setOnline(true) + await queryClient.resumePausedMutations() + await result + await owner.retire() + } + }) + + it('drains an accepted native mutation and rejects new native work after retirement', async () => { + const queryClient = new QueryClient() + const stored = makeStorage() + const owner = makeCache(queryClient, stored) + queryClient.setQueryData( + [...ownerKeyPrefix(identity), 'rpc', 'users', 'list', 'query'], + [{ id: 1, name: 'Ada', locale: 'en' }], + ) + owner.persistDirectory() + expect(stored.values.size).toBe(1) + const entered = Deferred.makeUnsafe() + const release = Deferred.makeUnsafe() + const mutation = new MutationObserver( + queryClient, + owner.trackMutationOptions({ + mutationFn: async () => { + await Effect.runPromise(Deferred.succeed(entered, undefined)) + return await Effect.runPromise(Deferred.await(release)) + }, + }), + ) + const result = mutation.mutate() + await Effect.runPromise(Deferred.await(entered)) + owner.persistDirectory() + expect(stored.values.size).toBe(0) + const retiring = owner.retire() + await Effect.runPromise(Deferred.succeed(release, 4)) + await expect(result).resolves.toBe(4) + await retiring + const late = new MutationObserver( + queryClient, + owner.trackMutationOptions({ + mutationFn: async () => await Promise.resolve(5), + }), + ) + await expect(late.mutate()).rejects.toThrow('Owner is inactive') + queryClient.clear() + }) + + it('preserves an explicit reload snapshot while retiring its old in-memory owner', async () => { + const storage = makeStorage() + const queryClient = new QueryClient() + const owner = makeCache(queryClient, storage) + const key = [...ownerKeyPrefix(identity), 'rpc', 'users', 'list', 'query'] + queryClient.setQueryData(key, [{ id: 2, name: 'Grace', locale: 'fr' }]) + await owner.retire({ discardPersistence: false }) + expect(owner.isActive()).toBe(false) + expect(queryClient.getQueryCache().getAll()).toHaveLength(0) + expect(storage.values.size).toBe(1) + const browser = new QueryClient() + const resumed = makeCache(browser, storage) + expect(resumed.restoreDirectory()).toBe(true) + expect(browser.getQueryData(key)).toStrictEqual([{ id: 2, name: 'Grace', locale: 'fr' }]) + expect(browser.getQueryState(key)?.dataUpdatedAt).toBe(0) + await resumed.retire() + expect(storage.values.size).toBe(0) + expect(resumed.restoreDirectory()).toBe(false) + }) + + it('restores only validated directory DTOs for the same owner and compatible busters', () => { + const storage = makeStorage() + const server = new QueryClient() + const original = makeCache(server, storage) + const key = [...ownerKeyPrefix(identity), 'rpc', 'users', 'list', 'query'] + server.setQueryData(key, [{ id: 1, name: 'Ada', locale: 'en' }]) + original.persistDirectory() + const [saved] = [...storage.values.entries()] + expect(saved).toBeDefined() + if (saved === undefined) { + throw new Error('Expected a persisted directory') + } + const [storageKey, json] = saved + const browser = new QueryClient() + const restored = makeCache(browser, storage) + expect(restored.restoreDirectory()).toBe(true) + expect(browser.getQueryData(key)).toStrictEqual([{ id: 1, name: 'Ada', locale: 'en' }]) + browser.clear() + const snapshot = { + identity, + schemaVersion: 1, + keyVersion: 1, + directories: [{ adapter: 'rpc', data: [{ id: 1, name: 'Ada', locale: 'en' }] }], + } + for (const incompatible of [ + { ...snapshot, identity: { ...identity, tenantId: 'team-b' } }, + { ...snapshot, identity: { ...identity, permissionGeneration: 8 } }, + { ...snapshot, identity: { ...identity, sessionGeneration: 4 } }, + { ...snapshot, schemaVersion: 2 }, + { ...snapshot, keyVersion: 2 }, + { + ...snapshot, + directories: [{ adapter: 'rpc', data: [{ id: 'invalid', name: 'Ada', locale: 'en' }] }], + }, + ]) { + storage.setItem(storageKey, JSON.stringify(incompatible)) + expect(restored.restoreDirectory()).toBe(false) + expect(browser.getQueryCache().getAll()).toHaveLength(0) + expect(storage.getItem(storageKey)).toBeNull() + } + storage.setItem(storageKey, json) + const other = makeCache(browser, storage, { ...identity, tenantId: 'team-b' }) + expect(other.restoreDirectory()).toBe(false) + expect(storage.getItem(storageKey)).toBe(json) + server.clear() + browser.clear() + }) + + it('retires immediately, removes private state, and lets accepted mutations settle', async () => { + const queryClient = new QueryClient() + const storage = makeStorage() + const owner = makeCache(queryClient, storage) + const key = [...ownerKeyPrefix(identity), 'rpc', 'users', 'list', 'query'] + queryClient.setQueryData(key, [{ id: 1, name: 'Ada', locale: 'en' }]) + owner.persistDirectory() + expect(storage.values.size).toBe(1) + const complete = Deferred.makeUnsafe() + let serverWriteCompleted = false + const result = owner.runMutation(async () => { + const value = await Effect.runPromise(Deferred.await(complete)) + serverWriteCompleted = true + return value + }) + await Promise.resolve() + const retiring = owner.retire() + expect(owner.isActive()).toBe(false) + await expect(owner.runMutation(async () => await Promise.resolve(3))).rejects.toThrow( + 'Owner is inactive', + ) + await Promise.resolve() + expect(queryClient.getQueryData(key)).toBeUndefined() + expect(storage.values.size).toBe(0) + owner.persistDirectory() + expect(storage.values.size).toBe(0) + let retired = false + void (async () => { + await retiring + retired = true + })() + await Promise.resolve() + expect(retired).toBe(false) + await Effect.runPromise(Deferred.succeed(complete, 2)) + await expect(result).resolves.toBe(2) + await retiring + expect(serverWriteCompleted).toBe(true) + expect(retired).toBe(true) + await owner.retire() + }) +}) diff --git a/examples/vite-react/tests/owner-switch.test.ts b/examples/vite-react/tests/owner-switch.test.ts new file mode 100644 index 0000000..8d22877 --- /dev/null +++ b/examples/vite-react/tests/owner-switch.test.ts @@ -0,0 +1,111 @@ +import { startExampleRpcServer } from '@effect-api-query/server' +import { MutationObserver } from '@tanstack/react-query' +import { Deferred, Effect, Exit, Scope } from 'effect' +import { describe, expect, it, vi } from 'vite-plus/test' + +import { startViteReactApplication, switchViteReactApplication } from '../src/lib/application.ts' +import type { ViteReactApplication } from '../src/lib/application.ts' + +const identity = { + tenantId: 'example-team', + userId: 'example-user', + sessionGeneration: 1, + permissionGeneration: 1, +} + +const storage = () => { + const values = new Map() + return { + getItem: (key: string) => values.get(key) ?? null, + removeItem: (key: string) => { + values.delete(key) + }, + setItem: (key: string, value: string) => { + values.set(key, value) + }, + values, + } +} + +describe('existing Vite application owner handoff', () => { + it('isolates delayed mutation callbacks while preserving the completed server write', async () => { + const serverScope = await Effect.runPromise(Scope.make()) + const callbackEntered = Deferred.makeUnsafe() + const releaseCallback = Deferred.makeUnsafe() + const persisted = storage() + let previous: ViteReactApplication | undefined + let current: ViteReactApplication | undefined + try { + const server = await Effect.runPromise( + startExampleRpcServer().pipe(Scope.provide(serverScope)), + ) + previous = await startViteReactApplication({ + rpcUrl: server.rpcUrl, + identity, + directoryStorage: persisted, + }) + const captured = previous + await captured.queryClient.query(captured.rpcQuery.users.list.queryOptions()) + captured.persistDirectory() + expect(persisted.values.size).toBe(1) + const streamOptions = captured.rpcQuery.diagnostics.stream.liveOptions() + const streaming = captured.queryClient.query(streamOptions).catch(() => null) + await vi.waitFor(() => { + expect(captured.queryClient.isFetching({ queryKey: streamOptions.queryKey })).toBe(1) + }) + const writeOptions = captured.rpcQuery.users.create.mutationOptions() + const mutation = new MutationObserver(captured.queryClient, { + ...writeOptions, + mutationFn: async (input) => + await captured.runMutation(async () => await writeOptions.mutationFn(input)), + onSuccess: async (user) => { + await Effect.runPromise(Deferred.succeed(callbackEntered, undefined)) + await Effect.runPromise(Deferred.await(releaseCallback)) + if (captured.isActive()) { + captured.queryClient.setQueryData( + captured.rpcQuery.users.get.queryKey({ id: user.id }), + user, + ) + } + await captured.invalidateUsers() + captured.persistDirectory() + }, + onSettled: async () => { + await captured.invalidateUsers() + captured.persistDirectory() + }, + }) + const writing = mutation.mutate({ name: 'Completed before handoff' }) + await Effect.runPromise(Deferred.await(callbackEntered)) + current = await switchViteReactApplication(captured, { + rpcUrl: server.rpcUrl, + identity: { ...identity, permissionGeneration: 2 }, + directoryStorage: persisted, + }) + expect(captured.isActive()).toBe(false) + expect(captured.queryClient.getQueryCache().getAll()).toHaveLength(0) + expect(captured.queryClient.getMutationCache().getAll()).toHaveLength(0) + expect(persisted.values.size).toBe(0) + expect(current.queryClient).not.toBe(captured.queryClient) + expect(current.rpcQuery.key()).not.toStrictEqual(captured.rpcQuery.key()) + expect(current.httpQuery.key()).not.toStrictEqual(captured.httpQuery.key()) + const users = await current.queryClient.query(current.rpcQuery.users.list.queryOptions()) + expect(users.at(-1)?.name).toBe('Completed before handoff') + const newKey = current.rpcQuery.users.list.queryKey() + current.persistDirectory() + const currentSnapshot = [...persisted.values.entries()] + await Effect.runPromise(Deferred.succeed(releaseCallback, undefined)) + await writing + await streaming + expect(captured.queryClient.getQueryCache().getAll()).toHaveLength(0) + expect(current.queryClient.getQueryState(newKey)?.isInvalidated).toBe(false) + expect(current.queryClient.getQueryData(newKey)).toBe(users) + expect([...persisted.values.entries()]).toStrictEqual(currentSnapshot) + } finally { + await Effect.runPromise(Deferred.succeed(releaseCallback, undefined)) + await previous?.dispose() + await current?.dispose() + await Effect.runPromise(Scope.close(serverScope, Exit.void)) + } + }) +}) diff --git a/scripts/verify-docs-examples.mts b/scripts/verify-docs-examples.mts index 492b4ed..e3255d0 100644 --- a/scripts/verify-docs-examples.mts +++ b/scripts/verify-docs-examples.mts @@ -7,6 +7,7 @@ for (const [page, fixtures] of [ ['getting-started/quick-start', ['types/docs-rpc-quick-start']], ['getting-started/http-quick-start', ['types/docs-http-quick-start']], ['guides/retry-queries', ['packed-consumer/docs-retry']], + ['guides/switch-cache-owners', ['packed-consumer/docs-owner-cache']], [ 'guides/cache-filters', ['packed-consumer/docs-cache-filters-rpc', 'packed-consumer/docs-cache-filters-http'], diff --git a/scripts/verify-packed-consumer.mts b/scripts/verify-packed-consumer.mts index 7997580..4b1c165 100644 --- a/scripts/verify-packed-consumer.mts +++ b/scripts/verify-packed-consumer.mts @@ -420,6 +420,11 @@ const verifyConsumer = (peer: (typeof peerCases)[number]): void => { ) } + cpSync( + nodePath.join(repositoryRoot, 'examples/vite-react/src/lib/owner-cache.ts'), + nodePath.join(consumerDirectory, 'owner-cache.ts'), + ) + const consumerManifest = { name: `effect-api-query-packed-consumer-${peer.label}`, private: true, @@ -505,6 +510,7 @@ const verifyConsumer = (peer: (typeof peerCases)[number]): void => { } for (const fixture of [ + 'owner-cache-runtime.mts', 'hashing-runtime.mts', 'filter-runtime.mts', 'runtime.mts', diff --git a/tests/packed-consumer/docs-owner-cache.ts b/tests/packed-consumer/docs-owner-cache.ts new file mode 100644 index 0000000..a9ea58f --- /dev/null +++ b/tests/packed-consumer/docs-owner-cache.ts @@ -0,0 +1,98 @@ +import { QueryClient } from '@tanstack/react-query' +import { Effect, Schema } from 'effect' +import { createHttpApiQueryUtils, createRpcQueryUtils } from 'effect-api-query' +import type { RunPromiseExit } from 'effect-api-query' +import { HttpApi, HttpApiEndpoint, HttpApiGroup } from 'effect/http-api' +import type { HttpApiClient } from 'effect/http-api' +import { Rpc, RpcGroup } from 'effect/rpc' +import type { RpcClient } from 'effect/rpc' + +import { makeOwnerCache, ownerKeyPrefix } from './owner-cache.ts' +import type { ApplicationOwnerIdentity, DirectoryStorage } from './owner-cache.ts' + +export const DirectoryUser = Schema.Struct({ + id: Schema.Int, + name: Schema.String, + locale: Schema.String, +}) + +export const ownerGroup = RpcGroup.make( + Rpc.make('users.list', { success: Schema.Array(DirectoryUser) }), + Rpc.make('users.get', { payload: { id: Schema.Int }, success: DirectoryUser }), + Rpc.make('users.create', { + payload: { name: Schema.String }, + success: DirectoryUser, + }), + Rpc.make('users.watch', { success: Schema.String, stream: true }), +) + +export const ownerApi = HttpApi.make('owner-directory').add( + HttpApiGroup.make('users').add( + HttpApiEndpoint.get('list', '/users', { success: Schema.Array(DirectoryUser) }), + HttpApiEndpoint.get('get', '/users/:id', { + params: { id: Schema.Int }, + success: DirectoryUser, + }), + HttpApiEndpoint.post('create', '/users', { + payload: { name: Schema.String }, + success: DirectoryUser, + }), + ), +) + +export const makeOwnerQueries = ({ + rpcClient, + httpClient, + identity, + storage, + runPromiseExit = Effect.runPromiseExit, +}: { + readonly rpcClient: RpcClient.RpcClient.Flat, RpcError> + readonly httpClient: HttpApiClient.ForApi + readonly identity: ApplicationOwnerIdentity + readonly storage?: DirectoryStorage + readonly runPromiseExit?: RunPromiseExit +}) => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false, networkMode: 'always' }, + }, + }) + const keyPrefix = ownerKeyPrefix(identity) + const rpc = createRpcQueryUtils(ownerGroup, { + client: rpcClient, + keyPrefix, + runPromiseExit, + }) + const http = createHttpApiQueryUtils(ownerApi, { client: httpClient, keyPrefix, runPromiseExit }) + const owner = makeOwnerCache({ + identity, + queryClient, + directoryKeys: { rpc: rpc.users.list.queryKey(), http: http.users.list.queryKey() }, + storage, + }) + owner.restoreDirectory() + const invalidateUsers = async () => { + if (!owner.isActive()) { + return + } + await Promise.all([ + queryClient.invalidateQueries({ queryKey: rpc.users.key() }), + queryClient.invalidateQueries({ queryKey: http.users.key() }), + ]) + } + const createUser = owner.trackMutationOptions( + rpc.users.create.mutationOptions({ + onSuccess: async (user) => { + if (!owner.isActive()) { + return + } + queryClient.setQueryData(rpc.users.get.queryKey({ id: user.id }), user) + await invalidateUsers() + }, + onSettled: invalidateUsers, + }), + ) + return { owner, queryClient, rpc, http, createUser, invalidateUsers } +} diff --git a/tests/packed-consumer/owner-cache-contract.ts b/tests/packed-consumer/owner-cache-contract.ts new file mode 100644 index 0000000..8dcb654 --- /dev/null +++ b/tests/packed-consumer/owner-cache-contract.ts @@ -0,0 +1,57 @@ +import type { MutationOptions, QueryClient } from '@tanstack/react-query' +import type { Schema } from 'effect' +import type { HttpApiClient } from 'effect/http-api' +import type { RpcClient, RpcGroup } from 'effect/rpc' + +import { DirectoryUser, makeOwnerQueries, ownerApi, ownerGroup } from './docs-owner-cache.ts' +import { ownerKeyPrefix } from './owner-cache.ts' +import type { ApplicationOwnerIdentity } from './owner-cache.ts' + +type Equal = + (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false +type Assert = T + +export type OwnerPrefixProof = Assert< + Equal, readonly ['vite-react', string, string, number, number]> +> + +export const ownerContract = ( + client: RpcClient.RpcClient.Flat, RpcError>, + httpClient: HttpApiClient.ForApi, + identity: ApplicationOwnerIdentity, +) => { + const application = makeOwnerQueries({ rpcClient: client, httpClient, identity }) + const rpcData: readonly (typeof DirectoryUser.Type)[] | undefined = + application.queryClient.getQueryData(application.rpc.users.list.queryKey()) + const httpData: readonly (typeof DirectoryUser.Type)[] | undefined = + application.queryClient.getQueryData(application.http.users.list.queryKey()) + const mutation: MutationOptions = + application.createUser + const queryClient: QueryClient = application.queryClient + return { rpcData, httpData, mutation, queryClient } +} + +export type PersistedDtoProof = Assert< + Equal< + Schema.Schema.Type, + { readonly id: number; readonly name: string; readonly locale: string } + > +> + +if (false) { + ownerKeyPrefix({ + tenantId: 'team', + userId: 'user', + sessionGeneration: 1, + permissionGeneration: 1, + // @ts-expect-error Credentials are not an owner identity field. + bearer: 'credential', + }) + ownerKeyPrefix({ + tenantId: 'team', + userId: 'user', + sessionGeneration: 1, + // @ts-expect-error Permission generations are numeric safe identities. + permissionGeneration: 'token', + }) +} diff --git a/tests/packed-consumer/owner-cache-runtime.mts b/tests/packed-consumer/owner-cache-runtime.mts new file mode 100644 index 0000000..d16a7f9 --- /dev/null +++ b/tests/packed-consumer/owner-cache-runtime.mts @@ -0,0 +1,286 @@ +import { MutationObserver, QueryClient } from '@tanstack/react-query' +import type { QueryKey } from '@tanstack/react-query' +import { Deferred, Effect, Exit, Scope, Stream } from 'effect' +import { HttpServer } from 'effect/http' +import { HttpApiBuilder, HttpApiTest } from 'effect/http-api' +import { RpcTest } from 'effect/rpc' +import { deepStrictEqual, equal, ok, rejects } from 'node:assert/strict' + +import { makeOwnerQueries, ownerApi, ownerGroup } from './docs-owner-cache.ts' +import { makeOwnerCache, ownerKeyPrefix } from './owner-cache.ts' + +const identity = { + tenantId: 'team-a', + userId: 'user-a', + sessionGeneration: 2, + permissionGeneration: 4, +} +const values = new Map() +const storage = { + getItem: (key: string) => values.get(key) ?? null, + removeItem: (key: string) => { + values.delete(key) + }, + setItem: (key: string, json: string) => { + values.set(key, json) + }, +} +const wait = async (deferred: Deferred.Deferred) => + await Effect.runPromise(Deferred.await(deferred).pipe(Effect.timeout('5 seconds'))) +const waitForData = async (queryClient: QueryClient, key: QueryKey, expected: string) => { + const published = Deferred.makeUnsafe() + const check = () => { + if (queryClient.getQueryData(key) === expected) { + Effect.runSync(Deferred.succeed(published, undefined)) + } + } + const unsubscribe = queryClient.getQueryCache().subscribe(check) + try { + check() + await wait(published) + } finally { + unsubscribe() + } +} + +await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const oldScope = yield* Scope.make() + const newScope = yield* Scope.make() + const mutationEntered = Deferred.makeUnsafe() + const releaseMutation = Deferred.makeUnsafe() + const callbackEntered = Deferred.makeUnsafe() + const releaseCallback = Deferred.makeUnsafe() + const finalized = Deferred.makeUnsafe() + const oldDirectory = [{ id: 1, name: 'Old private directory', locale: 'en' }] + let newDirectory = [{ id: 10, name: 'New private directory', locale: 'fr' }] + const makeClients = Effect.fnUntraced(function* (scope: Scope.Scope, isOld: boolean) { + const directory = () => (isOld ? oldDirectory : newDirectory) + const create = Effect.fnUntraced(function* ({ name }: { readonly name: string }) { + if (isOld) { + yield* Deferred.succeed(mutationEntered, undefined) + yield* Deferred.await(releaseMutation) + } + const user = { id: isOld ? 2 : 11, name, locale: 'en' } + if (isOld) { + oldDirectory.push(user) + } else { + newDirectory = [...newDirectory, user] + } + return user + }) + const rpcClient = yield* RpcTest.makeClient(ownerGroup, { flatten: true }).pipe( + Effect.provide( + ownerGroup.toLayer({ + 'users.list': () => Effect.sync(directory), + 'users.get': ({ id }) => + Effect.succeed({ id, name: isOld ? 'Old user' : 'New user', locale: 'en' }), + 'users.create': create, + 'users.watch': () => + Stream.make('old first visible').pipe( + Stream.concat(Stream.fromEffect(Effect.never)), + Stream.ensuring(Deferred.succeed(finalized, undefined).pipe(Effect.asVoid)), + ), + }), + ), + Scope.provide(scope), + ) + const httpClient = yield* HttpApiTest.groups(ownerApi, ['users']).pipe( + Effect.provide( + HttpApiBuilder.group(ownerApi, 'users', (handlers) => + handlers.handleAll({ + list: () => Effect.sync(directory), + get: ({ params }) => + Effect.succeed({ + id: params.id, + name: isOld ? 'Old user' : 'New user', + locale: 'en', + }), + create: ({ payload }) => create(payload), + }), + ), + ), + Scope.provide(scope), + ) + return { rpcClient, httpClient } + }) + const oldClients = yield* makeClients(oldScope, true) + const previous = makeOwnerQueries({ ...oldClients, identity, storage }) + let current: ReturnType | undefined + try { + yield* Effect.promise(() => + previous.queryClient.query(previous.rpc.users.list.queryOptions()), + ) + yield* Effect.promise(() => + previous.queryClient.query(previous.http.users.list.queryOptions()), + ) + previous.owner.persistDirectory() + equal(values.size, 1) + const watchOptions = previous.rpc.users.watch.liveOptions() + const watching = previous.queryClient.query(watchOptions).catch(() => null) + yield* Effect.promise(() => + waitForData(previous.queryClient, watchOptions.queryKey, 'old first visible'), + ) + const mutation = new MutationObserver(previous.queryClient, { + ...previous.createUser, + onSuccess: async (...args) => { + await Effect.runPromise(Deferred.succeed(callbackEntered, undefined)) + await wait(releaseCallback) + await previous.createUser.onSuccess?.(...args) + }, + }) + const writing = mutation.mutate({ name: 'Completed remote write' }) + yield* Deferred.await(mutationEntered) + previous.owner.persistDirectory() + equal(values.size, 0) + let retired = false + const retirement = (async () => { + await previous.owner.retire() + retired = true + })() + equal(previous.owner.isActive(), false) + yield* Effect.promise(() => + rejects( + previous.owner.runMutation(async () => 1), + /Owner is inactive/u, + ), + ) + yield* Effect.promise(async () => { + await Promise.resolve() + }) + equal(retired, false) + equal(previous.queryClient.getQueryCache().getAll().length, 0) + yield* Deferred.succeed(releaseMutation, undefined) + yield* Deferred.await(callbackEntered) + yield* Effect.promise(() => retirement) + deepStrictEqual( + oldDirectory.map((user) => user.name), + ['Old private directory', 'Completed remote write'], + ) + yield* Scope.close(oldScope, Exit.void) + yield* Deferred.await(finalized) + yield* Effect.promise(() => watching) + equal(previous.queryClient.isFetching(), 0) + const newIdentity = { ...identity, permissionGeneration: 5 } + const newClients = yield* makeClients(newScope, false) + current = makeOwnerQueries({ ...newClients, identity: newIdentity, storage }) + ok(current.queryClient !== previous.queryClient) + for (const name of [ + 'tenantId', + 'userId', + 'sessionGeneration', + 'permissionGeneration', + ] as const) { + const nextIdentity = { + ...identity, + [name]: typeof identity[name] === 'number' ? 99 : 'another', + } + ok( + JSON.stringify(ownerKeyPrefix(nextIdentity)) !== + JSON.stringify(ownerKeyPrefix(identity)), + ) + } + deepStrictEqual( + yield* Effect.promise(() => + current!.queryClient.query(current!.rpc.users.list.queryOptions()), + ), + [{ id: 10, name: 'New private directory', locale: 'fr' }], + ) + yield* Effect.promise(() => + current!.queryClient.query(current!.http.users.list.queryOptions()), + ) + current.owner.persistDirectory() + const newSnapshot = [...values.entries()] + yield* Deferred.succeed(releaseCallback, undefined) + yield* Effect.promise(() => writing) + equal(previous.queryClient.getQueryCache().getAll().length, 0) + equal( + current.queryClient.getQueryState(current.rpc.users.list.queryKey())?.isInvalidated, + false, + ) + deepStrictEqual([...values.entries()], newSnapshot) + const restored = makeOwnerQueries({ ...newClients, identity: newIdentity, storage }) + deepStrictEqual( + restored.queryClient.getQueryData(restored.rpc.users.list.queryKey()), + newDirectory, + ) + deepStrictEqual( + restored.queryClient.getQueryData(restored.http.users.list.queryKey()), + newDirectory, + ) + newDirectory = [{ id: 10, name: 'Fresh after restore', locale: 'fr' }] + deepStrictEqual( + yield* Effect.promise(() => + restored.queryClient.query(restored.rpc.users.list.queryOptions()), + ), + newDirectory, + ) + deepStrictEqual( + yield* Effect.promise(() => + restored.queryClient.query(restored.http.users.list.queryOptions()), + ), + newDirectory, + ) + yield* Effect.promise(() => restored.owner.retire()) + equal(values.size, 0) + } finally { + yield* Deferred.succeed(releaseMutation, undefined) + yield* Deferred.succeed(releaseCallback, undefined) + yield* Effect.promise(() => previous.owner.retire()) + if (current !== undefined) { + yield* Effect.promise(() => current!.owner.retire()) + } + yield* Scope.close(oldScope, Exit.void) + yield* Scope.close(newScope, Exit.void) + } + }), + ).pipe(Effect.provide(HttpServer.layerServices)), +) + +const directory = [{ id: 1, name: 'Safe DTO', locale: 'en' }] +const queryClient = new QueryClient() +const directoryKeys = { + rpc: [...ownerKeyPrefix(identity), 'rpc', 'users', 'list', 'query'], + http: [...ownerKeyPrefix(identity), 'http', 'users', 'list', 'query'], +} +const owner = makeOwnerCache({ identity, queryClient, directoryKeys, storage }) +const storageKey = `effect-api-query:directory:${JSON.stringify(ownerKeyPrefix(identity))}` +const snapshot = { + identity, + schemaVersion: 1, + keyVersion: 1, + directories: [{ adapter: 'rpc', data: directory }], +} +for (const invalid of [ + { ...snapshot, identity: { ...identity, userId: 'another-user' } }, + { ...snapshot, identity: { ...identity, tenantId: 'another-team' } }, + { ...snapshot, identity: { ...identity, sessionGeneration: 3 } }, + { ...snapshot, identity: { ...identity, permissionGeneration: 5 } }, + { ...snapshot, schemaVersion: 2 }, + { ...snapshot, keyVersion: 2 }, + { + ...snapshot, + directories: [ + { adapter: 'rpc', data: directory }, + { adapter: 'http', data: [{ id: 'invalid' }] }, + ], + }, +]) { + storage.setItem(storageKey, JSON.stringify(invalid)) + equal(owner.restoreDirectory(), false) + equal(queryClient.getQueryCache().getAll().length, 0) + equal(values.size, 0) +} +storage.setItem(storageKey, '{invalid json') +equal(owner.restoreDirectory(), false) +equal(values.size, 0) +storage.setItem(storageKey, JSON.stringify(snapshot)) +equal(owner.restoreDirectory(), true) +deepStrictEqual(queryClient.getQueryData(directoryKeys.rpc), directory) +equal(queryClient.getQueryState(directoryKeys.rpc)?.dataUpdatedAt, 0) +await owner.retire() +owner.persistDirectory() +equal(owner.restoreDirectory(), false) +equal(values.size, 0) +equal(queryClient.getQueryCache().getAll().length, 0) diff --git a/tests/packed-consumer/owner-cache.ts b/tests/packed-consumer/owner-cache.ts new file mode 100644 index 0000000..9870a31 --- /dev/null +++ b/tests/packed-consumer/owner-cache.ts @@ -0,0 +1,5 @@ +export { makeOwnerCache, ownerKeyPrefix } from '../../examples/vite-react/src/lib/owner-cache.ts' +export type { + ApplicationOwnerIdentity, + DirectoryStorage, +} from '../../examples/vite-react/src/lib/owner-cache.ts' diff --git a/tests/packed-consumer/tsconfig.json b/tests/packed-consumer/tsconfig.json index 4eddf36..5ad5699 100644 --- a/tests/packed-consumer/tsconfig.json +++ b/tests/packed-consumer/tsconfig.json @@ -13,6 +13,10 @@ "types": ["node"] }, "include": [ + "owner-cache.ts", + "docs-owner-cache.ts", + "owner-cache-contract.ts", + "owner-cache-runtime.mts", "hashing-runtime.mts", "filter-runtime.mts", "filter-contract.ts", From 18d0a5e3b480042ed7cdc2321dcd1d84a50b898c Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Sat, 10 Oct 2026 11:50:19 +0200 Subject: [PATCH 40/97] Reconcile optimistic user writes across typed caches --- .fallowrc.jsonc | 2 + apps/docs/astro.config.mjs | 1 + .../content/docs/guides/cache-management.md | 3 + .../content/docs/guides/optimistic-writes.md | 97 +++++ .../src/components/sections/http-section.tsx | 30 +- .../src/components/users/create-user-form.tsx | 8 +- .../src/components/users/user-list.tsx | 14 +- examples/vite-react/src/lib/application.ts | 24 +- examples/vite-react/src/lib/user-writes.ts | 386 ++++++++++++++++++ examples/vite-react/tests/app.test.tsx | 52 +++ examples/vite-react/tests/user-writes.test.ts | 311 ++++++++++++++ scripts/verify-packed-consumer.mts | 57 +++ tests/fixtures/optimistic-users.ts | 238 +++++++++++ tests/packed-consumer/optimistic-contract.ts | 49 +++ tests/packed-consumer/optimistic-runtime.mts | 258 ++++++++++++ tests/packed-consumer/tsconfig.json | 2 + tsconfig.json | 1 + 17 files changed, 1502 insertions(+), 31 deletions(-) create mode 100644 apps/docs/src/content/docs/guides/optimistic-writes.md create mode 100644 examples/vite-react/src/lib/user-writes.ts create mode 100644 examples/vite-react/tests/user-writes.test.ts create mode 100644 tests/fixtures/optimistic-users.ts create mode 100644 tests/packed-consumer/optimistic-contract.ts create mode 100644 tests/packed-consumer/optimistic-runtime.mts diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index aee8e84..a4ecc91 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -7,6 +7,8 @@ "apps/docs/public/package-version.js", "playwright.docs.config.ts", "tests/packed-consumer/hashing-runtime.mts", + "tests/packed-consumer/optimistic-runtime.mts", + "tests/packed-consumer/optimistic-contract.ts", "tests/packed-consumer/filter-runtime.mts", "tests/packed-consumer/filter-contract.ts", "tests/packed-consumer/http-metadata-runtime.mts", diff --git a/apps/docs/astro.config.mjs b/apps/docs/astro.config.mjs index 3653555..d2996fb 100644 --- a/apps/docs/astro.config.mjs +++ b/apps/docs/astro.config.mjs @@ -71,6 +71,7 @@ export default defineConfig({ { label: 'Stream Snapshots', slug: 'guides/stream-snapshots' }, { label: 'Cache Management', slug: 'guides/cache-management' }, { label: 'Cache Filters', slug: 'guides/cache-filters' }, + { label: 'Optimistic User Writes', slug: 'guides/optimistic-writes' }, { label: 'Cancellation', slug: 'guides/cancellation' }, { label: 'Handle Failures', slug: 'guides/handle-failures' }, { label: 'Retry Queries', slug: 'guides/retry-queries' }, diff --git a/apps/docs/src/content/docs/guides/cache-management.md b/apps/docs/src/content/docs/guides/cache-management.md index 193f7f6..452815e 100644 --- a/apps/docs/src/content/docs/guides/cache-management.md +++ b/apps/docs/src/content/docs/guides/cache-management.md @@ -10,6 +10,9 @@ await queryClient.invalidateQueries({ queryKey: rpcQuery.users.key() }) await queryClient.invalidateQueries({ queryKey: rpcQuery.users.get.key() }) ``` +For response seeding, targeted rollback, and overlapping mutations, follow +[Optimistic User Writes](/effect-api-query/guides/optimistic-writes/). + For operation prefixes and partial encoded-input matching, use the complete [Cache Filters recipe](/effect-api-query/guides/cache-filters/). diff --git a/apps/docs/src/content/docs/guides/optimistic-writes.md b/apps/docs/src/content/docs/guides/optimistic-writes.md new file mode 100644 index 0000000..7612350 --- /dev/null +++ b/apps/docs/src/content/docs/guides/optimistic-writes.md @@ -0,0 +1,97 @@ +--- +title: Optimistic User Writes +description: Seed typed entries, roll back individual writes, and reconcile overlapping mutations. +--- + +Use generated concrete keys to update the reads affected by a mutation. Keep the write workflow +in the application, where the relationship between a domain operation and its reads is known. + +The Vite React example applies this workflow to its existing user forms and directory. Copy +[the user-write module](https://github.com/ueberBrot/effect-api-query/blob/main/examples/vite-react/src/lib/user-writes.ts) +and compose it with your application's ready clients and QueryClient, as in +[the application](https://github.com/ueberBrot/effect-api-query/blob/main/examples/vite-react/src/lib/application.ts). +Its RPC and HTTP forms use the same application workflow and maintain distinct cache entries. + +## Seed the mutation response + +After creating a user, the example replaces that mutation's temporary entry in each cached +list with the returned `User`. It also seeds these concrete detail keys: + +- `rpcQuery.users.get.queryKey({ id: user.id, locale: user.locale })` +- `httpQuery.users.get.queryKey({ params: { id: user.id }, query: { locale: user.locale } })` + +The locale is part of the read input. A response with locale `fr` does not satisfy a detail query +whose constructor supplies the default locale `en`. + +Use the generated DataTag keys directly with `getQueryData` and `setQueryData`. QueryClient owns +the hash function, including global and prefix defaults; the generated keys also serve native +cancellation and invalidation. Do not hash the keys independently. + +## Apply and reconcile an optimistic write + +The example's mutation options perform these actions: + +1. Capture the original owner and reserve the write before awaiting anything. +2. Cancel the related RPC and HTTP reads through their generated user branch prefixes. +3. Snapshot the affected list and infinite entries. For deletion, keep the removed entity and its + positions. For creation, allocate a temporary negative ID that belongs to this write. +4. Publish the optimistic change. Pending users cannot be deleted through the forms. +5. Replace a created user's temporary ID with the response, or roll back the failed write. +6. Invalidate both user branches when the final overlapping write settles. + +Mutation responses remain ordinary Effect success values. An optimistic rollback restores the +application's cache; it cannot undo work the server has accepted. + +## Preserve infinite pages + +The example updates its existing four-user page views through their generated infinite keys. +It retains every page, `pageParams`, and unrelated user. A failed deletion restores only the +removed user at its former page position, preserving users created in the meantime. + +Creation adjusts the total count on cached pages. It appends the new user to the last loaded page +only when that page has no next cursor. A partial directory waits for reconciliation to determine +where the user belongs. Each reconstructed page retains the `UserPage` representation. + +Offset pagination can change after deletion or insertion. Preserving the current page structure +keeps the optimistic view usable until settlement invalidation refreshes the server's page +boundaries and cursors. Add concrete keys for any other page sizes or filters your application +uses; this example registers only its existing page size and initial cursor. + +## Keep overlapping writes safe + +A full snapshot rollback can erase a later successful write. The example instead removes a +failed creation's own temporary user, or restores one deleted user without replacing the list. +Overlapping deletions of the same user share the first snapshot: one successful deletion prevents +an older failure from restoring it, while all failed deletions restore the original entity once. + +Each new write cancels an earlier reconciliation fetch before publishing its optimistic change. +Only the final pending write triggers settlement refetches, so an older settlement does not +refresh over a newer optimistic result. Success callbacks also cancel reads before seeding the +returned value. Native cancellation prevents a cancelled query from publishing a late result. + +All writes to these views must participate in the same application workflow. Background domain +refreshes, manual cache replacement, and server-side write conflicts need their own coordination. +The example does not infer server ordering or define a general conflict-resolution policy. + +## Retain the original owner + +The `onMutate` result carries the original owner's commit, rollback, and settlement actions. +Callbacks invoke those actions and check that owner is active. A late response can neither refill +a cleared retired cache nor seed or invalidate a replacement client's entries. + +Native MutationObserver option updates can replace callbacks on a pending mutation. Carrying the +transaction in `onMutate` prevents a later callback closure from redirecting an already-started +write. Remount mutation consumers when replacing an application owner: an options change while +`onMutate` awaits cancellation can otherwise replace the mutation function before it starts. + +Track mutation-function completion before releasing the application's ready clients. These +writes use `networkMode: 'always'` and `retry: false`, so a retired owner does not retain paused +mutation work. They do not use a native mutation scope queue. Mutations have no query abort signal; +retirement and cache clearing do not cancel an accepted server write. + +The existing +[Vite tests](https://github.com/ueberBrot/effect-api-query/blob/main/examples/vite-react/tests/user-writes.test.ts) +and +[copied packed consumer](https://github.com/ueberBrot/effect-api-query/blob/main/tests/packed-consumer/optimistic-runtime.mts) +exercise response seeding, page reconciliation, older failures, late callbacks, stale refetches, +native hashes, and retired owners. diff --git a/examples/vite-react/src/components/sections/http-section.tsx b/examples/vite-react/src/components/sections/http-section.tsx index 4c4ac5b..c792f42 100644 --- a/examples/vite-react/src/components/sections/http-section.tsx +++ b/examples/vite-react/src/components/sections/http-section.tsx @@ -32,7 +32,7 @@ const HttpSelectionResult = ({ } export const HttpSection = ({ application }: { readonly application: ViteReactApplication }) => { - const { httpQuery, invalidateUsers, queryClient } = application + const { httpQuery, invalidateUsers, queryClient, userWrites } = application const users = useQuery(httpQuery.users.list.queryOptions()) const [selectedId, setSelectedId] = useState() const selectedUser = useQuery( @@ -54,19 +54,8 @@ export const HttpSection = ({ application }: { readonly application: ViteReactAp const [name, setName] = useState('') const [locale, setLocale] = useState('') const [cacheMessage, setCacheMessage] = useState() - const createUser = useMutation( - httpQuery.users.create.mutationOptions({ onSuccess: invalidateUsers }), - ) - const deleteUser = useMutation( - httpQuery.users.delete.mutationOptions({ - onSuccess: async (_, input) => { - if (selectedId === input.params.id) { - setSelectedId(undefined) - } - await invalidateUsers() - }, - }), - ) + const createUser = useMutation(userWrites.httpCreate()) + const deleteUser = useMutation(userWrites.httpDelete()) const failure = useMutation(httpQuery.diagnostics.fail.mutationOptions()) const slowQuery = useSlowQueryCancellation(application, 'http') const slowMessage = describeSlowQueryCancellation(slowQuery.state) @@ -123,9 +112,18 @@ export const HttpSection = ({ application }: { readonly application: ViteReactAp HTTP: {user.name} { - deleteUser.mutate({ params: { id: user.id } }) + deleteUser.mutate( + { params: { id: user.id } }, + { + onSuccess: () => { + if (selectedId === user.id) { + setSelectedId(undefined) + } + }, + }, + ) }} type="button" variant="danger" diff --git a/examples/vite-react/src/components/users/create-user-form.tsx b/examples/vite-react/src/components/users/create-user-form.tsx index 8b25170..2e682bb 100644 --- a/examples/vite-react/src/components/users/create-user-form.tsx +++ b/examples/vite-react/src/components/users/create-user-form.tsx @@ -7,14 +7,10 @@ import { ActionButton } from '../ui/action-button.tsx' import { EffectErrorDetails } from '../ui/effect-error-details.tsx' export const CreateUserForm = ({ application }: { readonly application: ViteReactApplication }) => { - const { invalidateUsers, rpcQuery } = application + const { userWrites } = application const [locale, setLocale] = useState('') const [name, setName] = useState('') - const createUser = useMutation( - rpcQuery.users.create.mutationOptions({ - onSuccess: invalidateUsers, - }), - ) + const createUser = useMutation(userWrites.rpcCreate()) const submitUser = (event: SubmitEvent) => { event.preventDefault() diff --git a/examples/vite-react/src/components/users/user-list.tsx b/examples/vite-react/src/components/users/user-list.tsx index 60e1d53..543ccaf 100644 --- a/examples/vite-react/src/components/users/user-list.tsx +++ b/examples/vite-react/src/components/users/user-list.tsx @@ -12,12 +12,8 @@ export const UserList = ({ readonly application: ViteReactApplication readonly users: readonly User[] | undefined }) => { - const { invalidateUsers, rpcQuery } = application - const deleteUser = useMutation( - rpcQuery.users.delete.mutationOptions({ - onSuccess: invalidateUsers, - }), - ) + const { userWrites } = application + const deleteUser = useMutation(userWrites.rpcDelete()) return ( <> @@ -30,12 +26,14 @@ export const UserList = ({ {user.name} - User {user.id}, locale {user.locale} + {user.id < 0 ? 'Adding user' : `User ${String(user.id)}`}, locale {user.locale} { deleteUser.mutate({ id: user.id }) }} diff --git a/examples/vite-react/src/lib/application.ts b/examples/vite-react/src/lib/application.ts index 6cd4ddb..9b37bba 100644 --- a/examples/vite-react/src/lib/application.ts +++ b/examples/vite-react/src/lib/application.ts @@ -7,9 +7,16 @@ import { createHttpApiQueryUtils, createRpcQueryUtils } from 'effect-api-query' import type { RunPromiseExit } from 'effect-api-query' import { FetchHttpClient, HttpClient, HttpClientRequest } from 'effect/http' import { HttpApiClient } from 'effect/http-api' +import type { RpcClientError } from 'effect/rpc' + +import { makeUserWrites } from './user-writes.ts' const makeExampleRpcQueryUtils = (client: ExampleRpcClient, runPromiseExit: RunPromiseExit) => - createRpcQueryUtils(exampleRpcGroup, { + createRpcQueryUtils< + typeof exampleRpcGroup, + readonly ['vite-react'], + RpcClientError.RpcClientError + >(exampleRpcGroup, { client, keyPrefix: ['vite-react'] as const, runPromiseExit, @@ -22,8 +29,13 @@ const makeExampleHttpQueryUtils = ( runPromiseExit: RunPromiseExit, ) => createHttpApiQueryUtils(exampleHttpApi, { client, keyPrefix: ['vite-react'], runPromiseExit }) +export type ExampleHttpQueryUtils = ReturnType + export interface ViteReactApplication { readonly httpQuery: ReturnType + readonly isActive: () => boolean + readonly runMutation: (execute: () => Promise) => Promise + readonly userWrites: ReturnType readonly invalidateUsers: () => Promise readonly dispose: () => Promise readonly queryClient: QueryClient @@ -49,6 +61,13 @@ export const startViteReactApplication = async ({ }, }) let disposal: Promise | undefined + const isActive = () => disposal === undefined + const runMutation = async (execute: () => Promise) => { + if (!isActive()) { + throw new Error('The application owner has retired') + } + return execute() + } const dispose = async () => { // Stop queries before releasing the ready clients they execute through. disposal ??= (async () => { @@ -79,6 +98,9 @@ export const startViteReactApplication = async ({ const rpcQuery = makeExampleRpcQueryUtils(rpcClient.client, rpcClient.runPromiseExit) return { httpQuery, + isActive, + runMutation, + userWrites: makeUserWrites({ queryClient, rpcQuery, httpQuery, isActive, runMutation }), invalidateUsers: async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: rpcQuery.users.key() }), diff --git a/examples/vite-react/src/lib/user-writes.ts b/examples/vite-react/src/lib/user-writes.ts new file mode 100644 index 0000000..c7ce356 --- /dev/null +++ b/examples/vite-react/src/lib/user-writes.ts @@ -0,0 +1,386 @@ +import { User, UserPage } from '@effect-api-query/contracts' +import type { InfiniteData, MutationOptions, QueryClient } from '@tanstack/react-query' + +import type { ExampleHttpQueryUtils, ExampleRpcQueryUtils } from './application.ts' + +export interface UserWriteOwner { + readonly queryClient: QueryClient + readonly rpcQuery: Pick + readonly httpQuery: Pick + readonly isActive: () => boolean + readonly runMutation: (execute: () => Promise) => Promise +} + +export interface CreateTransaction { + readonly commit: (user: User) => Promise + readonly rollback: () => void + readonly settle: () => Promise +} +export interface DeleteTransaction { + readonly commit: () => Promise + readonly rollback: () => void + readonly settle: () => Promise +} +export type UserMutationOptions = MutationOptions< + Data, + Failure, + Input, + Transaction +> & + Required, 'mutationFn' | 'mutationKey'>> +export type RpcCreateOptions = ReturnType< + ExampleRpcQueryUtils['users']['create']['mutationOptions'] +> +export type HttpCreateOptions = ReturnType< + ExampleHttpQueryUtils['users']['create']['mutationOptions'] +> +export type RpcDeleteOptions = ReturnType< + ExampleRpcQueryUtils['users']['delete']['mutationOptions'] +> +export type HttpDeleteOptions = ReturnType< + ExampleHttpQueryUtils['users']['delete']['mutationOptions'] +> +export interface UserWrites { + readonly rpcCreate: () => UserMutationOptions< + User, + Parameters>[0], + Parameters[0], + CreateTransaction + > + readonly httpCreate: () => UserMutationOptions< + User, + Parameters>[0], + Parameters[0], + CreateTransaction + > + readonly rpcDelete: () => UserMutationOptions< + void, + Parameters>[0], + Parameters[0], + DeleteTransaction + > + readonly httpDelete: () => UserMutationOptions< + void, + Parameters>[0], + Parameters[0], + DeleteTransaction + > +} + +const restoreList = ( + current: readonly User[] | undefined, + snapshot: readonly User[] | undefined, + id: number, +) => { + const index = snapshot?.findIndex((user) => user.id === id) ?? -1 + const user = snapshot?.[index] + if ( + current === undefined || + user === undefined || + current.some((existing) => existing.id === id) + ) { + return current + } + return [...current.slice(0, index), user, ...current.slice(index)] +} + +const restorePages = ( + current: InfiniteData | undefined, + snapshot: InfiniteData | undefined, + id: number, +) => { + if (current === undefined || snapshot === undefined) { + return current + } + return { + ...current, + pages: current.pages.map((page, pageIndex) => { + const before = snapshot.pages[pageIndex] + const restored = restoreList(page.users, before?.users, id) ?? page.users + return new UserPage({ users: restored, total: page.total + 1, nextCursor: page.nextCursor }) + }), + } +} + +export const makeUserWrites = ({ + queryClient, + rpcQuery, + httpQuery, + isActive, + runMutation, +}: UserWriteOwner): UserWrites => { + let nextWrite = 0 + const pending = new Set() + const rpcList = rpcQuery.users.list.queryKey() + const httpList = httpQuery.users.list.queryKey() + const rpcPages = rpcQuery.users.page.infiniteKey({ cursor: 0, pageSize: 4 }) + const httpPages = httpQuery.users.page.infiniteKey({ query: { cursor: 0, pageSize: 4 } }) + const cancel = async () => { + await Promise.all([ + queryClient.cancelQueries({ queryKey: rpcQuery.users.key() }), + queryClient.cancelQueries({ queryKey: httpQuery.users.key() }), + ]) + } + const begin = async () => { + if (!isActive()) { + throw new Error('The application owner has retired') + } + nextWrite += 1 + const write = nextWrite + pending.add(write) + try { + await cancel() + if (!isActive()) { + throw new Error('The application owner has retired') + } + return write + } catch (error) { + pending.delete(write) + throw error + } + } + const settle = async (write: number | undefined) => { + if (write === undefined) { + return + } + pending.delete(write) + if (!isActive() || pending.size !== 0) { + return + } + await Promise.all([ + queryClient.invalidateQueries({ queryKey: rpcQuery.users.key() }), + queryClient.invalidateQueries({ queryKey: httpQuery.users.key() }), + ]) + } + const editLists = (edit: (users: readonly User[]) => readonly User[]) => { + queryClient.setQueryData(rpcList, (users) => (users === undefined ? undefined : edit(users))) + queryClient.setQueryData(httpList, (users) => (users === undefined ? undefined : edit(users))) + } + const editPages = (edit: (data: InfiniteData) => InfiniteData) => { + queryClient.setQueryData(rpcPages, (data) => (data === undefined ? undefined : edit(data))) + queryClient.setQueryData(httpPages, (data) => (data === undefined ? undefined : edit(data))) + } + const replaceUser = (id: number, replacement: User | undefined, totalChange: number) => { + editLists((users) => { + const retained = users.filter((user) => user.id !== id && user.id !== replacement?.id) + if (replacement === undefined) { + return retained + } + const index = users.findIndex((user) => user.id === id) + return index === -1 + ? [...retained, replacement] + : [...retained.slice(0, index), replacement, ...retained.slice(index)] + }) + editPages((data) => ({ + ...data, + pages: data.pages.map((page, pageIndex) => { + const index = page.users.findIndex((user) => user.id === id) + const retained = page.users.filter((user) => user.id !== id && user.id !== replacement?.id) + const append = + replacement !== undefined && + pageIndex === data.pages.length - 1 && + page.nextCursor === null + let users = retained + if (replacement !== undefined && index !== -1) { + users = [...retained.slice(0, index), replacement, ...retained.slice(index)] + } else if (append) { + users = [...retained, replacement] + } + return new UserPage({ nextCursor: page.nextCursor, users, total: page.total + totalChange }) + }), + })) + } + const created = async (user: User, write: number) => { + if (!isActive()) { + return + } + await cancel() + if (!isActive()) { + return + } + replaceUser(-write, user, 0) + queryClient.setQueryData( + rpcQuery.users.get.queryKey({ id: user.id, locale: user.locale }), + user, + ) + queryClient.setQueryData( + httpQuery.users.get.queryKey({ params: { id: user.id }, query: { locale: user.locale } }), + user, + ) + } + const createFailed = (write: number | undefined) => { + if (isActive() && write !== undefined) { + replaceUser(-write, undefined, -1) + } + } + const startCreate = async (input: { + readonly name: string + readonly locale?: string | undefined + }) => { + const write = await begin() + if (!isActive()) { + pending.delete(write) + throw new Error('The application owner has retired') + } + const temporaryId = -write + replaceUser( + temporaryId, + new User({ id: temporaryId, name: input.name, locale: input.locale ?? 'en' }), + 1, + ) + return { + commit: async (user: User) => created(user, write), + rollback: () => { + createFailed(write) + }, + settle: async () => settle(write), + } + } + const rpcCreate = () => { + const options = rpcQuery.users.create.mutationOptions({ + networkMode: 'always', + retry: false, + onMutate: startCreate, + onSuccess: async (user, _input, transaction) => transaction.commit(user), + onError: (_error, _input, transaction) => { + transaction?.rollback() + }, + onSettled: async (_data, _error, _input, transaction) => transaction?.settle(), + }) + return { + ...options, + mutationFn: async (...args: Parameters) => + runMutation(async () => options.mutationFn(...args)), + } + } + const httpCreate = () => { + const options = httpQuery.users.create.mutationOptions({ + networkMode: 'always', + retry: false, + onMutate: async ({ payload }) => startCreate(payload), + onSuccess: async (user, _input, transaction) => transaction.commit(user), + onError: (_error, _input, transaction) => { + transaction?.rollback() + }, + onSettled: async (_data, _error, _input, transaction) => transaction?.settle(), + }) + return { + ...options, + mutationFn: async (...args: Parameters) => + runMutation(async () => options.mutationFn(...args)), + } + } + const deletionGroups = new Map< + number, + { + readonly pending: Set + readonly rpcList: readonly User[] | undefined + readonly httpList: readonly User[] | undefined + readonly rpcPages: InfiniteData | undefined + readonly httpPages: InfiniteData | undefined + readonly applied: boolean + succeeded: boolean + } + >() + const finishDelete = (id: number, write: number | undefined, succeeded: boolean) => { + if (write === undefined) { + return + } + const group = deletionGroups.get(id) + if (group === undefined) { + return + } + group.succeeded ||= succeeded + group.pending.delete(write) + if (group.pending.size !== 0) { + return + } + deletionGroups.delete(id) + if (!isActive() || group.succeeded || !group.applied) { + return + } + queryClient.setQueryData(rpcList, (current) => restoreList(current, group.rpcList, id)) + queryClient.setQueryData(httpList, (current) => restoreList(current, group.httpList, id)) + queryClient.setQueryData(rpcPages, (current) => restorePages(current, group.rpcPages, id)) + queryClient.setQueryData(httpPages, (current) => restorePages(current, group.httpPages, id)) + } + const deleted = async (id: number, write: number) => { + if (isActive()) { + await cancel() + } + finishDelete(id, write, true) + } + const startDelete = async (id: number) => { + const write = await begin() + if (!isActive()) { + pending.delete(write) + throw new Error('The application owner has retired') + } + let group = deletionGroups.get(id) + if (group === undefined) { + const lists = [queryClient.getQueryData(rpcList), queryClient.getQueryData(httpList)] + const pages = [queryClient.getQueryData(rpcPages), queryClient.getQueryData(httpPages)] + const applied = + lists.some((users) => users?.some((user) => user.id === id) ?? false) || + pages.some( + (data) => data?.pages.some((page) => page.users.some((user) => user.id === id)) ?? false, + ) + group = { + pending: new Set(), + succeeded: false, + applied, + rpcList: lists[0], + httpList: lists[1], + rpcPages: pages[0], + httpPages: pages[1], + } + deletionGroups.set(id, group) + if (applied) { + replaceUser(id, undefined, -1) + } + } + group.pending.add(write) + return { + commit: async () => deleted(id, write), + rollback: () => { + finishDelete(id, write, false) + }, + settle: async () => settle(write), + } + } + const rpcDelete = () => { + const options = rpcQuery.users.delete.mutationOptions({ + networkMode: 'always', + retry: false, + onMutate: async ({ id }) => startDelete(id), + onSuccess: async (_data, _input, transaction) => transaction.commit(), + onError: (_error, _input, transaction) => { + transaction?.rollback() + }, + onSettled: async (_data, _error, _input, transaction) => transaction?.settle(), + }) + return { + ...options, + mutationFn: async (...args: Parameters) => + runMutation(async () => options.mutationFn(...args)), + } + } + const httpDelete = () => { + const options = httpQuery.users.delete.mutationOptions({ + networkMode: 'always', + retry: false, + onMutate: async ({ params }) => startDelete(params.id), + onSuccess: async (_data, _input, transaction) => transaction.commit(), + onError: (_error, _input, transaction) => { + transaction?.rollback() + }, + onSettled: async (_data, _error, _input, transaction) => transaction?.settle(), + }) + return { + ...options, + mutationFn: async (...args: Parameters) => + runMutation(async () => options.mutationFn(...args)), + } + } + return { rpcCreate, httpCreate, rpcDelete, httpDelete } +} diff --git a/examples/vite-react/tests/app.test.tsx b/examples/vite-react/tests/app.test.tsx index ab75bfc..562611d 100644 --- a/examples/vite-react/tests/app.test.tsx +++ b/examples/vite-react/tests/app.test.tsx @@ -1,14 +1,29 @@ // @vitest-environment jsdom +import { User } from '@effect-api-query/contracts' import { startExampleRpcServer } from '@effect-api-query/server' +import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { Effect, Exit, Scope } from 'effect' import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test' +import { makeControlledUserWrites } from '../../../tests/fixtures/optimistic-users.ts' import { ViteReactExample } from '../src/app.tsx' +import { CreateUserForm } from '../src/components/users/create-user-form.tsx' +import { UserList } from '../src/components/users/user-list.tsx' import { startViteReactApplication } from '../src/lib/application.ts' import type { ViteReactApplication } from '../src/lib/application.ts' +const OptimisticUsers = ({ application }: { readonly application: ViteReactApplication }) => { + const users = useQuery(application.rpcQuery.users.list.queryOptions({ staleTime: Infinity })) + return ( + <> + + + + ) +} + describe('plain Vite React integration', () => { let application: ViteReactApplication | undefined let serverScope: Scope.Closeable | undefined @@ -183,6 +198,43 @@ describe('plain Vite React integration', () => { ).toBe(0) }) + it('shows optimistic creation and removal, then restores a failed deletion beside the created user', async () => { + cleanup() + const fixture = await Effect.runPromise(makeControlledUserWrites(new QueryClient())) + const owned = fixture.application + const ada = new User({ id: 1, name: 'Ada', locale: 'en' }) + fixture.seed([ada]) + owned.queryClient.setQueryData(owned.rpcQuery.users.list.queryKey(), [ada]) + render( + + + , + ) + try { + fireEvent.click(screen.getByRole('button', { name: 'Delete Ada' })) + const deletion = await fixture.nextDelete() + await waitFor(() => { + expect(screen.queryByText('Ada', { exact: true })).toBeNull() + }) + fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Grace' } }) + fireEvent.click(screen.getByRole('button', { name: 'Add user' })) + const creation = await fixture.nextCreate() + await expect(screen.findByText('Grace', { exact: true })).resolves.toBeDefined() + expect(screen.getByRole('button', { name: 'Delete Grace' }).hasAttribute('disabled')).toBe( + true, + ) + await creation.succeed(new User({ id: 2, name: 'Grace', locale: 'en' })) + await expect(screen.findByText('Added Grace')).resolves.toBeDefined() + await deletion.fail() + await expect(screen.findByText('Ada', { exact: true })).resolves.toBeDefined() + expect(screen.getByText('Grace', { exact: true })).toBeDefined() + expect(screen.getByRole('alert').textContent).toContain('users.delete') + } finally { + cleanup() + await owned.dispose() + } + }) + it('disposes its client Scope and runtime idempotently', async () => { const ownedApplication = application expect(ownedApplication).toBeDefined() diff --git a/examples/vite-react/tests/user-writes.test.ts b/examples/vite-react/tests/user-writes.test.ts new file mode 100644 index 0000000..7312afd --- /dev/null +++ b/examples/vite-react/tests/user-writes.test.ts @@ -0,0 +1,311 @@ +import { User, UserPage } from '@effect-api-query/contracts' +import { MutationObserver, QueryClient, QueryObserver } from '@tanstack/react-query' +import { Effect } from 'effect' +import { describe, expect, it } from 'vite-plus/test' + +import { makeControlledUserWrites } from '../../../tests/fixtures/optimistic-users.ts' + +describe('User writes in the Vite application', () => { + it('publishes a pending user and seeds the returned user through exact generated keys', async () => { + const fixture = await Effect.runPromise(makeControlledUserWrites(new QueryClient())) + const { queryClient, rpcQuery, httpQuery, userWrites } = fixture.application + const ada = new User({ id: 1, name: 'Ada', locale: 'en' }) + queryClient.setQueryData(rpcQuery.users.list.queryKey(), [ada]) + queryClient.setQueryData(httpQuery.users.list.queryKey(), [ada]) + try { + const mutation = new MutationObserver(queryClient, userWrites.rpcCreate()) + const result = mutation.mutate({ name: 'Grace', locale: 'fr' }) + const request = await fixture.nextCreate() + expect( + queryClient.getQueryData(rpcQuery.users.list.queryKey())?.map((user) => user.name), + ).toStrictEqual(['Ada', 'Grace']) + const grace = new User({ id: 2, name: 'Grace', locale: 'fr' }) + await request.succeed(grace) + await expect(result).resolves.toStrictEqual(grace) + expect( + queryClient.getQueryData(rpcQuery.users.get.queryKey({ id: 2, locale: 'fr' })), + ).toStrictEqual(grace) + expect( + queryClient.getQueryData( + httpQuery.users.get.queryKey({ params: { id: 2 }, query: { locale: 'fr' } }), + ), + ).toStrictEqual(grace) + expect(queryClient.getQueryData(rpcQuery.users.list.queryKey())).toStrictEqual([ada, grace]) + } finally { + await fixture.application.dispose() + } + }) + + it('rolls back a failed deletion without erasing a newer creation or changing page parameters', async () => { + const fixture = await Effect.runPromise(makeControlledUserWrites(new QueryClient())) + const { queryClient, rpcQuery, httpQuery, userWrites } = fixture.application + const ada = new User({ id: 1, name: 'Ada', locale: 'en' }) + const alan = new User({ id: 2, name: 'Alan', locale: 'en' }) + const grace = new User({ id: 3, name: 'Grace', locale: 'fr' }) + const rpcPages = rpcQuery.users.page.infiniteKey({ cursor: 0, pageSize: 4 }) + const httpPages = httpQuery.users.page.infiniteKey({ query: { cursor: 0, pageSize: 4 } }) + fixture.seed([ada, alan]) + for (const key of [rpcQuery.users.list.queryKey(), httpQuery.users.list.queryKey()]) { + queryClient.setQueryData(key, [ada, alan]) + } + for (const key of [rpcPages, httpPages]) { + queryClient.setQueryData(key, { + pages: [ + new UserPage({ users: [ada], total: 2, nextCursor: 1 }), + new UserPage({ users: [alan], total: 2, nextCursor: null }), + ], + pageParams: [0, 1], + }) + } + try { + const deletion = new MutationObserver(queryClient, userWrites.rpcDelete()).mutate({ id: 1 }) + const failure = Effect.runPromiseExit(Effect.promise(async () => deletion)) + const deleteRequest = await fixture.nextDelete() + expect(queryClient.getQueryData(rpcQuery.users.list.queryKey())).toStrictEqual([alan]) + expect(queryClient.getQueryData(rpcPages)?.pages[0]?.users).toStrictEqual([]) + const creation = new MutationObserver(queryClient, userWrites.httpCreate()).mutate({ + payload: { name: 'Grace', locale: 'fr' }, + }) + const createRequest = await fixture.nextCreate() + await createRequest.succeed(grace) + await creation + await deleteRequest.fail() + await expect(failure).resolves.toHaveProperty('_tag', 'Failure') + expect(queryClient.getQueryData(rpcQuery.users.list.queryKey())).toStrictEqual([ + ada, + alan, + grace, + ]) + expect(queryClient.getQueryData(httpQuery.users.list.queryKey())).toStrictEqual([ + ada, + alan, + grace, + ]) + expect(queryClient.getQueryData(rpcPages)).toStrictEqual({ + pages: [ + new UserPage({ users: [ada], total: 3, nextCursor: 1 }), + new UserPage({ users: [alan, grace], total: 3, nextCursor: null }), + ], + pageParams: [0, 1], + }) + expect(queryClient.getQueryData(httpPages)).toStrictEqual(queryClient.getQueryData(rpcPages)) + } finally { + await fixture.application.dispose() + } + }) + + it('keeps a newer successful deletion when an older delete fails', async () => { + const fixture = await Effect.runPromise(makeControlledUserWrites(new QueryClient())) + const { queryClient, rpcQuery, userWrites } = fixture.application + const ada = new User({ id: 1, name: 'Ada', locale: 'en' }) + fixture.seed([ada]) + queryClient.setQueryData(rpcQuery.users.list.queryKey(), [ada]) + try { + const older = Effect.runPromiseExit( + Effect.promise(async () => + new MutationObserver(queryClient, userWrites.rpcDelete()).mutate({ id: 1 }), + ), + ) + const olderRequest = await fixture.nextDelete() + const newer = new MutationObserver(queryClient, userWrites.rpcDelete()).mutate({ id: 1 }) + const newerRequest = await fixture.nextDelete() + await newerRequest.succeed() + await newer + await olderRequest.fail() + await expect(older).resolves.toHaveProperty('_tag', 'Failure') + expect(queryClient.getQueryData(rpcQuery.users.list.queryKey())).toStrictEqual([]) + } finally { + await fixture.application.dispose() + } + }) + + it('restores the original entity when both overlapping deletes fail', async () => { + const fixture = await Effect.runPromise(makeControlledUserWrites(new QueryClient())) + const { queryClient, rpcQuery, userWrites } = fixture.application + const ada = new User({ id: 1, name: 'Ada', locale: 'en' }) + queryClient.setQueryData(rpcQuery.users.list.queryKey(), [ada]) + try { + const older = Effect.runPromiseExit( + Effect.promise(async () => + new MutationObserver(queryClient, userWrites.rpcDelete()).mutate({ id: 1 }), + ), + ) + const olderRequest = await fixture.nextDelete() + const newer = Effect.runPromiseExit( + Effect.promise(async () => + new MutationObserver(queryClient, userWrites.rpcDelete()).mutate({ id: 1 }), + ), + ) + const newerRequest = await fixture.nextDelete() + await olderRequest.fail() + await older + expect(queryClient.getQueryData(rpcQuery.users.list.queryKey())).toStrictEqual([]) + await newerRequest.fail() + await expect(newer).resolves.toHaveProperty('_tag', 'Failure') + expect(queryClient.getQueryData(rpcQuery.users.list.queryKey())).toStrictEqual([ada]) + } finally { + await fixture.application.dispose() + } + }) + + it('preserves a newer creation when an older create callback completes late', async () => { + const fixture = await Effect.runPromise(makeControlledUserWrites(new QueryClient())) + const { queryClient, rpcQuery, userWrites } = fixture.application + queryClient.setQueryData(rpcQuery.users.list.queryKey(), []) + try { + const older = new MutationObserver(queryClient, userWrites.rpcCreate()).mutate({ + name: 'Ada', + }) + const olderRequest = await fixture.nextCreate() + const newer = new MutationObserver(queryClient, userWrites.rpcCreate()).mutate({ + name: 'Grace', + }) + const newerRequest = await fixture.nextCreate() + const grace = new User({ id: 2, name: 'Grace', locale: 'en' }) + await newerRequest.succeed(grace) + await newer + const ada = new User({ id: 1, name: 'Ada', locale: 'en' }) + await olderRequest.succeed(ada) + await older + expect(queryClient.getQueryData(rpcQuery.users.list.queryKey())).toStrictEqual([ada, grace]) + } finally { + await fixture.application.dispose() + } + }) + + it('cancels an older reconciliation fetch before publishing a newer write', async () => { + const fixture = await Effect.runPromise( + makeControlledUserWrites( + new QueryClient({ + defaultOptions: { queries: { queryKeyHashFn: (key) => `owner:${JSON.stringify(key)}` } }, + }), + ), + ) + const { queryClient, rpcQuery, userWrites } = fixture.application + queryClient.setQueryData(rpcQuery.users.list.queryKey(), []) + fixture.holdLists() + const observer = new QueryObserver( + queryClient, + rpcQuery.users.list.queryOptions({ staleTime: Infinity }), + ) + const unsubscribe = observer.subscribe(() => {}) + try { + const older = new MutationObserver(queryClient, userWrites.rpcCreate()).mutate({ + name: 'Ada', + }) + const first = await fixture.nextCreate() + const ada = new User({ id: 1, name: 'Ada', locale: 'en' }) + await first.succeed(ada) + const stale = await fixture.nextList() + const newer = new MutationObserver(queryClient, userWrites.rpcCreate()).mutate({ + name: 'Grace', + }) + const second = await fixture.nextCreate() + await older + const grace = new User({ id: 2, name: 'Grace', locale: 'en' }) + await second.succeed(grace) + const current = await fixture.nextList() + await stale.succeed([ada]) + expect(queryClient.getQueryData(rpcQuery.users.list.queryKey())).toStrictEqual([ada, grace]) + await current.succeed([ada, grace]) + await newer + expect(queryClient.getQueryData(rpcQuery.users.list.queryKey())).toStrictEqual([ada, grace]) + expect(queryClient.getQueriesData({ queryKey: rpcQuery.users.list.key() })).toHaveLength(1) + expect(queryClient.getQueryState(rpcQuery.users.list.queryKey())?.isInvalidated).toBe(false) + } finally { + unsubscribe() + await fixture.application.dispose() + } + }) + + it('keeps late callbacks bound to a retired owner and leaves a replacement client untouched', async () => { + const fixture = await Effect.runPromise(makeControlledUserWrites(new QueryClient())) + const { queryClient, rpcQuery, userWrites } = fixture.application + queryClient.setQueryData(rpcQuery.users.list.queryKey(), []) + const replacement = new QueryClient() + const grace = new User({ id: 2, name: 'Grace', locale: 'en' }) + replacement.setQueryData(rpcQuery.users.list.queryKey(), [grace]) + try { + const mutation = new MutationObserver(queryClient, userWrites.rpcCreate()).mutate({ + name: 'Ada', + }) + const request = await fixture.nextCreate() + fixture.retire() + queryClient.clear() + await request.succeed(new User({ id: 1, name: 'Ada', locale: 'en' })) + await mutation + expect(queryClient.getQueryCache().getAll()).toHaveLength(0) + expect(replacement.getQueryData(rpcQuery.users.list.queryKey())).toStrictEqual([grace]) + expect(replacement.getQueryState(rpcQuery.users.list.queryKey())?.isInvalidated).toBe(false) + } finally { + replacement.clear() + await fixture.application.dispose() + } + }) + + it('settles through its original owner after native observer callbacks are replaced', async () => { + const original = await Effect.runPromise(makeControlledUserWrites(new QueryClient())) + const replacement = await Effect.runPromise(makeControlledUserWrites(new QueryClient())) + const first = original.application + const second = replacement.application + const grace = new User({ id: 2, name: 'Grace', locale: 'en' }) + first.queryClient.setQueryData(first.rpcQuery.users.list.queryKey(), []) + second.queryClient.setQueryData(second.rpcQuery.users.list.queryKey(), [grace]) + try { + const observer = new MutationObserver(first.queryClient, first.userWrites.rpcCreate()) + const mutation = observer.mutate({ name: 'Ada' }) + const request = await original.nextCreate() + observer.setOptions(second.userWrites.rpcCreate()) + const ada = new User({ id: 1, name: 'Ada', locale: 'en' }) + await request.succeed(ada) + await mutation + expect(first.queryClient.getQueryData(first.rpcQuery.users.list.queryKey())).toStrictEqual([ + ada, + ]) + expect(second.queryClient.getQueryData(second.rpcQuery.users.list.queryKey())).toStrictEqual([ + grace, + ]) + expect( + second.queryClient.getQueryState(second.rpcQuery.users.list.queryKey())?.isInvalidated, + ).toBe(false) + } finally { + await original.application.dispose() + await replacement.application.dispose() + } + }) + + it('removes a failed creation without erasing a newer response or corrupting page totals', async () => { + const fixture = await Effect.runPromise(makeControlledUserWrites(new QueryClient())) + const { queryClient, rpcQuery, userWrites } = fixture.application + queryClient.setQueryData(rpcQuery.users.list.queryKey(), []) + const pages = rpcQuery.users.page.infiniteKey({ cursor: 0, pageSize: 4 }) + queryClient.setQueryData(pages, { + pages: [new UserPage({ users: [], total: 0, nextCursor: null })], + pageParams: [0], + }) + try { + const older = Effect.runPromiseExit( + Effect.promise(async () => + new MutationObserver(queryClient, userWrites.rpcCreate()).mutate({ name: 'Ada' }), + ), + ) + const first = await fixture.nextCreate() + const newer = new MutationObserver(queryClient, userWrites.rpcCreate()).mutate({ + name: 'Grace', + }) + const second = await fixture.nextCreate() + const grace = new User({ id: 2, name: 'Grace', locale: 'en' }) + await second.succeed(grace) + await newer + await first.fail() + await expect(older).resolves.toHaveProperty('_tag', 'Failure') + expect(queryClient.getQueryData(rpcQuery.users.list.queryKey())).toStrictEqual([grace]) + expect(queryClient.getQueryData(pages)).toStrictEqual({ + pages: [new UserPage({ users: [grace], total: 1, nextCursor: null })], + pageParams: [0], + }) + } finally { + await fixture.application.dispose() + } + }) +}) diff --git a/scripts/verify-packed-consumer.mts b/scripts/verify-packed-consumer.mts index a6fb011..c07ceed 100644 --- a/scripts/verify-packed-consumer.mts +++ b/scripts/verify-packed-consumer.mts @@ -10,6 +10,7 @@ import { existsSync, mkdtempSync, readFileSync, + readdirSync, realpathSync, rmSync, writeFileSync, @@ -406,6 +407,61 @@ const verifyConsumer = (peer: (typeof peerCases)[number]): void => { try { cpSync(consumerFixtureDirectory, consumerDirectory, { recursive: true }) + cpSync( + nodePath.join(repositoryRoot, 'examples', 'contracts', 'src'), + nodePath.join(consumerDirectory, 'optimistic-contracts'), + { recursive: true }, + ) + const applicationDirectory = nodePath.join(consumerDirectory, 'optimistic-application') + cpSync( + nodePath.join(repositoryRoot, 'examples', 'vite-react', 'src', 'lib'), + applicationDirectory, + { recursive: true }, + ) + for (const file of readdirSync(applicationDirectory)) { + if (file.endsWith('.ts')) { + const path = nodePath.join(applicationDirectory, file) + writeFileSync( + path, + readFileSync(path, 'utf-8') + .replaceAll( + "'@effect-api-query/contracts/client'", + "'../optimistic-contracts/client.ts'", + ) + .replaceAll("'@effect-api-query/contracts'", "'../optimistic-contracts/index.ts'"), + ) + } + } + writeFileSync( + nodePath.join(consumerDirectory, 'optimistic-users.ts'), + readFileSync( + nodePath.join(repositoryRoot, 'tests', 'fixtures', 'optimistic-users.ts'), + 'utf-8', + ) + .replaceAll("'@effect-api-query/contracts/client'", "'./optimistic-contracts/client.ts'") + .replaceAll("'@effect-api-query/contracts'", "'./optimistic-contracts/index.ts'") + .replaceAll("'#effect-api-query'", "'effect-api-query'") + .replaceAll( + "'../../examples/vite-react/src/lib/application.ts'", + "'./optimistic-application/application.ts'", + ) + .replaceAll( + "'../../examples/vite-react/src/lib/user-writes.ts'", + "'./optimistic-application/user-writes.ts'", + ), + ) + for (const file of ['optimistic-runtime.mts', 'optimistic-contract.ts']) { + const path = nodePath.join(consumerDirectory, file) + writeFileSync( + path, + readFileSync(path, 'utf-8') + .replaceAll( + "'../../examples/contracts/src/contracts.ts'", + "'./optimistic-contracts/contracts.ts'", + ) + .replaceAll("'../fixtures/optimistic-users.ts'", "'./optimistic-users.ts'"), + ) + } for (const fixture of [ 'public-contract.ts', 'tanstack-start-contract.ts', @@ -507,6 +563,7 @@ const verifyConsumer = (peer: (typeof peerCases)[number]): void => { } for (const fixture of [ + 'optimistic-runtime.mts', 'hashing-runtime.mts', 'filter-runtime.mts', 'runtime.mts', diff --git a/tests/fixtures/optimistic-users.ts b/tests/fixtures/optimistic-users.ts new file mode 100644 index 0000000..a8f23ef --- /dev/null +++ b/tests/fixtures/optimistic-users.ts @@ -0,0 +1,238 @@ +import { + CommandStatus, + ExampleAuthorization, + ExampleHttpAuthorization, + User, + UserPage, + exampleHttpApi, + exampleRpcGroup, +} from '@effect-api-query/contracts' +import type { ExampleRpcClient } from '@effect-api-query/contracts/client' +import type { QueryClient } from '@tanstack/query-core' +import { Deferred, Effect, Exit, Layer, Queue, Scope, Stream } from 'effect' +import { HttpServer } from 'effect/http' +import { HttpApiBuilder, HttpApiTest } from 'effect/http-api' +import { RpcTest } from 'effect/rpc' +import type { RpcClientError } from 'effect/rpc' + +import { createHttpApiQueryUtils, createRpcQueryUtils } from '#effect-api-query' + +import type { ViteReactApplication } from '../../examples/vite-react/src/lib/application.ts' +import { makeUserWrites } from '../../examples/vite-react/src/lib/user-writes.ts' + +interface CreateRequest { + readonly name: string + readonly succeed: (user: User) => Promise + readonly fail: () => Promise +} +interface DeleteRequest { + readonly id: number + readonly succeed: () => Promise + readonly fail: () => Promise +} +interface ListRequest { + readonly signal: Deferred.Deferred + readonly succeed: (users: readonly User[]) => Promise +} + +interface ControlledUserWrites { + readonly application: ViteReactApplication + readonly nextCreate: () => Promise + readonly nextDelete: () => Promise + readonly nextList: () => Promise + readonly holdLists: () => void + readonly seed: (users: readonly User[]) => void + readonly retire: () => void +} + +export const makeControlledUserWrites = Effect.fnUntraced(function* ( + queryClient: QueryClient, +): Effect.fn.Return { + const scope = yield* Scope.make() + const run = Effect.runPromiseWith(yield* Effect.context()) + const creates = yield* Queue.unbounded() + const deletes = yield* Queue.unbounded() + const lists = yield* Queue.unbounded() + let holdLists = false + let active = true + let users: readonly User[] = [] + const create = Effect.fnUntraced(function* ({ name }: { readonly name: string }) { + const gate = yield* Deferred.make() + yield* Queue.offer(creates, { + name, + succeed: async (user) => { + users = [...users.filter((existing) => existing.id !== user.id), user] + await run(Deferred.succeed(gate, user)) + }, + fail: async () => { + await run(Deferred.interrupt(gate)) + }, + }) + return yield* Deferred.await(gate) + }) + const remove = Effect.fnUntraced(function* ({ id }: { readonly id: number }) { + const gate = yield* Deferred.make() + yield* Queue.offer(deletes, { + id, + succeed: async () => { + users = users.filter((user) => user.id !== id) + await run(Deferred.succeed(gate, undefined)) + }, + fail: async () => { + await run(Deferred.fail(gate, 'user-not-found')) + }, + }) + return yield* Deferred.await(gate) + }) + const list = Effect.fnUntraced(function* () { + if (!holdLists) { + return users + } + const gate = yield* Deferred.make() + yield* Queue.offer(lists, { + signal: gate, + succeed: async (snapshot) => { + await run(Deferred.succeed(gate, snapshot)) + }, + }) + return yield* Deferred.await(gate) + }) + const page = ({ cursor, pageSize }: { readonly cursor: number; readonly pageSize: number }) => + Effect.succeed( + new UserPage({ + users: users.slice(cursor, cursor + pageSize), + total: users.length, + nextCursor: cursor + pageSize < users.length ? cursor + pageSize : null, + }), + ) + const get = ({ id, locale }: { readonly id: number; readonly locale?: string | undefined }) => { + const user = users.find((candidate) => candidate.id === id) + return user === undefined + ? Effect.fail('user-not-found' as const) + : Effect.succeed(new User({ id: user.id, name: user.name, locale: locale ?? 'en' })) + } + const rpcClient: ExampleRpcClient = yield* RpcTest.makeClient(exampleRpcGroup, { + flatten: true, + }).pipe( + Effect.provide( + exampleRpcGroup.toLayer({ + 'users.create': create, + 'users.delete': remove, + 'users.list': list, + 'users.get': get, + 'users.page': page, + 'testing.reset': () => Effect.void, + 'testing.seed': () => Effect.succeed(users), + 'diagnostics.stream': () => Stream.empty, + 'diagnostics.fail': () => Effect.die('unused diagnostic'), + 'diagnostics.cancel': () => Effect.void, + 'diagnostics.slow': () => Effect.succeed('done'), + 'diagnostics.status': () => Effect.succeed({ started: 0, interrupted: 0 }), + 'diagnostics.operationStatus': () => Effect.succeed({ started: 0, interrupted: 0 }), + 'commands.start': ({ operationId, steps }) => + Effect.succeed( + new CommandStatus({ + operationId, + totalSteps: steps, + completedSteps: steps, + state: 'completed', + }), + ), + 'commands.status': () => Effect.succeed(null), + 'commands.cancel': ({ operationId }) => + Effect.succeed( + new CommandStatus({ + operationId, + totalSteps: 0, + completedSteps: 0, + state: 'cancelled', + }), + ), + }), + ), + Effect.provideService( + ExampleAuthorization, + ExampleAuthorization.of((effect) => effect), + ), + Scope.provide(scope), + ) + const httpHandlers = HttpApiBuilder.group(exampleHttpApi, 'users', (group) => + group + .handle('create', ({ payload }) => create(payload)) + .handle('delete', ({ params }) => remove(params)) + .handle('get', ({ params, query }) => get({ ...params, ...query })) + .handle('list', list) + .handle('page', ({ query }) => page(query)), + ) + const diagnostics = HttpApiBuilder.group(exampleHttpApi, 'diagnostics', (group) => + group + .handle('fail', () => Effect.die('unused diagnostic')) + .handle('slow', () => Effect.succeed('done')) + .handle('status', () => Effect.succeed({ started: 0, interrupted: 0 })) + .handle('operationStatus', () => Effect.succeed({ started: 0, interrupted: 0 })), + ) + const httpClient = yield* HttpApiTest.groups(exampleHttpApi, ['users', 'diagnostics']).pipe( + Effect.provide(Layer.mergeAll(httpHandlers, diagnostics, HttpServer.layerServices)), + Effect.provideService( + ExampleHttpAuthorization, + ExampleHttpAuthorization.of((effect) => effect), + ), + Scope.provide(scope), + ) + const rpcQuery = createRpcQueryUtils< + typeof exampleRpcGroup, + readonly ['vite-react'], + RpcClientError.RpcClientError + >(exampleRpcGroup, { + client: rpcClient, + keyPrefix: ['vite-react'], + }) + const httpQuery = createHttpApiQueryUtils(exampleHttpApi, { + client: httpClient, + keyPrefix: ['vite-react'], + }) + const isActive = () => active + const runMutation = async (execute: () => Promise) => { + if (!active) { + throw new Error('The application owner has retired') + } + return execute() + } + const userWrites = makeUserWrites({ queryClient, rpcQuery, httpQuery, isActive, runMutation }) + const invalidateUsers = async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: rpcQuery.users.key() }), + queryClient.invalidateQueries({ queryKey: httpQuery.users.key() }), + ]) + } + const dispose = async () => { + active = false + await queryClient.cancelQueries() + queryClient.clear() + await run(Scope.close(scope, Exit.void)) + } + return { + application: { + queryClient, + rpcQuery, + httpQuery, + userWrites, + isActive, + runMutation, + invalidateUsers, + dispose, + }, + nextCreate: async () => run(Queue.take(creates)), + nextDelete: async () => run(Queue.take(deletes)), + nextList: async () => run(Queue.take(lists)), + holdLists: () => { + holdLists = true + }, + seed: (data: readonly User[]) => { + users = data + }, + retire: () => { + active = false + }, + } +}) diff --git a/tests/packed-consumer/optimistic-contract.ts b/tests/packed-consumer/optimistic-contract.ts new file mode 100644 index 0000000..88b1631 --- /dev/null +++ b/tests/packed-consumer/optimistic-contract.ts @@ -0,0 +1,49 @@ +import { QueryClient } from '@tanstack/query-core' +import type { InfiniteData } from '@tanstack/query-core' +import type { Effect } from 'effect' + +import { User } from '../../examples/contracts/src/contracts.ts' +import type { UserPage } from '../../examples/contracts/src/contracts.ts' +import type { makeControlledUserWrites } from '../fixtures/optimistic-users.ts' + +type Equal = + (() => Value extends Left ? 1 : 2) extends () => Value extends Right ? 1 : 2 + ? true + : false +type Assert = Value + +declare const fixture: Effect.Success> +const { rpcQuery, httpQuery, userWrites } = fixture.application +const queryClient = new QueryClient() +const rpcUser = queryClient.getQueryData(rpcQuery.users.get.queryKey({ id: 1 })) +const httpUser = queryClient.getQueryData( + httpQuery.users.get.queryKey({ params: { id: 1 }, query: {} }), +) +const rpcList = queryClient.getQueryData(rpcQuery.users.list.queryKey()) +const rpcPages = queryClient.getQueryData( + rpcQuery.users.page.infiniteKey({ cursor: 0, pageSize: 4 }), +) +const httpPages = queryClient.getQueryData( + httpQuery.users.page.infiniteKey({ query: { cursor: 0, pageSize: 4 } }), +) +true satisfies Assert> +true satisfies Assert> +true satisfies Assert> +true satisfies Assert | undefined>> +true satisfies Assert | undefined>> +const rpcCreate = userWrites.rpcCreate() +const httpCreate = userWrites.httpCreate() +const rpcDelete = userWrites.rpcDelete() +const httpDelete = userWrites.httpDelete() +true satisfies Assert>, User>> +true satisfies Assert>, User>> +true satisfies Assert>, void>> +true satisfies Assert>, void>> +queryClient.setQueryData( + rpcQuery.users.get.queryKey({ id: 1 }), + new User({ id: 1, name: 'Ada', locale: 'en' }), +) +queryClient.setQueryData( + httpQuery.users.get.queryKey({ params: { id: 1 }, query: {} }), + new User({ id: 1, name: 'Ada', locale: 'en' }), +) diff --git a/tests/packed-consumer/optimistic-runtime.mts b/tests/packed-consumer/optimistic-runtime.mts new file mode 100644 index 0000000..9689779 --- /dev/null +++ b/tests/packed-consumer/optimistic-runtime.mts @@ -0,0 +1,258 @@ +import { MutationObserver, QueryClient, QueryObserver } from '@tanstack/query-core' +import { Effect, Exit } from 'effect' +import { deepStrictEqual, equal } from 'node:assert/strict' + +import { User, UserPage } from '../../examples/contracts/src/contracts.ts' +import { makeControlledUserWrites } from '../fixtures/optimistic-users.ts' + +const makeOwner = async () => + Effect.runPromise( + makeControlledUserWrites( + new QueryClient({ + defaultOptions: { queries: { queryKeyHashFn: (key) => `global:${JSON.stringify(key)}` } }, + }), + ), + ) +const seed = (fixture: Awaited>, users: readonly User[]) => { + const { queryClient, rpcQuery, httpQuery } = fixture.application + fixture.seed(users) + queryClient.setQueryDefaults(rpcQuery.users.key(), { + queryKeyHashFn: (key) => `rpc:${JSON.stringify(key)}`, + }) + queryClient.setQueryDefaults(httpQuery.users.key(), { + queryKeyHashFn: (key) => `http:${JSON.stringify(key)}`, + }) + queryClient.setQueryData(rpcQuery.users.list.queryKey(), users) + queryClient.setQueryData(httpQuery.users.list.queryKey(), users) +} +const ada = new User({ id: 1, name: 'Ada', locale: 'en' }) +const alan = new User({ id: 2, name: 'Alan', locale: 'en' }) +const grace = new User({ id: 3, name: 'Grace', locale: 'fr' }) + +{ + const fixture = await makeOwner() + const { queryClient, rpcQuery, httpQuery, userWrites } = fixture.application + seed(fixture, [ada, alan]) + const rpcPages = rpcQuery.users.page.infiniteKey({ cursor: 0, pageSize: 4 }) + const httpPages = httpQuery.users.page.infiniteKey({ query: { cursor: 0, pageSize: 4 } }) + const data = { + pages: [ + new UserPage({ users: [ada], total: 2, nextCursor: 1 }), + new UserPage({ users: [alan], total: 2, nextCursor: null }), + ], + pageParams: [0, 1], + } + queryClient.setQueryData(rpcPages, data) + queryClient.setQueryData(httpPages, data) + try { + const deletion = Effect.runPromiseExit( + Effect.promise(() => + new MutationObserver(queryClient, userWrites.rpcDelete()).mutate({ id: 1 }), + ), + ) + const deleteRequest = await fixture.nextDelete() + deepStrictEqual(queryClient.getQueryData(rpcQuery.users.list.queryKey()), [alan]) + deepStrictEqual(queryClient.getQueryData(httpPages)?.pages[0]?.users, []) + const creation = new MutationObserver(queryClient, userWrites.httpCreate()).mutate({ + payload: { name: 'Grace', locale: 'fr' }, + }) + const createRequest = await fixture.nextCreate() + deepStrictEqual( + queryClient.getQueryData(httpQuery.users.list.queryKey())?.map((user) => user.name), + ['Alan', 'Grace'], + ) + await createRequest.succeed(grace) + await creation + deepStrictEqual( + queryClient.getQueryData(rpcQuery.users.get.queryKey({ id: 3, locale: 'fr' })), + grace, + ) + deepStrictEqual( + queryClient.getQueryData( + httpQuery.users.get.queryKey({ params: { id: 3 }, query: { locale: 'fr' } }), + ), + grace, + ) + await deleteRequest.fail() + equal(Exit.isFailure(await deletion), true) + deepStrictEqual(queryClient.getQueryData(rpcQuery.users.list.queryKey()), [ada, alan, grace]) + deepStrictEqual(queryClient.getQueryData(httpQuery.users.list.queryKey()), [ada, alan, grace]) + deepStrictEqual(queryClient.getQueryData(rpcPages), { + pages: [ + new UserPage({ users: [ada], total: 3, nextCursor: 1 }), + new UserPage({ users: [alan, grace], total: 3, nextCursor: null }), + ], + pageParams: [0, 1], + }) + deepStrictEqual(queryClient.getQueryData(httpPages), queryClient.getQueryData(rpcPages)) + equal(queryClient.getQueryState(rpcQuery.users.list.queryKey())?.isInvalidated, true) + equal(queryClient.getQueryState(httpQuery.users.list.queryKey())?.isInvalidated, true) + equal(queryClient.getQueriesData({ queryKey: rpcQuery.users.list.key() }).length, 1) + equal(queryClient.getQueriesData({ queryKey: httpQuery.users.list.key() }).length, 1) + } finally { + await fixture.application.dispose() + } +} + +for (const newerSucceeds of [true, false]) { + const fixture = await makeOwner() + const { queryClient, rpcQuery, userWrites } = fixture.application + seed(fixture, [ada]) + try { + const older = Effect.runPromiseExit( + Effect.promise(() => + new MutationObserver(queryClient, userWrites.rpcDelete()).mutate({ id: 1 }), + ), + ) + const first = await fixture.nextDelete() + const newer = Effect.runPromiseExit( + Effect.promise(() => + new MutationObserver(queryClient, userWrites.httpDelete()).mutate({ params: { id: 1 } }), + ), + ) + const second = await fixture.nextDelete() + if (newerSucceeds) await second.succeed() + else await second.fail() + await newer + await first.fail() + equal(Exit.isFailure(await older), true) + deepStrictEqual( + queryClient.getQueryData(rpcQuery.users.list.queryKey()), + newerSucceeds ? [] : [ada], + ) + } finally { + await fixture.application.dispose() + } +} + +{ + const fixture = await makeOwner() + const { queryClient, rpcQuery, userWrites } = fixture.application + seed(fixture, []) + fixture.holdLists() + const observer = new QueryObserver( + queryClient, + rpcQuery.users.list.queryOptions({ staleTime: Infinity }), + ) + const unsubscribe = observer.subscribe(() => {}) + try { + const older = new MutationObserver(queryClient, userWrites.rpcCreate()).mutate({ name: 'Ada' }) + const first = await fixture.nextCreate() + await first.succeed(ada) + const stale = await fixture.nextList() + const newer = new MutationObserver(queryClient, userWrites.rpcCreate()).mutate({ + name: 'Grace', + locale: 'fr', + }) + const second = await fixture.nextCreate() + await older + await second.succeed(grace) + const current = await fixture.nextList() + await stale.succeed([ada]) + deepStrictEqual(queryClient.getQueryData(rpcQuery.users.list.queryKey()), [ada, grace]) + await current.succeed([ada, grace]) + await newer + deepStrictEqual(queryClient.getQueryData(rpcQuery.users.list.queryKey()), [ada, grace]) + equal(queryClient.getQueriesData({ queryKey: rpcQuery.users.list.key() }).length, 1) + } finally { + unsubscribe() + await fixture.application.dispose() + } +} + +{ + const fixture = await makeOwner() + const { queryClient, rpcQuery, userWrites } = fixture.application + seed(fixture, []) + try { + const older = new MutationObserver(queryClient, userWrites.rpcCreate()).mutate({ name: 'Ada' }) + const first = await fixture.nextCreate() + const newer = new MutationObserver(queryClient, userWrites.rpcCreate()).mutate({ + name: 'Grace', + locale: 'fr', + }) + const second = await fixture.nextCreate() + await second.succeed(grace) + await newer + await first.succeed(ada) + await older + deepStrictEqual(queryClient.getQueryData(rpcQuery.users.list.queryKey()), [ada, grace]) + } finally { + await fixture.application.dispose() + } +} + +{ + const original = await makeOwner() + const replacement = await makeOwner() + const first = original.application + const second = replacement.application + seed(original, []) + seed(replacement, [grace]) + try { + const observer = new MutationObserver(first.queryClient, first.userWrites.rpcCreate()) + const mutation = observer.mutate({ name: 'Ada' }) + const request = await original.nextCreate() + observer.setOptions(second.userWrites.rpcCreate()) + await request.succeed(ada) + await mutation + deepStrictEqual(first.queryClient.getQueryData(first.rpcQuery.users.list.queryKey()), [ada]) + deepStrictEqual(second.queryClient.getQueryData(second.rpcQuery.users.list.queryKey()), [grace]) + equal( + second.queryClient.getQueryState(second.rpcQuery.users.list.queryKey())?.isInvalidated, + false, + ) + const pending = new MutationObserver(first.queryClient, first.userWrites.rpcCreate()).mutate({ + name: 'Alan', + }) + const late = await original.nextCreate() + original.retire() + first.queryClient.clear() + await late.succeed(alan) + await pending + equal(first.queryClient.getQueryCache().getAll().length, 0) + deepStrictEqual(second.queryClient.getQueryData(second.rpcQuery.users.list.queryKey()), [grace]) + } finally { + await original.application.dispose() + await replacement.application.dispose() + } +} + +{ + const fixture = await makeOwner() + const { queryClient, rpcQuery, userWrites } = fixture.application + seed(fixture, []) + const pages = rpcQuery.users.page.infiniteKey({ cursor: 0, pageSize: 4 }) + queryClient.setQueryData(pages, { + pages: [new UserPage({ users: [], total: 0, nextCursor: null })], + pageParams: [0], + }) + try { + const older = Effect.runPromiseExit( + Effect.promise(() => + new MutationObserver(queryClient, userWrites.rpcCreate()).mutate({ name: 'Ada' }), + ), + ) + const first = await fixture.nextCreate() + const newer = new MutationObserver(queryClient, userWrites.rpcCreate()).mutate({ + name: 'Grace', + locale: 'fr', + }) + const second = await fixture.nextCreate() + await second.succeed(grace) + await newer + await first.fail() + equal(Exit.isFailure(await older), true) + deepStrictEqual(queryClient.getQueryData(rpcQuery.users.list.queryKey()), [grace]) + deepStrictEqual(queryClient.getQueryData(pages), { + pages: [new UserPage({ users: [grace], total: 1, nextCursor: null })], + pageParams: [0], + }) + } finally { + await fixture.application.dispose() + } +} + +console.log( + 'Packed copied optimistic user writes, pages, overlap, stale refetch and original ownership verified', +) diff --git a/tests/packed-consumer/tsconfig.json b/tests/packed-consumer/tsconfig.json index 3a45bca..3bc3280 100644 --- a/tests/packed-consumer/tsconfig.json +++ b/tests/packed-consumer/tsconfig.json @@ -13,6 +13,8 @@ "types": ["node"] }, "include": [ + "optimistic-runtime.mts", + "optimistic-contract.ts", "hashing-runtime.mts", "filter-runtime.mts", "filter-contract.ts", diff --git a/tsconfig.json b/tsconfig.json index 53d39ac..5462fc0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -51,6 +51,7 @@ "tests/fixtures/server-local-rpc.ts", "tests/fixtures/rpc-transport-queries.ts", "tests/fixtures/http-client.ts", + "tests/fixtures/optimistic-users.ts", "tests/types/**/*.ts" ], "options": { From e3a4436e5bea04fcfbd69750520603599b495849 Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Sat, 10 Oct 2026 11:54:47 +0200 Subject: [PATCH 41/97] Keep optimistic write guidance focused on application behavior --- apps/docs/src/content/docs/guides/optimistic-writes.md | 7 ------- 1 file changed, 7 deletions(-) diff --git a/apps/docs/src/content/docs/guides/optimistic-writes.md b/apps/docs/src/content/docs/guides/optimistic-writes.md index 7612350..4c29c25 100644 --- a/apps/docs/src/content/docs/guides/optimistic-writes.md +++ b/apps/docs/src/content/docs/guides/optimistic-writes.md @@ -88,10 +88,3 @@ Track mutation-function completion before releasing the application's ready clie writes use `networkMode: 'always'` and `retry: false`, so a retired owner does not retain paused mutation work. They do not use a native mutation scope queue. Mutations have no query abort signal; retirement and cache clearing do not cancel an accepted server write. - -The existing -[Vite tests](https://github.com/ueberBrot/effect-api-query/blob/main/examples/vite-react/tests/user-writes.test.ts) -and -[copied packed consumer](https://github.com/ueberBrot/effect-api-query/blob/main/tests/packed-consumer/optimistic-runtime.mts) -exercise response seeding, page reconciliation, older failures, late callbacks, stale refetches, -native hashes, and retired owners. From a524532e4069914af614998d592e2b32eef0fc1f Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Sat, 10 Oct 2026 11:55:03 +0200 Subject: [PATCH 42/97] Guard owner callbacks and remount replacement query clients --- .../docs/guides/switch-cache-owners.md | 10 +- examples/vite-react/src/app.tsx | 17 +++- examples/vite-react/src/lib/application.ts | 21 +++- .../vite-react/tests/owner-boundary.test.tsx | 97 ++++++++++--------- .../vite-react/tests/owner-switch.test.ts | 39 ++++++++ tests/packed-consumer/docs-owner-cache.ts | 2 +- tests/packed-consumer/owner-cache-contract.ts | 59 ++++++----- tests/packed-consumer/owner-cache-runtime.mts | 26 ++--- 8 files changed, 178 insertions(+), 93 deletions(-) diff --git a/apps/docs/src/content/docs/guides/switch-cache-owners.md b/apps/docs/src/content/docs/guides/switch-cache-owners.md index 0cc7283..19a9c2c 100644 --- a/apps/docs/src/content/docs/guides/switch-cache-owners.md +++ b/apps/docs/src/content/docs/guides/switch-cache-owners.md @@ -53,7 +53,7 @@ export const ownerApi = HttpApi.make('owner-directory').add( success: DirectoryUser, }), HttpApiEndpoint.post('create', '/users', { - payload: { name: Schema.String }, + payload: Schema.Struct({ name: Schema.String }), success: DirectoryUser, }), ), @@ -128,11 +128,13 @@ The [existing Vite application](https://github.com/ueberBrot/effect-api-query/bl provides `switchViteReactApplication(previous, options)`. Capture `previous`, unmount its consumer tree, then await the handoff and render the returned application. The helper retires the old owner before constructing the new clients, QueryClient, and utilities. A failed acquisition leaves the -old owner inactive; handle that failure in the surrounding application. +old owner inactive; handle that failure in the surrounding application. Use the same unmount, +retire, and replacement sequence when reconnecting clients with an unchanged semantic identity. [ViteReactExample](https://github.com/ueberBrot/effect-api-query/blob/main/examples/vite-react/src/app.tsx) -keys its QueryClientProvider by the immutable owner identity. A changed identity or generation -therefore remounts query and mutation consumers. Passing a new QueryClient through an existing +keys its QueryClientProvider by a component key that changes with the application QueryClient. +Replacing that client therefore remounts query and mutation consumers, including when the safe +semantic identity stays the same. This component key is not part of query keys or persistence. Passing a new QueryClient through an existing provider alone does not replace the QueryClient captured by existing hooks. Reassigning options on a pending MutationObserver can also replace its callbacks. Capture originating ownership in mutation context and replace observers at the handoff; arbitrary observer reassignment is outside diff --git a/examples/vite-react/src/app.tsx b/examples/vite-react/src/app.tsx index ff6aa97..a51a393 100644 --- a/examples/vite-react/src/app.tsx +++ b/examples/vite-react/src/app.tsx @@ -1,4 +1,5 @@ import { QueryClientProvider } from '@tanstack/react-query' +import { useMemo } from 'react' import { CommandsSection } from './components/sections/commands-section.tsx' import { DiagnosticsSection } from './components/sections/diagnostics-section.tsx' @@ -33,8 +34,14 @@ export const ViteReactExample = ({ application, }: { readonly application: ViteReactApplication -}) => ( - - - -) +}) => { + const boundary = useMemo( + () => ({ client: application.queryClient, key: globalThis.crypto.randomUUID() }), + [application.queryClient], + ) + return ( + + + + ) +} diff --git a/examples/vite-react/src/lib/application.ts b/examples/vite-react/src/lib/application.ts index b13bfaf..c757f9e 100644 --- a/examples/vite-react/src/lib/application.ts +++ b/examples/vite-react/src/lib/application.ts @@ -7,6 +7,7 @@ import { createHttpApiQueryUtils, createRpcQueryUtils } from 'effect-api-query' import type { RunPromiseExit } from 'effect-api-query' import { FetchHttpClient, HttpClient, HttpClientRequest } from 'effect/http' import { HttpApiClient } from 'effect/http-api' +import type { RpcClientError } from 'effect/rpc' import { makeOwnerCache, ownerKeyPrefix } from './owner-cache.ts' import type { ApplicationOwnerIdentity, DirectoryStorage } from './owner-cache.ts' @@ -16,7 +17,11 @@ const makeExampleRpcQueryUtils = ( runPromiseExit: RunPromiseExit, keyPrefix: ReturnType, ) => - createRpcQueryUtils(exampleRpcGroup, { + createRpcQueryUtils< + typeof exampleRpcGroup, + ReturnType, + RpcClientError.RpcClientError + >(exampleRpcGroup, { client, keyPrefix, runPromiseExit, @@ -30,8 +35,10 @@ const makeExampleHttpQueryUtils = ( keyPrefix: ReturnType, ) => createHttpApiQueryUtils(exampleHttpApi, { client, keyPrefix, runPromiseExit }) +export type ExampleHttpQueryUtils = ReturnType + export interface ViteReactApplication { - readonly httpQuery: ReturnType + readonly httpQuery: ExampleHttpQueryUtils readonly invalidateUsers: () => Promise readonly dispose: (options?: { readonly discardPersistence?: boolean }) => Promise readonly identity: ApplicationOwnerIdentity @@ -75,7 +82,15 @@ export const startViteReactApplication = async ({ let disposal: Promise | undefined const dispose = async (options?: { readonly discardPersistence?: boolean }) => { const retirement = owner === undefined ? queryClient.cancelQueries() : owner.retire(options) - disposal ??= (async () => { + if (disposal !== undefined) { + try { + await retirement + } finally { + await disposal + } + return + } + disposal = (async () => { try { await retirement } finally { diff --git a/examples/vite-react/tests/owner-boundary.test.tsx b/examples/vite-react/tests/owner-boundary.test.tsx index f4574d0..bd192c0 100644 --- a/examples/vite-react/tests/owner-boundary.test.tsx +++ b/examples/vite-react/tests/owner-boundary.test.tsx @@ -11,50 +11,57 @@ import { startViteReactApplication } from '../src/lib/application.ts' import type { ViteReactApplication } from '../src/lib/application.ts' describe('React consumers at the application owner boundary', () => { - it('remounts a pending mutation observer when the permission generation changes', async () => { - const serverScope = await Effect.runPromise(Scope.make()) - let previous: ViteReactApplication | undefined - let current: ViteReactApplication | undefined - try { - const server = await Effect.runPromise( - startExampleRpcServer().pipe(Scope.provide(serverScope)), - ) - previous = await startViteReactApplication({ rpcUrl: server.rpcUrl }) - const view = render() - const first = screen.getByRole('region', { name: 'Command 1' }) - fireEvent.click(within(first).getByRole('button', { name: 'Start command' })) - await expect(within(first).findByText('Server state: running')).resolves.toBeDefined() - const [status] = previous.queryClient - .getQueriesData({ queryKey: previous.rpcQuery.commands.status.key() }) - .map(([, data]) => data) - expect(status?.operationId).toBeDefined() - if (status === undefined || status === null) { - throw new Error('Expected the started command status') + it.each([1, 2])( + 'remounts native observers when replacing owner clients at permission generation %s', + async (permissionGeneration) => { + const serverScope = await Effect.runPromise(Scope.make()) + let previous: ViteReactApplication | undefined + let current: ViteReactApplication | undefined + try { + const server = await Effect.runPromise( + startExampleRpcServer().pipe(Scope.provide(serverScope)), + ) + previous = await startViteReactApplication({ rpcUrl: server.rpcUrl }) + const view = render() + const first = screen.getByRole('region', { name: 'Command 1' }) + fireEvent.click(within(first).getByRole('button', { name: 'Start command' })) + await expect(within(first).findByText('Server state: running')).resolves.toBeDefined() + const [status] = previous.queryClient + .getQueriesData({ + queryKey: previous.rpcQuery.commands.status.key(), + }) + .map(([, data]) => data) + expect(status?.operationId).toBeDefined() + if (status === undefined || status === null) { + throw new Error('Expected the started command status') + } + current = await startViteReactApplication({ + rpcUrl: server.rpcUrl, + identity: { ...previous.identity, permissionGeneration }, + }) + view.rerender() + expect(screen.getAllByText('Start mutation: idle')).toHaveLength(2) + expect(screen.getAllByText('Server state: not started')).toHaveLength(2) + const capturedCurrent = current + await vi.waitFor(() => { + expect( + capturedCurrent.queryClient.getQueryData( + capturedCurrent.rpcQuery.users.list.queryKey(), + ), + ).toBeDefined() + }) + await current.rpcQuery.commands.cancel + .mutationOptions() + .mutationFn({ operationId: status.operationId }) + await previous.dispose() + expect(previous.queryClient.getQueryCache().getAll()).toHaveLength(0) + expect(current.queryClient.getMutationCache().getAll()).toHaveLength(0) + } finally { + cleanup() + await previous?.dispose() + await current?.dispose() + await Effect.runPromise(Scope.close(serverScope, Exit.void)) } - current = await startViteReactApplication({ - rpcUrl: server.rpcUrl, - identity: { ...previous.identity, permissionGeneration: 2 }, - }) - view.rerender() - expect(screen.getAllByText('Start mutation: idle')).toHaveLength(2) - expect(screen.getAllByText('Server state: not started')).toHaveLength(2) - const capturedCurrent = current - await vi.waitFor(() => { - expect( - capturedCurrent.queryClient.getQueryData(capturedCurrent.rpcQuery.users.list.queryKey()), - ).toBeDefined() - }) - await current.rpcQuery.commands.cancel - .mutationOptions() - .mutationFn({ operationId: status.operationId }) - await previous.dispose() - expect(previous.queryClient.getQueryCache().getAll()).toHaveLength(0) - expect(current.queryClient.getMutationCache().getAll()).toHaveLength(0) - } finally { - cleanup() - await previous?.dispose() - await current?.dispose() - await Effect.runPromise(Scope.close(serverScope, Exit.void)) - } - }) + }, + ) }) diff --git a/examples/vite-react/tests/owner-switch.test.ts b/examples/vite-react/tests/owner-switch.test.ts index 8d22877..1120dd4 100644 --- a/examples/vite-react/tests/owner-switch.test.ts +++ b/examples/vite-react/tests/owner-switch.test.ts @@ -28,6 +28,45 @@ const storage = () => { } describe('existing Vite application owner handoff', () => { + it('reports concurrent persistence-removal failures after cleaning its captured owner', async () => { + const serverScope = await Effect.runPromise(Scope.make()) + const persisted = storage() + const removalFailure = new Error('Storage removal failed') + let removalFails = false + let application: ViteReactApplication | undefined + try { + const server = await Effect.runPromise( + startExampleRpcServer().pipe(Scope.provide(serverScope)), + ) + application = await startViteReactApplication({ + rpcUrl: server.rpcUrl, + identity, + directoryStorage: { + ...persisted, + removeItem: (key) => { + if (removalFails) { + throw removalFailure + } + persisted.removeItem(key) + }, + }, + }) + await application.queryClient.query(application.rpcQuery.users.list.queryOptions()) + removalFails = true + const results = await Promise.allSettled([application.dispose(), application.dispose()]) + expect(results).toStrictEqual([ + { status: 'rejected', reason: removalFailure }, + { status: 'rejected', reason: removalFailure }, + ]) + expect(application.isActive()).toBe(false) + expect(application.queryClient.getQueryCache().getAll()).toHaveLength(0) + } finally { + removalFails = false + await application?.dispose().catch(() => null) + await Effect.runPromise(Scope.close(serverScope, Exit.void)) + } + }) + it('isolates delayed mutation callbacks while preserving the completed server write', async () => { const serverScope = await Effect.runPromise(Scope.make()) const callbackEntered = Deferred.makeUnsafe() diff --git a/tests/packed-consumer/docs-owner-cache.ts b/tests/packed-consumer/docs-owner-cache.ts index a9ea58f..0cf27ac 100644 --- a/tests/packed-consumer/docs-owner-cache.ts +++ b/tests/packed-consumer/docs-owner-cache.ts @@ -34,7 +34,7 @@ export const ownerApi = HttpApi.make('owner-directory').add( success: DirectoryUser, }), HttpApiEndpoint.post('create', '/users', { - payload: { name: Schema.String }, + payload: Schema.Struct({ name: Schema.String }), success: DirectoryUser, }), ), diff --git a/tests/packed-consumer/owner-cache-contract.ts b/tests/packed-consumer/owner-cache-contract.ts index 8dcb654..c1b207e 100644 --- a/tests/packed-consumer/owner-cache-contract.ts +++ b/tests/packed-consumer/owner-cache-contract.ts @@ -1,7 +1,8 @@ import type { MutationOptions, QueryClient } from '@tanstack/react-query' import type { Schema } from 'effect' +import type { EffectRpcQueryError } from 'effect-api-query' import type { HttpApiClient } from 'effect/http-api' -import type { RpcClient, RpcGroup } from 'effect/rpc' +import type { RpcClient, RpcClientError, RpcGroup } from 'effect/rpc' import { DirectoryUser, makeOwnerQueries, ownerApi, ownerGroup } from './docs-owner-cache.ts' import { ownerKeyPrefix } from './owner-cache.ts' @@ -11,32 +12,42 @@ type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false type Assert = T -export type OwnerPrefixProof = Assert< - Equal, readonly ['vite-react', string, string, number, number]> +declare const rpcClient: RpcClient.RpcClient.Flat< + RpcGroup.Rpcs, + RpcClientError.RpcClientError > +declare const httpClient: HttpApiClient.ForApi +declare const identity: ApplicationOwnerIdentity -export const ownerContract = ( - client: RpcClient.RpcClient.Flat, RpcError>, - httpClient: HttpApiClient.ForApi, - identity: ApplicationOwnerIdentity, -) => { - const application = makeOwnerQueries({ rpcClient: client, httpClient, identity }) - const rpcData: readonly (typeof DirectoryUser.Type)[] | undefined = - application.queryClient.getQueryData(application.rpc.users.list.queryKey()) - const httpData: readonly (typeof DirectoryUser.Type)[] | undefined = - application.queryClient.getQueryData(application.http.users.list.queryKey()) - const mutation: MutationOptions = - application.createUser - const queryClient: QueryClient = application.queryClient - return { rpcData, httpData, mutation, queryClient } -} - -export type PersistedDtoProof = Assert< - Equal< - Schema.Schema.Type, - { readonly id: number; readonly name: string; readonly locale: string } - > +const application = makeOwnerQueries({ rpcClient, httpClient, identity }) +const rpcData = application.queryClient.getQueryData(application.rpc.users.list.queryKey()) +const httpData = application.queryClient.getQueryData(application.http.users.list.queryKey()) +application.createUser satisfies MutationOptions< + typeof DirectoryUser.Type, + EffectRpcQueryError, + { readonly name: string }, + undefined > +application.queryClient satisfies QueryClient + +type Contract = [ + Assert< + Equal< + ReturnType, + readonly ['vite-react', string, string, number, number] + > + >, + Assert>, + Assert>, + Assert< + Equal< + Schema.Schema.Type, + { readonly id: number; readonly name: string; readonly locale: string } + > + >, +] +declare const contract: Contract +contract satisfies [true, true, true, true] if (false) { ownerKeyPrefix({ diff --git a/tests/packed-consumer/owner-cache-runtime.mts b/tests/packed-consumer/owner-cache-runtime.mts index d16a7f9..c7cf1ef 100644 --- a/tests/packed-consumer/owner-cache-runtime.mts +++ b/tests/packed-consumer/owner-cache-runtime.mts @@ -1,5 +1,4 @@ import { MutationObserver, QueryClient } from '@tanstack/react-query' -import type { QueryKey } from '@tanstack/react-query' import { Deferred, Effect, Exit, Scope, Stream } from 'effect' import { HttpServer } from 'effect/http' import { HttpApiBuilder, HttpApiTest } from 'effect/http-api' @@ -27,17 +26,17 @@ const storage = { } const wait = async (deferred: Deferred.Deferred) => await Effect.runPromise(Deferred.await(deferred).pipe(Effect.timeout('5 seconds'))) -const waitForData = async (queryClient: QueryClient, key: QueryKey, expected: string) => { - const published = Deferred.makeUnsafe() +const waitForCache = async (queryClient: QueryClient, predicate: () => boolean) => { + const reached = Deferred.makeUnsafe() const check = () => { - if (queryClient.getQueryData(key) === expected) { - Effect.runSync(Deferred.succeed(published, undefined)) + if (predicate()) { + Effect.runSync(Deferred.succeed(reached, undefined)) } } const unsubscribe = queryClient.getQueryCache().subscribe(check) try { check() - await wait(published) + await wait(reached) } finally { unsubscribe() } @@ -107,7 +106,7 @@ await Effect.runPromise( }) const oldClients = yield* makeClients(oldScope, true) const previous = makeOwnerQueries({ ...oldClients, identity, storage }) - let current: ReturnType | undefined + let current: ReturnType> | undefined try { yield* Effect.promise(() => previous.queryClient.query(previous.rpc.users.list.queryOptions()), @@ -120,7 +119,10 @@ await Effect.runPromise( const watchOptions = previous.rpc.users.watch.liveOptions() const watching = previous.queryClient.query(watchOptions).catch(() => null) yield* Effect.promise(() => - waitForData(previous.queryClient, watchOptions.queryKey, 'old first visible'), + waitForCache( + previous.queryClient, + () => previous.queryClient.getQueryData(watchOptions.queryKey) === 'old first visible', + ), ) const mutation = new MutationObserver(previous.queryClient, { ...previous.createUser, @@ -134,6 +136,10 @@ await Effect.runPromise( yield* Deferred.await(mutationEntered) previous.owner.persistDirectory() equal(values.size, 0) + const cacheCleared = waitForCache( + previous.queryClient, + () => previous.queryClient.getQueryCache().getAll().length === 0, + ) let retired = false const retirement = (async () => { await previous.owner.retire() @@ -146,9 +152,7 @@ await Effect.runPromise( /Owner is inactive/u, ), ) - yield* Effect.promise(async () => { - await Promise.resolve() - }) + yield* Effect.promise(() => cacheCleared) equal(retired, false) equal(previous.queryClient.getQueryCache().getAll().length, 0) yield* Deferred.succeed(releaseMutation, undefined) From 7bb700810ea99bf2883b77b97cd74d7733a20269 Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Sat, 10 Oct 2026 11:59:12 +0200 Subject: [PATCH 43/97] Preserve query view representations through JSON hydration --- apps/docs/astro.config.mjs | 1 + .../docs/guides/hydrate-query-views.md | 253 ++++++ .../content/docs/guides/hydrate-unary-data.md | 4 +- scripts/verify-docs-examples.mts | 1 + scripts/verify-packed-consumer.mts | 1 + tests/packed-consumer/docs-hydration-views.ts | 159 ++++ .../hydration-views-contract.ts | 53 ++ .../hydration-views-runtime.mts | 832 ++++++++++++++++++ tests/packed-consumer/tsconfig.json | 2 + 9 files changed, 1304 insertions(+), 2 deletions(-) create mode 100644 apps/docs/src/content/docs/guides/hydrate-query-views.md create mode 100644 tests/packed-consumer/docs-hydration-views.ts create mode 100644 tests/packed-consumer/hydration-views-contract.ts create mode 100644 tests/packed-consumer/hydration-views-runtime.mts diff --git a/apps/docs/astro.config.mjs b/apps/docs/astro.config.mjs index 3653555..48926df 100644 --- a/apps/docs/astro.config.mjs +++ b/apps/docs/astro.config.mjs @@ -76,6 +76,7 @@ export default defineConfig({ { label: 'Retry Queries', slug: 'guides/retry-queries' }, { label: 'Query Defaults', slug: 'guides/query-defaults' }, { label: 'Hydrate Unary Data', slug: 'guides/hydrate-unary-data' }, + { label: 'Hydrate Query Views', slug: 'guides/hydrate-query-views' }, { label: 'Custom Key Encoders', slug: 'guides/custom-key-encoders' }, { label: 'Conditional Queries', slug: 'guides/conditional-queries' }, ], diff --git a/apps/docs/src/content/docs/guides/hydrate-query-views.md b/apps/docs/src/content/docs/guides/hydrate-query-views.md new file mode 100644 index 0000000..c3e5f0a --- /dev/null +++ b/apps/docs/src/content/docs/guides/hydrate-query-views.md @@ -0,0 +1,253 @@ +--- +title: Hydrate Query Views +description: Preserve infinite pages, stream histories, live values, and HTTP metadata across JSON hydration and refetch. +--- + +Choose one codec for each cached representation. A success Schema describes a decoded result; +infinite pages also contain page parameters, and HTTP metadata adds an envelope. Keep decoded +values in the Query Client and encode only the snapshot sent to the browser. + +This recipe extends the [paired Profile codec](/effect-api-query/guides/hydrate-unary-data/). +The server and browser own independent ready clients, runtimes, Scopes, and Query Clients. +Use the same contract, input, key prefix, and Query Client hashing defaults on both sides. + +## Give each view an explicit codec + +Reserve a key prefix of `['hydration-views', codecId, ...applicationIdentity]` when creating each +utility tree. The generated key suffix retains its usual operation, input, and view policies. +Choose `codecId` from the map below; add your owner identity after it when data depends on an owner. + +| Codec ID | Cached representation | +| ----------------- | ------------------------------------------------------------- | +| `history` | Ordered `Profile`, `undefined`, and `null` elements | +| `infinite` | Profile pages and explicitly encoded `Cursor` page parameters | +| `live` | Latest Profile or normalized `null` | +| `buffered` | Decoded HTTP header wrapper with either declared success | +| `metadata` | Buffered wrapper, response status, and raw string headers | +| `wrapped-history` | Ordered decoded HTTP header wrappers | +| `wrapped-live` | Latest decoded HTTP header wrapper | + +Save this as `docs-hydration-views.ts` beside `docs-hydration-rich.ts` from the paired-codec guide: + +```ts +import { defaultShouldDehydrateQuery, dehydrate, hydrate } from '@tanstack/query-core' +import type { DehydratedState, QueryClient, QueryKey } from '@tanstack/query-core' +import { Context, Effect, Schema, SchemaTransformation } from 'effect' +import { HttpApiSchema } from 'effect/http-api' + +import { Profile } from './docs-hydration-rich.ts' + +const UndefinedElement = Schema.Struct({ _tag: Schema.Literal('Undefined') }).pipe( + Schema.decodeTo( + Schema.Undefined, + SchemaTransformation.transform({ + decode: () => undefined, + encode: () => ({ _tag: 'Undefined' as const }), + }), + ), +) + +export const HistoryElement = Schema.Union([Profile, Schema.Null, UndefinedElement]) +export const ProfileHistory = Schema.Array(HistoryElement) + +export class Cursor extends Schema.Class('Cursor')({ offset: Schema.BigIntFromString }) { + isStart(): boolean { + return this.offset === 0n + } +} + +export const ProfilePage = Schema.Struct({ + rows: Schema.Array(Profile), + next: Schema.NullOr(Schema.Finite), +}) +export const ProfilePages = Schema.Struct({ + pages: Schema.Array(ProfilePage), + pageParams: Schema.Array(Cursor), +}) + +export class ArchivedProfile extends Schema.Class('ArchivedProfile')({ + archived: Schema.Literal(true), + name: Schema.String, + credits: Schema.BigIntFromString, +}) { + summary(): string { + return `${this.name}: ${this.credits} archived credits` + } +} + +const DecodedHeaders = Schema.Struct({ 'x-version': Schema.FiniteFromString }) +export const WrappedElement = Schema.Struct({ body: HistoryElement, headers: DecodedHeaders }).pipe( + Schema.decodeTo( + Schema.toType(HttpApiSchema.WithHeaders(HistoryElement, DecodedHeaders)), + SchemaTransformation.transform({ + decode: HttpApiSchema.withHeaders, + encode: ({ body, headers }) => ({ body, headers }), + }), + ), +) +const BufferedBody = Schema.Union([Profile, ArchivedProfile]) +export const BufferedProfile = Schema.Struct({ + body: BufferedBody, + headers: DecodedHeaders, +}).pipe( + Schema.decodeTo( + Schema.toType(HttpApiSchema.WithHeaders(BufferedBody, DecodedHeaders)), + SchemaTransformation.transform({ + decode: HttpApiSchema.withHeaders, + encode: ({ body, headers }) => ({ body, headers }), + }), + ), +) +export const ProfileMetadata = Schema.Struct({ + data: BufferedProfile, + status: Schema.Finite, + headers: Schema.Record(Schema.String, Schema.String), +}) + +export class SnapshotEncoding extends Context.Service< + SnapshotEncoding, + { + readonly beforeEncode: Effect.Effect + } +>()('SnapshotEncoding') {} + +export class SnapshotDecoding extends Context.Service< + SnapshotDecoding, + { + readonly beforeDecode: Effect.Effect + } +>()('SnapshotDecoding') {} + +const prepared = >(schema: S) => + schema.pipe( + Schema.middlewareEncoding((encoding) => + Effect.flatMap(SnapshotEncoding, ({ beforeEncode }) => + Effect.andThen(beforeEncode, encoding), + ), + ), + Schema.middlewareDecoding((decoding) => + Effect.flatMap(SnapshotDecoding, ({ beforeDecode }) => + Effect.andThen(beforeDecode, decoding), + ), + ), + ) + +const codecs = { + history: prepared(ProfileHistory), + infinite: prepared(ProfilePages), + live: prepared(Schema.NullOr(Profile)), + buffered: prepared(BufferedProfile), + metadata: prepared(ProfileMetadata), + 'wrapped-history': prepared(Schema.Array(WrappedElement)), + 'wrapped-live': prepared(WrappedElement), +} +const ViewId = Schema.Literals([ + 'history', + 'infinite', + 'live', + 'buffered', + 'metadata', + 'wrapped-history', + 'wrapped-live', +]) + +const codecFor = Effect.fnUntraced(function* (key: QueryKey) { + yield* Schema.decodeUnknownEffect(Schema.Literal('hydration-views'))(key[0]) + const id = yield* Schema.decodeUnknownEffect(ViewId)(key[1]) + return codecs[id] +}) + +export const prepareViewSnapshot = Effect.fnUntraced(function* (queryClient: QueryClient) { + const snapshot = dehydrate(queryClient, { + shouldDehydrateMutation: () => false, + shouldDehydrateQuery: (query) => + defaultShouldDehydrateQuery(query) && query.queryKey[0] === 'hydration-views', + }) + const queries = yield* Effect.forEach( + snapshot.queries, + Effect.fnUntraced(function* (query) { + const codec = yield* codecFor(query.queryKey) + const data = yield* Schema.encodeUnknownEffect(codec)(query.state.data) + return { ...query, state: { ...query.state, data } } + }), + ) + return JSON.stringify({ ...snapshot, queries }) +}) + +export const prepareViewHydration = Effect.fnUntraced(function* ( + queryClient: QueryClient, + json: string, +) { + const snapshot: DehydratedState = JSON.parse(json) + const queries = yield* Effect.forEach( + snapshot.queries, + Effect.fnUntraced(function* (query) { + const codec = yield* codecFor(query.queryKey) + const data = yield* Schema.decodeUnknownEffect(codec)(query.state.data) + return { ...query, state: { ...query.state, data } } + }), + ) + yield* Effect.sync(() => hydrate(queryClient, { ...snapshot, queries })) +}) +``` + +The map belongs to the application: it knows which page-parameter policy, declared success union, +and public metadata each view needs. TanStack's `serializeData` callback receives data alone. +Use the query key in an explicit preparation stage to choose among these codecs. + +## Preserve page parameters and stream values + +Use a `Cursor` as `infiniteOptions.initialPageParam`. Return the next Cursor from +`getNextPageParam`, and let the deterministic `input` mapper convert its offset into your +contract's request input. `ProfilePages` encodes both arrays: its page parameters become decimal +strings at the JSON boundary and regain their Cursor prototype and methods during hydration. +A result Schema alone cannot choose this page-parameter representation. + +Accumulated history preserves decoded `undefined` elements. Ordinary JSON would turn an +undefined array element into `null`, so `UndefinedElement` encodes it as a distinct tagged value. +A real `null` remains `null`. Live queries already normalize an entirely undefined emission to +`null`; their codec retains that value. + +For an HTTP header wrapper, normalization applies to the whole emitted value. A wrapper whose +`body` is undefined remains a wrapper with an undefined body. `WrappedElement` preserves that +body with the same tagged encoding and reconstructs the wrapper with +`HttpApiSchema.withHeaders`. `WithHeaders` is an HTTP declaration; use explicit body/header +codecs at the JSON boundary so rich body values and decoded numeric headers regain their types. + +`BufferedProfile` includes both declared HTTP successes. Adapt that union to your endpoint and +its status declarations. `ProfileMetadata` preserves decoded data, numeric status, and raw +string headers. Hydrated envelopes belong to the application and remain unfrozen. A subsequent +metadata fetch produces the library's frozen envelope and raw headers again; decoded data stays +mutable. Select public response headers before sending metadata that contains private values. + +## Prepare the complete snapshot before continuing + +After prefetching finite queries, run `prepareViewSnapshot(serverQueryClient)` through the +application's runner with `SnapshotEncoding` provided. Supply any asynchronous encoding work +through `beforeEncode`, then await the returned JSON before publishing it through your framework. +The original cache keeps decoded classes, bigint values, byte arrays, and page parameters. + +For an open HTTP or RPC stream, first await +[fetchStreamSnapshot](/effect-api-query/guides/stream-snapshots/) with its exact generated stream +options. It captures the first visible value and awaits local iterator cleanup. Keep exclusive +ownership of that idle, unobserved key during capture. Only then prepare JSON and dispose the +server's ready-client resources. Local cleanup does not acknowledge remote cancellation. + +On the browser, provide `SnapshotDecoding` and await `prepareViewHydration(browserQueryClient, +json)` before querying or mounting observers. Encoding and decoding require separate services; +one cannot satisfy the other. All selected data decodes before a single native `hydrate` call, +so invalid codec data or an unknown codec ID leaves the browser cache untouched. + +This copied recipe accepts your own trusted server's native dehydrated envelope. It validates +selected query data; it is not a validator for arbitrary external TanStack snapshots. Handle +malformed JSON and preparation failures in the application's rendering or hydration error path. + +Only successful queries in the reserved family enter the snapshot. Mutations and failed-query +errors/Causes are omitted. Hydration therefore leaves an omitted failed query available for a +new browser fetch. Error hydration needs its own application-safe representation. + +Choose native freshness defaults to control duplicate reads. A successful fresh hydrated query +can reuse its reconstructed data; later invalidation and refetch use the browser's independent +ready client and the same decoded representation. For streams, explicit cached capture acquires +no new work; fresh capture starts the browser stream again. Keep Effects and Promises outside +query data and finish preparation before publishing or hydrating. diff --git a/apps/docs/src/content/docs/guides/hydrate-unary-data.md b/apps/docs/src/content/docs/guides/hydrate-unary-data.md index 047e754..79439d8 100644 --- a/apps/docs/src/content/docs/guides/hydrate-unary-data.md +++ b/apps/docs/src/content/docs/guides/hydrate-unary-data.md @@ -8,8 +8,8 @@ and browser must use the same contract, key prefix, and inputs. Each owns its Qu Effect client, and runtime. Cancel outstanding queries before disposing a runtime. These recipes cover unary profile reads through either factory. They keep only successful profile -queries and omit mutations. Use an explicit strategy for other cached shapes, including infinite -pages and stream snapshots. +queries and omit mutations. Use the [query-view recipe](/effect-api-query/guides/hydrate-query-views/) +for infinite pages, stream snapshots, and HTTP metadata. ## Keep DTOs in both caches diff --git a/scripts/verify-docs-examples.mts b/scripts/verify-docs-examples.mts index 66b4d5f..8bd22de 100644 --- a/scripts/verify-docs-examples.mts +++ b/scripts/verify-docs-examples.mts @@ -16,6 +16,7 @@ for (const [page, fixtures] of [ 'guides/query-defaults', ['packed-consumer/docs-defaults', 'packed-consumer/docs-http-defaults'], ], + ['guides/hydrate-query-views', ['packed-consumer/docs-hydration-views']], [ 'guides/hydrate-unary-data', [ diff --git a/scripts/verify-packed-consumer.mts b/scripts/verify-packed-consumer.mts index a6fb011..fa5bf28 100644 --- a/scripts/verify-packed-consumer.mts +++ b/scripts/verify-packed-consumer.mts @@ -521,6 +521,7 @@ const verifyConsumer = (peer: (typeof peerCases)[number]): void => { 'snapshot-runtime.mts', 'defaults-runtime.mts', 'hydration-runtime.mts', + 'hydration-views-runtime.mts', ]) { execFileSync(process.execPath, ['--experimental-import-meta-resolve', fixture], { cwd: consumerDirectory, diff --git a/tests/packed-consumer/docs-hydration-views.ts b/tests/packed-consumer/docs-hydration-views.ts new file mode 100644 index 0000000..48665c7 --- /dev/null +++ b/tests/packed-consumer/docs-hydration-views.ts @@ -0,0 +1,159 @@ +import { defaultShouldDehydrateQuery, dehydrate, hydrate } from '@tanstack/query-core' +import type { DehydratedState, QueryClient, QueryKey } from '@tanstack/query-core' +import { Context, Effect, Schema, SchemaTransformation } from 'effect' +import { HttpApiSchema } from 'effect/http-api' + +import { Profile } from './docs-hydration-rich.ts' + +const UndefinedElement = Schema.Struct({ _tag: Schema.Literal('Undefined') }).pipe( + Schema.decodeTo( + Schema.Undefined, + SchemaTransformation.transform({ + decode: () => undefined, + encode: () => ({ _tag: 'Undefined' as const }), + }), + ), +) + +export const HistoryElement = Schema.Union([Profile, Schema.Null, UndefinedElement]) +export const ProfileHistory = Schema.Array(HistoryElement) + +export class Cursor extends Schema.Class('Cursor')({ offset: Schema.BigIntFromString }) { + isStart(): boolean { + return this.offset === 0n + } +} + +export const ProfilePage = Schema.Struct({ + rows: Schema.Array(Profile), + next: Schema.NullOr(Schema.Finite), +}) +export const ProfilePages = Schema.Struct({ + pages: Schema.Array(ProfilePage), + pageParams: Schema.Array(Cursor), +}) + +export class ArchivedProfile extends Schema.Class('ArchivedProfile')({ + archived: Schema.Literal(true), + name: Schema.String, + credits: Schema.BigIntFromString, +}) { + summary(): string { + return `${this.name}: ${this.credits} archived credits` + } +} + +const DecodedHeaders = Schema.Struct({ 'x-version': Schema.FiniteFromString }) +export const WrappedElement = Schema.Struct({ body: HistoryElement, headers: DecodedHeaders }).pipe( + Schema.decodeTo( + Schema.toType(HttpApiSchema.WithHeaders(HistoryElement, DecodedHeaders)), + SchemaTransformation.transform({ + decode: HttpApiSchema.withHeaders, + encode: ({ body, headers }) => ({ body, headers }), + }), + ), +) +const BufferedBody = Schema.Union([Profile, ArchivedProfile]) +export const BufferedProfile = Schema.Struct({ + body: BufferedBody, + headers: DecodedHeaders, +}).pipe( + Schema.decodeTo( + Schema.toType(HttpApiSchema.WithHeaders(BufferedBody, DecodedHeaders)), + SchemaTransformation.transform({ + decode: HttpApiSchema.withHeaders, + encode: ({ body, headers }) => ({ body, headers }), + }), + ), +) +export const ProfileMetadata = Schema.Struct({ + data: BufferedProfile, + status: Schema.Finite, + headers: Schema.Record(Schema.String, Schema.String), +}) + +export class SnapshotEncoding extends Context.Service< + SnapshotEncoding, + { + readonly beforeEncode: Effect.Effect + } +>()('SnapshotEncoding') {} + +export class SnapshotDecoding extends Context.Service< + SnapshotDecoding, + { + readonly beforeDecode: Effect.Effect + } +>()('SnapshotDecoding') {} + +const prepared = >(schema: S) => + schema.pipe( + Schema.middlewareEncoding((encoding) => + Effect.flatMap(SnapshotEncoding, ({ beforeEncode }) => + Effect.andThen(beforeEncode, encoding), + ), + ), + Schema.middlewareDecoding((decoding) => + Effect.flatMap(SnapshotDecoding, ({ beforeDecode }) => + Effect.andThen(beforeDecode, decoding), + ), + ), + ) + +const codecs = { + history: prepared(ProfileHistory), + infinite: prepared(ProfilePages), + live: prepared(Schema.NullOr(Profile)), + buffered: prepared(BufferedProfile), + metadata: prepared(ProfileMetadata), + 'wrapped-history': prepared(Schema.Array(WrappedElement)), + 'wrapped-live': prepared(WrappedElement), +} +const ViewId = Schema.Literals([ + 'history', + 'infinite', + 'live', + 'buffered', + 'metadata', + 'wrapped-history', + 'wrapped-live', +]) + +const codecFor = Effect.fnUntraced(function* (key: QueryKey) { + yield* Schema.decodeUnknownEffect(Schema.Literal('hydration-views'))(key[0]) + const id = yield* Schema.decodeUnknownEffect(ViewId)(key[1]) + return codecs[id] +}) + +export const prepareViewSnapshot = Effect.fnUntraced(function* (queryClient: QueryClient) { + const snapshot = dehydrate(queryClient, { + shouldDehydrateMutation: () => false, + shouldDehydrateQuery: (query) => + defaultShouldDehydrateQuery(query) && query.queryKey[0] === 'hydration-views', + }) + const queries = yield* Effect.forEach( + snapshot.queries, + Effect.fnUntraced(function* (query) { + const codec = yield* codecFor(query.queryKey) + const data = yield* Schema.encodeUnknownEffect(codec)(query.state.data) + return { ...query, state: { ...query.state, data } } + }), + ) + return JSON.stringify({ ...snapshot, queries }) +}) + +export const prepareViewHydration = Effect.fnUntraced(function* ( + queryClient: QueryClient, + json: string, +) { + const snapshot: DehydratedState = JSON.parse(json) + const queries = yield* Effect.forEach( + snapshot.queries, + Effect.fnUntraced(function* (query) { + const codec = yield* codecFor(query.queryKey) + const data = yield* Schema.decodeUnknownEffect(codec)(query.state.data) + return { ...query, state: { ...query.state, data } } + }), + ) + yield* Effect.sync(() => hydrate(queryClient, { ...snapshot, queries })) +}) diff --git a/tests/packed-consumer/hydration-views-contract.ts b/tests/packed-consumer/hydration-views-contract.ts new file mode 100644 index 0000000..66e1862 --- /dev/null +++ b/tests/packed-consumer/hydration-views-contract.ts @@ -0,0 +1,53 @@ +import type { QueryClient } from '@tanstack/query-core' +import { Effect, Schema } from 'effect' + +import { Profile } from './docs-hydration-rich.ts' +import { + Cursor, + HistoryElement, + prepareViewHydration, + prepareViewSnapshot, + ProfilePages, + SnapshotDecoding, + SnapshotEncoding, +} from './docs-hydration-views.ts' + +type Equal = + (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false +type Assert = T +type Services = T extends Effect.Effect ? R : never +type Contract = [ + Assert>, + Assert>, + Assert>, SnapshotEncoding>>, + Assert>, SnapshotDecoding>>, +] + +declare const queryClient: QueryClient +prepareViewSnapshot(queryClient) satisfies Effect.Effect< + string, + Schema.SchemaError, + SnapshotEncoding +> +prepareViewHydration(queryClient, '{}') satisfies Effect.Effect< + void, + Schema.SchemaError, + SnapshotDecoding +> +// @ts-expect-error Snapshot encoding requires its own caller-provided service. +Effect.runPromise(prepareViewSnapshot(queryClient)) +const decodingDoesNotEncode = prepareViewSnapshot(queryClient).pipe( + Effect.provideService(SnapshotDecoding, { beforeDecode: Effect.void }), +) +// @ts-expect-error A decoding service cannot satisfy encoding. +Effect.runPromise(decodingDoesNotEncode) +// @ts-expect-error Snapshot decoding requires its own caller-provided service. +Effect.runPromise(prepareViewHydration(queryClient, '{}')) +const encodingDoesNotDecode = prepareViewHydration(queryClient, '{}').pipe( + Effect.provideService(SnapshotEncoding, { beforeEncode: Effect.void }), +) +// @ts-expect-error An encoding service cannot satisfy decoding. +Effect.runPromise(encodingDoesNotDecode) + +declare const contract: Contract +contract satisfies [true, true, true, true] diff --git a/tests/packed-consumer/hydration-views-runtime.mts b/tests/packed-consumer/hydration-views-runtime.mts new file mode 100644 index 0000000..ac73a62 --- /dev/null +++ b/tests/packed-consumer/hydration-views-runtime.mts @@ -0,0 +1,832 @@ +import { QueryClient } from '@tanstack/query-core' +import type { QueryExecuteOptions, QueryFunction, QueryKey } from '@tanstack/query-core' +import { Deferred, Effect, Exit, Schema, Scope, Stream } from 'effect' +import { createHttpApiQueryUtils, createRpcQueryUtils, fetchStreamSnapshot } from 'effect-api-query' +import { HttpServer } from 'effect/http' +import { + HttpApi, + HttpApiBuilder, + HttpApiEndpoint, + HttpApiGroup, + HttpApiSchema, + HttpApiTest, +} from 'effect/http-api' +import { Rpc, RpcGroup, RpcTest } from 'effect/rpc' +import { deepStrictEqual, equal, ok, rejects } from 'node:assert/strict' + +import { Profile } from './docs-hydration-rich.ts' +import { + ArchivedProfile, + BufferedProfile, + Cursor, + HistoryElement, + prepareViewHydration, + prepareViewSnapshot, + ProfilePage, + ProfilePages, + ProfileMetadata, + SnapshotDecoding, + SnapshotEncoding, + WrappedElement, +} from './docs-hydration-views.ts' + +const serverProfile = new Profile({ + name: 'Ada', + credits: 9007199254740993n, + avatar: new Uint8Array([0, 1, 255]), +}) +const browserProfile = new Profile({ + name: 'Grace', + credits: 9007199254740995n, + avatar: new Uint8Array([255, 1, 2]), +}) +const viewsGroup = RpcGroup.make( + Rpc.make('page', { payload: { cursor: Schema.Finite }, success: ProfilePage }), + Rpc.make('watch', { payload: { mode: Schema.String }, success: HistoryElement, stream: true }), + Rpc.make('empty', { success: Schema.Void }), +) +const viewsApi = HttpApi.make('hydration-views').add( + HttpApiGroup.make('profiles').add( + HttpApiEndpoint.get('page', '/page', { + query: { cursor: Schema.FiniteFromString }, + success: ProfilePage, + }), + HttpApiEndpoint.get('watch', '/watch', { + query: { mode: Schema.String }, + success: HttpApiSchema.StreamSse({ data: HistoryElement }), + }), + HttpApiEndpoint.get('empty', '/empty', { success: HttpApiSchema.NoContent }), + ), +) + +const envelopeApi = HttpApi.make('hydration-envelopes').add( + HttpApiGroup.make('profiles').add( + HttpApiEndpoint.get('read', '/read', { + success: [ + HttpApiSchema.WithHeaders(Profile, { 'x-version': Schema.FiniteFromString }), + HttpApiSchema.WithHeaders(ArchivedProfile, { 'x-version': Schema.FiniteFromString }).pipe( + HttpApiSchema.status(203), + ), + ], + error: Schema.String, + }), + HttpApiEndpoint.get('watch', '/watch', { + success: HttpApiSchema.WithHeaders(HttpApiSchema.StreamSse({ data: HistoryElement }), { + 'x-version': Schema.FiniteFromString, + }), + }), + ), +) + +const encode = (client: QueryClient) => + Effect.runPromise( + prepareViewSnapshot(client).pipe( + Effect.provideService(SnapshotEncoding, { beforeEncode: Effect.void }), + ), + ) +const decode = (client: QueryClient, json: string) => + Effect.runPromise( + prepareViewHydration(client, json).pipe( + Effect.provideService(SnapshotDecoding, { beforeDecode: Effect.void }), + ), + ) + +const finiteValues = (profile: Profile, mode: string) => + mode === 'history' + ? Stream.make(profile, undefined, null) + : mode === 'null' + ? Stream.make(profile, undefined) + : Stream.succeed(profile) + +const execution = ( + options: QueryExecuteOptions, +) => ({ + key: options.queryKey, + execute: (cache: QueryClient): Promise => cache.query(options), +}) + +const snapshotExecution = ( + options: QueryExecuteOptions & { + readonly queryFn: QueryFunction + readonly queryHash?: never + readonly queryKeyHashFn?: never + }, +) => ({ + key: options.queryKey, + capture: (cache: QueryClient, mode: 'fresh' | 'cached'): Promise => + fetchStreamSnapshot(cache, options, { mode, timeoutMs: 5_000 }), +}) + +await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + for (const transport of ['rpc', 'http'] as const) { + for (const view of ['history', 'live', 'null', 'empty'] as const) { + const serverScope = yield* Scope.make() + const browserScope = yield* Scope.make() + const server = new QueryClient() + const browser = new QueryClient() + let serverReads = 0 + let browserReads = 0 + const makeOptions = Effect.fnUntraced(function* ( + profile: Profile, + scope: Scope.Scope, + onRead: () => void, + ) { + const mode = + profile === serverProfile && view === 'null' + ? 'null' + : view === 'history' + ? 'history' + : 'latest' + const codecId = view === 'history' ? 'history' : 'live' + if (transport === 'rpc') { + const client = yield* RpcTest.makeClient(viewsGroup, { flatten: true }).pipe( + Effect.provide( + viewsGroup.toLayer({ + page: ({ cursor }) => + Effect.succeed({ rows: [profile], next: cursor === 0 ? 1 : null }), + watch: () => + Stream.suspend(() => { + onRead() + return finiteValues(profile, mode) + }), + empty: () => Effect.sync(onRead), + }), + ), + Effect.provideService(Scope.Scope, scope), + ) + const utils = createRpcQueryUtils(viewsGroup, { + client, + keyPrefix: ['hydration-views', codecId, 'rpc'], + }) + return view === 'empty' + ? execution(utils.empty.queryOptions({ retry: false, staleTime: Infinity })) + : view === 'history' + ? execution( + utils.watch.streamedOptions({ + input: { mode: view }, + retry: false, + staleTime: Infinity, + }), + ) + : execution( + utils.watch.liveOptions({ + input: { mode: view }, + retry: false, + staleTime: Infinity, + }), + ) + } + const client = yield* HttpApiTest.groups(viewsApi, ['profiles']).pipe( + Effect.provide( + HttpApiBuilder.group(viewsApi, 'profiles', (handlers) => + handlers + .handle('page', ({ query }) => + Effect.succeed({ rows: [profile], next: query.cursor === 0 ? 1 : null }), + ) + .handle('watch', () => + Effect.sync(() => { + onRead() + return finiteValues(profile, mode) + }), + ) + .handle('empty', () => Effect.sync(onRead)), + ), + ), + Effect.provideService(Scope.Scope, scope), + ) + const utils = createHttpApiQueryUtils(viewsApi, { + client, + keyPrefix: ['hydration-views', codecId, 'http'], + }) + return view === 'empty' + ? execution(utils.profiles.empty.queryOptions({ retry: false, staleTime: Infinity })) + : view === 'history' + ? execution( + utils.profiles.watch.streamedOptions({ + input: { query: { mode: view } }, + retry: false, + staleTime: Infinity, + }), + ) + : execution( + utils.profiles.watch.liveOptions({ + input: { query: { mode: view } }, + retry: false, + staleTime: Infinity, + }), + ) + }) + try { + const serverOptions = yield* makeOptions(serverProfile, serverScope, () => { + serverReads += 1 + }) + const browserOptions = yield* makeOptions(browserProfile, browserScope, () => { + browserReads += 1 + }) + const initial = yield* Effect.promise(() => serverOptions.execute(server)) + if (view === 'history') deepStrictEqual(initial, [serverProfile, undefined, null]) + else + equal( + initial instanceof Profile ? initial.summary() : initial, + view === 'live' ? 'Ada: 9007199254740993 credits' : null, + ) + const json = yield* Effect.promise(() => encode(server)) + equal(server.getQueryData(serverOptions.key), initial) + if (view === 'history') + deepStrictEqual(JSON.parse(json).queries[0].state.data.slice(1), [ + { _tag: 'Undefined' }, + null, + ]) + yield* Effect.promise(() => server.cancelQueries()) + server.clear() + yield* Scope.close(serverScope, Exit.void) + yield* Effect.promise(() => decode(browser, json)) + const restored = yield* Effect.promise(() => browserOptions.execute(browser)) + if (view === 'history') { + deepStrictEqual(restored, [serverProfile, undefined, null]) + ok(Array.isArray(restored) && restored[0] instanceof Profile) + equal(restored[0].summary(), 'Ada: 9007199254740993 credits') + } else + equal( + restored instanceof Profile ? restored.summary() : restored, + view === 'live' ? 'Ada: 9007199254740993 credits' : null, + ) + equal(browserReads, 0) + yield* Effect.promise(() => + browser.invalidateQueries({ + exact: true, + queryKey: browserOptions.key, + refetchType: 'none', + }), + ) + const refetched = yield* Effect.promise(() => browserOptions.execute(browser)) + if (view === 'history') { + deepStrictEqual(refetched, [browserProfile, undefined, null]) + ok(Array.isArray(refetched) && refetched[0] instanceof Profile) + equal(refetched[0].summary(), 'Grace: 9007199254740995 credits') + } else + equal( + refetched instanceof Profile ? refetched.summary() : refetched, + view === 'empty' ? null : 'Grace: 9007199254740995 credits', + ) + equal(serverReads, 1) + equal(browserReads, 1) + } finally { + yield* Effect.promise(() => browser.cancelQueries()) + browser.clear() + server.clear() + yield* Scope.close(serverScope, Exit.void) + yield* Scope.close(browserScope, Exit.void) + } + } + } + }), + ).pipe(Effect.provide(HttpServer.layerServices)), +) + +await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + for (const view of ['history', 'live', 'wrapped-history', 'wrapped-live'] as const) { + const serverScope = yield* Scope.make() + const browserScope = yield* Scope.make() + const server = new QueryClient() + const browser = new QueryClient() + let serverReads = 0 + let browserReads = 0 + const makeSnapshot = Effect.fnUntraced(function* ( + scope: Scope.Scope, + browserSide: boolean, + ) { + const stream = () => { + if (browserSide) browserReads += 1 + else serverReads += 1 + return Stream.succeed(browserSide ? undefined : serverProfile).pipe( + Stream.concat(Stream.fromEffect(Effect.never)), + ) + } + const client = yield* HttpApiTest.groups(viewsApi, ['profiles']).pipe( + Effect.provide( + HttpApiBuilder.group(viewsApi, 'profiles', (handlers) => + handlers + .handle('page', () => Effect.succeed({ rows: [], next: null })) + .handle('empty', () => Effect.void) + .handle('watch', () => Effect.sync(stream)), + ), + ), + Effect.provideService(Scope.Scope, scope), + ) + const wrapped = yield* HttpApiTest.groups(envelopeApi, ['profiles']).pipe( + Effect.provide( + HttpApiBuilder.group(envelopeApi, 'profiles', (handlers) => + handlers + .handle('read', () => + Effect.succeed( + HttpApiSchema.withHeaders({ + body: serverProfile, + headers: { 'x-version': 4 }, + }), + ), + ) + .handle('watch', () => + Effect.sync(() => + HttpApiSchema.withHeaders({ + body: stream(), + headers: { 'x-version': browserSide ? 7 : 4 }, + }), + ), + ), + ), + ), + Effect.provideService(Scope.Scope, scope), + ) + const utils = createHttpApiQueryUtils(viewsApi, { + client, + keyPrefix: ['hydration-views', view, 'http'], + }) + const wrappedUtils = createHttpApiQueryUtils(envelopeApi, { + client: wrapped, + keyPrefix: ['hydration-views', view, 'http'], + }) + if (view === 'history') + return snapshotExecution( + utils.profiles.watch.streamedOptions({ + input: { query: { mode: 'open' } }, + retry: false, + }), + ) + if (view === 'live') + return snapshotExecution( + utils.profiles.watch.liveOptions({ + input: { query: { mode: 'open' } }, + retry: false, + }), + ) + if (view === 'wrapped-history') + return snapshotExecution(wrappedUtils.profiles.watch.streamedOptions({ retry: false })) + return snapshotExecution(wrappedUtils.profiles.watch.liveOptions({ retry: false })) + }) + try { + const serverOptions = yield* makeSnapshot(serverScope, false) + const browserOptions = yield* makeSnapshot(browserScope, true) + const initial = yield* Effect.promise(() => serverOptions.capture(server, 'fresh')) + equal(server.isFetching(), 0) + equal(server.getQueryCache().hasListeners(), false) + if (view === 'history') deepStrictEqual(initial, [serverProfile]) + else if (view === 'live') + equal( + initial instanceof Profile ? initial.summary() : initial, + 'Ada: 9007199254740993 credits', + ) + else + deepStrictEqual( + initial, + view === 'wrapped-history' + ? [HttpApiSchema.withHeaders({ body: serverProfile, headers: { 'x-version': 4 } })] + : HttpApiSchema.withHeaders({ body: serverProfile, headers: { 'x-version': 4 } }), + ) + const json = yield* Effect.promise(() => encode(server)) + equal(server.getQueryData(serverOptions.key), initial) + server.clear() + yield* Scope.close(serverScope, Exit.void) + yield* Effect.promise(() => decode(browser, json)) + const restored = yield* Effect.promise(() => browserOptions.capture(browser, 'cached')) + deepStrictEqual(restored, initial) + const restoredProfile = + view === 'history' + ? browser.getQueryData<(typeof HistoryElement.Type)[]>(browserOptions.key)?.[0] + : view === 'live' + ? browser.getQueryData(browserOptions.key) + : view === 'wrapped-history' + ? browser.getQueryData<(typeof WrappedElement.Type)[]>(browserOptions.key)?.[0] + ?.body + : browser.getQueryData(browserOptions.key)?.body + ok(restoredProfile instanceof Profile) + equal(restoredProfile.summary(), 'Ada: 9007199254740993 credits') + equal(browserReads, 0) + const refetched = yield* Effect.promise(() => browserOptions.capture(browser, 'fresh')) + if (view === 'history') deepStrictEqual(refetched, [undefined]) + else if (view === 'live') equal(refetched, null) + else + deepStrictEqual( + refetched, + view === 'wrapped-history' + ? [HttpApiSchema.withHeaders({ body: undefined, headers: { 'x-version': 7 } })] + : HttpApiSchema.withHeaders({ body: undefined, headers: { 'x-version': 7 } }), + ) + const nextJson = yield* Effect.promise(() => encode(browser)) + const recreated = new QueryClient() + try { + yield* Effect.promise(() => decode(recreated, nextJson)) + deepStrictEqual(recreated.getQueryData(browserOptions.key), refetched) + } finally { + recreated.clear() + } + equal(browser.isFetching(), 0) + equal(browser.getQueryCache().hasListeners(), false) + equal(serverReads, 1) + equal(browserReads, 1) + } finally { + yield* Effect.promise(() => browser.cancelQueries()) + server.clear() + browser.clear() + yield* Scope.close(serverScope, Exit.void) + yield* Scope.close(browserScope, Exit.void) + } + } + }), + ).pipe(Effect.provide(HttpServer.layerServices)), +) + +await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + for (const view of ['buffered', 'metadata'] as const) { + const serverScope = yield* Scope.make() + const browserScope = yield* Scope.make() + const server = new QueryClient() + const browser = new QueryClient() + let serverReads = 0 + let browserReads = 0 + let failed = false + const archived = new ArchivedProfile({ + archived: true, + name: 'Grace', + credits: 9007199254740995n, + }) + const makeRead = Effect.fnUntraced(function* (scope: Scope.Scope, browserSide: boolean) { + const client = yield* HttpApiTest.groups(envelopeApi, ['profiles']).pipe( + Effect.provide( + HttpApiBuilder.group(envelopeApi, 'profiles', (handlers) => + handlers + .handle('read', () => + Effect.suspend(() => { + if (browserSide) browserReads += 1 + else serverReads += 1 + return failed + ? Effect.fail('private upstream detail') + : Effect.succeed( + browserSide + ? HttpApiSchema.withHeaders({ + body: archived, + headers: { 'x-version': 7 }, + }) + : HttpApiSchema.withHeaders({ + body: serverProfile, + headers: { 'x-version': 4 }, + }), + ) + }), + ) + .handle('watch', () => + Effect.succeed( + HttpApiSchema.withHeaders({ + body: Stream.empty, + headers: { 'x-version': 4 }, + }), + ), + ), + ), + ), + Effect.provideService(Scope.Scope, scope), + ) + const utils = createHttpApiQueryUtils(envelopeApi, { + client, + keyPrefix: ['hydration-views', view, 'http'], + }) + return view === 'metadata' + ? execution(utils.profiles.read.metadataOptions({ retry: false, staleTime: Infinity })) + : execution(utils.profiles.read.queryOptions({ retry: false, staleTime: Infinity })) + }) + try { + const serverOptions = yield* makeRead(serverScope, false) + const browserOptions = yield* makeRead(browserScope, true) + const initial = yield* Effect.promise(() => serverOptions.execute(server)) + const json = yield* Effect.promise(() => encode(server)) + equal(server.getQueryData(serverOptions.key), initial) + const wire = JSON.parse(json).queries[0].state.data + equal((view === 'metadata' ? wire.data : wire).body.credits, '9007199254740993') + equal((view === 'metadata' ? wire.data : wire).headers['x-version'], '4') + if (view === 'metadata') { + equal(wire.status, 200) + equal(wire.headers['x-version'], '4') + equal(Object.isFrozen(initial), true) + } + server.clear() + failed = true + yield* Effect.promise(() => rejects(serverOptions.execute(server))) + const failedJson = yield* Effect.promise(() => encode(server)) + equal(JSON.parse(failedJson).queries.length, 0) + equal(failedJson.includes('private upstream detail'), false) + failed = false + server.clear() + yield* Scope.close(serverScope, Exit.void) + yield* Effect.promise(() => decode(browser, json)) + yield* Effect.promise(() => browserOptions.execute(browser)) + const restoredMetadata = + view === 'metadata' + ? browser.getQueryData(browserOptions.key) + : undefined + const restored = + view === 'metadata' + ? restoredMetadata?.data + : browser.getQueryData(browserOptions.key) + ok(restored?.body instanceof Profile) + equal(restored.body.summary(), 'Ada: 9007199254740993 credits') + deepStrictEqual(restored.body.avatar, new Uint8Array([0, 1, 255])) + equal(restored.headers['x-version'], 4) + if (view === 'metadata') { + equal(restoredMetadata?.status, 200) + equal(restoredMetadata?.headers['x-version'], '4') + equal(Object.isFrozen(restoredMetadata), false) + equal(Object.isFrozen(restoredMetadata?.headers), false) + } + equal(browserReads, 0) + yield* Effect.promise(() => + browser.invalidateQueries({ + exact: true, + queryKey: browserOptions.key, + refetchType: 'none', + }), + ) + yield* Effect.promise(() => browserOptions.execute(browser)) + const refetchedMetadata = + view === 'metadata' + ? browser.getQueryData(browserOptions.key) + : undefined + const refetched = + view === 'metadata' + ? refetchedMetadata?.data + : browser.getQueryData(browserOptions.key) + ok(refetched?.body instanceof ArchivedProfile) + equal(refetched.body.summary(), 'Grace: 9007199254740995 archived credits') + equal(refetched.headers['x-version'], 7) + if (view === 'metadata') { + equal(refetchedMetadata?.status, 203) + equal(refetchedMetadata?.headers['x-version'], '7') + equal(Object.isFrozen(refetchedMetadata), true) + equal(Object.isFrozen(refetchedMetadata?.headers), true) + } + equal(serverReads, 2) + equal(browserReads, 1) + browser.clear() + yield* Effect.promise(() => decode(browser, failedJson)) + equal(browser.getQueryCache().getAll().length, 0) + yield* Effect.promise(() => browserOptions.execute(browser)) + equal(browserReads, 2) + } finally { + yield* Effect.promise(() => browser.cancelQueries()) + server.clear() + browser.clear() + yield* Scope.close(serverScope, Exit.void) + yield* Scope.close(browserScope, Exit.void) + } + } + }), + ).pipe(Effect.provide(HttpServer.layerServices)), +) + +await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + for (const transport of ['rpc', 'http'] as const) { + const serverScope = yield* Scope.make() + const browserScope = yield* Scope.make() + const server = new QueryClient() + const browser = new QueryClient() + let serverReads = 0 + let browserReads = 0 + const makePages = Effect.fnUntraced(function* ( + profile: Profile, + scope: Scope.Scope, + onRead: () => void, + ) { + const read = (cursor: number) => + Effect.sync(() => { + onRead() + return { rows: [profile], next: cursor === 0 ? 1 : null } + }) + const policy = { + initialPageParam: new Cursor({ offset: 0n }), + getNextPageParam: (page: typeof ProfilePage.Type) => + page.next === null ? undefined : new Cursor({ offset: BigInt(page.next) }), + retry: false, + staleTime: Infinity, + } + if (transport === 'rpc') { + const client = yield* RpcTest.makeClient(viewsGroup, { flatten: true }).pipe( + Effect.provide( + viewsGroup.toLayer({ + page: ({ cursor }) => read(cursor), + watch: () => Stream.empty, + empty: () => Effect.void, + }), + ), + Effect.provideService(Scope.Scope, scope), + ) + const utils = createRpcQueryUtils(viewsGroup, { + client, + keyPrefix: ['hydration-views', 'infinite', 'rpc'], + }) + const options = utils.page.infiniteOptions({ + ...policy, + input: (cursor) => ({ cursor: Number(cursor.offset) }), + }) + return { + key: options.queryKey, + execute: (cache: QueryClient): Promise => + cache.infiniteQuery({ ...options, pages: 2 }), + } + } + const client = yield* HttpApiTest.groups(viewsApi, ['profiles']).pipe( + Effect.provide( + HttpApiBuilder.group(viewsApi, 'profiles', (handlers) => + handlers + .handle('page', ({ query }) => read(query.cursor)) + .handle('watch', () => Effect.succeed(Stream.empty)) + .handle('empty', () => Effect.void), + ), + ), + Effect.provideService(Scope.Scope, scope), + ) + const utils = createHttpApiQueryUtils(viewsApi, { + client, + keyPrefix: ['hydration-views', 'infinite', 'http'], + }) + const options = utils.profiles.page.infiniteOptions({ + ...policy, + input: (cursor) => ({ query: { cursor: Number(cursor.offset) } }), + }) + return { + key: options.queryKey, + execute: (cache: QueryClient): Promise => + cache.infiniteQuery({ ...options, pages: 2 }), + } + }) + try { + const serverOptions = yield* makePages(serverProfile, serverScope, () => { + serverReads += 1 + }) + const browserOptions = yield* makePages(browserProfile, browserScope, () => { + browserReads += 1 + }) + yield* Effect.promise(() => serverOptions.execute(server)) + const initial = server.getQueryData(serverOptions.key) + ok(initial) + equal(initial.pages.length, 2) + equal(initial.pages[0]?.rows[0]?.summary(), 'Ada: 9007199254740993 credits') + equal(initial.pages[1]?.rows[0]?.summary(), 'Ada: 9007199254740993 credits') + equal(initial.pages[0]?.next, 1) + equal(initial.pages[1]?.next, null) + deepStrictEqual( + initial.pageParams.map((cursor) => cursor.offset), + [0n, 1n], + ) + deepStrictEqual( + initial.pageParams.map((cursor) => cursor.isStart()), + [true, false], + ) + const json = yield* Effect.promise(() => encode(server)) + deepStrictEqual(JSON.parse(json).queries[0].state.data.pageParams, [ + { offset: '0' }, + { offset: '1' }, + ]) + equal(server.getQueryData(serverOptions.key), initial) + server.clear() + yield* Scope.close(serverScope, Exit.void) + yield* Effect.promise(() => decode(browser, json)) + yield* Effect.promise(() => browserOptions.execute(browser)) + const restored = browser.getQueryData(browserOptions.key) + ok(restored) + ok(restored.pages[0]?.rows[0] instanceof Profile) + ok(restored.pages[1]?.rows[0] instanceof Profile) + equal(restored.pages[1]?.rows[0]?.summary(), 'Ada: 9007199254740993 credits') + deepStrictEqual( + restored.pageParams.map((cursor) => cursor.offset), + [0n, 1n], + ) + deepStrictEqual( + restored.pageParams.map((cursor) => cursor.isStart()), + [true, false], + ) + equal(browserReads, 0) + yield* Effect.promise(() => + browser.invalidateQueries({ + exact: true, + queryKey: browserOptions.key, + refetchType: 'none', + }), + ) + yield* Effect.promise(() => browserOptions.execute(browser)) + const refetched = browser.getQueryData(browserOptions.key) + ok(refetched) + equal(refetched.pages[0]?.rows[0]?.summary(), 'Grace: 9007199254740995 credits') + equal(refetched.pages[1]?.rows[0]?.summary(), 'Grace: 9007199254740995 credits') + deepStrictEqual( + refetched.pageParams.map((cursor) => cursor.offset), + [0n, 1n], + ) + deepStrictEqual( + refetched.pageParams.map((cursor) => cursor.isStart()), + [true, false], + ) + equal(serverReads, 2) + equal(browserReads, 2) + } finally { + yield* Effect.promise(() => browser.cancelQueries()) + server.clear() + browser.clear() + yield* Scope.close(serverScope, Exit.void) + yield* Scope.close(browserScope, Exit.void) + } + } + }), + ).pipe(Effect.provide(HttpServer.layerServices)), +) + +await Effect.runPromise( + Effect.gen(function* () { + const server = new QueryClient() + const browser = new QueryClient() + const untouched = new QueryClient() + const historyKey = ['hydration-views', 'history', 'application'] as const + const infiniteKey = ['hydration-views', 'infinite', 'application'] as const + const metadataKey = ['hydration-views', 'metadata', 'application'] as const + const history = [serverProfile, undefined, null] + server.setQueryData(historyKey, history) + server.setQueryData(infiniteKey, { + pages: [{ rows: [serverProfile], next: null }], + pageParams: [new Cursor({ offset: 0n })], + }) + server.setQueryData(metadataKey, { + data: HttpApiSchema.withHeaders({ body: serverProfile, headers: { 'x-version': 4 } }), + status: 200, + headers: { 'x-version': '4' }, + }) + const encodingStarted = yield* Deferred.make() + const encodingAllowed = yield* Deferred.make() + const decodingStarted = yield* Deferred.make() + const decodingAllowed = yield* Deferred.make() + let published: string | undefined + try { + const encoding = Effect.runPromise( + prepareViewSnapshot(server).pipe( + Effect.provideService(SnapshotEncoding, { + beforeEncode: Deferred.succeed(encodingStarted, undefined).pipe( + Effect.andThen(Deferred.await(encodingAllowed)), + ), + }), + ), + ).then((json) => { + published = json + return json + }) + yield* Deferred.await(encodingStarted) + equal(published, undefined) + equal(server.getQueryData(historyKey), history) + yield* Deferred.succeed(encodingAllowed, undefined) + const json = yield* Effect.promise(() => encoding) + equal(JSON.parse(json).queries.length, 3) + equal(server.getQueryData(historyKey), history) + const decoding = Effect.runPromise( + prepareViewHydration(browser, json).pipe( + Effect.provideService(SnapshotDecoding, { + beforeDecode: Deferred.succeed(decodingStarted, undefined).pipe( + Effect.andThen(Deferred.await(decodingAllowed)), + ), + }), + ), + ) + yield* Deferred.await(decodingStarted) + equal(browser.getQueryCache().getAll().length, 0) + yield* Deferred.succeed(decodingAllowed, undefined) + yield* Effect.promise(() => decoding) + deepStrictEqual(browser.getQueryData(historyKey), [serverProfile, undefined, null]) + equal( + browser.getQueryData(infiniteKey)?.pageParams[0]?.isStart(), + true, + ) + const metadata = browser.getQueryData(metadataKey) + ok(metadata?.data.body instanceof Profile) + equal(metadata.data.body.summary(), 'Ada: 9007199254740993 credits') + deepStrictEqual( + metadata.data, + HttpApiSchema.withHeaders({ body: serverProfile, headers: { 'x-version': 4 } }), + ) + const invalid = JSON.parse(json) + invalid.queries[2].state.data.data.body.credits = 'not a bigint' + yield* Effect.promise(() => rejects(decode(untouched, JSON.stringify(invalid)))) + equal(untouched.getQueryCache().getAll().length, 0) + const unknown = JSON.parse(json) + unknown.queries[2].queryKey[1] = 'unsupported-codec' + yield* Effect.promise(() => rejects(decode(untouched, JSON.stringify(unknown)))) + equal(untouched.getQueryCache().getAll().length, 0) + } finally { + server.clear() + browser.clear() + untouched.clear() + } + }), +) diff --git a/tests/packed-consumer/tsconfig.json b/tests/packed-consumer/tsconfig.json index 3a45bca..5b8a874 100644 --- a/tests/packed-consumer/tsconfig.json +++ b/tests/packed-consumer/tsconfig.json @@ -28,6 +28,8 @@ "docs-defaults.ts", "docs-http-defaults.ts", "hydration-runtime.mts", + "hydration-views-runtime.mts", + "hydration-views-contract.ts", "http-runtime.mts", "http-metadata-runtime.mts", "http-etag-recipe.mts", From b4b7bce3de753bf415da4455ef0c2f64852c23c8 Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Sat, 10 Oct 2026 12:00:03 +0200 Subject: [PATCH 44/97] Preserve relative order when overlapping user deletions roll back --- .../content/docs/guides/optimistic-writes.md | 3 + examples/vite-react/src/lib/user-writes.ts | 56 ++++++++++++--- examples/vite-react/tests/user-writes.test.ts | 69 +++++++++++++++++++ tests/packed-consumer/optimistic-runtime.mts | 60 ++++++++++++++++ 4 files changed, 178 insertions(+), 10 deletions(-) diff --git a/apps/docs/src/content/docs/guides/optimistic-writes.md b/apps/docs/src/content/docs/guides/optimistic-writes.md index 4c29c25..5da7cde 100644 --- a/apps/docs/src/content/docs/guides/optimistic-writes.md +++ b/apps/docs/src/content/docs/guides/optimistic-writes.md @@ -61,6 +61,9 @@ uses; this example registers only its existing page size and initial cursor. A full snapshot rollback can erase a later successful write. The example instead removes a failed creation's own temporary user, or restores one deleted user without replacing the list. +Overlapping deletions retain their original relative order, even when their failures finish in a +different order. Rollback places the removed user beside surviving original neighbors and keeps +newly created users. Overlapping deletions of the same user share the first snapshot: one successful deletion prevents an older failure from restoring it, while all failed deletions restore the original entity once. diff --git a/examples/vite-react/src/lib/user-writes.ts b/examples/vite-react/src/lib/user-writes.ts index c7ce356..23cd299 100644 --- a/examples/vite-react/src/lib/user-writes.ts +++ b/examples/vite-react/src/lib/user-writes.ts @@ -21,6 +21,12 @@ export interface DeleteTransaction { readonly rollback: () => void readonly settle: () => Promise } +interface DirectorySnapshot { + readonly rpcList: readonly User[] | undefined + readonly httpList: readonly User[] | undefined + readonly rpcPages: InfiniteData | undefined + readonly httpPages: InfiniteData | undefined +} export type UserMutationOptions = MutationOptions< Data, Failure, @@ -81,7 +87,20 @@ const restoreList = ( ) { return current } - return [...current.slice(0, index), user, ...current.slice(index)] + const following = snapshot + ?.slice(index + 1) + .find((candidate) => current.some((existing) => existing.id === candidate.id)) + let position = 0 + for (const candidate of snapshot?.slice(0, index) ?? []) { + const precedingIndex = current.findIndex((existing) => existing.id === candidate.id) + if (precedingIndex !== -1) { + position = precedingIndex + 1 + } + } + if (following !== undefined) { + position = current.findIndex((existing) => existing.id === following.id) + } + return [...current.slice(0, position), user, ...current.slice(position)] } const restorePages = ( @@ -270,14 +289,11 @@ export const makeUserWrites = ({ runMutation(async () => options.mutationFn(...args)), } } + let deletionBaseline: DirectorySnapshot | undefined const deletionGroups = new Map< number, - { + DirectorySnapshot & { readonly pending: Set - readonly rpcList: readonly User[] | undefined - readonly httpList: readonly User[] | undefined - readonly rpcPages: InfiniteData | undefined - readonly httpPages: InfiniteData | undefined readonly applied: boolean succeeded: boolean } @@ -296,6 +312,9 @@ export const makeUserWrites = ({ return } deletionGroups.delete(id) + if (deletionGroups.size === 0) { + deletionBaseline = undefined + } if (!isActive() || group.succeeded || !group.applied) { return } @@ -320,6 +339,23 @@ export const makeUserWrites = ({ if (group === undefined) { const lists = [queryClient.getQueryData(rpcList), queryClient.getQueryData(httpList)] const pages = [queryClient.getQueryData(rpcPages), queryClient.getQueryData(httpPages)] + deletionBaseline ??= { + rpcList: lists[0], + httpList: lists[1], + rpcPages: pages[0], + httpPages: pages[1], + } + const listSnapshot = ( + baseline: readonly User[] | undefined, + current: readonly User[] | undefined, + ) => ((baseline?.some((user) => user.id === id) ?? false) ? baseline : current) + const pageSnapshot = ( + baseline: InfiniteData | undefined, + current: InfiniteData | undefined, + ) => + (baseline?.pages.some((page) => page.users.some((user) => user.id === id)) ?? false) + ? baseline + : current const applied = lists.some((users) => users?.some((user) => user.id === id) ?? false) || pages.some( @@ -329,10 +365,10 @@ export const makeUserWrites = ({ pending: new Set(), succeeded: false, applied, - rpcList: lists[0], - httpList: lists[1], - rpcPages: pages[0], - httpPages: pages[1], + rpcList: listSnapshot(deletionBaseline.rpcList, lists[0]), + httpList: listSnapshot(deletionBaseline.httpList, lists[1]), + rpcPages: pageSnapshot(deletionBaseline.rpcPages, pages[0]), + httpPages: pageSnapshot(deletionBaseline.httpPages, pages[1]), } deletionGroups.set(id, group) if (applied) { diff --git a/examples/vite-react/tests/user-writes.test.ts b/examples/vite-react/tests/user-writes.test.ts index 7312afd..fb597d6 100644 --- a/examples/vite-react/tests/user-writes.test.ts +++ b/examples/vite-react/tests/user-writes.test.ts @@ -148,6 +148,75 @@ describe('User writes in the Vite application', () => { } }) + it.each([true, false])( + 'restores distinct failed deletions in directory order with the earlier failure first: %s', + async (earlierFailsFirst) => { + const fixture = await Effect.runPromise(makeControlledUserWrites(new QueryClient())) + const { queryClient, rpcQuery, httpQuery, userWrites } = fixture.application + const ada = new User({ id: 1, name: 'Ada', locale: 'en' }) + const alan = new User({ id: 2, name: 'Alan', locale: 'en' }) + const grace = new User({ id: 3, name: 'Grace', locale: 'en' }) + const lists = [rpcQuery.users.list.queryKey(), httpQuery.users.list.queryKey()] + const pages = [ + rpcQuery.users.page.infiniteKey({ cursor: 0, pageSize: 4 }), + httpQuery.users.page.infiniteKey({ query: { cursor: 0, pageSize: 4 } }), + ] + for (const key of lists) { + queryClient.setQueryData(key, [ada, alan]) + } + for (const key of pages) { + queryClient.setQueryData(key, { + pages: [new UserPage({ users: [ada, alan], total: 2, nextCursor: null })], + pageParams: [0], + }) + } + try { + const older = Effect.runPromiseExit( + Effect.promise(async () => + new MutationObserver(queryClient, userWrites.rpcDelete()).mutate({ id: 1 }), + ), + ) + const first = await fixture.nextDelete() + const newer = Effect.runPromiseExit( + Effect.promise(async () => + new MutationObserver(queryClient, userWrites.httpDelete()).mutate({ + params: { id: 2 }, + }), + ), + ) + const second = await fixture.nextDelete() + const creation = new MutationObserver(queryClient, userWrites.rpcCreate()).mutate({ + name: 'Grace', + }) + const created = await fixture.nextCreate() + await created.succeed(grace) + await creation + if (earlierFailsFirst) { + await first.fail() + await older + await second.fail() + await newer + } else { + await second.fail() + await newer + await first.fail() + await older + } + for (const key of lists) { + expect(queryClient.getQueryData(key)).toStrictEqual([ada, alan, grace]) + } + for (const key of pages) { + expect(queryClient.getQueryData(key)).toStrictEqual({ + pages: [new UserPage({ users: [ada, alan, grace], total: 3, nextCursor: null })], + pageParams: [0], + }) + } + } finally { + await fixture.application.dispose() + } + }, + ) + it('preserves a newer creation when an older create callback completes late', async () => { const fixture = await Effect.runPromise(makeControlledUserWrites(new QueryClient())) const { queryClient, rpcQuery, userWrites } = fixture.application diff --git a/tests/packed-consumer/optimistic-runtime.mts b/tests/packed-consumer/optimistic-runtime.mts index 9689779..d41a38f 100644 --- a/tests/packed-consumer/optimistic-runtime.mts +++ b/tests/packed-consumer/optimistic-runtime.mts @@ -94,6 +94,66 @@ const grace = new User({ id: 3, name: 'Grace', locale: 'fr' }) } } +for (const earlierFailsFirst of [true, false]) { + const fixture = await makeOwner() + const { queryClient, rpcQuery, httpQuery, userWrites } = fixture.application + seed(fixture, [ada, alan]) + const lists = [rpcQuery.users.list.queryKey(), httpQuery.users.list.queryKey()] + const pages = [ + rpcQuery.users.page.infiniteKey({ cursor: 0, pageSize: 4 }), + httpQuery.users.page.infiniteKey({ query: { cursor: 0, pageSize: 4 } }), + ] + for (const key of pages) { + queryClient.setQueryData(key, { + pages: [new UserPage({ users: [ada, alan], total: 2, nextCursor: null })], + pageParams: [0], + }) + } + try { + const older = Effect.runPromiseExit( + Effect.promise(() => + new MutationObserver(queryClient, userWrites.rpcDelete()).mutate({ id: 1 }), + ), + ) + const first = await fixture.nextDelete() + const newer = Effect.runPromiseExit( + Effect.promise(() => + new MutationObserver(queryClient, userWrites.httpDelete()).mutate({ params: { id: 2 } }), + ), + ) + const second = await fixture.nextDelete() + const creation = new MutationObserver(queryClient, userWrites.rpcCreate()).mutate({ + name: 'Grace', + locale: 'fr', + }) + const created = await fixture.nextCreate() + await created.succeed(grace) + await creation + if (earlierFailsFirst) { + await first.fail() + await older + await second.fail() + await newer + } else { + await second.fail() + await newer + await first.fail() + await older + } + for (const key of lists) { + deepStrictEqual(queryClient.getQueryData(key), [ada, alan, grace]) + } + for (const key of pages) { + deepStrictEqual(queryClient.getQueryData(key), { + pages: [new UserPage({ users: [ada, alan, grace], total: 3, nextCursor: null })], + pageParams: [0], + }) + } + } finally { + await fixture.application.dispose() + } +} + for (const newerSucceeds of [true, false]) { const fixture = await makeOwner() const { queryClient, rpcQuery, userWrites } = fixture.application From 2a055ba089cffaf4cabaeac9ebd8e71f14c73b22 Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Sat, 10 Oct 2026 12:04:08 +0200 Subject: [PATCH 45/97] Keep tracked mutation option declarations portable --- examples/vite-react/src/lib/owner-cache.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/examples/vite-react/src/lib/owner-cache.ts b/examples/vite-react/src/lib/owner-cache.ts index 7c219b6..e492e82 100644 --- a/examples/vite-react/src/lib/owner-cache.ts +++ b/examples/vite-react/src/lib/owner-cache.ts @@ -15,6 +15,16 @@ const OwnerIdentity = Schema.Struct({ export type ApplicationOwnerIdentity = typeof OwnerIdentity.Type export type DirectoryStorage = Pick +type TrackedMutationOptions = MutationOptions< + TData, + TError, + TVariables, + TOnMutateResult +> & { + readonly mutationFn: MutationFunction + readonly networkMode: 'always' + readonly retry: false +} const Directory = Schema.Array( Schema.Struct({ id: Schema.Int, name: Schema.String, locale: Schema.String }), @@ -78,7 +88,7 @@ export const makeOwnerCache = ({ options: MutationOptions & { readonly mutationFn: MutationFunction }, - ) => ({ + ): TrackedMutationOptions => ({ ...options, networkMode: 'always' as const, retry: false as const, From e06eb6cfbb2bc8984c5ccc680a7092e2e57410c6 Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Sat, 10 Oct 2026 12:08:23 +0200 Subject: [PATCH 46/97] Keep ownership handoff explicit and hydration recipes reachable --- .fallowrc.jsonc | 1 + apps/docs/src/content/docs/guides/hydrate-query-views.md | 2 +- apps/docs/src/content/docs/guides/switch-cache-owners.md | 6 +++--- examples/vite-react/src/lib/application.ts | 8 -------- examples/vite-react/tests/owner-switch.test.ts | 5 +++-- tests/packed-consumer/docs-hydration-views.ts | 2 +- 6 files changed, 9 insertions(+), 15 deletions(-) diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 2c83b72..2c721dc 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -25,6 +25,7 @@ "tests/packed-consumer/defaults-runtime.mts", "tests/packed-consumer/defaults-contract.ts", "tests/packed-consumer/hydration-runtime.mts", + "tests/packed-consumer/hydration-views-runtime.mts", "tests/packed-consumer/hydration-contract.ts" ], diff --git a/apps/docs/src/content/docs/guides/hydrate-query-views.md b/apps/docs/src/content/docs/guides/hydrate-query-views.md index c3e5f0a..266a418 100644 --- a/apps/docs/src/content/docs/guides/hydrate-query-views.md +++ b/apps/docs/src/content/docs/guides/hydrate-query-views.md @@ -48,7 +48,7 @@ const UndefinedElement = Schema.Struct({ _tag: Schema.Literal('Undefined') }).pi ) export const HistoryElement = Schema.Union([Profile, Schema.Null, UndefinedElement]) -export const ProfileHistory = Schema.Array(HistoryElement) +const ProfileHistory = Schema.Array(HistoryElement) export class Cursor extends Schema.Class('Cursor')({ offset: Schema.BigIntFromString }) { isStart(): boolean { diff --git a/apps/docs/src/content/docs/guides/switch-cache-owners.md b/apps/docs/src/content/docs/guides/switch-cache-owners.md index 19a9c2c..e16264c 100644 --- a/apps/docs/src/content/docs/guides/switch-cache-owners.md +++ b/apps/docs/src/content/docs/guides/switch-cache-owners.md @@ -125,9 +125,9 @@ manual cache updates, and any later work after an `await` too. ## Replace the consumer boundary The [existing Vite application](https://github.com/ueberBrot/effect-api-query/blob/main/examples/vite-react/src/lib/application.ts) -provides `switchViteReactApplication(previous, options)`. Capture `previous`, unmount its consumer -tree, then await the handoff and render the returned application. The helper retires the old owner -before constructing the new clients, QueryClient, and utilities. A failed acquisition leaves the +provides `startViteReactApplication(options)`. Capture `previous`, unmount its consumer tree, +and await `previous.dispose()` before starting and rendering the replacement application. This +retires the old owner before constructing the new clients, QueryClient, and utilities. A failed acquisition leaves the old owner inactive; handle that failure in the surrounding application. Use the same unmount, retire, and replacement sequence when reconnecting clients with an unchanged semantic identity. diff --git a/examples/vite-react/src/lib/application.ts b/examples/vite-react/src/lib/application.ts index 0ef73ef..a765e38 100644 --- a/examples/vite-react/src/lib/application.ts +++ b/examples/vite-react/src/lib/application.ts @@ -173,11 +173,3 @@ export const startViteReactApplication = async ({ throw error } } - -export const switchViteReactApplication = async ( - previous: ViteReactApplication, - options: StartViteReactApplicationOptions, -): Promise => { - await previous.dispose() - return startViteReactApplication(options) -} diff --git a/examples/vite-react/tests/owner-switch.test.ts b/examples/vite-react/tests/owner-switch.test.ts index 0ef1738..e2173a6 100644 --- a/examples/vite-react/tests/owner-switch.test.ts +++ b/examples/vite-react/tests/owner-switch.test.ts @@ -3,7 +3,7 @@ import { MutationObserver } from '@tanstack/react-query' import { Deferred, Effect, Exit, Scope } from 'effect' import { describe, expect, it, vi } from 'vite-plus/test' -import { startViteReactApplication, switchViteReactApplication } from '../src/lib/application.ts' +import { startViteReactApplication } from '../src/lib/application.ts' import type { ViteReactApplication } from '../src/lib/application.ts' const identity = { @@ -152,7 +152,8 @@ describe('existing Vite application owner handoff', () => { }) const writing = mutation.mutate({ name: 'Completed before handoff' }) await Effect.runPromise(Deferred.await(callbackEntered)) - current = await switchViteReactApplication(captured, { + await captured.dispose() + current = await startViteReactApplication({ rpcUrl: server.rpcUrl, identity: { ...identity, permissionGeneration: 2 }, directoryStorage: persisted, diff --git a/tests/packed-consumer/docs-hydration-views.ts b/tests/packed-consumer/docs-hydration-views.ts index 48665c7..6d5c8f5 100644 --- a/tests/packed-consumer/docs-hydration-views.ts +++ b/tests/packed-consumer/docs-hydration-views.ts @@ -16,7 +16,7 @@ const UndefinedElement = Schema.Struct({ _tag: Schema.Literal('Undefined') }).pi ) export const HistoryElement = Schema.Union([Profile, Schema.Null, UndefinedElement]) -export const ProfileHistory = Schema.Array(HistoryElement) +const ProfileHistory = Schema.Array(HistoryElement) export class Cursor extends Schema.Class('Cursor')({ offset: Schema.BigIntFromString }) { isStart(): boolean { From f4d6e714a9f2051f6692211a7aeb04ad04192e43 Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Sat, 10 Oct 2026 12:29:58 +0200 Subject: [PATCH 47/97] Refresh generated reads from scoped user events --- .fallowrc.jsonc | 2 + apps/docs/astro.config.mjs | 1 + .../content/docs/guides/cache-management.md | 2 + .../docs/guides/refresh-from-events.md | 142 ++++++ scripts/verify-docs-examples.mts | 1 + scripts/verify-packed-consumer.mts | 18 +- tests/packed-consumer/docs-user-events.ts | 29 ++ tests/packed-consumer/events-contract.ts | 42 ++ tests/packed-consumer/events-runtime.mts | 430 ++++++++++++++++++ tests/packed-consumer/tsconfig.json | 4 + tests/packed-consumer/user-events.ts | 247 ++++++++++ 11 files changed, 917 insertions(+), 1 deletion(-) create mode 100644 apps/docs/src/content/docs/guides/refresh-from-events.md create mode 100644 tests/packed-consumer/docs-user-events.ts create mode 100644 tests/packed-consumer/events-contract.ts create mode 100644 tests/packed-consumer/events-runtime.mts create mode 100644 tests/packed-consumer/user-events.ts diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 2c721dc..cf54708 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -11,6 +11,8 @@ "tests/packed-consumer/hashing-runtime.mts", "tests/packed-consumer/optimistic-runtime.mts", "tests/packed-consumer/optimistic-contract.ts", + "tests/packed-consumer/events-runtime.mts", + "tests/packed-consumer/events-contract.ts", "tests/packed-consumer/filter-runtime.mts", "tests/packed-consumer/filter-contract.ts", "tests/packed-consumer/http-metadata-runtime.mts", diff --git a/apps/docs/astro.config.mjs b/apps/docs/astro.config.mjs index 357473b..61ed051 100644 --- a/apps/docs/astro.config.mjs +++ b/apps/docs/astro.config.mjs @@ -73,6 +73,7 @@ export default defineConfig({ { label: 'Cache Filters', slug: 'guides/cache-filters' }, { label: 'Switch Cache Owners', slug: 'guides/switch-cache-owners' }, { label: 'Optimistic User Writes', slug: 'guides/optimistic-writes' }, + { label: 'Refresh from Domain Events', slug: 'guides/refresh-from-events' }, { label: 'Cancellation', slug: 'guides/cancellation' }, { label: 'Handle Failures', slug: 'guides/handle-failures' }, { label: 'Retry Queries', slug: 'guides/retry-queries' }, diff --git a/apps/docs/src/content/docs/guides/cache-management.md b/apps/docs/src/content/docs/guides/cache-management.md index 4d39153..96aabd5 100644 --- a/apps/docs/src/content/docs/guides/cache-management.md +++ b/apps/docs/src/content/docs/guides/cache-management.md @@ -12,6 +12,8 @@ await queryClient.invalidateQueries({ queryKey: rpcQuery.users.get.key() }) For response seeding, targeted rollback, and overlapping mutations, follow [Optimistic User Writes](/effect-api-query/guides/optimistic-writes/). +Map delivered domain events and scoped Effect Reactivity keys with +[Refresh Reads from Domain Events](/effect-api-query/guides/refresh-from-events/). For operation prefixes and partial encoded-input matching, use the complete [Cache Filters recipe](/effect-api-query/guides/cache-filters/). diff --git a/apps/docs/src/content/docs/guides/refresh-from-events.md b/apps/docs/src/content/docs/guides/refresh-from-events.md new file mode 100644 index 0000000..52fec20 --- /dev/null +++ b/apps/docs/src/content/docs/guides/refresh-from-events.md @@ -0,0 +1,142 @@ +--- +title: Refresh Reads from Domain Events +description: Map domain delivery and Effect Reactivity to scoped native Query invalidation. +--- + +Use one application-owned consumer to map a domain event to the reads it changes. The mapping +must name both adapters when RPC and HTTP represent the same domain: their generated cache roots +remain separate. + +Copy [the user-event consumer](https://github.com/ueberBrot/effect-api-query/blob/main/tests/packed-consumer/user-events.ts) +next to your owner, write, and client modules, adjusting its local application imports. Its inputs are the captured owner and +an application-owned Effect Reactivity service. Supply your own decoded network stream; the module +creates no connection, broker, provider, or second cache. + +## Register one consumer for the owner + +Acquire the consumer once in the owner's Scope. The following attachment accepts a supplied +Effect Stream and preserves its failure and service requirements. Run its `consume` Effect with +your application runtime and keep that Scope open across transport reconnects. + +```ts +import { Effect, Stream } from 'effect' +import type { Reactivity } from 'effect/reactivity' + +import { decodeUserEvent, makeUserEventConsumer } from './user-events.ts' +import type { UserEventOwner } from './user-events.ts' + +export const attachUserEvents = Effect.fnUntraced(function* ( + owner: UserEventOwner, + reactivity: Reactivity.Reactivity, +) { + const consumer = yield* makeUserEventConsumer(owner, reactivity, { + observedUsers: [{ id: 1, locale: 'en' }], + }) + const consume = (events: Stream.Stream) => + events.pipe( + Stream.mapEffect((event) => decodeUserEvent(event)), + Stream.runForEach(consumer.deliver), + Effect.ensuring(Effect.promise(consumer.flush)), + ) + return { + consumer, + consume, + latestDiagnostic: owner.rpcQuery.diagnostics.stream.liveOptions(), + diagnosticHistory: owner.rpcQuery.diagnostics.stream.streamedOptions({ + maxChunks: 64, + refetchMode: 'reset', + }), + } +}) +``` + +`makeUserEventConsumer` rejects an inactive owner or a second consumer for the same QueryClient. +Scope closure unregisters Reactivity handlers and the mutation-cache listener. Close the event +Scope, retire the captured owner, and await its disposal before replacing clients; remount the +React consumers as described in [Switch Cache Owners](/effect-api-query/guides/switch-cache-owners/). + +A closed or retired consumer ignores queued delivery and invalidation callbacks. It retains its +original QueryClient and cannot redirect an old event to a replacement client. A refresh already +started belongs to the native query lifecycle; owner retirement cancels those reads and awaits +local cleanup before releasing ready clients. + +## Map events to reads + +The `users.v1` envelope includes the consumer's `ownerKey`, a positive safe-integer cursor, and one +of these event kinds: + +| Event | Reads invalidated | +| -------------------------------------- | ----------------------------------------------------------------- | +| `users.changed` | RPC and HTTP list/page prefixes, including infinite page entries. | +| `user.changed`, with `id` and `locale` | Those directory prefixes and both exact user-detail keys. | +| `users.reset` | Both complete user branches. | + +Other user IDs, locales, and diagnostic branches remain untouched by an ordinary detail event. +Generated keys and native filters retain the owning QueryClient's global and prefix hash defaults. +The mapping depends on the domain relationship, so the package does not infer it from mutation or +event names. + +The `ownerKey` contains safe tenant, user, session, and permission-generation identity. It is a +routing partition, not authentication. The application must authenticate the transport and +validate which owner may receive each event. + +## Coalesce local invalidation + +Use the consumer's `reactivityKeys.directory`, `reactivityKeys.all`, or +`reactivityKeys.user({ id, locale })` with Effect Reactivity. Exact local user mappings exist for +the `observedUsers` registered when the consumer starts; include every detail read your local +writers must refresh. Delivered domain events can target any valid user. + +These keys are stable application strings. Reactivity hashes its own keys; a TanStack query tuple +has no implied Reactivity subscription or prefix relationship. Register the deliberate mapping +rather than passing generated tuples to both systems. + +Reactivity `withBatch` combines repeated invalidations of the same key. Distinct keys can still +invoke distinct handlers. The consumer collects their intent in one microtask and lets a full +user-branch refresh subsume narrower work. It merges more than 64 pending detail identities into +a full refresh, bounding delivery bookkeeping. Events received during a refresh form the next +batch. + +Refreshes wait while the captured QueryClient has pending mutations, so an event does not start a +read over an optimistic write. The scoped mutation listener schedules retained intent when those +writes settle. Use the same QueryClient for these writes and follow +[Optimistic User Writes](/effect-api-query/guides/optimistic-writes/). Coordinate external writes +and other manual refresh paths through your own application policy. + +`flush()` awaits currently runnable batches. During a pending mutation it leaves intent queued +until settlement. `onRefreshError` receives failures from the refresh job; ordinary native query +failures retain TanStack's query/error state and retry policy. The default refresh-job handler +reports the error to the console. + +## Resume delivery deliberately + +This recipe assumes one ordered, monotonically increasing cursor sequence per owner. It ignores +cursors already accepted. A gap queues a full user-branch refresh and advances the accepted cursor. +The cursor records accepted invalidation intent; it is not a durable acknowledgement or a claim +that a read succeeded. + +Reconnect using `consumer.cursor()` as the last accepted cursor. Call `consumer.resume(true)` when +the transport can replay subsequent events in order. Replayed duplicates remain harmless. If +replay is unavailable, call `consumer.resume(false)` before accepting the new sequence: it resets +the cursor and refreshes both user branches. Stop delivery from the old connection first. + +The application owns reconnect backoff, replay retention, cursor storage, source epochs, schema +migration, authorization, acknowledgements, and out-of-order delivery handling. A new owner Scope +starts at cursor zero. Fetch its authoritative reads on startup; this recipe does not promise +durable or exactly-once delivery. Invalid envelopes fail Schema decoding before changing the cursor. + +## Choose a visible stream view + +The example's diagnostic stream emits complete diagnostic values. `latestDiagnostic` uses a live +query; `diagnosticHistory` keeps at most 64 values with reset refetch behavior. Use the same choices +for an indefinitely running state stream: live queries require complete snapshots, while a bounded +accumulated query can display a recent event history. + +An accumulated query with `refetchMode: 'replace'` publishes its replacement only when the refetch +stream completes. An indefinite stream may never publish that replacement. Use a live full +snapshot or a bounded history with a visibility policy suited to the UI. A live query containing +only the latest delta does not reconstruct domain state. + +Native `Stream.runForEach` runs the supplied stream, and generated keys serve native Query filters. +Keep delivery, replay, and owner policy in the application consumer so its runtime and Scope remain +explicit. diff --git a/scripts/verify-docs-examples.mts b/scripts/verify-docs-examples.mts index 177e2ad..07cbba7 100644 --- a/scripts/verify-docs-examples.mts +++ b/scripts/verify-docs-examples.mts @@ -8,6 +8,7 @@ for (const [page, fixtures] of [ ['getting-started/http-quick-start', ['types/docs-http-quick-start']], ['guides/retry-queries', ['packed-consumer/docs-retry']], ['guides/switch-cache-owners', ['packed-consumer/docs-owner-cache']], + ['guides/refresh-from-events', ['packed-consumer/docs-user-events']], ['guides/stream-snapshots', ['packed-consumer/docs-stream-snapshot']], [ 'guides/cache-filters', diff --git a/scripts/verify-packed-consumer.mts b/scripts/verify-packed-consumer.mts index d249c9a..12c385f 100644 --- a/scripts/verify-packed-consumer.mts +++ b/scripts/verify-packed-consumer.mts @@ -454,7 +454,12 @@ const verifyConsumer = (peer: (typeof peerCases)[number]): void => { "'./optimistic-application/user-writes.ts'", ), ) - for (const file of ['optimistic-runtime.mts', 'optimistic-contract.ts']) { + for (const file of [ + 'optimistic-runtime.mts', + 'optimistic-contract.ts', + 'events-runtime.mts', + 'events-contract.ts', + ]) { const path = nodePath.join(consumerDirectory, file) writeFileSync( path, @@ -466,6 +471,16 @@ const verifyConsumer = (peer: (typeof peerCases)[number]): void => { .replaceAll("'../fixtures/optimistic-users.ts'", "'./optimistic-users.ts'"), ) } + const eventRecipe = nodePath.join(consumerDirectory, 'user-events.ts') + writeFileSync( + eventRecipe, + readFileSync(eventRecipe, 'utf-8') + .replaceAll( + "'../../examples/vite-react/src/lib/application.ts'", + "'./optimistic-application/application.ts'", + ) + .replaceAll("'../../examples/vite-react/src/lib/owner-cache.ts'", "'./owner-cache.ts'"), + ) for (const fixture of [ 'public-contract.ts', 'tanstack-start-contract.ts', @@ -574,6 +589,7 @@ const verifyConsumer = (peer: (typeof peerCases)[number]): void => { for (const fixture of [ 'owner-cache-runtime.mts', 'optimistic-runtime.mts', + 'events-runtime.mts', 'hashing-runtime.mts', 'filter-runtime.mts', 'runtime.mts', diff --git a/tests/packed-consumer/docs-user-events.ts b/tests/packed-consumer/docs-user-events.ts new file mode 100644 index 0000000..2ed26d2 --- /dev/null +++ b/tests/packed-consumer/docs-user-events.ts @@ -0,0 +1,29 @@ +import { Effect, Stream } from 'effect' +import type { Reactivity } from 'effect/reactivity' + +import { decodeUserEvent, makeUserEventConsumer } from './user-events.ts' +import type { UserEventOwner } from './user-events.ts' + +export const attachUserEvents = Effect.fnUntraced(function* ( + owner: UserEventOwner, + reactivity: Reactivity.Reactivity, +) { + const consumer = yield* makeUserEventConsumer(owner, reactivity, { + observedUsers: [{ id: 1, locale: 'en' }], + }) + const consume = (events: Stream.Stream) => + events.pipe( + Stream.mapEffect((event) => decodeUserEvent(event)), + Stream.runForEach(consumer.deliver), + Effect.ensuring(Effect.promise(consumer.flush)), + ) + return { + consumer, + consume, + latestDiagnostic: owner.rpcQuery.diagnostics.stream.liveOptions(), + diagnosticHistory: owner.rpcQuery.diagnostics.stream.streamedOptions({ + maxChunks: 64, + refetchMode: 'reset', + }), + } +}) diff --git a/tests/packed-consumer/events-contract.ts b/tests/packed-consumer/events-contract.ts new file mode 100644 index 0000000..85b047a --- /dev/null +++ b/tests/packed-consumer/events-contract.ts @@ -0,0 +1,42 @@ +import type { Effect, Schema, Scope, Stream } from 'effect' + +import type { attachUserEvents } from './docs-user-events.ts' +import type { + UserEvent, + UserEventConsumerUnavailable, + makeUserEventConsumer, +} from './user-events.ts' + +type Equal = + (() => Value extends Left ? 1 : 2) extends () => Value extends Right ? 1 : 2 + ? true + : false +type Assert = Value + +type Attachment = Effect.Success> +declare const attachment: Attachment +interface DeliveryFailure { + readonly _tag: 'DeliveryFailure' +} +interface DeliveryService { + readonly _tag: 'DeliveryService' +} +declare const delivered: Stream.Stream +const consumption = attachment.consume(delivered) +const delivery = attachment.consumer.deliver({ + schema: 'users.v1', + ownerKey: 'owner', + cursor: 1, + kind: 'users.changed', +}) +true satisfies Assert< + Equal>, UserEventConsumerUnavailable> +> +true satisfies Assert>, Scope.Scope>> +true satisfies Assert, DeliveryFailure | Schema.SchemaError>> +true satisfies Assert, DeliveryService>> +true satisfies Assert, Schema.SchemaError>> +true satisfies Assert, never>> +true satisfies Assert[0], UserEvent>> +true satisfies Assert, Promise>> +true satisfies Assert>, number>> diff --git a/tests/packed-consumer/events-runtime.mts b/tests/packed-consumer/events-runtime.mts new file mode 100644 index 0000000..d061e54 --- /dev/null +++ b/tests/packed-consumer/events-runtime.mts @@ -0,0 +1,430 @@ +import { MutationObserver, QueryClient, QueryObserver } from '@tanstack/query-core' +import { Effect, Exit, Option, Scope, Stream } from 'effect' +import { Reactivity } from 'effect/reactivity' +import { deepStrictEqual, equal } from 'node:assert/strict' + +import { User, UserPage } from '../../examples/contracts/src/contracts.ts' +import { makeControlledUserWrites } from '../fixtures/optimistic-users.ts' +import { attachUserEvents } from './docs-user-events.ts' +import { decodeUserEvent, makeUserEventConsumer } from './user-events.ts' + +const queryClient = new QueryClient({ + defaultOptions: { queries: { queryKeyHashFn: (key) => `owner:${JSON.stringify(key)}` } }, +}) +const fixture = await Effect.runPromise(makeControlledUserWrites(queryClient)) +const { rpcQuery, httpQuery } = fixture.application +queryClient.setQueryDefaults(rpcQuery.users.key(), { + queryKeyHashFn: (key) => `rpc:${JSON.stringify(key)}`, +}) +queryClient.setQueryDefaults(httpQuery.users.key(), { + queryKeyHashFn: (key) => `http:${JSON.stringify(key)}`, +}) +const scope = Effect.runSync(Scope.make()) +const reactivity = Effect.runSync(Reactivity.make) +const ada = new User({ id: 1, name: 'Ada', locale: 'en' }) +const alan = new User({ id: 2, name: 'Alan', locale: 'en' }) +const rpcDirectory = rpcQuery.users.list.queryKey() +const httpDirectory = httpQuery.users.list.queryKey() +const rpcAda = rpcQuery.users.get.queryKey({ id: 1, locale: 'en' }) +const httpAda = httpQuery.users.get.queryKey({ params: { id: 1 }, query: { locale: 'en' } }) +const rpcAlan = rpcQuery.users.get.queryKey({ id: 2, locale: 'en' }) +const diagnostic = rpcQuery.diagnostics.status.queryKey() +queryClient.setQueryData(rpcDirectory, [ada, alan]) +queryClient.setQueryData(httpDirectory, [ada, alan]) +queryClient.setQueryData(rpcAda, ada) +queryClient.setQueryData(httpAda, ada) +queryClient.setQueryData(rpcAlan, alan) +queryClient.setQueryData(diagnostic, { started: 0, interrupted: 0 }) +try { + const consumer = await Effect.runPromise( + makeUserEventConsumer(fixture.application, reactivity, { + observedUsers: [{ id: 1, locale: 'en' }], + }).pipe(Scope.provide(scope)), + ) + equal( + await Effect.runPromise( + consumer.deliver({ + schema: 'users.v1', + ownerKey: consumer.ownerKey, + cursor: 1, + kind: 'user.changed', + id: 1, + locale: 'en', + }), + ), + true, + ) + await consumer.flush() + equal(queryClient.getQueryState(rpcDirectory)?.isInvalidated, true) + equal(queryClient.getQueryState(httpDirectory)?.isInvalidated, true) + equal(queryClient.getQueryState(rpcAda)?.isInvalidated, true) + equal(queryClient.getQueryState(httpAda)?.isInvalidated, true) + equal(queryClient.getQueryState(rpcAlan)?.isInvalidated, false) + equal(queryClient.getQueryState(diagnostic)?.isInvalidated, false) + queryClient.setQueryData(rpcAda, ada) + queryClient.setQueryData(httpAda, ada) + reactivity.invalidateUnsafe([consumer.reactivityKeys.user({ id: 1, locale: 'en' })]) + await consumer.flush() + equal(queryClient.getQueryState(rpcAda)?.isInvalidated, true) + equal(queryClient.getQueryState(httpAda)?.isInvalidated, true) + equal(queryClient.getQueryState(rpcAlan)?.isInvalidated, false) + fixture.holdLists() + const rpcObserver = new QueryObserver( + queryClient, + rpcQuery.users.list.queryOptions({ + staleTime: Infinity, + }), + ) + const httpObserver = new QueryObserver( + queryClient, + httpQuery.users.list.queryOptions({ + staleTime: Infinity, + }), + ) + queryClient.setQueryData(rpcDirectory, [ada, alan]) + queryClient.setQueryData(httpDirectory, [ada, alan]) + const unsubscribeRpc = rpcObserver.subscribe(() => {}) + const unsubscribeHttp = httpObserver.subscribe(() => {}) + try { + await Effect.runPromise( + reactivity.withBatch( + Effect.gen(function* () { + yield* reactivity.invalidate([consumer.reactivityKeys.directory]) + yield* reactivity.invalidate([consumer.reactivityKeys.directory]) + yield* reactivity.invalidate([consumer.reactivityKeys.all]) + }), + ), + ) + const first = await fixture.nextList() + const second = await fixture.nextList() + const grace = new User({ id: 3, name: 'Grace', locale: 'fr' }) + await first.succeed([grace]) + await second.succeed([grace]) + await consumer.flush() + deepStrictEqual( + queryClient.getQueryData(rpcDirectory)?.map((user) => user.name), + ['Grace'], + ) + deepStrictEqual( + queryClient.getQueryData(httpDirectory)?.map((user) => user.name), + ['Grace'], + ) + equal(queryClient.getQueryState(diagnostic)?.isInvalidated, false) + } finally { + unsubscribeRpc() + unsubscribeHttp() + } + queryClient.setQueryData(rpcAda, ada) + queryClient.setQueryData(httpAda, ada) + equal( + await Effect.runPromise( + consumer.deliver({ + schema: 'users.v1', + ownerKey: consumer.ownerKey, + cursor: 1, + kind: 'user.changed', + id: 1, + locale: 'en', + }), + ), + false, + ) + await consumer.flush() + equal(queryClient.getQueryState(rpcAda)?.isInvalidated, false) + equal(await Effect.runPromise(consumer.resume(true)), 1) + equal( + await Effect.runPromise( + consumer.deliver({ + schema: 'users.v1', + ownerKey: consumer.ownerKey, + cursor: 3, + kind: 'user.changed', + id: 1, + locale: 'en', + }), + ), + true, + ) + await consumer.flush() + equal(queryClient.getQueryState(rpcAlan)?.isInvalidated, true) + equal(queryClient.getQueryState(diagnostic)?.isInvalidated, false) + queryClient.setQueryData(rpcAlan, alan) + equal(await Effect.runPromise(consumer.resume(false)), 0) + await consumer.flush() + equal(queryClient.getQueryState(rpcAlan)?.isInvalidated, true) + equal(consumer.cursor(), 0) + queryClient.setQueryData(rpcAda, ada) + const rpcPages = rpcQuery.users.page.infiniteKey({ cursor: 0, pageSize: 4 }) + const httpPages = httpQuery.users.page.infiniteKey({ query: { cursor: 0, pageSize: 4 } }) + const pages = { + pages: [new UserPage({ users: [ada], total: 1, nextCursor: null })], + pageParams: [0], + } + queryClient.setQueryData(rpcPages, pages) + queryClient.setQueryData(httpPages, pages) + equal( + await Effect.runPromise( + consumer.deliver({ + schema: 'users.v1', + ownerKey: consumer.ownerKey, + cursor: 1, + kind: 'users.changed', + }), + ), + true, + ) + await consumer.flush() + equal(queryClient.getQueryState(rpcPages)?.isInvalidated, true) + equal(queryClient.getQueryState(httpPages)?.isInvalidated, true) + equal(queryClient.getQueryState(rpcAda)?.isInvalidated, false) + equal( + await Effect.runPromise( + consumer.deliver({ + schema: 'users.v1', + ownerKey: consumer.ownerKey, + cursor: 2, + kind: 'users.reset', + }), + ), + true, + ) + await consumer.flush() + equal(queryClient.getQueryState(rpcAda)?.isInvalidated, true) + const malformed = await Effect.runPromiseExit( + decodeUserEvent({ + schema: 'users.v2', + ownerKey: consumer.ownerKey, + cursor: 1, + kind: 'user.changed', + id: 1, + locale: 'en', + }).pipe(Effect.flatMap(consumer.deliver)), + ) + equal(Exit.isFailure(malformed), true) + equal(consumer.cursor(), 2) + equal( + await Effect.runPromise( + consumer.deliver({ + schema: 'users.v1', + ownerKey: 'other-owner', + cursor: 3, + kind: 'user.changed', + id: 1, + locale: 'en', + }), + ), + false, + ) + equal(consumer.cursor(), 2) + const duplicateScope = Effect.runSync(Scope.make()) + try { + const duplicate = await Effect.runPromiseExit( + makeUserEventConsumer(fixture.application, reactivity).pipe(Scope.provide(duplicateScope)), + ) + equal(Exit.isFailure(duplicate), true) + } finally { + await Effect.runPromise(Scope.close(duplicateScope, Exit.void)) + } + queryClient.setQueryData(rpcAda, ada) + await Effect.runPromise(Scope.close(scope, Exit.void)) + reactivity.invalidateUnsafe([consumer.reactivityKeys.all]) + equal( + await Effect.runPromise( + consumer.deliver({ + schema: 'users.v1', + ownerKey: consumer.ownerKey, + cursor: 1, + kind: 'user.changed', + id: 1, + locale: 'en', + }), + ), + false, + ) + await consumer.flush() + equal(queryClient.getQueryState(rpcAda)?.isInvalidated, false) + const reconnectScope = Effect.runSync(Scope.make()) + try { + const reconnect = await Effect.runPromise( + makeUserEventConsumer(fixture.application, reactivity).pipe(Scope.provide(reconnectScope)), + ) + reactivity.invalidateUnsafe([reconnect.reactivityKeys.all]) + const queuedDelivery = Promise.resolve().then(async () => + Effect.runPromise( + reconnect.deliver({ + schema: 'users.v1', + ownerKey: reconnect.ownerKey, + cursor: 1, + kind: 'user.changed', + id: 1, + locale: 'en', + }), + ), + ) + fixture.retire() + equal(await queuedDelivery, false) + await reconnect.flush() + await fixture.application.dispose() + equal(queryClient.getQueryData(rpcAda), undefined) + equal( + await Effect.runPromise( + reconnect.deliver({ + schema: 'users.v1', + ownerKey: reconnect.ownerKey, + cursor: 1, + kind: 'user.changed', + id: 1, + locale: 'en', + }), + ), + false, + ) + const replacementClient = new QueryClient() + const replacement = await Effect.runPromise(makeControlledUserWrites(replacementClient)) + try { + const key = replacement.application.rpcQuery.users.get.queryKey({ id: 1, locale: 'en' }) + const newOwnerUser = new User({ id: 1, locale: 'en', name: 'New owner' }) + replacementClient.setQueryData(key, newOwnerUser) + equal( + await Effect.runPromise( + reconnect.deliver({ + schema: 'users.v1', + ownerKey: reconnect.ownerKey, + cursor: 2, + kind: 'users.reset', + }), + ), + false, + ) + await reconnect.flush() + equal(replacementClient.getQueryState(key)?.isInvalidated, false) + deepStrictEqual(replacementClient.getQueryData(key), newOwnerUser) + } finally { + await replacement.application.dispose() + } + } finally { + await Effect.runPromise(Scope.close(reconnectScope, Exit.void)) + } +} finally { + await Effect.runPromise(Scope.close(scope, Exit.void)) + await fixture.application.dispose() +} + +{ + const client = new QueryClient() + const bounded = await Effect.runPromise(makeControlledUserWrites(client)) + const boundedScope = Effect.runSync(Scope.make()) + const reactivity = Effect.runSync(Reactivity.make) + const key = bounded.application.rpcQuery.users.get.queryKey({ id: 1, locale: 'en' }) + client.setQueryData(key, ada) + try { + const consumer = await Effect.runPromise( + makeUserEventConsumer(bounded.application, reactivity).pipe(Scope.provide(boundedScope)), + ) + await Effect.runPromise( + Effect.gen(function* () { + for (let cursor = 1; cursor <= 65; cursor += 1) { + yield* consumer.deliver({ + schema: 'users.v1', + ownerKey: consumer.ownerKey, + cursor, + kind: 'user.changed', + id: cursor + 1, + locale: 'en', + }) + } + }), + ) + await consumer.flush() + equal(client.getQueryState(key)?.isInvalidated, true) + equal(consumer.cursor(), 65) + } finally { + await Effect.runPromise(Scope.close(boundedScope, Exit.void)) + await bounded.application.dispose() + } +} + +{ + const client = new QueryClient() + const writing = await Effect.runPromise(makeControlledUserWrites(client)) + const writingScope = Effect.runSync(Scope.make()) + const writingReactivity = Effect.runSync(Reactivity.make) + const app = writing.application + writing.seed([ada]) + client.setQueryData(app.rpcQuery.users.list.queryKey(), [ada]) + client.setQueryData(app.httpQuery.users.list.queryKey(), [ada]) + let finishCreation: (() => Promise) | undefined + try { + const consumer = await Effect.runPromise( + makeUserEventConsumer(app, writingReactivity).pipe(Scope.provide(writingScope)), + ) + const mutation = new MutationObserver(client, app.userWrites.rpcCreate()).mutate({ + name: 'Grace', + }) + const create = await writing.nextCreate() + finishCreation = async () => { + await create.succeed(new User({ id: 3, name: 'Grace', locale: 'en' })) + await mutation + } + await Effect.runPromise( + consumer.deliver({ + schema: 'users.v1', + ownerKey: consumer.ownerKey, + cursor: 1, + kind: 'users.reset', + }), + ) + await consumer.flush() + equal(client.getQueryState(app.rpcQuery.users.list.queryKey())?.isInvalidated, false) + deepStrictEqual( + client.getQueryData(app.rpcQuery.users.list.queryKey())?.map((user) => user.name), + ['Ada', 'Grace'], + ) + await create.succeed(new User({ id: 3, name: 'Grace', locale: 'en' })) + await mutation + await consumer.flush() + equal(client.getQueryState(app.rpcQuery.users.list.queryKey())?.isInvalidated, true) + equal(client.getQueryState(app.httpQuery.users.list.queryKey())?.isInvalidated, true) + } finally { + await finishCreation?.() + await Effect.runPromise(Scope.close(writingScope, Exit.void)) + await app.dispose() + } +} + +{ + const client = new QueryClient() + const streaming = await Effect.runPromise(makeControlledUserWrites(client)) + const streamScope = Effect.runSync(Scope.make()) + const app = streaming.application + const reactivity = Effect.runSync(Reactivity.make) + client.setQueryData(app.rpcQuery.users.list.queryKey(), [ada]) + client.setQueryData(app.httpQuery.users.list.queryKey(), [ada]) + try { + const attached = await Effect.runPromise( + attachUserEvents(app, reactivity).pipe(Scope.provide(streamScope)), + ) + const event = { + schema: 'users.v1', + ownerKey: attached.consumer.ownerKey, + cursor: 1, + kind: 'users.changed', + } + await Effect.runPromise(attached.consume(Stream.fromIterable([event, event]))) + equal(attached.consumer.cursor(), 1) + equal(client.getQueryState(app.rpcQuery.users.list.queryKey())?.isInvalidated, true) + equal(client.getQueryState(app.httpQuery.users.list.queryKey())?.isInvalidated, true) + const failedDelivery = await Effect.runPromiseExit( + attached.consume(Stream.fail('connection-lost' as const)), + ) + equal(Exit.isFailure(failedDelivery), true) + equal(Option.getOrThrow(Exit.findErrorOption(failedDelivery)), 'connection-lost') + } finally { + await Effect.runPromise(Scope.close(streamScope, Exit.void)) + await app.dispose() + } +} + +console.log( + 'Packed scoped domain events, replay, native invalidation and optimistic coordination verified', +) diff --git a/tests/packed-consumer/tsconfig.json b/tests/packed-consumer/tsconfig.json index 7d90d4f..5bdf2c6 100644 --- a/tests/packed-consumer/tsconfig.json +++ b/tests/packed-consumer/tsconfig.json @@ -19,6 +19,10 @@ "owner-cache-runtime.mts", "optimistic-runtime.mts", "optimistic-contract.ts", + "events-runtime.mts", + "events-contract.ts", + "docs-user-events.ts", + "user-events.ts", "hashing-runtime.mts", "filter-runtime.mts", "filter-contract.ts", diff --git a/tests/packed-consumer/user-events.ts b/tests/packed-consumer/user-events.ts new file mode 100644 index 0000000..e06aeb9 --- /dev/null +++ b/tests/packed-consumer/user-events.ts @@ -0,0 +1,247 @@ +import type { QueryClient, QueryFilters } from '@tanstack/react-query' +import { Data, Effect, Schema } from 'effect' +import type { Reactivity } from 'effect/reactivity' + +import type { ViteReactApplication } from '../../examples/vite-react/src/lib/application.ts' +import { ownerKeyPrefix } from '../../examples/vite-react/src/lib/owner-cache.ts' + +const EventEnvelope = { + schema: Schema.Literal('users.v1'), + ownerKey: Schema.String, + cursor: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER })), +} +const UserEventSchema = Schema.Union([ + Schema.Struct({ + ...EventEnvelope, + kind: Schema.Literal('user.changed'), + id: Schema.Int, + locale: Schema.String, + }), + Schema.Struct({ ...EventEnvelope, kind: Schema.Literal('users.changed') }), + Schema.Struct({ ...EventEnvelope, kind: Schema.Literal('users.reset') }), +]) + +export const decodeUserEvent = Schema.decodeUnknownEffect(UserEventSchema) +export type UserEvent = typeof UserEventSchema.Type + +export class UserEventConsumerUnavailable extends Data.TaggedError('UserEventConsumerUnavailable')<{ + readonly reason: 'inactive-owner' | 'already-registered' +}> {} + +interface UserIdentity { + readonly id: number + readonly locale: string +} + +export type UserEventOwner = Pick< + ViteReactApplication, + 'identity' | 'isActive' | 'queryClient' | 'rpcQuery' | 'httpQuery' +> + +const consumers = new WeakSet() + +export const makeUserEventConsumer = Effect.fnUntraced(function* ( + owner: UserEventOwner, + reactivity: Reactivity.Reactivity, + { + onRefreshError = console.error, + observedUsers = [], + }: { + readonly onRefreshError?: (error: Error) => void + readonly observedUsers?: readonly UserIdentity[] + } = {}, +) { + const runPromise = Effect.runPromiseWith(yield* Effect.context()) + const ownerKey = JSON.stringify(ownerKeyPrefix(owner.identity)) + const reactivityKeys = { + directory: JSON.stringify([ownerKey, 'users.directory']), + all: JSON.stringify([ownerKey, 'users.all']), + user: ({ id, locale }: UserIdentity) => JSON.stringify([ownerKey, 'user', id, locale]), + } + const { queryClient, rpcQuery, httpQuery } = owner + let pending: Promise | undefined + let all = false + let directory = false + let cursor = 0 + let active = true + const isActive = () => active && owner.isActive() + const users = new Map() + const markUser = (user: UserIdentity) => { + if (all) { + return + } + users.set(JSON.stringify([user.id, user.locale]), user) + if (users.size > 64) { + all = true + users.clear() + } + } + const run = Effect.fnUntraced(function* () { + while (all || directory || users.size > 0) { + if (!isActive()) { + all = false + directory = false + users.clear() + return + } + if (queryClient.isMutating() > 0) { + return + } + const filters: QueryFilters[] = all + ? [{ queryKey: rpcQuery.users.key() }, { queryKey: httpQuery.users.key() }] + : [ + ...(directory + ? [ + { queryKey: rpcQuery.users.list.key() }, + { queryKey: rpcQuery.users.page.key() }, + { queryKey: httpQuery.users.list.key() }, + { queryKey: httpQuery.users.page.key() }, + ] + : []), + ...[...users.values()].flatMap(({ id, locale }) => [ + { queryKey: rpcQuery.users.get.queryKey({ id, locale }), exact: true }, + { + queryKey: httpQuery.users.get.queryKey({ + params: { id }, + query: { locale }, + }), + exact: true, + }, + ]), + ] + all = false + directory = false + users.clear() + yield* Effect.promise(async () => + Promise.all(filters.map(async (filter) => queryClient.invalidateQueries(filter))), + ) + } + }) + const schedule = () => { + if (pending !== undefined || queryClient.isMutating() > 0) { + return + } + pending = (async () => { + await Promise.resolve() + try { + await runPromise(run()) + } finally { + pending = undefined + if (isActive() && (all || directory || users.size > 0)) { + schedule() + } + } + })() + const scheduled = pending + void (async () => { + try { + await scheduled + } catch (error) { + onRefreshError(new Error('Refreshing user cache failed', { cause: error })) + } + })() + } + const register = () => [ + reactivity.registerUnsafe([reactivityKeys.directory], () => { + if (!isActive()) { + return + } + directory = true + schedule() + }), + ...observedUsers.map(({ id, locale }) => + reactivity.registerUnsafe([reactivityKeys.user({ id, locale })], () => { + if (!isActive()) { + return + } + markUser({ id, locale }) + schedule() + }), + ), + reactivity.registerUnsafe([reactivityKeys.all], () => { + if (!isActive()) { + return + } + all = true + schedule() + }), + queryClient.getMutationCache().subscribe(() => { + if (isActive() && queryClient.isMutating() === 0 && (all || directory || users.size > 0)) { + schedule() + } + }), + ] + yield* Effect.acquireRelease( + Effect.suspend(() => { + if (!owner.isActive() || consumers.has(queryClient)) { + return new UserEventConsumerUnavailable({ + reason: owner.isActive() ? 'already-registered' : 'inactive-owner', + }) + } + return Effect.sync(() => { + consumers.add(queryClient) + return register() + }) + }), + (unregister) => + Effect.sync(() => { + active = false + all = false + directory = false + users.clear() + consumers.delete(queryClient) + for (const cancel of unregister) { + cancel() + } + }), + ) + const deliver = Effect.fnUntraced(function* (input: UserEvent) { + const event = yield* decodeUserEvent(input) + if (!isActive() || event.ownerKey !== ownerKey || event.cursor <= cursor) { + return false + } + if (event.cursor !== cursor + 1) { + all = true + } + ;({ cursor } = event) + if (event.kind === 'user.changed') { + markUser(event) + reactivity.invalidateUnsafe([reactivityKeys.directory]) + } else { + reactivity.invalidateUnsafe([ + event.kind === 'users.reset' ? reactivityKeys.all : reactivityKeys.directory, + ]) + } + return true + }) + const resume = Effect.fnUntraced(function* (replayAvailable: boolean) { + if (!isActive()) { + return cursor + } + if (!replayAvailable) { + cursor = 0 + reactivity.invalidateUnsafe([reactivityKeys.all]) + } + yield* Effect.void + return cursor + }) + const flush = async (): Promise => + runPromise( + Effect.gen(function* () { + let scheduled = pending + while (scheduled !== undefined) { + const current = scheduled + yield* Effect.promise(async () => current) + scheduled = pending + } + }), + ) + return { + ownerKey, + reactivityKeys, + deliver, + resume, + cursor: () => cursor, + flush, + } +}) From 65547e5fd4b45dc8c27ebeee344a6c85ce6ed757 Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Sat, 10 Oct 2026 12:34:09 +0200 Subject: [PATCH 48/97] Demonstrate request-owned Start execution and prepared hydration --- .fallowrc.jsonc | 1 + apps/docs/src/content/docs/examples.md | 11 +- .../src/content/docs/guides/tanstack-start.md | 32 ++- e2e/tanstack-start.spec.ts | 5 +- .../server/src}/server-local-rpc.ts | 0 examples/server/src/web-handler.ts | 47 +++- examples/tanstack-start/src/lib/api-server.ts | 21 +- .../tanstack-start/src/lib/application.ts | 64 +++++- examples/tanstack-start/src/lib/query-ssr.ts | 79 ++++++- .../src/lib/server-application.ts | 53 +++++ .../tanstack-start/src/lib/server-host.ts | 26 +++ .../src/lib/snapshot-preparation.ts | 100 +++++++++ examples/tanstack-start/src/router.tsx | 34 ++- examples/tanstack-start/src/routes/index.tsx | 5 +- .../tests/async-hydration.test.ts | 201 +++++++++++++++++ .../tanstack-start/tests/server-local.test.ts | 202 ++++++++++++++++++ scripts/verify-packed-consumer.mts | 59 +++++ ...reate-rpc-query-utils-server-local.test.ts | 2 +- tests/fixtures/tsconfig.server-local.json | 5 +- tests/packed-consumer/start-ssr-runtime.mts | 162 ++++++++++++++ tests/packed-consumer/tsconfig.json | 1 + tests/types/server-local-client-contract.ts | 2 +- tsconfig.json | 2 +- 23 files changed, 1040 insertions(+), 74 deletions(-) rename {tests/fixtures => examples/server/src}/server-local-rpc.ts (100%) create mode 100644 examples/tanstack-start/src/lib/server-application.ts create mode 100644 examples/tanstack-start/src/lib/server-host.ts create mode 100644 examples/tanstack-start/src/lib/snapshot-preparation.ts create mode 100644 examples/tanstack-start/tests/async-hydration.test.ts create mode 100644 examples/tanstack-start/tests/server-local.test.ts create mode 100644 tests/packed-consumer/start-ssr-runtime.mts diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 2c721dc..a2c9267 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -5,6 +5,7 @@ // Starlight head configuration, which static import analysis cannot follow. "entry": [ "tests/packed-consumer/owner-cache-runtime.mts", + "tests/packed-consumer/start-ssr-runtime.mts", "tests/packed-consumer/owner-cache-contract.ts", "apps/docs/public/package-version.js", "playwright.docs.config.ts", diff --git a/apps/docs/src/content/docs/examples.md b/apps/docs/src/content/docs/examples.md index 09ac6c7..34a7768 100644 --- a/apps/docs/src/content/docs/examples.md +++ b/apps/docs/src/content/docs/examples.md @@ -92,15 +92,16 @@ through the real server and browser suites. vp run tanstack-start-dev ``` -This task starts one full-stack process. The browser and server-rendering clients call the -Start application's `/rpc` and `/api/$` routes. Choose **HTTP users** to inspect the server-rendered directory +This task starts one full-stack process. Browser clients call the Start application's `/rpc` and +`/api/$` routes. Server loaders execute within the same host without a network round trip. Choose **HTTP users** to inspect the server-rendered directory and first page, reuse the hydrated cache, and try the same HTTP operations as in Vite React. Choose **HTTP SSR failure** to see the browser retry a failed server query that was omitted from dehydration. -Server rendering uses the trusted origin `http://127.0.0.1:3000`. If the Start server listens -elsewhere, set the server-only `EXAMPLE_API_ORIGIN` environment variable to its HTTP(S) origin, -without a path, credentials, query, or fragment. Browser requests stay on the same origin. +Server rendering owns a separate RPC connection and Query Client for each request. Local RPC and +HTTP execution share the network handlers' demonstration directory. A browser write therefore +appears in the next server-rendered page, while disposing one request leaves other owners usable. +Browser requests stay on the same origin. See [TanStack Start](/effect-api-query/guides/tanstack-start/) for request ownership, authentication, cache isolation, and hydration setup. diff --git a/apps/docs/src/content/docs/guides/tanstack-start.md b/apps/docs/src/content/docs/guides/tanstack-start.md index a709dce..a5885f9 100644 --- a/apps/docs/src/content/docs/guides/tanstack-start.md +++ b/apps/docs/src/content/docs/guides/tanstack-start.md @@ -35,8 +35,8 @@ const queryUtils = createHttpApiQueryUtils(httpApi, { Use `identity` to partition the cache safely by the current user or tenant. `runPromiseExit` is your application's runner. Configure transport URLs, authentication, and required services when -creating the client and runner. Use a trusted server destination for SSR and a browser-accessible -destination for client requests. +creating the client and runner. Use a request-owned server-local client when your application can execute the handlers locally. +Otherwise, use a trusted server destination. Browser clients use a browser-accessible destination. The [RPC factory reference](/effect-api-query/reference/factory/) and [HTTP guide](/effect-api-query/guides/http-queries-and-mutations/) cover client setup, optional @@ -94,7 +94,9 @@ setupRouterSsrQueryIntegration({ The server dehydrates its Query Client. The browser hydrates its own Query Client and uses its own ready client and runner. Register cleanup with the server request lifecycle. When SSR finishes -or the request aborts, cancel outstanding queries before disposing their runtime. Keep the browser +or the request aborts, cancel outstanding queries and await outstanding snapshot captures before +disposing their ready clients and runtime. Native cancellation alone may settle before local iterator +finalizers finish. Keep the browser runtime alive for the browser application's lifetime. Successful query data must satisfy your serializer's contract. If an endpoint returns decoded @@ -138,9 +140,27 @@ See [Executable Examples](/effect-api-query/examples/) for commands and controls The example serves RPC at `/rpc` and HTTP at `/api/$`. Both handlers share a demonstration user directory, so writes invalidate both sets of query keys. The authorization header contains a public demonstration value. The ownership tests use separate identities to verify that caches -and resource disposal stay isolated. Its SSR setup clones class properties for field-only views. -`structuredClone` does not reconstruct class methods or define a JSON encoding for rich values; -use a paired codec when the browser needs the decoded domain representation. +and resource disposal stay isolated. + +Server rendering uses request-owned connections built with Effect's public decoded-message client +and server. They execute the same application state as the network handlers. RPC execution avoids +HTTP round trips; HTTP loaders call the host's Web handler in process and retain HTTP Schema codecs. +The browser acquires separate network clients and reconnects both stream views after hydration. + +Local RPC middleware captures the request's demonstration authorization value. Per-call headers and +caller execution services cannot replace that authority. The example defaults that public value to +`allowed`; replace this demonstration policy with your application's authentication. Decoded-message +RPC execution bypasses transport Schema codecs. Keep a schema-aware protocol when wire validation +or codec effects are required. + +The example pairs Schema encoding and decoding for its directory, pages, diagnostic history and +live values. It awaits encoding before publishing the initial snapshot and each streamed batch, +and awaits decoding of an entire batch before native hydration. Request disposal interrupts +preparation and awaits its finalizers before releasing the clients. Serialization callbacks stay +synchronous. Failed queries remain omitted. Its copied map covers the example's own generated +key families; add an explicit codec when you add another hydratable view. See +[Hydrate Query Views](/effect-api-query/guides/hydrate-query-views/) for page parameters, richer values +and application-owned preparation services. The example also disables Vite preview compression for its API routes. The pinned middleware delays response-close listeners until the first write, preventing a pending buffered request from diff --git a/e2e/tanstack-start.spec.ts b/e2e/tanstack-start.spec.ts index 3eb459a..7c7e06e 100644 --- a/e2e/tanstack-start.spec.ts +++ b/e2e/tanstack-start.spec.ts @@ -164,7 +164,7 @@ test.describe('TanStack Start application', () => { await expect(page.getByText('8 of 12 loaded')).toBeVisible() }) - test('mutates and invalidates the hydrated users query', async ({ page }) => { + test('mutates, invalidates and server-renders the shared directory', async ({ page }) => { const listResponse = page.waitForResponse( (response) => response.ok() && recordsRpc(response.request().postData(), 'users.list'), ) @@ -173,6 +173,9 @@ test.describe('TanStack Start application', () => { await expect(page.getByText('Grace Hopper', { exact: true })).toBeVisible() await expect(page.getByText('13 users in one response')).toBeVisible() + await page.reload() + await expect(page.getByText('Grace Hopper', { exact: true })).toBeVisible() + await expect(page.getByText('13 users in one response')).toBeVisible() }) test('shows declared failures and externally visible cancellation', async ({ page }) => { diff --git a/tests/fixtures/server-local-rpc.ts b/examples/server/src/server-local-rpc.ts similarity index 100% rename from tests/fixtures/server-local-rpc.ts rename to examples/server/src/server-local-rpc.ts diff --git a/examples/server/src/web-handler.ts b/examples/server/src/web-handler.ts index bd25f05..2910700 100644 --- a/examples/server/src/web-handler.ts +++ b/examples/server/src/web-handler.ts @@ -1,5 +1,9 @@ -import { exampleRpcGroup } from '@effect-api-query/contracts' -import { Effect, Layer, Scope } from 'effect' +import { + exampleRpcGroup, + ExampleAuthorization, + ExampleAuthorizationError, +} from '@effect-api-query/contracts' +import { Context, Effect, Layer, Scope } from 'effect' import type { HttpServerRequest } from 'effect/http' import { HttpEffect, HttpMiddleware, HttpRouter, HttpServer } from 'effect/http' import { RpcSerialization, RpcServer } from 'effect/rpc' @@ -7,6 +11,7 @@ import { RpcSerialization, RpcServer } from 'effect/rpc' import { ExampleDomain } from './domain.ts' import { exampleHttpRoutes } from './http-handlers.ts' import { exampleRpcHandlersLayer } from './rpc-handlers.ts' +import { makeServerLocalRpcClient } from './server-local-rpc.ts' const rpcLayer = Layer.mergeAll(exampleRpcHandlersLayer, RpcSerialization.layerJson) const maxRequestBodyBytes = 1024 * 1024 @@ -103,7 +108,7 @@ export const makeExampleRpcWebHandler = Effect.fn('ExampleRpc.makeExampleRpcWebH ) /** Hosts RPC and HTTP contracts over one application state within the caller-owned Scope. */ -export const makeExampleWebHandler = Effect.fn('ExampleServer.makeExampleWebHandler')(function* () { +const makeHostWebHandler = Effect.fn('ExampleServer.makeHostWebHandler')(function* () { const rpc = yield* makeRpcWebHandler() const httpEffect = yield* HttpRouter.toHttpEffect( exampleHttpRoutes.pipe(Layer.provide(HttpServer.layerServices)), @@ -125,4 +130,38 @@ export const makeExampleWebHandler = Effect.fn('ExampleServer.makeExampleWebHand } return new Response(null, { status: 404 }) } -}, provideDomain) +}) + +export const makeExampleHost = Effect.fn('ExampleServer.makeExampleHost')(function* () { + const scope = yield* Scope.Scope + const domainContext = yield* Layer.buildWithScope(ExampleDomain.layer, scope) + const handlersContext = yield* Layer.buildWithScope(exampleRpcHandlersLayer, scope).pipe( + Effect.provide(domainContext), + ) + const handleRequest = yield* makeHostWebHandler().pipe(Effect.provide(domainContext)) + const makeRpcClient = Effect.fn('ExampleServer.makeRequestRpcClient')(function* ( + authorization: string | undefined, + ) { + const requestScope = yield* Scope.Scope + const authority = ExampleAuthorization.of((effect) => + authorization === 'allowed' + ? effect + : Effect.fail(new ExampleAuthorizationError({ reason: 'missing-example-authorization' })), + ) + const serverContext = Context.add( + Context.add(handlersContext, ExampleAuthorization, authority), + Scope.Scope, + requestScope, + ) + return yield* makeServerLocalRpcClient(exampleRpcGroup).pipe( + Effect.provideContext(serverContext), + ) + }) + return { handleRequest, makeRpcClient } +}) + +export type ExampleHost = Effect.Success> + +export const makeExampleWebHandler = Effect.fn('ExampleServer.makeExampleWebHandler')(() => + makeExampleHost().pipe(Effect.map(({ handleRequest }) => handleRequest)), +) diff --git a/examples/tanstack-start/src/lib/api-server.ts b/examples/tanstack-start/src/lib/api-server.ts index 99533dd..f557c66 100644 --- a/examples/tanstack-start/src/lib/api-server.ts +++ b/examples/tanstack-start/src/lib/api-server.ts @@ -1,29 +1,14 @@ import '@tanstack/react-start/server-only' -import { makeExampleWebHandler } from '@effect-api-query/server/web-handler' import { getRouterInstance } from '@tanstack/react-start' -import { Effect, Exit, Scope } from 'effect' -const serverScope = Scope.makeUnsafe() -const serverHandler = Effect.runPromise(makeExampleWebHandler().pipe(Scope.provide(serverScope))) -let disposal: Promise | undefined - -const disposeServerHandler = async (): Promise => { - disposal ??= Effect.runPromise(Scope.close(serverScope, Exit.void)) - return disposal -} - -if (import.meta.hot !== undefined) { - import.meta.hot.dispose(() => { - void disposeServerHandler() - }) -} +import { getExampleHost } from './server-host.ts' /** Serves HTTP and RPC for the lifetime of this TanStack Start server module. */ export const handleApiRequest = async (request: Request): Promise => { const router = await getRouterInstance() try { - const handler = await serverHandler - return await handler(request) + const host = await getExampleHost() + return await host.handleRequest(request) } finally { // Server routes bypass SSR cleanup. Release the ready clients created with the router. await router.options.context.dispose() diff --git a/examples/tanstack-start/src/lib/application.ts b/examples/tanstack-start/src/lib/application.ts index 3986c93..0f80e08 100644 --- a/examples/tanstack-start/src/lib/application.ts +++ b/examples/tanstack-start/src/lib/application.ts @@ -1,9 +1,9 @@ import { exampleHttpApi, exampleRpcGroup } from '@effect-api-query/contracts' import { startExampleRpcClient } from '@effect-api-query/contracts/client' -import type { ExampleRpcClient } from '@effect-api-query/contracts/client' +import type { ExampleRpcClient, StartedExampleRpcClient } from '@effect-api-query/contracts/client' import { QueryClient } from '@tanstack/react-query' -import { ManagedRuntime } from 'effect' -import { createHttpApiQueryUtils, createRpcQueryUtils } from 'effect-api-query' +import { Effect, Layer, ManagedRuntime } from 'effect' +import { createHttpApiQueryUtils, createRpcQueryUtils, fetchStreamSnapshot } from 'effect-api-query' import type { RunPromiseExit } from 'effect-api-query' import { FetchHttpClient, HttpClient, HttpClientRequest } from 'effect/http' import { HttpApiClient } from 'effect/http-api' @@ -33,6 +33,8 @@ const makeExampleHttpQueryUtils = ( }) export interface TanStackStartApplication { + readonly runPreparation: (effect: Effect.Effect) => Promise + readonly captureSnapshot: typeof fetchStreamSnapshot readonly httpQuery: ReturnType readonly invalidateUsers: () => Promise readonly dispose: () => Promise @@ -42,6 +44,8 @@ export interface TanStackStartApplication { export interface StartTanStackStartApplicationOptions { readonly rpcUrl: string + readonly rpcClient?: StartedExampleRpcClient + readonly fetch?: typeof globalThis.fetch readonly httpBaseUrl?: string readonly identity?: string readonly httpAuthorization?: string @@ -50,24 +54,64 @@ export interface StartTanStackStartApplicationOptions { /** Creates clients, query utilities, and cleanup together so the example can be copied as a whole. */ export const startTanStackStartApplication = async ({ rpcUrl, + rpcClient: readyRpcClient, + fetch: applicationFetch, identity = 'example', httpAuthorization = 'allowed', httpBaseUrl = new URL(rpcUrl, globalThis.location?.href).origin, }: StartTanStackStartApplicationOptions): Promise => { - const rpcClient = await startExampleRpcClient(rpcUrl) - const httpRuntime = ManagedRuntime.make(FetchHttpClient.layer) + const rpcClient = readyRpcClient ?? (await startExampleRpcClient(rpcUrl)) + const httpRuntime = ManagedRuntime.make( + applicationFetch === undefined + ? FetchHttpClient.layer + : FetchHttpClient.layer.pipe( + Layer.provide(Layer.succeed(FetchHttpClient.Fetch, applicationFetch)), + ), + ) const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false }, queries: { retry: false, staleTime: 60_000 }, }, }) + const abort = new AbortController() + const pendingWork = new Set>() + const runPreparation: TanStackStartApplication['runPreparation'] = async (effect) => { + abort.signal.throwIfAborted() + const execution = Effect.runPromise(effect, { signal: abort.signal }) + pendingWork.add(execution) + try { + return await execution + } finally { + pendingWork.delete(execution) + } + } + const captureSnapshot: typeof fetchStreamSnapshot = async (owner, options, controls = {}) => { + if (owner !== queryClient) { + throw new Error('Snapshot belongs to another request') + } + const snapshot = fetchStreamSnapshot(owner, options, { + ...controls, + signal: + controls.signal === undefined + ? abort.signal + : AbortSignal.any([abort.signal, controls.signal]), + }) + pendingWork.add(snapshot) + try { + return await snapshot + } finally { + pendingWork.delete(snapshot) + } + } let disposal: Promise | undefined const dispose = async () => { // Stop queries before releasing the ready clients they execute through. disposal ??= (async () => { try { + abort.abort() await queryClient.cancelQueries() + await Promise.allSettled(pendingWork) } finally { queryClient.clear() try { @@ -92,6 +136,8 @@ export const startTanStackStartApplication = async ({ const httpQuery = makeExampleHttpQueryUtils(httpClient, httpRuntime.runPromiseExit, identity) const rpcQuery = makeExampleRpcQueryUtils(rpcClient.client, rpcClient.runPromiseExit, identity) return { + captureSnapshot, + runPreparation, httpQuery, invalidateUsers: async () => { await Promise.all([ @@ -114,3 +160,11 @@ export const startTanStackStartApplication = async ({ throw error } } + +export const reportCleanupFailure = async (cleanup: Promise): Promise => { + try { + await cleanup + } catch (error) { + console.error('Start resource cleanup failed', error) + } +} diff --git a/examples/tanstack-start/src/lib/query-ssr.ts b/examples/tanstack-start/src/lib/query-ssr.ts index 80fca9b..5292947 100644 --- a/examples/tanstack-start/src/lib/query-ssr.ts +++ b/examples/tanstack-start/src/lib/query-ssr.ts @@ -1,18 +1,81 @@ import { defaultShouldDehydrateQuery } from '@tanstack/react-query' -import type { QueryClient } from '@tanstack/react-query' +import type { DehydratedState } from '@tanstack/react-query' import type { AnyRouter } from '@tanstack/react-router' import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query' +import { Effect } from 'effect' + +import type { TanStackStartApplication } from './application.ts' +import { makeSnapshotPreparation } from './snapshot-preparation.ts' +import type { SnapshotPreparation } from './snapshot-preparation.ts' export { fetchStreamSnapshot } from 'effect-api-query' -export const setupQuerySsr = (router: AnyRouter, queryClient: QueryClient): void => { +type SnapshotQueries = DehydratedState['queries'] +export interface RouterSnapshot { + readonly query?: { + initial?: DehydratedState['queries'] + readonly stream: ReadableStream + } +} +const defaultPreparation: SnapshotPreparation = { + beforeEncode: Effect.yieldNow, + beforeDecode: Effect.yieldNow, +} + +const prepareSnapshot = async ( + snapshot: RouterSnapshot, + prepare: (queries: SnapshotQueries) => Promise, +): Promise => { + if (snapshot.query === undefined) { + return snapshot + } + let initial: SnapshotQueries | undefined + try { + initial = + snapshot.query.initial === undefined ? undefined : await prepare(snapshot.query.initial) + } catch (error) { + await snapshot.query.stream.cancel(error) + throw error + } + const query: NonNullable = { + stream: snapshot.query.stream.pipeThrough( + new TransformStream({ + transform: async (queries, controller) => { + controller.enqueue(await prepare(queries)) + }, + }), + ), + } + if (initial !== undefined) { + query.initial = initial + } + return { ...snapshot, query } +} + +export const setupQuerySsr = ( + router: AnyRouter, + application: TanStackStartApplication, + preparation: SnapshotPreparation = defaultPreparation, +): void => { + const codecs = makeSnapshotPreparation(application, preparation) setupRouterSsrQueryIntegration({ - dehydrateOptions: { - // oxlint-disable-next-line anti-slop/no-unknown-parameters - serializeData: (data: unknown) => structuredClone(data), - shouldDehydrateQuery: defaultShouldDehydrateQuery, - }, - queryClient, + dehydrateOptions: { shouldDehydrateQuery: defaultShouldDehydrateQuery }, + queryClient: application.queryClient, router, }) + const nativeDehydrate: + | (() => RouterSnapshot | undefined | Promise) + | undefined = router.options.dehydrate + if (nativeDehydrate !== undefined) { + router.options.dehydrate = async () => { + const snapshot = await nativeDehydrate() + return snapshot === undefined ? undefined : await prepareSnapshot(snapshot, codecs.encode) + } + } + const nativeHydrate = router.options.hydrate + if (nativeHydrate !== undefined) { + router.options.hydrate = async (snapshot: RouterSnapshot) => { + await nativeHydrate(await prepareSnapshot(snapshot, codecs.decode)) + } + } } diff --git a/examples/tanstack-start/src/lib/server-application.ts b/examples/tanstack-start/src/lib/server-application.ts new file mode 100644 index 0000000..17a1527 --- /dev/null +++ b/examples/tanstack-start/src/lib/server-application.ts @@ -0,0 +1,53 @@ +import { Effect, Exit, Scope } from 'effect' + +import type { ExampleHost } from '../../../server/src/web-handler.ts' +import { reportCleanupFailure, startTanStackStartApplication } from './application.ts' + +export const startServerApplication = async ({ + host, + identity = 'example', + authorization, + signal, +}: { + readonly host: ExampleHost + readonly identity?: string + readonly authorization: string | undefined + readonly signal?: AbortSignal +}) => { + signal?.throwIfAborted() + const scope = Scope.makeUnsafe() + try { + const client = await Effect.runPromise( + host.makeRpcClient(authorization).pipe(Scope.provide(scope)), + ) + const application = await startTanStackStartApplication({ + rpcUrl: 'http://example.local/rpc', + identity, + httpAuthorization: authorization ?? '', + fetch: async (input, init) => host.handleRequest(new Request(input, init)), + rpcClient: { + client, + runPromiseExit: Effect.runPromiseExit, + dispose: async () => Effect.runPromise(Scope.close(scope, Exit.void)), + }, + }) + const dispose = () => { + void reportCleanupFailure(application.dispose()) + } + signal?.addEventListener('abort', dispose, { once: true }) + if (signal?.aborted === true) { + await application.dispose() + signal.throwIfAborted() + } + return { + ...application, + dispose: async () => { + signal?.removeEventListener('abort', dispose) + await application.dispose() + }, + } + } catch (error) { + await Effect.runPromise(Scope.close(scope, Exit.void)) + throw error + } +} diff --git a/examples/tanstack-start/src/lib/server-host.ts b/examples/tanstack-start/src/lib/server-host.ts new file mode 100644 index 0000000..df42aed --- /dev/null +++ b/examples/tanstack-start/src/lib/server-host.ts @@ -0,0 +1,26 @@ +import '@tanstack/react-start/server-only' +import { Effect, Exit, Scope } from 'effect' + +import { makeExampleHost } from '../../../server/src/web-handler.ts' +import { reportCleanupFailure } from './application.ts' +import { startServerApplication } from './server-application.ts' + +const scope = Scope.makeUnsafe() +const host = Effect.runPromise(makeExampleHost().pipe(Scope.provide(scope))) +let disposal: Promise | undefined + +if (import.meta.hot !== undefined) { + import.meta.hot.dispose(() => { + disposal ??= Effect.runPromise(Scope.close(scope, Exit.void)) + void reportCleanupFailure(disposal) + }) +} + +export const getExampleHost = async () => host + +export const startRequestApplication = async (request: Request) => + startServerApplication({ + host: await host, + authorization: request.headers.get('x-example-authorization') ?? 'allowed', + signal: request.signal, + }) diff --git a/examples/tanstack-start/src/lib/snapshot-preparation.ts b/examples/tanstack-start/src/lib/snapshot-preparation.ts new file mode 100644 index 0000000..df01926 --- /dev/null +++ b/examples/tanstack-start/src/lib/snapshot-preparation.ts @@ -0,0 +1,100 @@ +import { CommandStatus, DiagnosticStatus, User, UserPage } from '@effect-api-query/contracts' +import { hashKey } from '@tanstack/react-query' +import type { DehydratedState, QueryKey } from '@tanstack/react-query' +import { Context, Effect, Schema } from 'effect' + +import type { TanStackStartApplication } from './application.ts' + +export interface SnapshotPreparation { + readonly beforeEncode: Effect.Effect + readonly beforeDecode: Effect.Effect +} + +type SnapshotEncodingService = Pick +const SnapshotEncoding = Context.Service('Start/SnapshotEncoding') +type SnapshotDecodingService = Pick +const SnapshotDecoding = Context.Service('Start/SnapshotDecoding') + +const prepared = >(schema: S) => + schema.pipe( + Schema.middlewareEncoding((encoding) => + SnapshotEncoding.pipe( + Effect.flatMap(({ beforeEncode }) => beforeEncode.pipe(Effect.andThen(encoding))), + ), + ), + Schema.middlewareDecoding((decoding) => + SnapshotDecoding.pipe( + Effect.flatMap(({ beforeDecode }) => beforeDecode.pipe(Effect.andThen(decoding))), + ), + ), + ) + +const Users = prepared(Schema.Array(User)) +const SingleUser = prepared(User) +const Pages = prepared( + Schema.Struct({ pages: Schema.Array(UserPage), pageParams: Schema.Array(Schema.Int) }), +) +const Status = prepared(DiagnosticStatus) +const Command = prepared(Schema.NullOr(CommandStatus)) +const Text = prepared(Schema.String) +const History = prepared(Schema.Array(Schema.String)) + +export interface SnapshotCodecs { + readonly encode: (queries: DehydratedState['queries']) => Promise + readonly decode: (queries: DehydratedState['queries']) => Promise +} + +export const makeSnapshotPreparation = ( + { rpcQuery, httpQuery, runPreparation }: TanStackStartApplication, + services: SnapshotPreparation, +): SnapshotCodecs => { + const codecs = [ + { prefix: rpcQuery.users.list.queryKey(), codec: Users }, + { prefix: httpQuery.users.list.queryKey(), codec: Users }, + { prefix: [...rpcQuery.users.get.key(), 'query'], codec: SingleUser }, + { prefix: [...httpQuery.users.get.key(), 'query'], codec: SingleUser }, + { prefix: [...rpcQuery.users.page.key(), 'infinite'], codec: Pages }, + { prefix: [...httpQuery.users.page.key(), 'infinite'], codec: Pages }, + { prefix: rpcQuery.diagnostics.status.queryKey(), codec: Status }, + { prefix: httpQuery.diagnostics.status.queryKey(), codec: Status }, + { prefix: [...rpcQuery.diagnostics.operationStatus.key(), 'query'], codec: Status }, + { prefix: [...httpQuery.diagnostics.operationStatus.key(), 'query'], codec: Status }, + { prefix: [...rpcQuery.commands.status.key(), 'query'], codec: Command }, + { prefix: [...rpcQuery.diagnostics.slow.key(), 'query'], codec: Text }, + { prefix: [...httpQuery.diagnostics.slow.key(), 'query'], codec: Text }, + { prefix: [...rpcQuery.diagnostics.stream.key(), 'live'], codec: Text }, + { prefix: [...rpcQuery.diagnostics.stream.key(), 'streamed'], codec: History }, + ] + const codecFor = (key: QueryKey) => + codecs.find(({ prefix }) => hashKey(key.slice(0, prefix.length)) === hashKey(prefix))?.codec + const encode = Effect.fnUntraced(function* (queries: DehydratedState['queries']) { + return yield* Effect.forEach( + Effect.fnUntraced(function* (query: DehydratedState['queries'][number]) { + const codec = codecFor(query.queryKey) + if (codec === undefined) { + return yield* Effect.die(new TypeError('No Start snapshot codec for query')) + } + const data = yield* Schema.encodeUnknownEffect(codec)(query.state.data) + return { ...query, state: { ...query.state, data } } + }), + )(queries) + }) + const decode = Effect.fnUntraced(function* (queries: DehydratedState['queries']) { + return yield* Effect.forEach( + Effect.fnUntraced(function* (query: DehydratedState['queries'][number]) { + const codec = codecFor(query.queryKey) + if (codec === undefined) { + return yield* Effect.die(new TypeError('No Start snapshot codec for query')) + } + const data = yield* Schema.decodeUnknownEffect(codec)(query.state.data) + return { ...query, state: { ...query.state, data } } + }), + )(queries) + }) + return { + encode: async (queries: DehydratedState['queries']) => + runPreparation(encode(queries).pipe(Effect.provideService(SnapshotEncoding, services))), + decode: async (queries: DehydratedState['queries']) => + runPreparation(decode(queries).pipe(Effect.provideService(SnapshotDecoding, services))), + } +} diff --git a/examples/tanstack-start/src/router.tsx b/examples/tanstack-start/src/router.tsx index d4cdad2..6a552cf 100644 --- a/examples/tanstack-start/src/router.tsx +++ b/examples/tanstack-start/src/router.tsx @@ -3,13 +3,15 @@ import type { RouterHistory } from '@tanstack/react-router' import { createIsomorphicFn } from '@tanstack/react-start' import { ErrorPage, NotFoundPage, PendingPage } from './components/router-status.tsx' -import { startTanStackStartApplication } from './lib/application.ts' +import { reportCleanupFailure, startTanStackStartApplication } from './lib/application.ts' import type { TanStackStartApplication } from './lib/application.ts' import { setupQuerySsr } from './lib/query-ssr.ts' +import type { SnapshotPreparation } from './lib/snapshot-preparation.ts' import { routeTree } from './routeTree.gen.ts' export interface RouterOptions { readonly history?: RouterHistory + readonly preparation?: SnapshotPreparation readonly isServer?: boolean readonly scrollRestoration?: boolean } @@ -25,7 +27,7 @@ const registerBrowserDisposal = (application: TanStackStartApplication): void => return } const dispose = () => { - void application.dispose() + void reportCleanupFailure(application.dispose()) } window.addEventListener('pagehide', dispose, { once: true }) } @@ -54,7 +56,7 @@ export const createTanStackStartRouter = async (options: CreateTanStackStartRout scrollRestoration, }) - setupQuerySsr(router, application.queryClient) + setupQuerySsr(router, application, options.preparation) if (router.isServer) { router.serverSsrLifecycle = { @@ -63,7 +65,7 @@ export const createTanStackStartRouter = async (options: CreateTanStackStartRout ...(router.serverSsrLifecycle?.onServerSsrAttach ?? []), (serverSsr) => { serverSsr.onCleanup(() => { - void application.dispose() + void reportCleanupFailure(application.dispose()) }) }, ], @@ -75,24 +77,16 @@ export const createTanStackStartRouter = async (options: CreateTanStackStartRout return router } -const rpcUrl = createIsomorphicFn() - .server(() => { - const origin = new URL(process.env['EXAMPLE_API_ORIGIN'] ?? 'http://127.0.0.1:3000') - if ( - !['http:', 'https:'].includes(origin.protocol) || - origin.username !== '' || - origin.password !== '' || - origin.pathname !== '/' || - origin.search !== '' || - origin.hash !== '' - ) { - throw new Error('EXAMPLE_API_ORIGIN must be an HTTP(S) origin without credentials') - } - return new URL('/rpc', origin).href +const startApplication = createIsomorphicFn() + .server(async () => { + const { getRequest } = await import('@tanstack/react-start/server') + const { startRequestApplication } = await import('./lib/server-host.ts') + return await startRequestApplication(getRequest()) }) - .client(() => '/rpc') + .client(async () => startTanStackStartApplication({ rpcUrl: '/rpc' })) -export const getRouter = async () => createTanStackStartRouter({ rpcUrl: rpcUrl() }) +export const getRouter = async () => + createTanStackStartRouter({ application: await startApplication() }) declare module '@tanstack/react-router' { interface Register { diff --git a/examples/tanstack-start/src/routes/index.tsx b/examples/tanstack-start/src/routes/index.tsx index b2efaf9..a8d2adc 100644 --- a/examples/tanstack-start/src/routes/index.tsx +++ b/examples/tanstack-start/src/routes/index.tsx @@ -6,7 +6,6 @@ import { ActionButton } from '../components/action-button.tsx' import { ConditionalUserQuery } from '../components/conditional-user-query.tsx' import { PageLayout, Panel } from '../components/page-layout.tsx' import type { TanStackStartApplication } from '../lib/application.ts' -import { fetchStreamSnapshot } from '../lib/query-ssr.ts' const PAGE_SIZE = 4 @@ -281,8 +280,8 @@ export const Route = createFileRoute('/')({ ...userPagesOptions(context.rpcQuery), staleTime: 'static', }), - fetchStreamSnapshot(context.queryClient, streamedDiagnosticsOptions(context.rpcQuery)), - fetchStreamSnapshot(context.queryClient, liveDiagnosticOptions(context.rpcQuery)), + context.captureSnapshot(context.queryClient, streamedDiagnosticsOptions(context.rpcQuery)), + context.captureSnapshot(context.queryClient, liveDiagnosticOptions(context.rpcQuery)), ]) }, }) diff --git a/examples/tanstack-start/tests/async-hydration.test.ts b/examples/tanstack-start/tests/async-hydration.test.ts new file mode 100644 index 0000000..b9f52e3 --- /dev/null +++ b/examples/tanstack-start/tests/async-hydration.test.ts @@ -0,0 +1,201 @@ +import { User } from '@effect-api-query/contracts' +import { createMemoryHistory } from '@tanstack/react-router' +import { attachRouterServerSsrUtils } from '@tanstack/react-start/server' +import { Deferred, Effect, Exit, Schema, Scope } from 'effect' +import { describe, expect, it, vi } from 'vite-plus/test' + +import { makeExampleHost } from '../../server/src/web-handler.ts' +import type { RouterSnapshot } from '../src/lib/query-ssr.ts' +import { startServerApplication } from '../src/lib/server-application.ts' +import { createTanStackStartRouter } from '../src/router.tsx' + +describe('Start snapshot preparation', () => { + it('finishes application encoding before publication and decoding before native hydration', async () => { + const scope = Scope.makeUnsafe() + const encodingStarted = Deferred.makeUnsafe() + const decodingStarted = Deferred.makeUnsafe() + const encode = Deferred.makeUnsafe() + const decode = Deferred.makeUnsafe() + const host = await Effect.runPromise(makeExampleHost().pipe(Scope.provide(scope))) + const server = await startServerApplication({ host, authorization: 'allowed' }) + const browser = await startServerApplication({ host, authorization: 'allowed' }) + try { + const serverRouter = await createTanStackStartRouter({ + application: server, + scrollRestoration: false, + preparation: { + beforeEncode: Deferred.succeed(encodingStarted, undefined).pipe( + Effect.andThen(Deferred.await(encode)), + ), + beforeDecode: Effect.void, + }, + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + attachRouterServerSsrUtils({ manifest: undefined, router: serverRouter }) + await serverRouter.load() + let published = false + const dehydrate: + | (() => RouterSnapshot | undefined | Promise) + | undefined = serverRouter.options.dehydrate + const publication = Promise.resolve(dehydrate?.()).then((value) => { + published = true + return value + }) + await Effect.runPromise(Deferred.await(encodingStarted)) + expect(published).toBe(false) + await Effect.runPromise(Deferred.succeed(encode, undefined)) + const snapshot = await publication + if (snapshot?.query?.initial === undefined) { + throw new Error('Snapshot was not published') + } + serverRouter.serverSsr?.setRenderFinished() + vi.stubGlobal('window', Object.assign(new EventTarget(), { origin: 'http://localhost' })) + const browserRouter = await createTanStackStartRouter({ + application: browser, + scrollRestoration: false, + preparation: { + beforeEncode: Effect.void, + beforeDecode: Deferred.succeed(decodingStarted, undefined).pipe( + Effect.andThen(Deferred.await(decode)), + ), + }, + isServer: false, + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + const json = JSON.stringify(snapshot.query.initial) + const initial = Schema.decodeUnknownSync(Schema.Array(Schema.Unknown))(JSON.parse(json)) + const hydration = browserRouter.options.hydrate?.({ + ...snapshot, + query: { + ...snapshot.query, + initial, + }, + }) + await Effect.runPromise(Deferred.await(decodingStarted)) + expect( + browser.queryClient.getQueryData(browser.rpcQuery.users.list.queryKey()), + ).toBeUndefined() + await Effect.runPromise(Deferred.succeed(decode, undefined)) + await hydration + const users = browser.queryClient.getQueryData(browser.rpcQuery.users.list.queryKey()) + expect(users?.[0]).toBeInstanceOf(User) + expect(users?.[0]?.name).toBe('Ada Lovelace') + expect( + browser.queryClient.getQueryData( + browser.rpcQuery.users.page.infiniteKey({ cursor: 0, pageSize: 4 }), + ), + ).toMatchObject({ pageParams: [0], pages: [{ total: 12 }] }) + serverRouter.serverSsr?.cleanup() + } finally { + vi.unstubAllGlobals() + await Promise.all([server.dispose(), browser.dispose()]) + await Effect.runPromise(Scope.close(scope, Exit.void)) + } + }) + + it('rejects malformed later data before hydrating any earlier query', async () => { + const scope = Scope.makeUnsafe() + const host = await Effect.runPromise(makeExampleHost().pipe(Scope.provide(scope))) + const server = await startServerApplication({ host, authorization: 'allowed' }) + const browser = await startServerApplication({ host, authorization: 'allowed' }) + try { + const serverRouter = await createTanStackStartRouter({ + application: server, + history: createMemoryHistory({ initialEntries: ['/'] }), + scrollRestoration: false, + }) + attachRouterServerSsrUtils({ manifest: undefined, router: serverRouter }) + await serverRouter.load() + const dehydrate: + | (() => RouterSnapshot | undefined | Promise) + | undefined = serverRouter.options.dehydrate + const snapshot = await dehydrate?.() + if (snapshot?.query?.initial === undefined) { + throw new Error('Snapshot was not published') + } + serverRouter.serverSsr?.setRenderFinished() + const lastIndex = snapshot.query.initial.length - 1 + const initial = snapshot.query.initial.map((query, index) => + index === lastIndex + ? { ...query, state: { ...query.state, data: { malformed: true } } } + : query, + ) + vi.stubGlobal('window', Object.assign(new EventTarget(), { origin: 'http://localhost' })) + const browserRouter = await createTanStackStartRouter({ + application: browser, + isServer: false, + history: createMemoryHistory({ initialEntries: ['/'] }), + scrollRestoration: false, + }) + await expect( + browserRouter.options.hydrate?.({ ...snapshot, query: { ...snapshot.query, initial } }), + ).rejects.toMatchObject({ name: 'SchemaError' }) + expect(browser.queryClient.getQueryCache().getAll()).toHaveLength(0) + serverRouter.serverSsr?.cleanup() + } finally { + vi.unstubAllGlobals() + await Promise.all([server.dispose(), browser.dispose()]) + await Effect.runPromise(Scope.close(scope, Exit.void)) + } + }) + + it('interrupts codec preparation and finishes its finalizers before releasing request clients', async () => { + const scope = Scope.makeUnsafe() + const host = await Effect.runPromise(makeExampleHost().pipe(Scope.provide(scope))) + const started = Deferred.makeUnsafe() + const permit = Deferred.makeUnsafe() + const events: string[] = [] + const application = await startServerApplication({ + host: { + ...host, + makeRpcClient: Effect.fnUntraced(function* (authorization: string | undefined) { + yield* Effect.addFinalizer(() => + Effect.sync(() => { + events.push('clients-released') + }), + ) + return yield* host.makeRpcClient(authorization) + }), + }, + authorization: 'allowed', + }) + let publishing: Promise | undefined + try { + const router = await createTanStackStartRouter({ + application, + history: createMemoryHistory({ initialEntries: ['/'] }), + scrollRestoration: false, + preparation: { + beforeEncode: Effect.scoped( + Effect.acquireRelease(Deferred.succeed(started, undefined), () => + Effect.sync(() => { + events.push('codec-released') + }), + ).pipe(Effect.andThen(Deferred.await(permit))), + ), + beforeDecode: Effect.void, + }, + }) + attachRouterServerSsrUtils({ manifest: undefined, router }) + await router.load() + publishing = (async () => { + try { + await router.options.dehydrate?.() + return null + } catch (error) { + return error + } + })() + await Effect.runPromise(Deferred.await(started)) + await application.dispose() + expect(events).toStrictEqual(['codec-released', 'clients-released']) + await expect(publishing).resolves.not.toBeNull() + router.serverSsr?.cleanup() + } finally { + await Effect.runPromise(Deferred.succeed(permit, undefined)) + await publishing + await application.dispose() + await Effect.runPromise(Scope.close(scope, Exit.void)) + } + }) +}) diff --git a/examples/tanstack-start/tests/server-local.test.ts b/examples/tanstack-start/tests/server-local.test.ts new file mode 100644 index 0000000..82d0806 --- /dev/null +++ b/examples/tanstack-start/tests/server-local.test.ts @@ -0,0 +1,202 @@ +import { createMemoryHistory } from '@tanstack/react-router' +import { Effect, Exit, Scope } from 'effect' +import { afterEach, describe, expect, it, vi } from 'vite-plus/test' + +import { makeExampleHost } from '../../server/src/web-handler.ts' +import type { TanStackStartApplication } from '../src/lib/application.ts' +import { startServerApplication } from '../src/lib/server-application.ts' +import { createTanStackStartRouter } from '../src/router.tsx' + +describe('Start request-local execution', () => { + const applications: TanStackStartApplication[] = [] + const scope = Scope.makeUnsafe() + + afterEach(async () => { + await Promise.all(applications.splice(0).map(async ({ dispose }) => dispose())) + vi.unstubAllGlobals() + }) + + it('renders shared host state without network calls and isolates request authority', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + await Effect.runPromise(Effect.yieldNow) + throw new Error('Unexpected network request') + }), + ) + const host = await Effect.runPromise(makeExampleHost().pipe(Scope.provide(scope))) + try { + const allowed = await startServerApplication({ + host, + identity: 'reader', + authorization: 'allowed', + }) + const denied = await startServerApplication({ + host, + identity: 'restricted', + authorization: 'denied', + }) + applications.push(allowed, denied) + const create = allowed.rpcQuery.users.create.mutationOptions().mutationFn + if (create === undefined) { + throw new Error('Mutation function is missing') + } + await create({ name: 'Shared SSR user' }) + const router = await createTanStackStartRouter({ + application: allowed, + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + await router.load() + expect(allowed.queryClient.getQueryData(allowed.rpcQuery.users.list.queryKey())).toHaveLength( + 13, + ) + expect(denied.queryClient.getQueryData(denied.rpcQuery.users.list.queryKey())).toBeUndefined() + expect(allowed.rpcQuery.users.list.queryKey()).not.toStrictEqual( + denied.rpcQuery.users.list.queryKey(), + ) + const remove = denied.rpcQuery.users.delete.mutationOptions({ + rpcOptions: { headers: { 'x-example-authorization': 'allowed' } }, + }).mutationFn + if (remove === undefined) { + throw new Error('Mutation function is missing') + } + await expect(remove({ id: 1 })).rejects.toMatchObject({ name: 'EffectRpcQueryError' }) + expect(globalThis.fetch).not.toHaveBeenCalled() + await allowed.dispose() + await expect( + denied.queryClient.query(denied.rpcQuery.users.list.queryOptions()), + ).resolves.toHaveLength(13) + } finally { + await Promise.all(applications.splice(0).map(async ({ dispose }) => dispose())) + await Effect.runPromise(Scope.close(scope, Exit.void)) + } + }) + + it('settles an aborted capture before releasing its request clients', async () => { + const ownerScope = Scope.makeUnsafe() + const host = await Effect.runPromise(makeExampleHost().pipe(Scope.provide(ownerScope))) + const events: string[] = [] + const application = await startServerApplication({ + host: { + ...host, + makeRpcClient: Effect.fnUntraced(function* (authorization: string | undefined) { + yield* Effect.addFinalizer(() => + Effect.sync(() => { + events.push('released') + }), + ) + return yield* host.makeRpcClient(authorization) + }), + }, + authorization: 'allowed', + }) + try { + const capture = application.captureSnapshot( + application.queryClient, + application.rpcQuery.diagnostics.stream.streamedOptions(), + ) + const failure = (async () => { + try { + await capture + return null + } catch (error) { + return error + } finally { + events.push('settled') + } + })() + expect(application.queryClient.isFetching()).toBe(1) + await application.dispose() + await expect(failure).resolves.toMatchObject({ name: 'AbortError' }) + expect(events).toStrictEqual(['settled', 'released']) + expect(application.queryClient.getQueryCache().hasListeners()).toBe(false) + expect(application.queryClient.getQueryCache().getAll()).toHaveLength(0) + } finally { + await application.dispose() + await Effect.runPromise(Scope.close(ownerScope, Exit.void)) + } + }) + + it('finishes interrupted local work while another request remains usable', async () => { + const ownerScope = Scope.makeUnsafe() + const host = await Effect.runPromise(makeExampleHost().pipe(Scope.provide(ownerScope))) + const abort = new AbortController() + const first = await startServerApplication({ + host, + authorization: 'allowed', + signal: abort.signal, + }) + const second = await startServerApplication({ host, authorization: 'allowed' }) + try { + const read = first.queryClient.query( + first.rpcQuery.diagnostics.slow.queryOptions({ + input: { + operationId: 'retired-request', + durationMs: 60_000, + }, + }), + ) + const rejected = (async () => { + await expect(read).rejects.toMatchObject({ message: 'CancelledError' }) + })() + await expect + .poll(async () => + second.queryClient.query({ + ...second.rpcQuery.diagnostics.operationStatus.queryOptions({ + input: { + operationId: 'retired-request', + }, + }), + staleTime: 0, + }), + ) + .toMatchObject({ started: 1, interrupted: 0 }) + abort.abort() + await first.dispose() + await rejected + await expect( + second.queryClient.query({ + ...second.rpcQuery.diagnostics.operationStatus.queryOptions({ + input: { + operationId: 'retired-request', + }, + }), + staleTime: 0, + }), + ).resolves.toStrictEqual({ started: 1, interrupted: 1 }) + await expect( + second.queryClient.query(second.httpQuery.users.list.queryOptions()), + ).resolves.toHaveLength(12) + } finally { + await Promise.all([first.dispose(), second.dispose()]) + await Effect.runPromise(Scope.close(ownerScope, Exit.void)) + } + }) + + it('reports aborted request cleanup failures and preserves explicit disposal rejection', async () => { + const ownerScope = Scope.makeUnsafe() + const host = await Effect.runPromise(makeExampleHost().pipe(Scope.provide(ownerScope))) + const abort = new AbortController() + const failure = new Error('Request resource finalizer failed') + const log = vi.spyOn(console, 'error').mockReturnValue(undefined) + const application = await startServerApplication({ + host: { + ...host, + makeRpcClient: Effect.fnUntraced(function* (authorization: string | undefined) { + yield* Effect.addFinalizer(() => Effect.die(failure)) + return yield* host.makeRpcClient(authorization) + }), + }, + authorization: 'allowed', + signal: abort.signal, + }) + try { + abort.abort() + await expect(application.dispose()).rejects.toBe(failure) + expect(log).toHaveBeenCalledWith('Start resource cleanup failed', failure) + } finally { + log.mockRestore() + await Effect.runPromise(Scope.close(ownerScope, Exit.void)) + } + }) +}) diff --git a/scripts/verify-packed-consumer.mts b/scripts/verify-packed-consumer.mts index d249c9a..ce7a3b3 100644 --- a/scripts/verify-packed-consumer.mts +++ b/scripts/verify-packed-consumer.mts @@ -8,6 +8,7 @@ import { createHash } from 'node:crypto' import { cpSync, existsSync, + mkdirSync, mkdtempSync, readFileSync, readdirSync, @@ -432,6 +433,45 @@ const verifyConsumer = (peer: (typeof peerCases)[number]): void => { ) } } + for (const [directory, files] of [ + [ + 'server', + [ + 'commands.ts', + 'diagnostic-operations.ts', + 'domain.ts', + 'http-handlers.ts', + 'rpc-handlers.ts', + 'server-local-rpc.ts', + 'users.ts', + 'web-handler.ts', + ], + ], + ['tanstack-start', ['application.ts', 'server-application.ts', 'snapshot-preparation.ts']], + ] as const) { + const destination = nodePath.join(consumerDirectory, `start-${directory}`) + mkdirSync(destination) + for (const file of files) { + const source = nodePath.join( + repositoryRoot, + 'examples', + directory, + 'src', + directory === 'tanstack-start' ? 'lib' : '', + file, + ) + writeFileSync( + nodePath.join(destination, file), + readFileSync(source, 'utf-8') + .replaceAll( + "'@effect-api-query/contracts/client'", + "'../optimistic-contracts/client.ts'", + ) + .replaceAll("'@effect-api-query/contracts'", "'../optimistic-contracts/index.ts'") + .replaceAll("'../../../server/src/web-handler.ts'", "'../start-server/web-handler.ts'"), + ) + } + } writeFileSync( nodePath.join(consumerDirectory, 'optimistic-users.ts'), readFileSync( @@ -466,6 +506,24 @@ const verifyConsumer = (peer: (typeof peerCases)[number]): void => { .replaceAll("'../fixtures/optimistic-users.ts'", "'./optimistic-users.ts'"), ) } + const startRuntimePath = nodePath.join(consumerDirectory, 'start-ssr-runtime.mts') + writeFileSync( + startRuntimePath, + readFileSync(startRuntimePath, 'utf-8') + .replaceAll( + "'../../examples/contracts/src/contracts.ts'", + "'./optimistic-contracts/contracts.ts'", + ) + .replaceAll("'../../examples/server/src/web-handler.ts'", "'./start-server/web-handler.ts'") + .replaceAll( + "'../../examples/tanstack-start/src/lib/server-application.ts'", + "'./start-tanstack-start/server-application.ts'", + ) + .replaceAll( + "'../../examples/tanstack-start/src/lib/snapshot-preparation.ts'", + "'./start-tanstack-start/snapshot-preparation.ts'", + ), + ) for (const fixture of [ 'public-contract.ts', 'tanstack-start-contract.ts', @@ -573,6 +631,7 @@ const verifyConsumer = (peer: (typeof peerCases)[number]): void => { for (const fixture of [ 'owner-cache-runtime.mts', + 'start-ssr-runtime.mts', 'optimistic-runtime.mts', 'hashing-runtime.mts', 'filter-runtime.mts', diff --git a/tests/create-rpc-query-utils-server-local.test.ts b/tests/create-rpc-query-utils-server-local.test.ts index 3954670..cb75b3c 100644 --- a/tests/create-rpc-query-utils-server-local.test.ts +++ b/tests/create-rpc-query-utils-server-local.test.ts @@ -5,8 +5,8 @@ import { describe, expect, it, vi } from 'vite-plus/test' import { createRpcQueryUtils, isEffectRpcQueryError } from '#effect-api-query' +import { makeServerLocalRpcClient } from '../examples/server/src/server-local-rpc.ts' import { captureFailure } from './fixtures/async.ts' -import { makeServerLocalRpcClient } from './fixtures/server-local-rpc.ts' describe('server-local ready-client construction', () => { it('executes a production decoded-message client without calling the network fetch boundary', async () => { diff --git a/tests/fixtures/tsconfig.server-local.json b/tests/fixtures/tsconfig.server-local.json index d801642..fbe9f14 100644 --- a/tests/fixtures/tsconfig.server-local.json +++ b/tests/fixtures/tsconfig.server-local.json @@ -1,4 +1,7 @@ { "extends": "../../tsconfig.json", - "include": ["server-local-rpc.ts", "../types/server-local-client-contract.ts"] + "include": [ + "../../examples/server/src/server-local-rpc.ts", + "../types/server-local-client-contract.ts" + ] } diff --git a/tests/packed-consumer/start-ssr-runtime.mts b/tests/packed-consumer/start-ssr-runtime.mts new file mode 100644 index 0000000..93d4f73 --- /dev/null +++ b/tests/packed-consumer/start-ssr-runtime.mts @@ -0,0 +1,162 @@ +import { dehydrate, hydrate } from '@tanstack/react-query' +import { Deferred, Effect, Exit, Scope } from 'effect' +import { deepStrictEqual, equal, ok } from 'node:assert/strict' + +import { User } from '../../examples/contracts/src/contracts.ts' +import { makeExampleHost } from '../../examples/server/src/web-handler.ts' +import { startServerApplication } from '../../examples/tanstack-start/src/lib/server-application.ts' +import { makeSnapshotPreparation } from '../../examples/tanstack-start/src/lib/snapshot-preparation.ts' + +const scope = Scope.makeUnsafe() +const host = await Effect.runPromise(makeExampleHost().pipe(Scope.provide(scope))) +const server = await startServerApplication({ + host, + identity: 'same-owner', + authorization: 'allowed', +}) +const browser = await startServerApplication({ + host, + identity: 'same-owner', + authorization: 'allowed', +}) +try { + const create = server.rpcQuery.users.create.mutationOptions().mutationFn + if (create === undefined) throw new Error('Missing generated mutation') + await create({ name: 'Shared local state' }) + const list = server.rpcQuery.users.list.queryOptions() + equal((await server.queryClient.query(list)).length, 13) + const pages = server.rpcQuery.users.page.infiniteOptions({ + initialPageParam: 0, + input: (cursor: number) => ({ cursor, pageSize: 4 }), + getNextPageParam: (page) => page.nextCursor ?? undefined, + }) + const pageData = await server.queryClient.infiniteQuery(pages) + ok(pageData.pages[0]?.users[0] instanceof User) + await server.captureSnapshot( + server.queryClient, + server.rpcQuery.diagnostics.stream.streamedOptions(), + ) + await server.captureSnapshot(server.queryClient, server.rpcQuery.diagnostics.stream.liveOptions()) + equal((await server.queryClient.query(server.httpQuery.users.list.queryOptions())).length, 13) + const encodingStarted = Deferred.makeUnsafe() + const permitEncoding = Deferred.makeUnsafe() + const decodingStarted = Deferred.makeUnsafe() + const permitDecoding = Deferred.makeUnsafe() + const encoding = makeSnapshotPreparation(server, { + beforeEncode: Deferred.succeed(encodingStarted, undefined).pipe( + Effect.andThen(Deferred.await(permitEncoding)), + ), + beforeDecode: Effect.void, + }) + const decoding = makeSnapshotPreparation(browser, { + beforeEncode: Effect.void, + beforeDecode: Deferred.succeed(decodingStarted, undefined).pipe( + Effect.andThen(Deferred.await(permitDecoding)), + ), + }) + const native = dehydrate(server.queryClient, { shouldDehydrateMutation: () => false }) + let published = false + const publication = (async () => { + const queries = await encoding.encode(native.queries) + const json = JSON.stringify({ ...native, queries }) + published = true + return json + })() + await Effect.runPromise(Deferred.await(encodingStarted)) + equal(published, false) + await Effect.runPromise(Deferred.succeed(permitEncoding, undefined)) + const json = await publication + const encoded: typeof native = JSON.parse(json) + const hydration = (async () => { + const queries = await decoding.decode(encoded.queries) + hydrate(browser.queryClient, { ...encoded, queries }) + })() + await Effect.runPromise(Deferred.await(decodingStarted)) + equal(browser.queryClient.getQueryCache().getAll().length, 0) + await Effect.runPromise(Deferred.succeed(permitDecoding, undefined)) + await hydration + const browserList = browser.rpcQuery.users.list.queryOptions() + deepStrictEqual(browserList.queryKey, list.queryKey) + let duplicateReads = 0 + const unsubscribe = browser.queryClient.getQueryCache().subscribe((event) => { + if (event.query.state.fetchStatus === 'fetching') duplicateReads += 1 + }) + equal((await browser.queryClient.query({ ...browserList, staleTime: 'static' })).length, 13) + equal(duplicateReads, 0) + unsubscribe() + ok(browser.queryClient.getQueryData(browserList.queryKey)?.[0] instanceof User) + const hydratedPage = browser.queryClient.getQueryData( + browser.rpcQuery.users.page.infiniteKey({ cursor: 0, pageSize: 4 }), + ) + deepStrictEqual(hydratedPage?.pageParams, [0]) + ok(hydratedPage?.pages[0]?.users[0] instanceof User) + await server.dispose() + deepStrictEqual( + await browser.captureSnapshot( + browser.queryClient, + browser.rpcQuery.diagnostics.stream.streamedOptions(), + ), + ['Connection opened'], + ) + equal( + await browser.captureSnapshot( + browser.queryClient, + browser.rpcQuery.diagnostics.stream.liveOptions(), + ), + 'Connection opened', + ) + equal( + ( + await browser.queryClient.query({ + ...browser.httpQuery.users.list.queryOptions(), + staleTime: 0, + }) + ).length, + 13, + ) + const retired = await startServerApplication({ + host, + identity: 'retired', + authorization: 'allowed', + }) + const preparationStarted = Deferred.makeUnsafe() + const permit = Deferred.makeUnsafe() + let released = false + let preparing: Promise | undefined + try { + await retired.queryClient.query(retired.rpcQuery.users.list.queryOptions()) + const codecs = makeSnapshotPreparation(retired, { + beforeEncode: Effect.scoped( + Effect.acquireRelease(Deferred.succeed(preparationStarted, undefined), () => + Effect.sync(() => { + released = true + }), + ).pipe(Effect.andThen(Deferred.await(permit))), + ), + beforeDecode: Effect.void, + }) + preparing = (async () => { + try { + await codecs.encode(dehydrate(retired.queryClient).queries) + return null + } catch (error) { + return error + } + })() + await Effect.runPromise(Deferred.await(preparationStarted)) + await retired.dispose() + equal(released, true) + ok(await preparing) + equal((await browser.queryClient.query({ ...browserList, staleTime: 0 })).length, 13) + } finally { + await Effect.runPromise(Deferred.succeed(permit, undefined)) + await preparing + await retired.dispose() + } + console.log( + 'Packed copied Start host, request lifetimes, classes, pages, streams and async hydration verified', + ) +} finally { + await Promise.all([server.dispose(), browser.dispose()]) + await Effect.runPromise(Scope.close(scope, Exit.void)) +} diff --git a/tests/packed-consumer/tsconfig.json b/tests/packed-consumer/tsconfig.json index 7d90d4f..9e4b36c 100644 --- a/tests/packed-consumer/tsconfig.json +++ b/tests/packed-consumer/tsconfig.json @@ -17,6 +17,7 @@ "docs-owner-cache.ts", "owner-cache-contract.ts", "owner-cache-runtime.mts", + "start-ssr-runtime.mts", "optimistic-runtime.mts", "optimistic-contract.ts", "hashing-runtime.mts", diff --git a/tests/types/server-local-client-contract.ts b/tests/types/server-local-client-contract.ts index 0c1d9fd..489b6a9 100644 --- a/tests/types/server-local-client-contract.ts +++ b/tests/types/server-local-client-contract.ts @@ -1,7 +1,7 @@ import { Context, Effect, Schema } from 'effect' import { Rpc, RpcGroup } from 'effect/rpc' -import { makeServerLocalRpcClient } from '../fixtures/server-local-rpc.ts' +import { makeServerLocalRpcClient } from '../../examples/server/src/server-local-rpc.ts' class CodecService extends Context.Service()( 'effect-api-query/tests/ServerLocalCodecService', diff --git a/tsconfig.json b/tsconfig.json index 5462fc0..5a9393a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -48,7 +48,7 @@ "tests/create-rpc-query-utils*.test.ts", "tests/http-*.test.ts", "tests/fixtures/effect-rpc.ts", - "tests/fixtures/server-local-rpc.ts", + "examples/server/src/server-local-rpc.ts", "tests/fixtures/rpc-transport-queries.ts", "tests/fixtures/http-client.ts", "tests/fixtures/optimistic-users.ts", From 2829acf7f362e335a33c7dfd53ad460b18248cb4 Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Sat, 10 Oct 2026 12:39:51 +0200 Subject: [PATCH 49/97] Keep Start server imports and snapshot types within their consumers --- examples/tanstack-start/src/lib/query-ssr.ts | 4 +--- examples/tanstack-start/src/lib/server-application.ts | 2 +- examples/tanstack-start/src/lib/server-host.ts | 2 +- examples/tanstack-start/tests/async-hydration.test.ts | 9 ++++++++- examples/tanstack-start/tests/server-render.test.tsx | 3 +-- scripts/verify-packed-consumer.mts | 5 ++++- 6 files changed, 16 insertions(+), 9 deletions(-) diff --git a/examples/tanstack-start/src/lib/query-ssr.ts b/examples/tanstack-start/src/lib/query-ssr.ts index 5292947..739f66a 100644 --- a/examples/tanstack-start/src/lib/query-ssr.ts +++ b/examples/tanstack-start/src/lib/query-ssr.ts @@ -8,10 +8,8 @@ import type { TanStackStartApplication } from './application.ts' import { makeSnapshotPreparation } from './snapshot-preparation.ts' import type { SnapshotPreparation } from './snapshot-preparation.ts' -export { fetchStreamSnapshot } from 'effect-api-query' - type SnapshotQueries = DehydratedState['queries'] -export interface RouterSnapshot { +interface RouterSnapshot { readonly query?: { initial?: DehydratedState['queries'] readonly stream: ReadableStream diff --git a/examples/tanstack-start/src/lib/server-application.ts b/examples/tanstack-start/src/lib/server-application.ts index 17a1527..40cda34 100644 --- a/examples/tanstack-start/src/lib/server-application.ts +++ b/examples/tanstack-start/src/lib/server-application.ts @@ -1,6 +1,6 @@ +import type { ExampleHost } from '@effect-api-query/server/web-handler' import { Effect, Exit, Scope } from 'effect' -import type { ExampleHost } from '../../../server/src/web-handler.ts' import { reportCleanupFailure, startTanStackStartApplication } from './application.ts' export const startServerApplication = async ({ diff --git a/examples/tanstack-start/src/lib/server-host.ts b/examples/tanstack-start/src/lib/server-host.ts index df42aed..4ab812c 100644 --- a/examples/tanstack-start/src/lib/server-host.ts +++ b/examples/tanstack-start/src/lib/server-host.ts @@ -1,7 +1,7 @@ import '@tanstack/react-start/server-only' +import { makeExampleHost } from '@effect-api-query/server/web-handler' import { Effect, Exit, Scope } from 'effect' -import { makeExampleHost } from '../../../server/src/web-handler.ts' import { reportCleanupFailure } from './application.ts' import { startServerApplication } from './server-application.ts' diff --git a/examples/tanstack-start/tests/async-hydration.test.ts b/examples/tanstack-start/tests/async-hydration.test.ts index b9f52e3..a752165 100644 --- a/examples/tanstack-start/tests/async-hydration.test.ts +++ b/examples/tanstack-start/tests/async-hydration.test.ts @@ -1,14 +1,21 @@ import { User } from '@effect-api-query/contracts' +import type { DehydratedState } from '@tanstack/react-query' import { createMemoryHistory } from '@tanstack/react-router' import { attachRouterServerSsrUtils } from '@tanstack/react-start/server' import { Deferred, Effect, Exit, Schema, Scope } from 'effect' import { describe, expect, it, vi } from 'vite-plus/test' import { makeExampleHost } from '../../server/src/web-handler.ts' -import type { RouterSnapshot } from '../src/lib/query-ssr.ts' import { startServerApplication } from '../src/lib/server-application.ts' import { createTanStackStartRouter } from '../src/router.tsx' +interface RouterSnapshot { + readonly query?: { + readonly initial?: DehydratedState['queries'] + readonly stream: ReadableStream + } +} + describe('Start snapshot preparation', () => { it('finishes application encoding before publication and decoding before native hydration', async () => { const scope = Scope.makeUnsafe() diff --git a/examples/tanstack-start/tests/server-render.test.tsx b/examples/tanstack-start/tests/server-render.test.tsx index eb88503..fa74bb6 100644 --- a/examples/tanstack-start/tests/server-render.test.tsx +++ b/examples/tanstack-start/tests/server-render.test.tsx @@ -2,7 +2,7 @@ import { startExampleRpcServer } from '@effect-api-query/server' import { QueryClient } from '@tanstack/react-query' import { createMemoryHistory, RouterProvider } from '@tanstack/react-router' import { Deferred, Effect, Exit, Schema, Scope, Stream } from 'effect' -import { createRpcQueryUtils } from 'effect-api-query' +import { createRpcQueryUtils, fetchStreamSnapshot } from 'effect-api-query' import { Rpc, RpcGroup } from 'effect/rpc' import type { RpcClient } from 'effect/rpc' import { renderToString } from 'react-dom/server' @@ -10,7 +10,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test' import { fetchStreamSnapshot as captureStreamSnapshot } from '../../../src/index.ts' import type { StreamSnapshotOptions } from '../../../src/index.ts' -import { fetchStreamSnapshot } from '../src/lib/query-ssr.ts' import { createTanStackStartRouter } from '../src/router.tsx' describe('TanStack Start server rendering', () => { diff --git a/scripts/verify-packed-consumer.mts b/scripts/verify-packed-consumer.mts index e9d0c48..f4807d1 100644 --- a/scripts/verify-packed-consumer.mts +++ b/scripts/verify-packed-consumer.mts @@ -468,7 +468,10 @@ const verifyConsumer = (peer: (typeof peerCases)[number]): void => { "'../optimistic-contracts/client.ts'", ) .replaceAll("'@effect-api-query/contracts'", "'../optimistic-contracts/index.ts'") - .replaceAll("'../../../server/src/web-handler.ts'", "'../start-server/web-handler.ts'"), + .replaceAll( + "'@effect-api-query/server/web-handler'", + "'../start-server/web-handler.ts'", + ), ) } } From fffe200b2924808039f9760ee8bf1fa3a7123804 Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Sat, 10 Oct 2026 12:53:31 +0200 Subject: [PATCH 50/97] Certify Vue and Solid consumers with native reactive options --- .fallowrc.jsonc | 3 + apps/docs/astro.config.mjs | 1 + .../src/content/docs/guides/vue-and-solid.md | 97 ++++ .../reference/compatibility-and-limits.md | 6 + scripts/verify-packed-consumer.mts | 19 +- scripts/verify-vue-solid-consumer.mts | 58 +++ src/core/types.ts | 480 ++++++++++++------ src/rpc/types.ts | 3 +- tests/packed-consumer/defaults-runtime.mts | 29 ++ tests/packed-consumer/framework-fixture.ts | 112 ++++ tests/packed-consumer/tsconfig.vue-solid.json | 5 + tests/packed-consumer/vue-solid-contract.ts | 412 +++++++++++++++ tests/packed-consumer/vue-solid-runtime.ts | 242 +++++++++ tests/types/public-contract.ts | 47 +- 14 files changed, 1352 insertions(+), 162 deletions(-) create mode 100644 apps/docs/src/content/docs/guides/vue-and-solid.md create mode 100644 scripts/verify-vue-solid-consumer.mts create mode 100644 tests/packed-consumer/framework-fixture.ts create mode 100644 tests/packed-consumer/tsconfig.vue-solid.json create mode 100644 tests/packed-consumer/vue-solid-contract.ts create mode 100644 tests/packed-consumer/vue-solid-runtime.ts diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 2c721dc..f65e60a 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -4,6 +4,9 @@ // Astro copies this browser entry from public/ and loads it through the // Starlight head configuration, which static import analysis cannot follow. "entry": [ + "scripts/verify-vue-solid-consumer.mts", + "tests/packed-consumer/vue-solid-contract.ts", + "tests/packed-consumer/vue-solid-runtime.ts", "tests/packed-consumer/owner-cache-runtime.mts", "tests/packed-consumer/owner-cache-contract.ts", "apps/docs/public/package-version.js", diff --git a/apps/docs/astro.config.mjs b/apps/docs/astro.config.mjs index 357473b..f4db4e3 100644 --- a/apps/docs/astro.config.mjs +++ b/apps/docs/astro.config.mjs @@ -66,6 +66,7 @@ export default defineConfig({ label: 'Guides', items: [ { label: 'React Query', slug: 'guides/react-query' }, + { label: 'Vue and Solid Query', slug: 'guides/vue-and-solid' }, { label: 'HTTP Queries and Mutations', slug: 'guides/http-queries-and-mutations' }, { label: 'TanStack Start', slug: 'guides/tanstack-start' }, { label: 'Stream Snapshots', slug: 'guides/stream-snapshots' }, diff --git a/apps/docs/src/content/docs/guides/vue-and-solid.md b/apps/docs/src/content/docs/guides/vue-and-solid.md new file mode 100644 index 0000000..69b82f8 --- /dev/null +++ b/apps/docs/src/content/docs/guides/vue-and-solid.md @@ -0,0 +1,97 @@ +--- +title: Vue and Solid Query +description: Rebuild generated options from reactive input and keep client resources alive. +--- + +Pass generated options from either factory to the framework's native Query hooks. Configure +`VueQueryPlugin` in Vue or `QueryClientProvider` in Solid, and acquire the ready client before +creating the utilities. Keep its `Scope` and any serviceful runner alive while queries use them. + +| Framework | Framework version | Query wrapper and matching Query Core | +| --------- | ----------------- | ------------------------------------- | +| Vue | 3.5.43 | 5.103.1 and 5.104.0 | +| Solid | 1.9.15 | 5.103.1 and 5.104.0 | + +These combinations use Effect 4.0.0 with strict TypeScript 5.9.3 and 7.0.2. The same generated +builders provide selection, defined initial data, skipping, mutations, pagination, accumulated +streams, live values, and HTTP metadata views. See the +[compatibility reference](/effect-api-query/reference/compatibility-and-limits/) for the wider scope. + +## Rebuild options when input changes + +Builders capture plain input when called. Build options inside Vue `computed` or Solid's options +accessor so a changed input produces a new key and query function. Passing a builder's result +once leaves the query attached to the original input. + +In Vue component setup, use a computed options object. This example assumes `rpc.users.read` +returns a user with a `name`: + +```ts +import type { InferDataFromTag, InferErrorFromTag } from '@tanstack/query-core' +import { useQuery } from '@tanstack/vue-query' +import { skipToken } from 'effect-api-query' +import { computed, ref } from 'vue' + +const userId = ref(1) +const options = computed(() => + rpc.users.read.queryOptions({ + input: userId.value === undefined ? skipToken : { id: userId.value }, + select: (user) => user.name, + }), +) +const user = useQuery< + InferDataFromTag, + InferErrorFromTag, + string +>(options) +``` + +Read the selected value from `user.data.value`. The explicit native hook generics preserve the +data and error carried by the generated key while bounding Vue's recursive ref unwrapping. +Automatic inference can otherwise exceed TypeScript's instantiation depth when it unwraps an +Effect `Cause` through a tagged key. Let the builder infer its arguments; put these type arguments +on the Vue hook. + +In a Solid component, pass the builder call as an accessor: + +```ts +import { useQuery } from '@tanstack/solid-query' +import { skipToken } from 'effect-api-query' +import { createSignal } from 'solid-js' + +const [userId] = createSignal(1) +const user = useQuery(() => { + const id = userId() + return rpc.users.read.queryOptions({ + input: id === undefined ? skipToken : { id }, + select: (value) => value.name, + }) +}) +``` + +Read the selected value from `user.data`. Apply the same computed/accessor pattern to +`infiniteOptions`, `streamedOptions`, `liveOptions`, and `metadataOptions` when their input changes. + +## Choose native options carefully + +Vue evaluates a function-valued `enabled` as a getter with no arguments. Use a boolean inside +computed options, or a zero-argument getter returning a boolean. A Core `enabled(query)` callback +cannot be passed directly to Vue. Solid accepts the native Core callback. + +The pinned Solid query and infinite-query overloads accept defined initial data or absent initial +data. A value or initializer that may return `undefined` does not match those overloads. Omit +`initialData` or supply a defined seed. Vue accepts a possibly undefined initializer. An explicit +`initialData: undefined` still overrides inherited QueryClient initial data in both integrations. + +## Own streams and disposal + +Open streams expose their first value while fetching continues. Changing reactive input replaces +the query; when its last observer leaves, query cancellation closes the stream iterator. Component +unmount or Solid owner disposal also releases observed streams. Keep client resources alive until +these queries finish cancelling, then clear the owned QueryClient and close the client scope. +Transport interruption depends on the ready client. + +Use `streamedOptions` for an accumulated history and `liveOptions` for its latest emission. HTTP +`metadataOptions` retains decoded data, response status, and header snapshots in a separate cache +entry. Follow [client lifecycle](/effect-api-query/concepts/client-lifecycle/) and +[cancellation](/effect-api-query/guides/cancellation/) when disposing the cache owner. diff --git a/apps/docs/src/content/docs/reference/compatibility-and-limits.md b/apps/docs/src/content/docs/reference/compatibility-and-limits.md index b93bd13..f5c20ba 100644 --- a/apps/docs/src/content/docs/reference/compatibility-and-limits.md +++ b/apps/docs/src/content/docs/reference/compatibility-and-limits.md @@ -9,6 +9,11 @@ The package targets Effect 4 and TanStack Query Core 5.103.1 or later in v5, wit integrations checked in this repository. It is ESM-only and targets ES2022. Use TypeScript with `strict: true`. +Vue 3.5.43 and Solid 1.9.15 use their native Query wrappers at 5.103.1 and 5.104.0, each with matching +Query Core, Effect 4.0.0, and TypeScript 5.9.3 or 7.0.2. See +[Vue and Solid Query](/effect-api-query/guides/vue-and-solid/) for reactive option rebuilding, +Vue's type-inference and `enabled` distinctions, and Solid's initial-data overload limits. + Consult [package metadata](https://github.com/ueberBrot/effect-api-query/blob/main/package.json) for peer ranges and the [workspace catalog](https://github.com/ueberBrot/effect-api-query/blob/main/pnpm-workspace.yaml) for the pinned Effect release and framework versions. The @@ -43,6 +48,7 @@ supplies the client, policy, or lifecycle. **Deferred** means the adapter does n | Mutation cancellation | No upstream TanStack mutation abort signal; explicit cancellable command is application-owned and tested | No upstream TanStack mutation abort signal; domain cancellation is application-owned | | Authentication, middleware, and residual services | Application-owned ready client and runner | Application-owned ready client and runner | | React hooks and QueryClient | Tested public consumers and Vite React example | Tested public consumers and Vite React example | +| Vue and Solid Query | Generated options with reactive browser lifecycle | Generated options with reactive browser lifecycle | | SSR and hydration | Application-owned; tested Start route loading and stream snapshots | Application-owned; tested Start SSR, hydration, failed-query refetch, and request isolation | | Host routes | Application-owned; tested standalone server and Start `/rpc` | Application-owned; tested standalone server and Start `/api/$` | | Cache serialization and mutation invalidation | Application-owned | Application-owned | diff --git a/scripts/verify-packed-consumer.mts b/scripts/verify-packed-consumer.mts index d249c9a..2bf5adf 100644 --- a/scripts/verify-packed-consumer.mts +++ b/scripts/verify-packed-consumer.mts @@ -384,7 +384,11 @@ const peerCases = queryCoreCompatibilityCases() const runTypeScript = ( consumerDirectory: string, compiler: (typeof compilerCases)[number], - project: 'tsconfig.json' | 'tsconfig.tanstack-start.json' | 'tsconfig.type-scale.json', + project: + | 'tsconfig.json' + | 'tsconfig.tanstack-start.json' + | 'tsconfig.type-scale.json' + | 'tsconfig.vue-solid.json', extendedDiagnostics: boolean, ): void => { console.log(`Verifying ${project} with ${compiler.label}`) @@ -495,6 +499,8 @@ const verifyConsumer = (peer: (typeof peerCases)[number]): void => { dependencies: { '@tanstack/query-core': peer.queryCoreVersion, '@tanstack/react-query': peer.reactQueryVersion, + '@tanstack/solid-query': peer.queryCoreVersion, + '@tanstack/vue-query': peer.queryCoreVersion, '@tanstack/react-router': testedVersion('@tanstack/react-router'), '@tanstack/react-router-ssr-query': testedVersion('@tanstack/react-router-ssr-query'), '@tanstack/react-start': testedVersion('@tanstack/react-start'), @@ -505,6 +511,8 @@ const verifyConsumer = (peer: (typeof peerCases)[number]): void => { 'effect-api-query': `file:${tarballPath}`, react: testedVersion('react'), 'react-dom': testedVersion('react-dom'), + 'solid-js': '1.9.15', + vue: '3.5.43', }, } writeFileSync( @@ -562,6 +570,7 @@ const verifyConsumer = (peer: (typeof peerCases)[number]): void => { for (const compiler of compilerCases) { runTypeScript(consumerDirectory, compiler, 'tsconfig.json', false) runTypeScript(consumerDirectory, compiler, 'tsconfig.tanstack-start.json', false) + runTypeScript(consumerDirectory, compiler, 'tsconfig.vue-solid.json', false) runTypeScript( consumerDirectory, compiler, @@ -595,6 +604,14 @@ const verifyConsumer = (peer: (typeof peerCases)[number]): void => { stdio: 'inherit', }) } + execFileSync( + process.execPath, + [ + nodePath.join(repositoryRoot, 'scripts', 'verify-vue-solid-consumer.mts'), + consumerDirectory, + ], + { stdio: 'inherit' }, + ) } finally { rmSync(consumerDirectory, { force: true, recursive: true }) } diff --git a/scripts/verify-vue-solid-consumer.mts b/scripts/verify-vue-solid-consumer.mts new file mode 100644 index 0000000..f96c513 --- /dev/null +++ b/scripts/verify-vue-solid-consumer.mts @@ -0,0 +1,58 @@ +import { chromium } from '@playwright/test' +import { Predicate } from 'effect' +import { strictEqual } from 'node:assert' +import { writeFileSync } from 'node:fs' +import nodePath from 'node:path' +import { createServer } from 'vite' + +const [consumerDirectory] = process.argv.slice(2) +if (consumerDirectory === undefined) { + throw new Error('A consumer directory is required') +} +writeFileSync( + nodePath.join(consumerDirectory, 'index.html'), + '', +) +const server = await createServer({ + configFile: false, + root: consumerDirectory, + server: { host: '127.0.0.1', port: 0 }, + optimizeDeps: { noDiscovery: true }, + define: { + __VUE_OPTIONS_API__: true, + __VUE_PROD_DEVTOOLS__: false, + __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false, + }, +}) +try { + await server.listen() + const address = server.httpServer?.address() + if (address === undefined || address === null || Predicate.isString(address)) { + throw new Error('The browser consumer server has no TCP address') + } + const browser = await chromium.launch() + try { + const page = await browser.newPage() + const errors: string[] = [] + page.on('pageerror', (error) => { + errors.push(error.message) + }) + await page.goto(`http://127.0.0.1:${address.port}`) + await page.waitForFunction( + () => document.documentElement.dataset['frameworkStatus'] !== undefined, + undefined, + { timeout: 30_000 }, + ) + const result = await page.evaluate(() => ({ + status: document.documentElement.dataset['frameworkStatus'], + message: document.documentElement.dataset['frameworkMessage'], + })) + strictEqual(result?.status, 'passed', result?.message ?? errors.join('\n')) + strictEqual(errors.length, 0, errors.join('\n')) + console.log('Vue and Solid reactive browser consumers passed') + } finally { + await browser.close() + } +} finally { + await server.close() +} diff --git a/src/core/types.ts b/src/core/types.ts index 7c9e1b2..0f93082 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -56,7 +56,7 @@ export type QueryOptions< Error, Selected, Key extends QueryKey, - Fn = QueryFunction, + Fn = QueryFunction, > = QueryInput & { readonly queryFn: Fn readonly queryKey: Key @@ -67,7 +67,7 @@ export type ConditionalQueryOptions Error, Selected, Key, - QueryFunction | SkipToken + QueryFunction | SkipToken > export type InputQueryOptions< @@ -85,6 +85,42 @@ export type InputQueryOptions< ? ConditionalQueryOptions : QueryOptions +type ExactBuilderOptions = Expected & + Supplied & { + readonly [ + Key in Exclude + ]: string extends Key ? Supplied[Key] : number extends Key ? Supplied[Key] : never + } + +type SuppliedInitialData = 'initialData' extends keyof Supplied + ? undefined extends Supplied['initialData'] + ? Omit & { + readonly initialData?: Exclude + } + : Supplied + : Supplied + +declare const uninferredBuilderOptions: unique symbol +type UninferredBuilderOptions = { readonly [uninferredBuilderOptions]?: never } + +type SuppliedQueryOptions = + typeof uninferredBuilderOptions extends keyof Supplied + ? Options + : Pick< + Options, + Extract< + keyof Options, + | 'queryFn' + | 'queryKey' + | 'select' + | 'retry' + | 'retryDelay' + | 'initialPageParam' + | 'getNextPageParam' + > + > & + Omit, 'input' | keyof AdapterOptions> + /** Owns unary query inference; adapters supply their request, data, error, and key types. */ export type UnaryQueryBuilder< Input, @@ -97,91 +133,171 @@ export type UnaryQueryBuilder< > = Inputless extends true ? { ( - options: WithDefinedInitialData< - QueryInput, - Data + options?: undefined, + ): SuppliedQueryOptions< + QueryOptions, + Record, + AdapterOptions + > + ( + options: ExactBuilderOptions< + WithDefinedInitialData, Data>, + Supplied >, - ): WithDefinedInitialData, Data> - ( - options?: WithUndefinedInitialData< - QueryInput, - Data + ): WithDefinedInitialData< + SuppliedQueryOptions, Supplied, AdapterOptions>, + Data + > + ( + options: ExactBuilderOptions< + WithUndefinedInitialData, Data>, + Supplied >, - ): QueryOptions + ): SuppliedQueryOptions, Supplied, AdapterOptions> } : { - ( - options: WithDefinedInitialData< - QueryInput< - Data, - Error, - Selected, - InputQueryOptions['queryKey'], - AdapterOptions - >, - Data - > & { readonly input: ActualInput }, + < + Selected = Data, + ActualInput extends SkipToken = SkipToken, + Supplied extends object = UninferredBuilderOptions, + >( + options: ExactBuilderOptions< + WithDefinedInitialData< + QueryInput< + Data, + Error, + Selected, + InputQueryOptions['queryKey'], + AdapterOptions + >, + Data + > & { readonly input: ActualInput }, + Supplied + >, ): WithDefinedInitialData< - InputQueryOptions, + SuppliedQueryOptions< + InputQueryOptions, + Supplied, + AdapterOptions + >, Data > - ( - options: WithUndefinedInitialData< - QueryInput< - Data, - Error, - Selected, - InputQueryOptions['queryKey'], - AdapterOptions - >, - Data - > & { readonly input: ActualInput }, - ): InputQueryOptions - ( - options: WithDefinedInitialData< - QueryInput< - Data, - Error, - Selected, - InputQueryOptions['queryKey'], - AdapterOptions - >, - Data - > & { readonly input: ActualInput }, - ): WithDefinedInitialData< + < + Selected = Data, + ActualInput extends SkipToken = SkipToken, + Supplied extends object = UninferredBuilderOptions, + >( + options: ExactBuilderOptions< + WithUndefinedInitialData< + QueryInput< + Data, + Error, + Selected, + InputQueryOptions['queryKey'], + AdapterOptions + >, + Data + > & { readonly input: ActualInput }, + Supplied + >, + ): SuppliedQueryOptions< InputQueryOptions, + Supplied, + AdapterOptions + > + < + Selected = Data, + ActualInput extends Input = Input, + Supplied extends object = UninferredBuilderOptions, + >( + options: ExactBuilderOptions< + WithDefinedInitialData< + QueryInput< + Data, + Error, + Selected, + InputQueryOptions['queryKey'], + AdapterOptions + >, + Data + > & { readonly input: ActualInput }, + Supplied + >, + ): WithDefinedInitialData< + SuppliedQueryOptions< + InputQueryOptions, + Supplied, + AdapterOptions + >, Data > - ( - options: WithUndefinedInitialData< - QueryInput< - Data, - Error, - Selected, - InputQueryOptions['queryKey'], - AdapterOptions - >, - Data - > & { readonly input: ActualInput }, - ): InputQueryOptions - ( - options: WithDefinedInitialData< - Omit, OwnedQueryOption>, - Data - > & - AdapterOptions & { readonly input: Input | SkipToken }, + < + Selected = Data, + ActualInput extends Input = Input, + Supplied extends object = UninferredBuilderOptions, + >( + options: ExactBuilderOptions< + WithUndefinedInitialData< + QueryInput< + Data, + Error, + Selected, + InputQueryOptions['queryKey'], + AdapterOptions + >, + Data + > & { readonly input: ActualInput }, + Supplied + >, + ): SuppliedQueryOptions< + InputQueryOptions, + Supplied, + AdapterOptions + > + ( + options: ExactBuilderOptions< + WithDefinedInitialData< + Omit< + ConditionalQueryOptions, + OwnedQueryOption + >, + Data + > & + AdapterOptions & { readonly input: Input | SkipToken }, + Supplied + >, ): WithDefinedInitialData< - ConditionalQueryOptions, + SuppliedQueryOptions< + ConditionalQueryOptions, + Supplied, + AdapterOptions + >, Data > - ( - options: WithUndefinedInitialData< - Omit, OwnedQueryOption>, - Data - > & - AdapterOptions & { readonly input: Input | SkipToken }, - ): ConditionalQueryOptions - (token: SkipToken): QueryOptions + ( + options: ExactBuilderOptions< + WithUndefinedInitialData< + Omit< + ConditionalQueryOptions, + OwnedQueryOption + >, + Data + > & + AdapterOptions & { readonly input: Input | SkipToken }, + Supplied + >, + ): SuppliedQueryOptions< + ConditionalQueryOptions, + Supplied, + AdapterOptions + > + ( + token: SkipToken, + ): SuppliedQueryOptions< + QueryOptions, + Record, + AdapterOptions + > } export type InfiniteInput< @@ -200,7 +316,7 @@ export type InfiniteOptions< Selected, Key extends QueryKey, PageParam, - Fn = QueryFunction, + Fn = QueryFunction, > = InfiniteInput & { readonly queryFn: Fn readonly queryKey: Key @@ -251,7 +367,7 @@ export type ConditionalInfiniteOptions< Selected, Key, PageParam, - QueryFunction | SkipToken + QueryFunction | SkipToken > /** Owns infinite-query inference while adapters supply request, data, error, and operation keys. */ @@ -263,21 +379,63 @@ export type InfiniteQueryBuilder< AdapterOptions = unknown, Inputless extends boolean = void extends Input ? true : false, > = { - >( - options: WithDefinedInitialData< - MappedInfiniteInput< - Input, + < + PageParam, + Selected = InfiniteData, + Supplied extends object = UninferredBuilderOptions, + >( + options: ExactBuilderOptions< + WithDefinedInitialData< + MappedInfiniteInput< + Input, + Data, + Error, + OperationKey, + Selected, + PageParam, + AdapterOptions, + Inputless + >, + InfiniteData + >, + Supplied + >, + ): WithDefinedInitialData< + SuppliedQueryOptions< + InfiniteOptions< Data, Error, - OperationKey, Selected, - PageParam, - AdapterOptions, - Inputless + InfiniteQueryKey, + PageParam >, - InfiniteData + Supplied, + AdapterOptions >, - ): WithDefinedInitialData< + InfiniteData + > + < + PageParam, + Selected = InfiniteData, + Supplied extends object = UninferredBuilderOptions, + >( + options: ExactBuilderOptions< + WithUndefinedInitialData< + MappedInfiniteInput< + Input, + Data, + Error, + OperationKey, + Selected, + PageParam, + AdapterOptions, + Inputless + >, + InfiniteData + >, + Supplied + >, + ): SuppliedQueryOptions< InfiniteOptions< Data, Error, @@ -285,60 +443,104 @@ export type InfiniteQueryBuilder< InfiniteQueryKey, PageParam >, - InfiniteData - > - >( - options: WithUndefinedInitialData< - MappedInfiniteInput< - Input, - Data, - Error, - OperationKey, - Selected, - PageParam, - AdapterOptions, - Inputless - >, - InfiniteData - >, - ): InfiniteOptions< - Data, - Error, - Selected, - InfiniteQueryKey, - PageParam + Supplied, + AdapterOptions > } & (Inputless extends true ? unknown : { - >( - options: WithDefinedInitialData< - InfiniteInput, - InfiniteData - > & { readonly input: SkipToken }, + < + PageParam, + Selected = InfiniteData, + Supplied extends object = UninferredBuilderOptions, + >( + options: ExactBuilderOptions< + WithDefinedInitialData< + InfiniteInput, + InfiniteData + > & { readonly input: SkipToken }, + Supplied + >, ): WithDefinedInitialData< - InfiniteOptions, + SuppliedQueryOptions< + InfiniteOptions, + Supplied, + AdapterOptions + >, InfiniteData > - >( - options: WithUndefinedInitialData< - InfiniteInput, - InfiniteData - > & { readonly input: SkipToken }, - ): InfiniteOptions - >( - options: WithDefinedInitialData< - InfiniteInput< + < + PageParam, + Selected = InfiniteData, + Supplied extends object = UninferredBuilderOptions, + >( + options: ExactBuilderOptions< + WithUndefinedInitialData< + InfiniteInput, + InfiniteData + > & { readonly input: SkipToken }, + Supplied + >, + ): SuppliedQueryOptions< + InfiniteOptions, + Supplied, + AdapterOptions + > + < + PageParam, + Selected = InfiniteData, + Supplied extends object = UninferredBuilderOptions, + >( + options: ExactBuilderOptions< + WithDefinedInitialData< + InfiniteInput< + Data, + Error, + Selected, + | InfiniteQueryKey + | OperationKey, + PageParam, + AdapterOptions + >, + InfiniteData + > & { readonly input: ((pageParam: PageParam) => Input) | SkipToken }, + Supplied + >, + ): WithDefinedInitialData< + SuppliedQueryOptions< + ConditionalInfiniteOptions< Data, Error, Selected, InfiniteQueryKey | OperationKey, - PageParam, - AdapterOptions + PageParam >, - InfiniteData - > & { readonly input: ((pageParam: PageParam) => Input) | SkipToken }, - ): WithDefinedInitialData< + Supplied, + AdapterOptions + >, + InfiniteData + > + < + PageParam, + Selected = InfiniteData, + Supplied extends object = UninferredBuilderOptions, + >( + options: ExactBuilderOptions< + WithUndefinedInitialData< + InfiniteInput< + Data, + Error, + Selected, + | InfiniteQueryKey + | OperationKey, + PageParam, + AdapterOptions + >, + InfiniteData + > & { readonly input: ((pageParam: PageParam) => Input) | SkipToken }, + Supplied + >, + ): SuppliedQueryOptions< ConditionalInfiniteOptions< Data, Error, @@ -346,26 +548,8 @@ export type InfiniteQueryBuilder< InfiniteQueryKey | OperationKey, PageParam >, - InfiniteData - > - >( - options: WithUndefinedInitialData< - InfiniteInput< - Data, - Error, - Selected, - InfiniteQueryKey | OperationKey, - PageParam, - AdapterOptions - >, - InfiniteData - > & { readonly input: ((pageParam: PageParam) => Input) | SkipToken }, - ): ConditionalInfiniteOptions< - Data, - Error, - Selected, - InfiniteQueryKey | OperationKey, - PageParam + Supplied, + AdapterOptions > }) diff --git a/src/rpc/types.ts b/src/rpc/types.ts index db3181e..1a3433f 100644 --- a/src/rpc/types.ts +++ b/src/rpc/types.ts @@ -23,10 +23,11 @@ export interface UnaryRpcOptions { readonly discard?: never /** Stream adaptation belongs to the package. */ readonly asQueue?: never + readonly streamBufferSize?: never } /** Request-local options for accumulated-stream and live queries. */ -export interface StreamingRpcOptions extends UnaryRpcOptions { +export interface StreamingRpcOptions extends Omit { readonly streamBufferSize?: number | undefined } diff --git a/tests/packed-consumer/defaults-runtime.mts b/tests/packed-consumer/defaults-runtime.mts index 9f0b757..92dfcc6 100644 --- a/tests/packed-consumer/defaults-runtime.mts +++ b/tests/packed-consumer/defaults-runtime.mts @@ -73,6 +73,35 @@ await Effect.runPromise( observer.destroy() queryClient.clear() } + for (const mode of ['global', 'prefix'] as const) { + const seedClient = new QueryClient({ + defaultOptions: { + queries: { + gcTime: Infinity, + staleTime: Infinity, + retry: false, + ...(mode === 'global' ? { initialData: { id: -1, name: 'inherited' } } : {}), + }, + }, + }) + if (mode === 'prefix') { + seedClient.setQueryDefaults(rpc.users.key(), { + initialData: { id: -1, name: 'inherited' }, + }) + } + try { + const options = rpc.users.get.queryOptions({ + input: { id: 1 }, + initialData: undefined, + }) + deepStrictEqual(yield* Effect.promise(() => seedClient.query(options)), { + id: 1, + name: 'Ada', + }) + } finally { + seedClient.clear() + } + } for (const mode of ['global', 'prefix'] as const) { let fallbackCalls = 0 const hashClient = new QueryClient({ diff --git a/tests/packed-consumer/framework-fixture.ts b/tests/packed-consumer/framework-fixture.ts new file mode 100644 index 0000000..eef07fc --- /dev/null +++ b/tests/packed-consumer/framework-fixture.ts @@ -0,0 +1,112 @@ +import { Effect, Exit, Schema, Scope, Stream } from 'effect' +import { createRpcQueryUtils, createHttpApiQueryUtils } from 'effect-api-query' +import { HttpServer } from 'effect/http' +import { + HttpApi, + HttpApiEndpoint, + HttpApiGroup, + HttpApiSchema, + HttpApiBuilder, + HttpApiTest, +} from 'effect/http-api' +import { Rpc, RpcGroup, RpcTest } from 'effect/rpc' + +export const expectEqual = (actual: unknown, expected: unknown, message: string) => { + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error( + `${message}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`, + ) + } +} +export const waitFor = (check: () => boolean, message: string) => + new Promise((resolve, reject) => { + const deadline = performance.now() + 10_000 + const checkFrame = () => { + if (check()) resolve() + else if (performance.now() >= deadline) reject(new Error(message)) + else requestAnimationFrame(checkFrame) + } + checkFrame() + }) +const Watch = Rpc.make('values.watch', { + payload: { id: Schema.Int }, + success: Schema.Int, + stream: true, +}) +const group = RpcGroup.make(Watch) +const api = HttpApi.make('framework-runtime').add( + HttpApiGroup.make('values').add( + HttpApiEndpoint.get('watch', '/watch/:id', { + params: { id: Schema.FiniteFromString }, + success: HttpApiSchema.StreamSse({ data: Schema.Int }), + }), + HttpApiEndpoint.get('read', '/read/:id', { + params: { id: Schema.FiniteFromString }, + success: Schema.Int.pipe(HttpApiSchema.status(203)), + }), + ), +) +export const makeFixture = async (name: string) => { + const scope = Scope.makeUnsafe() + const finalized: number[] = [] + const client = await Effect.runPromise( + RpcTest.makeClient(group, { flatten: true }).pipe( + Effect.provide( + group.toLayer({ + 'values.watch': ({ id }) => + Stream.unwrap( + Effect.acquireRelease( + Effect.succeed( + Stream.succeed(id).pipe(Stream.concat(Stream.fromEffect(Effect.never))), + ), + () => + Effect.sync(() => { + finalized.push(id) + }), + ), + ), + }), + ), + Scope.provide(scope), + ), + ) + const utils = createRpcQueryUtils(group, { client, keyPrefix: [name] }) + const httpFinalized: number[] = [] + const httpClient = await Effect.runPromise( + HttpApiTest.groups(api, ['values']).pipe( + Effect.provide( + HttpApiBuilder.group(api, 'values', (handlers) => + handlers + .handle('read', ({ params }) => Effect.succeed(params.id)) + .handle('watch', ({ params }) => + Effect.succeed( + Stream.unwrap( + Effect.acquireRelease( + Effect.succeed( + Stream.succeed(params.id).pipe( + Stream.concat(Stream.fromEffect(Effect.never)), + ), + ), + () => + Effect.sync(() => { + httpFinalized.push(params.id) + }), + ), + ), + ), + ), + ), + ), + Effect.provide(HttpServer.layerServices), + Scope.provide(scope), + ), + ) + const http = createHttpApiQueryUtils(api, { client: httpClient, keyPrefix: [name] }) + return { + utils, + http, + finalized, + httpFinalized, + close: () => Effect.runPromise(Scope.close(scope, Exit.void)), + } +} diff --git a/tests/packed-consumer/tsconfig.vue-solid.json b/tests/packed-consumer/tsconfig.vue-solid.json new file mode 100644 index 0000000..72e39ee --- /dev/null +++ b/tests/packed-consumer/tsconfig.vue-solid.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { "types": ["node"] }, + "include": ["vue-solid-contract.ts", "vue-solid-runtime.ts"] +} diff --git a/tests/packed-consumer/vue-solid-contract.ts b/tests/packed-consumer/vue-solid-contract.ts new file mode 100644 index 0000000..69a4a99 --- /dev/null +++ b/tests/packed-consumer/vue-solid-contract.ts @@ -0,0 +1,412 @@ +import { + QueryClient, + type InfiniteData, + type InferDataFromTag, + type InferErrorFromTag, + type QueryKey, +} from '@tanstack/query-core' +import { + useInfiniteQuery as useSolidInfiniteQuery, + useMutation as useSolidMutation, + useQuery as useSolidQuery, +} from '@tanstack/solid-query' +import { + useInfiniteQuery as useVueInfiniteQuery, + useMutation as useVueMutation, + useQuery as useVueQuery, +} from '@tanstack/vue-query' +import { Context, Effect, Schema, SchemaTransformation } from 'effect' +import { + createHttpApiQueryUtils, + createRpcQueryUtils, + skipToken, + type CreateHttpApiQueryUtilsOptions, + type CreateRpcQueryUtilsOptions, + type EffectHttpApiQueryEmptyStreamError, + type EffectHttpApiQueryError, + type EffectRpcQueryEmptyStreamError, + type EffectRpcQueryError, + type RunPromiseExit, +} from 'effect-api-query' +import { + HttpApi, + HttpApiEndpoint, + HttpApiGroup, + HttpApiSchema, + type HttpApiClient, +} from 'effect/http-api' +import { Rpc, type RpcClient, RpcGroup } from 'effect/rpc' +import { createSignal } from 'solid-js' +import { computed, ref } from 'vue' + +type Equal = + (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false +type Assert = T +class Encoder extends Context.Service()( + 'Framework/Encoder', +) {} +class Decoder extends Context.Service()( + 'Framework/Decoder', +) {} +const Payload = Schema.Struct({ id: Schema.Int }) +const EncodedPayload = Payload.pipe( + Schema.middlewareEncoding((encoding) => + Effect.flatMap(Encoder, () => encoding), + ), +) +const User = Schema.Struct({ id: Schema.Int, name: Schema.String }) +const Read = Rpc.make('users.read', { + payload: EncodedPayload, + success: User, + error: Schema.Literal('missing'), +}) +const Watch = Rpc.make('users.watch', { + payload: { id: Schema.Int }, + success: Schema.UndefinedOr(Schema.Int), + error: Schema.Literal('closed'), + stream: true, +}) +const group = RpcGroup.make(Read, Watch) +declare const rpcClient: RpcClient.RpcClient.Flat, 'transport'> +declare const rpcRunner: RunPromiseExit +const rpc = createRpcQueryUtils(group, { + client: rpcClient, + keyPrefix: ['framework', 'rpc-owner'], + runPromiseExit: rpcRunner, + keyEncoders: { + 'users.read': (input) => { + input.id satisfies number + return input.id + }, + }, +}) +type RpcRunner = CreateRpcQueryUtilsOptions< + typeof group, + readonly ['framework'], + 'transport' +>['runPromiseExit'] +true satisfies Assert>> +// @ts-expect-error Encoding requires the caller's serviceful runner. +createRpcQueryUtils(group, { + client: rpcClient, + keyPrefix: ['missing'], + keyEncoders: { 'users.read': (input) => input.id }, +}) +const client = new QueryClient() +const rpcOptions = rpc.users.read.queryOptions({ + input: { id: 1 }, + initialData: { id: 1, name: 'initial' }, + select: (user) => user.name, + staleTime: Infinity, + gcTime: 60_000, + retry: (attempt, error) => { + error satisfies EffectRpcQueryError<'missing' | 'transport'> + return attempt < 2 + }, +}) +const cached = client.getQueryData(rpcOptions.queryKey) +true satisfies Assert> +const vueSelected = useVueQuery< + typeof User.Type, + EffectRpcQueryError<'missing' | 'transport'>, + string +>(rpcOptions) +true satisfies Assert> +vueSelected.error.value satisfies EffectRpcQueryError<'missing' | 'transport'> | null +const solidSelected = useSolidQuery(() => rpcOptions) +true satisfies Assert> +true satisfies Assert< + Equal | null> +> +const vueId = ref(1) +const vueReactiveOptions = computed(() => + rpc.users.read.queryOptions({ input: { id: vueId.value }, select: (user) => user.name }), +) +const vueReactive = useVueQuery< + typeof User.Type, + EffectRpcQueryError<'missing' | 'transport'>, + string +>(vueReactiveOptions) +true satisfies Assert> +const [solidId] = createSignal(1) +const solidReactive = useSolidQuery(() => + rpc.users.read.queryOptions({ input: { id: solidId() }, select: (user) => user.name }), +) +true satisfies Assert> +const skipped = rpc.users.read.queryOptions({ + input: skipToken, + initialData: { id: 1, name: 'initial' }, +}) +skipped.queryFn satisfies typeof skipToken +const vueSkipped = useVueQuery>( + skipped, +) +const solidSkipped = useSolidQuery(() => skipped) +vueSkipped.data.value satisfies typeof User.Type | undefined +true satisfies Assert> +const conditional = rpc.users.read.queryOptions({ + input: Math.random() > 0.5 ? { id: 1 } : skipToken, +}) +true satisfies Assert, never>> +useVueQuery>(conditional) +useSolidQuery(() => conditional) +const vueEnabled = rpc.users.read.queryOptions({ + input: { id: 1 }, + enabled: () => vueId.value > 0, +}) +useVueQuery>(vueEnabled) +const coreEnabled = rpc.users.read.queryOptions({ + input: { id: 1 }, + enabled: (query) => { + query.state.data satisfies typeof User.Type | undefined + query.state.error satisfies EffectRpcQueryError<'missing' | 'transport'> | null + return query.state.data?.id !== 0 + }, +}) +useSolidQuery(() => coreEnabled) +// @ts-expect-error Vue's enabled getter receives no Core Query argument. +useVueQuery>(coreEnabled) +const maybeSeed = rpc.users.read.queryOptions({ + input: { id: 1 }, + initialData: () => client.getQueryData(rpc.users.read.queryKey({ id: 1 })), +}) +const vueMaybeSeed = useVueQuery>( + maybeSeed, +) +true satisfies Assert> +// @ts-expect-error Solid's pinned overloads require defined or undefined initialData. +useSolidQuery(() => maybeSeed) +const explicitUndefinedSeed = rpc.users.read.queryOptions({ + input: { id: 1 }, + initialData: undefined, +}) +const solidUndefinedSeed = useSolidQuery(() => explicitUndefinedSeed) +true satisfies Assert> +const mutationOptions = rpc.users.read.mutationOptions({ + onMutate: (input) => { + input.id satisfies number + return { owner: 'original' as const } + }, + onSuccess: (data, input, transaction) => { + data satisfies typeof User.Type + input.id satisfies number + transaction?.owner satisfies 'original' | undefined + }, +}) +const vueMutation = useVueMutation(mutationOptions) +const solidMutation = useSolidMutation(() => mutationOptions) +vueMutation.mutate({ id: 1 }) +solidMutation.mutate({ id: 1 }) +vueMutation.data.value satisfies typeof User.Type | undefined +true satisfies Assert> +// @ts-expect-error The installed Vue mutation retains the declared payload. +vueMutation.mutate({ id: 'wrong' }) +// @ts-expect-error The installed Solid mutation retains the declared payload. +solidMutation.mutate({ id: 'wrong' }) +const infiniteOptions = rpc.users.read.infiniteOptions({ + input: (cursor: number) => ({ id: cursor }), + initialPageParam: 0, + getNextPageParam: (last, _pages, lastParam) => { + last.name satisfies string + lastParam satisfies number + return lastParam + 1 + }, + initialData: { pages: [{ id: 0, name: 'zero' }], pageParams: [0] }, + select: (value) => value.pages.map((user) => user.name), +}) +const vueInfinite = useVueInfiniteQuery< + typeof User.Type, + EffectRpcQueryError<'missing' | 'transport'>, + string[], + QueryKey, + number +>(infiniteOptions) +const solidInfinite = useSolidInfiniteQuery(() => infiniteOptions) +vueInfinite.data.value satisfies string[] | undefined +true satisfies Assert> +const infiniteCache = client.getQueryData(infiniteOptions.queryKey) +true satisfies Assert< + Equal | undefined> +> +const accumulated = rpc.users.watch.streamedOptions({ + input: { id: 1 }, + maxChunks: 4, + refetchMode: 'append', + select: (values) => values.length, + initialData: [1], +}) +const live = rpc.users.watch.liveOptions({ + input: { id: 1 }, + initialData: null, + select: (value) => (value === null ? 'empty' : value.toFixed()), +}) +const vueHistory = useVueQuery< + readonly (number | undefined)[], + EffectRpcQueryError<'closed' | 'transport'>, + number +>(accumulated) +const solidHistory = useSolidQuery(() => accumulated) +true satisfies Assert> +true satisfies Assert> +const vueLive = useVueQuery< + number | null, + EffectRpcQueryError<'closed' | 'transport'> | EffectRpcQueryEmptyStreamError, + string +>(live) +const solidLive = useSolidQuery(() => live) +true satisfies Assert> +true satisfies Assert> +solidLive.error satisfies + | EffectRpcQueryError<'closed' | 'transport'> + | EffectRpcQueryEmptyStreamError + | null +const liveCache = client.getQueryData(live.queryKey) +true satisfies Assert> +const historyCache = client.getQueryData(accumulated.queryKey) +true satisfies Assert> +useVueQuery< + number | null, + EffectRpcQueryError<'closed' | 'transport'> | EffectRpcQueryEmptyStreamError +>(rpc.users.watch.liveOptions(skipToken)) +useSolidQuery(() => rpc.users.watch.streamedOptions(skipToken)) + +const Decoded = Schema.Finite.pipe( + Schema.decodeTo( + Schema.Finite, + SchemaTransformation.transformEffect({ + decode: (value) => Decoder.pipe(Effect.map(({ factor }) => value * factor)), + encode: Effect.succeed, + }), + ), +) +const api = HttpApi.make('framework').add( + HttpApiGroup.make('values').add( + HttpApiEndpoint.get('read', '/values/:id', { + params: { id: Schema.FiniteFromString }, + success: User, + error: Schema.Literal('http-missing'), + }), + HttpApiEndpoint.get('watch', '/values/watch/:id', { + params: { id: Schema.FiniteFromString }, + success: HttpApiSchema.StreamSse({ data: Decoded, error: Schema.Literal('http-closed') }), + }), + ), +) +declare const httpClient: HttpApiClient.ForApi +declare const httpRunner: RunPromiseExit +const http = createHttpApiQueryUtils(api, { + client: httpClient, + keyPrefix: ['framework', 'http-owner'], + runPromiseExit: httpRunner, + keyEncoders: { + values: { + read: (request) => { + request.params.id satisfies number + return request.params.id + }, + }, + }, +}) +type HttpRunner = CreateHttpApiQueryUtilsOptions< + typeof api, + readonly ['framework'], + typeof httpClient +>['runPromiseExit'] +true satisfies Assert>> +// @ts-expect-error Decoding and client services remain application-owned. +createHttpApiQueryUtils(api, { client: httpClient, keyPrefix: ['missing'] }) +const request = { params: { id: 1 } } +const httpOptions = http.values.read.queryOptions({ + input: request, + initialData: { id: 1, name: 'initial' }, + select: (user) => user.name, +}) +const vueHttp = useVueQuery< + InferDataFromTag, + InferErrorFromTag, + string +>(httpOptions) +const solidHttp = useSolidQuery(() => httpOptions) +true satisfies Assert> +true satisfies Assert> +const metadata = http.values.read.metadataOptions({ + input: request, + select: (view) => view.data.name, +}) +const vueMetadata = useVueQuery< + InferDataFromTag, + InferErrorFromTag, + string +>(metadata) +const solidMetadata = useSolidQuery(() => metadata) +true satisfies Assert> +true satisfies Assert> +const metadataState = client.getQueryState(metadata.queryKey) +type HttpError = NonNullable['error'] +solidMetadata.error satisfies HttpError +vueMetadata.error.value satisfies HttpError +const metadataCache = client.getQueryData(metadata.queryKey) +metadataCache?.data satisfies typeof User.Type | undefined +metadataCache?.status satisfies number | undefined +metadataCache?.headers satisfies Readonly> | undefined +const httpMutation = http.values.read.mutationOptions({ + onSuccess: (value, input) => { + value.name satisfies string + input.params.id satisfies number + }, +}) +useVueMutation(httpMutation).mutate(request) +useSolidMutation(() => httpMutation).mutate(request) +const httpInfinite = http.values.read.infiniteOptions({ + input: (cursor: number) => ({ params: { id: cursor } }), + initialPageParam: 0, + getNextPageParam: (_last, _pages, cursor) => cursor + 1, + select: (value) => value.pages.map((user) => user.name), +}) +useVueInfiniteQuery< + typeof User.Type, + InferErrorFromTag, + string[], + QueryKey, + number +>(httpInfinite).data.value satisfies string[] | undefined +useSolidInfiniteQuery(() => httpInfinite).data satisfies string[] | undefined +const httpHistory = http.values.watch.streamedOptions({ + input: request, + maxChunks: 3, + select: (values) => values.length, + initialData: [1], +}) +const httpLive = http.values.watch.liveOptions({ + input: request, + initialData: 1, + select: (value) => value.toFixed(), +}) +const vueHttpHistory = useVueQuery< + InferDataFromTag, + InferErrorFromTag, + number +>(httpHistory) +const solidHttpHistory = useSolidQuery(() => httpHistory) +const vueHttpLive = useVueQuery< + InferDataFromTag, + InferErrorFromTag, + string +>(httpLive) +const solidHttpLive = useSolidQuery(() => httpLive) +true satisfies Assert> +true satisfies Assert> +true satisfies Assert> +true satisfies Assert> +solidHttpLive.error satisfies + | EffectHttpApiQueryError + | EffectHttpApiQueryEmptyStreamError + | null +useVueQuery>( + http.values.read.queryOptions(skipToken), +) +useSolidQuery(() => http.values.read.metadataOptions(skipToken)) +useVueQuery>( + http.values.watch.streamedOptions(skipToken), +) +useSolidQuery(() => http.values.watch.liveOptions(skipToken)) diff --git a/tests/packed-consumer/vue-solid-runtime.ts b/tests/packed-consumer/vue-solid-runtime.ts new file mode 100644 index 0000000..36035cd --- /dev/null +++ b/tests/packed-consumer/vue-solid-runtime.ts @@ -0,0 +1,242 @@ +import type { InferDataFromTag, InferErrorFromTag } from '@tanstack/query-core' +import { + QueryClient as SolidQueryClient, + QueryClientProvider, + useQuery as useSolidQuery, +} from '@tanstack/solid-query' +import { + QueryClient as VueQueryClient, + VueQueryPlugin, + useQuery as useVueQuery, +} from '@tanstack/vue-query' +import { type EffectRpcQueryError, type EffectRpcQueryEmptyStreamError } from 'effect-api-query' +import { createComponent, createEffect, createSignal } from 'solid-js' +import { render } from 'solid-js/web' +import { computed, createApp, defineComponent, h, ref } from 'vue' + +import { expectEqual, makeFixture, waitFor } from './framework-fixture.ts' + +const runVue = async () => { + const fixture = await makeFixture('vue') + const client = new VueQueryClient({ defaultOptions: { queries: { retry: false } } }) + const id = ref(1) + const enabled = ref(false) + let data: () => unknown = () => undefined + let fetchStatus: () => string = () => 'idle' + let views: () => unknown = () => undefined + const host = document.createElement('div') + document.body.append(host) + const app = createApp( + defineComponent({ + setup() { + const result = useVueQuery< + number, + EffectRpcQueryError | EffectRpcQueryEmptyStreamError + >( + computed(() => + fixture.utils.values.watch.liveOptions({ + input: { id: id.value }, + enabled: () => enabled.value, + }), + ), + ) + const historyOptions = computed(() => + fixture.utils.values.watch.streamedOptions({ + input: { id: id.value + 10 }, + maxChunks: 2, + }), + ) + const history = useVueQuery< + InferDataFromTag, + InferErrorFromTag + >(historyOptions) + const liveOptions = computed(() => + fixture.http.values.watch.liveOptions({ input: { params: { id: id.value + 100 } } }), + ) + const httpLive = useVueQuery< + InferDataFromTag, + InferErrorFromTag + >(liveOptions) + const httpOptions = computed(() => + fixture.http.values.watch.streamedOptions({ + input: { params: { id: id.value + 110 } }, + maxChunks: 2, + }), + ) + const httpHistory = useVueQuery< + InferDataFromTag, + InferErrorFromTag + >(httpOptions) + const metaOptions = computed(() => + fixture.http.values.read.metadataOptions({ + input: { params: { id: id.value + 100 } }, + select: (value) => value.data, + }), + ) + const metadata = useVueQuery< + InferDataFromTag, + InferErrorFromTag, + number + >(metaOptions) + data = () => result.data.value + fetchStatus = () => result.fetchStatus.value + views = () => [ + history.data.value, + httpLive.data.value, + httpHistory.data.value, + metadata.data.value, + ] + return () => h('output', JSON.stringify([result.data.value, views()])) + }, + }), + ).use(VueQueryPlugin, { queryClient: client }) + try { + app.mount(host) + expectEqual(data(), undefined, 'Vue keeps a disabled stream empty') + expectEqual(fetchStatus(), 'idle', 'Vue does not fetch its disabled stream') + enabled.value = true + await waitFor(() => data() === 1, 'Vue did not expose the first open-stream value') + await waitFor( + () => JSON.stringify(views()) === '[[11],101,[111],101]', + 'Vue did not expose every first stream view', + ) + expectEqual(fetchStatus(), 'fetching', 'Vue first value remains open') + expectEqual(client.isFetching(), 4, 'Vue stream views remain open') + id.value = 2 + await waitFor(() => data() === 2, 'Vue did not rebuild options after input changed') + await waitFor(() => fixture.finalized.includes(1), 'Vue did not finalize the replaced stream') + await waitFor( + () => JSON.stringify(views()) === '[[12],102,[112],102]', + 'Vue views did not follow the changed input', + ) + expectEqual(host.textContent, '[2,[[12],102,[112],102]]', 'Vue rendered the changed views') + app.unmount() + await waitFor(() => fixture.finalized.includes(2), 'Vue did not finalize its unmounted stream') + await waitFor( + () => fixture.finalized.length === 4 && fixture.httpFinalized.length === 4, + 'Vue did not finalize every owned stream', + ) + expectEqual( + [...fixture.finalized].sort((a, b) => a - b), + [1, 2, 11, 12], + 'Vue owns all RPC streams', + ) + expectEqual( + [...fixture.httpFinalized].sort((a, b) => a - b), + [101, 102, 111, 112], + 'Vue owns all HTTP streams', + ) + } finally { + app.unmount() + await client.cancelQueries() + client.clear() + await fixture.close() + host.remove() + } +} + +const runSolid = async () => { + const fixture = await makeFixture('solid') + const client = new SolidQueryClient({ defaultOptions: { queries: { retry: false } } }) + const [id, setId] = createSignal(1) + let data: () => unknown = () => undefined + let fetchStatus: () => string = () => 'idle' + let views: () => unknown = () => undefined + const host = document.createElement('div') + document.body.append(host) + const Component = () => { + const result = useSolidQuery(() => + fixture.utils.values.watch.liveOptions({ input: { id: id() } }), + ) + const history = useSolidQuery(() => + fixture.utils.values.watch.streamedOptions({ input: { id: id() + 10 }, maxChunks: 2 }), + ) + const httpLive = useSolidQuery(() => + fixture.http.values.watch.liveOptions({ input: { params: { id: id() + 100 } } }), + ) + const httpHistory = useSolidQuery(() => + fixture.http.values.watch.streamedOptions({ + input: { params: { id: id() + 110 } }, + maxChunks: 2, + }), + ) + const metadata = useSolidQuery(() => + fixture.http.values.read.metadataOptions({ + input: { params: { id: id() + 100 } }, + select: (value) => value.data, + }), + ) + data = () => result.data + fetchStatus = () => result.fetchStatus + views = () => [history.data, httpLive.data, httpHistory.data, metadata.data] + const output = document.createElement('output') + createEffect(() => { + output.textContent = JSON.stringify([result.data, views()]) + }) + return output + } + const dispose = render( + () => + createComponent(QueryClientProvider, { + client, + get children() { + return createComponent(Component, {}) + }, + }), + host, + ) + try { + await waitFor(() => data() === 1, 'Solid did not expose the first open-stream value') + await waitFor( + () => JSON.stringify(views()) === '[[11],101,[111],101]', + 'Solid did not expose every first stream view', + ) + expectEqual(fetchStatus(), 'fetching', 'Solid first value remains open') + expectEqual(client.isFetching(), 4, 'Solid stream views remain open') + setId(2) + await waitFor(() => data() === 2, 'Solid did not rebuild options after input changed') + await waitFor(() => fixture.finalized.includes(1), 'Solid did not finalize the replaced stream') + await waitFor( + () => JSON.stringify(views()) === '[[12],102,[112],102]', + 'Solid views did not follow the changed input', + ) + expectEqual(host.textContent, '[2,[[12],102,[112],102]]', 'Solid rendered the changed views') + dispose() + await waitFor( + () => fixture.finalized.includes(2), + 'Solid did not finalize its unmounted stream', + ) + await waitFor( + () => fixture.finalized.length === 4 && fixture.httpFinalized.length === 4, + 'Solid did not finalize every owned stream', + ) + expectEqual( + [...fixture.finalized].sort((a, b) => a - b), + [1, 2, 11, 12], + 'Solid owns all RPC streams', + ) + expectEqual( + [...fixture.httpFinalized].sort((a, b) => a - b), + [101, 102, 111, 112], + 'Solid owns all HTTP streams', + ) + } finally { + dispose() + await client.cancelQueries() + client.clear() + await fixture.close() + host.remove() + } +} + +void runVue() + .then(runSolid) + .then( + () => { + document.documentElement.dataset['frameworkStatus'] = 'passed' + }, + (error: unknown) => { + document.documentElement.dataset['frameworkStatus'] = 'failed' + document.documentElement.dataset['frameworkMessage'] = String(error) + }, + ) diff --git a/tests/types/public-contract.ts b/tests/types/public-contract.ts index e382371..de190da 100644 --- a/tests/types/public-contract.ts +++ b/tests/types/public-contract.ts @@ -738,6 +738,39 @@ const validPageOptions = { initialPageParam: 0, input: (cursor: number) => ({ cursor }), } +const instantiatedPages = buildPages({ + ...validPageOptions, + enabled: (query) => query.state.data?.pages.length !== 0, + getPreviousPageParam: (_page, _pages, cursor) => cursor - 1, + networkMode: 'offlineFirst', +}) +instantiatedPages.enabled +instantiatedPages.getPreviousPageParam +instantiatedPages.networkMode +const explicitUnary = utils.users.get.queryOptions({ + input: { id: 1 }, + enabled: (query) => query.state.data?.id !== 0, + select: (user) => user.name, + networkMode: 'offlineFirst', +}) +explicitUnary.enabled +explicitUnary.networkMode +true satisfies Assert< + Equal< + ReturnType, + Rpc.Success | Promise> + > +> +useQuery(explicitUnary).data satisfies string | undefined +const explicitInfinite = utils.users.pages.infiniteOptions({ + ...validPageOptions, + initialData: initialInfiniteData, + select: (data) => data.pages.flatMap((page) => page.users.map((user) => user.name)), + enabled: (query) => query.state.data?.pages.length !== 0, +}) +explicitInfinite.enabled +const explicitInfiniteHook = useInfiniteQuery(explicitInfinite) +true satisfies Assert> // @ts-expect-error infinite input mappers must return the RPC payload constructor input buildPages({ ...validPageOptions, input: () => ({ cursor: 'invalid' }) }) // @ts-expect-error the package owns the infinite-query function @@ -1845,18 +1878,8 @@ const broadUnknownOptions = broadPayloadUtils.payload.unknown.queryOptions({ input: broadUnknownInput, }) const broadAnyOptions = broadPayloadUtils.payload.any.queryOptions({ input: broadAnyInput }) -true satisfies Assert< - Equal< - typeof broadUnknownOptions.queryFn, - QueryFunction | SkipToken - > -> -true satisfies Assert< - Equal< - typeof broadAnyOptions.queryFn, - QueryFunction | SkipToken - > -> +true satisfies Assert | SkipToken>> +true satisfies Assert | SkipToken>> broadPayloadUtils.payload.void.queryKey() satisfies readonly [ 'payload', From 807816343bab232918dcb435771aae285c38bc0e Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Sat, 10 Oct 2026 12:54:19 +0200 Subject: [PATCH 51/97] Certify Svelte and Angular generated query consumption --- .fallowrc.jsonc | 4 + apps/docs/astro.config.mjs | 1 + .../content/docs/guides/svelte-and-angular.md | 118 +++++++ .../reference/compatibility-and-limits.md | 5 + scripts/verify-docs-examples.mts | 1 + scripts/verify-packed-consumer.mts | 30 +- scripts/verify-svelte-angular-consumer.mts | 92 +++++ tests/packed-consumer/docs-angular.ts | 33 ++ tests/packed-consumer/framework-fixture.ts | 112 +++++++ .../svelte-angular-contract.ts | 314 ++++++++++++++++++ .../packed-consumer/svelte-angular-runtime.ts | 193 +++++++++++ tests/packed-consumer/svelte-consumer.svelte | 50 +++ .../tsconfig.svelte-angular.json | 16 + 13 files changed, 968 insertions(+), 1 deletion(-) create mode 100644 apps/docs/src/content/docs/guides/svelte-and-angular.md create mode 100644 scripts/verify-svelte-angular-consumer.mts create mode 100644 tests/packed-consumer/docs-angular.ts create mode 100644 tests/packed-consumer/framework-fixture.ts create mode 100644 tests/packed-consumer/svelte-angular-contract.ts create mode 100644 tests/packed-consumer/svelte-angular-runtime.ts create mode 100644 tests/packed-consumer/svelte-consumer.svelte create mode 100644 tests/packed-consumer/tsconfig.svelte-angular.json diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index d249e01..009e5ec 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -27,6 +27,10 @@ "tests/packed-consumer/snapshot-contract.ts", "tests/packed-consumer/defaults-runtime.mts", "tests/packed-consumer/defaults-contract.ts", + "scripts/verify-svelte-angular-consumer.mts", + "tests/packed-consumer/svelte-angular-runtime.ts", + "tests/packed-consumer/svelte-angular-contract.ts", + "tests/packed-consumer/docs-angular.ts", "tests/packed-consumer/hydration-runtime.mts", "tests/packed-consumer/hydration-views-runtime.mts", "tests/packed-consumer/hydration-contract.ts" diff --git a/apps/docs/astro.config.mjs b/apps/docs/astro.config.mjs index 61ed051..24a4bb6 100644 --- a/apps/docs/astro.config.mjs +++ b/apps/docs/astro.config.mjs @@ -66,6 +66,7 @@ export default defineConfig({ label: 'Guides', items: [ { label: 'React Query', slug: 'guides/react-query' }, + { label: 'Svelte and Angular', slug: 'guides/svelte-and-angular' }, { label: 'HTTP Queries and Mutations', slug: 'guides/http-queries-and-mutations' }, { label: 'TanStack Start', slug: 'guides/tanstack-start' }, { label: 'Stream Snapshots', slug: 'guides/stream-snapshots' }, diff --git a/apps/docs/src/content/docs/guides/svelte-and-angular.md b/apps/docs/src/content/docs/guides/svelte-and-angular.md new file mode 100644 index 0000000..932b169 --- /dev/null +++ b/apps/docs/src/content/docs/guides/svelte-and-angular.md @@ -0,0 +1,118 @@ +--- +title: Svelte and Angular +description: Rebuild generated options when reactive inputs change and keep client lifetime in the application. +--- + +Pass generated options through the framework's reactive accessor. Each builder captures its +plain input when called, so create the options inside that accessor whenever the input can change. +The same rule applies to RPC and HTTP queries, metadata views, accumulated streams, and live queries. + +## Svelte + +Create the utility tree in your application module from an acquired ready client. Provide a +QueryClient through Svelte Query's `QueryClientProvider`, then read reactive inputs inside the +accessor passed to `createQuery`: + +```svelte + + + +

{user.data}

+``` + +Use `createInfiniteQuery(() => rpc.users.list.infiniteOptions(...))` for pagination and +`createMutation(() => rpc.users.write.mutationOptions(...))` for writes. A mutation's variables +provide the input for each execution. Mutation options can be static when their callbacks and +other settings do not depend on reactive state. + +Compile the Svelte application with `module: "ESNext"` and `moduleResolution: "Bundler"`. +The supported Svelte Query releases require this resolution for their emitted `.svelte` +declarations; strict NodeNext declaration checking reports an upstream `Box` import error. + +## Angular + +Register `provideTanStackQuery(queryClient)` in your application providers. Construct queries +inside an Angular injection context and read the signal inside the accessor passed to `injectQuery`. +The ready client below is acquired and disposed by the application: + +```ts +import { Component, signal } from '@angular/core' +import { injectQuery } from '@tanstack/angular-query-experimental' +import { Schema } from 'effect' +import { createRpcQueryUtils } from 'effect-api-query' +import { Rpc, RpcGroup, type RpcClient } from 'effect/rpc' + +const group = RpcGroup.make( + Rpc.make('users.read', { + payload: { id: Schema.Int }, + success: Schema.Struct({ id: Schema.Int, name: Schema.String }), + }), +) +declare const client: RpcClient.RpcClient.Flat> +const rpc = createRpcQueryUtils(group, { client, keyPrefix: ['current-owner'] }) + +@Component({ + selector: 'user-details', + standalone: true, + template: '

{{ user.data() }}

', +}) +export class UserDetails { + readonly id = signal(1) + readonly user = injectQuery(() => + rpc.users.read.queryOptions({ + input: { id: this.id() }, + select: (user) => user.name, + }), + ) + + next() { + this.id.update((id) => id + 1) + } +} +``` + +Use `injectInfiniteQuery` and `injectMutation` with the corresponding builders. Angular query +results expose signals such as `data()`, `error()`, and `fetchStatus()`. + +Angular's defined-data overload accepts a query function rather than `skipToken`. A skipped query +therefore retains `undefined` in its `data()` type even when it has defined `initialData`. Svelte +preserves the defined-data type for that combination. Both frameworks accept initial-data functions +that may return `undefined`, with optional result data. + +## Own the lifecycle + +Changing a generated query's key replaces the observer's active query. Unmounting a Svelte +component or destroying an Angular component removes its observers; the last observer of an +abort-aware open stream triggers cancellation. Keep the ready client and runtime alive until +local stream finalizers finish. Other observers of the same query retain their subscription. + +For an owner change, unmount the old tree, cancel and clear its QueryClient, await local cleanup, +then dispose its client resources. Mount the new tree with independently owned resources and a +safe key prefix. See [Switch Cache Owners](/effect-api-query/guides/switch-cache-owners/) and +[Client Lifecycle](/effect-api-query/concepts/client-lifecycle/). + +## Version boundaries + +| Framework | Framework release | Query integration | Matching Query Core | +| --------- | ----------------- | ---------------------------------- | ------------------- | +| Svelte | 5.57.1 | Svelte Query 6.2.1 | 5.103.1 | +| Svelte | 5.57.1 | Svelte Query 6.3.0 | 5.104.0 | +| Angular | 22.2.1 | Angular Query experimental 5.103.1 | 5.103.1 | +| Angular | 22.2.1 | Angular Query experimental 5.104.0 | 5.104.0 | + +Angular core, common, compiler, and platform-browser use the same release, with RxJS 7.8.2. +The published option contract supports TypeScript 5.9.3 and 7.0.2 with strict checking; choose a +compiler release supported by your framework's build tooling. Framework query wrappers +must share the application's Query Core version. This scope covers browser components; framework +server rendering and hydration need their own application integration. diff --git a/apps/docs/src/content/docs/reference/compatibility-and-limits.md b/apps/docs/src/content/docs/reference/compatibility-and-limits.md index b93bd13..db9e92f 100644 --- a/apps/docs/src/content/docs/reference/compatibility-and-limits.md +++ b/apps/docs/src/content/docs/reference/compatibility-and-limits.md @@ -9,6 +9,10 @@ The package targets Effect 4 and TanStack Query Core 5.103.1 or later in v5, wit integrations checked in this repository. It is ESM-only and targets ES2022. Use TypeScript with `strict: true`. +Svelte 5.57.1 and Angular 22.2.1 browser components can consume generated Core options through +their native accessors. See [Svelte and Angular](/effect-api-query/guides/svelte-and-angular/) for +matching Query wrapper versions, compiler resolution, and reactive ownership boundaries. + Consult [package metadata](https://github.com/ueberBrot/effect-api-query/blob/main/package.json) for peer ranges and the [workspace catalog](https://github.com/ueberBrot/effect-api-query/blob/main/pnpm-workspace.yaml) for the pinned Effect release and framework versions. The @@ -43,6 +47,7 @@ supplies the client, policy, or lifecycle. **Deferred** means the adapter does n | Mutation cancellation | No upstream TanStack mutation abort signal; explicit cancellable command is application-owned and tested | No upstream TanStack mutation abort signal; domain cancellation is application-owned | | Authentication, middleware, and residual services | Application-owned ready client and runner | Application-owned ready client and runner | | React hooks and QueryClient | Tested public consumers and Vite React example | Tested public consumers and Vite React example | +| Svelte and Angular browser components | Tested native reactive accessors for generated queries and streams | Tested native reactive accessors for buffered, metadata, and SSE views | | SSR and hydration | Application-owned; tested Start route loading and stream snapshots | Application-owned; tested Start SSR, hydration, failed-query refetch, and request isolation | | Host routes | Application-owned; tested standalone server and Start `/rpc` | Application-owned; tested standalone server and Start `/api/$` | | Cache serialization and mutation invalidation | Application-owned | Application-owned | diff --git a/scripts/verify-docs-examples.mts b/scripts/verify-docs-examples.mts index 07cbba7..a8f999c 100644 --- a/scripts/verify-docs-examples.mts +++ b/scripts/verify-docs-examples.mts @@ -7,6 +7,7 @@ for (const [page, fixtures] of [ ['getting-started/quick-start', ['types/docs-rpc-quick-start']], ['getting-started/http-quick-start', ['types/docs-http-quick-start']], ['guides/retry-queries', ['packed-consumer/docs-retry']], + ['guides/svelte-and-angular', ['packed-consumer/docs-angular']], ['guides/switch-cache-owners', ['packed-consumer/docs-owner-cache']], ['guides/refresh-from-events', ['packed-consumer/docs-user-events']], ['guides/stream-snapshots', ['packed-consumer/docs-stream-snapshot']], diff --git a/scripts/verify-packed-consumer.mts b/scripts/verify-packed-consumer.mts index e9d0c48..66462e6 100644 --- a/scripts/verify-packed-consumer.mts +++ b/scripts/verify-packed-consumer.mts @@ -385,7 +385,11 @@ const peerCases = queryCoreCompatibilityCases() const runTypeScript = ( consumerDirectory: string, compiler: (typeof compilerCases)[number], - project: 'tsconfig.json' | 'tsconfig.tanstack-start.json' | 'tsconfig.type-scale.json', + project: + | 'tsconfig.json' + | 'tsconfig.tanstack-start.json' + | 'tsconfig.type-scale.json' + | 'tsconfig.svelte-angular.json', extendedDiagnostics: boolean, ): void => { console.log(`Verifying ${project} with ${compiler.label}`) @@ -560,17 +564,30 @@ const verifyConsumer = (peer: (typeof peerCases)[number]): void => { nodePath.join(consumerDirectory, 'owner-cache.ts'), ) + const svelteQueryVersion = new Map([ + ['5.103.1', '6.2.1'], + ['5.104.0', '6.3.0'], + ]).get(peer.queryCoreVersion) + if (svelteQueryVersion === undefined) { + throw new Error(`No Svelte Query compatibility case for Query Core ${peer.queryCoreVersion}`) + } const consumerManifest = { name: `effect-api-query-packed-consumer-${peer.label}`, private: true, type: 'module', intent: { skills: ['effect-api-query'] }, dependencies: { + '@angular/common': '22.2.1', + '@angular/compiler': '22.2.1', + '@angular/core': '22.2.1', + '@angular/platform-browser': '22.2.1', + '@tanstack/angular-query-experimental': peer.queryCoreVersion, '@tanstack/query-core': peer.queryCoreVersion, '@tanstack/react-query': peer.reactQueryVersion, '@tanstack/react-router': testedVersion('@tanstack/react-router'), '@tanstack/react-router-ssr-query': testedVersion('@tanstack/react-router-ssr-query'), '@tanstack/react-start': testedVersion('@tanstack/react-start'), + '@tanstack/svelte-query': svelteQueryVersion, '@types/node': testedVersion('@types/node'), '@types/react': testedVersion('@types/react'), '@types/react-dom': testedVersion('@types/react-dom'), @@ -578,6 +595,8 @@ const verifyConsumer = (peer: (typeof peerCases)[number]): void => { 'effect-api-query': `file:${tarballPath}`, react: testedVersion('react'), 'react-dom': testedVersion('react-dom'), + rxjs: '7.8.2', + svelte: '5.57.1', }, } writeFileSync( @@ -635,6 +654,7 @@ const verifyConsumer = (peer: (typeof peerCases)[number]): void => { for (const compiler of compilerCases) { runTypeScript(consumerDirectory, compiler, 'tsconfig.json', false) runTypeScript(consumerDirectory, compiler, 'tsconfig.tanstack-start.json', false) + runTypeScript(consumerDirectory, compiler, 'tsconfig.svelte-angular.json', false) runTypeScript( consumerDirectory, compiler, @@ -670,6 +690,14 @@ const verifyConsumer = (peer: (typeof peerCases)[number]): void => { stdio: 'inherit', }) } + execFileSync( + process.execPath, + [ + nodePath.join(repositoryRoot, 'scripts', 'verify-svelte-angular-consumer.mts'), + consumerDirectory, + ], + { cwd: repositoryRoot, stdio: 'inherit' }, + ) } finally { rmSync(consumerDirectory, { force: true, recursive: true }) } diff --git a/scripts/verify-svelte-angular-consumer.mts b/scripts/verify-svelte-angular-consumer.mts new file mode 100644 index 0000000..1eb2872 --- /dev/null +++ b/scripts/verify-svelte-angular-consumer.mts @@ -0,0 +1,92 @@ +import { chromium } from '@playwright/test' +import { Predicate, Schema } from 'effect' +import { strictEqual } from 'node:assert' +import { writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import nodePath from 'node:path' +import { createServer } from 'vite' + +const consumerDirectory = process.argv.at(2) +if (consumerDirectory === undefined) { + throw new Error('A consumer directory is required') +} +const require = createRequire(nodePath.join(consumerDirectory, 'package.json')) +interface CompilerOptions { + filename: string + generate: 'client' +} +interface CompilerOutput { + js: { code: string } +} +interface SvelteCompiler { + compile: (source: string, options: CompilerOptions) => CompilerOutput + compileModule: (source: string, options: CompilerOptions) => CompilerOutput +} +const compiler = Schema.decodeUnknownSync( + Schema.declare( + (value): value is SvelteCompiler => + Predicate.hasProperty(value, 'compile') && + Predicate.isFunction(value.compile) && + Predicate.hasProperty(value, 'compileModule') && + Predicate.isFunction(value.compileModule), + ), +)(require('svelte/compiler')) +const output = Schema.decodeUnknownSync( + Schema.Struct({ js: Schema.Struct({ code: Schema.String }) }), +) +writeFileSync( + nodePath.join(consumerDirectory, 'index.html'), + '', +) +const server = await createServer({ + configFile: false, + root: consumerDirectory, + plugins: [ + { + name: 'consumer-svelte', + enforce: 'pre', + transform(source, id) { + const [filename] = id.split('?') + if (filename?.endsWith('.svelte') === true) { + return output(compiler.compile(source, { filename, generate: 'client' })).js.code + } + if (filename?.endsWith('.svelte.js') === true) { + return output(compiler.compileModule(source, { filename, generate: 'client' })).js.code + } + return null + }, + }, + ], + server: { host: '127.0.0.1', port: 0 }, + optimizeDeps: { noDiscovery: true }, +}) +try { + await server.listen() + const address = server.httpServer?.address() + if (address === undefined || address === null || Predicate.isString(address)) { + throw new Error('The browser consumer server has no TCP address') + } + const browser = await chromium.launch() + try { + const page = await browser.newPage() + const errors: string[] = [] + page.on('pageerror', (error) => errors.push(error.message)) + await page.goto(`http://127.0.0.1:${address.port}`) + await page.waitForFunction( + () => document.documentElement.dataset['frameworkStatus'] !== undefined, + undefined, + { timeout: 30_000 }, + ) + const result = await page.evaluate(() => ({ + status: document.documentElement.dataset['frameworkStatus'], + message: document.documentElement.dataset['frameworkMessage'], + })) + strictEqual(result.status, 'passed', result.message ?? errors.join('\n')) + strictEqual(errors.length, 0, errors.join('\n')) + console.log('Svelte and Angular reactive browser consumers passed') + } finally { + await browser.close() + } +} finally { + await server.close() +} diff --git a/tests/packed-consumer/docs-angular.ts b/tests/packed-consumer/docs-angular.ts new file mode 100644 index 0000000..ca54c42 --- /dev/null +++ b/tests/packed-consumer/docs-angular.ts @@ -0,0 +1,33 @@ +import { Component, signal } from '@angular/core' +import { injectQuery } from '@tanstack/angular-query-experimental' +import { Schema } from 'effect' +import { createRpcQueryUtils } from 'effect-api-query' +import { Rpc, RpcGroup, type RpcClient } from 'effect/rpc' + +const group = RpcGroup.make( + Rpc.make('users.read', { + payload: { id: Schema.Int }, + success: Schema.Struct({ id: Schema.Int, name: Schema.String }), + }), +) +declare const client: RpcClient.RpcClient.Flat> +const rpc = createRpcQueryUtils(group, { client, keyPrefix: ['current-owner'] }) + +@Component({ + selector: 'user-details', + standalone: true, + template: '

{{ user.data() }}

', +}) +export class UserDetails { + readonly id = signal(1) + readonly user = injectQuery(() => + rpc.users.read.queryOptions({ + input: { id: this.id() }, + select: (user) => user.name, + }), + ) + + next() { + this.id.update((id) => id + 1) + } +} diff --git a/tests/packed-consumer/framework-fixture.ts b/tests/packed-consumer/framework-fixture.ts new file mode 100644 index 0000000..eef07fc --- /dev/null +++ b/tests/packed-consumer/framework-fixture.ts @@ -0,0 +1,112 @@ +import { Effect, Exit, Schema, Scope, Stream } from 'effect' +import { createRpcQueryUtils, createHttpApiQueryUtils } from 'effect-api-query' +import { HttpServer } from 'effect/http' +import { + HttpApi, + HttpApiEndpoint, + HttpApiGroup, + HttpApiSchema, + HttpApiBuilder, + HttpApiTest, +} from 'effect/http-api' +import { Rpc, RpcGroup, RpcTest } from 'effect/rpc' + +export const expectEqual = (actual: unknown, expected: unknown, message: string) => { + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error( + `${message}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`, + ) + } +} +export const waitFor = (check: () => boolean, message: string) => + new Promise((resolve, reject) => { + const deadline = performance.now() + 10_000 + const checkFrame = () => { + if (check()) resolve() + else if (performance.now() >= deadline) reject(new Error(message)) + else requestAnimationFrame(checkFrame) + } + checkFrame() + }) +const Watch = Rpc.make('values.watch', { + payload: { id: Schema.Int }, + success: Schema.Int, + stream: true, +}) +const group = RpcGroup.make(Watch) +const api = HttpApi.make('framework-runtime').add( + HttpApiGroup.make('values').add( + HttpApiEndpoint.get('watch', '/watch/:id', { + params: { id: Schema.FiniteFromString }, + success: HttpApiSchema.StreamSse({ data: Schema.Int }), + }), + HttpApiEndpoint.get('read', '/read/:id', { + params: { id: Schema.FiniteFromString }, + success: Schema.Int.pipe(HttpApiSchema.status(203)), + }), + ), +) +export const makeFixture = async (name: string) => { + const scope = Scope.makeUnsafe() + const finalized: number[] = [] + const client = await Effect.runPromise( + RpcTest.makeClient(group, { flatten: true }).pipe( + Effect.provide( + group.toLayer({ + 'values.watch': ({ id }) => + Stream.unwrap( + Effect.acquireRelease( + Effect.succeed( + Stream.succeed(id).pipe(Stream.concat(Stream.fromEffect(Effect.never))), + ), + () => + Effect.sync(() => { + finalized.push(id) + }), + ), + ), + }), + ), + Scope.provide(scope), + ), + ) + const utils = createRpcQueryUtils(group, { client, keyPrefix: [name] }) + const httpFinalized: number[] = [] + const httpClient = await Effect.runPromise( + HttpApiTest.groups(api, ['values']).pipe( + Effect.provide( + HttpApiBuilder.group(api, 'values', (handlers) => + handlers + .handle('read', ({ params }) => Effect.succeed(params.id)) + .handle('watch', ({ params }) => + Effect.succeed( + Stream.unwrap( + Effect.acquireRelease( + Effect.succeed( + Stream.succeed(params.id).pipe( + Stream.concat(Stream.fromEffect(Effect.never)), + ), + ), + () => + Effect.sync(() => { + httpFinalized.push(params.id) + }), + ), + ), + ), + ), + ), + ), + Effect.provide(HttpServer.layerServices), + Scope.provide(scope), + ), + ) + const http = createHttpApiQueryUtils(api, { client: httpClient, keyPrefix: [name] }) + return { + utils, + http, + finalized, + httpFinalized, + close: () => Effect.runPromise(Scope.close(scope, Exit.void)), + } +} diff --git a/tests/packed-consumer/svelte-angular-contract.ts b/tests/packed-consumer/svelte-angular-contract.ts new file mode 100644 index 0000000..55b0b26 --- /dev/null +++ b/tests/packed-consumer/svelte-angular-contract.ts @@ -0,0 +1,314 @@ +import { signal } from '@angular/core' +import { + injectInfiniteQuery, + injectMutation, + injectQuery, +} from '@tanstack/angular-query-experimental' +import { QueryClient, type InfiniteData } from '@tanstack/query-core' +import { createInfiniteQuery, createMutation, createQuery } from '@tanstack/svelte-query' +import { Context, Effect, Schema } from 'effect' +import { + createHttpApiQueryUtils, + createRpcQueryUtils, + skipToken, + type EffectHttpApiQueryEmptyStreamError, + type EffectHttpApiQueryError, + type EffectRpcQueryEmptyStreamError, + type EffectRpcQueryError, + type RunPromiseExit, +} from 'effect-api-query' +import type { HttpClientError } from 'effect/http' +import { + HttpApi, + HttpApiEndpoint, + HttpApiGroup, + HttpApiSchema, + type HttpApiClient, +} from 'effect/http-api' +import { Rpc, RpcGroup, type RpcClient } from 'effect/rpc' + +type Equal = + (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false +type Assert = T + +class Encoding extends Context.Service()( + 'Framework/Encoding', +) {} +const Payload = Schema.Struct({ id: Schema.Int }) +const ServicefulPayload = Payload.pipe( + Schema.middlewareEncoding((encode) => + Effect.flatMap(Encoding, () => encode), + ), +) +const User = Schema.Struct({ id: Schema.Int, name: Schema.String }) +const Read = Rpc.make('users.read', { + payload: ServicefulPayload, + success: User, + error: Schema.Literal('missing'), +}) +const Watch = Rpc.make('users.watch', { + payload: { id: Schema.Int }, + success: Schema.UndefinedOr(Schema.Int), + error: Schema.Literal('closed'), + stream: true, +}) +const group = RpcGroup.make(Read, Watch) +declare const readyRpc: RpcClient.RpcClient.Flat, 'transport'> +declare const runner: RunPromiseExit +const rpc = createRpcQueryUtils(group, { + client: readyRpc, + keyPrefix: ['framework', 'rpc-owner'], + runPromiseExit: runner, + keyEncoders: { 'users.read': (input) => input.id }, +}) +// @ts-expect-error The encoding service remains a runner requirement. +createRpcQueryUtils(group, { + client: readyRpc, + keyPrefix: ['missing'], + keyEncoders: { 'users.read': (input) => input.id }, +}) +const client = new QueryClient() +const selected = rpc.users.read.queryOptions({ + input: { id: 1 }, + initialData: { id: 1, name: 'initial' }, + select: (user) => user.name, + retry: (attempt, error) => { + error satisfies EffectRpcQueryError<'missing' | 'transport'> + return attempt < 2 + }, + enabled: (query) => query.state.data?.id !== 0, +}) +const svelteSelected = createQuery(() => selected) +const angularSelected = injectQuery(() => selected) +true satisfies Assert> +true satisfies Assert, string>> +true satisfies Assert< + Equal | null> +> +true satisfies Assert< + Equal< + ReturnType, + EffectRpcQueryError<'missing' | 'transport'> | null + > +> +const cached = client.getQueryData(selected.queryKey) +true satisfies Assert> +const id = signal(1) +const svelteReactive = createQuery(() => + rpc.users.read.queryOptions({ input: { id: id() }, select: (user) => user.name }), +) +const angularReactive = injectQuery(() => + rpc.users.read.queryOptions({ input: { id: id() }, select: (user) => user.name }), +) +true satisfies Assert> +true satisfies Assert, string | undefined>> +const skipped = rpc.users.read.queryOptions({ + input: skipToken, + initialData: { id: 1, name: 'initial' }, +}) +skipped.queryFn satisfies typeof skipToken +const svelteSkipped = createQuery(() => skipped) +true satisfies Assert> +const angularSkipped = injectQuery(() => skipped) +true satisfies Assert, typeof User.Type | undefined>> +const nativeAngularSkippedOptions: { + queryKey: readonly ['native-skipped'] + queryFn: typeof skipToken + initialData: typeof User.Type +} = { + queryKey: ['native-skipped'], + queryFn: skipToken, + initialData: { id: 1, name: 'initial' }, +} +const nativeAngularSkipped = injectQuery(() => nativeAngularSkippedOptions) +true satisfies Assert< + Equal, typeof User.Type | undefined> +> +const explicitlyUndefined = rpc.users.read.queryOptions({ + input: { id: 1 }, + initialData: undefined, +}) +const possiblyUndefined = rpc.users.read.queryOptions({ + input: { id: 1 }, + initialData: (): typeof User.Type | undefined => client.getQueryData(selected.queryKey), +}) +const svelteUndefined = createQuery(() => explicitlyUndefined) +const angularUndefined = injectQuery(() => explicitlyUndefined) +const sveltePossible = createQuery(() => possiblyUndefined) +const angularPossible = injectQuery(() => possiblyUndefined) +true satisfies Assert> +true satisfies Assert, typeof User.Type | undefined>> +true satisfies Assert> +true satisfies Assert, typeof User.Type | undefined>> +// @ts-expect-error Svelte expects an accessor around generated plain options. +createQuery(selected) +// @ts-expect-error Angular expects an accessor around generated plain options. +injectQuery(selected) +const conditional = rpc.users.read.queryOptions({ + input: Math.random() > 0.5 ? { id: 1 } : skipToken, +}) +createQuery(() => conditional) +injectQuery(() => conditional) +const mutation = rpc.users.read.mutationOptions({ + onMutate: (input) => ({ original: input.id }), + onSuccess: (user, input, transaction) => { + user.name satisfies string + input.id satisfies number + transaction?.original satisfies number | undefined + }, +}) +const svelteMutation = createMutation(() => mutation) +const angularMutation = injectMutation(() => mutation) +svelteMutation.mutate({ id: 1 }) +angularMutation.mutate({ id: 1 }) +true satisfies Assert> +true satisfies Assert, typeof User.Type | undefined>> +// @ts-expect-error Mutation variables retain the decoded input. +svelteMutation.mutate({ id: 'wrong' }) +// @ts-expect-error Mutation variables retain the decoded input. +angularMutation.mutate({ id: 'wrong' }) +const infinite = rpc.users.read.infiniteOptions({ + input: (cursor: number) => ({ id: cursor }), + initialPageParam: 0, + getNextPageParam: (last, _pages, cursor) => { + last.name satisfies string + cursor satisfies number + return cursor + 1 + }, + initialData: { pages: [{ id: 0, name: 'zero' }], pageParams: [0] }, + select: (value) => value.pages.map((user) => user.name), +}) +const svelteInfinite = createInfiniteQuery(() => infinite) +const angularInfinite = injectInfiniteQuery(() => infinite) +true satisfies Assert> +true satisfies Assert, string[]>> +const infiniteCache = client.getQueryData(infinite.queryKey) +true satisfies Assert< + Equal | undefined> +> +const history = rpc.users.watch.streamedOptions({ + input: { id: 1 }, + maxChunks: 4, + initialData: [1], + select: (values) => values.length, +}) +const live = rpc.users.watch.liveOptions({ + input: { id: 1 }, + initialData: null, + select: (value) => (value === null ? 'empty' : value.toFixed()), +}) +const svelteHistory = createQuery(() => history) +const angularHistory = injectQuery(() => history) +const svelteLive = createQuery(() => live) +const angularLive = injectQuery(() => live) +true satisfies Assert> +true satisfies Assert, number>> +true satisfies Assert> +true satisfies Assert, string>> +svelteLive.error satisfies + | EffectRpcQueryError<'closed' | 'transport'> + | EffectRpcQueryEmptyStreamError + | null +angularLive.error() satisfies + | EffectRpcQueryError<'closed' | 'transport'> + | EffectRpcQueryEmptyStreamError + | null +const historyCache = client.getQueryData(history.queryKey) +const liveCache = client.getQueryData(live.queryKey) +true satisfies Assert> +true satisfies Assert> +createQuery(() => rpc.users.watch.streamedOptions(skipToken)) +injectQuery(() => rpc.users.watch.liveOptions(skipToken)) + +const api = HttpApi.make('framework').add( + HttpApiGroup.make('values').add( + HttpApiEndpoint.get('read', '/values/:id', { + params: { id: Schema.FiniteFromString }, + success: User, + error: Schema.Literal('http-missing'), + }), + HttpApiEndpoint.get('watch', '/values/watch/:id', { + params: { id: Schema.FiniteFromString }, + success: HttpApiSchema.StreamSse({ data: Schema.Int, error: Schema.Literal('http-closed') }), + }), + ), +) +declare const readyHttp: HttpApiClient.ForApi +const http = createHttpApiQueryUtils(api, { + client: readyHttp, + keyPrefix: ['framework', 'http-owner'], + runPromiseExit: runner, +}) +// @ts-expect-error The HTTP client service remains a runner requirement. +createHttpApiQueryUtils(api, { client: readyHttp, keyPrefix: ['missing'] }) +const request = { params: { id: 1 } } +const httpQuery = http.values.read.queryOptions({ + input: request, + initialData: { id: 1, name: 'initial' }, + select: (user) => user.name, +}) +const svelteHttp = createQuery(() => httpQuery) +const angularHttp = injectQuery(() => httpQuery) +true satisfies Assert> +true satisfies Assert, string>> +const metadata = http.values.read.metadataOptions({ + input: request, + select: (view) => view.data.name, +}) +const svelteMetadata = createQuery(() => metadata) +const angularMetadata = injectQuery(() => metadata) +true satisfies Assert> +true satisfies Assert, string | undefined>> +svelteMetadata.error satisfies EffectHttpApiQueryError< + Schema.SchemaError | HttpClientError.HttpClientError | 'http-missing' | 'http-transport' +> | null +angularMetadata.error() satisfies EffectHttpApiQueryError< + Schema.SchemaError | HttpClientError.HttpClientError | 'http-missing' | 'http-transport' +> | null +const metadataCache = client.getQueryData(metadata.queryKey) +metadataCache?.data satisfies typeof User.Type | undefined +metadataCache?.status satisfies number | undefined +metadataCache?.headers satisfies Readonly> | undefined +const httpMutation = http.values.read.mutationOptions({ + onSuccess: (value, input) => { + value.name satisfies string + input.params.id satisfies number + }, +}) +createMutation(() => httpMutation).mutate(request) +injectMutation(() => httpMutation).mutate(request) +const httpInfinite = http.values.read.infiniteOptions({ + input: (cursor: number) => ({ params: { id: cursor } }), + initialPageParam: 0, + getNextPageParam: (_last, _pages, cursor) => cursor + 1, + select: (value) => value.pages.map((user) => user.name), +}) +createInfiniteQuery(() => httpInfinite).data satisfies string[] | undefined +injectInfiniteQuery(() => httpInfinite).data() satisfies string[] | undefined +const httpHistory = http.values.watch.streamedOptions({ + input: request, + maxChunks: 3, + initialData: [1], + select: (values) => values.length, +}) +const httpLive = http.values.watch.liveOptions({ + input: request, + initialData: 1, + select: (value) => value.toFixed(), +}) +const svelteHttpHistory = createQuery(() => httpHistory) +const angularHttpHistory = injectQuery(() => httpHistory) +const svelteHttpLive = createQuery(() => httpLive) +const angularHttpLive = injectQuery(() => httpLive) +true satisfies Assert> +true satisfies Assert, number>> +true satisfies Assert> +true satisfies Assert, string>> +svelteHttpLive.error satisfies + | EffectHttpApiQueryError + | EffectHttpApiQueryEmptyStreamError + | null +createQuery(() => http.values.read.queryOptions(skipToken)) +injectQuery(() => http.values.read.metadataOptions(skipToken)) +createQuery(() => http.values.watch.streamedOptions(skipToken)) +injectQuery(() => http.values.watch.liveOptions(skipToken)) diff --git a/tests/packed-consumer/svelte-angular-runtime.ts b/tests/packed-consumer/svelte-angular-runtime.ts new file mode 100644 index 0000000..a310910 --- /dev/null +++ b/tests/packed-consumer/svelte-angular-runtime.ts @@ -0,0 +1,193 @@ +import '@angular/compiler' +import { Component, signal, provideZonelessChangeDetection } from '@angular/core' +import { bootstrapApplication } from '@angular/platform-browser' +import { + injectQuery, + provideTanStackQuery, + QueryClient as AngularQueryClient, +} from '@tanstack/angular-query-experimental' +import { QueryClient } from '@tanstack/query-core' +import { QueryClient as SvelteQueryClient } from '@tanstack/svelte-query' +import { mount, unmount } from 'svelte' + +import { expectEqual, makeFixture, waitFor } from './framework-fixture.ts' +import SvelteConsumer from './svelte-consumer.svelte' + +if (AngularQueryClient !== QueryClient || SvelteQueryClient !== QueryClient) { + throw new Error('Framework integrations must share Query Core bindings') +} + +type View = { + data: () => unknown + views: () => unknown + fetchStatus: () => string +} + +const verifyOwner = async ( + name: string, + fixture: Awaited>, + client: QueryClient, + host: HTMLElement, + view: View, + destroy: () => Promise | void, +) => { + await waitFor(() => view.data() === 1, `${name} did not expose the first open-stream value`) + await waitFor( + () => JSON.stringify(view.views()) === '[[11],101,[111],101]', + `${name} did not expose every first stream view`, + ) + expectEqual(view.fetchStatus(), 'fetching', `${name} first value remains open`) + expectEqual(client.isFetching(), 4, `${name} stream views remain open`) + host.querySelector('button')?.click() + await waitFor(() => view.data() === 2, `${name} did not rebuild options after input changed`) + await waitFor(() => fixture.finalized.includes(1), `${name} did not finalize the replaced stream`) + await waitFor( + () => JSON.stringify(view.views()) === '[[12],102,[112],102]', + `${name} views did not follow the changed input`, + ) + await waitFor( + () => host.querySelector('output')?.textContent === '[2,[[12],102,[112],102]]', + `${name} did not render its changed query results`, + ) + expectEqual( + host.querySelector('output')?.textContent, + '[2,[[12],102,[112],102]]', + `${name} rendered the changed views`, + ) + await destroy() + await waitFor( + () => fixture.finalized.length === 4 && fixture.httpFinalized.length === 4, + `${name} did not finalize every owned stream`, + ) + expectEqual( + [...fixture.finalized].sort((a, b) => a - b), + [1, 2, 11, 12], + `${name} owns all RPC streams`, + ) + expectEqual( + [...fixture.httpFinalized].sort((a, b) => a - b), + [101, 102, 111, 112], + `${name} owns all HTTP streams`, + ) +} + +const runSvelte = async () => { + const fixture = await makeFixture('svelte') + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + client.mount() + const host = document.createElement('div') + document.body.append(host) + let view: View | undefined + let mounted = true + const component = mount(SvelteConsumer, { + target: host, + props: { + fixture, + client, + expose: (value: View) => { + view = value + }, + }, + }) + try { + await waitFor(() => view !== undefined, 'Svelte did not initialize its component') + if (view === undefined) throw new Error('Svelte has no query result') + await verifyOwner('Svelte', fixture, client, host, view, async () => { + mounted = false + await unmount(component) + }) + } finally { + if (mounted) await unmount(component) + await client.cancelQueries() + client.clear() + client.unmount() + await fixture.close() + host.remove() + } +} + +const runAngular = async () => { + const fixture = await makeFixture('angular') + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + let instance: AngularConsumer | undefined + class AngularConsumer { + readonly id = signal(1) + readonly live = injectQuery(() => + fixture.utils.values.watch.liveOptions({ input: { id: this.id() } }), + ) + readonly history = injectQuery(() => + fixture.utils.values.watch.streamedOptions({ input: { id: this.id() + 10 }, maxChunks: 2 }), + ) + readonly httpLive = injectQuery(() => + fixture.http.values.watch.liveOptions({ input: { params: { id: this.id() + 100 } } }), + ) + readonly httpHistory = injectQuery(() => + fixture.http.values.watch.streamedOptions({ + input: { params: { id: this.id() + 110 } }, + maxChunks: 2, + }), + ) + readonly metadata = injectQuery(() => + fixture.http.values.read.metadataOptions({ + input: { params: { id: this.id() + 100 } }, + select: (value) => value.data, + }), + ) + readonly views = () => [ + this.history.data(), + this.httpLive.data(), + this.httpHistory.data(), + this.metadata.data(), + ] + readonly output = () => JSON.stringify([this.live.data(), this.views()]) + readonly increment = (value: number) => value + 1 + constructor() { + instance = this + } + } + Component({ + selector: 'angular-consumer', + standalone: true, + template: + '{{ output() }}', + })(AngularConsumer) + const host = document.createElement('angular-consumer') + document.body.append(host) + const application = await bootstrapApplication(AngularConsumer, { + providers: [provideZonelessChangeDetection(), provideTanStackQuery(client)], + }) + try { + if (instance === undefined) throw new Error('Angular did not initialize its component') + const owner = instance + await verifyOwner( + 'Angular', + fixture, + client, + host, + { + data: () => owner.live.data(), + views: owner.views, + fetchStatus: () => owner.live.fetchStatus(), + }, + () => application.destroy(), + ) + } finally { + if (!application.destroyed) application.destroy() + await client.cancelQueries() + client.clear() + await fixture.close() + host.remove() + } +} + +void runSvelte() + .then(runAngular) + .then( + () => { + document.documentElement.dataset['frameworkStatus'] = 'passed' + }, + (error: unknown) => { + document.documentElement.dataset['frameworkStatus'] = 'failed' + document.documentElement.dataset['frameworkMessage'] = String(error) + }, + ) diff --git a/tests/packed-consumer/svelte-consumer.svelte b/tests/packed-consumer/svelte-consumer.svelte new file mode 100644 index 0000000..3aba062 --- /dev/null +++ b/tests/packed-consumer/svelte-consumer.svelte @@ -0,0 +1,50 @@ + + + +{JSON.stringify([live.data, [history.data, httpLive.data, httpHistory.data, metadata.data]])} diff --git a/tests/packed-consumer/tsconfig.svelte-angular.json b/tests/packed-consumer/tsconfig.svelte-angular.json new file mode 100644 index 0000000..9953bf8 --- /dev/null +++ b/tests/packed-consumer/tsconfig.svelte-angular.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "lib": ["ES2022", "DOM", "DOM.Iterable", "ESNext.Disposable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + "allowImportingTsExtensions": true, + "exactOptionalPropertyTypes": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": false, + "strict": true, + "target": "ES2022", + "types": ["node"] + }, + "include": ["svelte-angular-contract.ts", "docs-angular.ts"] +} From a512ad09493ed991a527371f28cea05ce77f9e82 Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Sat, 10 Oct 2026 13:00:49 +0200 Subject: [PATCH 52/97] Verify packed browser and worker query hosts --- .fallowrc.jsonc | 4 +- .github/workflows/ci.yml | 6 + apps/docs/astro.config.mjs | 1 + .../reference/browser-and-worker-hosts.md | 57 +++++ .../reference/compatibility-and-limits.md | 4 + e2e/packed-hosts.spec.ts | 117 +++++++++ scripts/packed-host-consumer.mts | 108 ++++++++ tests/packed-consumer/hosts/browser.ts | 26 ++ tests/packed-consumer/hosts/index.html | 11 + tests/packed-consumer/hosts/runtime.ts | 242 ++++++++++++++++++ tests/packed-consumer/hosts/worker.ts | 7 + tests/packed-consumer/tsconfig.json | 1 + 12 files changed, 583 insertions(+), 1 deletion(-) create mode 100644 apps/docs/src/content/docs/reference/browser-and-worker-hosts.md create mode 100644 e2e/packed-hosts.spec.ts create mode 100644 scripts/packed-host-consumer.mts create mode 100644 tests/packed-consumer/hosts/browser.ts create mode 100644 tests/packed-consumer/hosts/index.html create mode 100644 tests/packed-consumer/hosts/runtime.ts create mode 100644 tests/packed-consumer/hosts/worker.ts diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index d249e01..9c8c8f3 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -29,7 +29,9 @@ "tests/packed-consumer/defaults-contract.ts", "tests/packed-consumer/hydration-runtime.mts", "tests/packed-consumer/hydration-views-runtime.mts", - "tests/packed-consumer/hydration-contract.ts" + "tests/packed-consumer/hydration-contract.ts", + "tests/packed-consumer/hosts/browser.ts", + "tests/packed-consumer/hosts/worker.ts" ], "ignorePatterns": ["examples/tanstack-start/src/routeTree.gen.ts"], diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0dd8acd..5a17900 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -137,6 +137,12 @@ jobs: - name: Install ${{ matrix.browser }} run: vp exec playwright install --with-deps ${{ matrix.browser }} + - name: Download verified package for host acceptance + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + name: package-${{ github.sha }} + path: .artifacts + - name: Check documentation in ${{ matrix.browser }} run: vp run docs-e2e --project=${{ matrix.browser }} diff --git a/apps/docs/astro.config.mjs b/apps/docs/astro.config.mjs index 61ed051..a958aa4 100644 --- a/apps/docs/astro.config.mjs +++ b/apps/docs/astro.config.mjs @@ -103,6 +103,7 @@ export default defineConfig({ { label: 'RPC Builders', slug: 'reference/generated-builders' }, { label: 'Errors', slug: 'reference/errors' }, { label: 'Public Exports', slug: 'reference/public-exports' }, + { label: 'Browser and Worker Hosts', slug: 'reference/browser-and-worker-hosts' }, { label: 'Compatibility and Limits', slug: 'reference/compatibility-and-limits' }, ], }, diff --git a/apps/docs/src/content/docs/reference/browser-and-worker-hosts.md b/apps/docs/src/content/docs/reference/browser-and-worker-hosts.md new file mode 100644 index 0000000..3f24855 --- /dev/null +++ b/apps/docs/src/content/docs/reference/browser-and-worker-hosts.md @@ -0,0 +1,57 @@ +--- +title: Browser and Worker Hosts +description: Framework-free browser and dedicated worker coverage, ready-client ownership, and transport limits. +--- + +The package root executes generated RPC and HTTP operations in browser windows and dedicated +workers. Both hosts use the same factories and TanStack Query Core options. Each host acquires +its own ready clients and owns their `Scope`, runner, QueryClient, and disposal. + +## Host and operation coverage + +The current host matrix uses Effect 4.0.0, Query Core 5.103.1 and 5.104.0, and Playwright 1.63.0. +Chromium 153.0.8010.12 and Firefox 155.0 execute every operation below in both a window and a +dedicated worker. + +| Operation or behavior | RPC | HTTP | +| -------------------------- | -------------------------------------------- | --------------------------------------------------------------------- | +| Buffered reads and writes | Generated unary queries and mutations | Generated queries and mutations with native request/response encoding | +| Accumulated streamed query | Ordered values from a ready RPC client | Decoded SSE values from a ready HTTP API client | +| Live query | Latest emitted value | Latest decoded SSE value | +| Declared response headers | Outside the RPC contract | Preserved decoded header wrappers | +| Metadata view | Outside the RPC contract | Decoded data, status, and immutable raw header snapshot | +| Conditional query sentinel | Consumer Query Core `skipToken` binding | Consumer Query Core `skipToken` binding | +| Independent cache owners | Separate clients, prefixes, and QueryClients | Separate clients, prefixes, and QueryClients | +| Cancellation and disposal | Stream finalization before client disposal | Stream finalization before client disposal | + +The RPC host coverage uses an in-process decoded-message ready client. HTTP coverage uses the +native in-process routing, response encoding, and client decoding pipeline, including SSE. These +hosts do not certify a network transport, browser authentication scheme, service worker, shared +worker, or cross-host cache protocol. + +## Own the worker lifetime + +Create a utility tree inside the worker after acquiring its ready client. Run queries and maintain +their QueryClient in that worker. Messages to the window may carry application DTOs or events; +the utility tree, client, Effect runtime, and QueryClient remain owned by their host. + +On shutdown, stop accepting work, cancel the owner's queries, and await the application's active +stream finalizers and accepted mutations before clearing private cache data and closing the +client's `Scope`. Native query cancellation can settle before asynchronous finalizers finish. +Wait for application-owned cleanup acknowledgements before terminating the worker. Disposing one +owner must not close another owner's clients or cancel its queries. + +See [client lifecycle](/effect-api-query/concepts/client-lifecycle/) and +[cancellation](/effect-api-query/guides/cancellation/) for the shared ownership contract. + +## Worker transport boundary + +A worker hosting ready-client queries is distinct from a window sending Effect RPC requests to +a worker over `postMessage`. The latter is not certified by this matrix. + +Effect exposes worker protocols through `effect/rpc` and worker services through `effect/workers`. +`RpcClient.layerProtocolWorker` requires application-supplied `WorkerPlatform` and `Spawner` +services. The server's worker protocol requires `WorkerRunnerPlatform`. The package supplies none +of these services and does not construct a worker transport. A browser worker transport needs a +compatible platform integration and its own acquisition, interruption, and disposal contract. +Supplying an already acquired client preserves the factories' existing ready-client boundary. diff --git a/apps/docs/src/content/docs/reference/compatibility-and-limits.md b/apps/docs/src/content/docs/reference/compatibility-and-limits.md index b93bd13..95a8fb4 100644 --- a/apps/docs/src/content/docs/reference/compatibility-and-limits.md +++ b/apps/docs/src/content/docs/reference/compatibility-and-limits.md @@ -58,6 +58,10 @@ and [executable examples](/effect-api-query/examples/) establish the tested scop builders use TanStack's experimental `streamedQuery` helper. Use the ready client directly for calls that do not need TanStack Query. +[Browser and worker hosts](/effect-api-query/reference/browser-and-worker-hosts/) lists the +framework-free execution matrix and distinguishes worker-hosted ready clients from a worker RPC +transport. + ## Runtime and cache behavior - The caller owns RPC or HTTP client acquisition, `Scope`, transport, Query Client, providers, router, SSR, diff --git a/e2e/packed-hosts.spec.ts b/e2e/packed-hosts.spec.ts new file mode 100644 index 0000000..f3aa2c9 --- /dev/null +++ b/e2e/packed-hosts.spec.ts @@ -0,0 +1,117 @@ +import { expect, test } from '@playwright/test' +import { Schema } from 'effect' +import { readFile } from 'node:fs/promises' + +import { startPackedHostConsumer } from '../scripts/packed-host-consumer.mts' + +const workspace = await readFile(new URL('../pnpm-workspace.yaml', import.meta.url), 'utf-8') +const current = /^ {2}['"]?@tanstack\/query-core['"]?: (?\S+)$/mu.exec(workspace) + ?.groups?.['version'] +if (current === undefined) { + throw new Error('The catalog must define Query Core') +} + +const headerValue = Schema.Struct({ + body: Schema.Finite, + headers: Schema.Record(Schema.String, Schema.String), +}) +const hostObservation = Schema.Struct({ + host: Schema.String, + read: Schema.Finite, + rpc: Schema.Struct({ + mutation: Schema.Finite, + after: Schema.Finite, + accumulated: Schema.Array(Schema.Finite), + live: Schema.Finite, + }), + http: Schema.Struct({ + read: headerValue, + mutation: Schema.Finite, + after: headerValue, + accumulated: Schema.Array(Schema.Finite), + live: Schema.Finite, + metadata: Schema.Struct({ + data: headerValue, + status: Schema.Finite, + owner: Schema.String, + frozenSnapshot: Schema.Boolean, + frozenHeaders: Schema.Boolean, + }), + }), + skipIdentity: Schema.Array(Schema.Boolean), + ownership: Schema.Struct({ + before: Schema.Array(Schema.Finite), + activeBefore: Schema.Array(Schema.Finite), + activeAfterFirst: Schema.Array(Schema.Finite), + secondData: Schema.Array(Schema.Finite), + activeAfterBoth: Schema.Array(Schema.Finite), + firstEvents: Schema.Array(Schema.String), + secondEvents: Schema.Array(Schema.String), + }), +}) +const decodeResult = Schema.decodeSync( + Schema.fromJsonString(Schema.Struct({ window: hostObservation, worker: hostObservation })), +) + +for (const peer of new Set(['5.103.1', current])) { + test(`packed RPC and HTTP operations execute in window and worker with Query Core ${peer}`, async ({ + page, + }) => { + test.setTimeout(120_000) + const host = await startPackedHostConsumer(peer) + try { + await page.goto(host.url) + await expect(page.locator('#result')).toHaveAttribute('data-status', 'done', { + timeout: 30_000, + }) + const result = decodeResult((await page.locator('#result').textContent()) ?? '') + expect(result.window.host).toBe('Window') + expect(result.worker.host).toBe('DedicatedWorkerGlobalScope') + expect(result.window.read).toBe(10) + expect(result.worker.read).toBe(10) + for (const observation of [result.window, result.worker]) { + expect(observation.rpc).toEqual({ + mutation: 41, + after: 41, + accumulated: [41, 42, 43], + live: 43, + }) + expect(observation.http).toEqual({ + read: { body: 10, headers: { 'x-owner': 'Ada' } }, + mutation: 52, + after: { body: 52, headers: { 'x-owner': 'Ada' } }, + accumulated: [52, 53, 54], + live: 54, + metadata: { + data: { body: 52, headers: { 'x-owner': 'Ada' } }, + status: 200, + owner: 'Ada', + frozenSnapshot: true, + frozenHeaders: true, + }, + }) + expect(observation.skipIdentity).toEqual([true, true, true]) + expect(observation.ownership.before).toEqual([20, 20]) + expect(observation.ownership.activeBefore).toEqual([2, 2]) + expect(observation.ownership.activeAfterFirst).toEqual([0, 2]) + expect(observation.ownership.secondData).toEqual([20, 20]) + expect(observation.ownership.activeAfterBoth).toEqual([0, 0]) + for (const [name, events] of [ + ['Ada', observation.ownership.firstEvents], + ['Grace', observation.ownership.secondEvents], + ] as const) { + expect(events).toEqual( + expect.arrayContaining([ + `${name}:rpc-finalized`, + `${name}:http-finalized`, + `${name}:disposed`, + ]), + ) + expect(events.at(-1)).toBe(`${name}:disposed`) + } + } + } finally { + await host.dispose() + } + }) +} diff --git a/scripts/packed-host-consumer.mts b/scripts/packed-host-consumer.mts new file mode 100644 index 0000000..7466e83 --- /dev/null +++ b/scripts/packed-host-consumer.mts @@ -0,0 +1,108 @@ +import { Schema } from 'effect' +import { equal } from 'node:assert/strict' +import { spawn } from 'node:child_process' +import { createHash } from 'node:crypto' +import { once } from 'node:events' +import { cp, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { build, preview } from 'vite' + +const repositoryRoot = path.resolve(import.meta.dirname, '..') + +const install = async (directory: string): Promise => { + const installation = spawn('pnpm', ['install', '--ignore-scripts', '--prefer-offline'], { + cwd: directory, + stdio: 'inherit', + }) + const [code] = Schema.decodeUnknownSync( + Schema.Tuple([Schema.NullOr(Schema.Int), Schema.NullOr(Schema.String)]), + )(await once(installation, 'exit')) + if (code !== 0) { + throw new Error(`Host consumer installation exited with ${code}`) + } +} + +export const startPackedHostConsumer = async (queryCoreVersion: string) => { + const manifest = Schema.decodeSync( + Schema.fromJsonString(Schema.Struct({ version: Schema.String })), + )(await readFile(path.join(repositoryRoot, 'package.json'), 'utf-8')) + const archive = path.resolve( + repositoryRoot, + process.env['EFFECT_API_QUERY_TARBALL'] ?? + `.artifacts/effect-api-query-${manifest.version}.tgz`, + ) + const digest = async () => + createHash('sha512') + .update(await readFile(archive)) + .digest('hex') + const sha512 = await digest() + const directory = await mkdtemp(path.join(tmpdir(), 'effect-api-query-host-')) + let server: Awaited> | undefined + const dispose = async () => { + try { + await server?.close() + } finally { + await rm(directory, { force: true, recursive: true }) + equal(await digest(), sha512, 'Host execution must preserve the exact archive') + } + } + try { + const workspace = await readFile(path.join(repositoryRoot, 'pnpm-workspace.yaml'), 'utf-8') + const effectVersion = /^ {2}effect: (?\S+)$/mu.exec(workspace)?.groups?.['version'] + if (effectVersion === undefined) { + throw new Error('The catalog must define Effect') + } + await cp(path.join(repositoryRoot, 'tests/packed-consumer/hosts'), directory, { + recursive: true, + }) + await writeFile(path.join(directory, 'pnpm-workspace.yaml'), workspace) + await writeFile( + path.join(directory, 'package.json'), + JSON.stringify({ + name: 'effect-api-query-host-consumer', + private: true, + type: 'module', + dependencies: { + '@tanstack/query-core': queryCoreVersion, + effect: effectVersion, + 'effect-api-query': `file:${archive}`, + }, + }), + ) + await install(directory) + await Promise.all( + ( + [ + ['@tanstack/query-core', queryCoreVersion], + ['effect', effectVersion], + ['effect-api-query', manifest.version], + ] as const + ).map(async ([dependency, version]) => { + const installed = Schema.decodeSync( + Schema.fromJsonString(Schema.Struct({ version: Schema.String })), + )(await readFile(path.join(directory, 'node_modules', dependency, 'package.json'), 'utf-8')) + equal(installed.version, version) + }), + ) + await build({ + configFile: false, + root: directory, + build: { target: 'es2022' }, + worker: { format: 'es' }, + }) + server = await preview({ + configFile: false, + root: directory, + preview: { host: '127.0.0.1', port: 0, strictPort: true }, + }) + const url = server.resolvedUrls?.local[0] + if (url === undefined) { + throw new Error('The host consumer must expose a local URL') + } + return { dispose, url } + } catch (error) { + await dispose() + throw error + } +} diff --git a/tests/packed-consumer/hosts/browser.ts b/tests/packed-consumer/hosts/browser.ts new file mode 100644 index 0000000..396435a --- /dev/null +++ b/tests/packed-consumer/hosts/browser.ts @@ -0,0 +1,26 @@ +import { runHostOperations } from './runtime.ts' + +const output = document.querySelector('#result') +if (output === null) throw new Error('The host consumer requires its result element') +const worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' }) +const workerResult = new Promise((resolve, reject) => { + worker.addEventListener( + 'message', + (event: MessageEvent) => { + if (event.data.error !== undefined) reject(new Error(event.data.error)) + else resolve(event.data) + }, + { once: true }, + ) + worker.addEventListener('error', reject, { once: true }) +}) +try { + const [window, workerData] = await Promise.all([runHostOperations(), workerResult]) + output.textContent = JSON.stringify({ window, worker: workerData }) + output.setAttribute('data-status', 'done') +} catch (error) { + output.textContent = String(error) + output.setAttribute('data-status', 'error') +} finally { + worker.terminate() +} diff --git a/tests/packed-consumer/hosts/index.html b/tests/packed-consumer/hosts/index.html new file mode 100644 index 0000000..387af71 --- /dev/null +++ b/tests/packed-consumer/hosts/index.html @@ -0,0 +1,11 @@ + + + + + Packed host consumer + + + + + + diff --git a/tests/packed-consumer/hosts/runtime.ts b/tests/packed-consumer/hosts/runtime.ts new file mode 100644 index 0000000..bec37c7 --- /dev/null +++ b/tests/packed-consumer/hosts/runtime.ts @@ -0,0 +1,242 @@ +import { MutationObserver, QueryClient, skipToken as coreSkipToken } from '@tanstack/query-core' +import { Deferred, Effect, Exit, Layer, Schema, Scope, Stream } from 'effect' +import { createHttpApiQueryUtils, createRpcQueryUtils, skipToken } from 'effect-api-query' +import { HttpServer } from 'effect/http' +import { + HttpApi, + HttpApiBuilder, + HttpApiEndpoint, + HttpApiGroup, + HttpApiSchema, + HttpApiTest, +} from 'effect/http-api' +import { Rpc, RpcGroup, RpcTest } from 'effect/rpc' + +const group = RpcGroup.make( + Rpc.make('host.read', { payload: { offset: Schema.Int }, success: Schema.Finite }), + Rpc.make('host.write', { payload: { value: Schema.Finite }, success: Schema.Finite }), + Rpc.make('host.watch', { success: Schema.Finite, stream: true }), + Rpc.make('host.open', { success: Schema.Finite, stream: true }), +) +const api = HttpApi.make('host-http').add( + HttpApiGroup.make('values').add( + HttpApiEndpoint.get('read', '/value', { + query: { offset: Schema.FiniteFromString }, + success: HttpApiSchema.WithHeaders(Schema.Finite, { 'x-owner': Schema.String }), + }), + HttpApiEndpoint.post('write', '/value', { + payload: Schema.Struct({ value: Schema.FiniteFromString }), + success: Schema.Finite, + }), + HttpApiEndpoint.get('watch', '/watch', { + success: HttpApiSchema.StreamSse({ data: Schema.Finite }), + }), + HttpApiEndpoint.get('open', '/open', { + success: HttpApiSchema.StreamSse({ data: Schema.Finite }), + }), + ), +) + +const makeOwner = Effect.fnUntraced(function* (name: string, initial: number) { + const scope = yield* Scope.make() + const cache = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const events: string[] = [] + const openStreams = new Set>() + yield* Effect.addFinalizer(() => + Effect.sync(() => { + events.push(`${name}:disposed`) + }), + ).pipe(Effect.provideService(Scope.Scope, scope)) + const open = (transport: string, value: number) => + Stream.suspend(() => { + const finalized = Deferred.makeUnsafe() + openStreams.add(finalized) + return Stream.succeed(value).pipe( + Stream.concat(Stream.fromEffect(Effect.never)), + Stream.ensuring( + Effect.sync(() => { + events.push(`${name}:${transport}-finalized`) + openStreams.delete(finalized) + Effect.runSync(Deferred.succeed(finalized, undefined)) + }), + ), + ) + }) + let rpcValue = initial + let httpValue = initial + const client = yield* RpcTest.makeClient(group, { flatten: true }).pipe( + Effect.provide( + group.toLayer({ + 'host.read': ({ offset }) => Effect.sync(() => rpcValue + offset), + 'host.write': ({ value }) => + Effect.sync(() => { + rpcValue = value + return value + }), + 'host.watch': () => Stream.make(rpcValue, rpcValue + 1, rpcValue + 2), + 'host.open': () => open('rpc', rpcValue), + }), + ), + Effect.provideService(Scope.Scope, scope), + ) + const httpClient = yield* HttpApiTest.groups(api, ['values']).pipe( + Effect.provide( + Layer.mergeAll( + HttpServer.layerServices, + HttpApiBuilder.group(api, 'values', (handlers) => + handlers + .handle('read', ({ query }) => + Effect.sync(() => + HttpApiSchema.withHeaders({ + body: httpValue + query.offset, + headers: { 'x-owner': name }, + }), + ), + ) + .handle('write', ({ payload }) => + Effect.sync(() => { + httpValue = payload.value + return httpValue + }), + ) + .handle('watch', () => + Effect.succeed(Stream.make(httpValue, httpValue + 1, httpValue + 2)), + ) + .handle('open', () => Effect.succeed(open('http', httpValue))), + ), + ), + ), + Effect.provideService(Scope.Scope, scope), + ) + return { + cache, + events, + rpc: createRpcQueryUtils(group, { client, keyPrefix: ['host', name] }), + http: createHttpApiQueryUtils(api, { client: httpClient, keyPrefix: ['host', name] }), + async dispose() { + const drains = [...openStreams] + try { + await cache.cancelQueries() + await Effect.runPromise( + Effect.all(drains.map(Deferred.await)).pipe(Effect.timeout('5 seconds')), + ) + } finally { + cache.clear() + await Effect.runPromise(Scope.close(scope, Exit.void)) + } + }, + } +}) + +const waitForData = async (cache: QueryClient, check: () => boolean) => { + const ready = Deferred.makeUnsafe() + const observe = () => { + if (check()) Effect.runSync(Deferred.succeed(ready, undefined)) + } + const unsubscribe = cache.getQueryCache().subscribe(observe) + try { + observe() + await Effect.runPromise(Deferred.await(ready).pipe(Effect.timeout('5 seconds'))) + } finally { + unsubscribe() + } +} + +export const runHostOperations = async () => { + const owner = await Effect.runPromise(makeOwner('Ada', 10)) + const second = await Effect.runPromise(makeOwner('Grace', 20)) + const { cache, rpc, http } = owner + const rpcInput = { offset: 0 } + const httpInput = { query: { offset: 0 } } + try { + const read = await cache.query(rpc.host.read.queryOptions({ input: rpcInput })) + const rpcMutation = await new MutationObserver(cache, rpc.host.write.mutationOptions()).mutate({ + value: 41, + }) + const rpcAfter = await cache.query(rpc.host.read.queryOptions({ input: rpcInput })) + const rpcAccumulated = await cache.query(rpc.host.watch.streamedOptions()) + const rpcLive = await cache.query(rpc.host.watch.liveOptions()) + const httpRead = await cache.query(http.values.read.queryOptions({ input: httpInput })) + const httpMutation = await new MutationObserver( + cache, + http.values.write.mutationOptions(), + ).mutate({ payload: { value: 52 } }) + const httpAfter = await cache.query(http.values.read.queryOptions({ input: httpInput })) + const httpAccumulated = await cache.query(http.values.watch.streamedOptions()) + const httpLive = await cache.query(http.values.watch.liveOptions()) + const metadata = await cache.query(http.values.read.metadataOptions({ input: httpInput })) + const before = [ + await second.cache.query(second.rpc.host.read.queryOptions({ input: rpcInput })), + (await second.cache.query(second.http.values.read.queryOptions({ input: httpInput }))).body, + ] + const firstRpcOpen = rpc.host.open.streamedOptions() + const firstHttpOpen = http.values.open.liveOptions() + const secondRpcOpen = second.rpc.host.open.streamedOptions() + const secondHttpOpen = second.http.values.open.liveOptions() + const pending = [ + cache.query(firstRpcOpen), + cache.query(firstHttpOpen), + second.cache.query(secondRpcOpen), + second.cache.query(secondHttpOpen), + ].map((promise) => promise.catch((error: unknown) => error)) + await Promise.all([ + waitForData( + cache, + () => + cache.getQueryData(firstRpcOpen.queryKey)?.[0] === 41 && + cache.getQueryData(firstHttpOpen.queryKey) === 52, + ), + waitForData( + second.cache, + () => + second.cache.getQueryData(secondRpcOpen.queryKey)?.[0] === 20 && + second.cache.getQueryData(secondHttpOpen.queryKey) === 20, + ), + ]) + const activeBefore = [cache.isFetching(), second.cache.isFetching()] + await owner.dispose() + const activeAfterFirst = [cache.isFetching(), second.cache.isFetching()] + const secondData = [ + second.cache.getQueryData(secondRpcOpen.queryKey)?.[0], + second.cache.getQueryData(secondHttpOpen.queryKey), + ] + await second.dispose() + await Promise.all(pending) + return { + host: globalThis.constructor.name, + read, + rpc: { mutation: rpcMutation, after: rpcAfter, accumulated: rpcAccumulated, live: rpcLive }, + http: { + read: { body: httpRead.body, headers: httpRead.headers }, + mutation: httpMutation, + after: { body: httpAfter.body, headers: httpAfter.headers }, + accumulated: httpAccumulated, + live: httpLive, + metadata: { + data: { body: metadata.data.body, headers: metadata.data.headers }, + status: metadata.status, + owner: metadata.headers['x-owner'], + frozenSnapshot: Object.isFrozen(metadata), + frozenHeaders: Object.isFrozen(metadata.headers), + }, + }, + skipIdentity: [ + skipToken === coreSkipToken, + rpc.host.read.queryOptions({ input: skipToken }).queryFn === coreSkipToken, + http.values.read.queryOptions({ input: skipToken }).queryFn === coreSkipToken, + ], + ownership: { + before, + activeBefore, + activeAfterFirst, + secondData, + activeAfterBoth: [cache.isFetching(), second.cache.isFetching()], + firstEvents: owner.events, + secondEvents: second.events, + }, + } + } finally { + await owner.dispose() + await second.dispose() + } +} diff --git a/tests/packed-consumer/hosts/worker.ts b/tests/packed-consumer/hosts/worker.ts new file mode 100644 index 0000000..ee7d4f0 --- /dev/null +++ b/tests/packed-consumer/hosts/worker.ts @@ -0,0 +1,7 @@ +import { runHostOperations } from './runtime.ts' + +try { + globalThis.postMessage(await runHostOperations()) +} catch (error) { + globalThis.postMessage({ error: String(error) }) +} diff --git a/tests/packed-consumer/tsconfig.json b/tests/packed-consumer/tsconfig.json index 758b249..9baff11 100644 --- a/tests/packed-consumer/tsconfig.json +++ b/tests/packed-consumer/tsconfig.json @@ -49,6 +49,7 @@ "http-stream-contract.ts", "public-contract.ts", "http-contract.ts", + "hosts/**/*.ts", "docs-rpc-quick-start.ts", "docs-http-quick-start.ts", "docs-retry.ts", From 4bdea92758d1d2c6d64e28be015e9c3cde8f736d Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Sat, 10 Oct 2026 13:01:20 +0200 Subject: [PATCH 53/97] Share framework browser verification and track isolated consumers --- .fallowrc.jsonc | 6 +++ .../content/docs/guides/svelte-and-angular.md | 2 +- scripts/verify-framework-browser.mts | 40 +++++++++++++++++++ scripts/verify-svelte-angular-consumer.mts | 35 ++-------------- scripts/verify-vue-solid-consumer.mts | 38 ++---------------- tests/packed-consumer/docs-angular.ts | 2 +- 6 files changed, 54 insertions(+), 69 deletions(-) create mode 100644 scripts/verify-framework-browser.mts diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index dc7f760..a402972 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -91,12 +91,18 @@ // Vite+ tasks, packing, and imported lint presets invoke tools Fallow cannot trace. // Keep Effect peer and test dependencies available for the library and fixtures. "ignoreDependencies": [ + "@angular/compiler", + "@angular/core", + "@angular/platform-browser", "@arethetypeswrong/cli", "@effect/tsgo", "@effect/vitest", "@shadcn/lint", + "@tanstack/angular-query-experimental", + "@tanstack/svelte-query", "effect", "publint", + "svelte", "typescript-5.9", "unplugin-unused" ], diff --git a/apps/docs/src/content/docs/guides/svelte-and-angular.md b/apps/docs/src/content/docs/guides/svelte-and-angular.md index 932b169..682ea0d 100644 --- a/apps/docs/src/content/docs/guides/svelte-and-angular.md +++ b/apps/docs/src/content/docs/guides/svelte-and-angular.md @@ -67,7 +67,7 @@ const rpc = createRpcQueryUtils(group, { client, keyPrefix: ['current-owner'] }) standalone: true, template: '

{{ user.data() }}

', }) -export class UserDetails { +class UserDetails { readonly id = signal(1) readonly user = injectQuery(() => rpc.users.read.queryOptions({ diff --git a/scripts/verify-framework-browser.mts b/scripts/verify-framework-browser.mts new file mode 100644 index 0000000..0c8e845 --- /dev/null +++ b/scripts/verify-framework-browser.mts @@ -0,0 +1,40 @@ +import { chromium } from '@playwright/test' +import { Predicate } from 'effect' +import { strictEqual } from 'node:assert' +import type { ViteDevServer } from 'vite' + +export const verifyFrameworkBrowser = async ( + server: ViteDevServer, + message: string, +): Promise => { + try { + await server.listen() + const address = server.httpServer?.address() + if (address === undefined || address === null || Predicate.isString(address)) { + throw new Error('The browser consumer server has no TCP address') + } + const browser = await chromium.launch() + try { + const page = await browser.newPage() + const errors: string[] = [] + page.on('pageerror', (error) => errors.push(error.message)) + await page.goto(`http://127.0.0.1:${address.port}`) + await page.waitForFunction( + () => document.documentElement.dataset['frameworkStatus'] !== undefined, + undefined, + { timeout: 30_000 }, + ) + const result = await page.evaluate(() => ({ + status: document.documentElement.dataset['frameworkStatus'], + message: document.documentElement.dataset['frameworkMessage'], + })) + strictEqual(result.status, 'passed', result.message ?? errors.join('\n')) + strictEqual(errors.length, 0, errors.join('\n')) + console.log(message) + } finally { + await browser.close() + } + } finally { + await server.close() + } +} diff --git a/scripts/verify-svelte-angular-consumer.mts b/scripts/verify-svelte-angular-consumer.mts index 1eb2872..7412b8d 100644 --- a/scripts/verify-svelte-angular-consumer.mts +++ b/scripts/verify-svelte-angular-consumer.mts @@ -1,11 +1,11 @@ -import { chromium } from '@playwright/test' import { Predicate, Schema } from 'effect' -import { strictEqual } from 'node:assert' import { writeFileSync } from 'node:fs' import { createRequire } from 'node:module' import nodePath from 'node:path' import { createServer } from 'vite' +import { verifyFrameworkBrowser } from './verify-framework-browser.mts' + const consumerDirectory = process.argv.at(2) if (consumerDirectory === undefined) { throw new Error('A consumer directory is required') @@ -60,33 +60,4 @@ const server = await createServer({ server: { host: '127.0.0.1', port: 0 }, optimizeDeps: { noDiscovery: true }, }) -try { - await server.listen() - const address = server.httpServer?.address() - if (address === undefined || address === null || Predicate.isString(address)) { - throw new Error('The browser consumer server has no TCP address') - } - const browser = await chromium.launch() - try { - const page = await browser.newPage() - const errors: string[] = [] - page.on('pageerror', (error) => errors.push(error.message)) - await page.goto(`http://127.0.0.1:${address.port}`) - await page.waitForFunction( - () => document.documentElement.dataset['frameworkStatus'] !== undefined, - undefined, - { timeout: 30_000 }, - ) - const result = await page.evaluate(() => ({ - status: document.documentElement.dataset['frameworkStatus'], - message: document.documentElement.dataset['frameworkMessage'], - })) - strictEqual(result.status, 'passed', result.message ?? errors.join('\n')) - strictEqual(errors.length, 0, errors.join('\n')) - console.log('Svelte and Angular reactive browser consumers passed') - } finally { - await browser.close() - } -} finally { - await server.close() -} +await verifyFrameworkBrowser(server, 'Svelte and Angular reactive browser consumers passed') diff --git a/scripts/verify-vue-solid-consumer.mts b/scripts/verify-vue-solid-consumer.mts index f96c513..04134e8 100644 --- a/scripts/verify-vue-solid-consumer.mts +++ b/scripts/verify-vue-solid-consumer.mts @@ -1,10 +1,9 @@ -import { chromium } from '@playwright/test' -import { Predicate } from 'effect' -import { strictEqual } from 'node:assert' import { writeFileSync } from 'node:fs' import nodePath from 'node:path' import { createServer } from 'vite' +import { verifyFrameworkBrowser } from './verify-framework-browser.mts' + const [consumerDirectory] = process.argv.slice(2) if (consumerDirectory === undefined) { throw new Error('A consumer directory is required') @@ -24,35 +23,4 @@ const server = await createServer({ __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false, }, }) -try { - await server.listen() - const address = server.httpServer?.address() - if (address === undefined || address === null || Predicate.isString(address)) { - throw new Error('The browser consumer server has no TCP address') - } - const browser = await chromium.launch() - try { - const page = await browser.newPage() - const errors: string[] = [] - page.on('pageerror', (error) => { - errors.push(error.message) - }) - await page.goto(`http://127.0.0.1:${address.port}`) - await page.waitForFunction( - () => document.documentElement.dataset['frameworkStatus'] !== undefined, - undefined, - { timeout: 30_000 }, - ) - const result = await page.evaluate(() => ({ - status: document.documentElement.dataset['frameworkStatus'], - message: document.documentElement.dataset['frameworkMessage'], - })) - strictEqual(result?.status, 'passed', result?.message ?? errors.join('\n')) - strictEqual(errors.length, 0, errors.join('\n')) - console.log('Vue and Solid reactive browser consumers passed') - } finally { - await browser.close() - } -} finally { - await server.close() -} +await verifyFrameworkBrowser(server, 'Vue and Solid reactive browser consumers passed') diff --git a/tests/packed-consumer/docs-angular.ts b/tests/packed-consumer/docs-angular.ts index ca54c42..0ab6641 100644 --- a/tests/packed-consumer/docs-angular.ts +++ b/tests/packed-consumer/docs-angular.ts @@ -18,7 +18,7 @@ const rpc = createRpcQueryUtils(group, { client, keyPrefix: ['current-owner'] }) standalone: true, template: '

{{ user.data() }}

', }) -export class UserDetails { +class UserDetails { readonly id = signal(1) readonly user = injectQuery(() => rpc.users.read.queryOptions({ From 5ff2638289a1ba47d4b7d867d9c50deb5b4d5c4a Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Sat, 10 Oct 2026 13:01:38 +0200 Subject: [PATCH 54/97] Preserve builder argument contracts and compile reactive guide examples --- .fallowrc.jsonc | 6 ++++++ .../src/content/docs/guides/vue-and-solid.md | 11 +++++++--- scripts/verify-docs-examples.mts | 1 + src/core/types.ts | 10 +++++----- tests/packed-consumer/docs-solid-query.ts | 14 +++++++++++++ tests/packed-consumer/docs-vue-query.ts | 20 +++++++++++++++++++ tests/packed-consumer/queries.ts | 12 +++++++++++ tests/packed-consumer/tsconfig.vue-solid.json | 7 ++++++- tests/types/public-contract.ts | 2 ++ 9 files changed, 74 insertions(+), 9 deletions(-) create mode 100644 tests/packed-consumer/docs-solid-query.ts create mode 100644 tests/packed-consumer/docs-vue-query.ts create mode 100644 tests/packed-consumer/queries.ts diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 3603526..4474479 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -5,6 +5,8 @@ // Starlight head configuration, which static import analysis cannot follow. "entry": [ "scripts/verify-vue-solid-consumer.mts", + "tests/packed-consumer/docs-vue-query.ts", + "tests/packed-consumer/docs-solid-query.ts", "tests/packed-consumer/vue-solid-contract.ts", "tests/packed-consumer/vue-solid-runtime.ts", "tests/packed-consumer/owner-cache-runtime.mts", @@ -87,6 +89,10 @@ // Vite+ tasks, packing, and imported lint presets invoke tools Fallow cannot trace. // Keep Effect peer and test dependencies available for the library and fixtures. "ignoreDependencies": [ + "@tanstack/solid-query", + "@tanstack/vue-query", + "solid-js", + "vue", "@arethetypeswrong/cli", "@effect/tsgo", "@effect/vitest", diff --git a/apps/docs/src/content/docs/guides/vue-and-solid.md b/apps/docs/src/content/docs/guides/vue-and-solid.md index 69b82f8..7733194 100644 --- a/apps/docs/src/content/docs/guides/vue-and-solid.md +++ b/apps/docs/src/content/docs/guides/vue-and-solid.md @@ -32,6 +32,8 @@ import { useQuery } from '@tanstack/vue-query' import { skipToken } from 'effect-api-query' import { computed, ref } from 'vue' +import { rpc } from './queries.ts' + const userId = ref(1) const options = computed(() => rpc.users.read.queryOptions({ @@ -39,15 +41,16 @@ const options = computed(() => select: (user) => user.name, }), ) +type UserKey = ReturnType const user = useQuery< - InferDataFromTag, - InferErrorFromTag, + InferDataFromTag, + InferErrorFromTag, string >(options) ``` Read the selected value from `user.data.value`. The explicit native hook generics preserve the -data and error carried by the generated key while bounding Vue's recursive ref unwrapping. +data and error carried by the concrete generated key while bounding Vue's recursive ref unwrapping. Automatic inference can otherwise exceed TypeScript's instantiation depth when it unwraps an Effect `Cause` through a tagged key. Let the builder infer its arguments; put these type arguments on the Vue hook. @@ -59,6 +62,8 @@ import { useQuery } from '@tanstack/solid-query' import { skipToken } from 'effect-api-query' import { createSignal } from 'solid-js' +import { rpc } from './queries.ts' + const [userId] = createSignal(1) const user = useQuery(() => { const id = userId() diff --git a/scripts/verify-docs-examples.mts b/scripts/verify-docs-examples.mts index 07cbba7..9602e90 100644 --- a/scripts/verify-docs-examples.mts +++ b/scripts/verify-docs-examples.mts @@ -7,6 +7,7 @@ for (const [page, fixtures] of [ ['getting-started/quick-start', ['types/docs-rpc-quick-start']], ['getting-started/http-quick-start', ['types/docs-http-quick-start']], ['guides/retry-queries', ['packed-consumer/docs-retry']], + ['guides/vue-and-solid', ['packed-consumer/docs-vue-query', 'packed-consumer/docs-solid-query']], ['guides/switch-cache-owners', ['packed-consumer/docs-owner-cache']], ['guides/refresh-from-events', ['packed-consumer/docs-user-events']], ['guides/stream-snapshots', ['packed-consumer/docs-stream-snapshot']], diff --git a/src/core/types.ts b/src/core/types.ts index 0f93082..3997b08 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -85,14 +85,14 @@ export type InputQueryOptions< ? ConditionalQueryOptions : QueryOptions -type ExactBuilderOptions = Expected & +export type ExactBuilderOptions = Expected & Supplied & { readonly [ Key in Exclude ]: string extends Key ? Supplied[Key] : number extends Key ? Supplied[Key] : never } -type SuppliedInitialData = 'initialData' extends keyof Supplied +export type SuppliedInitialData = 'initialData' extends keyof Supplied ? undefined extends Supplied['initialData'] ? Omit & { readonly initialData?: Exclude @@ -101,9 +101,9 @@ type SuppliedInitialData = 'initialData' extends keyof Supplied : Supplied declare const uninferredBuilderOptions: unique symbol -type UninferredBuilderOptions = { readonly [uninferredBuilderOptions]?: never } +export type UninferredBuilderOptions = { readonly [uninferredBuilderOptions]?: never } -type SuppliedQueryOptions = +export type SuppliedQueryOptions = typeof uninferredBuilderOptions extends keyof Supplied ? Options : Pick< @@ -149,7 +149,7 @@ export type UnaryQueryBuilder< Data > ( - options: ExactBuilderOptions< + options?: ExactBuilderOptions< WithUndefinedInitialData, Data>, Supplied >, diff --git a/tests/packed-consumer/docs-solid-query.ts b/tests/packed-consumer/docs-solid-query.ts new file mode 100644 index 0000000..17f8576 --- /dev/null +++ b/tests/packed-consumer/docs-solid-query.ts @@ -0,0 +1,14 @@ +import { useQuery } from '@tanstack/solid-query' +import { skipToken } from 'effect-api-query' +import { createSignal } from 'solid-js' + +import { rpc } from './queries.ts' + +const [userId] = createSignal(1) +const user = useQuery(() => { + const id = userId() + return rpc.users.read.queryOptions({ + input: id === undefined ? skipToken : { id }, + select: (value) => value.name, + }) +}) diff --git a/tests/packed-consumer/docs-vue-query.ts b/tests/packed-consumer/docs-vue-query.ts new file mode 100644 index 0000000..1868eba --- /dev/null +++ b/tests/packed-consumer/docs-vue-query.ts @@ -0,0 +1,20 @@ +import type { InferDataFromTag, InferErrorFromTag } from '@tanstack/query-core' +import { useQuery } from '@tanstack/vue-query' +import { skipToken } from 'effect-api-query' +import { computed, ref } from 'vue' + +import { rpc } from './queries.ts' + +const userId = ref(1) +const options = computed(() => + rpc.users.read.queryOptions({ + input: userId.value === undefined ? skipToken : { id: userId.value }, + select: (user) => user.name, + }), +) +type UserKey = ReturnType +const user = useQuery< + InferDataFromTag, + InferErrorFromTag, + string +>(options) diff --git a/tests/packed-consumer/queries.ts b/tests/packed-consumer/queries.ts new file mode 100644 index 0000000..e205e57 --- /dev/null +++ b/tests/packed-consumer/queries.ts @@ -0,0 +1,12 @@ +import { Schema } from 'effect' +import { createRpcQueryUtils } from 'effect-api-query' +import { Rpc, RpcGroup, type RpcClient } from 'effect/rpc' + +const Read = Rpc.make('users.read', { + payload: { id: Schema.Int }, + success: Schema.Struct({ id: Schema.Int, name: Schema.String }), + error: Schema.Literal('missing'), +}) +const group = RpcGroup.make(Read) +declare const client: RpcClient.RpcClient.Flat> +export const rpc = createRpcQueryUtils(group, { client, keyPrefix: ['framework-guide'] }) diff --git a/tests/packed-consumer/tsconfig.vue-solid.json b/tests/packed-consumer/tsconfig.vue-solid.json index 72e39ee..a2418f3 100644 --- a/tests/packed-consumer/tsconfig.vue-solid.json +++ b/tests/packed-consumer/tsconfig.vue-solid.json @@ -1,5 +1,10 @@ { "extends": "./tsconfig.json", "compilerOptions": { "types": ["node"] }, - "include": ["vue-solid-contract.ts", "vue-solid-runtime.ts"] + "include": [ + "vue-solid-contract.ts", + "vue-solid-runtime.ts", + "docs-vue-query.ts", + "docs-solid-query.ts" + ] } diff --git a/tests/types/public-contract.ts b/tests/types/public-contract.ts index de190da..78c0ee1 100644 --- a/tests/types/public-contract.ts +++ b/tests/types/public-contract.ts @@ -238,6 +238,8 @@ type ExactStreamLeafInterface = Assert< const exactStreamLeafInterface: ExactStreamLeafInterface = true const projectKey = utils.projects['by-id'].find.queryKey({ id: 'project-1' }) const projectPing = utils.projects.health.ping.queryOptions() +const omittedInputlessArguments: Parameters = [] +void omittedInputlessArguments const bracketOnly = utils['billing-history']['list all'].queryOptions({ input: { accountId: 'account-1' }, }) From 38142644f41f1fb9fbc686dc9b111fd661acad1e Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Sat, 10 Oct 2026 13:04:08 +0200 Subject: [PATCH 55/97] Prepare Chromium for packed framework validation --- .github/workflows/ci.yml | 3 +++ .github/workflows/release.yml | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a17900..b54aeba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,6 +69,9 @@ jobs: - name: Construct and inspect package run: vp run pack + - name: Install Chromium for framework consumers + run: vp exec playwright install --with-deps chromium + - name: Verify packed consumers and type scale run: vp run packed-package diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 361580b..665c091 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -134,6 +134,10 @@ jobs: if: github.event_name == 'push' run: vp run validate + - name: Install Chromium for dry-run framework consumers + if: github.event_name != 'push' + run: vp exec playwright install --with-deps chromium + - name: Verify dry-run artifact if: github.event_name != 'push' run: vp run verify-packed-package From e85ec1baea28ea815f7c896a91df7e414f4b0236 Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Sat, 10 Oct 2026 13:06:29 +0200 Subject: [PATCH 56/97] Keep packed host setup within its acceptance test --- e2e/packed-hosts.spec.ts | 108 ++++++++++++++++++++++++++++++- scripts/packed-host-consumer.mts | 108 ------------------------------- 2 files changed, 106 insertions(+), 110 deletions(-) delete mode 100644 scripts/packed-host-consumer.mts diff --git a/e2e/packed-hosts.spec.ts b/e2e/packed-hosts.spec.ts index f3aa2c9..7eb02d1 100644 --- a/e2e/packed-hosts.spec.ts +++ b/e2e/packed-hosts.spec.ts @@ -1,8 +1,112 @@ import { expect, test } from '@playwright/test' import { Schema } from 'effect' -import { readFile } from 'node:fs/promises' +import { equal } from 'node:assert/strict' +import { spawn } from 'node:child_process' +import { createHash } from 'node:crypto' +import { once } from 'node:events' +import { cp, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { build, preview } from 'vite' -import { startPackedHostConsumer } from '../scripts/packed-host-consumer.mts' +const repositoryRoot = path.resolve(import.meta.dirname, '..') + +const install = async (directory: string): Promise => { + const installation = spawn('pnpm', ['install', '--ignore-scripts', '--prefer-offline'], { + cwd: directory, + stdio: 'inherit', + }) + const [code] = Schema.decodeUnknownSync( + Schema.Tuple([Schema.NullOr(Schema.Int), Schema.NullOr(Schema.String)]), + )(await once(installation, 'exit')) + if (code !== 0) { + throw new Error(`Host consumer installation exited with ${code}`) + } +} + +const startPackedHostConsumer = async (queryCoreVersion: string) => { + const manifest = Schema.decodeSync( + Schema.fromJsonString(Schema.Struct({ version: Schema.String })), + )(await readFile(path.join(repositoryRoot, 'package.json'), 'utf-8')) + const archive = path.resolve( + repositoryRoot, + process.env['EFFECT_API_QUERY_TARBALL'] ?? + `.artifacts/effect-api-query-${manifest.version}.tgz`, + ) + const digest = async () => + createHash('sha512') + .update(await readFile(archive)) + .digest('hex') + const sha512 = await digest() + const directory = await mkdtemp(path.join(tmpdir(), 'effect-api-query-host-')) + let server: Awaited> | undefined + const dispose = async () => { + try { + await server?.close() + } finally { + await rm(directory, { force: true, recursive: true }) + equal(await digest(), sha512, 'Host execution must preserve the exact archive') + } + } + try { + const workspace = await readFile(path.join(repositoryRoot, 'pnpm-workspace.yaml'), 'utf-8') + const effectVersion = /^ {2}effect: (?\S+)$/mu.exec(workspace)?.groups?.['version'] + if (effectVersion === undefined) { + throw new Error('The catalog must define Effect') + } + await cp(path.join(repositoryRoot, 'tests/packed-consumer/hosts'), directory, { + recursive: true, + }) + await writeFile(path.join(directory, 'pnpm-workspace.yaml'), workspace) + await writeFile( + path.join(directory, 'package.json'), + JSON.stringify({ + name: 'effect-api-query-host-consumer', + private: true, + type: 'module', + dependencies: { + '@tanstack/query-core': queryCoreVersion, + effect: effectVersion, + 'effect-api-query': `file:${archive}`, + }, + }), + ) + await install(directory) + await Promise.all( + ( + [ + ['@tanstack/query-core', queryCoreVersion], + ['effect', effectVersion], + ['effect-api-query', manifest.version], + ] as const + ).map(async ([dependency, version]) => { + const installed = Schema.decodeSync( + Schema.fromJsonString(Schema.Struct({ version: Schema.String })), + )(await readFile(path.join(directory, 'node_modules', dependency, 'package.json'), 'utf-8')) + equal(installed.version, version) + }), + ) + await build({ + configFile: false, + root: directory, + build: { target: 'es2022' }, + worker: { format: 'es' }, + }) + server = await preview({ + configFile: false, + root: directory, + preview: { host: '127.0.0.1', port: 0, strictPort: true }, + }) + const url = server.resolvedUrls?.local[0] + if (url === undefined) { + throw new Error('The host consumer must expose a local URL') + } + return { dispose, url } + } catch (error) { + await dispose() + throw error + } +} const workspace = await readFile(new URL('../pnpm-workspace.yaml', import.meta.url), 'utf-8') const current = /^ {2}['"]?@tanstack\/query-core['"]?: (?\S+)$/mu.exec(workspace) diff --git a/scripts/packed-host-consumer.mts b/scripts/packed-host-consumer.mts deleted file mode 100644 index 7466e83..0000000 --- a/scripts/packed-host-consumer.mts +++ /dev/null @@ -1,108 +0,0 @@ -import { Schema } from 'effect' -import { equal } from 'node:assert/strict' -import { spawn } from 'node:child_process' -import { createHash } from 'node:crypto' -import { once } from 'node:events' -import { cp, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import path from 'node:path' -import { build, preview } from 'vite' - -const repositoryRoot = path.resolve(import.meta.dirname, '..') - -const install = async (directory: string): Promise => { - const installation = spawn('pnpm', ['install', '--ignore-scripts', '--prefer-offline'], { - cwd: directory, - stdio: 'inherit', - }) - const [code] = Schema.decodeUnknownSync( - Schema.Tuple([Schema.NullOr(Schema.Int), Schema.NullOr(Schema.String)]), - )(await once(installation, 'exit')) - if (code !== 0) { - throw new Error(`Host consumer installation exited with ${code}`) - } -} - -export const startPackedHostConsumer = async (queryCoreVersion: string) => { - const manifest = Schema.decodeSync( - Schema.fromJsonString(Schema.Struct({ version: Schema.String })), - )(await readFile(path.join(repositoryRoot, 'package.json'), 'utf-8')) - const archive = path.resolve( - repositoryRoot, - process.env['EFFECT_API_QUERY_TARBALL'] ?? - `.artifacts/effect-api-query-${manifest.version}.tgz`, - ) - const digest = async () => - createHash('sha512') - .update(await readFile(archive)) - .digest('hex') - const sha512 = await digest() - const directory = await mkdtemp(path.join(tmpdir(), 'effect-api-query-host-')) - let server: Awaited> | undefined - const dispose = async () => { - try { - await server?.close() - } finally { - await rm(directory, { force: true, recursive: true }) - equal(await digest(), sha512, 'Host execution must preserve the exact archive') - } - } - try { - const workspace = await readFile(path.join(repositoryRoot, 'pnpm-workspace.yaml'), 'utf-8') - const effectVersion = /^ {2}effect: (?\S+)$/mu.exec(workspace)?.groups?.['version'] - if (effectVersion === undefined) { - throw new Error('The catalog must define Effect') - } - await cp(path.join(repositoryRoot, 'tests/packed-consumer/hosts'), directory, { - recursive: true, - }) - await writeFile(path.join(directory, 'pnpm-workspace.yaml'), workspace) - await writeFile( - path.join(directory, 'package.json'), - JSON.stringify({ - name: 'effect-api-query-host-consumer', - private: true, - type: 'module', - dependencies: { - '@tanstack/query-core': queryCoreVersion, - effect: effectVersion, - 'effect-api-query': `file:${archive}`, - }, - }), - ) - await install(directory) - await Promise.all( - ( - [ - ['@tanstack/query-core', queryCoreVersion], - ['effect', effectVersion], - ['effect-api-query', manifest.version], - ] as const - ).map(async ([dependency, version]) => { - const installed = Schema.decodeSync( - Schema.fromJsonString(Schema.Struct({ version: Schema.String })), - )(await readFile(path.join(directory, 'node_modules', dependency, 'package.json'), 'utf-8')) - equal(installed.version, version) - }), - ) - await build({ - configFile: false, - root: directory, - build: { target: 'es2022' }, - worker: { format: 'es' }, - }) - server = await preview({ - configFile: false, - root: directory, - preview: { host: '127.0.0.1', port: 0, strictPort: true }, - }) - const url = server.resolvedUrls?.local[0] - if (url === undefined) { - throw new Error('The host consumer must expose a local URL') - } - return { dispose, url } - } catch (error) { - await dispose() - throw error - } -} From eafaed720f16b29b071754fafcf038bc1d65d327 Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Sat, 10 Oct 2026 13:21:28 +0200 Subject: [PATCH 57/97] Verify application-owned WebSocket query lifetimes --- .../content/docs/guides/websocket-clients.md | 157 +++++++++ .../server/tests/fixtures/websocket-server.ts | 34 ++ .../tests/rpc-websocket-consumer.test.ts | 44 +++ scripts/verify-docs-examples.mts | 4 + scripts/verify-packed-consumer.mts | 18 ++ tests/packed-consumer/docs-websocket-use.ts | 34 ++ tests/packed-consumer/socket-client.ts | 43 +++ tests/packed-consumer/tsconfig.json | 5 + tests/packed-consumer/websocket-checks.ts | 297 ++++++++++++++++++ tests/packed-consumer/websocket-runtime.mts | 13 + tsconfig.json | 2 + 11 files changed, 651 insertions(+) create mode 100644 apps/docs/src/content/docs/guides/websocket-clients.md create mode 100644 examples/server/tests/fixtures/websocket-server.ts create mode 100644 examples/server/tests/rpc-websocket-consumer.test.ts create mode 100644 tests/packed-consumer/docs-websocket-use.ts create mode 100644 tests/packed-consumer/socket-client.ts create mode 100644 tests/packed-consumer/websocket-checks.ts create mode 100644 tests/packed-consumer/websocket-runtime.mts diff --git a/apps/docs/src/content/docs/guides/websocket-clients.md b/apps/docs/src/content/docs/guides/websocket-clients.md new file mode 100644 index 0000000..0513e9f --- /dev/null +++ b/apps/docs/src/content/docs/guides/websocket-clients.md @@ -0,0 +1,157 @@ +--- +title: WebSocket ready clients +description: Acquire a shared RPC connection and keep cancellation and replacement application-owned. +--- + +Acquire a flat Effect RPC client inside an application Scope, then pass it to +`createRpcQueryUtils`. The same connection can carry concurrent unary requests, +accumulated streamed queries, and live queries. Each request retains its own query key +and cancellation. + +## Acquire the connection + +Save this module as `socket-client.ts`, and replace `socketGroup` with your shared contract. +Keep the parent Scope alive while any returned utility is in use. The child Scope owns +the socket, protocol, ready client, and QueryClient cleanup. + +```ts +import { QueryClient } from '@tanstack/query-core' +import { Effect, Exit, Layer, Schema, Scope } from 'effect' +import { createRpcQueryUtils } from 'effect-api-query' +import { Rpc, RpcClient, RpcGroup, RpcSerialization } from 'effect/rpc' +import { Socket } from 'effect/socket' + +export const socketGroup = RpcGroup.make( + Rpc.make('values.read', { payload: { id: Schema.Int }, success: Schema.Int }), + Rpc.make('values.watch', { + payload: { channel: Schema.String }, + success: Schema.Int, + stream: true, + }), +) + +export const acquireSocketQueries = Effect.fn('acquireSocketQueries')(function* ( + url: string, + owner: string, +) { + const clientScope = yield* Scope.fork(yield* Effect.scope, 'sequential') + const socket = yield* Socket.makeWebSocket(url).pipe(Scope.provide(clientScope)) + const protocol = yield* Layer.buildWithScope( + RpcClient.layerProtocolSocket().pipe( + Layer.provide(RpcSerialization.layerJson), + Layer.provide(Layer.succeed(Socket.Socket, socket)), + ), + clientScope, + ) + const client = yield* RpcClient.make(socketGroup, { flatten: true }).pipe( + Effect.provide(protocol), + Scope.provide(clientScope), + ) + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const rpc = createRpcQueryUtils(socketGroup, { client, keyPrefix: ['socket', owner] }) + yield* Scope.addFinalizer( + clientScope, + Effect.promise(async () => { + await queryClient.cancelQueries() + queryClient.clear() + }), + ) + return { client, rpc, queryClient, dispose: Scope.close(clientScope, Exit.void) } +}) +``` + +Supply a WebSocket constructor for the host. Browsers can use +`Socket.layerWebSocketConstructorGlobal`; Node applications can supply the existing +`NodeSocket.layerWebSocketConstructorWS` from `@effect/platform-node`. +The server must expose the matching group through `RpcServer.toHttpEffectWebsocket` +and use the same serialization. This recipe uses JSON on both ends. + +## Read and capture stream data + +The following browser function runs two unary reads, then captures the first successful +value from each stream view. Run it within a caller-owned Scope. `fetchStreamSnapshot` +stops and drains local iterator work before returning, so an open stream can supply a +snapshot without waiting for completion. + +```ts +import { Effect } from 'effect' +import { fetchStreamSnapshot } from 'effect-api-query' +import { Socket } from 'effect/socket' + +import { acquireSocketQueries } from './socket-client.ts' + +export const readSocketSnapshot = Effect.fn('readSocketSnapshot')(function* (url: string) { + const application = yield* acquireSocketQueries(url, 'account-42').pipe( + Effect.provide(Socket.layerWebSocketConstructorGlobal), + ) + const { queryClient, rpc } = application + try { + const reads = yield* Effect.promise(() => + Promise.all([ + queryClient.query(rpc.values.read.queryOptions({ input: { id: 1 } })), + queryClient.query(rpc.values.read.queryOptions({ input: { id: 2 } })), + ]), + ) + const input = { channel: 'clock' } + const history = yield* Effect.promise(() => + fetchStreamSnapshot(queryClient, rpc.values.watch.streamedOptions({ input, maxChunks: 20 }), { + timeoutMs: 5_000, + }), + ) + const latest = yield* Effect.promise(() => + fetchStreamSnapshot(queryClient, rpc.values.watch.liveOptions({ input }), { + timeoutMs: 5_000, + }), + ) + return { reads, history, latest } + } finally { + yield* application.dispose + } +}) +``` + +For a long-lived UI, give the generated `streamedOptions` or `liveOptions` to a native +observer instead. Its first emission becomes successful query data while `fetchStatus` +remains `fetching`. Accumulated streams preserve history; live queries retain the latest +value. `maxChunks` controls cached history, and `rpcOptions.streamBufferSize` controls +the ready client's transport queue. They govern different resources. + +## Cancel and replace an owner + +Cancel one stream with its generated exact key, for example +`cancelQueries({ queryKey: options.queryKey, exact: true })`. Other stream keys and +concurrent unary calls continue on the same connection. TanStack retains its native +cancellation result: after a stream has published data, cancellation can settle its +query promise successfully with cached data. + +Call `application.dispose` before disposing the server or an enclosing runtime. The +registered finalizer cancels queries and clears their cache before the client and socket +close. For a replacement owner, await disposal, acquire a new application Scope and +ready client, and construct a new QueryClient. The same semantic prefix can produce +the same keys while resource lifetimes and cache instances remain independent. Follow +[owner-switching rules](/guides/switch-cache-owners/) when authentication changes. + +Replacement acquires a new connection. Transport reconnection, backoff, credentials, +event replay, and whether failed queries retry belong to the application and Effect's +transport configuration. Reconnection does not imply that a cancelled stream resumes +from its last cached event. + +## Interpret cancellation and failures + +The WebSocket transport forwards an interrupted request to the RPC server. Cooperative +handler finalizers were observed on this transport for both stream views. A local abort +signal or idle query state alone cannot prove remote completion; use a domain +acknowledgement when an application must wait for server work to stop. Closing a +connection immediately after cancellation can race delivery of that interrupt. + +An independent server interruption remains an `EffectRpcQueryError`, even when TanStack +has not cancelled the query. Its Cause preserves the failure received by the ready client; +wire serialization can change the original server's Cause representation. A failed stream +leaves already published data available alongside its error. + +With Effect 4.0.0, cancelling a paused stream with a full transport buffer of size 1 can +stall other calls on the connection. This recipe uses the native default of 16. Verify +smaller buffers against the pinned transport version before changing that setting. + +Query cancellation stops future work cooperatively. It does not undo completed writes; +use an [explicit cancellable command](/guides/cancellation/) when the domain requires one. diff --git a/examples/server/tests/fixtures/websocket-server.ts b/examples/server/tests/fixtures/websocket-server.ts new file mode 100644 index 0000000..51a2b08 --- /dev/null +++ b/examples/server/tests/fixtures/websocket-server.ts @@ -0,0 +1,34 @@ +import { NodeHttpServer, NodeSocket } from '@effect/platform-node' +import { Effect, Layer } from 'effect' +import type { Scope, Stream } from 'effect' +import { RpcSerialization, RpcServer } from 'effect/rpc' +import type { Socket } from 'effect/socket' +import { createServer } from 'node:http' + +import { socketGroup } from '../../../../tests/packed-consumer/socket-client.ts' + +export const serveSocketGroup = Effect.fn('serveSocketGroup')(function* (handlers: { + readonly 'values.read': (input: { readonly id: number }) => Effect.Effect + readonly 'values.watch': (input: { readonly channel: string }) => Stream.Stream +}) { + const server = yield* NodeHttpServer.make(createServer, { + host: '127.0.0.1', + port: 0, + disablePreemptiveShutdown: true, + }) + if (server.address._tag === 'UnixPathAddress') { + return yield* Effect.die(new Error('Expected TCP address')) + } + const app = yield* RpcServer.toHttpEffectWebsocket(socketGroup).pipe( + Effect.provide(Layer.merge(socketGroup.toLayer(handlers), RpcSerialization.layerJson)), + ) + yield* server.serve(app) + return `ws://127.0.0.1:${String(server.address.port)}/rpc` +}) + +export const runSocketCase = async ( + effect: Effect.Effect, +) => + await Effect.runPromise( + effect.pipe(Effect.provide(NodeSocket.layerWebSocketConstructorWS), Effect.scoped), + ) diff --git a/examples/server/tests/rpc-websocket-consumer.test.ts b/examples/server/tests/rpc-websocket-consumer.test.ts new file mode 100644 index 0000000..10d2050 --- /dev/null +++ b/examples/server/tests/rpc-websocket-consumer.test.ts @@ -0,0 +1,44 @@ +import { expect, it } from '@effect/vitest' +import { Effect } from 'effect' + +import { + exerciseSocketConcurrency, + exerciseSocketInterruption, + exerciseSocketReplacement, +} from '../../../tests/packed-consumer/websocket-checks.ts' + +it.live( + 'runs concurrent reads and both open stream views through a ready WebSocket client', + () => + Effect.gen(function* () { + expect(yield* Effect.promise(exerciseSocketConcurrency)).toStrictEqual({ + reads: [10, 20], + history: [1], + latest: 1, + historyCancelled: true, + liveContinued: 2, + liveCancelled: true, + }) + }), + 20_000, +) + +it.live('retires one socket owner before disposal and acquires an independent replacement', () => + Effect.gen(function* () { + expect(yield* Effect.promise(exerciseSocketReplacement)).toStrictEqual({ + oldOwnerCancelled: true, + unrelatedOwnerContinued: 2, + replacementValue: 1, + disconnectsAfterQueryCancellation: true, + }) + }), +) + +it.live('preserves independently interrupted wire Causes while other socket calls continue', () => + Effect.gen(function* () { + expect(yield* Effect.promise(exerciseSocketInterruption)).toStrictEqual({ + comparedWireCauses: 5, + unaffectedRead: 30, + }) + }), +) diff --git a/scripts/verify-docs-examples.mts b/scripts/verify-docs-examples.mts index 9602e90..555781c 100644 --- a/scripts/verify-docs-examples.mts +++ b/scripts/verify-docs-examples.mts @@ -10,6 +10,10 @@ for (const [page, fixtures] of [ ['guides/vue-and-solid', ['packed-consumer/docs-vue-query', 'packed-consumer/docs-solid-query']], ['guides/switch-cache-owners', ['packed-consumer/docs-owner-cache']], ['guides/refresh-from-events', ['packed-consumer/docs-user-events']], + [ + 'guides/websocket-clients', + ['packed-consumer/socket-client', 'packed-consumer/docs-websocket-use'], + ], ['guides/stream-snapshots', ['packed-consumer/docs-stream-snapshot']], [ 'guides/cache-filters', diff --git a/scripts/verify-packed-consumer.mts b/scripts/verify-packed-consumer.mts index 247f3c6..b9f25ef 100644 --- a/scripts/verify-packed-consumer.mts +++ b/scripts/verify-packed-consumer.mts @@ -21,6 +21,7 @@ import { tmpdir } from 'node:os' import nodePath from 'node:path' import ts from 'typescript-5.9' +import serverManifest from '../examples/server/package.json' with { type: 'json' } import repositoryManifest from '../package.json' with { type: 'json' } const repositoryRoot = nodePath.resolve(import.meta.dirname, '..') @@ -412,6 +413,21 @@ const verifyConsumer = (peer: (typeof peerCases)[number]): void => { try { cpSync(consumerFixtureDirectory, consumerDirectory, { recursive: true }) + writeFileSync( + nodePath.join(consumerDirectory, 'websocket-server.ts'), + readFileSync( + nodePath.join(repositoryRoot, 'examples/server/tests/fixtures/websocket-server.ts'), + 'utf-8', + ).replaceAll("'../../../../tests/packed-consumer/socket-client.ts'", "'./socket-client.ts'"), + ) + const socketChecksPath = nodePath.join(consumerDirectory, 'websocket-checks.ts') + writeFileSync( + socketChecksPath, + readFileSync(socketChecksPath, 'utf-8').replaceAll( + "'../../examples/server/tests/fixtures/websocket-server.ts'", + "'./websocket-server.ts'", + ), + ) cpSync( nodePath.join(repositoryRoot, 'examples', 'contracts', 'src'), nodePath.join(consumerDirectory, 'optimistic-contracts'), @@ -573,6 +589,7 @@ const verifyConsumer = (peer: (typeof peerCases)[number]): void => { type: 'module', intent: { skills: ['effect-api-query'] }, dependencies: { + '@effect/platform-node': serverManifest.dependencies['@effect/platform-node'], '@tanstack/query-core': peer.queryCoreVersion, '@tanstack/react-query': peer.reactQueryVersion, '@tanstack/solid-query': peer.queryCoreVersion, @@ -661,6 +678,7 @@ const verifyConsumer = (peer: (typeof peerCases)[number]): void => { 'start-ssr-runtime.mts', 'optimistic-runtime.mts', 'events-runtime.mts', + 'websocket-runtime.mts', 'hashing-runtime.mts', 'filter-runtime.mts', 'runtime.mts', diff --git a/tests/packed-consumer/docs-websocket-use.ts b/tests/packed-consumer/docs-websocket-use.ts new file mode 100644 index 0000000..7f74872 --- /dev/null +++ b/tests/packed-consumer/docs-websocket-use.ts @@ -0,0 +1,34 @@ +import { Effect } from 'effect' +import { fetchStreamSnapshot } from 'effect-api-query' +import { Socket } from 'effect/socket' + +import { acquireSocketQueries } from './socket-client.ts' + +export const readSocketSnapshot = Effect.fn('readSocketSnapshot')(function* (url: string) { + const application = yield* acquireSocketQueries(url, 'account-42').pipe( + Effect.provide(Socket.layerWebSocketConstructorGlobal), + ) + const { queryClient, rpc } = application + try { + const reads = yield* Effect.promise(() => + Promise.all([ + queryClient.query(rpc.values.read.queryOptions({ input: { id: 1 } })), + queryClient.query(rpc.values.read.queryOptions({ input: { id: 2 } })), + ]), + ) + const input = { channel: 'clock' } + const history = yield* Effect.promise(() => + fetchStreamSnapshot(queryClient, rpc.values.watch.streamedOptions({ input, maxChunks: 20 }), { + timeoutMs: 5_000, + }), + ) + const latest = yield* Effect.promise(() => + fetchStreamSnapshot(queryClient, rpc.values.watch.liveOptions({ input }), { + timeoutMs: 5_000, + }), + ) + return { reads, history, latest } + } finally { + yield* application.dispose + } +}) diff --git a/tests/packed-consumer/socket-client.ts b/tests/packed-consumer/socket-client.ts new file mode 100644 index 0000000..881d74c --- /dev/null +++ b/tests/packed-consumer/socket-client.ts @@ -0,0 +1,43 @@ +import { QueryClient } from '@tanstack/query-core' +import { Effect, Exit, Layer, Schema, Scope } from 'effect' +import { createRpcQueryUtils } from 'effect-api-query' +import { Rpc, RpcClient, RpcGroup, RpcSerialization } from 'effect/rpc' +import { Socket } from 'effect/socket' + +export const socketGroup = RpcGroup.make( + Rpc.make('values.read', { payload: { id: Schema.Int }, success: Schema.Int }), + Rpc.make('values.watch', { + payload: { channel: Schema.String }, + success: Schema.Int, + stream: true, + }), +) + +export const acquireSocketQueries = Effect.fn('acquireSocketQueries')(function* ( + url: string, + owner: string, +) { + const clientScope = yield* Scope.fork(yield* Effect.scope, 'sequential') + const socket = yield* Socket.makeWebSocket(url).pipe(Scope.provide(clientScope)) + const protocol = yield* Layer.buildWithScope( + RpcClient.layerProtocolSocket().pipe( + Layer.provide(RpcSerialization.layerJson), + Layer.provide(Layer.succeed(Socket.Socket, socket)), + ), + clientScope, + ) + const client = yield* RpcClient.make(socketGroup, { flatten: true }).pipe( + Effect.provide(protocol), + Scope.provide(clientScope), + ) + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const rpc = createRpcQueryUtils(socketGroup, { client, keyPrefix: ['socket', owner] }) + yield* Scope.addFinalizer( + clientScope, + Effect.promise(async () => { + await queryClient.cancelQueries() + queryClient.clear() + }), + ) + return { client, rpc, queryClient, dispose: Scope.close(clientScope, Exit.void) } +}) diff --git a/tests/packed-consumer/tsconfig.json b/tests/packed-consumer/tsconfig.json index 9baff11..e6d640c 100644 --- a/tests/packed-consumer/tsconfig.json +++ b/tests/packed-consumer/tsconfig.json @@ -21,6 +21,11 @@ "optimistic-runtime.mts", "optimistic-contract.ts", "events-runtime.mts", + "socket-client.ts", + "docs-websocket-use.ts", + "websocket-checks.ts", + "websocket-server.ts", + "websocket-runtime.mts", "events-contract.ts", "docs-user-events.ts", "user-events.ts", diff --git a/tests/packed-consumer/websocket-checks.ts b/tests/packed-consumer/websocket-checks.ts new file mode 100644 index 0000000..75e2b74 --- /dev/null +++ b/tests/packed-consumer/websocket-checks.ts @@ -0,0 +1,297 @@ +import { isCancelledError } from '@tanstack/query-core' +import type { QueryClient, QueryKey } from '@tanstack/query-core' +import { Cause, Deferred, Effect, Equal, Exit, Stream } from 'effect' +import { isEffectRpcQueryError } from 'effect-api-query' +import { RpcClient } from 'effect/rpc' +import { deepStrictEqual, equal, ok } from 'node:assert/strict' + +import { + runSocketCase, + serveSocketGroup, +} from '../../examples/server/tests/fixtures/websocket-server.ts' +import { acquireSocketQueries } from './socket-client.ts' + +const wait =
(deferred: Deferred.Deferred) => + Deferred.await(deferred).pipe(Effect.timeout('5 seconds')) + +const waitForData = async ( + queryClient: QueryClient, + queryKey: QueryKey, + expected: number | readonly number[], +) => { + const published = Deferred.makeUnsafe() + const check = () => { + const data = queryClient.getQueryData(queryKey) + if (JSON.stringify(data) === JSON.stringify(expected)) { + Effect.runSync(Deferred.succeed(published, undefined)) + } + } + const unsubscribe = queryClient.getQueryCache().subscribe(check) + try { + check() + await Effect.runPromise(wait(published)) + } finally { + unsubscribe() + } +} + +const settle = async (promise: PromiseLike) => { + try { + return { status: 'success', data: await promise } as const + } catch (error) { + return { status: 'failure', error } as const + } +} + +export const exerciseSocketConcurrency = async () => + await runSocketCase( + Effect.gen(function* () { + const readsStarted = yield* Deferred.make() + const releaseReads = yield* Deferred.make() + const releaseSecond = yield* Deferred.make() + const historyClosed = yield* Deferred.make() + const liveClosed = yield* Deferred.make() + let started = 0 + const url = yield* serveSocketGroup({ + 'values.read': Effect.fnUntraced(function* ({ id }) { + started += 1 + if (started === 2) yield* Deferred.succeed(readsStarted, undefined) + yield* Deferred.await(releaseReads) + return id * 10 + }), + 'values.watch': ({ channel }) => + Stream.succeed(1).pipe( + Stream.concat(Stream.fromEffect(Deferred.await(releaseSecond).pipe(Effect.as(2)))), + Stream.concat(Stream.fromEffect(Effect.never)), + Stream.ensuring( + Deferred.succeed(channel === 'history' ? historyClosed : liveClosed, undefined).pipe( + Effect.asVoid, + ), + ), + ), + }) + const { queryClient, rpc, dispose } = yield* acquireSocketQueries(url, 'first') + const reads = [1, 2].map((id) => + queryClient.query(rpc.values.read.queryOptions({ input: { id } })), + ) + const historyOptions = rpc.values.watch.streamedOptions({ + input: { channel: 'history' }, + rpcOptions: { streamBufferSize: 16 }, + }) + const liveOptions = rpc.values.watch.liveOptions({ + input: { channel: 'live' }, + rpcOptions: { streamBufferSize: 16 }, + }) + const history = settle(queryClient.query(historyOptions)) + const live = settle(queryClient.query(liveOptions)) + yield* wait(readsStarted) + yield* Effect.promise(() => waitForData(queryClient, historyOptions.queryKey, [1])) + yield* Effect.promise(() => waitForData(queryClient, liveOptions.queryKey, 1)) + equal(queryClient.getQueryState(historyOptions.queryKey)?.status, 'success') + equal(queryClient.getQueryState(historyOptions.queryKey)?.fetchStatus, 'fetching') + equal(queryClient.getQueryState(liveOptions.queryKey)?.status, 'success') + equal(queryClient.getQueryState(liveOptions.queryKey)?.fetchStatus, 'fetching') + equal(yield* Deferred.isDone(historyClosed), false) + equal(yield* Deferred.isDone(liveClosed), false) + yield* Effect.promise(() => + queryClient.cancelQueries({ queryKey: historyOptions.queryKey, exact: true }), + ) + yield* wait(historyClosed) + const historyResult = yield* Effect.promise(() => history) + equal(historyResult.status, 'success') + if (historyResult.status === 'success') deepStrictEqual(historyResult.data, [1]) + equal(yield* Deferred.isDone(liveClosed), false) + equal(queryClient.getQueryState(liveOptions.queryKey)?.fetchStatus, 'fetching') + yield* Deferred.succeed(releaseSecond, undefined) + yield* Effect.promise(() => waitForData(queryClient, liveOptions.queryKey, 2)) + yield* Deferred.succeed(releaseReads, undefined) + const values = yield* Effect.promise(() => Promise.all(reads)) + deepStrictEqual(values, [10, 20]) + yield* Effect.promise(() => + queryClient.cancelQueries({ queryKey: liveOptions.queryKey, exact: true }), + ) + yield* wait(liveClosed) + const liveResult = yield* Effect.promise(() => live) + equal(liveResult.status, 'success') + if (liveResult.status === 'success') equal(liveResult.data, 2) + yield* dispose + equal(queryClient.isFetching(), 0) + equal(queryClient.getQueryCache().getAll().length, 0) + return { + reads: values, + history: [1], + latest: 1, + historyCancelled: true, + liveContinued: 2, + liveCancelled: true, + } + }), + ) + +export const exerciseSocketInterruption = async () => + await runSocketCase( + Effect.gen(function* () { + const readStarted = yield* Deferred.make() + const releaseRead = yield* Deferred.make() + const url = yield* serveSocketGroup({ + 'values.read': Effect.fnUntraced(function* ({ id }) { + if (id < 0) return yield* Effect.failCause(Cause.interrupt(42)) + yield* Deferred.succeed(readStarted, undefined) + yield* Deferred.await(releaseRead) + return id * 10 + }), + 'values.watch': ({ channel }) => { + const interruption = Stream.failCause(Cause.interrupt(42)) + return channel.endsWith('emitted') + ? Stream.succeed(7).pipe(Stream.concat(interruption)) + : interruption + }, + }) + const { client, rpc, queryClient, dispose } = yield* acquireSocketQueries(url, 'interrupted') + const unaffected = queryClient.query(rpc.values.read.queryOptions({ input: { id: 3 } })) + yield* wait(readStarted) + const nativeUnary = yield* client('values.read', { id: -1 }).pipe(Effect.exit) + const generatedUnary = yield* Effect.promise(() => + settle(queryClient.query(rpc.values.read.queryOptions({ input: { id: -1 } }))), + ) + ok(Exit.isFailure(nativeUnary)) + equal(generatedUnary.status, 'failure') + if (generatedUnary.status === 'failure') { + ok(isEffectRpcQueryError(generatedUnary.error)) + equal(generatedUnary.error.operation, 'query') + equal(Equal.equals(nativeUnary.cause, generatedUnary.error.cause), true) + equal(Cause.hasInterruptsOnly(generatedUnary.error.cause), true) + } + for (const view of ['streamed', 'live'] as const) { + for (const emitted of [false, true]) { + const input = { channel: `${view}-${emitted ? 'emitted' : 'empty'}` } + const nativeStream = yield* client('values.watch', input, { + streamBufferSize: 16, + }).pipe(Stream.runCollect, Effect.exit) + const options = + view === 'streamed' + ? rpc.values.watch.streamedOptions({ input, rpcOptions: { streamBufferSize: 16 } }) + : rpc.values.watch.liveOptions({ input, rpcOptions: { streamBufferSize: 16 } }) + const generatedStream = yield* Effect.promise(() => settle(queryClient.query(options))) + ok(Exit.isFailure(nativeStream)) + equal(generatedStream.status, 'failure') + if (generatedStream.status === 'failure') { + ok(isEffectRpcQueryError(generatedStream.error)) + equal(generatedStream.error.rpcTag, 'values.watch') + equal(generatedStream.error.operation, view) + equal(Equal.equals(nativeStream.cause, generatedStream.error.cause), true) + equal(Cause.hasInterruptsOnly(generatedStream.error.cause), true) + equal(queryClient.getQueryState(options.queryKey)?.error, generatedStream.error) + equal(queryClient.getQueryState(options.queryKey)?.status, 'error') + } + deepStrictEqual( + queryClient.getQueryData(options.queryKey), + emitted ? (view === 'streamed' ? [7] : 7) : undefined, + ) + equal( + queryClient.getQueryState(rpc.values.read.queryKey({ id: 3 }))?.fetchStatus, + 'fetching', + ) + } + } + yield* Deferred.succeed(releaseRead, undefined) + const unaffectedRead = yield* Effect.promise(() => unaffected) + equal(unaffectedRead, 30) + yield* dispose + return { comparedWireCauses: 5, unaffectedRead } + }), + ) + +export const exerciseSocketReplacement = async () => + await runSocketCase( + Effect.gen(function* () { + const oldClosed = yield* Deferred.make() + const unrelatedClosed = yield* Deferred.make() + const replacementClosed = yield* Deferred.make() + const releaseSecond = yield* Deferred.make() + const readStarted = yield* Deferred.make() + const disconnected = yield* Deferred.make() + const ownerFinalizers = [oldClosed, replacementClosed] + const url = yield* serveSocketGroup({ + 'values.read': () => + Deferred.succeed(readStarted, undefined).pipe(Effect.andThen(Effect.never)), + 'values.watch': ({ channel }) => { + const finalized = channel === 'owner-a' ? ownerFinalizers.shift() : unrelatedClosed + if (finalized === undefined) return Stream.die(new Error('Unexpected owner acquisition')) + return Stream.succeed(1).pipe( + Stream.concat(Stream.fromEffect(Deferred.await(releaseSecond).pipe(Effect.as(2)))), + Stream.concat(Stream.fromEffect(Effect.never)), + Stream.ensuring(Deferred.succeed(finalized, undefined).pipe(Effect.asVoid)), + ) + }, + }) + let retiringQueries: QueryClient | undefined + const oldOwner = yield* acquireSocketQueries(url, 'a').pipe( + Effect.provideService(RpcClient.ConnectionHooks, { + onConnect: Effect.void, + onDisconnect: Effect.sync(() => { + equal(retiringQueries?.isFetching(), 0) + equal(retiringQueries?.getQueryCache().getAll().length, 0) + Effect.runSync(Deferred.succeed(disconnected, undefined)) + }), + }), + ) + retiringQueries = oldOwner.queryClient + const unrelatedOwner = yield* acquireSocketQueries(url, 'b') + const oldOptions = oldOwner.rpc.values.watch.liveOptions({ input: { channel: 'owner-a' } }) + const unrelatedOptions = unrelatedOwner.rpc.values.watch.liveOptions({ + input: { channel: 'owner-b' }, + }) + const oldRead = settle( + oldOwner.queryClient.query(oldOwner.rpc.values.read.queryOptions({ input: { id: 1 } })), + ) + const oldStream = settle(oldOwner.queryClient.query(oldOptions)) + const unrelatedStream = settle(unrelatedOwner.queryClient.query(unrelatedOptions)) + yield* wait(readStarted) + yield* Effect.promise(() => waitForData(oldOwner.queryClient, oldOptions.queryKey, 1)) + yield* Effect.promise(() => + waitForData(unrelatedOwner.queryClient, unrelatedOptions.queryKey, 1), + ) + yield* oldOwner.dispose + yield* wait(disconnected) + yield* wait(oldClosed) + const cancelledRead = yield* Effect.promise(() => oldRead) + equal(cancelledRead.status, 'failure') + if (cancelledRead.status === 'failure') equal(isCancelledError(cancelledRead.error), true) + yield* Effect.promise(() => oldStream) + equal(yield* Deferred.isDone(unrelatedClosed), false) + equal( + unrelatedOwner.queryClient.getQueryState(unrelatedOptions.queryKey)?.fetchStatus, + 'fetching', + ) + const replacement = yield* acquireSocketQueries(url, 'a') + const replacementOptions = replacement.rpc.values.watch.liveOptions({ + input: { channel: 'owner-a' }, + }) + deepStrictEqual(replacementOptions.queryKey, oldOptions.queryKey) + equal(replacement.queryClient === oldOwner.queryClient, false) + const replacementStream = settle(replacement.queryClient.query(replacementOptions)) + yield* Effect.promise(() => + waitForData(replacement.queryClient, replacementOptions.queryKey, 1), + ) + yield* Deferred.succeed(releaseSecond, undefined) + yield* Effect.promise(() => + waitForData(unrelatedOwner.queryClient, unrelatedOptions.queryKey, 2), + ) + yield* Effect.promise(() => + waitForData(replacement.queryClient, replacementOptions.queryKey, 2), + ) + equal(oldOwner.queryClient.getQueryCache().getAll().length, 0) + yield* unrelatedOwner.dispose + yield* replacement.dispose + yield* wait(unrelatedClosed) + yield* wait(replacementClosed) + yield* Effect.promise(() => Promise.all([unrelatedStream, replacementStream])) + return { + oldOwnerCancelled: true, + unrelatedOwnerContinued: 2, + replacementValue: 1, + disconnectsAfterQueryCancellation: true, + } + }), + ) diff --git a/tests/packed-consumer/websocket-runtime.mts b/tests/packed-consumer/websocket-runtime.mts new file mode 100644 index 0000000..c27020f --- /dev/null +++ b/tests/packed-consumer/websocket-runtime.mts @@ -0,0 +1,13 @@ +import { + exerciseSocketConcurrency, + exerciseSocketInterruption, + exerciseSocketReplacement, +} from './websocket-checks.ts' + +await exerciseSocketConcurrency() +await exerciseSocketInterruption() +await exerciseSocketReplacement() + +console.log( + 'Packed WebSocket concurrent queries, wire Causes, remote cancellation, and owner replacement executed', +) diff --git a/tsconfig.json b/tsconfig.json index 5a9393a..e445caa 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -39,6 +39,8 @@ "examples/server/tests/http-api.test.ts", "examples/server/tests/server.test.ts", "examples/server/tests/rpc-transport-overhead.test.ts", + "examples/server/tests/rpc-websocket-consumer.test.ts", + "examples/server/tests/fixtures/websocket-server.ts", "examples/tanstack-start/src/lib/application.ts", "examples/tanstack-start/tests/server-render.test.tsx", "examples/vite-react/src/lib/application.ts", From 685c82a2cfa7a1442d67baee51a2a888bbb4e0f9 Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Sat, 10 Oct 2026 13:23:01 +0200 Subject: [PATCH 58/97] Verify generated OpenAPI HTTP consumers --- .fallowrc.jsonc | 8 + apps/docs/astro.config.mjs | 1 + .../reference/compatibility-and-limits.md | 5 + .../docs/reference/external-http-clients.md | 104 ++++++ scripts/verify-docs-examples.mts | 1 + scripts/verify-packed-consumer.mts | 12 + tests/packed-consumer/docs-openapi.ts | 37 +++ tests/packed-consumer/openapi-api.ts | 76 +++++ tests/packed-consumer/openapi-contract.ts | 95 ++++++ tests/packed-consumer/openapi-generate.mjs | 17 + tests/packed-consumer/openapi-runtime.mts | 307 ++++++++++++++++++ tests/packed-consumer/tsconfig.openapi.json | 4 + 12 files changed, 667 insertions(+) create mode 100644 apps/docs/src/content/docs/reference/external-http-clients.md create mode 100644 tests/packed-consumer/docs-openapi.ts create mode 100644 tests/packed-consumer/openapi-api.ts create mode 100644 tests/packed-consumer/openapi-contract.ts create mode 100644 tests/packed-consumer/openapi-generate.mjs create mode 100644 tests/packed-consumer/openapi-runtime.mts create mode 100644 tests/packed-consumer/tsconfig.openapi.json diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index a5b430e..0a46b40 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -22,6 +22,10 @@ "tests/packed-consumer/filter-runtime.mts", "tests/packed-consumer/filter-contract.ts", "tests/packed-consumer/http-metadata-runtime.mts", + "tests/packed-consumer/openapi-contract.ts", + "tests/packed-consumer/openapi-runtime.mts", + "tests/packed-consumer/openapi-generate.mjs", + "tests/packed-consumer/docs-openapi.ts", "tests/packed-consumer/http-etag-recipe.mts", "tests/packed-consumer/http-stream-runtime.mts", "tests/packed-consumer/http-live-runtime.mts", @@ -44,6 +48,7 @@ ], "ignorePatterns": ["examples/tanstack-start/src/routeTree.gen.ts"], + "ignoreUnresolvedImports": ["./openapi-generated.ts"], // Framework tooling consumes these exports through convention-generated entries. "ignoreExports": [ @@ -105,10 +110,13 @@ "@arethetypeswrong/cli", "@effect/tsgo", "@effect/vitest", + "@effect/platform-node", "@shadcn/lint", "@tanstack/angular-query-experimental", "@tanstack/svelte-query", "effect", + "openapi-fetch", + "openapi-typescript", "publint", "svelte", "typescript-5.9", diff --git a/apps/docs/astro.config.mjs b/apps/docs/astro.config.mjs index 0a8cb81..5e9bc96 100644 --- a/apps/docs/astro.config.mjs +++ b/apps/docs/astro.config.mjs @@ -106,6 +106,7 @@ export default defineConfig({ { label: 'Errors', slug: 'reference/errors' }, { label: 'Public Exports', slug: 'reference/public-exports' }, { label: 'Browser and Worker Hosts', slug: 'reference/browser-and-worker-hosts' }, + { label: 'External HTTP Clients', slug: 'reference/external-http-clients' }, { label: 'Compatibility and Limits', slug: 'reference/compatibility-and-limits' }, ], }, diff --git a/apps/docs/src/content/docs/reference/compatibility-and-limits.md b/apps/docs/src/content/docs/reference/compatibility-and-limits.md index 0d856ad..aa42c30 100644 --- a/apps/docs/src/content/docs/reference/compatibility-and-limits.md +++ b/apps/docs/src/content/docs/reference/compatibility-and-limits.md @@ -73,6 +73,11 @@ calls that do not need TanStack Query. framework-free execution matrix and distinguishes worker-hosted ready clients from a worker RPC transport. +[External HTTP clients](/effect-api-query/reference/external-http-clients/) covers generated +OpenAPI consumption with `openapi-fetch` 0.17.0 and `openapi-typescript` 7.13.0. Encoded DTOs, +multipart serialization, and raw SSE access retain their own contracts; the external client does +not replace a ready Effect client. + ## Runtime and cache behavior - The caller owns RPC or HTTP client acquisition, `Scope`, transport, Query Client, providers, router, SSR, diff --git a/apps/docs/src/content/docs/reference/external-http-clients.md b/apps/docs/src/content/docs/reference/external-http-clients.md new file mode 100644 index 0000000..e7300b0 --- /dev/null +++ b/apps/docs/src/content/docs/reference/external-http-clients.md @@ -0,0 +1,104 @@ +--- +title: External HTTP Clients +description: Generated OpenAPI consumers, encoded request values, multipart uploads, and raw SSE limits. +--- + +An external HTTP client can call an Effect HttpApi server through its declared HTTP contract. +The supported combination is `openapi-fetch` 0.17.0 with `openapi-typescript` 7.13.0 and Effect 4.0.0. +Generate `paths` from the JSON document returned by native `OpenApi.fromApi(api)`. + +`createHttpApiQueryUtils` accepts a ready Effect HttpApiClient. Keep that client and its runner +native; the external Promise client calls HTTP independently. This interoperability does not +establish compatibility with the Effect RPC wire protocol. + +## Encoded and decoded values + +OpenAPI describes encoded HTTP values. An endpoint using `Schema.NumberFromString` accepts a +string on the wire and a number through the ready Effect client. A generated external client +returns a plain encoded DTO; native Schema decoding constructs the domain value. +The external client's generated types add no runtime Schema decoding or validation. + +| Value | External client | Ready Effect client | +| ----------------------- | --------------------------------- | ------------------------------------------------------------- | +| Request path parameter | `params.path.id: '5'` | `params.id: 5` | +| Request query parameter | `params.query.limit: '2'` | `query.limit: 2` | +| Declared request header | `params.header['x-factor']: '3'` | `headers['x-factor']: 3` | +| JSON request payload | `body.amount: '7'` | `payload.amount: 7` | +| Response body | Plain object with `total: '17'` | Schema class with `total: 17` and methods | +| Response headers | Raw strings on `response.headers` | Decoded declared header wrapper; raw strings in metadata view | + +## Buffered requests and uploads + +The report contract below declares `POST /reports/:id`, a JSON payload, query and header fields, +and a 203 response. Its missing-report response has status 404 and a tagged JSON body. An update +uses `PATCH` and declares a 409 conflict. Read declared failures through `error` and inspect +`response.status`; the external client does not construct an Effect execution-error wrapper. + +Array query parameters use repeated names, including a single-element array. JSON array +responses retain their ordinary JSON representation. This scope does not include GET payloads +or form-urlencoded payloads. + +For `multipart/form-data`, map a binary schema to `Blob` with the generator's `transform` hook. +Use a `bodySerializer` that returns `FormData`. Let Fetch set the multipart boundary; the default +JSON serializer receives HTTP 415 from a multipart-only endpoint. The example upload includes +path/query/header values, a text field, and a file with its filename and contents intact. + +```ts +import createClient from 'openapi-fetch' + +import type { paths } from './openapi-generated.ts' + +const external = createClient({ baseUrl: 'https://api.example.test' }) +const report = await external.POST('/reports/{id}', { + params: { + path: { id: '5' }, + query: { limit: '2', labels: ['a b', 'c+d'] }, + header: { 'x-factor': '3' }, + }, + body: { amount: '7', title: 'Quarter 1' }, +}) +if (report.error) { + throw new Error(`Report failed with HTTP ${report.response.status}`) +} +const revision = report.response.headers.get('x-revision') + +const uploaded = await external.POST('/reports/{id}/files', { + params: { + path: { id: '5' }, + query: { revision: '3' }, + header: { 'x-kind': 'document' }, + }, + body: { title: 'Notes', file: new File(['contents'], 'notes.txt') }, + bodySerializer: (body) => { + const form = new FormData() + form.set('title', body.title) + form.set('file', body.file) + return form + }, +}) +const events = await external.GET('/reports/events/{channel}', { + params: { path: { channel: 'values' } }, + parseAs: 'stream', +}) +export { events, report, revision, uploaded } +``` + +The external client's `Response` retains HTTP status and raw headers after buffering. The native +HTTP metadata view retains decoded data with immutable plain status/header snapshots. These +are distinct representations. + +## SSE boundary + +`parseAs: 'stream'` returns a raw `ReadableStream`. It does not parse events, decode +Schema values, or turn a reserved failure event into an error. Default JSON parsing fails on +an SSE response. + +Effect's SSE data mode encodes each value as JSON in an event's `data` field. A custom event can +also carry `id` and `event`. A stream failure uses the reserved event name +`effect/http-api/stream/failure` and an encoded Cause body while HTTP status remains 200. +The generated document records these details in `x-effect-stream`; this client does not consume +that extension. Typed SSE consumption requires the native ready Effect client or an +application-owned compatible decoder. Raw stream access alone gives no typed SSE guarantee. + +See the [external client's API](https://openapi-ts.dev/openapi-fetch/api) and +[generator's Node API](https://openapi-ts.dev/node) for their configuration. diff --git a/scripts/verify-docs-examples.mts b/scripts/verify-docs-examples.mts index a84e001..e20a262 100644 --- a/scripts/verify-docs-examples.mts +++ b/scripts/verify-docs-examples.mts @@ -6,6 +6,7 @@ import { readFileSync } from 'node:fs' for (const [page, fixtures] of [ ['getting-started/quick-start', ['types/docs-rpc-quick-start']], ['getting-started/http-quick-start', ['types/docs-http-quick-start']], + ['reference/external-http-clients', ['packed-consumer/docs-openapi']], ['guides/retry-queries', ['packed-consumer/docs-retry']], ['guides/svelte-and-angular', ['packed-consumer/docs-angular']], ['guides/vue-and-solid', ['packed-consumer/docs-vue-query', 'packed-consumer/docs-solid-query']], diff --git a/scripts/verify-packed-consumer.mts b/scripts/verify-packed-consumer.mts index 437da78..0e540d7 100644 --- a/scripts/verify-packed-consumer.mts +++ b/scripts/verify-packed-consumer.mts @@ -21,6 +21,7 @@ import { tmpdir } from 'node:os' import nodePath from 'node:path' import ts from 'typescript-5.9' +import serverManifest from '../examples/server/package.json' with { type: 'json' } import repositoryManifest from '../package.json' with { type: 'json' } const repositoryRoot = nodePath.resolve(import.meta.dirname, '..') @@ -387,6 +388,7 @@ const runTypeScript = ( compiler: (typeof compilerCases)[number], project: | 'tsconfig.json' + | 'tsconfig.openapi.json' | 'tsconfig.tanstack-start.json' | 'tsconfig.type-scale.json' | 'tsconfig.svelte-angular.json' @@ -585,6 +587,7 @@ const verifyConsumer = (peer: (typeof peerCases)[number]): void => { '@angular/compiler': '22.2.1', '@angular/core': '22.2.1', '@angular/platform-browser': '22.2.1', + '@effect/platform-node': serverManifest.dependencies['@effect/platform-node'], '@tanstack/angular-query-experimental': peer.queryCoreVersion, '@tanstack/query-core': peer.queryCoreVersion, '@tanstack/react-query': peer.reactQueryVersion, @@ -599,11 +602,14 @@ const verifyConsumer = (peer: (typeof peerCases)[number]): void => { '@types/react-dom': testedVersion('@types/react-dom'), effect: testedVersion('effect'), 'effect-api-query': `file:${tarballPath}`, + 'openapi-fetch': '0.17.0', + 'openapi-typescript': '7.13.0', react: testedVersion('react'), 'react-dom': testedVersion('react-dom'), rxjs: '7.8.2', svelte: '5.57.1', 'solid-js': '1.9.15', + typescript: testedVersion('typescript-5.9').replace('npm:typescript@', ''), vue: '3.5.43', }, } @@ -622,6 +628,10 @@ const verifyConsumer = (peer: (typeof peerCases)[number]): void => { cwd: consumerDirectory, stdio: 'inherit', }) + execFileSync(process.execPath, ['openapi-generate.mjs'], { + cwd: consumerDirectory, + stdio: 'inherit', + }) const skillUse = 'effect-api-query#effect-api-query' const intentOptions = { cwd: consumerDirectory } @@ -661,6 +671,7 @@ const verifyConsumer = (peer: (typeof peerCases)[number]): void => { for (const compiler of compilerCases) { runTypeScript(consumerDirectory, compiler, 'tsconfig.json', false) + runTypeScript(consumerDirectory, compiler, 'tsconfig.openapi.json', false) runTypeScript(consumerDirectory, compiler, 'tsconfig.tanstack-start.json', false) runTypeScript(consumerDirectory, compiler, 'tsconfig.svelte-angular.json', false) runTypeScript(consumerDirectory, compiler, 'tsconfig.vue-solid.json', false) @@ -682,6 +693,7 @@ const verifyConsumer = (peer: (typeof peerCases)[number]): void => { 'filter-runtime.mts', 'runtime.mts', 'http-runtime.mts', + 'openapi-runtime.mts', 'http-metadata-runtime.mts', 'http-etag-recipe.mts', 'http-stream-runtime.mts', diff --git a/tests/packed-consumer/docs-openapi.ts b/tests/packed-consumer/docs-openapi.ts new file mode 100644 index 0000000..b80ff66 --- /dev/null +++ b/tests/packed-consumer/docs-openapi.ts @@ -0,0 +1,37 @@ +import createClient from 'openapi-fetch' + +import type { paths } from './openapi-generated.ts' + +const external = createClient({ baseUrl: 'https://api.example.test' }) +const report = await external.POST('/reports/{id}', { + params: { + path: { id: '5' }, + query: { limit: '2', labels: ['a b', 'c+d'] }, + header: { 'x-factor': '3' }, + }, + body: { amount: '7', title: 'Quarter 1' }, +}) +if (report.error) { + throw new Error(`Report failed with HTTP ${report.response.status}`) +} +const revision = report.response.headers.get('x-revision') + +const uploaded = await external.POST('/reports/{id}/files', { + params: { + path: { id: '5' }, + query: { revision: '3' }, + header: { 'x-kind': 'document' }, + }, + body: { title: 'Notes', file: new File(['contents'], 'notes.txt') }, + bodySerializer: (body) => { + const form = new FormData() + form.set('title', body.title) + form.set('file', body.file) + return form + }, +}) +const events = await external.GET('/reports/events/{channel}', { + params: { path: { channel: 'values' } }, + parseAs: 'stream', +}) +export { events, report, revision, uploaded } diff --git a/tests/packed-consumer/openapi-api.ts b/tests/packed-consumer/openapi-api.ts new file mode 100644 index 0000000..fb34975 --- /dev/null +++ b/tests/packed-consumer/openapi-api.ts @@ -0,0 +1,76 @@ +import { Schema } from 'effect' +import { Multipart } from 'effect/http' +import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from 'effect/http-api' + +export class Report extends Schema.Class('external-http/Report')({ + total: Schema.NumberFromString, + labels: Schema.Array(Schema.String), + title: Schema.String, +}) { + summary() { + return `${this.title}: ${this.total}` + } +} + +const Missing = Schema.TaggedStruct('Missing', { id: Schema.Number }).pipe( + HttpApiSchema.status(404), +) +const Conflict = Schema.TaggedStruct('Conflict', { expected: Schema.String }).pipe( + HttpApiSchema.status(409), +) +const Expired = Schema.TaggedStruct('Expired', { reason: Schema.String }) + +export const api = HttpApi.make('external-http').add( + HttpApiGroup.make('reports').add( + HttpApiEndpoint.post('read', '/reports/:id', { + params: { id: Schema.NumberFromString }, + query: { limit: Schema.NumberFromString, labels: Schema.Array(Schema.String) }, + headers: { 'x-factor': Schema.NumberFromString }, + payload: Schema.Struct({ amount: Schema.NumberFromString, title: Schema.String }), + success: HttpApiSchema.WithHeaders(Report, { + 'x-revision': Schema.NumberFromString, + etag: Schema.String, + }).pipe(HttpApiSchema.status(203)), + error: Missing, + }), + HttpApiEndpoint.patch('update', '/reports/:id', { + params: { id: Schema.NumberFromString }, + headers: { 'if-match': Schema.String }, + payload: Schema.Struct({ title: Schema.String }), + success: Schema.String, + error: Conflict, + }), + HttpApiEndpoint.get('list', '/reports', { + success: Schema.Array(Schema.Struct({ id: Schema.Number, title: Schema.String })), + }), + HttpApiEndpoint.post('upload', '/reports/:id/files', { + params: { id: Schema.NumberFromString }, + query: { revision: Schema.NumberFromString }, + headers: { 'x-kind': Schema.String }, + payload: Schema.Struct({ title: Schema.String, file: Multipart.SingleFileSchema }).pipe( + HttpApiSchema.asMultipart(), + ), + success: Schema.Struct({ + id: Schema.Number, + revision: Schema.Number, + kind: Schema.String, + title: Schema.String, + fileName: Schema.String, + contents: Schema.String, + }), + }), + HttpApiEndpoint.get('watch', '/reports/events/:channel', { + params: { channel: Schema.String }, + success: HttpApiSchema.StreamSse({ data: Schema.NumberFromString, error: Expired }), + }), + HttpApiEndpoint.get('records', '/reports/records', { + success: HttpApiSchema.StreamSse({ + events: Schema.Struct({ + id: Schema.String, + event: Schema.Literal('changed'), + data: Schema.String, + }), + }), + }), + ), +) diff --git a/tests/packed-consumer/openapi-contract.ts b/tests/packed-consumer/openapi-contract.ts new file mode 100644 index 0000000..d75e502 --- /dev/null +++ b/tests/packed-consumer/openapi-contract.ts @@ -0,0 +1,95 @@ +import { QueryClient } from '@tanstack/query-core' +import { createHttpApiQueryUtils } from 'effect-api-query' +import type { HttpApiClient } from 'effect/http-api' +import createClient from 'openapi-fetch' + +import { events, report, revision, uploaded } from './docs-openapi.ts' +import { api, Report } from './openapi-api.ts' +import type { operations, paths } from './openapi-generated.ts' + +type Equal = + (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false +declare function assertType(): void +assertType['total'], string>>() +assertType>() +assertType['fileName'], string>>() +assertType, ReadableStream>>>() + +declare const ready: HttpApiClient.ForApi +const http = createHttpApiQueryUtils(api, { client: ready, keyPrefix: ['external'] }) +const external = createClient({ baseUrl: 'https://external.test' }) +const encoded = { + params: { + path: { id: '5' }, + query: { limit: '2', labels: ['a b', 'c+d'] }, + header: { 'x-factor': '3' }, + }, + body: { amount: '7', title: 'Quarter 1' }, +} +const response = external.POST('/reports/{id}', encoded) +type ExternalData = NonNullable['data']> +assertType>() +assertType>() +declare const dto: ExternalData +// @ts-expect-error +dto.summary() +// @ts-expect-error +external.GET('/reports/{id}', encoded) +// @ts-expect-error +external.POST('/reports/5', encoded) +// @ts-expect-error +external.POST('/reports/{id}', { ...encoded, body: { amount: 7, title: 'Quarter 1' } }) +// @ts-expect-error +external.POST('/reports/{id}', { body: encoded.body }) +const missing = external.POST('/reports/{id}', { + ...encoded, + params: { ...encoded.params, path: { id: '-1' } }, +}) +type DeclaredFailure = NonNullable['error']> +assertType>() +assertType< + Equal< + operations['reports.update']['responses'][409]['content']['application/json']['_tag'], + 'Conflict' + > +>() + +const native = new QueryClient().query( + http.reports.read.queryOptions({ + input: { + params: { id: 5 }, + query: { limit: 2, labels: ['a b', 'c+d'] }, + headers: { 'x-factor': 3 }, + payload: { amount: 7, title: 'Quarter 1' }, + }, + }), +) +assertType['body'], Report>>() +assertType['headers']['x-revision'], number>>() +// @ts-expect-error +createHttpApiQueryUtils(api, { client: external, keyPrefix: ['external'] }) +// @ts-expect-error +http.reports.read.queryOptions({ input: encoded }) + +type UploadBody = operations['reports.upload']['requestBody']['content']['multipart/form-data'] +assertType>() +external.POST('/reports/{id}/files', { + params: { path: { id: '5' }, query: { revision: '3' }, header: { 'x-kind': 'document' } }, + body: { title: 'Notes', file: new File(['contents'], 'notes.txt') }, + bodySerializer: (body) => { + const data = new FormData() + data.set('title', body.title) + data.set('file', body.file) + return data + }, +}) +const raw = external.GET('/reports/events/{channel}', { + params: { path: { channel: 'values' } }, + parseAs: 'stream', +}) +assertType< + Equal['data']>, ReadableStream>> +>() +declare const bytes: NonNullable['data']> +// @ts-expect-error +bytes.summary() diff --git a/tests/packed-consumer/openapi-generate.mjs b/tests/packed-consumer/openapi-generate.mjs new file mode 100644 index 0000000..719d23b --- /dev/null +++ b/tests/packed-consumer/openapi-generate.mjs @@ -0,0 +1,17 @@ +import { OpenApi } from 'effect/http-api' +import { writeFileSync } from 'node:fs' +import openapiTS, { astToString } from 'openapi-typescript' +import ts from 'typescript' + +import { api } from './openapi-api.ts' + +const document = OpenApi.fromApi(api) +const source = await openapiTS(JSON.stringify(document), { + transform: (schema) => + schema.format === 'binary' ? ts.factory.createTypeReferenceNode('Blob') : undefined, +}) +writeFileSync(`${import.meta.dirname}/openapi.json`, `${JSON.stringify(document, null, 2)}\n`) +writeFileSync( + `${import.meta.dirname}/openapi-generated.ts`, + astToString(source, { formatOptions: { removeComments: true } }), +) diff --git a/tests/packed-consumer/openapi-runtime.mts b/tests/packed-consumer/openapi-runtime.mts new file mode 100644 index 0000000..ed07735 --- /dev/null +++ b/tests/packed-consumer/openapi-runtime.mts @@ -0,0 +1,307 @@ +import { NodeFileSystem, NodePath } from '@effect/platform-node' +import { MutationObserver, QueryClient } from '@tanstack/query-core' +import { Cause, Effect, FileSystem, Layer, Result, Stream } from 'effect' +import { createHttpApiQueryUtils, isEffectHttpApiQueryError } from 'effect-api-query' +import { + Etag, + HttpClient, + HttpClientRequest, + HttpClientResponse, + HttpPlatform, + HttpRouter, +} from 'effect/http' +import { HttpApiBuilder, HttpApiClient, HttpApiSchema, OpenApi } from 'effect/http-api' +import { deepStrictEqual, equal, match, ok, rejects } from 'node:assert/strict' +import createClient from 'openapi-fetch' + +import { api, Report } from './openapi-api.ts' +import type { paths } from './openapi-generated.ts' + +const handlers = HttpApiBuilder.group(api, 'reports', (group) => + group + .handle('read', ({ params, query, headers, payload }) => + params.id < 0 + ? Effect.fail({ _tag: 'Missing' as const, id: params.id }) + : Effect.succeed( + HttpApiSchema.withHeaders({ + body: new Report({ + total: params.id + query.limit + headers['x-factor'] + payload.amount, + labels: [...query.labels], + title: payload.title, + }), + headers: { 'x-revision': 4, etag: '"revision-4"' }, + }), + ), + ) + .handle('update', ({ headers, payload }) => + headers['if-match'] === '"revision-4"' + ? Effect.succeed(payload.title) + : Effect.fail({ _tag: 'Conflict' as const, expected: '"revision-4"' }), + ) + .handle('list', () => Effect.succeed([{ id: 1, title: 'First' }])) + .handle( + 'upload', + Effect.fnUntraced(function* ({ params, query, headers, payload }) { + const fs = yield* FileSystem.FileSystem + const contents = yield* fs.readFileString(payload.file.path).pipe(Effect.orDie) + return { + id: params.id, + revision: query.revision, + kind: headers['x-kind'], + title: payload.title, + fileName: payload.file.name, + contents, + } + }), + ) + .handle('watch', ({ params }) => + Effect.succeed( + params.channel === 'failure' + ? Stream.concat( + Stream.succeed(1), + Stream.fail({ _tag: 'Expired' as const, reason: 'resume' }), + ) + : Stream.make(1, 2), + ), + ) + .handle('records', () => + Effect.succeed(Stream.make({ id: 'v1', event: 'changed' as const, data: 'first' })), + ), +) +const platform = Layer.mergeAll( + NodeFileSystem.layer, + NodePath.layer, + Etag.layerWeak, + HttpPlatform.layer.pipe(Layer.provide(NodeFileSystem.layer)), +) +const web = HttpRouter.toWebHandler( + HttpApiBuilder.layer(api).pipe(Layer.provide(handlers), Layer.provideMerge(platform)), + { disableLogger: true }, +) +const external = createClient({ baseUrl: 'https://external.test', fetch: web.handler }) +const sent: Request[] = [] +external.use({ + onRequest: async ({ request }) => { + const body = await request.clone().arrayBuffer() + sent.push( + new Request(request.url, { + method: request.method, + headers: request.headers, + ...(body.byteLength === 0 ? {} : { body }), + }), + ) + }, +}) +const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) +try { + const options = { + params: { + path: { id: '5' }, + query: { limit: '2', labels: ['a b', 'c+d'] }, + header: { 'x-factor': '3' }, + }, + body: { amount: '7', title: 'Quarter 1' }, + } + const result = await external.POST('/reports/{id}', options) + equal(result.error, undefined) + deepStrictEqual(result.data, { total: '17', labels: ['a b', 'c+d'], title: 'Quarter 1' }) + equal(result.data instanceof Report, false) + equal(result.response.status, 203) + equal(result.response.headers.get('x-revision'), '4') + equal(result.response.headers.get('etag'), '"revision-4"') + equal(result.response.bodyUsed, true) + const request = sent[0] + ok(request !== undefined) + equal(request.method, 'POST') + const url = new URL(request.url) + equal(url.pathname, '/reports/5') + equal(url.searchParams.get('limit'), '2') + deepStrictEqual(url.searchParams.getAll('labels'), ['a b', 'c+d']) + equal(request.headers.get('x-factor'), '3') + equal(request.headers.get('content-type'), 'application/json') + deepStrictEqual(await request.json(), { amount: '7', title: 'Quarter 1' }) + const singleton = await external.POST('/reports/{id}', { + ...options, + params: { ...options.params, query: { limit: '2', labels: ['only'] } }, + }) + equal(singleton.response.status, 203) + deepStrictEqual(singleton.data, { total: '17', labels: ['only'], title: 'Quarter 1' }) + + const decodedWire = await web.handler( + new Request('https://external.test/reports/5?limit=2&labels=one&labels=two', { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-factor': '3' }, + body: JSON.stringify({ amount: 7, title: 'Quarter 1' }), + }), + ) + equal(decodedWire.status, 400) + const missing = await external.POST('/reports/{id}', { + ...options, + params: { ...options.params, path: { id: '-1' } }, + }) + equal(missing.data, undefined) + equal(missing.response.status, 404) + deepStrictEqual(missing.error, { _tag: 'Missing', id: -1 }) + const conflict = await external.PATCH('/reports/{id}', { + params: { path: { id: '5' }, header: { 'if-match': 'stale' } }, + body: { title: 'Changed' }, + }) + equal(conflict.response.status, 409) + deepStrictEqual(conflict.error, { _tag: 'Conflict', expected: '"revision-4"' }) + const updated = await external.PATCH('/reports/{id}', { + params: { path: { id: '5' }, header: { 'if-match': '"revision-4"' } }, + body: { title: 'Changed' }, + }) + equal(updated.data, 'Changed') + const patchRequest = sent.find((item) => item.method === 'PATCH') + ok(patchRequest !== undefined) + equal(new URL(patchRequest.url).pathname, '/reports/5') + equal(patchRequest.headers.get('if-match'), 'stale') + deepStrictEqual(await patchRequest.json(), { title: 'Changed' }) + deepStrictEqual((await external.GET('/reports')).data, [{ id: 1, title: 'First' }]) + + const client = await Effect.runPromise( + HttpApiClient.makeWith(api, { + baseUrl: 'https://external.test', + httpClient: HttpClient.make( + Effect.fnUntraced(function* (request) { + const outgoing = yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie) + const response = yield* Effect.promise(() => web.handler(outgoing)) + return HttpClientResponse.fromWeb(request, response) + }), + ), + }), + ) + const http = createHttpApiQueryUtils(api, { client, keyPrefix: ['external'] }) + const metadata = await queryClient.query( + http.reports.read.metadataOptions({ + input: { + params: { id: 5 }, + query: { limit: 2, labels: ['a b', 'c+d'] }, + headers: { 'x-factor': 3 }, + payload: { amount: 7, title: 'Quarter 1' }, + }, + }), + ) + ok(metadata.data.body instanceof Report) + equal(metadata.data.body.summary(), 'Quarter 1: 17') + equal(metadata.data.headers['x-revision'], 4) + equal(metadata.status, 203) + equal(metadata.headers['x-revision'], '4') + equal(metadata.headers['etag'], '"revision-4"') + equal(Object.isFrozen(metadata), true) + equal(Object.isFrozen(metadata.headers), true) + + const uploadOptions = { + params: { + path: { id: '5' }, + query: { revision: '3' }, + header: { 'x-kind': 'document' }, + }, + body: { + title: 'Release notes', + file: new File(['first upload'], 'notes.txt', { type: 'text/plain' }), + }, + } + equal((await external.POST('/reports/{id}/files', uploadOptions)).response.status, 415) + const upload = await external.POST('/reports/{id}/files', { + ...uploadOptions, + bodySerializer: (body) => { + const data = new FormData() + data.set('title', body.title) + data.set('file', body.file) + return data + }, + }) + const expectedUpload = { + id: 5, + revision: 3, + kind: 'document', + title: 'Release notes', + fileName: 'notes.txt', + contents: 'first upload', + } + equal(upload.error, undefined) + deepStrictEqual(upload.data, expectedUpload) + const uploadRequest = sent.at(-1) + ok(uploadRequest !== undefined) + equal(uploadRequest.method, 'POST') + equal(new URL(uploadRequest.url).pathname, '/reports/5/files') + equal(new URL(uploadRequest.url).searchParams.get('revision'), '3') + equal(uploadRequest.headers.get('x-kind'), 'document') + match(uploadRequest.headers.get('content-type') ?? '', /^multipart\/form-data; boundary=/u) + const form = await uploadRequest.formData() + equal(form.get('title'), 'Release notes') + const file = form.get('file') + ok(file instanceof File) + equal(file.name, 'notes.txt') + equal(await file.text(), 'first upload') + const nativeBody = new FormData() + nativeBody.set('title', 'Release notes') + nativeBody.set('file', uploadOptions.body.file) + deepStrictEqual( + await new MutationObserver(queryClient, http.reports.upload.mutationOptions()).mutate({ + params: { id: 5 }, + query: { revision: 3 }, + headers: { 'x-kind': 'document' }, + payload: nativeBody, + }), + expectedUpload, + ) + + await rejects( + external.GET('/reports/events/{channel}', { params: { path: { channel: 'values' } } }), + SyntaxError, + ) + const streamed = await external.GET('/reports/events/{channel}', { + params: { path: { channel: 'values' } }, + parseAs: 'stream', + }) + ok(streamed.data instanceof ReadableStream) + equal(streamed.response.headers.get('content-type'), 'text/event-stream') + equal(await new Response(streamed.data).text(), 'data: "1"\n\ndata: "2"\n\n') + const records = await external.GET('/reports/records', { parseAs: 'text' }) + equal(records.data, 'id: v1\nevent: changed\ndata: first\n\n') + const streamFailure = await external.GET('/reports/events/{channel}', { + params: { path: { channel: 'failure' } }, + parseAs: 'text', + }) + equal(streamFailure.response.status, 200) + equal(streamFailure.error, undefined) + match(streamFailure.data ?? '', /^data: "1"\n\nevent: effect\/http-api\/stream\/failure\ndata: /u) + match(streamFailure.data ?? '', /"_tag":"Expired","reason":"resume"/u) + deepStrictEqual( + await queryClient.query( + http.reports.watch.streamedOptions({ input: { params: { channel: 'values' } } }), + ), + [1, 2], + ) + equal( + await queryClient.query( + http.reports.watch.liveOptions({ input: { params: { channel: 'values' } } }), + ), + 2, + ) + await rejects( + queryClient.query( + http.reports.watch.streamedOptions({ input: { params: { channel: 'failure' } } }), + ), + (error: unknown) => { + ok(isEffectHttpApiQueryError(error)) + const failure = Cause.findError(error.cause) + ok(Result.isSuccess(failure)) + deepStrictEqual(failure.success, { _tag: 'Expired', reason: 'resume' }) + return true + }, + ) + const document = OpenApi.fromApi(api) + const streaming = + document.paths['/reports/events/{channel}']?.get?.responses['200']?.content?.[ + 'text/event-stream' + ] + equal(streaming?.['x-effect-stream']?.encoding, 'sse') + equal(streaming?.['x-effect-stream']?.failureEvent, 'effect/http-api/stream/failure') +} finally { + queryClient.clear() + await web.dispose() +} diff --git a/tests/packed-consumer/tsconfig.openapi.json b/tests/packed-consumer/tsconfig.openapi.json new file mode 100644 index 0000000..810f824 --- /dev/null +++ b/tests/packed-consumer/tsconfig.openapi.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "include": ["openapi-*.ts", "openapi-*.mts", "docs-openapi.ts"] +} From a1a4045dc0f6f10bfe9cef5fd33b805d0acd4ed7 Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Sat, 10 Oct 2026 13:29:56 +0200 Subject: [PATCH 59/97] Exercise copied WebSocket snapshots across peers --- .fallowrc.jsonc | 1 + apps/docs/astro.config.mjs | 1 + .../content/docs/guides/websocket-clients.md | 4 +-- .../reference/compatibility-and-limits.md | 5 ++++ tests/packed-consumer/websocket-checks.ts | 27 +++++++++++++++++-- 5 files changed, 34 insertions(+), 4 deletions(-) diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index a5b430e..0df7965 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -18,6 +18,7 @@ "tests/packed-consumer/optimistic-runtime.mts", "tests/packed-consumer/optimistic-contract.ts", "tests/packed-consumer/events-runtime.mts", + "tests/packed-consumer/websocket-runtime.mts", "tests/packed-consumer/events-contract.ts", "tests/packed-consumer/filter-runtime.mts", "tests/packed-consumer/filter-contract.ts", diff --git a/apps/docs/astro.config.mjs b/apps/docs/astro.config.mjs index 0a8cb81..49ff314 100644 --- a/apps/docs/astro.config.mjs +++ b/apps/docs/astro.config.mjs @@ -70,6 +70,7 @@ export default defineConfig({ { label: 'Vue and Solid Query', slug: 'guides/vue-and-solid' }, { label: 'HTTP Queries and Mutations', slug: 'guides/http-queries-and-mutations' }, { label: 'TanStack Start', slug: 'guides/tanstack-start' }, + { label: 'WebSocket Ready Clients', slug: 'guides/websocket-clients' }, { label: 'Stream Snapshots', slug: 'guides/stream-snapshots' }, { label: 'Cache Management', slug: 'guides/cache-management' }, { label: 'Cache Filters', slug: 'guides/cache-filters' }, diff --git a/apps/docs/src/content/docs/guides/websocket-clients.md b/apps/docs/src/content/docs/guides/websocket-clients.md index 0513e9f..680d66b 100644 --- a/apps/docs/src/content/docs/guides/websocket-clients.md +++ b/apps/docs/src/content/docs/guides/websocket-clients.md @@ -129,7 +129,7 @@ registered finalizer cancels queries and clears their cache before the client an close. For a replacement owner, await disposal, acquire a new application Scope and ready client, and construct a new QueryClient. The same semantic prefix can produce the same keys while resource lifetimes and cache instances remain independent. Follow -[owner-switching rules](/guides/switch-cache-owners/) when authentication changes. +[owner-switching rules](/effect-api-query/guides/switch-cache-owners/) when authentication changes. Replacement acquires a new connection. Transport reconnection, backoff, credentials, event replay, and whether failed queries retry belong to the application and Effect's @@ -154,4 +154,4 @@ stall other calls on the connection. This recipe uses the native default of 16. smaller buffers against the pinned transport version before changing that setting. Query cancellation stops future work cooperatively. It does not undo completed writes; -use an [explicit cancellable command](/guides/cancellation/) when the domain requires one. +use an [explicit cancellable command](/effect-api-query/guides/cancellation/) when the domain requires one. diff --git a/apps/docs/src/content/docs/reference/compatibility-and-limits.md b/apps/docs/src/content/docs/reference/compatibility-and-limits.md index 0d856ad..e59afad 100644 --- a/apps/docs/src/content/docs/reference/compatibility-and-limits.md +++ b/apps/docs/src/content/docs/reference/compatibility-and-limits.md @@ -73,6 +73,11 @@ calls that do not need TanStack Query. framework-free execution matrix and distinguishes worker-hosted ready clients from a worker RPC transport. +The [WebSocket ready-client recipe](/effect-api-query/guides/websocket-clients/) supports concurrent +unary calls, both stream views, independent interruption, and application-owned connection replacement. +Cooperative server stream finalizers are observed through the native RPC WebSocket protocol; +local cancellation alone remains insufficient proof of remote completion. + ## Runtime and cache behavior - The caller owns RPC or HTTP client acquisition, `Scope`, transport, Query Client, providers, router, SSR, diff --git a/tests/packed-consumer/websocket-checks.ts b/tests/packed-consumer/websocket-checks.ts index 75e2b74..3c9e9cd 100644 --- a/tests/packed-consumer/websocket-checks.ts +++ b/tests/packed-consumer/websocket-checks.ts @@ -9,6 +9,7 @@ import { runSocketCase, serveSocketGroup, } from '../../examples/server/tests/fixtures/websocket-server.ts' +import { readSocketSnapshot } from './docs-websocket-use.ts' import { acquireSocketQueries } from './socket-client.ts' const wait = (deferred: Deferred.Deferred) => @@ -61,7 +62,11 @@ export const exerciseSocketConcurrency = async () => }), 'values.watch': ({ channel }) => Stream.succeed(1).pipe( - Stream.concat(Stream.fromEffect(Deferred.await(releaseSecond).pipe(Effect.as(2)))), + Stream.concat( + channel === 'clock' + ? Stream.empty + : Stream.fromEffect(Deferred.await(releaseSecond).pipe(Effect.as(2))), + ), Stream.concat(Stream.fromEffect(Effect.never)), Stream.ensuring( Deferred.succeed(channel === 'history' ? historyClosed : liveClosed, undefined).pipe( @@ -114,6 +119,11 @@ export const exerciseSocketConcurrency = async () => const liveResult = yield* Effect.promise(() => live) equal(liveResult.status, 'success') if (liveResult.status === 'success') equal(liveResult.data, 2) + deepStrictEqual(yield* readSocketSnapshot(url), { + reads: [10, 20], + history: [1], + latest: 1, + }) yield* dispose equal(queryClient.isFetching(), 0) equal(queryClient.getQueryCache().getAll().length, 0) @@ -172,7 +182,20 @@ export const exerciseSocketInterruption = async () => view === 'streamed' ? rpc.values.watch.streamedOptions({ input, rpcOptions: { streamBufferSize: 16 } }) : rpc.values.watch.liveOptions({ input, rpcOptions: { streamBufferSize: 16 } }) - const generatedStream = yield* Effect.promise(() => settle(queryClient.query(options))) + const generatedStream = yield* Effect.promise(() => + settle( + view === 'streamed' + ? queryClient.query( + rpc.values.watch.streamedOptions({ + input, + rpcOptions: { streamBufferSize: 16 }, + }), + ) + : queryClient.query( + rpc.values.watch.liveOptions({ input, rpcOptions: { streamBufferSize: 16 } }), + ), + ), + ) ok(Exit.isFailure(nativeStream)) equal(generatedStream.status, 'failure') if (generatedStream.status === 'failure') { From d96977842d2bac3a0ba78a9f96e7a49a08dd46e8 Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Sat, 10 Oct 2026 13:38:23 +0200 Subject: [PATCH 60/97] Add a repeatable stream retention baseline --- apps/docs/astro.config.mjs | 1 + .../docs/guides/choose-stream-history.md | 76 +++++ ...te-rpc-query-utils-stream-baseline.test.ts | 304 ++++++++++++++++++ vite.config.ts | 2 +- 4 files changed, 382 insertions(+), 1 deletion(-) create mode 100644 apps/docs/src/content/docs/guides/choose-stream-history.md create mode 100644 tests/create-rpc-query-utils-stream-baseline.test.ts diff --git a/apps/docs/astro.config.mjs b/apps/docs/astro.config.mjs index 0a8cb81..c9dccb3 100644 --- a/apps/docs/astro.config.mjs +++ b/apps/docs/astro.config.mjs @@ -71,6 +71,7 @@ export default defineConfig({ { label: 'HTTP Queries and Mutations', slug: 'guides/http-queries-and-mutations' }, { label: 'TanStack Start', slug: 'guides/tanstack-start' }, { label: 'Stream Snapshots', slug: 'guides/stream-snapshots' }, + { label: 'Choose Stream History', slug: 'guides/choose-stream-history' }, { label: 'Cache Management', slug: 'guides/cache-management' }, { label: 'Cache Filters', slug: 'guides/cache-filters' }, { label: 'Switch Cache Owners', slug: 'guides/switch-cache-owners' }, diff --git a/apps/docs/src/content/docs/guides/choose-stream-history.md b/apps/docs/src/content/docs/guides/choose-stream-history.md new file mode 100644 index 0000000..628a9b1 --- /dev/null +++ b/apps/docs/src/content/docs/guides/choose-stream-history.md @@ -0,0 +1,76 @@ +--- +title: Choose Stream History +description: Choose retained history, transport buffering, and update rates for long-running streams. +--- + +Choose the cached representation from what the screen needs: + +| Need | Builder | Retained data | +| ---------------------------- | ---------------------------------- | --------------------------------------------- | +| Every emitted value in order | `streamedOptions` | Unlimited history by default. | +| A recent event window | `streamedOptions` with `maxChunks` | Up to the specified number after an emission. | +| Current state | `liveOptions` | The latest emitted value. | + +For a long-running event display, choose a finite `maxChunks`. For current progress or a full-state +feed, choose a live query. Unlimited accumulation grows with the number of emissions; it suits +finite histories or workloads where that growth is deliberate. Persist durable history outside +the query cache when it must survive cache eviction or application restarts. + +These choices apply to both RPC streams and supported HTTP SSE endpoints. Live queries normalize +successful `undefined` to `null`; accumulated histories preserve their emitted values. + +## Set retention and buffering separately + +`maxChunks` limits the number of cached elements after each new emission. Seeded or hydrated history +can exceed the bound until another element arrives. One element can contain a large object or array, +so this bound does not limit bytes. It discards older elements while preserving the remaining order. +Use the same retention and refetch policy when building an exact key and its options. + +RPC `rpcOptions.streamBufferSize` controls the ready client's transport buffer before consumption. +It does not set cache history or the rate of cache writes. HTTP transport buffering belongs to +the ready HTTP API client. Changing a history bound does not slow the producer. + +An initial accumulated or live fetch exposes its first value while the stream remains open. +An accumulated `replace` refetch publishes its replacement only after successful completion; +an indefinitely open replacement therefore keeps showing the previous history. See +[Stream Snapshots](/effect-api-query/guides/stream-snapshots/) for finite server captures. + +## Assess writes and references separately + +Accumulated streams publish immutable arrays. A history bound limits their length; each consumed +value still contributes to cache updates. Live queries also accept each value, including repeated +values. Native structural sharing can preserve equal data references without suppressing those +cache writes. + +For JSON-compatible values, TanStack's default structural sharing can reuse unchanged objects and +nested values. A moving window changes array positions, so sharing can also produce additional +distinct references. Compare the data shape your application receives before choosing +`structuralSharing: false` or a custom native policy. + +Cache notifications also include query creation and fetch lifecycle changes. Count data writes, +data reference changes, and retained elements separately. Framework observer selection and +render scheduling add their own behavior; cache notification counts do not count component renders. + +If the producer outruns the interface, set an explicit rate or sampling policy in the producer or +an application-owned Effect Stream transformation. Sampling intentionally discards values. +Retention and transport buffering serve different purposes from publication rate. + +## Repeat the repository baseline + +From a repository checkout with its dependencies available, run: + +```sh +STREAM_MEASURE=/tmp/stream-baseline.json vp run --no-cache test tests/create-rpc-query-utils-stream-baseline.test.ts +``` + +The command replaces the chosen JSON file. It compares 64 numeric or object emissions across +unlimited history, windows of four and sixteen elements, and live values, with native structural +sharing enabled and disabled. Each case uses a fresh QueryClient and a finite in-memory ready +RPC client, with no framework observers. + +The report records dependency versions, source and workload fingerprints, cache notifications, +data writes, changed references, and retained sequences. It separates the stream's manual cache +writes from the final successful fetch write. Array-slot and object-reference counts +describe values observed at the cache boundary. They do not measure total allocation, heap size, +garbage collection, network throughput, or browser rendering time. Use the same workload and +toolchain when comparing changes, then measure any rendering concern in the application that has it. diff --git a/tests/create-rpc-query-utils-stream-baseline.test.ts b/tests/create-rpc-query-utils-stream-baseline.test.ts new file mode 100644 index 0000000..7f1559f --- /dev/null +++ b/tests/create-rpc-query-utils-stream-baseline.test.ts @@ -0,0 +1,304 @@ +import { describe, expect, it } from '@effect/vitest' +import { QueryClient } from '@tanstack/query-core' +import { Array as EffectArray, Effect, Predicate, Schema, Stream } from 'effect' +import { Rpc, RpcGroup, RpcTest } from 'effect/rpc' +import { createHash } from 'node:crypto' +import { readFileSync, readdirSync, writeFileSync } from 'node:fs' +import { afterAll } from 'vitest' + +import { createRpcQueryUtils } from '#effect-api-query' + +const SampleSchema = Schema.Struct({ + version: Schema.Finite, + reading: Schema.Struct({ unit: Schema.Literal('items'), value: Schema.Finite }), + labels: Schema.Array(Schema.String), +}) +type Sample = typeof SampleSchema.Type +type Value = number | Sample +type Data = Value | null | readonly Value[] +const Watch = Rpc.make('samples.watch', { + payload: { workload: Schema.Literals(['numbers', 'objects']) }, + success: Schema.Union([Schema.Finite, SampleSchema]), + stream: true, +}) +const group = RpcGroup.make(Watch) +const values = [0, 1, 2, 3, 4, 5, 6, 7].flatMap((value) => Array.from({ length: 8 }, () => value)) + +const modes = [ + { view: 'unlimited', maxChunks: undefined }, + { view: 'bounded-4', maxChunks: 4 }, + { view: 'bounded-16', maxChunks: 16 }, + { view: 'live', maxChunks: undefined }, +] as const +const cases = (['numbers', 'objects'] as const).flatMap((workload) => + modes.flatMap((mode) => + ([true, false] as const).map((structuralSharing) => ({ workload, ...mode, structuralSharing })), + ), +) +type Case = (typeof cases)[number] + +const retainedValues = (data: Data | undefined): readonly Value[] => { + if (data === undefined || data === null) { + return [] + } + return EffectArray.isArray(data) ? data : [data] +} + +const versionOf = (value: Value) => (Predicate.isNumber(value) ? value : value.version) + +const observedObjects = () => { + const samples = new WeakSet() + const readings = new WeakSet() + const labels = new WeakSet() + let sampleReferences = 0 + let readingReferences = 0 + let labelArrayReferences = 0 + const observe = (data: Data | undefined) => { + for (const value of retainedValues(data)) { + if (!Predicate.isNumber(value)) { + if (!samples.has(value)) { + samples.add(value) + sampleReferences += 1 + } + if (!readings.has(value.reading)) { + readings.add(value.reading) + readingReferences += 1 + } + if (!labels.has(value.labels)) { + labels.add(value.labels) + labelArrayReferences += 1 + } + } + } + } + return { + observe, + result: () => ({ sampleReferences, readingReferences, labelArrayReferences }), + } +} + +const measure = Effect.fnUntraced(function* (scenario: Case) { + let sourceEmissions = 0 + let finalized = false + const client = yield* RpcTest.makeClient(group, { flatten: true }).pipe( + Effect.provide( + group.toLayer({ + 'samples.watch': ({ workload }) => + Stream.fromIterable(values).pipe( + Stream.map((version): Value => { + sourceEmissions += 1 + return workload === 'numbers' + ? version + : { + version, + reading: { unit: 'items', value: version }, + labels: ['west', 'current'], + } + }), + Stream.ensuring( + Effect.sync(() => { + finalized = true + }), + ), + ), + }), + ), + ) + const queryClient = new QueryClient() + const leaf = createRpcQueryUtils(group, { + client, + keyPrefix: ['stream-baseline'], + }).samples.watch + const input = { workload: scenario.workload } + const queryKey = + scenario.view === 'live' + ? leaf.liveKey(input) + : leaf.streamedKey(input, { maxChunks: scenario.maxChunks }) + const objects = observedObjects() + let cacheNotifications = 0 + let cacheUpdates = 0 + let dataWrites = 0 + let manualDataWrites = 0 + let fetchCompletionWrites = 0 + let dataReferenceChanges = 0 + let publishedArrayReferences = 0 + let changedReferenceElementSlots = 0 + let dataWriteElementSlots = 0 + let maxRetainedValues = 0 + let previous: Data | undefined + let firstPublication: Data | undefined + const arrays = new WeakSet() + const unsubscribe = queryClient.getQueryCache().subscribe((event) => { + cacheNotifications += 1 + if (event.type === 'updated') { + cacheUpdates += 1 + if (event.action.type === 'success') { + dataWrites += 1 + if (event.action.manual === true) { + manualDataWrites += 1 + } else { + fetchCompletionWrites += 1 + } + const current = queryClient.getQueryData(queryKey) + firstPublication ??= current + const retained = retainedValues(current).length + dataWriteElementSlots += retained + maxRetainedValues = Math.max(maxRetainedValues, retained) + if (!Object.is(previous, current)) { + dataReferenceChanges += 1 + changedReferenceElementSlots += retained + if (EffectArray.isArray(current) && !arrays.has(current)) { + arrays.add(current) + publishedArrayReferences += 1 + } + } + objects.observe(current) + previous = current + } + } + }) + try { + const data = yield* Effect.promise(async () => + scenario.view === 'live' + ? await queryClient.query( + leaf.liveOptions({ input, structuralSharing: scenario.structuralSharing }), + ) + : await queryClient.query( + leaf.streamedOptions({ + input, + maxChunks: scenario.maxChunks, + structuralSharing: scenario.structuralSharing, + }), + ), + ) + const retained = retainedValues(data) + return { + ...scenario, + data, + sourceEmissions, + finalized, + cacheNotifications, + cacheUpdates, + dataWrites, + manualDataWrites, + fetchCompletionWrites, + dataReferenceChanges, + lifecycleUpdates: cacheUpdates - dataWrites, + equalReferenceWrites: dataWrites - dataReferenceChanges, + publishedArrayReferences, + changedReferenceElementSlots, + dataWriteElementSlots, + maxRetainedValues, + retainedValues: retained.length, + retainedVersions: retained.map(versionOf), + firstPublicationVersions: retainedValues(firstPublication).map(versionOf), + ...objects.result(), + retainedSampleReferences: new Set(retained.filter((value) => !Predicate.isNumber(value))) + .size, + } + } finally { + unsubscribe() + queryClient.clear() + } +}) + +const baselineCase = { + workload: 'numbers', + view: 'bounded-4', + maxChunks: 4, + structuralSharing: true, +} as const + +const manifestVersion = (name: string) => + Schema.decodeUnknownSync(Schema.Struct({ version: Schema.String }))( + JSON.parse( + readFileSync(new URL(`../node_modules/${name}/package.json`, import.meta.url), 'utf-8'), + ), + ).version + +const context = () => { + const sources = readdirSync(new URL('../src/', import.meta.url), { + recursive: true, + encoding: 'utf-8', + }) + .filter((source) => source.endsWith('.ts')) + .sort((left, right) => left.localeCompare(right)) + const sourceHash = createHash('sha256') + for (const source of sources) { + sourceHash.update(source) + sourceHash.update(readFileSync(new URL(`../src/${source}`, import.meta.url))) + } + return { + node: process.version, + platform: process.platform, + arch: process.arch, + effect: manifestVersion('effect'), + queryCore: manifestVersion('@tanstack/query-core'), + vitest: manifestVersion('vitest'), + sourceSha256: sourceHash.digest('hex'), + fixtureSha256: createHash('sha256') + .update(readFileSync(new URL(import.meta.url))) + .digest('hex'), + readyClient: 'RpcTest.makeClient, in-memory no-serialization, default stream buffer', + execution: 'fresh QueryClient, finite initial fetch, default reset policy, no observers', + workload: '64 emissions: versions 0 through 7, each repeated eight times', + } +} + +const reports: Omit>, 'data'>[] = [] + +describe('stream baseline', () => { + afterAll(() => { + const output = process.env['STREAM_MEASURE'] + if (output !== undefined && output !== '') { + writeFileSync( + output, + `${JSON.stringify({ context: context(), measurements: reports }, null, 2)}\n`, + ) + } + }) + it.live('separates cache data writes from fetch lifecycle updates', () => + Effect.scoped( + Effect.gen(function* () { + const measurement = yield* measure(baselineCase) + expect(measurement.data).toStrictEqual([7, 7, 7, 7]) + expect(measurement.lifecycleUpdates).toBeGreaterThan(0) + expect(measurement.dataWrites).toBeLessThan(measurement.cacheUpdates) + }), + ), + ) + it.live('does not treat structurally equal history writes as new data references', () => + Effect.scoped( + Effect.gen(function* () { + const measurement = yield* measure(baselineCase) + expect(measurement.dataReferenceChanges).toBeLessThan(measurement.dataWrites) + }), + ), + ) + it.live.each(cases)('$workload $view structuralSharing=$structuralSharing', (scenario) => + Effect.scoped( + Effect.gen(function* () { + const measurement = yield* measure(scenario) + let expected = values + if (scenario.view === 'live') { + expected = [7] + } else if (scenario.maxChunks === 4) { + expected = [7, 7, 7, 7] + } else if (scenario.maxChunks === 16) { + expected = [6, 6, 6, 6, 6, 6, 6, 6, 7, 7, 7, 7, 7, 7, 7, 7] + } + expect(measurement.retainedVersions).toStrictEqual(expected) + expect(measurement.firstPublicationVersions).toStrictEqual([0]) + expect(measurement.sourceEmissions).toBe(64) + expect(measurement.finalized).toBe(true) + expect(measurement.maxRetainedValues).toBe(expected.length) + expect(measurement.dataReferenceChanges).toBeLessThanOrEqual(measurement.dataWrites) + expect(measurement.lifecycleUpdates).toBeGreaterThan(0) + if (process.env['STREAM_MEASURE'] !== undefined) { + const { data: _data, ...report } = measurement + reports.push(report) + } + }), + ), + ) +}) diff --git a/vite.config.ts b/vite.config.ts index 3693136..bfed9c9 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -329,7 +329,7 @@ export default defineConfig({ command: 'vp test', dependsOn: ['pack', 'server-local-types'], cache: { - env: ['RPC_TRANSPORT_MEASURE'], + env: ['RPC_TRANSPORT_MEASURE', 'STREAM_MEASURE'], output: [], }, }, From 4419f0c5b204ab5ab22bb246df00b61ff5ebe40f Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Sat, 10 Oct 2026 13:54:08 +0200 Subject: [PATCH 61/97] Measure packed construction, compiler contracts, and bundles --- .fallowrc.jsonc | 2 + apps/docs/astro.config.mjs | 1 + .../reference/compatibility-and-limits.md | 3 + .../src/content/docs/reference/performance.md | 63 +++++ scripts/measure-packed-consumer.mts | 222 ++++++++++++++++ scripts/verify-packed-consumer.mts | 45 +++- .../packed-consumer/construction-baseline.mts | 244 ++++++++++++++++++ .../tsconfig.contract-baseline.json | 4 + .../tsconfig.http-baseline.json | 4 + tests/packed-consumer/tsconfig.json | 1 + .../tsconfig.rpc-baseline.json | 4 + .../packed-consumer/tsconfig.type-scale.json | 4 - tests/types/http-type-scale.ts | 2 - tests/types/type-scale.ts | 3 - 14 files changed, 586 insertions(+), 16 deletions(-) create mode 100644 apps/docs/src/content/docs/reference/performance.md create mode 100644 scripts/measure-packed-consumer.mts create mode 100644 tests/packed-consumer/construction-baseline.mts create mode 100644 tests/packed-consumer/tsconfig.contract-baseline.json create mode 100644 tests/packed-consumer/tsconfig.http-baseline.json create mode 100644 tests/packed-consumer/tsconfig.rpc-baseline.json delete mode 100644 tests/packed-consumer/tsconfig.type-scale.json diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index d9d5a4d..2a458c9 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -4,6 +4,8 @@ // Astro copies this browser entry from public/ and loads it through the // Starlight head configuration, which static import analysis cannot follow. "entry": [ + "scripts/measure-packed-consumer.mts", + "tests/packed-consumer/construction-baseline.mts", "scripts/verify-vue-solid-consumer.mts", "tests/packed-consumer/docs-vue-query.ts", "tests/packed-consumer/docs-solid-query.ts", diff --git a/apps/docs/astro.config.mjs b/apps/docs/astro.config.mjs index c651459..0afd0d7 100644 --- a/apps/docs/astro.config.mjs +++ b/apps/docs/astro.config.mjs @@ -108,6 +108,7 @@ export default defineConfig({ { label: 'Public Exports', slug: 'reference/public-exports' }, { label: 'Browser and Worker Hosts', slug: 'reference/browser-and-worker-hosts' }, { label: 'External HTTP Clients', slug: 'reference/external-http-clients' }, + { label: 'Performance and Bundling', slug: 'reference/performance' }, { label: 'Compatibility and Limits', slug: 'reference/compatibility-and-limits' }, ], }, diff --git a/apps/docs/src/content/docs/reference/compatibility-and-limits.md b/apps/docs/src/content/docs/reference/compatibility-and-limits.md index 2ef540b..65f02b4 100644 --- a/apps/docs/src/content/docs/reference/compatibility-and-limits.md +++ b/apps/docs/src/content/docs/reference/compatibility-and-limits.md @@ -5,6 +5,9 @@ description: Supported versions, RPC and HTTP operations, cache identity, and ru ## Supported integrations +See [Performance and Bundling](/effect-api-query/reference/performance/) for construction costs, +repeatable compiler measurements, and adapter versus application bundle sizes. + The package targets Effect 4 and TanStack Query Core 5.103.1 or later in v5, with React Query and TanStack Start integrations checked in this repository. It is ESM-only and targets ES2022. Use TypeScript with `strict: true`. diff --git a/apps/docs/src/content/docs/reference/performance.md b/apps/docs/src/content/docs/reference/performance.md new file mode 100644 index 0000000..f98c5eb --- /dev/null +++ b/apps/docs/src/content/docs/reference/performance.md @@ -0,0 +1,63 @@ +--- +title: Performance and Bundling +description: Construction costs, compiler measurements, and the distinction between adapter and application bundles. +--- + +## Construction + +Factories eagerly build and freeze the complete RPC or HTTP utility tree. Build each tree once +for its ready client and cache owner. A larger contract increases factory work even when an +application uses only a few leaves. + +Payload-bearing keys prepare a fresh immutable canonical payload. RPC preparation constructs +and Schema-encodes the normalized payload; HTTP preparation encodes the declared request parts. +Larger payloads increase this work. Option builders also capture the request and create a fresh +query function. Repeated calls do not memoize payloads or option objects. + +A custom RPC key encoder replaces Schema encoding. Payload construction, JSON validation, +canonical copying, and freezing still run. Choose an encoder for correct semantic identity; +it must preserve every input field that can change the result. + +## Repeatable measurements + +Maintainers can save a report while verifying the packed package: + +```sh +EFFECT_API_QUERY_BASELINE=.artifacts/packed-baseline.json vp run packed-package +``` + +The report identifies the exact archive, runtime, peers, compiler versions, and source hashes. +Construction measurements use one or 250 operations and inputs containing one or 100 records. +Each stage reports five samples after warm-up, with factory construction separate from key and +option construction. RPC payload construction and Schema encoding also have separate samples; +the custom-encoder key measurement still includes the package's normal preparation. + +Compiler reports separate the public contract, 250 RPC operations, and 250 HTTP endpoints under +both supported consumer compilers. A cold compiler process starts without an incremental build +file, then an unchanged incremental process reuses it. This does not reset operating-system caches. +Type and instantiation counts help compare declaration changes when the inputs and versions match. +Local elapsed time and memory measurements are observations, not performance guarantees or +universal regression thresholds. + +## Adapter and application bundles + +The package marks its exports as free of side effects and keeps Effect and Query Core as peers. +An application can import one factory or an error guard from the package root. Unused factories +and the stream snapshot helper can then be removed by the bundler. + +The report builds an error-guard import, an RPC-factory import, and an HTTP-factory import with +Vite's Oxc minifier and an ES2022 target. Each probe has two sizes: + +| Peer treatment | What the size includes | +| -------------- | -------------------------------------------------------------------- | +| External | Retained package code and peer imports; dependency code is excluded. | +| Included | Retained package code and the peer code needed by that import. | + +Sizes include JavaScript bytes and gzip bytes at compression level nine. An error guard can +eliminate all peer code in the included probe, while an external probe retains bare peer imports +whose side effects the bundler cannot inspect. The included size can therefore be smaller for +that probe. + +These probes measure one retained package export. An application's contract Schemas, transport, +QueryClient, framework, and other imports change its final bundle. Compare reports from the same +probe and dependency versions before attributing a size change to the adapter. diff --git a/scripts/measure-packed-consumer.mts b/scripts/measure-packed-consumer.mts new file mode 100644 index 0000000..f527b18 --- /dev/null +++ b/scripts/measure-packed-consumer.mts @@ -0,0 +1,222 @@ +import { deepStrictEqual, equal } from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { readFileSync, rmSync, writeFileSync } from 'node:fs' +import nodePath from 'node:path' +import { performance } from 'node:perf_hooks' +import { gzipSync } from 'node:zlib' +import { build, version as viteVersion } from 'vite' + +interface Compiler { + readonly executable: string + readonly label: string +} + +const digest = (value: string) => createHash('sha256').update(value).digest('hex') +const compilerProjects = [ + [ + 'contract', + 'tsconfig.contract-baseline.json', + ['public-contract.ts', 'http-contract.ts', 'http-stream-contract.ts'], + ], + ['rpc', 'tsconfig.rpc-baseline.json', ['type-scale.ts']], + ['http', 'tsconfig.http-baseline.json', ['http-type-scale.ts']], +] as const + +const measureCompiler = (consumerDirectory: string, compiler: Compiler) => { + const version = execFileSync(process.execPath, [compiler.executable, '--version'], { + encoding: 'utf-8', + }).trim() + return compilerProjects.flatMap(([fixture, project, files]) => { + const buildInfo = nodePath.join(consumerDirectory, `${fixture}-${compiler.label}.tsbuildinfo`) + rmSync(buildInfo, { force: true }) + const fixtureHashes = Object.fromEntries( + files.map((file) => [ + file, + digest(readFileSync(nodePath.join(consumerDirectory, file), 'utf-8')), + ]), + ) + return (['cold-compiler', 'incremental-unchanged'] as const).map((phase) => { + const started = performance.now() + const output = execFileSync( + process.execPath, + [ + compiler.executable, + '-p', + project, + '--pretty', + 'false', + '--extendedDiagnostics', + '--incremental', + '--tsBuildInfoFile', + buildInfo, + ], + { cwd: consumerDirectory, encoding: 'utf-8', maxBuffer: 16 * 1024 * 1024 }, + ) + const milliseconds = performance.now() - started + const diagnostics: Record = {} + for (const entry of output.matchAll(/^(?(deferred: Deferred.Deferred) => Deferred.await(deferred).pipe(Effect.timeout('5 seconds')) @@ -20,7 +25,7 @@ const waitForData = async ( queryKey: QueryKey, expected: number | readonly number[], ) => { - const published = Deferred.makeUnsafe() + const published = Deferred.makeUnsafe() const check = () => { const data = queryClient.getQueryData(queryKey) if (JSON.stringify(data) === JSON.stringify(expected)) { @@ -47,16 +52,18 @@ const settle = async (promise: PromiseLike) => { export const exerciseSocketConcurrency = async () => await runSocketCase( Effect.gen(function* () { - const readsStarted = yield* Deferred.make() - const releaseReads = yield* Deferred.make() - const releaseSecond = yield* Deferred.make() - const historyClosed = yield* Deferred.make() - const liveClosed = yield* Deferred.make() + const readsStarted = yield* Deferred.make() + const releaseReads = yield* Deferred.make() + const releaseSecond = yield* Deferred.make() + const historyClosed = yield* Deferred.make() + const liveClosed = yield* Deferred.make() let started = 0 const url = yield* serveSocketGroup({ 'values.read': Effect.fnUntraced(function* ({ id }) { started += 1 - if (started === 2) yield* Deferred.succeed(readsStarted, undefined) + if (started === 2) { + yield* Deferred.succeed(readsStarted, undefined) + } yield* Deferred.await(releaseReads) return id * 10 }), @@ -76,8 +83,8 @@ export const exerciseSocketConcurrency = async () => ), }) const { queryClient, rpc, dispose } = yield* acquireSocketQueries(url, 'first') - const reads = [1, 2].map((id) => - queryClient.query(rpc.values.read.queryOptions({ input: { id } })), + const reads = [1, 2].map( + async (id) => await queryClient.query(rpc.values.read.queryOptions({ input: { id } })), ) const historyOptions = rpc.values.watch.streamedOptions({ input: { channel: 'history' }, @@ -90,35 +97,45 @@ export const exerciseSocketConcurrency = async () => const history = settle(queryClient.query(historyOptions)) const live = settle(queryClient.query(liveOptions)) yield* wait(readsStarted) - yield* Effect.promise(() => waitForData(queryClient, historyOptions.queryKey, [1])) - yield* Effect.promise(() => waitForData(queryClient, liveOptions.queryKey, 1)) + yield* Effect.promise(async () => { + await waitForData(queryClient, historyOptions.queryKey, [1]) + }) + yield* Effect.promise(async () => { + await waitForData(queryClient, liveOptions.queryKey, 1) + }) equal(queryClient.getQueryState(historyOptions.queryKey)?.status, 'success') equal(queryClient.getQueryState(historyOptions.queryKey)?.fetchStatus, 'fetching') equal(queryClient.getQueryState(liveOptions.queryKey)?.status, 'success') equal(queryClient.getQueryState(liveOptions.queryKey)?.fetchStatus, 'fetching') equal(yield* Deferred.isDone(historyClosed), false) equal(yield* Deferred.isDone(liveClosed), false) - yield* Effect.promise(() => - queryClient.cancelQueries({ queryKey: historyOptions.queryKey, exact: true }), - ) + yield* Effect.promise(async () => { + await queryClient.cancelQueries({ queryKey: historyOptions.queryKey, exact: true }) + }) yield* wait(historyClosed) - const historyResult = yield* Effect.promise(() => history) + const historyResult = yield* Effect.promise(async () => await history) equal(historyResult.status, 'success') - if (historyResult.status === 'success') deepStrictEqual(historyResult.data, [1]) + if (historyResult.status === 'success') { + deepStrictEqual(historyResult.data, [1]) + } equal(yield* Deferred.isDone(liveClosed), false) equal(queryClient.getQueryState(liveOptions.queryKey)?.fetchStatus, 'fetching') yield* Deferred.succeed(releaseSecond, undefined) - yield* Effect.promise(() => waitForData(queryClient, liveOptions.queryKey, 2)) + yield* Effect.promise(async () => { + await waitForData(queryClient, liveOptions.queryKey, 2) + }) yield* Deferred.succeed(releaseReads, undefined) - const values = yield* Effect.promise(() => Promise.all(reads)) + const values = yield* Effect.promise(async () => await Promise.all(reads)) deepStrictEqual(values, [10, 20]) - yield* Effect.promise(() => - queryClient.cancelQueries({ queryKey: liveOptions.queryKey, exact: true }), - ) + yield* Effect.promise(async () => { + await queryClient.cancelQueries({ queryKey: liveOptions.queryKey, exact: true }) + }) yield* wait(liveClosed) - const liveResult = yield* Effect.promise(() => live) + const liveResult = yield* Effect.promise(async () => await live) equal(liveResult.status, 'success') - if (liveResult.status === 'success') equal(liveResult.data, 2) + if (liveResult.status === 'success') { + equal(liveResult.data, 2) + } deepStrictEqual(yield* readSocketSnapshot(url), { reads: [10, 20], history: [1], @@ -141,11 +158,13 @@ export const exerciseSocketConcurrency = async () => export const exerciseSocketInterruption = async () => await runSocketCase( Effect.gen(function* () { - const readStarted = yield* Deferred.make() - const releaseRead = yield* Deferred.make() + const readStarted = yield* Deferred.make() + const releaseRead = yield* Deferred.make() const url = yield* serveSocketGroup({ 'values.read': Effect.fnUntraced(function* ({ id }) { - if (id < 0) return yield* Effect.failCause(Cause.interrupt(42)) + if (id < 0) { + return yield* Effect.failCause(Cause.interrupt(42)) + } yield* Deferred.succeed(readStarted, undefined) yield* Deferred.await(releaseRead) return id * 10 @@ -161,8 +180,9 @@ export const exerciseSocketInterruption = async () => const unaffected = queryClient.query(rpc.values.read.queryOptions({ input: { id: 3 } })) yield* wait(readStarted) const nativeUnary = yield* client('values.read', { id: -1 }).pipe(Effect.exit) - const generatedUnary = yield* Effect.promise(() => - settle(queryClient.query(rpc.values.read.queryOptions({ input: { id: -1 } }))), + const generatedUnary = yield* Effect.promise( + async () => + await settle(queryClient.query(rpc.values.read.queryOptions({ input: { id: -1 } }))), ) ok(Exit.isFailure(nativeUnary)) equal(generatedUnary.status, 'failure') @@ -182,19 +202,20 @@ export const exerciseSocketInterruption = async () => view === 'streamed' ? rpc.values.watch.streamedOptions({ input, rpcOptions: { streamBufferSize: 16 } }) : rpc.values.watch.liveOptions({ input, rpcOptions: { streamBufferSize: 16 } }) - const generatedStream = yield* Effect.promise(() => - settle( - view === 'streamed' - ? queryClient.query( - rpc.values.watch.streamedOptions({ - input, - rpcOptions: { streamBufferSize: 16 }, - }), - ) - : queryClient.query( - rpc.values.watch.liveOptions({ input, rpcOptions: { streamBufferSize: 16 } }), - ), - ), + const generatedStream = yield* Effect.promise( + async () => + await settle( + view === 'streamed' + ? queryClient.query( + rpc.values.watch.streamedOptions({ + input, + rpcOptions: { streamBufferSize: 16 }, + }), + ) + : queryClient.query( + rpc.values.watch.liveOptions({ input, rpcOptions: { streamBufferSize: 16 } }), + ), + ), ) ok(Exit.isFailure(nativeStream)) equal(generatedStream.status, 'failure') @@ -207,9 +228,10 @@ export const exerciseSocketInterruption = async () => equal(queryClient.getQueryState(options.queryKey)?.error, generatedStream.error) equal(queryClient.getQueryState(options.queryKey)?.status, 'error') } + const emittedData = view === 'streamed' ? [7] : 7 deepStrictEqual( queryClient.getQueryData(options.queryKey), - emitted ? (view === 'streamed' ? [7] : 7) : undefined, + emitted ? emittedData : undefined, ) equal( queryClient.getQueryState(rpc.values.read.queryKey({ id: 3 }))?.fetchStatus, @@ -218,7 +240,7 @@ export const exerciseSocketInterruption = async () => } } yield* Deferred.succeed(releaseRead, undefined) - const unaffectedRead = yield* Effect.promise(() => unaffected) + const unaffectedRead = yield* Effect.promise(async () => await unaffected) equal(unaffectedRead, 30) yield* dispose return { comparedWireCauses: 5, unaffectedRead } @@ -228,19 +250,21 @@ export const exerciseSocketInterruption = async () => export const exerciseSocketReplacement = async () => await runSocketCase( Effect.gen(function* () { - const oldClosed = yield* Deferred.make() - const unrelatedClosed = yield* Deferred.make() - const replacementClosed = yield* Deferred.make() - const releaseSecond = yield* Deferred.make() - const readStarted = yield* Deferred.make() - const disconnected = yield* Deferred.make() + const oldClosed = yield* Deferred.make() + const unrelatedClosed = yield* Deferred.make() + const replacementClosed = yield* Deferred.make() + const releaseSecond = yield* Deferred.make() + const readStarted = yield* Deferred.make() + const disconnected = yield* Deferred.make() const ownerFinalizers = [oldClosed, replacementClosed] const url = yield* serveSocketGroup({ 'values.read': () => Deferred.succeed(readStarted, undefined).pipe(Effect.andThen(Effect.never)), 'values.watch': ({ channel }) => { const finalized = channel === 'owner-a' ? ownerFinalizers.shift() : unrelatedClosed - if (finalized === undefined) return Stream.die(new Error('Unexpected owner acquisition')) + if (finalized === undefined) { + return Stream.die(new Error('Unexpected owner acquisition')) + } return Stream.succeed(1).pipe( Stream.concat(Stream.fromEffect(Deferred.await(releaseSecond).pipe(Effect.as(2)))), Stream.concat(Stream.fromEffect(Effect.never)), @@ -248,18 +272,18 @@ export const exerciseSocketReplacement = async () => ) }, }) - let retiringQueries: QueryClient | undefined + const retiringOwner: RetiringSocketOwner = { queryClient: undefined } const oldOwner = yield* acquireSocketQueries(url, 'a').pipe( Effect.provideService(RpcClient.ConnectionHooks, { onConnect: Effect.void, - onDisconnect: Effect.sync(() => { - equal(retiringQueries?.isFetching(), 0) - equal(retiringQueries?.getQueryCache().getAll().length, 0) - Effect.runSync(Deferred.succeed(disconnected, undefined)) + onDisconnect: Effect.gen(function* () { + equal(retiringOwner.queryClient?.isFetching(), 0) + equal(retiringOwner.queryClient?.getQueryCache().getAll().length, 0) + yield* Deferred.succeed(disconnected, undefined) }), }), ) - retiringQueries = oldOwner.queryClient + retiringOwner.queryClient = oldOwner.queryClient const unrelatedOwner = yield* acquireSocketQueries(url, 'b') const oldOptions = oldOwner.rpc.values.watch.liveOptions({ input: { channel: 'owner-a' } }) const unrelatedOptions = unrelatedOwner.rpc.values.watch.liveOptions({ @@ -271,17 +295,21 @@ export const exerciseSocketReplacement = async () => const oldStream = settle(oldOwner.queryClient.query(oldOptions)) const unrelatedStream = settle(unrelatedOwner.queryClient.query(unrelatedOptions)) yield* wait(readStarted) - yield* Effect.promise(() => waitForData(oldOwner.queryClient, oldOptions.queryKey, 1)) - yield* Effect.promise(() => - waitForData(unrelatedOwner.queryClient, unrelatedOptions.queryKey, 1), - ) + yield* Effect.promise(async () => { + await waitForData(oldOwner.queryClient, oldOptions.queryKey, 1) + }) + yield* Effect.promise(async () => { + await waitForData(unrelatedOwner.queryClient, unrelatedOptions.queryKey, 1) + }) yield* oldOwner.dispose yield* wait(disconnected) yield* wait(oldClosed) - const cancelledRead = yield* Effect.promise(() => oldRead) + const cancelledRead = yield* Effect.promise(async () => await oldRead) equal(cancelledRead.status, 'failure') - if (cancelledRead.status === 'failure') equal(isCancelledError(cancelledRead.error), true) - yield* Effect.promise(() => oldStream) + if (cancelledRead.status === 'failure') { + equal(cancelledRead.error instanceof CancelledError, true) + } + yield* Effect.promise(async () => await oldStream) equal(yield* Deferred.isDone(unrelatedClosed), false) equal( unrelatedOwner.queryClient.getQueryState(unrelatedOptions.queryKey)?.fetchStatus, @@ -294,22 +322,22 @@ export const exerciseSocketReplacement = async () => deepStrictEqual(replacementOptions.queryKey, oldOptions.queryKey) equal(replacement.queryClient === oldOwner.queryClient, false) const replacementStream = settle(replacement.queryClient.query(replacementOptions)) - yield* Effect.promise(() => - waitForData(replacement.queryClient, replacementOptions.queryKey, 1), - ) + yield* Effect.promise(async () => { + await waitForData(replacement.queryClient, replacementOptions.queryKey, 1) + }) yield* Deferred.succeed(releaseSecond, undefined) - yield* Effect.promise(() => - waitForData(unrelatedOwner.queryClient, unrelatedOptions.queryKey, 2), - ) - yield* Effect.promise(() => - waitForData(replacement.queryClient, replacementOptions.queryKey, 2), - ) + yield* Effect.promise(async () => { + await waitForData(unrelatedOwner.queryClient, unrelatedOptions.queryKey, 2) + }) + yield* Effect.promise(async () => { + await waitForData(replacement.queryClient, replacementOptions.queryKey, 2) + }) equal(oldOwner.queryClient.getQueryCache().getAll().length, 0) yield* unrelatedOwner.dispose yield* replacement.dispose yield* wait(unrelatedClosed) yield* wait(replacementClosed) - yield* Effect.promise(() => Promise.all([unrelatedStream, replacementStream])) + yield* Effect.promise(async () => await Promise.all([unrelatedStream, replacementStream])) return { oldOwnerCancelled: true, unrelatedOwnerContinued: 2, diff --git a/tests/http-cached-views.test.ts b/tests/http-cached-views.test.ts new file mode 100644 index 0000000..16f7990 --- /dev/null +++ b/tests/http-cached-views.test.ts @@ -0,0 +1,333 @@ +import { it } from '@effect/vitest' +import { QueryClient } from '@tanstack/query-core' +import { Cause, Deferred, Effect, Exit, Predicate, Schema, SchemaTransformation } from 'effect' +import { HttpClient, HttpClientResponse } from 'effect/http' +import { + HttpApi, + HttpApiClient, + HttpApiEndpoint, + HttpApiGroup, + HttpApiSchema, +} from 'effect/http-api' +import { expect } from 'vite-plus/test' + +import { createHttpApiQueryUtils, isEffectHttpApiQueryError } from '#effect-api-query' + +import { captureFailure } from './fixtures/async.ts' +import { waitForQueryState } from './fixtures/query-cache.ts' + +const UndefinedValue = Schema.Null.pipe( + Schema.decodeTo( + Schema.Void, + SchemaTransformation.transform({ decode: () => {}, encode: () => null }), + ), +) +const api = HttpApi.make('cached-views').add( + HttpApiGroup.make('items').add( + HttpApiEndpoint.get('watch', '/watch', { + success: HttpApiSchema.StreamSse({ data: Schema.Union([Schema.Int, UndefinedValue]) }), + }), + HttpApiEndpoint.get('wrapped', '/wrapped', { + success: HttpApiSchema.WithHeaders(HttpApiSchema.StreamSse({ data: Schema.Int }), { + 'x-version': Schema.FiniteFromString, + }), + }), + HttpApiEndpoint.get('read', '/read', { + success: HttpApiSchema.WithHeaders(Schema.String, { etag: Schema.String }).pipe( + HttpApiSchema.status(203), + ), + }), + ), +) +const makeClient = (response: () => Response) => + HttpApiClient.makeWith(api, { + baseUrl: 'https://example.test', + httpClient: HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, response())), + ), + }) +const sse = (body: string, headers = {}) => + new Response(body, { headers: { 'content-type': 'text/event-stream', ...headers } }) + +it.effect('caches decoded SSE views with distinct decoder policies and header wrappers', () => + Effect.gen(function* () { + const client = yield* makeClient(() => sse('data: 1\n\ndata: null\n\n', { 'x-version': '4' })) + const http = createHttpApiQueryUtils(api, { client, keyPrefix: ['views'] }) + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + yield* Effect.addFinalizer(() => + Effect.sync(() => { + queryClient.clear() + }), + ) + expect( + yield* Effect.promise( + async () => await queryClient.query(http.items.watch.streamedOptions()), + ), + ).toStrictEqual([1, undefined]) + expect( + yield* Effect.promise(async () => await queryClient.query(http.items.watch.liveOptions())), + ).toBeNull() + const wrapped = yield* makeClient(() => sse('data: 3\n\ndata: 7\n\n', { 'x-version': '4' })) + const wrappedHttp = createHttpApiQueryUtils(api, { client: wrapped, keyPrefix: ['wrapped'] }) + expect( + yield* Effect.promise( + async () => await queryClient.query(wrappedHttp.items.wrapped.streamedOptions()), + ), + ).toStrictEqual([ + HttpApiSchema.withHeaders({ body: 3, headers: { 'x-version': 4 } }), + HttpApiSchema.withHeaders({ body: 7, headers: { 'x-version': 4 } }), + ]) + expect( + yield* Effect.promise( + async () => await queryClient.query(wrappedHttp.items.wrapped.liveOptions()), + ), + ).toStrictEqual(HttpApiSchema.withHeaders({ body: 7, headers: { 'x-version': 4 } })) + expect(http.items.watch.streamedKey()).toStrictEqual( + http.items.watch.streamedKey({ sseOptions: { maxEventSize: 10 * 1024 * 1024 } }), + ) + expect(http.items.watch.streamedKey()).not.toStrictEqual( + http.items.watch.streamedKey({ sseOptions: { maxEventSize: 3 } }), + ) + const decoderClient = yield* makeClient( + () => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('data: 12345')) + controller.enqueue(new TextEncoder().encode('\n\n')) + controller.close() + }, + }), + { headers: { 'content-type': 'text/event-stream' } }, + ), + ) + const decoderHttp = createHttpApiQueryUtils(api, { + client: decoderClient, + keyPrefix: ['decoder'], + }) + const failed = yield* Effect.promise( + async () => + await captureFailure( + queryClient.query( + decoderHttp.items.watch.streamedOptions({ sseOptions: { maxEventSize: 3 } }), + ), + ), + ) + expect(failed).toMatchObject({ + _tag: 'EffectHttpApiQueryError', + operation: 'streamed', + endpoint: 'watch', + }) + if (!isEffectHttpApiQueryError(failed)) { + throw new TypeError('Expected an HTTP execution error') + } + expect(failed.cause.reasons[0]).toMatchObject({ _tag: 'Fail', error: { _tag: 'SseError' } }) + expect(queryClient.getQueryData(http.items.watch.streamedKey())).toStrictEqual([1, undefined]) + }), +) + +it.effect.each(['live', 'streamed'] as const)( + 'preserves mixed acquisition Causes in HTTP %s views', + (view) => + Effect.gen(function* () { + const defect = new Error('transport defect') + const cause = Cause.combine(Cause.interrupt(123), Cause.die(defect)) + const client = yield* HttpApiClient.makeWith(api, { + baseUrl: 'https://example.test', + httpClient: HttpClient.make(() => Effect.failCause(cause)), + }) + const http = createHttpApiQueryUtils(api, { client, keyPrefix: ['failure', view] }) + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + yield* Effect.addFinalizer(() => + Effect.sync(() => { + queryClient.clear() + }), + ) + const error = yield* Effect.promise( + async () => + await captureFailure( + view === 'live' + ? queryClient.query(http.items.watch.liveOptions()) + : queryClient.query(http.items.watch.streamedOptions()), + ), + ) + expect(error).toMatchObject({ + _tag: 'EffectHttpApiQueryError', + apiId: 'cached-views', + groupId: 'items', + endpoint: 'watch', + method: 'GET', + operation: view, + }) + if (!isEffectHttpApiQueryError(error)) { + throw new TypeError('Expected an HTTP execution error') + } + expect(error.cause.reasons.map((reason) => reason._tag)).toStrictEqual(['Interrupt', 'Die']) + expect(error.cause.reasons[0]).toMatchObject({ fiberId: 123 }) + const [, reason] = error.cause.reasons + if (reason?._tag !== 'Die') { + throw new TypeError('Expected the original defect') + } + expect(reason.defect).toBe(defect) + }), +) + +it.effect.each( + (['live', 'streamed'] as const).flatMap((view) => [ + { view, body: 'data: "bad"\n\n', tag: 'SchemaError', published: false }, + { view, body: 'id: v3\ndata: 1\n\nretry: 25\n\n', tag: 'Retry', published: true }, + ]), +)('preserves HTTP $view failure $tag and prior publication', ({ view, body, tag, published }) => + Effect.gen(function* () { + const client = yield* makeClient(() => sse(body)) + const http = createHttpApiQueryUtils(api, { client, keyPrefix: ['decode', view] }) + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + yield* Effect.addFinalizer(() => + Effect.sync(() => { + queryClient.clear() + }), + ) + const error = yield* Effect.promise( + async () => + await captureFailure( + view === 'live' + ? queryClient.query(http.items.watch.liveOptions()) + : queryClient.query(http.items.watch.streamedOptions()), + ), + ) + if (!isEffectHttpApiQueryError(error)) { + throw new TypeError('Expected an HTTP execution error') + } + expect(error.cause.reasons[0]).toMatchObject({ _tag: 'Fail', error: { _tag: tag } }) + const publishedData = view === 'live' ? 1 : [1] + expect( + queryClient.getQueryData( + view === 'live' ? http.items.watch.liveKey() : http.items.watch.streamedKey(), + ), + ).toStrictEqual(published ? publishedData : undefined) + }), +) + +it.effect.each(['live', 'streamed'] as const)( + 'cancels and closes an HTTP %s body after the first publication', + (view) => + Effect.gen(function* () { + const finalized = yield* Deferred.make() + let signal: AbortSignal | undefined + let closed = 0 + const client = yield* HttpApiClient.makeWith(api, { + baseUrl: 'https://example.test', + httpClient: HttpClient.make((request, _url, transportSignal) => { + signal = transportSignal + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('data: 1\n\n')) + }, + cancel() { + closed += 1 + Deferred.doneUnsafe(finalized, Exit.succeed(undefined)) + }, + }), + { headers: { 'content-type': 'text/event-stream' } }, + ), + ), + ) + }), + }) + const http = createHttpApiQueryUtils(api, { client, keyPrefix: ['cancel', view] }) + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const queryKey = view === 'live' ? http.items.watch.liveKey() : http.items.watch.streamedKey() + const pending = + view === 'live' + ? queryClient.query(http.items.watch.liveOptions()) + : queryClient.query(http.items.watch.streamedOptions()) + try { + yield* waitForQueryState( + queryClient, + () => queryClient.getQueryState(queryKey)?.status === 'success', + ) + expect(queryClient.getQueryState(queryKey)?.fetchStatus).toBe('fetching') + yield* Effect.promise(async () => { + await queryClient.cancelQueries({ queryKey, exact: true }) + }) + yield* Deferred.await(finalized) + expect(signal?.aborted).toBe(true) + expect(closed).toBe(1) + expect(yield* Effect.promise(async () => await pending)).toStrictEqual( + view === 'live' ? 1 : [1], + ) + } finally { + yield* Effect.promise(async () => { + await queryClient.cancelQueries({ queryKey, exact: true }) + }) + queryClient.clear() + } + }), +) + +it.effect( + 'captures immutable HTTP metadata separately from decoded data and preserves native sharing', + () => + Effect.gen(function* () { + let revision = 1 + const client = yield* makeClient(() => + Response.json('document', { + status: 203, + headers: { 'content-type': 'application/json', etag: `"revision-${revision}"` }, + }), + ) + const http = createHttpApiQueryUtils(api, { client, keyPrefix: ['metadata'] }) + const queryClient = new QueryClient() + yield* Effect.addFinalizer(() => + Effect.sync(() => { + queryClient.clear() + }), + ) + const options = http.items.read.metadataOptions() + const first = yield* Effect.promise(async () => await queryClient.query(options)) + expect(first).toMatchObject({ + data: { body: 'document', headers: { etag: '"revision-1"' } }, + status: 203, + headers: { etag: '"revision-1"' }, + }) + expect(Object.isFrozen(first)).toBe(true) + expect(Object.isFrozen(first.headers)).toBe(true) + expect( + yield* Effect.promise(async () => await queryClient.query(http.items.read.queryOptions())), + ).toStrictEqual(first.data) + expect(queryClient.getQueryCache().findAll({ queryKey: http.items.read.key() })).toHaveLength( + 2, + ) + revision = 2 + const second = yield* Effect.promise(async () => await queryClient.query(options)) + expect(second.headers['etag']).toBe('"revision-2"') + expect(first.headers['etag']).toBe('"revision-1"') + const cached = queryClient.getQueryData(options.queryKey) + yield* Effect.promise(async () => await queryClient.query(options)) + expect(queryClient.getQueryData(options.queryKey)).toBe(cached) + queryClient.setQueryDefaults(http.items.read.key(), { + structuralSharing: (_previous, data) => { + if (!Predicate.isObject(data)) { + throw new TypeError('Expected a metadata view') + } + return { ...data, status: 211 } + }, + }) + yield* Effect.promise(async () => await queryClient.query(http.items.read.metadataOptions())) + const shared = queryClient.getQueryData(options.queryKey) + if (shared === undefined) { + throw new TypeError('Expected cached metadata') + } + expect(shared.status).toBe(211) + expect(Object.isFrozen(shared)).toBe(true) + expect(Object.isFrozen(shared.headers)).toBe(true) + const manual = { ...shared, headers: { etag: 'manual' } } + queryClient.setQueryData(options.queryKey, manual) + expect(Object.isFrozen(manual)).toBe(false) + expect(Object.isFrozen(manual.headers)).toBe(false) + }), +) diff --git a/tests/packed-consumer/construction-baseline.mts b/tests/packed-consumer/construction-baseline.mts deleted file mode 100644 index fb0df31..0000000 --- a/tests/packed-consumer/construction-baseline.mts +++ /dev/null @@ -1,248 +0,0 @@ -import { QueryClient } from '@tanstack/query-core' -import { Clock, Effect, Layer, Schema } from 'effect' -import { - createHttpApiQueryUtils, - createRpcQueryUtils, - EffectRpcQueryConfigError, - EffectRpcQueryKeyError, - type JsonValue, -} from 'effect-api-query' -import { HttpServer } from 'effect/http' -import { - HttpApi, - HttpApiBuilder, - HttpApiEndpoint, - HttpApiGroup, - HttpApiTest, -} from 'effect/http-api' -import { Rpc, RpcGroup, RpcTest } from 'effect/rpc' -import { deepStrictEqual, equal, ok, throws } from 'node:assert/strict' - -const Payload = Schema.Struct({ - id: Schema.Int, - locale: Schema.String.pipe(Schema.withConstructorDefault(Effect.succeed('en'))), - rows: Schema.Array( - Schema.Struct({ id: Schema.Int, title: Schema.String, flags: Schema.Array(Schema.Boolean) }), - ), -}) -const input = (count: number) => ({ - id: 1, - rows: Array.from({ length: count }, (_, id) => ({ - id, - title: `record-${id}`, - flags: [true, false], - })), -}) -const records = [1, 100] as const -const operationCounts = [1, 250] as const -const samples = 5 -const measurements: { - adapter: 'rpc' | 'http' - stage: string - operations: number - records: number - iterations: number - milliseconds: number[] -}[] = [] - -const measure = ( - clock: Clock.Clock, - adapter: 'rpc' | 'http', - stage: string, - operations: number, - recordCount: number, - iterations: number, - operation: () => unknown, -) => { - for (let warmup = 0; warmup < 20; warmup += 1) operation() - const milliseconds = Array.from({ length: samples }, () => { - const started = clock.monotonicTimeNanosUnsafe() - for (let iteration = 0; iteration < iterations; iteration += 1) operation() - return Number(clock.monotonicTimeNanosUnsafe() - started) / 1_000_000 - }) - measurements.push({ adapter, stage, operations, records: recordCount, iterations, milliseconds }) -} - -await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const clock = yield* Clock.Clock - for (const operationCount of operationCounts) { - const rpcs = Array.from({ length: operationCount }, (_, index) => - Rpc.make(`records.read-${index}`, { payload: Payload, success: Schema.Int }), - ) - const group = RpcGroup.make(...rpcs) - const rpcClient = yield* RpcTest.makeClient(group, { flatten: true }).pipe( - Effect.provide( - group.toLayer( - Object.fromEntries( - rpcs.map((rpc) => [ - rpc._tag, - (value: typeof Payload.Type) => Effect.succeed(value.id), - ]), - ), - ), - ), - ) - const rpcFactory = () => - createRpcQueryUtils(group, { client: rpcClient, keyPrefix: ['construction', 'owner-a'] }) - const rpc = rpcFactory() - equal(Object.isFrozen(rpc), true) - equal(Object.isFrozen(rpc.records), true) - equal(Object.keys(rpc.records).length, operationCount + 1) - equal(Object.isFrozen(rpc.records['read-0']), true) - measure(clock, 'rpc', 'factory', operationCount, 0, 50, rpcFactory) - - const endpoint = (index: number) => - HttpApiEndpoint.post(`read-${index}`, `/read-${index}/:owner`, { - params: { owner: Schema.NumberFromString }, - payload: Payload, - success: Schema.Int, - }) - const endpoints = [ - endpoint(0), - ...Array.from({ length: operationCount - 1 }, (_, index) => endpoint(index + 1)), - ] as const - const api = HttpApi.make('construction').add(HttpApiGroup.make('records').add(...endpoints)) - const httpHandlers: Record< - `read-${number}`, - (request: { payload: typeof Payload.Type }) => Effect.Effect - > = Object.fromEntries( - endpoints.map((current) => [ - current.identifier, - ({ payload }: { payload: typeof Payload.Type }) => Effect.succeed(payload.id), - ]), - ) - const httpClient = yield* HttpApiTest.groups(api, ['records']).pipe( - Effect.provide( - Layer.mergeAll( - HttpApiBuilder.group(api, 'records', (handlers) => handlers.handleAll(httpHandlers)), - HttpServer.layerServices, - ), - ), - ) - const httpFactory = () => - createHttpApiQueryUtils(api, { - client: httpClient, - keyPrefix: ['construction', 'owner-a'], - }) - const http = httpFactory() - equal(Object.isFrozen(http), true) - equal(Object.isFrozen(http.records), true) - equal(Object.keys(http.records).length, operationCount + 1) - equal(Object.isFrozen(http.records['read-0']), true) - measure(clock, 'http', 'factory', operationCount, 0, 50, httpFactory) - - if (operationCount !== 1) continue - const custom = createRpcQueryUtils(group, { - client: rpcClient, - keyPrefix: ['construction', 'owner-a'], - keyEncoders: { 'records.read-0': (value) => value }, - }) - const leaf = rpc.records['read-0'] - const customLeaf = custom.records['read-0'] - const httpLeaf = http.records['read-0'] - ok(leaf !== undefined) - ok(customLeaf !== undefined) - ok(httpLeaf !== undefined) - for (const recordCount of records) { - const value = input(recordCount) - const normalized = Payload.make(value) - const encode = Schema.encodeSync(Payload) - const request = { params: { owner: 7 }, payload: normalized } - const queryKey = leaf.queryKey(value) - const httpKey: readonly JsonValue[] = httpLeaf.queryKey(request) - deepStrictEqual(httpKey.slice(0, 7), [ - 'construction', - 'owner-a', - 'http', - 'construction', - 'records', - 'read-0', - 'query', - ]) - equal(Object.isFrozen(httpKey), true) - equal(Object.isFrozen(httpKey.at(-1)), true) - deepStrictEqual(queryKey, customLeaf.queryKey(value)) - deepStrictEqual(queryKey.slice(0, 6), [ - 'construction', - 'owner-a', - 'rpc', - 'records', - 'read-0', - 'query', - ]) - equal(Object.isFrozen(leaf.queryOptions({ input: value, staleTime: 1000 })), false) - equal(Object.isFrozen(queryKey), true) - equal(Object.isFrozen(queryKey.at(-1)), true) - equal(Object.isFrozen(value), false) - equal(Object.isFrozen(value.rows), false) - measure(clock, 'rpc', 'payload-construction', 1, recordCount, 500, () => - Payload.make(value), - ) - measure(clock, 'rpc', 'schema-encoding', 1, recordCount, 500, () => encode(normalized)) - measure(clock, 'rpc', 'query-key', 1, recordCount, 500, () => leaf.queryKey(value)) - measure(clock, 'rpc', 'query-key-custom-encoder', 1, recordCount, 500, () => - customLeaf.queryKey(value), - ) - measure(clock, 'rpc', 'query-options', 1, recordCount, 500, () => - leaf.queryOptions({ input: value }), - ) - measure(clock, 'http', 'query-key', 1, recordCount, 500, () => httpLeaf.queryKey(request)) - measure(clock, 'http', 'query-options', 1, recordCount, 500, () => - httpLeaf.queryOptions({ input: request }), - ) - } - const queryClient = new QueryClient() - try { - const first = leaf.queryOptions({ input: { id: 41, rows: [] } }) - const second = leaf.queryOptions({ input: { id: 42, rows: [] } }) - deepStrictEqual( - yield* Effect.promise(() => - Promise.all([queryClient.query(first), queryClient.query(second)]), - ), - [41, 42], - ) - equal(queryClient.getQueryData(first.queryKey), 41) - equal(queryClient.getQueryData(second.queryKey), 42) - const otherOwner = createRpcQueryUtils(group, { - client: rpcClient, - keyPrefix: ['construction', 'owner-b'], - }) - const otherLeaf = otherOwner.records['read-0'] - ok(otherLeaf !== undefined) - equal(queryClient.getQueryData(otherLeaf.queryKey({ id: 41, rows: [] })), undefined) - equal( - yield* Effect.promise(() => - queryClient.query( - httpLeaf.queryOptions({ - input: { params: { owner: 7 }, payload: { id: 43, locale: 'en', rows: [] } }, - }), - ), - ), - 43, - ) - throws(() => leaf.queryKey({ id: 1.5, rows: [] }), EffectRpcQueryKeyError) - const invalidGroup = RpcGroup.make( - Rpc.make('records.key', { payload: Payload, success: Schema.Int }), - ) - const invalidClient = yield* RpcTest.makeClient(invalidGroup, { flatten: true }).pipe( - Effect.provide(invalidGroup.toLayer({ 'records.key': () => Effect.succeed(0) })), - ) - throws( - () => - createRpcQueryUtils(invalidGroup, { - client: invalidClient, - keyPrefix: ['construction'], - }), - EffectRpcQueryConfigError, - ) - } finally { - queryClient.clear() - } - } - }), - ), -) - -console.log(JSON.stringify({ samples, warmupIterations: 20, measurements })) diff --git a/tests/packed-consumer/defaults-contract.ts b/tests/packed-consumer/defaults-contract.ts deleted file mode 100644 index 91ed2a9..0000000 --- a/tests/packed-consumer/defaults-contract.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { QueryObserver } from '@tanstack/query-core' -import type { Schema } from 'effect' -import type { EffectHttpApiQueryError, EffectRpcQueryError } from 'effect-api-query' -import type { HttpClientError } from 'effect/http' -import type { HttpApiClient } from 'effect/http-api' -import type { RpcClient, RpcClientError, RpcGroup } from 'effect/rpc' - -import { createApplicationQueries, usersRpc } from './docs-defaults.ts' -import { createHttpApplicationQueries, usersApi } from './docs-http-defaults.ts' - -type Equal = - (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false -type Assert = T -type User = { readonly id: number; readonly name: string } -type RetryLater = { readonly _tag: 'RetryLater' } - -declare const rpcClient: RpcClient.RpcClient.Flat< - RpcGroup.Rpcs, - RpcClientError.RpcClientError -> -declare const httpClient: HttpApiClient.ForApi - -const rpcSetup = createApplicationQueries(rpcClient) -const httpSetup = createHttpApplicationQueries(httpClient) -const rpcSelected = new QueryObserver(rpcSetup.queryClient, rpcSetup.user).getCurrentResult() -const httpObserved = new QueryObserver(httpSetup.queryClient, httpSetup.user).getCurrentResult() -const rpcFetched = rpcSetup.queryClient.query(rpcSetup.user) -const rpcRefreshed = rpcSetup.queryClient.query(rpcSetup.refreshUser) -const rpcCached = rpcSetup.queryClient.getQueryData(rpcSetup.user.queryKey) -const httpFetched = httpSetup.queryClient.query(httpSetup.user) -const httpCached = httpSetup.queryClient.getQueryData(httpSetup.user.queryKey) -const metadataFetched = httpSetup.queryClient.query(httpSetup.metadata) -const metadataCached = httpSetup.queryClient.getQueryData(httpSetup.metadata.queryKey) - -type Metadata = { - readonly data: User - readonly status: number - readonly headers: Readonly> -} - -type Contract = [ - Assert>, - Assert< - Equal< - typeof rpcSelected.error, - EffectRpcQueryError | null - > - >, - Assert>>, - Assert>>, - Assert>, - Assert>, - Assert>>, - Assert>, - Assert>>, - Assert>, - Assert, never>>, - Assert, never>>, - Assert, never>>, -] - -rpcSetup.rpc.users.get.queryOptions({ - input: { id: 1 }, - retry: (_count, error) => { - error satisfies EffectRpcQueryError - return false - }, -}) -httpSetup.http.users.get.queryOptions({ - input: { params: { id: 1 } }, - retry: (_count, error) => { - error satisfies EffectHttpApiQueryError< - RetryLater | HttpClientError.HttpClientError | Schema.SchemaError - > - return false - }, -}) -httpSetup.http.users.get.metadataOptions({ - input: { params: { id: 1 } }, - select: (snapshot) => snapshot.data.name, - retry: (_count, error) => { - error satisfies EffectHttpApiQueryError< - RetryLater | HttpClientError.HttpClientError | Schema.SchemaError - > - return false - }, -}) - -rpcSetup.queryClient.setQueryData(rpcSetup.user.queryKey, { id: 1, name: 'Grace' }) -httpSetup.queryClient.setQueryData(httpSetup.user.queryKey, { id: 1, name: 'Grace' }) -httpSetup.queryClient.setQueryData(httpSetup.metadata.queryKey, { - data: { id: 1, name: 'Grace' }, - status: 200, - headers: { etag: '"v2"' }, -}) - -// @ts-expect-error A selected string is not the cached RPC query data. -rpcSetup.queryClient.setQueryData(rpcSetup.user.queryKey, 'Grace') -// @ts-expect-error HTTP cache writes preserve the declared decoded data type. -httpSetup.queryClient.setQueryData(httpSetup.user.queryKey, { id: '1', name: 'Grace' }) -// @ts-expect-error A metadata key requires the complete metadata view. -httpSetup.queryClient.setQueryData(httpSetup.metadata.queryKey, { id: 1, name: 'Grace' }) -// @ts-expect-error Generated builders own query keys. -rpcSetup.rpc.users.get.queryOptions({ input: { id: 1 }, queryKey: ['replacement'] }) -// @ts-expect-error Generated builders own execution functions. -httpSetup.http.users.get.queryOptions({ - input: { params: { id: 1 } }, - queryFn: async () => ({ id: 1, name: 'replacement' }), -}) -// @ts-expect-error Hashing belongs to QueryClient defaults. -rpcSetup.rpc.users.get.queryOptions({ input: { id: 1 }, queryKeyHashFn: JSON.stringify }) -// @ts-expect-error Hashing belongs to QueryClient defaults. -httpSetup.http.users.get.metadataOptions({ input: { params: { id: 1 } }, queryHash: 'replacement' }) - -declare const contract: Contract -contract satisfies [true, true, true, true, true, true, true, true, true, true, true, true, true] diff --git a/tests/packed-consumer/defaults-runtime.mts b/tests/packed-consumer/defaults-runtime.mts deleted file mode 100644 index 92dfcc6..0000000 --- a/tests/packed-consumer/defaults-runtime.mts +++ /dev/null @@ -1,241 +0,0 @@ -import { QueryClient, QueryObserver } from '@tanstack/query-core' -import { Effect, Layer } from 'effect' -import { isEffectRpcQueryError } from 'effect-api-query' -import { HttpServer, HttpServerResponse } from 'effect/http' -import { HttpApiBuilder, HttpApiTest } from 'effect/http-api' -import { RpcTest } from 'effect/rpc' -import { deepStrictEqual, equal, ok, rejects } from 'node:assert/strict' - -import { createApplicationQueries, usersRpc } from './docs-defaults.ts' -import { createHttpApplicationQueries, usersApi } from './docs-http-defaults.ts' - -await Effect.runPromise( - Effect.gen(function* () { - const attempts = new Map() - const client = yield* RpcTest.makeClient(usersRpc, { flatten: true }).pipe( - Effect.provide( - usersRpc.toLayer({ - 'users.get': ({ id }) => { - const attempt = (attempts.get(id) ?? 0) + 1 - attempts.set(id, attempt) - return id === 2 || attempt < 3 - ? Effect.fail({ _tag: 'RetryLater' as const }) - : Effect.succeed({ id, name: 'Ada' }) - }, - }), - ), - ) - const { queryClient, rpc, user, refreshUser } = createApplicationQueries(client) - const observer = new QueryObserver(queryClient, user) - let unsubscribe: () => void = () => undefined - try { - equal(observer.options.staleTime, 120_000) - equal(observer.options.gcTime, 600_000) - equal(observer.options.retry, 2) - unsubscribe = observer.subscribe(() => undefined) - equal(yield* Effect.promise(() => queryClient.query(user)), 'Ada') - equal(attempts.get(1), 3) - equal(observer.getCurrentResult().data, 'Ada') - equal(observer.getCurrentResult().isStale, false) - deepStrictEqual(queryClient.getQueryData(rpc.users.get.queryKey({ id: 1 })), { - id: 1, - name: 'Ada', - }) - queryClient.setQueryData(rpc.users.get.queryKey({ id: 1 }), { id: 1, name: 'Grace' }) - equal(observer.getCurrentResult().data, 'Grace') - equal(yield* Effect.promise(() => queryClient.query(user)), 'Grace') - equal(attempts.get(1), 3) - deepStrictEqual(yield* Effect.promise(() => queryClient.query(refreshUser)), { - id: 1, - name: 'Ada', - }) - equal(attempts.get(1), 4) - const refreshObserver = new QueryObserver(queryClient, refreshUser) - equal(refreshObserver.options.staleTime, 0) - equal(refreshObserver.options.retry, false) - refreshObserver.destroy() - yield* Effect.promise(() => - rejects( - queryClient.query(rpc.users.get.queryOptions({ input: { id: 2 }, retry: false })), - (error: unknown) => isEffectRpcQueryError(error), - ), - ) - equal(attempts.get(2), 1) - yield* Effect.promise(() => - rejects( - queryClient.query(rpc.users.get.queryOptions({ input: { id: 2 }, retryDelay: 0 })), - (error: unknown) => isEffectRpcQueryError(error), - ), - ) - equal(attempts.get(2), 4) - } finally { - unsubscribe() - observer.destroy() - queryClient.clear() - } - for (const mode of ['global', 'prefix'] as const) { - const seedClient = new QueryClient({ - defaultOptions: { - queries: { - gcTime: Infinity, - staleTime: Infinity, - retry: false, - ...(mode === 'global' ? { initialData: { id: -1, name: 'inherited' } } : {}), - }, - }, - }) - if (mode === 'prefix') { - seedClient.setQueryDefaults(rpc.users.key(), { - initialData: { id: -1, name: 'inherited' }, - }) - } - try { - const options = rpc.users.get.queryOptions({ - input: { id: 1 }, - initialData: undefined, - }) - deepStrictEqual(yield* Effect.promise(() => seedClient.query(options)), { - id: 1, - name: 'Ada', - }) - } finally { - seedClient.clear() - } - } - for (const mode of ['global', 'prefix'] as const) { - let fallbackCalls = 0 - const hashClient = new QueryClient({ - defaultOptions: { - queries: { - gcTime: Infinity, - staleTime: Infinity, - retry: false, - queryKeyHashFn: (key) => `global:${JSON.stringify(key)}`, - }, - }, - }) - hashClient.setQueryDefaults(rpc.key(), { - queryFn: () => { - fallbackCalls += 1 - return Promise.resolve({ id: -1, name: 'fallback' }) - }, - }) - if (mode === 'prefix') - hashClient.setQueryDefaults(rpc.users.key(), { - queryKeyHashFn: (key) => `prefix:${JSON.stringify(key)}`, - }) - const options = rpc.users.get.queryOptions({ input: { id: 1 } }) - const selected = new QueryObserver( - hashClient, - rpc.users.get.queryOptions({ input: { id: 1 }, select: (value) => value.name }), - ) - let stop: () => void = () => undefined - try { - deepStrictEqual(yield* Effect.promise(() => hashClient.query(options)), { - id: 1, - name: 'Ada', - }) - const key = rpc.users.get.queryKey({ id: 1 }) - deepStrictEqual(hashClient.getQueryData(key), { id: 1, name: 'Ada' }) - stop = selected.subscribe(() => undefined) - hashClient.setQueryData(key, { id: 1, name: 'native' }) - equal(selected.getCurrentResult().data, 'native') - deepStrictEqual(yield* Effect.promise(() => hashClient.query(options)), { - id: 1, - name: 'native', - }) - equal(fallbackCalls, 0) - equal(hashClient.getQueryCache().getAll().length, 1) - equal( - hashClient.getQueryCache().getAll()[0]?.queryHash, - `${mode}:["users-app","rpc","users","get","query",{"id":1}]`, - ) - ok(Object.isFrozen(key)) - } finally { - stop() - selected.destroy() - hashClient.clear() - } - } - }).pipe(Effect.scoped), -) - -let httpAttempts = 0 -const httpHandlers = HttpApiBuilder.group(usersApi, 'users', (group) => - group.handleRaw('get', ({ params }) => { - httpAttempts += 1 - return HttpServerResponse.json( - httpAttempts === 1 ? { _tag: 'RetryLater' } : { id: params.id, name: 'Ada' }, - { status: httpAttempts === 1 ? 503 : 200, headers: { etag: '"v1"' } }, - ).pipe(Effect.orDie) - }), -) -await Effect.runPromise( - Effect.gen(function* () { - const client = yield* HttpApiTest.groups(usersApi, ['users']) - const { queryClient, http, user, metadata } = createHttpApplicationQueries(client) - const userObserver = new QueryObserver(queryClient, user) - const metadataObserver = new QueryObserver(queryClient, metadata) - let unsubscribe: () => void = () => undefined - try { - equal(userObserver.options.staleTime, 30_000) - equal(userObserver.options.retry, 1) - equal(metadataObserver.options.staleTime, 5_000) - equal(metadataObserver.options.retry, 1) - deepStrictEqual(yield* Effect.promise(() => queryClient.query(user)), { - id: 1, - name: 'Ada', - }) - equal(httpAttempts, 2) - const snapshot = yield* Effect.promise(() => queryClient.query(metadata)) - deepStrictEqual(snapshot.data, { id: 1, name: 'Ada' }) - equal(snapshot.status, 200) - equal(snapshot.headers['etag'], '"v1"') - equal(httpAttempts, 3) - ok(Object.isFrozen(snapshot)) - ok(Object.isFrozen(snapshot.headers)) - equal(queryClient.getQueryData(http.users.get.metadataKey({ params: { id: 1 } })), snapshot) - unsubscribe = userObserver.subscribe(() => undefined) - queryClient.setQueryData(http.users.get.queryKey({ params: { id: 1 } }), { - id: 1, - name: 'Grace', - }) - deepStrictEqual(userObserver.getCurrentResult().data, { id: 1, name: 'Grace' }) - deepStrictEqual(yield* Effect.promise(() => queryClient.query(user)), { - id: 1, - name: 'Grace', - }) - equal(yield* Effect.promise(() => queryClient.query(metadata)), snapshot) - equal(httpAttempts, 3) - const fresh = http.users.get.queryOptions({ - input: { params: { id: 1 } }, - staleTime: 0, - retry: false, - }) - deepStrictEqual(yield* Effect.promise(() => queryClient.query(fresh)), { - id: 1, - name: 'Ada', - }) - equal(httpAttempts, 4) - equal(queryClient.getQueryCache().getAll().length, 2) - deepStrictEqual( - queryClient - .getQueryCache() - .getAll() - .map((query) => query.queryHash), - [ - 'users:["users-app","http","users","users","get","query",{"params":{"id":"1"}}]', - 'users:["users-app","http","users","users","get","metadata",{"params":{"id":"1"}}]', - ], - ) - } finally { - unsubscribe() - userObserver.destroy() - metadataObserver.destroy() - queryClient.clear() - } - }).pipe(Effect.provide(Layer.mergeAll(httpHandlers, HttpServer.layerServices)), Effect.scoped), -) -console.log( - 'Packed RPC/HTTP defaults, precedence, observed values, retries and native hashing executed', -) diff --git a/tests/packed-consumer/docs-angular.ts b/tests/packed-consumer/docs-angular.ts deleted file mode 100644 index 0ab6641..0000000 --- a/tests/packed-consumer/docs-angular.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Component, signal } from '@angular/core' -import { injectQuery } from '@tanstack/angular-query-experimental' -import { Schema } from 'effect' -import { createRpcQueryUtils } from 'effect-api-query' -import { Rpc, RpcGroup, type RpcClient } from 'effect/rpc' - -const group = RpcGroup.make( - Rpc.make('users.read', { - payload: { id: Schema.Int }, - success: Schema.Struct({ id: Schema.Int, name: Schema.String }), - }), -) -declare const client: RpcClient.RpcClient.Flat> -const rpc = createRpcQueryUtils(group, { client, keyPrefix: ['current-owner'] }) - -@Component({ - selector: 'user-details', - standalone: true, - template: '

{{ user.data() }}

', -}) -class UserDetails { - readonly id = signal(1) - readonly user = injectQuery(() => - rpc.users.read.queryOptions({ - input: { id: this.id() }, - select: (user) => user.name, - }), - ) - - next() { - this.id.update((id) => id + 1) - } -} diff --git a/tests/packed-consumer/docs-cache-filters-http.ts b/tests/packed-consumer/docs-cache-filters-http.ts deleted file mode 100644 index d9d87cd..0000000 --- a/tests/packed-consumer/docs-cache-filters-http.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { Schema } from 'effect' -import { createHttpApiQueryUtils } from 'effect-api-query' -import { - HttpApi, - HttpApiClient, - HttpApiEndpoint, - HttpApiGroup, - HttpApiSchema, -} from 'effect/http-api' - -export const usersApi = HttpApi.make('users-api').add( - HttpApiGroup.make('users').add( - HttpApiEndpoint.post('get', '/users/:id', { - params: { id: Schema.FiniteFromString }, - query: { id: Schema.FiniteFromString, filter: Schema.optional(Schema.String) }, - headers: Schema.Record(Schema.String, Schema.optional(Schema.String)), - payload: Schema.Struct({ id: Schema.FiniteFromString }), - success: Schema.String, - }), - HttpApiEndpoint.get('watch', '/users/events', { - query: { id: Schema.FiniteFromString }, - success: HttpApiSchema.StreamSse({ data: Schema.String }), - }), - ), - HttpApiGroup.make('system', { topLevel: true }).add( - HttpApiEndpoint.get('ping', '/health', { success: Schema.String }), - ), -) - -export const httpCacheFilters = (client: HttpApiClient.ForApi) => { - const http = createHttpApiQueryUtils(usersApi, { client, keyPrefix: ['users-app'] }) - const queryPrefix = [...http.users.get.key(), 'query'] as const - - return { - http, - root: { queryKey: http.key() }, - branch: { queryKey: http.users.key() }, - leaf: { queryKey: http.users.get.key() }, - query: { queryKey: queryPrefix }, - forId: (id: number) => ({ - queryKey: [...queryPrefix, { params: { id: String(id) } }] as const, - }), - forLocale: (locale: string) => ({ - queryKey: [...queryPrefix, { headers: { 'x-locale': locale } }] as const, - }), - historyForId: (id: number) => ({ - queryKey: [...http.users.watch.key(), 'streamed', { query: { id: String(id) } }] as const, - }), - exact: (input: Parameters[0]) => ({ - queryKey: http.users.get.queryKey(input), - exact: true as const, - }), - } -} diff --git a/tests/packed-consumer/docs-cache-filters-rpc.ts b/tests/packed-consumer/docs-cache-filters-rpc.ts deleted file mode 100644 index d9f4a16..0000000 --- a/tests/packed-consumer/docs-cache-filters-rpc.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Effect, Schema } from 'effect' -import { createRpcQueryUtils } from 'effect-api-query' -import { Rpc, RpcClient, RpcGroup } from 'effect/rpc' - -const User = Schema.Struct({ id: Schema.Finite, name: Schema.String }) -const payload = { - id: Schema.FiniteFromString, - locale: Schema.String.pipe(Schema.withConstructorDefault(Effect.succeed('en'))), -} -export const usersRpc = RpcGroup.make( - Rpc.make('users.get', { payload, success: User }), - Rpc.make('users.watch', { payload, success: User, stream: true }), - Rpc.make('users.list', { success: Schema.Array(User) }), - Rpc.make('health.ping', { success: Schema.String }), -) - -export const userCacheFilters = ( - client: RpcClient.RpcClient.Flat, ClientError>, -) => { - const rpc = createRpcQueryUtils(usersRpc, { - client, - keyPrefix: ['users-app'], - }) - const queryPrefix = [...rpc.users.get.key(), 'query'] as const - const historyPrefix = [...rpc.users.watch.key(), 'streamed'] as const - - return { - rpc, - root: { queryKey: rpc.key() }, - branch: { queryKey: rpc.users.key() }, - leaf: { queryKey: rpc.users.get.key() }, - query: { queryKey: queryPrefix }, - forId: (id: number) => ({ queryKey: [...queryPrefix, { id: String(id) }] as const }), - history: { queryKey: historyPrefix }, - historyForId: (id: number) => ({ - queryKey: [...historyPrefix, { id: String(id) }] as const, - }), - exact: (id: number, locale = 'en') => ({ - queryKey: rpc.users.get.queryKey({ id, locale }), - exact: true as const, - }), - } -} diff --git a/tests/packed-consumer/docs-defaults.ts b/tests/packed-consumer/docs-defaults.ts deleted file mode 100644 index 9132b09..0000000 --- a/tests/packed-consumer/docs-defaults.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { QueryClient } from '@tanstack/query-core' -import { Schema } from 'effect' -import { createRpcQueryUtils } from 'effect-api-query' -import { Rpc, RpcGroup } from 'effect/rpc' -import type { RpcClient } from 'effect/rpc' - -export const usersRpc = RpcGroup.make( - Rpc.make('users.get', { - payload: { id: Schema.Int }, - success: Schema.Struct({ id: Schema.Int, name: Schema.String }), - error: Schema.TaggedStruct('RetryLater', {}), - }), -) - -export const createApplicationQueries = ( - client: RpcClient.RpcClient.Flat, ClientError>, -) => { - const rpc = createRpcQueryUtils(usersRpc, { - client, - keyPrefix: ['users-app'], - }) - const queryClient = new QueryClient({ - defaultOptions: { - queries: { staleTime: 30_000, gcTime: 600_000, retry: false, retryDelay: 250 }, - mutations: { retry: false }, - }, - }) - queryClient.setQueryDefaults(rpc.key(), { staleTime: 60_000 }) - queryClient.setQueryDefaults(rpc.users.key(), { retry: 2 }) - queryClient.setQueryDefaults([...rpc.users.get.key(), 'query'], { staleTime: 120_000 }) - - const user = rpc.users.get.queryOptions({ - input: { id: 1 }, - select: (value) => value.name, - }) - const refreshUser = rpc.users.get.queryOptions({ - input: { id: 1 }, - staleTime: 0, - retry: false, - }) - return { queryClient, rpc, user, refreshUser } -} diff --git a/tests/packed-consumer/docs-http-defaults.ts b/tests/packed-consumer/docs-http-defaults.ts deleted file mode 100644 index c44c50a..0000000 --- a/tests/packed-consumer/docs-http-defaults.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { QueryClient } from '@tanstack/query-core' -import { Schema } from 'effect' -import { createHttpApiQueryUtils } from 'effect-api-query' -import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from 'effect/http-api' -import type { HttpApiClient } from 'effect/http-api' - -export const usersApi = HttpApi.make('users').add( - HttpApiGroup.make('users').add( - HttpApiEndpoint.get('get', '/users/:id', { - params: { id: Schema.Int }, - success: Schema.Struct({ id: Schema.Int, name: Schema.String }), - error: Schema.TaggedStruct('RetryLater', {}).pipe(HttpApiSchema.status(503)), - }), - ), -) - -export const createHttpApplicationQueries = (client: HttpApiClient.ForApi) => { - const http = createHttpApiQueryUtils(usersApi, { client, keyPrefix: ['users-app'] }) - const queryClient = new QueryClient({ - defaultOptions: { - queries: { staleTime: 30_000, retry: false, retryDelay: 250 }, - }, - }) - queryClient.setQueryDefaults(http.key(), { - queryKeyHashFn: (key) => `users:${JSON.stringify(key)}`, - }) - queryClient.setQueryDefaults(http.users.key(), { retry: 1 }) - queryClient.setQueryDefaults([...http.users.get.key(), 'metadata'], { staleTime: 5_000 }) - - const input = { params: { id: 1 } } - const user = http.users.get.queryOptions({ input }) - const metadata = http.users.get.metadataOptions({ input }) - return { queryClient, http, user, metadata } -} diff --git a/tests/packed-consumer/docs-openapi.ts b/tests/packed-consumer/docs-openapi.ts deleted file mode 100644 index b80ff66..0000000 --- a/tests/packed-consumer/docs-openapi.ts +++ /dev/null @@ -1,37 +0,0 @@ -import createClient from 'openapi-fetch' - -import type { paths } from './openapi-generated.ts' - -const external = createClient({ baseUrl: 'https://api.example.test' }) -const report = await external.POST('/reports/{id}', { - params: { - path: { id: '5' }, - query: { limit: '2', labels: ['a b', 'c+d'] }, - header: { 'x-factor': '3' }, - }, - body: { amount: '7', title: 'Quarter 1' }, -}) -if (report.error) { - throw new Error(`Report failed with HTTP ${report.response.status}`) -} -const revision = report.response.headers.get('x-revision') - -const uploaded = await external.POST('/reports/{id}/files', { - params: { - path: { id: '5' }, - query: { revision: '3' }, - header: { 'x-kind': 'document' }, - }, - body: { title: 'Notes', file: new File(['contents'], 'notes.txt') }, - bodySerializer: (body) => { - const form = new FormData() - form.set('title', body.title) - form.set('file', body.file) - return form - }, -}) -const events = await external.GET('/reports/events/{channel}', { - params: { path: { channel: 'values' } }, - parseAs: 'stream', -}) -export { events, report, revision, uploaded } diff --git a/tests/packed-consumer/docs-owner-cache.ts b/tests/packed-consumer/docs-owner-cache.ts deleted file mode 100644 index 0cf27ac..0000000 --- a/tests/packed-consumer/docs-owner-cache.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { QueryClient } from '@tanstack/react-query' -import { Effect, Schema } from 'effect' -import { createHttpApiQueryUtils, createRpcQueryUtils } from 'effect-api-query' -import type { RunPromiseExit } from 'effect-api-query' -import { HttpApi, HttpApiEndpoint, HttpApiGroup } from 'effect/http-api' -import type { HttpApiClient } from 'effect/http-api' -import { Rpc, RpcGroup } from 'effect/rpc' -import type { RpcClient } from 'effect/rpc' - -import { makeOwnerCache, ownerKeyPrefix } from './owner-cache.ts' -import type { ApplicationOwnerIdentity, DirectoryStorage } from './owner-cache.ts' - -export const DirectoryUser = Schema.Struct({ - id: Schema.Int, - name: Schema.String, - locale: Schema.String, -}) - -export const ownerGroup = RpcGroup.make( - Rpc.make('users.list', { success: Schema.Array(DirectoryUser) }), - Rpc.make('users.get', { payload: { id: Schema.Int }, success: DirectoryUser }), - Rpc.make('users.create', { - payload: { name: Schema.String }, - success: DirectoryUser, - }), - Rpc.make('users.watch', { success: Schema.String, stream: true }), -) - -export const ownerApi = HttpApi.make('owner-directory').add( - HttpApiGroup.make('users').add( - HttpApiEndpoint.get('list', '/users', { success: Schema.Array(DirectoryUser) }), - HttpApiEndpoint.get('get', '/users/:id', { - params: { id: Schema.Int }, - success: DirectoryUser, - }), - HttpApiEndpoint.post('create', '/users', { - payload: Schema.Struct({ name: Schema.String }), - success: DirectoryUser, - }), - ), -) - -export const makeOwnerQueries = ({ - rpcClient, - httpClient, - identity, - storage, - runPromiseExit = Effect.runPromiseExit, -}: { - readonly rpcClient: RpcClient.RpcClient.Flat, RpcError> - readonly httpClient: HttpApiClient.ForApi - readonly identity: ApplicationOwnerIdentity - readonly storage?: DirectoryStorage - readonly runPromiseExit?: RunPromiseExit -}) => { - const queryClient = new QueryClient({ - defaultOptions: { - queries: { retry: false }, - mutations: { retry: false, networkMode: 'always' }, - }, - }) - const keyPrefix = ownerKeyPrefix(identity) - const rpc = createRpcQueryUtils(ownerGroup, { - client: rpcClient, - keyPrefix, - runPromiseExit, - }) - const http = createHttpApiQueryUtils(ownerApi, { client: httpClient, keyPrefix, runPromiseExit }) - const owner = makeOwnerCache({ - identity, - queryClient, - directoryKeys: { rpc: rpc.users.list.queryKey(), http: http.users.list.queryKey() }, - storage, - }) - owner.restoreDirectory() - const invalidateUsers = async () => { - if (!owner.isActive()) { - return - } - await Promise.all([ - queryClient.invalidateQueries({ queryKey: rpc.users.key() }), - queryClient.invalidateQueries({ queryKey: http.users.key() }), - ]) - } - const createUser = owner.trackMutationOptions( - rpc.users.create.mutationOptions({ - onSuccess: async (user) => { - if (!owner.isActive()) { - return - } - queryClient.setQueryData(rpc.users.get.queryKey({ id: user.id }), user) - await invalidateUsers() - }, - onSettled: invalidateUsers, - }), - ) - return { owner, queryClient, rpc, http, createUser, invalidateUsers } -} diff --git a/tests/packed-consumer/docs-solid-query.ts b/tests/packed-consumer/docs-solid-query.ts deleted file mode 100644 index 17f8576..0000000 --- a/tests/packed-consumer/docs-solid-query.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { useQuery } from '@tanstack/solid-query' -import { skipToken } from 'effect-api-query' -import { createSignal } from 'solid-js' - -import { rpc } from './queries.ts' - -const [userId] = createSignal(1) -const user = useQuery(() => { - const id = userId() - return rpc.users.read.queryOptions({ - input: id === undefined ? skipToken : { id }, - select: (value) => value.name, - }) -}) diff --git a/tests/packed-consumer/docs-vue-query.ts b/tests/packed-consumer/docs-vue-query.ts deleted file mode 100644 index 1868eba..0000000 --- a/tests/packed-consumer/docs-vue-query.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { InferDataFromTag, InferErrorFromTag } from '@tanstack/query-core' -import { useQuery } from '@tanstack/vue-query' -import { skipToken } from 'effect-api-query' -import { computed, ref } from 'vue' - -import { rpc } from './queries.ts' - -const userId = ref(1) -const options = computed(() => - rpc.users.read.queryOptions({ - input: userId.value === undefined ? skipToken : { id: userId.value }, - select: (user) => user.name, - }), -) -type UserKey = ReturnType -const user = useQuery< - InferDataFromTag, - InferErrorFromTag, - string ->(options) diff --git a/tests/packed-consumer/docs-websocket-use.ts b/tests/packed-consumer/docs-websocket-use.ts deleted file mode 100644 index 7f74872..0000000 --- a/tests/packed-consumer/docs-websocket-use.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Effect } from 'effect' -import { fetchStreamSnapshot } from 'effect-api-query' -import { Socket } from 'effect/socket' - -import { acquireSocketQueries } from './socket-client.ts' - -export const readSocketSnapshot = Effect.fn('readSocketSnapshot')(function* (url: string) { - const application = yield* acquireSocketQueries(url, 'account-42').pipe( - Effect.provide(Socket.layerWebSocketConstructorGlobal), - ) - const { queryClient, rpc } = application - try { - const reads = yield* Effect.promise(() => - Promise.all([ - queryClient.query(rpc.values.read.queryOptions({ input: { id: 1 } })), - queryClient.query(rpc.values.read.queryOptions({ input: { id: 2 } })), - ]), - ) - const input = { channel: 'clock' } - const history = yield* Effect.promise(() => - fetchStreamSnapshot(queryClient, rpc.values.watch.streamedOptions({ input, maxChunks: 20 }), { - timeoutMs: 5_000, - }), - ) - const latest = yield* Effect.promise(() => - fetchStreamSnapshot(queryClient, rpc.values.watch.liveOptions({ input }), { - timeoutMs: 5_000, - }), - ) - return { reads, history, latest } - } finally { - yield* application.dispose - } -}) diff --git a/tests/packed-consumer/events-contract.ts b/tests/packed-consumer/events-contract.ts deleted file mode 100644 index 85b047a..0000000 --- a/tests/packed-consumer/events-contract.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { Effect, Schema, Scope, Stream } from 'effect' - -import type { attachUserEvents } from './docs-user-events.ts' -import type { - UserEvent, - UserEventConsumerUnavailable, - makeUserEventConsumer, -} from './user-events.ts' - -type Equal = - (() => Value extends Left ? 1 : 2) extends () => Value extends Right ? 1 : 2 - ? true - : false -type Assert = Value - -type Attachment = Effect.Success> -declare const attachment: Attachment -interface DeliveryFailure { - readonly _tag: 'DeliveryFailure' -} -interface DeliveryService { - readonly _tag: 'DeliveryService' -} -declare const delivered: Stream.Stream -const consumption = attachment.consume(delivered) -const delivery = attachment.consumer.deliver({ - schema: 'users.v1', - ownerKey: 'owner', - cursor: 1, - kind: 'users.changed', -}) -true satisfies Assert< - Equal>, UserEventConsumerUnavailable> -> -true satisfies Assert>, Scope.Scope>> -true satisfies Assert, DeliveryFailure | Schema.SchemaError>> -true satisfies Assert, DeliveryService>> -true satisfies Assert, Schema.SchemaError>> -true satisfies Assert, never>> -true satisfies Assert[0], UserEvent>> -true satisfies Assert, Promise>> -true satisfies Assert>, number>> diff --git a/tests/packed-consumer/events-runtime.mts b/tests/packed-consumer/events-runtime.mts deleted file mode 100644 index d061e54..0000000 --- a/tests/packed-consumer/events-runtime.mts +++ /dev/null @@ -1,430 +0,0 @@ -import { MutationObserver, QueryClient, QueryObserver } from '@tanstack/query-core' -import { Effect, Exit, Option, Scope, Stream } from 'effect' -import { Reactivity } from 'effect/reactivity' -import { deepStrictEqual, equal } from 'node:assert/strict' - -import { User, UserPage } from '../../examples/contracts/src/contracts.ts' -import { makeControlledUserWrites } from '../fixtures/optimistic-users.ts' -import { attachUserEvents } from './docs-user-events.ts' -import { decodeUserEvent, makeUserEventConsumer } from './user-events.ts' - -const queryClient = new QueryClient({ - defaultOptions: { queries: { queryKeyHashFn: (key) => `owner:${JSON.stringify(key)}` } }, -}) -const fixture = await Effect.runPromise(makeControlledUserWrites(queryClient)) -const { rpcQuery, httpQuery } = fixture.application -queryClient.setQueryDefaults(rpcQuery.users.key(), { - queryKeyHashFn: (key) => `rpc:${JSON.stringify(key)}`, -}) -queryClient.setQueryDefaults(httpQuery.users.key(), { - queryKeyHashFn: (key) => `http:${JSON.stringify(key)}`, -}) -const scope = Effect.runSync(Scope.make()) -const reactivity = Effect.runSync(Reactivity.make) -const ada = new User({ id: 1, name: 'Ada', locale: 'en' }) -const alan = new User({ id: 2, name: 'Alan', locale: 'en' }) -const rpcDirectory = rpcQuery.users.list.queryKey() -const httpDirectory = httpQuery.users.list.queryKey() -const rpcAda = rpcQuery.users.get.queryKey({ id: 1, locale: 'en' }) -const httpAda = httpQuery.users.get.queryKey({ params: { id: 1 }, query: { locale: 'en' } }) -const rpcAlan = rpcQuery.users.get.queryKey({ id: 2, locale: 'en' }) -const diagnostic = rpcQuery.diagnostics.status.queryKey() -queryClient.setQueryData(rpcDirectory, [ada, alan]) -queryClient.setQueryData(httpDirectory, [ada, alan]) -queryClient.setQueryData(rpcAda, ada) -queryClient.setQueryData(httpAda, ada) -queryClient.setQueryData(rpcAlan, alan) -queryClient.setQueryData(diagnostic, { started: 0, interrupted: 0 }) -try { - const consumer = await Effect.runPromise( - makeUserEventConsumer(fixture.application, reactivity, { - observedUsers: [{ id: 1, locale: 'en' }], - }).pipe(Scope.provide(scope)), - ) - equal( - await Effect.runPromise( - consumer.deliver({ - schema: 'users.v1', - ownerKey: consumer.ownerKey, - cursor: 1, - kind: 'user.changed', - id: 1, - locale: 'en', - }), - ), - true, - ) - await consumer.flush() - equal(queryClient.getQueryState(rpcDirectory)?.isInvalidated, true) - equal(queryClient.getQueryState(httpDirectory)?.isInvalidated, true) - equal(queryClient.getQueryState(rpcAda)?.isInvalidated, true) - equal(queryClient.getQueryState(httpAda)?.isInvalidated, true) - equal(queryClient.getQueryState(rpcAlan)?.isInvalidated, false) - equal(queryClient.getQueryState(diagnostic)?.isInvalidated, false) - queryClient.setQueryData(rpcAda, ada) - queryClient.setQueryData(httpAda, ada) - reactivity.invalidateUnsafe([consumer.reactivityKeys.user({ id: 1, locale: 'en' })]) - await consumer.flush() - equal(queryClient.getQueryState(rpcAda)?.isInvalidated, true) - equal(queryClient.getQueryState(httpAda)?.isInvalidated, true) - equal(queryClient.getQueryState(rpcAlan)?.isInvalidated, false) - fixture.holdLists() - const rpcObserver = new QueryObserver( - queryClient, - rpcQuery.users.list.queryOptions({ - staleTime: Infinity, - }), - ) - const httpObserver = new QueryObserver( - queryClient, - httpQuery.users.list.queryOptions({ - staleTime: Infinity, - }), - ) - queryClient.setQueryData(rpcDirectory, [ada, alan]) - queryClient.setQueryData(httpDirectory, [ada, alan]) - const unsubscribeRpc = rpcObserver.subscribe(() => {}) - const unsubscribeHttp = httpObserver.subscribe(() => {}) - try { - await Effect.runPromise( - reactivity.withBatch( - Effect.gen(function* () { - yield* reactivity.invalidate([consumer.reactivityKeys.directory]) - yield* reactivity.invalidate([consumer.reactivityKeys.directory]) - yield* reactivity.invalidate([consumer.reactivityKeys.all]) - }), - ), - ) - const first = await fixture.nextList() - const second = await fixture.nextList() - const grace = new User({ id: 3, name: 'Grace', locale: 'fr' }) - await first.succeed([grace]) - await second.succeed([grace]) - await consumer.flush() - deepStrictEqual( - queryClient.getQueryData(rpcDirectory)?.map((user) => user.name), - ['Grace'], - ) - deepStrictEqual( - queryClient.getQueryData(httpDirectory)?.map((user) => user.name), - ['Grace'], - ) - equal(queryClient.getQueryState(diagnostic)?.isInvalidated, false) - } finally { - unsubscribeRpc() - unsubscribeHttp() - } - queryClient.setQueryData(rpcAda, ada) - queryClient.setQueryData(httpAda, ada) - equal( - await Effect.runPromise( - consumer.deliver({ - schema: 'users.v1', - ownerKey: consumer.ownerKey, - cursor: 1, - kind: 'user.changed', - id: 1, - locale: 'en', - }), - ), - false, - ) - await consumer.flush() - equal(queryClient.getQueryState(rpcAda)?.isInvalidated, false) - equal(await Effect.runPromise(consumer.resume(true)), 1) - equal( - await Effect.runPromise( - consumer.deliver({ - schema: 'users.v1', - ownerKey: consumer.ownerKey, - cursor: 3, - kind: 'user.changed', - id: 1, - locale: 'en', - }), - ), - true, - ) - await consumer.flush() - equal(queryClient.getQueryState(rpcAlan)?.isInvalidated, true) - equal(queryClient.getQueryState(diagnostic)?.isInvalidated, false) - queryClient.setQueryData(rpcAlan, alan) - equal(await Effect.runPromise(consumer.resume(false)), 0) - await consumer.flush() - equal(queryClient.getQueryState(rpcAlan)?.isInvalidated, true) - equal(consumer.cursor(), 0) - queryClient.setQueryData(rpcAda, ada) - const rpcPages = rpcQuery.users.page.infiniteKey({ cursor: 0, pageSize: 4 }) - const httpPages = httpQuery.users.page.infiniteKey({ query: { cursor: 0, pageSize: 4 } }) - const pages = { - pages: [new UserPage({ users: [ada], total: 1, nextCursor: null })], - pageParams: [0], - } - queryClient.setQueryData(rpcPages, pages) - queryClient.setQueryData(httpPages, pages) - equal( - await Effect.runPromise( - consumer.deliver({ - schema: 'users.v1', - ownerKey: consumer.ownerKey, - cursor: 1, - kind: 'users.changed', - }), - ), - true, - ) - await consumer.flush() - equal(queryClient.getQueryState(rpcPages)?.isInvalidated, true) - equal(queryClient.getQueryState(httpPages)?.isInvalidated, true) - equal(queryClient.getQueryState(rpcAda)?.isInvalidated, false) - equal( - await Effect.runPromise( - consumer.deliver({ - schema: 'users.v1', - ownerKey: consumer.ownerKey, - cursor: 2, - kind: 'users.reset', - }), - ), - true, - ) - await consumer.flush() - equal(queryClient.getQueryState(rpcAda)?.isInvalidated, true) - const malformed = await Effect.runPromiseExit( - decodeUserEvent({ - schema: 'users.v2', - ownerKey: consumer.ownerKey, - cursor: 1, - kind: 'user.changed', - id: 1, - locale: 'en', - }).pipe(Effect.flatMap(consumer.deliver)), - ) - equal(Exit.isFailure(malformed), true) - equal(consumer.cursor(), 2) - equal( - await Effect.runPromise( - consumer.deliver({ - schema: 'users.v1', - ownerKey: 'other-owner', - cursor: 3, - kind: 'user.changed', - id: 1, - locale: 'en', - }), - ), - false, - ) - equal(consumer.cursor(), 2) - const duplicateScope = Effect.runSync(Scope.make()) - try { - const duplicate = await Effect.runPromiseExit( - makeUserEventConsumer(fixture.application, reactivity).pipe(Scope.provide(duplicateScope)), - ) - equal(Exit.isFailure(duplicate), true) - } finally { - await Effect.runPromise(Scope.close(duplicateScope, Exit.void)) - } - queryClient.setQueryData(rpcAda, ada) - await Effect.runPromise(Scope.close(scope, Exit.void)) - reactivity.invalidateUnsafe([consumer.reactivityKeys.all]) - equal( - await Effect.runPromise( - consumer.deliver({ - schema: 'users.v1', - ownerKey: consumer.ownerKey, - cursor: 1, - kind: 'user.changed', - id: 1, - locale: 'en', - }), - ), - false, - ) - await consumer.flush() - equal(queryClient.getQueryState(rpcAda)?.isInvalidated, false) - const reconnectScope = Effect.runSync(Scope.make()) - try { - const reconnect = await Effect.runPromise( - makeUserEventConsumer(fixture.application, reactivity).pipe(Scope.provide(reconnectScope)), - ) - reactivity.invalidateUnsafe([reconnect.reactivityKeys.all]) - const queuedDelivery = Promise.resolve().then(async () => - Effect.runPromise( - reconnect.deliver({ - schema: 'users.v1', - ownerKey: reconnect.ownerKey, - cursor: 1, - kind: 'user.changed', - id: 1, - locale: 'en', - }), - ), - ) - fixture.retire() - equal(await queuedDelivery, false) - await reconnect.flush() - await fixture.application.dispose() - equal(queryClient.getQueryData(rpcAda), undefined) - equal( - await Effect.runPromise( - reconnect.deliver({ - schema: 'users.v1', - ownerKey: reconnect.ownerKey, - cursor: 1, - kind: 'user.changed', - id: 1, - locale: 'en', - }), - ), - false, - ) - const replacementClient = new QueryClient() - const replacement = await Effect.runPromise(makeControlledUserWrites(replacementClient)) - try { - const key = replacement.application.rpcQuery.users.get.queryKey({ id: 1, locale: 'en' }) - const newOwnerUser = new User({ id: 1, locale: 'en', name: 'New owner' }) - replacementClient.setQueryData(key, newOwnerUser) - equal( - await Effect.runPromise( - reconnect.deliver({ - schema: 'users.v1', - ownerKey: reconnect.ownerKey, - cursor: 2, - kind: 'users.reset', - }), - ), - false, - ) - await reconnect.flush() - equal(replacementClient.getQueryState(key)?.isInvalidated, false) - deepStrictEqual(replacementClient.getQueryData(key), newOwnerUser) - } finally { - await replacement.application.dispose() - } - } finally { - await Effect.runPromise(Scope.close(reconnectScope, Exit.void)) - } -} finally { - await Effect.runPromise(Scope.close(scope, Exit.void)) - await fixture.application.dispose() -} - -{ - const client = new QueryClient() - const bounded = await Effect.runPromise(makeControlledUserWrites(client)) - const boundedScope = Effect.runSync(Scope.make()) - const reactivity = Effect.runSync(Reactivity.make) - const key = bounded.application.rpcQuery.users.get.queryKey({ id: 1, locale: 'en' }) - client.setQueryData(key, ada) - try { - const consumer = await Effect.runPromise( - makeUserEventConsumer(bounded.application, reactivity).pipe(Scope.provide(boundedScope)), - ) - await Effect.runPromise( - Effect.gen(function* () { - for (let cursor = 1; cursor <= 65; cursor += 1) { - yield* consumer.deliver({ - schema: 'users.v1', - ownerKey: consumer.ownerKey, - cursor, - kind: 'user.changed', - id: cursor + 1, - locale: 'en', - }) - } - }), - ) - await consumer.flush() - equal(client.getQueryState(key)?.isInvalidated, true) - equal(consumer.cursor(), 65) - } finally { - await Effect.runPromise(Scope.close(boundedScope, Exit.void)) - await bounded.application.dispose() - } -} - -{ - const client = new QueryClient() - const writing = await Effect.runPromise(makeControlledUserWrites(client)) - const writingScope = Effect.runSync(Scope.make()) - const writingReactivity = Effect.runSync(Reactivity.make) - const app = writing.application - writing.seed([ada]) - client.setQueryData(app.rpcQuery.users.list.queryKey(), [ada]) - client.setQueryData(app.httpQuery.users.list.queryKey(), [ada]) - let finishCreation: (() => Promise) | undefined - try { - const consumer = await Effect.runPromise( - makeUserEventConsumer(app, writingReactivity).pipe(Scope.provide(writingScope)), - ) - const mutation = new MutationObserver(client, app.userWrites.rpcCreate()).mutate({ - name: 'Grace', - }) - const create = await writing.nextCreate() - finishCreation = async () => { - await create.succeed(new User({ id: 3, name: 'Grace', locale: 'en' })) - await mutation - } - await Effect.runPromise( - consumer.deliver({ - schema: 'users.v1', - ownerKey: consumer.ownerKey, - cursor: 1, - kind: 'users.reset', - }), - ) - await consumer.flush() - equal(client.getQueryState(app.rpcQuery.users.list.queryKey())?.isInvalidated, false) - deepStrictEqual( - client.getQueryData(app.rpcQuery.users.list.queryKey())?.map((user) => user.name), - ['Ada', 'Grace'], - ) - await create.succeed(new User({ id: 3, name: 'Grace', locale: 'en' })) - await mutation - await consumer.flush() - equal(client.getQueryState(app.rpcQuery.users.list.queryKey())?.isInvalidated, true) - equal(client.getQueryState(app.httpQuery.users.list.queryKey())?.isInvalidated, true) - } finally { - await finishCreation?.() - await Effect.runPromise(Scope.close(writingScope, Exit.void)) - await app.dispose() - } -} - -{ - const client = new QueryClient() - const streaming = await Effect.runPromise(makeControlledUserWrites(client)) - const streamScope = Effect.runSync(Scope.make()) - const app = streaming.application - const reactivity = Effect.runSync(Reactivity.make) - client.setQueryData(app.rpcQuery.users.list.queryKey(), [ada]) - client.setQueryData(app.httpQuery.users.list.queryKey(), [ada]) - try { - const attached = await Effect.runPromise( - attachUserEvents(app, reactivity).pipe(Scope.provide(streamScope)), - ) - const event = { - schema: 'users.v1', - ownerKey: attached.consumer.ownerKey, - cursor: 1, - kind: 'users.changed', - } - await Effect.runPromise(attached.consume(Stream.fromIterable([event, event]))) - equal(attached.consumer.cursor(), 1) - equal(client.getQueryState(app.rpcQuery.users.list.queryKey())?.isInvalidated, true) - equal(client.getQueryState(app.httpQuery.users.list.queryKey())?.isInvalidated, true) - const failedDelivery = await Effect.runPromiseExit( - attached.consume(Stream.fail('connection-lost' as const)), - ) - equal(Exit.isFailure(failedDelivery), true) - equal(Option.getOrThrow(Exit.findErrorOption(failedDelivery)), 'connection-lost') - } finally { - await Effect.runPromise(Scope.close(streamScope, Exit.void)) - await app.dispose() - } -} - -console.log( - 'Packed scoped domain events, replay, native invalidation and optimistic coordination verified', -) diff --git a/tests/packed-consumer/filter-contract.ts b/tests/packed-consumer/filter-contract.ts deleted file mode 100644 index 12b2c9b..0000000 --- a/tests/packed-consumer/filter-contract.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { QueryClient } from '@tanstack/query-core' -import type { QueryFilters } from '@tanstack/query-core' -import type { EffectRpcQueryError } from 'effect-api-query' -import { HttpApiClient } from 'effect/http-api' -import { RpcClient, RpcGroup } from 'effect/rpc' - -import { httpCacheFilters, usersApi } from './docs-cache-filters-http.ts' -import { userCacheFilters, usersRpc } from './docs-cache-filters-rpc.ts' - -type Equal = - (() => Value extends Left ? 1 : 2) extends () => Value extends Right ? 1 : 2 - ? true - : false -type Assert = Value - -declare const rpcClient: RpcClient.RpcClient.Flat> -declare const httpClient: HttpApiClient.ForApi -declare const transportClient: RpcClient.RpcClient.Flat< - RpcGroup.Rpcs, - { readonly _tag: 'TransportUnavailable' } -> -const queryClient = new QueryClient() -const rpcFilters = userCacheFilters(rpcClient) -const httpFilters = httpCacheFilters(httpClient) -const transportFilters = userCacheFilters(transportClient) -const transportState = queryClient.getQueryState(transportFilters.exact(1).queryKey) -transportState?.error satisfies - | EffectRpcQueryError<{ readonly _tag: 'TransportUnavailable' }> - | null - | undefined - -rpcFilters.root.queryKey satisfies readonly ['users-app', 'rpc'] -rpcFilters.branch.queryKey satisfies readonly ['users-app', 'rpc', 'users'] -rpcFilters.leaf.queryKey satisfies readonly ['users-app', 'rpc', 'users', 'get'] -rpcFilters.query.queryKey satisfies readonly ['users-app', 'rpc', 'users', 'get', 'query'] -rpcFilters.forId(1) satisfies QueryFilters -rpcFilters.historyForId(1) satisfies QueryFilters -rpcFilters.exact(1) satisfies QueryFilters -httpFilters.root.queryKey satisfies readonly ['users-app', 'http', 'users-api'] -httpFilters.branch.queryKey satisfies readonly ['users-app', 'http', 'users-api', 'users'] -httpFilters.query.queryKey satisfies readonly [ - 'users-app', - 'http', - 'users-api', - 'users', - 'get', - 'query', -] -httpFilters.forId(1) satisfies QueryFilters -httpFilters.forLocale('en') satisfies QueryFilters -httpFilters.historyForId(1) satisfies QueryFilters - -const exactUser = queryClient.getQueryData(rpcFilters.exact(1).queryKey) -true satisfies Assert< - Equal -> -queryClient.setQueryData(rpcFilters.exact(1).queryKey, { id: 1, name: 'Ada' }) -// @ts-expect-error A concrete DataTag retains its query-data contract -queryClient.setQueryData(rpcFilters.exact(1).queryKey, 'Ada') -// @ts-expect-error Required constructor input cannot be omitted to build a partial filter -rpcFilters.rpc.users.get.queryKey({ locale: 'en' }) - -const partialUser = queryClient.getQueryData(rpcFilters.forId(1).queryKey) -true satisfies Assert> -const matchingUsers = queryClient.getQueriesData(rpcFilters.forId(1)) -true satisfies Assert> -const history = queryClient.getQueryData( - rpcFilters.rpc.users.watch.streamedKey({ id: 1 }, { maxChunks: 2, refetchMode: 'append' }), -) -true satisfies Assert< - Equal -> -const liveUser = queryClient.getQueryData(rpcFilters.rpc.users.watch.liveKey({ id: 1 })) -true satisfies Assert< - Equal -> - -const input = { - params: { id: 1 }, - query: { id: 7 }, - headers: { 'X-Locale': 'en' }, - payload: { id: 9 }, -} -const exactHttp = httpFilters.exact(input) -exactHttp satisfies QueryFilters -const httpData = queryClient.getQueryData(exactHttp.queryKey) -true satisfies Assert> -queryClient.setQueryData(exactHttp.queryKey, 'Ada') -// @ts-expect-error HTTP concrete DataTags retain their own data representation -queryClient.setQueryData(exactHttp.queryKey, { id: 1, name: 'Ada' }) -const partialHttp = queryClient.getQueryData(httpFilters.forId(1).queryKey) -true satisfies Assert> -const httpHistory = queryClient.getQueryData( - httpFilters.http.users.watch.streamedKey({ query: { id: 1 } }, { maxChunks: 2 }), -) -true satisfies Assert> -const httpLive = queryClient.getQueryData( - httpFilters.http.users.watch.liveKey({ query: { id: 1 } }), -) -true satisfies Assert> -const metadata = queryClient.getQueryData(httpFilters.http.users.get.metadataKey(input)) -metadata?.data satisfies string | undefined -metadata?.status satisfies number | undefined -metadata?.headers satisfies Readonly> | undefined - -queryClient.invalidateQueries({ ...rpcFilters.forId(1), type: 'inactive', stale: false }) -queryClient.invalidateQueries(httpFilters.historyForId(1)) -queryClient.invalidateQueries(exactHttp) -queryClient.setQueriesData(rpcFilters.query, (previous) => { - true satisfies Assert> - return previous -}) diff --git a/tests/packed-consumer/filter-runtime.mts b/tests/packed-consumer/filter-runtime.mts deleted file mode 100644 index 1a932b0..0000000 --- a/tests/packed-consumer/filter-runtime.mts +++ /dev/null @@ -1,361 +0,0 @@ -import { QueryClient } from '@tanstack/query-core' -import type { QueryFilters, QueryKey } from '@tanstack/query-core' -import { Effect, Layer, Stream } from 'effect' -import { - createHttpApiQueryUtils, - createRpcQueryUtils, - EffectRpcQueryKeyError, -} from 'effect-api-query' -import { HttpServer } from 'effect/http' -import { HttpApiBuilder, HttpApiTest } from 'effect/http-api' -import { RpcTest } from 'effect/rpc' -import { deepStrictEqual, equal, throws } from 'node:assert/strict' - -import { httpCacheFilters, usersApi } from './docs-cache-filters-http.ts' -import { userCacheFilters, usersRpc } from './docs-cache-filters-rpc.ts' - -const verifyFilters = Effect.fnUntraced(function* ( - queryClient: QueryClient, - cases: readonly [QueryFilters, readonly QueryKey[]][], -) { - const snapshots = queryClient.getQueriesData({}) - for (const [filter, expectedKeys] of cases) { - const owner = new QueryClient() - try { - for (const [key, data] of snapshots) owner.setQueryData(key, data) - deepStrictEqual( - owner.getQueriesData(filter).map(([key]) => key), - expectedKeys, - ) - yield* Effect.promise(() => owner.invalidateQueries(filter)) - deepStrictEqual( - snapshots.map(([key]) => owner.getQueryState(key)?.isInvalidated), - snapshots.map(([key]) => - expectedKeys.some((expected) => JSON.stringify(expected) === JSON.stringify(key)), - ), - ) - } finally { - owner.clear() - } - } -}) - -await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const client = yield* RpcTest.makeClient(usersRpc, { flatten: true }).pipe( - Effect.provide( - usersRpc.toLayer({ - 'users.get': ({ id, locale }) => Effect.succeed({ id, name: locale }), - 'users.watch': ({ id, locale }) => Stream.succeed({ id, name: locale }), - 'users.list': () => Effect.succeed([]), - 'health.ping': () => Effect.succeed('ok'), - }), - ), - ) - const filters = userCacheFilters(client) - const queryClient = new QueryClient() - try { - for (const input of [{ id: 1 }, { id: 1, locale: 'de' }, { id: 2 }]) { - yield* Effect.promise(() => - queryClient.query(filters.rpc.users.get.queryOptions({ input })), - ) - } - equal(queryClient.getQueriesData(filters.forId(1)).length, 2) - deepStrictEqual(queryClient.getQueryData(filters.exact(1).queryKey), { - id: 1, - name: 'en', - }) - deepStrictEqual( - filters.rpc.users.get.queryKey({ id: 1 }), - filters.rpc.users.get.queryKey({ id: 1, locale: 'en' }), - ) - equal( - queryClient.getQueriesData({ - queryKey: [...filters.query.queryKey, { id: 1 }], - }).length, - 0, - ) - equal( - queryClient.getQueriesData({ queryKey: filters.rpc.users.get.queryKey({ id: 1 }) }) - .length, - 1, - ) - throws( - () => Reflect.apply(filters.rpc.users.get.queryKey, undefined, [{ locale: 'en' }]), - EffectRpcQueryKeyError, - ) - equal( - queryClient.getQueriesData({ ...filters.forId(1), type: 'inactive', stale: false }) - .length, - 2, - ) - - const infinite = filters.rpc.users.get.infiniteKey({ id: 1 }) - queryClient.setQueryData(infinite, { - pages: [{ id: 1, name: 'page' }], - pageParams: [0], - }) - const histories = [] - for (const refetchMode of ['reset', 'append', 'replace'] as const) { - for (const maxChunks of [undefined, 2]) { - const key = filters.rpc.users.watch.streamedKey({ id: 1 }, { maxChunks, refetchMode }) - queryClient.setQueryData(key, [{ id: 1, name: refetchMode }]) - histories.push(key) - } - } - const otherHistory = filters.rpc.users.watch.streamedKey({ id: 2 }, { maxChunks: 2 }) - queryClient.setQueryData(otherHistory, [{ id: 2, name: 'other' }]) - const live = filters.rpc.users.watch.liveKey({ id: 1 }) - queryClient.setQueryData(live, { id: 1, name: 'live' }) - const list = filters.rpc.users.list.queryKey() - queryClient.setQueryData(list, []) - const ping = filters.rpc.health.ping.queryKey() - queryClient.setQueryData(ping, 'ok') - queryClient.setQueryData(['unrelated'], 'other') - - const unaryKeys = [ - filters.rpc.users.get.queryKey({ id: 1 }), - filters.rpc.users.get.queryKey({ id: 1, locale: 'de' }), - filters.rpc.users.get.queryKey({ id: 2 }), - ] - const userKeys = [...unaryKeys, infinite, ...histories, otherHistory, live, list] - const cases: readonly [QueryFilters, readonly QueryKey[]][] = [ - [filters.root, [...userKeys, ping]], - [filters.branch, userKeys], - [filters.leaf, [...unaryKeys, infinite]], - [filters.query, unaryKeys], - [filters.forId(1), unaryKeys.slice(0, 2)], - [filters.exact(1), [filters.exact(1).queryKey]], - [filters.history, [...histories, otherHistory]], - [filters.historyForId(1), histories], - [ - { - queryKey: filters.rpc.users.watch.streamedKey({ id: 1 }, { maxChunks: 2 }), - exact: true, - }, - [filters.rpc.users.watch.streamedKey({ id: 1 }, { maxChunks: 2 })], - ], - [ - { - queryKey: filters.rpc.users.watch.streamedKey( - { id: 1 }, - { maxChunks: undefined, refetchMode: 'reset' }, - ), - exact: true, - }, - [filters.rpc.users.watch.streamedKey({ id: 1 })], - ], - ] - yield* verifyFilters(queryClient, cases) - - const projected = createRpcQueryUtils(usersRpc, { - client, - keyPrefix: ['projected'], - keyEncoders: { - 'users.get': ({ id, locale }) => ({ - entity: { identifier: String(id) }, - language: locale, - }), - }, - }) - const scalar = createRpcQueryUtils(usersRpc, { - client, - keyPrefix: ['scalar'], - keyEncoders: { 'users.get': ({ id, locale }) => `${id}:${locale}` }, - }) - for (const input of [{ id: 1 }, { id: 1, locale: 'de' }, { id: 2 }]) { - queryClient.setQueryData(projected.users.get.queryKey(input), { - id: input.id, - name: 'custom', - }) - queryClient.setQueryData(scalar.users.get.queryKey(input), { - id: input.id, - name: 'scalar', - }) - } - equal( - queryClient.getQueriesData({ - queryKey: [...projected.users.get.key(), 'query', { id: '1' }], - }).length, - 0, - ) - equal( - queryClient.getQueriesData({ - queryKey: [...projected.users.get.key(), 'query', { entity: { identifier: '1' } }], - }).length, - 2, - ) - equal(queryClient.getQueriesData({ queryKey: scalar.users.get.key() }).length, 3) - equal( - queryClient.getQueriesData({ queryKey: [...scalar.users.get.key(), 'query', '1'] }) - .length, - 0, - ) - equal( - queryClient.getQueriesData({ - queryKey: scalar.users.get.queryKey({ id: 1 }), - exact: true, - }).length, - 1, - ) - deepStrictEqual(queryClient.getQueryData(projected.users.get.queryKey({ id: 1 })), { - id: 1, - name: 'custom', - }) - } finally { - queryClient.clear() - } - }), - ), -) - -console.log( - 'Packed RPC native prefixes, encoded partial filters, exact keys and stream policies verified', -) - -await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const handlers = HttpApiBuilder.group(usersApi, 'users', (group) => - group - .handle('get', ({ params, query, payload, headers }) => - Effect.succeed(`${params.id}:${query.id}:${payload.id}:${headers['x-locale']}`), - ) - .handle('watch', ({ query }) => Effect.succeed(Stream.succeed(String(query.id)))), - ) - const system = HttpApiBuilder.group(usersApi, 'system', (group) => - group.handle('ping', () => Effect.succeed('ok')), - ) - const client = yield* HttpApiTest.groups(usersApi, ['users', 'system']).pipe( - Effect.provide(Layer.mergeAll(handlers, system, HttpServer.layerServices)), - ) - const filters = httpCacheFilters(client) - const queryClient = new QueryClient() - const input = { - params: { id: 1 }, - query: { id: 7, filter: undefined }, - headers: { 'X-Locale': 'en', ignored: undefined }, - payload: { id: 9 }, - } - const germanInput = { ...input, headers: { 'x-locale': 'de' } } - const otherInput = { ...input, params: { id: 2 } } - const swappedInput = { ...input, query: { id: 9, filter: undefined }, payload: { id: 7 } } - const inputs = [input, germanInput, otherInput, swappedInput] - try { - for (const request of inputs) { - yield* Effect.promise(() => - queryClient.query(filters.http.users.get.queryOptions({ input: request })), - ) - } - deepStrictEqual(queryClient.getQueryData(filters.exact(input).queryKey), '1:7:9:en') - deepStrictEqual( - filters.exact(input).queryKey, - filters.exact({ ...input, query: { id: 7 }, headers: { 'x-locale': 'en' } }).queryKey, - ) - equal(queryClient.getQueriesData(filters.forId(1)).length, 3) - equal(queryClient.getQueriesData(filters.forLocale('en')).length, 3) - for (const partial of [{ params: { id: 1 } }, { headers: { 'X-Locale': 'en' } }]) { - equal( - queryClient.getQueriesData({ queryKey: [...filters.query.queryKey, partial] }).length, - 0, - ) - } - for (const [partial, count] of [ - [{ query: { id: '7' } }, 3], - [{ payload: { id: '7' } }, 1], - [{ id: '7' }, 0], - ] as const) { - equal( - queryClient.getQueriesData({ queryKey: [...filters.query.queryKey, partial] }).length, - count, - ) - } - const metadata = filters.http.users.get.metadataKey(input) - queryClient.setQueryData(metadata, { data: 'metadata', status: 200, headers: {} }) - const infinite = filters.http.users.get.infiniteKey(input) - queryClient.setQueryData(infinite, { pages: ['page'], pageParams: [0] }) - const histories = [] - for (const refetchMode of ['reset', 'append', 'replace'] as const) { - for (const maxChunks of [undefined, 2]) { - const key = filters.http.users.watch.streamedKey( - { query: { id: 1 } }, - { maxChunks, refetchMode }, - ) - queryClient.setQueryData(key, ['history']) - histories.push(key) - } - } - const otherHistory = filters.http.users.watch.streamedKey({ query: { id: 2 } }) - queryClient.setQueryData(otherHistory, ['other']) - const live = filters.http.users.watch.liveKey({ query: { id: 1 } }) - queryClient.setQueryData(live, 'live') - const ping = filters.http.ping.queryKey() - queryClient.setQueryData(ping, 'ok') - queryClient.setQueryData(['unrelated'], 'other') - const unaryKeys = inputs.map((request) => filters.exact(request).queryKey) - const userKeys = [...unaryKeys, metadata, infinite, ...histories, otherHistory, live] - const bounded = filters.http.users.watch.streamedKey({ query: { id: 1 } }, { maxChunks: 2 }) - const cases: readonly [QueryFilters, readonly QueryKey[]][] = [ - [filters.root, [...userKeys, ping]], - [filters.branch, userKeys], - [filters.leaf, [...unaryKeys, metadata, infinite]], - [filters.query, unaryKeys], - [ - filters.forId(1), - [input, germanInput, swappedInput].map((request) => filters.exact(request).queryKey), - ], - [filters.exact(input), [filters.exact(input).queryKey]], - [filters.historyForId(1), histories], - [ - { queryKey: [...filters.http.users.watch.key(), 'streamed'] }, - [...histories, otherHistory], - ], - [{ queryKey: bounded, exact: true }, [bounded]], - ] - yield* verifyFilters(queryClient, cases) - - const projected = createHttpApiQueryUtils(usersApi, { - client, - keyPrefix: ['http-project'], - keyEncoders: { - users: { - get: ({ params, query, payload, headers }) => ({ - routeId: params.id, - queryId: query.id, - bodyId: payload.id, - language: headers['X-Locale'] ?? headers['x-locale'] ?? null, - }), - }, - }, - }) - for (const request of inputs) { - queryClient.setQueryData(projected.users.get.queryKey(request), 'projected') - } - equal( - queryClient.getQueriesData({ - queryKey: [...projected.users.get.key(), 'query', { params: { id: '1' } }], - }).length, - 0, - ) - equal( - queryClient.getQueriesData({ - queryKey: [...projected.users.get.key(), 'query', { routeId: 1 }], - }).length, - 3, - ) - equal( - queryClient.getQueriesData({ queryKey: projected.users.get.queryKey(input), exact: true }) - .length, - 1, - ) - } finally { - queryClient.clear() - } - }), - ), -) - -console.log( - 'Packed HTTP native filters, request labels, header normalization and policies verified', -) diff --git a/tests/packed-consumer/framework-fixture.ts b/tests/packed-consumer/framework-fixture.ts deleted file mode 100644 index eef07fc..0000000 --- a/tests/packed-consumer/framework-fixture.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { Effect, Exit, Schema, Scope, Stream } from 'effect' -import { createRpcQueryUtils, createHttpApiQueryUtils } from 'effect-api-query' -import { HttpServer } from 'effect/http' -import { - HttpApi, - HttpApiEndpoint, - HttpApiGroup, - HttpApiSchema, - HttpApiBuilder, - HttpApiTest, -} from 'effect/http-api' -import { Rpc, RpcGroup, RpcTest } from 'effect/rpc' - -export const expectEqual = (actual: unknown, expected: unknown, message: string) => { - if (JSON.stringify(actual) !== JSON.stringify(expected)) { - throw new Error( - `${message}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`, - ) - } -} -export const waitFor = (check: () => boolean, message: string) => - new Promise((resolve, reject) => { - const deadline = performance.now() + 10_000 - const checkFrame = () => { - if (check()) resolve() - else if (performance.now() >= deadline) reject(new Error(message)) - else requestAnimationFrame(checkFrame) - } - checkFrame() - }) -const Watch = Rpc.make('values.watch', { - payload: { id: Schema.Int }, - success: Schema.Int, - stream: true, -}) -const group = RpcGroup.make(Watch) -const api = HttpApi.make('framework-runtime').add( - HttpApiGroup.make('values').add( - HttpApiEndpoint.get('watch', '/watch/:id', { - params: { id: Schema.FiniteFromString }, - success: HttpApiSchema.StreamSse({ data: Schema.Int }), - }), - HttpApiEndpoint.get('read', '/read/:id', { - params: { id: Schema.FiniteFromString }, - success: Schema.Int.pipe(HttpApiSchema.status(203)), - }), - ), -) -export const makeFixture = async (name: string) => { - const scope = Scope.makeUnsafe() - const finalized: number[] = [] - const client = await Effect.runPromise( - RpcTest.makeClient(group, { flatten: true }).pipe( - Effect.provide( - group.toLayer({ - 'values.watch': ({ id }) => - Stream.unwrap( - Effect.acquireRelease( - Effect.succeed( - Stream.succeed(id).pipe(Stream.concat(Stream.fromEffect(Effect.never))), - ), - () => - Effect.sync(() => { - finalized.push(id) - }), - ), - ), - }), - ), - Scope.provide(scope), - ), - ) - const utils = createRpcQueryUtils(group, { client, keyPrefix: [name] }) - const httpFinalized: number[] = [] - const httpClient = await Effect.runPromise( - HttpApiTest.groups(api, ['values']).pipe( - Effect.provide( - HttpApiBuilder.group(api, 'values', (handlers) => - handlers - .handle('read', ({ params }) => Effect.succeed(params.id)) - .handle('watch', ({ params }) => - Effect.succeed( - Stream.unwrap( - Effect.acquireRelease( - Effect.succeed( - Stream.succeed(params.id).pipe( - Stream.concat(Stream.fromEffect(Effect.never)), - ), - ), - () => - Effect.sync(() => { - httpFinalized.push(params.id) - }), - ), - ), - ), - ), - ), - ), - Effect.provide(HttpServer.layerServices), - Scope.provide(scope), - ), - ) - const http = createHttpApiQueryUtils(api, { client: httpClient, keyPrefix: [name] }) - return { - utils, - http, - finalized, - httpFinalized, - close: () => Effect.runPromise(Scope.close(scope, Exit.void)), - } -} diff --git a/tests/packed-consumer/hashing-runtime.mts b/tests/packed-consumer/hashing-runtime.mts deleted file mode 100644 index b73e496..0000000 --- a/tests/packed-consumer/hashing-runtime.mts +++ /dev/null @@ -1,260 +0,0 @@ -import { QueryClient, QueryObserver } from '@tanstack/query-core' -import type { - InfiniteData, - InfiniteQueryObserverOptions, - QueryKey, - QueryObserverOptions, -} from '@tanstack/query-core' -import { Effect, Layer, Schema, Stream } from 'effect' -import { - EffectHttpApiQueryConfigError, - EffectRpcQueryConfigError, - createHttpApiQueryUtils, - createRpcQueryUtils, - skipToken, -} from 'effect-api-query' -import { HttpServer } from 'effect/http' -import { - HttpApi, - HttpApiBuilder, - HttpApiEndpoint, - HttpApiGroup, - HttpApiTest, -} from 'effect/http-api' -import { Rpc, RpcGroup, RpcTest } from 'effect/rpc' -import { deepStrictEqual, equal, ok, throws } from 'node:assert/strict' - -const group = RpcGroup.make( - Rpc.make('read', { payload: { id: Schema.Int }, success: Schema.String }), - Rpc.make('page', { payload: { cursor: Schema.Int }, success: Schema.Int }), - Rpc.make('watch', { payload: { id: Schema.Int }, success: Schema.String, stream: true }), -) -const api = HttpApi.make('hashing-api').add( - HttpApiGroup.make('items').add( - HttpApiEndpoint.get('read', '/items/:id', { - params: { id: Schema.FiniteFromString }, - success: Schema.String, - }), - HttpApiEndpoint.get('page', '/pages', { - query: { cursor: Schema.FiniteFromString }, - success: Schema.Int, - }), - ), -) -const httpHandlers = HttpApiBuilder.group(api, 'items', (handlers) => - handlers - .handle('read', ({ params }) => Effect.succeed(`http:${params.id}`)) - .handle('page', ({ query }) => Effect.succeed(query.cursor)), -) - -const assertInfinitePages = async ( - queryClient: QueryClient, - options: InfiniteQueryObserverOptions, Key, number>, -) => { - deepStrictEqual(await queryClient.infiniteQuery({ ...options, pages: 2 }), { - pages: [0, 1], - pageParams: [0, 1], - }) - deepStrictEqual(queryClient.getQueryData(options.queryKey), { - pages: [0, 1], - pageParams: [0, 1], - }) - queryClient.setQueryData>(options.queryKey, { - pages: [7], - pageParams: [0], - }) - deepStrictEqual(await queryClient.infiniteQuery(options), { - pages: [7], - pageParams: [0], - }) - ok(Object.isFrozen(options.queryKey)) -} - -const assertStreamSnapshot = async ( - queryClient: QueryClient, - options: QueryObserverOptions, - expected: Data, - updated: Data, -) => { - const observer = new QueryObserver(queryClient, options) - let unsubscribe = () => {} - let timeout: ReturnType | undefined - try { - const first = await new Promise<{ readonly data: Data; readonly fetchStatus: string }>( - (resolve, reject) => { - timeout = setTimeout(() => reject(new Error('First emission timed out')), 10_000) - unsubscribe = observer.subscribe((result) => { - if (result.status === 'success') resolve(result) - else if (result.status === 'error') reject(result.error) - }) - }, - ) - deepStrictEqual(first.data, expected) - equal(first.fetchStatus, 'fetching') - deepStrictEqual(queryClient.getQueryData(options.queryKey), expected) - queryClient.setQueryData(options.queryKey, updated) - deepStrictEqual(observer.getCurrentResult().data, updated) - deepStrictEqual(queryClient.getQueryData(options.queryKey), updated) - equal( - queryClient.getQueryCache().findAll({ queryKey: options.queryKey, exact: true }).length, - 1, - ) - ok(Object.isFrozen(options.queryKey)) - } finally { - clearTimeout(timeout) - await queryClient.cancelQueries({ queryKey: options.queryKey }) - unsubscribe() - observer.destroy() - } -} - -await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const client = yield* RpcTest.makeClient(group, { flatten: true }).pipe( - Effect.provide( - group.toLayer({ - read: ({ id }) => Effect.succeed(`rpc:${id}`), - page: ({ cursor }) => Effect.succeed(cursor), - watch: () => Stream.succeed('first').pipe(Stream.concat(Stream.never)), - }), - ), - ) - const utils = createRpcQueryUtils(group, { client, keyPrefix: ['hashing'] }) - const httpClient = yield* HttpApiTest.groups(api, ['items']).pipe( - Effect.provide(Layer.mergeAll(httpHandlers, HttpServer.layerServices)), - ) - const http = createHttpApiQueryUtils(api, { client: httpClient, keyPrefix: ['hashing'] }) - for (const [field, value] of [ - ['queryKeyHashFn', () => 'caller'], - ['queryHash', 'caller'], - ['queryKeyHashFn', undefined], - ['queryHash', undefined], - ] as const) { - const rpcError = (error: unknown) => { - ok(error instanceof EffectRpcQueryConfigError) - equal(error.code, 'UnsupportedQueryHash') - ok(error.rpcTag === 'read' || error.rpcTag === 'page' || error.rpcTag === 'watch') - return true - } - const httpError = (error: unknown) => { - ok(error instanceof EffectHttpApiQueryConfigError) - equal(error.code, 'UnsupportedQueryHash') - equal(error.apiId, 'hashing-api') - equal(error.groupId, 'items') - ok(error.endpoint === 'read' || error.endpoint === 'page') - return true - } - for (const input of [{ id: 1 }, skipToken] as const) { - const argument = Object.freeze({ input, [field]: value }) - throws(() => utils.read.queryOptions(argument), rpcError) - throws(() => utils.watch.liveOptions(argument), rpcError) - throws(() => utils.watch.streamedOptions(argument), rpcError) - ok(Object.hasOwn(argument, field)) - } - for (const input of [{ params: { id: 1 } }, skipToken] as const) { - throws(() => http.items.read.queryOptions({ input, [field]: value }), httpError) - } - throws( - () => - utils.page.infiniteOptions({ - input: (cursor: number) => ({ cursor }), - initialPageParam: 0, - getNextPageParam: () => undefined, - [field]: value, - }), - rpcError, - ) - throws( - () => - http.items.page.infiniteOptions({ - input: (cursor: number) => ({ query: { cursor } }), - initialPageParam: 0, - getNextPageParam: () => undefined, - [field]: value, - }), - httpError, - ) - } - for (const mode of ['global', 'prefix'] as const) { - const queryClient = new QueryClient({ - defaultOptions: { - queries: { - gcTime: Infinity, - retry: false, - staleTime: Infinity, - queryKeyHashFn: (key) => `global:${JSON.stringify(key)}`, - }, - }, - }) - if (mode === 'prefix') { - queryClient.setQueryDefaults(utils.key(), { - queryKeyHashFn: (key) => `prefix:${JSON.stringify(key)}`, - }) - queryClient.setQueryDefaults(http.key(), { - queryKeyHashFn: (key) => `prefix:${JSON.stringify(key)}`, - }) - } - try { - const options = utils.read.queryOptions({ input: { id: 1 } }) - equal(yield* Effect.promise(() => queryClient.query(options)), 'rpc:1') - equal(queryClient.getQueryData(utils.read.queryKey({ id: 1 })), 'rpc:1') - queryClient.setQueryData(utils.read.queryKey({ id: 1 }), 'native') - equal(queryClient.getQueryData(options.queryKey), 'native') - equal(yield* Effect.promise(() => queryClient.query(options)), 'native') - equal(queryClient.getQueryCache().getAll().length, 1) - ok(Object.isFrozen(options.queryKey)) - ok(Object.isFrozen(options.queryKey.at(-1))) - - const httpOptions = http.items.read.queryOptions({ input: { params: { id: 1 } } }) - equal(yield* Effect.promise(() => queryClient.query(httpOptions)), 'http:1') - equal(queryClient.getQueryData(http.items.read.queryKey({ params: { id: 1 } })), 'http:1') - queryClient.setQueryData(http.items.read.queryKey({ params: { id: 1 } }), 'native-http') - equal(queryClient.getQueryData(httpOptions.queryKey), 'native-http') - equal(yield* Effect.promise(() => queryClient.query(httpOptions)), 'native-http') - ok(Object.isFrozen(httpOptions.queryKey)) - ok(Object.isFrozen(httpOptions.queryKey.at(-1))) - - const pages = utils.page.infiniteOptions({ - input: (cursor) => ({ cursor }), - initialPageParam: 0, - getNextPageParam: (_last, _pages, cursor) => (cursor < 1 ? cursor + 1 : undefined), - }) - const httpPages = http.items.page.infiniteOptions({ - input: (cursor) => ({ query: { cursor } }), - initialPageParam: 0, - getNextPageParam: (_last, _pages, cursor) => (cursor < 1 ? cursor + 1 : undefined), - }) - yield* Effect.promise(() => assertInfinitePages(queryClient, pages)) - yield* Effect.promise(() => assertInfinitePages(queryClient, httpPages)) - - yield* Effect.promise(() => - assertStreamSnapshot( - queryClient, - utils.watch.liveOptions({ input: { id: 1 } }), - 'first', - 'native-live', - ), - ) - yield* Effect.promise(() => - assertStreamSnapshot( - queryClient, - utils.watch.streamedOptions({ input: { id: 1 } }), - ['first'], - ['first', 'native-streamed'], - ), - ) - equal(queryClient.getQueryCache().getAll().length, 6) - ok( - queryClient - .getQueryCache() - .getAll() - .every((query) => query.queryHash.startsWith(`${mode}:`)), - ) - } finally { - queryClient.clear() - } - } - }), - ), -) diff --git a/tests/packed-consumer/hosts/browser.ts b/tests/packed-consumer/hosts/browser.ts deleted file mode 100644 index 396435a..0000000 --- a/tests/packed-consumer/hosts/browser.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { runHostOperations } from './runtime.ts' - -const output = document.querySelector('#result') -if (output === null) throw new Error('The host consumer requires its result element') -const worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' }) -const workerResult = new Promise((resolve, reject) => { - worker.addEventListener( - 'message', - (event: MessageEvent) => { - if (event.data.error !== undefined) reject(new Error(event.data.error)) - else resolve(event.data) - }, - { once: true }, - ) - worker.addEventListener('error', reject, { once: true }) -}) -try { - const [window, workerData] = await Promise.all([runHostOperations(), workerResult]) - output.textContent = JSON.stringify({ window, worker: workerData }) - output.setAttribute('data-status', 'done') -} catch (error) { - output.textContent = String(error) - output.setAttribute('data-status', 'error') -} finally { - worker.terminate() -} diff --git a/tests/packed-consumer/hosts/index.html b/tests/packed-consumer/hosts/index.html deleted file mode 100644 index 387af71..0000000 --- a/tests/packed-consumer/hosts/index.html +++ /dev/null @@ -1,11 +0,0 @@ - - - - - Packed host consumer - - - - - - diff --git a/tests/packed-consumer/hosts/runtime.ts b/tests/packed-consumer/hosts/runtime.ts deleted file mode 100644 index bec37c7..0000000 --- a/tests/packed-consumer/hosts/runtime.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { MutationObserver, QueryClient, skipToken as coreSkipToken } from '@tanstack/query-core' -import { Deferred, Effect, Exit, Layer, Schema, Scope, Stream } from 'effect' -import { createHttpApiQueryUtils, createRpcQueryUtils, skipToken } from 'effect-api-query' -import { HttpServer } from 'effect/http' -import { - HttpApi, - HttpApiBuilder, - HttpApiEndpoint, - HttpApiGroup, - HttpApiSchema, - HttpApiTest, -} from 'effect/http-api' -import { Rpc, RpcGroup, RpcTest } from 'effect/rpc' - -const group = RpcGroup.make( - Rpc.make('host.read', { payload: { offset: Schema.Int }, success: Schema.Finite }), - Rpc.make('host.write', { payload: { value: Schema.Finite }, success: Schema.Finite }), - Rpc.make('host.watch', { success: Schema.Finite, stream: true }), - Rpc.make('host.open', { success: Schema.Finite, stream: true }), -) -const api = HttpApi.make('host-http').add( - HttpApiGroup.make('values').add( - HttpApiEndpoint.get('read', '/value', { - query: { offset: Schema.FiniteFromString }, - success: HttpApiSchema.WithHeaders(Schema.Finite, { 'x-owner': Schema.String }), - }), - HttpApiEndpoint.post('write', '/value', { - payload: Schema.Struct({ value: Schema.FiniteFromString }), - success: Schema.Finite, - }), - HttpApiEndpoint.get('watch', '/watch', { - success: HttpApiSchema.StreamSse({ data: Schema.Finite }), - }), - HttpApiEndpoint.get('open', '/open', { - success: HttpApiSchema.StreamSse({ data: Schema.Finite }), - }), - ), -) - -const makeOwner = Effect.fnUntraced(function* (name: string, initial: number) { - const scope = yield* Scope.make() - const cache = new QueryClient({ defaultOptions: { queries: { retry: false } } }) - const events: string[] = [] - const openStreams = new Set>() - yield* Effect.addFinalizer(() => - Effect.sync(() => { - events.push(`${name}:disposed`) - }), - ).pipe(Effect.provideService(Scope.Scope, scope)) - const open = (transport: string, value: number) => - Stream.suspend(() => { - const finalized = Deferred.makeUnsafe() - openStreams.add(finalized) - return Stream.succeed(value).pipe( - Stream.concat(Stream.fromEffect(Effect.never)), - Stream.ensuring( - Effect.sync(() => { - events.push(`${name}:${transport}-finalized`) - openStreams.delete(finalized) - Effect.runSync(Deferred.succeed(finalized, undefined)) - }), - ), - ) - }) - let rpcValue = initial - let httpValue = initial - const client = yield* RpcTest.makeClient(group, { flatten: true }).pipe( - Effect.provide( - group.toLayer({ - 'host.read': ({ offset }) => Effect.sync(() => rpcValue + offset), - 'host.write': ({ value }) => - Effect.sync(() => { - rpcValue = value - return value - }), - 'host.watch': () => Stream.make(rpcValue, rpcValue + 1, rpcValue + 2), - 'host.open': () => open('rpc', rpcValue), - }), - ), - Effect.provideService(Scope.Scope, scope), - ) - const httpClient = yield* HttpApiTest.groups(api, ['values']).pipe( - Effect.provide( - Layer.mergeAll( - HttpServer.layerServices, - HttpApiBuilder.group(api, 'values', (handlers) => - handlers - .handle('read', ({ query }) => - Effect.sync(() => - HttpApiSchema.withHeaders({ - body: httpValue + query.offset, - headers: { 'x-owner': name }, - }), - ), - ) - .handle('write', ({ payload }) => - Effect.sync(() => { - httpValue = payload.value - return httpValue - }), - ) - .handle('watch', () => - Effect.succeed(Stream.make(httpValue, httpValue + 1, httpValue + 2)), - ) - .handle('open', () => Effect.succeed(open('http', httpValue))), - ), - ), - ), - Effect.provideService(Scope.Scope, scope), - ) - return { - cache, - events, - rpc: createRpcQueryUtils(group, { client, keyPrefix: ['host', name] }), - http: createHttpApiQueryUtils(api, { client: httpClient, keyPrefix: ['host', name] }), - async dispose() { - const drains = [...openStreams] - try { - await cache.cancelQueries() - await Effect.runPromise( - Effect.all(drains.map(Deferred.await)).pipe(Effect.timeout('5 seconds')), - ) - } finally { - cache.clear() - await Effect.runPromise(Scope.close(scope, Exit.void)) - } - }, - } -}) - -const waitForData = async (cache: QueryClient, check: () => boolean) => { - const ready = Deferred.makeUnsafe() - const observe = () => { - if (check()) Effect.runSync(Deferred.succeed(ready, undefined)) - } - const unsubscribe = cache.getQueryCache().subscribe(observe) - try { - observe() - await Effect.runPromise(Deferred.await(ready).pipe(Effect.timeout('5 seconds'))) - } finally { - unsubscribe() - } -} - -export const runHostOperations = async () => { - const owner = await Effect.runPromise(makeOwner('Ada', 10)) - const second = await Effect.runPromise(makeOwner('Grace', 20)) - const { cache, rpc, http } = owner - const rpcInput = { offset: 0 } - const httpInput = { query: { offset: 0 } } - try { - const read = await cache.query(rpc.host.read.queryOptions({ input: rpcInput })) - const rpcMutation = await new MutationObserver(cache, rpc.host.write.mutationOptions()).mutate({ - value: 41, - }) - const rpcAfter = await cache.query(rpc.host.read.queryOptions({ input: rpcInput })) - const rpcAccumulated = await cache.query(rpc.host.watch.streamedOptions()) - const rpcLive = await cache.query(rpc.host.watch.liveOptions()) - const httpRead = await cache.query(http.values.read.queryOptions({ input: httpInput })) - const httpMutation = await new MutationObserver( - cache, - http.values.write.mutationOptions(), - ).mutate({ payload: { value: 52 } }) - const httpAfter = await cache.query(http.values.read.queryOptions({ input: httpInput })) - const httpAccumulated = await cache.query(http.values.watch.streamedOptions()) - const httpLive = await cache.query(http.values.watch.liveOptions()) - const metadata = await cache.query(http.values.read.metadataOptions({ input: httpInput })) - const before = [ - await second.cache.query(second.rpc.host.read.queryOptions({ input: rpcInput })), - (await second.cache.query(second.http.values.read.queryOptions({ input: httpInput }))).body, - ] - const firstRpcOpen = rpc.host.open.streamedOptions() - const firstHttpOpen = http.values.open.liveOptions() - const secondRpcOpen = second.rpc.host.open.streamedOptions() - const secondHttpOpen = second.http.values.open.liveOptions() - const pending = [ - cache.query(firstRpcOpen), - cache.query(firstHttpOpen), - second.cache.query(secondRpcOpen), - second.cache.query(secondHttpOpen), - ].map((promise) => promise.catch((error: unknown) => error)) - await Promise.all([ - waitForData( - cache, - () => - cache.getQueryData(firstRpcOpen.queryKey)?.[0] === 41 && - cache.getQueryData(firstHttpOpen.queryKey) === 52, - ), - waitForData( - second.cache, - () => - second.cache.getQueryData(secondRpcOpen.queryKey)?.[0] === 20 && - second.cache.getQueryData(secondHttpOpen.queryKey) === 20, - ), - ]) - const activeBefore = [cache.isFetching(), second.cache.isFetching()] - await owner.dispose() - const activeAfterFirst = [cache.isFetching(), second.cache.isFetching()] - const secondData = [ - second.cache.getQueryData(secondRpcOpen.queryKey)?.[0], - second.cache.getQueryData(secondHttpOpen.queryKey), - ] - await second.dispose() - await Promise.all(pending) - return { - host: globalThis.constructor.name, - read, - rpc: { mutation: rpcMutation, after: rpcAfter, accumulated: rpcAccumulated, live: rpcLive }, - http: { - read: { body: httpRead.body, headers: httpRead.headers }, - mutation: httpMutation, - after: { body: httpAfter.body, headers: httpAfter.headers }, - accumulated: httpAccumulated, - live: httpLive, - metadata: { - data: { body: metadata.data.body, headers: metadata.data.headers }, - status: metadata.status, - owner: metadata.headers['x-owner'], - frozenSnapshot: Object.isFrozen(metadata), - frozenHeaders: Object.isFrozen(metadata.headers), - }, - }, - skipIdentity: [ - skipToken === coreSkipToken, - rpc.host.read.queryOptions({ input: skipToken }).queryFn === coreSkipToken, - http.values.read.queryOptions({ input: skipToken }).queryFn === coreSkipToken, - ], - ownership: { - before, - activeBefore, - activeAfterFirst, - secondData, - activeAfterBoth: [cache.isFetching(), second.cache.isFetching()], - firstEvents: owner.events, - secondEvents: second.events, - }, - } - } finally { - await owner.dispose() - await second.dispose() - } -} diff --git a/tests/packed-consumer/hosts/worker.ts b/tests/packed-consumer/hosts/worker.ts deleted file mode 100644 index ee7d4f0..0000000 --- a/tests/packed-consumer/hosts/worker.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { runHostOperations } from './runtime.ts' - -try { - globalThis.postMessage(await runHostOperations()) -} catch (error) { - globalThis.postMessage({ error: String(error) }) -} diff --git a/tests/packed-consumer/http-etag-recipe.mts b/tests/packed-consumer/http-etag-recipe.mts deleted file mode 100644 index 5b525a8..0000000 --- a/tests/packed-consumer/http-etag-recipe.mts +++ /dev/null @@ -1,88 +0,0 @@ -import { MutationObserver, QueryClient } from '@tanstack/query-core' -import { Effect, Layer, Schema } from 'effect' -import { createHttpApiQueryUtils } from 'effect-api-query' -import { HttpServer, HttpServerResponse } from 'effect/http' -import { - HttpApi, - HttpApiBuilder, - HttpApiEndpoint, - HttpApiGroup, - HttpApiSchema, - HttpApiTest, -} from 'effect/http-api' -import { equal } from 'node:assert/strict' - -const User = Schema.Struct({ id: Schema.Finite, name: Schema.String }) -const api = HttpApi.make('etag-recipe').add( - HttpApiGroup.make('users').add( - HttpApiEndpoint.get('get', '/users/:id', { - params: { id: Schema.FiniteFromString }, - success: User, - }), - HttpApiEndpoint.put('update', '/users/:id', { - params: { id: Schema.FiniteFromString }, - headers: { 'if-match': Schema.String }, - payload: Schema.Struct({ name: Schema.String }), - success: User, - error: Schema.Literal('precondition-failed').pipe(HttpApiSchema.status(412)), - }), - ), -) -let name = 'Grace' -let revision = 1 -const handlers = HttpApiBuilder.group(api, 'users', (group) => - group - .handleRaw('get', ({ params }) => - HttpServerResponse.json( - { id: params.id, name }, - { - headers: { etag: `"revision-${revision}"` }, - }, - ).pipe(Effect.orDie), - ) - .handle('update', ({ params, headers, payload }) => { - if (headers['if-match'] !== `"revision-${revision}"`) - return Effect.fail('precondition-failed') - name = payload.name - revision += 1 - return Effect.succeed({ id: params.id, name }) - }), -) -await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const client = yield* HttpApiTest.groups(api, ['users']) - const http = createHttpApiQueryUtils(api, { client, keyPrefix: ['etag'] }) - const queryClient = new QueryClient({ - defaultOptions: { queries: { staleTime: Infinity, retry: false } }, - }) - yield* Effect.addFinalizer(() => Effect.sync(() => queryClient.clear())) - yield* Effect.promise(async () => { - const input = { params: { id: 1 } } - const metadataOptions = http.users.get.metadataOptions({ input }) - const current = await queryClient.query(metadataOptions) - const etag = current.headers['etag'] - if (etag === undefined) throw new Error('The server supplied no ETag') - - await queryClient.cancelQueries({ queryKey: http.users.get.key() }) - const update = new MutationObserver( - queryClient, - http.users.update.mutationOptions({ - onSuccess: () => queryClient.invalidateQueries({ queryKey: http.users.get.key() }), - }), - ) - await update.mutate({ - params: input.params, - headers: { 'if-match': etag }, - payload: { name: 'Ada' }, - }) - - const refreshed = await queryClient.query(metadataOptions) - equal(current.data.name, 'Grace') - equal(refreshed.data.name, 'Ada') - equal(etag, '"revision-1"') - equal(refreshed.headers['etag'], '"revision-2"') - }) - }).pipe(Effect.provide(Layer.mergeAll(handlers, HttpServer.layerServices))), - ), -) diff --git a/tests/packed-consumer/http-live-runtime.mts b/tests/packed-consumer/http-live-runtime.mts deleted file mode 100644 index 0c880c1..0000000 --- a/tests/packed-consumer/http-live-runtime.mts +++ /dev/null @@ -1,601 +0,0 @@ -import { QueryClient, QueryObserver, isCancelledError } from '@tanstack/query-core' -import { - Cause, - Context, - Deferred, - Effect, - Exit, - Layer, - Schema, - SchemaTransformation, - Stream, -} from 'effect' -import { - createHttpApiQueryUtils, - EffectHttpApiQueryConfigError, - EffectHttpApiQueryEmptyStreamError, - EffectHttpApiQueryError, - skipToken, -} from 'effect-api-query' -import { HttpClient, HttpClientResponse, HttpServer } from 'effect/http' -import { - HttpApi, - HttpApiBuilder, - HttpApiClient, - HttpApiEndpoint, - HttpApiGroup, - HttpApiSchema, - HttpApiTest, -} from 'effect/http-api' -import { deepStrictEqual, equal, notDeepStrictEqual, ok, rejects, throws } from 'node:assert/strict' - -const UndefinedValue = Schema.Null.pipe( - Schema.decodeTo( - Schema.Void, - SchemaTransformation.transform({ - decode: (): void => undefined, - encode: (_value: void) => null, - }), - ), -) -const Expired = Schema.TaggedStruct('Expired', { reason: Schema.String }) -const api = HttpApi.make('http-live').add( - HttpApiGroup.make('events').add( - HttpApiEndpoint.get('watch', '/watch', { - query: { channel: Schema.String }, - success: HttpApiSchema.StreamSse({ data: Schema.Finite, error: Expired }), - }), - HttpApiEndpoint.get('nullable', '/nullable', { - success: HttpApiSchema.StreamSse({ data: Schema.NullOr(Schema.Finite) }), - }), - HttpApiEndpoint.get('undefined', '/undefined', { - success: HttpApiSchema.StreamSse({ data: UndefinedValue }), - }), - HttpApiEndpoint.get('mixedUndefined', '/mixed-undefined', { - success: HttpApiSchema.StreamSse({ data: Schema.Union([Schema.Finite, UndefinedValue]) }), - }), - HttpApiEndpoint.get('wrapped', '/wrapped', { - success: HttpApiSchema.WithHeaders(HttpApiSchema.StreamSse({ data: Schema.Finite }), { - 'x-version': Schema.FiniteFromString, - }), - }), - HttpApiEndpoint.get('wrappedUndefined', '/wrapped-undefined', { - success: HttpApiSchema.WithHeaders(HttpApiSchema.StreamSse({ data: UndefinedValue }), { - 'x-version': Schema.FiniteFromString, - }), - }), - HttpApiEndpoint.get('empty', '/empty', { - success: HttpApiSchema.StreamSse({ data: Schema.Finite }), - }), - HttpApiEndpoint.get('cursor', '/cursor', { - headers: { 'last-event-id': Schema.String }, - success: HttpApiSchema.StreamSse({ - events: Schema.Struct({ - id: Schema.String, - event: Schema.Literal('changed'), - data: Schema.String, - }), - }), - }), - ), -) -const handlers = HttpApiBuilder.group(api, 'events', (group) => - group - .handle('watch', ({ query }) => - Effect.succeed( - query.channel === 'failure' - ? Stream.concat( - Stream.succeed(1), - Stream.fail({ _tag: 'Expired' as const, reason: 'resume' }), - ) - : query.channel === 'interruption' - ? Stream.failCause(Cause.interrupt(123)) - : query.channel === 'mixed' - ? Stream.failCause( - Cause.combine(Cause.interrupt(123), Cause.die(new Error('defect'))), - ) - : Stream.make(1, 2, 3), - ), - ) - .handle('nullable', () => Effect.succeed(Stream.make(1, null))) - .handle('undefined', () => Effect.succeed(Stream.succeed(undefined))) - .handle('mixedUndefined', () => Effect.succeed(Stream.make(1, undefined))) - .handle('wrapped', () => - Effect.succeed( - HttpApiSchema.withHeaders({ body: Stream.make(3, 7), headers: { 'x-version': 4 } }), - ), - ) - .handle('wrappedUndefined', () => - Effect.succeed( - HttpApiSchema.withHeaders({ body: Stream.succeed(undefined), headers: { 'x-version': 4 } }), - ), - ) - .handle('empty', () => Effect.succeed(Stream.empty)) - .handle('cursor', ({ headers }) => - Effect.succeed( - Stream.succeed({ id: headers['last-event-id'], event: 'changed' as const, data: 'next' }), - ), - ), -) -const waitFor = async (client: QueryClient, check: () => boolean) => { - const ready = Deferred.makeUnsafe() - const observe = () => { - if (check()) Effect.runSync(Deferred.succeed(ready, undefined)) - } - const unsubscribe = client.getQueryCache().subscribe(observe) - try { - observe() - await Effect.runPromise(Deferred.await(ready).pipe(Effect.timeout('5 seconds'))) - } finally { - unsubscribe() - } -} -const sse = (body: string, headers: Record = {}) => - new Response(body, { headers: { 'content-type': 'text/event-stream', ...headers } }) -const makeClient = (response: () => Response) => - Effect.runPromise( - HttpApiClient.makeWith(api, { - baseUrl: 'https://example.test', - httpClient: HttpClient.make((request) => - Effect.succeed(HttpClientResponse.fromWeb(request, response())), - ), - }), - ) -const expectFailure = - (endpoint: string, check: (cause: Cause.Cause) => void) => (error: unknown) => { - ok(error instanceof EffectHttpApiQueryError) - equal(error.apiId, 'http-live') - equal(error.groupId, 'events') - equal(error.endpoint, endpoint) - equal(error.method, 'GET') - equal(error.operation, 'live') - check(error.cause) - return true - } - -await Effect.runPromise( - Effect.gen(function* () { - const client = yield* HttpApiTest.groups(api, ['events']) - const http = createHttpApiQueryUtils(api, { client, keyPrefix: ['consumer'] }) - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) - const input = { query: { channel: 'news' } } - try { - equal( - yield* Effect.promise(() => queryClient.query(http.events.watch.liveOptions({ input }))), - 3, - ) - equal(queryClient.getQueryData(http.events.watch.liveKey(input)), 3) - equal( - yield* Effect.promise(() => queryClient.query(http.events.nullable.liveOptions())), - null, - ) - equal( - yield* Effect.promise(() => queryClient.query(http.events.undefined.liveOptions())), - null, - ) - equal( - yield* Effect.promise(() => queryClient.query(http.events.mixedUndefined.liveOptions())), - null, - ) - deepStrictEqual( - yield* Effect.promise(() => queryClient.query(http.events.wrapped.liveOptions())), - HttpApiSchema.withHeaders({ body: 7, headers: { 'x-version': 4 } }), - ) - deepStrictEqual( - yield* Effect.promise(() => queryClient.query(http.events.wrappedUndefined.liveOptions())), - HttpApiSchema.withHeaders({ body: undefined, headers: { 'x-version': 4 } }), - ) - yield* Effect.promise(() => - rejects(queryClient.query(http.events.empty.liveOptions()), (error: unknown) => { - ok(error instanceof EffectHttpApiQueryEmptyStreamError) - equal(error._tag, 'EffectHttpApiQueryEmptyStreamError') - equal(error.apiId, 'http-live') - equal(error.groupId, 'events') - equal(error.endpoint, 'empty') - equal(error.method, 'GET') - equal(error.operation, 'live') - return true - }), - ) - for (const channel of ['failure', 'interruption', 'mixed']) { - const options = http.events.watch.liveOptions({ input: { query: { channel } } }) - yield* Effect.promise(() => - rejects( - queryClient.query(options), - expectFailure('watch', (cause) => { - if (channel === 'failure') - deepStrictEqual( - cause.reasons.map((reason) => - reason._tag === 'Fail' ? reason.error : reason._tag, - ), - [{ _tag: 'Expired', reason: 'resume' }], - ) - else ok(Cause.hasInterrupts(cause)) - if (channel === 'mixed') ok(Cause.hasDies(cause)) - }), - ), - ) - if (channel === 'failure') equal(queryClient.getQueryData(options.queryKey), 1) - } - for (const cursor of ['v1', 'v2']) { - const options = http.events.cursor.liveOptions({ - input: { headers: { 'last-event-id': cursor } }, - sseOptions: { maxEventSize: 1024 }, - }) - deepStrictEqual(yield* Effect.promise(() => queryClient.query(options)), { - id: cursor, - event: 'changed', - data: 'next', - }) - deepStrictEqual(queryClient.getQueryData(options.queryKey), { - id: cursor, - event: 'changed', - data: 'next', - }) - } - const defaultKey = http.events.watch.liveKey(input) - for (const sseOptions of [ - undefined, - {}, - { maxEventSize: undefined }, - { maxEventSize: 10 * 1024 * 1024 }, - ]) { - deepStrictEqual(defaultKey, http.events.watch.liveKey(input, { sseOptions })) - deepStrictEqual(defaultKey, http.events.watch.liveOptions({ input, sseOptions }).queryKey) - deepStrictEqual( - http.events.undefined.liveKey(), - http.events.undefined.liveOptions({ sseOptions }).queryKey, - ) - } - notDeepStrictEqual( - defaultKey, - http.events.watch.liveKey(input, { sseOptions: { maxEventSize: 1024 } }), - ) - notDeepStrictEqual(defaultKey, http.events.watch.streamedKey(input)) - equal(Object.isFrozen(defaultKey.at(-1)), true) - const skipped = http.events.watch.liveOptions({ - input: skipToken, - sseOptions: { maxEventSize: 1024 }, - staleTime: 123, - }) - equal(http.events.watch.liveOptions(skipToken).queryFn, skipToken) - equal(skipped.queryFn, skipToken) - equal(skipped.staleTime, 123) - deepStrictEqual(skipped.queryKey, [...http.events.watch.key(), 'live']) - for (const maxEventSize of [ - 0, - -1, - 0.5, - NaN, - Infinity, - Number.MAX_SAFE_INTEGER + 1, - null, - '2', - ]) { - const policy = { sseOptions: { maxEventSize } } - for (const build of [ - () => Reflect.apply(http.events.watch.liveKey, undefined, [input, policy]), - () => Reflect.apply(http.events.undefined.liveKey, undefined, [policy]), - ...[input, skipToken].map( - (request) => () => - Reflect.apply(http.events.watch.liveOptions, undefined, [ - { input: request, ...policy }, - ]), - ), - ]) { - throws(build, (error: unknown) => { - ok(error instanceof EffectHttpApiQueryConfigError) - equal(error.code, 'InvalidMaxEventSize') - return true - }) - } - } - queryClient.setQueryData( - http.events.watch.liveKey(input, { sseOptions: { maxEventSize: 1024 } }), - 4, - ) - queryClient.setQueryData(http.events.watch.streamedKey(input), [3]) - equal(queryClient.getQueriesData({ queryKey: http.events.watch.key() }).length, 6) - yield* Effect.promise(() => - queryClient.invalidateQueries({ queryKey: http.events.watch.key() }), - ) - equal(queryClient.getQueryState(defaultKey)?.isInvalidated, true) - equal(queryClient.getQueryState(http.events.watch.streamedKey(input))?.isInvalidated, true) - equal(queryClient.getQueryState(http.events.wrapped.liveKey())?.isInvalidated, false) - } finally { - queryClient.clear() - } - }).pipe(Effect.provide(Layer.mergeAll(handlers, HttpServer.layerServices)), Effect.scoped), -) - -for (const [endpoint, firstChunk, lastChunk, firstValue, lastValue] of [ - ['nullable', 'null', '5', null, 5], - ['nullable', '1', 'null', 1, null], - ['mixedUndefined', '1', 'null', 1, null], - ['mixedUndefined', 'null', '5', null, 5], -] as const) { - let finish!: () => void - let closed = 0 - const client = await makeClient( - () => - new Response( - new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode(`data: ${firstChunk}\n\n`)) - finish = () => { - controller.enqueue(new TextEncoder().encode(`data: ${lastChunk}\n\n`)) - controller.close() - } - }, - cancel() { - closed += 1 - }, - }), - { headers: { 'content-type': 'text/event-stream' } }, - ), - ) - const http = createHttpApiQueryUtils(api, { - client, - keyPrefix: ['visibility', endpoint, firstChunk], - }) - const queryClient = new QueryClient({ - defaultOptions: { - queries: { retry: false, queryKeyHashFn: (key) => `custom:${JSON.stringify(key)}` }, - }, - }) - const options = - endpoint === 'nullable' - ? http.events.nullable.liveOptions() - : http.events.mixedUndefined.liveOptions() - const observer = - endpoint === 'nullable' - ? new QueryObserver(queryClient, http.events.nullable.liveOptions()) - : new QueryObserver(queryClient, http.events.mixedUndefined.liveOptions()) - const unsubscribe = observer.subscribe(() => undefined) - try { - await waitFor( - queryClient, - () => - observer.getCurrentResult().status === 'success' && - observer.getCurrentResult().data === firstValue, - ) - equal(observer.getCurrentResult().fetchStatus, 'fetching') - equal(queryClient.getQueryData(options.queryKey), firstValue) - equal(queryClient.getQueryCache().getAll().length, 1) - finish() - await waitFor(queryClient, () => observer.getCurrentResult().fetchStatus === 'idle') - equal(observer.getCurrentResult().data, lastValue) - equal(queryClient.getQueryData(options.queryKey), lastValue) - equal(closed, 0) - } finally { - unsubscribe() - queryClient.clear() - } -} - -for (const [body, endpoint, headers, failureTag] of [ - ['data: "bad"\n\n', 'nullable', {}, 'SchemaError'], - ['data: 1\n\n', 'wrapped', { 'x-version': 'bad' }, 'SchemaError'], - ['id: v3\ndata: 1\n\nretry: 25\n\n', 'nullable', {}, 'Retry'], -] as const) { - const client = await makeClient(() => sse(body, headers)) - const http = createHttpApiQueryUtils(api, { client, keyPrefix: ['failure'] }) - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) - try { - const pending = - endpoint === 'wrapped' - ? queryClient.query(http.events.wrapped.liveOptions()) - : queryClient.query(http.events.nullable.liveOptions()) - await rejects( - pending, - expectFailure(endpoint, (cause) => { - deepStrictEqual( - cause.reasons.map((reason) => - reason._tag === 'Fail' ? Reflect.get(reason.error as object, '_tag') : reason._tag, - ), - [failureTag], - ) - if (failureTag === 'Retry') { - const reason = cause.reasons[0] - ok(reason?._tag === 'Fail') - equal(Reflect.get(reason.error as object, 'lastEventId'), 'v3') - } - }), - ) - if (failureTag === 'Retry') equal(queryClient.getQueryData(http.events.nullable.liveKey()), 1) - } finally { - queryClient.clear() - } -} - -{ - const client = await makeClient( - () => - new Response( - new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode('data: 12345')) - controller.enqueue(new TextEncoder().encode('\n\n')) - controller.close() - }, - }), - { headers: { 'content-type': 'text/event-stream' } }, - ), - ) - const http = createHttpApiQueryUtils(api, { client, keyPrefix: ['decoder'] }) - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) - try { - equal(await queryClient.query(http.events.nullable.liveOptions()), 12345) - await rejects( - queryClient.query(http.events.nullable.liveOptions({ sseOptions: { maxEventSize: 3 } })), - expectFailure('nullable', (cause) => { - const reason = cause.reasons[0] - ok(reason?._tag === 'Fail') - equal(Reflect.get(reason.error as object, '_tag'), 'SseError') - }), - ) - } finally { - queryClient.clear() - } -} - -for (const phase of ['acquisition', 'consumption', 'unsubscribe'] as const) { - const started = Deferred.makeUnsafe() - const finalized = Deferred.makeUnsafe() - let closed = 0 - let signal: AbortSignal | undefined - const client = await Effect.runPromise( - HttpApiClient.makeWith(api, { - baseUrl: 'https://example.test', - httpClient: HttpClient.make((request, _url, transportSignal) => { - signal = transportSignal - if (phase === 'acquisition') - return Deferred.succeed(started, undefined).pipe( - Effect.andThen(Effect.never), - Effect.ensuring(Deferred.succeed(finalized, undefined)), - ) - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - new Response( - new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode('data: 1\n\n')) - }, - cancel() { - closed += 1 - Effect.runSync(Deferred.succeed(finalized, undefined)) - }, - }), - { headers: { 'content-type': 'text/event-stream' } }, - ), - ), - ) - }), - }), - ) - const http = createHttpApiQueryUtils(api, { client, keyPrefix: ['cancel', phase] }) - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) - const options = http.events.nullable.liveOptions() - const observer = new QueryObserver(queryClient, options) - const unsubscribe = - phase === 'unsubscribe' ? observer.subscribe(() => undefined) : () => undefined - const pending = phase === 'unsubscribe' ? undefined : queryClient.query(options) - const outcome = pending?.catch((error: unknown) => error) - try { - if (phase === 'acquisition') - await Effect.runPromise(Deferred.await(started).pipe(Effect.timeout('5 seconds'))) - else await waitFor(queryClient, () => queryClient.getQueryData(options.queryKey) === 1) - if (phase === 'unsubscribe') unsubscribe() - else await queryClient.cancelQueries({ queryKey: options.queryKey, exact: true }) - await Effect.runPromise(Deferred.await(finalized).pipe(Effect.timeout('5 seconds'))) - equal(signal?.aborted, true) - equal(closed, phase === 'acquisition' ? 0 : 1) - const result = await outcome - if (phase === 'acquisition') ok(isCancelledError(result)) - else if (phase === 'consumption') equal(result, 1) - equal(queryClient.getQueryData(options.queryKey), phase === 'acquisition' ? undefined : 1) - equal(queryClient.getQueryState(options.queryKey)?.fetchStatus, 'idle') - equal(queryClient.getQueryState(options.queryKey)?.error, null) - } finally { - unsubscribe() - queryClient.clear() - } -} - -{ - const cause = Cause.combine(Cause.interrupt(123), Cause.die(new Error('acquisition defect'))) - const client = await Effect.runPromise( - HttpApiClient.makeWith(api, { - baseUrl: 'https://example.test', - httpClient: HttpClient.make(() => Effect.failCause(cause)), - }), - ) - const http = createHttpApiQueryUtils(api, { client, keyPrefix: ['acquisition-failure'] }) - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) - try { - await rejects( - queryClient.query(http.events.nullable.liveOptions()), - expectFailure('nullable', (actual) => { - deepStrictEqual( - actual.reasons.map((reason) => reason._tag), - ['Interrupt', 'Die'], - ) - const defect = actual.reasons[1] - ok(defect?._tag === 'Die') - const original = cause.reasons[1] - ok(original?._tag === 'Die') - equal(defect.defect, original.defect) - }), - ) - } finally { - queryClient.clear() - } -} - -class Scale extends Context.Service()('HttpLive/Scale') {} -const Scaled = Schema.Finite.pipe( - Schema.decodeTo( - Schema.Finite, - SchemaTransformation.transformEffect({ - decode: (value) => Scale.pipe(Effect.map(({ factor }) => value * factor)), - encode: Effect.succeed, - }), - ), -) -const serviceApi = HttpApi.make('live-services').add( - HttpApiGroup.make('events').add( - HttpApiEndpoint.get('watch', '/watch', { success: HttpApiSchema.StreamSse({ data: Scaled }) }), - ), -) -const serviceClient = await Effect.runPromise( - HttpApiClient.makeWith(serviceApi, { - baseUrl: 'https://example.test', - httpClient: HttpClient.make((request) => - Effect.succeed(HttpClientResponse.fromWeb(request, sse('data: 3\n\n'))), - ), - }), -) -const serviceHttp = createHttpApiQueryUtils(serviceApi, { - client: serviceClient, - keyPrefix: ['services'], - runPromiseExit: (effect, options) => - Effect.runPromiseExit(effect.pipe(Effect.provideService(Scale, { factor: 2 })), options), -}) -const serviceQueryClient = new QueryClient() -try { - equal(await serviceQueryClient.query(serviceHttp.events.watch.liveOptions()), 6) -} finally { - serviceQueryClient.clear() -} - -{ - const client = await makeClient(() => sse('data: 1\n\n')) - const cause = Cause.combine(Cause.interrupt(123), Cause.die(new Error('defect'))) - const failedExit = Exit.failCause(cause) - const failed = createHttpApiQueryUtils(api, { - client, - keyPrefix: ['runner-cause'], - runPromiseExit: () => Promise.resolve(failedExit), - }) - const rejection = new Error('runner rejected') - const http = createHttpApiQueryUtils(api, { - client, - keyPrefix: ['runner-rejection'], - runPromiseExit: () => Promise.reject(rejection), - }) - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) - try { - await rejects( - queryClient.query(failed.events.nullable.liveOptions()), - expectFailure('nullable', (actual) => equal(actual, cause)), - ) - await rejects( - queryClient.query(http.events.nullable.liveOptions()), - (error) => error === rejection, - ) - } finally { - queryClient.clear() - } -} -console.log('Packed HTTP live values, policies, failures, services, and resource cleanup executed') diff --git a/tests/packed-consumer/http-metadata-runtime.mts b/tests/packed-consumer/http-metadata-runtime.mts deleted file mode 100644 index 6cb222f..0000000 --- a/tests/packed-consumer/http-metadata-runtime.mts +++ /dev/null @@ -1,513 +0,0 @@ -import { MutationObserver, QueryClient, QueryObserver } from '@tanstack/query-core' -import { Cause, Effect, Exit, Layer, Predicate, Result, Schema } from 'effect' -import { - createHttpApiQueryUtils, - EffectHttpApiQueryConfigError, - isEffectHttpApiQueryError, - skipToken, - type RunPromiseExit, -} from 'effect-api-query' -import { HttpClient, HttpClientResponse, HttpServer } from 'effect/http' -import { - HttpApi, - HttpApiBuilder, - HttpApiClient, - HttpApiEndpoint, - HttpApiGroup, - HttpApiSchema, - HttpApiTest, -} from 'effect/http-api' -import { deepStrictEqual, equal, notDeepStrictEqual, ok, rejects, throws } from 'node:assert/strict' - -const Conflict = Schema.TaggedStruct('Conflict', {}).pipe(HttpApiSchema.status(412)) - -const api = HttpApi.make('packed-metadata').add( - HttpApiGroup.make('documents').add( - HttpApiEndpoint.post('read', '/documents/:id', { - params: { id: Schema.FiniteFromString }, - query: { factor: Schema.FiniteFromString }, - headers: { 'x-request-version': Schema.FiniteFromString }, - payload: Schema.Struct({ amount: Schema.FiniteFromString }), - success: HttpApiSchema.WithHeaders(Schema.FiniteFromString, { - 'x-version': Schema.FiniteFromString, - etag: Schema.String, - }).pipe(HttpApiSchema.status(203)), - error: Schema.Literal('missing'), - }), - HttpApiEndpoint.put('update', '/documents/:id', { - params: { id: Schema.FiniteFromString }, - headers: { 'if-match': Schema.String }, - payload: Schema.Struct({ value: Schema.FiniteFromString }), - success: Schema.FiniteFromString, - error: Conflict, - }), - ), - HttpApiGroup.make('system', { topLevel: true }).add( - HttpApiEndpoint.get('empty', '/empty', { success: HttpApiSchema.NoContent }), - HttpApiEndpoint.get('wrappedEmpty', '/wrapped-empty', { - success: HttpApiSchema.WithHeaders(HttpApiSchema.NoContent, { etag: Schema.String }), - }), - ), -) - -const requests: unknown[] = [] -let revision = 4 -let savedValue: number | undefined -const handlers = Layer.mergeAll( - HttpApiBuilder.group(api, 'documents', (group) => - group - .handle('read', ({ params, query, headers, payload }) => { - requests.push({ params, query, headers, payload }) - if (params.id < 0) return Effect.fail('missing') - return Effect.succeed( - HttpApiSchema.withHeaders({ - body: - savedValue ?? - params.id + query.factor + headers['x-request-version'] + payload.amount, - headers: { 'x-version': revision, etag: `"revision-${revision}"` }, - }), - ) - }) - .handle('update', ({ headers, payload }) => { - if (headers['if-match'] !== `"revision-${revision}"`) { - return Effect.fail({ _tag: 'Conflict' as const }) - } - savedValue = payload.value - revision += 1 - return Effect.succeed(savedValue) - }), - ), - HttpApiBuilder.group(api, 'system', (group) => - group - .handle('empty', () => Effect.void) - .handle('wrappedEmpty', () => - Effect.succeed(HttpApiSchema.withHeaders({ body: undefined, headers: { etag: 'empty' } })), - ), - ), -) - -await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const client = yield* HttpApiTest.groups(api, ['documents', 'system']) - const http = createHttpApiQueryUtils(api, { client, keyPrefix: ['metadata'] }) - const queryClient = new QueryClient({ - defaultOptions: { queries: { staleTime: Infinity, retry: false } }, - }) - yield* Effect.addFinalizer(() => Effect.sync(() => queryClient.clear())) - const input = { - params: { id: 1 }, - query: { factor: 2 }, - headers: { 'x-request-version': 3 }, - payload: { amount: 7 }, - } - const options = http.documents.read.metadataOptions({ input }) - const metadata = yield* Effect.promise(() => queryClient.query(options)) - deepStrictEqual( - metadata.data, - HttpApiSchema.withHeaders({ - body: 13, - headers: { 'x-version': 4, etag: '"revision-4"' }, - }), - ) - equal(metadata.status, 203) - equal(metadata.headers['x-version'], '4') - equal(metadata.headers['etag'], '"revision-4"') - equal(Object.getPrototypeOf(metadata), Object.prototype) - equal(Object.getPrototypeOf(metadata.headers), Object.prototype) - equal(Object.isFrozen(metadata), true) - equal(Object.isFrozen(metadata.headers), true) - deepStrictEqual(Object.keys(metadata).sort(), ['data', 'headers', 'status']) - deepStrictEqual(requests, [input]) - deepStrictEqual(http.documents.read.metadataKey(input), options.queryKey) - notDeepStrictEqual(options.queryKey, http.documents.read.queryKey(input)) - const data = yield* Effect.promise(() => - queryClient.query(http.documents.read.queryOptions({ input })), - ) - deepStrictEqual(data, metadata.data) - equal(queryClient.getQueryCache().findAll({ queryKey: http.documents.read.key() }).length, 2) - const selected = new QueryObserver( - queryClient, - http.documents.read.metadataOptions({ - input, - select: (view) => view.data.body, - }), - ) - equal(selected.getCurrentResult().data, 13) - equal(queryClient.getQueryData(options.queryKey), metadata) - const empty = yield* Effect.promise(() => queryClient.query(http.empty.metadataOptions())) - equal(empty.data, null) - equal(empty.status, 204) - const wrappedEmpty = yield* Effect.promise(() => - queryClient.query(http.wrappedEmpty.metadataOptions()), - ) - deepStrictEqual( - wrappedEmpty.data, - HttpApiSchema.withHeaders({ body: undefined, headers: { etag: 'empty' } }), - ) - - const etag = metadata.headers['etag'] - ok(etag !== undefined) - yield* Effect.promise(() => - queryClient.cancelQueries({ queryKey: http.documents.read.key() }), - ) - const update = new MutationObserver( - queryClient, - http.documents.update.mutationOptions({ - onSuccess: () => queryClient.invalidateQueries({ queryKey: http.documents.read.key() }), - }), - ) - equal( - yield* Effect.promise(() => - update.mutate({ - params: { id: 1 }, - headers: { 'if-match': etag }, - payload: { value: 29 }, - }), - ), - 29, - ) - equal(queryClient.getQueryState(options.queryKey)?.isInvalidated, true) - equal(queryClient.getQueryState(http.documents.read.queryKey(input))?.isInvalidated, true) - const refreshed = yield* Effect.promise(() => queryClient.query(options)) - equal(Object.isFrozen(refreshed), true) - equal(Object.isFrozen(refreshed.headers), true) - equal(Object.isFrozen(queryClient.getQueryData(options.queryKey)), true) - equal(Object.isFrozen(queryClient.getQueryData(options.queryKey)?.headers), true) - equal(Object.isFrozen(refreshed.data), false) - equal(Object.isFrozen(refreshed.data.headers), false) - equal(refreshed.data.body, 29) - equal(refreshed.headers['etag'], '"revision-5"') - equal(metadata.headers['etag'], '"revision-4"') - const sharedMetadata = queryClient.getQueryData(options.queryKey) - yield* Effect.promise(() => - queryClient.invalidateQueries({ queryKey: options.queryKey, exact: true }), - ) - yield* Effect.promise(() => queryClient.query(options)) - equal(queryClient.getQueryData(options.queryKey), sharedMetadata) - yield* Effect.promise(async () => { - const owned = { ...refreshed, headers: { ...refreshed.headers } } - const initialClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) - const customClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) - try { - const initialOptions = http.documents.read.metadataOptions({ input, initialData: owned }) - await initialClient.query(initialOptions) - const initialSnapshot = initialClient.getQueryData(initialOptions.queryKey) - ok(initialSnapshot !== owned) - ok(initialSnapshot?.headers !== owned.headers) - equal(initialSnapshot?.data, owned.data) - equal(Object.isFrozen(initialSnapshot), true) - equal(Object.isFrozen(initialSnapshot?.headers), true) - equal(Object.isFrozen(owned), false) - equal(Object.isFrozen(owned.headers), false) - const customOptions = http.documents.read.metadataOptions({ - input, - structuralSharing: () => owned, - }) - await customClient.query(customOptions) - const customSnapshot = customClient.getQueryData(customOptions.queryKey) - ok(customSnapshot !== owned) - ok(customSnapshot?.headers !== owned.headers) - equal(customSnapshot?.data, owned.data) - equal(Object.isFrozen(customSnapshot), true) - equal(Object.isFrozen(customSnapshot?.headers), true) - owned.status = 299 - owned.headers['etag'] = 'application-owned' - equal(initialSnapshot?.status, 203) - equal(customSnapshot?.status, 203) - equal(initialSnapshot?.headers['etag'], '"revision-5"') - equal(customSnapshot?.headers['etag'], '"revision-5"') - equal(Object.isFrozen(owned.data), false) - } finally { - initialClient.clear() - customClient.clear() - } - }) - yield* Effect.promise(() => - rejects( - update.mutate({ - params: { id: 1 }, - headers: { 'if-match': etag }, - payload: { value: 30 }, - }), - (error: unknown) => { - ok(isEffectHttpApiQueryError(error)) - equal(error.operation, 'mutation') - const failure = Cause.findError(error.cause) - ok(Result.isSuccess(failure)) - deepStrictEqual(failure.success, { _tag: 'Conflict' }) - return true - }, - ), - ) - - const skipped = http.documents.read.metadataOptions({ input: skipToken, staleTime: 123 }) - equal(skipped.queryFn, skipToken) - equal(skipped.staleTime, 123) - deepStrictEqual(skipped.queryKey, [...http.documents.read.key(), 'metadata']) - for (const option of ['queryKeyHashFn', 'queryHash']) { - throws( - () => http.documents.read.metadataOptions({ input: skipToken, [option]: undefined }), - (error: unknown) => - error instanceof EffectHttpApiQueryConfigError && error.code === 'UnsupportedQueryHash', - ) - } - - let completeCause: Cause.Cause | undefined - const runPromiseExit: RunPromiseExit = async (effect, runnerOptions) => { - const exit = await Effect.runPromiseExit(effect, runnerOptions) - if (Exit.isSuccess(exit)) return exit - const cause = Cause.combine( - exit.cause, - Cause.combine(Cause.die('defect'), Cause.interrupt(123)), - ) - completeCause = cause - return Exit.failCause(cause) - } - const failing = createHttpApiQueryUtils(api, { - client, - keyPrefix: ['failing'], - runPromiseExit, - }) - yield* Effect.promise(() => - rejects( - queryClient.query( - failing.documents.read.metadataOptions({ - input: { ...input, params: { id: -1 } }, - }), - ), - (error: unknown) => { - ok(isEffectHttpApiQueryError(error)) - equal(error.apiId, 'packed-metadata') - equal(error.groupId, 'documents') - equal(error.endpoint, 'read') - equal(error.method, 'POST') - equal(error.operation, 'metadata') - equal(error.cause, completeCause) - deepStrictEqual( - error.cause.reasons.map((reason) => reason._tag), - ['Fail', 'Die', 'Interrupt'], - ) - return true - }, - ), - ) - const rejected = new Error('runner rejected') - const rejectingRunner: RunPromiseExit = () => Promise.reject(rejected) - const rejecting = createHttpApiQueryUtils(api, { - client, - keyPrefix: ['rejection'], - runPromiseExit: rejectingRunner, - }) - yield* Effect.promise(() => - rejects( - queryClient.query(rejecting.empty.metadataOptions()), - (error: unknown) => error === rejected, - ), - ) - yield* Effect.promise(async () => { - let globalCalls = 0 - let prefixCalls = 0 - let localCalls = 0 - const left = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - structuralSharing: (_previous, data) => { - globalCalls += 1 - return { ...(Predicate.isObject(data) ? data : undefined), status: 211 } - }, - }, - }, - }) - const right = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - structuralSharing: false, - }, - }, - }) - right.setQueryDefaults(http.empty.key(), { - structuralSharing: (_previous, data) => { - prefixCalls += 1 - return { ...(Predicate.isObject(data) ? data : undefined), status: 212 } - }, - }) - try { - const sharedOptions = http.empty.metadataOptions() - await Promise.all([left.query(sharedOptions), right.query(sharedOptions)]) - equal(left.getQueryData(sharedOptions.queryKey)?.status, 211) - equal(right.getQueryData(sharedOptions.queryKey)?.status, 212) - equal(globalCalls, 1) - equal(prefixCalls, 1) - equal(Object.isFrozen(left.getQueryData(sharedOptions.queryKey)), true) - equal(Object.isFrozen(right.getQueryData(sharedOptions.queryKey)?.headers), true) - await Promise.all([left.query(sharedOptions), right.query(sharedOptions)]) - equal(globalCalls, 2) - equal(prefixCalls, 2) - equal(left.getQueryData(sharedOptions.queryKey)?.status, 211) - equal(right.getQueryData(sharedOptions.queryKey)?.status, 212) - const localOptions = http.empty.metadataOptions({ - structuralSharing: (_previous, data) => { - localCalls += 1 - return { ...(Predicate.isObject(data) ? data : undefined), status: 213 } - }, - }) - await Promise.all([left.query(localOptions), right.query(localOptions)]) - equal(globalCalls, 2) - equal(prefixCalls, 2) - equal(localCalls, 2) - equal(left.getQueryData(localOptions.queryKey)?.status, 213) - equal(right.getQueryData(localOptions.queryKey)?.status, 213) - const customManual = { data: null, status: 218, headers: { etag: 'manual-custom' } } - const leftManual = left.setQueryData(localOptions.queryKey, customManual) - const rightManual = right.setQueryData(localOptions.queryKey, customManual) - equal(localCalls, 4) - equal(globalCalls, 2) - equal(prefixCalls, 2) - equal(leftManual?.status, 213) - equal(rightManual?.status, 213) - equal(leftManual?.headers, customManual.headers) - equal(rightManual?.headers, customManual.headers) - equal(Object.isFrozen(leftManual), false) - equal(Object.isFrozen(rightManual), false) - equal(Object.isFrozen(customManual), false) - equal(Object.isFrozen(customManual.headers), false) - const inheritedFalse = http.empty.metadataOptions() - left.setDefaultOptions({ queries: { retry: false, structuralSharing: false } }) - right.setQueryDefaults(http.empty.key(), { structuralSharing: false }) - const [leftInherited, rightInherited] = await Promise.all([ - left.query(inheritedFalse), - right.query(inheritedFalse), - ]) - equal(left.getQueryData(inheritedFalse.queryKey), leftInherited) - equal(right.getQueryData(inheritedFalse.queryKey), rightInherited) - equal(globalCalls, 2) - equal(prefixCalls, 2) - const noSharing = http.empty.metadataOptions({ structuralSharing: false }) - const [leftValue, rightValue] = await Promise.all([ - left.query(noSharing), - right.query(noSharing), - ]) - equal(left.getQueryData(noSharing.queryKey), leftValue) - equal(right.getQueryData(noSharing.queryKey), rightValue) - equal(Object.isFrozen(leftValue), true) - const falseManual = { data: null, status: 219, headers: { etag: 'manual-false' } } - equal(left.setQueryData(noSharing.queryKey, falseManual), falseManual) - equal(right.setQueryData(noSharing.queryKey, falseManual), falseManual) - equal(Object.isFrozen(falseManual), false) - equal(Object.isFrozen(falseManual.headers), false) - const selected = new QueryObserver( - left, - http.empty.metadataOptions({ select: (view) => ({ status: view.status }) }), - ) - await selected.refetch() - const inheritedSelection = selected.getCurrentResult().data - await selected.refetch() - equal(selected.getCurrentResult().data, inheritedSelection) - equal(Object.isFrozen(inheritedSelection), false) - selected.setOptions( - http.empty.metadataOptions({ - select: (view) => ({ status: view.status }), - structuralSharing: false, - }), - ) - await selected.refetch() - const explicitSelection = selected.getCurrentResult().data - await selected.refetch() - ok(selected.getCurrentResult().data !== explicitSelection) - equal(Object.isFrozen(selected.getCurrentResult().data), false) - selected.destroy() - equal(globalCalls, 2) - equal(prefixCalls, 2) - equal(localCalls, 4) - } finally { - left.clear() - right.clear() - } - }) - }).pipe(Effect.provide(Layer.mergeAll(handlers, HttpServer.layerServices))), - ), -) - -const wireApi = HttpApi.make('metadata-wire').add( - HttpApiGroup.make('documents').add( - HttpApiEndpoint.get('read', '/read', { - success: HttpApiSchema.WithHeaders(Schema.FiniteFromString, { - 'x-version': Schema.FiniteFromString, - }), - }), - ), -) -let response: HttpClientResponse.HttpClientResponse | undefined -let web: Response | undefined -let responseReady: () => void = () => undefined -let endBody: () => void = () => undefined -const client = await Effect.runPromise( - HttpApiClient.makeWith(wireApi, { - baseUrl: 'https://metadata.test', - httpClient: HttpClient.make((request) => { - web = new Response( - new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode('"42"')) - endBody = () => controller.close() - }, - }), - { - headers: { - 'content-type': 'application/json', - 'x-version': '4', - authorization: 'raw-header-value', - }, - }, - ) - response = HttpClientResponse.fromWeb(request, web) - responseReady() - return Effect.succeed(response) - }), - }), -) -for (const mode of ['global', 'prefix'] as const) { - const queryClient = new QueryClient({ - defaultOptions: { - queries: { - staleTime: Infinity, - retry: false, - ...(mode === 'global' ? { queryKeyHashFn: (key) => `global:${JSON.stringify(key)}` } : {}), - }, - }, - }) - try { - const http = createHttpApiQueryUtils(wireApi, { client, keyPrefix: ['wire', mode] }) - if (mode === 'prefix') - queryClient.setQueryDefaults(http.key(), { - queryKeyHashFn: (key) => `prefix:${JSON.stringify(key)}`, - }) - const received = new Promise((resolve) => { - responseReady = resolve - }) - const pending = queryClient.query(http.documents.read.metadataOptions()) - await received - equal(queryClient.getQueryData(http.documents.read.metadataKey()), undefined) - equal(queryClient.isFetching(), 1) - endBody() - const view = await pending - equal(view.data.body, 42) - equal(view.headers['authorization'], 'raw-header-value') - equal(view.headers['x-version'], '4') - equal(web?.bodyUsed, true) - notDeepStrictEqual( - Object.getPrototypeOf(view.headers), - Object.getPrototypeOf(response?.headers), - ) - ok(view.headers !== response?.headers) - equal(queryClient.getQueryData(http.documents.read.metadataKey()), view) - queryClient.setQueryData(http.documents.read.metadataKey(), { ...view, status: 203 }) - equal(queryClient.getQueryData(http.documents.read.metadataKey())?.status, 203) - equal(queryClient.getQueryCache().findAll({ queryKey: http.documents.read.key() }).length, 1) - } finally { - queryClient.clear() - } -} diff --git a/tests/packed-consumer/http-runtime.mts b/tests/packed-consumer/http-runtime.mts deleted file mode 100644 index 4f99759..0000000 --- a/tests/packed-consumer/http-runtime.mts +++ /dev/null @@ -1,620 +0,0 @@ -import { NodeHttpServer } from '@effect/platform-node' -// fallow-ignore-file unused-file -// The packed-package verifier executes this fixture in isolated consumers. -import { MutationObserver, QueryClient, isCancelledError } from '@tanstack/query-core' -import { Cause, Deferred, Effect, Exit, Layer, Predicate, Schema } from 'effect' -import { - createHttpApiQueryUtils, - createRpcQueryUtils, - EffectHttpApiQueryError, - isEffectHttpApiQueryError, - skipToken, - type RunPromiseExit, -} from 'effect-api-query' -import { - FetchHttpClient, - HttpClient, - HttpClientRequest, - HttpClientResponse, - HttpServer, - Multipart, -} from 'effect/http' -import { - HttpApi, - HttpApiBuilder, - HttpApiClient, - HttpApiEndpoint, - HttpApiGroup, - HttpApiMiddleware, - HttpApiSchema, - HttpApiTest, -} from 'effect/http-api' -import { Rpc, RpcGroup, RpcTest } from 'effect/rpc' -import { deepStrictEqual, equal, notDeepStrictEqual, ok, rejects } from 'node:assert/strict' -import { createServer } from 'node:http' - -const UploadPayload = Schema.Struct({ - name: Schema.String, - amount: Schema.NumberFromString, - file: Multipart.SingleFileSchema, -}).pipe(HttpApiSchema.asMultipart()) -const api = HttpApi.make('packed-http').add( - HttpApiGroup.make('compatibility').add( - HttpApiEndpoint.get('read', '/value/:id', { - params: { id: Schema.NumberFromString }, - success: Schema.NumberFromString, - error: Schema.String, - }), - HttpApiEndpoint.post('write', '/value', { - payload: Schema.Struct({ value: Schema.NumberFromString }), - success: Schema.NumberFromString, - }), - ), - HttpApiGroup.make('other').add(HttpApiEndpoint.get('read', '/other', { success: Schema.String })), - HttpApiGroup.make('system', { topLevel: true }).add( - HttpApiEndpoint.get('empty', '/empty', { success: HttpApiSchema.NoContent }), - ), -) -const uploadApi = HttpApi.make('packed-uploads').add( - HttpApiGroup.make('files').add( - HttpApiEndpoint.post('upload', '/upload/:id', { - params: { id: Schema.NumberFromString }, - query: { factor: Schema.NumberFromString }, - headers: { 'x-version': Schema.Literal('v1') }, - payload: UploadPayload, - success: Schema.NumberFromString, - error: Schema.Literal('upload-rejected'), - }), - HttpApiEndpoint.post('mixedUpload', '/mixed-upload', { - payload: [Schema.Struct({ value: Schema.NumberFromString }), UploadPayload], - success: Schema.NumberFromString, - }), - ), - HttpApiGroup.make('system', { topLevel: true }).add( - HttpApiEndpoint.post('uploaded', '/uploaded', { - payload: UploadPayload, - success: HttpApiSchema.NoContent, - }), - ), -) - -const cancellationApi = HttpApi.make('packed-cancellation').add( - HttpApiGroup.make('pages').add( - HttpApiEndpoint.get('read', '/pages/:page', { - params: { page: Schema.FiniteFromString }, - success: Schema.String, - }), - ), -) -for (const mode of ['query', 'metadata', 'metadata body', 'later page'] as const) { - await Effect.runPromise( - Effect.gen(function* () { - const requestReceived = yield* Deferred.make() - const requestDisconnected = yield* Deferred.make() - const interruption = yield* Deferred.make>() - const runSync = Effect.runSyncWith(yield* Effect.context()) - const paths: string[] = [] - const server = createServer((request, response) => { - paths.push(request.url ?? '') - if (request.url === '/pages/0') { - response.setHeader('content-type', 'application/json') - response.end(JSON.stringify('first')) - return - } - response.on('close', () => { - runSync(Deferred.succeed(requestDisconnected, undefined)) - }) - if (mode === 'metadata body') { - response.setHeader('content-type', 'application/json') - response.write('"pending') - } - runSync(Deferred.succeed(requestReceived, undefined)) - }) - yield* NodeHttpServer.make(() => server, { - host: '127.0.0.1', - port: 0, - disablePreemptiveShutdown: true, - }) - yield* Effect.addFinalizer(() => - Effect.sync(() => { - server.closeAllConnections() - }), - ) - const address = server.address() - ok(address !== null && !Predicate.isString(address)) - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) - yield* Effect.addFinalizer(() => - Effect.sync(() => { - queryClient.clear() - }), - ) - const client = yield* HttpApiClient.make(cancellationApi, { - baseUrl: `http://127.0.0.1:${address.port}`, - }).pipe(Effect.provide(FetchHttpClient.layer)) - let requestSignal: AbortSignal | undefined - const runPromiseExit: RunPromiseExit = async (effect, options) => { - requestSignal = options?.signal - const exit = await Effect.runPromiseExit(effect, options) - if (Exit.isFailure(exit)) { - runSync(Deferred.succeed(interruption, exit.cause)) - } - return exit - } - const utils = createHttpApiQueryUtils(cancellationApi, { - client, - keyPrefix: ['packed'], - runPromiseExit, - }) - const pending = - mode === 'query' - ? queryClient.query(utils.pages.read.queryOptions({ input: { params: { page: 1 } } })) - : mode === 'metadata' || mode === 'metadata body' - ? queryClient.query( - utils.pages.read.metadataOptions({ - input: { params: { page: 1 } }, - initialData: { data: 'cached', status: 200, headers: { etag: 'cached' } }, - }), - ) - : queryClient.infiniteQuery({ - ...utils.pages.read.infiniteOptions({ - initialPageParam: 0, - input: (page) => ({ params: { page } }), - getNextPageParam: (_last, _pages, page) => page + 1, - }), - pages: 2, - }) - const result = pending.catch((error: unknown) => error) - yield* Deferred.await(requestReceived) - equal(requestSignal?.aborted, false) - yield* Effect.promise(async () => { - await queryClient.cancelQueries({ queryKey: utils.pages.read.key() }) - }) - const settled = yield* Effect.promise(async () => await result) - if (mode === 'metadata' || mode === 'metadata body') { - deepStrictEqual(settled, { data: 'cached', status: 200, headers: { etag: 'cached' } }) - } else { - ok(isCancelledError(settled)) - } - equal(requestSignal?.aborted, true) - ok(Cause.hasInterrupts(yield* Deferred.await(interruption))) - yield* Deferred.await(requestDisconnected) - deepStrictEqual(paths, mode === 'later page' ? ['/pages/0', '/pages/1'] : ['/pages/1']) - if (mode === 'metadata' || mode === 'metadata body') { - deepStrictEqual( - queryClient.getQueryData(utils.pages.read.metadataKey({ params: { page: 1 } })), - { - data: 'cached', - status: 200, - headers: { etag: 'cached' }, - }, - ) - } - equal(queryClient.isFetching(), 0) - }).pipe( - Effect.timeoutOrElse({ - duration: '10 seconds', - orElse: () => Effect.fail(new Error(`HTTP ${mode} cancellation timed out`)), - }), - Effect.scoped, - ), - ) -} - -class Authentication extends HttpApiMiddleware.Service()( - 'PackedHttp/Authentication', - { - requiredForClient: true, - }, -) {} -const identityApi = HttpApi.make('identity').add( - HttpApiGroup.make('accounts').add( - HttpApiEndpoint.get('read', '/account', { - query: { locale: Schema.optional(Schema.String) }, - success: Schema.String, - }).middleware(Authentication), - ), -) -let identityRequests = 0 -const transport = HttpClient.make((request) => { - identityRequests += 1 - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - Response.json(request.headers['authorization'] === 'Bearer token-ada' ? 'Ada' : 'Grace'), - ), - ) -}) -const readyClientFor = (token: string) => - Effect.runPromise( - HttpApiClient.makeWith(identityApi, { - httpClient: transport, - baseUrl: 'https://example.test', - }).pipe( - Effect.provide( - HttpApiMiddleware.layerClient(Authentication, ({ request, next }) => - next(HttpClientRequest.bearerToken(request, token)), - ), - ), - ), - ) -const ada = createHttpApiQueryUtils(identityApi, { - client: await readyClientFor('token-ada'), - keyPrefix: ['tenant', 'north', 'user', 'ada'], -}) -const grace = createHttpApiQueryUtils(identityApi, { - client: await readyClientFor('token-grace'), - keyPrefix: ['tenant', 'north', 'user', 'grace'], -}) -const south = createHttpApiQueryUtils(identityApi, { - client: await readyClientFor('token-ada'), - keyPrefix: ['tenant', 'south', 'user', 'ada'], -}) -const identityCache = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity } } }) -try { - equal(await identityCache.query(ada.accounts.read.queryOptions({ input: { query: {} } })), 'Ada') - equal( - await identityCache.query( - ada.accounts.read.queryOptions({ input: { query: { locale: undefined } } }), - ), - 'Ada', - ) - equal(identityRequests, 1) - equal( - await identityCache.query(grace.accounts.read.queryOptions({ input: { query: {} } })), - 'Grace', - ) - equal( - await identityCache.query(south.accounts.read.queryOptions({ input: { query: {} } })), - 'Ada', - ) - equal(identityRequests, 3) - notDeepStrictEqual( - ada.accounts.read.queryKey({ query: {} }), - grace.accounts.read.queryKey({ query: {} }), - ) - ok(!JSON.stringify(ada.accounts.read.queryKey({ query: {} })).includes('token-ada')) -} finally { - identityCache.clear() -} - -let value = 1 -const decodedRequests: Array = [] -const serializedUploadRequests: Array = [] -const uploadKinds: string[] = [] -const handlers = Layer.mergeAll( - HttpApiBuilder.group(api, 'compatibility', (group) => - group - .handle( - 'read', - Effect.fn('PackedHttp.read')(function* ({ params }) { - decodedRequests.push(params) - if (params.id < 0) return yield* Effect.fail('missing') - return value - }), - ) - .handle( - 'write', - Effect.fn('PackedHttp.write')(function* ({ payload }) { - decodedRequests.push(payload) - value = payload.value - return value - }), - ), - ), - HttpApiBuilder.group(api, 'other', (group) => - group.handle('read', () => Effect.succeed('other')), - ), - HttpApiBuilder.group(api, 'system', (group) => group.handle('empty', () => Effect.void)), -) - -const rpc = RpcGroup.make(Rpc.make('compatibility.read', { success: Schema.String })) - -await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const client = yield* HttpApiTest.groups(api, ['compatibility', 'other', 'system']) - const uploadClient = yield* HttpApiClient.makeWith(uploadApi, { - baseUrl: 'https://uploads.test', - httpClient: HttpClient.make((request) => - Effect.gen(function* () { - const web = yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie) - const url = new URL(web.url) - if ( - url.pathname === '/mixed-upload' && - web.headers.get('content-type')?.startsWith('application/json') - ) { - const body = yield* Effect.promise(() => web.json()) - deepStrictEqual(body, { value: '9' }) - uploadKinds.push('plain') - return HttpClientResponse.fromWeb(request, Response.json('9')) - } - const body = yield* Effect.promise(() => web.formData()) - if (url.pathname === '/uploaded') { - return HttpClientResponse.fromWeb(request, new Response(null, { status: 204 })) - } - if (url.pathname === '/mixed-upload') { - equal(body.get('amount'), '7') - uploadKinds.push('multipart') - return HttpClientResponse.fromWeb(request, Response.json('7')) - } - if (body.get('name') === 'reject') { - return HttpClientResponse.fromWeb( - request, - Response.json('upload-rejected', { status: 500 }), - ) - } - const file = body.get('file') - ok(file instanceof File) - serializedUploadRequests.push({ - url: web.url, - version: web.headers.get('x-version'), - name: body.get('name'), - amount: body.get('amount'), - fileName: file.name, - contents: yield* Effect.promise(() => file.text()), - }) - return HttpClientResponse.fromWeb(request, Response.json('17')) - }), - ), - }) - const rpcClient = yield* RpcTest.makeClient(rpc, { flatten: true }).pipe( - Effect.provide(rpc.toLayer({ 'compatibility.read': () => Effect.succeed('rpc') })), - ) - const queryClient = new QueryClient({ - defaultOptions: { - queries: { retry: false, staleTime: Infinity }, - mutations: { retry: false, gcTime: Infinity }, - }, - }) - yield* Effect.addFinalizer(() => Effect.sync(() => queryClient.clear())) - const http = createHttpApiQueryUtils(api, { client, keyPrefix: ['shared'] as const }) - const uploads = createHttpApiQueryUtils(uploadApi, { - client: uploadClient, - keyPrefix: ['shared'] as const, - }) - const rpcUtils = createRpcQueryUtils(rpc, { - client: rpcClient, - keyPrefix: ['shared'] as const, - }) - - const readInput = { params: { id: 1 }, responseMode: 'response-only' } as const - // @ts-expect-error Runtime misuse still returns decoded data; response controls are private. - const readOptions = http.compatibility.read.queryOptions({ input: readInput }) - equal(yield* Effect.promise(() => queryClient.query(readOptions)), 1) - equal(yield* Effect.promise(() => queryClient.query(readOptions)), 1) - deepStrictEqual(decodedRequests, [{ id: 1 }]) - - const writeInput = { payload: { value: 7 }, responseMode: 'response-only' } as const - const write = new MutationObserver(queryClient, http.compatibility.write.mutationOptions()) - // @ts-expect-error Runtime misuse cannot select a raw mutation response. - equal(yield* Effect.promise(() => write.mutate(writeInput)), 7) - deepStrictEqual(decodedRequests, [{ id: 1 }, { value: 7 }]) - equal(queryClient.getQueryData(readOptions.queryKey), 1) - - yield* Effect.promise(() => - queryClient.invalidateQueries({ queryKey: http.compatibility.read.key() }), - ) - equal(yield* Effect.promise(() => queryClient.query(readOptions)), 7) - equal(yield* Effect.promise(() => queryClient.query(http.empty.queryOptions())), null) - const emptyMutation = new MutationObserver(queryClient, http.empty.mutationOptions()) - equal(yield* Effect.promise(() => emptyMutation.mutate(undefined)), undefined) - equal( - queryClient.getMutationCache().findAll({ mutationKey: http.empty.mutationKey() }).length, - 1, - ) - notDeepStrictEqual(http.empty.queryKey(), http.empty.mutationKey()) - - const formData = new FormData() - formData.set('name', 'Ada') - formData.set('amount', '7') - formData.set('file', new File(['release upload'], 'release.txt', { type: 'text/plain' })) - const uploadInput = { - params: { id: 3 }, - query: { factor: 2 }, - headers: { 'x-version': 'v1' as const }, - payload: formData, - } - const uploadCallbacks: string[] = [] - const upload = new MutationObserver( - queryClient, - uploads.files.upload.mutationOptions({ - onMutate: (request) => { - equal(request, uploadInput) - equal(request.payload, formData) - uploadCallbacks.push('mutate') - return 'prepared' - }, - onSuccess: (data, request, mutateResult) => { - equal(data, 17) - equal(request, uploadInput) - equal(mutateResult, 'prepared') - uploadCallbacks.push('success') - }, - onSettled: (data, error, request, mutateResult) => { - equal(data, 17) - equal(error, null) - equal(request, uploadInput) - equal(mutateResult, 'prepared') - uploadCallbacks.push('settled') - }, - }), - ) - equal(yield* Effect.promise(() => upload.mutate(uploadInput)), 17) - deepStrictEqual(uploadCallbacks, ['mutate', 'success', 'settled']) - deepStrictEqual(serializedUploadRequests, [ - { - url: 'https://uploads.test/upload/3?factor=2', - version: 'v1', - name: 'Ada', - amount: '7', - fileName: 'release.txt', - contents: 'release upload', - }, - ]) - deepStrictEqual(uploads.files.upload.mutationKey(), [ - 'shared', - 'http', - 'packed-uploads', - 'files', - 'upload', - 'mutation', - ]) - equal( - queryClient.getMutationCache().findAll({ mutationKey: uploads.files.upload.key() }).length, - 1, - ) - const mixedUpload = new MutationObserver( - queryClient, - uploads.files.mixedUpload.mutationOptions(), - ) - equal(yield* Effect.promise(() => mixedUpload.mutate({ payload: { value: 9 } })), 9) - equal(yield* Effect.promise(() => mixedUpload.mutate({ payload: formData })), 7) - deepStrictEqual(uploadKinds, ['plain', 'multipart']) - const completedUpload = new MutationObserver(queryClient, uploads.uploaded.mutationOptions()) - equal(yield* Effect.promise(() => completedUpload.mutate({ payload: formData })), undefined) - equal(completedUpload.getCurrentResult().status, 'success') - const rejectedFormData = new FormData() - rejectedFormData.set('name', 'reject') - rejectedFormData.set('amount', '0') - const rejectedUpload = new MutationObserver( - queryClient, - uploads.files.upload.mutationOptions(), - ) - yield* Effect.promise(() => - rejects( - rejectedUpload.mutate({ ...uploadInput, payload: rejectedFormData }), - (error: unknown) => { - ok(isEffectHttpApiQueryError(error)) - equal(error.apiId, 'packed-uploads') - equal(error.groupId, 'files') - equal(error.endpoint, 'upload') - equal(error.method, 'POST') - equal(error.operation, 'mutation') - deepStrictEqual( - error.cause.reasons.map((reason) => - reason._tag === 'Fail' ? reason.error : undefined, - ), - ['upload-rejected'], - ) - return true - }, - ), - ) - - const rpcOptions = rpcUtils.compatibility.read.queryOptions() - equal(yield* Effect.promise(() => queryClient.query(rpcOptions)), 'rpc') - equal(yield* Effect.promise(() => queryClient.query(http.other.read.queryOptions())), 'other') - const secondRead = http.compatibility.read.queryOptions({ input: { params: { id: 2 } } }) - equal(yield* Effect.promise(() => queryClient.query(secondRead)), 7) - deepStrictEqual(http.key(), ['shared', 'http', 'packed-http']) - deepStrictEqual(rpcUtils.key(), ['shared', 'rpc']) - notDeepStrictEqual(readOptions.queryKey, rpcOptions.queryKey) - equal(queryClient.getQueryCache().findAll({ queryKey: http.key() }).length, 4) - equal(queryClient.getQueryCache().findAll({ queryKey: rpcUtils.key() }).length, 1) - equal( - queryClient.getQueryCache().findAll({ queryKey: http.compatibility.read.key() }).length, - 2, - ) - - yield* Effect.promise(() => - queryClient.invalidateQueries({ queryKey: http.compatibility.key() }), - ) - equal(queryClient.getQueryState(readOptions.queryKey)?.isInvalidated, true) - equal(queryClient.getQueryState(secondRead.queryKey)?.isInvalidated, true) - equal(queryClient.getQueryState(http.other.read.queryKey())?.isInvalidated, false) - equal(queryClient.getQueryState(http.empty.queryKey())?.isInvalidated, false) - equal(queryClient.getQueryState(rpcOptions.queryKey)?.isInvalidated, false) - - yield* Effect.promise(() => queryClient.invalidateQueries({ queryKey: http.key() })) - equal(queryClient.getQueryState(http.other.read.queryKey())?.isInvalidated, true) - equal(queryClient.getQueryState(http.empty.queryKey())?.isInvalidated, true) - equal(queryClient.getQueryState(rpcOptions.queryKey)?.isInvalidated, false) - yield* Effect.promise(() => queryClient.invalidateQueries({ queryKey: ['shared'] })) - equal(queryClient.getQueryState(rpcOptions.queryKey)?.isInvalidated, true) - - yield* Effect.promise(() => - rejects( - queryClient.query( - http.compatibility.read.queryOptions({ input: { params: { id: -1 } } }), - ), - (error: unknown) => { - ok(error instanceof EffectHttpApiQueryError) - ok(isEffectHttpApiQueryError(error)) - deepStrictEqual( - error.cause.reasons.map((reason) => reason._tag), - ['Fail'], - ) - deepStrictEqual( - error.cause.reasons.map((reason) => - reason._tag === 'Fail' ? reason.error : undefined, - ), - ['missing'], - ) - return true - }, - ), - ) - - const skipped = http.compatibility.read.queryOptions({ input: skipToken, staleTime: 123 }) - equal(skipped.queryFn, skipToken) - equal(skipped.staleTime, 123) - deepStrictEqual(skipped.queryKey, [...http.compatibility.read.key(), 'query']) - const pages = http.compatibility.read.infiniteOptions({ - initialPageParam: 0, - input: (id) => ({ params: { id } }), - getNextPageParam: (_last, _pages, id) => (id < 1 ? id + 1 : undefined), - }) - decodedRequests.length = 0 - deepStrictEqual( - yield* Effect.promise(() => queryClient.infiniteQuery({ ...pages, pages: 2 })), - { - pages: [7, 7], - pageParams: [0, 1], - }, - ) - deepStrictEqual(decodedRequests, [{ id: 0 }, { id: 1 }]) - notDeepStrictEqual(pages.queryKey, http.compatibility.read.queryKey({ params: { id: 0 } })) - deepStrictEqual( - yield* Effect.promise(() => - queryClient.infiniteQuery( - http.empty.infiniteOptions({ - initialPageParam: 0, - getNextPageParam: () => undefined, - }), - ), - ), - { pages: [null], pageParams: [0] }, - ) - - let completeCause: Cause.Cause | undefined - const runPromiseExit: RunPromiseExit = async (effect, options) => { - const exit = await Effect.runPromiseExit(effect, options) - if (Exit.isSuccess(exit)) return exit - const cause = Cause.combine( - exit.cause, - Cause.combine(Cause.die(new Error('defect')), Cause.interrupt(123)), - ) - completeCause = cause - return Exit.failCause(cause) - } - const failingHttp = createHttpApiQueryUtils(api, { - client, - keyPrefix: ['failure'] as const, - runPromiseExit, - }) - yield* Effect.promise(() => - rejects( - queryClient.query( - failingHttp.compatibility.read.queryOptions({ input: { params: { id: -1 } } }), - ), - (error: unknown) => { - ok(error instanceof EffectHttpApiQueryError) - ok(completeCause) - equal(error.cause, completeCause) - return true - }, - ), - ) - }).pipe(Effect.provide(Layer.mergeAll(handlers, HttpServer.layerServices))), - ), -) diff --git a/tests/packed-consumer/http-stream-runtime.mts b/tests/packed-consumer/http-stream-runtime.mts deleted file mode 100644 index 4fb842e..0000000 --- a/tests/packed-consumer/http-stream-runtime.mts +++ /dev/null @@ -1,627 +0,0 @@ -import { QueryClient, QueryObserver, isCancelledError } from '@tanstack/query-core' -import { - Cause, - Context, - Deferred, - Effect, - Exit, - Layer, - Schema, - SchemaTransformation, - Stream, -} from 'effect' -import { - createHttpApiQueryUtils, - EffectHttpApiQueryConfigError, - EffectHttpApiQueryError, - skipToken, -} from 'effect-api-query' -import { HttpClient, HttpClientResponse, HttpServer } from 'effect/http' -import { - HttpApi, - HttpApiBuilder, - HttpApiClient, - HttpApiEndpoint, - HttpApiGroup, - HttpApiSchema, - HttpApiTest, -} from 'effect/http-api' -import { deepStrictEqual, equal, notDeepStrictEqual, ok, rejects, throws } from 'node:assert/strict' - -const UndefinedValue = Schema.Null.pipe( - Schema.decodeTo( - Schema.Void, - SchemaTransformation.transform({ - decode: (): void => undefined, - encode: (_value: void) => null, - }), - ), -) -const Expired = Schema.TaggedStruct('Expired', { reason: Schema.String }) -const api = HttpApi.make('http-streams').add( - HttpApiGroup.make('events').add( - HttpApiEndpoint.get('watch', '/watch', { - query: { channel: Schema.String }, - success: HttpApiSchema.StreamSse({ data: Schema.Finite, error: Expired }), - }), - HttpApiEndpoint.get('cursor', '/cursor', { - headers: { 'last-event-id': Schema.String }, - success: HttpApiSchema.StreamSse({ data: Schema.String }), - }), - HttpApiEndpoint.get('wrapped', '/wrapped', { - success: HttpApiSchema.WithHeaders(HttpApiSchema.StreamSse({ data: Schema.Finite }), { - 'x-version': Schema.FiniteFromString, - }), - }), - HttpApiEndpoint.get('undefined', '/undefined', { - success: HttpApiSchema.StreamSse({ data: UndefinedValue }), - }), - HttpApiEndpoint.get('plain', '/plain', { - success: HttpApiSchema.StreamSse({ data: Schema.Finite }), - }), - HttpApiEndpoint.get('records', '/records', { - success: HttpApiSchema.StreamSse({ - events: Schema.Struct({ - id: Schema.String, - event: Schema.Literal('changed'), - data: Schema.String, - }), - }), - }), - ), -) -const handlers = HttpApiBuilder.group(api, 'events', (group) => - group - .handle('watch', ({ query }) => - Effect.succeed( - query.channel === 'failure' - ? Stream.concat( - Stream.succeed(1), - Stream.fail({ _tag: 'Expired' as const, reason: 'resume' }), - ) - : query.channel === 'interruption' - ? Stream.failCause(Cause.interrupt(123)) - : query.channel === 'mixed' - ? Stream.failCause( - Cause.combine(Cause.interrupt(123), Cause.die(new Error('defect'))), - ) - : Stream.make(1, 2, 3, 4), - ), - ) - .handle('cursor', ({ headers }) => Effect.succeed(Stream.succeed(headers['last-event-id']))) - .handle('undefined', () => Effect.succeed(Stream.make(undefined, undefined))) - .handle('wrapped', () => - Effect.succeed( - HttpApiSchema.withHeaders({ body: Stream.make(3, 7), headers: { 'x-version': 4 } }), - ), - ) - .handle('plain', () => Effect.succeed(Stream.empty)) - .handle('records', () => - Effect.succeed(Stream.make({ id: 'v1', event: 'changed', data: 'first' })), - ), -) -const waitFor = async (client: QueryClient, check: () => boolean) => { - const ready = Deferred.makeUnsafe() - const observe = () => { - if (check()) Effect.runSync(Deferred.succeed(ready, undefined)) - } - const unsubscribe = client.getQueryCache().subscribe(observe) - try { - observe() - await Effect.runPromise(Deferred.await(ready).pipe(Effect.timeout('5 seconds'))) - } finally { - unsubscribe() - } -} -const makeClient = (response: () => Response) => - Effect.runPromise( - HttpApiClient.makeWith(api, { - baseUrl: 'https://example.test', - httpClient: HttpClient.make((request) => - Effect.succeed(HttpClientResponse.fromWeb(request, response())), - ), - }), - ) -const sse = (body: string, headers: Record = {}) => - new Response(body, { headers: { 'content-type': 'text/event-stream', ...headers } }) -const expectFailure = - (endpoint: string, check: (cause: Cause.Cause) => void) => (error: unknown) => { - ok(error instanceof EffectHttpApiQueryError) - equal(error.apiId, 'http-streams') - equal(error.groupId, 'events') - equal(error.endpoint, endpoint) - equal(error.method, 'GET') - equal(error.operation, 'streamed') - check(error.cause) - return true - } - -await Effect.runPromise( - Effect.gen(function* () { - const client = yield* HttpApiTest.groups(api, ['events']) - const utils = createHttpApiQueryUtils(api, { client, keyPrefix: ['consumer'] }) - const input = { query: { channel: 'news' } } - const queryClient = new QueryClient() - try { - const options = utils.events.watch.streamedOptions({ input }) - deepStrictEqual(yield* Effect.promise(() => queryClient.query(options)), [1, 2, 3, 4]) - deepStrictEqual(queryClient.getQueryData(options.queryKey), [1, 2, 3, 4]) - deepStrictEqual( - yield* Effect.promise(() => - queryClient.query(utils.events.watch.streamedOptions({ input, maxChunks: 2 })), - ), - [3, 4], - ) - equal(queryClient.getQueryCache().getAll().length, 2) - for (const cursor of ['v1', 'v2']) { - const cursorOptions = utils.events.cursor.streamedOptions({ - input: { headers: { 'last-event-id': cursor } }, - sseOptions: { maxEventSize: 1024 }, - }) - deepStrictEqual(yield* Effect.promise(() => queryClient.query(cursorOptions)), [cursor]) - deepStrictEqual( - queryClient.getQueryData( - utils.events.cursor.streamedKey( - { headers: { 'last-event-id': cursor } }, - { sseOptions: { maxEventSize: 1024 } }, - ), - ), - [cursor], - ) - } - deepStrictEqual( - yield* Effect.promise(() => queryClient.query(utils.events.wrapped.streamedOptions())), - [ - HttpApiSchema.withHeaders({ body: 3, headers: { 'x-version': 4 } }), - HttpApiSchema.withHeaders({ body: 7, headers: { 'x-version': 4 } }), - ], - ) - deepStrictEqual( - yield* Effect.promise(() => queryClient.query(utils.events.records.streamedOptions())), - [{ id: 'v1', event: 'changed', data: 'first' }], - ) - deepStrictEqual( - yield* Effect.promise(() => queryClient.query(utils.events.plain.streamedOptions())), - [], - ) - deepStrictEqual( - yield* Effect.promise(() => queryClient.query(utils.events.undefined.streamedOptions())), - [undefined, undefined], - ) - const defaultKey = utils.events.watch.streamedKey(input) - for (const sseOptions of [ - undefined, - {}, - { maxEventSize: undefined }, - { maxEventSize: 10 * 1024 * 1024 }, - ]) { - deepStrictEqual( - defaultKey, - utils.events.watch.streamedKey(input, { - sseOptions, - maxChunks: undefined, - refetchMode: 'reset', - }), - ) - deepStrictEqual( - defaultKey, - utils.events.watch.streamedOptions({ input, sseOptions }).queryKey, - ) - } - notDeepStrictEqual( - defaultKey, - utils.events.watch.streamedKey(input, { sseOptions: { maxEventSize: 1024 } }), - ) - notDeepStrictEqual( - defaultKey, - utils.events.watch.streamedKey(input, { refetchMode: 'append' }), - ) - equal(Object.isFrozen(defaultKey), true) - equal(Object.isFrozen(defaultKey.at(-1)), true) - for (const refetchMode of ['reset', 'append', 'replace'] as const) { - for (const maxChunks of [undefined, 2]) { - deepStrictEqual( - utils.events.watch.streamedKey(input, { refetchMode, maxChunks }), - utils.events.watch.streamedOptions({ input, refetchMode, maxChunks }).queryKey, - ) - deepStrictEqual( - utils.events.plain.streamedKey({ refetchMode, maxChunks }), - utils.events.plain.streamedOptions({ refetchMode, maxChunks }).queryKey, - ) - } - } - const skipped = utils.events.watch.streamedOptions({ - input: skipToken, - maxChunks: 2, - sseOptions: { maxEventSize: 1024 }, - staleTime: 123, - }) - equal(skipped.queryFn, skipToken) - equal(skipped.staleTime, 123) - deepStrictEqual(skipped.queryKey, [...utils.events.watch.key(), 'streamed']) - for (const [policy, code] of [ - ...[0, -1, 0.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, '2', null].map( - (maxChunks) => [{ maxChunks }, 'InvalidMaxChunks'] as const, - ), - ...['unknown', null, 3].map( - (refetchMode) => [{ refetchMode }, 'InvalidRefetchMode'] as const, - ), - ...[0, -1, 0.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, '2', null].map( - (maxEventSize) => [{ sseOptions: { maxEventSize } }, 'InvalidMaxEventSize'] as const, - ), - ]) { - for (const build of [ - () => Reflect.apply(utils.events.watch.streamedKey, undefined, [input, policy]), - () => Reflect.apply(utils.events.plain.streamedKey, undefined, [policy]), - ...[input, skipToken].map( - (request) => () => - Reflect.apply(utils.events.watch.streamedOptions, undefined, [ - { input: request, ...policy }, - ]), - ), - ]) { - throws(build, (error: unknown) => { - ok(error instanceof EffectHttpApiQueryConfigError) - equal(error.code, code) - equal(error.apiId, 'http-streams') - return true - }) - } - } - equal(queryClient.getQueriesData({ queryKey: utils.events.watch.key() }).length, 2) - yield* Effect.promise(() => - queryClient.invalidateQueries({ queryKey: utils.events.watch.key() }), - ) - equal(queryClient.getQueryState(options.queryKey)?.isInvalidated, true) - equal(queryClient.getQueryState(utils.events.wrapped.streamedKey())?.isInvalidated, false) - for (const [channel, tags] of [ - ['failure', ['Fail']], - ['interruption', ['Interrupt']], - ['mixed', ['Interrupt', 'Die']], - ] as const) { - const stream = yield* client.events.watch({ - query: { channel }, - responseMode: 'decoded-only', - }) - const native = yield* Effect.exit(Stream.runCollect(stream)) - ok(Exit.isFailure(native)) - deepStrictEqual( - native.cause.reasons.map((reason) => reason._tag), - tags, - ) - const failedOptions = utils.events.watch.streamedOptions({ - input: { query: { channel } }, - retry: false, - }) - yield* Effect.promise(() => - rejects( - queryClient.query(failedOptions), - expectFailure('watch', (cause) => { - deepStrictEqual( - cause.reasons.map((reason) => reason._tag), - tags, - ) - if (channel === 'failure') { - deepStrictEqual(cause.reasons[0]?._tag === 'Fail' && cause.reasons[0].error, { - _tag: 'Expired', - reason: 'resume', - }) - } - }), - ), - ) - if (channel === 'failure') { - deepStrictEqual(queryClient.getQueryData(failedOptions.queryKey), [1]) - } - } - } finally { - queryClient.clear() - } - }).pipe(Effect.provide(Layer.mergeAll(handlers, HttpServer.layerServices)), Effect.scoped), -) - -for (const refetchMode of ['reset', 'append', 'replace'] as const) { - for (const maxChunks of [undefined, 2]) { - let body = 'data: 1\n\ndata: 2\n\ndata: 3\n\n' - const client = await makeClient(() => sse(body)) - const utils = createHttpApiQueryUtils(api, { client, keyPrefix: ['refetch'] }) - const queryClient = new QueryClient() - const options = utils.events.plain.streamedOptions({ - refetchMode, - maxChunks, - initialData: [99], - }) - try { - deepStrictEqual(await queryClient.query(options), maxChunks === 2 ? [2, 3] : [99, 1, 2, 3]) - body = 'data: 4\n\ndata: 5\n\n' - deepStrictEqual( - await queryClient.query(options), - refetchMode === 'append' && maxChunks === undefined ? [99, 1, 2, 3, 4, 5] : [4, 5], - ) - body = '' - deepStrictEqual( - await queryClient.query(options), - refetchMode === 'append' ? (maxChunks === undefined ? [99, 1, 2, 3, 4, 5] : [4, 5]) : [], - ) - } finally { - queryClient.clear() - } - } -} - -for (const [body, endpoint, headers, failureTag] of [ - ['data: "bad"\n\n', 'plain', {}, 'SchemaError'], - ['data: 1\n\n', 'wrapped', { 'x-version': 'bad' }, 'SchemaError'], - ['id: v3\ndata: 1\n\nretry: 25\n\n', 'plain', {}, 'Retry'], -] as const) { - const client = await makeClient(() => sse(body, headers)) - const utils = createHttpApiQueryUtils(api, { client, keyPrefix: ['failures'] }) - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) - try { - const options = - endpoint === 'wrapped' - ? utils.events.wrapped.streamedOptions() - : utils.events.plain.streamedOptions() - const pending = - endpoint === 'wrapped' - ? queryClient.query(utils.events.wrapped.streamedOptions()) - : queryClient.query(utils.events.plain.streamedOptions()) - await rejects( - pending, - expectFailure(endpoint, (cause) => - deepStrictEqual( - cause.reasons.map((reason) => - reason._tag === 'Fail' ? Reflect.get(reason.error as object, '_tag') : reason._tag, - ), - [failureTag], - ), - ), - ) - if (failureTag === 'Retry') deepStrictEqual(queryClient.getQueryData(options.queryKey), [1]) - } finally { - queryClient.clear() - } -} - -{ - const client = await makeClient( - () => - new Response( - new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode('data: 12345')) - controller.enqueue(new TextEncoder().encode('\n\n')) - controller.close() - }, - }), - { headers: { 'content-type': 'text/event-stream' } }, - ), - ) - const utils = createHttpApiQueryUtils(api, { client, keyPrefix: ['decoder'] }) - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) - try { - deepStrictEqual(await queryClient.query(utils.events.plain.streamedOptions()), [12345]) - await rejects( - queryClient.query(utils.events.plain.streamedOptions({ sseOptions: { maxEventSize: 3 } })), - expectFailure('plain', (cause) => - equal( - cause.reasons[0]?._tag === 'Fail' && - Reflect.get(cause.reasons[0].error as object, '_tag'), - 'SseError', - ), - ), - ) - } finally { - queryClient.clear() - } -} - -for (const phase of ['acquisition', 'consumption'] as const) { - const cause = Cause.combine(Cause.interrupt(123), Cause.die(new Error('defect'))) - const client = await Effect.runPromise( - HttpApiClient.makeWith(api, { - baseUrl: 'https://example.test', - httpClient: HttpClient.make((request) => - phase === 'acquisition' - ? Effect.failCause(cause) - : Effect.succeed( - HttpClientResponse.fromWeb( - request, - new Response( - new ReadableStream({ - start(controller) { - controller.error(new Error('transport')) - }, - }), - { headers: { 'content-type': 'text/event-stream' } }, - ), - ), - ), - ), - }), - ) - const utils = createHttpApiQueryUtils(api, { client, keyPrefix: ['cause'] }) - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) - try { - await rejects( - queryClient.query(utils.events.plain.streamedOptions()), - expectFailure('plain', (actual) => - phase === 'acquisition' - ? deepStrictEqual( - actual.reasons.map((reason) => reason._tag), - ['Interrupt', 'Die'], - ) - : ok(Cause.hasFails(actual)), - ), - ) - } finally { - queryClient.clear() - } -} - -for (const cancelPhase of ['acquisition', 'consumption'] as const) { - let closed = 0 - let signal: AbortSignal | undefined - const started = Deferred.makeUnsafe() - const finalized = Deferred.makeUnsafe() - const client = await Effect.runPromise( - HttpApiClient.makeWith(api, { - baseUrl: 'https://example.test', - httpClient: HttpClient.make((request, _url, transportSignal) => { - signal = transportSignal - if (cancelPhase === 'acquisition') - return Deferred.succeed(started, undefined).pipe( - Effect.andThen(Effect.never), - Effect.ensuring(Deferred.succeed(finalized, undefined)), - ) - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - new Response( - new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode('data: 1\n\n')) - }, - cancel() { - closed += 1 - Effect.runSync(Deferred.succeed(finalized, undefined)) - }, - }), - { headers: { 'content-type': 'text/event-stream' } }, - ), - ), - ) - }), - }), - ) - const utils = createHttpApiQueryUtils(api, { client, keyPrefix: ['cancel'] }) - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) - const options = utils.events.plain.streamedOptions() - const pending = queryClient.query(options) - const outcome = pending.catch((error: unknown) => error) - try { - if (cancelPhase === 'acquisition') - await Effect.runPromise(Deferred.await(started).pipe(Effect.timeout('5 seconds'))) - else await waitFor(queryClient, () => queryClient.getQueryData(options.queryKey)?.length === 1) - await queryClient.cancelQueries({ queryKey: options.queryKey, exact: true }) - const result = await outcome - if (cancelPhase === 'acquisition') ok(isCancelledError(result)) - else deepStrictEqual(result, [1]) - await Effect.runPromise(Deferred.await(finalized).pipe(Effect.timeout('5 seconds'))) - equal(signal?.aborted, true) - equal(closed, cancelPhase === 'consumption' ? 1 : 0) - deepStrictEqual( - queryClient.getQueryData(options.queryKey), - cancelPhase === 'acquisition' ? undefined : [1], - ) - } finally { - queryClient.clear() - } -} - -{ - let closed = 0 - const finalized = Deferred.makeUnsafe() - const client = await makeClient( - () => - new Response( - new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode('data: 1\n\n')) - }, - cancel() { - closed += 1 - Effect.runSync(Deferred.succeed(finalized, undefined)) - }, - }), - { headers: { 'content-type': 'text/event-stream' } }, - ), - ) - const utils = createHttpApiQueryUtils(api, { client, keyPrefix: ['observer'] }) - const queryClient = new QueryClient({ - defaultOptions: { - queries: { queryKeyHashFn: (key) => `custom:${JSON.stringify(key)}`, retry: false }, - }, - }) - const observer = new QueryObserver(queryClient, utils.events.plain.streamedOptions()) - const unsubscribe = observer.subscribe(() => undefined) - try { - await waitFor(queryClient, () => observer.getCurrentResult().data?.length === 1) - equal(observer.getCurrentResult().status, 'success') - equal(observer.getCurrentResult().fetchStatus, 'fetching') - deepStrictEqual(queryClient.getQueryData(utils.events.plain.streamedKey()), [1]) - equal(queryClient.getQueryCache().getAll().length, 1) - unsubscribe() - await Effect.runPromise(Deferred.await(finalized).pipe(Effect.timeout('5 seconds'))) - equal(closed, 1) - } finally { - unsubscribe() - queryClient.clear() - } -} - -class Scale extends Context.Service()('HttpStream/Scale') {} -const Scaled = Schema.Finite.pipe( - Schema.decodeTo( - Schema.Finite, - SchemaTransformation.transformEffect({ - decode: (value) => Scale.pipe(Effect.map(({ factor }) => value * factor)), - encode: Effect.succeed, - }), - ), -) -const serviceApi = HttpApi.make('services').add( - HttpApiGroup.make('events').add( - HttpApiEndpoint.get('watch', '/watch', { success: HttpApiSchema.StreamSse({ data: Scaled }) }), - ), -) -const serviceClient = await Effect.runPromise( - HttpApiClient.makeWith(serviceApi, { - baseUrl: 'https://example.test', - httpClient: HttpClient.make((request) => - Effect.succeed(HttpClientResponse.fromWeb(request, sse('data: 3\n\n'))), - ), - }), -) -const serviceUtils = createHttpApiQueryUtils(serviceApi, { - client: serviceClient, - keyPrefix: ['services'], - runPromiseExit: (effect, options) => - Effect.runPromiseExit(effect.pipe(Effect.provideService(Scale, { factor: 2 })), options), -}) -const serviceQueryClient = new QueryClient() -try { - deepStrictEqual(await serviceQueryClient.query(serviceUtils.events.watch.streamedOptions()), [6]) -} finally { - serviceQueryClient.clear() -} - -{ - const client = await makeClient(() => sse('data: 1\n\n')) - const cause = Cause.combine(Cause.interrupt(123), Cause.die(new Error('defect'))) - const failedExit = Exit.failCause(cause) - const failed = createHttpApiQueryUtils(api, { - client, - keyPrefix: ['runner-cause'], - runPromiseExit: () => Promise.resolve(failedExit), - }) - const rejection = new Error('runner rejected') - const utils = createHttpApiQueryUtils(api, { - client, - keyPrefix: ['runner'], - runPromiseExit: () => Promise.reject(rejection), - }) - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) - try { - await rejects( - queryClient.query(failed.events.plain.streamedOptions()), - expectFailure('plain', (actual) => equal(actual, cause)), - ) - await rejects( - queryClient.query(utils.events.plain.streamedOptions()), - (error) => error === rejection, - ) - } finally { - queryClient.clear() - } -} diff --git a/tests/packed-consumer/hydration-contract.ts b/tests/packed-consumer/hydration-contract.ts deleted file mode 100644 index f13af8d..0000000 --- a/tests/packed-consumer/hydration-contract.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { QueryClient } from '@tanstack/query-core' -import { Effect, Schema } from 'effect' -import type { HttpApiClient } from 'effect/http-api' -import type { RpcClient, RpcGroup } from 'effect/rpc' - -import { - HydrationPreparation, - prepareProfileHydration, - prepareProfileSnapshot, -} from './docs-hydration-async.ts' -import { - dtoApi, - dtoGroup, - dtoHttpOptions, - dtoRpcOptions, - ProfileDto, -} from './docs-hydration-dto.ts' -import { - Profile, - profileApi, - profileGroup, - profileHttpOptions, - profileRpcOptions, -} from './docs-hydration-rich.ts' - -type Equal = - (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false -type Assert = T -type Success = T extends Promise ? A : never -type Services = T extends Effect.Effect ? R : never - -declare const queryClient: QueryClient -declare const rpcClient: RpcClient.RpcClient.Flat> -declare const httpClient: HttpApiClient.ForApi -declare const dtoRpcClient: RpcClient.RpcClient.Flat> -declare const dtoHttpClient: HttpApiClient.ForApi - -const rpcData = queryClient.query(profileRpcOptions(rpcClient)) -const httpData = queryClient.query(profileHttpOptions(httpClient)) -const rpcDto = queryClient.query(dtoRpcOptions(dtoRpcClient)) -const httpDto = queryClient.query(dtoHttpOptions(dtoHttpClient)) - -type Contract = [ - Assert, Profile>>, - Assert, Profile>>, - Assert, typeof ProfileDto.Type>>, - Assert, typeof ProfileDto.Type>>, - Assert>, HydrationPreparation>>, - Assert>, HydrationPreparation>>, -] - -prepareProfileSnapshot(queryClient) satisfies Effect.Effect< - string, - Schema.SchemaError, - HydrationPreparation -> -prepareProfileHydration(queryClient, '{}') satisfies Effect.Effect< - void, - Schema.SchemaError, - HydrationPreparation -> -// @ts-expect-error The caller must provide the hydration preparation service -Effect.runPromise(prepareProfileSnapshot(queryClient)) -// @ts-expect-error The caller must provide the hydration preparation service -Effect.runPromise(prepareProfileHydration(queryClient, '{}')) - -declare const contract: Contract -contract satisfies [true, true, true, true, true, true] diff --git a/tests/packed-consumer/hydration-runtime.mts b/tests/packed-consumer/hydration-runtime.mts deleted file mode 100644 index 1228553..0000000 --- a/tests/packed-consumer/hydration-runtime.mts +++ /dev/null @@ -1,335 +0,0 @@ -import { dehydrate, QueryClient } from '@tanstack/query-core' -import { Deferred, Effect, Exit, Scope } from 'effect' -import { HttpServer } from 'effect/http' -import { HttpApiBuilder, HttpApiTest } from 'effect/http-api' -import { RpcTest } from 'effect/rpc' -import { deepStrictEqual, equal, ok, rejects, throws } from 'node:assert/strict' - -import { - HydrationPreparation, - prepareProfileHydration, - prepareProfileSnapshot, -} from './docs-hydration-async.ts' -import { - dtoApi, - dtoGroup, - dtoHttpOptions, - dtoRpcOptions, - hydrateDto, - snapshotDto, -} from './docs-hydration-dto.ts' -import { - hydrateProfile, - Profile, - profileApi, - profileGroup, - profileHttpOptions, - profileRpcOptions, - snapshotProfile, -} from './docs-hydration-rich.ts' - -const serverDto = { name: 'Ada', credits: '9007199254740993', avatar: 'AAH/' } -const browserDto = { name: 'Grace', credits: '9007199254740995', avatar: '/wEC' } - -await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - for (const transport of ['rpc', 'http'] as const) { - let serverReads = 0 - let browserReads = 0 - const serverScope = yield* Scope.make() - const serverClient = new QueryClient() - const browserClient = new QueryClient() - try { - const serverRpc = yield* RpcTest.makeClient(dtoGroup, { flatten: true }).pipe( - Effect.provide( - dtoGroup.toLayer({ - 'profile.read': () => - Effect.sync(() => { - serverReads += 1 - return serverDto - }), - }), - ), - Effect.provideService(Scope.Scope, serverScope), - ) - const browserRpc = yield* RpcTest.makeClient(dtoGroup, { flatten: true }).pipe( - Effect.provide( - dtoGroup.toLayer({ - 'profile.read': () => - Effect.sync(() => { - browserReads += 1 - return browserDto - }), - }), - ), - ) - const serverHttp = yield* HttpApiTest.groups(dtoApi, ['profile']).pipe( - Effect.provide( - HttpApiBuilder.group(dtoApi, 'profile', (handlers) => - handlers.handle('read', () => - Effect.sync(() => { - serverReads += 1 - return serverDto - }), - ), - ), - ), - Effect.provideService(Scope.Scope, serverScope), - ) - const browserHttp = yield* HttpApiTest.groups(dtoApi, ['profile']).pipe( - Effect.provide( - HttpApiBuilder.group(dtoApi, 'profile', (handlers) => - handlers.handle('read', () => - Effect.sync(() => { - browserReads += 1 - return browserDto - }), - ), - ), - ), - ) - const serverQuery = - transport === 'rpc' - ? () => serverClient.query(dtoRpcOptions(serverRpc)) - : () => serverClient.query(dtoHttpOptions(serverHttp)) - const browserQuery = - transport === 'rpc' - ? () => browserClient.query(dtoRpcOptions(browserRpc)) - : () => browserClient.query(dtoHttpOptions(browserHttp)) - const browserKey = - transport === 'rpc' - ? dtoRpcOptions(browserRpc).queryKey - : dtoHttpOptions(browserHttp).queryKey - deepStrictEqual(yield* Effect.promise(serverQuery), serverDto) - const json = snapshotDto(serverClient) - equal(typeof json, 'string') - yield* Effect.promise(() => serverClient.cancelQueries()) - serverClient.clear() - yield* Scope.close(serverScope, Exit.void) - hydrateDto(browserClient, json) - deepStrictEqual(yield* Effect.promise(browserQuery), serverDto) - equal(browserReads, 0) - yield* Effect.promise(() => browserClient.invalidateQueries({ queryKey: browserKey })) - deepStrictEqual(yield* Effect.promise(browserQuery), browserDto) - equal(serverReads, 1) - equal(browserReads, 1) - } finally { - yield* Effect.promise(() => browserClient.cancelQueries()) - browserClient.clear() - serverClient.clear() - yield* Scope.close(serverScope, Exit.void) - } - } - }), - ).pipe(Effect.provide(HttpServer.layerServices)), -) - -const serverProfile = new Profile({ - name: 'Ada', - credits: 9007199254740993n, - avatar: new Uint8Array([0, 1, 255]), -}) -const browserProfile = new Profile({ - name: 'Grace', - credits: 9007199254740995n, - avatar: new Uint8Array([255, 1, 2]), -}) -const cloned = structuredClone(serverProfile) -equal(cloned instanceof Profile, false) -equal('summary' in cloned, false) -equal(cloned.credits, 9007199254740993n) -ok(cloned.avatar instanceof Uint8Array) -throws(() => JSON.stringify(cloned), TypeError) - -await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - for (const transport of ['rpc', 'http'] as const) { - let serverReads = 0 - let browserReads = 0 - let failed = false - const serverScope = yield* Scope.make() - const serverClient = new QueryClient() - const browserClient = new QueryClient() - const makeOptions = Effect.fnUntraced(function* ( - profile: Profile, - onRead: () => void, - scope: Scope.Scope, - ) { - const read = () => - Effect.suspend(() => { - onRead() - return failed ? Effect.fail('private upstream detail') : Effect.succeed(profile) - }) - if (transport === 'rpc') { - const client = yield* RpcTest.makeClient(profileGroup, { flatten: true }).pipe( - Effect.provide(profileGroup.toLayer({ 'profile.read': read })), - Effect.provideService(Scope.Scope, scope), - ) - const options = profileRpcOptions(client) - return { - query: (cache: QueryClient) => cache.query(options), - queryKey: options.queryKey, - } - } - const client = yield* HttpApiTest.groups(profileApi, ['profile']).pipe( - Effect.provide( - HttpApiBuilder.group(profileApi, 'profile', (handlers) => - handlers.handle('read', read), - ), - ), - Effect.provideService(Scope.Scope, scope), - ) - const options = profileHttpOptions(client) - return { - query: (cache: QueryClient) => cache.query(options), - queryKey: options.queryKey, - } - }) - try { - const browserScope = yield* Scope.Scope - const serverOptions = yield* makeOptions( - serverProfile, - () => { - serverReads += 1 - }, - serverScope, - ) - const browserOptions = yield* makeOptions( - browserProfile, - () => { - browserReads += 1 - }, - browserScope, - ) - const initial = yield* Effect.promise(() => serverOptions.query(serverClient)) - ok(initial instanceof Profile) - equal(initial.summary(), 'Ada: 9007199254740993 credits') - const json = snapshotProfile(serverClient) - const wire = JSON.parse(json) - equal(wire.queries[0].state.data.credits, '9007199254740993') - equal(wire.queries[0].state.data.avatar, 'AAH/') - serverClient.clear() - failed = true - yield* Effect.promise(() => rejects(serverOptions.query(serverClient))) - equal(dehydrate(serverClient).queries.length, 0) - const failedJson = snapshotProfile(serverClient) - equal(JSON.parse(failedJson).queries.length, 0) - equal(failedJson.includes('private upstream detail'), false) - failed = false - yield* Effect.promise(() => serverClient.cancelQueries()) - serverClient.clear() - yield* Scope.close(serverScope, Exit.void) - hydrateProfile(browserClient, json) - const restored = yield* Effect.promise(() => browserOptions.query(browserClient)) - ok(restored instanceof Profile) - equal(restored.summary(), 'Ada: 9007199254740993 credits') - equal(restored.credits, 9007199254740993n) - deepStrictEqual(restored.avatar, new Uint8Array([0, 1, 255])) - equal(browserReads, 0) - yield* Effect.promise(() => - browserClient.invalidateQueries({ queryKey: browserOptions.queryKey }), - ) - const refetched = yield* Effect.promise(() => browserOptions.query(browserClient)) - ok(refetched instanceof Profile) - equal(refetched.summary(), 'Grace: 9007199254740995 credits') - equal(refetched.credits, 9007199254740995n) - deepStrictEqual(refetched.avatar, new Uint8Array([255, 1, 2])) - equal(serverReads, 2) - equal(browserReads, 1) - browserClient.clear() - hydrateProfile(browserClient, failedJson) - const afterOmittedFailure = yield* Effect.promise(() => - browserOptions.query(browserClient), - ) - ok(afterOmittedFailure instanceof Profile) - equal(afterOmittedFailure.summary(), 'Grace: 9007199254740995 credits') - equal(browserReads, 2) - } finally { - yield* Effect.promise(() => browserClient.cancelQueries()) - browserClient.clear() - serverClient.clear() - yield* Scope.close(serverScope, Exit.void) - } - } - }), - ).pipe(Effect.provide(HttpServer.layerServices)), -) - -await Effect.runPromise( - Effect.gen(function* () { - const encodingStarted = yield* Deferred.make() - const encodingAllowed = yield* Deferred.make() - const decodingStarted = yield* Deferred.make() - const decodingAllowed = yield* Deferred.make() - const preparation = { - beforeEncode: Deferred.succeed(encodingStarted, undefined).pipe( - Effect.andThen(Deferred.await(encodingAllowed)), - ), - beforeDecode: Deferred.succeed(decodingStarted, undefined).pipe( - Effect.andThen(Deferred.await(decodingAllowed)), - ), - } - const serverClient = new QueryClient() - const browserClient = new QueryClient() - const key = ['profile', 'prepared'] as const - serverClient.setQueryData(key, serverProfile) - let published: string | undefined - const encoding = Effect.runPromise( - prepareProfileSnapshot(serverClient).pipe( - Effect.provideService(HydrationPreparation, preparation), - ), - ).then((json) => { - published = json - return json - }) - yield* Deferred.await(encodingStarted) - equal(published, undefined) - equal(serverClient.getQueryData(key), serverProfile) - yield* Deferred.succeed(encodingAllowed, undefined) - const json = yield* Effect.promise(() => encoding) - equal(JSON.parse(json).queries[0].state.data.credits, '9007199254740993') - const decoding = Effect.runPromise( - prepareProfileHydration(browserClient, json).pipe( - Effect.provideService(HydrationPreparation, preparation), - ), - ) - yield* Deferred.await(decodingStarted) - equal(browserClient.getQueryData(key), undefined) - yield* Deferred.succeed(decodingAllowed, undefined) - yield* Effect.promise(() => decoding) - const hydrated = browserClient.getQueryData(key) - ok(hydrated instanceof Profile) - equal(hydrated.summary(), 'Ada: 9007199254740993 credits') - equal(hydrated.credits, 9007199254740993n) - deepStrictEqual(hydrated.avatar, new Uint8Array([0, 1, 255])) - const invalid = JSON.parse(json) - invalid.queries.push({ - ...invalid.queries[0], - queryHash: 'invalid', - queryKey: ['profile', 'invalid'], - state: { - ...invalid.queries[0].state, - data: { name: 'invalid', credits: 'oops', avatar: 'AAH/' }, - }, - }) - const untouched = new QueryClient() - yield* Effect.promise(() => - rejects( - Effect.runPromise( - prepareProfileHydration(untouched, JSON.stringify(invalid)).pipe( - Effect.provideService(HydrationPreparation, { - beforeEncode: Effect.void, - beforeDecode: Effect.void, - }), - ), - ), - ), - ) - equal(untouched.getQueryCache().getAll().length, 0) - untouched.clear() - serverClient.clear() - browserClient.clear() - }), -) diff --git a/tests/packed-consumer/hydration-views-contract.ts b/tests/packed-consumer/hydration-views-contract.ts deleted file mode 100644 index 66e1862..0000000 --- a/tests/packed-consumer/hydration-views-contract.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { QueryClient } from '@tanstack/query-core' -import { Effect, Schema } from 'effect' - -import { Profile } from './docs-hydration-rich.ts' -import { - Cursor, - HistoryElement, - prepareViewHydration, - prepareViewSnapshot, - ProfilePages, - SnapshotDecoding, - SnapshotEncoding, -} from './docs-hydration-views.ts' - -type Equal = - (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false -type Assert = T -type Services = T extends Effect.Effect ? R : never -type Contract = [ - Assert>, - Assert>, - Assert>, SnapshotEncoding>>, - Assert>, SnapshotDecoding>>, -] - -declare const queryClient: QueryClient -prepareViewSnapshot(queryClient) satisfies Effect.Effect< - string, - Schema.SchemaError, - SnapshotEncoding -> -prepareViewHydration(queryClient, '{}') satisfies Effect.Effect< - void, - Schema.SchemaError, - SnapshotDecoding -> -// @ts-expect-error Snapshot encoding requires its own caller-provided service. -Effect.runPromise(prepareViewSnapshot(queryClient)) -const decodingDoesNotEncode = prepareViewSnapshot(queryClient).pipe( - Effect.provideService(SnapshotDecoding, { beforeDecode: Effect.void }), -) -// @ts-expect-error A decoding service cannot satisfy encoding. -Effect.runPromise(decodingDoesNotEncode) -// @ts-expect-error Snapshot decoding requires its own caller-provided service. -Effect.runPromise(prepareViewHydration(queryClient, '{}')) -const encodingDoesNotDecode = prepareViewHydration(queryClient, '{}').pipe( - Effect.provideService(SnapshotEncoding, { beforeEncode: Effect.void }), -) -// @ts-expect-error An encoding service cannot satisfy decoding. -Effect.runPromise(encodingDoesNotDecode) - -declare const contract: Contract -contract satisfies [true, true, true, true] diff --git a/tests/packed-consumer/hydration-views-runtime.mts b/tests/packed-consumer/hydration-views-runtime.mts deleted file mode 100644 index ac73a62..0000000 --- a/tests/packed-consumer/hydration-views-runtime.mts +++ /dev/null @@ -1,832 +0,0 @@ -import { QueryClient } from '@tanstack/query-core' -import type { QueryExecuteOptions, QueryFunction, QueryKey } from '@tanstack/query-core' -import { Deferred, Effect, Exit, Schema, Scope, Stream } from 'effect' -import { createHttpApiQueryUtils, createRpcQueryUtils, fetchStreamSnapshot } from 'effect-api-query' -import { HttpServer } from 'effect/http' -import { - HttpApi, - HttpApiBuilder, - HttpApiEndpoint, - HttpApiGroup, - HttpApiSchema, - HttpApiTest, -} from 'effect/http-api' -import { Rpc, RpcGroup, RpcTest } from 'effect/rpc' -import { deepStrictEqual, equal, ok, rejects } from 'node:assert/strict' - -import { Profile } from './docs-hydration-rich.ts' -import { - ArchivedProfile, - BufferedProfile, - Cursor, - HistoryElement, - prepareViewHydration, - prepareViewSnapshot, - ProfilePage, - ProfilePages, - ProfileMetadata, - SnapshotDecoding, - SnapshotEncoding, - WrappedElement, -} from './docs-hydration-views.ts' - -const serverProfile = new Profile({ - name: 'Ada', - credits: 9007199254740993n, - avatar: new Uint8Array([0, 1, 255]), -}) -const browserProfile = new Profile({ - name: 'Grace', - credits: 9007199254740995n, - avatar: new Uint8Array([255, 1, 2]), -}) -const viewsGroup = RpcGroup.make( - Rpc.make('page', { payload: { cursor: Schema.Finite }, success: ProfilePage }), - Rpc.make('watch', { payload: { mode: Schema.String }, success: HistoryElement, stream: true }), - Rpc.make('empty', { success: Schema.Void }), -) -const viewsApi = HttpApi.make('hydration-views').add( - HttpApiGroup.make('profiles').add( - HttpApiEndpoint.get('page', '/page', { - query: { cursor: Schema.FiniteFromString }, - success: ProfilePage, - }), - HttpApiEndpoint.get('watch', '/watch', { - query: { mode: Schema.String }, - success: HttpApiSchema.StreamSse({ data: HistoryElement }), - }), - HttpApiEndpoint.get('empty', '/empty', { success: HttpApiSchema.NoContent }), - ), -) - -const envelopeApi = HttpApi.make('hydration-envelopes').add( - HttpApiGroup.make('profiles').add( - HttpApiEndpoint.get('read', '/read', { - success: [ - HttpApiSchema.WithHeaders(Profile, { 'x-version': Schema.FiniteFromString }), - HttpApiSchema.WithHeaders(ArchivedProfile, { 'x-version': Schema.FiniteFromString }).pipe( - HttpApiSchema.status(203), - ), - ], - error: Schema.String, - }), - HttpApiEndpoint.get('watch', '/watch', { - success: HttpApiSchema.WithHeaders(HttpApiSchema.StreamSse({ data: HistoryElement }), { - 'x-version': Schema.FiniteFromString, - }), - }), - ), -) - -const encode = (client: QueryClient) => - Effect.runPromise( - prepareViewSnapshot(client).pipe( - Effect.provideService(SnapshotEncoding, { beforeEncode: Effect.void }), - ), - ) -const decode = (client: QueryClient, json: string) => - Effect.runPromise( - prepareViewHydration(client, json).pipe( - Effect.provideService(SnapshotDecoding, { beforeDecode: Effect.void }), - ), - ) - -const finiteValues = (profile: Profile, mode: string) => - mode === 'history' - ? Stream.make(profile, undefined, null) - : mode === 'null' - ? Stream.make(profile, undefined) - : Stream.succeed(profile) - -const execution = ( - options: QueryExecuteOptions, -) => ({ - key: options.queryKey, - execute: (cache: QueryClient): Promise => cache.query(options), -}) - -const snapshotExecution = ( - options: QueryExecuteOptions & { - readonly queryFn: QueryFunction - readonly queryHash?: never - readonly queryKeyHashFn?: never - }, -) => ({ - key: options.queryKey, - capture: (cache: QueryClient, mode: 'fresh' | 'cached'): Promise => - fetchStreamSnapshot(cache, options, { mode, timeoutMs: 5_000 }), -}) - -await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - for (const transport of ['rpc', 'http'] as const) { - for (const view of ['history', 'live', 'null', 'empty'] as const) { - const serverScope = yield* Scope.make() - const browserScope = yield* Scope.make() - const server = new QueryClient() - const browser = new QueryClient() - let serverReads = 0 - let browserReads = 0 - const makeOptions = Effect.fnUntraced(function* ( - profile: Profile, - scope: Scope.Scope, - onRead: () => void, - ) { - const mode = - profile === serverProfile && view === 'null' - ? 'null' - : view === 'history' - ? 'history' - : 'latest' - const codecId = view === 'history' ? 'history' : 'live' - if (transport === 'rpc') { - const client = yield* RpcTest.makeClient(viewsGroup, { flatten: true }).pipe( - Effect.provide( - viewsGroup.toLayer({ - page: ({ cursor }) => - Effect.succeed({ rows: [profile], next: cursor === 0 ? 1 : null }), - watch: () => - Stream.suspend(() => { - onRead() - return finiteValues(profile, mode) - }), - empty: () => Effect.sync(onRead), - }), - ), - Effect.provideService(Scope.Scope, scope), - ) - const utils = createRpcQueryUtils(viewsGroup, { - client, - keyPrefix: ['hydration-views', codecId, 'rpc'], - }) - return view === 'empty' - ? execution(utils.empty.queryOptions({ retry: false, staleTime: Infinity })) - : view === 'history' - ? execution( - utils.watch.streamedOptions({ - input: { mode: view }, - retry: false, - staleTime: Infinity, - }), - ) - : execution( - utils.watch.liveOptions({ - input: { mode: view }, - retry: false, - staleTime: Infinity, - }), - ) - } - const client = yield* HttpApiTest.groups(viewsApi, ['profiles']).pipe( - Effect.provide( - HttpApiBuilder.group(viewsApi, 'profiles', (handlers) => - handlers - .handle('page', ({ query }) => - Effect.succeed({ rows: [profile], next: query.cursor === 0 ? 1 : null }), - ) - .handle('watch', () => - Effect.sync(() => { - onRead() - return finiteValues(profile, mode) - }), - ) - .handle('empty', () => Effect.sync(onRead)), - ), - ), - Effect.provideService(Scope.Scope, scope), - ) - const utils = createHttpApiQueryUtils(viewsApi, { - client, - keyPrefix: ['hydration-views', codecId, 'http'], - }) - return view === 'empty' - ? execution(utils.profiles.empty.queryOptions({ retry: false, staleTime: Infinity })) - : view === 'history' - ? execution( - utils.profiles.watch.streamedOptions({ - input: { query: { mode: view } }, - retry: false, - staleTime: Infinity, - }), - ) - : execution( - utils.profiles.watch.liveOptions({ - input: { query: { mode: view } }, - retry: false, - staleTime: Infinity, - }), - ) - }) - try { - const serverOptions = yield* makeOptions(serverProfile, serverScope, () => { - serverReads += 1 - }) - const browserOptions = yield* makeOptions(browserProfile, browserScope, () => { - browserReads += 1 - }) - const initial = yield* Effect.promise(() => serverOptions.execute(server)) - if (view === 'history') deepStrictEqual(initial, [serverProfile, undefined, null]) - else - equal( - initial instanceof Profile ? initial.summary() : initial, - view === 'live' ? 'Ada: 9007199254740993 credits' : null, - ) - const json = yield* Effect.promise(() => encode(server)) - equal(server.getQueryData(serverOptions.key), initial) - if (view === 'history') - deepStrictEqual(JSON.parse(json).queries[0].state.data.slice(1), [ - { _tag: 'Undefined' }, - null, - ]) - yield* Effect.promise(() => server.cancelQueries()) - server.clear() - yield* Scope.close(serverScope, Exit.void) - yield* Effect.promise(() => decode(browser, json)) - const restored = yield* Effect.promise(() => browserOptions.execute(browser)) - if (view === 'history') { - deepStrictEqual(restored, [serverProfile, undefined, null]) - ok(Array.isArray(restored) && restored[0] instanceof Profile) - equal(restored[0].summary(), 'Ada: 9007199254740993 credits') - } else - equal( - restored instanceof Profile ? restored.summary() : restored, - view === 'live' ? 'Ada: 9007199254740993 credits' : null, - ) - equal(browserReads, 0) - yield* Effect.promise(() => - browser.invalidateQueries({ - exact: true, - queryKey: browserOptions.key, - refetchType: 'none', - }), - ) - const refetched = yield* Effect.promise(() => browserOptions.execute(browser)) - if (view === 'history') { - deepStrictEqual(refetched, [browserProfile, undefined, null]) - ok(Array.isArray(refetched) && refetched[0] instanceof Profile) - equal(refetched[0].summary(), 'Grace: 9007199254740995 credits') - } else - equal( - refetched instanceof Profile ? refetched.summary() : refetched, - view === 'empty' ? null : 'Grace: 9007199254740995 credits', - ) - equal(serverReads, 1) - equal(browserReads, 1) - } finally { - yield* Effect.promise(() => browser.cancelQueries()) - browser.clear() - server.clear() - yield* Scope.close(serverScope, Exit.void) - yield* Scope.close(browserScope, Exit.void) - } - } - } - }), - ).pipe(Effect.provide(HttpServer.layerServices)), -) - -await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - for (const view of ['history', 'live', 'wrapped-history', 'wrapped-live'] as const) { - const serverScope = yield* Scope.make() - const browserScope = yield* Scope.make() - const server = new QueryClient() - const browser = new QueryClient() - let serverReads = 0 - let browserReads = 0 - const makeSnapshot = Effect.fnUntraced(function* ( - scope: Scope.Scope, - browserSide: boolean, - ) { - const stream = () => { - if (browserSide) browserReads += 1 - else serverReads += 1 - return Stream.succeed(browserSide ? undefined : serverProfile).pipe( - Stream.concat(Stream.fromEffect(Effect.never)), - ) - } - const client = yield* HttpApiTest.groups(viewsApi, ['profiles']).pipe( - Effect.provide( - HttpApiBuilder.group(viewsApi, 'profiles', (handlers) => - handlers - .handle('page', () => Effect.succeed({ rows: [], next: null })) - .handle('empty', () => Effect.void) - .handle('watch', () => Effect.sync(stream)), - ), - ), - Effect.provideService(Scope.Scope, scope), - ) - const wrapped = yield* HttpApiTest.groups(envelopeApi, ['profiles']).pipe( - Effect.provide( - HttpApiBuilder.group(envelopeApi, 'profiles', (handlers) => - handlers - .handle('read', () => - Effect.succeed( - HttpApiSchema.withHeaders({ - body: serverProfile, - headers: { 'x-version': 4 }, - }), - ), - ) - .handle('watch', () => - Effect.sync(() => - HttpApiSchema.withHeaders({ - body: stream(), - headers: { 'x-version': browserSide ? 7 : 4 }, - }), - ), - ), - ), - ), - Effect.provideService(Scope.Scope, scope), - ) - const utils = createHttpApiQueryUtils(viewsApi, { - client, - keyPrefix: ['hydration-views', view, 'http'], - }) - const wrappedUtils = createHttpApiQueryUtils(envelopeApi, { - client: wrapped, - keyPrefix: ['hydration-views', view, 'http'], - }) - if (view === 'history') - return snapshotExecution( - utils.profiles.watch.streamedOptions({ - input: { query: { mode: 'open' } }, - retry: false, - }), - ) - if (view === 'live') - return snapshotExecution( - utils.profiles.watch.liveOptions({ - input: { query: { mode: 'open' } }, - retry: false, - }), - ) - if (view === 'wrapped-history') - return snapshotExecution(wrappedUtils.profiles.watch.streamedOptions({ retry: false })) - return snapshotExecution(wrappedUtils.profiles.watch.liveOptions({ retry: false })) - }) - try { - const serverOptions = yield* makeSnapshot(serverScope, false) - const browserOptions = yield* makeSnapshot(browserScope, true) - const initial = yield* Effect.promise(() => serverOptions.capture(server, 'fresh')) - equal(server.isFetching(), 0) - equal(server.getQueryCache().hasListeners(), false) - if (view === 'history') deepStrictEqual(initial, [serverProfile]) - else if (view === 'live') - equal( - initial instanceof Profile ? initial.summary() : initial, - 'Ada: 9007199254740993 credits', - ) - else - deepStrictEqual( - initial, - view === 'wrapped-history' - ? [HttpApiSchema.withHeaders({ body: serverProfile, headers: { 'x-version': 4 } })] - : HttpApiSchema.withHeaders({ body: serverProfile, headers: { 'x-version': 4 } }), - ) - const json = yield* Effect.promise(() => encode(server)) - equal(server.getQueryData(serverOptions.key), initial) - server.clear() - yield* Scope.close(serverScope, Exit.void) - yield* Effect.promise(() => decode(browser, json)) - const restored = yield* Effect.promise(() => browserOptions.capture(browser, 'cached')) - deepStrictEqual(restored, initial) - const restoredProfile = - view === 'history' - ? browser.getQueryData<(typeof HistoryElement.Type)[]>(browserOptions.key)?.[0] - : view === 'live' - ? browser.getQueryData(browserOptions.key) - : view === 'wrapped-history' - ? browser.getQueryData<(typeof WrappedElement.Type)[]>(browserOptions.key)?.[0] - ?.body - : browser.getQueryData(browserOptions.key)?.body - ok(restoredProfile instanceof Profile) - equal(restoredProfile.summary(), 'Ada: 9007199254740993 credits') - equal(browserReads, 0) - const refetched = yield* Effect.promise(() => browserOptions.capture(browser, 'fresh')) - if (view === 'history') deepStrictEqual(refetched, [undefined]) - else if (view === 'live') equal(refetched, null) - else - deepStrictEqual( - refetched, - view === 'wrapped-history' - ? [HttpApiSchema.withHeaders({ body: undefined, headers: { 'x-version': 7 } })] - : HttpApiSchema.withHeaders({ body: undefined, headers: { 'x-version': 7 } }), - ) - const nextJson = yield* Effect.promise(() => encode(browser)) - const recreated = new QueryClient() - try { - yield* Effect.promise(() => decode(recreated, nextJson)) - deepStrictEqual(recreated.getQueryData(browserOptions.key), refetched) - } finally { - recreated.clear() - } - equal(browser.isFetching(), 0) - equal(browser.getQueryCache().hasListeners(), false) - equal(serverReads, 1) - equal(browserReads, 1) - } finally { - yield* Effect.promise(() => browser.cancelQueries()) - server.clear() - browser.clear() - yield* Scope.close(serverScope, Exit.void) - yield* Scope.close(browserScope, Exit.void) - } - } - }), - ).pipe(Effect.provide(HttpServer.layerServices)), -) - -await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - for (const view of ['buffered', 'metadata'] as const) { - const serverScope = yield* Scope.make() - const browserScope = yield* Scope.make() - const server = new QueryClient() - const browser = new QueryClient() - let serverReads = 0 - let browserReads = 0 - let failed = false - const archived = new ArchivedProfile({ - archived: true, - name: 'Grace', - credits: 9007199254740995n, - }) - const makeRead = Effect.fnUntraced(function* (scope: Scope.Scope, browserSide: boolean) { - const client = yield* HttpApiTest.groups(envelopeApi, ['profiles']).pipe( - Effect.provide( - HttpApiBuilder.group(envelopeApi, 'profiles', (handlers) => - handlers - .handle('read', () => - Effect.suspend(() => { - if (browserSide) browserReads += 1 - else serverReads += 1 - return failed - ? Effect.fail('private upstream detail') - : Effect.succeed( - browserSide - ? HttpApiSchema.withHeaders({ - body: archived, - headers: { 'x-version': 7 }, - }) - : HttpApiSchema.withHeaders({ - body: serverProfile, - headers: { 'x-version': 4 }, - }), - ) - }), - ) - .handle('watch', () => - Effect.succeed( - HttpApiSchema.withHeaders({ - body: Stream.empty, - headers: { 'x-version': 4 }, - }), - ), - ), - ), - ), - Effect.provideService(Scope.Scope, scope), - ) - const utils = createHttpApiQueryUtils(envelopeApi, { - client, - keyPrefix: ['hydration-views', view, 'http'], - }) - return view === 'metadata' - ? execution(utils.profiles.read.metadataOptions({ retry: false, staleTime: Infinity })) - : execution(utils.profiles.read.queryOptions({ retry: false, staleTime: Infinity })) - }) - try { - const serverOptions = yield* makeRead(serverScope, false) - const browserOptions = yield* makeRead(browserScope, true) - const initial = yield* Effect.promise(() => serverOptions.execute(server)) - const json = yield* Effect.promise(() => encode(server)) - equal(server.getQueryData(serverOptions.key), initial) - const wire = JSON.parse(json).queries[0].state.data - equal((view === 'metadata' ? wire.data : wire).body.credits, '9007199254740993') - equal((view === 'metadata' ? wire.data : wire).headers['x-version'], '4') - if (view === 'metadata') { - equal(wire.status, 200) - equal(wire.headers['x-version'], '4') - equal(Object.isFrozen(initial), true) - } - server.clear() - failed = true - yield* Effect.promise(() => rejects(serverOptions.execute(server))) - const failedJson = yield* Effect.promise(() => encode(server)) - equal(JSON.parse(failedJson).queries.length, 0) - equal(failedJson.includes('private upstream detail'), false) - failed = false - server.clear() - yield* Scope.close(serverScope, Exit.void) - yield* Effect.promise(() => decode(browser, json)) - yield* Effect.promise(() => browserOptions.execute(browser)) - const restoredMetadata = - view === 'metadata' - ? browser.getQueryData(browserOptions.key) - : undefined - const restored = - view === 'metadata' - ? restoredMetadata?.data - : browser.getQueryData(browserOptions.key) - ok(restored?.body instanceof Profile) - equal(restored.body.summary(), 'Ada: 9007199254740993 credits') - deepStrictEqual(restored.body.avatar, new Uint8Array([0, 1, 255])) - equal(restored.headers['x-version'], 4) - if (view === 'metadata') { - equal(restoredMetadata?.status, 200) - equal(restoredMetadata?.headers['x-version'], '4') - equal(Object.isFrozen(restoredMetadata), false) - equal(Object.isFrozen(restoredMetadata?.headers), false) - } - equal(browserReads, 0) - yield* Effect.promise(() => - browser.invalidateQueries({ - exact: true, - queryKey: browserOptions.key, - refetchType: 'none', - }), - ) - yield* Effect.promise(() => browserOptions.execute(browser)) - const refetchedMetadata = - view === 'metadata' - ? browser.getQueryData(browserOptions.key) - : undefined - const refetched = - view === 'metadata' - ? refetchedMetadata?.data - : browser.getQueryData(browserOptions.key) - ok(refetched?.body instanceof ArchivedProfile) - equal(refetched.body.summary(), 'Grace: 9007199254740995 archived credits') - equal(refetched.headers['x-version'], 7) - if (view === 'metadata') { - equal(refetchedMetadata?.status, 203) - equal(refetchedMetadata?.headers['x-version'], '7') - equal(Object.isFrozen(refetchedMetadata), true) - equal(Object.isFrozen(refetchedMetadata?.headers), true) - } - equal(serverReads, 2) - equal(browserReads, 1) - browser.clear() - yield* Effect.promise(() => decode(browser, failedJson)) - equal(browser.getQueryCache().getAll().length, 0) - yield* Effect.promise(() => browserOptions.execute(browser)) - equal(browserReads, 2) - } finally { - yield* Effect.promise(() => browser.cancelQueries()) - server.clear() - browser.clear() - yield* Scope.close(serverScope, Exit.void) - yield* Scope.close(browserScope, Exit.void) - } - } - }), - ).pipe(Effect.provide(HttpServer.layerServices)), -) - -await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - for (const transport of ['rpc', 'http'] as const) { - const serverScope = yield* Scope.make() - const browserScope = yield* Scope.make() - const server = new QueryClient() - const browser = new QueryClient() - let serverReads = 0 - let browserReads = 0 - const makePages = Effect.fnUntraced(function* ( - profile: Profile, - scope: Scope.Scope, - onRead: () => void, - ) { - const read = (cursor: number) => - Effect.sync(() => { - onRead() - return { rows: [profile], next: cursor === 0 ? 1 : null } - }) - const policy = { - initialPageParam: new Cursor({ offset: 0n }), - getNextPageParam: (page: typeof ProfilePage.Type) => - page.next === null ? undefined : new Cursor({ offset: BigInt(page.next) }), - retry: false, - staleTime: Infinity, - } - if (transport === 'rpc') { - const client = yield* RpcTest.makeClient(viewsGroup, { flatten: true }).pipe( - Effect.provide( - viewsGroup.toLayer({ - page: ({ cursor }) => read(cursor), - watch: () => Stream.empty, - empty: () => Effect.void, - }), - ), - Effect.provideService(Scope.Scope, scope), - ) - const utils = createRpcQueryUtils(viewsGroup, { - client, - keyPrefix: ['hydration-views', 'infinite', 'rpc'], - }) - const options = utils.page.infiniteOptions({ - ...policy, - input: (cursor) => ({ cursor: Number(cursor.offset) }), - }) - return { - key: options.queryKey, - execute: (cache: QueryClient): Promise => - cache.infiniteQuery({ ...options, pages: 2 }), - } - } - const client = yield* HttpApiTest.groups(viewsApi, ['profiles']).pipe( - Effect.provide( - HttpApiBuilder.group(viewsApi, 'profiles', (handlers) => - handlers - .handle('page', ({ query }) => read(query.cursor)) - .handle('watch', () => Effect.succeed(Stream.empty)) - .handle('empty', () => Effect.void), - ), - ), - Effect.provideService(Scope.Scope, scope), - ) - const utils = createHttpApiQueryUtils(viewsApi, { - client, - keyPrefix: ['hydration-views', 'infinite', 'http'], - }) - const options = utils.profiles.page.infiniteOptions({ - ...policy, - input: (cursor) => ({ query: { cursor: Number(cursor.offset) } }), - }) - return { - key: options.queryKey, - execute: (cache: QueryClient): Promise => - cache.infiniteQuery({ ...options, pages: 2 }), - } - }) - try { - const serverOptions = yield* makePages(serverProfile, serverScope, () => { - serverReads += 1 - }) - const browserOptions = yield* makePages(browserProfile, browserScope, () => { - browserReads += 1 - }) - yield* Effect.promise(() => serverOptions.execute(server)) - const initial = server.getQueryData(serverOptions.key) - ok(initial) - equal(initial.pages.length, 2) - equal(initial.pages[0]?.rows[0]?.summary(), 'Ada: 9007199254740993 credits') - equal(initial.pages[1]?.rows[0]?.summary(), 'Ada: 9007199254740993 credits') - equal(initial.pages[0]?.next, 1) - equal(initial.pages[1]?.next, null) - deepStrictEqual( - initial.pageParams.map((cursor) => cursor.offset), - [0n, 1n], - ) - deepStrictEqual( - initial.pageParams.map((cursor) => cursor.isStart()), - [true, false], - ) - const json = yield* Effect.promise(() => encode(server)) - deepStrictEqual(JSON.parse(json).queries[0].state.data.pageParams, [ - { offset: '0' }, - { offset: '1' }, - ]) - equal(server.getQueryData(serverOptions.key), initial) - server.clear() - yield* Scope.close(serverScope, Exit.void) - yield* Effect.promise(() => decode(browser, json)) - yield* Effect.promise(() => browserOptions.execute(browser)) - const restored = browser.getQueryData(browserOptions.key) - ok(restored) - ok(restored.pages[0]?.rows[0] instanceof Profile) - ok(restored.pages[1]?.rows[0] instanceof Profile) - equal(restored.pages[1]?.rows[0]?.summary(), 'Ada: 9007199254740993 credits') - deepStrictEqual( - restored.pageParams.map((cursor) => cursor.offset), - [0n, 1n], - ) - deepStrictEqual( - restored.pageParams.map((cursor) => cursor.isStart()), - [true, false], - ) - equal(browserReads, 0) - yield* Effect.promise(() => - browser.invalidateQueries({ - exact: true, - queryKey: browserOptions.key, - refetchType: 'none', - }), - ) - yield* Effect.promise(() => browserOptions.execute(browser)) - const refetched = browser.getQueryData(browserOptions.key) - ok(refetched) - equal(refetched.pages[0]?.rows[0]?.summary(), 'Grace: 9007199254740995 credits') - equal(refetched.pages[1]?.rows[0]?.summary(), 'Grace: 9007199254740995 credits') - deepStrictEqual( - refetched.pageParams.map((cursor) => cursor.offset), - [0n, 1n], - ) - deepStrictEqual( - refetched.pageParams.map((cursor) => cursor.isStart()), - [true, false], - ) - equal(serverReads, 2) - equal(browserReads, 2) - } finally { - yield* Effect.promise(() => browser.cancelQueries()) - server.clear() - browser.clear() - yield* Scope.close(serverScope, Exit.void) - yield* Scope.close(browserScope, Exit.void) - } - } - }), - ).pipe(Effect.provide(HttpServer.layerServices)), -) - -await Effect.runPromise( - Effect.gen(function* () { - const server = new QueryClient() - const browser = new QueryClient() - const untouched = new QueryClient() - const historyKey = ['hydration-views', 'history', 'application'] as const - const infiniteKey = ['hydration-views', 'infinite', 'application'] as const - const metadataKey = ['hydration-views', 'metadata', 'application'] as const - const history = [serverProfile, undefined, null] - server.setQueryData(historyKey, history) - server.setQueryData(infiniteKey, { - pages: [{ rows: [serverProfile], next: null }], - pageParams: [new Cursor({ offset: 0n })], - }) - server.setQueryData(metadataKey, { - data: HttpApiSchema.withHeaders({ body: serverProfile, headers: { 'x-version': 4 } }), - status: 200, - headers: { 'x-version': '4' }, - }) - const encodingStarted = yield* Deferred.make() - const encodingAllowed = yield* Deferred.make() - const decodingStarted = yield* Deferred.make() - const decodingAllowed = yield* Deferred.make() - let published: string | undefined - try { - const encoding = Effect.runPromise( - prepareViewSnapshot(server).pipe( - Effect.provideService(SnapshotEncoding, { - beforeEncode: Deferred.succeed(encodingStarted, undefined).pipe( - Effect.andThen(Deferred.await(encodingAllowed)), - ), - }), - ), - ).then((json) => { - published = json - return json - }) - yield* Deferred.await(encodingStarted) - equal(published, undefined) - equal(server.getQueryData(historyKey), history) - yield* Deferred.succeed(encodingAllowed, undefined) - const json = yield* Effect.promise(() => encoding) - equal(JSON.parse(json).queries.length, 3) - equal(server.getQueryData(historyKey), history) - const decoding = Effect.runPromise( - prepareViewHydration(browser, json).pipe( - Effect.provideService(SnapshotDecoding, { - beforeDecode: Deferred.succeed(decodingStarted, undefined).pipe( - Effect.andThen(Deferred.await(decodingAllowed)), - ), - }), - ), - ) - yield* Deferred.await(decodingStarted) - equal(browser.getQueryCache().getAll().length, 0) - yield* Deferred.succeed(decodingAllowed, undefined) - yield* Effect.promise(() => decoding) - deepStrictEqual(browser.getQueryData(historyKey), [serverProfile, undefined, null]) - equal( - browser.getQueryData(infiniteKey)?.pageParams[0]?.isStart(), - true, - ) - const metadata = browser.getQueryData(metadataKey) - ok(metadata?.data.body instanceof Profile) - equal(metadata.data.body.summary(), 'Ada: 9007199254740993 credits') - deepStrictEqual( - metadata.data, - HttpApiSchema.withHeaders({ body: serverProfile, headers: { 'x-version': 4 } }), - ) - const invalid = JSON.parse(json) - invalid.queries[2].state.data.data.body.credits = 'not a bigint' - yield* Effect.promise(() => rejects(decode(untouched, JSON.stringify(invalid)))) - equal(untouched.getQueryCache().getAll().length, 0) - const unknown = JSON.parse(json) - unknown.queries[2].queryKey[1] = 'unsupported-codec' - yield* Effect.promise(() => rejects(decode(untouched, JSON.stringify(unknown)))) - equal(untouched.getQueryCache().getAll().length, 0) - } finally { - server.clear() - browser.clear() - untouched.clear() - } - }), -) diff --git a/tests/packed-consumer/openapi-api.ts b/tests/packed-consumer/openapi-api.ts deleted file mode 100644 index fb34975..0000000 --- a/tests/packed-consumer/openapi-api.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { Schema } from 'effect' -import { Multipart } from 'effect/http' -import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from 'effect/http-api' - -export class Report extends Schema.Class('external-http/Report')({ - total: Schema.NumberFromString, - labels: Schema.Array(Schema.String), - title: Schema.String, -}) { - summary() { - return `${this.title}: ${this.total}` - } -} - -const Missing = Schema.TaggedStruct('Missing', { id: Schema.Number }).pipe( - HttpApiSchema.status(404), -) -const Conflict = Schema.TaggedStruct('Conflict', { expected: Schema.String }).pipe( - HttpApiSchema.status(409), -) -const Expired = Schema.TaggedStruct('Expired', { reason: Schema.String }) - -export const api = HttpApi.make('external-http').add( - HttpApiGroup.make('reports').add( - HttpApiEndpoint.post('read', '/reports/:id', { - params: { id: Schema.NumberFromString }, - query: { limit: Schema.NumberFromString, labels: Schema.Array(Schema.String) }, - headers: { 'x-factor': Schema.NumberFromString }, - payload: Schema.Struct({ amount: Schema.NumberFromString, title: Schema.String }), - success: HttpApiSchema.WithHeaders(Report, { - 'x-revision': Schema.NumberFromString, - etag: Schema.String, - }).pipe(HttpApiSchema.status(203)), - error: Missing, - }), - HttpApiEndpoint.patch('update', '/reports/:id', { - params: { id: Schema.NumberFromString }, - headers: { 'if-match': Schema.String }, - payload: Schema.Struct({ title: Schema.String }), - success: Schema.String, - error: Conflict, - }), - HttpApiEndpoint.get('list', '/reports', { - success: Schema.Array(Schema.Struct({ id: Schema.Number, title: Schema.String })), - }), - HttpApiEndpoint.post('upload', '/reports/:id/files', { - params: { id: Schema.NumberFromString }, - query: { revision: Schema.NumberFromString }, - headers: { 'x-kind': Schema.String }, - payload: Schema.Struct({ title: Schema.String, file: Multipart.SingleFileSchema }).pipe( - HttpApiSchema.asMultipart(), - ), - success: Schema.Struct({ - id: Schema.Number, - revision: Schema.Number, - kind: Schema.String, - title: Schema.String, - fileName: Schema.String, - contents: Schema.String, - }), - }), - HttpApiEndpoint.get('watch', '/reports/events/:channel', { - params: { channel: Schema.String }, - success: HttpApiSchema.StreamSse({ data: Schema.NumberFromString, error: Expired }), - }), - HttpApiEndpoint.get('records', '/reports/records', { - success: HttpApiSchema.StreamSse({ - events: Schema.Struct({ - id: Schema.String, - event: Schema.Literal('changed'), - data: Schema.String, - }), - }), - }), - ), -) diff --git a/tests/packed-consumer/openapi-contract.ts b/tests/packed-consumer/openapi-contract.ts deleted file mode 100644 index d75e502..0000000 --- a/tests/packed-consumer/openapi-contract.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { QueryClient } from '@tanstack/query-core' -import { createHttpApiQueryUtils } from 'effect-api-query' -import type { HttpApiClient } from 'effect/http-api' -import createClient from 'openapi-fetch' - -import { events, report, revision, uploaded } from './docs-openapi.ts' -import { api, Report } from './openapi-api.ts' -import type { operations, paths } from './openapi-generated.ts' - -type Equal = - (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false -declare function assertType(): void -assertType['total'], string>>() -assertType>() -assertType['fileName'], string>>() -assertType, ReadableStream>>>() - -declare const ready: HttpApiClient.ForApi -const http = createHttpApiQueryUtils(api, { client: ready, keyPrefix: ['external'] }) -const external = createClient({ baseUrl: 'https://external.test' }) -const encoded = { - params: { - path: { id: '5' }, - query: { limit: '2', labels: ['a b', 'c+d'] }, - header: { 'x-factor': '3' }, - }, - body: { amount: '7', title: 'Quarter 1' }, -} -const response = external.POST('/reports/{id}', encoded) -type ExternalData = NonNullable['data']> -assertType>() -assertType>() -declare const dto: ExternalData -// @ts-expect-error -dto.summary() -// @ts-expect-error -external.GET('/reports/{id}', encoded) -// @ts-expect-error -external.POST('/reports/5', encoded) -// @ts-expect-error -external.POST('/reports/{id}', { ...encoded, body: { amount: 7, title: 'Quarter 1' } }) -// @ts-expect-error -external.POST('/reports/{id}', { body: encoded.body }) -const missing = external.POST('/reports/{id}', { - ...encoded, - params: { ...encoded.params, path: { id: '-1' } }, -}) -type DeclaredFailure = NonNullable['error']> -assertType>() -assertType< - Equal< - operations['reports.update']['responses'][409]['content']['application/json']['_tag'], - 'Conflict' - > ->() - -const native = new QueryClient().query( - http.reports.read.queryOptions({ - input: { - params: { id: 5 }, - query: { limit: 2, labels: ['a b', 'c+d'] }, - headers: { 'x-factor': 3 }, - payload: { amount: 7, title: 'Quarter 1' }, - }, - }), -) -assertType['body'], Report>>() -assertType['headers']['x-revision'], number>>() -// @ts-expect-error -createHttpApiQueryUtils(api, { client: external, keyPrefix: ['external'] }) -// @ts-expect-error -http.reports.read.queryOptions({ input: encoded }) - -type UploadBody = operations['reports.upload']['requestBody']['content']['multipart/form-data'] -assertType>() -external.POST('/reports/{id}/files', { - params: { path: { id: '5' }, query: { revision: '3' }, header: { 'x-kind': 'document' } }, - body: { title: 'Notes', file: new File(['contents'], 'notes.txt') }, - bodySerializer: (body) => { - const data = new FormData() - data.set('title', body.title) - data.set('file', body.file) - return data - }, -}) -const raw = external.GET('/reports/events/{channel}', { - params: { path: { channel: 'values' } }, - parseAs: 'stream', -}) -assertType< - Equal['data']>, ReadableStream>> ->() -declare const bytes: NonNullable['data']> -// @ts-expect-error -bytes.summary() diff --git a/tests/packed-consumer/openapi-generate.mjs b/tests/packed-consumer/openapi-generate.mjs deleted file mode 100644 index 85bc322..0000000 --- a/tests/packed-consumer/openapi-generate.mjs +++ /dev/null @@ -1,29 +0,0 @@ -import { NodeRuntime, NodeServices } from '@effect/platform-node' -import { Effect, FileSystem, Path } from 'effect' -import { OpenApi } from 'effect/http-api' -import openapiTS, { astToString } from 'openapi-typescript' -import ts from 'typescript' - -import { api } from './openapi-api.ts' - -const program = Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem - const path = yield* Path.Path - const directory = yield* path.fromFileUrl(new URL('.', import.meta.url)) - const document = OpenApi.fromApi(api) - const source = yield* Effect.promise(() => - openapiTS(JSON.stringify(document), { - transform: (schema) => - schema.format === 'binary' ? ts.factory.createTypeReferenceNode('Blob') : undefined, - }), - ) - yield* fs.writeFileString( - path.join(directory, 'openapi.json'), - `${JSON.stringify(document, null, 2)}\n`, - ) - yield* fs.writeFileString( - path.join(directory, 'openapi-generated.ts'), - astToString(source, { formatOptions: { removeComments: true } }), - ) -}) -NodeRuntime.runMain(program.pipe(Effect.provide(NodeServices.layer))) diff --git a/tests/packed-consumer/openapi-runtime.mts b/tests/packed-consumer/openapi-runtime.mts deleted file mode 100644 index ed07735..0000000 --- a/tests/packed-consumer/openapi-runtime.mts +++ /dev/null @@ -1,307 +0,0 @@ -import { NodeFileSystem, NodePath } from '@effect/platform-node' -import { MutationObserver, QueryClient } from '@tanstack/query-core' -import { Cause, Effect, FileSystem, Layer, Result, Stream } from 'effect' -import { createHttpApiQueryUtils, isEffectHttpApiQueryError } from 'effect-api-query' -import { - Etag, - HttpClient, - HttpClientRequest, - HttpClientResponse, - HttpPlatform, - HttpRouter, -} from 'effect/http' -import { HttpApiBuilder, HttpApiClient, HttpApiSchema, OpenApi } from 'effect/http-api' -import { deepStrictEqual, equal, match, ok, rejects } from 'node:assert/strict' -import createClient from 'openapi-fetch' - -import { api, Report } from './openapi-api.ts' -import type { paths } from './openapi-generated.ts' - -const handlers = HttpApiBuilder.group(api, 'reports', (group) => - group - .handle('read', ({ params, query, headers, payload }) => - params.id < 0 - ? Effect.fail({ _tag: 'Missing' as const, id: params.id }) - : Effect.succeed( - HttpApiSchema.withHeaders({ - body: new Report({ - total: params.id + query.limit + headers['x-factor'] + payload.amount, - labels: [...query.labels], - title: payload.title, - }), - headers: { 'x-revision': 4, etag: '"revision-4"' }, - }), - ), - ) - .handle('update', ({ headers, payload }) => - headers['if-match'] === '"revision-4"' - ? Effect.succeed(payload.title) - : Effect.fail({ _tag: 'Conflict' as const, expected: '"revision-4"' }), - ) - .handle('list', () => Effect.succeed([{ id: 1, title: 'First' }])) - .handle( - 'upload', - Effect.fnUntraced(function* ({ params, query, headers, payload }) { - const fs = yield* FileSystem.FileSystem - const contents = yield* fs.readFileString(payload.file.path).pipe(Effect.orDie) - return { - id: params.id, - revision: query.revision, - kind: headers['x-kind'], - title: payload.title, - fileName: payload.file.name, - contents, - } - }), - ) - .handle('watch', ({ params }) => - Effect.succeed( - params.channel === 'failure' - ? Stream.concat( - Stream.succeed(1), - Stream.fail({ _tag: 'Expired' as const, reason: 'resume' }), - ) - : Stream.make(1, 2), - ), - ) - .handle('records', () => - Effect.succeed(Stream.make({ id: 'v1', event: 'changed' as const, data: 'first' })), - ), -) -const platform = Layer.mergeAll( - NodeFileSystem.layer, - NodePath.layer, - Etag.layerWeak, - HttpPlatform.layer.pipe(Layer.provide(NodeFileSystem.layer)), -) -const web = HttpRouter.toWebHandler( - HttpApiBuilder.layer(api).pipe(Layer.provide(handlers), Layer.provideMerge(platform)), - { disableLogger: true }, -) -const external = createClient({ baseUrl: 'https://external.test', fetch: web.handler }) -const sent: Request[] = [] -external.use({ - onRequest: async ({ request }) => { - const body = await request.clone().arrayBuffer() - sent.push( - new Request(request.url, { - method: request.method, - headers: request.headers, - ...(body.byteLength === 0 ? {} : { body }), - }), - ) - }, -}) -const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) -try { - const options = { - params: { - path: { id: '5' }, - query: { limit: '2', labels: ['a b', 'c+d'] }, - header: { 'x-factor': '3' }, - }, - body: { amount: '7', title: 'Quarter 1' }, - } - const result = await external.POST('/reports/{id}', options) - equal(result.error, undefined) - deepStrictEqual(result.data, { total: '17', labels: ['a b', 'c+d'], title: 'Quarter 1' }) - equal(result.data instanceof Report, false) - equal(result.response.status, 203) - equal(result.response.headers.get('x-revision'), '4') - equal(result.response.headers.get('etag'), '"revision-4"') - equal(result.response.bodyUsed, true) - const request = sent[0] - ok(request !== undefined) - equal(request.method, 'POST') - const url = new URL(request.url) - equal(url.pathname, '/reports/5') - equal(url.searchParams.get('limit'), '2') - deepStrictEqual(url.searchParams.getAll('labels'), ['a b', 'c+d']) - equal(request.headers.get('x-factor'), '3') - equal(request.headers.get('content-type'), 'application/json') - deepStrictEqual(await request.json(), { amount: '7', title: 'Quarter 1' }) - const singleton = await external.POST('/reports/{id}', { - ...options, - params: { ...options.params, query: { limit: '2', labels: ['only'] } }, - }) - equal(singleton.response.status, 203) - deepStrictEqual(singleton.data, { total: '17', labels: ['only'], title: 'Quarter 1' }) - - const decodedWire = await web.handler( - new Request('https://external.test/reports/5?limit=2&labels=one&labels=two', { - method: 'POST', - headers: { 'content-type': 'application/json', 'x-factor': '3' }, - body: JSON.stringify({ amount: 7, title: 'Quarter 1' }), - }), - ) - equal(decodedWire.status, 400) - const missing = await external.POST('/reports/{id}', { - ...options, - params: { ...options.params, path: { id: '-1' } }, - }) - equal(missing.data, undefined) - equal(missing.response.status, 404) - deepStrictEqual(missing.error, { _tag: 'Missing', id: -1 }) - const conflict = await external.PATCH('/reports/{id}', { - params: { path: { id: '5' }, header: { 'if-match': 'stale' } }, - body: { title: 'Changed' }, - }) - equal(conflict.response.status, 409) - deepStrictEqual(conflict.error, { _tag: 'Conflict', expected: '"revision-4"' }) - const updated = await external.PATCH('/reports/{id}', { - params: { path: { id: '5' }, header: { 'if-match': '"revision-4"' } }, - body: { title: 'Changed' }, - }) - equal(updated.data, 'Changed') - const patchRequest = sent.find((item) => item.method === 'PATCH') - ok(patchRequest !== undefined) - equal(new URL(patchRequest.url).pathname, '/reports/5') - equal(patchRequest.headers.get('if-match'), 'stale') - deepStrictEqual(await patchRequest.json(), { title: 'Changed' }) - deepStrictEqual((await external.GET('/reports')).data, [{ id: 1, title: 'First' }]) - - const client = await Effect.runPromise( - HttpApiClient.makeWith(api, { - baseUrl: 'https://external.test', - httpClient: HttpClient.make( - Effect.fnUntraced(function* (request) { - const outgoing = yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie) - const response = yield* Effect.promise(() => web.handler(outgoing)) - return HttpClientResponse.fromWeb(request, response) - }), - ), - }), - ) - const http = createHttpApiQueryUtils(api, { client, keyPrefix: ['external'] }) - const metadata = await queryClient.query( - http.reports.read.metadataOptions({ - input: { - params: { id: 5 }, - query: { limit: 2, labels: ['a b', 'c+d'] }, - headers: { 'x-factor': 3 }, - payload: { amount: 7, title: 'Quarter 1' }, - }, - }), - ) - ok(metadata.data.body instanceof Report) - equal(metadata.data.body.summary(), 'Quarter 1: 17') - equal(metadata.data.headers['x-revision'], 4) - equal(metadata.status, 203) - equal(metadata.headers['x-revision'], '4') - equal(metadata.headers['etag'], '"revision-4"') - equal(Object.isFrozen(metadata), true) - equal(Object.isFrozen(metadata.headers), true) - - const uploadOptions = { - params: { - path: { id: '5' }, - query: { revision: '3' }, - header: { 'x-kind': 'document' }, - }, - body: { - title: 'Release notes', - file: new File(['first upload'], 'notes.txt', { type: 'text/plain' }), - }, - } - equal((await external.POST('/reports/{id}/files', uploadOptions)).response.status, 415) - const upload = await external.POST('/reports/{id}/files', { - ...uploadOptions, - bodySerializer: (body) => { - const data = new FormData() - data.set('title', body.title) - data.set('file', body.file) - return data - }, - }) - const expectedUpload = { - id: 5, - revision: 3, - kind: 'document', - title: 'Release notes', - fileName: 'notes.txt', - contents: 'first upload', - } - equal(upload.error, undefined) - deepStrictEqual(upload.data, expectedUpload) - const uploadRequest = sent.at(-1) - ok(uploadRequest !== undefined) - equal(uploadRequest.method, 'POST') - equal(new URL(uploadRequest.url).pathname, '/reports/5/files') - equal(new URL(uploadRequest.url).searchParams.get('revision'), '3') - equal(uploadRequest.headers.get('x-kind'), 'document') - match(uploadRequest.headers.get('content-type') ?? '', /^multipart\/form-data; boundary=/u) - const form = await uploadRequest.formData() - equal(form.get('title'), 'Release notes') - const file = form.get('file') - ok(file instanceof File) - equal(file.name, 'notes.txt') - equal(await file.text(), 'first upload') - const nativeBody = new FormData() - nativeBody.set('title', 'Release notes') - nativeBody.set('file', uploadOptions.body.file) - deepStrictEqual( - await new MutationObserver(queryClient, http.reports.upload.mutationOptions()).mutate({ - params: { id: 5 }, - query: { revision: 3 }, - headers: { 'x-kind': 'document' }, - payload: nativeBody, - }), - expectedUpload, - ) - - await rejects( - external.GET('/reports/events/{channel}', { params: { path: { channel: 'values' } } }), - SyntaxError, - ) - const streamed = await external.GET('/reports/events/{channel}', { - params: { path: { channel: 'values' } }, - parseAs: 'stream', - }) - ok(streamed.data instanceof ReadableStream) - equal(streamed.response.headers.get('content-type'), 'text/event-stream') - equal(await new Response(streamed.data).text(), 'data: "1"\n\ndata: "2"\n\n') - const records = await external.GET('/reports/records', { parseAs: 'text' }) - equal(records.data, 'id: v1\nevent: changed\ndata: first\n\n') - const streamFailure = await external.GET('/reports/events/{channel}', { - params: { path: { channel: 'failure' } }, - parseAs: 'text', - }) - equal(streamFailure.response.status, 200) - equal(streamFailure.error, undefined) - match(streamFailure.data ?? '', /^data: "1"\n\nevent: effect\/http-api\/stream\/failure\ndata: /u) - match(streamFailure.data ?? '', /"_tag":"Expired","reason":"resume"/u) - deepStrictEqual( - await queryClient.query( - http.reports.watch.streamedOptions({ input: { params: { channel: 'values' } } }), - ), - [1, 2], - ) - equal( - await queryClient.query( - http.reports.watch.liveOptions({ input: { params: { channel: 'values' } } }), - ), - 2, - ) - await rejects( - queryClient.query( - http.reports.watch.streamedOptions({ input: { params: { channel: 'failure' } } }), - ), - (error: unknown) => { - ok(isEffectHttpApiQueryError(error)) - const failure = Cause.findError(error.cause) - ok(Result.isSuccess(failure)) - deepStrictEqual(failure.success, { _tag: 'Expired', reason: 'resume' }) - return true - }, - ) - const document = OpenApi.fromApi(api) - const streaming = - document.paths['/reports/events/{channel}']?.get?.responses['200']?.content?.[ - 'text/event-stream' - ] - equal(streaming?.['x-effect-stream']?.encoding, 'sse') - equal(streaming?.['x-effect-stream']?.failureEvent, 'effect/http-api/stream/failure') -} finally { - queryClient.clear() - await web.dispose() -} diff --git a/tests/packed-consumer/optimistic-contract.ts b/tests/packed-consumer/optimistic-contract.ts deleted file mode 100644 index 88b1631..0000000 --- a/tests/packed-consumer/optimistic-contract.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { QueryClient } from '@tanstack/query-core' -import type { InfiniteData } from '@tanstack/query-core' -import type { Effect } from 'effect' - -import { User } from '../../examples/contracts/src/contracts.ts' -import type { UserPage } from '../../examples/contracts/src/contracts.ts' -import type { makeControlledUserWrites } from '../fixtures/optimistic-users.ts' - -type Equal = - (() => Value extends Left ? 1 : 2) extends () => Value extends Right ? 1 : 2 - ? true - : false -type Assert = Value - -declare const fixture: Effect.Success> -const { rpcQuery, httpQuery, userWrites } = fixture.application -const queryClient = new QueryClient() -const rpcUser = queryClient.getQueryData(rpcQuery.users.get.queryKey({ id: 1 })) -const httpUser = queryClient.getQueryData( - httpQuery.users.get.queryKey({ params: { id: 1 }, query: {} }), -) -const rpcList = queryClient.getQueryData(rpcQuery.users.list.queryKey()) -const rpcPages = queryClient.getQueryData( - rpcQuery.users.page.infiniteKey({ cursor: 0, pageSize: 4 }), -) -const httpPages = queryClient.getQueryData( - httpQuery.users.page.infiniteKey({ query: { cursor: 0, pageSize: 4 } }), -) -true satisfies Assert> -true satisfies Assert> -true satisfies Assert> -true satisfies Assert | undefined>> -true satisfies Assert | undefined>> -const rpcCreate = userWrites.rpcCreate() -const httpCreate = userWrites.httpCreate() -const rpcDelete = userWrites.rpcDelete() -const httpDelete = userWrites.httpDelete() -true satisfies Assert>, User>> -true satisfies Assert>, User>> -true satisfies Assert>, void>> -true satisfies Assert>, void>> -queryClient.setQueryData( - rpcQuery.users.get.queryKey({ id: 1 }), - new User({ id: 1, name: 'Ada', locale: 'en' }), -) -queryClient.setQueryData( - httpQuery.users.get.queryKey({ params: { id: 1 }, query: {} }), - new User({ id: 1, name: 'Ada', locale: 'en' }), -) diff --git a/tests/packed-consumer/optimistic-runtime.mts b/tests/packed-consumer/optimistic-runtime.mts deleted file mode 100644 index d41a38f..0000000 --- a/tests/packed-consumer/optimistic-runtime.mts +++ /dev/null @@ -1,318 +0,0 @@ -import { MutationObserver, QueryClient, QueryObserver } from '@tanstack/query-core' -import { Effect, Exit } from 'effect' -import { deepStrictEqual, equal } from 'node:assert/strict' - -import { User, UserPage } from '../../examples/contracts/src/contracts.ts' -import { makeControlledUserWrites } from '../fixtures/optimistic-users.ts' - -const makeOwner = async () => - Effect.runPromise( - makeControlledUserWrites( - new QueryClient({ - defaultOptions: { queries: { queryKeyHashFn: (key) => `global:${JSON.stringify(key)}` } }, - }), - ), - ) -const seed = (fixture: Awaited>, users: readonly User[]) => { - const { queryClient, rpcQuery, httpQuery } = fixture.application - fixture.seed(users) - queryClient.setQueryDefaults(rpcQuery.users.key(), { - queryKeyHashFn: (key) => `rpc:${JSON.stringify(key)}`, - }) - queryClient.setQueryDefaults(httpQuery.users.key(), { - queryKeyHashFn: (key) => `http:${JSON.stringify(key)}`, - }) - queryClient.setQueryData(rpcQuery.users.list.queryKey(), users) - queryClient.setQueryData(httpQuery.users.list.queryKey(), users) -} -const ada = new User({ id: 1, name: 'Ada', locale: 'en' }) -const alan = new User({ id: 2, name: 'Alan', locale: 'en' }) -const grace = new User({ id: 3, name: 'Grace', locale: 'fr' }) - -{ - const fixture = await makeOwner() - const { queryClient, rpcQuery, httpQuery, userWrites } = fixture.application - seed(fixture, [ada, alan]) - const rpcPages = rpcQuery.users.page.infiniteKey({ cursor: 0, pageSize: 4 }) - const httpPages = httpQuery.users.page.infiniteKey({ query: { cursor: 0, pageSize: 4 } }) - const data = { - pages: [ - new UserPage({ users: [ada], total: 2, nextCursor: 1 }), - new UserPage({ users: [alan], total: 2, nextCursor: null }), - ], - pageParams: [0, 1], - } - queryClient.setQueryData(rpcPages, data) - queryClient.setQueryData(httpPages, data) - try { - const deletion = Effect.runPromiseExit( - Effect.promise(() => - new MutationObserver(queryClient, userWrites.rpcDelete()).mutate({ id: 1 }), - ), - ) - const deleteRequest = await fixture.nextDelete() - deepStrictEqual(queryClient.getQueryData(rpcQuery.users.list.queryKey()), [alan]) - deepStrictEqual(queryClient.getQueryData(httpPages)?.pages[0]?.users, []) - const creation = new MutationObserver(queryClient, userWrites.httpCreate()).mutate({ - payload: { name: 'Grace', locale: 'fr' }, - }) - const createRequest = await fixture.nextCreate() - deepStrictEqual( - queryClient.getQueryData(httpQuery.users.list.queryKey())?.map((user) => user.name), - ['Alan', 'Grace'], - ) - await createRequest.succeed(grace) - await creation - deepStrictEqual( - queryClient.getQueryData(rpcQuery.users.get.queryKey({ id: 3, locale: 'fr' })), - grace, - ) - deepStrictEqual( - queryClient.getQueryData( - httpQuery.users.get.queryKey({ params: { id: 3 }, query: { locale: 'fr' } }), - ), - grace, - ) - await deleteRequest.fail() - equal(Exit.isFailure(await deletion), true) - deepStrictEqual(queryClient.getQueryData(rpcQuery.users.list.queryKey()), [ada, alan, grace]) - deepStrictEqual(queryClient.getQueryData(httpQuery.users.list.queryKey()), [ada, alan, grace]) - deepStrictEqual(queryClient.getQueryData(rpcPages), { - pages: [ - new UserPage({ users: [ada], total: 3, nextCursor: 1 }), - new UserPage({ users: [alan, grace], total: 3, nextCursor: null }), - ], - pageParams: [0, 1], - }) - deepStrictEqual(queryClient.getQueryData(httpPages), queryClient.getQueryData(rpcPages)) - equal(queryClient.getQueryState(rpcQuery.users.list.queryKey())?.isInvalidated, true) - equal(queryClient.getQueryState(httpQuery.users.list.queryKey())?.isInvalidated, true) - equal(queryClient.getQueriesData({ queryKey: rpcQuery.users.list.key() }).length, 1) - equal(queryClient.getQueriesData({ queryKey: httpQuery.users.list.key() }).length, 1) - } finally { - await fixture.application.dispose() - } -} - -for (const earlierFailsFirst of [true, false]) { - const fixture = await makeOwner() - const { queryClient, rpcQuery, httpQuery, userWrites } = fixture.application - seed(fixture, [ada, alan]) - const lists = [rpcQuery.users.list.queryKey(), httpQuery.users.list.queryKey()] - const pages = [ - rpcQuery.users.page.infiniteKey({ cursor: 0, pageSize: 4 }), - httpQuery.users.page.infiniteKey({ query: { cursor: 0, pageSize: 4 } }), - ] - for (const key of pages) { - queryClient.setQueryData(key, { - pages: [new UserPage({ users: [ada, alan], total: 2, nextCursor: null })], - pageParams: [0], - }) - } - try { - const older = Effect.runPromiseExit( - Effect.promise(() => - new MutationObserver(queryClient, userWrites.rpcDelete()).mutate({ id: 1 }), - ), - ) - const first = await fixture.nextDelete() - const newer = Effect.runPromiseExit( - Effect.promise(() => - new MutationObserver(queryClient, userWrites.httpDelete()).mutate({ params: { id: 2 } }), - ), - ) - const second = await fixture.nextDelete() - const creation = new MutationObserver(queryClient, userWrites.rpcCreate()).mutate({ - name: 'Grace', - locale: 'fr', - }) - const created = await fixture.nextCreate() - await created.succeed(grace) - await creation - if (earlierFailsFirst) { - await first.fail() - await older - await second.fail() - await newer - } else { - await second.fail() - await newer - await first.fail() - await older - } - for (const key of lists) { - deepStrictEqual(queryClient.getQueryData(key), [ada, alan, grace]) - } - for (const key of pages) { - deepStrictEqual(queryClient.getQueryData(key), { - pages: [new UserPage({ users: [ada, alan, grace], total: 3, nextCursor: null })], - pageParams: [0], - }) - } - } finally { - await fixture.application.dispose() - } -} - -for (const newerSucceeds of [true, false]) { - const fixture = await makeOwner() - const { queryClient, rpcQuery, userWrites } = fixture.application - seed(fixture, [ada]) - try { - const older = Effect.runPromiseExit( - Effect.promise(() => - new MutationObserver(queryClient, userWrites.rpcDelete()).mutate({ id: 1 }), - ), - ) - const first = await fixture.nextDelete() - const newer = Effect.runPromiseExit( - Effect.promise(() => - new MutationObserver(queryClient, userWrites.httpDelete()).mutate({ params: { id: 1 } }), - ), - ) - const second = await fixture.nextDelete() - if (newerSucceeds) await second.succeed() - else await second.fail() - await newer - await first.fail() - equal(Exit.isFailure(await older), true) - deepStrictEqual( - queryClient.getQueryData(rpcQuery.users.list.queryKey()), - newerSucceeds ? [] : [ada], - ) - } finally { - await fixture.application.dispose() - } -} - -{ - const fixture = await makeOwner() - const { queryClient, rpcQuery, userWrites } = fixture.application - seed(fixture, []) - fixture.holdLists() - const observer = new QueryObserver( - queryClient, - rpcQuery.users.list.queryOptions({ staleTime: Infinity }), - ) - const unsubscribe = observer.subscribe(() => {}) - try { - const older = new MutationObserver(queryClient, userWrites.rpcCreate()).mutate({ name: 'Ada' }) - const first = await fixture.nextCreate() - await first.succeed(ada) - const stale = await fixture.nextList() - const newer = new MutationObserver(queryClient, userWrites.rpcCreate()).mutate({ - name: 'Grace', - locale: 'fr', - }) - const second = await fixture.nextCreate() - await older - await second.succeed(grace) - const current = await fixture.nextList() - await stale.succeed([ada]) - deepStrictEqual(queryClient.getQueryData(rpcQuery.users.list.queryKey()), [ada, grace]) - await current.succeed([ada, grace]) - await newer - deepStrictEqual(queryClient.getQueryData(rpcQuery.users.list.queryKey()), [ada, grace]) - equal(queryClient.getQueriesData({ queryKey: rpcQuery.users.list.key() }).length, 1) - } finally { - unsubscribe() - await fixture.application.dispose() - } -} - -{ - const fixture = await makeOwner() - const { queryClient, rpcQuery, userWrites } = fixture.application - seed(fixture, []) - try { - const older = new MutationObserver(queryClient, userWrites.rpcCreate()).mutate({ name: 'Ada' }) - const first = await fixture.nextCreate() - const newer = new MutationObserver(queryClient, userWrites.rpcCreate()).mutate({ - name: 'Grace', - locale: 'fr', - }) - const second = await fixture.nextCreate() - await second.succeed(grace) - await newer - await first.succeed(ada) - await older - deepStrictEqual(queryClient.getQueryData(rpcQuery.users.list.queryKey()), [ada, grace]) - } finally { - await fixture.application.dispose() - } -} - -{ - const original = await makeOwner() - const replacement = await makeOwner() - const first = original.application - const second = replacement.application - seed(original, []) - seed(replacement, [grace]) - try { - const observer = new MutationObserver(first.queryClient, first.userWrites.rpcCreate()) - const mutation = observer.mutate({ name: 'Ada' }) - const request = await original.nextCreate() - observer.setOptions(second.userWrites.rpcCreate()) - await request.succeed(ada) - await mutation - deepStrictEqual(first.queryClient.getQueryData(first.rpcQuery.users.list.queryKey()), [ada]) - deepStrictEqual(second.queryClient.getQueryData(second.rpcQuery.users.list.queryKey()), [grace]) - equal( - second.queryClient.getQueryState(second.rpcQuery.users.list.queryKey())?.isInvalidated, - false, - ) - const pending = new MutationObserver(first.queryClient, first.userWrites.rpcCreate()).mutate({ - name: 'Alan', - }) - const late = await original.nextCreate() - original.retire() - first.queryClient.clear() - await late.succeed(alan) - await pending - equal(first.queryClient.getQueryCache().getAll().length, 0) - deepStrictEqual(second.queryClient.getQueryData(second.rpcQuery.users.list.queryKey()), [grace]) - } finally { - await original.application.dispose() - await replacement.application.dispose() - } -} - -{ - const fixture = await makeOwner() - const { queryClient, rpcQuery, userWrites } = fixture.application - seed(fixture, []) - const pages = rpcQuery.users.page.infiniteKey({ cursor: 0, pageSize: 4 }) - queryClient.setQueryData(pages, { - pages: [new UserPage({ users: [], total: 0, nextCursor: null })], - pageParams: [0], - }) - try { - const older = Effect.runPromiseExit( - Effect.promise(() => - new MutationObserver(queryClient, userWrites.rpcCreate()).mutate({ name: 'Ada' }), - ), - ) - const first = await fixture.nextCreate() - const newer = new MutationObserver(queryClient, userWrites.rpcCreate()).mutate({ - name: 'Grace', - locale: 'fr', - }) - const second = await fixture.nextCreate() - await second.succeed(grace) - await newer - await first.fail() - equal(Exit.isFailure(await older), true) - deepStrictEqual(queryClient.getQueryData(rpcQuery.users.list.queryKey()), [grace]) - deepStrictEqual(queryClient.getQueryData(pages), { - pages: [new UserPage({ users: [grace], total: 1, nextCursor: null })], - pageParams: [0], - }) - } finally { - await fixture.application.dispose() - } -} - -console.log( - 'Packed copied optimistic user writes, pages, overlap, stale refetch and original ownership verified', -) diff --git a/tests/packed-consumer/owner-cache-contract.ts b/tests/packed-consumer/owner-cache-contract.ts deleted file mode 100644 index c1b207e..0000000 --- a/tests/packed-consumer/owner-cache-contract.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { MutationOptions, QueryClient } from '@tanstack/react-query' -import type { Schema } from 'effect' -import type { EffectRpcQueryError } from 'effect-api-query' -import type { HttpApiClient } from 'effect/http-api' -import type { RpcClient, RpcClientError, RpcGroup } from 'effect/rpc' - -import { DirectoryUser, makeOwnerQueries, ownerApi, ownerGroup } from './docs-owner-cache.ts' -import { ownerKeyPrefix } from './owner-cache.ts' -import type { ApplicationOwnerIdentity } from './owner-cache.ts' - -type Equal = - (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false -type Assert = T - -declare const rpcClient: RpcClient.RpcClient.Flat< - RpcGroup.Rpcs, - RpcClientError.RpcClientError -> -declare const httpClient: HttpApiClient.ForApi -declare const identity: ApplicationOwnerIdentity - -const application = makeOwnerQueries({ rpcClient, httpClient, identity }) -const rpcData = application.queryClient.getQueryData(application.rpc.users.list.queryKey()) -const httpData = application.queryClient.getQueryData(application.http.users.list.queryKey()) -application.createUser satisfies MutationOptions< - typeof DirectoryUser.Type, - EffectRpcQueryError, - { readonly name: string }, - undefined -> -application.queryClient satisfies QueryClient - -type Contract = [ - Assert< - Equal< - ReturnType, - readonly ['vite-react', string, string, number, number] - > - >, - Assert>, - Assert>, - Assert< - Equal< - Schema.Schema.Type, - { readonly id: number; readonly name: string; readonly locale: string } - > - >, -] -declare const contract: Contract -contract satisfies [true, true, true, true] - -if (false) { - ownerKeyPrefix({ - tenantId: 'team', - userId: 'user', - sessionGeneration: 1, - permissionGeneration: 1, - // @ts-expect-error Credentials are not an owner identity field. - bearer: 'credential', - }) - ownerKeyPrefix({ - tenantId: 'team', - userId: 'user', - sessionGeneration: 1, - // @ts-expect-error Permission generations are numeric safe identities. - permissionGeneration: 'token', - }) -} diff --git a/tests/packed-consumer/owner-cache-runtime.mts b/tests/packed-consumer/owner-cache-runtime.mts deleted file mode 100644 index c7cf1ef..0000000 --- a/tests/packed-consumer/owner-cache-runtime.mts +++ /dev/null @@ -1,290 +0,0 @@ -import { MutationObserver, QueryClient } from '@tanstack/react-query' -import { Deferred, Effect, Exit, Scope, Stream } from 'effect' -import { HttpServer } from 'effect/http' -import { HttpApiBuilder, HttpApiTest } from 'effect/http-api' -import { RpcTest } from 'effect/rpc' -import { deepStrictEqual, equal, ok, rejects } from 'node:assert/strict' - -import { makeOwnerQueries, ownerApi, ownerGroup } from './docs-owner-cache.ts' -import { makeOwnerCache, ownerKeyPrefix } from './owner-cache.ts' - -const identity = { - tenantId: 'team-a', - userId: 'user-a', - sessionGeneration: 2, - permissionGeneration: 4, -} -const values = new Map() -const storage = { - getItem: (key: string) => values.get(key) ?? null, - removeItem: (key: string) => { - values.delete(key) - }, - setItem: (key: string, json: string) => { - values.set(key, json) - }, -} -const wait = async (deferred: Deferred.Deferred) => - await Effect.runPromise(Deferred.await(deferred).pipe(Effect.timeout('5 seconds'))) -const waitForCache = async (queryClient: QueryClient, predicate: () => boolean) => { - const reached = Deferred.makeUnsafe() - const check = () => { - if (predicate()) { - Effect.runSync(Deferred.succeed(reached, undefined)) - } - } - const unsubscribe = queryClient.getQueryCache().subscribe(check) - try { - check() - await wait(reached) - } finally { - unsubscribe() - } -} - -await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const oldScope = yield* Scope.make() - const newScope = yield* Scope.make() - const mutationEntered = Deferred.makeUnsafe() - const releaseMutation = Deferred.makeUnsafe() - const callbackEntered = Deferred.makeUnsafe() - const releaseCallback = Deferred.makeUnsafe() - const finalized = Deferred.makeUnsafe() - const oldDirectory = [{ id: 1, name: 'Old private directory', locale: 'en' }] - let newDirectory = [{ id: 10, name: 'New private directory', locale: 'fr' }] - const makeClients = Effect.fnUntraced(function* (scope: Scope.Scope, isOld: boolean) { - const directory = () => (isOld ? oldDirectory : newDirectory) - const create = Effect.fnUntraced(function* ({ name }: { readonly name: string }) { - if (isOld) { - yield* Deferred.succeed(mutationEntered, undefined) - yield* Deferred.await(releaseMutation) - } - const user = { id: isOld ? 2 : 11, name, locale: 'en' } - if (isOld) { - oldDirectory.push(user) - } else { - newDirectory = [...newDirectory, user] - } - return user - }) - const rpcClient = yield* RpcTest.makeClient(ownerGroup, { flatten: true }).pipe( - Effect.provide( - ownerGroup.toLayer({ - 'users.list': () => Effect.sync(directory), - 'users.get': ({ id }) => - Effect.succeed({ id, name: isOld ? 'Old user' : 'New user', locale: 'en' }), - 'users.create': create, - 'users.watch': () => - Stream.make('old first visible').pipe( - Stream.concat(Stream.fromEffect(Effect.never)), - Stream.ensuring(Deferred.succeed(finalized, undefined).pipe(Effect.asVoid)), - ), - }), - ), - Scope.provide(scope), - ) - const httpClient = yield* HttpApiTest.groups(ownerApi, ['users']).pipe( - Effect.provide( - HttpApiBuilder.group(ownerApi, 'users', (handlers) => - handlers.handleAll({ - list: () => Effect.sync(directory), - get: ({ params }) => - Effect.succeed({ - id: params.id, - name: isOld ? 'Old user' : 'New user', - locale: 'en', - }), - create: ({ payload }) => create(payload), - }), - ), - ), - Scope.provide(scope), - ) - return { rpcClient, httpClient } - }) - const oldClients = yield* makeClients(oldScope, true) - const previous = makeOwnerQueries({ ...oldClients, identity, storage }) - let current: ReturnType> | undefined - try { - yield* Effect.promise(() => - previous.queryClient.query(previous.rpc.users.list.queryOptions()), - ) - yield* Effect.promise(() => - previous.queryClient.query(previous.http.users.list.queryOptions()), - ) - previous.owner.persistDirectory() - equal(values.size, 1) - const watchOptions = previous.rpc.users.watch.liveOptions() - const watching = previous.queryClient.query(watchOptions).catch(() => null) - yield* Effect.promise(() => - waitForCache( - previous.queryClient, - () => previous.queryClient.getQueryData(watchOptions.queryKey) === 'old first visible', - ), - ) - const mutation = new MutationObserver(previous.queryClient, { - ...previous.createUser, - onSuccess: async (...args) => { - await Effect.runPromise(Deferred.succeed(callbackEntered, undefined)) - await wait(releaseCallback) - await previous.createUser.onSuccess?.(...args) - }, - }) - const writing = mutation.mutate({ name: 'Completed remote write' }) - yield* Deferred.await(mutationEntered) - previous.owner.persistDirectory() - equal(values.size, 0) - const cacheCleared = waitForCache( - previous.queryClient, - () => previous.queryClient.getQueryCache().getAll().length === 0, - ) - let retired = false - const retirement = (async () => { - await previous.owner.retire() - retired = true - })() - equal(previous.owner.isActive(), false) - yield* Effect.promise(() => - rejects( - previous.owner.runMutation(async () => 1), - /Owner is inactive/u, - ), - ) - yield* Effect.promise(() => cacheCleared) - equal(retired, false) - equal(previous.queryClient.getQueryCache().getAll().length, 0) - yield* Deferred.succeed(releaseMutation, undefined) - yield* Deferred.await(callbackEntered) - yield* Effect.promise(() => retirement) - deepStrictEqual( - oldDirectory.map((user) => user.name), - ['Old private directory', 'Completed remote write'], - ) - yield* Scope.close(oldScope, Exit.void) - yield* Deferred.await(finalized) - yield* Effect.promise(() => watching) - equal(previous.queryClient.isFetching(), 0) - const newIdentity = { ...identity, permissionGeneration: 5 } - const newClients = yield* makeClients(newScope, false) - current = makeOwnerQueries({ ...newClients, identity: newIdentity, storage }) - ok(current.queryClient !== previous.queryClient) - for (const name of [ - 'tenantId', - 'userId', - 'sessionGeneration', - 'permissionGeneration', - ] as const) { - const nextIdentity = { - ...identity, - [name]: typeof identity[name] === 'number' ? 99 : 'another', - } - ok( - JSON.stringify(ownerKeyPrefix(nextIdentity)) !== - JSON.stringify(ownerKeyPrefix(identity)), - ) - } - deepStrictEqual( - yield* Effect.promise(() => - current!.queryClient.query(current!.rpc.users.list.queryOptions()), - ), - [{ id: 10, name: 'New private directory', locale: 'fr' }], - ) - yield* Effect.promise(() => - current!.queryClient.query(current!.http.users.list.queryOptions()), - ) - current.owner.persistDirectory() - const newSnapshot = [...values.entries()] - yield* Deferred.succeed(releaseCallback, undefined) - yield* Effect.promise(() => writing) - equal(previous.queryClient.getQueryCache().getAll().length, 0) - equal( - current.queryClient.getQueryState(current.rpc.users.list.queryKey())?.isInvalidated, - false, - ) - deepStrictEqual([...values.entries()], newSnapshot) - const restored = makeOwnerQueries({ ...newClients, identity: newIdentity, storage }) - deepStrictEqual( - restored.queryClient.getQueryData(restored.rpc.users.list.queryKey()), - newDirectory, - ) - deepStrictEqual( - restored.queryClient.getQueryData(restored.http.users.list.queryKey()), - newDirectory, - ) - newDirectory = [{ id: 10, name: 'Fresh after restore', locale: 'fr' }] - deepStrictEqual( - yield* Effect.promise(() => - restored.queryClient.query(restored.rpc.users.list.queryOptions()), - ), - newDirectory, - ) - deepStrictEqual( - yield* Effect.promise(() => - restored.queryClient.query(restored.http.users.list.queryOptions()), - ), - newDirectory, - ) - yield* Effect.promise(() => restored.owner.retire()) - equal(values.size, 0) - } finally { - yield* Deferred.succeed(releaseMutation, undefined) - yield* Deferred.succeed(releaseCallback, undefined) - yield* Effect.promise(() => previous.owner.retire()) - if (current !== undefined) { - yield* Effect.promise(() => current!.owner.retire()) - } - yield* Scope.close(oldScope, Exit.void) - yield* Scope.close(newScope, Exit.void) - } - }), - ).pipe(Effect.provide(HttpServer.layerServices)), -) - -const directory = [{ id: 1, name: 'Safe DTO', locale: 'en' }] -const queryClient = new QueryClient() -const directoryKeys = { - rpc: [...ownerKeyPrefix(identity), 'rpc', 'users', 'list', 'query'], - http: [...ownerKeyPrefix(identity), 'http', 'users', 'list', 'query'], -} -const owner = makeOwnerCache({ identity, queryClient, directoryKeys, storage }) -const storageKey = `effect-api-query:directory:${JSON.stringify(ownerKeyPrefix(identity))}` -const snapshot = { - identity, - schemaVersion: 1, - keyVersion: 1, - directories: [{ adapter: 'rpc', data: directory }], -} -for (const invalid of [ - { ...snapshot, identity: { ...identity, userId: 'another-user' } }, - { ...snapshot, identity: { ...identity, tenantId: 'another-team' } }, - { ...snapshot, identity: { ...identity, sessionGeneration: 3 } }, - { ...snapshot, identity: { ...identity, permissionGeneration: 5 } }, - { ...snapshot, schemaVersion: 2 }, - { ...snapshot, keyVersion: 2 }, - { - ...snapshot, - directories: [ - { adapter: 'rpc', data: directory }, - { adapter: 'http', data: [{ id: 'invalid' }] }, - ], - }, -]) { - storage.setItem(storageKey, JSON.stringify(invalid)) - equal(owner.restoreDirectory(), false) - equal(queryClient.getQueryCache().getAll().length, 0) - equal(values.size, 0) -} -storage.setItem(storageKey, '{invalid json') -equal(owner.restoreDirectory(), false) -equal(values.size, 0) -storage.setItem(storageKey, JSON.stringify(snapshot)) -equal(owner.restoreDirectory(), true) -deepStrictEqual(queryClient.getQueryData(directoryKeys.rpc), directory) -equal(queryClient.getQueryState(directoryKeys.rpc)?.dataUpdatedAt, 0) -await owner.retire() -owner.persistDirectory() -equal(owner.restoreDirectory(), false) -equal(values.size, 0) -equal(queryClient.getQueryCache().getAll().length, 0) diff --git a/tests/packed-consumer/owner-cache.ts b/tests/packed-consumer/owner-cache.ts deleted file mode 100644 index 9870a31..0000000 --- a/tests/packed-consumer/owner-cache.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { makeOwnerCache, ownerKeyPrefix } from '../../examples/vite-react/src/lib/owner-cache.ts' -export type { - ApplicationOwnerIdentity, - DirectoryStorage, -} from '../../examples/vite-react/src/lib/owner-cache.ts' diff --git a/tests/packed-consumer/queries.ts b/tests/packed-consumer/queries.ts deleted file mode 100644 index e205e57..0000000 --- a/tests/packed-consumer/queries.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Schema } from 'effect' -import { createRpcQueryUtils } from 'effect-api-query' -import { Rpc, RpcGroup, type RpcClient } from 'effect/rpc' - -const Read = Rpc.make('users.read', { - payload: { id: Schema.Int }, - success: Schema.Struct({ id: Schema.Int, name: Schema.String }), - error: Schema.Literal('missing'), -}) -const group = RpcGroup.make(Read) -declare const client: RpcClient.RpcClient.Flat> -export const rpc = createRpcQueryUtils(group, { client, keyPrefix: ['framework-guide'] }) diff --git a/tests/packed-consumer/retry-runtime.mts b/tests/packed-consumer/retry-runtime.mts deleted file mode 100644 index 3bb5fde..0000000 --- a/tests/packed-consumer/retry-runtime.mts +++ /dev/null @@ -1,523 +0,0 @@ -import { QueryClient } from '@tanstack/query-core' -import { - Cause, - Context, - Deferred, - Effect, - Exit, - Predicate, - Result, - Schema, - Scope, - Stream, -} from 'effect' -import { - createHttpApiQueryUtils, - createRpcQueryUtils, - EffectHttpApiQueryKeyError, - EffectRpcQueryKeyError, - isEffectHttpApiQueryError, - isEffectRpcQueryError, -} from 'effect-api-query' -import type { RunPromiseExit } from 'effect-api-query' -import { - HttpClient, - HttpClientError, - HttpClientRequest, - HttpClientResponse, - HttpServer, -} from 'effect/http' -import { - HttpApi, - HttpApiClient, - HttpApiBuilder, - HttpApiEndpoint, - HttpApiGroup, - HttpApiTest, -} from 'effect/http-api' -import { Rpc, RpcClient, RpcClientError, RpcGroup, RpcTest } from 'effect/rpc' -import { deepStrictEqual, equal, ok, rejects, throws } from 'node:assert/strict' - -import { - AuthenticationRequired, - httpReadOptions, - NotFound, - readApi, - readGroup, - RetryLater, - rpcReadOptions, -} from './docs-retry.ts' - -const domain = new NotFound({}) -const authentication = new AuthenticationRequired({}) -const retryLater = new RetryLater({ retryAfterMs: 0 }) -const defect = new Error('read defect') -const terminalCauses: readonly Cause.Cause[] = [ - Cause.fail(domain), - Cause.fail(authentication), - Cause.die(defect), - Cause.interrupt(42), - Cause.combine(Cause.fail(retryLater), Cause.die(defect)), - Cause.combine(Cause.fail(retryLater), Cause.interrupt(42)), - Cause.combine(Cause.fail(retryLater), Cause.fail(authentication)), -] - -await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - let attempts = 0 - let failure = Cause.fail(retryLater) - let successOnAttempt = Infinity - const client = yield* RpcTest.makeClient(readGroup, { flatten: true }).pipe( - Effect.provide( - readGroup.toLayer({ - 'profile.read': () => - Effect.suspend(() => { - attempts += 1 - return attempts === successOnAttempt - ? Effect.succeed('profile') - : Effect.failCause(failure) - }), - }), - ), - ) - const options = rpcReadOptions(client) - const queryClient = new QueryClient() - try { - for (const cause of terminalCauses) { - failure = cause - const direct = yield* Effect.exit(client('profile.read', undefined)) - ok(Exit.isFailure(direct)) - attempts = 0 - queryClient.clear() - yield* Effect.promise(() => - rejects(queryClient.query(options), (error: unknown) => { - ok(isEffectRpcQueryError(error)) - equal(attempts, 1) - equal(Cause.hasDies(error.cause), Cause.hasDies(direct.cause)) - equal(Cause.hasInterrupts(error.cause), Cause.hasInterrupts(direct.cause)) - const actual = Cause.findError(error.cause) - const expected = Cause.findError(direct.cause) - equal(Result.isSuccess(actual), Result.isSuccess(expected)) - if (Result.isSuccess(actual) && Result.isSuccess(expected)) { - equal(actual.success, expected.success) - } - return true - }), - ) - } - - failure = Cause.fail(retryLater) - attempts = 0 - queryClient.clear() - yield* Effect.promise(() => - rejects(queryClient.query(options), (error: unknown) => { - ok(isEffectRpcQueryError(error)) - equal(attempts, 3) - equal(Cause.findError(error.cause)._tag, 'Success') - return true - }), - ) - - attempts = 0 - successOnAttempt = 3 - queryClient.clear() - const delayIndexes: number[] = [] - const result = yield* Effect.promise(() => - queryClient.query({ - ...options, - retryDelay: (attemptIndex, error) => { - delayIndexes.push(attemptIndex) - ok(isEffectRpcQueryError(error)) - ok(typeof options.retryDelay === 'function') - return options.retryDelay(attemptIndex, error) - }, - }), - ) - equal(result, 'profile') - equal(attempts, 3) - deepStrictEqual(delayIndexes, [0, 1]) - - attempts = 0 - successOnAttempt = Infinity - queryClient.clear() - const rpc = createRpcQueryUtils(readGroup, { client, keyPrefix: ['defaults'] }) - yield* Effect.promise(() => rejects(queryClient.query(rpc.profile.read.queryOptions()))) - equal(attempts, 1) - } finally { - queryClient.clear() - } - - const transportError = new RpcClientError.RpcClientError({ - reason: HttpClientError.HttpClientErrorSchema.fromHttpClientError( - new HttpClientError.HttpClientError({ - reason: new HttpClientError.TransportError({ - request: HttpClientRequest.get('https://profiles.test/rpc'), - }), - }), - ), - }) - let sent = 0 - const transport = yield* RpcClient.makeNoSerialization(readGroup, { - flatten: true, - onFromClient: ({ message }) => - message._tag === 'Request' - ? Effect.sync(() => { - sent += 1 - }).pipe(Effect.andThen(Effect.fail(transportError))) - : Effect.void, - }) - const transportQueryClient = new QueryClient() - try { - yield* Effect.promise(() => - rejects( - transportQueryClient.query(rpcReadOptions(transport.client)), - (error: unknown) => { - ok(isEffectRpcQueryError(error)) - const failure = Cause.findError(error.cause) - ok(Result.isSuccess(failure)) - equal(failure.success, transportError) - return true - }, - ), - ) - equal(sent, 3) - } finally { - transportQueryClient.clear() - } - - for (const error of [domain, authentication, retryLater]) { - const expectedTag = error._tag - let httpAttempts = 0 - const httpClient = yield* HttpApiTest.groups(readApi, ['profile']).pipe( - Effect.provide( - HttpApiBuilder.group(readApi, 'profile', (handlers) => - handlers.handle('read', () => - Effect.suspend(() => { - httpAttempts += 1 - return httpAttempts === 3 ? Effect.succeed('profile') : Effect.fail(error) - }), - ), - ), - ), - ) - const queryClient = new QueryClient() - try { - const query = queryClient.query(httpReadOptions(httpClient)) - if (error._tag === 'RetryLater') { - equal(yield* Effect.promise(() => query), 'profile') - equal(httpAttempts, 3) - } else { - yield* Effect.promise(() => - rejects(query, (error: unknown) => { - ok(isEffectHttpApiQueryError(error)) - const failure = Cause.findError(error.cause) - ok(Result.isSuccess(failure)) - ok(Predicate.isTagged(failure.success, expectedTag)) - return true - }), - ) - equal(httpAttempts, 1) - } - } finally { - queryClient.clear() - } - } - }), - ).pipe(Effect.provide(HttpServer.layerServices)), -) - -let transportAttempts = 0 -const httpClient = await Effect.runPromise( - HttpApiClient.makeWith(readApi, { - baseUrl: 'https://profiles.test', - httpClient: HttpClient.make((request) => - Effect.suspend(() => { - transportAttempts += 1 - return transportAttempts === 3 - ? Effect.succeed(HttpClientResponse.fromWeb(request, Response.json('profile'))) - : Effect.fail( - new HttpClientError.HttpClientError({ - reason: new HttpClientError.TransportError({ request }), - }), - ) - }), - ), - }), -) -const queryClient = new QueryClient() -try { - const httpOptions = httpReadOptions(httpClient) - const delays: number[] = [] - equal( - await queryClient.query({ - ...httpOptions, - retryDelay: (attempt, error) => { - ok(isEffectHttpApiQueryError(error)) - ok(typeof httpOptions.retryDelay === 'function') - delays.push(httpOptions.retryDelay(attempt, error)) - return 0 - }, - }), - 'profile', - ) - equal(transportAttempts, 3) - deepStrictEqual(delays, [1_000, 2_000]) - - for (const adapter of ['rpc', 'http'] as const) { - const scope = Scope.makeUnsafe() - const rejection = new Error(`${adapter} runner rejected`) - let runnerAttempts = 0 - const runPromiseExit: RunPromiseExit = async () => { - runnerAttempts += 1 - return await Promise.reject(rejection) - } - try { - let query: Promise - if (adapter === 'rpc') { - const client = await Effect.runPromise( - RpcTest.makeClient(readGroup, { flatten: true }).pipe( - Effect.provide(readGroup.toLayer({ 'profile.read': () => Effect.succeed('profile') })), - Scope.provide(scope), - ), - ) - const rpc = createRpcQueryUtils(readGroup, { - client, - keyPrefix: ['runner'], - runPromiseExit, - }) - const policy = rpcReadOptions(client) - ok(typeof policy.retry === 'function') - ok(typeof policy.retryDelay === 'function') - query = queryClient.query( - rpc.profile.read.queryOptions({ retry: policy.retry, retryDelay: policy.retryDelay }), - ) - } else { - const http = createHttpApiQueryUtils(readApi, { - client: httpClient, - keyPrefix: ['runner'], - runPromiseExit, - }) - const policy = httpReadOptions(httpClient) - ok(typeof policy.retry === 'function') - ok(typeof policy.retryDelay === 'function') - query = queryClient.query( - http.profile.read.queryOptions({ retry: policy.retry, retryDelay: policy.retryDelay }), - ) - } - await rejects(query, (error: unknown) => { - equal(error, rejection) - return true - }) - equal(runnerAttempts, 1) - } finally { - await queryClient.cancelQueries() - queryClient.clear() - await Effect.runPromise(Scope.close(scope, Exit.void)) - } - } - - const preparationFailure = new Error('key encoder rejected') - const payloadApi = HttpApi.make('key-preparation').add( - HttpApiGroup.make('profile').add( - HttpApiEndpoint.get('read', '/profile/:id', { - params: { id: Schema.String }, - success: Schema.String, - }), - ), - ) - const payloadClient = await Effect.runPromise( - HttpApiClient.makeWith(payloadApi, { - baseUrl: 'https://profiles.test', - httpClient: HttpClient.make((request) => - Effect.sync(() => { - transportAttempts += 1 - return HttpClientResponse.fromWeb(request, Response.json('profile')) - }), - ), - }), - ) - const http = createHttpApiQueryUtils(payloadApi, { - client: payloadClient, - keyPrefix: ['preparation'], - keyEncoders: { - profile: { - read: () => { - throw preparationFailure - }, - }, - }, - }) - throws( - () => http.profile.read.queryOptions({ input: { params: { id: 'user' } }, retry: 2 }), - (error: unknown) => { - ok(error instanceof EffectHttpApiQueryKeyError) - equal(error.cause, preparationFailure) - return true - }, - ) - equal(transportAttempts, 3) - equal(queryClient.getQueryCache().getAll().length, 0) - const payloadGroup = RpcGroup.make( - Rpc.make('read', { payload: { id: Schema.String }, success: Schema.String }), - ) - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - let executions = 0 - const client = yield* RpcTest.makeClient(payloadGroup, { flatten: true }).pipe( - Effect.provide( - payloadGroup.toLayer({ read: () => Effect.sync(() => String(++executions)) }), - ), - ) - const rpc = createRpcQueryUtils(payloadGroup, { - client, - keyPrefix: ['preparation'], - keyEncoders: { - read: () => { - throw preparationFailure - }, - }, - }) - throws( - () => rpc.read.queryOptions({ input: { id: 'user' }, retry: 2 }), - (error: unknown) => { - ok(error instanceof EffectRpcQueryKeyError) - equal(error.cause, preparationFailure) - return true - }, - ) - equal(executions, 0) - equal(queryClient.getQueryCache().getAll().length, 0) - }), - ), - ) -} finally { - queryClient.clear() -} - -class StreamContext extends Context.Service()( - 'RetryRunner/StreamContext', -) {} - -const StreamValue = Schema.String.pipe( - Schema.middlewareDecoding((decoding) => Effect.flatMap(StreamContext, () => decoding)), -) -const watchGroup = RpcGroup.make(Rpc.make('events.watch', { success: StreamValue, stream: true })) -const wait = async (deferred: Deferred.Deferred) => - await Effect.runPromise(Deferred.await(deferred).pipe(Effect.timeout('5 seconds'))) - -for (const operation of ['streamed', 'live'] as const) { - const scope = Scope.makeUnsafe() - const queryClient = new QueryClient() - const events: string[] = [] - const published = Deferred.makeUnsafe() - const finalized = Deferred.makeUnsafe() - const serverFinalized = Deferred.makeUnsafe() - let signal: AbortSignal | undefined - const readyClient = await Effect.runPromise( - RpcTest.makeClient(watchGroup, { flatten: true }).pipe( - Effect.provide( - watchGroup.toLayer({ - 'events.watch': () => - Stream.succeed('ready').pipe( - Stream.concat(Stream.fromEffect(Effect.never)), - Stream.ensuring(Deferred.succeed(serverFinalized, undefined).pipe(Effect.asVoid)), - ), - }), - ), - Scope.provide(scope), - ), - ) - await Effect.runPromise( - Scope.addFinalizer( - scope, - Effect.sync(() => events.push('disposed')), - ), - ) - const client = (( - tag: 'events.watch', - input: void, - options?: { - readonly headers?: import('effect/http').Headers.Input | undefined - readonly context?: Context.Context | undefined - readonly streamBufferSize?: number | undefined - }, - ) => - readyClient(tag, input, options).pipe( - Stream.mapEffect((value) => - Effect.map(StreamContext, ({ prefix }) => { - events.push('consumed') - return `${prefix}:${value}` - }), - ), - Stream.ensuring( - Effect.flatMap(StreamContext, ({ prefix }) => - Effect.sync(() => { - events.push(`finalized:${prefix}`) - Effect.runSync(Deferred.succeed(finalized, undefined)) - }), - ), - ), - )) as typeof readyClient - const runPromiseExit: RunPromiseExit = async (effect, options) => { - events.push('creation-started') - signal = options?.signal - const exit = await Effect.runPromiseExit( - effect.pipe(Effect.provideService(StreamContext, { prefix: 'provided' })), - options, - ) - events.push('creation-completed') - return exit - } - const utils = createRpcQueryUtils(watchGroup, { - client, - keyPrefix: ['runner-lifetime', operation], - runPromiseExit, - }) - const options = - operation === 'live' - ? utils.events.watch.liveOptions({ retry: false }) - : utils.events.watch.streamedOptions({ retry: false }) - const unsubscribe = queryClient.getQueryCache().subscribe(() => { - const data = queryClient.getQueryData(options.queryKey) - if (data === 'provided:ready' || (Array.isArray(data) && data[0] === 'provided:ready')) { - Effect.runSync(Deferred.succeed(published, undefined)) - } - }) - const query = - operation === 'live' - ? queryClient.query(utils.events.watch.liveOptions({ retry: false })) - : queryClient.query(utils.events.watch.streamedOptions({ retry: false })) - const outcome = query.then( - (data) => ({ _tag: 'Success', data }) as const, - (error: unknown) => ({ _tag: 'Failure', error }) as const, - ) - try { - await wait(published) - deepStrictEqual(events, ['creation-started', 'creation-completed', 'consumed']) - equal(queryClient.getQueryState(options.queryKey)?.fetchStatus, 'fetching') - await queryClient.cancelQueries({ queryKey: options.queryKey, exact: true }) - await wait(finalized) - await wait(serverFinalized) - const result = await outcome - ok(result._tag === 'Success') - deepStrictEqual(result.data, operation === 'live' ? 'provided:ready' : ['provided:ready']) - equal(queryClient.getQueryState(options.queryKey)?.fetchStatus, 'idle') - equal(queryClient.getQueryState(options.queryKey)?.error, null) - equal(signal?.aborted, true) - deepStrictEqual(events, [ - 'creation-started', - 'creation-completed', - 'consumed', - 'finalized:provided', - ]) - } finally { - unsubscribe() - await queryClient.cancelQueries() - await wait(finalized) - queryClient.clear() - await Effect.runPromise(Scope.close(scope, Exit.void)) - } - equal(events.at(-1), 'disposed') -} diff --git a/tests/packed-consumer/runtime.mts b/tests/packed-consumer/runtime.mts deleted file mode 100644 index a4b351f..0000000 --- a/tests/packed-consumer/runtime.mts +++ /dev/null @@ -1,556 +0,0 @@ -import { MutationObserver, QueryClient, QueryObserver, skipToken } from '@tanstack/query-core' -import { skipToken as reactQuerySkipToken } from '@tanstack/react-query' -import { Effect, Schema, Stream } from 'effect' -import * as rpcQuery from 'effect-api-query' -import type { - CreateRpcQueryUtilsOptions, - EffectRpcQueryConfigErrorCode, - EffectRpcQueryKeyErrorCode, - JsonValue, - KeyEncoder, - QueryData, - RpcQueryUtils, - RunPromiseExit, - SkipToken, -} from 'effect-api-query' -import { Rpc, RpcGroup, RpcTest } from 'effect/rpc' -// fallow-ignore-file unused-file -// The packed-package verifier copies and executes this fixture in temporary consumers. -import { deepStrictEqual, equal, ok, rejects, throws } from 'node:assert/strict' - -type PublicTypes = [ - CreateRpcQueryUtilsOptions, - EffectRpcQueryConfigErrorCode, - EffectRpcQueryKeyErrorCode, - KeyEncoder, - QueryData, - RpcQueryUtils, - RunPromiseExit, - SkipToken, -] - -const expectedExports = [ - 'EffectHttpApiQueryConfigError', - 'EffectHttpApiQueryEmptyStreamError', - 'EffectHttpApiQueryError', - 'EffectHttpApiQueryKeyError', - 'EffectRpcQueryConfigError', - 'EffectRpcQueryEmptyStreamError', - 'EffectRpcQueryError', - 'EffectRpcQueryKeyError', - 'createHttpApiQueryUtils', - 'createRpcQueryUtils', - 'fetchStreamSnapshot', - 'isEffectHttpApiQueryError', - 'isEffectRpcQueryError', - 'skipToken', -] as const satisfies ReadonlyArray - -if (JSON.stringify(Object.keys(rpcQuery).sort()) !== JSON.stringify(expectedExports)) { - throw new Error('The package root exposed an unexpected runtime surface') -} -if (rpcQuery.skipToken !== skipToken || rpcQuery.skipToken !== reactQuerySkipToken) { - throw new Error('The package returned a different skipToken instance') -} - -const resolveFrom = import.meta.resolve as (specifier: string, parent?: string) => string -const packageEntry = resolveFrom('effect-api-query') -const consumerModules = new URL('./node_modules/', import.meta.url).href - -for (const specifier of ['effect-api-query', 'effect', '@tanstack/query-core']) { - ok( - resolveFrom(specifier).startsWith(consumerModules), - `${specifier} must resolve inside the isolated consumer`, - ) -} -for (const specifier of [ - '#effect-api-query', - '#effect-api-query/core', - 'effect-api-query/core', - 'effect-api-query/rpc', - 'effect-api-query/http', - 'effect-api-query/src/index.ts', - 'effect-api-query/dist/index.mjs', -]) { - await rejects(import(specifier), (error: unknown) => { - ok(error instanceof Error && 'code' in error) - equal( - error.code, - specifier.startsWith('#') - ? 'ERR_PACKAGE_IMPORT_NOT_DEFINED' - : 'ERR_PACKAGE_PATH_NOT_EXPORTED', - ) - return true - }) -} - -equal( - resolveFrom('@tanstack/query-core', packageEntry), - resolveFrom('@tanstack/query-core'), - 'The package must resolve the consumer Query Core runtime', -) -equal( - resolveFrom('effect', packageEntry), - resolveFrom('effect'), - 'The package must resolve the consumer Effect runtime', -) - -const Read = Rpc.make('compatibility.read', { success: Schema.String }) -const Page = Rpc.make('compatibility.page', { - payload: { cursor: Schema.Int }, - success: Schema.Int, -}) -const Watch = Rpc.make('compatibility.watch', { success: Schema.String, stream: true }) -const UndefinedWatch = Rpc.make('compatibility.undefined', { - success: Schema.Undefined, - stream: true, -}) -const NullWatch = Rpc.make('compatibility.null', { success: Schema.Null, stream: true }) -const OptionalWatch = Rpc.make('compatibility.optional', { - success: Schema.UndefinedOr(Schema.String), - stream: true, -}) -const OpenWatch = Rpc.make('compatibility.open', { success: Schema.Undefined, stream: true }) -const EmptyWatch = Rpc.make('compatibility.empty', { success: Schema.Undefined, stream: true }) -const WriteUndefined = Rpc.make('compatibility.write', { success: Schema.Undefined }) -const group = RpcGroup.make( - Read, - Page, - Watch, - UndefinedWatch, - NullWatch, - OptionalWatch, - OpenWatch, - EmptyWatch, - WriteUndefined, -) -const handlers = group.of({ - 'compatibility.empty': () => Stream.empty, - 'compatibility.null': () => Stream.succeed(null), - 'compatibility.open': () => - Stream.succeed(undefined).pipe(Stream.concat(Stream.fromEffect(Effect.never))), - 'compatibility.optional': () => Stream.make('value', undefined), - 'compatibility.page': Effect.fn('CompatibilityRpc.page')(({ cursor }) => Effect.succeed(cursor)), - 'compatibility.read': Effect.fn('CompatibilityRpc.read')(() => Effect.succeed('ordinary')), - 'compatibility.undefined': () => Stream.succeed(undefined), - 'compatibility.watch': () => Stream.make('first', 'second'), - 'compatibility.write': () => Effect.succeed(undefined), -}) - -await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const client = yield* RpcTest.makeClient(group, { flatten: true }).pipe( - Effect.provide(group.toLayer(handlers)), - ) - const queryClient = new QueryClient() - const rpcUtilityTree = rpcQuery.createRpcQueryUtils(group, { - client, - keyPrefix: ['compatibility'] as const, - }) - - equal( - yield* Effect.promise(() => - queryClient.query(rpcUtilityTree.compatibility.read.queryOptions()), - ), - 'ordinary', - ) - deepStrictEqual( - yield* Effect.promise(() => - queryClient.infiniteQuery( - rpcUtilityTree.compatibility.page.infiniteOptions({ - getNextPageParam: () => undefined, - initialPageParam: 0, - input: (cursor) => ({ cursor }), - }), - ), - ), - { pageParams: [0], pages: [0] }, - ) - deepStrictEqual( - yield* Effect.promise(() => - queryClient.query(rpcUtilityTree.compatibility.watch.streamedOptions()), - ), - ['first', 'second'], - ) - equal( - yield* Effect.promise(() => - queryClient.query(rpcUtilityTree.compatibility.watch.liveOptions()), - ), - 'second', - ) - equal( - yield* Effect.promise(() => - queryClient.query(rpcUtilityTree.compatibility.undefined.liveOptions()), - ), - null, - 'An undefined emission must become the live value', - ) - equal(queryClient.getQueryData(rpcUtilityTree.compatibility.undefined.liveKey()), null) - equal( - yield* Effect.promise(() => - queryClient.query(rpcUtilityTree.compatibility.null.liveOptions()), - ), - null, - 'An explicit null emission must remain null', - ) - equal(queryClient.getQueryData(rpcUtilityTree.compatibility.null.liveKey()), null) - - const optionalOptions = rpcUtilityTree.compatibility.optional.liveOptions() - const latestValues: unknown[] = [] - const stopRecording = queryClient.getQueryCache().subscribe((event) => { - if ( - event.type === 'updated' && - event.action.type === 'success' && - event.query === - queryClient.getQueryCache().find({ queryKey: optionalOptions.queryKey, exact: true }) - ) { - latestValues.push(event.query.state.data) - } - }) - try { - equal(yield* Effect.promise(() => queryClient.query(optionalOptions)), null) - deepStrictEqual(latestValues.slice(0, 2), ['value', null]) - equal(queryClient.getQueryData(optionalOptions.queryKey), null) - } finally { - stopRecording() - } - - deepStrictEqual( - yield* Effect.promise(() => - queryClient.query(rpcUtilityTree.compatibility.undefined.streamedOptions()), - ), - [undefined], - 'Accumulated undefined elements must be preserved', - ) - deepStrictEqual( - yield* Effect.promise(() => - queryClient.query(rpcUtilityTree.compatibility.optional.streamedOptions()), - ), - ['value', undefined], - ) - const mutation = new MutationObserver( - queryClient, - rpcUtilityTree.compatibility.write.mutationOptions(), - ) - equal(yield* Effect.promise(() => mutation.mutate(undefined)), undefined) - - yield* Effect.promise(() => - rejects( - queryClient.query(rpcUtilityTree.compatibility.empty.liveOptions({ retry: false })), - (error: unknown) => { - ok(error instanceof rpcQuery.EffectRpcQueryEmptyStreamError) - equal(error.rpcTag, 'compatibility.empty') - return true - }, - ), - ) - equal(queryClient.getQueryData(rpcUtilityTree.compatibility.empty.liveKey()), undefined) - - const openOptions = rpcUtilityTree.compatibility.open.liveOptions({ retry: false }) - const observer = new QueryObserver(queryClient, openOptions) - let unsubscribe = () => {} - let timeout: ReturnType | undefined - try { - const firstSnapshot = yield* Effect.promise( - () => - new Promise<{ readonly data: null; readonly fetchStatus: string }>( - (resolve, reject) => { - timeout = setTimeout( - () => reject(new Error('Live first emission timed out')), - 10_000, - ) - unsubscribe = observer.subscribe((result) => { - if (result.status === 'success') { - resolve(result) - } else if (result.status === 'error') { - reject(result.error) - } - }) - }, - ), - ) - equal(firstSnapshot.data, null) - equal(firstSnapshot.fetchStatus, 'fetching') - equal(queryClient.getQueryData(openOptions.queryKey), null) - } finally { - clearTimeout(timeout) - unsubscribe() - yield* Effect.promise(() => queryClient.cancelQueries({ queryKey: openOptions.queryKey })) - observer.destroy() - queryClient.clear() - } - }), - ), -) - -const BroadRead = Rpc.make('payload.unknown', { payload: Schema.Unknown, success: Schema.String }) -const AnyRead = Rpc.make('payload.any', { payload: Schema.Any, success: Schema.String }) -const OptionalRead = Rpc.make('payload.optional', { - payload: Schema.Union([Schema.String, Schema.Void]), - success: Schema.String, -}) -const VoidRead = Rpc.make('payload.void', { payload: Schema.Void, success: Schema.String }) -const DefaultRead = Rpc.make('payload.defaults', { - payload: { - id: Schema.Int, - locale: Schema.String.pipe( - Schema.optionalKey, - Schema.withConstructorDefault(Effect.succeed('en')), - ), - }, - success: Schema.String, -}) -const BroadWatch = Rpc.make('payload.unknownWatch', { - payload: Schema.Unknown, - success: Schema.String, - stream: true, -}) -const AnyWatch = Rpc.make('payload.anyWatch', { - payload: Schema.Any, - success: Schema.String, - stream: true, -}) -const BroadOptionalWatch = Rpc.make('payload.optionalWatch', { - payload: Schema.Union([Schema.String, Schema.Void]), - success: Schema.String, - stream: true, -}) -const VoidWatch = Rpc.make('payload.voidWatch', { - payload: Schema.Void, - success: Schema.String, - stream: true, -}) -const broadGroup = RpcGroup.make( - BroadRead, - AnyRead, - OptionalRead, - VoidRead, - DefaultRead, - BroadWatch, - AnyWatch, - BroadOptionalWatch, - VoidWatch, -) -const broadHandlers = broadGroup.of({ - 'payload.unknown': (input) => Effect.succeed(JSON.stringify(input)), - 'payload.any': (input) => Effect.succeed(String(input)), - 'payload.optional': (input) => Effect.succeed(input ?? 'empty'), - 'payload.void': () => Effect.succeed('payloadless'), - 'payload.defaults': ({ id, locale }) => Effect.succeed(`${id}:${locale}`), - 'payload.unknownWatch': (input) => Stream.make(JSON.stringify(input), 'unknown-last'), - 'payload.anyWatch': (input) => Stream.make(String(input), 'any-last'), - 'payload.optionalWatch': (input) => Stream.make(input ?? 'empty', 'optional-last'), - 'payload.voidWatch': () => Stream.make('payloadless-first', 'payloadless-last'), -}) - -await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const client = yield* RpcTest.makeClient(broadGroup, { flatten: true }).pipe( - Effect.provide(broadGroup.toLayer(broadHandlers)), - ) - const queryClient = new QueryClient() - const defaults = rpcQuery.createRpcQueryUtils(broadGroup, { - client, - keyPrefix: ['payload'], - }) - const encoded = rpcQuery.createRpcQueryUtils(broadGroup, { - client, - keyPrefix: ['encoded'], - keyEncoders: { - 'payload.unknown': (input) => (input === undefined ? 'empty' : String(input)), - 'payload.any': (input) => String(input), - 'payload.optional': (input) => input ?? null, - 'payload.unknownWatch': (input) => String(input), - 'payload.anyWatch': (input) => String(input), - 'payload.optionalWatch': (input) => input ?? null, - }, - }) - - deepStrictEqual(defaults.payload.unknown.queryKey({ id: 7 }), [ - 'payload', - 'rpc', - 'payload', - 'unknown', - 'query', - { id: 7 }, - ]) - deepStrictEqual(defaults.payload.any.queryKey(7), [ - 'payload', - 'rpc', - 'payload', - 'any', - 'query', - 7, - ]) - deepStrictEqual(defaults.payload.optional.queryKey('input'), [ - 'payload', - 'rpc', - 'payload', - 'optional', - 'query', - 'input', - ]) - for (const invalid of [undefined, 1n, new Date(0)]) { - throws(() => defaults.payload.unknown.queryKey(invalid), rpcQuery.EffectRpcQueryKeyError) - throws(() => defaults.payload.any.queryKey(invalid), rpcQuery.EffectRpcQueryKeyError) - } - throws(() => defaults.payload.optional.queryKey(undefined), rpcQuery.EffectRpcQueryKeyError) - deepStrictEqual(encoded.payload.optional.queryKey(undefined), [ - 'encoded', - 'rpc', - 'payload', - 'optional', - 'query', - null, - ]) - deepStrictEqual( - defaults.payload.defaults.queryKey({ id: 7 }), - defaults.payload.defaults.queryKey({ id: 7, locale: 'en' }), - ) - equal( - yield* Effect.promise(() => - queryClient.query(defaults.payload.defaults.queryOptions({ input: { id: 7 } })), - ), - '7:en', - ) - equal( - yield* Effect.promise(() => - queryClient.query(defaults.payload.unknown.queryOptions({ input: { id: 7 } })), - ), - '{"id":7}', - ) - equal( - yield* Effect.promise(() => - queryClient.query(defaults.payload.any.queryOptions({ input: 7 })), - ), - '7', - ) - equal( - yield* Effect.promise(() => - queryClient.query(encoded.payload.optional.queryOptions({ input: undefined })), - ), - 'empty', - ) - equal( - yield* Effect.promise(() => - queryClient.query(encoded.payload.any.queryOptions({ input: 1n })), - ), - '1', - ) - equal( - yield* Effect.promise(() => - new MutationObserver(queryClient, defaults.payload.unknown.mutationOptions()).mutate({ - id: 8, - }), - ), - '{"id":8}', - ) - equal( - yield* Effect.promise(() => - new MutationObserver(queryClient, defaults.payload.any.mutationOptions()).mutate(2n), - ), - '2', - ) - equal( - yield* Effect.promise(() => - new MutationObserver(queryClient, defaults.payload.optional.mutationOptions()).mutate( - undefined, - ), - ), - 'empty', - ) - deepStrictEqual( - yield* Effect.promise(() => - queryClient.infiniteQuery( - defaults.payload.unknown.infiniteOptions({ - input: (page: number) => ({ page }), - initialPageParam: 0, - getNextPageParam: () => undefined, - }), - ), - ), - { pages: ['{"page":0}'], pageParams: [0] }, - ) - deepStrictEqual( - yield* Effect.promise(() => - queryClient.infiniteQuery( - defaults.payload.any.infiniteOptions({ - input: (page: number) => page, - initialPageParam: 0, - getNextPageParam: () => undefined, - }), - ), - ), - { pages: ['0'], pageParams: [0] }, - ) - deepStrictEqual( - yield* Effect.promise(() => - queryClient.infiniteQuery( - encoded.payload.optional.infiniteOptions({ - input: () => undefined, - initialPageParam: 0, - getNextPageParam: () => undefined, - }), - ), - ), - { pages: ['empty'], pageParams: [0] }, - ) - deepStrictEqual( - yield* Effect.promise(() => - queryClient.query(defaults.payload.unknownWatch.streamedOptions({ input: { id: 7 } })), - ), - ['{"id":7}', 'unknown-last'], - ) - deepStrictEqual( - yield* Effect.promise(() => - queryClient.query(defaults.payload.anyWatch.streamedOptions({ input: 7 })), - ), - ['7', 'any-last'], - ) - deepStrictEqual( - yield* Effect.promise(() => - queryClient.query(encoded.payload.optionalWatch.streamedOptions({ input: undefined })), - ), - ['empty', 'optional-last'], - ) - equal( - yield* Effect.promise(() => - queryClient.query(defaults.payload.unknownWatch.liveOptions({ input: { id: 7 } })), - ), - 'unknown-last', - ) - equal( - yield* Effect.promise(() => - queryClient.query(defaults.payload.anyWatch.liveOptions({ input: 7 })), - ), - 'any-last', - ) - equal( - yield* Effect.promise(() => - queryClient.query(encoded.payload.optionalWatch.liveOptions({ input: undefined })), - ), - 'optional-last', - ) - equal( - yield* Effect.promise(() => queryClient.query(defaults.payload.void.queryOptions())), - 'payloadless', - ) - equal( - yield* Effect.promise(() => defaults.payload.void.mutationOptions().mutationFn()), - 'payloadless', - ) - deepStrictEqual( - yield* Effect.promise(() => - queryClient.query(defaults.payload.voidWatch.streamedOptions()), - ), - ['payloadless-first', 'payloadless-last'], - ) - equal( - yield* Effect.promise(() => queryClient.query(defaults.payload.voidWatch.liveOptions())), - 'payloadless-last', - ) - }), - ), -) diff --git a/tests/packed-consumer/snapshot-runtime.mts b/tests/packed-consumer/snapshot-runtime.mts deleted file mode 100644 index f497f26..0000000 --- a/tests/packed-consumer/snapshot-runtime.mts +++ /dev/null @@ -1,810 +0,0 @@ -import { - dehydrate, - hydrate, - QueryClient, - QueryObserver, - isCancelledError, - onlineManager, - skipToken, -} from '@tanstack/query-core' -import { Deferred, Effect, Exit, Schema, Scope, Stream } from 'effect' -import { - createHttpApiQueryUtils, - createRpcQueryUtils, - fetchStreamSnapshot, - isEffectRpcQueryError, -} from 'effect-api-query' -import type { RunPromiseExit } from 'effect-api-query' -import { HttpServer } from 'effect/http' -import { - HttpApi, - HttpApiBuilder, - HttpApiEndpoint, - HttpApiGroup, - HttpApiSchema, - HttpApiTest, -} from 'effect/http-api' -import { Rpc, RpcGroup, RpcTest } from 'effect/rpc' -import { deepStrictEqual, equal, ok, rejects } from 'node:assert/strict' -import { getEventListeners } from 'node:events' - -import { captureUpdates, updates } from './docs-stream-snapshot.ts' - -const group = RpcGroup.make( - Rpc.make('watch', { - payload: { channel: Schema.String }, - success: Schema.Int, - error: Schema.String, - stream: true, - }), -) -const api = HttpApi.make('snapshot-http').add( - HttpApiGroup.make('events').add( - HttpApiEndpoint.get('watch', '/watch', { - success: HttpApiSchema.StreamSse({ data: Schema.Int }), - }), - ), -) - -await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const client = yield* HttpApiTest.groups(api, ['events']).pipe( - Effect.provide( - HttpApiBuilder.group(api, 'events', (handlers) => - handlers.handle('watch', () => - Effect.succeed( - Stream.succeed(1).pipe(Stream.concat(Stream.fromEffect(Effect.never))), - ), - ), - ), - ), - ) - const utils = createHttpApiQueryUtils(api, { client, keyPrefix: ['snapshot-http'] }) - const queryClient = new QueryClient() - deepStrictEqual( - yield* Effect.promise(() => - fetchStreamSnapshot(queryClient, utils.events.watch.streamedOptions({ retry: false })), - ), - [1], - ) - equal( - yield* Effect.promise(() => - fetchStreamSnapshot(queryClient, utils.events.watch.liveOptions({ retry: false })), - ), - 1, - ) - equal(queryClient.isFetching(), 0) - equal(queryClient.getQueryCache().hasListeners(), false) - queryClient.clear() - }), - ).pipe(Effect.provide(HttpServer.layerServices)), -) - -await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - for (const view of ['streamed', 'live'] as const) { - for (const path of [ - 'success', - 'timeout', - 'abort', - 'external cancel', - 'failure', - 'empty', - 'complete', - ] as const) { - const entered = yield* Deferred.make() - const closing = yield* Deferred.make() - const release = yield* Deferred.make() - let finalized = false - const client = yield* RpcTest.makeClient(group, { flatten: true }).pipe( - Effect.provide(group.toLayer({ watch: () => Stream.succeed(1) })), - ) - const source = - path === 'failure' - ? Stream.fail('unavailable') - : path === 'empty' - ? Stream.empty - : path === 'complete' - ? Stream.make(1, 2) - : path === 'success' - ? Stream.succeed(1).pipe(Stream.concat(Stream.fromEffect(Effect.never))) - : Stream.fromEffect( - Deferred.succeed(entered, undefined).pipe(Effect.andThen(Effect.never)), - ) - const ready = ((...args: Parameters) => - client(args[0], args[1], { ...args[2], asQueue: false }).pipe( - Stream.flatMap(() => source), - Stream.ensuring( - Deferred.succeed(closing, undefined).pipe( - Effect.andThen(Deferred.await(release)), - Effect.andThen( - Effect.sync(() => { - finalized = true - }), - ), - ), - ), - )) as typeof client - const options = createRpcQueryUtils(group, { - client: ready, - keyPrefix: ['snapshot', path], - }).watch - const queryClient = new QueryClient() - const abort = new AbortController() - const reason = new Error('page aborted') - let settled = false - const nativeOptions = - view === 'streamed' - ? options.streamedOptions({ input: { channel: 'owned' }, retry: false }) - : options.liveOptions({ input: { channel: 'owned' }, retry: false }) - const queryKey = nativeOptions.queryKey - const snapshot = - view === 'streamed' - ? fetchStreamSnapshot( - queryClient, - options.streamedOptions({ input: { channel: 'owned' }, retry: false }), - { signal: abort.signal, timeoutMs: path === 'timeout' ? 10 : 1_000 }, - ) - : fetchStreamSnapshot( - queryClient, - options.liveOptions({ input: { channel: 'owned' }, retry: false }), - { signal: abort.signal, timeoutMs: path === 'timeout' ? 10 : 1_000 }, - ) - const outcome = snapshot.then( - (data) => { - settled = true - return { status: 'success' as const, data } - }, - (error: unknown) => { - settled = true - return { status: 'failure' as const, error } - }, - ) - if (path === 'abort' || path === 'external cancel') { - yield* Deferred.await(entered) - if (path === 'abort') abort.abort(reason) - else yield* Effect.promise(() => queryClient.cancelQueries({ exact: true, queryKey })) - } - yield* Deferred.await(closing) - yield* Effect.sleep('1 millis') - equal(settled, false) - equal(finalized, false) - yield* Deferred.succeed(release, undefined) - const result = yield* Effect.promise(() => outcome) - equal(finalized, true) - equal(queryClient.getQueryCache().hasListeners(), false) - equal(queryClient.isFetching(), 0) - equal(getEventListeners(abort.signal, 'abort').length, 0) - if (path === 'timeout') { - equal(result.status, 'failure') - if (result.status === 'failure') { - ok(result.error instanceof DOMException) - equal(result.error.name, 'TimeoutError') - } - } else if (path === 'abort') { - equal(result.status, 'failure') - if (result.status === 'failure') equal(result.error, reason) - } else if (path === 'external cancel') { - equal(result.status, 'failure') - if (result.status === 'failure') ok(isCancelledError(result.error)) - } else if (path === 'failure' || (path === 'empty' && view === 'live')) { - equal(result.status, 'failure') - if (result.status === 'failure') { - if (path === 'failure') ok(isEffectRpcQueryError(result.error)) - else - ok( - result.error instanceof Error && - result.error.name === 'EffectRpcQueryEmptyStreamError', - ) - } - } else { - equal(result.status, 'success') - if (result.status === 'success') - deepStrictEqual(result.data, view === 'streamed' ? (path === 'empty' ? [] : [1]) : 1) - } - abort.abort(new Error('late abort')) - equal(queryClient.getQueryCache().hasListeners(), false) - queryClient.clear() - } - } - }), - ), -) - -await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const acquiring = yield* Deferred.make() - const allow = yield* Deferred.make() - let acquisitionFinalized = false - let reads = 0 - const client = yield* RpcTest.makeClient(group, { flatten: true }).pipe( - Effect.provide( - group.toLayer({ - watch: () => - Stream.fromEffect( - Effect.sync(() => { - reads += 1 - return 1 - }), - ), - }), - ), - ) - const runPromiseExit: RunPromiseExit = (effect, options) => - Effect.runPromiseExit( - Deferred.succeed(acquiring, undefined).pipe( - Effect.andThen(Deferred.await(allow)), - Effect.andThen(effect), - Effect.ensuring( - Effect.sync(() => { - acquisitionFinalized = true - }), - ), - ), - options, - ) - const options = createRpcQueryUtils(group, { - client, - keyPrefix: ['acquisition'], - runPromiseExit, - }).watch.liveOptions({ input: { channel: 'owned' }, retry: false }) - const queryClient = new QueryClient() - const before = AbortSignal.abort(new Error('already aborted')) - yield* Effect.promise(() => - rejects( - fetchStreamSnapshot(queryClient, options, { signal: before }), - (error: unknown) => error === before.reason, - ), - ) - equal(queryClient.getQueryCache().getAll().length, 0) - const abort = new AbortController() - const reason = new Error('abort acquisition') - const outcome = rejects( - fetchStreamSnapshot(queryClient, options, { signal: abort.signal }), - (error: unknown) => error === reason, - ) - yield* Deferred.await(acquiring) - abort.abort(reason) - yield* Effect.promise(() => outcome) - equal(acquisitionFinalized, true) - equal(reads, 0) - equal(getEventListeners(abort.signal, 'abort').length, 0) - equal(queryClient.getQueryCache().hasListeners(), false) - queryClient.clear() - }), - ), -) - -await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - let ownedClosed = false - let otherClosed = false - const started = yield* Deferred.make() - const client = yield* RpcTest.makeClient(group, { flatten: true }).pipe( - Effect.provide( - group.toLayer({ - watch: ({ channel }) => - Stream.succeed(channel === 'owned' ? 1 : 2).pipe( - Stream.concat(Stream.fromEffect(Effect.never)), - ), - }), - ), - ) - const ready = ((...args: Parameters) => - client(args[0], args[1], { ...args[2], asQueue: false }).pipe( - Stream.ensuring( - Effect.sync(() => { - if (args[1].channel === 'owned') ownedClosed = true - else otherClosed = true - }), - ), - )) as typeof client - const utils = createRpcQueryUtils(group, { client: ready, keyPrefix: ['exact'] }) - const queryClient = new QueryClient() - const other = utils.watch.liveOptions({ input: { channel: 'other' }, retry: false }) - const stop = queryClient.getQueryCache().subscribe(() => { - if (queryClient.getQueryData(other.queryKey) === 2) - Effect.runFork(Deferred.succeed(started, undefined)) - }) - const fetching = queryClient.query(other) - yield* Deferred.await(started) - stop() - const owned = utils.watch.liveOptions({ input: { channel: 'owned' }, retry: false }) - equal(yield* Effect.promise(() => fetchStreamSnapshot(queryClient, owned)), 1) - equal(ownedClosed, true) - equal(otherClosed, false) - equal(queryClient.getQueryState(other.queryKey)?.fetchStatus, 'fetching') - equal(queryClient.getQueryData(other.queryKey), 2) - yield* Effect.promise(() => - queryClient.cancelQueries({ exact: true, queryKey: other.queryKey }), - ) - yield* Effect.promise(() => fetching) - equal(queryClient.getQueryCache().hasListeners(), false) - queryClient.clear() - }), - ), -) - -await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - for (const view of ['streamed', 'live'] as const) { - const serverScope = yield* Scope.make() - let serverClosed = false - let browserClosed = false - let browserReads = 0 - const server = yield* RpcTest.makeClient(group, { flatten: true }).pipe( - Effect.provide( - group.toLayer({ - watch: () => Stream.succeed(1).pipe(Stream.concat(Stream.fromEffect(Effect.never))), - }), - ), - Effect.provideService(Scope.Scope, serverScope), - ) - const browser = yield* RpcTest.makeClient(group, { flatten: true }).pipe( - Effect.provide( - group.toLayer({ - watch: () => - Stream.fromEffect( - Effect.sync(() => { - browserReads += 1 - return 2 - }), - ).pipe(Stream.concat(Stream.fromEffect(Effect.never))), - }), - ), - ) - const serverReady = ((...args: Parameters) => - server(args[0], args[1], { ...args[2], asQueue: false }).pipe( - Stream.ensuring( - Effect.sync(() => { - serverClosed = true - }), - ), - )) as typeof server - const browserReady = ((...args: Parameters) => - browser(args[0], args[1], { ...args[2], asQueue: false }).pipe( - Stream.ensuring( - Effect.sync(() => { - browserClosed = true - }), - ), - )) as typeof browser - const serverUtils = createRpcQueryUtils(group, { - client: serverReady, - keyPrefix: ['hydrate'], - }) - const browserUtils = createRpcQueryUtils(group, { - client: browserReady, - keyPrefix: ['hydrate'], - }) - const input = { channel: 'owned' } - const serverOptions = serverUtils.watch.streamedOptions({ input, retry: false }) - const serverLive = serverUtils.watch.liveOptions({ input, retry: false }) - const browserOptions = browserUtils.watch.streamedOptions({ input, retry: false }) - const browserLive = browserUtils.watch.liveOptions({ input, retry: false }) - const serverQueryClient = new QueryClient() - const browserQueryClient = new QueryClient() - deepStrictEqual( - view === 'streamed' - ? yield* Effect.promise(() => fetchStreamSnapshot(serverQueryClient, serverOptions)) - : yield* Effect.promise(() => fetchStreamSnapshot(serverQueryClient, serverLive)), - view === 'streamed' ? [1] : 1, - ) - equal(serverClosed, true) - const json = JSON.stringify(dehydrate(serverQueryClient)) - serverQueryClient.clear() - yield* Scope.close(serverScope, Exit.void) - hydrate(browserQueryClient, JSON.parse(json)) - deepStrictEqual( - view === 'streamed' - ? yield* Effect.promise(() => - fetchStreamSnapshot(browserQueryClient, browserOptions, { mode: 'cached' }), - ) - : yield* Effect.promise(() => - fetchStreamSnapshot(browserQueryClient, browserLive, { mode: 'cached' }), - ), - view === 'streamed' ? [1] : 1, - ) - equal(browserReads, 0) - deepStrictEqual( - view === 'streamed' - ? yield* Effect.promise(() => fetchStreamSnapshot(browserQueryClient, browserOptions)) - : yield* Effect.promise(() => fetchStreamSnapshot(browserQueryClient, browserLive)), - view === 'streamed' ? [2] : 2, - ) - equal(browserReads, 1) - equal(browserClosed, true) - browserQueryClient.clear() - } - }), - ), -) - -await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - let attempts = 0 - const client = yield* RpcTest.makeClient(group, { flatten: true }).pipe( - Effect.provide( - group.toLayer({ - watch: () => - Stream.suspend(() => { - attempts += 1 - return attempts === 1 ? Stream.fail('try again') : Stream.make(1) - }), - }), - ), - ) - const options = createRpcQueryUtils(group, { - client, - keyPrefix: ['retry-snapshot'], - }).watch.liveOptions({ input: { channel: 'owned' }, retry: 1, retryDelay: 0 }) - const queryClient = new QueryClient() - equal(yield* Effect.promise(() => fetchStreamSnapshot(queryClient, options)), 1) - equal(attempts, 2) - queryClient.clear() - }), - ), -) - -await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - for (const policy of ['reset', 'append', 'replace', 'live'] as const) { - const entered = yield* Deferred.make() - const emit = yield* Deferred.make() - const emitted = yield* Deferred.make() - let finalized = false - const client = yield* RpcTest.makeClient(group, { flatten: true }).pipe( - Effect.provide(group.toLayer({ watch: () => Stream.succeed(1) })), - ) - const ready = ((...args: Parameters) => - client(args[0], args[1], { ...args[2], asQueue: false }).pipe( - Stream.flatMap(() => - Stream.fromEffect( - Deferred.succeed(entered, undefined).pipe( - Effect.andThen(Deferred.await(emit)), - Effect.andThen(Deferred.succeed(emitted, undefined)), - Effect.as(1), - ), - ), - ), - Stream.concat(Stream.fromEffect(Effect.never)), - Stream.ensuring( - Effect.sync(() => { - finalized = true - }), - ), - )) as typeof client - const utils = createRpcQueryUtils(group, { client: ready, keyPrefix: ['cached', policy] }) - const queryClient = new QueryClient() - let settled = false - const input = { channel: 'owned' } - const streamed = utils.watch.streamedOptions({ - input, - refetchMode: policy === 'live' ? 'reset' : policy, - retry: false, - staleTime: 60_000, - }) - const live = utils.watch.liveOptions({ input, retry: false, staleTime: 60_000 }) - const queryKey = policy === 'live' ? live.queryKey : streamed.queryKey - if (policy === 'live') queryClient.setQueryData(live.queryKey, 1) - else queryClient.setQueryData(streamed.queryKey, [0]) - const fetching = - policy === 'live' - ? fetchStreamSnapshot(queryClient, live, { timeoutMs: 100 }) - : fetchStreamSnapshot(queryClient, streamed, { timeoutMs: 100 }) - const outcome = fetching.then( - (value) => { - settled = true - return { status: 'success' as const, value } - }, - (error: unknown) => { - settled = true - return { status: 'failure' as const, error } - }, - ) - yield* Deferred.await(entered) - equal(settled, false) - yield* Effect.promise(() => - rejects( - policy === 'live' - ? fetchStreamSnapshot(queryClient, live) - : fetchStreamSnapshot(queryClient, streamed), - /idle query without observers/, - ), - ) - if (policy === 'append' || policy === 'replace') { - deepStrictEqual( - yield* Effect.promise(() => - fetchStreamSnapshot(queryClient, streamed, { mode: 'cached' }), - ), - [0], - ) - equal(finalized, false) - } - yield* Deferred.succeed(emit, undefined) - yield* Deferred.await(emitted) - const result = yield* Effect.promise(() => outcome) - equal(finalized, true) - equal(queryClient.getQueryCache().hasListeners(), false) - if (policy === 'replace') { - equal(result.status, 'failure') - if (result.status === 'failure') - ok(result.error instanceof DOMException && result.error.name === 'TimeoutError') - deepStrictEqual(queryClient.getQueryData(queryKey), [0]) - } else { - equal(result.status, 'success') - if (result.status === 'success') - deepStrictEqual( - result.value, - policy === 'live' ? 1 : policy === 'append' ? [0, 1] : [1], - ) - } - queryClient.clear() - } - }), - ), -) - -await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const client = yield* RpcTest.makeClient(group, { flatten: true }).pipe( - Effect.provide(group.toLayer({ watch: () => Stream.make(1, 2) })), - ) - const options = createRpcQueryUtils(group, { - client, - keyPrefix: ['completed'], - }).watch.streamedOptions({ - input: { channel: 'owned' }, - refetchMode: 'replace', - retry: false, - }) - const queryClient = new QueryClient() - queryClient.setQueryData(options.queryKey, [0]) - deepStrictEqual( - yield* Effect.promise(() => fetchStreamSnapshot(queryClient, options)), - [1, 2], - ) - queryClient.setQueryDefaults(options.queryKey, { - select: (data) => `items:${Array.isArray(data) ? data.length : 0}`, - }) - equal( - yield* Effect.promise(() => fetchStreamSnapshot(queryClient, options, { mode: 'cached' })), - 'items:2', - ) - equal( - yield* Effect.promise(() => - fetchStreamSnapshot(queryClient, { ...options, select: (values) => values.length }), - ), - 2, - ) - deepStrictEqual(queryClient.getQueryData(options.queryKey), [1, 2]) - const observer = new QueryObserver(queryClient, { ...options, enabled: false }) - const unsubscribe = observer.subscribe(() => {}) - yield* Effect.promise(() => - rejects(fetchStreamSnapshot(queryClient, options), /idle query without observers/), - ) - equal( - yield* Effect.promise(() => - fetchStreamSnapshot( - queryClient, - { ...options, select: (values) => values.length }, - { mode: 'cached' }, - ), - ), - 2, - ) - unsubscribe() - equal(queryClient.getQueryCache().hasListeners(), false) - queryClient.clear() - }), - ), -) - -await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const client = yield* RpcTest.makeClient(group, { flatten: true }).pipe( - Effect.provide( - group.toLayer({ - watch: () => Stream.succeed(1).pipe(Stream.concat(Stream.fromEffect(Effect.never))), - }), - ), - ) - for (const cached of [true, false]) { - for (const view of ['streamed', 'live'] as const) { - const utils = createRpcQueryUtils(group, { - client, - keyPrefix: ['clock-skew', cached, view], - }) - const queryClient = new QueryClient() - const input = { channel: 'owned' } - const future = Date.now() + 60_000 - const streamed = utils.watch.streamedOptions({ - input, - initialData: [0], - initialDataUpdatedAt: future, - retry: false, - }) - const live = utils.watch.liveOptions({ - input, - initialData: 0, - initialDataUpdatedAt: future, - retry: false, - }) - if (cached) { - if (view === 'streamed') - queryClient.setQueryData(streamed.queryKey, [0], { updatedAt: future }) - else queryClient.setQueryData(live.queryKey, 0, { updatedAt: future }) - const previous = - view === 'streamed' - ? yield* Effect.promise(() => - fetchStreamSnapshot(queryClient, streamed, { mode: 'cached' }), - ) - : yield* Effect.promise(() => - fetchStreamSnapshot(queryClient, live, { mode: 'cached' }), - ) - deepStrictEqual(previous, view === 'streamed' ? [0] : 0) - } - const result = - view === 'streamed' - ? yield* Effect.promise(() => fetchStreamSnapshot(queryClient, streamed)) - : yield* Effect.promise(() => fetchStreamSnapshot(queryClient, live)) - deepStrictEqual(result, view === 'streamed' ? (cached ? [1] : [0, 1]) : 1) - equal(queryClient.isFetching(), 0) - equal(queryClient.getQueryCache().hasListeners(), false) - queryClient.clear() - } - } - for (const prefix of [true, false]) { - const utils = createRpcQueryUtils(group, { client, keyPrefix: ['snapshot-hash', prefix] }) - const queryClient = new QueryClient({ - defaultOptions: { queries: { queryKeyHashFn: (key) => `global:${JSON.stringify(key)}` } }, - }) - if (prefix) - queryClient.setQueryDefaults(utils.watch.key(), { - queryKeyHashFn: (key) => `prefix:${JSON.stringify(key)}`, - }) - const options = utils.watch.liveOptions({ input: { channel: 'owned' }, retry: false }) - equal(yield* Effect.promise(() => fetchStreamSnapshot(queryClient, options)), 1) - equal(queryClient.getQueryData(options.queryKey), 1) - equal(queryClient.getQueryCache().getAll().length, 1) - equal(queryClient.getQueryCache().hasListeners(), false) - queryClient.clear() - } - }), - ), -) - -await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const undefinedGroup = RpcGroup.make( - Rpc.make('watch', { success: Schema.Undefined, stream: true }), - ) - const client = yield* RpcTest.makeClient(undefinedGroup, { flatten: true }).pipe( - Effect.provide( - undefinedGroup.toLayer({ - watch: () => - Stream.succeed(undefined).pipe(Stream.concat(Stream.fromEffect(Effect.never))), - }), - ), - ) - const options = createRpcQueryUtils(undefinedGroup, { - client, - keyPrefix: ['snapshot-null'], - }).watch.liveOptions({ retry: false }) - const queryClient = new QueryClient() - equal(yield* Effect.promise(() => fetchStreamSnapshot(queryClient, options)), null) - equal(queryClient.getQueryData(options.queryKey), null) - equal(queryClient.isFetching(), 0) - queryClient.clear() - }), - ), -) - -for (const extra of [{ queryHash: 'custom' }, { queryKeyHashFn: () => 'custom' }]) { - const queryClient = new QueryClient() - await rejects( - Reflect.apply(fetchStreamSnapshot, undefined, [ - queryClient, - { - queryKey: ['per-call-hash'], - queryFn: () => Promise.resolve(1), - ...extra, - }, - ]), - TypeError, - ) - equal(queryClient.getQueryCache().getAll().length, 0) - equal(queryClient.getQueryCache().hasListeners(), false) -} - -for (const timeoutMs of [0, -1, Number.POSITIVE_INFINITY, Number.NaN, 2_147_483_648]) { - const queryClient = new QueryClient() - await rejects( - fetchStreamSnapshot( - queryClient, - { - queryKey: ['timeout'], - queryFn: () => Promise.resolve(1), - }, - { timeoutMs }, - ), - RangeError, - ) - equal(queryClient.getQueryCache().getAll().length, 0) - equal(queryClient.getQueryCache().hasListeners(), false) -} - -const previousOnline = onlineManager.isOnline() -const pausedClient = new QueryClient() -try { - onlineManager.setOnline(false) - const options = { queryKey: ['paused'], queryFn: () => Promise.resolve(1) } - const paused = pausedClient.query(options).catch((error: unknown) => error) - equal(pausedClient.getQueryState(options.queryKey)?.fetchStatus, 'paused') - await rejects(fetchStreamSnapshot(pausedClient, options), /idle query without observers/) - equal(pausedClient.getQueryState(options.queryKey)?.fetchStatus, 'paused') - equal(pausedClient.getQueryCache().hasListeners(), false) - await pausedClient.cancelQueries({ exact: true, queryKey: options.queryKey }) - ok(isCancelledError(await paused)) -} finally { - onlineManager.setOnline(previousOnline) - pausedClient.clear() -} - -for (const queryFn of [undefined, skipToken, 'skipToken']) { - const queryClient = new QueryClient() - await rejects( - Reflect.apply(fetchStreamSnapshot, undefined, [ - queryClient, - { - queryKey: ['invalid-query-function'], - queryFn, - }, - ]), - TypeError, - ) - equal(queryClient.getQueryCache().getAll().length, 0) - equal(queryClient.getQueryCache().hasListeners(), false) -} - -console.log( - 'Packed stream snapshots, local cleanup, fresh/cached modes, ownership, hashing, and reconnection executed', -) - -await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const client = yield* RpcTest.makeClient(updates, { flatten: true }).pipe( - Effect.provide( - updates.toLayer({ - 'updates.watch': () => - Stream.succeed('announcement').pipe(Stream.concat(Stream.fromEffect(Effect.never))), - }), - ), - ) - const queryClient = new QueryClient() - deepStrictEqual( - yield* Effect.promise(() => - captureUpdates(queryClient, client, new AbortController().signal), - ), - { - history: ['announcement'], - latest: 'announcement', - }, - ) - equal(queryClient.isFetching(), 0) - equal(queryClient.getQueryCache().hasListeners(), false) - queryClient.clear() - }), - ), -) diff --git a/tests/packed-consumer/start-ssr-runtime.mts b/tests/packed-consumer/start-ssr-runtime.mts deleted file mode 100644 index 93d4f73..0000000 --- a/tests/packed-consumer/start-ssr-runtime.mts +++ /dev/null @@ -1,162 +0,0 @@ -import { dehydrate, hydrate } from '@tanstack/react-query' -import { Deferred, Effect, Exit, Scope } from 'effect' -import { deepStrictEqual, equal, ok } from 'node:assert/strict' - -import { User } from '../../examples/contracts/src/contracts.ts' -import { makeExampleHost } from '../../examples/server/src/web-handler.ts' -import { startServerApplication } from '../../examples/tanstack-start/src/lib/server-application.ts' -import { makeSnapshotPreparation } from '../../examples/tanstack-start/src/lib/snapshot-preparation.ts' - -const scope = Scope.makeUnsafe() -const host = await Effect.runPromise(makeExampleHost().pipe(Scope.provide(scope))) -const server = await startServerApplication({ - host, - identity: 'same-owner', - authorization: 'allowed', -}) -const browser = await startServerApplication({ - host, - identity: 'same-owner', - authorization: 'allowed', -}) -try { - const create = server.rpcQuery.users.create.mutationOptions().mutationFn - if (create === undefined) throw new Error('Missing generated mutation') - await create({ name: 'Shared local state' }) - const list = server.rpcQuery.users.list.queryOptions() - equal((await server.queryClient.query(list)).length, 13) - const pages = server.rpcQuery.users.page.infiniteOptions({ - initialPageParam: 0, - input: (cursor: number) => ({ cursor, pageSize: 4 }), - getNextPageParam: (page) => page.nextCursor ?? undefined, - }) - const pageData = await server.queryClient.infiniteQuery(pages) - ok(pageData.pages[0]?.users[0] instanceof User) - await server.captureSnapshot( - server.queryClient, - server.rpcQuery.diagnostics.stream.streamedOptions(), - ) - await server.captureSnapshot(server.queryClient, server.rpcQuery.diagnostics.stream.liveOptions()) - equal((await server.queryClient.query(server.httpQuery.users.list.queryOptions())).length, 13) - const encodingStarted = Deferred.makeUnsafe() - const permitEncoding = Deferred.makeUnsafe() - const decodingStarted = Deferred.makeUnsafe() - const permitDecoding = Deferred.makeUnsafe() - const encoding = makeSnapshotPreparation(server, { - beforeEncode: Deferred.succeed(encodingStarted, undefined).pipe( - Effect.andThen(Deferred.await(permitEncoding)), - ), - beforeDecode: Effect.void, - }) - const decoding = makeSnapshotPreparation(browser, { - beforeEncode: Effect.void, - beforeDecode: Deferred.succeed(decodingStarted, undefined).pipe( - Effect.andThen(Deferred.await(permitDecoding)), - ), - }) - const native = dehydrate(server.queryClient, { shouldDehydrateMutation: () => false }) - let published = false - const publication = (async () => { - const queries = await encoding.encode(native.queries) - const json = JSON.stringify({ ...native, queries }) - published = true - return json - })() - await Effect.runPromise(Deferred.await(encodingStarted)) - equal(published, false) - await Effect.runPromise(Deferred.succeed(permitEncoding, undefined)) - const json = await publication - const encoded: typeof native = JSON.parse(json) - const hydration = (async () => { - const queries = await decoding.decode(encoded.queries) - hydrate(browser.queryClient, { ...encoded, queries }) - })() - await Effect.runPromise(Deferred.await(decodingStarted)) - equal(browser.queryClient.getQueryCache().getAll().length, 0) - await Effect.runPromise(Deferred.succeed(permitDecoding, undefined)) - await hydration - const browserList = browser.rpcQuery.users.list.queryOptions() - deepStrictEqual(browserList.queryKey, list.queryKey) - let duplicateReads = 0 - const unsubscribe = browser.queryClient.getQueryCache().subscribe((event) => { - if (event.query.state.fetchStatus === 'fetching') duplicateReads += 1 - }) - equal((await browser.queryClient.query({ ...browserList, staleTime: 'static' })).length, 13) - equal(duplicateReads, 0) - unsubscribe() - ok(browser.queryClient.getQueryData(browserList.queryKey)?.[0] instanceof User) - const hydratedPage = browser.queryClient.getQueryData( - browser.rpcQuery.users.page.infiniteKey({ cursor: 0, pageSize: 4 }), - ) - deepStrictEqual(hydratedPage?.pageParams, [0]) - ok(hydratedPage?.pages[0]?.users[0] instanceof User) - await server.dispose() - deepStrictEqual( - await browser.captureSnapshot( - browser.queryClient, - browser.rpcQuery.diagnostics.stream.streamedOptions(), - ), - ['Connection opened'], - ) - equal( - await browser.captureSnapshot( - browser.queryClient, - browser.rpcQuery.diagnostics.stream.liveOptions(), - ), - 'Connection opened', - ) - equal( - ( - await browser.queryClient.query({ - ...browser.httpQuery.users.list.queryOptions(), - staleTime: 0, - }) - ).length, - 13, - ) - const retired = await startServerApplication({ - host, - identity: 'retired', - authorization: 'allowed', - }) - const preparationStarted = Deferred.makeUnsafe() - const permit = Deferred.makeUnsafe() - let released = false - let preparing: Promise | undefined - try { - await retired.queryClient.query(retired.rpcQuery.users.list.queryOptions()) - const codecs = makeSnapshotPreparation(retired, { - beforeEncode: Effect.scoped( - Effect.acquireRelease(Deferred.succeed(preparationStarted, undefined), () => - Effect.sync(() => { - released = true - }), - ).pipe(Effect.andThen(Deferred.await(permit))), - ), - beforeDecode: Effect.void, - }) - preparing = (async () => { - try { - await codecs.encode(dehydrate(retired.queryClient).queries) - return null - } catch (error) { - return error - } - })() - await Effect.runPromise(Deferred.await(preparationStarted)) - await retired.dispose() - equal(released, true) - ok(await preparing) - equal((await browser.queryClient.query({ ...browserList, staleTime: 0 })).length, 13) - } finally { - await Effect.runPromise(Deferred.succeed(permit, undefined)) - await preparing - await retired.dispose() - } - console.log( - 'Packed copied Start host, request lifetimes, classes, pages, streams and async hydration verified', - ) -} finally { - await Promise.all([server.dispose(), browser.dispose()]) - await Effect.runPromise(Scope.close(scope, Exit.void)) -} diff --git a/tests/packed-consumer/stream-cause-runtime.mts b/tests/packed-consumer/stream-cause-runtime.mts deleted file mode 100644 index 8c315b4..0000000 --- a/tests/packed-consumer/stream-cause-runtime.mts +++ /dev/null @@ -1,323 +0,0 @@ -import { isCancelledError, QueryClient, QueryObserver } from '@tanstack/query-core' -import type { QueryKey } from '@tanstack/query-core' -import { Cause, Deferred, Effect, Equal, Schema, Stream } from 'effect' -import { createRpcQueryUtils, isEffectRpcQueryError } from 'effect-api-query' -import type { RunPromiseExit } from 'effect-api-query' -import type { RpcClient } from 'effect/rpc' -import { Rpc, RpcGroup, RpcTest } from 'effect/rpc' -import { deepStrictEqual, equal, ok, rejects } from 'node:assert/strict' - -const Watch = Rpc.make('events.watch', { success: Schema.String, stream: true }) -const group = RpcGroup.make(Watch) - -const operations = ['streamed', 'live'] as const -const defect = new Error('stream defect') -const causes = [Cause.interrupt(42), Cause.combine(Cause.interrupt(42), Cause.die(defect))] - -for (const operation of operations) { - for (const cause of causes) { - for (const emitted of [false, true]) { - let finalized = false - const failure = Stream.failCause(cause) - const client = (() => - (emitted ? Stream.make('ready').pipe(Stream.concat(failure)) : failure).pipe( - Stream.ensuring( - Effect.sync(() => { - finalized = true - }), - ), - )) as RpcClient.RpcClient.Flat> - const utils = createRpcQueryUtils(group, { client, keyPrefix: ['interrupt', operation] }) - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) - try { - const queryKey = - operation === 'live' ? utils.events.watch.liveKey() : utils.events.watch.streamedKey() - const query = - operation === 'live' - ? queryClient.query(utils.events.watch.liveOptions()) - : queryClient.query(utils.events.watch.streamedOptions()) - await rejects(query, (error: unknown) => { - ok(isEffectRpcQueryError(error)) - equal(error.rpcTag, 'events.watch') - equal(error.operation, operation) - equal( - Equal.equals(error.cause, cause), - true, - 'Preserve the entire independent stream Cause', - ) - equal(finalized, true) - equal(queryClient.getQueryState(queryKey)?.status, 'error') - equal(queryClient.getQueryState(queryKey)?.error, error) - deepStrictEqual( - queryClient.getQueryData(queryKey), - emitted ? (operation === 'live' ? 'ready' : ['ready']) : undefined, - ) - return true - }) - } finally { - queryClient.clear() - } - } - } -} - -for (const operation of operations) { - const rejection = new Error('runner rejection') - const runPromiseExit: RunPromiseExit = async () => await Promise.reject(rejection) - const client = (() => Stream.empty) as RpcClient.RpcClient.Flat> - const utils = createRpcQueryUtils(group, { - client, - keyPrefix: ['runner', operation], - runPromiseExit, - }) - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) - try { - const query = - operation === 'live' - ? queryClient.query(utils.events.watch.liveOptions()) - : queryClient.query(utils.events.watch.streamedOptions()) - await rejects(query, (error: unknown) => { - equal(error, rejection) - return true - }) - } finally { - queryClient.clear() - } -} - -const wait = async (deferred: Deferred.Deferred) => - await Effect.runPromise(Deferred.await(deferred).pipe(Effect.timeout('5 seconds'))) - -const settle = async (promise: PromiseLike) => { - try { - return { status: 'success', data: await promise } as const - } catch (error) { - return { status: 'error', error } as const - } -} - -const waitForValue = async (queryClient: QueryClient, key: QueryKey, value: string) => { - const published = Deferred.makeUnsafe() - const check = () => { - const data = queryClient.getQueryData(key) - if (data === value || (Array.isArray(data) && data.at(-1) === value)) { - Effect.runSync(Deferred.succeed(published, undefined)) - } - } - const unsubscribe = queryClient.getQueryCache().subscribe(check) - try { - check() - await wait(published) - } finally { - unsubscribe() - } -} - -await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - for (const operation of operations) { - const finalized = Deferred.makeUnsafe() - const client = yield* RpcTest.makeClient(group, { flatten: true }).pipe( - Effect.provide( - group.toLayer({ - 'events.watch': () => - Stream.make('first', 'second').pipe( - Stream.ensuring(Deferred.succeed(finalized, undefined).pipe(Effect.asVoid)), - ), - }), - ), - ) - const utils = createRpcQueryUtils(group, { client, keyPrefix: ['completion', operation] }) - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) - try { - const result = yield* Effect.promise( - async () => - await (operation === 'live' - ? queryClient.query(utils.events.watch.liveOptions()) - : queryClient.query(utils.events.watch.streamedOptions())), - ) - deepStrictEqual(result, operation === 'live' ? 'second' : ['first', 'second']) - yield* Effect.promise(() => wait(finalized)) - const key = - operation === 'live' ? utils.events.watch.liveKey() : utils.events.watch.streamedKey() - equal(queryClient.getQueryState(key)?.status, 'success') - equal(queryClient.getQueryState(key)?.fetchStatus, 'idle') - } finally { - queryClient.clear() - } - } - - for (const operation of operations) { - for (const cached of [false, true]) { - const started = Deferred.makeUnsafe() - const finalized = Deferred.makeUnsafe() - let signal: AbortSignal | undefined - const runPromiseExit: RunPromiseExit = async (effect, options) => { - signal = options?.signal - return await Effect.runPromiseExit(effect, options) - } - const client = yield* RpcTest.makeClient(group, { flatten: true }).pipe( - Effect.provide( - group.toLayer({ - 'events.watch': () => - Stream.fromEffect( - Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)), - ).pipe( - Stream.ensuring(Deferred.succeed(finalized, undefined).pipe(Effect.asVoid)), - ), - }), - ), - ) - const utils = createRpcQueryUtils(group, { - client, - keyPrefix: ['cancellation', operation, cached], - runPromiseExit, - }) - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) - const query = settle( - operation === 'live' - ? queryClient.query( - utils.events.watch.liveOptions(cached ? { initialData: 'cached' } : {}), - ) - : queryClient.query( - utils.events.watch.streamedOptions(cached ? { initialData: ['cached'] } : {}), - ), - ) - const key = - operation === 'live' ? utils.events.watch.liveKey() : utils.events.watch.streamedKey() - try { - yield* Effect.promise(() => wait(started)) - equal(signal?.aborted, false) - yield* Effect.promise(() => queryClient.cancelQueries({ queryKey: key, exact: true })) - yield* Effect.promise(() => wait(finalized)) - equal(signal?.aborted, true) - const result = yield* Effect.promise(() => query) - if (cached) { - equal(result.status, 'success') - ok(result.status === 'success') - deepStrictEqual(result.data, operation === 'live' ? 'cached' : ['cached']) - } else { - equal(result.status, 'error') - ok(result.status === 'error') - ok(isCancelledError(result.error)) - } - deepStrictEqual( - queryClient.getQueryData(key), - cached ? (operation === 'live' ? 'cached' : ['cached']) : undefined, - ) - equal(queryClient.getQueryState(key)?.status, cached ? 'success' : 'pending') - equal(queryClient.getQueryState(key)?.fetchStatus, 'idle') - equal(queryClient.getQueryState(key)?.error, null) - } finally { - queryClient.clear() - } - } - } - - for (const operation of operations) { - const started = [Deferred.makeUnsafe(), Deferred.makeUnsafe()] - const finalized = [Deferred.makeUnsafe(), Deferred.makeUnsafe()] - let invocation = 0 - const client = yield* RpcTest.makeClient(group, { flatten: true }).pipe( - Effect.provide( - group.toLayer({ - 'events.watch': () => { - const index = invocation++ - const start = started[index]! - const finish = finalized[index]! - return Stream.make(`run-${index + 1}`).pipe( - Stream.concat( - Stream.fromEffect( - Deferred.succeed(start, undefined).pipe(Effect.andThen(Effect.never)), - ), - ), - Stream.ensuring(Deferred.succeed(finish, undefined).pipe(Effect.asVoid)), - ) - }, - }), - ), - ) - const utils = createRpcQueryUtils(group, { client, keyPrefix: ['refetch', operation] }) - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) - const key = - operation === 'live' ? utils.events.watch.liveKey() : utils.events.watch.streamedKey() - const first = settle( - operation === 'live' - ? queryClient.query(utils.events.watch.liveOptions()) - : queryClient.query(utils.events.watch.streamedOptions()), - ) - try { - yield* Effect.promise(() => wait(started[0]!)) - yield* Effect.promise(() => waitForValue(queryClient, key, 'run-1')) - const refetch = queryClient.refetchQueries({ queryKey: key, exact: true }) - yield* Effect.promise(() => wait(finalized[0]!)) - yield* Effect.promise(() => wait(started[1]!)) - yield* Effect.promise(() => waitForValue(queryClient, key, 'run-2')) - deepStrictEqual(queryClient.getQueryData(key), operation === 'live' ? 'run-2' : ['run-2']) - yield* Effect.promise(() => queryClient.cancelQueries({ queryKey: key, exact: true })) - yield* Effect.promise(() => wait(finalized[1]!)) - yield* Effect.promise(() => refetch) - const firstResult = yield* Effect.promise(() => first) - if (firstResult.status === 'error') { - ok(isCancelledError(firstResult.error)) - } else { - deepStrictEqual(firstResult.data, operation === 'live' ? 'run-2' : ['run-2']) - } - equal(invocation, 2) - equal(queryClient.getQueryState(key)?.error, null) - equal(queryClient.getQueryState(key)?.fetchStatus, 'idle') - } finally { - queryClient.clear() - } - } - - for (const operation of operations) { - const started = Deferred.makeUnsafe() - const finalized = Deferred.makeUnsafe() - const client = yield* RpcTest.makeClient(group, { flatten: true }).pipe( - Effect.provide( - group.toLayer({ - 'events.watch': () => - Stream.make('ready').pipe( - Stream.concat( - Stream.fromEffect( - Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)), - ), - ), - Stream.ensuring(Deferred.succeed(finalized, undefined).pipe(Effect.asVoid)), - ), - }), - ), - ) - const utils = createRpcQueryUtils(group, { client, keyPrefix: ['observer', operation] }) - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) - const observer = - operation === 'live' - ? new QueryObserver(queryClient, utils.events.watch.liveOptions()) - : new QueryObserver(queryClient, utils.events.watch.streamedOptions()) - const unsubscribe = observer.subscribe(() => {}) - try { - yield* Effect.promise(() => wait(started)) - const key = - operation === 'live' ? utils.events.watch.liveKey() : utils.events.watch.streamedKey() - yield* Effect.promise(() => waitForValue(queryClient, key, 'ready')) - equal(observer.getCurrentResult().status, 'success') - unsubscribe() - yield* Effect.promise(() => wait(finalized)) - equal( - queryClient.getQueryCache().find({ queryKey: key, exact: true })?.getObserversCount(), - 0, - ) - equal(queryClient.getQueryState(key)?.error, null) - equal(queryClient.getQueryState(key)?.fetchStatus, 'idle') - } finally { - unsubscribe() - queryClient.clear() - } - } - }), - ), -) - -console.log('Packed stream Causes, runner rejections, cancellation, and lifecycle verified') diff --git a/tests/packed-consumer/stream-policy-runtime.mts b/tests/packed-consumer/stream-policy-runtime.mts deleted file mode 100644 index 4014872..0000000 --- a/tests/packed-consumer/stream-policy-runtime.mts +++ /dev/null @@ -1,355 +0,0 @@ -import { QueryClient, QueryObserver, skipToken } from '@tanstack/query-core' -import type { QueryKey } from '@tanstack/query-core' -import { Deferred, Effect, Schema, Stream } from 'effect' -import { createRpcQueryUtils, EffectRpcQueryConfigError } from 'effect-api-query' -import { Rpc, RpcGroup, RpcTest } from 'effect/rpc' -import { deepStrictEqual, equal, notDeepStrictEqual, ok, throws } from 'node:assert/strict' - -const Watch = Rpc.make('events.watch', { - payload: { channel: Schema.String }, - success: Schema.Number, - stream: true, -}) -const Audit = Rpc.make('events.audit.watch', { success: Schema.Number, stream: true }) -const Other = Rpc.make('other.watch', { success: Schema.Number, stream: true }) -const group = RpcGroup.make(Watch, Audit, Other) - -const waitFor = async (queryClient: QueryClient, check: () => boolean) => { - const ready = Deferred.makeUnsafe() - const observe = () => { - if (check()) { - Effect.runSync(Deferred.succeed(ready, undefined)) - } - } - const unsubscribe = queryClient.getQueryCache().subscribe(observe) - try { - observe() - await Effect.runPromise(Deferred.await(ready).pipe(Effect.timeout('5 seconds'))) - } finally { - unsubscribe() - } -} - -await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const client = yield* RpcTest.makeClient(group, { flatten: true }).pipe( - Effect.provide( - group.toLayer({ - 'events.watch': () => Stream.make(1, 2, 3, 4), - 'events.audit.watch': () => Stream.make(1, 2, 3, 4), - 'other.watch': () => Stream.make(1, 2, 3, 4), - }), - ), - ) - const utils = createRpcQueryUtils(group, { client, keyPrefix: ['stream-policy'] }) - const input = { channel: 'news' } - deepStrictEqual( - utils.events.watch.streamedKey(input), - utils.events.watch.streamedKey(input, { maxChunks: undefined, refetchMode: 'reset' }), - ) - deepStrictEqual( - utils.events.watch.streamedOptions({ input }).queryKey, - utils.events.watch.streamedOptions({ input, maxChunks: undefined, refetchMode: undefined }) - .queryKey, - ) - deepStrictEqual( - utils.events.audit.watch.streamedKey(), - utils.events.audit.watch.streamedKey({ maxChunks: undefined, refetchMode: 'reset' }), - ) - for (const refetchMode of ['reset', 'append', 'replace'] as const) { - for (const maxChunks of [undefined, 2]) { - const policy = { maxChunks, refetchMode } - deepStrictEqual( - utils.events.watch.streamedKey(input, policy), - utils.events.watch.streamedOptions({ input, ...policy }).queryKey, - ) - deepStrictEqual( - utils.events.audit.watch.streamedKey(policy), - utils.events.audit.watch.streamedOptions(policy).queryKey, - ) - } - } - notDeepStrictEqual( - utils.events.watch.streamedKey(input, { refetchMode: 'append' }), - utils.events.watch.streamedKey(input, { refetchMode: 'replace' }), - ) - const capturedPolicy = { maxChunks: 2, refetchMode: 'append' as const } - const capturedKey = utils.events.watch.streamedKey(input, capturedPolicy) - capturedPolicy.maxChunks = 4 - deepStrictEqual( - capturedKey, - utils.events.watch.streamedKey(input, { maxChunks: 2, refetchMode: 'append' }), - ) - equal(Object.isFrozen(capturedKey), true) - for (const order of [ - [4, 2], - [2, 4], - ]) { - const queryClient = new QueryClient() - try { - for (const maxChunks of order) { - const options = utils.events.watch.streamedOptions({ - input: { channel: 'news' }, - maxChunks, - staleTime: Infinity, - }) - const result = yield* Effect.promise(() => queryClient.query(options)) - deepStrictEqual(result, maxChunks === 2 ? [3, 4] : [1, 2, 3, 4]) - } - equal(queryClient.getQueryCache().getAll().length, 2) - } finally { - queryClient.clear() - } - } - - const streamKeys: QueryKey[] = [] - for (const refetchMode of ['reset', 'append', 'replace'] as const) { - for (const maxChunks of [undefined, 2]) { - streamKeys.push(utils.events.watch.streamedKey(input, { maxChunks, refetchMode })) - } - } - const keys = [ - ...streamKeys, - utils.events.watch.liveKey(input), - utils.events.audit.watch.streamedKey(), - utils.other.watch.streamedKey(), - ['unrelated'], - ] - const prefixes = [ - { key: utils.key(), count: 9 }, - { key: utils.events.key(), count: 8 }, - { key: utils.events.watch.key(), count: 7 }, - { key: [...utils.events.watch.key(), 'streamed'], count: 6 }, - ] - for (const prefix of prefixes) { - const queryClient = new QueryClient() - try { - for (const key of keys) { - queryClient.setQueryData(key, [1]) - } - equal(queryClient.getQueriesData({ queryKey: prefix.key }).length, prefix.count) - yield* Effect.promise(() => queryClient.invalidateQueries({ queryKey: prefix.key })) - deepStrictEqual( - keys.map((key) => queryClient.getQueryState(key)?.isInvalidated), - [ - ...Array.from({ length: prefix.count }, () => true), - ...Array.from({ length: 10 - prefix.count }, () => false), - ], - ) - } finally { - queryClient.clear() - } - } - - for (const [policy, code] of [ - ...[0, -1, 0.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, '2', null].map( - (maxChunks) => [{ maxChunks }, 'InvalidMaxChunks'] as const, - ), - ...['unknown', null, 3].map( - (refetchMode) => [{ refetchMode }, 'InvalidRefetchMode'] as const, - ), - ]) { - const builders = [ - () => Reflect.apply(utils.events.watch.streamedKey, undefined, [input, policy]), - () => Reflect.apply(utils.events.audit.watch.streamedKey, undefined, [policy]), - ...[input, skipToken].map( - (request) => () => - Reflect.apply(utils.events.watch.streamedOptions, undefined, [ - { input: request, ...policy }, - ]), - ), - ] - for (const build of builders) { - throws(build, (error: unknown) => { - ok(error instanceof EffectRpcQueryConfigError) - equal(error.code, code) - return true - }) - } - } - }), - ), -) - -await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const release = Deferred.makeUnsafe() - const client = yield* RpcTest.makeClient(RpcGroup.make(Watch), { flatten: true }).pipe( - Effect.provide( - RpcGroup.make(Watch).toLayer({ - 'events.watch': () => - Stream.make(1, 2, 3, 4).pipe( - Stream.concat(Stream.fromEffect(Deferred.await(release).pipe(Effect.as(5)))), - ), - }), - ), - ) - const utils = createRpcQueryUtils(RpcGroup.make(Watch), { - client, - keyPrefix: ['concurrent-stream-policy'], - }) - const queryClient = new QueryClient() - const narrow = utils.events.watch.streamedOptions({ - input: { channel: 'news' }, - maxChunks: 2, - }) - const wide = utils.events.watch.streamedOptions({ input: { channel: 'news' }, maxChunks: 4 }) - const narrowObserver = new QueryObserver(queryClient, narrow) - const wideObserver = new QueryObserver(queryClient, wide) - const unsubscribeNarrow = narrowObserver.subscribe(() => undefined) - const unsubscribeWide = wideObserver.subscribe(() => undefined) - try { - yield* Effect.promise(() => - waitFor( - queryClient, - () => - narrowObserver.getCurrentResult().data?.at(-1) === 4 && - wideObserver.getCurrentResult().data?.at(-1) === 4, - ), - ) - deepStrictEqual(narrowObserver.getCurrentResult().data, [3, 4]) - deepStrictEqual(wideObserver.getCurrentResult().data, [1, 2, 3, 4]) - equal(narrowObserver.getCurrentResult().fetchStatus, 'fetching') - equal(wideObserver.getCurrentResult().fetchStatus, 'fetching') - yield* Deferred.succeed(release, undefined) - yield* Effect.promise(() => - waitFor( - queryClient, - () => - narrowObserver.getCurrentResult().fetchStatus === 'idle' && - wideObserver.getCurrentResult().fetchStatus === 'idle', - ), - ) - deepStrictEqual(narrowObserver.getCurrentResult().data, [4, 5]) - deepStrictEqual(wideObserver.getCurrentResult().data, [2, 3, 4, 5]) - equal(queryClient.getQueryCache().getAll().length, 2) - } finally { - unsubscribeNarrow() - unsubscribeWide() - queryClient.clear() - } - }), - ), -) - -for (const maxChunks of [undefined, 3]) { - for (const refetchMode of ['reset', 'append', 'replace'] as const) { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - let values = [1, 2] - let release: Deferred.Deferred | undefined - const received = Deferred.makeUnsafe() - const watchGroup = RpcGroup.make(Watch) - const client = yield* RpcTest.makeClient(watchGroup, { flatten: true }).pipe( - Effect.provide( - watchGroup.toLayer({ - 'events.watch': () => { - const stream = Stream.fromIterable(values) - return release === undefined - ? stream - : stream.pipe( - Stream.concat( - Stream.fromEffect( - Deferred.succeed(received, undefined).pipe( - Effect.andThen(Deferred.await(release)), - ), - ).pipe(Stream.drain), - ), - ) - }, - }), - ), - ) - const utils = createRpcQueryUtils(watchGroup, { client, keyPrefix: ['refetch-policy'] }) - const queryClient = new QueryClient() - const options = utils.events.watch.streamedOptions({ - input: { channel: 'news' }, - maxChunks, - refetchMode, - initialData: [99], - }) - try { - deepStrictEqual(yield* Effect.promise(() => queryClient.query(options)), [99, 1, 2]) - values = [3, 4] - release = Deferred.makeUnsafe() - const pending = queryClient.refetchQueries({ queryKey: options.queryKey, exact: true }) - yield* Deferred.await(received).pipe(Effect.timeout('5 seconds')) - const appended = maxChunks === 3 ? [2, 3, 4] : [99, 1, 2, 3, 4] - if (refetchMode !== 'replace') { - yield* Effect.promise(() => - waitFor( - queryClient, - () => queryClient.getQueryData(options.queryKey)?.at(-1) === 4, - ), - ) - } - deepStrictEqual( - queryClient.getQueryData(options.queryKey), - refetchMode === 'replace' ? [99, 1, 2] : refetchMode === 'append' ? appended : [3, 4], - ) - equal(queryClient.getQueryState(options.queryKey)?.fetchStatus, 'fetching') - yield* Deferred.succeed(release, undefined) - yield* Effect.promise(() => pending) - deepStrictEqual( - queryClient.getQueryData(options.queryKey), - refetchMode === 'append' ? appended : [3, 4], - ) - equal(queryClient.getQueryState(options.queryKey)?.fetchStatus, 'idle') - values = [] - release = undefined - deepStrictEqual( - yield* Effect.promise(() => queryClient.query(options)), - refetchMode === 'append' ? appended : [], - ) - } finally { - queryClient.clear() - } - }), - ), - ) - } -} - -await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const watchGroup = RpcGroup.make(Watch) - const client = yield* RpcTest.makeClient(watchGroup, { flatten: true }).pipe( - Effect.provide(watchGroup.toLayer({ 'events.watch': () => Stream.make(8) })), - ) - const initialData: number[] = [] - initialData.length = 3 - initialData[0] = 1 - initialData[2] = 7 - const queryClient = new QueryClient() - const options = createRpcQueryUtils(watchGroup, { - client, - keyPrefix: ['seeded-history'], - }).events.watch.streamedOptions({ - input: { channel: 'news' }, - initialData, - maxChunks: 4, - refetchMode: 'append', - }) - try { - deepStrictEqual(yield* Effect.promise(() => queryClient.query(options)), [ - 1, - undefined, - 7, - 8, - ]) - equal(initialData.length, 3) - equal(initialData[0], 1) - equal(initialData[2], 7) - equal(1 in initialData, false) - } finally { - queryClient.clear() - } - }), - ), -) - -console.log('Packed stream policy identity, concurrent histories, refetches, and prefixes verified') diff --git a/tests/packed-consumer/svelte-angular-contract.ts b/tests/packed-consumer/svelte-angular-contract.ts deleted file mode 100644 index 55b0b26..0000000 --- a/tests/packed-consumer/svelte-angular-contract.ts +++ /dev/null @@ -1,314 +0,0 @@ -import { signal } from '@angular/core' -import { - injectInfiniteQuery, - injectMutation, - injectQuery, -} from '@tanstack/angular-query-experimental' -import { QueryClient, type InfiniteData } from '@tanstack/query-core' -import { createInfiniteQuery, createMutation, createQuery } from '@tanstack/svelte-query' -import { Context, Effect, Schema } from 'effect' -import { - createHttpApiQueryUtils, - createRpcQueryUtils, - skipToken, - type EffectHttpApiQueryEmptyStreamError, - type EffectHttpApiQueryError, - type EffectRpcQueryEmptyStreamError, - type EffectRpcQueryError, - type RunPromiseExit, -} from 'effect-api-query' -import type { HttpClientError } from 'effect/http' -import { - HttpApi, - HttpApiEndpoint, - HttpApiGroup, - HttpApiSchema, - type HttpApiClient, -} from 'effect/http-api' -import { Rpc, RpcGroup, type RpcClient } from 'effect/rpc' - -type Equal = - (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false -type Assert = T - -class Encoding extends Context.Service()( - 'Framework/Encoding', -) {} -const Payload = Schema.Struct({ id: Schema.Int }) -const ServicefulPayload = Payload.pipe( - Schema.middlewareEncoding((encode) => - Effect.flatMap(Encoding, () => encode), - ), -) -const User = Schema.Struct({ id: Schema.Int, name: Schema.String }) -const Read = Rpc.make('users.read', { - payload: ServicefulPayload, - success: User, - error: Schema.Literal('missing'), -}) -const Watch = Rpc.make('users.watch', { - payload: { id: Schema.Int }, - success: Schema.UndefinedOr(Schema.Int), - error: Schema.Literal('closed'), - stream: true, -}) -const group = RpcGroup.make(Read, Watch) -declare const readyRpc: RpcClient.RpcClient.Flat, 'transport'> -declare const runner: RunPromiseExit -const rpc = createRpcQueryUtils(group, { - client: readyRpc, - keyPrefix: ['framework', 'rpc-owner'], - runPromiseExit: runner, - keyEncoders: { 'users.read': (input) => input.id }, -}) -// @ts-expect-error The encoding service remains a runner requirement. -createRpcQueryUtils(group, { - client: readyRpc, - keyPrefix: ['missing'], - keyEncoders: { 'users.read': (input) => input.id }, -}) -const client = new QueryClient() -const selected = rpc.users.read.queryOptions({ - input: { id: 1 }, - initialData: { id: 1, name: 'initial' }, - select: (user) => user.name, - retry: (attempt, error) => { - error satisfies EffectRpcQueryError<'missing' | 'transport'> - return attempt < 2 - }, - enabled: (query) => query.state.data?.id !== 0, -}) -const svelteSelected = createQuery(() => selected) -const angularSelected = injectQuery(() => selected) -true satisfies Assert> -true satisfies Assert, string>> -true satisfies Assert< - Equal | null> -> -true satisfies Assert< - Equal< - ReturnType, - EffectRpcQueryError<'missing' | 'transport'> | null - > -> -const cached = client.getQueryData(selected.queryKey) -true satisfies Assert> -const id = signal(1) -const svelteReactive = createQuery(() => - rpc.users.read.queryOptions({ input: { id: id() }, select: (user) => user.name }), -) -const angularReactive = injectQuery(() => - rpc.users.read.queryOptions({ input: { id: id() }, select: (user) => user.name }), -) -true satisfies Assert> -true satisfies Assert, string | undefined>> -const skipped = rpc.users.read.queryOptions({ - input: skipToken, - initialData: { id: 1, name: 'initial' }, -}) -skipped.queryFn satisfies typeof skipToken -const svelteSkipped = createQuery(() => skipped) -true satisfies Assert> -const angularSkipped = injectQuery(() => skipped) -true satisfies Assert, typeof User.Type | undefined>> -const nativeAngularSkippedOptions: { - queryKey: readonly ['native-skipped'] - queryFn: typeof skipToken - initialData: typeof User.Type -} = { - queryKey: ['native-skipped'], - queryFn: skipToken, - initialData: { id: 1, name: 'initial' }, -} -const nativeAngularSkipped = injectQuery(() => nativeAngularSkippedOptions) -true satisfies Assert< - Equal, typeof User.Type | undefined> -> -const explicitlyUndefined = rpc.users.read.queryOptions({ - input: { id: 1 }, - initialData: undefined, -}) -const possiblyUndefined = rpc.users.read.queryOptions({ - input: { id: 1 }, - initialData: (): typeof User.Type | undefined => client.getQueryData(selected.queryKey), -}) -const svelteUndefined = createQuery(() => explicitlyUndefined) -const angularUndefined = injectQuery(() => explicitlyUndefined) -const sveltePossible = createQuery(() => possiblyUndefined) -const angularPossible = injectQuery(() => possiblyUndefined) -true satisfies Assert> -true satisfies Assert, typeof User.Type | undefined>> -true satisfies Assert> -true satisfies Assert, typeof User.Type | undefined>> -// @ts-expect-error Svelte expects an accessor around generated plain options. -createQuery(selected) -// @ts-expect-error Angular expects an accessor around generated plain options. -injectQuery(selected) -const conditional = rpc.users.read.queryOptions({ - input: Math.random() > 0.5 ? { id: 1 } : skipToken, -}) -createQuery(() => conditional) -injectQuery(() => conditional) -const mutation = rpc.users.read.mutationOptions({ - onMutate: (input) => ({ original: input.id }), - onSuccess: (user, input, transaction) => { - user.name satisfies string - input.id satisfies number - transaction?.original satisfies number | undefined - }, -}) -const svelteMutation = createMutation(() => mutation) -const angularMutation = injectMutation(() => mutation) -svelteMutation.mutate({ id: 1 }) -angularMutation.mutate({ id: 1 }) -true satisfies Assert> -true satisfies Assert, typeof User.Type | undefined>> -// @ts-expect-error Mutation variables retain the decoded input. -svelteMutation.mutate({ id: 'wrong' }) -// @ts-expect-error Mutation variables retain the decoded input. -angularMutation.mutate({ id: 'wrong' }) -const infinite = rpc.users.read.infiniteOptions({ - input: (cursor: number) => ({ id: cursor }), - initialPageParam: 0, - getNextPageParam: (last, _pages, cursor) => { - last.name satisfies string - cursor satisfies number - return cursor + 1 - }, - initialData: { pages: [{ id: 0, name: 'zero' }], pageParams: [0] }, - select: (value) => value.pages.map((user) => user.name), -}) -const svelteInfinite = createInfiniteQuery(() => infinite) -const angularInfinite = injectInfiniteQuery(() => infinite) -true satisfies Assert> -true satisfies Assert, string[]>> -const infiniteCache = client.getQueryData(infinite.queryKey) -true satisfies Assert< - Equal | undefined> -> -const history = rpc.users.watch.streamedOptions({ - input: { id: 1 }, - maxChunks: 4, - initialData: [1], - select: (values) => values.length, -}) -const live = rpc.users.watch.liveOptions({ - input: { id: 1 }, - initialData: null, - select: (value) => (value === null ? 'empty' : value.toFixed()), -}) -const svelteHistory = createQuery(() => history) -const angularHistory = injectQuery(() => history) -const svelteLive = createQuery(() => live) -const angularLive = injectQuery(() => live) -true satisfies Assert> -true satisfies Assert, number>> -true satisfies Assert> -true satisfies Assert, string>> -svelteLive.error satisfies - | EffectRpcQueryError<'closed' | 'transport'> - | EffectRpcQueryEmptyStreamError - | null -angularLive.error() satisfies - | EffectRpcQueryError<'closed' | 'transport'> - | EffectRpcQueryEmptyStreamError - | null -const historyCache = client.getQueryData(history.queryKey) -const liveCache = client.getQueryData(live.queryKey) -true satisfies Assert> -true satisfies Assert> -createQuery(() => rpc.users.watch.streamedOptions(skipToken)) -injectQuery(() => rpc.users.watch.liveOptions(skipToken)) - -const api = HttpApi.make('framework').add( - HttpApiGroup.make('values').add( - HttpApiEndpoint.get('read', '/values/:id', { - params: { id: Schema.FiniteFromString }, - success: User, - error: Schema.Literal('http-missing'), - }), - HttpApiEndpoint.get('watch', '/values/watch/:id', { - params: { id: Schema.FiniteFromString }, - success: HttpApiSchema.StreamSse({ data: Schema.Int, error: Schema.Literal('http-closed') }), - }), - ), -) -declare const readyHttp: HttpApiClient.ForApi -const http = createHttpApiQueryUtils(api, { - client: readyHttp, - keyPrefix: ['framework', 'http-owner'], - runPromiseExit: runner, -}) -// @ts-expect-error The HTTP client service remains a runner requirement. -createHttpApiQueryUtils(api, { client: readyHttp, keyPrefix: ['missing'] }) -const request = { params: { id: 1 } } -const httpQuery = http.values.read.queryOptions({ - input: request, - initialData: { id: 1, name: 'initial' }, - select: (user) => user.name, -}) -const svelteHttp = createQuery(() => httpQuery) -const angularHttp = injectQuery(() => httpQuery) -true satisfies Assert> -true satisfies Assert, string>> -const metadata = http.values.read.metadataOptions({ - input: request, - select: (view) => view.data.name, -}) -const svelteMetadata = createQuery(() => metadata) -const angularMetadata = injectQuery(() => metadata) -true satisfies Assert> -true satisfies Assert, string | undefined>> -svelteMetadata.error satisfies EffectHttpApiQueryError< - Schema.SchemaError | HttpClientError.HttpClientError | 'http-missing' | 'http-transport' -> | null -angularMetadata.error() satisfies EffectHttpApiQueryError< - Schema.SchemaError | HttpClientError.HttpClientError | 'http-missing' | 'http-transport' -> | null -const metadataCache = client.getQueryData(metadata.queryKey) -metadataCache?.data satisfies typeof User.Type | undefined -metadataCache?.status satisfies number | undefined -metadataCache?.headers satisfies Readonly> | undefined -const httpMutation = http.values.read.mutationOptions({ - onSuccess: (value, input) => { - value.name satisfies string - input.params.id satisfies number - }, -}) -createMutation(() => httpMutation).mutate(request) -injectMutation(() => httpMutation).mutate(request) -const httpInfinite = http.values.read.infiniteOptions({ - input: (cursor: number) => ({ params: { id: cursor } }), - initialPageParam: 0, - getNextPageParam: (_last, _pages, cursor) => cursor + 1, - select: (value) => value.pages.map((user) => user.name), -}) -createInfiniteQuery(() => httpInfinite).data satisfies string[] | undefined -injectInfiniteQuery(() => httpInfinite).data() satisfies string[] | undefined -const httpHistory = http.values.watch.streamedOptions({ - input: request, - maxChunks: 3, - initialData: [1], - select: (values) => values.length, -}) -const httpLive = http.values.watch.liveOptions({ - input: request, - initialData: 1, - select: (value) => value.toFixed(), -}) -const svelteHttpHistory = createQuery(() => httpHistory) -const angularHttpHistory = injectQuery(() => httpHistory) -const svelteHttpLive = createQuery(() => httpLive) -const angularHttpLive = injectQuery(() => httpLive) -true satisfies Assert> -true satisfies Assert, number>> -true satisfies Assert> -true satisfies Assert, string>> -svelteHttpLive.error satisfies - | EffectHttpApiQueryError - | EffectHttpApiQueryEmptyStreamError - | null -createQuery(() => http.values.read.queryOptions(skipToken)) -injectQuery(() => http.values.read.metadataOptions(skipToken)) -createQuery(() => http.values.watch.streamedOptions(skipToken)) -injectQuery(() => http.values.watch.liveOptions(skipToken)) diff --git a/tests/packed-consumer/svelte-angular-runtime.ts b/tests/packed-consumer/svelte-angular-runtime.ts deleted file mode 100644 index a310910..0000000 --- a/tests/packed-consumer/svelte-angular-runtime.ts +++ /dev/null @@ -1,193 +0,0 @@ -import '@angular/compiler' -import { Component, signal, provideZonelessChangeDetection } from '@angular/core' -import { bootstrapApplication } from '@angular/platform-browser' -import { - injectQuery, - provideTanStackQuery, - QueryClient as AngularQueryClient, -} from '@tanstack/angular-query-experimental' -import { QueryClient } from '@tanstack/query-core' -import { QueryClient as SvelteQueryClient } from '@tanstack/svelte-query' -import { mount, unmount } from 'svelte' - -import { expectEqual, makeFixture, waitFor } from './framework-fixture.ts' -import SvelteConsumer from './svelte-consumer.svelte' - -if (AngularQueryClient !== QueryClient || SvelteQueryClient !== QueryClient) { - throw new Error('Framework integrations must share Query Core bindings') -} - -type View = { - data: () => unknown - views: () => unknown - fetchStatus: () => string -} - -const verifyOwner = async ( - name: string, - fixture: Awaited>, - client: QueryClient, - host: HTMLElement, - view: View, - destroy: () => Promise | void, -) => { - await waitFor(() => view.data() === 1, `${name} did not expose the first open-stream value`) - await waitFor( - () => JSON.stringify(view.views()) === '[[11],101,[111],101]', - `${name} did not expose every first stream view`, - ) - expectEqual(view.fetchStatus(), 'fetching', `${name} first value remains open`) - expectEqual(client.isFetching(), 4, `${name} stream views remain open`) - host.querySelector('button')?.click() - await waitFor(() => view.data() === 2, `${name} did not rebuild options after input changed`) - await waitFor(() => fixture.finalized.includes(1), `${name} did not finalize the replaced stream`) - await waitFor( - () => JSON.stringify(view.views()) === '[[12],102,[112],102]', - `${name} views did not follow the changed input`, - ) - await waitFor( - () => host.querySelector('output')?.textContent === '[2,[[12],102,[112],102]]', - `${name} did not render its changed query results`, - ) - expectEqual( - host.querySelector('output')?.textContent, - '[2,[[12],102,[112],102]]', - `${name} rendered the changed views`, - ) - await destroy() - await waitFor( - () => fixture.finalized.length === 4 && fixture.httpFinalized.length === 4, - `${name} did not finalize every owned stream`, - ) - expectEqual( - [...fixture.finalized].sort((a, b) => a - b), - [1, 2, 11, 12], - `${name} owns all RPC streams`, - ) - expectEqual( - [...fixture.httpFinalized].sort((a, b) => a - b), - [101, 102, 111, 112], - `${name} owns all HTTP streams`, - ) -} - -const runSvelte = async () => { - const fixture = await makeFixture('svelte') - const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) - client.mount() - const host = document.createElement('div') - document.body.append(host) - let view: View | undefined - let mounted = true - const component = mount(SvelteConsumer, { - target: host, - props: { - fixture, - client, - expose: (value: View) => { - view = value - }, - }, - }) - try { - await waitFor(() => view !== undefined, 'Svelte did not initialize its component') - if (view === undefined) throw new Error('Svelte has no query result') - await verifyOwner('Svelte', fixture, client, host, view, async () => { - mounted = false - await unmount(component) - }) - } finally { - if (mounted) await unmount(component) - await client.cancelQueries() - client.clear() - client.unmount() - await fixture.close() - host.remove() - } -} - -const runAngular = async () => { - const fixture = await makeFixture('angular') - const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) - let instance: AngularConsumer | undefined - class AngularConsumer { - readonly id = signal(1) - readonly live = injectQuery(() => - fixture.utils.values.watch.liveOptions({ input: { id: this.id() } }), - ) - readonly history = injectQuery(() => - fixture.utils.values.watch.streamedOptions({ input: { id: this.id() + 10 }, maxChunks: 2 }), - ) - readonly httpLive = injectQuery(() => - fixture.http.values.watch.liveOptions({ input: { params: { id: this.id() + 100 } } }), - ) - readonly httpHistory = injectQuery(() => - fixture.http.values.watch.streamedOptions({ - input: { params: { id: this.id() + 110 } }, - maxChunks: 2, - }), - ) - readonly metadata = injectQuery(() => - fixture.http.values.read.metadataOptions({ - input: { params: { id: this.id() + 100 } }, - select: (value) => value.data, - }), - ) - readonly views = () => [ - this.history.data(), - this.httpLive.data(), - this.httpHistory.data(), - this.metadata.data(), - ] - readonly output = () => JSON.stringify([this.live.data(), this.views()]) - readonly increment = (value: number) => value + 1 - constructor() { - instance = this - } - } - Component({ - selector: 'angular-consumer', - standalone: true, - template: - '{{ output() }}', - })(AngularConsumer) - const host = document.createElement('angular-consumer') - document.body.append(host) - const application = await bootstrapApplication(AngularConsumer, { - providers: [provideZonelessChangeDetection(), provideTanStackQuery(client)], - }) - try { - if (instance === undefined) throw new Error('Angular did not initialize its component') - const owner = instance - await verifyOwner( - 'Angular', - fixture, - client, - host, - { - data: () => owner.live.data(), - views: owner.views, - fetchStatus: () => owner.live.fetchStatus(), - }, - () => application.destroy(), - ) - } finally { - if (!application.destroyed) application.destroy() - await client.cancelQueries() - client.clear() - await fixture.close() - host.remove() - } -} - -void runSvelte() - .then(runAngular) - .then( - () => { - document.documentElement.dataset['frameworkStatus'] = 'passed' - }, - (error: unknown) => { - document.documentElement.dataset['frameworkStatus'] = 'failed' - document.documentElement.dataset['frameworkMessage'] = String(error) - }, - ) diff --git a/tests/packed-consumer/svelte-consumer.svelte b/tests/packed-consumer/svelte-consumer.svelte deleted file mode 100644 index 3aba062..0000000 --- a/tests/packed-consumer/svelte-consumer.svelte +++ /dev/null @@ -1,50 +0,0 @@ - - - -{JSON.stringify([live.data, [history.data, httpLive.data, httpHistory.data, metadata.data]])} diff --git a/tests/packed-consumer/tsconfig.contract-baseline.json b/tests/packed-consumer/tsconfig.contract-baseline.json deleted file mode 100644 index 723ea99..0000000 --- a/tests/packed-consumer/tsconfig.contract-baseline.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "./tsconfig.json", - "include": ["public-contract.ts", "http-contract.ts", "http-stream-contract.ts"] -} diff --git a/tests/packed-consumer/tsconfig.http-baseline.json b/tests/packed-consumer/tsconfig.http-baseline.json deleted file mode 100644 index f5008e0..0000000 --- a/tests/packed-consumer/tsconfig.http-baseline.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "./tsconfig.json", - "include": ["http-type-scale.ts"] -} diff --git a/tests/packed-consumer/tsconfig.json b/tests/packed-consumer/tsconfig.json deleted file mode 100644 index fbf29e1..0000000 --- a/tests/packed-consumer/tsconfig.json +++ /dev/null @@ -1,67 +0,0 @@ -{ - "compilerOptions": { - "lib": ["ES2022", "DOM", "DOM.Iterable", "ESNext.Disposable"], - "module": "NodeNext", - "moduleResolution": "NodeNext", - "noEmit": true, - "allowImportingTsExtensions": true, - "exactOptionalPropertyTypes": true, - "noUncheckedIndexedAccess": true, - "skipLibCheck": false, - "strict": true, - "target": "ES2022", - "types": ["node"] - }, - "include": [ - "construction-baseline.mts", - "owner-cache.ts", - "docs-owner-cache.ts", - "owner-cache-contract.ts", - "owner-cache-runtime.mts", - "start-ssr-runtime.mts", - "optimistic-runtime.mts", - "optimistic-contract.ts", - "events-runtime.mts", - "socket-client.ts", - "docs-websocket-use.ts", - "websocket-checks.ts", - "websocket-server.ts", - "websocket-runtime.mts", - "events-contract.ts", - "docs-user-events.ts", - "user-events.ts", - "hashing-runtime.mts", - "filter-runtime.mts", - "filter-contract.ts", - "docs-cache-filters-*.ts", - "runtime.mts", - "stream-cause-runtime.mts", - "stream-policy-runtime.mts", - "retry-runtime.mts", - "snapshot-runtime.mts", - "snapshot-contract.ts", - "defaults-runtime.mts", - "defaults-contract.ts", - "docs-defaults.ts", - "docs-http-defaults.ts", - "hydration-runtime.mts", - "hydration-views-runtime.mts", - "hydration-views-contract.ts", - "http-runtime.mts", - "http-metadata-runtime.mts", - "http-etag-recipe.mts", - "http-stream-runtime.mts", - "http-live-runtime.mts", - "http-stream-contract.ts", - "public-contract.ts", - "http-contract.ts", - "hosts/**/*.ts", - "docs-rpc-quick-start.ts", - "docs-http-quick-start.ts", - "docs-retry.ts", - "docs-stream-snapshot.ts", - "docs-hydration-*.ts", - "hydration-contract.ts", - "skill-example-*.ts" - ] -} diff --git a/tests/packed-consumer/tsconfig.openapi.json b/tests/packed-consumer/tsconfig.openapi.json deleted file mode 100644 index 810f824..0000000 --- a/tests/packed-consumer/tsconfig.openapi.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "./tsconfig.json", - "include": ["openapi-*.ts", "openapi-*.mts", "docs-openapi.ts"] -} diff --git a/tests/packed-consumer/tsconfig.rpc-baseline.json b/tests/packed-consumer/tsconfig.rpc-baseline.json deleted file mode 100644 index 9ef0a98..0000000 --- a/tests/packed-consumer/tsconfig.rpc-baseline.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "./tsconfig.json", - "include": ["type-scale.ts"] -} diff --git a/tests/packed-consumer/tsconfig.svelte-angular.json b/tests/packed-consumer/tsconfig.svelte-angular.json deleted file mode 100644 index 9953bf8..0000000 --- a/tests/packed-consumer/tsconfig.svelte-angular.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "lib": ["ES2022", "DOM", "DOM.Iterable", "ESNext.Disposable"], - "module": "ESNext", - "moduleResolution": "Bundler", - "noEmit": true, - "allowImportingTsExtensions": true, - "exactOptionalPropertyTypes": true, - "noUncheckedIndexedAccess": true, - "skipLibCheck": false, - "strict": true, - "target": "ES2022", - "types": ["node"] - }, - "include": ["svelte-angular-contract.ts", "docs-angular.ts"] -} diff --git a/tests/packed-consumer/tsconfig.tanstack-start.json b/tests/packed-consumer/tsconfig.tanstack-start.json deleted file mode 100644 index 6dbaad5..0000000 --- a/tests/packed-consumer/tsconfig.tanstack-start.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "skipLibCheck": true - }, - "include": ["public-contract.ts", "tanstack-start-contract.ts"] -} diff --git a/tests/packed-consumer/tsconfig.vue-solid.json b/tests/packed-consumer/tsconfig.vue-solid.json deleted file mode 100644 index a2418f3..0000000 --- a/tests/packed-consumer/tsconfig.vue-solid.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { "types": ["node"] }, - "include": [ - "vue-solid-contract.ts", - "vue-solid-runtime.ts", - "docs-vue-query.ts", - "docs-solid-query.ts" - ] -} diff --git a/tests/packed-consumer/vue-solid-contract.ts b/tests/packed-consumer/vue-solid-contract.ts deleted file mode 100644 index 69a4a99..0000000 --- a/tests/packed-consumer/vue-solid-contract.ts +++ /dev/null @@ -1,412 +0,0 @@ -import { - QueryClient, - type InfiniteData, - type InferDataFromTag, - type InferErrorFromTag, - type QueryKey, -} from '@tanstack/query-core' -import { - useInfiniteQuery as useSolidInfiniteQuery, - useMutation as useSolidMutation, - useQuery as useSolidQuery, -} from '@tanstack/solid-query' -import { - useInfiniteQuery as useVueInfiniteQuery, - useMutation as useVueMutation, - useQuery as useVueQuery, -} from '@tanstack/vue-query' -import { Context, Effect, Schema, SchemaTransformation } from 'effect' -import { - createHttpApiQueryUtils, - createRpcQueryUtils, - skipToken, - type CreateHttpApiQueryUtilsOptions, - type CreateRpcQueryUtilsOptions, - type EffectHttpApiQueryEmptyStreamError, - type EffectHttpApiQueryError, - type EffectRpcQueryEmptyStreamError, - type EffectRpcQueryError, - type RunPromiseExit, -} from 'effect-api-query' -import { - HttpApi, - HttpApiEndpoint, - HttpApiGroup, - HttpApiSchema, - type HttpApiClient, -} from 'effect/http-api' -import { Rpc, type RpcClient, RpcGroup } from 'effect/rpc' -import { createSignal } from 'solid-js' -import { computed, ref } from 'vue' - -type Equal = - (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false -type Assert = T -class Encoder extends Context.Service()( - 'Framework/Encoder', -) {} -class Decoder extends Context.Service()( - 'Framework/Decoder', -) {} -const Payload = Schema.Struct({ id: Schema.Int }) -const EncodedPayload = Payload.pipe( - Schema.middlewareEncoding((encoding) => - Effect.flatMap(Encoder, () => encoding), - ), -) -const User = Schema.Struct({ id: Schema.Int, name: Schema.String }) -const Read = Rpc.make('users.read', { - payload: EncodedPayload, - success: User, - error: Schema.Literal('missing'), -}) -const Watch = Rpc.make('users.watch', { - payload: { id: Schema.Int }, - success: Schema.UndefinedOr(Schema.Int), - error: Schema.Literal('closed'), - stream: true, -}) -const group = RpcGroup.make(Read, Watch) -declare const rpcClient: RpcClient.RpcClient.Flat, 'transport'> -declare const rpcRunner: RunPromiseExit -const rpc = createRpcQueryUtils(group, { - client: rpcClient, - keyPrefix: ['framework', 'rpc-owner'], - runPromiseExit: rpcRunner, - keyEncoders: { - 'users.read': (input) => { - input.id satisfies number - return input.id - }, - }, -}) -type RpcRunner = CreateRpcQueryUtilsOptions< - typeof group, - readonly ['framework'], - 'transport' ->['runPromiseExit'] -true satisfies Assert>> -// @ts-expect-error Encoding requires the caller's serviceful runner. -createRpcQueryUtils(group, { - client: rpcClient, - keyPrefix: ['missing'], - keyEncoders: { 'users.read': (input) => input.id }, -}) -const client = new QueryClient() -const rpcOptions = rpc.users.read.queryOptions({ - input: { id: 1 }, - initialData: { id: 1, name: 'initial' }, - select: (user) => user.name, - staleTime: Infinity, - gcTime: 60_000, - retry: (attempt, error) => { - error satisfies EffectRpcQueryError<'missing' | 'transport'> - return attempt < 2 - }, -}) -const cached = client.getQueryData(rpcOptions.queryKey) -true satisfies Assert> -const vueSelected = useVueQuery< - typeof User.Type, - EffectRpcQueryError<'missing' | 'transport'>, - string ->(rpcOptions) -true satisfies Assert> -vueSelected.error.value satisfies EffectRpcQueryError<'missing' | 'transport'> | null -const solidSelected = useSolidQuery(() => rpcOptions) -true satisfies Assert> -true satisfies Assert< - Equal | null> -> -const vueId = ref(1) -const vueReactiveOptions = computed(() => - rpc.users.read.queryOptions({ input: { id: vueId.value }, select: (user) => user.name }), -) -const vueReactive = useVueQuery< - typeof User.Type, - EffectRpcQueryError<'missing' | 'transport'>, - string ->(vueReactiveOptions) -true satisfies Assert> -const [solidId] = createSignal(1) -const solidReactive = useSolidQuery(() => - rpc.users.read.queryOptions({ input: { id: solidId() }, select: (user) => user.name }), -) -true satisfies Assert> -const skipped = rpc.users.read.queryOptions({ - input: skipToken, - initialData: { id: 1, name: 'initial' }, -}) -skipped.queryFn satisfies typeof skipToken -const vueSkipped = useVueQuery>( - skipped, -) -const solidSkipped = useSolidQuery(() => skipped) -vueSkipped.data.value satisfies typeof User.Type | undefined -true satisfies Assert> -const conditional = rpc.users.read.queryOptions({ - input: Math.random() > 0.5 ? { id: 1 } : skipToken, -}) -true satisfies Assert, never>> -useVueQuery>(conditional) -useSolidQuery(() => conditional) -const vueEnabled = rpc.users.read.queryOptions({ - input: { id: 1 }, - enabled: () => vueId.value > 0, -}) -useVueQuery>(vueEnabled) -const coreEnabled = rpc.users.read.queryOptions({ - input: { id: 1 }, - enabled: (query) => { - query.state.data satisfies typeof User.Type | undefined - query.state.error satisfies EffectRpcQueryError<'missing' | 'transport'> | null - return query.state.data?.id !== 0 - }, -}) -useSolidQuery(() => coreEnabled) -// @ts-expect-error Vue's enabled getter receives no Core Query argument. -useVueQuery>(coreEnabled) -const maybeSeed = rpc.users.read.queryOptions({ - input: { id: 1 }, - initialData: () => client.getQueryData(rpc.users.read.queryKey({ id: 1 })), -}) -const vueMaybeSeed = useVueQuery>( - maybeSeed, -) -true satisfies Assert> -// @ts-expect-error Solid's pinned overloads require defined or undefined initialData. -useSolidQuery(() => maybeSeed) -const explicitUndefinedSeed = rpc.users.read.queryOptions({ - input: { id: 1 }, - initialData: undefined, -}) -const solidUndefinedSeed = useSolidQuery(() => explicitUndefinedSeed) -true satisfies Assert> -const mutationOptions = rpc.users.read.mutationOptions({ - onMutate: (input) => { - input.id satisfies number - return { owner: 'original' as const } - }, - onSuccess: (data, input, transaction) => { - data satisfies typeof User.Type - input.id satisfies number - transaction?.owner satisfies 'original' | undefined - }, -}) -const vueMutation = useVueMutation(mutationOptions) -const solidMutation = useSolidMutation(() => mutationOptions) -vueMutation.mutate({ id: 1 }) -solidMutation.mutate({ id: 1 }) -vueMutation.data.value satisfies typeof User.Type | undefined -true satisfies Assert> -// @ts-expect-error The installed Vue mutation retains the declared payload. -vueMutation.mutate({ id: 'wrong' }) -// @ts-expect-error The installed Solid mutation retains the declared payload. -solidMutation.mutate({ id: 'wrong' }) -const infiniteOptions = rpc.users.read.infiniteOptions({ - input: (cursor: number) => ({ id: cursor }), - initialPageParam: 0, - getNextPageParam: (last, _pages, lastParam) => { - last.name satisfies string - lastParam satisfies number - return lastParam + 1 - }, - initialData: { pages: [{ id: 0, name: 'zero' }], pageParams: [0] }, - select: (value) => value.pages.map((user) => user.name), -}) -const vueInfinite = useVueInfiniteQuery< - typeof User.Type, - EffectRpcQueryError<'missing' | 'transport'>, - string[], - QueryKey, - number ->(infiniteOptions) -const solidInfinite = useSolidInfiniteQuery(() => infiniteOptions) -vueInfinite.data.value satisfies string[] | undefined -true satisfies Assert> -const infiniteCache = client.getQueryData(infiniteOptions.queryKey) -true satisfies Assert< - Equal | undefined> -> -const accumulated = rpc.users.watch.streamedOptions({ - input: { id: 1 }, - maxChunks: 4, - refetchMode: 'append', - select: (values) => values.length, - initialData: [1], -}) -const live = rpc.users.watch.liveOptions({ - input: { id: 1 }, - initialData: null, - select: (value) => (value === null ? 'empty' : value.toFixed()), -}) -const vueHistory = useVueQuery< - readonly (number | undefined)[], - EffectRpcQueryError<'closed' | 'transport'>, - number ->(accumulated) -const solidHistory = useSolidQuery(() => accumulated) -true satisfies Assert> -true satisfies Assert> -const vueLive = useVueQuery< - number | null, - EffectRpcQueryError<'closed' | 'transport'> | EffectRpcQueryEmptyStreamError, - string ->(live) -const solidLive = useSolidQuery(() => live) -true satisfies Assert> -true satisfies Assert> -solidLive.error satisfies - | EffectRpcQueryError<'closed' | 'transport'> - | EffectRpcQueryEmptyStreamError - | null -const liveCache = client.getQueryData(live.queryKey) -true satisfies Assert> -const historyCache = client.getQueryData(accumulated.queryKey) -true satisfies Assert> -useVueQuery< - number | null, - EffectRpcQueryError<'closed' | 'transport'> | EffectRpcQueryEmptyStreamError ->(rpc.users.watch.liveOptions(skipToken)) -useSolidQuery(() => rpc.users.watch.streamedOptions(skipToken)) - -const Decoded = Schema.Finite.pipe( - Schema.decodeTo( - Schema.Finite, - SchemaTransformation.transformEffect({ - decode: (value) => Decoder.pipe(Effect.map(({ factor }) => value * factor)), - encode: Effect.succeed, - }), - ), -) -const api = HttpApi.make('framework').add( - HttpApiGroup.make('values').add( - HttpApiEndpoint.get('read', '/values/:id', { - params: { id: Schema.FiniteFromString }, - success: User, - error: Schema.Literal('http-missing'), - }), - HttpApiEndpoint.get('watch', '/values/watch/:id', { - params: { id: Schema.FiniteFromString }, - success: HttpApiSchema.StreamSse({ data: Decoded, error: Schema.Literal('http-closed') }), - }), - ), -) -declare const httpClient: HttpApiClient.ForApi -declare const httpRunner: RunPromiseExit -const http = createHttpApiQueryUtils(api, { - client: httpClient, - keyPrefix: ['framework', 'http-owner'], - runPromiseExit: httpRunner, - keyEncoders: { - values: { - read: (request) => { - request.params.id satisfies number - return request.params.id - }, - }, - }, -}) -type HttpRunner = CreateHttpApiQueryUtilsOptions< - typeof api, - readonly ['framework'], - typeof httpClient ->['runPromiseExit'] -true satisfies Assert>> -// @ts-expect-error Decoding and client services remain application-owned. -createHttpApiQueryUtils(api, { client: httpClient, keyPrefix: ['missing'] }) -const request = { params: { id: 1 } } -const httpOptions = http.values.read.queryOptions({ - input: request, - initialData: { id: 1, name: 'initial' }, - select: (user) => user.name, -}) -const vueHttp = useVueQuery< - InferDataFromTag, - InferErrorFromTag, - string ->(httpOptions) -const solidHttp = useSolidQuery(() => httpOptions) -true satisfies Assert> -true satisfies Assert> -const metadata = http.values.read.metadataOptions({ - input: request, - select: (view) => view.data.name, -}) -const vueMetadata = useVueQuery< - InferDataFromTag, - InferErrorFromTag, - string ->(metadata) -const solidMetadata = useSolidQuery(() => metadata) -true satisfies Assert> -true satisfies Assert> -const metadataState = client.getQueryState(metadata.queryKey) -type HttpError = NonNullable['error'] -solidMetadata.error satisfies HttpError -vueMetadata.error.value satisfies HttpError -const metadataCache = client.getQueryData(metadata.queryKey) -metadataCache?.data satisfies typeof User.Type | undefined -metadataCache?.status satisfies number | undefined -metadataCache?.headers satisfies Readonly> | undefined -const httpMutation = http.values.read.mutationOptions({ - onSuccess: (value, input) => { - value.name satisfies string - input.params.id satisfies number - }, -}) -useVueMutation(httpMutation).mutate(request) -useSolidMutation(() => httpMutation).mutate(request) -const httpInfinite = http.values.read.infiniteOptions({ - input: (cursor: number) => ({ params: { id: cursor } }), - initialPageParam: 0, - getNextPageParam: (_last, _pages, cursor) => cursor + 1, - select: (value) => value.pages.map((user) => user.name), -}) -useVueInfiniteQuery< - typeof User.Type, - InferErrorFromTag, - string[], - QueryKey, - number ->(httpInfinite).data.value satisfies string[] | undefined -useSolidInfiniteQuery(() => httpInfinite).data satisfies string[] | undefined -const httpHistory = http.values.watch.streamedOptions({ - input: request, - maxChunks: 3, - select: (values) => values.length, - initialData: [1], -}) -const httpLive = http.values.watch.liveOptions({ - input: request, - initialData: 1, - select: (value) => value.toFixed(), -}) -const vueHttpHistory = useVueQuery< - InferDataFromTag, - InferErrorFromTag, - number ->(httpHistory) -const solidHttpHistory = useSolidQuery(() => httpHistory) -const vueHttpLive = useVueQuery< - InferDataFromTag, - InferErrorFromTag, - string ->(httpLive) -const solidHttpLive = useSolidQuery(() => httpLive) -true satisfies Assert> -true satisfies Assert> -true satisfies Assert> -true satisfies Assert> -solidHttpLive.error satisfies - | EffectHttpApiQueryError - | EffectHttpApiQueryEmptyStreamError - | null -useVueQuery>( - http.values.read.queryOptions(skipToken), -) -useSolidQuery(() => http.values.read.metadataOptions(skipToken)) -useVueQuery>( - http.values.watch.streamedOptions(skipToken), -) -useSolidQuery(() => http.values.watch.liveOptions(skipToken)) diff --git a/tests/packed-consumer/vue-solid-runtime.ts b/tests/packed-consumer/vue-solid-runtime.ts deleted file mode 100644 index 36035cd..0000000 --- a/tests/packed-consumer/vue-solid-runtime.ts +++ /dev/null @@ -1,242 +0,0 @@ -import type { InferDataFromTag, InferErrorFromTag } from '@tanstack/query-core' -import { - QueryClient as SolidQueryClient, - QueryClientProvider, - useQuery as useSolidQuery, -} from '@tanstack/solid-query' -import { - QueryClient as VueQueryClient, - VueQueryPlugin, - useQuery as useVueQuery, -} from '@tanstack/vue-query' -import { type EffectRpcQueryError, type EffectRpcQueryEmptyStreamError } from 'effect-api-query' -import { createComponent, createEffect, createSignal } from 'solid-js' -import { render } from 'solid-js/web' -import { computed, createApp, defineComponent, h, ref } from 'vue' - -import { expectEqual, makeFixture, waitFor } from './framework-fixture.ts' - -const runVue = async () => { - const fixture = await makeFixture('vue') - const client = new VueQueryClient({ defaultOptions: { queries: { retry: false } } }) - const id = ref(1) - const enabled = ref(false) - let data: () => unknown = () => undefined - let fetchStatus: () => string = () => 'idle' - let views: () => unknown = () => undefined - const host = document.createElement('div') - document.body.append(host) - const app = createApp( - defineComponent({ - setup() { - const result = useVueQuery< - number, - EffectRpcQueryError | EffectRpcQueryEmptyStreamError - >( - computed(() => - fixture.utils.values.watch.liveOptions({ - input: { id: id.value }, - enabled: () => enabled.value, - }), - ), - ) - const historyOptions = computed(() => - fixture.utils.values.watch.streamedOptions({ - input: { id: id.value + 10 }, - maxChunks: 2, - }), - ) - const history = useVueQuery< - InferDataFromTag, - InferErrorFromTag - >(historyOptions) - const liveOptions = computed(() => - fixture.http.values.watch.liveOptions({ input: { params: { id: id.value + 100 } } }), - ) - const httpLive = useVueQuery< - InferDataFromTag, - InferErrorFromTag - >(liveOptions) - const httpOptions = computed(() => - fixture.http.values.watch.streamedOptions({ - input: { params: { id: id.value + 110 } }, - maxChunks: 2, - }), - ) - const httpHistory = useVueQuery< - InferDataFromTag, - InferErrorFromTag - >(httpOptions) - const metaOptions = computed(() => - fixture.http.values.read.metadataOptions({ - input: { params: { id: id.value + 100 } }, - select: (value) => value.data, - }), - ) - const metadata = useVueQuery< - InferDataFromTag, - InferErrorFromTag, - number - >(metaOptions) - data = () => result.data.value - fetchStatus = () => result.fetchStatus.value - views = () => [ - history.data.value, - httpLive.data.value, - httpHistory.data.value, - metadata.data.value, - ] - return () => h('output', JSON.stringify([result.data.value, views()])) - }, - }), - ).use(VueQueryPlugin, { queryClient: client }) - try { - app.mount(host) - expectEqual(data(), undefined, 'Vue keeps a disabled stream empty') - expectEqual(fetchStatus(), 'idle', 'Vue does not fetch its disabled stream') - enabled.value = true - await waitFor(() => data() === 1, 'Vue did not expose the first open-stream value') - await waitFor( - () => JSON.stringify(views()) === '[[11],101,[111],101]', - 'Vue did not expose every first stream view', - ) - expectEqual(fetchStatus(), 'fetching', 'Vue first value remains open') - expectEqual(client.isFetching(), 4, 'Vue stream views remain open') - id.value = 2 - await waitFor(() => data() === 2, 'Vue did not rebuild options after input changed') - await waitFor(() => fixture.finalized.includes(1), 'Vue did not finalize the replaced stream') - await waitFor( - () => JSON.stringify(views()) === '[[12],102,[112],102]', - 'Vue views did not follow the changed input', - ) - expectEqual(host.textContent, '[2,[[12],102,[112],102]]', 'Vue rendered the changed views') - app.unmount() - await waitFor(() => fixture.finalized.includes(2), 'Vue did not finalize its unmounted stream') - await waitFor( - () => fixture.finalized.length === 4 && fixture.httpFinalized.length === 4, - 'Vue did not finalize every owned stream', - ) - expectEqual( - [...fixture.finalized].sort((a, b) => a - b), - [1, 2, 11, 12], - 'Vue owns all RPC streams', - ) - expectEqual( - [...fixture.httpFinalized].sort((a, b) => a - b), - [101, 102, 111, 112], - 'Vue owns all HTTP streams', - ) - } finally { - app.unmount() - await client.cancelQueries() - client.clear() - await fixture.close() - host.remove() - } -} - -const runSolid = async () => { - const fixture = await makeFixture('solid') - const client = new SolidQueryClient({ defaultOptions: { queries: { retry: false } } }) - const [id, setId] = createSignal(1) - let data: () => unknown = () => undefined - let fetchStatus: () => string = () => 'idle' - let views: () => unknown = () => undefined - const host = document.createElement('div') - document.body.append(host) - const Component = () => { - const result = useSolidQuery(() => - fixture.utils.values.watch.liveOptions({ input: { id: id() } }), - ) - const history = useSolidQuery(() => - fixture.utils.values.watch.streamedOptions({ input: { id: id() + 10 }, maxChunks: 2 }), - ) - const httpLive = useSolidQuery(() => - fixture.http.values.watch.liveOptions({ input: { params: { id: id() + 100 } } }), - ) - const httpHistory = useSolidQuery(() => - fixture.http.values.watch.streamedOptions({ - input: { params: { id: id() + 110 } }, - maxChunks: 2, - }), - ) - const metadata = useSolidQuery(() => - fixture.http.values.read.metadataOptions({ - input: { params: { id: id() + 100 } }, - select: (value) => value.data, - }), - ) - data = () => result.data - fetchStatus = () => result.fetchStatus - views = () => [history.data, httpLive.data, httpHistory.data, metadata.data] - const output = document.createElement('output') - createEffect(() => { - output.textContent = JSON.stringify([result.data, views()]) - }) - return output - } - const dispose = render( - () => - createComponent(QueryClientProvider, { - client, - get children() { - return createComponent(Component, {}) - }, - }), - host, - ) - try { - await waitFor(() => data() === 1, 'Solid did not expose the first open-stream value') - await waitFor( - () => JSON.stringify(views()) === '[[11],101,[111],101]', - 'Solid did not expose every first stream view', - ) - expectEqual(fetchStatus(), 'fetching', 'Solid first value remains open') - expectEqual(client.isFetching(), 4, 'Solid stream views remain open') - setId(2) - await waitFor(() => data() === 2, 'Solid did not rebuild options after input changed') - await waitFor(() => fixture.finalized.includes(1), 'Solid did not finalize the replaced stream') - await waitFor( - () => JSON.stringify(views()) === '[[12],102,[112],102]', - 'Solid views did not follow the changed input', - ) - expectEqual(host.textContent, '[2,[[12],102,[112],102]]', 'Solid rendered the changed views') - dispose() - await waitFor( - () => fixture.finalized.includes(2), - 'Solid did not finalize its unmounted stream', - ) - await waitFor( - () => fixture.finalized.length === 4 && fixture.httpFinalized.length === 4, - 'Solid did not finalize every owned stream', - ) - expectEqual( - [...fixture.finalized].sort((a, b) => a - b), - [1, 2, 11, 12], - 'Solid owns all RPC streams', - ) - expectEqual( - [...fixture.httpFinalized].sort((a, b) => a - b), - [101, 102, 111, 112], - 'Solid owns all HTTP streams', - ) - } finally { - dispose() - await client.cancelQueries() - client.clear() - await fixture.close() - host.remove() - } -} - -void runVue() - .then(runSolid) - .then( - () => { - document.documentElement.dataset['frameworkStatus'] = 'passed' - }, - (error: unknown) => { - document.documentElement.dataset['frameworkStatus'] = 'failed' - document.documentElement.dataset['frameworkMessage'] = String(error) - }, - ) diff --git a/tests/packed-consumer/websocket-runtime.mts b/tests/packed-consumer/websocket-runtime.mts deleted file mode 100644 index c27020f..0000000 --- a/tests/packed-consumer/websocket-runtime.mts +++ /dev/null @@ -1,13 +0,0 @@ -import { - exerciseSocketConcurrency, - exerciseSocketInterruption, - exerciseSocketReplacement, -} from './websocket-checks.ts' - -await exerciseSocketConcurrency() -await exerciseSocketInterruption() -await exerciseSocketReplacement() - -console.log( - 'Packed WebSocket concurrent queries, wire Causes, remote cancellation, and owner replacement executed', -) diff --git a/tests/stream-snapshot.test.ts b/tests/stream-snapshot.test.ts new file mode 100644 index 0000000..bbbf324 --- /dev/null +++ b/tests/stream-snapshot.test.ts @@ -0,0 +1,177 @@ +import { it } from '@effect/vitest' +import { QueryClient, QueryObserver } from '@tanstack/query-core' +import { Deferred, Effect, Exit, Schema, Stream } from 'effect' +import { Rpc, RpcGroup } from 'effect/rpc' +import { describe, expect, it as test } from 'vite-plus/test' + +import { createRpcQueryUtils, fetchStreamSnapshot } from '#effect-api-query' + +import { captureFailure } from './fixtures/async.ts' +import { makeRpcTestClient } from './fixtures/effect-rpc.ts' + +describe('stream snapshots', () => { + it.effect( + 'uses cached snapshots without acquiring a stream and requires ownership for fresh capture', + () => + Effect.gen(function* () { + const group = RpcGroup.make(Rpc.make('watch', { success: Schema.Int, stream: true })) + const entered = yield* Deferred.make() + const emit = yield* Deferred.make() + const finalized = yield* Deferred.make() + let executions = 0 + const client = yield* makeRpcTestClient(group, { + watch: () => + Stream.fromEffect( + Effect.sync(() => { + executions += 1 + }).pipe( + Effect.andThen(Deferred.succeed(entered, undefined)), + Effect.andThen(Deferred.await(emit)), + Effect.as(1), + ), + ).pipe( + Stream.concat(Stream.never), + Stream.ensuring(Deferred.succeed(finalized, undefined)), + ), + }) + const rpc = createRpcQueryUtils(group, { client, keyPrefix: ['snapshot'] }) + const options = rpc.watch.liveOptions({ retry: false, staleTime: Infinity }) + const queryClient = new QueryClient() + try { + queryClient.setQueryData(options.queryKey, 0) + expect( + yield* Effect.promise( + async () => await fetchStreamSnapshot(queryClient, options, { mode: 'cached' }), + ), + ).toBe(0) + expect(executions).toBe(0) + const pending = captureFailure(fetchStreamSnapshot(queryClient, options)) + yield* Deferred.await(entered) + yield* Effect.promise(async () => { + await expect(fetchStreamSnapshot(queryClient, options)).rejects.toThrow( + 'idle query without observers', + ) + }) + expect(Deferred.isDoneUnsafe(finalized)).toBe(false) + yield* Deferred.succeed(emit, undefined) + expect(yield* Effect.promise(async () => await pending)).toBe(1) + yield* Deferred.await(finalized) + expect(executions).toBe(1) + const observer = new QueryObserver(queryClient, { ...options, enabled: false }) + const stop = observer.subscribe(() => {}) + try { + yield* Effect.promise(async () => { + await expect(fetchStreamSnapshot(queryClient, options)).rejects.toThrow( + 'idle query without observers', + ) + }) + expect( + yield* Effect.promise( + async () => await fetchStreamSnapshot(queryClient, options, { mode: 'cached' }), + ), + ).toBe(1) + } finally { + stop() + } + expect(queryClient.isFetching()).toBe(0) + expect(queryClient.getQueryCache().hasListeners()).toBe(false) + } finally { + yield* Effect.promise(async () => { + await queryClient.cancelQueries() + }) + queryClient.clear() + } + }), + ) + + test.each(['timeout', 'abort', 'cleanup-failure'] as const)( + 'settles a %s snapshot after query cleanup', + async (path) => { + const queryClient = new QueryClient() + const entered = Deferred.makeUnsafe() + const closing = Deferred.makeUnsafe() + const release = Deferred.makeUnsafe() + const controller = new AbortController() + const abortReason = new Error('request aborted') + const cleanupError = new Error('cleanup failed') + let finalized = false + let settled = false + const options = { + queryKey: ['snapshot-cleanup', path], + retry: false, + queryFn: async ({ signal }: { signal: AbortSignal }): Promise => { + Deferred.doneUnsafe(entered, Exit.succeed(undefined)) + if (path === 'cleanup-failure') { + queryClient.setQueryData(options.queryKey, 1) + } + const interrupted = await captureFailure(Effect.runPromise(Effect.never, { signal })) + Deferred.doneUnsafe(closing, Exit.succeed(undefined)) + await Effect.runPromise(Deferred.await(release)) + finalized = true + throw path === 'cleanup-failure' ? cleanupError : interrupted + }, + } + const pending = fetchStreamSnapshot(queryClient, options, { + signal: controller.signal, + timeoutMs: path === 'timeout' ? 10 : 1000, + }) + const outcome = captureFailure(pending).then((result) => { + settled = true + return result + }) + try { + await Effect.runPromise(Deferred.await(entered)) + if (path === 'abort') { + controller.abort(abortReason) + } + await Effect.runPromise(Deferred.await(closing)) + expect(settled).toBe(false) + expect(finalized).toBe(false) + Effect.runSync(Deferred.succeed(release, undefined)) + const result = await outcome + const failure = { + timeout: { name: 'TimeoutError' }, + abort: abortReason, + 'cleanup-failure': cleanupError, + }[path] + expect(result).toMatchObject(failure) + expect(path === 'timeout' || result === failure).toBe(true) + expect(finalized).toBe(true) + expect(queryClient.isFetching()).toBe(0) + expect(queryClient.getQueryCache().hasListeners()).toBe(false) + } finally { + Effect.runSync(Deferred.succeed(release, undefined)) + await queryClient.cancelQueries() + await outcome + queryClient.clear() + } + }, + ) + + test('rejects an aborted snapshot before executing the query', async () => { + const queryClient = new QueryClient() + const controller = new AbortController() + const reason = new Error('request already aborted') + controller.abort(reason) + let executions = 0 + try { + await expect( + fetchStreamSnapshot( + queryClient, + { + queryKey: ['aborted'], + queryFn: async () => { + executions += 1 + return await Promise.resolve(1) + }, + }, + { signal: controller.signal }, + ), + ).rejects.toBe(reason) + expect(executions).toBe(0) + expect(queryClient.getQueryCache().getAll()).toHaveLength(0) + } finally { + queryClient.clear() + } + }) +}) diff --git a/tests/packed-consumer/docs-hydration-async.ts b/tests/types/docs-hydration-async.ts similarity index 88% rename from tests/packed-consumer/docs-hydration-async.ts rename to tests/types/docs-hydration-async.ts index 4dbb590..a797468 100644 --- a/tests/packed-consumer/docs-hydration-async.ts +++ b/tests/types/docs-hydration-async.ts @@ -4,7 +4,7 @@ import { Context, Effect, Schema } from 'effect' import { Profile } from './docs-hydration-rich.ts' -export class HydrationPreparation extends Context.Service< +class HydrationPreparation extends Context.Service< HydrationPreparation, { readonly beforeEncode: Effect.Effect @@ -25,7 +25,7 @@ const PreparedProfile = Profile.pipe( ), ) -export const prepareProfileSnapshot = Effect.fnUntraced(function* (queryClient: QueryClient) { +const prepareProfileSnapshot = Effect.fnUntraced(function* (queryClient: QueryClient) { const snapshot = dehydrate(queryClient, { shouldDehydrateMutation: () => false, shouldDehydrateQuery: (query) => @@ -39,7 +39,7 @@ export const prepareProfileSnapshot = Effect.fnUntraced(function* (queryClient: return JSON.stringify({ ...snapshot, queries }) }) -export const prepareProfileHydration = Effect.fnUntraced(function* ( +const prepareProfileHydration = Effect.fnUntraced(function* ( queryClient: QueryClient, json: string, ) { diff --git a/tests/packed-consumer/docs-hydration-dto.ts b/tests/types/docs-hydration-dto.ts similarity index 70% rename from tests/packed-consumer/docs-hydration-dto.ts rename to tests/types/docs-hydration-dto.ts index eb43b48..a75d5bf 100644 --- a/tests/packed-consumer/docs-hydration-dto.ts +++ b/tests/types/docs-hydration-dto.ts @@ -5,36 +5,34 @@ import { createHttpApiQueryUtils, createRpcQueryUtils } from 'effect-api-query' import { HttpApi, HttpApiClient, HttpApiEndpoint, HttpApiGroup } from 'effect/http-api' import { Rpc, RpcClient, RpcGroup } from 'effect/rpc' -export const ProfileDto = Schema.Struct({ +const ProfileDto = Schema.Struct({ name: Schema.String, credits: Schema.String, avatar: Schema.String.check(Schema.isBase64()), }) -export const dtoGroup = RpcGroup.make(Rpc.make('profile.read', { success: ProfileDto })) -export const dtoApi = HttpApi.make('dto').add( +const dtoGroup = RpcGroup.make(Rpc.make('profile.read', { success: ProfileDto })) +const dtoApi = HttpApi.make('dto').add( HttpApiGroup.make('profile').add( HttpApiEndpoint.get('read', '/profile', { success: ProfileDto }), ), ) -export function dtoRpcOptions( - client: RpcClient.RpcClient.Flat, E>, -) { +function dtoRpcOptions(client: RpcClient.RpcClient.Flat, E>) { return createRpcQueryUtils(dtoGroup, { client, keyPrefix: ['dto', 'rpc'], }).profile.read.queryOptions({ retry: false, staleTime: 60_000 }) } -export function dtoHttpOptions(client: HttpApiClient.ForApi) { +function dtoHttpOptions(client: HttpApiClient.ForApi) { return createHttpApiQueryUtils(dtoApi, { client, keyPrefix: ['dto', 'http'], }).profile.read.queryOptions({ retry: false, staleTime: 60_000 }) } -export const snapshotDto = (queryClient: QueryClient): string => +const snapshotDto = (queryClient: QueryClient): string => JSON.stringify( dehydrate(queryClient, { shouldDehydrateMutation: () => false, @@ -43,5 +41,5 @@ export const snapshotDto = (queryClient: QueryClient): string => }), ) -export const hydrateDto = (queryClient: QueryClient, json: string): void => +const hydrateDto = (queryClient: QueryClient, json: string): void => hydrate(queryClient, JSON.parse(json)) diff --git a/tests/packed-consumer/docs-hydration-rich.ts b/tests/types/docs-hydration-rich.ts similarity index 82% rename from tests/packed-consumer/docs-hydration-rich.ts rename to tests/types/docs-hydration-rich.ts index bf9fa33..fff45ac 100644 --- a/tests/packed-consumer/docs-hydration-rich.ts +++ b/tests/types/docs-hydration-rich.ts @@ -15,16 +15,16 @@ export class Profile extends Schema.Class('Profile')({ } } -export const profileGroup = RpcGroup.make( +const profileGroup = RpcGroup.make( Rpc.make('profile.read', { success: Profile, error: Schema.String }), ) -export const profileApi = HttpApi.make('rich').add( +const profileApi = HttpApi.make('rich').add( HttpApiGroup.make('profile').add( HttpApiEndpoint.get('read', '/profile', { success: Profile, error: Schema.String }), ), ) -export function profileRpcOptions( +function profileRpcOptions( client: RpcClient.RpcClient.Flat, E>, ) { return createRpcQueryUtils(profileGroup, { @@ -33,14 +33,14 @@ export function profileRpcOptions( }).profile.read.queryOptions({ retry: false, staleTime: 60_000 }) } -export function profileHttpOptions(client: HttpApiClient.ForApi) { +function profileHttpOptions(client: HttpApiClient.ForApi) { return createHttpApiQueryUtils(profileApi, { client, keyPrefix: ['profile', 'http'], }).profile.read.queryOptions({ retry: false, staleTime: 60_000 }) } -export const snapshotProfile = (queryClient: QueryClient): string => +const snapshotProfile = (queryClient: QueryClient): string => JSON.stringify( dehydrate(queryClient, { shouldDehydrateMutation: () => false, @@ -50,7 +50,7 @@ export const snapshotProfile = (queryClient: QueryClient): string => }), ) -export const hydrateProfile = (queryClient: QueryClient, json: string): void => +const hydrateProfile = (queryClient: QueryClient, json: string): void => hydrate(queryClient, JSON.parse(json), { defaultOptions: { deserializeData: Schema.decodeUnknownSync(Profile) }, }) diff --git a/tests/packed-consumer/docs-hydration-views.ts b/tests/types/docs-hydration-views.ts similarity index 93% rename from tests/packed-consumer/docs-hydration-views.ts rename to tests/types/docs-hydration-views.ts index 6d5c8f5..335837f 100644 --- a/tests/packed-consumer/docs-hydration-views.ts +++ b/tests/types/docs-hydration-views.ts @@ -28,12 +28,12 @@ export const ProfilePage = Schema.Struct({ rows: Schema.Array(Profile), next: Schema.NullOr(Schema.Finite), }) -export const ProfilePages = Schema.Struct({ +const ProfilePages = Schema.Struct({ pages: Schema.Array(ProfilePage), pageParams: Schema.Array(Cursor), }) -export class ArchivedProfile extends Schema.Class('ArchivedProfile')({ +class ArchivedProfile extends Schema.Class('ArchivedProfile')({ archived: Schema.Literal(true), name: Schema.String, credits: Schema.BigIntFromString, @@ -44,7 +44,7 @@ export class ArchivedProfile extends Schema.Class('ArchivedProf } const DecodedHeaders = Schema.Struct({ 'x-version': Schema.FiniteFromString }) -export const WrappedElement = Schema.Struct({ body: HistoryElement, headers: DecodedHeaders }).pipe( +const WrappedElement = Schema.Struct({ body: HistoryElement, headers: DecodedHeaders }).pipe( Schema.decodeTo( Schema.toType(HttpApiSchema.WithHeaders(HistoryElement, DecodedHeaders)), SchemaTransformation.transform({ @@ -54,7 +54,7 @@ export const WrappedElement = Schema.Struct({ body: HistoryElement, headers: Dec ), ) const BufferedBody = Schema.Union([Profile, ArchivedProfile]) -export const BufferedProfile = Schema.Struct({ +const BufferedProfile = Schema.Struct({ body: BufferedBody, headers: DecodedHeaders, }).pipe( @@ -66,7 +66,7 @@ export const BufferedProfile = Schema.Struct({ }), ), ) -export const ProfileMetadata = Schema.Struct({ +const ProfileMetadata = Schema.Struct({ data: BufferedProfile, status: Schema.Finite, headers: Schema.Record(Schema.String, Schema.String), diff --git a/tests/packed-consumer/docs-retry.ts b/tests/types/docs-retry.ts similarity index 88% rename from tests/packed-consumer/docs-retry.ts rename to tests/types/docs-retry.ts index 7f04be4..4d693f6 100644 --- a/tests/packed-consumer/docs-retry.ts +++ b/tests/types/docs-retry.ts @@ -9,26 +9,26 @@ import { HttpClientError } from 'effect/http' import { HttpApi, HttpApiClient, HttpApiEndpoint, HttpApiGroup } from 'effect/http-api' import { Rpc, RpcClient, RpcClientError, RpcGroup } from 'effect/rpc' -export class NotFound extends Schema.TaggedError()('NotFound', {}) {} -export class AuthenticationRequired extends Schema.TaggedError()( +class NotFound extends Schema.TaggedError()('NotFound', {}) {} +class AuthenticationRequired extends Schema.TaggedError()( 'AuthenticationRequired', {}, ) {} -export class RetryLater extends Schema.TaggedError()('RetryLater', { +class RetryLater extends Schema.TaggedError()('RetryLater', { retryAfterMs: Schema.Finite, }) {} const ReadError = Schema.Union([NotFound, AuthenticationRequired, RetryLater]) -export const readGroup = RpcGroup.make( +const readGroup = RpcGroup.make( Rpc.make('profile.read', { success: Schema.String, error: ReadError }), ) -export const readApi = HttpApi.make('profile-api').add( +const readApi = HttpApi.make('profile-api').add( HttpApiGroup.make('profile').add( HttpApiEndpoint.get('read', '/profile', { success: Schema.String, error: ReadError }), ), ) -export const rpcReadOptions = ( +const rpcReadOptions = ( client: RpcClient.RpcClient.Flat, RpcClientError.RpcClientError>, ) => { const rpc = createRpcQueryUtils< @@ -73,7 +73,7 @@ export const rpcReadOptions = ( }) } -export const httpReadOptions = (client: HttpApiClient.ForApi) => { +const httpReadOptions = (client: HttpApiClient.ForApi) => { const http = createHttpApiQueryUtils(readApi, { client, keyPrefix: ['profile-app'] }) return http.profile.read.queryOptions({ diff --git a/tests/packed-consumer/docs-stream-snapshot.ts b/tests/types/docs-stream-snapshot.ts similarity index 93% rename from tests/packed-consumer/docs-stream-snapshot.ts rename to tests/types/docs-stream-snapshot.ts index fb79670..dc738b5 100644 --- a/tests/packed-consumer/docs-stream-snapshot.ts +++ b/tests/types/docs-stream-snapshot.ts @@ -4,7 +4,7 @@ import { createRpcQueryUtils, fetchStreamSnapshot } from 'effect-api-query' import { Rpc, RpcGroup } from 'effect/rpc' import type { RpcClient } from 'effect/rpc' -export const updates = RpcGroup.make( +const updates = RpcGroup.make( Rpc.make('updates.watch', { payload: { channel: Schema.String }, success: Schema.String, @@ -12,7 +12,7 @@ export const updates = RpcGroup.make( }), ) -export async function captureUpdates( +async function captureUpdates( queryClient: QueryClient, client: RpcClient.RpcClient.Flat, E>, signal: AbortSignal, diff --git a/tests/packed-consumer/docs-user-events.ts b/tests/types/docs-user-events.ts similarity index 79% rename from tests/packed-consumer/docs-user-events.ts rename to tests/types/docs-user-events.ts index 2ed26d2..16b7cc2 100644 --- a/tests/packed-consumer/docs-user-events.ts +++ b/tests/types/docs-user-events.ts @@ -1,10 +1,10 @@ import { Effect, Stream } from 'effect' import type { Reactivity } from 'effect/reactivity' -import { decodeUserEvent, makeUserEventConsumer } from './user-events.ts' -import type { UserEventOwner } from './user-events.ts' +import { decodeUserEvent, makeUserEventConsumer } from '../fixtures/user-events.ts' +import type { UserEventOwner } from '../fixtures/user-events.ts' -export const attachUserEvents = Effect.fnUntraced(function* ( +const attachUserEvents = Effect.fnUntraced(function* ( owner: UserEventOwner, reactivity: Reactivity.Reactivity, ) { diff --git a/tests/packed-consumer/snapshot-contract.ts b/tests/types/snapshot-contract.ts similarity index 100% rename from tests/packed-consumer/snapshot-contract.ts rename to tests/types/snapshot-contract.ts diff --git a/tests/types/tanstack-start-contract.ts b/tests/types/tanstack-start-contract.ts index 28c374f..2f8892d 100644 --- a/tests/types/tanstack-start-contract.ts +++ b/tests/types/tanstack-start-contract.ts @@ -1,5 +1,4 @@ // fallow-ignore-file unused-file -// This packed fixture verifies that every generated operation fits one TanStack Start route. import { QueryClient } from '@tanstack/query-core' import { useInfiniteQuery, useMutation, useQuery, useSuspenseQuery } from '@tanstack/react-query' import { createRootRouteWithContext, createRoute, createRouter } from '@tanstack/react-router' diff --git a/tests/types/tsconfig.json b/tests/types/tsconfig.json new file mode 100644 index 0000000..02b8c92 --- /dev/null +++ b/tests/types/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.json", + "include": ["./*.ts"], + "exclude": [] +} diff --git a/tsconfig.json b/tsconfig.json index 6076814..0490417 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -54,9 +54,14 @@ "tests/fixtures/rpc-transport-queries.ts", "tests/fixtures/http-client.ts", "tests/fixtures/optimistic-users.ts", - "tests/types/**/*.ts", - "scripts/run-command.mts", - "scripts/measure-packed-consumer.mts" + "tests/fixtures/user-events.ts", + "tests/fixtures/socket-client.ts", + "tests/fixtures/websocket-checks.ts", + "tests/fixtures/docs-websocket-use.ts", + "tests/stream-snapshot.test.ts", + "tests/cache-recipes.test.ts", + "scripts/measure-construction.mts", + "tests/types/**/*.ts" ], "options": { "diagnosticSeverity": { @@ -80,5 +85,5 @@ "tests/**/*.ts", "vite.config.ts" ], - "exclude": ["dist", "node_modules", "tests/packed-consumer"] + "exclude": ["dist", "node_modules"] } diff --git a/vite.config.ts b/vite.config.ts index f224d19..d007aad 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -24,9 +24,6 @@ const ignoredPaths = [ 'test-results/**', ] -// Packed fixtures resolve only the installed tarball from isolated temporary projects. -const packedFixtures = ['tests/packed-consumer/**', 'tests/types/**'] - const testFiles = ['tests/**/*.test.ts', 'examples/**/*.test.{ts,tsx}'] const effectTestBlocks = ['it.effect', 'it.live', 'it.scoped', 'it.scopedLive'] @@ -99,7 +96,7 @@ export default defineConfig({ ignorePatterns: [ ...(ultraciteCore.ignorePatterns ?? []), ...ignoredPaths, - ...packedFixtures, + 'tests/types/**', 'apps/docs/**', ], options: { @@ -167,8 +164,7 @@ export default defineConfig({ 'src/{http,rpc}/**/*.ts', 'tests/**/*.ts', 'examples/**/*.{ts,tsx}', - 'scripts/run-command.mts', - 'scripts/measure-packed-consumer.mts', + 'scripts/measure-construction.mts', ], rules: { // Effect 4 transports are experimental and pinned; match the compiler policy. @@ -211,7 +207,7 @@ export default defineConfig({ rules: { // These runtime adapters erase caller-defined schemas/options, then validate // inputs with their declaration. A domain-specific replacement loses the - // generic public contract, which the packed compile-time fixtures verify. + // generic public contract. 'anti-slop/no-unknown-parameters': 'off', 'anti-slop/no-unknown-returns': 'off', 'anti-slop/no-unsafe-dictionary-type': 'off', @@ -299,21 +295,6 @@ export default defineConfig({ output: ['dist/**'], }, }, - 'packed-package': { - command: [ - 'vp pm pack --pack-destination .artifacts -- --config.ignore-scripts=true', - 'fallow dead-code --private-type-leaks --file dist/index.d.mts', - 'vp run verify-packed-package', - ], - dependsOn: ['pack', 'skills-check'], - cache: { - output: ['.artifacts/*.tgz'], - }, - }, - 'verify-packed-package': { - command: 'node scripts/verify-packed-consumer.mts', - cache: false, - }, 'skills-check': { command: 'intent validate skills --check', cache: false, @@ -331,12 +312,24 @@ export default defineConfig({ }, test: { command: 'vp test', - dependsOn: ['pack', 'server-local-types'], + dependsOn: ['pack', 'api-types', 'server-local-types'], cache: { env: ['RPC_TRANSPORT_MEASURE', 'STREAM_MEASURE'], output: [], }, }, + 'construction-baseline': { + command: 'tsx scripts/measure-construction.mts', + cache: false, + }, + 'api-types': { + command: [ + 'tsc --project tests/types/tsconfig.json', + 'node node_modules/typescript-5.9/bin/tsc --project tests/types/tsconfig.json', + ], + dependsOn: ['pack'], + cache: { output: [] }, + }, 'server-local-types': { command: [ 'tsc --project tests/fixtures/tsconfig.server-local.json', @@ -439,7 +432,7 @@ export default defineConfig({ validate: { command: [ 'vp run quality', - 'vp run packed-package', + 'vp run skills-check', 'vp run vite-react-build', 'vp run tanstack-start-build', 'vp run docs-build', From e2e1f0767bb2bc0dd6770d5477c835351e27b317 Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Sat, 10 Oct 2026 21:52:05 +0200 Subject: [PATCH 82/97] Preserve focused HTTP hydration recipe coverage --- .intent/review-state.json | 38 ++- .../docs/guides/hydrate-query-views.md | 2 +- .../docs/guides/refresh-from-events.md | 4 +- scripts/verify-docs-examples.mts | 5 +- tests/http-hydration.test.ts | 312 ++++++++++++++++++ tests/types/docs-hydration-views.ts | 2 +- 6 files changed, 340 insertions(+), 23 deletions(-) create mode 100644 tests/http-hydration.test.ts diff --git a/.intent/review-state.json b/.intent/review-state.json index c33ac16..c409228 100644 --- a/.intent/review-state.json +++ b/.intent/review-state.json @@ -3,7 +3,7 @@ "baseline": "5b91ddb1de13970365669c887b9d767ea417fbd4", "items": { "skill:skills/effect-api-query/SKILL.md": { - "fingerprint": "48fdd7d812fe4e2751de4f32aeba48bc7099c74992ea09e6d4116cb0ebb18e6d", + "fingerprint": "2b8fd1a3ae0c84ebe7e69e0dd47d528b6cc6ef7484604b8980654b0d81724678", "snapshot": { "GLOSSARY.md": "67de06543ad2fd9fd2917419e19d1f071e7b6eee53d48fca05e9dddfc739c683", "README.md": "b84fc747700efa4d7e53447f4b670e52fc22018de2777eaae0a663dd296e6528", @@ -27,12 +27,12 @@ "apps/docs/src/content/docs/guides/custom-key-encoders.md": "ffa8b915f0831e2081a5bd50c115977e444ee6319c624123e296e3455d754230", "apps/docs/src/content/docs/guides/handle-failures.md": "6b4e7bed61daba9c29d988c9795e570efc5a4f2783503d696a419cfcf9737a91", "apps/docs/src/content/docs/guides/http-queries-and-mutations.md": "6e4377bfbc2300f7cc195ce095125a5d9051c1f09c7b20cfc09a7251852d1056", - "apps/docs/src/content/docs/guides/hydrate-query-views.md": "c81aaa70bba0f60a475ebd3796b01af77e6a1e222ad416446e4c7d52bf7fca7e", + "apps/docs/src/content/docs/guides/hydrate-query-views.md": "085a7ba81468cd5cca835d6957e03797d1e66fd410bf8992accf40973ef15a66", "apps/docs/src/content/docs/guides/hydrate-unary-data.md": "a4e34e994b6c606ac918726e0c02ad20c9f1a668f483042979b1798400b35e18", "apps/docs/src/content/docs/guides/optimistic-writes.md": "b76e2336f6958e7482786312a15bb9c25a1608c659018c5b4d3162301e767bef", "apps/docs/src/content/docs/guides/query-defaults.md": "98353365fc09fab7d2424cdc7d2870a52735710a05778aa6c693697bc16c288c", "apps/docs/src/content/docs/guides/react-query.md": "29a200b31d19e0339805a0ae3e50be403300e1b8bb7b870949cf779853fbb347", - "apps/docs/src/content/docs/guides/refresh-from-events.md": "31397960dc42e41aff19ae3b0b15cfe2246e80750c1772aff6b8ad501107bd6a", + "apps/docs/src/content/docs/guides/refresh-from-events.md": "a51ef3777eb9ea07f561d2223c6d8ffa063c87ecda25cb74ce0aea4b037fa2fe", "apps/docs/src/content/docs/guides/retry-queries.md": "f725f54696712cc917089e18e905022c6ab097e44f8d22f499ab87fb30af9a99", "apps/docs/src/content/docs/guides/stream-snapshots.md": "62e343dc7fa2f4b1b6fb1b699ecb947bcdfc0312c9ffab584b0b8bfaf954f854", "apps/docs/src/content/docs/guides/svelte-and-angular.md": "aa8d0e6c20a46e95050314b3ef3b0e6b1d88fce8474a7b719f823d1c26ca502b", @@ -117,7 +117,7 @@ "scripts/measure-construction.mts": "10522cb10ab51fd44d8990896a2a73e8bf4d4cd2646441e958b6180cb1afd147", "scripts/measure-packed-consumer.mts": null, "scripts/run-command.mts": null, - "scripts/verify-docs-examples.mts": "83cbca671490730d02b0c7a780f879104d82b8ef474d80122de46c87479cf3a9", + "scripts/verify-docs-examples.mts": "9e379c826c5d7adafb1d6b59b526cbd59d3fd76b32f417da0ce2cd1bb40eb01c", "scripts/verify-framework-browser.mts": null, "scripts/verify-packed-consumer.mts": null, "scripts/verify-svelte-angular-consumer.mts": null, @@ -171,6 +171,7 @@ "tests/fixtures/websocket-checks.ts": "7b8035264ece4e501e829b3e2b755f9d7b17c010ffa1036db6338a368ff6f93c", "tests/http-cached-views.test.ts": "a1dc10f45dcc2d8a53c8b80e4205f6adb48646fba60a427b8531f02a26068725", "tests/http-execution.test.ts": "d5869dc2faef8f2edf0f721173f40b352f1b2d70ef19919214a342b3bd82ea18", + "tests/http-hydration.test.ts": "1873ccb57214eb9542927fd92204cff9e98d2cc0217fd78fbeeb20bebd5e05b7", "tests/http-live-query.test.ts": "79df219cb204669520c584123f167479ea3fa514d899897bf2810a04e8e1ca65", "tests/http-multipart.test.ts": "ff816ffae79e827883cea1d1f99f5f763e95c9e856040627a34818df6569feb1", "tests/http-query-options.test.ts": "bba47a56841732c7a3f3451ce100e9b864ab2fa6374bc7c8c912658e31c30a3a", @@ -222,7 +223,7 @@ "tests/types/docs-hydration-async.ts": "de67135bf3da0dffdac47e9f2e49469edddac3d7d026fbd93d39aea6964c0dd8", "tests/types/docs-hydration-dto.ts": "ebcd5c3895e8fa40bff3b62e8e13213e98a3e1c4796d89979b23ecc64cdc13ff", "tests/types/docs-hydration-rich.ts": "56b35d7bb4a0f0ebc777161beb2e73a19dfc7de4db26fc74e3abab5d164f1402", - "tests/types/docs-hydration-views.ts": "6756cada98db671590898fe05a5b1c42e048e8b77d55b064cb9bea879b5c1e16", + "tests/types/docs-hydration-views.ts": "08d75fe114e4a682fc0ad62b847898dba270672e0ce6d19a4f4d66cf92f75365", "tests/types/docs-retry.ts": "30d8a5635399635c8b1a21ad77e7b1e2b6d12df4d032eaa95b1b0438da0486f2", "tests/types/docs-rpc-quick-start.ts": "95deee30de122cbaa7b9cb28257c234aa1162a0b549a87cf50a80bd84e871081", "tests/types/docs-stream-snapshot.ts": "bc924cff90a9f9a8e416bf7fbdfedbd607c88e7a678435aaf20ef878862e06ff", @@ -237,15 +238,15 @@ "tests/types/tanstack-start-contract.ts": "51681e3d0d7504a614d490f18adb6f44db82d34b9d4d7cb9cf941aea33de284f", "tests/types/type-scale.ts": "a9b307c25c72f3c184eabc8e22ddaeea96feb331c0f8d748773d765d358dbfca" }, - "head": "1e235b1b5f4e4f6d6d34ce397ad0b573e6c5392d", - "outcome": "updated", - "reason": "Reference claims now distinguish application-owned reactive and worker integration from the retired maintained certification matrices. The entry and tree source mappings follow retained ordinary tests/recipes and concise ADRs; description, purpose, supported factories, exact Effect peer, cache/lifetime rules and existing task routing are preserved. Public runtime implementation is unchanged.", + "head": "55ee50f86d171e05f9c66df2f36bc6ce1f15c1a1", + "outcome": "no-change", + "reason": "The existing hydration-and-ssr reference already requires reconstruction of decoded HTTP wrappers, preservation of undefined wrapped bodies, all declared successes, rich values and caller-owned encoding/decoding services. Existing cache-workflows guidance retains original-owner scoped events. New ordinary HTTP controls exercise those claims and the event guide now matches its documented app-relative helper layout through compiler parity normalization; skill task routing, source globs, activation and purpose remain accurate.", "evidence": [ - "Base 1e235b1b5f4e4f6d6d34ce397ad0b573e6c5392d; vp run --no-cache quality passed: 38 files, 283 runtime tests, strict public and server-local fixtures on TS 7.0.2 and TS 5.9.3, native pack/attw/publint, Effect diagnostics and Fallow audits. Both example builds and docs-build passed (44 pages; docs parity and Astro diagnostics). Existing docs-e2e passed 14 Chromium/Firefox tests. Native skills-check validated the owned skill. Native source construction-baseline passed. Logs are under /tmp/effect-api-query-implementation/notes/131-*. No fresh independent consumer session ran for this reference/source-mapping update; retired installation/certification matrices were not repeated. The optional generic skill-creator validator rejected the existing Intent sources frontmatter field; native Intent validation passed and its required schema is preserved. Reviewed item: skills/effect-api-query/SKILL.md." + "Correction source at 55ee50f86d171e05f9c66df2f36bc6ce1f15c1a1 on full base 1e235b1b5f4e4f6d6d34ce397ad0b573e6c5392d. Native vp run --no-cache test tests/http-hydration.test.ts tests/cache-recipes.test.ts passed 4 tests in 2 files plus public/server-local compiler projects on TS 7.0.2 and TS 5.9.3, native pack publint/attw. Native check, strict effect-check, Fallow and docs-check passed. Logs: /tmp/effect-api-query-implementation/notes/131-correction-*. No fresh independent consumer session was run for these existing-guidance/import and regression-test corrections; it remains unverified. No skill description, purpose, public behavior, dependency, host, release or support-claim change. Reviewed item: skills/effect-api-query/SKILL.md." ] }, "planning:_artifacts": { - "fingerprint": "526464efc6f053d6f536e3e700b3251e3effd3223db4d8e5a7b229b9f93089fb", + "fingerprint": "ac317b6880cb266c398ece266cb61985ec63ec00bbcbd163fe824ed10eafc13d", "snapshot": { ".agents/skills/ask-matt/PHASE-BOUNDARIES.md": "a8aa20158609ef39e2b308b6ba0660c91066838d07a867202e63a7744a88a3ed", ".agents/skills/ask-matt/SKILL.md": "1371f5ce005de0992a44b137748bfb960a56948aa9871b405fe9d90b24332e3c", @@ -352,12 +353,12 @@ "apps/docs/src/content/docs/guides/custom-key-encoders.md": "ffa8b915f0831e2081a5bd50c115977e444ee6319c624123e296e3455d754230", "apps/docs/src/content/docs/guides/handle-failures.md": "6b4e7bed61daba9c29d988c9795e570efc5a4f2783503d696a419cfcf9737a91", "apps/docs/src/content/docs/guides/http-queries-and-mutations.md": "6e4377bfbc2300f7cc195ce095125a5d9051c1f09c7b20cfc09a7251852d1056", - "apps/docs/src/content/docs/guides/hydrate-query-views.md": "c81aaa70bba0f60a475ebd3796b01af77e6a1e222ad416446e4c7d52bf7fca7e", + "apps/docs/src/content/docs/guides/hydrate-query-views.md": "085a7ba81468cd5cca835d6957e03797d1e66fd410bf8992accf40973ef15a66", "apps/docs/src/content/docs/guides/hydrate-unary-data.md": "a4e34e994b6c606ac918726e0c02ad20c9f1a668f483042979b1798400b35e18", "apps/docs/src/content/docs/guides/optimistic-writes.md": "b76e2336f6958e7482786312a15bb9c25a1608c659018c5b4d3162301e767bef", "apps/docs/src/content/docs/guides/query-defaults.md": "98353365fc09fab7d2424cdc7d2870a52735710a05778aa6c693697bc16c288c", "apps/docs/src/content/docs/guides/react-query.md": "29a200b31d19e0339805a0ae3e50be403300e1b8bb7b870949cf779853fbb347", - "apps/docs/src/content/docs/guides/refresh-from-events.md": "31397960dc42e41aff19ae3b0b15cfe2246e80750c1772aff6b8ad501107bd6a", + "apps/docs/src/content/docs/guides/refresh-from-events.md": "a51ef3777eb9ea07f561d2223c6d8ffa063c87ecda25cb74ce0aea4b037fa2fe", "apps/docs/src/content/docs/guides/retry-queries.md": "f725f54696712cc917089e18e905022c6ab097e44f8d22f499ab87fb30af9a99", "apps/docs/src/content/docs/guides/stream-snapshots.md": "62e343dc7fa2f4b1b6fb1b699ecb947bcdfc0312c9ffab584b0b8bfaf954f854", "apps/docs/src/content/docs/guides/svelte-and-angular.md": "aa8d0e6c20a46e95050314b3ef3b0e6b1d88fce8474a7b719f823d1c26ca502b", @@ -442,7 +443,7 @@ "scripts/measure-construction.mts": "10522cb10ab51fd44d8990896a2a73e8bf4d4cd2646441e958b6180cb1afd147", "scripts/measure-packed-consumer.mts": null, "scripts/run-command.mts": null, - "scripts/verify-docs-examples.mts": "83cbca671490730d02b0c7a780f879104d82b8ef474d80122de46c87479cf3a9", + "scripts/verify-docs-examples.mts": "9e379c826c5d7adafb1d6b59b526cbd59d3fd76b32f417da0ce2cd1bb40eb01c", "scripts/verify-framework-browser.mts": null, "scripts/verify-packed-consumer.mts": null, "scripts/verify-svelte-angular-consumer.mts": null, @@ -496,6 +497,7 @@ "tests/fixtures/websocket-checks.ts": "7b8035264ece4e501e829b3e2b755f9d7b17c010ffa1036db6338a368ff6f93c", "tests/http-cached-views.test.ts": "a1dc10f45dcc2d8a53c8b80e4205f6adb48646fba60a427b8531f02a26068725", "tests/http-execution.test.ts": "d5869dc2faef8f2edf0f721173f40b352f1b2d70ef19919214a342b3bd82ea18", + "tests/http-hydration.test.ts": "1873ccb57214eb9542927fd92204cff9e98d2cc0217fd78fbeeb20bebd5e05b7", "tests/http-live-query.test.ts": "79df219cb204669520c584123f167479ea3fa514d899897bf2810a04e8e1ca65", "tests/http-multipart.test.ts": "ff816ffae79e827883cea1d1f99f5f763e95c9e856040627a34818df6569feb1", "tests/http-query-options.test.ts": "bba47a56841732c7a3f3451ce100e9b864ab2fa6374bc7c8c912658e31c30a3a", @@ -547,7 +549,7 @@ "tests/types/docs-hydration-async.ts": "de67135bf3da0dffdac47e9f2e49469edddac3d7d026fbd93d39aea6964c0dd8", "tests/types/docs-hydration-dto.ts": "ebcd5c3895e8fa40bff3b62e8e13213e98a3e1c4796d89979b23ecc64cdc13ff", "tests/types/docs-hydration-rich.ts": "56b35d7bb4a0f0ebc777161beb2e73a19dfc7de4db26fc74e3abab5d164f1402", - "tests/types/docs-hydration-views.ts": "6756cada98db671590898fe05a5b1c42e048e8b77d55b064cb9bea879b5c1e16", + "tests/types/docs-hydration-views.ts": "08d75fe114e4a682fc0ad62b847898dba270672e0ce6d19a4f4d66cf92f75365", "tests/types/docs-retry.ts": "30d8a5635399635c8b1a21ad77e7b1e2b6d12df4d032eaa95b1b0438da0486f2", "tests/types/docs-rpc-quick-start.ts": "95deee30de122cbaa7b9cb28257c234aa1162a0b549a87cf50a80bd84e871081", "tests/types/docs-stream-snapshot.ts": "bc924cff90a9f9a8e416bf7fbdfedbd607c88e7a678435aaf20ef878862e06ff", @@ -562,11 +564,11 @@ "tests/types/tanstack-start-contract.ts": "51681e3d0d7504a614d490f18adb6f44db82d34b9d4d7cb9cf941aea33de284f", "tests/types/type-scale.ts": "a9b307c25c72f3c184eabc8e22ddaeea96feb331c0f8d748773d765d358dbfca" }, - "head": "1e235b1b5f4e4f6d6d34ce397ad0b573e6c5392d", - "outcome": "updated", - "reason": "All three shared planning records were reconciled: domain_map points hydration and reactive failure evidence to retained ordinary fixtures/guides; skill_tree matches the existing skill source mappings; skill_spec describes strict compiler/runtime/application/native build validation without the retired installed gate. Existing domains, coverage, ownership decisions and unrelated skills remain intact.", + "head": "55ee50f86d171e05f9c66df2f36bc6ce1f15c1a1", + "outcome": "no-change", + "reason": "Reviewed all three planning records. domain_map already covers all decoded query views, rich paired codecs, caller ownership and snapshot cleanup, with the same retained hydration fixture as evidence. skill_spec already distinguishes compiler fixtures from runtime recipe/ownership checks. skill_tree existing tests/**/*.ts, scripts/*.mts and docs source globs cover the new focused test and corrected imports. No domain, task, boundary, file route, version or source-mapping change is needed.", "evidence": [ - "Base 1e235b1b5f4e4f6d6d34ce397ad0b573e6c5392d; vp run --no-cache quality passed: 38 files, 283 runtime tests, strict public and server-local fixtures on TS 7.0.2 and TS 5.9.3, native pack/attw/publint, Effect diagnostics and Fallow audits. Both example builds and docs-build passed (44 pages; docs parity and Astro diagnostics). Existing docs-e2e passed 14 Chromium/Firefox tests. Native skills-check validated the owned skill. Native source construction-baseline passed. Logs are under /tmp/effect-api-query-implementation/notes/131-*. No fresh independent consumer session ran for this reference/source-mapping update; retired installation/certification matrices were not repeated. The optional generic skill-creator validator rejected the existing Intent sources frontmatter field; native Intent validation passed and its required schema is preserved. Reviewed item: _artifacts." + "Correction source at 55ee50f86d171e05f9c66df2f36bc6ce1f15c1a1 on full base 1e235b1b5f4e4f6d6d34ce397ad0b573e6c5392d. Native vp run --no-cache test tests/http-hydration.test.ts tests/cache-recipes.test.ts passed 4 tests in 2 files plus public/server-local compiler projects on TS 7.0.2 and TS 5.9.3, native pack publint/attw. Native check, strict effect-check, Fallow and docs-check passed. Logs: /tmp/effect-api-query-implementation/notes/131-correction-*. No fresh independent consumer session was run for these existing-guidance/import and regression-test corrections; it remains unverified. No skill description, purpose, public behavior, dependency, host, release or support-claim change. Reviewed item: _artifacts." ] }, "source:vite.config.ts": { diff --git a/apps/docs/src/content/docs/guides/hydrate-query-views.md b/apps/docs/src/content/docs/guides/hydrate-query-views.md index e3e8e0a..1cbede1 100644 --- a/apps/docs/src/content/docs/guides/hydrate-query-views.md +++ b/apps/docs/src/content/docs/guides/hydrate-query-views.md @@ -65,7 +65,7 @@ const ProfilePages = Schema.Struct({ pageParams: Schema.Array(Cursor), }) -class ArchivedProfile extends Schema.Class('ArchivedProfile')({ +export class ArchivedProfile extends Schema.Class('ArchivedProfile')({ archived: Schema.Literal(true), name: Schema.String, credits: Schema.BigIntFromString, diff --git a/apps/docs/src/content/docs/guides/refresh-from-events.md b/apps/docs/src/content/docs/guides/refresh-from-events.md index af2e737..844e310 100644 --- a/apps/docs/src/content/docs/guides/refresh-from-events.md +++ b/apps/docs/src/content/docs/guides/refresh-from-events.md @@ -22,8 +22,8 @@ your application runtime and keep that Scope open across transport reconnects. import { Effect, Stream } from 'effect' import type { Reactivity } from 'effect/reactivity' -import { decodeUserEvent, makeUserEventConsumer } from '../fixtures/user-events.ts' -import type { UserEventOwner } from '../fixtures/user-events.ts' +import { decodeUserEvent, makeUserEventConsumer } from './user-events.ts' +import type { UserEventOwner } from './user-events.ts' const attachUserEvents = Effect.fnUntraced(function* ( owner: UserEventOwner, diff --git a/scripts/verify-docs-examples.mts b/scripts/verify-docs-examples.mts index 62581f3..d5f8c38 100644 --- a/scripts/verify-docs-examples.mts +++ b/scripts/verify-docs-examples.mts @@ -43,7 +43,10 @@ const program = Effect.gen(function* () { ) strictEqual( samples[index]?.groups?.['source']?.trimEnd(), - source.replaceAll("from '#effect-api-query'", "from 'effect-api-query'").trimEnd(), + source + .replaceAll("from '#effect-api-query'", "from 'effect-api-query'") + .replaceAll("from '../fixtures/user-events.ts'", "from './user-events.ts'") + .trimEnd(), `${page} differs from ${fixture}.ts`, ) } diff --git a/tests/http-hydration.test.ts b/tests/http-hydration.test.ts new file mode 100644 index 0000000..2628aff --- /dev/null +++ b/tests/http-hydration.test.ts @@ -0,0 +1,312 @@ +import { it } from '@effect/vitest' +import { QueryClient } from '@tanstack/query-core' +import { Deferred, Effect, Exit, Schema, Scope, Stream } from 'effect' +import { HttpServer } from 'effect/http' +import { + HttpApi, + HttpApiBuilder, + HttpApiEndpoint, + HttpApiGroup, + HttpApiSchema, + HttpApiTest, +} from 'effect/http-api' +import { expect } from 'vite-plus/test' + +import { createHttpApiQueryUtils, fetchStreamSnapshot } from '#effect-api-query' + +import { Profile } from './types/docs-hydration-rich.ts' +import { + ArchivedProfile, + HistoryElement, + prepareViewHydration, + prepareViewSnapshot, + SnapshotDecoding, + SnapshotEncoding, +} from './types/docs-hydration-views.ts' + +it.effect('hydrates rich HTTP metadata and refetches an alternate declared success', () => + Effect.gen(function* () { + const api = HttpApi.make('hydration-metadata').add( + HttpApiGroup.make('profiles').add( + HttpApiEndpoint.get('read', '/read', { + success: [ + HttpApiSchema.WithHeaders(Profile, { 'x-version': Schema.FiniteFromString }), + HttpApiSchema.WithHeaders(ArchivedProfile, { + 'x-version': Schema.FiniteFromString, + }).pipe(HttpApiSchema.status(203)), + ], + }), + ), + ) + const profile = new Profile({ + name: 'Ada', + credits: 9_007_199_254_740_993n, + avatar: new Uint8Array([0, 1, 255]), + }) + const archived = new ArchivedProfile({ archived: true, name: 'Grace', credits: 42n }) + const serverScope = yield* Scope.make() + const browserScope = yield* Scope.make() + const server = new QueryClient() + const browser = new QueryClient() + const restored = new QueryClient() + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + server.clear() + browser.clear() + restored.clear() + yield* Scope.close(serverScope, Exit.void) + yield* Scope.close(browserScope, Exit.void) + }), + ) + let serverReads = 0 + let browserReads = 0 + let encoded = 0 + let decoded = 0 + const encoding = { + beforeEncode: Effect.sync(() => { + encoded += 1 + }), + } + const decoding = { + beforeDecode: Effect.sync(() => { + decoded += 1 + }), + } + const serverClient = yield* HttpApiTest.groups(api, ['profiles']).pipe( + Effect.provide( + HttpApiBuilder.group(api, 'profiles', (handlers) => + handlers.handle('read', () => + Effect.sync(() => { + serverReads += 1 + return HttpApiSchema.withHeaders({ body: profile, headers: { 'x-version': 4 } }) + }), + ), + ), + ), + Effect.provideService(Scope.Scope, serverScope), + ) + const browserClient = yield* HttpApiTest.groups(api, ['profiles']).pipe( + Effect.provide( + HttpApiBuilder.group(api, 'profiles', (handlers) => + handlers.handle('read', () => + Effect.sync(() => { + browserReads += 1 + return HttpApiSchema.withHeaders({ body: archived, headers: { 'x-version': 7 } }) + }), + ), + ), + ), + Effect.provideService(Scope.Scope, browserScope), + ) + const serverOptions = createHttpApiQueryUtils(api, { + client: serverClient, + keyPrefix: ['hydration-views', 'metadata'], + }).profiles.read.metadataOptions({ retry: false, staleTime: Infinity }) + const browserOptions = createHttpApiQueryUtils(api, { + client: browserClient, + keyPrefix: ['hydration-views', 'metadata'], + }).profiles.read.metadataOptions({ retry: false, staleTime: Infinity }) + const initial = yield* Effect.promise(async () => await server.query(serverOptions)) + const json = yield* prepareViewSnapshot(server).pipe( + Effect.provideService(SnapshotEncoding, encoding), + ) + expect(server.getQueryData(serverOptions.queryKey)).toBe(initial) + expect(initial.data.body).toBeInstanceOf(Profile) + expect(initial.status).toBe(200) + server.clear() + yield* Scope.close(serverScope, Exit.void) + yield* prepareViewHydration(browser, json).pipe( + Effect.provideService(SnapshotDecoding, decoding), + ) + const hydrated = yield* Effect.promise(async () => await browser.query(browserOptions)) + expect(browserReads).toBe(0) + expect(hydrated.data).toStrictEqual( + HttpApiSchema.withHeaders({ body: profile, headers: { 'x-version': 4 } }), + ) + expect(hydrated.data.body).toBeInstanceOf(Profile) + if (!(hydrated.data.body instanceof Profile)) { + throw new TypeError('Expected a hydrated Profile') + } + expect(hydrated.data.body.summary()).toBe('Ada: 9007199254740993 credits') + expect(hydrated.data.body.avatar).toStrictEqual(new Uint8Array([0, 1, 255])) + expect(hydrated.data.headers['x-version']).toBe(4) + expect(hydrated.status).toBe(200) + expect(hydrated.headers['x-version']).toBe('4') + yield* Effect.promise(async () => { + await browser.invalidateQueries({ + queryKey: browserOptions.queryKey, + exact: true, + refetchType: 'none', + }) + }) + const refreshed = yield* Effect.promise(async () => await browser.query(browserOptions)) + expect(refreshed.data.body).toBeInstanceOf(ArchivedProfile) + expect(refreshed.data.body.summary()).toBe('Grace: 42 archived credits') + expect(refreshed.data.headers['x-version']).toBe(7) + expect(refreshed.status).toBe(203) + expect(refreshed.headers['x-version']).toBe('7') + expect(Object.isFrozen(refreshed)).toBe(true) + expect(Object.isFrozen(refreshed.headers)).toBe(true) + const refreshedJson = yield* prepareViewSnapshot(browser).pipe( + Effect.provideService(SnapshotEncoding, encoding), + ) + yield* prepareViewHydration(restored, refreshedJson).pipe( + Effect.provideService(SnapshotDecoding, decoding), + ) + const restoredMetadata = restored.getQueryData(browserOptions.queryKey) + expect(restoredMetadata?.data).toStrictEqual( + HttpApiSchema.withHeaders({ body: archived, headers: { 'x-version': 7 } }), + ) + expect(restoredMetadata?.data.body).toBeInstanceOf(ArchivedProfile) + expect(restoredMetadata?.data.body.summary()).toBe('Grace: 42 archived credits') + expect(restoredMetadata).toMatchObject({ + status: 203, + headers: { 'x-version': '7' }, + data: { headers: { 'x-version': 7 } }, + }) + expect([serverReads, browserReads, encoded, decoded]).toStrictEqual([1, 1, 2, 2]) + }).pipe(Effect.provide(HttpServer.layerServices)), +) + +it.effect('hydrates a wrapped SSE snapshot and reconnects with independent browser resources', () => + Effect.gen(function* () { + const api = HttpApi.make('hydration-sse').add( + HttpApiGroup.make('profiles').add( + HttpApiEndpoint.get('watch', '/watch', { + success: HttpApiSchema.WithHeaders(HttpApiSchema.StreamSse({ data: HistoryElement }), { + 'x-version': Schema.FiniteFromString, + }), + }), + ), + ) + const profile = new Profile({ + name: 'Ada', + credits: 9_007_199_254_740_993n, + avatar: new Uint8Array([0, 1, 255]), + }) + const serverClosed = yield* Deferred.make() + const browserClosed = yield* Deferred.make() + const serverScope = yield* Scope.make() + const browserScope = yield* Scope.make() + const server = new QueryClient() + const browser = new QueryClient() + const restored = new QueryClient() + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + yield* Effect.promise(async () => { + await server.cancelQueries() + await browser.cancelQueries() + }) + server.clear() + browser.clear() + restored.clear() + yield* Scope.close(serverScope, Exit.void) + yield* Scope.close(browserScope, Exit.void) + }), + ) + let serverReads = 0 + let browserReads = 0 + const serverClient = yield* HttpApiTest.groups(api, ['profiles']).pipe( + Effect.provide( + HttpApiBuilder.group(api, 'profiles', (handlers) => + handlers.handle('watch', () => + Effect.sync(() => { + serverReads += 1 + return HttpApiSchema.withHeaders({ + body: Stream.make(profile).pipe( + Stream.concat(Stream.never), + Stream.ensuring(Deferred.succeed(serverClosed, undefined)), + ), + headers: { 'x-version': 4 }, + }) + }), + ), + ), + ), + Effect.provideService(Scope.Scope, serverScope), + ) + const browserClient = yield* HttpApiTest.groups(api, ['profiles']).pipe( + Effect.provide( + HttpApiBuilder.group(api, 'profiles', (handlers) => + handlers.handle('watch', () => + Effect.sync(() => { + browserReads += 1 + return HttpApiSchema.withHeaders({ + body: Stream.make(undefined).pipe( + Stream.concat(Stream.never), + Stream.ensuring(Deferred.succeed(browserClosed, undefined)), + ), + headers: { 'x-version': 7 }, + }) + }), + ), + ), + ), + Effect.provideService(Scope.Scope, browserScope), + ) + const serverOptions = createHttpApiQueryUtils(api, { + client: serverClient, + keyPrefix: ['hydration-views', 'wrapped-live'], + }).profiles.watch.liveOptions({ retry: false }) + const browserOptions = createHttpApiQueryUtils(api, { + client: browserClient, + keyPrefix: ['hydration-views', 'wrapped-live'], + }).profiles.watch.liveOptions({ retry: false }) + const initial = yield* Effect.promise( + async () => await fetchStreamSnapshot(server, serverOptions), + ) + yield* Deferred.await(serverClosed) + expect(initial.body).toBeInstanceOf(Profile) + expect(initial.headers['x-version']).toBe(4) + const json = yield* prepareViewSnapshot(server).pipe( + Effect.provideService(SnapshotEncoding, { beforeEncode: Effect.void }), + ) + expect(server.getQueryData(serverOptions.queryKey)).toBe(initial) + server.clear() + yield* Scope.close(serverScope, Exit.void) + expect(Deferred.isDoneUnsafe(browserClosed)).toBe(false) + yield* prepareViewHydration(browser, json).pipe( + Effect.provideService(SnapshotDecoding, { beforeDecode: Effect.void }), + ) + const hydrated = yield* Effect.promise( + async () => await fetchStreamSnapshot(browser, browserOptions, { mode: 'cached' }), + ) + expect(browserReads).toBe(0) + expect(hydrated).toStrictEqual( + HttpApiSchema.withHeaders({ body: profile, headers: { 'x-version': 4 } }), + ) + expect(hydrated.body).toBeInstanceOf(Profile) + if (!(hydrated.body instanceof Profile)) { + throw new TypeError('Expected a hydrated Profile') + } + expect(hydrated.body.summary()).toBe('Ada: 9007199254740993 credits') + expect(hydrated.body.avatar).toStrictEqual(new Uint8Array([0, 1, 255])) + expect(hydrated.headers['x-version']).toBe(4) + const fresh = yield* Effect.promise( + async () => await fetchStreamSnapshot(browser, browserOptions), + ) + yield* Deferred.await(browserClosed) + expect(fresh).toStrictEqual( + HttpApiSchema.withHeaders({ body: undefined, headers: { 'x-version': 7 } }), + ) + expect(fresh.body).toBeUndefined() + expect(fresh.headers['x-version']).toBe(7) + const freshJson = yield* prepareViewSnapshot(browser).pipe( + Effect.provideService(SnapshotEncoding, { beforeEncode: Effect.void }), + ) + yield* prepareViewHydration(restored, freshJson).pipe( + Effect.provideService(SnapshotDecoding, { beforeDecode: Effect.void }), + ) + const restoredSnapshot = restored.getQueryData(browserOptions.queryKey) + expect(restoredSnapshot).toStrictEqual( + HttpApiSchema.withHeaders({ body: undefined, headers: { 'x-version': 7 } }), + ) + expect(restoredSnapshot).toHaveProperty('body', undefined) + expect(restoredSnapshot?.headers['x-version']).toBe(7) + expect([serverReads, browserReads]).toStrictEqual([1, 1]) + expect(server.isFetching()).toBe(0) + expect(browser.isFetching()).toBe(0) + expect(server.getQueryCache().hasListeners()).toBe(false) + expect(browser.getQueryCache().hasListeners()).toBe(false) + }).pipe(Effect.provide(HttpServer.layerServices)), +) diff --git a/tests/types/docs-hydration-views.ts b/tests/types/docs-hydration-views.ts index 335837f..caca5ee 100644 --- a/tests/types/docs-hydration-views.ts +++ b/tests/types/docs-hydration-views.ts @@ -33,7 +33,7 @@ const ProfilePages = Schema.Struct({ pageParams: Schema.Array(Cursor), }) -class ArchivedProfile extends Schema.Class('ArchivedProfile')({ +export class ArchivedProfile extends Schema.Class('ArchivedProfile')({ archived: Schema.Literal(true), name: Schema.String, credits: Schema.BigIntFromString, From b8f2e44c793da1e71c888f952f813d7bfb5a14bb Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Sat, 10 Oct 2026 22:41:31 +0200 Subject: [PATCH 83/97] docs: state external HTTP example versions --- .intent/review-state.json | 114 +++--------------- .../docs/reference/external-http-clients.md | 1 + 2 files changed, 15 insertions(+), 100 deletions(-) diff --git a/.intent/review-state.json b/.intent/review-state.json index c409228..03f7885 100644 --- a/.intent/review-state.json +++ b/.intent/review-state.json @@ -3,7 +3,7 @@ "baseline": "5b91ddb1de13970365669c887b9d767ea417fbd4", "items": { "skill:skills/effect-api-query/SKILL.md": { - "fingerprint": "2b8fd1a3ae0c84ebe7e69e0dd47d528b6cc6ef7484604b8980654b0d81724678", + "fingerprint": "6cb271efec8ef9a5329b2ba28873ed5b4d07ad5cf2fc527dceb4235cee9f2b5c", "snapshot": { "GLOSSARY.md": "67de06543ad2fd9fd2917419e19d1f071e7b6eee53d48fca05e9dddfc739c683", "README.md": "b84fc747700efa4d7e53447f4b670e52fc22018de2777eaae0a663dd296e6528", @@ -44,7 +44,7 @@ "apps/docs/src/content/docs/reference/browser-and-worker-hosts.md": "70743a4429a39937515c8d5fee79ca75714a92dd526f1093f9fa8ea12fbde984", "apps/docs/src/content/docs/reference/compatibility-and-limits.md": "99909357a5c32eb8ed9443fc0d92c43b228591d5c2203f1c822c9bcc4a7b280d", "apps/docs/src/content/docs/reference/errors.md": "4454725375e05ea7a7d976d798c1c1d0c2731eefc79043f5e40dba88154069b1", - "apps/docs/src/content/docs/reference/external-http-clients.md": "a531d71bfd40e8a63683f88383c8d4fb807840177f23d6fb4e9481b6779aec2c", + "apps/docs/src/content/docs/reference/external-http-clients.md": "613f0e9493221416993e0c8012a97abdb3de2f80321aa7e4b0cda0f585618f91", "apps/docs/src/content/docs/reference/factory.md": "b099ed80d1ad8aab6a59213ee286290302bcf7885569ffb693fed784ca872dd3", "apps/docs/src/content/docs/reference/generated-builders.md": "b0f990437f967752f971ab0b7dc183119d619ee0159387079114fb83bea3ed21", "apps/docs/src/content/docs/reference/http-factory.md": "b4e24e6d63e4ed2088920dda8d142fbcae1bdda7ae23e43cfad398f8c95d9f0e", @@ -115,13 +115,8 @@ "pnpm-workspace.yaml": "58fd69fb2ebc237bf589893fd3150ec0dd25988aee57f465f5b373b038aee606", "scripts/decode-utf8.mts": "2e90c53e95c22b864a209a87c98084db6ec95e83f842007b21528d286d45c18c", "scripts/measure-construction.mts": "10522cb10ab51fd44d8990896a2a73e8bf4d4cd2646441e958b6180cb1afd147", - "scripts/measure-packed-consumer.mts": null, - "scripts/run-command.mts": null, "scripts/verify-docs-examples.mts": "9e379c826c5d7adafb1d6b59b526cbd59d3fd76b32f417da0ce2cd1bb40eb01c", - "scripts/verify-framework-browser.mts": null, "scripts/verify-packed-consumer.mts": null, - "scripts/verify-svelte-angular-consumer.mts": null, - "scripts/verify-vue-solid-consumer.mts": null, "skills/effect-api-query/SKILL.md": "1939a0f7536f3e88e1e1106e8125019c11d6fdd4ada8ae4d010507f264b234e5", "skills/effect-api-query/references/cache-workflows.md": "27bb96c48c84254ab8c379a529402f81cea3723c17d6396527c090a8c37e6f65", "skills/effect-api-query/references/frameworks-and-hosts.md": "d84c787ccfc76b1f01f3d44d02d969ed9422af7d9dbdda862e661d65af9e44df", @@ -178,46 +173,6 @@ "tests/http-semantic-keys.test.ts": "2e78788697bdf7efec374669d8123da57c93df3c368d41920decfcd175bc2359", "tests/http-transport-cancellation.test.ts": "e56f8571f949adc9d44f14b61e20edddb188a9565bf7a585859ffe73a851ac39", "tests/http-view-proof.test.ts": "4dc91c38efe5b0c643ef61c98dcdf4a89acf4ef4c1651030124d82ca6cb55a61", - "tests/packed-consumer/defaults-contract.ts": null, - "tests/packed-consumer/docs-angular.ts": null, - "tests/packed-consumer/docs-cache-filters-http.ts": null, - "tests/packed-consumer/docs-cache-filters-rpc.ts": null, - "tests/packed-consumer/docs-defaults.ts": null, - "tests/packed-consumer/docs-http-defaults.ts": null, - "tests/packed-consumer/docs-hydration-async.ts": null, - "tests/packed-consumer/docs-hydration-dto.ts": null, - "tests/packed-consumer/docs-hydration-rich.ts": null, - "tests/packed-consumer/docs-hydration-views.ts": null, - "tests/packed-consumer/docs-openapi.ts": null, - "tests/packed-consumer/docs-owner-cache.ts": null, - "tests/packed-consumer/docs-retry.ts": null, - "tests/packed-consumer/docs-solid-query.ts": null, - "tests/packed-consumer/docs-stream-snapshot.ts": null, - "tests/packed-consumer/docs-user-events.ts": null, - "tests/packed-consumer/docs-vue-query.ts": null, - "tests/packed-consumer/docs-websocket-use.ts": null, - "tests/packed-consumer/events-contract.ts": null, - "tests/packed-consumer/filter-contract.ts": null, - "tests/packed-consumer/framework-fixture.ts": null, - "tests/packed-consumer/hosts/browser.ts": null, - "tests/packed-consumer/hosts/runtime.ts": null, - "tests/packed-consumer/hosts/worker.ts": null, - "tests/packed-consumer/hydration-contract.ts": null, - "tests/packed-consumer/hydration-views-contract.ts": null, - "tests/packed-consumer/openapi-api.ts": null, - "tests/packed-consumer/openapi-contract.ts": null, - "tests/packed-consumer/optimistic-contract.ts": null, - "tests/packed-consumer/owner-cache-contract.ts": null, - "tests/packed-consumer/owner-cache.ts": null, - "tests/packed-consumer/queries.ts": null, - "tests/packed-consumer/snapshot-contract.ts": null, - "tests/packed-consumer/socket-client.ts": null, - "tests/packed-consumer/svelte-angular-contract.ts": null, - "tests/packed-consumer/svelte-angular-runtime.ts": null, - "tests/packed-consumer/user-events.ts": null, - "tests/packed-consumer/vue-solid-contract.ts": null, - "tests/packed-consumer/vue-solid-runtime.ts": null, - "tests/packed-consumer/websocket-checks.ts": null, "tests/stream-snapshot.test.ts": "c419902fad27b91205ff5e4b65510ce9ed0c3d0969300c8891f1bc314a7ce0e5", "tests/types/docs-http-quick-start.ts": "5f7eef98be244ea392fc2c5b527858f159a08efebf6f2665725301aa8a910050", "tests/types/docs-hydration-async.ts": "de67135bf3da0dffdac47e9f2e49469edddac3d7d026fbd93d39aea6964c0dd8", @@ -238,15 +193,17 @@ "tests/types/tanstack-start-contract.ts": "51681e3d0d7504a614d490f18adb6f44db82d34b9d4d7cb9cf941aea33de284f", "tests/types/type-scale.ts": "a9b307c25c72f3c184eabc8e22ddaeea96feb331c0f8d748773d765d358dbfca" }, - "head": "55ee50f86d171e05f9c66df2f36bc6ce1f15c1a1", + "head": "60c89f64b99028bb46584eae853ce1e5ea1343d7", "outcome": "no-change", - "reason": "The existing hydration-and-ssr reference already requires reconstruction of decoded HTTP wrappers, preservation of undefined wrapped bodies, all declared successes, rich values and caller-owned encoding/decoding services. Existing cache-workflows guidance retains original-owner scoped events. New ordinary HTTP controls exercise those claims and the event guide now matches its documented app-relative helper layout through compiler parity normalization; skill task routing, source globs, activation and purpose remain accurate.", + "reason": "The external HTTP reference now identifies the example API versions (openapi-fetch 0.17.0, openapi-typescript 7.13.0, Effect 4.0.0), without changing an API, recipe, or support guarantee. The shipped HTTP and frameworks-and-hosts guidance still correctly requires native ready Effect clients, describes encoded DTO consumption separately, and preserves multipart/SSE limits. No skill rewrite or version change is needed.", "evidence": [ - "Correction source at 55ee50f86d171e05f9c66df2f36bc6ce1f15c1a1 on full base 1e235b1b5f4e4f6d6d34ce397ad0b573e6c5392d. Native vp run --no-cache test tests/http-hydration.test.ts tests/cache-recipes.test.ts passed 4 tests in 2 files plus public/server-local compiler projects on TS 7.0.2 and TS 5.9.3, native pack publint/attw. Native check, strict effect-check, Fallow and docs-check passed. Logs: /tmp/effect-api-query-implementation/notes/131-correction-*. No fresh independent consumer session was run for these existing-guidance/import and regression-test corrections; it remains unverified. No skill description, purpose, public behavior, dependency, host, release or support-claim change. Reviewed item: skills/effect-api-query/SKILL.md." + "Author base 60c89f64b99028bb46584eae853ce1e5ea1343d7; apps/docs/src/content/docs/reference/external-http-clients.md adds one example-scope sentence with versions grounded in 1e235b1b5f4e4f6d6d34ce397ad0b573e6c5392d.", + "Read skills/effect-api-query/SKILL.md and references/http.md and references/frameworks-and-hosts.md; native sync reports Nothing to synchronize.", + "vp run format passed; vp run docs-build passed documentation example parity, Astro check (0 errors, warnings, hints), 44-page build, search and internal links. No changed code or snippets, runtime tests, dependencies or installations. Fresh consumer execution is not rerun for this prose-only clarification." ] }, "planning:_artifacts": { - "fingerprint": "ac317b6880cb266c398ece266cb61985ec63ec00bbcbd163fe824ed10eafc13d", + "fingerprint": "aec09f656d8eeef68940b664baf6b8f6cae6b721bd1bdc844c9620949fa532c9", "snapshot": { ".agents/skills/ask-matt/PHASE-BOUNDARIES.md": "a8aa20158609ef39e2b308b6ba0660c91066838d07a867202e63a7744a88a3ed", ".agents/skills/ask-matt/SKILL.md": "1371f5ce005de0992a44b137748bfb960a56948aa9871b405fe9d90b24332e3c", @@ -370,7 +327,7 @@ "apps/docs/src/content/docs/reference/browser-and-worker-hosts.md": "70743a4429a39937515c8d5fee79ca75714a92dd526f1093f9fa8ea12fbde984", "apps/docs/src/content/docs/reference/compatibility-and-limits.md": "99909357a5c32eb8ed9443fc0d92c43b228591d5c2203f1c822c9bcc4a7b280d", "apps/docs/src/content/docs/reference/errors.md": "4454725375e05ea7a7d976d798c1c1d0c2731eefc79043f5e40dba88154069b1", - "apps/docs/src/content/docs/reference/external-http-clients.md": "a531d71bfd40e8a63683f88383c8d4fb807840177f23d6fb4e9481b6779aec2c", + "apps/docs/src/content/docs/reference/external-http-clients.md": "613f0e9493221416993e0c8012a97abdb3de2f80321aa7e4b0cda0f585618f91", "apps/docs/src/content/docs/reference/factory.md": "b099ed80d1ad8aab6a59213ee286290302bcf7885569ffb693fed784ca872dd3", "apps/docs/src/content/docs/reference/generated-builders.md": "b0f990437f967752f971ab0b7dc183119d619ee0159387079114fb83bea3ed21", "apps/docs/src/content/docs/reference/http-factory.md": "b4e24e6d63e4ed2088920dda8d142fbcae1bdda7ae23e43cfad398f8c95d9f0e", @@ -441,13 +398,8 @@ "pnpm-workspace.yaml": "58fd69fb2ebc237bf589893fd3150ec0dd25988aee57f465f5b373b038aee606", "scripts/decode-utf8.mts": "2e90c53e95c22b864a209a87c98084db6ec95e83f842007b21528d286d45c18c", "scripts/measure-construction.mts": "10522cb10ab51fd44d8990896a2a73e8bf4d4cd2646441e958b6180cb1afd147", - "scripts/measure-packed-consumer.mts": null, - "scripts/run-command.mts": null, "scripts/verify-docs-examples.mts": "9e379c826c5d7adafb1d6b59b526cbd59d3fd76b32f417da0ce2cd1bb40eb01c", - "scripts/verify-framework-browser.mts": null, "scripts/verify-packed-consumer.mts": null, - "scripts/verify-svelte-angular-consumer.mts": null, - "scripts/verify-vue-solid-consumer.mts": null, "skills/effect-api-query/SKILL.md": "1939a0f7536f3e88e1e1106e8125019c11d6fdd4ada8ae4d010507f264b234e5", "skills/effect-api-query/references/cache-workflows.md": "27bb96c48c84254ab8c379a529402f81cea3723c17d6396527c090a8c37e6f65", "skills/effect-api-query/references/frameworks-and-hosts.md": "d84c787ccfc76b1f01f3d44d02d969ed9422af7d9dbdda862e661d65af9e44df", @@ -504,46 +456,6 @@ "tests/http-semantic-keys.test.ts": "2e78788697bdf7efec374669d8123da57c93df3c368d41920decfcd175bc2359", "tests/http-transport-cancellation.test.ts": "e56f8571f949adc9d44f14b61e20edddb188a9565bf7a585859ffe73a851ac39", "tests/http-view-proof.test.ts": "4dc91c38efe5b0c643ef61c98dcdf4a89acf4ef4c1651030124d82ca6cb55a61", - "tests/packed-consumer/defaults-contract.ts": null, - "tests/packed-consumer/docs-angular.ts": null, - "tests/packed-consumer/docs-cache-filters-http.ts": null, - "tests/packed-consumer/docs-cache-filters-rpc.ts": null, - "tests/packed-consumer/docs-defaults.ts": null, - "tests/packed-consumer/docs-http-defaults.ts": null, - "tests/packed-consumer/docs-hydration-async.ts": null, - "tests/packed-consumer/docs-hydration-dto.ts": null, - "tests/packed-consumer/docs-hydration-rich.ts": null, - "tests/packed-consumer/docs-hydration-views.ts": null, - "tests/packed-consumer/docs-openapi.ts": null, - "tests/packed-consumer/docs-owner-cache.ts": null, - "tests/packed-consumer/docs-retry.ts": null, - "tests/packed-consumer/docs-solid-query.ts": null, - "tests/packed-consumer/docs-stream-snapshot.ts": null, - "tests/packed-consumer/docs-user-events.ts": null, - "tests/packed-consumer/docs-vue-query.ts": null, - "tests/packed-consumer/docs-websocket-use.ts": null, - "tests/packed-consumer/events-contract.ts": null, - "tests/packed-consumer/filter-contract.ts": null, - "tests/packed-consumer/framework-fixture.ts": null, - "tests/packed-consumer/hosts/browser.ts": null, - "tests/packed-consumer/hosts/runtime.ts": null, - "tests/packed-consumer/hosts/worker.ts": null, - "tests/packed-consumer/hydration-contract.ts": null, - "tests/packed-consumer/hydration-views-contract.ts": null, - "tests/packed-consumer/openapi-api.ts": null, - "tests/packed-consumer/openapi-contract.ts": null, - "tests/packed-consumer/optimistic-contract.ts": null, - "tests/packed-consumer/owner-cache-contract.ts": null, - "tests/packed-consumer/owner-cache.ts": null, - "tests/packed-consumer/queries.ts": null, - "tests/packed-consumer/snapshot-contract.ts": null, - "tests/packed-consumer/socket-client.ts": null, - "tests/packed-consumer/svelte-angular-contract.ts": null, - "tests/packed-consumer/svelte-angular-runtime.ts": null, - "tests/packed-consumer/user-events.ts": null, - "tests/packed-consumer/vue-solid-contract.ts": null, - "tests/packed-consumer/vue-solid-runtime.ts": null, - "tests/packed-consumer/websocket-checks.ts": null, "tests/stream-snapshot.test.ts": "c419902fad27b91205ff5e4b65510ce9ed0c3d0969300c8891f1bc314a7ce0e5", "tests/types/docs-http-quick-start.ts": "5f7eef98be244ea392fc2c5b527858f159a08efebf6f2665725301aa8a910050", "tests/types/docs-hydration-async.ts": "de67135bf3da0dffdac47e9f2e49469edddac3d7d026fbd93d39aea6964c0dd8", @@ -564,11 +476,13 @@ "tests/types/tanstack-start-contract.ts": "51681e3d0d7504a614d490f18adb6f44db82d34b9d4d7cb9cf941aea33de284f", "tests/types/type-scale.ts": "a9b307c25c72f3c184eabc8e22ddaeea96feb331c0f8d748773d765d358dbfca" }, - "head": "55ee50f86d171e05f9c66df2f36bc6ce1f15c1a1", + "head": "60c89f64b99028bb46584eae853ce1e5ea1343d7", "outcome": "no-change", - "reason": "Reviewed all three planning records. domain_map already covers all decoded query views, rich paired codecs, caller ownership and snapshot cleanup, with the same retained hydration fixture as evidence. skill_spec already distinguishes compiler fixtures from runtime recipe/ownership checks. skill_tree existing tests/**/*.ts, scripts/*.mts and docs source globs cover the new focused test and corrected imports. No domain, task, boundary, file route, version or source-mapping change is needed.", + "reason": "Reviewed all three planning files. domain_map.yaml already covers application-owned transports within documented version and host boundaries; skill_spec.md already routes version/host limits to the established conditional reference; skill_tree.yaml already tracks this external HTTP reference through the docs source glob. The example-scope sentence changes no task, seam, ownership, source mapping, prerequisite or distribution decision, so all three records remain accurate.", "evidence": [ - "Correction source at 55ee50f86d171e05f9c66df2f36bc6ce1f15c1a1 on full base 1e235b1b5f4e4f6d6d34ce397ad0b573e6c5392d. Native vp run --no-cache test tests/http-hydration.test.ts tests/cache-recipes.test.ts passed 4 tests in 2 files plus public/server-local compiler projects on TS 7.0.2 and TS 5.9.3, native pack publint/attw. Native check, strict effect-check, Fallow and docs-check passed. Logs: /tmp/effect-api-query-implementation/notes/131-correction-*. No fresh independent consumer session was run for these existing-guidance/import and regression-test corrections; it remains unverified. No skill description, purpose, public behavior, dependency, host, release or support-claim change. Reviewed item: _artifacts." + "_artifacts/domain_map.yaml, _artifacts/skill_spec.md, and _artifacts/skill_tree.yaml were read and compared with the one-line diff at author base 60c89f64b99028bb46584eae853ce1e5ea1343d7.", + "Native maintainer sync made no changes; actual maintainer review reports this external HTTP doc as the sole owned changed source with no owned mapping or planning problems.", + "vp run format and vp run docs-build passed; git diff --check passed. No artificial planning history or guidance version bump added." ] }, "source:vite.config.ts": { diff --git a/apps/docs/src/content/docs/reference/external-http-clients.md b/apps/docs/src/content/docs/reference/external-http-clients.md index 70de11f..a72f700 100644 --- a/apps/docs/src/content/docs/reference/external-http-clients.md +++ b/apps/docs/src/content/docs/reference/external-http-clients.md @@ -6,6 +6,7 @@ description: Generated OpenAPI consumers, encoded request values, multipart uplo An external HTTP client can call an Effect HttpApi server through its declared HTTP contract. For example, use `openapi-typescript` to generate `paths` from the JSON document returned by native `OpenApi.fromApi(api)` and call the server with `openapi-fetch`. +The examples target `openapi-fetch` 0.17.0, `openapi-typescript` 7.13.0, and Effect 4.0.0. `createHttpApiQueryUtils` accepts a ready Effect HttpApiClient. Keep that client and its runner native; the external Promise client calls HTTP independently. This interoperability does not From ce109cb1cc8e842831561b82bc5e1fbbb1e8ee96 Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Sat, 10 Oct 2026 22:52:31 +0200 Subject: [PATCH 84/97] chore: author Astro config as TypeScript ESM --- .fallowrc.jsonc | 2 +- .intent/review-state.json | 40 ++++++++++++++++--- .../{astro.config.mjs => astro.config.mts} | 2 - 3 files changed, 36 insertions(+), 8 deletions(-) rename apps/docs/{astro.config.mjs => astro.config.mts} (99%) diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 8532b52..4970bc8 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -16,7 +16,7 @@ // Framework tooling consumes these exports through convention-generated entries. "ignoreExports": [ { - "file": "apps/docs/astro.config.mjs", + "file": "apps/docs/astro.config.mts", "exports": ["default"] }, { diff --git a/.intent/review-state.json b/.intent/review-state.json index 03f7885..6f382b8 100644 --- a/.intent/review-state.json +++ b/.intent/review-state.json @@ -568,15 +568,17 @@ ] }, "source:.fallowrc.jsonc": { - "fingerprint": "b446acae3ab99d47f9faf63e50f26342d84dec40d958328e038d670da4efdf91", + "fingerprint": "2c6bb6ebb6e814610554e636e2d203764192734a848111efdedaf145cc0b62f6", "snapshot": { - ".fallowrc.jsonc": "984d1fa5cb742119f829e1cd30df07024882bc7be2145e3ee003b462e78c76af" + ".fallowrc.jsonc": "c0b0ec773be6b6247fd4e7a1db0229965323d01b40236e05f1fa7c3cbb0f95a1" }, - "head": "1e235b1b5f4e4f6d6d34ce397ad0b573e6c5392d", + "head": "2e4ecf18e93a4ee086d74102c3addb5e2029be56", "outcome": "no-change", - "reason": "Retired installed-consumer entries and obsolete framework/OpenAPI allowances were removed. Retained compiler fixtures and actual source tooling are entry points. Intent remains a required native repository CLI after its verifier imports disappear, so its dependency receives the same tooling allowance as other native tasks. Normal entry/production/dependency/duplication/health audits pass; this changes maintenance reachability, not integration guidance.", + "reason": "The explicit ignored default-export path now names apps/docs/astro.config.mts. This preserves the existing framework-tooling export exception for the same unchanged configuration; native Fallow entry/production checks pass. No library integration task, shipped guidance, or planning source mapping changes.", "evidence": [ - "Base 1e235b1b5f4e4f6d6d34ce397ad0b573e6c5392d; vp run --no-cache quality passed: 38 files, 283 runtime tests, strict public and server-local fixtures on TS 7.0.2 and TS 5.9.3, native pack/attw/publint, Effect diagnostics and Fallow audits. Both example builds and docs-build passed (44 pages; docs parity and Astro diagnostics). Existing docs-e2e passed 14 Chromium/Firefox tests. Native skills-check validated the owned skill. Native source construction-baseline passed. Logs are under /tmp/effect-api-query-implementation/notes/131-*. No fresh independent consumer session ran for this reference/source-mapping update; retired installation/certification matrices were not repeated. The optional generic skill-creator validator rejected the existing Intent sources frontmatter field; native Intent validation passed and its required schema is preserved. Reviewed item: .fallowrc.jsonc. The final tooling list was alphabetized and the native Fallow task passed again after that configuration-only cleanup." + "Author base 2e4ecf18e93a4ee086d74102c3addb5e2029be56. Rename apps/docs/astro.config.mjs to astro.config.mts, remove ts-check directive, and update only .fallowrc.jsonc ignoreExports path. Installed loader evidence: apps/docs/node_modules/astro/dist/core/config/config.js configPaths contains astro.config.mts.", + "vp run format apps/docs/astro.config.mts .fallowrc.jsonc passed. vp run fallow passed entry/production dead-code checks, duplicates task, and health threshold. vp run docs-build passed Astro check with zero errors/warnings/hints and build of 44 pages with valid internal links; unchanged documentation example parity was a native cache hit. Logs: /tmp/effect-api-query-implementation/notes/astro-mts-*.log.", + "Read _artifacts/domain_map.yaml, skill_spec.md, and skill_tree.yaml: existing library task/seam/ownership coverage and source globs remain accurate; no library behavior, guidance, dependency or installation change. Native sync changed nothing; review reports no owned skill/planning drift. No new independent consumer or browser execution is needed for this repository config rename." ] }, "source:.github/workflows/ci.yml": { @@ -998,6 +1000,34 @@ "evidence": [ "Base 1e235b1b5f4e4f6d6d34ce397ad0b573e6c5392d; vp run --no-cache quality passed: 38 files, 283 runtime tests, strict public and server-local fixtures on TS 7.0.2 and TS 5.9.3, native pack/attw/publint, Effect diagnostics and Fallow audits. Both example builds and docs-build passed (44 pages; docs parity and Astro diagnostics). Existing docs-e2e passed 14 Chromium/Firefox tests. Native skills-check validated the owned skill. Native source construction-baseline passed. Logs are under /tmp/effect-api-query-implementation/notes/131-*. No fresh independent consumer session ran for this reference/source-mapping update; retired installation/certification matrices were not repeated. The optional generic skill-creator validator rejected the existing Intent sources frontmatter field; native Intent validation passed and its required schema is preserved. Reviewed item: tests/types/tsconfig.json." ] + }, + "source:apps/docs/astro.config.mjs": { + "fingerprint": "b235762ea71837b6baccbfcaa7064ba8bdf99fc1f45eb6ca6e70b31d209f970e", + "snapshot": { + "apps/docs/astro.config.mjs": null + }, + "head": "2e4ecf18e93a4ee086d74102c3addb5e2029be56", + "outcome": "no-change", + "reason": "The authored JavaScript config is removed in favor of astro.config.mts, with the same imports, JSON import attribute, and complete Astro/Starlight configuration. Only the redundant JavaScript ts-check directive disappears. Removing the old path lets installed Astro discover the .mts file; no runtime setting, public API, recipe or support claim changes, so shipped guidance and planning remain accurate.", + "evidence": [ + "Author base 2e4ecf18e93a4ee086d74102c3addb5e2029be56. Rename apps/docs/astro.config.mjs to astro.config.mts, remove ts-check directive, and update only .fallowrc.jsonc ignoreExports path. Installed loader evidence: apps/docs/node_modules/astro/dist/core/config/config.js configPaths contains astro.config.mts.", + "vp run format apps/docs/astro.config.mts .fallowrc.jsonc passed. vp run fallow passed entry/production dead-code checks, duplicates task, and health threshold. vp run docs-build passed Astro check with zero errors/warnings/hints and build of 44 pages with valid internal links; unchanged documentation example parity was a native cache hit. Logs: /tmp/effect-api-query-implementation/notes/astro-mts-*.log.", + "Read _artifacts/domain_map.yaml, skill_spec.md, and skill_tree.yaml: existing library task/seam/ownership coverage and source globs remain accurate; no library behavior, guidance, dependency or installation change. Native sync changed nothing; review reports no owned skill/planning drift. No new independent consumer or browser execution is needed for this repository config rename." + ] + }, + "source:apps/docs/astro.config.mts": { + "fingerprint": "a203f42f861dd5c68e83ad5196f881fd30a98b99e9378bdf8137569697216d8b", + "snapshot": { + "apps/docs/astro.config.mts": "397aafc4621e59a3df38395dab8ae88f7dab3c86d121399c25f3e79dcbd23d85" + }, + "head": "2e4ecf18e93a4ee086d74102c3addb5e2029be56", + "outcome": "no-change", + "reason": "This is the existing Astro configuration under an explicitly TypeScript ESM extension. Installed Astro 7.3.5 discovers .mts configurations; ordinary Astro diagnostics and the complete static documentation build load it successfully. The source settings, plugins, paths and site content are preserved. It remains repository tooling, outside the shipped integration skill source scope, so no new mapping or guidance task is warranted.", + "evidence": [ + "Author base 2e4ecf18e93a4ee086d74102c3addb5e2029be56. Rename apps/docs/astro.config.mjs to astro.config.mts, remove ts-check directive, and update only .fallowrc.jsonc ignoreExports path. Installed loader evidence: apps/docs/node_modules/astro/dist/core/config/config.js configPaths contains astro.config.mts.", + "vp run format apps/docs/astro.config.mts .fallowrc.jsonc passed. vp run fallow passed entry/production dead-code checks, duplicates task, and health threshold. vp run docs-build passed Astro check with zero errors/warnings/hints and build of 44 pages with valid internal links; unchanged documentation example parity was a native cache hit. Logs: /tmp/effect-api-query-implementation/notes/astro-mts-*.log.", + "Read _artifacts/domain_map.yaml, skill_spec.md, and skill_tree.yaml: existing library task/seam/ownership coverage and source globs remain accurate; no library behavior, guidance, dependency or installation change. Native sync changed nothing; review reports no owned skill/planning drift. No new independent consumer or browser execution is needed for this repository config rename." + ] } } } diff --git a/apps/docs/astro.config.mjs b/apps/docs/astro.config.mts similarity index 99% rename from apps/docs/astro.config.mjs rename to apps/docs/astro.config.mts index e23aa40..97966aa 100644 --- a/apps/docs/astro.config.mjs +++ b/apps/docs/astro.config.mts @@ -1,5 +1,3 @@ -// @ts-check - import starlight from '@astrojs/starlight' import { defineConfig } from 'astro/config' import starlightLinksValidator from 'starlight-links-validator' From 935c9e0a943ca778717fd4ccf0bd5d892acfa012 Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Sat, 10 Oct 2026 23:17:43 +0200 Subject: [PATCH 85/97] chore: align native Intent maintainer checks --- .github/workflows/ci.yml | 11 +- .intent/review-state.json | 317 +++++++++++++++++++++++++------------- AGENTS.md | 3 +- README.md | 2 +- _artifacts/skill_spec.md | 23 ++- package.json | 2 +- pnpm-lock.yaml | 10 +- 7 files changed, 254 insertions(+), 114 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7f2a998..bb1794a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,7 @@ jobs: name: Check out repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + fetch-depth: 0 persist-credentials: false - &setup_repository @@ -51,7 +52,15 @@ jobs: run: vp run api-types - name: Check shipped skills - run: vp run skills-check + env: + INTENT_REVIEW_BASE: ${{ github.event.pull_request.base.sha }} + run: | + vp run skills-check + if [ -n "$INTENT_REVIEW_BASE" ]; then + vp exec intent maintainer check --base "$INTENT_REVIEW_BASE" --github-summary + else + vp exec intent maintainer check --github-summary + fi - name: Run Effect diagnostics run: vp run effect-check diff --git a/.intent/review-state.json b/.intent/review-state.json index 6f382b8..b07cc9c 100644 --- a/.intent/review-state.json +++ b/.intent/review-state.json @@ -3,10 +3,10 @@ "baseline": "5b91ddb1de13970365669c887b9d767ea417fbd4", "items": { "skill:skills/effect-api-query/SKILL.md": { - "fingerprint": "6cb271efec8ef9a5329b2ba28873ed5b4d07ad5cf2fc527dceb4235cee9f2b5c", + "fingerprint": "7c985709b807e79703000bd34c139f7a306d7fe16136783a0bc55090eac2f8f4", "snapshot": { "GLOSSARY.md": "67de06543ad2fd9fd2917419e19d1f071e7b6eee53d48fca05e9dddfc739c683", - "README.md": "b84fc747700efa4d7e53447f4b670e52fc22018de2777eaae0a663dd296e6528", + "README.md": "35d680dfe39a73da641b0d3fda4480883a486b6f43942138d2db9981971b82bf", "apps/docs/src/content/docs/concepts/client-lifecycle.md": "4a7d412f6e3c7dfbf0442b51ee9054c2b854026bcf968961cee65071f7cbb424", "apps/docs/src/content/docs/concepts/data-normalization.md": "f1505ec3eb35a33fac289706c2cc318a2ffb80555e3302ca102e67e21c9012da", "apps/docs/src/content/docs/concepts/http-utility-tree.md": "ca4acbe1af20538c0d08a3e03969efe1966e7822ce6dfce8d0c1efb6913795cf", @@ -111,7 +111,7 @@ "examples/vite-react/tests/owner-cache.test.ts": "8818e30fdd3071a2e5dc2d865161e0d9f40de1ff37813b0a396bb2e83dd08a1d", "examples/vite-react/tests/owner-switch.test.ts": "c997c9758269586591cbf55de4651c64091abc9061b56edb024dcbf9dbaa9c1d", "examples/vite-react/tests/user-writes.test.ts": "2492f48018e7d8e684eae1c8d302acc9961a1bfbd60bf3214c113dc1b6fbe54a", - "package.json": "3f04c1e2c88b69a86c704a0178d7a82a75d47fcec89e6225ca6be3272d8970ac", + "package.json": "dc41aa3f5b1633ffb2e6f6a0b7efb4e0119d74b9bc8c0003b1a8ee5fad854782", "pnpm-workspace.yaml": "58fd69fb2ebc237bf589893fd3150ec0dd25988aee57f465f5b373b038aee606", "scripts/decode-utf8.mts": "2e90c53e95c22b864a209a87c98084db6ec95e83f842007b21528d286d45c18c", "scripts/measure-construction.mts": "10522cb10ab51fd44d8990896a2a73e8bf4d4cd2646441e958b6180cb1afd147", @@ -193,102 +193,22 @@ "tests/types/tanstack-start-contract.ts": "51681e3d0d7504a614d490f18adb6f44db82d34b9d4d7cb9cf941aea33de284f", "tests/types/type-scale.ts": "a9b307c25c72f3c184eabc8e22ddaeea96feb331c0f8d748773d765d358dbfca" }, - "head": "60c89f64b99028bb46584eae853ce1e5ea1343d7", + "head": "6893603eab70bd9d67e9848414eccc8a3421d870", "outcome": "no-change", - "reason": "The external HTTP reference now identifies the example API versions (openapi-fetch 0.17.0, openapi-typescript 7.13.0, Effect 4.0.0), without changing an API, recipe, or support guarantee. The shipped HTTP and frameworks-and-hosts guidance still correctly requires native ready Effect clients, describes encoded DTO consumption separately, and preserves multipart/SSE limits. No skill rewrite or version change is needed.", + "reason": "The only mapped changes since the last review are README delivery syntax and the Intent devDependency pin. The seven-file integration skill still describes the same public API and application ownership; no consumer recipe changed.", "evidence": [ - "Author base 60c89f64b99028bb46584eae853ce1e5ea1343d7; apps/docs/src/content/docs/reference/external-http-clients.md adds one example-scope sentence with versions grounded in 1e235b1b5f4e4f6d6d34ce397ad0b573e6c5392d.", - "Read skills/effect-api-query/SKILL.md and references/http.md and references/frameworks-and-hosts.md; native sync reports Nothing to synchronize.", - "vp run format passed; vp run docs-build passed documentation example parity, Astro check (0 errors, warnings, hints), 44-page build, search and internal links. No changed code or snippets, runtime tests, dependencies or installations. Fresh consumer execution is not rerun for this prose-only clarification." + "README.md adds the native-supported --full-depth flag; package.json pins eligible @tanstack/intent 0.5.3.", + "Skills CLI 1.7.0 genuinely discovered and copied this repository-root skill: seven byte-identical files and 13 internal relative links; notes/skills-cli-smoke.md records the local-copy scope and limits.", + "Current batch: scoped vp run format, native vp run skills-check, and installed YAML parsing passed; vp exec intent maintainer sync reported nothing to synchronize." ] }, "planning:_artifacts": { - "fingerprint": "aec09f656d8eeef68940b664baf6b8f6cae6b721bd1bdc844c9620949fa532c9", + "fingerprint": "be5b9673275b1e93a738b52799433b9f9c6785a34ada4b9250c84a9fa5d4bbea", "snapshot": { - ".agents/skills/ask-matt/PHASE-BOUNDARIES.md": "a8aa20158609ef39e2b308b6ba0660c91066838d07a867202e63a7744a88a3ed", - ".agents/skills/ask-matt/SKILL.md": "1371f5ce005de0992a44b137748bfb960a56948aa9871b405fe9d90b24332e3c", - ".agents/skills/ask-matt/agents/openai.yaml": "bdffbc5a0a99ed1b6ef3253d251d755fd18162b9845972e380007f844b09b05c", - ".agents/skills/code-review/SKILL.md": "09eb147f793e4647949edd32dbad278f0554dfb56e0d46b699cd4c2c55bf0357", - ".agents/skills/code-review/agents/openai.yaml": "8229ca854e11dc8e6aef2131ee03f31fb1561cf905fab9ccc325180cf3331352", - ".agents/skills/codebase-design/DEEPENING.md": "f3dd099ce99289bd213914d8ee3e2429b78309c3957ca4583f7659551b1d53c1", - ".agents/skills/codebase-design/DESIGN-IT-TWICE.md": "52b52e75655a27aea59c6966b410bac82448968ad46ef1ebb9f07c274e3ae010", - ".agents/skills/codebase-design/SKILL.md": "2c20617f87ec8af6a434859f381b2f061a69b530444e74eb39e78bb016a6d1e2", - ".agents/skills/codebase-design/agents/openai.yaml": "edebc9e4fcfe102114012575eaa9600b9b5fd08c311664f389c36e7bc717740f", - ".agents/skills/diagnosing-bugs/SKILL.md": "849198df88cdb65fd0d97236604c1fca7ff2f22d2f705f9724a800d0266b118d", - ".agents/skills/diagnosing-bugs/agents/openai.yaml": "3e430dbe4334a87597488c060cb3dc3786bb00c9182877d6f5ec41f62490e90b", - ".agents/skills/diagnosing-bugs/scripts/hitl-loop.template.sh": "35103539fc36873eea36074769ad454f9379d6fc8b2dc0e26ce987fd3bfe5503", - ".agents/skills/documentation-writer/SKILL.md": "7e8244988c9f4eb63bf8c0edf160578544621eb96e5e51e2d848f1401c5de8f1", - ".agents/skills/domain-modeling/ADR-FORMAT.md": "944c92aa790e8fbdc9199640b170979abb8a34ba8d0fe18c2a01a63bce140ca0", - ".agents/skills/domain-modeling/GLOSSARY-FORMAT.md": "21dcc40cdec8e151a829b98ab1517c1867bc444e3665134a98c6983411996801", - ".agents/skills/domain-modeling/SKILL.md": "7b925d7b1e341a2eeae33ad68a8a8c0ab889a38ddd22a7598e4c240e5a7556a3", - ".agents/skills/domain-modeling/agents/openai.yaml": "f6bf2aa996c6e6f53fdd0708e18a0d16a56aed8322cca59fedbe3c0d2c75f06b", - ".agents/skills/effect-ts/SKILL.md": "509ed4e10def32dc3f6b20854c9ae50a56b7dc525a14a02ab9871eef53a2052e", - ".agents/skills/grill-me/SKILL.md": "caaf8b8de1684f96e26b28f3c29189db5c89cce4b73e1c93d86164f66ef88637", - ".agents/skills/grill-me/agents/openai.yaml": "c061e39c3e0f9d865fb1b97556d485704af2a8a58f4b8221a8917a5c2074a32b", - ".agents/skills/grill-with-docs/SKILL.md": "7de372c13488f1ee96cc11cd8907b56b6809cc93eef776eeddd37de6b6cbe3fe", - ".agents/skills/grill-with-docs/agents/openai.yaml": "94cd0ab161fb468a836349f5ed482ba58ce8e709a05c57ce533d739dbd35cca9", - ".agents/skills/grilling/SKILL.md": "befdff1e27a1dfe2a294d5871169ac1afe16d983911daa4f32627514a48c1e15", - ".agents/skills/grilling/agents/openai.yaml": "1411d7df7d99b7e621a1ff8283c8133cc2464be63d064e52d8ce169c6800ee9b", - ".agents/skills/handoff/SKILL.md": "4cbb043950dad2ffd3662ff1a5a0b51ae676004b5cae10e3787e8ee7a4ee69d3", - ".agents/skills/handoff/agents/openai.yaml": "5c479fd562c691851690e8b18c8501045bef0943c10743d636b2fae26add1d28", - ".agents/skills/implement-spec/SKILL.md": "7a22dd2e60b8fcece478ced9d2533582adf3520d695acd3ab715341535c9e56d", - ".agents/skills/implement-spec/agents/openai.yaml": "fd41efb42c60c96320d6531be7e4bf15cbf5518cd652d57dff27ccffa46988e9", - ".agents/skills/implement/SKILL.md": "483bdc4ccf4ac31d86ac7beeaa0d0db80313e71e120bb8c59b3196ae5decd53d", - ".agents/skills/implement/agents/openai.yaml": "8970a8596ade0c28ab427f41a4ea242d6bdf6186c59ebf55e1238dbecaab79dc", - ".agents/skills/improve-codebase-architecture/HTML-REPORT.md": "581e8bb5a521e46bbda8ca7e19b15948bed882187108092ebb90c62513b77528", - ".agents/skills/improve-codebase-architecture/SKILL.md": "552240a5ab5cec6b67c15dd1ad6e9f6962b1bca6ce870059a8c2713760c20392", - ".agents/skills/improve-codebase-architecture/agents/openai.yaml": "c8cb20f68ebf0edb4e497bc11ae5fcaa196004e661cd189015b04f4109ced7f1", - ".agents/skills/pr/CREDITS.md": "a7a087127665d7298a72280ef6e19c41ad8404523712a788c8dc3ab414bc2497", - ".agents/skills/pr/SKILL.md": "ab63f1cf78647389edcd386c9427c5dfca27ed2836930c24773ffee834c19bcd", - ".agents/skills/pr/agents/openai.yaml": "2295b586624db4ef546f3fd4695492f039d941a46d8a1593a702ce3ca511b8ad", - ".agents/skills/prototype/LOGIC.md": "f61c7d249e786a79ef289018901c348271e1798dd0b0bc5607b5c6f4d4a01ab9", - ".agents/skills/prototype/SKILL.md": "714de632d116bb73f65cdb5a882db15b9369a6713b9a47c0fad827848f0bfbe3", - ".agents/skills/prototype/UI.md": "723211e878acbc7b6ff09755263f3295cde724ba902ff0064da41eed51d45ad3", - ".agents/skills/prototype/agents/openai.yaml": "5af65e43ab41a350436697b81e27b7f848d36782043b73c322bb2c9fa9cc55dc", - ".agents/skills/research/SKILL.md": "985569f15739c713d6784887c3d186d4ef9ac85bec5ad9c068d25bf0739928e4", - ".agents/skills/research/agents/openai.yaml": "9b4c470d63221c1f68f22df70b83e2f12401b317babe0d1b7b5f24a974474d0d", - ".agents/skills/resolving-merge-conflicts/SKILL.md": "9d8114f8ef0b31f535a265fc05c364bd8cf2e2895a830040e06c22acb11f54b0", - ".agents/skills/resolving-merge-conflicts/agents/openai.yaml": "a1f4f96838f2ed6282eb28abbbf99029cb8fadce552baf53da90a025b8bffddf", - ".agents/skills/retro/SKILL.md": "33fbc28568e3146f20fd908b12998bc0ab30aa4f4c87288199a40793efa94696", - ".agents/skills/retro/agents/openai.yaml": "cb6fb9d7e6b4be5975f41f6def1dd00a1255bad5d9b02696b124c924a8b8745c", - ".agents/skills/setup-matt-pocock-skills/SKILL.md": "f148858ffb7f63c03c06fca06e6fdb1d91a35c6b293244b8fdfacd5f4cc028c4", - ".agents/skills/setup-matt-pocock-skills/agents/openai.yaml": "9527de0110541c45712319025155aeab8dc7d77c6ed6e5e83271bab1851ab939", - ".agents/skills/setup-matt-pocock-skills/domain.md": "593a7042218689f1d24df89df14eaf0a20e19e0d0a7d479a01947ac78f0784b9", - ".agents/skills/setup-matt-pocock-skills/issue-tracker-github.md": "f0653fe106dd8ee7b33be27bd7fe3104b40555896cb45f61997aed20467020c3", - ".agents/skills/setup-matt-pocock-skills/issue-tracker-gitlab.md": "591a9e7057382aaccdfbed3f7ea3f2ad07e0cd29f012d18b82ee4e485fd81e24", - ".agents/skills/setup-matt-pocock-skills/issue-tracker-local.md": "7dcda20a2eb4bdc89b95d1143423c0691309921cadae3132e6424f371030506e", - ".agents/skills/setup-matt-pocock-skills/triage-labels.md": "ae512af838c78b8750471c42cd2b20fa32319553e61d84c9a23d171b40ce14aa", - ".agents/skills/show-me/SKILL.md": "bea6da70a58096730b9aeb0bae293ddf4726103a98efc9ce13c481619942a810", - ".agents/skills/tdd/SKILL.md": "5505d25bbaa8fc14a79e4d20163936958992a2995b7f1dd1d6197edc9ed722e0", - ".agents/skills/tdd/agents/openai.yaml": "ea6f01cf1b8c06a4b0f5b649d74b1b8ce8685e72af1b38d70d877693e092af0b", - ".agents/skills/tdd/mocking.md": "3ceb807fdf4a47d6a93d4d9a891e5ba6d362a6247bd08adc451feebfc17361ef", - ".agents/skills/tdd/tests.md": "859f9e592c188fda4fc7277dd180e4ce9c7a2e13f6efe1f6f29eccc9d28c106a", - ".agents/skills/to-questionnaire/SKILL.md": "b5eb929842ee0e93d867c5e906d183d350f2f2d149eaeaa86967d94d8eda1d3b", - ".agents/skills/to-questionnaire/agents/openai.yaml": "9e8a06c38c8842eea8d4922cb9d1ead8e3ace647bab259b943c994a1b4742bc2", - ".agents/skills/to-spec/SKILL.md": "43ad9cf318e5e7d3d1fa360253a37021796dc87a0c2e595ad262661a10f85088", - ".agents/skills/to-spec/agents/openai.yaml": "1c5b4d1e3d8e52287ef19cc2742fdbbfae1914ac75d33af3e4c8174f08cc55bb", - ".agents/skills/to-tickets/SKILL.md": "56ec23c5b2782785d4e65bff9e315b6d3aeb4ef977c174a219e99489fada1995", - ".agents/skills/to-tickets/agents/openai.yaml": "21bc6215fffcd7614e9f772bb1760e87cc5fc7dcc707e7d282bc9414267a6090", - ".agents/skills/triage/AGENT-BRIEF.md": "7f5f87fed3033df1b06e083c480f3e847b1fbb0647746b8a4c6a50f9f26ef44f", - ".agents/skills/triage/OUT-OF-SCOPE.md": "1fbf9b432130c6380aa65813b55553cfda150309b27c595e183f8e335b8a190d", - ".agents/skills/triage/SKILL.md": "917b0baf74be583e74f110857ed45df2766617f75c00baf61fd136d7af515edf", - ".agents/skills/triage/agents/openai.yaml": "2e683717720cf456d165d0bb1a68bb600d0b6a8ccb61841c172e50d26f95351c", - ".agents/skills/wait-what/SKILL.md": "d961bb37defd975c539427edb8f450cdbccdcdd552e213c1ece46676a7970905", - ".agents/skills/wait-what/agents/openai.yaml": "b41faadf741d51e4ea184148b2d1854d93f947aaf754c7844f70a00f04194999", - ".agents/skills/wayfinder/SKILL.md": "9be7b478c389605a24517d27752f278933588da97a1b5921b165f455edf4c5b7", - ".agents/skills/wayfinder/agents/openai.yaml": "88bc81a11a6d52ac67aeaa76b8b619e387020d47c5133a4dd4927fd15c4ad073", - ".agents/skills/wizard/SKILL.md": "bdf31d48211ea559878f95a4f344aeabf8d85897488ba564382bab0b000daac1", - ".agents/skills/wizard/agents/openai.yaml": "98f44d682d58e262f160dc59a8befc365e0aa65820dd0261864af26aa8e59d83", - ".agents/skills/wizard/template.sh": "7c70da7da1b0cab497c469411aa92b5650d43ef24e6b88728eb3a19a63ee8be7", - ".agents/skills/writing-clearly-and-concisely/SKILL.md": "1448ea19147b339ce37ae70551b233bb329d6bdfa46f0bf627b43b6133372233", - ".agents/skills/writing-clearly-and-concisely/elements-of-style.md": "d0edf854b5d39e22da68793603830e5bbdddb9266d272007993bcb821ba2799d", - ".agents/skills/writing-for-agents/SKILL-MECHANICS.md": "c768e6307c7c10728c401c213f2c4ba71c542127eeb7ad2956aabd15a0fa0059", - ".agents/skills/writing-for-agents/SKILL.md": "551adca942227b44192edba88acd4e8db911f0121ce58ad16944ccf6a896a74a", - ".agents/skills/writing-for-agents/agents/openai.yaml": "eacb24b2a618cfb81dacb0416f4fdd75ddf3a8060f8ddb99aae1b1e301907e4b", "GLOSSARY.md": "67de06543ad2fd9fd2917419e19d1f071e7b6eee53d48fca05e9dddfc739c683", - "README.md": "b84fc747700efa4d7e53447f4b670e52fc22018de2777eaae0a663dd296e6528", + "README.md": "35d680dfe39a73da641b0d3fda4480883a486b6f43942138d2db9981971b82bf", "_artifacts/domain_map.yaml": "cc465ff4f0142ca19c5e9af32b31f618420db77a4601a8e827df709b8af7acfc", - "_artifacts/skill_spec.md": "a3c081e6786e310c912234b50f3e2d02650bcfb6974ceb1e79ba016df6478973", + "_artifacts/skill_spec.md": "3d590a87ae382859f39d028319e46e7dbccc16e69315e6a0b31cd4c31ec1cefd", "_artifacts/skill_tree.yaml": "da82bd81b41250e02e263147afd7a12ce5379f86cf8b6a9e09895d1227df84fc", "apps/docs/src/content/docs/concepts/client-lifecycle.md": "4a7d412f6e3c7dfbf0442b51ee9054c2b854026bcf968961cee65071f7cbb424", "apps/docs/src/content/docs/concepts/data-normalization.md": "f1505ec3eb35a33fac289706c2cc318a2ffb80555e3302ca102e67e21c9012da", @@ -394,7 +314,7 @@ "examples/vite-react/tests/owner-cache.test.ts": "8818e30fdd3071a2e5dc2d865161e0d9f40de1ff37813b0a396bb2e83dd08a1d", "examples/vite-react/tests/owner-switch.test.ts": "c997c9758269586591cbf55de4651c64091abc9061b56edb024dcbf9dbaa9c1d", "examples/vite-react/tests/user-writes.test.ts": "2492f48018e7d8e684eae1c8d302acc9961a1bfbd60bf3214c113dc1b6fbe54a", - "package.json": "3f04c1e2c88b69a86c704a0178d7a82a75d47fcec89e6225ca6be3272d8970ac", + "package.json": "dc41aa3f5b1633ffb2e6f6a0b7efb4e0119d74b9bc8c0003b1a8ee5fad854782", "pnpm-workspace.yaml": "58fd69fb2ebc237bf589893fd3150ec0dd25988aee57f465f5b373b038aee606", "scripts/decode-utf8.mts": "2e90c53e95c22b864a209a87c98084db6ec95e83f842007b21528d286d45c18c", "scripts/measure-construction.mts": "10522cb10ab51fd44d8990896a2a73e8bf4d4cd2646441e958b6180cb1afd147", @@ -476,13 +396,16 @@ "tests/types/tanstack-start-contract.ts": "51681e3d0d7504a614d490f18adb6f44db82d34b9d4d7cb9cf941aea33de284f", "tests/types/type-scale.ts": "a9b307c25c72f3c184eabc8e22ddaeea96feb331c0f8d748773d765d358dbfca" }, - "head": "60c89f64b99028bb46584eae853ce1e5ea1343d7", - "outcome": "no-change", - "reason": "Reviewed all three planning files. domain_map.yaml already covers application-owned transports within documented version and host boundaries; skill_spec.md already routes version/host limits to the established conditional reference; skill_tree.yaml already tracks this external HTTP reference through the docs source glob. The example-scope sentence changes no task, seam, ownership, source mapping, prerequisite or distribution decision, so all three records remain accurate.", + "head": "6893603eab70bd9d67e9848414eccc8a3421d870", + "outcome": "updated", + "reason": "Added actual coverage and batch history plus the native combined-check requirement to the existing spec. The domain map still owns ten tasks and their failure modes; the tree still registers one skill with the same sources, path, prerequisites, and package-only distribution. Removed hidden authoring paths in this snapshot reflect native 0.5.3 selection, not edits or completed reviews of unrelated skills.", "evidence": [ - "_artifacts/domain_map.yaml, _artifacts/skill_spec.md, and _artifacts/skill_tree.yaml were read and compared with the one-line diff at author base 60c89f64b99028bb46584eae853ce1e5ea1343d7.", - "Native maintainer sync made no changes; actual maintainer review reports this external HTTP doc as the sole owned changed source with no owned mapping or planning problems.", - "vp run format and vp run docs-build passed; git diff --check passed. No artificial planning history or guidance version bump added." + "Reviewed _artifacts/domain_map.yaml, _artifacts/skill_spec.md, and _artifacts/skill_tree.yaml against unchanged skills/effect-api-query.", + "Both planning YAML files parsed; existing task coverage, source mappings, local prerequisite list, and distribution.mode:none verified. Native sync required no metadata edits.", + "Installed 0.5.3 dist/utils-BIaJYiMg.mjs isDefaultSkillPath excludes hidden directories; no .agents ignore or tool patch was added.", + "Source 6893603: CI 38085762210 passed source quality, both example builds, documentation, and Chromium/Firefox/WebKit acceptance; saved final-mts-ci.json and final-mts-ci.log.", + "Current batch: scoped vp run format, native vp run skills-check, and installed YAML parsing passed; vp exec intent maintainer sync reported nothing to synchronize.", + "Native combined maintainer check passed with default base and actual PR base 5b91ddb1de13970365669c887b9d767ea417fbd4; GitHub summary reports one skill, zero stale metadata, zero authoring issues, and zero pending reviews. Scoped formatting passed after the history edit." ] }, "source:vite.config.ts": { @@ -582,15 +505,17 @@ ] }, "source:.github/workflows/ci.yml": { - "fingerprint": "2b8dfb9f43256657bf35b4dccc4f3bd9bc23a0793ee0ef507ebd2aa480453e54", + "fingerprint": "2cc9c350f72e4978f0a280c50fe092cebcb834c05c14fdc52d887da2be89872e", "snapshot": { - ".github/workflows/ci.yml": "a279a8bd27933dc12e2ae8b13010f4ef272a6495c2f9b0987ec22cc6284d9439" + ".github/workflows/ci.yml": "73d896c802c0f78b275c14144ddae557b7efbbe296599be041e54791cda9714f" }, - "head": "1e235b1b5f4e4f6d6d34ce397ad0b573e6c5392d", + "head": "6893603eab70bd9d67e9848414eccc8a3421d870", "outcome": "no-change", - "reason": "The extra package-contract install/archive gate is removed. Source quality retains explicit public compiler and skill checks, examples/docs builds and existing three-engine application/site tests. Library usage and host ownership recommendations remain covered by the updated existing skill.", + "reason": "The existing read-only workflow retains structural skill validation and now runs the installed native maintainer check with full checkout history, the actual PR base through a quoted environment variable, and a GitHub summary. This maintainer gate changes no library integration behavior or recipe.", "evidence": [ - "Base 1e235b1b5f4e4f6d6d34ce397ad0b573e6c5392d; vp run --no-cache quality passed: 38 files, 283 runtime tests, strict public and server-local fixtures on TS 7.0.2 and TS 5.9.3, native pack/attw/publint, Effect diagnostics and Fallow audits. Both example builds and docs-build passed (44 pages; docs parity and Astro diagnostics). Existing docs-e2e passed 14 Chromium/Firefox tests. Native skills-check validated the owned skill. Native source construction-baseline passed. Logs are under /tmp/effect-api-query-implementation/notes/131-*. No fresh independent consumer session ran for this reference/source-mapping update; retired installation/certification matrices were not repeated. The optional generic skill-creator validator rejected the existing Intent sources frontmatter field; native Intent validation passed and its required schema is preserved. Reviewed item: .github/workflows/ci.yml." + ".github/workflows/ci.yml parsed with the installed YAML parser; contents:read, persist-credentials:false, fetch-depth:0, structural gate, quoted PR-base env, and both native check branches verified.", + "Installed native maintainer-commands.md and workflow-security.md reviewed before this existing workflow edit.", + "Current batch: scoped vp run format, native vp run skills-check, and installed YAML parsing passed; vp exec intent maintainer sync reported nothing to synchronize." ] }, "source:tests/packed-consumer/construction-baseline.mts": { @@ -1028,6 +953,192 @@ "vp run format apps/docs/astro.config.mts .fallowrc.jsonc passed. vp run fallow passed entry/production dead-code checks, duplicates task, and health threshold. vp run docs-build passed Astro check with zero errors/warnings/hints and build of 44 pages with valid internal links; unchanged documentation example parity was a native cache hit. Logs: /tmp/effect-api-query-implementation/notes/astro-mts-*.log.", "Read _artifacts/domain_map.yaml, skill_spec.md, and skill_tree.yaml: existing library task/seam/ownership coverage and source globs remain accurate; no library behavior, guidance, dependency or installation change. Native sync changed nothing; review reports no owned skill/planning drift. No new independent consumer or browser execution is needed for this repository config rename." ] + }, + "source:docs/adr/0016-automate-releases-with-changesets.md": { + "fingerprint": "6ef98996d907f829fdbb01507863ffb03582e7a4ce75f2739bb5a29f2fac03bf", + "snapshot": { + "docs/adr/0016-automate-releases-with-changesets.md": "35b5cdc29855b561c502d4bc5de33bbbbdd757ff69be55f13808d24176d5c67e" + }, + "head": "6893603eab70bd9d67e9848414eccc8a3421d870", + "outcome": "no-change", + "reason": "The ADR distinguishes initial unpublished candidate preparation from post-publication Changesets and preserves main-only trusted publishing. This release decision changes neither public integrations nor the existing framework/host and runtime ownership guidance.", + "evidence": [ + "Reviewed the complete ADR diff from 5b91ddb to 6893603.", + "Release run 38085762195 validated and packed the exact 6893603 candidate; publishing and deployment were skipped as intended.", + "Current batch: scoped vp run format, native vp run skills-check, and installed YAML parsing passed; vp exec intent maintainer sync reported nothing to synchronize." + ] + }, + "source:docs/adr/0017-quarantine-new-dependency-releases-for-seven-days.md": { + "fingerprint": "4be5f1ac6db00f4e928006c3e6230947279e5df2ecc5203eb48d4c96a490607a", + "snapshot": { + "docs/adr/0017-quarantine-new-dependency-releases-for-seven-days.md": "683309834b8c9e15b801ff9a7e640a6facf47a26690765deaf7a5e0a540dcef6" + }, + "head": "6893603eab70bd9d67e9848414eccc8a3421d870", + "outcome": "no-change", + "reason": "The ADR makes missing publication times fail closed and applies the same strict age policy to isolated consumers and frozen installs. Existing consumer API guidance and qualified dependency versions remain accurate; no new integration task is introduced.", + "evidence": [ + "Reviewed the complete ADR diff from 5b91ddb to 6893603; pnpm-workspace.yaml policy remains unchanged.", + "Native eligible Intent 0.5.3 upgrade and isolated Skills CLI 1.7.0 install succeeded under minimumReleaseAge:10080, Strict:true, IgnoreMissingTime:false, trustLockfile:false without exceptions.", + "Current batch: scoped vp run format, native vp run skills-check, and installed YAML parsing passed; vp exec intent maintainer sync reported nothing to synchronize." + ] + }, + "source:e2e/tanstack-start.spec.ts": { + "fingerprint": "ed1590bcfc4446b7fddb2924b2813b63d04124961f04f3ffb96fa2b719498b24", + "snapshot": { + "e2e/tanstack-start.spec.ts": "b50a8fd958cce8e66c3081aa0e7d2a4e759e67580efc3d8da6d7c251b36c6fcc" + }, + "head": "6893603eab70bd9d67e9848414eccc8a3421d870", + "outcome": "no-change", + "reason": "The added reload assertion verifies that the shared directory mutation remains visible through server rendering. Existing hydration guidance already requires matching representations and independent server/browser owners; an acceptance assertion adds no new consumer contract.", + "evidence": [ + "Reviewed the complete test diff from 5b91ddb to 6893603 against skills/effect-api-query/references/hydration-and-ssr.md.", + "Source 6893603: CI 38085762210 passed source quality, both example builds, documentation, and Chromium/Firefox/WebKit acceptance; saved final-mts-ci.json and final-mts-ci.log." + ] + }, + "source:e2e/vite-react.spec.ts": { + "fingerprint": "cb02aba2d2d073025a375e34532f922c265466c21416ffa95a64a605afdb252e", + "snapshot": { + "e2e/vite-react.spec.ts": "e730e19cebf8ac11895bff4b466e9882f707666d1417ff6aaaef2df78aa30f64" + }, + "head": "6893603eab70bd9d67e9848414eccc8a3421d870", + "outcome": "no-change", + "reason": "The pagehide storage-failure case verifies one handled disposal rejection, resource cleanup, and no page error. Existing cache-owner guidance already tells lifecycle callbacks to handle rejected disposal while completing cleanup.", + "evidence": [ + "Reviewed the complete test diff from 5b91ddb to 6893603 against the disposal policy in skills/effect-api-query/references/cache-workflows.md.", + "Source 6893603: CI 38085762210 passed source quality, both example builds, documentation, and Chromium/Firefox/WebKit acceptance; saved final-mts-ci.json and final-mts-ci.log." + ] + }, + "source:examples/tanstack-start/src/routes/index.tsx": { + "fingerprint": "a56fc6c1a96f303b4dda65d866efc454add3b44c6e201b1a62a2e53b94a3e513", + "snapshot": { + "examples/tanstack-start/src/routes/index.tsx": "857ac39eca9264b8df23814108092540aea6409b94019b6ed14c88262b1ca937" + }, + "head": "6893603eab70bd9d67e9848414eccc8a3421d870", + "outcome": "no-change", + "reason": "Routing stream capture through the request context tracks the same snapshot in its owning request lifecycle. Existing SSR guidance already requires capture/preparation settlement and local finalizers before disposing request clients.", + "evidence": [ + "Reviewed the complete route diff from 5b91ddb to 6893603 and skills/effect-api-query/references/hydration-and-ssr.md.", + "Source 6893603: CI 38085762210 passed source quality, both example builds, documentation, and Chromium/Firefox/WebKit acceptance; saved final-mts-ci.json and final-mts-ci.log." + ] + }, + "source:examples/vite-react/src/components/sections/commands-section.tsx": { + "fingerprint": "a00dc770d73fd319e4da616e87460baaf5b0d415c7143437c3959b35fe2b20df", + "snapshot": { + "examples/vite-react/src/components/sections/commands-section.tsx": "113856c225c332dacf328f9d06b3ea98f3a56b288f5b1bdb9fa98c8d1e326cf3" + }, + "head": "6893603eab70bd9d67e9848414eccc8a3421d870", + "outcome": "no-change", + "reason": "Start/cancel mutations now use the application mutation tracker and guard reconciliation with the originating owner. Existing cache guidance already distinguishes local retirement from remote command cancellation and preserves originating actions after awaits.", + "evidence": [ + "Reviewed the complete component diff from 5b91ddb to 6893603 and skills/effect-api-query/references/cache-workflows.md.", + "Source 6893603: CI 38085762210 passed source quality, both example builds, documentation, and Chromium/Firefox/WebKit acceptance; saved final-mts-ci.json and final-mts-ci.log." + ] + }, + "source:examples/vite-react/src/components/sections/diagnostics-section.tsx": { + "fingerprint": "be2a2add266d90ffc3b2a87a16f23492d57024bb88f69d176767edbcda3179a5", + "snapshot": { + "examples/vite-react/src/components/sections/diagnostics-section.tsx": "257ed7b14a4a971a1ee3547cd5e45a801425f25071ae53eb2ffb9d521b9b68ad" + }, + "head": "6893603eab70bd9d67e9848414eccc8a3421d870", + "outcome": "no-change", + "reason": "Tracking the declared-failure mutation makes diagnostics obey the application-owned mutation drain. Existing cache-owner guidance already requires accepted mutations to settle before ready-client release; the error contract is unchanged.", + "evidence": [ + "Reviewed the complete component diff from 5b91ddb to 6893603 and the owner replacement sequence in skills/effect-api-query/references/cache-workflows.md.", + "Source 6893603: CI 38085762210 passed source quality, both example builds, documentation, and Chromium/Firefox/WebKit acceptance; saved final-mts-ci.json and final-mts-ci.log." + ] + }, + "source:examples/vite-react/src/components/sections/http-section.tsx": { + "fingerprint": "e0f0733aa85363147a8ad802fe4977a45bd920e82daa0d99884c7291a5953762", + "snapshot": { + "examples/vite-react/src/components/sections/http-section.tsx": "089876bb63fe88d62f0a5ec191e44e85bf93de1dbcd8b329f63b191b7911d0ca" + }, + "head": "6893603eab70bd9d67e9848414eccc8a3421d870", + "outcome": "no-change", + "reason": "HTTP creates/deletes now use the shared write coordinator, track the failure demonstration, and disable deletion of temporary IDs. Existing guidance already reserves individual optimistic entities, preserves neighboring writes, and checks the originating owner; request builders are unchanged.", + "evidence": [ + "Reviewed the complete component diff from 5b91ddb to 6893603 against skills/effect-api-query/references/cache-workflows.md and references/http.md.", + "Source 6893603: CI 38085762210 passed source quality, both example builds, documentation, and Chromium/Firefox/WebKit acceptance; saved final-mts-ci.json and final-mts-ci.log." + ] + }, + "source:examples/vite-react/src/components/sections/mutations-section.tsx": { + "fingerprint": "d8883900b79077c3da3a9567125caa509d1982a184fe0d5c1ccafb5b5514e4e1", + "snapshot": { + "examples/vite-react/src/components/sections/mutations-section.tsx": "c2bf278014950f0e4a8d66d0cf397811858fae00f8284cb1ed01168c766a7b6a" + }, + "head": "6893603eab70bd9d67e9848414eccc8a3421d870", + "outcome": "no-change", + "reason": "Seed/reset mutations enter the application tracker while existing application-owned invalidation remains in place. Existing guidance already requires every participating write and reconciliation to follow the owner lifecycle.", + "evidence": [ + "Reviewed the complete component diff from 5b91ddb to 6893603 and skills/effect-api-query/references/cache-workflows.md.", + "Source 6893603: CI 38085762210 passed source quality, both example builds, documentation, and Chromium/Firefox/WebKit acceptance; saved final-mts-ci.json and final-mts-ci.log." + ] + }, + "source:examples/vite-react/src/components/users/create-user-form.tsx": { + "fingerprint": "1768011de182927bc8d5a0341f65f7e323cfccf6f76152578bc25d877a54ded3", + "snapshot": { + "examples/vite-react/src/components/users/create-user-form.tsx": "1c89f7235da5c16d6a2f3c3dc0bf81d33234a8d65c1e2c687e54255b10017690" + }, + "head": "6893603eab70bd9d67e9848414eccc8a3421d870", + "outcome": "no-change", + "reason": "Creation delegates to the shared RPC write coordinator instead of invalidating from the form. Existing optimistic-write guidance already assigns entity reservation, commit, rollback, and final reconciliation to application-owned actions.", + "evidence": [ + "Reviewed the complete component diff from 5b91ddb to 6893603 against skills/effect-api-query/references/cache-workflows.md.", + "Source 6893603: CI 38085762210 passed source quality, both example builds, documentation, and Chromium/Firefox/WebKit acceptance; saved final-mts-ci.json and final-mts-ci.log." + ] + }, + "source:examples/vite-react/src/components/users/user-list.tsx": { + "fingerprint": "74b615716653bc7b6ce5ec65939f0e0749bf3a7c470c9635c42878bc7af3d789", + "snapshot": { + "examples/vite-react/src/components/users/user-list.tsx": "4bff61d9e094f94f720f1351d206bf3d2d31fb9a8c0d0f3cdec904db701c434a" + }, + "head": "6893603eab70bd9d67e9848414eccc8a3421d870", + "outcome": "no-change", + "reason": "Deletion delegates to the shared RPC write coordinator; pending temporary users display their state and cannot be deleted. Existing guidance already allocates temporary IDs per write and preserves neighboring optimistic entities, with no public API change.", + "evidence": [ + "Reviewed the complete component diff from 5b91ddb to 6893603 against skills/effect-api-query/references/cache-workflows.md.", + "Source 6893603: CI 38085762210 passed source quality, both example builds, documentation, and Chromium/Firefox/WebKit acceptance; saved final-mts-ci.json and final-mts-ci.log." + ] + }, + "source:examples/vite-react/src/hooks/use-slow-query-cancellation.ts": { + "fingerprint": "5d15e7915a554f041ccec537294f63a591016ef79a04d0a986cdf36fef89b710", + "snapshot": { + "examples/vite-react/src/hooks/use-slow-query-cancellation.ts": "6de14cf383862176674f3ac3cdc6651b54a3f63fe3fa866419d1274501a96057" + }, + "head": "6893603eab70bd9d67e9848414eccc8a3421d870", + "outcome": "no-change", + "reason": "The helper receives an originating-owner activity guard and checks it before and after asynchronous status reads. Existing guidance already guards late side effects after owner replacement and treats remote command cancellation separately from local query cancellation.", + "evidence": [ + "Reviewed the complete hook diff from 5b91ddb to 6893603 against skills/effect-api-query/references/cache-workflows.md and references/frameworks-and-hosts.md.", + "Source 6893603: CI 38085762210 passed source quality, both example builds, documentation, and Chromium/Firefox/WebKit acceptance; saved final-mts-ci.json and final-mts-ci.log." + ] + }, + "source:tests/fixtures/tsconfig.server-local.json": { + "fingerprint": "3531d0da011912efd6ff745f45fd9407badcf84d19257252d43c3d13679a32aa", + "snapshot": { + "tests/fixtures/tsconfig.server-local.json": "870aa1b0555c672cc89142854f6f4328dd718272abc78d1efa28a01d878e949f" + }, + "head": "6893603eab70bd9d67e9848414eccc8a3421d870", + "outcome": "no-change", + "reason": "The added compiler configuration scopes the existing server-local RPC contract fixture under ordinary repository type checks. It changes validation placement without changing an exported type or introducing a consumer recipe.", + "evidence": [ + "Reviewed the seven-line configuration and its server-local RPC/type-contract includes from 5b91ddb to 6893603.", + "Source-quality CI 38085762210 ran the native test prerequisite that checks this fixture with both configured TypeScript versions; saved final-mts-ci.log.", + "Current batch: scoped vp run format, native vp run skills-check, and installed YAML parsing passed; vp exec intent maintainer sync reported nothing to synchronize." + ] + }, + "source:tests/packed-consumer/tsconfig.type-scale.json": { + "fingerprint": "ed8ba74d56e70784820e5369ead1b0aea87da056caffeb5e61daeb4c68c55b4d", + "snapshot": { + "tests/packed-consumer/tsconfig.type-scale.json": null + }, + "head": "6893603eab70bd9d67e9848414eccc8a3421d870", + "outcome": "no-change", + "reason": "Removing the redundant packed-consumer compiler configuration leaves type-scale coverage in ordinary repository fixtures. Existing guidance already bounds measurements to their fixtures and makes no installed-consumer budget claim.", + "evidence": [ + "Reviewed the deleted configuration from 5b91ddb to 6893603 and the retained tests/types fixtures plus _artifacts/skill_spec.md measurement boundary.", + "Source 6893603: CI 38085762210 passed source quality, both example builds, documentation, and Chromium/Firefox/WebKit acceptance; saved final-mts-ci.json and final-mts-ci.log.", + "Current batch: scoped vp run format, native vp run skills-check, and installed YAML parsing passed; vp exec intent maintainer sync reported nothing to synchronize." + ] } } } diff --git a/AGENTS.md b/AGENTS.md index a17ec71..b157251 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,8 @@ Read the shared [planning record](_artifacts/skill_spec.md); maintain its domain and skill tree alongside the affected guidance. Run `vp exec intent maintainer sync`, review actual source changes with `vp exec intent maintainer review --json`, and record justified outcomes for the owned skill, planning, and source items after -task checks. Leave unrelated skills unresolved. Finish with `vp run skills-check`. +task checks. Leave unrelated skills unresolved. Run `vp exec intent maintainer check`, +report pending work, and finish with `vp run skills-check`. ### Issue tracker diff --git a/README.md b/README.md index 855c87d..d7203e4 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ for accumulated HTTP event queries. Install the library usage skill from this repository: ```sh -npx skills add ueberBrot/effect-api-query --skill effect-api-query +npx skills add ueberBrot/effect-api-query --full-depth --skill effect-api-query ``` The same skill ships in the npm package. With the library installed, run diff --git a/_artifacts/skill_spec.md b/_artifacts/skill_spec.md index f8dae9c..c056126 100644 --- a/_artifacts/skill_spec.md +++ b/_artifacts/skill_spec.md @@ -36,10 +36,29 @@ Measurements describe their fixtures; they do not establish universal budgets. ## Validation Native Intent maintenance detects source changes and records justified outcomes -for the owned skill and planning records. Leave unrelated skills unresolved and -run the uncached scoped `skills-check` task. +for the owned skill and planning records. Run `intent maintainer check`, report +pending work, and finish with the uncached scoped `skills-check` task. Leave +unrelated skills unresolved. Strict compiler fixtures check the public declarations and retained documentation examples. Ordinary runtime tests and application examples cover cache views, cancellation, hydration, and ownership. Native builds validate package declarations and exports. + +## Coverage and batch history + +- **0.1.0 candidate, source `6893603` (2026-10-10):** one skill and six references + cover the ten developer tasks in the domain map. Guidance includes originating + cache owners, overlapping writes, stream capture and preparation drain, paired + hydration codecs, and qualified framework and host limits. CI `38085762210` + and release validation `38085762195` passed; publication remains separate. +- **Maintainer alignment (2026-10-10), based on `6893603`:** pin native Intent + 0.5.3 under the seven-day dependency policy and add its revision-aware check to + CI. Its default discovery excludes hidden repository authoring skills. Preserve + the existing task coverage, source mappings, and package-only distribution. + Skills CLI 1.7.0 discovered and copied this same skill from the repository root: + all seven files matched, and all 13 relative links resolved within the copy. + Guidance is unchanged, so this batch checks delivery without repeating API + tasks. Scoped formatting, skill validation, and native maintainer checks with + the default and actual PR bases passed. Initial package publication remains + pending. diff --git a/package.json b/package.json index 91c9ea4..56e5051 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "@effect/vitest": "4.0.0", "@playwright/test": "1.63.0", "@shadcn/lint": "0.2.0", - "@tanstack/intent": "0.5.0", + "@tanstack/intent": "0.5.3", "@tanstack/query-core": "catalog:", "@tanstack/react-query": "catalog:", "@tanstack/react-router": "catalog:", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d3f210e..820675f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -271,8 +271,8 @@ importers: specifier: 0.2.0 version: 0.2.0 '@tanstack/intent': - specifier: 0.5.0 - version: 0.5.0 + specifier: 0.5.3 + version: 0.5.3 '@tanstack/query-core': specifier: 'catalog:' version: 5.104.0 @@ -2149,8 +2149,8 @@ packages: resolution: {integrity: sha512-utTS5L2OkeYUzXGohL1Z8sefu1GLNOJcxe8Hd6iIdc/Xo1K1nDB2JEp4iSFhvYh33xKC9V91TxrS8qfrpoKobQ==} engines: {node: '>=20.19'} - '@tanstack/intent@0.5.0': - resolution: {integrity: sha512-27zOOpQLfBTM5cQjqkASf6JH3jggMwr29UhK8dlAI1WP9rjzywJ87/I6meiH/khyPIFNRluh9Q8wlIVrpN4Jpg==} + '@tanstack/intent@0.5.3': + resolution: {integrity: sha512-2zeLGRJ+796daqv3DrwXsrXn7TZb4yOIogJgrqeF96YEIwOsJoDIW/eQBwZVAcR1OQ3znA3dGDizb5GBmxk4Zg==} engines: {node: '>=20.12.0'} hasBin: true @@ -6583,7 +6583,7 @@ snapshots: '@tanstack/history@1.162.4': {} - '@tanstack/intent@0.5.0': {} + '@tanstack/intent@0.5.3': {} '@tanstack/query-core@5.104.0': {} From 259b4e1617ad930793f070522f6c6fb6d63a627a Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Sat, 10 Oct 2026 23:45:03 +0200 Subject: [PATCH 86/97] docs: clarify native framework examples --- .intent/review-state.json | 40 +++++++++---------- .../content/docs/guides/svelte-and-angular.md | 3 ++ .../src/content/docs/guides/vue-and-solid.md | 8 ++++ 3 files changed, 31 insertions(+), 20 deletions(-) diff --git a/.intent/review-state.json b/.intent/review-state.json index b07cc9c..e693b83 100644 --- a/.intent/review-state.json +++ b/.intent/review-state.json @@ -3,7 +3,7 @@ "baseline": "5b91ddb1de13970365669c887b9d767ea417fbd4", "items": { "skill:skills/effect-api-query/SKILL.md": { - "fingerprint": "7c985709b807e79703000bd34c139f7a306d7fe16136783a0bc55090eac2f8f4", + "fingerprint": "9ef1b45598771f31217b5424f0b5fa3db37bdec994fff19f4fe7fd5011f61302", "snapshot": { "GLOSSARY.md": "67de06543ad2fd9fd2917419e19d1f071e7b6eee53d48fca05e9dddfc739c683", "README.md": "35d680dfe39a73da641b0d3fda4480883a486b6f43942138d2db9981971b82bf", @@ -35,10 +35,10 @@ "apps/docs/src/content/docs/guides/refresh-from-events.md": "a51ef3777eb9ea07f561d2223c6d8ffa063c87ecda25cb74ce0aea4b037fa2fe", "apps/docs/src/content/docs/guides/retry-queries.md": "f725f54696712cc917089e18e905022c6ab097e44f8d22f499ab87fb30af9a99", "apps/docs/src/content/docs/guides/stream-snapshots.md": "62e343dc7fa2f4b1b6fb1b699ecb947bcdfc0312c9ffab584b0b8bfaf954f854", - "apps/docs/src/content/docs/guides/svelte-and-angular.md": "aa8d0e6c20a46e95050314b3ef3b0e6b1d88fce8474a7b719f823d1c26ca502b", + "apps/docs/src/content/docs/guides/svelte-and-angular.md": "8a5741996407c81c54a75ab3fb086a4c6866913a2a14f71801876891fb49753f", "apps/docs/src/content/docs/guides/switch-cache-owners.md": "08e883a245679cf05d4cb65c93e5b9f1887022a972b98ab295aa248880ab9b7f", "apps/docs/src/content/docs/guides/tanstack-start.md": "28a196e44e1b6b49131b05e370cb029670599fefe6aad6ff54ed9d3d3f07ddac", - "apps/docs/src/content/docs/guides/vue-and-solid.md": "53bf77115f7d5e1bb5538041d0435643991294b945d4f37cf6ecdbfb8afca269", + "apps/docs/src/content/docs/guides/vue-and-solid.md": "2b0b31da53f2b5e86cd5fb3abe5353c28e02e73dc485133d6f593c9f5d30cf06", "apps/docs/src/content/docs/guides/websocket-clients.md": "9ec635105fd84c30dc52833ed3adcf80a46313deb0112d4e72cd1a5603c6170c", "apps/docs/src/content/docs/index.mdx": "5c22ad2256052013855d92e21d4a277f4c3e92931ea71fd0c1b3e7f5aceb9584", "apps/docs/src/content/docs/reference/browser-and-worker-hosts.md": "70743a4429a39937515c8d5fee79ca75714a92dd526f1093f9fa8ea12fbde984", @@ -193,17 +193,19 @@ "tests/types/tanstack-start-contract.ts": "51681e3d0d7504a614d490f18adb6f44db82d34b9d4d7cb9cf941aea33de284f", "tests/types/type-scale.ts": "a9b307c25c72f3c184eabc8e22ddaeea96feb331c0f8d748773d765d358dbfca" }, - "head": "6893603eab70bd9d67e9848414eccc8a3421d870", + "head": "f56fbfaa6c79e869f15e98d457b5ee6eb07d7b86", "outcome": "no-change", - "reason": "The only mapped changes since the last review are README delivery syntax and the Intent devDependency pin. The seven-file integration skill still describes the same public API and application ownership; no consumer recipe changed.", + "reason": "The two human guides now state their historical example-version scope and the existing native Vue/Solid mutation-hook mapping. The shipped frameworks reference already specifies matching native mutation hooks, per-execution variables, reactive option rebuilding, wrapper/Core matching, and the same native inference/lifetime limits. No public behavior or developer task changed, so all seven skill files remain accurate.", "evidence": [ - "README.md adds the native-supported --full-depth flag; package.json pins eligible @tanstack/intent 0.5.3.", - "Skills CLI 1.7.0 genuinely discovered and copied this repository-root skill: seven byte-identical files and 13 internal relative links; notes/skills-cli-smoke.md records the local-copy scope and limits.", - "Current batch: scoped vp run format, native vp run skills-check, and installed YAML parsing passed; vp exec intent maintainer sync reported nothing to synchronize." + "Read skills/effect-api-query/SKILL.md and references/frameworks-and-hosts.md; the existing mutation/variable/reactivity and framework-boundary statements still match the guide corrections.", + "Historical 5ff2638289a1ba47d4b7d867d9c50deb5b4d5c4a tests/packed-consumer/vue-solid-contract.ts:185-205 and 352-359 use Vue useMutation(options), Solid useMutation(() => options), and mutate(variables); completed 118-final-matrix.log records the actual Vue3.5.43/Solid1.9.15 plus wrappers5.104.0 checks.", + "119.md and primary 119-framework-metadata.json establish the Svelte5.57.1/Query6.3.0 and Angular22.2.1/experimentalQuery5.104.0 recipe scope. No current certification or new framework matrix is claimed.", + "Fresh vp run docs-build passed exact snippet parity, Astro0errors/warnings/hints,44pages/search/internal links with0/3cache hits; scoped vp run format and git diff --check passed.", + "No fresh framework consumer task was run for these prose additions; unchanged historical compiler/host evidence supplies the API comparison. Retired installed matrices remain retired." ] }, "planning:_artifacts": { - "fingerprint": "be5b9673275b1e93a738b52799433b9f9c6785a34ada4b9250c84a9fa5d4bbea", + "fingerprint": "ce230bb1a3ed5e994d0aac0740ecaee72fe7601fc1feafa3b78a2718dad155d0", "snapshot": { "GLOSSARY.md": "67de06543ad2fd9fd2917419e19d1f071e7b6eee53d48fca05e9dddfc739c683", "README.md": "35d680dfe39a73da641b0d3fda4480883a486b6f43942138d2db9981971b82bf", @@ -238,10 +240,10 @@ "apps/docs/src/content/docs/guides/refresh-from-events.md": "a51ef3777eb9ea07f561d2223c6d8ffa063c87ecda25cb74ce0aea4b037fa2fe", "apps/docs/src/content/docs/guides/retry-queries.md": "f725f54696712cc917089e18e905022c6ab097e44f8d22f499ab87fb30af9a99", "apps/docs/src/content/docs/guides/stream-snapshots.md": "62e343dc7fa2f4b1b6fb1b699ecb947bcdfc0312c9ffab584b0b8bfaf954f854", - "apps/docs/src/content/docs/guides/svelte-and-angular.md": "aa8d0e6c20a46e95050314b3ef3b0e6b1d88fce8474a7b719f823d1c26ca502b", + "apps/docs/src/content/docs/guides/svelte-and-angular.md": "8a5741996407c81c54a75ab3fb086a4c6866913a2a14f71801876891fb49753f", "apps/docs/src/content/docs/guides/switch-cache-owners.md": "08e883a245679cf05d4cb65c93e5b9f1887022a972b98ab295aa248880ab9b7f", "apps/docs/src/content/docs/guides/tanstack-start.md": "28a196e44e1b6b49131b05e370cb029670599fefe6aad6ff54ed9d3d3f07ddac", - "apps/docs/src/content/docs/guides/vue-and-solid.md": "53bf77115f7d5e1bb5538041d0435643991294b945d4f37cf6ecdbfb8afca269", + "apps/docs/src/content/docs/guides/vue-and-solid.md": "2b0b31da53f2b5e86cd5fb3abe5353c28e02e73dc485133d6f593c9f5d30cf06", "apps/docs/src/content/docs/guides/websocket-clients.md": "9ec635105fd84c30dc52833ed3adcf80a46313deb0112d4e72cd1a5603c6170c", "apps/docs/src/content/docs/index.mdx": "5c22ad2256052013855d92e21d4a277f4c3e92931ea71fd0c1b3e7f5aceb9584", "apps/docs/src/content/docs/reference/browser-and-worker-hosts.md": "70743a4429a39937515c8d5fee79ca75714a92dd526f1093f9fa8ea12fbde984", @@ -396,16 +398,14 @@ "tests/types/tanstack-start-contract.ts": "51681e3d0d7504a614d490f18adb6f44db82d34b9d4d7cb9cf941aea33de284f", "tests/types/type-scale.ts": "a9b307c25c72f3c184eabc8e22ddaeea96feb331c0f8d748773d765d358dbfca" }, - "head": "6893603eab70bd9d67e9848414eccc8a3421d870", - "outcome": "updated", - "reason": "Added actual coverage and batch history plus the native combined-check requirement to the existing spec. The domain map still owns ten tasks and their failure modes; the tree still registers one skill with the same sources, path, prerequisites, and package-only distribution. Removed hidden authoring paths in this snapshot reflect native 0.5.3 selection, not edits or completed reviews of unrelated skills.", + "head": "f56fbfaa6c79e869f15e98d457b5ee6eb07d7b86", + "outcome": "no-change", + "reason": "All three shared planning documents remain accurate: the domain map retains the same ten tasks and reactive/ownership failure modes; the spec retains one skill, six references, assessed coverage and existing history; the tree retains the same identity, source globs covering both changed guides, prerequisites and package-only distribution. This guide clarification adds no task, seam, source mapping, dependency, or behavior, so no planning rewrite or artificial history entry is needed.", "evidence": [ - "Reviewed _artifacts/domain_map.yaml, _artifacts/skill_spec.md, and _artifacts/skill_tree.yaml against unchanged skills/effect-api-query.", - "Both planning YAML files parsed; existing task coverage, source mappings, local prerequisite list, and distribution.mode:none verified. Native sync required no metadata edits.", - "Installed 0.5.3 dist/utils-BIaJYiMg.mjs isDefaultSkillPath excludes hidden directories; no .agents ignore or tool patch was added.", - "Source 6893603: CI 38085762210 passed source quality, both example builds, documentation, and Chromium/Firefox/WebKit acceptance; saved final-mts-ci.json and final-mts-ci.log.", - "Current batch: scoped vp run format, native vp run skills-check, and installed YAML parsing passed; vp exec intent maintainer sync reported nothing to synchronize.", - "Native combined maintainer check passed with default base and actual PR base 5b91ddb1de13970365669c887b9d767ea417fbd4; GitHub summary reports one skill, zero stale metadata, zero authoring issues, and zero pending reviews. Scoped formatting passed after the history edit." + "Read _artifacts/domain_map.yaml, _artifacts/skill_spec.md and _artifacts/skill_tree.yaml against the two guide additions and unchanged seven-file skill.", + "Installed YAML parsing passed for map/tree, verified ten assessed tasks, one implemented skill with existing path/no prerequisites, and distribution.mode:none; native maintainer sync reported nothing to synchronize.", + "Fresh vp run docs-build and scoped format passed; documentation version scopes are historical recipe targets and do not widen the planning support claims.", + "Current native review contains only this planning item and the owned skill item; mapped documentation inputs are not separate unmapped source items. All other stored outcomes are preserved." ] }, "source:vite.config.ts": { diff --git a/apps/docs/src/content/docs/guides/svelte-and-angular.md b/apps/docs/src/content/docs/guides/svelte-and-angular.md index 27b5d73..d7f5e12 100644 --- a/apps/docs/src/content/docs/guides/svelte-and-angular.md +++ b/apps/docs/src/content/docs/guides/svelte-and-angular.md @@ -7,6 +7,9 @@ Pass generated options through the framework's reactive accessor. Each builder c plain input when called, so create the options inside that accessor whenever the input can change. The same rule applies to RPC and HTTP queries, metadata views, accumulated streams, and live queries. +The examples target Svelte 5.57.1 with Svelte Query 6.3.0 and Angular 22.2.1 with Angular Query +experimental 5.104.0, both using Query Core 5.104.0. + Keep captured values unchanged while their options can execute. See [Data Normalization](/effect-api-query/concepts/data-normalization/#keep-captured-inputs-unchanged) for the distinction between immutable keys and caller-owned input. diff --git a/apps/docs/src/content/docs/guides/vue-and-solid.md b/apps/docs/src/content/docs/guides/vue-and-solid.md index 1dd0d8e..b2b7669 100644 --- a/apps/docs/src/content/docs/guides/vue-and-solid.md +++ b/apps/docs/src/content/docs/guides/vue-and-solid.md @@ -7,6 +7,9 @@ Pass generated options from either factory to the framework's native Query hooks `VueQueryPlugin` in Vue or `QueryClientProvider` in Solid, and acquire the ready client before creating the utilities. Keep its `Scope` and any serviceful runner alive while queries use them. +The examples target Vue 3.5.43 with Vue Query 5.104.0 and Solid 1.9.15 with Solid Query 5.104.0. +Both use Query Core 5.104.0. + Match the native Query wrapper to the application's Query Core and use a compiler supported by the framework's tooling. See the [compatibility reference](/effect-api-query/reference/compatibility-and-limits/) for adapter capabilities. @@ -75,6 +78,11 @@ const user = useQuery(() => { Read the selected value from `user.data`. Apply the same computed/accessor pattern to `infiniteOptions`, `streamedOptions`, `liveOptions`, and `metadataOptions` when their input changes. +Pass `rpc.users.write.mutationOptions()` to Vue's `useMutation`, or use +`useMutation(() => rpc.users.write.mutationOptions())` in Solid. Supply each execution's input +through `mutate(variables)`; use Vue `computed` or a Solid accessor when mutation settings depend +on reactive state. + ## Choose native options carefully Vue evaluates a function-valued `enabled` as a getter with no arguments. Use a boolean inside From 75a6b099bdaf8a01dd343ca88aec3afbd7a54338 Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Sun, 11 Oct 2026 00:02:55 +0200 Subject: [PATCH 87/97] docs: give each framework its own Query guide --- .intent/review-state.json | 79 ++++++------ README.md | 2 +- _artifacts/domain_map.yaml | 2 +- apps/docs/astro.config.mts | 6 +- .../src/content/docs/guides/angular-query.md | 88 ++++++++++++++ .../src/content/docs/guides/solid-query.md | 68 +++++++++++ .../content/docs/guides/svelte-and-angular.md | 114 ------------------ .../src/content/docs/guides/svelte-query.md | 73 +++++++++++ .../src/content/docs/guides/vue-and-solid.md | 108 ----------------- .../docs/src/content/docs/guides/vue-query.md | 83 +++++++++++++ apps/docs/src/content/docs/index.mdx | 5 +- .../reference/compatibility-and-limits.md | 11 +- .../references/frameworks-and-hosts.md | 5 +- 13 files changed, 374 insertions(+), 270 deletions(-) create mode 100644 apps/docs/src/content/docs/guides/angular-query.md create mode 100644 apps/docs/src/content/docs/guides/solid-query.md delete mode 100644 apps/docs/src/content/docs/guides/svelte-and-angular.md create mode 100644 apps/docs/src/content/docs/guides/svelte-query.md delete mode 100644 apps/docs/src/content/docs/guides/vue-and-solid.md create mode 100644 apps/docs/src/content/docs/guides/vue-query.md diff --git a/.intent/review-state.json b/.intent/review-state.json index e693b83..04a0c40 100644 --- a/.intent/review-state.json +++ b/.intent/review-state.json @@ -3,10 +3,10 @@ "baseline": "5b91ddb1de13970365669c887b9d767ea417fbd4", "items": { "skill:skills/effect-api-query/SKILL.md": { - "fingerprint": "9ef1b45598771f31217b5424f0b5fa3db37bdec994fff19f4fe7fd5011f61302", + "fingerprint": "b37da5dd07f1af5dc54bbb3eb88c0ec100fb5554c162275e5f48d9313094e4db", "snapshot": { "GLOSSARY.md": "67de06543ad2fd9fd2917419e19d1f071e7b6eee53d48fca05e9dddfc739c683", - "README.md": "35d680dfe39a73da641b0d3fda4480883a486b6f43942138d2db9981971b82bf", + "README.md": "324f4e55b7b41232cc25b9b8780f49b136b25a0ee7e6650dea6ca371ff46669f", "apps/docs/src/content/docs/concepts/client-lifecycle.md": "4a7d412f6e3c7dfbf0442b51ee9054c2b854026bcf968961cee65071f7cbb424", "apps/docs/src/content/docs/concepts/data-normalization.md": "f1505ec3eb35a33fac289706c2cc318a2ffb80555e3302ca102e67e21c9012da", "apps/docs/src/content/docs/concepts/http-utility-tree.md": "ca4acbe1af20538c0d08a3e03969efe1966e7822ce6dfce8d0c1efb6913795cf", @@ -19,6 +19,7 @@ "apps/docs/src/content/docs/getting-started/http-quick-start.md": "eaf62e213ba22d1926dd75d7bad7bb9e6e69419309f0fcb1e6a7c0efe4d85c8b", "apps/docs/src/content/docs/getting-started/installation.md": "a334554486477e2c6ecd63c325b90fa9738c57877a3a23ec22a64c406c3f21ab", "apps/docs/src/content/docs/getting-started/quick-start.md": "52689fd2313064eb8660ec4152cba36fb46dbaba7f1039d43ed02b12a5f9ae2e", + "apps/docs/src/content/docs/guides/angular-query.md": "14534aec24fab7549906456a35b8a12a47e85c924e04aaefda15c366c4a435f9", "apps/docs/src/content/docs/guides/cache-filters.md": "46d06ee7e24f2ec143d0bb174442a7f5bc5299657d4bcdfb28a36782a69b3de6", "apps/docs/src/content/docs/guides/cache-management.md": "18f9ef5b5c51365a629601d19d2d2307c243c3fa4e2dd4f53bed3974eae802e5", "apps/docs/src/content/docs/guides/cancellation.md": "b0a79fb1578777115619d65f075f407595de0634ef0c52c4fc77256f0dca833c", @@ -34,15 +35,18 @@ "apps/docs/src/content/docs/guides/react-query.md": "29a200b31d19e0339805a0ae3e50be403300e1b8bb7b870949cf779853fbb347", "apps/docs/src/content/docs/guides/refresh-from-events.md": "a51ef3777eb9ea07f561d2223c6d8ffa063c87ecda25cb74ce0aea4b037fa2fe", "apps/docs/src/content/docs/guides/retry-queries.md": "f725f54696712cc917089e18e905022c6ab097e44f8d22f499ab87fb30af9a99", + "apps/docs/src/content/docs/guides/solid-query.md": "e01a800c1a4b8dbfe2d5dfaef5a53e2a12e92e1db42fdd926422bb6fcd4eb780", "apps/docs/src/content/docs/guides/stream-snapshots.md": "62e343dc7fa2f4b1b6fb1b699ecb947bcdfc0312c9ffab584b0b8bfaf954f854", - "apps/docs/src/content/docs/guides/svelte-and-angular.md": "8a5741996407c81c54a75ab3fb086a4c6866913a2a14f71801876891fb49753f", + "apps/docs/src/content/docs/guides/svelte-and-angular.md": null, + "apps/docs/src/content/docs/guides/svelte-query.md": "ef3dd4cb2b88759223e77182446b41a53ccf6f4f471e8748f53c93aeafa880e2", "apps/docs/src/content/docs/guides/switch-cache-owners.md": "08e883a245679cf05d4cb65c93e5b9f1887022a972b98ab295aa248880ab9b7f", "apps/docs/src/content/docs/guides/tanstack-start.md": "28a196e44e1b6b49131b05e370cb029670599fefe6aad6ff54ed9d3d3f07ddac", - "apps/docs/src/content/docs/guides/vue-and-solid.md": "2b0b31da53f2b5e86cd5fb3abe5353c28e02e73dc485133d6f593c9f5d30cf06", + "apps/docs/src/content/docs/guides/vue-and-solid.md": null, + "apps/docs/src/content/docs/guides/vue-query.md": "b115c710cdd520cbf016fd19c48af2c849fe7e20344a2e900b78f0a0e5c5bd12", "apps/docs/src/content/docs/guides/websocket-clients.md": "9ec635105fd84c30dc52833ed3adcf80a46313deb0112d4e72cd1a5603c6170c", - "apps/docs/src/content/docs/index.mdx": "5c22ad2256052013855d92e21d4a277f4c3e92931ea71fd0c1b3e7f5aceb9584", + "apps/docs/src/content/docs/index.mdx": "232b5a9f64169463d7973c6801b3f3c326328870715fcb45e9b0a25ee085e197", "apps/docs/src/content/docs/reference/browser-and-worker-hosts.md": "70743a4429a39937515c8d5fee79ca75714a92dd526f1093f9fa8ea12fbde984", - "apps/docs/src/content/docs/reference/compatibility-and-limits.md": "99909357a5c32eb8ed9443fc0d92c43b228591d5c2203f1c822c9bcc4a7b280d", + "apps/docs/src/content/docs/reference/compatibility-and-limits.md": "1499a387d91e364fcfe44bd82f9e1589f9fab2a93fc081f9172a6e8da703cb25", "apps/docs/src/content/docs/reference/errors.md": "4454725375e05ea7a7d976d798c1c1d0c2731eefc79043f5e40dba88154069b1", "apps/docs/src/content/docs/reference/external-http-clients.md": "613f0e9493221416993e0c8012a97abdb3de2f80321aa7e4b0cda0f585618f91", "apps/docs/src/content/docs/reference/factory.md": "b099ed80d1ad8aab6a59213ee286290302bcf7885569ffb693fed784ca872dd3", @@ -119,7 +123,7 @@ "scripts/verify-packed-consumer.mts": null, "skills/effect-api-query/SKILL.md": "1939a0f7536f3e88e1e1106e8125019c11d6fdd4ada8ae4d010507f264b234e5", "skills/effect-api-query/references/cache-workflows.md": "27bb96c48c84254ab8c379a529402f81cea3723c17d6396527c090a8c37e6f65", - "skills/effect-api-query/references/frameworks-and-hosts.md": "d84c787ccfc76b1f01f3d44d02d969ed9422af7d9dbdda862e661d65af9e44df", + "skills/effect-api-query/references/frameworks-and-hosts.md": "8b39b303cf3a9ae3222a257f53df5f9696b80c54859765f2c4d7b39554b8719f", "skills/effect-api-query/references/http.md": "9625411753fcc3ce816512f80158a19aa1057518ab3bfde98720e0cc7f845b66", "skills/effect-api-query/references/hydration-and-ssr.md": "61e761b36ebcf56c7cc1b67b3d795edba52d0c0224c3e7964297ed6864e1b044", "skills/effect-api-query/references/query-patterns.md": "33ea307b7042660da4ee4f6acea147d4218688a0256d82f3d582ff2e9cef4f9c", @@ -193,23 +197,21 @@ "tests/types/tanstack-start-contract.ts": "51681e3d0d7504a614d490f18adb6f44db82d34b9d4d7cb9cf941aea33de284f", "tests/types/type-scale.ts": "a9b307c25c72f3c184eabc8e22ddaeea96feb331c0f8d748773d765d358dbfca" }, - "head": "f56fbfaa6c79e869f15e98d457b5ee6eb07d7b86", - "outcome": "no-change", - "reason": "The two human guides now state their historical example-version scope and the existing native Vue/Solid mutation-hook mapping. The shipped frameworks reference already specifies matching native mutation hooks, per-execution variables, reactive option rebuilding, wrapper/Core matching, and the same native inference/lifetime limits. No public behavior or developer task changed, so all seven skill files remain accurate.", + "head": "a0a363afa19552badd7c40795ac7a4735595dddb", + "outcome": "updated", + "reason": "The human framework documentation is now four native Query guides rather than two paired pages. Update only the existing frameworks-and-hosts source footer to the four live routes. The single shipped skill, its six reference boundaries, activation, recommended integrations and public behavior remain unchanged.", "evidence": [ - "Read skills/effect-api-query/SKILL.md and references/frameworks-and-hosts.md; the existing mutation/variable/reactivity and framework-boundary statements still match the guide corrections.", - "Historical 5ff2638289a1ba47d4b7d867d9c50deb5b4d5c4a tests/packed-consumer/vue-solid-contract.ts:185-205 and 352-359 use Vue useMutation(options), Solid useMutation(() => options), and mutate(variables); completed 118-final-matrix.log records the actual Vue3.5.43/Solid1.9.15 plus wrappers5.104.0 checks.", - "119.md and primary 119-framework-metadata.json establish the Svelte5.57.1/Query6.3.0 and Angular22.2.1/experimentalQuery5.104.0 recipe scope. No current certification or new framework matrix is claimed.", - "Fresh vp run docs-build passed exact snippet parity, Astro0errors/warnings/hints,44pages/search/internal links with0/3cache hits; scoped vp run format and git diff --check passed.", - "No fresh framework consumer task was run for these prose additions; unchanged historical compiler/host evidence supplies the API comparison. Retired installed matrices remain retired." + "Author base a0a363afa19552badd7c40795ac7a4735595dddb. Read skills/effect-api-query/SKILL.md and references/frameworks-and-hosts.md; footer now links Vue, Solid, Svelte and Angular Query guides. README, landing page, compatibility links and capability rows use the same four routes.", + "All four moved recipe fences match the prior paired guides byte for byte. Scoped format passed on 10 affected files. Uncached vp run docs-build passed retained 8-page snippet parity, Astro diagnostics (0 errors/warnings/hints), 46-page production build, search index and internal link validation; four built routes and full/small LLM links exist with old routes absent.", + "vp run skills-check passed native validation of 1 skill file. No recommended behavior or activation changed; no fresh framework compilation, consumer API task or independent consumer session is claimed for this editorial split. Evidence: /tmp/effect-api-query-implementation/notes/framework-guide-split-content-check.json, framework-guide-split-artifact-check.json and framework-guide-split-docs-build.log." ] }, "planning:_artifacts": { - "fingerprint": "ce230bb1a3ed5e994d0aac0740ecaee72fe7601fc1feafa3b78a2718dad155d0", + "fingerprint": "37ccd5a30b43c8dde4bab5e3cbf13616b38ab37280a50975dafc0b7ddec9976c", "snapshot": { "GLOSSARY.md": "67de06543ad2fd9fd2917419e19d1f071e7b6eee53d48fca05e9dddfc739c683", - "README.md": "35d680dfe39a73da641b0d3fda4480883a486b6f43942138d2db9981971b82bf", - "_artifacts/domain_map.yaml": "cc465ff4f0142ca19c5e9af32b31f618420db77a4601a8e827df709b8af7acfc", + "README.md": "324f4e55b7b41232cc25b9b8780f49b136b25a0ee7e6650dea6ca371ff46669f", + "_artifacts/domain_map.yaml": "47f3dda6368ce94f3dd14f6e49b7c1e26f54091e582a339aebb656bd3361664c", "_artifacts/skill_spec.md": "3d590a87ae382859f39d028319e46e7dbccc16e69315e6a0b31cd4c31ec1cefd", "_artifacts/skill_tree.yaml": "da82bd81b41250e02e263147afd7a12ce5379f86cf8b6a9e09895d1227df84fc", "apps/docs/src/content/docs/concepts/client-lifecycle.md": "4a7d412f6e3c7dfbf0442b51ee9054c2b854026bcf968961cee65071f7cbb424", @@ -224,6 +226,7 @@ "apps/docs/src/content/docs/getting-started/http-quick-start.md": "eaf62e213ba22d1926dd75d7bad7bb9e6e69419309f0fcb1e6a7c0efe4d85c8b", "apps/docs/src/content/docs/getting-started/installation.md": "a334554486477e2c6ecd63c325b90fa9738c57877a3a23ec22a64c406c3f21ab", "apps/docs/src/content/docs/getting-started/quick-start.md": "52689fd2313064eb8660ec4152cba36fb46dbaba7f1039d43ed02b12a5f9ae2e", + "apps/docs/src/content/docs/guides/angular-query.md": "14534aec24fab7549906456a35b8a12a47e85c924e04aaefda15c366c4a435f9", "apps/docs/src/content/docs/guides/cache-filters.md": "46d06ee7e24f2ec143d0bb174442a7f5bc5299657d4bcdfb28a36782a69b3de6", "apps/docs/src/content/docs/guides/cache-management.md": "18f9ef5b5c51365a629601d19d2d2307c243c3fa4e2dd4f53bed3974eae802e5", "apps/docs/src/content/docs/guides/cancellation.md": "b0a79fb1578777115619d65f075f407595de0634ef0c52c4fc77256f0dca833c", @@ -239,15 +242,18 @@ "apps/docs/src/content/docs/guides/react-query.md": "29a200b31d19e0339805a0ae3e50be403300e1b8bb7b870949cf779853fbb347", "apps/docs/src/content/docs/guides/refresh-from-events.md": "a51ef3777eb9ea07f561d2223c6d8ffa063c87ecda25cb74ce0aea4b037fa2fe", "apps/docs/src/content/docs/guides/retry-queries.md": "f725f54696712cc917089e18e905022c6ab097e44f8d22f499ab87fb30af9a99", + "apps/docs/src/content/docs/guides/solid-query.md": "e01a800c1a4b8dbfe2d5dfaef5a53e2a12e92e1db42fdd926422bb6fcd4eb780", "apps/docs/src/content/docs/guides/stream-snapshots.md": "62e343dc7fa2f4b1b6fb1b699ecb947bcdfc0312c9ffab584b0b8bfaf954f854", - "apps/docs/src/content/docs/guides/svelte-and-angular.md": "8a5741996407c81c54a75ab3fb086a4c6866913a2a14f71801876891fb49753f", + "apps/docs/src/content/docs/guides/svelte-and-angular.md": null, + "apps/docs/src/content/docs/guides/svelte-query.md": "ef3dd4cb2b88759223e77182446b41a53ccf6f4f471e8748f53c93aeafa880e2", "apps/docs/src/content/docs/guides/switch-cache-owners.md": "08e883a245679cf05d4cb65c93e5b9f1887022a972b98ab295aa248880ab9b7f", "apps/docs/src/content/docs/guides/tanstack-start.md": "28a196e44e1b6b49131b05e370cb029670599fefe6aad6ff54ed9d3d3f07ddac", - "apps/docs/src/content/docs/guides/vue-and-solid.md": "2b0b31da53f2b5e86cd5fb3abe5353c28e02e73dc485133d6f593c9f5d30cf06", + "apps/docs/src/content/docs/guides/vue-and-solid.md": null, + "apps/docs/src/content/docs/guides/vue-query.md": "b115c710cdd520cbf016fd19c48af2c849fe7e20344a2e900b78f0a0e5c5bd12", "apps/docs/src/content/docs/guides/websocket-clients.md": "9ec635105fd84c30dc52833ed3adcf80a46313deb0112d4e72cd1a5603c6170c", - "apps/docs/src/content/docs/index.mdx": "5c22ad2256052013855d92e21d4a277f4c3e92931ea71fd0c1b3e7f5aceb9584", + "apps/docs/src/content/docs/index.mdx": "232b5a9f64169463d7973c6801b3f3c326328870715fcb45e9b0a25ee085e197", "apps/docs/src/content/docs/reference/browser-and-worker-hosts.md": "70743a4429a39937515c8d5fee79ca75714a92dd526f1093f9fa8ea12fbde984", - "apps/docs/src/content/docs/reference/compatibility-and-limits.md": "99909357a5c32eb8ed9443fc0d92c43b228591d5c2203f1c822c9bcc4a7b280d", + "apps/docs/src/content/docs/reference/compatibility-and-limits.md": "1499a387d91e364fcfe44bd82f9e1589f9fab2a93fc081f9172a6e8da703cb25", "apps/docs/src/content/docs/reference/errors.md": "4454725375e05ea7a7d976d798c1c1d0c2731eefc79043f5e40dba88154069b1", "apps/docs/src/content/docs/reference/external-http-clients.md": "613f0e9493221416993e0c8012a97abdb3de2f80321aa7e4b0cda0f585618f91", "apps/docs/src/content/docs/reference/factory.md": "b099ed80d1ad8aab6a59213ee286290302bcf7885569ffb693fed784ca872dd3", @@ -324,7 +330,7 @@ "scripts/verify-packed-consumer.mts": null, "skills/effect-api-query/SKILL.md": "1939a0f7536f3e88e1e1106e8125019c11d6fdd4ada8ae4d010507f264b234e5", "skills/effect-api-query/references/cache-workflows.md": "27bb96c48c84254ab8c379a529402f81cea3723c17d6396527c090a8c37e6f65", - "skills/effect-api-query/references/frameworks-and-hosts.md": "d84c787ccfc76b1f01f3d44d02d969ed9422af7d9dbdda862e661d65af9e44df", + "skills/effect-api-query/references/frameworks-and-hosts.md": "8b39b303cf3a9ae3222a257f53df5f9696b80c54859765f2c4d7b39554b8719f", "skills/effect-api-query/references/http.md": "9625411753fcc3ce816512f80158a19aa1057518ab3bfde98720e0cc7f845b66", "skills/effect-api-query/references/hydration-and-ssr.md": "61e761b36ebcf56c7cc1b67b3d795edba52d0c0224c3e7964297ed6864e1b044", "skills/effect-api-query/references/query-patterns.md": "33ea307b7042660da4ee4f6acea147d4218688a0256d82f3d582ff2e9cef4f9c", @@ -398,14 +404,13 @@ "tests/types/tanstack-start-contract.ts": "51681e3d0d7504a614d490f18adb6f44db82d34b9d4d7cb9cf941aea33de284f", "tests/types/type-scale.ts": "a9b307c25c72f3c184eabc8e22ddaeea96feb331c0f8d748773d765d358dbfca" }, - "head": "f56fbfaa6c79e869f15e98d457b5ee6eb07d7b86", - "outcome": "no-change", - "reason": "All three shared planning documents remain accurate: the domain map retains the same ten tasks and reactive/ownership failure modes; the spec retains one skill, six references, assessed coverage and existing history; the tree retains the same identity, source globs covering both changed guides, prerequisites and package-only distribution. This guide clarification adds no task, seam, source mapping, dependency, or behavior, so no planning rewrite or artificial history entry is needed.", + "head": "a0a363afa19552badd7c40795ac7a4735595dddb", + "outcome": "updated", + "reason": "Update the reactive-options failure source in domain_map.yaml to solid-query.md, which contains the existing Solid accessor correction. The ten task domains, one-skill/six-reference coverage and source globs remain accurate. skill_tree.yaml and skill_spec.md retain their valid inventory, prior decisions and batch history; this route change needs no new task, skill, taxonomy or artificial history entry.", "evidence": [ - "Read _artifacts/domain_map.yaml, _artifacts/skill_spec.md and _artifacts/skill_tree.yaml against the two guide additions and unchanged seven-file skill.", - "Installed YAML parsing passed for map/tree, verified ten assessed tasks, one implemented skill with existing path/no prerequisites, and distribution.mode:none; native maintainer sync reported nothing to synchronize.", - "Fresh vp run docs-build and scoped format passed; documentation version scopes are historical recipe targets and do not widen the planning support claims.", - "Current native review contains only this planning item and the owned skill item; mapped documentation inputs are not separate unmapped source items. All other stored outcomes are preserved." + "Read _artifacts/domain_map.yaml, _artifacts/skill_spec.md and _artifacts/skill_tree.yaml against the guide diff at a0a363afa19552badd7c40795ac7a4735595dddb. Map source now points to existing guides/solid-query.md and its accessor recipe; the unchanged **/apps/docs/src/content/docs/**/*.md glob covers all four new pages.", + "Both planning YAML files parsed with the installed Intent YAML parser and contain the existing library/skill entry. Native maintainer sync reports nothing to synchronize. Scoped formatting, 46-page docs build/internal links, verbatim recipe comparison and native skills-check passed; the existing skill activation and six reference files remain authoritative.", + "No runtime/type/API/dependency, ownership or support-matrix change. No new task-quality or discovery claim; planning guidance preserves existing scope and publication remains separately pending. This outcome covers all three shared planning documents and their current skill/source snapshot." ] }, "source:vite.config.ts": { @@ -941,17 +946,17 @@ ] }, "source:apps/docs/astro.config.mts": { - "fingerprint": "a203f42f861dd5c68e83ad5196f881fd30a98b99e9378bdf8137569697216d8b", + "fingerprint": "395c3f595f9efe24a1ef692865d00245e353817c323a941a2bc4bb426d86c608", "snapshot": { - "apps/docs/astro.config.mts": "397aafc4621e59a3df38395dab8ae88f7dab3c86d121399c25f3e79dcbd23d85" + "apps/docs/astro.config.mts": "a4ae131af72451431a9e667a4c5492ef837eec39b4fd7a11c23d9e86de97403a" }, - "head": "2e4ecf18e93a4ee086d74102c3addb5e2029be56", + "head": "a0a363afa19552badd7c40795ac7a4735595dddb", "outcome": "no-change", - "reason": "This is the existing Astro configuration under an explicitly TypeScript ESM extension. Installed Astro 7.3.5 discovers .mts configurations; ordinary Astro diagnostics and the complete static documentation build load it successfully. The source settings, plugins, paths and site content are preserved. It remains repository tooling, outside the shipped integration skill source scope, so no new mapping or guidance task is warranted.", + "reason": "The existing documentation sidebar now names four individual framework guides. It remains documentation-site configuration outside the library integration task mapping; no new shipped skill, source glob or recommended behavior is warranted. The linked guide changes and existing skill source footer already own the developer-facing material.", "evidence": [ - "Author base 2e4ecf18e93a4ee086d74102c3addb5e2029be56. Rename apps/docs/astro.config.mjs to astro.config.mts, remove ts-check directive, and update only .fallowrc.jsonc ignoreExports path. Installed loader evidence: apps/docs/node_modules/astro/dist/core/config/config.js configPaths contains astro.config.mts.", - "vp run format apps/docs/astro.config.mts .fallowrc.jsonc passed. vp run fallow passed entry/production dead-code checks, duplicates task, and health threshold. vp run docs-build passed Astro check with zero errors/warnings/hints and build of 44 pages with valid internal links; unchanged documentation example parity was a native cache hit. Logs: /tmp/effect-api-query-implementation/notes/astro-mts-*.log.", - "Read _artifacts/domain_map.yaml, skill_spec.md, and skill_tree.yaml: existing library task/seam/ownership coverage and source globs remain accurate; no library behavior, guidance, dependency or installation change. Native sync changed nothing; review reports no owned skill/planning drift. No new independent consumer or browser execution is needed for this repository config rename." + "Author base a0a363afa19552badd7c40795ac7a4735595dddb. apps/docs/astro.config.mts replaces only the two paired sidebar items with Vue Query, Solid Query, Svelte Query and Angular Query labels/slugs; every new slug matches its corresponding frontmatter title and generated page.", + "Scoped format passed. Uncached vp run docs-build loaded the actual .mts configuration, reported 0 Astro errors/warnings/hints, built/indexed 46 pages and validated all internal links. Static artifact inspection found all four sidebar links on every new framework page and no old route directories.", + "Read all three planning records and current shipped framework guidance. Documentation source globs already cover the four human guides, while sidebar configuration adds no developer integration task. No public API/runtime/dependency or independent-consumer behavior change." ] }, "source:docs/adr/0016-automate-releases-with-changesets.md": { diff --git a/README.md b/README.md index d7203e4..76e0cc6 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ for accumulated HTTP event queries. ## Integrate with your application - [React Query](https://ueberbrot.github.io/effect-api-query/guides/react-query/): use generated options with hooks. -- [Vue and Solid](https://ueberbrot.github.io/effect-api-query/guides/vue-and-solid/) and [Svelte and Angular](https://ueberbrot.github.io/effect-api-query/guides/svelte-and-angular/): rebuild options from reactive input with native Query integrations. +- [Vue Query](https://ueberbrot.github.io/effect-api-query/guides/vue-query/), [Solid Query](https://ueberbrot.github.io/effect-api-query/guides/solid-query/), [Svelte Query](https://ueberbrot.github.io/effect-api-query/guides/svelte-query/), and [Angular Query](https://ueberbrot.github.io/effect-api-query/guides/angular-query/): rebuild options from reactive input with native Query integrations. - [TanStack Start](https://ueberbrot.github.io/effect-api-query/guides/tanstack-start/): share RPC and HTTP options across loaders, server rendering, and hydration. - [Stream snapshots](https://ueberbrot.github.io/effect-api-query/guides/stream-snapshots/): capture open streams and await local cleanup before server disposal. - [Hydrate Unary Data](https://ueberbrot.github.io/effect-api-query/guides/hydrate-unary-data/) and [Query Views](https://ueberbrot.github.io/effect-api-query/guides/hydrate-query-views/): keep DTOs or reconstructed Schema values consistent across JSON hydration and refetch. diff --git a/_artifacts/domain_map.yaml b/_artifacts/domain_map.yaml index bb3b8e5..01b55c0 100644 --- a/_artifacts/domain_map.yaml +++ b/_artifacts/domain_map.yaml @@ -92,7 +92,7 @@ skills: mechanism: Builders capture the first input and cannot follow reactive changes. wrong_pattern: useQuery(rpc.users.read.queryOptions({ input })) correct_pattern: 'useQuery(() => rpc.users.read.queryOptions({ input: readInput() }))' - source: apps/docs/src/content/docs/guides/vue-and-solid.md + source: apps/docs/src/content/docs/guides/solid-query.md priority: HIGH status: active - mistake: Rely on changed metadata options after a fresh hit diff --git a/apps/docs/astro.config.mts b/apps/docs/astro.config.mts index 97966aa..8ae2daa 100644 --- a/apps/docs/astro.config.mts +++ b/apps/docs/astro.config.mts @@ -64,8 +64,10 @@ export default defineConfig({ label: 'Guides', items: [ { label: 'React Query', slug: 'guides/react-query' }, - { label: 'Svelte and Angular', slug: 'guides/svelte-and-angular' }, - { label: 'Vue and Solid Query', slug: 'guides/vue-and-solid' }, + { label: 'Vue Query', slug: 'guides/vue-query' }, + { label: 'Solid Query', slug: 'guides/solid-query' }, + { label: 'Svelte Query', slug: 'guides/svelte-query' }, + { label: 'Angular Query', slug: 'guides/angular-query' }, { label: 'HTTP Queries and Mutations', slug: 'guides/http-queries-and-mutations' }, { label: 'TanStack Start', slug: 'guides/tanstack-start' }, { label: 'WebSocket Ready Clients', slug: 'guides/websocket-clients' }, diff --git a/apps/docs/src/content/docs/guides/angular-query.md b/apps/docs/src/content/docs/guides/angular-query.md new file mode 100644 index 0000000..1842eac --- /dev/null +++ b/apps/docs/src/content/docs/guides/angular-query.md @@ -0,0 +1,88 @@ +--- +title: Angular Query +description: Rebuild generated options from Angular signals and keep client lifetime in the application. +--- + +Pass generated options through the framework's reactive accessor. Each builder captures its +plain input when called, so create the options inside that accessor whenever the input can change. +The same rule applies to RPC and HTTP queries, metadata views, accumulated streams, and live queries. + +The examples target Angular 22.2.1 with Angular Query experimental 5.104.0 and Query Core 5.104.0. + +Keep captured values unchanged while their options can execute. See +[Data Normalization](/effect-api-query/concepts/data-normalization/#keep-captured-inputs-unchanged) +for the distinction between immutable keys and caller-owned input. + +## Create queries in an injection context + +Register `provideTanStackQuery(queryClient)` in your application providers. Construct queries +inside an Angular injection context and read the signal inside the accessor passed to `injectQuery`. +The ready client below is acquired and disposed by the application. Keep its `Scope` and any +serviceful runner alive while queries use them: + +```ts +import { Component, signal } from '@angular/core' +import { injectQuery } from '@tanstack/angular-query-experimental' +import { Schema } from 'effect' +import { createRpcQueryUtils } from 'effect-api-query' +import { Rpc, RpcGroup, type RpcClient } from 'effect/rpc' + +const group = RpcGroup.make( + Rpc.make('users.read', { + payload: { id: Schema.Int }, + success: Schema.Struct({ id: Schema.Int, name: Schema.String }), + }), +) +declare const client: RpcClient.RpcClient.Flat> +const rpc = createRpcQueryUtils(group, { client, keyPrefix: ['current-owner'] }) + +@Component({ + selector: 'user-details', + standalone: true, + template: '

{{ user.data() }}

', +}) +class UserDetails { + readonly id = signal(1) + readonly user = injectQuery(() => + rpc.users.read.queryOptions({ + input: { id: this.id() }, + select: (user) => user.name, + }), + ) + + next() { + this.id.update((id) => id + 1) + } +} +``` + +Use `injectInfiniteQuery` with `infiniteOptions` and `injectMutation` with `mutationOptions`. +Supply each mutation's input through its execution variables. Angular query results expose +signals such as `data()`, `error()`, and `fetchStatus()`. + +Use the same accessor for `streamedOptions`, `liveOptions`, and HTTP `metadataOptions`. These +provide accumulated history, the latest emission, and decoded data with status/header snapshots, +respectively. + +## Choose native options carefully + +Angular's defined-data overload accepts a query function rather than `skipToken`. A skipped query +therefore retains `undefined` in its `data()` type even when it has defined `initialData`. An +initial-data function that may return `undefined` also produces optional result data. + +## Own the lifecycle + +Open streams expose their first value while fetching continues. Changing a generated query's key +replaces the observer's active query. Destroying an Angular component removes its observers; +the last observer of an abort-aware open stream triggers +cancellation. Keep the ready client and runtime alive until local stream finalizers finish. +Other observers of the same query retain their subscription. + +For an owner change, unmount the old tree, cancel and clear its QueryClient, await local cleanup, +then dispose its client resources. Mount the new tree with independently owned resources and a +safe key prefix. See [Switch Cache Owners](/effect-api-query/guides/switch-cache-owners/) and +[Client Lifecycle](/effect-api-query/concepts/client-lifecycle/). + +Match Angular Query to the application's Query Core and use a compiler supported by its build +tooling. Angular core, common, compiler, and platform-browser should share one release. +Server rendering and hydration remain application integrations. diff --git a/apps/docs/src/content/docs/guides/solid-query.md b/apps/docs/src/content/docs/guides/solid-query.md new file mode 100644 index 0000000..a67cee3 --- /dev/null +++ b/apps/docs/src/content/docs/guides/solid-query.md @@ -0,0 +1,68 @@ +--- +title: Solid Query +description: Rebuild generated options with a Solid accessor and keep client resources alive. +--- + +Pass generated options from either factory to Solid Query's native hooks. Configure +`QueryClientProvider` and acquire the ready client before creating the utilities. Keep its `Scope` +and any serviceful runner alive while queries use them. + +The examples target Solid 1.9.15 with Solid Query 5.104.0 and Query Core 5.104.0. + +Match the native Query wrapper to the application's Query Core and use a compiler supported by +the framework's tooling. See the +[compatibility reference](/effect-api-query/reference/compatibility-and-limits/) for adapter capabilities. + +## Rebuild options when input changes + +Builders capture plain input when called. Build options inside Solid's options accessor so a +changed input produces a new key and query function. Passing a builder's result once leaves the +query attached to the original input. + +Keep captured values unchanged while their options can execute. See +[Data Normalization](/effect-api-query/concepts/data-normalization/#keep-captured-inputs-unchanged) +for the distinction between immutable keys and caller-owned input. + +In a Solid component, pass the builder call as an accessor: + +```ts +import { useQuery } from '@tanstack/solid-query' +import { skipToken } from 'effect-api-query' +import { createSignal } from 'solid-js' + +import { rpc } from './queries.ts' + +const [userId] = createSignal(1) +const user = useQuery(() => { + const id = userId() + return rpc.users.read.queryOptions({ + input: id === undefined ? skipToken : { id }, + select: (value) => value.name, + }) +}) +``` + +Read the selected value from `user.data`. Apply the same accessor pattern to `infiniteOptions`, +`streamedOptions`, `liveOptions`, and `metadataOptions` when their input changes. Use +`useMutation(() => rpc.users.write.mutationOptions())` for writes, supplying each execution's +input through `mutate(variables)`. Read reactive mutation settings inside the accessor. + +## Choose native options carefully + +Solid accepts Core's `enabled(query)` callback. Solid Query 5.104.0 query and infinite-query +overloads accept defined initial data or absent initial data. A value or initializer that may +return `undefined` does not match those overloads. Omit `initialData` or supply a defined seed. +An explicit `initialData: undefined` overrides inherited QueryClient initial data. + +## Own streams and disposal + +Open streams expose their first value while fetching continues. Changing reactive input replaces +the query; when its last observer leaves, query cancellation closes the stream iterator. Solid +owner disposal also releases observed streams. Keep client resources alive until these queries +finish cancelling, then clear the owned QueryClient and close the client scope. Transport +interruption depends on the ready client. + +Use `streamedOptions` for an accumulated history and `liveOptions` for its latest emission. HTTP +`metadataOptions` retains decoded data, response status, and header snapshots in a separate cache +entry. Follow [client lifecycle](/effect-api-query/concepts/client-lifecycle/) and +[cancellation](/effect-api-query/guides/cancellation/) when disposing the cache owner. diff --git a/apps/docs/src/content/docs/guides/svelte-and-angular.md b/apps/docs/src/content/docs/guides/svelte-and-angular.md deleted file mode 100644 index d7f5e12..0000000 --- a/apps/docs/src/content/docs/guides/svelte-and-angular.md +++ /dev/null @@ -1,114 +0,0 @@ ---- -title: Svelte and Angular -description: Rebuild generated options when reactive inputs change and keep client lifetime in the application. ---- - -Pass generated options through the framework's reactive accessor. Each builder captures its -plain input when called, so create the options inside that accessor whenever the input can change. -The same rule applies to RPC and HTTP queries, metadata views, accumulated streams, and live queries. - -The examples target Svelte 5.57.1 with Svelte Query 6.3.0 and Angular 22.2.1 with Angular Query -experimental 5.104.0, both using Query Core 5.104.0. - -Keep captured values unchanged while their options can execute. See -[Data Normalization](/effect-api-query/concepts/data-normalization/#keep-captured-inputs-unchanged) -for the distinction between immutable keys and caller-owned input. - -## Svelte - -Create the utility tree in your application module from an acquired ready client. Provide a -QueryClient through Svelte Query's `QueryClientProvider`, then read reactive inputs inside the -accessor passed to `createQuery`: - -```svelte - - - -

{user.data}

-``` - -Use `createInfiniteQuery(() => rpc.users.list.infiniteOptions(...))` for pagination and -`createMutation(() => rpc.users.write.mutationOptions(...))` for writes. A mutation's variables -provide the input for each execution. Mutation options can be static when their callbacks and -other settings do not depend on reactive state. - -Compile the Svelte application with `module: "ESNext"` and `moduleResolution: "Bundler"`. -Svelte Query 6 requires this resolution for its emitted `.svelte` -declarations; strict NodeNext declaration checking reports an upstream `Box` import error. - -## Angular - -Register `provideTanStackQuery(queryClient)` in your application providers. Construct queries -inside an Angular injection context and read the signal inside the accessor passed to `injectQuery`. -The ready client below is acquired and disposed by the application: - -```ts -import { Component, signal } from '@angular/core' -import { injectQuery } from '@tanstack/angular-query-experimental' -import { Schema } from 'effect' -import { createRpcQueryUtils } from 'effect-api-query' -import { Rpc, RpcGroup, type RpcClient } from 'effect/rpc' - -const group = RpcGroup.make( - Rpc.make('users.read', { - payload: { id: Schema.Int }, - success: Schema.Struct({ id: Schema.Int, name: Schema.String }), - }), -) -declare const client: RpcClient.RpcClient.Flat> -const rpc = createRpcQueryUtils(group, { client, keyPrefix: ['current-owner'] }) - -@Component({ - selector: 'user-details', - standalone: true, - template: '

{{ user.data() }}

', -}) -class UserDetails { - readonly id = signal(1) - readonly user = injectQuery(() => - rpc.users.read.queryOptions({ - input: { id: this.id() }, - select: (user) => user.name, - }), - ) - - next() { - this.id.update((id) => id + 1) - } -} -``` - -Use `injectInfiniteQuery` and `injectMutation` with the corresponding builders. Angular query -results expose signals such as `data()`, `error()`, and `fetchStatus()`. - -Angular's defined-data overload accepts a query function rather than `skipToken`. A skipped query -therefore retains `undefined` in its `data()` type even when it has defined `initialData`. Svelte -preserves the defined-data type for that combination. Both frameworks accept initial-data functions -that may return `undefined`, with optional result data. - -## Own the lifecycle - -Changing a generated query's key replaces the observer's active query. Unmounting a Svelte -component or destroying an Angular component removes its observers; the last observer of an -abort-aware open stream triggers cancellation. Keep the ready client and runtime alive until -local stream finalizers finish. Other observers of the same query retain their subscription. - -For an owner change, unmount the old tree, cancel and clear its QueryClient, await local cleanup, -then dispose its client resources. Mount the new tree with independently owned resources and a -safe key prefix. See [Switch Cache Owners](/effect-api-query/guides/switch-cache-owners/) and -[Client Lifecycle](/effect-api-query/concepts/client-lifecycle/). - -Match each framework Query wrapper to the application's Query Core and use a compiler supported -by its build tooling. Angular core, common, compiler, and platform-browser should share one -release. Framework server rendering and hydration remain application integrations. diff --git a/apps/docs/src/content/docs/guides/svelte-query.md b/apps/docs/src/content/docs/guides/svelte-query.md new file mode 100644 index 0000000..91e283d --- /dev/null +++ b/apps/docs/src/content/docs/guides/svelte-query.md @@ -0,0 +1,73 @@ +--- +title: Svelte Query +description: Rebuild generated options from Svelte reactive input and keep client lifetime in the application. +--- + +Pass generated options through the framework's reactive accessor. Each builder captures its +plain input when called, so create the options inside that accessor whenever the input can change. +The same rule applies to RPC and HTTP queries, metadata views, accumulated streams, and live queries. + +The examples target Svelte 5.57.1 with Svelte Query 6.3.0 and Query Core 5.104.0. + +Keep captured values unchanged while their options can execute. See +[Data Normalization](/effect-api-query/concepts/data-normalization/#keep-captured-inputs-unchanged) +for the distinction between immutable keys and caller-owned input. + +## Create native queries + +Create the utility tree in your application module from an acquired ready client. Provide a +QueryClient through Svelte Query's `QueryClientProvider`, then read reactive inputs inside the +accessor passed to `createQuery`. Keep the client's `Scope` and any serviceful runner alive while +queries use them: + +```svelte + + + +

{user.data}

+``` + +Use `createInfiniteQuery(() => rpc.users.list.infiniteOptions(...))` for pagination and +`createMutation(() => rpc.users.write.mutationOptions(...))` for writes. A mutation's variables +provide the input for each execution. Mutation options can be static when their callbacks and +other settings do not depend on reactive state. + +Use the same accessor for `streamedOptions`, `liveOptions`, and HTTP `metadataOptions`. These +provide accumulated history, the latest emission, and decoded data with status/header snapshots, +respectively. + +## Choose native options carefully + +A skipped query retains the defined-data type when it has defined `initialData`. An initial-data +function that may return `undefined` produces optional result data. + +Compile the Svelte application with `module: "ESNext"` and `moduleResolution: "Bundler"`. +Svelte Query 6 requires this resolution for its emitted `.svelte` declarations; strict NodeNext +declaration checking reports an upstream `Box` import error. + +## Own the lifecycle + +Open streams expose their first value while fetching continues. Changing a generated query's key +replaces the observer's active query. Unmounting a Svelte component removes its observers; +the last observer of an abort-aware open stream triggers +cancellation. Keep the ready client and runtime alive until local stream finalizers finish. +Other observers of the same query retain their subscription. + +For an owner change, unmount the old tree, cancel and clear its QueryClient, await local cleanup, +then dispose its client resources. Mount the new tree with independently owned resources and a +safe key prefix. See [Switch Cache Owners](/effect-api-query/guides/switch-cache-owners/) and +[Client Lifecycle](/effect-api-query/concepts/client-lifecycle/). + +Match Svelte Query to the application's Query Core and use a compiler supported by its build +tooling. Server rendering and hydration remain application integrations. diff --git a/apps/docs/src/content/docs/guides/vue-and-solid.md b/apps/docs/src/content/docs/guides/vue-and-solid.md deleted file mode 100644 index b2b7669..0000000 --- a/apps/docs/src/content/docs/guides/vue-and-solid.md +++ /dev/null @@ -1,108 +0,0 @@ ---- -title: Vue and Solid Query -description: Rebuild generated options from reactive input and keep client resources alive. ---- - -Pass generated options from either factory to the framework's native Query hooks. Configure -`VueQueryPlugin` in Vue or `QueryClientProvider` in Solid, and acquire the ready client before -creating the utilities. Keep its `Scope` and any serviceful runner alive while queries use them. - -The examples target Vue 3.5.43 with Vue Query 5.104.0 and Solid 1.9.15 with Solid Query 5.104.0. -Both use Query Core 5.104.0. - -Match the native Query wrapper to the application's Query Core and use a compiler supported by -the framework's tooling. See the -[compatibility reference](/effect-api-query/reference/compatibility-and-limits/) for adapter capabilities. - -## Rebuild options when input changes - -Builders capture plain input when called. Build options inside Vue `computed` or Solid's options -accessor so a changed input produces a new key and query function. Passing a builder's result -once leaves the query attached to the original input. - -Keep captured values unchanged while their options can execute. See -[Data Normalization](/effect-api-query/concepts/data-normalization/#keep-captured-inputs-unchanged) -for the distinction between immutable keys and caller-owned input. - -In Vue component setup, use a computed options object. This example assumes `rpc.users.read` -returns a user with a `name`: - -```ts -import type { InferDataFromTag, InferErrorFromTag } from '@tanstack/query-core' -import { useQuery } from '@tanstack/vue-query' -import { skipToken } from 'effect-api-query' -import { computed, ref } from 'vue' - -import { rpc } from './queries.ts' - -const userId = ref(1) -const options = computed(() => - rpc.users.read.queryOptions({ - input: userId.value === undefined ? skipToken : { id: userId.value }, - select: (user) => user.name, - }), -) -type UserKey = ReturnType -const user = useQuery< - InferDataFromTag, - InferErrorFromTag, - string ->(options) -``` - -Read the selected value from `user.data.value`. The explicit native hook generics preserve the -data and error carried by the concrete generated key while bounding Vue's recursive ref unwrapping. -Automatic inference can otherwise exceed TypeScript's instantiation depth when it unwraps an -Effect `Cause` through a tagged key. Let the builder infer its arguments; put these type arguments -on the Vue hook. - -In a Solid component, pass the builder call as an accessor: - -```ts -import { useQuery } from '@tanstack/solid-query' -import { skipToken } from 'effect-api-query' -import { createSignal } from 'solid-js' - -import { rpc } from './queries.ts' - -const [userId] = createSignal(1) -const user = useQuery(() => { - const id = userId() - return rpc.users.read.queryOptions({ - input: id === undefined ? skipToken : { id }, - select: (value) => value.name, - }) -}) -``` - -Read the selected value from `user.data`. Apply the same computed/accessor pattern to -`infiniteOptions`, `streamedOptions`, `liveOptions`, and `metadataOptions` when their input changes. - -Pass `rpc.users.write.mutationOptions()` to Vue's `useMutation`, or use -`useMutation(() => rpc.users.write.mutationOptions())` in Solid. Supply each execution's input -through `mutate(variables)`; use Vue `computed` or a Solid accessor when mutation settings depend -on reactive state. - -## Choose native options carefully - -Vue evaluates a function-valued `enabled` as a getter with no arguments. Use a boolean inside -computed options, or a zero-argument getter returning a boolean. A Core `enabled(query)` callback -cannot be passed directly to Vue. Solid accepts the native Core callback. - -Solid Query 5.104.0 query and infinite-query overloads accept defined initial data or absent initial -data. A value or initializer that may return `undefined` does not match those overloads. Omit -`initialData` or supply a defined seed. Vue accepts a possibly undefined initializer. An explicit -`initialData: undefined` still overrides inherited QueryClient initial data in both integrations. - -## Own streams and disposal - -Open streams expose their first value while fetching continues. Changing reactive input replaces -the query; when its last observer leaves, query cancellation closes the stream iterator. Component -unmount or Solid owner disposal also releases observed streams. Keep client resources alive until -these queries finish cancelling, then clear the owned QueryClient and close the client scope. -Transport interruption depends on the ready client. - -Use `streamedOptions` for an accumulated history and `liveOptions` for its latest emission. HTTP -`metadataOptions` retains decoded data, response status, and header snapshots in a separate cache -entry. Follow [client lifecycle](/effect-api-query/concepts/client-lifecycle/) and -[cancellation](/effect-api-query/guides/cancellation/) when disposing the cache owner. diff --git a/apps/docs/src/content/docs/guides/vue-query.md b/apps/docs/src/content/docs/guides/vue-query.md new file mode 100644 index 0000000..9122b13 --- /dev/null +++ b/apps/docs/src/content/docs/guides/vue-query.md @@ -0,0 +1,83 @@ +--- +title: Vue Query +description: Rebuild generated options with Vue computed and keep client resources alive. +--- + +Pass generated options from either factory to Vue Query's native hooks. Configure `VueQueryPlugin` +and acquire the ready client before creating the utilities. Keep its `Scope` and any serviceful +runner alive while queries use them. + +The examples target Vue 3.5.43 with Vue Query 5.104.0 and Query Core 5.104.0. + +Match the native Query wrapper to the application's Query Core and use a compiler supported by +the framework's tooling. See the +[compatibility reference](/effect-api-query/reference/compatibility-and-limits/) for adapter capabilities. + +## Rebuild options when input changes + +Builders capture plain input when called. Build options inside Vue `computed` so a changed input +produces a new key and query function. Passing a builder's result once leaves the query attached +to the original input. + +Keep captured values unchanged while their options can execute. See +[Data Normalization](/effect-api-query/concepts/data-normalization/#keep-captured-inputs-unchanged) +for the distinction between immutable keys and caller-owned input. + +In component setup, use a computed options object. This example assumes `rpc.users.read` returns +a user with a `name`: + +```ts +import type { InferDataFromTag, InferErrorFromTag } from '@tanstack/query-core' +import { useQuery } from '@tanstack/vue-query' +import { skipToken } from 'effect-api-query' +import { computed, ref } from 'vue' + +import { rpc } from './queries.ts' + +const userId = ref(1) +const options = computed(() => + rpc.users.read.queryOptions({ + input: userId.value === undefined ? skipToken : { id: userId.value }, + select: (user) => user.name, + }), +) +type UserKey = ReturnType +const user = useQuery< + InferDataFromTag, + InferErrorFromTag, + string +>(options) +``` + +Read the selected value from `user.data.value`. The explicit native hook generics preserve the +data and error carried by the concrete generated key while bounding Vue's recursive ref unwrapping. +Automatic inference can otherwise exceed TypeScript's instantiation depth when it unwraps an +Effect `Cause` through a tagged key. Let the builder infer its arguments; put these type arguments +on the Vue hook. + +Apply the same computed pattern to `infiniteOptions`, `streamedOptions`, `liveOptions`, and +`metadataOptions` when their input changes. Pass `rpc.users.write.mutationOptions()` to +`useMutation` and supply each execution's input through `mutate(variables)`. Use `computed` +when mutation settings depend on reactive state. + +## Choose native options carefully + +Vue evaluates a function-valued `enabled` as a getter with no arguments. Use a boolean inside +computed options, or a zero-argument getter returning a boolean. A Core `enabled(query)` callback +cannot be passed directly to Vue. + +Vue accepts an initial-data initializer that may return `undefined`, with optional result data. +An explicit `initialData: undefined` overrides inherited QueryClient initial data. + +## Own streams and disposal + +Open streams expose their first value while fetching continues. Changing reactive input replaces +the query; when its last observer leaves, query cancellation closes the stream iterator. Component +unmount also releases observed streams. Keep client resources alive until these queries finish +cancelling, then clear the owned QueryClient and close the client scope. Transport interruption +depends on the ready client. + +Use `streamedOptions` for an accumulated history and `liveOptions` for its latest emission. HTTP +`metadataOptions` retains decoded data, response status, and header snapshots in a separate cache +entry. Follow [client lifecycle](/effect-api-query/concepts/client-lifecycle/) and +[cancellation](/effect-api-query/guides/cancellation/) when disposing the cache owner. diff --git a/apps/docs/src/content/docs/index.mdx b/apps/docs/src/content/docs/index.mdx index 6f2b6e0..f96532a 100644 --- a/apps/docs/src/content/docs/index.mdx +++ b/apps/docs/src/content/docs/index.mdx @@ -37,8 +37,9 @@ controls its client, cache policies, and resource lifetime. - Check [feature support](/effect-api-query/getting-started/feature-support/) to choose operations and plan your integration. - Use the [React Query guide](/effect-api-query/guides/react-query/) for hooks and mutations. -- Use [Vue and Solid Query](/effect-api-query/guides/vue-and-solid/) or - [Svelte and Angular](/effect-api-query/guides/svelte-and-angular/) for native reactive consumers. +- Use [Vue Query](/effect-api-query/guides/vue-query/), [Solid Query](/effect-api-query/guides/solid-query/), + [Svelte Query](/effect-api-query/guides/svelte-query/), or [Angular Query](/effect-api-query/guides/angular-query/) + for native reactive consumers. - Use the [TanStack Start guide](/effect-api-query/guides/tanstack-start/) for loaders, SSR, and hydration. - Capture open streams with [Stream Snapshots](/effect-api-query/guides/stream-snapshots/), then diff --git a/apps/docs/src/content/docs/reference/compatibility-and-limits.md b/apps/docs/src/content/docs/reference/compatibility-and-limits.md index 4526f07..791d24b 100644 --- a/apps/docs/src/content/docs/reference/compatibility-and-limits.md +++ b/apps/docs/src/content/docs/reference/compatibility-and-limits.md @@ -25,8 +25,9 @@ Effect packages coordinated with the exact peer; see [Compatibility and Stability](/effect-api-query/getting-started/compatibility-and-stability/). Use native reactive accessors with generated Core options. See -[Svelte and Angular](/effect-api-query/guides/svelte-and-angular/) and -[Vue and Solid Query](/effect-api-query/guides/vue-and-solid/) for option rebuilding, +[Vue Query](/effect-api-query/guides/vue-query/), [Solid Query](/effect-api-query/guides/solid-query/), +[Svelte Query](/effect-api-query/guides/svelte-query/), and [Angular Query](/effect-api-query/guides/angular-query/) +for option rebuilding, compiler resolution, hook overloads, and resource ownership. Consult [package metadata](https://github.com/ueberBrot/effect-api-query/blob/main/package.json) for @@ -54,8 +55,10 @@ supplies the client, policy, or lifecycle. **Deferred** means the adapter does n | Mutation cancellation | No upstream TanStack mutation abort signal; explicit cancellable command is application-owned and tested | No upstream TanStack mutation abort signal; domain cancellation is application-owned | | Authentication, middleware, and residual services | Application-owned ready client and runner | Application-owned ready client and runner | | React hooks and QueryClient | Tested public types and Vite React example | Tested public types and Vite React example | -| Svelte and Angular browser components | Native reactive accessors for generated queries and streams; application-owned lifecycle | Native reactive accessors for buffered, metadata, and SSE views; application-owned lifecycle | -| Vue and Solid Query | Native reactive accessors; application-owned lifecycle | Native reactive accessors; application-owned lifecycle | +| Vue Query | Native reactive accessors; application-owned lifecycle | Native reactive accessors; application-owned lifecycle | +| Solid Query | Native reactive accessors; application-owned lifecycle | Native reactive accessors; application-owned lifecycle | +| Svelte Query | Native reactive accessors for generated queries and streams; application-owned lifecycle | Native reactive accessors for buffered, metadata, and SSE views; application-owned lifecycle | +| Angular Query | Native reactive accessors for generated queries and streams; application-owned lifecycle | Native reactive accessors for buffered, metadata, and SSE views; application-owned lifecycle | | SSR and hydration | Application-owned; tested Start route loading and stream snapshots | Application-owned; tested Start SSR, hydration, failed-query refetch, and request isolation | | Host routes | Application-owned; tested standalone server and Start `/rpc` | Application-owned; tested standalone server and Start `/api/$` | | Cache serialization and mutation invalidation | Application-owned | Application-owned | diff --git a/skills/effect-api-query/references/frameworks-and-hosts.md b/skills/effect-api-query/references/frameworks-and-hosts.md index 84c113a..b363cfd 100644 --- a/skills/effect-api-query/references/frameworks-and-hosts.md +++ b/skills/effect-api-query/references/frameworks-and-hosts.md @@ -129,6 +129,9 @@ The factories preserve the ready client's actual execution contract; they supply no transport batching, platform services, or framework providers. Error guards use same-realm `instanceof`; transfer safe error DTOs deliberately across realms. -Sources: [framework guides](https://ueberbrot.github.io/effect-api-query/guides/vue-and-solid/), +Sources: [Vue Query](https://ueberbrot.github.io/effect-api-query/guides/vue-query/), +[Solid Query](https://ueberbrot.github.io/effect-api-query/guides/solid-query/), +[Svelte Query](https://ueberbrot.github.io/effect-api-query/guides/svelte-query/), +[Angular Query](https://ueberbrot.github.io/effect-api-query/guides/angular-query/), [host boundaries](https://ueberbrot.github.io/effect-api-query/reference/browser-and-worker-hosts/), [WebSocket clients](https://ueberbrot.github.io/effect-api-query/guides/websocket-clients/). From 98894c44dd3c64c5339eb54984c17043b2f18442 Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Sun, 11 Oct 2026 00:40:03 +0200 Subject: [PATCH 88/97] Focus human docs on library integration --- .intent/review-state.json | 191 ++++++------ README.md | 32 +- .../content/docs/concepts/client-lifecycle.md | 40 +-- .../docs/concepts/http-utility-tree.md | 18 +- .../docs/concepts/queries-and-mutations.md | 3 - .../content/docs/concepts/semantic-keys.md | 3 - apps/docs/src/content/docs/examples.md | 95 ++---- .../compatibility-and-stability.md | 3 +- .../docs/getting-started/feature-support.md | 44 +-- .../docs/getting-started/http-quick-start.md | 137 +++------ .../docs/getting-started/installation.md | 5 +- .../docs/getting-started/quick-start.md | 134 +++------ .../src/content/docs/guides/angular-query.md | 42 +-- .../src/content/docs/guides/cache-filters.md | 150 +++------- .../content/docs/guides/cache-management.md | 12 +- .../src/content/docs/guides/cancellation.md | 44 ++- .../docs/guides/conditional-queries.md | 11 +- .../docs/guides/custom-key-encoders.md | 9 +- .../content/docs/guides/handle-failures.md | 4 +- .../docs/guides/http-queries-and-mutations.md | 283 +++++++----------- .../docs/guides/hydrate-query-views.md | 172 ++--------- .../content/docs/guides/hydrate-unary-data.md | 146 +-------- .../src/content/docs/guides/query-defaults.md | 134 ++++----- .../src/content/docs/guides/react-query.md | 88 +++--- .../docs/guides/refresh-from-events.md | 113 +++---- .../src/content/docs/guides/retry-queries.md | 188 +++++------- .../src/content/docs/guides/solid-query.md | 7 +- .../content/docs/guides/stream-snapshots.md | 75 ++--- .../src/content/docs/guides/svelte-query.md | 6 +- .../docs/guides/switch-cache-owners.md | 134 +++------ .../src/content/docs/guides/tanstack-start.md | 91 ++---- .../docs/src/content/docs/guides/vue-query.md | 12 +- .../content/docs/guides/websocket-clients.md | 127 ++------ apps/docs/src/content/docs/index.mdx | 36 +-- .../reference/compatibility-and-limits.md | 48 ++- .../docs/reference/external-http-clients.md | 17 +- .../src/content/docs/reference/factory.md | 2 - .../docs/reference/generated-builders.md | 4 - .../content/docs/reference/http-factory.md | 22 +- scripts/verify-docs-examples.mts | 38 ++- 40 files changed, 921 insertions(+), 1799 deletions(-) diff --git a/.intent/review-state.json b/.intent/review-state.json index 04a0c40..b831813 100644 --- a/.intent/review-state.json +++ b/.intent/review-state.json @@ -3,55 +3,53 @@ "baseline": "5b91ddb1de13970365669c887b9d767ea417fbd4", "items": { "skill:skills/effect-api-query/SKILL.md": { - "fingerprint": "b37da5dd07f1af5dc54bbb3eb88c0ec100fb5554c162275e5f48d9313094e4db", + "fingerprint": "50481d0684c59c0a15b12a79773b153ca25541dbd300a47f01c1399aef0c5c87", "snapshot": { "GLOSSARY.md": "67de06543ad2fd9fd2917419e19d1f071e7b6eee53d48fca05e9dddfc739c683", - "README.md": "324f4e55b7b41232cc25b9b8780f49b136b25a0ee7e6650dea6ca371ff46669f", - "apps/docs/src/content/docs/concepts/client-lifecycle.md": "4a7d412f6e3c7dfbf0442b51ee9054c2b854026bcf968961cee65071f7cbb424", + "README.md": "7af677dd785f95bf08b4a9dbafd10f1b0a2cd9ce40d42edf359ede1e59de6fd3", + "apps/docs/src/content/docs/concepts/client-lifecycle.md": "fcbaca84f266060b946eed9cbf9d0b9815352b7b1d6ddaff5803b29484345380", "apps/docs/src/content/docs/concepts/data-normalization.md": "f1505ec3eb35a33fac289706c2cc318a2ffb80555e3302ca102e67e21c9012da", - "apps/docs/src/content/docs/concepts/http-utility-tree.md": "ca4acbe1af20538c0d08a3e03969efe1966e7822ce6dfce8d0c1efb6913795cf", - "apps/docs/src/content/docs/concepts/queries-and-mutations.md": "a7f95a5c002e87261fa988fbbfe3c936e3601aea8c5b9c1878b8b60bc17da696", + "apps/docs/src/content/docs/concepts/http-utility-tree.md": "ed03938167f31822b76efbeac381fd3f8d297061fe567928ee022f8dbfadb9b4", + "apps/docs/src/content/docs/concepts/queries-and-mutations.md": "f62769112999ebe71a75aa3c22e73380e0f11d8c1f64c3125e2e5e8dba11997e", "apps/docs/src/content/docs/concepts/rpc-utility-tree.md": "fd5fcc232279e9577b8b2faf321a85e9a340e6090ea5887bbab208cca2d24a4e", - "apps/docs/src/content/docs/concepts/semantic-keys.md": "eaf6d79c40af97da698bce206705633cdc3bf202921b745a82700214c2c4d48c", - "apps/docs/src/content/docs/examples.md": "7f22a2309b148934546fec37991fb81fe733ac7b8a3b843e8001a1d2ec1d8606", - "apps/docs/src/content/docs/getting-started/compatibility-and-stability.md": "b526e3f718863655caa6f98018aefdb82103464f9bd4a5730f7c75fa4d502f3e", - "apps/docs/src/content/docs/getting-started/feature-support.md": "01c46393cfdda15574c57f97081ccfd0b690457a7a247abc61444af408002f64", - "apps/docs/src/content/docs/getting-started/http-quick-start.md": "eaf62e213ba22d1926dd75d7bad7bb9e6e69419309f0fcb1e6a7c0efe4d85c8b", - "apps/docs/src/content/docs/getting-started/installation.md": "a334554486477e2c6ecd63c325b90fa9738c57877a3a23ec22a64c406c3f21ab", - "apps/docs/src/content/docs/getting-started/quick-start.md": "52689fd2313064eb8660ec4152cba36fb46dbaba7f1039d43ed02b12a5f9ae2e", - "apps/docs/src/content/docs/guides/angular-query.md": "14534aec24fab7549906456a35b8a12a47e85c924e04aaefda15c366c4a435f9", - "apps/docs/src/content/docs/guides/cache-filters.md": "46d06ee7e24f2ec143d0bb174442a7f5bc5299657d4bcdfb28a36782a69b3de6", - "apps/docs/src/content/docs/guides/cache-management.md": "18f9ef5b5c51365a629601d19d2d2307c243c3fa4e2dd4f53bed3974eae802e5", - "apps/docs/src/content/docs/guides/cancellation.md": "b0a79fb1578777115619d65f075f407595de0634ef0c52c4fc77256f0dca833c", + "apps/docs/src/content/docs/concepts/semantic-keys.md": "ae21f39db492acdad4f576c6942408b628124da775e40fdde543a4c8341de5ec", + "apps/docs/src/content/docs/examples.md": "217a98fef80d350becc9fb7472eb120af9ce528e091fa13840bc9531930e5d53", + "apps/docs/src/content/docs/getting-started/compatibility-and-stability.md": "74f9d59c908fe6ab70ccfb8940c1f976e060b38a05cc6bbe75f72403d3b3ed7b", + "apps/docs/src/content/docs/getting-started/feature-support.md": "2f22499798336f7e1958e86bce92fb497445befd909d52b5bcfd5557f23cf2a0", + "apps/docs/src/content/docs/getting-started/http-quick-start.md": "f73cc87d44999e85b942b3ef284bdd2d0cf5bbd84cb4e7e3f14e65da185ff81d", + "apps/docs/src/content/docs/getting-started/installation.md": "0e7d6e608fa0ea049446fd3784350ba635503c0010c12342bc5774b6e7105152", + "apps/docs/src/content/docs/getting-started/quick-start.md": "43e8837b84df91af5dfe1b989c6f2cd94557ffb038fd1c0692a5bc7342716b3c", + "apps/docs/src/content/docs/guides/angular-query.md": "fcce47f32059d4164ae3a0b5fb1cf35922dd2bd5bb5b1dfa38a139339483c539", + "apps/docs/src/content/docs/guides/cache-filters.md": "6c892eea9e50d2b7f046efe2329e519f8585eeac84a9f5d84773f7aceb904b26", + "apps/docs/src/content/docs/guides/cache-management.md": "72a1943ca0c4171a5d10b5b1667bfbec8b8f07922a1114b40b3457fbdc5c708e", + "apps/docs/src/content/docs/guides/cancellation.md": "eb846576e04d8e8fb3030352a8613664773f4140047e6da0ccd002fcb78a7a2e", "apps/docs/src/content/docs/guides/choose-stream-history.md": "6996df3a8b18c4fadbcc76a3635a8a5408f155b4068509d4801b38afa7f22f16", - "apps/docs/src/content/docs/guides/conditional-queries.md": "37cd6436daf90c0c060dcfcf4b9d7f0b165ee95858bb30cff9f38b45e132e029", - "apps/docs/src/content/docs/guides/custom-key-encoders.md": "ffa8b915f0831e2081a5bd50c115977e444ee6319c624123e296e3455d754230", - "apps/docs/src/content/docs/guides/handle-failures.md": "6b4e7bed61daba9c29d988c9795e570efc5a4f2783503d696a419cfcf9737a91", - "apps/docs/src/content/docs/guides/http-queries-and-mutations.md": "6e4377bfbc2300f7cc195ce095125a5d9051c1f09c7b20cfc09a7251852d1056", - "apps/docs/src/content/docs/guides/hydrate-query-views.md": "085a7ba81468cd5cca835d6957e03797d1e66fd410bf8992accf40973ef15a66", - "apps/docs/src/content/docs/guides/hydrate-unary-data.md": "a4e34e994b6c606ac918726e0c02ad20c9f1a668f483042979b1798400b35e18", + "apps/docs/src/content/docs/guides/conditional-queries.md": "52ae3d0b5e41c7001254f4dc5ee6dd2244d003832d51d307d02397c6b60b8bac", + "apps/docs/src/content/docs/guides/custom-key-encoders.md": "9a819f6c490234e46458d407e140fb3dc36cb7c67691a8574e8c2f6024c9c046", + "apps/docs/src/content/docs/guides/handle-failures.md": "f9d8e9abcf8a29a6d57bb8b2140b8faf02e8550490405eab75a3c2721186f5fe", + "apps/docs/src/content/docs/guides/http-queries-and-mutations.md": "928eb6def0170167e572ebd0e536bfc5cffaf6921c497da6367a6ecfec0841f7", + "apps/docs/src/content/docs/guides/hydrate-query-views.md": "d71f573e4c6e6815e51f7a63080b773655f90cf9ef07155d293f1550eac091d4", + "apps/docs/src/content/docs/guides/hydrate-unary-data.md": "0cf27b204e709f8a753fd61fd1510073404611e53a9fd6e82e8122af7e68f2fa", "apps/docs/src/content/docs/guides/optimistic-writes.md": "b76e2336f6958e7482786312a15bb9c25a1608c659018c5b4d3162301e767bef", - "apps/docs/src/content/docs/guides/query-defaults.md": "98353365fc09fab7d2424cdc7d2870a52735710a05778aa6c693697bc16c288c", - "apps/docs/src/content/docs/guides/react-query.md": "29a200b31d19e0339805a0ae3e50be403300e1b8bb7b870949cf779853fbb347", - "apps/docs/src/content/docs/guides/refresh-from-events.md": "a51ef3777eb9ea07f561d2223c6d8ffa063c87ecda25cb74ce0aea4b037fa2fe", - "apps/docs/src/content/docs/guides/retry-queries.md": "f725f54696712cc917089e18e905022c6ab097e44f8d22f499ab87fb30af9a99", - "apps/docs/src/content/docs/guides/solid-query.md": "e01a800c1a4b8dbfe2d5dfaef5a53e2a12e92e1db42fdd926422bb6fcd4eb780", - "apps/docs/src/content/docs/guides/stream-snapshots.md": "62e343dc7fa2f4b1b6fb1b699ecb947bcdfc0312c9ffab584b0b8bfaf954f854", - "apps/docs/src/content/docs/guides/svelte-and-angular.md": null, - "apps/docs/src/content/docs/guides/svelte-query.md": "ef3dd4cb2b88759223e77182446b41a53ccf6f4f471e8748f53c93aeafa880e2", - "apps/docs/src/content/docs/guides/switch-cache-owners.md": "08e883a245679cf05d4cb65c93e5b9f1887022a972b98ab295aa248880ab9b7f", - "apps/docs/src/content/docs/guides/tanstack-start.md": "28a196e44e1b6b49131b05e370cb029670599fefe6aad6ff54ed9d3d3f07ddac", - "apps/docs/src/content/docs/guides/vue-and-solid.md": null, - "apps/docs/src/content/docs/guides/vue-query.md": "b115c710cdd520cbf016fd19c48af2c849fe7e20344a2e900b78f0a0e5c5bd12", - "apps/docs/src/content/docs/guides/websocket-clients.md": "9ec635105fd84c30dc52833ed3adcf80a46313deb0112d4e72cd1a5603c6170c", - "apps/docs/src/content/docs/index.mdx": "232b5a9f64169463d7973c6801b3f3c326328870715fcb45e9b0a25ee085e197", + "apps/docs/src/content/docs/guides/query-defaults.md": "212138d564907a75c46a1b8261f5964c63b6dc8a9cbbd74e68e9f5d41b9b5248", + "apps/docs/src/content/docs/guides/react-query.md": "efa6c38843bbc5ebd827244628e7d2ddaa0f3d67924c99d8ff2a8dcb4b91aa7e", + "apps/docs/src/content/docs/guides/refresh-from-events.md": "a8e680ae9060a573336829b94dbf77dd32385671d48cd4aa0f092a1374444cd6", + "apps/docs/src/content/docs/guides/retry-queries.md": "23fe305768b5779d87a21af4f56c49ef2f03fcc7654ec6ffaf9db46628e3cc11", + "apps/docs/src/content/docs/guides/solid-query.md": "91151a68b0205da685a8e545fdc386cffa1c3ac76b6de9be73b19bfb470c19bc", + "apps/docs/src/content/docs/guides/stream-snapshots.md": "91e22e85c11d8822e25e5abb7b872b78f07af79fdb16eb2690baf8226fdbd9f7", + "apps/docs/src/content/docs/guides/svelte-query.md": "aeb8ba0254920677a3c0a0667d500a1186eb368ae56f16ff848f6edcae7903ac", + "apps/docs/src/content/docs/guides/switch-cache-owners.md": "5475234db1e23a3542f9caf005c767cbd3d36f8ba079bbb75c06890173aad180", + "apps/docs/src/content/docs/guides/tanstack-start.md": "6fae751389e9b5dfb843235059aea1a2fc885d62f797556de78a9ea77697ddcb", + "apps/docs/src/content/docs/guides/vue-query.md": "aff0843b1f4fb76563695e9a1d6050fad202007c4be994b74aff774ab2e038d0", + "apps/docs/src/content/docs/guides/websocket-clients.md": "1cd31655e433ced7b24795255cb03280b27734eda58afeb9730a42bfad433c43", + "apps/docs/src/content/docs/index.mdx": "a141fa2f98d38ea8042ad596c07369b81c9a27935805c17f552db0bc9dd22f6f", "apps/docs/src/content/docs/reference/browser-and-worker-hosts.md": "70743a4429a39937515c8d5fee79ca75714a92dd526f1093f9fa8ea12fbde984", - "apps/docs/src/content/docs/reference/compatibility-and-limits.md": "1499a387d91e364fcfe44bd82f9e1589f9fab2a93fc081f9172a6e8da703cb25", + "apps/docs/src/content/docs/reference/compatibility-and-limits.md": "f19fe01d6ab2fc2065827c23ceea2720bb136f28a35c3ba3759507224bb8b6c9", "apps/docs/src/content/docs/reference/errors.md": "4454725375e05ea7a7d976d798c1c1d0c2731eefc79043f5e40dba88154069b1", - "apps/docs/src/content/docs/reference/external-http-clients.md": "613f0e9493221416993e0c8012a97abdb3de2f80321aa7e4b0cda0f585618f91", - "apps/docs/src/content/docs/reference/factory.md": "b099ed80d1ad8aab6a59213ee286290302bcf7885569ffb693fed784ca872dd3", - "apps/docs/src/content/docs/reference/generated-builders.md": "b0f990437f967752f971ab0b7dc183119d619ee0159387079114fb83bea3ed21", - "apps/docs/src/content/docs/reference/http-factory.md": "b4e24e6d63e4ed2088920dda8d142fbcae1bdda7ae23e43cfad398f8c95d9f0e", + "apps/docs/src/content/docs/reference/external-http-clients.md": "4ed288ccf29f3bcdf2e3773172282eefe0ef0f84ed0fd1bc347e5cd388743aba", + "apps/docs/src/content/docs/reference/factory.md": "7d972a9ff279ce80164aba793cbeb5a3e1621e6e16be97392b6faa7493de339c", + "apps/docs/src/content/docs/reference/generated-builders.md": "4a6403a4d5f62eb222cecb71f6cfaee631854ac706c702b19388467e313dc4a4", + "apps/docs/src/content/docs/reference/http-factory.md": "ee3594ab263e20bfdda6896680760d72ca410942c24b4a92060a1bf23e263eba", "apps/docs/src/content/docs/reference/performance.md": "2f68c31cae28de0a84b1ce77f065481d37899171b78d9593ff65e017ff75d97a", "apps/docs/src/content/docs/reference/public-exports.md": "500d864720857c9098b325d6018b20b5ad6c1e666ef326921721ffdf6435dcb2", "docs/adr/0001-target-effect-4-release-candidate.md": "44119ba9104f767c5804c5026c4a21a78a8367f6d9ff5a9035bb3f423969fbb3", @@ -119,7 +117,7 @@ "pnpm-workspace.yaml": "58fd69fb2ebc237bf589893fd3150ec0dd25988aee57f465f5b373b038aee606", "scripts/decode-utf8.mts": "2e90c53e95c22b864a209a87c98084db6ec95e83f842007b21528d286d45c18c", "scripts/measure-construction.mts": "10522cb10ab51fd44d8990896a2a73e8bf4d4cd2646441e958b6180cb1afd147", - "scripts/verify-docs-examples.mts": "9e379c826c5d7adafb1d6b59b526cbd59d3fd76b32f417da0ce2cd1bb40eb01c", + "scripts/verify-docs-examples.mts": "b20cdbf909f68c177125ddbc69e86d4244571ac5faebe505fa43cdf2faa34d91", "scripts/verify-packed-consumer.mts": null, "skills/effect-api-query/SKILL.md": "1939a0f7536f3e88e1e1106e8125019c11d6fdd4ada8ae4d010507f264b234e5", "skills/effect-api-query/references/cache-workflows.md": "27bb96c48c84254ab8c379a529402f81cea3723c17d6396527c090a8c37e6f65", @@ -197,68 +195,68 @@ "tests/types/tanstack-start-contract.ts": "51681e3d0d7504a614d490f18adb6f44db82d34b9d4d7cb9cf941aea33de284f", "tests/types/type-scale.ts": "a9b307c25c72f3c184eabc8e22ddaeea96feb331c0f8d748773d765d358dbfca" }, - "head": "a0a363afa19552badd7c40795ac7a4735595dddb", - "outcome": "updated", - "reason": "The human framework documentation is now four native Query guides rather than two paired pages. Update only the existing frameworks-and-hosts source footer to the four live routes. The single shipped skill, its six reference boundaries, activation, recommended integrations and public behavior remain unchanged.", + "head": "75a6b099bdaf8a01dd343ca88aec3afbd7a54338", + "outcome": "no-change", + "reason": "Human documentation now presents placed source excerpts and links to complete retained recipes. Public behavior, native builder use, safe identity, Cause handling, captured inputs, framework limits, view codecs and owner cleanup are unchanged. The existing skill and all six references already preserve those contracts and point to the four current framework guides; no agent guidance edit is warranted.", "evidence": [ - "Author base a0a363afa19552badd7c40795ac7a4735595dddb. Read skills/effect-api-query/SKILL.md and references/frameworks-and-hosts.md; footer now links Vue, Solid, Svelte and Angular Query guides. README, landing page, compatibility links and capability rows use the same four routes.", - "All four moved recipe fences match the prior paired guides byte for byte. Scoped format passed on 10 affected files. Uncached vp run docs-build passed retained 8-page snippet parity, Astro diagnostics (0 errors/warnings/hints), 46-page production build, search index and internal link validation; four built routes and full/small LLM links exist with old routes absent.", - "vp run skills-check passed native validation of 1 skill file. No recommended behavior or activation changed; no fresh framework compilation, consumer API task or independent consumer session is claimed for this editorial split. Evidence: /tmp/effect-api-query-implementation/notes/framework-guide-split-content-check.json, framework-guide-split-artifact-check.json and framework-guide-split-docs-build.log." + "Author baseline 75a6b099bdaf8a01dd343ca88aec3afbd7a54338; 38 human documentation files and scripts/verify-docs-examples.mts reviewed.", + "All retained src, tests, examples and dependency inputs are byte unchanged against 75a6b099. Existing eight doc/fixture mappings and both import substitutions remain; each shown TypeScript fence must match a contiguous source excerpt modulo common indentation.", + "Fresh vp run --no-cache docs-build passed excerpt parity, Astro zero diagnostics, 46 built pages and all internal links (0/3 cache hits); all 48 repository source links resolve.", + "Fresh vp run --no-cache api-types passed TypeScript 7.0.2 and 5.9.3 for the nine docs type recipes; vp run --no-cache typecheck passed 140 root files including the two shared WebSocket recipes (0/2 cache hits).", + "vp run skills-check validated the one unchanged authoritative skill; no fresh independent consumer or framework compiler certification is claimed for this editorial update." ] }, "planning:_artifacts": { - "fingerprint": "37ccd5a30b43c8dde4bab5e3cbf13616b38ab37280a50975dafc0b7ddec9976c", + "fingerprint": "7a40392eef28658c865d9f895a34633d520f21dc0d9cb499f71a0f8d8f256f7e", "snapshot": { "GLOSSARY.md": "67de06543ad2fd9fd2917419e19d1f071e7b6eee53d48fca05e9dddfc739c683", - "README.md": "324f4e55b7b41232cc25b9b8780f49b136b25a0ee7e6650dea6ca371ff46669f", + "README.md": "7af677dd785f95bf08b4a9dbafd10f1b0a2cd9ce40d42edf359ede1e59de6fd3", "_artifacts/domain_map.yaml": "47f3dda6368ce94f3dd14f6e49b7c1e26f54091e582a339aebb656bd3361664c", "_artifacts/skill_spec.md": "3d590a87ae382859f39d028319e46e7dbccc16e69315e6a0b31cd4c31ec1cefd", "_artifacts/skill_tree.yaml": "da82bd81b41250e02e263147afd7a12ce5379f86cf8b6a9e09895d1227df84fc", - "apps/docs/src/content/docs/concepts/client-lifecycle.md": "4a7d412f6e3c7dfbf0442b51ee9054c2b854026bcf968961cee65071f7cbb424", + "apps/docs/src/content/docs/concepts/client-lifecycle.md": "fcbaca84f266060b946eed9cbf9d0b9815352b7b1d6ddaff5803b29484345380", "apps/docs/src/content/docs/concepts/data-normalization.md": "f1505ec3eb35a33fac289706c2cc318a2ffb80555e3302ca102e67e21c9012da", - "apps/docs/src/content/docs/concepts/http-utility-tree.md": "ca4acbe1af20538c0d08a3e03969efe1966e7822ce6dfce8d0c1efb6913795cf", - "apps/docs/src/content/docs/concepts/queries-and-mutations.md": "a7f95a5c002e87261fa988fbbfe3c936e3601aea8c5b9c1878b8b60bc17da696", + "apps/docs/src/content/docs/concepts/http-utility-tree.md": "ed03938167f31822b76efbeac381fd3f8d297061fe567928ee022f8dbfadb9b4", + "apps/docs/src/content/docs/concepts/queries-and-mutations.md": "f62769112999ebe71a75aa3c22e73380e0f11d8c1f64c3125e2e5e8dba11997e", "apps/docs/src/content/docs/concepts/rpc-utility-tree.md": "fd5fcc232279e9577b8b2faf321a85e9a340e6090ea5887bbab208cca2d24a4e", - "apps/docs/src/content/docs/concepts/semantic-keys.md": "eaf6d79c40af97da698bce206705633cdc3bf202921b745a82700214c2c4d48c", - "apps/docs/src/content/docs/examples.md": "7f22a2309b148934546fec37991fb81fe733ac7b8a3b843e8001a1d2ec1d8606", - "apps/docs/src/content/docs/getting-started/compatibility-and-stability.md": "b526e3f718863655caa6f98018aefdb82103464f9bd4a5730f7c75fa4d502f3e", - "apps/docs/src/content/docs/getting-started/feature-support.md": "01c46393cfdda15574c57f97081ccfd0b690457a7a247abc61444af408002f64", - "apps/docs/src/content/docs/getting-started/http-quick-start.md": "eaf62e213ba22d1926dd75d7bad7bb9e6e69419309f0fcb1e6a7c0efe4d85c8b", - "apps/docs/src/content/docs/getting-started/installation.md": "a334554486477e2c6ecd63c325b90fa9738c57877a3a23ec22a64c406c3f21ab", - "apps/docs/src/content/docs/getting-started/quick-start.md": "52689fd2313064eb8660ec4152cba36fb46dbaba7f1039d43ed02b12a5f9ae2e", - "apps/docs/src/content/docs/guides/angular-query.md": "14534aec24fab7549906456a35b8a12a47e85c924e04aaefda15c366c4a435f9", - "apps/docs/src/content/docs/guides/cache-filters.md": "46d06ee7e24f2ec143d0bb174442a7f5bc5299657d4bcdfb28a36782a69b3de6", - "apps/docs/src/content/docs/guides/cache-management.md": "18f9ef5b5c51365a629601d19d2d2307c243c3fa4e2dd4f53bed3974eae802e5", - "apps/docs/src/content/docs/guides/cancellation.md": "b0a79fb1578777115619d65f075f407595de0634ef0c52c4fc77256f0dca833c", + "apps/docs/src/content/docs/concepts/semantic-keys.md": "ae21f39db492acdad4f576c6942408b628124da775e40fdde543a4c8341de5ec", + "apps/docs/src/content/docs/examples.md": "217a98fef80d350becc9fb7472eb120af9ce528e091fa13840bc9531930e5d53", + "apps/docs/src/content/docs/getting-started/compatibility-and-stability.md": "74f9d59c908fe6ab70ccfb8940c1f976e060b38a05cc6bbe75f72403d3b3ed7b", + "apps/docs/src/content/docs/getting-started/feature-support.md": "2f22499798336f7e1958e86bce92fb497445befd909d52b5bcfd5557f23cf2a0", + "apps/docs/src/content/docs/getting-started/http-quick-start.md": "f73cc87d44999e85b942b3ef284bdd2d0cf5bbd84cb4e7e3f14e65da185ff81d", + "apps/docs/src/content/docs/getting-started/installation.md": "0e7d6e608fa0ea049446fd3784350ba635503c0010c12342bc5774b6e7105152", + "apps/docs/src/content/docs/getting-started/quick-start.md": "43e8837b84df91af5dfe1b989c6f2cd94557ffb038fd1c0692a5bc7342716b3c", + "apps/docs/src/content/docs/guides/angular-query.md": "fcce47f32059d4164ae3a0b5fb1cf35922dd2bd5bb5b1dfa38a139339483c539", + "apps/docs/src/content/docs/guides/cache-filters.md": "6c892eea9e50d2b7f046efe2329e519f8585eeac84a9f5d84773f7aceb904b26", + "apps/docs/src/content/docs/guides/cache-management.md": "72a1943ca0c4171a5d10b5b1667bfbec8b8f07922a1114b40b3457fbdc5c708e", + "apps/docs/src/content/docs/guides/cancellation.md": "eb846576e04d8e8fb3030352a8613664773f4140047e6da0ccd002fcb78a7a2e", "apps/docs/src/content/docs/guides/choose-stream-history.md": "6996df3a8b18c4fadbcc76a3635a8a5408f155b4068509d4801b38afa7f22f16", - "apps/docs/src/content/docs/guides/conditional-queries.md": "37cd6436daf90c0c060dcfcf4b9d7f0b165ee95858bb30cff9f38b45e132e029", - "apps/docs/src/content/docs/guides/custom-key-encoders.md": "ffa8b915f0831e2081a5bd50c115977e444ee6319c624123e296e3455d754230", - "apps/docs/src/content/docs/guides/handle-failures.md": "6b4e7bed61daba9c29d988c9795e570efc5a4f2783503d696a419cfcf9737a91", - "apps/docs/src/content/docs/guides/http-queries-and-mutations.md": "6e4377bfbc2300f7cc195ce095125a5d9051c1f09c7b20cfc09a7251852d1056", - "apps/docs/src/content/docs/guides/hydrate-query-views.md": "085a7ba81468cd5cca835d6957e03797d1e66fd410bf8992accf40973ef15a66", - "apps/docs/src/content/docs/guides/hydrate-unary-data.md": "a4e34e994b6c606ac918726e0c02ad20c9f1a668f483042979b1798400b35e18", + "apps/docs/src/content/docs/guides/conditional-queries.md": "52ae3d0b5e41c7001254f4dc5ee6dd2244d003832d51d307d02397c6b60b8bac", + "apps/docs/src/content/docs/guides/custom-key-encoders.md": "9a819f6c490234e46458d407e140fb3dc36cb7c67691a8574e8c2f6024c9c046", + "apps/docs/src/content/docs/guides/handle-failures.md": "f9d8e9abcf8a29a6d57bb8b2140b8faf02e8550490405eab75a3c2721186f5fe", + "apps/docs/src/content/docs/guides/http-queries-and-mutations.md": "928eb6def0170167e572ebd0e536bfc5cffaf6921c497da6367a6ecfec0841f7", + "apps/docs/src/content/docs/guides/hydrate-query-views.md": "d71f573e4c6e6815e51f7a63080b773655f90cf9ef07155d293f1550eac091d4", + "apps/docs/src/content/docs/guides/hydrate-unary-data.md": "0cf27b204e709f8a753fd61fd1510073404611e53a9fd6e82e8122af7e68f2fa", "apps/docs/src/content/docs/guides/optimistic-writes.md": "b76e2336f6958e7482786312a15bb9c25a1608c659018c5b4d3162301e767bef", - "apps/docs/src/content/docs/guides/query-defaults.md": "98353365fc09fab7d2424cdc7d2870a52735710a05778aa6c693697bc16c288c", - "apps/docs/src/content/docs/guides/react-query.md": "29a200b31d19e0339805a0ae3e50be403300e1b8bb7b870949cf779853fbb347", - "apps/docs/src/content/docs/guides/refresh-from-events.md": "a51ef3777eb9ea07f561d2223c6d8ffa063c87ecda25cb74ce0aea4b037fa2fe", - "apps/docs/src/content/docs/guides/retry-queries.md": "f725f54696712cc917089e18e905022c6ab097e44f8d22f499ab87fb30af9a99", - "apps/docs/src/content/docs/guides/solid-query.md": "e01a800c1a4b8dbfe2d5dfaef5a53e2a12e92e1db42fdd926422bb6fcd4eb780", - "apps/docs/src/content/docs/guides/stream-snapshots.md": "62e343dc7fa2f4b1b6fb1b699ecb947bcdfc0312c9ffab584b0b8bfaf954f854", - "apps/docs/src/content/docs/guides/svelte-and-angular.md": null, - "apps/docs/src/content/docs/guides/svelte-query.md": "ef3dd4cb2b88759223e77182446b41a53ccf6f4f471e8748f53c93aeafa880e2", - "apps/docs/src/content/docs/guides/switch-cache-owners.md": "08e883a245679cf05d4cb65c93e5b9f1887022a972b98ab295aa248880ab9b7f", - "apps/docs/src/content/docs/guides/tanstack-start.md": "28a196e44e1b6b49131b05e370cb029670599fefe6aad6ff54ed9d3d3f07ddac", - "apps/docs/src/content/docs/guides/vue-and-solid.md": null, - "apps/docs/src/content/docs/guides/vue-query.md": "b115c710cdd520cbf016fd19c48af2c849fe7e20344a2e900b78f0a0e5c5bd12", - "apps/docs/src/content/docs/guides/websocket-clients.md": "9ec635105fd84c30dc52833ed3adcf80a46313deb0112d4e72cd1a5603c6170c", - "apps/docs/src/content/docs/index.mdx": "232b5a9f64169463d7973c6801b3f3c326328870715fcb45e9b0a25ee085e197", + "apps/docs/src/content/docs/guides/query-defaults.md": "212138d564907a75c46a1b8261f5964c63b6dc8a9cbbd74e68e9f5d41b9b5248", + "apps/docs/src/content/docs/guides/react-query.md": "efa6c38843bbc5ebd827244628e7d2ddaa0f3d67924c99d8ff2a8dcb4b91aa7e", + "apps/docs/src/content/docs/guides/refresh-from-events.md": "a8e680ae9060a573336829b94dbf77dd32385671d48cd4aa0f092a1374444cd6", + "apps/docs/src/content/docs/guides/retry-queries.md": "23fe305768b5779d87a21af4f56c49ef2f03fcc7654ec6ffaf9db46628e3cc11", + "apps/docs/src/content/docs/guides/solid-query.md": "91151a68b0205da685a8e545fdc386cffa1c3ac76b6de9be73b19bfb470c19bc", + "apps/docs/src/content/docs/guides/stream-snapshots.md": "91e22e85c11d8822e25e5abb7b872b78f07af79fdb16eb2690baf8226fdbd9f7", + "apps/docs/src/content/docs/guides/svelte-query.md": "aeb8ba0254920677a3c0a0667d500a1186eb368ae56f16ff848f6edcae7903ac", + "apps/docs/src/content/docs/guides/switch-cache-owners.md": "5475234db1e23a3542f9caf005c767cbd3d36f8ba079bbb75c06890173aad180", + "apps/docs/src/content/docs/guides/tanstack-start.md": "6fae751389e9b5dfb843235059aea1a2fc885d62f797556de78a9ea77697ddcb", + "apps/docs/src/content/docs/guides/vue-query.md": "aff0843b1f4fb76563695e9a1d6050fad202007c4be994b74aff774ab2e038d0", + "apps/docs/src/content/docs/guides/websocket-clients.md": "1cd31655e433ced7b24795255cb03280b27734eda58afeb9730a42bfad433c43", + "apps/docs/src/content/docs/index.mdx": "a141fa2f98d38ea8042ad596c07369b81c9a27935805c17f552db0bc9dd22f6f", "apps/docs/src/content/docs/reference/browser-and-worker-hosts.md": "70743a4429a39937515c8d5fee79ca75714a92dd526f1093f9fa8ea12fbde984", - "apps/docs/src/content/docs/reference/compatibility-and-limits.md": "1499a387d91e364fcfe44bd82f9e1589f9fab2a93fc081f9172a6e8da703cb25", + "apps/docs/src/content/docs/reference/compatibility-and-limits.md": "f19fe01d6ab2fc2065827c23ceea2720bb136f28a35c3ba3759507224bb8b6c9", "apps/docs/src/content/docs/reference/errors.md": "4454725375e05ea7a7d976d798c1c1d0c2731eefc79043f5e40dba88154069b1", - "apps/docs/src/content/docs/reference/external-http-clients.md": "613f0e9493221416993e0c8012a97abdb3de2f80321aa7e4b0cda0f585618f91", - "apps/docs/src/content/docs/reference/factory.md": "b099ed80d1ad8aab6a59213ee286290302bcf7885569ffb693fed784ca872dd3", - "apps/docs/src/content/docs/reference/generated-builders.md": "b0f990437f967752f971ab0b7dc183119d619ee0159387079114fb83bea3ed21", - "apps/docs/src/content/docs/reference/http-factory.md": "b4e24e6d63e4ed2088920dda8d142fbcae1bdda7ae23e43cfad398f8c95d9f0e", + "apps/docs/src/content/docs/reference/external-http-clients.md": "4ed288ccf29f3bcdf2e3773172282eefe0ef0f84ed0fd1bc347e5cd388743aba", + "apps/docs/src/content/docs/reference/factory.md": "7d972a9ff279ce80164aba793cbeb5a3e1621e6e16be97392b6faa7493de339c", + "apps/docs/src/content/docs/reference/generated-builders.md": "4a6403a4d5f62eb222cecb71f6cfaee631854ac706c702b19388467e313dc4a4", + "apps/docs/src/content/docs/reference/http-factory.md": "ee3594ab263e20bfdda6896680760d72ca410942c24b4a92060a1bf23e263eba", "apps/docs/src/content/docs/reference/performance.md": "2f68c31cae28de0a84b1ce77f065481d37899171b78d9593ff65e017ff75d97a", "apps/docs/src/content/docs/reference/public-exports.md": "500d864720857c9098b325d6018b20b5ad6c1e666ef326921721ffdf6435dcb2", "docs/adr/0001-target-effect-4-release-candidate.md": "44119ba9104f767c5804c5026c4a21a78a8367f6d9ff5a9035bb3f423969fbb3", @@ -326,7 +324,7 @@ "pnpm-workspace.yaml": "58fd69fb2ebc237bf589893fd3150ec0dd25988aee57f465f5b373b038aee606", "scripts/decode-utf8.mts": "2e90c53e95c22b864a209a87c98084db6ec95e83f842007b21528d286d45c18c", "scripts/measure-construction.mts": "10522cb10ab51fd44d8990896a2a73e8bf4d4cd2646441e958b6180cb1afd147", - "scripts/verify-docs-examples.mts": "9e379c826c5d7adafb1d6b59b526cbd59d3fd76b32f417da0ce2cd1bb40eb01c", + "scripts/verify-docs-examples.mts": "b20cdbf909f68c177125ddbc69e86d4244571ac5faebe505fa43cdf2faa34d91", "scripts/verify-packed-consumer.mts": null, "skills/effect-api-query/SKILL.md": "1939a0f7536f3e88e1e1106e8125019c11d6fdd4ada8ae4d010507f264b234e5", "skills/effect-api-query/references/cache-workflows.md": "27bb96c48c84254ab8c379a529402f81cea3723c17d6396527c090a8c37e6f65", @@ -404,13 +402,14 @@ "tests/types/tanstack-start-contract.ts": "51681e3d0d7504a614d490f18adb6f44db82d34b9d4d7cb9cf941aea33de284f", "tests/types/type-scale.ts": "a9b307c25c72f3c184eabc8e22ddaeea96feb331c0f8d748773d765d358dbfca" }, - "head": "a0a363afa19552badd7c40795ac7a4735595dddb", - "outcome": "updated", - "reason": "Update the reactive-options failure source in domain_map.yaml to solid-query.md, which contains the existing Solid accessor correction. The ten task domains, one-skill/six-reference coverage and source globs remain accurate. skill_tree.yaml and skill_spec.md retain their valid inventory, prior decisions and batch history; this route change needs no new task, skill, taxonomy or artificial history entry.", + "head": "75a6b099bdaf8a01dd343ca88aec3afbd7a54338", + "outcome": "no-change", + "reason": "All three planning records remain accurate: the same ten developer tasks are covered by one skill and six references; source globs cover every edited human page and the verifier. Reactive-option failure still points to solid-query.md, metadata fresh-hit failure still points to query-defaults.md, and all active guidance links resolve. This is an editorial presentation change with no new task, seam, dependency, distribution or behavioral decision, so no artificial history entry or planning rewrite is needed.", "evidence": [ - "Read _artifacts/domain_map.yaml, _artifacts/skill_spec.md and _artifacts/skill_tree.yaml against the guide diff at a0a363afa19552badd7c40795ac7a4735595dddb. Map source now points to existing guides/solid-query.md and its accessor recipe; the unchanged **/apps/docs/src/content/docs/**/*.md glob covers all four new pages.", - "Both planning YAML files parsed with the installed Intent YAML parser and contain the existing library/skill entry. Native maintainer sync reports nothing to synchronize. Scoped formatting, 46-page docs build/internal links, verbatim recipe comparison and native skills-check passed; the existing skill activation and six reference files remain authoritative.", - "No runtime/type/API/dependency, ownership or support-matrix change. No new task-quality or discovery claim; planning guidance preserves existing scope and publication remains separately pending. This outcome covers all three shared planning documents and their current skill/source snapshot." + "Read _artifacts/domain_map.yaml, _artifacts/skill_spec.md and _artifacts/skill_tree.yaml completely; compared coverage/failure sources and existing guidance to the final source diff.", + "Native Intent 0.5.3 sync reports nothing to synchronize; actual review lists only this planning record and skills/effect-api-query/SKILL.md, with no source problem or unmapped source item.", + "Fresh docs build/source link checks, both API compilers, normal root typed check and structural skills-check passed; the original full programme base remains 5b91ddb1de13970365669c887b9d767ea417fbd4.", + "Prior coverage and maintainer-alignment history, distribution.mode:none, all source mappings and the 58 other stored review outcomes are preserved." ] }, "source:vite.config.ts": { diff --git a/README.md b/README.md index 76e0cc6..0b20f8d 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Before upgrading, review [compatibility and stability](https://ueberbrot.github. ## Query an RPC -Pass your RPC group and ready flat client to the factory: +In the application client module, use your existing RPC group, ready flat client and runner: ```ts import { createRpcQueryUtils } from 'effect-api-query' @@ -27,7 +27,11 @@ const rpc = createRpcQueryUtils(rpcGroup, { keyPrefix: ['my-app'], runPromiseExit, }) +``` + +In a loader or event handler, use the application's QueryClient: +```ts const options = rpc.users.get.queryOptions({ input: { id: 1 } }) const user = await queryClient.query(options) ``` @@ -37,7 +41,7 @@ for setup and cleanup. ## Query an HttpApi -Pass your HttpApi declaration and ready HTTP API client to the factory: +In the application client module, use your existing HttpApi, ready client and runner: ```ts import { createHttpApiQueryUtils } from 'effect-api-query' @@ -47,7 +51,11 @@ const http = createHttpApiQueryUtils(api, { keyPrefix: ['my-app'], runPromiseExit, }) +``` +In a loader or event handler: + +```ts const options = http.users.get.queryOptions({ input: { params: { id: 1 } } }) const user = await queryClient.query(options) ``` @@ -56,10 +64,6 @@ HTTP accepts decoded request parts; RPC accepts payload constructor input. Follo [HTTP quick start](https://ueberbrot.github.io/effect-api-query/getting-started/http-quick-start/) for a complete declaration, client, query, and mutation. -Use `metadataOptions` for response status and headers; follow the -[ETag recipe](https://ueberbrot.github.io/effect-api-query/guides/http-queries-and-mutations/#update-with-an-etag) -for conditional writes. - ## Choose an operation | API definition | Available operations | @@ -74,24 +78,16 @@ Both factories generate keys for cache reads, writes, prefetching, and invalidat forward cancellation to Effect. Mutations use TanStack's normal callbacks; invalidate affected queries in your application. -For uploads, build `FormData` explicitly and pass it as mutation `payload`. See the -[HTTP upload guide](https://ueberbrot.github.io/effect-api-query/guides/http-queries-and-mutations/#upload-a-file). -Mixed HTTP success modes, raw byte streams, and streaming multipart requests remain omitted. See -the [SSE guide](https://ueberbrot.github.io/effect-api-query/guides/http-queries-and-mutations/#retain-sse-events) -for accumulated HTTP event queries. +Multipart payloads use explicit `FormData`. Mixed HTTP success modes, raw byte streams and +streaming multipart requests are omitted; see [feature support](https://ueberbrot.github.io/effect-api-query/getting-started/feature-support/). ## Integrate with your application - [React Query](https://ueberbrot.github.io/effect-api-query/guides/react-query/): use generated options with hooks. - [Vue Query](https://ueberbrot.github.io/effect-api-query/guides/vue-query/), [Solid Query](https://ueberbrot.github.io/effect-api-query/guides/solid-query/), [Svelte Query](https://ueberbrot.github.io/effect-api-query/guides/svelte-query/), and [Angular Query](https://ueberbrot.github.io/effect-api-query/guides/angular-query/): rebuild options from reactive input with native Query integrations. - [TanStack Start](https://ueberbrot.github.io/effect-api-query/guides/tanstack-start/): share RPC and HTTP options across loaders, server rendering, and hydration. -- [Stream snapshots](https://ueberbrot.github.io/effect-api-query/guides/stream-snapshots/): capture open streams and await local cleanup before server disposal. -- [Hydrate Unary Data](https://ueberbrot.github.io/effect-api-query/guides/hydrate-unary-data/) and [Query Views](https://ueberbrot.github.io/effect-api-query/guides/hydrate-query-views/): keep DTOs or reconstructed Schema values consistent across JSON hydration and refetch. -- [Switch cache owners](https://ueberbrot.github.io/effect-api-query/guides/switch-cache-owners/) and [refresh from events](https://ueberbrot.github.io/effect-api-query/guides/refresh-from-events/): coordinate application-owned writes, persistence, and invalidation. -- [Run the examples](https://ueberbrot.github.io/effect-api-query/examples/): try complete Vite React and TanStack Start applications. -- [Feature support](https://ueberbrot.github.io/effect-api-query/getting-started/feature-support/): check available operations and integration limits. -- [Performance and bundling](https://ueberbrot.github.io/effect-api-query/reference/performance/): assess construction, compiler, and bundle costs. -- [API reference](https://ueberbrot.github.io/effect-api-query/reference/public-exports/): look up factories, builders, and errors. +- [Run the examples](https://ueberbrot.github.io/effect-api-query/examples/): copy complete Vite React and Start integrations. +- [API reference](https://ueberbrot.github.io/effect-api-query/reference/public-exports/): look up builders, cache views and errors. ## Agent skill diff --git a/apps/docs/src/content/docs/concepts/client-lifecycle.md b/apps/docs/src/content/docs/concepts/client-lifecycle.md index ebcb1c3..059c0ca 100644 --- a/apps/docs/src/content/docs/concepts/client-lifecycle.md +++ b/apps/docs/src/content/docs/concepts/client-lifecycle.md @@ -3,27 +3,19 @@ title: Client Lifecycle description: Keep RPC and HTTP clients, runtimes, and resources under caller ownership. --- -`createRpcQueryUtils` accepts a ready flat RPC client. Your application acquires the client, keeps -its `Scope` open, and disposes its resources: +Both factories use ready clients. The application owns transport, middleware, runtime, +Scope, and QueryClient: -1. Open the RPC client's `Scope` and build any required runtime. -2. Create a `QueryClient` and RPC utility tree. -3. Run ordinary, infinite, accumulated-stream, and live queries or mutations. Their generated - functions call the ready client through `runPromiseExit`. -4. At shutdown, cancel queries, await local stream cleanup and accepted mutations, clear the cache, - and then dispose the RPC client's resources. +1. Acquire the RPC or HTTP client and the services it requires. +2. Create a QueryClient and utility tree for that owner. +3. Keep those resources alive while queries, streams, and mutations run. +4. Stop consumers, cancel queries, await local stream cleanup and accepted mutations, + clear the cache, then release the client and runtime. -On the server, create these resources separately for each request to keep request data and scoped -services isolated. In the browser, keep them for the application lifetime. - -The factory has no React, router, transport, provider, or server-rendering lifecycle of its own. +Server requests own independent resources. Browser applications keep them for their lifetime. ## HTTP clients and execution services -`createHttpApiQueryUtils` accepts a ready HttpApiClient. The application builds its HTTP transport, -installs client middleware, and manages any runtime or `Scope` the client uses. On the server, -create a client and `QueryClient` for each request and dispose their resources when it ends. - Calls that need no services default to `Effect.runPromiseExit`. If an exposed endpoint needs request encoding, success decoding, or error decoding services, supply a `runPromiseExit` that provides them. The runner must also provide any services the ready client still requires. It must forward its @@ -52,12 +44,9 @@ returns a successful `Exit` before Query Core pulls values. Iterator pulls then Context while the query remains fetching. Keep its services and the ready client's Scope alive until consumption finishes. -Apply transformations to the stream returned by the ready client, before it reaches the utility -tree. Use `Stream.map` for value changes, `Stream.mapEffect` for service-dependent work, -`Stream.tap` for per-emission instrumentation, and `Stream.ensuring` for completion or cancellation -cleanup. For RPC, wrap the streaming call at the ready-client boundary; preserve its payload, -request options, and declared success/error types. For HTTP, retain the native client's response -mode contract when applying a stream transformation. +Transform or instrument the ready-client stream when the work must cover consumption. +Preserve its request options, declared value/error types, and HTTP response-mode contract. +Use a stream finalizer for completion or cancellation cleanup. A timer, span, finalizer, or retry schedule around the runner's creation Effect ends with iterable creation. It does not wrap the later pulls. Put stream-lifetime instrumentation and recovery on the @@ -65,10 +54,9 @@ stream itself. A retry in generated Query options reruns the query function and iterable. Coordinate it with stream and transport schedules as described in [retry queries](/effect-api-query/guides/retry-queries/#set-defaults-deliberately). -At shutdown, cancel active queries, await application-owned stream finalization, clear the cache, -and then dispose the runtime or close the client Scope. `cancelQueries` restores Query's cache -state; iterator cleanup can finish asynchronously, so wait for your resource's completion signal -before disposal. Settle pending mutations separately because they have no query abort signal. +Native `cancelQueries` settlement can precede asynchronous iterator cleanup. Await the +resource's completion signal before releasing its services. Mutations settle separately because +they have no query abort signal. For an open-stream server capture, await [fetchStreamSnapshot](/effect-api-query/guides/stream-snapshots/) before dehydration or client diff --git a/apps/docs/src/content/docs/concepts/http-utility-tree.md b/apps/docs/src/content/docs/concepts/http-utility-tree.md index c45de3b..7a32b1f 100644 --- a/apps/docs/src/content/docs/concepts/http-utility-tree.md +++ b/apps/docs/src/content/docs/concepts/http-utility-tree.md @@ -22,12 +22,15 @@ Every retained branch and endpoint has `key()`. Top-level groups have no branch custom key encoders still use their declaration group identifier. The factory rejects unsafe names, collisions, and invalid encoder configuration before returning any utilities. -The factory retains a single SSE success, including a decoded response-header wrapper. Its leaf -exposes `key()`, `streamedKey()`, `streamedOptions()`, `liveKey()`, and `liveOptions()`. Accumulated cache entries hold -ordered decoded events; live entries hold the latest value. -Decoded header wrappers surround each event. Decoder policy contributes to both views' concrete -identity; accumulated identity also includes retention and refetch policies. Endpoint prefixes -select every view policy. +| Endpoint success/payload | Generated cache views | +| ------------------------------------------ | ------------------------------------------------------------ | +| Buffered without multipart | Ordinary, infinite and mutation builders; buffered metadata | +| Any buffered multipart payload alternative | Mutations only, including mixed plain/multipart alternatives | +| Single SSE success without multipart | Accumulated history and latest-value views | + +SSE events retain decoded response-header wrappers. Decoder policy contributes to both view +keys; accumulated keys also include retention and refetch policy. Endpoint prefixes select all +policies. See [HTTP Factory](/effect-api-query/reference/http-factory/) for exact builders. Raw byte streams, mixed buffered/SSE successes, SSE endpoints with multipart payloads, and streaming multipart requests omit the complete endpoint. Groups containing only omitted endpoints disappear. @@ -44,6 +47,3 @@ responses; buffered metadata views copy status and headers alongside decoded dat controls are excluded from request input. See the [HTTP factory](/effect-api-query/reference/http-factory/) for request, response, and builder contracts, and [Semantic Keys](/effect-api-query/concepts/semantic-keys/) for the `http` namespace. - -The [public HTTP type consumer](https://github.com/ueberBrot/effect-api-query/blob/main/tests/types/http-contract.ts) -and [factory runtime tests](https://github.com/ueberBrot/effect-api-query/blob/main/tests/create-http-api-query-utils.test.ts) check these projection rules. diff --git a/apps/docs/src/content/docs/concepts/queries-and-mutations.md b/apps/docs/src/content/docs/concepts/queries-and-mutations.md index 6957039..952c349 100644 --- a/apps/docs/src/content/docs/concepts/queries-and-mutations.md +++ b/apps/docs/src/content/docs/concepts/queries-and-mutations.md @@ -34,6 +34,3 @@ removeUser.mutate({ id: 1 }) After a successful mutation, invalidate the affected query prefix explicitly. The package does not infer relationships between API operations. - -The [public RPC consumer](https://github.com/ueberBrot/effect-api-query/blob/main/tests/types/public-contract.ts) and -[public HTTP consumer](https://github.com/ueberBrot/effect-api-query/blob/main/tests/types/http-contract.ts) check these builder and hook contracts. diff --git a/apps/docs/src/content/docs/concepts/semantic-keys.md b/apps/docs/src/content/docs/concepts/semantic-keys.md index ebe211e..0fb2bba 100644 --- a/apps/docs/src/content/docs/concepts/semantic-keys.md +++ b/apps/docs/src/content/docs/concepts/semantic-keys.md @@ -56,6 +56,3 @@ Client middleware does not contribute to that declared identity. Include safe us other identifiers in the caller prefix when they affect results. See the [HTTP factory reference](/effect-api-query/reference/http-factory/#cache-identity-and-failures) and [HTTP encoder guide](/effect-api-query/guides/custom-key-encoders/#http-requests) for the complete rules. - -The [RPC key tests](https://github.com/ueberBrot/effect-api-query/blob/main/tests/create-rpc-query-utils-keys.test.ts) and -[HTTP key tests](https://github.com/ueberBrot/effect-api-query/blob/main/tests/http-semantic-keys.test.ts) verify canonical identity and its failure cases. diff --git a/apps/docs/src/content/docs/examples.md b/apps/docs/src/content/docs/examples.md index e85fd4c..ad762ed 100644 --- a/apps/docs/src/content/docs/examples.md +++ b/apps/docs/src/content/docs/examples.md @@ -53,42 +53,17 @@ demo authorization header through its HTTP client middleware. ### Compare HTTP and RPC -The HTTP panel uses `createHttpApiQueryUtils` from `effect-api-query`; the RPC panels -use `createRpcQueryUtils` from the same package root. The application owns both ready clients, -their runners, and the QueryClient. During disposal, it awaits query cancellation, clears the -cache, drains accepted commands and mutations, and then disposes the HTTP runtime and RPC client. -Query cancellation can settle before iterator cleanup. See -[Client Lifecycle](/effect-api-query/concepts/client-lifecycle/#stream-creation-and-consumption) -for a separate stream-completion signal, and -[Switch Cache Owners](/effect-api-query/guides/switch-cache-owners/) for persistence and replacement. - -Use the HTTP directory to read users, load another page, and create or delete a user. Each write -explicitly invalidates both generated user prefixes, so the RPC directory reflects HTTP writes -and the HTTP directory reflects RPC writes. The two adapters retain separate cache keys. -Choose **Reset directory** to restore the deterministic shared data. - -The HTTP user selector passes `skipToken` until a user is selected. HTTP request inputs use -structured decoded parts such as `params`, `query`, and `payload`; RPC inputs retain their -payload-constructor defaults. Deleting a user demonstrates an HTTP no-content mutation whose -result remains `undefined`. - -Trigger the HTTP failure to inspect `EffectHttpApiQueryError` and its preserved `Cause`. -The package adds declaration identifiers to error metadata; upstream Causes can still contain -request, response, or schema issue values. - -Choose **Start slow HTTP query**, wait for **HTTP: Ready to cancel**, then choose -**Cancel HTTP query**. After the HTTP request aborts, the example observes server interruption for -that operation. Each panel owns a distinct operation ID, so cancelling an RPC query leaves a -concurrent HTTP query running. This cancels observation and work in this handler; it does not -compensate completed mutations. The RPC cancellable-command panel demonstrates explicit domain -cancellation. - -The executable sources are -[application ownership](https://github.com/ueberBrot/effect-api-query/blob/main/examples/vite-react/src/lib/application.ts), -[HTTP contracts](https://github.com/ueberBrot/effect-api-query/tree/main/examples/contracts/src), and -[server handlers](https://github.com/ueberBrot/effect-api-query/tree/main/examples/server/src). -The repository type-checks the application against the public package root and exercises it -through the real server and browser suites. +| Try | Observable result | Complete source | +| ------------------------------------------------------------- | ---------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| Create/delete a user in either directory | Both adapters refresh through explicit cross-prefix invalidation | [application.ts](https://github.com/ueberBrot/effect-api-query/blob/main/examples/vite-react/src/lib/application.ts) | +| Trigger a declared HTTP failure | HTTP execution metadata and preserved Cause | [http-section.tsx](https://github.com/ueberBrot/effect-api-query/blob/main/examples/vite-react/src/components/sections/http-section.tsx) | +| Start a slow query, wait for **Ready to cancel**, then cancel | Only that operation is interrupted; completed writes remain | [cancellation guide](/effect-api-query/guides/cancellation/) | +| Switch cache owner or reload | Private caches retire; allowlisted directory DTOs may persist | [owner-cache.ts](https://github.com/ueberBrot/effect-api-query/blob/main/examples/vite-react/src/lib/owner-cache.ts) | + +Copy [shared contracts](https://github.com/ueberBrot/effect-api-query/tree/main/examples/contracts/src), +[ready-client ownership](https://github.com/ueberBrot/effect-api-query/blob/main/examples/vite-react/src/lib/application.ts), +and [server handlers](https://github.com/ueberBrot/effect-api-query/tree/main/examples/server/src). +The application owns clients, runners, providers, cross-adapter invalidation and disposal. ## Run TanStack Start @@ -96,47 +71,33 @@ through the real server and browser suites. vp run tanstack-start-dev ``` -This task starts one full-stack process. Browser clients call the Start application's `/rpc` and -`/api/$` routes. Server loaders execute within the same host without a network round trip. Choose **HTTP users** to inspect the server-rendered directory -and first page, reuse the hydrated cache, and try the same HTTP operations as in Vite React. -Choose **HTTP SSR failure** to see the browser retry a failed server query that was omitted -from dehydration. +This task starts one full-stack process at `http://127.0.0.1:3000`, serving `/rpc` and `/api/$`. +Choose **HTTP users** for server-rendered data and hydrated navigation, or **HTTP SSR failure** +for browser refetch of a failure omitted from dehydration. Each request owns its cache and +clients; the browser owns separate resources. -Server rendering owns a separate RPC connection and Query Client for each request. Local RPC and -HTTP execution share the network handlers' demonstration directory. A browser write therefore -appears in the next server-rendered page, while disposing one request leaves other owners usable. -Browser requests stay on the same origin. -See [TanStack Start](/effect-api-query/guides/tanstack-start/) for request ownership, authentication, -cache isolation, and hydration setup. +Copy the [router](https://github.com/ueberBrot/effect-api-query/blob/main/examples/tanstack-start/src/router.tsx), +[SSR codecs](https://github.com/ueberBrot/effect-api-query/blob/main/examples/tanstack-start/src/lib/query-ssr.ts), +and [failure route](https://github.com/ueberBrot/effect-api-query/blob/main/examples/tanstack-start/src/routes/http-failure.tsx). +See [TanStack Start](/effect-api-query/guides/tanstack-start/) for the library integration. ## Pause a query until a user is selected -In either application, find **Choose before fetching**. With **No user selected**, the generated -query uses `{ input: skipToken }` and sends no lookup request. Select **User 2: Edsger Dijkstra** to -load the user summary, then clear and reselect it within 30 seconds to reuse the cached result. - -The skipped and active queries preserve the same `select` and `staleTime` options. The example uses -ordinary `useQuery`; the TanStack Start loader leaves this interactive query paused during server rendering. +In either app's **Choose before fetching** panel, **No user selected** sends no lookup. +Select a user to fetch its summary; clear and reselect within 30 seconds to reuse the cache. +Skipped and active options retain the same `select` and `staleTime`. ## Compare full and bounded stream history -In either application, let the diagnostic stream finish, then choose **Replay newest 2**. -The accumulated history retains only "Workspace synchronized" and "Ready"; earlier updates disappear -as new ones arrive. Choose **Replay full history** to retain all four states again. The live query -continues to show only "Ready". - -The bounded replay supplies `maxChunks: 2` to `streamedOptions`. Each policy has its own generated -streamed key, so a cached full history cannot satisfy the bounded view. The application keeps the -selected policy for subsequent refetches. TanStack Start also demonstrates this after hydrating its -server snapshot. +After diagnostics finish, **Replay newest 2** retains the latest two states; **Replay full history** +retains all four. The live view shows only the latest state. Each accumulated policy has its own +key. See [Choose Stream History](/effect-api-query/guides/choose-stream-history/). ## Inspect request-local metadata -In either application's diagnostics panel, choose **Trigger declared failure**. Its generated -mutation options supply the `x-request-source: diagnostics-panel` RPC header. Application-wide -authorization still comes from the shared client runner. The same `rpcOptions` input works with -queries, infinite queries, and both stream builders; see -[Generated Builders](/effect-api-query/reference/generated-builders/#request-local-rpc-options). +**Trigger declared failure** supplies the request-local `x-request-source: diagnostics-panel` +RPC header while shared authorization stays on the client runner. See +[Request-local RPC Options](/effect-api-query/reference/generated-builders/#request-local-rpc-options). ## Build the examples diff --git a/apps/docs/src/content/docs/getting-started/compatibility-and-stability.md b/apps/docs/src/content/docs/getting-started/compatibility-and-stability.md index e13a96d..dbeebb0 100644 --- a/apps/docs/src/content/docs/getting-started/compatibility-and-stability.md +++ b/apps/docs/src/content/docs/getting-started/compatibility-and-stability.md @@ -7,8 +7,7 @@ Both factories use the same public `effect-api-query` package root and peer depe The package's [manifest](https://github.com/ueberBrot/effect-api-query/blob/main/package.json) defines peer requirements; the [workspace catalog](https://github.com/ueberBrot/effect-api-query/blob/main/pnpm-workspace.yaml) records the versions tested in this repository. See the -[compatibility reference](/effect-api-query/reference/compatibility-and-limits/) for how these -requirements are tested. +[compatibility reference](/effect-api-query/reference/compatibility-and-limits/) for integration versions and limits. The package is ESM-only and targets ES2022. Enable strict TypeScript checking. diff --git a/apps/docs/src/content/docs/getting-started/feature-support.md b/apps/docs/src/content/docs/getting-started/feature-support.md index 67994a2..c2626b4 100644 --- a/apps/docs/src/content/docs/getting-started/feature-support.md +++ b/apps/docs/src/content/docs/getting-started/feature-support.md @@ -11,28 +11,19 @@ Keep your native Effect declarations and ready clients. Effect and your applicat server, transport, authentication, and resource lifetime; this package derives the Query-facing keys, functions, and options. -The [capability matrix](/effect-api-query/reference/compatibility-and-limits/#capability-matrix) -compares RPC and HTTP support. It lists generated features, tested integrations, application -responsibilities, deferred operations, and missing upstream integration points. - ## Pick the operation -Use ordinary queries for cached reads, infinite queries for pagination, and mutations for commands. -Unary RPCs and buffered HTTP endpoints without multipart provide all three builders. Buffered -multipart HTTP endpoints provide mutations only. Writes refresh cached reads only when your -application invalidates the affected keys. - -Query-enabled buffered HTTP endpoints also expose `metadataOptions` for decoded data, status, and -raw response headers with separate cache identity. See -[Update with an ETag](/effect-api-query/guides/http-queries-and-mutations/#update-with-an-etag). +| Contract | Available views | Details | +| -------------------------------------- | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| Unary RPC / buffered nonmultipart HTTP | Query, infinite query, mutation | [RPC Builders](/effect-api-query/reference/generated-builders/), [HTTP Factory](/effect-api-query/reference/http-factory/) | +| Buffered HTTP | Separate decoded data/status/header metadata | [ETag workflow](/effect-api-query/guides/http-queries-and-mutations/#update-with-an-etag) | +| Buffered multipart HTTP | Mutation with explicit `FormData` | [Upload](/effect-api-query/guides/http-queries-and-mutations/#upload-a-file) | +| RPC stream / unambiguous HTTP SSE | Accumulated history or latest value | [Choose Stream History](/effect-api-query/guides/choose-stream-history/) | -RPC streams and unambiguous HTTP SSE endpoints have `streamedOptions` to accumulate values and -`liveOptions` to retain the latest value. Buffered multipart HTTP uploads accept explicit `FormData` -through `mutationOptions`; -see [upload a file](/effect-api-query/guides/http-queries-and-mutations/#upload-a-file). -Raw byte streams, mixed buffered/SSE successes, streaming multipart, and raw-response modes remain -omitted. SSE endpoints with buffered multipart payloads are also omitted. See -[HTTP streams](/effect-api-query/guides/http-queries-and-mutations/#retain-sse-events). +Use queries for cached reads and mutations for commands; writes refresh reads only when the +application invalidates them. Raw byte streams, mixed buffered/SSE successes, streaming +multipart and raw-response modes are omitted, as are SSE endpoints with multipart payloads. +See [Compatibility and Limits](/effect-api-query/reference/compatibility-and-limits/). ## Plan application ownership @@ -44,16 +35,5 @@ Include a safe user or tenant identity in `keyPrefix` whenever client configurat returned data. Keep credentials out of keys. See [semantic keys](/effect-api-query/concepts/semantic-keys/) and [client lifecycle](/effect-api-query/concepts/client-lifecycle/). -Start with the [RPC tutorial](/effect-api-query/getting-started/quick-start/) or -[HTTP tutorial](/effect-api-query/getting-started/http-quick-start/), then use the shared guides for -[conditional queries](/effect-api-query/guides/conditional-queries/), -[cache management](/effect-api-query/guides/cache-management/), and -[cancellation](/effect-api-query/guides/cancellation/). - -For application workflows, use [Query Defaults](/effect-api-query/guides/query-defaults/), -[Optimistic User Writes](/effect-api-query/guides/optimistic-writes/), -[Switch Cache Owners](/effect-api-query/guides/switch-cache-owners/), and -[Refresh from Domain Events](/effect-api-query/guides/refresh-from-events/). -For server data, use [Stream Snapshots](/effect-api-query/guides/stream-snapshots/) and -[Hydrate Query Views](/effect-api-query/guides/hydrate-query-views/). These recipes retain -application ownership of policies, codecs, persistence, and disposal. +Start with the [RPC Quick Start](/effect-api-query/getting-started/quick-start/) or +[HTTP Quick Start](/effect-api-query/getting-started/http-quick-start/). diff --git a/apps/docs/src/content/docs/getting-started/http-quick-start.md b/apps/docs/src/content/docs/getting-started/http-quick-start.md index 1fa18c7..93fcb57 100644 --- a/apps/docs/src/content/docs/getting-started/http-quick-start.md +++ b/apps/docs/src/content/docs/getting-started/http-quick-start.md @@ -1,107 +1,68 @@ --- title: HTTP Quick Start -description: Declare an HttpApi, own a ready client, and run a generated query and mutation. +description: Turn an existing HttpApi and ready client into query and mutation options. --- -This tutorial connects an Effect HttpApi client to TanStack Query Core. You will declare two -endpoints, acquire a ready client, read a user, and create another user. +Use an existing `HttpApi`, ready `HttpApiClient`, and `QueryClient`. This example's +`usersApi` declares `users.get` with an integer `id` param and `locale` query field, +plus `users.create` with a `{ name }` payload. +First [install the package](/effect-api-query/getting-started/installation/). -First [install the package](/effect-api-query/getting-started/installation/). Use a server at -`http://localhost:3000` that implements the declaration below and has an existing user with ID `1`. -Change the base URL to your server. For browser requests, configure the server's CORS policy to -allow the application's origin. -For complete HTTP handlers and applications, [run the examples](/effect-api-query/examples/). +Import `createHttpApiQueryUtils` from `effect-api-query` and `MutationObserver` from +`@tanstack/query-core`. The [complete typed recipe](https://github.com/ueberBrot/effect-api-query/blob/main/tests/types/docs-http-quick-start.ts) +contains the API and runtime setup; the [application examples](/effect-api-query/examples/) +contain runnable clients and handlers. -## Declare, connect, and call +## Create the utilities -Save this as `http-client.ts` in your application. Share the HttpApi declaration with your server; -keep the runtime, client, and `QueryClient` in the application that manages their lifetime. +In the application client module, after acquiring the client: ```ts -import { MutationObserver, QueryClient } from '@tanstack/query-core' -import { Effect, ManagedRuntime, Schema } from 'effect' -import { createHttpApiQueryUtils } from 'effect-api-query' -import { FetchHttpClient } from 'effect/http' -import { HttpApi, HttpApiClient, HttpApiEndpoint, HttpApiGroup } from 'effect/http-api' - -const User = Schema.Struct({ id: Schema.Int, name: Schema.String }) -const usersApi = HttpApi.make('users-api').add( - HttpApiGroup.make('users').add( - HttpApiEndpoint.get('get', '/users/:id', { - params: { id: Schema.Int }, - query: { - locale: Schema.String.pipe(Schema.withConstructorDefault(Effect.succeed('en'))), - }, - success: User, - }), - HttpApiEndpoint.post('create', '/users', { - payload: Schema.Struct({ name: Schema.String }), - success: User, - }), - ), -) +const http = createHttpApiQueryUtils(usersApi, { + client, + keyPrefix: ['users-app'] as const, + runPromiseExit: runtime.runPromiseExit, +}) +``` + +Use the application's signal-aware runner when client execution needs services. Keep its +runtime and client resources alive while queries and mutations use them. -const runtime = ManagedRuntime.make(FetchHttpClient.layer) -const queryClient = new QueryClient() - -try { - const client = await runtime.runPromise( - HttpApiClient.make(usersApi, { baseUrl: 'http://localhost:3000' }), - ) - const http = createHttpApiQueryUtils(usersApi, { - client, - keyPrefix: ['users-app'] as const, - runPromiseExit: runtime.runPromiseExit, - }) - - const user = await queryClient.query( - http.users.get.queryOptions({ input: { params: { id: 1 }, query: { locale: 'en' } } }), - ) - console.log(user.name) - - const createUser = new MutationObserver(queryClient, http.users.create.mutationOptions()) - const created = await createUser.mutate({ payload: { name: 'Ada' } }) - console.log(created.id) - await queryClient.invalidateQueries({ queryKey: http.users.key() }) -} finally { - try { - await queryClient.cancelQueries() - } finally { - queryClient.clear() - await runtime.dispose() - } -} +## Read a user + +In a loader or event handler, use the existing `queryClient`: + +```ts +const user = await queryClient.query( + http.users.get.queryOptions({ input: { params: { id: 1 }, query: { locale: 'en' } } }), +) ``` -`HttpApiClient.make` creates a ready client using the application's Fetch runtime. Pass that same -runtime's `runPromiseExit` to the factory so generated calls use that runner and -forward TanStack's query abort signal. +HTTP input is the **decoded request**. Supply the endpoint's declared `params`, `query`, +`headers`, and `payload` containers; the client handles wire encoding. Even if the Schema +has a constructor default, HTTP builders do not fill it: this request supplies `locale`. -HTTP input is the client's decoded request, with the `params`, `query`, `headers`, and `payload` -containers declared by the endpoint. Pass `id` as a number; the client handles its wire encoding. -The POST mutation receives `{ payload: { name: 'Ada' } }`. +## Execute a write -The request must supply `query: { locale: 'en' }` even though `locale` has a constructor default. -HTTP builders do not construct RPC payloads or fill constructor defaults. +Pass the complete decoded request to each mutation execution: -Groups and endpoint identifiers retain their literal names. `users` and `get` produce -`http.users.get`; a name containing a dot would require bracket access. A group declared with -`topLevel: true` places its endpoints at the utility root. +```ts +const createUser = new MutationObserver(queryClient, http.users.create.mutationOptions()) +const created = await createUser.mutate({ payload: { name: 'Ada' } }) +``` -## Choose cache and resource ownership +After success, refresh the affected reads: -Buffered endpoints without multipart offer both query and mutation builders, regardless of HTTP method. -Buffered multipart endpoints expose mutations only; see [upload a file](/effect-api-query/guides/http-queries-and-mutations/#upload-a-file). -Choose a query for an idempotent cached read and a mutation for a command. After a write, invalidate -the affected keys explicitly. RPC and HTTP have separate key namespaces, so applications that -expose the same resource through both must invalidate the affected keys in both namespaces. +```ts +await queryClient.invalidateQueries({ queryKey: http.users.key() }) +``` -The `finally` block cancels queries, clears the cache, and disposes the runtime. In a UI, keep these -resources alive for the application lifetime and settle pending mutations before disposal. Include -a safe user or tenant identity in `keyPrefix` whenever client configuration affects returned data. +Buffered nonmultipart endpoints offer query and mutation builders regardless of HTTP method; +buffered multipart endpoints offer mutations only. RPC and HTTP use separate namespaces, +so refresh both deliberately when they represent the same resource. -The repository compiles this complete snippet against the public package root in -[`tests/types/docs-http-quick-start.ts`](https://github.com/ueberBrot/effect-api-query/blob/main/tests/types/docs-http-quick-start.ts) -and checks that this page matches it. Continue with -[HTTP queries and mutations](/effect-api-query/guides/http-queries-and-mutations/) and the -[HTTP factory reference](/effect-api-query/reference/http-factory/). +Literal group/endpoint names become utility paths; dots require bracket access, and +`topLevel: true` places endpoints at the root. See [HTTP Factory](/effect-api-query/reference/http-factory/). +Before disposal, follow [Client Lifecycle](/effect-api-query/concepts/client-lifecycle/). +Continue with [HTTP Queries and Mutations](/effect-api-query/guides/http-queries-and-mutations/) +for metadata, uploads, pagination and SSE. diff --git a/apps/docs/src/content/docs/getting-started/installation.md b/apps/docs/src/content/docs/getting-started/installation.md index f2f8f63..3b6d9ed 100644 --- a/apps/docs/src/content/docs/getting-started/installation.md +++ b/apps/docs/src/content/docs/getting-started/installation.md @@ -14,7 +14,7 @@ by the package. If your application already pins Effect or disables automatic pe install the Effect version from the package's `peerDependencies` and resolve any peer mismatch before continuing. The [package manifest](https://github.com/ueberBrot/effect-api-query/blob/main/package.json) and [workspace catalog](https://github.com/ueberBrot/effect-api-query/blob/main/pnpm-workspace.yaml) -are the source for supported and tested versions. +define peer requirements and repository integration versions. React applications also need `@tanstack/react-query`, matching their Query Core version: @@ -23,8 +23,7 @@ pnpm add @tanstack/react-query ``` Use strict TypeScript checking, ESM, and an ES2022-capable application. See the -[compatibility reference](/effect-api-query/reference/compatibility-and-limits/) for the tested -compiler and framework integrations. +[compatibility reference](/effect-api-query/reference/compatibility-and-limits/) for compiler and framework versions. Continue with the [RPC quick start](/effect-api-query/getting-started/quick-start/) or [HTTP quick start](/effect-api-query/getting-started/http-quick-start/). diff --git a/apps/docs/src/content/docs/getting-started/quick-start.md b/apps/docs/src/content/docs/getting-started/quick-start.md index 9361b7f..d73f9bb 100644 --- a/apps/docs/src/content/docs/getting-started/quick-start.md +++ b/apps/docs/src/content/docs/getting-started/quick-start.md @@ -1,105 +1,63 @@ --- title: RPC Quick Start -description: Declare an RPC group, own a ready client, and run a generated query and mutation. +description: Turn an existing RPC contract and ready client into query and mutation options. --- -This tutorial connects an Effect RPC client to TanStack Query Core. You will declare two operations, -acquire a ready flat client, read a user, and create another user. +Use an existing `RpcGroup`, a ready flat RPC client, and a `QueryClient`. This example's +`usersRpc` declares `users.get` with an `id` and default `locale: 'en'`, plus `users.create` +with a `name`. First [install the package](/effect-api-query/getting-started/installation/). -First [install the package](/effect-api-query/getting-started/installation/). Use a server at -`http://localhost:3000/rpc` that implements the declaration below with JSON RPC serialization. -The server must have an existing user with ID `1`. Change the URL to your server. For browser -requests, configure the server's CORS policy to allow the application's origin. For a complete -server and browser application, [run the examples](/effect-api-query/examples/). +Import `createRpcQueryUtils` from `effect-api-query` and `MutationObserver` from +`@tanstack/query-core`. For the contract, transport and client acquisition, see the +[tests/types/docs-rpc-quick-start.ts](https://github.com/ueberBrot/effect-api-query/blob/main/tests/types/docs-rpc-quick-start.ts) +or [run the application examples](/effect-api-query/examples/). -## Declare, connect, and call +## Create the utilities -Save this as `rpc-client.ts` in your application. The declaration belongs in a shared module when -you connect your own server and client. Only the client application imports `effect-api-query`. +In the application client module, after acquiring the flat client: ```ts -import { MutationObserver, QueryClient } from '@tanstack/query-core' -import { Effect, Exit, Layer, Schema, Scope } from 'effect' -import { createRpcQueryUtils } from 'effect-api-query' -import { FetchHttpClient } from 'effect/http' -import { Rpc, RpcClient, RpcGroup, RpcSerialization } from 'effect/rpc' - -const User = Schema.Struct({ id: Schema.Int, name: Schema.String }) -const usersRpc = RpcGroup.make( - Rpc.make('users.get', { - payload: { - id: Schema.Int, - locale: Schema.String.pipe(Schema.withConstructorDefault(Effect.succeed('en'))), - }, - success: User, - }), - Rpc.make('users.create', { - payload: { name: Schema.String }, - success: User, - }), -) - -const protocol = RpcClient.layerProtocolHttp({ url: 'http://localhost:3000/rpc' }).pipe( - Layer.provide(RpcSerialization.layerJson), - Layer.provide(FetchHttpClient.layer), -) -const scope = await Effect.runPromise(Scope.make()) -const queryClient = new QueryClient() - -try { - const client = await Effect.runPromise( - RpcClient.make(usersRpc, { flatten: true }).pipe( - Effect.provide(protocol), - Scope.provide(scope), - ), - ) - const rpc = createRpcQueryUtils(usersRpc, { - client, - keyPrefix: ['users-app'] as const, - }) - - const user = await queryClient.query(rpc.users.get.queryOptions({ input: { id: 1 } })) - console.log(user.name) - - const createUser = new MutationObserver(queryClient, rpc.users.create.mutationOptions()) - const created = await createUser.mutate({ name: 'Ada' }) - console.log(created.id) - await queryClient.invalidateQueries({ queryKey: rpc.users.key() }) -} finally { - try { - await queryClient.cancelQueries() - } finally { - queryClient.clear() - await Effect.runPromise(Scope.close(scope, Exit.void)) - } -} +const rpc = createRpcQueryUtils(usersRpc, { + client, + keyPrefix: ['users-app'] as const, +}) ``` -`RpcClient.make(..., { flatten: true })` acquires the ready client inside the application's scope. -The factory derives the utility tree from that client and uses `Effect.runPromiseExit` -unless you supply a custom runner. +Dotted tags become nested paths: `users.get` becomes `rpc.users.get`. The factory uses +`Effect.runPromiseExit` by default; supply `runPromiseExit` when execution needs services. +Keep the client's `Scope` and runner alive while the utilities are used. -The query accepts the RPC payload's constructor input. Here `{ id: 1 }` is valid because the -schema constructor supplies `locale: 'en'`. Use deterministic constructor defaults: key preparation -and ready-client execution construct the payload separately. +## Query a user -Dotted RPC tags become nested properties: `users.get` becomes `rpc.users.get`. The query returns -a decoded user. Pass mutation variables to `mutate`, then invalidate the cached reads affected by -the write. +In a loader or event handler, use the existing `queryClient`: -## Keep the owner alive +```ts +const user = await queryClient.query(rpc.users.get.queryOptions({ input: { id: 1 } })) +``` + +The result is a decoded user. Query input is the RPC payload's **constructor input**: +`{ id: 1 }` works because its constructor supplies `locale: 'en'`. Defaults must be +deterministic because key preparation and client execution construct the payload separately. -The `finally` block cancels queries, clears their cache, and closes the client scope even when a -call fails. This short program waits for its mutation before cleanup. In a UI, keep the owner alive -for the application lifetime and settle pending mutations before disposal; query cancellation does -not cancel mutations. +## Execute a write + +Create mutation options beside the same client; pass each execution's input to `mutate`: + +```ts +const createUser = new MutationObserver(queryClient, rpc.users.create.mutationOptions()) +const created = await createUser.mutate({ name: 'Ada' }) +``` + +After success, refresh the reads affected by that write: + +```ts +await queryClient.invalidateQueries({ queryKey: rpc.users.key() }) +``` -Use a safe user or tenant identity in `keyPrefix` when client configuration affects which data -the client can return. See [Client Lifecycle](/effect-api-query/concepts/client-lifecycle/) and -[Cache Keys](/effect-api-query/concepts/semantic-keys/) before sharing clients or caches. +Invalidation is application policy. A safe user or tenant identity belongs in `keyPrefix` +when it affects returned data; credentials do not. Before disposing the client, stop +consumers and drain queries, local stream cleanup and accepted mutations. Follow +[Client Lifecycle](/effect-api-query/concepts/client-lifecycle/). -The repository compiles this complete snippet against the public package root in -[`tests/types/docs-rpc-quick-start.ts`](https://github.com/ueberBrot/effect-api-query/blob/main/tests/types/docs-rpc-quick-start.ts) -and checks that this page matches it. Continue with -[queries and mutations](/effect-api-query/concepts/queries-and-mutations/) or the -[generated RPC builders](/effect-api-query/reference/generated-builders/). +For hooks, continue with [React Query](/effect-api-query/guides/react-query/); for exact +method and input contracts, use [RPC Builders](/effect-api-query/reference/generated-builders/). diff --git a/apps/docs/src/content/docs/guides/angular-query.md b/apps/docs/src/content/docs/guides/angular-query.md index 1842eac..426e581 100644 --- a/apps/docs/src/content/docs/guides/angular-query.md +++ b/apps/docs/src/content/docs/guides/angular-query.md @@ -17,43 +17,19 @@ for the distinction between immutable keys and caller-owned input. Register `provideTanStackQuery(queryClient)` in your application providers. Construct queries inside an Angular injection context and read the signal inside the accessor passed to `injectQuery`. -The ready client below is acquired and disposed by the application. Keep its `Scope` and any -serviceful runner alive while queries use them: +Create `rpc` from your [ready client](/effect-api-query/getting-started/quick-start/). +Here `users.read` accepts `{ id: number }` and returns a user with `name`. +In an existing component's field initializers, with `signal` from `@angular/core` and +`injectQuery` from `@tanstack/angular-query-experimental`: ```ts -import { Component, signal } from '@angular/core' -import { injectQuery } from '@tanstack/angular-query-experimental' -import { Schema } from 'effect' -import { createRpcQueryUtils } from 'effect-api-query' -import { Rpc, RpcGroup, type RpcClient } from 'effect/rpc' - -const group = RpcGroup.make( - Rpc.make('users.read', { - payload: { id: Schema.Int }, - success: Schema.Struct({ id: Schema.Int, name: Schema.String }), +readonly id = signal(1) +readonly user = injectQuery(() => + rpc.users.read.queryOptions({ + input: { id: this.id() }, + select: (user) => user.name, }), ) -declare const client: RpcClient.RpcClient.Flat> -const rpc = createRpcQueryUtils(group, { client, keyPrefix: ['current-owner'] }) - -@Component({ - selector: 'user-details', - standalone: true, - template: '

{{ user.data() }}

', -}) -class UserDetails { - readonly id = signal(1) - readonly user = injectQuery(() => - rpc.users.read.queryOptions({ - input: { id: this.id() }, - select: (user) => user.name, - }), - ) - - next() { - this.id.update((id) => id + 1) - } -} ``` Use `injectInfiniteQuery` with `infiniteOptions` and `injectMutation` with `mutationOptions`. diff --git a/apps/docs/src/content/docs/guides/cache-filters.md b/apps/docs/src/content/docs/guides/cache-filters.md index d8b1aee..bbf4358 100644 --- a/apps/docs/src/content/docs/guides/cache-filters.md +++ b/apps/docs/src/content/docs/guides/cache-filters.md @@ -22,59 +22,35 @@ pending mutations. ## Filter RPC queries -This recipe accepts an application-owned ready client. Its `forId` filter matches every locale for -one user, while `exact` retains the generated DataTag for one complete payload: +In the contract module, suppose `users.get` and `users.watch` share these fields +(`Effect` and `Schema` are imported from `effect`): ```ts -import { Effect, Schema } from 'effect' -import { createRpcQueryUtils } from 'effect-api-query' -import { Rpc, RpcClient, RpcGroup } from 'effect/rpc' - -const User = Schema.Struct({ id: Schema.Finite, name: Schema.String }) const payload = { id: Schema.FiniteFromString, locale: Schema.String.pipe(Schema.withConstructorDefault(Effect.succeed('en'))), } -export const usersRpc = RpcGroup.make( - Rpc.make('users.get', { payload, success: User }), - Rpc.make('users.watch', { payload, success: User, stream: true }), - Rpc.make('users.list', { success: Schema.Array(User) }), - Rpc.make('health.ping', { success: Schema.String }), -) - -export const userCacheFilters = ( - client: RpcClient.RpcClient.Flat, ClientError>, -) => { - const rpc = createRpcQueryUtils(usersRpc, { - client, - keyPrefix: ['users-app'], - }) - const queryPrefix = [...rpc.users.get.key(), 'query'] as const - const historyPrefix = [...rpc.users.watch.key(), 'streamed'] as const - - return { - rpc, - root: { queryKey: rpc.key() }, - branch: { queryKey: rpc.users.key() }, - leaf: { queryKey: rpc.users.get.key() }, - query: { queryKey: queryPrefix }, - forId: (id: number) => ({ queryKey: [...queryPrefix, { id: String(id) }] as const }), - history: { queryKey: historyPrefix }, - historyForId: (id: number) => ({ - queryKey: [...historyPrefix, { id: String(id) }] as const, - }), - exact: (id: number, locale = 'en') => ({ - queryKey: rpc.users.get.queryKey({ id, locale }), - exact: true as const, - }), - } -} ``` -Create the filters with `userCacheFilters(client)`, then pass a filter to -`queryClient.invalidateQueries(filters.forId(1))`. Combine native conditions by spreading the filter, -for example `{ ...filters.forId(1), type: 'inactive', stale: false }`. Use -`queryClient.getQueryData(filters.exact(1).queryKey)` for a typed user read. +Use the existing `rpc` tree from [RPC Quick Start](/effect-api-query/getting-started/quick-start/). +In a cache callback, select every locale for user 1 with an encoded partial filter: + +```ts +const queryPrefix = [...rpc.users.get.key(), 'query'] as const +await queryClient.invalidateQueries({ + queryKey: [...queryPrefix, { id: '1' }], + type: 'inactive', + stale: false, +}) +``` + +For one complete request, preserve its generated DataTag and use `exact: true`: + +```ts +const queryKey = rpc.users.get.queryKey({ id: 1, locale: 'en' }) +const user = queryClient.getQueryData(queryKey) +await queryClient.invalidateQueries({ queryKey, exact: true }) +``` Partial matching compares stored canonical key payloads. Here `FiniteFromString` encodes the numeric constructor input `1` as `"1"`, so the partial object must use `id: "1"`. A raw `id: 1` matches no @@ -87,7 +63,7 @@ the default `locale: "en"`; its key therefore cannot express every locale for us `{ locale: "en" }` rejects the missing required `id`. Do not cast partial constructor input into a key builder or run it through the full payload constructor to derive a broad filter. -`historyForId(1)` stops before the stream-policy suffix and matches every retention bound and +`[...rpc.users.watch.key(), 'streamed', { id: '1' }]` stops before the stream-policy suffix and matches every retention bound and refetch mode for that encoded user. For one history, use `rpc.users.watch.streamedKey({ id: 1 }, { maxChunks: 2, refetchMode: 'append' })` with `exact: true`. The omitted policy and explicit unlimited/reset policy share the same normalized identity. Keep the @@ -95,69 +71,33 @@ remaining concrete suffix opaque; root, branch, leaf, and operation filters surv ## Filter HTTP queries -HTTP partial objects retain their labelled request containers. This recipe selects a path parameter -or normalized header without constraining the query or payload: +HTTP filters retain labelled request containers. Suppose `users.get` declares +`FiniteFromString` fields in params/query/payload and a decoded header record. Use the existing +`http` tree from [HTTP Quick Start](/effect-api-query/getting-started/http-quick-start/). +In a cache callback, choose the path parameter or header independently: ```ts -import { Schema } from 'effect' -import { createHttpApiQueryUtils } from 'effect-api-query' -import { - HttpApi, - HttpApiClient, - HttpApiEndpoint, - HttpApiGroup, - HttpApiSchema, -} from 'effect/http-api' - -export const usersApi = HttpApi.make('users-api').add( - HttpApiGroup.make('users').add( - HttpApiEndpoint.post('get', '/users/:id', { - params: { id: Schema.FiniteFromString }, - query: { id: Schema.FiniteFromString, filter: Schema.optional(Schema.String) }, - headers: Schema.Record(Schema.String, Schema.optional(Schema.String)), - payload: Schema.Struct({ id: Schema.FiniteFromString }), - success: Schema.String, - }), - HttpApiEndpoint.get('watch', '/users/events', { - query: { id: Schema.FiniteFromString }, - success: HttpApiSchema.StreamSse({ data: Schema.String }), - }), - ), - HttpApiGroup.make('system', { topLevel: true }).add( - HttpApiEndpoint.get('ping', '/health', { success: Schema.String }), - ), -) - -export const httpCacheFilters = (client: HttpApiClient.ForApi) => { - const http = createHttpApiQueryUtils(usersApi, { client, keyPrefix: ['users-app'] }) - const queryPrefix = [...http.users.get.key(), 'query'] as const - - return { - http, - root: { queryKey: http.key() }, - branch: { queryKey: http.users.key() }, - leaf: { queryKey: http.users.get.key() }, - query: { queryKey: queryPrefix }, - forId: (id: number) => ({ - queryKey: [...queryPrefix, { params: { id: String(id) } }] as const, - }), - forLocale: (locale: string) => ({ - queryKey: [...queryPrefix, { headers: { 'x-locale': locale } }] as const, - }), - historyForId: (id: number) => ({ - queryKey: [...http.users.watch.key(), 'streamed', { query: { id: String(id) } }] as const, - }), - exact: (input: Parameters[0]) => ({ - queryKey: http.users.get.queryKey(input), - exact: true as const, - }), - } -} +const queryPrefix = [...http.users.get.key(), 'query'] as const +await queryClient.invalidateQueries({ queryKey: [...queryPrefix, { params: { id: '1' } }] }) +await queryClient.invalidateQueries({ + queryKey: [...queryPrefix, { headers: { 'x-locale': 'en' } }], +}) +``` + +For an exact read, supply the complete decoded request to the builder: + +```ts +const key = http.users.get.queryKey({ + params: { id: 1 }, + query: { id: 7 }, + headers: { 'X-Locale': 'en' }, + payload: { id: 7 }, +}) +const user = queryClient.getQueryData(key) ``` -Pass `httpCacheFilters(client).forId(1)` to `invalidateQueries` to refresh ordinary queries for path -parameter 1. Use the leaf filter to include infinite and metadata views too. `historyForId(1)` -selects every accumulated retention policy for the SSE request with query parameter 1. +The leaf prefix also includes metadata and infinite views. For every history policy of a +`users.watch` request, use `[...http.users.watch.key(), 'streamed', { query: { id: '1' } }]`. Default HTTP keys Schema-encode each request part, omit encoded `undefined` object members, and lowercase header names. In this contract, `params: { id: "1" }` is a meaningful partial object; diff --git a/apps/docs/src/content/docs/guides/cache-management.md b/apps/docs/src/content/docs/guides/cache-management.md index 3dfebfc..a13eb62 100644 --- a/apps/docs/src/content/docs/guides/cache-management.md +++ b/apps/docs/src/content/docs/guides/cache-management.md @@ -3,7 +3,8 @@ title: Cache Management description: Invalidate and inspect caches with generated prefix keys. --- -Every branch and leaf exposes `key()`. Use these prefixes to match related cache entries: +In a mutation callback or event handler using the ready utility tree and QueryClient, +use `key()` to match a branch or operation: ```ts await queryClient.invalidateQueries({ queryKey: rpcQuery.users.key() }) @@ -31,15 +32,10 @@ await queryClient.invalidateQueries({ queryKey: key, exact: true }) Query keys have this flat shape: ```ts -;[ - ...keyPrefix, - 'rpc', - ...rpcTagSegments, - 'query', - canonicalPayload, // payload-bearing queries only -] +;[...keyPrefix, 'rpc', ...rpcTagSegments, 'query', canonicalPayload] ``` +Payload-bearing queries include `canonicalPayload`; payloadless queries omit it. Mutation keys end in `'mutation'` and never include variables. See [Semantic Keys](/effect-api-query/concepts/semantic-keys/) for normalization and hashing rules. diff --git a/apps/docs/src/content/docs/guides/cancellation.md b/apps/docs/src/content/docs/guides/cancellation.md index a3eea30..9b6b2ad 100644 --- a/apps/docs/src/content/docs/guides/cancellation.md +++ b/apps/docs/src/content/docs/guides/cancellation.md @@ -3,13 +3,14 @@ title: Cancellation description: Cancel queries and request server-side cancellation for long-running commands. --- -Generated query functions forward TanStack's `AbortSignal` to `runPromiseExit`: +In the application client module, forward TanStack's `AbortSignal` to your runtime. +Import `RunPromiseExit` from `effect-api-query`: ```ts const runPromiseExit: RunPromiseExit = (effect, options) => runtime.runPromiseExit(effect, options) ``` -Cancel one request with its generated query key: +In a loader or event handler using the ready RPC utility tree, cancel one request with its generated key: ```ts const input = { durationMs: 60_000, operationId: 'slow-query' } @@ -54,7 +55,10 @@ Use an explicit cancel RPC for long-running commands. The Vite React example pro has a client-generated operation ID. The start mutation waits for a terminal result; a separate query observes progress, and a separate mutation requests cancellation. -```tsx +In the React component setup, import `useState`, `useQuery`, and `useMutation` from their native +packages. With the example's ready `rpcQuery` and QueryClient: + +```ts const [operationId] = useState(() => crypto.randomUUID()) const input = { operationId } const reconcile = async () => { @@ -73,28 +77,20 @@ const status = useQuery( query.state.data == null || query.state.data.state === 'running' ? 100 : false, }), ) +``` -return ( - <> - - -

Server state: {status.data?.state ?? 'not started'}

- -) +In the start and cancel button handlers, call the corresponding mutation: + +```ts +start.mutate(input) +cancel.mutate(input) ``` +Disable start after its first attempt, and disable cancel before start, while cancellation is +pending, or after a terminal status. The +[complete command panel](https://github.com/ueberBrot/effect-api-query/blob/main/examples/vite-react/src/components/sections/commands-section.tsx) +includes the UI. + The cancel handler signals the worker and waits until it has stopped. The cancel mutation's `onSettled` callback then invalidates the generated status key. The active query refetches the server's status even if the cancel request failed, and polling stops at a terminal state. @@ -134,7 +130,3 @@ This example stores operation records in memory until the example is reset or th Production applications need their own authorization, durable storage, and retention policy if operations must survive restarts. The explicit RPC contract does not make this in-memory example a durable job queue. - -The [command panel](https://github.com/ueberBrot/effect-api-query/blob/main/examples/vite-react/src/components/sections/commands-section.tsx) -and [command tests](https://github.com/ueberBrot/effect-api-query/blob/main/examples/vite-react/tests/commands.test.ts) exercise this workflow. -The [HTTP transport tests](https://github.com/ueberBrot/effect-api-query/blob/main/tests/http-transport-cancellation.test.ts) verify cancellation through fetch. diff --git a/apps/docs/src/content/docs/guides/conditional-queries.md b/apps/docs/src/content/docs/guides/conditional-queries.md index 1d3b30e..6defecd 100644 --- a/apps/docs/src/content/docs/guides/conditional-queries.md +++ b/apps/docs/src/content/docs/guides/conditional-queries.md @@ -3,7 +3,9 @@ title: Conditional Queries description: Pause queries until their required input is available. --- -Use `{ input: skipToken }` when an input-bearing query has no valid input yet and needs TanStack +In component setup, import the native `useQuery` hook and use the ready utility tree from the +[RPC quick start](/effect-api-query/getting-started/quick-start/). `userId` is the selected ID, +which may be undefined. Use `{ input: skipToken }` when an input-bearing query has no valid input yet and needs TanStack options such as `staleTime`, `select`, or `initialData`: ```ts @@ -21,7 +23,7 @@ const userOptions = rpcQuery.users.get.queryOptions({ const user = useQuery(userOptions) ``` -The same form works for HTTP request input: +In the same component, the HTTP utility tree accepts a complete decoded request: ```ts const httpUserOptions = http.users.get.queryOptions({ @@ -51,7 +53,7 @@ including a conditional union. Keep the conditional input inside one builder cal has one consistent callback type. Concrete inputs and the literal `skipToken` retain their precise key and query-function types; conditional inputs preserve both possibilities. -The object form also works for accumulated streams and live queries: +For a declared `events.watch` stream with a channel input, build either view in that consumer: ```ts rpcQuery.events.watch.streamedOptions({ @@ -83,6 +85,3 @@ initial-request identity and cursor progression. Try this in either [executable example](/effect-api-query/examples/#pause-a-query-until-a-user-is-selected). The **Choose before fetching** control demonstrates pausing, selecting a user, and reusing fresh cached data after clearing and reselecting the same user. - -The [public RPC consumer](https://github.com/ueberBrot/effect-api-query/blob/main/tests/types/public-contract.ts) and -[public HTTP consumer](https://github.com/ueberBrot/effect-api-query/blob/main/tests/types/http-contract.ts) check skipped hook inference and rejected uses. diff --git a/apps/docs/src/content/docs/guides/custom-key-encoders.md b/apps/docs/src/content/docs/guides/custom-key-encoders.md index 5cf906e..9433497 100644 --- a/apps/docs/src/content/docs/guides/custom-key-encoders.md +++ b/apps/docs/src/content/docs/guides/custom-key-encoders.md @@ -9,6 +9,9 @@ By default, the factory constructs the RPC payload and synchronously encodes it Supply a custom encoder when encoding requires Effect services or the payload contains `Schema.Redacted` values, including schemas reached through `Schema.suspend`. +In the application client module, configure the ready RPC client. Here `identifySecret` is +your synchronous public-identity projection: + ```ts const rpcQuery = createRpcQueryUtils(rpcGroup, { client, @@ -43,6 +46,8 @@ Suppose a `forms` group's `submit` endpoint accepts a number encoded as text or as JSON. Both schemas can encode to `"1"`, but the HTTP bodies differ. Preserve that distinction in the key: +In the HTTP client module, configure that endpoint's request projection: + ```ts const http = createHttpApiQueryUtils(contract, { client, @@ -82,7 +87,3 @@ undefined object members, undefined or sparse array entries, non-finite numbers, non-JSON objects. Custom output bypasses the default HTTP rules for omitting `undefined` members and normalizing headers. See the [HTTP reference](/effect-api-query/reference/http-factory/#cache-identity-and-failures) for failure codes. Underlying error Causes remain intact and may contain values supplied by Effect. - -The [public RPC consumer](https://github.com/ueberBrot/effect-api-query/blob/main/tests/types/public-contract.ts) and -[public HTTP consumer](https://github.com/ueberBrot/effect-api-query/blob/main/tests/types/http-contract.ts) check encoder inputs and required services. -The [HTTP key tests](https://github.com/ueberBrot/effect-api-query/blob/main/tests/http-semantic-keys.test.ts) verify alternative payload and binary projections. diff --git a/apps/docs/src/content/docs/guides/handle-failures.md b/apps/docs/src/content/docs/guides/handle-failures.md index 0f227fc..cb23a96 100644 --- a/apps/docs/src/content/docs/guides/handle-failures.md +++ b/apps/docs/src/content/docs/guides/handle-failures.md @@ -4,7 +4,7 @@ description: Inspect RPC and HTTP Causes and distinguish configuration and key e --- A failed RPC `Exit` becomes `EffectRpcQueryError`. The error records the RPC tag, the generated -operation that ran, and the complete Effect `Cause`: +operation that ran, and the complete Effect `Cause`. In the application's error handler: ```ts import { Cause } from 'effect' @@ -18,6 +18,8 @@ const logRpcError = (error: unknown) => { } ``` +Review the Cause for private messages and transport values before logging it. + These error classes distinguish failure stages: - `EffectRpcQueryConfigError` reports invalid factory or builder configuration synchronously. diff --git a/apps/docs/src/content/docs/guides/http-queries-and-mutations.md b/apps/docs/src/content/docs/guides/http-queries-and-mutations.md index 41ba183..cd8a077 100644 --- a/apps/docs/src/content/docs/guides/http-queries-and-mutations.md +++ b/apps/docs/src/content/docs/guides/http-queries-and-mutations.md @@ -1,37 +1,29 @@ --- title: HTTP Queries and Mutations -description: Fetch and update data through an Effect HttpApi with TanStack Query. +description: Use decoded requests, writes, metadata and SSE views from an existing HttpApi. --- -Use `createHttpApiQueryUtils` with your HttpApi definition and ready HttpApiClient. The client -keeps its existing base URL, transport, and middleware configuration. - -This example assumes your API has a `users` group with `get` and `create` endpoints. The `get` -endpoint declares a decoded numeric `id` in `params`; `create` accepts `{ name: string }` in -`payload`. Replace the application module imports with your own contract and client. +Use your existing `http` utility tree and QueryClient. The examples below name the declared +request parts and result shape for each endpoint; substitute the corresponding operation +from your API. Import native observers/hooks from TanStack Query when using them. ## Create the utilities -```ts -import { MutationObserver, QueryClient } from '@tanstack/query-core' -import { createHttpApiQueryUtils } from 'effect-api-query' - -import { api } from './api.js' -import { client } from './http-client.js' +Create the tree in the application client module with your HttpApi and acquired client: -const queryClient = new QueryClient() -const http = createHttpApiQueryUtils(api, { - client, - keyPrefix: ['my-app'], -}) +```ts +const http = createHttpApiQueryUtils(api, { client, keyPrefix: ['my-app'] }) ``` -This client needs no additional execution services. If your client or its schemas require services, -also supply a `runPromiseExit` backed by your application runtime. Forward the runner's options -so [query cancellation](/effect-api-query/guides/cancellation/#cancel-an-http-query) reaches Effect. +Import the factory from `effect-api-query`. Keep base URL, middleware and authentication on the +ready client. When execution needs services, supply your application's signal-aware +`runPromiseExit`. The [HTTP Quick Start](/effect-api-query/getting-started/http-quick-start/) +shows factory, read and write setup. ## Fetch a user +For `users.get` declaring an integer `id` param, in a loader or event handler: + ```ts const userOptions = http.users.get.queryOptions({ input: { params: { id: 1 } }, @@ -41,22 +33,17 @@ const userOptions = http.users.get.queryOptions({ const user = await queryClient.query(userOptions) ``` -Pass decoded values in the endpoint's declared `params`, `query`, `headers`, and `payload` -containers. For example, a `Schema.FiniteFromString` parameter takes a number; the ready client -encodes it for transport. Include every declared container, even when its fields are optional. -An endpoint without request input uses `queryOptions()`. +Pass decoded values in every declared `params`, `query`, `headers`, and `payload` container, +even when its fields are optional. The client encodes them; HTTP builders add no constructor +defaults. An inputless endpoint uses `queryOptions()`. -React applications can pass `userOptions` directly to `useQuery`, `useSuspenseQuery`, or -`usePrefetchQuery`. A `select` callback receives decoded response data; it changes the observer's -data type without changing the cached value. `queryClient.getQueryData(userOptions.queryKey)` -still infers the decoded user type. +Pass `userOptions` to a native query hook for observation. `select` changes its result while +`getQueryData(userOptions.queryKey)` still infers the decoded cache value. ## Update with an ETag -Use a metadata view to read decoded data, status, and raw response headers together. For this -recipe, `users.get` returns a user and its server supplies an ETag. The `users.update` endpoint -declares numeric `id` params, a string `if-match` header, a `{ name: string }` payload, and its -precondition failure as a declared error. +Suppose `users.get` supplies an ETag and `users.update` declares `id` params, an `if-match` +header, a `{ name }` payload and its precondition error. In the write handler, read metadata: ```ts const input = { params: { id: 1 } } @@ -64,7 +51,11 @@ const metadataOptions = http.users.get.metadataOptions({ input }) const current = await queryClient.query(metadataOptions) const etag = current.headers['etag'] if (etag === undefined) throw new Error('The server supplied no ETag') +``` + +Cancel affected reads and prepare the mutation with endpoint-prefix invalidation: +```ts await queryClient.cancelQueries({ queryKey: http.users.get.key() }) const update = new MutationObserver( queryClient, @@ -72,43 +63,37 @@ const update = new MutationObserver( onSuccess: () => queryClient.invalidateQueries({ queryKey: http.users.get.key() }), }), ) +``` + +Execute that write with the ETag unchanged, **including quotes**: + +```ts await update.mutate({ params: input.params, headers: { 'if-match': etag }, payload: { name: 'Ada' }, }) - -const refreshed = await queryClient.query(metadataOptions) ``` -Send the ETag unchanged, including quotes. The server owns conditional-write enforcement; handle a -declared precondition failure through the mutation's HTTP execution error. Invalidation uses the -endpoint prefix so both ordinary data and metadata views refresh. Read the next ETag from the fresh -metadata result before another write. +After invalidation, fetch metadata again and use its next ETag before another write: -`current.data` retains the endpoint's decoded success, including any declared decoded header -wrapper. A top-level successful `undefined` becomes `null`. `current.status` is the response status; -`current.headers` is a copied, frozen plain string record. The outer result is frozen, while decoded -data keeps its declared representation. Raw headers can contain private values: choose which headers -your application may persist or dehydrate. - -Fetched snapshots stay frozen after structural sharing, using your global, prefix, or per-call -policy. Decoded data and values supplied through `initialData`, hydration, or manual cache writes -remain application-owned. Selected observer results and subsequent manual writes use the policy -explicitly passed to `metadataOptions`, or standard deep sharing if omitted; they do not inherit -global or prefix sharing policies. See -[Buffered metadata](/effect-api-query/reference/http-factory/#buffered-metadata). +```ts +const refreshed = await queryClient.query(metadataOptions) +``` -A fresh-cache hit from imperative `queryClient.query(newOptions)` retains existing Query options. -Install a changed sharing policy through an observer or an actual fetch before later manual writes. +The server enforces the precondition; handle a declared failure through the HTTP execution +error. The prefix refreshes both data and metadata entries. Metadata retains decoded success +(including decoded header wrappers), status and copied frozen raw string headers in a separate +cache view. Choose safe headers before persistence or hydration. -`metadataKey(input)` provides a typed key for cache reads and writes. Metadata options support -selection, initial data, skipped input, native hashing defaults, and cancellation like ordinary -queries. Metadata has its own cache identity and no mutation or infinite-query builders. +[Buffered metadata](/effect-api-query/reference/http-factory/#buffered-metadata) specifies +initial data, selection, immutable snapshots and structural sharing. A fresh imperative cache +hit keeps existing Query options; install a changed policy through an observer or actual fetch +before later manual writes. ## Wait for request input -Use `skipToken` until the complete request is available: +In a React component, use the exported `skipToken` until the full request exists: ```ts import { useQuery } from '@tanstack/react-query' @@ -123,22 +108,16 @@ const user = useQuery( ) ``` -`queryOptions(skipToken)` is shorthand when you need no other options. Skipping preserves caller -options and performs no request encoding or client call. Its query function is TanStack's exact -sentinel, so manual `refetch()` cannot execute it. Supply valid input to enable the query. Use -`enabled: false` with a complete request when you need a query that can run through manual refetch. -Suspense and prefetch-only hooks require executable options and reject skipped options. See -[Conditional Queries](/effect-api-query/guides/conditional-queries/) for the shared contract. +Skipping keeps caller options and performs no encoding or request. For manual refetch, use +`enabled: false` with complete input. [Conditional Queries](/effect-api-query/guides/conditional-queries/) +covers sentinel, suspense and prefetch restrictions. ## Load pages -For an endpoint `users.list` with decoded query fields `cursor: number` and `filter: string`, and -a response `{ items: User[]; nextCursor: number | null }`, map each page parameter to a complete -request: +For `users.list` declaring `cursor` and `filter` query fields and returning +`{ items, nextCursor }`, build options in the query consumer: ```ts -import { useInfiniteQuery } from '@tanstack/react-query' - const filter = 'active' const pageOptions = http.users.list.infiniteOptions({ initialPageParam: 0, @@ -146,30 +125,19 @@ const pageOptions = http.users.list.infiniteOptions({ getNextPageParam: (page) => page.nextCursor ?? undefined, select: (data) => data.pages.flatMap((page) => page.items), }) - -const users = useInfiniteQuery(pageOptions) ``` -TypeScript infers `cursor` from `initialPageParam`. Return every declared request container from -`input`, including any `params`, `headers`, or `payload`; each page is a fresh decoded HTTP request. -Use `users.fetchNextPage()` when `users.hasNextPage` is true. You control cursor progression through -`getNextPageParam`: return the server's next cursor, or `undefined` or `null` when there are no more -pages. - -The cache key uses `input(initialPageParam)` and an `infinite` discriminator. Keep all stable -filters in the initial request and every later request. Rebuild the options when a filter -changes so the first request produces a different key. Keep `input` deterministic and free of -side effects: it runs during key construction and again for page execution. A custom key encoder -must preserve these same filters because they affect results. - -TanStack owns page storage, invalidation, and refetching. `select` changes the hook result to the -flattened users; the cache still holds `pages` and `pageParams`. Each page uses the same response -normalization, wrapped failures, and cancellation as an ordinary HTTP query. To pause pagination, use -`input: skipToken` with `initialPageParam` and `getNextPageParam`; the infinite builder accepts -only this object form for skipping. +Pass `pageOptions` to the native infinite hook. The initial mapped request determines identity; +keep stable filters in every page and rebuild options when they change. The mapper runs during +key construction and execution, so keep it deterministic and return all declared containers. +`select` changes the observer result; cache data retains `pages` and `pageParams`. +See [HTTP pagination](/effect-api-query/reference/http-factory/#pagination) for skipping, +page inference and execution contracts. ## Create a user and refresh the cache +In the mutation module, prepare the write and refresh its affected branch: + ```ts const createUser = new MutationObserver( queryClient, @@ -177,22 +145,20 @@ const createUser = new MutationObserver( onSuccess: () => queryClient.invalidateQueries({ queryKey: http.users.key() }), }), ) +``` + +In an event handler, supply this execution's complete decoded request: +```ts await createUser.mutate({ payload: { name: 'Ada' } }) ``` -Mutation variables use the same decoded request shape as query input. Choose the invalidation -prefix for the data your write changes. React applications can pass the same mutation options to -`useMutation`. - -Both builders return decoded response data. Successful `undefined` query data becomes `null`; -mutation results retain `undefined`. Inspect execution failures with -[`isEffectHttpApiQueryError`](/effect-api-query/guides/handle-failures/#inspect-http-failures). +Native `useMutation` accepts the same options. Successful `undefined` query data becomes +`null`; mutation results keep `undefined`. See [Handle Failures](/effect-api-query/guides/handle-failures/#inspect-http-failures). ## Upload a file -Declare a buffered multipart payload in your shared API definition. For example, add this endpoint -to the `users` group and include it in the server implementation: +In the shared contract module, declare the buffered multipart payload: ```ts import { Schema } from 'effect' @@ -206,9 +172,8 @@ const upload = HttpApiEndpoint.post('upload', '/users/:id/files', { }) ``` -Create the utilities from the updated API and ready client. This endpoint exposes `key()`, -`mutationKey()`, and `mutationOptions()`. Build `FormData` explicitly and supply every other declared -request container in its decoded type: +In the mutation module, use the updated API's utilities. In the upload handler, build `FormData` +explicitly and pass it with all other declared containers: ```ts const uploadFile = new MutationObserver( @@ -223,19 +188,17 @@ payload.set('file', new Blob(['profile notes'], { type: 'text/plain' }), 'notes. const uploaded = await uploadFile.mutate({ params: { id: 1 }, payload }) ``` -The adapter forwards the `FormData` to the ready client and returns the decoded success. React -applications can use these options with `useMutation`; callback variables retain the complete request -type. Request encoding and middleware still use your application's client and runner. - -A buffered multipart alternative makes the whole endpoint mutation-only, even when it also accepts -plain payloads. Mixed alternatives retain Effect's client request union. Leave the endpoint out of -`keyEncoders`: mutation keys contain no request variables, and encoder entries are rejected. Any -streaming success or streaming multipart alternative omits the whole endpoint. +The ready client and runner own encoding/middleware; the adapter returns decoded success. +Any buffered multipart alternative makes the whole endpoint mutation-only. Leave it out of +`keyEncoders`: mutation keys contain no variables, so encoder entries are rejected. Streaming +success or streaming multipart alternatives omit the whole endpoint. +The [multipart runtime recipe](https://github.com/ueberBrot/effect-api-query/blob/main/tests/http-multipart.test.ts) +contains the complete contract and execution. ## Retain SSE events -An endpoint with one `HttpApiSchema.StreamSse` success exposes accumulated and live query builders. For a `watch` endpoint whose query declares `channel: Schema.String`, -retain its latest 100 decoded events: +For one `HttpApiSchema.StreamSse` success declaring a `channel` query field, build observation +options in the query consumer: ```ts const events = http.events.watch.streamedOptions({ @@ -244,77 +207,45 @@ const events = http.events.watch.streamedOptions({ refetchMode: 'reset', sseOptions: { maxEventSize: 1024 * 1024 }, }) - -const observer = new QueryObserver(queryClient, events) -const unsubscribe = observer.subscribe((result) => { - renderEvents(result.data ?? []) -}) ``` -Import `QueryObserver` from `@tanstack/query-core`, or pass the options to your framework's -query hook. Replace `renderEvents` with your application's rendering callback. The first event -makes the query successful while it remains fetching. Unsubscribe and cancel the owning queries -before disposing the ready client and runtime. - -The cache holds decoded events in order. Omit `maxChunks` for unlimited history; use a positive -safe integer to bound retention. `reset` starts a fresh history on refetch, `append` extends the -existing history, and `replace` keeps the previous history visible until the refetch completes. -Empty completion succeeds with an empty or retained history according to the refetch mode. -Accumulated arrays preserve decoded `undefined` elements. A declared `WithHeaders` success wraps -each event with the response's decoded headers. - -Keep `sseOptions` beside `input`. Its `maxEventSize` limits pending SSE parser text in JavaScript -string code units and defaults to 10 MiB. The same request with different effective decoder, -retention, or refetch policies has a different cache key. To read that exact entry, pass the -same policy to `streamedKey(input, policy)`; inputless endpoints accept policy alone. -`key()` remains a prefix matching every policy. Input-bearing builders also support `skipToken`. - -Declared request headers, such as a resume cursor, remain inside `input.headers` and contribute -to request identity. The ready client handles SSE decoding and failures; the adapter preserves -complete Causes, including `Sse.Retry`. Your application owns reconnection and resume policy. -Raw byte streams, mixed buffered/SSE successes, and SSE endpoints with multipart payloads are -omitted. Consume unsupported streams directly through the ready client. +Pass `events` to the framework query hook or `QueryObserver`. The first emission is successful +data while fetching continues. `maxChunks` bounds cached elements; omit it for unlimited +history. It does not bound transport buffering. `reset`, `append` and `replace` choose refetch +history; an open `replace` keeps old data until completion. Accumulated elements retain +`undefined` and decoded header wrappers. + +`sseOptions.maxEventSize` bounds pending parser text in code units (default 10 MiB). +Decoder and history policy affect exact identity; use the same policy with `streamedKey`, or +reuse `events.queryKey`. Declared resume headers belong in `input.headers`. Reconnection, +`Sse.Retry` and resume policy remain application-owned. See +[HTTP streams](/effect-api-query/reference/http-factory/#accumulated-sse-options) for full policies, +failures and omitted response shapes. ## Keep the latest SSE value -Use `liveOptions` when a stream emits snapshots and only the latest value belongs in the cache: +For snapshot-like emissions, in the same query consumer: ```ts const latest = http.events.watch.liveOptions({ input: { query: { channel: 'news' } }, sseOptions: { maxEventSize: 1024 * 1024 }, }) - -const observer = new QueryObserver(queryClient, latest) -const unsubscribe = observer.subscribe((result) => { - if (result.status === 'success') renderLatest(result.data) -}) ``` -Replace `renderLatest` with your application's rendering callback, or pass `latest` to your -framework's query hook. The first emission makes the query successful while it remains fetching. -Each later emission replaces the cached value; completion preserves the last value. A top-level -`undefined` emission becomes `null`, including after a defined value. Decoded header wrappers keep -their body unchanged, so a wrapped `undefined` body remains `undefined`. +Pass `latest` to a native query hook or observer. Each emission replaces cached data; completion +preserves it. Top-level `undefined` becomes `null`; a decoded header wrapper keeps its body. +Empty completion raises `EffectHttpApiQueryEmptyStreamError`. Transport/decoding/event failures +and independent interruption preserve their full Cause. -Completion before any emission raises `EffectHttpApiQueryEmptyStreamError`. Transport, decoding, -declared event, and independent interruption failures retain the complete Cause in -`EffectHttpApiQueryError` with operation `live`. Query cancellation closes consumption and follows -native cache reversion; unsubscribe and cancel active queries before disposing client resources. - -`liveKey(input, { sseOptions })` builds the matching exact key. Inputless endpoints accept decoder -policy alone. Live and accumulated views use separate cache entries; the endpoint's `key()` -selects both views and every decoder policy. Live queries retain one value and accept no history -bound or accumulated refetch mode. Input-bearing builders support `skipToken`. - -The ready client owns the SSE connection. Handle `Sse.Retry` and its resume cursor through your -application's retry policy, and supply a declared cursor in `input.headers` when reconnecting. -New cursor values produce new request identity. Raw bytes, mixed buffered/SSE successes, and -multipart SSE endpoints remain omitted. +Use `latest.queryKey` or `liveKey(input, { sseOptions })` for exact access. Live views accept no +history bound/refetch mode and remain separate from accumulated views. Both support skipped +input and endpoint-prefix filtering. Unsubscribe, cancel and drain local work before disposal; +other observers retain their subscriptions. See [Client Lifecycle](/effect-api-query/concepts/client-lifecycle/). ## Keep cache entries separate -Use generated keys for individual queries or whole branches: +In a cache callback or event handler: ```ts const key = http.users.get.queryKey({ params: { id: 1 } }) @@ -322,21 +253,13 @@ const cachedUser = queryClient.getQueryData(key) await queryClient.invalidateQueries({ queryKey: http.users.key() }) ``` -If client middleware changes results by user or tenant, include a safe user or tenant identifier in -`keyPrefix`. Keep credentials out of keys. Use a -[custom encoder](/effect-api-query/guides/custom-key-encoders/#http-requests) when request schemas -need encoding services, contain redacted values, or allow multiple payload alternatives. - -Buffered HTTP endpoints without multipart expose ordinary query, infinite query, and mutation -builders regardless of HTTP method. Choose the builder for the operation you intend. Buffered -multipart endpoints expose mutations only. A single SSE success exposes accumulated and live streamed -queries; raw byte streams, mixed buffered/SSE successes, and streaming multipart requests are omitted; see the [HTTP factory reference](/effect-api-query/reference/http-factory/) -for supported request formats and the complete builder contract. - -When your application or server request ends, cancel its active queries and clear its QueryClient -before disposing client and runtime resources. See -[Client Lifecycle](/effect-api-query/concepts/client-lifecycle/#http-clients-and-execution-services). +Include safe user/tenant identity in `keyPrefix` when middleware changes results. Keep credentials +out of keys; use [custom encoders](/effect-api-query/guides/custom-key-encoders/#http-requests) +when default request encoding cannot supply safe JSON identity. -The [public HTTP consumer](https://github.com/ueberBrot/effect-api-query/blob/main/tests/types/http-contract.ts) checks query, mutation, -pagination, and skipped-input types. The [Vite React HTTP panel](https://github.com/ueberBrot/effect-api-query/blob/main/examples/vite-react/src/components/sections/http-section.tsx) -shows the contract, ready client, hooks, and cache invalidation in a running application. +The [running React HTTP panel](https://github.com/ueberBrot/effect-api-query/blob/main/examples/vite-react/src/components/sections/http-section.tsx) +shows buffered users and diagnostics. Advanced type recipes are in +[http-contract.ts](https://github.com/ueberBrot/effect-api-query/blob/main/tests/types/http-contract.ts) and +[http-stream-contract.ts](https://github.com/ueberBrot/effect-api-query/blob/main/tests/types/http-stream-contract.ts); +[metadata runtime recipes](https://github.com/ueberBrot/effect-api-query/blob/main/tests/http-cached-views.test.ts) +cover metadata behavior. diff --git a/apps/docs/src/content/docs/guides/hydrate-query-views.md b/apps/docs/src/content/docs/guides/hydrate-query-views.md index 1cbede1..49afbfe 100644 --- a/apps/docs/src/content/docs/guides/hydrate-query-views.md +++ b/apps/docs/src/content/docs/guides/hydrate-query-views.md @@ -7,7 +7,7 @@ Choose one codec for each cached representation. A success Schema describes a de infinite pages also contain page parameters, and HTTP metadata adds an envelope. Keep decoded values in the Query Client and encode only the snapshot sent to the browser. -This recipe extends the [paired Profile codec](/effect-api-query/guides/hydrate-unary-data/). +This task extends the [paired Profile codec](/effect-api-query/guides/hydrate-unary-data/). The server and browser own independent ready clients, runtimes, Scopes, and Query Clients. Use the same contract, input, key prefix, and Query Client hashing defaults on both sides. @@ -27,16 +27,28 @@ Choose `codecId` from the map below; add your owner identity after it when data | `wrapped-history` | Ordered decoded HTTP header wrappers | | `wrapped-live` | Latest decoded HTTP header wrapper | -Save this as `docs-hydration-views.ts` beside `docs-hydration-rich.ts` from the paired-codec guide: +Define the view codecs in the application's hydration module. Import `Schema` and +`SchemaTransformation` from `effect`; reuse the paired `Profile` codec. The +[complete typed recipe](https://github.com/ueberBrot/effect-api-query/blob/main/tests/types/docs-hydration-views.ts) +includes wrapper codecs, codec dispatch, and awaited snapshot preparation. + +Use the linked module's `Cursor` and `BufferedProfile` declarations alongside these codecs. +For infinite data, encode both pages and page parameters: ```ts -import { defaultShouldDehydrateQuery, dehydrate, hydrate } from '@tanstack/query-core' -import type { DehydratedState, QueryClient, QueryKey } from '@tanstack/query-core' -import { Context, Effect, Schema, SchemaTransformation } from 'effect' -import { HttpApiSchema } from 'effect/http-api' +export const ProfilePage = Schema.Struct({ + rows: Schema.Array(Profile), + next: Schema.NullOr(Schema.Finite), +}) +const ProfilePages = Schema.Struct({ + pages: Schema.Array(ProfilePage), + pageParams: Schema.Array(Cursor), +}) +``` -import { Profile } from './docs-hydration-rich.ts' +For history, preserve an undefined element separately from a real `null`: +```ts const UndefinedElement = Schema.Struct({ _tag: Schema.Literal('Undefined') }).pipe( Schema.decodeTo( Schema.Undefined, @@ -49,154 +61,18 @@ const UndefinedElement = Schema.Struct({ _tag: Schema.Literal('Undefined') }).pi export const HistoryElement = Schema.Union([Profile, Schema.Null, UndefinedElement]) const ProfileHistory = Schema.Array(HistoryElement) +``` -export class Cursor extends Schema.Class('Cursor')({ offset: Schema.BigIntFromString }) { - isStart(): boolean { - return this.offset === 0n - } -} - -export const ProfilePage = Schema.Struct({ - rows: Schema.Array(Profile), - next: Schema.NullOr(Schema.Finite), -}) -const ProfilePages = Schema.Struct({ - pages: Schema.Array(ProfilePage), - pageParams: Schema.Array(Cursor), -}) +For HTTP metadata, encode the decoded response wrapper and the metadata envelope: -export class ArchivedProfile extends Schema.Class('ArchivedProfile')({ - archived: Schema.Literal(true), - name: Schema.String, - credits: Schema.BigIntFromString, -}) { - summary(): string { - return `${this.name}: ${this.credits} archived credits` - } -} - -const DecodedHeaders = Schema.Struct({ 'x-version': Schema.FiniteFromString }) -const WrappedElement = Schema.Struct({ body: HistoryElement, headers: DecodedHeaders }).pipe( - Schema.decodeTo( - Schema.toType(HttpApiSchema.WithHeaders(HistoryElement, DecodedHeaders)), - SchemaTransformation.transform({ - decode: HttpApiSchema.withHeaders, - encode: ({ body, headers }) => ({ body, headers }), - }), - ), -) -const BufferedBody = Schema.Union([Profile, ArchivedProfile]) -const BufferedProfile = Schema.Struct({ - body: BufferedBody, - headers: DecodedHeaders, -}).pipe( - Schema.decodeTo( - Schema.toType(HttpApiSchema.WithHeaders(BufferedBody, DecodedHeaders)), - SchemaTransformation.transform({ - decode: HttpApiSchema.withHeaders, - encode: ({ body, headers }) => ({ body, headers }), - }), - ), -) +```ts const ProfileMetadata = Schema.Struct({ data: BufferedProfile, status: Schema.Finite, headers: Schema.Record(Schema.String, Schema.String), }) - -export class SnapshotEncoding extends Context.Service< - SnapshotEncoding, - { - readonly beforeEncode: Effect.Effect - } ->()('SnapshotEncoding') {} - -export class SnapshotDecoding extends Context.Service< - SnapshotDecoding, - { - readonly beforeDecode: Effect.Effect - } ->()('SnapshotDecoding') {} - -const prepared = >(schema: S) => - schema.pipe( - Schema.middlewareEncoding((encoding) => - Effect.flatMap(SnapshotEncoding, ({ beforeEncode }) => - Effect.andThen(beforeEncode, encoding), - ), - ), - Schema.middlewareDecoding((decoding) => - Effect.flatMap(SnapshotDecoding, ({ beforeDecode }) => - Effect.andThen(beforeDecode, decoding), - ), - ), - ) - -const codecs = { - history: prepared(ProfileHistory), - infinite: prepared(ProfilePages), - live: prepared(Schema.NullOr(Profile)), - buffered: prepared(BufferedProfile), - metadata: prepared(ProfileMetadata), - 'wrapped-history': prepared(Schema.Array(WrappedElement)), - 'wrapped-live': prepared(WrappedElement), -} -const ViewId = Schema.Literals([ - 'history', - 'infinite', - 'live', - 'buffered', - 'metadata', - 'wrapped-history', - 'wrapped-live', -]) - -const codecFor = Effect.fnUntraced(function* (key: QueryKey) { - yield* Schema.decodeUnknownEffect(Schema.Literal('hydration-views'))(key[0]) - const id = yield* Schema.decodeUnknownEffect(ViewId)(key[1]) - return codecs[id] -}) - -export const prepareViewSnapshot = Effect.fnUntraced(function* (queryClient: QueryClient) { - const snapshot = dehydrate(queryClient, { - shouldDehydrateMutation: () => false, - shouldDehydrateQuery: (query) => - defaultShouldDehydrateQuery(query) && query.queryKey[0] === 'hydration-views', - }) - const queries = yield* Effect.forEach( - snapshot.queries, - Effect.fnUntraced(function* (query) { - const codec = yield* codecFor(query.queryKey) - const data = yield* Schema.encodeUnknownEffect(codec)(query.state.data) - return { ...query, state: { ...query.state, data } } - }), - ) - return JSON.stringify({ ...snapshot, queries }) -}) - -export const prepareViewHydration = Effect.fnUntraced(function* ( - queryClient: QueryClient, - json: string, -) { - const snapshot: DehydratedState = JSON.parse(json) - const queries = yield* Effect.forEach( - snapshot.queries, - Effect.fnUntraced(function* (query) { - const codec = yield* codecFor(query.queryKey) - const data = yield* Schema.decodeUnknownEffect(codec)(query.state.data) - return { ...query, state: { ...query.state, data } } - }), - ) - yield* Effect.sync(() => hydrate(queryClient, { ...snapshot, queries })) -}) ``` -The map belongs to the application: it knows which page-parameter policy, declared success union, -and public metadata each view needs. TanStack's `serializeData` callback receives data alone. -Use the query key in an explicit preparation stage to choose among these codecs. - -## Preserve page parameters and stream values - Use a `Cursor` as `infiniteOptions.initialPageParam`. Return the next Cursor from `getNextPageParam`, and let the deterministic `input` mapper convert its offset into your contract's request input. `ProfilePages` encodes both arrays: its page parameters become decimal @@ -238,8 +114,8 @@ json)` before querying or mounting observers. Encoding and decoding require sepa one cannot satisfy the other. All selected data decodes before a single native `hydrate` call, so invalid codec data or an unknown codec ID leaves the browser cache untouched. -This copied recipe accepts your own trusted server's native dehydrated envelope. It validates -selected query data; it is not a validator for arbitrary external TanStack snapshots. Handle +The preparation helpers accept the matching trusted server's native dehydrated envelope and +validate selected query data. Validate arbitrary external snapshots separately. Handle malformed JSON and preparation failures in the application's rendering or hydration error path. Only successful queries in the reserved family enter the snapshot. Mutations and failed-query diff --git a/apps/docs/src/content/docs/guides/hydrate-unary-data.md b/apps/docs/src/content/docs/guides/hydrate-unary-data.md index 05b77af..89906ee 100644 --- a/apps/docs/src/content/docs/guides/hydrate-unary-data.md +++ b/apps/docs/src/content/docs/guides/hydrate-unary-data.md @@ -17,56 +17,19 @@ Use a JSON-safe success schema when components need plain data. The contract bel as decimal strings and binary data as base64 strings. Return this DTO from the server operation; the ready RPC or HTTP client produces the same DTO on the server and browser. -Save the following as `docs-hydration-dto.ts`: +In the shared contract module, import `Schema` from `effect` and declare the DTO: ```ts -import { defaultShouldDehydrateQuery, dehydrate, hydrate } from '@tanstack/query-core' -import type { QueryClient } from '@tanstack/query-core' -import { Schema } from 'effect' -import { createHttpApiQueryUtils, createRpcQueryUtils } from 'effect-api-query' -import { HttpApi, HttpApiClient, HttpApiEndpoint, HttpApiGroup } from 'effect/http-api' -import { Rpc, RpcClient, RpcGroup } from 'effect/rpc' - const ProfileDto = Schema.Struct({ name: Schema.String, credits: Schema.String, avatar: Schema.String.check(Schema.isBase64()), }) - -const dtoGroup = RpcGroup.make(Rpc.make('profile.read', { success: ProfileDto })) -const dtoApi = HttpApi.make('dto').add( - HttpApiGroup.make('profile').add( - HttpApiEndpoint.get('read', '/profile', { success: ProfileDto }), - ), -) - -function dtoRpcOptions(client: RpcClient.RpcClient.Flat, E>) { - return createRpcQueryUtils(dtoGroup, { - client, - keyPrefix: ['dto', 'rpc'], - }).profile.read.queryOptions({ retry: false, staleTime: 60_000 }) -} - -function dtoHttpOptions(client: HttpApiClient.ForApi) { - return createHttpApiQueryUtils(dtoApi, { - client, - keyPrefix: ['dto', 'http'], - }).profile.read.queryOptions({ retry: false, staleTime: 60_000 }) -} - -const snapshotDto = (queryClient: QueryClient): string => - JSON.stringify( - dehydrate(queryClient, { - shouldDehydrateMutation: () => false, - shouldDehydrateQuery: (query) => - defaultShouldDehydrateQuery(query) && query.queryKey[0] === 'dto', - }), - ) - -const hydrateDto = (queryClient: QueryClient, json: string): void => - hydrate(queryClient, JSON.parse(json)) ``` +The [complete DTO recipe](https://github.com/ueberBrot/effect-api-query/blob/main/tests/types/docs-hydration-dto.ts) +connects this schema to both factories and provides `snapshotDto` and `hydrateDto`. + Prefetch with `queryClient.query(dtoRpcOptions(rpcClient))` or the HTTP equivalent, then publish `snapshotDto(queryClient)` through your framework's JSON transport. Call `hydrateDto` on the browser's Query Client before its first query. The 60-second freshness window avoids an immediate @@ -83,16 +46,9 @@ the snapshot boundary and decode during hydration. This `Profile` encodes bigint string and its `Uint8Array` avatar as base64. Decoding reconstructs the class and its `summary` method. -Save the following as `docs-hydration-rich.ts`: +In the shared contract module, define the rich success schema: ```ts -import { defaultShouldDehydrateQuery, dehydrate, hydrate } from '@tanstack/query-core' -import type { QueryClient } from '@tanstack/query-core' -import { Schema } from 'effect' -import { createHttpApiQueryUtils, createRpcQueryUtils } from 'effect-api-query' -import { HttpApi, HttpApiClient, HttpApiEndpoint, HttpApiGroup } from 'effect/http-api' -import { Rpc, RpcClient, RpcGroup } from 'effect/rpc' - export class Profile extends Schema.Class('Profile')({ name: Schema.String, credits: Schema.BigIntFromString, @@ -102,48 +58,14 @@ export class Profile extends Schema.Class('Profile')({ return `${this.name}: ${this.credits} credits` } } - -const profileGroup = RpcGroup.make( - Rpc.make('profile.read', { success: Profile, error: Schema.String }), -) -const profileApi = HttpApi.make('rich').add( - HttpApiGroup.make('profile').add( - HttpApiEndpoint.get('read', '/profile', { success: Profile, error: Schema.String }), - ), -) - -function profileRpcOptions( - client: RpcClient.RpcClient.Flat, E>, -) { - return createRpcQueryUtils(profileGroup, { - client, - keyPrefix: ['profile', 'rpc'], - }).profile.read.queryOptions({ retry: false, staleTime: 60_000 }) -} - -function profileHttpOptions(client: HttpApiClient.ForApi) { - return createHttpApiQueryUtils(profileApi, { - client, - keyPrefix: ['profile', 'http'], - }).profile.read.queryOptions({ retry: false, staleTime: 60_000 }) -} - -const snapshotProfile = (queryClient: QueryClient): string => - JSON.stringify( - dehydrate(queryClient, { - shouldDehydrateMutation: () => false, - shouldDehydrateQuery: (query) => - defaultShouldDehydrateQuery(query) && query.queryKey[0] === 'profile', - serializeData: Schema.encodeUnknownSync(Profile), - }), - ) - -const hydrateProfile = (queryClient: QueryClient, json: string): void => - hydrate(queryClient, JSON.parse(json), { - defaultOptions: { deserializeData: Schema.decodeUnknownSync(Profile) }, - }) ``` +In the server snapshot and browser hydration modules, pair +`serializeData: Schema.encodeUnknownSync(Profile)` with +`defaultOptions: { deserializeData: Schema.decodeUnknownSync(Profile) }`. +The [complete rich-value recipe](https://github.com/ueberBrot/effect-api-query/blob/main/tests/types/docs-hydration-rich.ts) +provides `snapshotProfile`, `hydrateProfile`, and options for both factories. + Prefetch with `profileRpcOptions` or `profileHttpOptions`, publish `snapshotProfile`, then call `hydrateProfile` on the browser's Query Client. The hydrated value and a later browser refetch both have the `Profile` prototype, bigint credits, and byte array. Only the snapshot contains the @@ -165,50 +87,12 @@ When a codec needs services or asynchronous work, prepare the entire snapshot ou synchronous callbacks. This example adds a caller-owned preparation service to the same Profile codec. Its hooks can await application work before encoding or decoding. -Save the following alongside `docs-hydration-rich.ts`: +The application's hydration module decodes all entries before changing the cache. +With `PreparedProfile` and its services from the +[complete asynchronous recipe](https://github.com/ueberBrot/effect-api-query/blob/main/tests/types/docs-hydration-async.ts), +that boundary is: ```ts -import { defaultShouldDehydrateQuery, dehydrate, hydrate } from '@tanstack/query-core' -import type { DehydratedState, QueryClient } from '@tanstack/query-core' -import { Context, Effect, Schema } from 'effect' - -import { Profile } from './docs-hydration-rich.ts' - -class HydrationPreparation extends Context.Service< - HydrationPreparation, - { - readonly beforeEncode: Effect.Effect - readonly beforeDecode: Effect.Effect - } ->()('HydrationPreparation') {} - -const PreparedProfile = Profile.pipe( - Schema.middlewareEncoding((encoding) => - Effect.flatMap(HydrationPreparation, ({ beforeEncode }) => - Effect.andThen(beforeEncode, encoding), - ), - ), - Schema.middlewareDecoding((decoding) => - Effect.flatMap(HydrationPreparation, ({ beforeDecode }) => - Effect.andThen(beforeDecode, decoding), - ), - ), -) - -const prepareProfileSnapshot = Effect.fnUntraced(function* (queryClient: QueryClient) { - const snapshot = dehydrate(queryClient, { - shouldDehydrateMutation: () => false, - shouldDehydrateQuery: (query) => - defaultShouldDehydrateQuery(query) && query.queryKey[0] === 'profile', - }) - const queries = yield* Effect.forEach(snapshot.queries, (query) => - Schema.encodeUnknownEffect(PreparedProfile)(query.state.data).pipe( - Effect.map((data) => ({ ...query, state: { ...query.state, data } })), - ), - ) - return JSON.stringify({ ...snapshot, queries }) -}) - const prepareProfileHydration = Effect.fnUntraced(function* ( queryClient: QueryClient, json: string, diff --git a/apps/docs/src/content/docs/guides/query-defaults.md b/apps/docs/src/content/docs/guides/query-defaults.md index ee4b1e3..b180037 100644 --- a/apps/docs/src/content/docs/guides/query-defaults.md +++ b/apps/docs/src/content/docs/guides/query-defaults.md @@ -17,49 +17,37 @@ This example disables retries globally, retries reads in the `users` branch twic `users.get` query view fresh for two minutes. The call-site `refreshUser` options request an immediate fetch without retries. `user` selects a name while the cache retains the full user. +In the application cache module, import `QueryClient` from `@tanstack/query-core`: + +```ts +const queryClient = new QueryClient({ + defaultOptions: { + queries: { staleTime: 30_000, gcTime: 600_000, retry: false, retryDelay: 250 }, + mutations: { retry: false }, + }, +}) +``` + +After creating `rpc` with your ready client, register matching defaults: + +```ts +queryClient.setQueryDefaults(rpc.key(), { staleTime: 60_000 }) +queryClient.setQueryDefaults(rpc.users.key(), { retry: 2 }) +queryClient.setQueryDefaults([...rpc.users.get.key(), 'query'], { staleTime: 120_000 }) +``` + +In the query consumer, omit inherited settings or override them for this call: + ```ts -import { QueryClient } from '@tanstack/query-core' -import { Schema } from 'effect' -import { createRpcQueryUtils } from 'effect-api-query' -import { Rpc, RpcGroup } from 'effect/rpc' -import type { RpcClient } from 'effect/rpc' - -export const usersRpc = RpcGroup.make( - Rpc.make('users.get', { - payload: { id: Schema.Int }, - success: Schema.Struct({ id: Schema.Int, name: Schema.String }), - error: Schema.TaggedStruct('RetryLater', {}), - }), -) - -export const createApplicationQueries = ( - client: RpcClient.RpcClient.Flat, ClientError>, -) => { - const rpc = createRpcQueryUtils(usersRpc, { - client, - keyPrefix: ['users-app'], - }) - const queryClient = new QueryClient({ - defaultOptions: { - queries: { staleTime: 30_000, gcTime: 600_000, retry: false, retryDelay: 250 }, - mutations: { retry: false }, - }, - }) - queryClient.setQueryDefaults(rpc.key(), { staleTime: 60_000 }) - queryClient.setQueryDefaults(rpc.users.key(), { retry: 2 }) - queryClient.setQueryDefaults([...rpc.users.get.key(), 'query'], { staleTime: 120_000 }) - - const user = rpc.users.get.queryOptions({ - input: { id: 1 }, - select: (value) => value.name, - }) - const refreshUser = rpc.users.get.queryOptions({ - input: { id: 1 }, - staleTime: 0, - retry: false, - }) - return { queryClient, rpc, user, refreshUser } -} +const user = rpc.users.get.queryOptions({ + input: { id: 1 }, + select: (value) => value.name, +}) +const refreshUser = rpc.users.get.queryOptions({ + input: { id: 1 }, + staleTime: 0, + retry: false, +}) ``` TanStack merges the QueryClient's global query defaults, every matching registered prefix in @@ -85,41 +73,22 @@ HTTP prefixes use the same native APIs. This example retries the `users` group o five-second stale time only to the metadata view, and hashes every view through QueryClient's root-prefix policy. The ordinary query retains the global thirty-second stale time. +In the same application cache module, with an existing `http` tree: + +```ts +queryClient.setQueryDefaults(http.key(), { + queryKeyHashFn: (key) => `users:${JSON.stringify(key)}`, +}) +queryClient.setQueryDefaults(http.users.key(), { retry: 1 }) +queryClient.setQueryDefaults([...http.users.get.key(), 'metadata'], { staleTime: 5_000 }) +``` + +In its query consumer: + ```ts -import { QueryClient } from '@tanstack/query-core' -import { Schema } from 'effect' -import { createHttpApiQueryUtils } from 'effect-api-query' -import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from 'effect/http-api' -import type { HttpApiClient } from 'effect/http-api' - -export const usersApi = HttpApi.make('users').add( - HttpApiGroup.make('users').add( - HttpApiEndpoint.get('get', '/users/:id', { - params: { id: Schema.Int }, - success: Schema.Struct({ id: Schema.Int, name: Schema.String }), - error: Schema.TaggedStruct('RetryLater', {}).pipe(HttpApiSchema.status(503)), - }), - ), -) - -export const createHttpApplicationQueries = (client: HttpApiClient.ForApi) => { - const http = createHttpApiQueryUtils(usersApi, { client, keyPrefix: ['users-app'] }) - const queryClient = new QueryClient({ - defaultOptions: { - queries: { staleTime: 30_000, retry: false, retryDelay: 250 }, - }, - }) - queryClient.setQueryDefaults(http.key(), { - queryKeyHashFn: (key) => `users:${JSON.stringify(key)}`, - }) - queryClient.setQueryDefaults(http.users.key(), { retry: 1 }) - queryClient.setQueryDefaults([...http.users.get.key(), 'metadata'], { staleTime: 5_000 }) - - const input = { params: { id: 1 } } - const user = http.users.get.queryOptions({ input }) - const metadata = http.users.get.metadataOptions({ input }) - return { queryClient, http, user, metadata } -} +const input = { params: { id: 1 } } +const user = http.users.get.queryOptions({ input }) +const metadata = http.users.get.metadataOptions({ input }) ``` The ordinary and metadata views have separate keys. Configure global or prefix `queryKeyHashFn` @@ -133,15 +102,14 @@ Use the generated exact `queryKey(input)` or `metadataKey(input)` with native `g the observer result, while a native write still supplies the complete user. A fresh native write is visible to observers and subsequent imperative reads without another client execution. -Fetched metadata snapshots inherit QueryClient's global or prefix `structuralSharing` policy. -Selected metadata results and later manual writes use an explicit per-call `metadataOptions` -policy, or standard deep sharing if omitted. Initial, hydrated, manually written, and selected -objects remain application-owned. Decoded data is not deep-frozen. See -[Buffered metadata](/effect-api-query/reference/http-factory/#buffered-metadata) for that boundary. +Fetched metadata follows native sharing defaults, while selected results and later manual writes +use explicit per-call policy or standard deep sharing. See +[Buffered metadata](/effect-api-query/reference/http-factory/#buffered-metadata) for immutable +snapshot and application-owned data rules. An imperative `queryClient.query(newOptions)` hit on fresh cached data retains existing Query -options. Install a changed sharing policy through an observer or an actual fetch before relying -on it for subsequent manual writes. +options. Install a changed sharing policy through an observer or actual fetch before relying +on it for later manual writes. Keep shared policies on QueryClient and operation-specific selection, input, and typed error callbacks on the generated builders. diff --git a/apps/docs/src/content/docs/guides/react-query.md b/apps/docs/src/content/docs/guides/react-query.md index 58a0d27..7726de7 100644 --- a/apps/docs/src/content/docs/guides/react-query.md +++ b/apps/docs/src/content/docs/guides/react-query.md @@ -3,75 +3,69 @@ title: React Query description: Use generated options with React Query hooks. --- -Create the RPC utility tree beside the application's `QueryClient`, then provide both through your -application context. Generated options pass directly to React Query hooks. - -In the example below, `AppRpcQuery` is your utility tree's type, such as `typeof rpcQuery`. - -```tsx -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' - -export const Users = ({ rpcQuery }: { rpcQuery: AppRpcQuery }) => { - const queryClient = useQueryClient() - const users = useQuery(rpcQuery.users.list.queryOptions()) - const removeUser = useMutation( - rpcQuery.users.delete.mutationOptions({ - onSuccess: () => queryClient.invalidateQueries({ queryKey: rpcQuery.users.key() }), - }), - ) - - if (users.data === undefined) return null - - return ( -
    - {users.data.map((user) => ( -
  • - {user.name} - -
  • - ))} -
- ) -} +Use the utility tree created in the application's client module. Import `useQuery`, +`useMutation`, `useSuspenseQuery`, and `useQueryClient` from `@tanstack/react-query`. +The application supplies its Query provider and exposes `rpcQuery` through context or props. + +## Read in a component + +Inside an existing component: + +```ts +const users = useQuery(rpcQuery.users.list.queryOptions()) +const user = useQuery(rpcQuery.users.get.queryOptions({ input: { id: 1 } })) ``` -Payload-bearing queries take constructor input inside the options argument: +Payload-bearing RPC reads take constructor input. Pass native options such as `select`, +`staleTime`, or `retry` to the builder; `select` changes observer data, not the cached value. + +## Write and refresh + +In the same component, create the mutation with an explicit invalidation policy: ```ts -useQuery(rpcQuery.users.get.queryOptions({ input: { id: 1 } })) +const queryClient = useQueryClient() +const removeUser = useMutation( + rpcQuery.users.delete.mutationOptions({ + onSuccess: () => queryClient.invalidateQueries({ queryKey: rpcQuery.users.key() }), + }), +) ``` -Pass mutation variables when calling `mutate`: +In its event handler, supply that execution's variables: ```ts -const createUser = useMutation(rpcQuery.users.create.mutationOptions()) -createUser.mutate({ name: 'Ada' }) +removeUser.mutate({ id: userId }) ``` -Streaming RPCs use the ordinary `useQuery` and `useSuspenseQuery` hooks. Choose whether the cache -should retain every value or only the latest value: +Writes do not automatically refresh reads. Choose the affected generated keys in the application. + +## Observe a stream + +Use ordinary query hooks inside the component for either cached representation: ```ts const events = useQuery(rpcQuery.events.watch.streamedOptions()) const latestEvent = useQuery(rpcQuery.events.watch.liveOptions()) ``` -The package owns each generated `queryFn`, `queryKey`, `mutationFn`, and `mutationKey`. -Stream builders own the same query fields. Configure custom hashing through QueryClient global -or prefix defaults; builders reject per-call `queryKeyHashFn` and `queryHash`. Other TanStack Query -options, including `select`, `retry`, and lifecycle callbacks, retain their normal TanStack types. +`useSuspenseQuery` also accepts these options. Streams expose data while fetching continues; +see [Choose Stream History](/effect-api-query/guides/choose-stream-history/) for retention policy. ## Use an HTTP endpoint -The same hooks accept HTTP options. For an endpoint that declares an `id` parameter, use: +The same hooks accept the complete decoded request: ```ts const user = useQuery(http.users.get.queryOptions({ input: { params: { id: 1 } } })) ``` -Follow [HTTP Queries and Mutations](/effect-api-query/guides/http-queries-and-mutations/) to create -the HTTP utilities and configure mutation invalidation. +See [HTTP Queries and Mutations](/effect-api-query/guides/http-queries-and-mutations/) for +metadata, uploads and SSE. Builders own their key/function fields; configure hashing through +QueryClient defaults. [RPC Builders](/effect-api-query/reference/generated-builders/) and +[HTTP Factory](/effect-api-query/reference/http-factory/) describe the precise option contracts. -The [public RPC consumer](https://github.com/ueberBrot/effect-api-query/blob/main/tests/types/public-contract.ts) and -[public HTTP consumer](https://github.com/ueberBrot/effect-api-query/blob/main/tests/types/http-contract.ts) compile these hook patterns. -The [Vite React example](/effect-api-query/examples/) provides the application setup. +For complete components and resource setup, use the [Vite React example](/effect-api-query/examples/): +[queries](https://github.com/ueberBrot/effect-api-query/blob/main/examples/vite-react/src/components/sections/queries-section.tsx), +[mutations](https://github.com/ueberBrot/effect-api-query/blob/main/examples/vite-react/src/components/sections/mutations-section.tsx), +and [application ownership](https://github.com/ueberBrot/effect-api-query/blob/main/examples/vite-react/src/lib/application.ts). diff --git a/apps/docs/src/content/docs/guides/refresh-from-events.md b/apps/docs/src/content/docs/guides/refresh-from-events.md index 844e310..4da4635 100644 --- a/apps/docs/src/content/docs/guides/refresh-from-events.md +++ b/apps/docs/src/content/docs/guides/refresh-from-events.md @@ -12,54 +12,6 @@ next to your owner, write, and client modules, adjusting its local application i an application-owned Effect Reactivity service. Supply your own decoded network stream; the module creates no connection, broker, provider, or second cache. -## Register one consumer for the owner - -Acquire the consumer once in the owner's Scope. The following attachment accepts a supplied -Effect Stream and preserves its failure and service requirements. Run its `consume` Effect with -your application runtime and keep that Scope open across transport reconnects. - -```ts -import { Effect, Stream } from 'effect' -import type { Reactivity } from 'effect/reactivity' - -import { decodeUserEvent, makeUserEventConsumer } from './user-events.ts' -import type { UserEventOwner } from './user-events.ts' - -const attachUserEvents = Effect.fnUntraced(function* ( - owner: UserEventOwner, - reactivity: Reactivity.Reactivity, -) { - const consumer = yield* makeUserEventConsumer(owner, reactivity, { - observedUsers: [{ id: 1, locale: 'en' }], - }) - const consume = (events: Stream.Stream) => - events.pipe( - Stream.mapEffect((event) => decodeUserEvent(event)), - Stream.runForEach(consumer.deliver), - Effect.ensuring(Effect.promise(consumer.flush)), - ) - return { - consumer, - consume, - latestDiagnostic: owner.rpcQuery.diagnostics.stream.liveOptions(), - diagnosticHistory: owner.rpcQuery.diagnostics.stream.streamedOptions({ - maxChunks: 64, - refetchMode: 'reset', - }), - } -}) -``` - -`makeUserEventConsumer` rejects an inactive owner or a second consumer for the same QueryClient. -Scope closure unregisters Reactivity handlers and the mutation-cache listener. Close the event -Scope, retire the captured owner, and await its disposal before replacing clients; remount the -React consumers as described in [Switch Cache Owners](/effect-api-query/guides/switch-cache-owners/). - -A closed or retired consumer ignores queued delivery and invalidation callbacks. It retains its -original QueryClient and cannot redirect an old event to a replacement client. A refresh already -started belongs to the native query lifecycle; owner retirement cancels those reads and awaits -local cleanup before releasing ready clients. - ## Map events to reads The `users.v1` envelope includes the consumer's `ownerKey`, a positive safe-integer cursor, and one @@ -80,6 +32,33 @@ The `ownerKey` contains safe tenant, user, session, and permission-generation id routing partition, not authentication. The application must authenticate the transport and validate which owner may receive each event. +## Register one consumer for the owner + +`makeUserEventConsumer`, `decodeUserEvent` and `flush` are application helpers from the linked +consumer module. Import `Effect` and `Stream` from `effect`; provide the captured `owner` and +application `reactivity` service. Inside the owner's Effect Scope, acquire once: + +Acquire `consumer` with `yield* makeUserEventConsumer(owner, reactivity, { observedUsers: +[{ id: 1, locale: 'en' }] })`. This registers the owner's selected local detail mappings. + +In that same scope, attach the supplied network stream with its existing service/error channels: + +```ts +const consume = (events: Stream.Stream) => + events.pipe( + Stream.mapEffect((event) => decodeUserEvent(event)), + Stream.runForEach(consumer.deliver), + Effect.ensuring(Effect.promise(consumer.flush)), + ) +``` + +Run `consume(events)` with the application's runtime. The +[complete typed attachment](https://github.com/ueberBrot/effect-api-query/blob/main/tests/types/docs-user-events.ts) +shows composition. It rejects inactive/duplicate owners and unregisters listeners on Scope closure. +A retired consumer ignores late delivery and cannot redirect it to a replacement QueryClient. +Close its Scope, cancel/drain started reads, then release clients before replacing the owner. +See [Switch Cache Owners](/effect-api-query/guides/switch-cache-owners/). + ## Coalesce local invalidation Use the consumer's `reactivityKeys.directory`, `reactivityKeys.all`, or @@ -91,22 +70,12 @@ These keys are stable application strings. Reactivity hashes its own keys; a Tan has no implied Reactivity subscription or prefix relationship. Register the deliberate mapping rather than passing generated tuples to both systems. -Reactivity `withBatch` combines repeated invalidations of the same key. Distinct keys can still -invoke distinct handlers. The consumer collects their intent in one microtask and lets a full -user-branch refresh subsume narrower work. It merges more than 64 pending detail identities into -a full refresh, bounding delivery bookkeeping. Events received during a refresh form the next -batch. - -Refreshes wait while the captured QueryClient has pending mutations, so an event does not start a -read over an optimistic write. The scoped mutation listener schedules retained intent when those -writes settle. Use the same QueryClient for these writes and follow -[Optimistic User Writes](/effect-api-query/guides/optimistic-writes/). Coordinate external writes -and other manual refresh paths through your own application policy. - -`flush()` awaits currently runnable batches. During a pending mutation it leaves intent queued -until settlement. `onRefreshError` receives failures from the refresh job; ordinary native query -failures retain TanStack's query/error state and retry policy. The default refresh-job handler -reports the error to the console. +The consumer coalesces invalidation intent and lets broad refreshes subsume narrow work. +It defers refresh while its captured QueryClient has pending mutations, protecting optimistic +writes; other refresh paths need the same application policy. `flush()` drains runnable batches, +leaving intent queued during pending writes. Refresh-job errors go to `onRefreshError`; query +errors retain native Query retry/state behavior. See the linked consumer for its bookkeeping +bounds and [Optimistic User Writes](/effect-api-query/guides/optimistic-writes/) for overlap rules. ## Resume delivery deliberately @@ -127,16 +96,6 @@ durable or exactly-once delivery. Invalid envelopes fail Schema decoding before ## Choose a visible stream view -The example's diagnostic stream emits complete diagnostic values. `latestDiagnostic` uses a live -query; `diagnosticHistory` keeps at most 64 values with reset refetch behavior. Use the same choices -for an indefinitely running state stream: live queries require complete snapshots, while a bounded -accumulated query can display a recent event history. - -An accumulated query with `refetchMode: 'replace'` publishes its replacement only when the refetch -stream completes. An indefinite stream may never publish that replacement. Use a live full -snapshot or a bounded history with a visibility policy suited to the UI. A live query containing -only the latest delta does not reconstruct domain state. - -Native `Stream.runForEach` runs the supplied stream, and generated keys serve native Query filters. -Keep delivery, replay, and owner policy in the application consumer so its runtime and Scope remain -explicit. +Use a live query for complete snapshots or bounded history for recent events. The latest delta +alone does not reconstruct state. An indefinitely open `replace` stream does not publish a +replacement before completion; see [Choose Stream History](/effect-api-query/guides/choose-stream-history/). diff --git a/apps/docs/src/content/docs/guides/retry-queries.md b/apps/docs/src/content/docs/guides/retry-queries.md index 9e02eac..326ef52 100644 --- a/apps/docs/src/content/docs/guides/retry-queries.md +++ b/apps/docs/src/content/docs/guides/retry-queries.md @@ -10,126 +10,88 @@ interruptions, and rejected runners. ## Inspect the typed failure -Share the declarations with your server and pass your application's ready client to -`rpcReadOptions` or `httpReadOptions`. Both functions return native query options for `useQuery` -or `queryClient.query`. Adapt the allowed tags and transport reasons to your read's contract. +Use the error union declared by your contract and the native builder's inferred callback types. +Import `Cause` and `Result` from `effect`, adapter guards from `effect-api-query`, and +`HttpClientError` from `effect/http`. The +[complete typed recipe](https://github.com/ueberBrot/effect-api-query/blob/main/tests/types/docs-retry.ts) +includes `RetryLater`, `NotFound`, authentication errors and both ready-client factories. + +### RPC retry callback + +In the query-options module, use these statements inside the `retry: (failureCount, error) => { ... }` +callback passed to `rpc.profile.read.queryOptions`. This example allows only an HTTP transport +failure or its declared `RetryLater`: ```ts -import { Cause, Result, Schema } from 'effect' -import { - createHttpApiQueryUtils, - createRpcQueryUtils, - isEffectHttpApiQueryError, - isEffectRpcQueryError, -} from 'effect-api-query' -import { HttpClientError } from 'effect/http' -import { HttpApi, HttpApiClient, HttpApiEndpoint, HttpApiGroup } from 'effect/http-api' -import { Rpc, RpcClient, RpcClientError, RpcGroup } from 'effect/rpc' - -class NotFound extends Schema.TaggedError()('NotFound', {}) {} -class AuthenticationRequired extends Schema.TaggedError()( - 'AuthenticationRequired', - {}, -) {} -class RetryLater extends Schema.TaggedError()('RetryLater', { - retryAfterMs: Schema.Finite, -}) {} - -const ReadError = Schema.Union([NotFound, AuthenticationRequired, RetryLater]) -const readGroup = RpcGroup.make( - Rpc.make('profile.read', { success: Schema.String, error: ReadError }), -) -const readApi = HttpApi.make('profile-api').add( - HttpApiGroup.make('profile').add( - HttpApiEndpoint.get('read', '/profile', { success: Schema.String, error: ReadError }), - ), -) - -const rpcReadOptions = ( - client: RpcClient.RpcClient.Flat, RpcClientError.RpcClientError>, -) => { - const rpc = createRpcQueryUtils< - typeof readGroup, - readonly ['profile-app'], - RpcClientError.RpcClientError - >(readGroup, { client, keyPrefix: ['profile-app'] }) - - return rpc.profile.read.queryOptions({ - retry: (failureCount, error) => { - if (!isEffectRpcQueryError(error) || failureCount >= 2) { - return false - } - if (Cause.hasDies(error.cause) || Cause.hasInterrupts(error.cause)) { - return false - } - const failure = Cause.findError(error.cause) - if (Result.isFailure(failure) || error.cause.reasons.length !== 1) { - return false - } - switch (failure.success._tag) { - case 'RetryLater': - return true - case 'RpcClientError': - return ( - failure.success.reason._tag === 'HttpError' && - failure.success.reason.kind === 'TransportError' - ) - default: - return false - } - }, - retryDelay: (attemptIndex, error) => { - if (isEffectRpcQueryError(error)) { - const failure = Cause.findError(error.cause) - if (Result.isSuccess(failure) && failure.success._tag === 'RetryLater') { - return Math.min(30_000, Math.max(0, failure.success.retryAfterMs)) - } - } - return Math.min(1_000 * 2 ** attemptIndex, 30_000) - }, - }) +if (!isEffectRpcQueryError(error) || failureCount >= 2) { + return false +} +if (Cause.hasDies(error.cause) || Cause.hasInterrupts(error.cause)) { + return false +} +const failure = Cause.findError(error.cause) +if (Result.isFailure(failure) || error.cause.reasons.length !== 1) { + return false +} +switch (failure.success._tag) { + case 'RetryLater': + return true + case 'RpcClientError': + return ( + failure.success.reason._tag === 'HttpError' && + failure.success.reason.kind === 'TransportError' + ) + default: + return false } +``` + +### HTTP retry callback + +For `http.profile.read.queryOptions`, the callback has the same guard stages but a different +transport-error union. Its `retry` callback body is: -const httpReadOptions = (client: HttpApiClient.ForApi) => { - const http = createHttpApiQueryUtils(readApi, { client, keyPrefix: ['profile-app'] }) - - return http.profile.read.queryOptions({ - retry: (failureCount, error) => { - if (!isEffectHttpApiQueryError(error) || failureCount >= 2) { - return false - } - if (Cause.hasDies(error.cause) || Cause.hasInterrupts(error.cause)) { - return false - } - const failure = Cause.findError(error.cause) - if (Result.isFailure(failure) || error.cause.reasons.length !== 1) { - return false - } - switch (failure.success._tag) { - case 'RetryLater': - return true - case 'HttpClientError': - return ( - HttpClientError.isHttpClientError(failure.success) && - failure.success.reason._tag === 'TransportError' - ) - default: - return false - } - }, - retryDelay: (attemptIndex, error) => { - if (isEffectHttpApiQueryError(error)) { - const failure = Cause.findError(error.cause) - if (Result.isSuccess(failure) && failure.success._tag === 'RetryLater') { - return Math.min(30_000, Math.max(0, failure.success.retryAfterMs)) - } - } - return Math.min(1_000 * 2 ** attemptIndex, 30_000) - }, - }) +```ts +if (!isEffectHttpApiQueryError(error) || failureCount >= 2) { + return false +} +if (Cause.hasDies(error.cause) || Cause.hasInterrupts(error.cause)) { + return false +} +const failure = Cause.findError(error.cause) +if (Result.isFailure(failure) || error.cause.reasons.length !== 1) { + return false +} +switch (failure.success._tag) { + case 'RetryLater': + return true + case 'HttpClientError': + return ( + HttpClientError.isHttpClientError(failure.success) && + failure.success.reason._tag === 'TransportError' + ) + default: + return false } ``` +### Retry delay callback + +In the RPC builder's `retryDelay: (attemptIndex, error) => { ... }` body: + +```ts +if (isEffectRpcQueryError(error)) { + const failure = Cause.findError(error.cause) + if (Result.isSuccess(failure) && failure.success._tag === 'RetryLater') { + return Math.min(30_000, Math.max(0, failure.success.retryAfterMs)) + } +} +return Math.min(1_000 * 2 ** attemptIndex, 30_000) +``` + +The complete recipe applies the equivalent guarded delay to HTTP. These callbacks are application +policy for idempotent reads, not a package retry policy. + `Cause.findError` returns a `Result`; after `Result.isSuccess`, `success` retains the declared error union. It extracts the first typed failure. Check defects and interruptions first, and inspect all `cause.reasons` when several failures are meaningful to your policy. This example diff --git a/apps/docs/src/content/docs/guides/solid-query.md b/apps/docs/src/content/docs/guides/solid-query.md index a67cee3..6e00abb 100644 --- a/apps/docs/src/content/docs/guides/solid-query.md +++ b/apps/docs/src/content/docs/guides/solid-query.md @@ -23,7 +23,9 @@ Keep captured values unchanged while their options can execute. See [Data Normalization](/effect-api-query/concepts/data-normalization/#keep-captured-inputs-unchanged) for the distinction between immutable keys and caller-owned input. -In a Solid component, pass the builder call as an accessor: +In component setup, use `rpc` from your [ready client](/effect-api-query/getting-started/quick-start/) +and pass the builder call as an accessor. Here `users.read` accepts an ID and returns a user +with `name`: ```ts import { useQuery } from '@tanstack/solid-query' @@ -56,6 +58,9 @@ An explicit `initialData: undefined` overrides inherited QueryClient initial dat ## Own streams and disposal +Other observers of the same query keep it active. At owner disposal, drain accepted mutations +as well as local stream finalizers before releasing client resources. + Open streams expose their first value while fetching continues. Changing reactive input replaces the query; when its last observer leaves, query cancellation closes the stream iterator. Solid owner disposal also releases observed streams. Keep client resources alive until these queries diff --git a/apps/docs/src/content/docs/guides/stream-snapshots.md b/apps/docs/src/content/docs/guides/stream-snapshots.md index 1312376..b4d06c1 100644 --- a/apps/docs/src/content/docs/guides/stream-snapshots.md +++ b/apps/docs/src/content/docs/guides/stream-snapshots.md @@ -9,60 +9,41 @@ awaits its local iterator cleanup. Ordinary `queryClient.query` waits for an ope ## Capture data before dehydration -Pass generated accumulated or live options to the helper. Keep the ready client and runner alive -until the returned Promise settles. This example captures separate history and latest-value views: +Import `fetchStreamSnapshot` from `effect-api-query`. In the request loader or server snapshot +module, use your existing `rpc`, request-local `queryClient` and abort `signal`. Keep the ready +client and runner alive until capture settles. For the declared `updates.watch` stream: ```ts -import type { QueryClient } from '@tanstack/query-core' -import { Schema } from 'effect' -import { createRpcQueryUtils, fetchStreamSnapshot } from 'effect-api-query' -import { Rpc, RpcGroup } from 'effect/rpc' -import type { RpcClient } from 'effect/rpc' - -const updates = RpcGroup.make( - Rpc.make('updates.watch', { - payload: { channel: Schema.String }, - success: Schema.String, - stream: true, +const input = { channel: 'announcements' } +const history = await fetchStreamSnapshot( + queryClient, + rpc.updates.watch.streamedOptions({ + input, + retry: false, + maxChunks: 20, }), + { signal, timeoutMs: 5_000 }, ) +``` + +In the same request, capture the independent latest-value view: -async function captureUpdates( - queryClient: QueryClient, - client: RpcClient.RpcClient.Flat, E>, - signal: AbortSignal, -) { - const rpc = createRpcQueryUtils(updates, { - client, - keyPrefix: ['updates'], - }) - const input = { channel: 'announcements' } - const history = await fetchStreamSnapshot( - queryClient, - rpc.updates.watch.streamedOptions({ - input, - retry: false, - maxChunks: 20, - }), - { signal, timeoutMs: 5_000 }, - ) - const latest = await fetchStreamSnapshot( - queryClient, - rpc.updates.watch.liveOptions({ - input, - retry: false, - }), - { signal, timeoutMs: 5_000 }, - ) - return { history, latest } -} +```ts +const latest = await fetchStreamSnapshot( + queryClient, + rpc.updates.watch.liveOptions({ + input, + retry: false, + }), + { signal, timeoutMs: 5_000 }, +) ``` -Call `captureUpdates` with a request-local QueryClient, ready client, and request abort signal. -Then dehydrate successful data with TanStack's normal policy and your application's serializer. -For HTTP SSE, pass `streamedOptions` or `liveOptions` from the HTTP utility tree in the same way. -Use [Hydrate Unary Data](/effect-api-query/guides/hydrate-unary-data/) for paired Schema codecs, -including accumulated arrays and live values. +Then dehydrate successful data with your application's serializer, and dispose request resources +after cleanup. HTTP SSE accepts the same helper with its generated options. The +[complete typed recipe](https://github.com/ueberBrot/effect-api-query/blob/main/tests/types/docs-stream-snapshot.ts) +includes contract/client composition; [Hydrate Query Views](/effect-api-query/guides/hydrate-query-views/) +covers history, live values and paired codecs. The default `fresh` mode starts a new attempt even when cached or initial data has a future timestamp. Old data cannot settle that attempt. Native stream policies still apply: `append` diff --git a/apps/docs/src/content/docs/guides/svelte-query.md b/apps/docs/src/content/docs/guides/svelte-query.md index 91e283d..443f45e 100644 --- a/apps/docs/src/content/docs/guides/svelte-query.md +++ b/apps/docs/src/content/docs/guides/svelte-query.md @@ -18,7 +18,8 @@ for the distinction between immutable keys and caller-owned input. Create the utility tree in your application module from an acquired ready client. Provide a QueryClient through Svelte Query's `QueryClientProvider`, then read reactive inputs inside the accessor passed to `createQuery`. Keep the client's `Scope` and any serviceful runner alive while -queries use them: +queries use them. Here `users.read` accepts an ID and returns a user with `name`. +In the Svelte component script: ```svelte - - -

{user.data}

``` Use `createInfiniteQuery(() => rpc.users.list.infiniteOptions(...))` for pagination and diff --git a/apps/docs/src/content/docs/guides/switch-cache-owners.md b/apps/docs/src/content/docs/guides/switch-cache-owners.md index dd7e64a..26cf6f1 100644 --- a/apps/docs/src/content/docs/guides/switch-cache-owners.md +++ b/apps/docs/src/content/docs/guides/switch-cache-owners.md @@ -11,112 +11,50 @@ owns authorization. ## Capture one owner -Copy [the existing Vite application's owner cache](https://github.com/ueberBrot/effect-api-query/blob/main/examples/vite-react/src/lib/owner-cache.ts) -into `owner-cache.ts`. The following focused recipe accepts already acquired clients and captures -one QueryClient, identity, and set of generated utilities. Keep their owning Scopes and runtimes -alive until this owner's work finishes. +Create the utility trees in the application module with already acquired clients and a +QueryClient reserved for this owner. Use a safe owner tuple in both factories: ```ts -import { QueryClient } from '@tanstack/react-query' -import { Effect, Schema } from 'effect' -import { createHttpApiQueryUtils, createRpcQueryUtils } from 'effect-api-query' -import type { RunPromiseExit } from 'effect-api-query' -import { HttpApi, HttpApiEndpoint, HttpApiGroup } from 'effect/http-api' -import type { HttpApiClient } from 'effect/http-api' -import { Rpc, RpcGroup } from 'effect/rpc' -import type { RpcClient } from 'effect/rpc' - -import { makeOwnerCache, ownerKeyPrefix } from './owner-cache.ts' -import type { ApplicationOwnerIdentity, DirectoryStorage } from './owner-cache.ts' - -export const DirectoryUser = Schema.Struct({ - id: Schema.Int, - name: Schema.String, - locale: Schema.String, -}) - -export const ownerGroup = RpcGroup.make( - Rpc.make('users.list', { success: Schema.Array(DirectoryUser) }), - Rpc.make('users.get', { payload: { id: Schema.Int }, success: DirectoryUser }), - Rpc.make('users.create', { - payload: { name: Schema.String }, - success: DirectoryUser, - }), - Rpc.make('users.watch', { success: Schema.String, stream: true }), -) +const keyPrefix = ownerKeyPrefix(identity) +const rpc = createRpcQueryUtils(ownerGroup, { client: rpcClient, keyPrefix, runPromiseExit }) +const http = createHttpApiQueryUtils(ownerApi, { client: httpClient, keyPrefix, runPromiseExit }) +``` -export const ownerApi = HttpApi.make('owner-directory').add( - HttpApiGroup.make('users').add( - HttpApiEndpoint.get('list', '/users', { success: Schema.Array(DirectoryUser) }), - HttpApiEndpoint.get('get', '/users/:id', { - params: { id: Schema.Int }, - success: DirectoryUser, - }), - HttpApiEndpoint.post('create', '/users', { - payload: Schema.Struct({ name: Schema.String }), - success: DirectoryUser, - }), - ), -) +`ownerKeyPrefix` and `owner` below come from the application's +[owner-cache module](https://github.com/ueberBrot/effect-api-query/blob/main/examples/vite-react/src/lib/owner-cache.ts). +The [application module](https://github.com/ueberBrot/effect-api-query/blob/main/examples/vite-react/src/lib/application.ts) +acquires clients, restores directory data, and tracks accepted mutation requests. These are +application helpers; keep their Scopes and runtimes alive until the owner's work finishes. -export const makeOwnerQueries = ({ - rpcClient, - httpClient, - identity, - storage, - runPromiseExit = Effect.runPromiseExit, -}: { - readonly rpcClient: RpcClient.RpcClient.Flat, RpcError> - readonly httpClient: HttpApiClient.ForApi - readonly identity: ApplicationOwnerIdentity - readonly storage?: DirectoryStorage - readonly runPromiseExit?: RunPromiseExit -}) => { - const queryClient = new QueryClient({ - defaultOptions: { - queries: { retry: false }, - mutations: { retry: false, networkMode: 'always' }, - }, - }) - const keyPrefix = ownerKeyPrefix(identity) - const rpc = createRpcQueryUtils(ownerGroup, { - client: rpcClient, - keyPrefix, - runPromiseExit, - }) - const http = createHttpApiQueryUtils(ownerApi, { client: httpClient, keyPrefix, runPromiseExit }) - const owner = makeOwnerCache({ - identity, - queryClient, - directoryKeys: { rpc: rpc.users.list.queryKey(), http: http.users.list.queryKey() }, - storage, - }) - owner.restoreDirectory() - const invalidateUsers = async () => { - if (!owner.isActive()) { - return - } - await Promise.all([ - queryClient.invalidateQueries({ queryKey: rpc.users.key() }), - queryClient.invalidateQueries({ queryKey: http.users.key() }), - ]) +In this owner's mutation options, guard each cache write and invalidation: + +```ts +const invalidateUsers = async () => { + if (!owner.isActive()) { + return } - const createUser = owner.trackMutationOptions( - rpc.users.create.mutationOptions({ - onSuccess: async (user) => { - if (!owner.isActive()) { - return - } - queryClient.setQueryData(rpc.users.get.queryKey({ id: user.id }), user) - await invalidateUsers() - }, - onSettled: invalidateUsers, - }), - ) - return { owner, queryClient, rpc, http, createUser, invalidateUsers } + await Promise.all([ + queryClient.invalidateQueries({ queryKey: rpc.users.key() }), + queryClient.invalidateQueries({ queryKey: http.users.key() }), + ]) } ``` +```ts +const createUser = owner.trackMutationOptions( + rpc.users.create.mutationOptions({ + onSuccess: async (user) => { + if (!owner.isActive()) { + return + } + queryClient.setQueryData(rpc.users.get.queryKey({ id: user.id }), user) + await invalidateUsers() + }, + onSettled: invalidateUsers, + }), +) +``` + The success callback captures its original owner. It checks that owner before seeding a concrete query, and settlement invalidation uses the same guard. An old callback never looks up a current application variable to choose where to write. Apply the guard to rollback, optimistic writes, diff --git a/apps/docs/src/content/docs/guides/tanstack-start.md b/apps/docs/src/content/docs/guides/tanstack-start.md index 6654cdf..fd04265 100644 --- a/apps/docs/src/content/docs/guides/tanstack-start.md +++ b/apps/docs/src/content/docs/guides/tanstack-start.md @@ -7,58 +7,30 @@ Use `effect-api-query` with your existing TanStack Start router and Effect contr factory for your contract, then use its generated options with TanStack Query in loaders and components. -## Choose a factory - -For an Effect RPC group, pass your ready RPC client to `createRpcQueryUtils`: - -```ts -import { createRpcQueryUtils } from 'effect-api-query' - -const queryUtils = createRpcQueryUtils(rpcGroup, { - client: rpcClient, - keyPrefix: ['app', identity], - runPromiseExit, -}) -``` - -For an Effect HTTP API, use `createHttpApiQueryUtils` with your ready `HttpApiClient` instead: - -```ts -import { createHttpApiQueryUtils } from 'effect-api-query' - -const queryUtils = createHttpApiQueryUtils(httpApi, { - client: httpClient, - keyPrefix: ['app', identity], - runPromiseExit, -}) -``` - -Use `identity` to partition the cache safely by the current user or tenant. `runPromiseExit` -is your application's runner. Configure transport URLs, authentication, and required services when -creating the client and runner. Use a request-owned server-local client when your application can execute the handlers locally. -Otherwise, use a trusted server destination. Browser clients use a browser-accessible destination. - -The [RPC factory reference](/effect-api-query/reference/factory/) and -[HTTP guide](/effect-api-query/guides/http-queries-and-mutations/) cover client setup, optional -runners, and request inputs. The package generates query options; your application owns the API -host, router, and providers. +Create `queryUtils` with the [RPC](/effect-api-query/getting-started/quick-start/) or +[HTTP](/effect-api-query/getting-started/http-quick-start/) factory in your client module. +Expose it and its QueryClient through router context. Use a safe identity prefix and a +signal-aware runner. The application owns the router, providers, API host and authentication. ## Prefetch in a loader and read in a component Expose your `queryUtils` and `QueryClient` through the router context. For a contract with a `users.list` operation, the same generated options work in both places: +In the route module, register the loader and the existing `UsersRoute` component: + ```tsx export const Route = createFileRoute('/users')({ loader: ({ context }) => context.queryClient.query(context.queryUtils.users.list.queryOptions()), component: UsersRoute, }) +``` + +Inside `UsersRoute`, read the same options with the native hook: -function UsersRoute() { - const { queryUtils } = Route.useRouteContext() - const users = useSuspenseQuery(queryUtils.users.list.queryOptions()) - return
{JSON.stringify(users.data, null, 2)}
-} +```ts +const { queryUtils } = Route.useRouteContext() +const users = useSuspenseQuery(queryUtils.users.list.queryOptions()) ``` Set `staleTime` on the Query Client or generated options long enough to keep successful loader @@ -130,29 +102,16 @@ timeouts, aborts, stream policies, and exclusive query ownership. ## Explore the executable example -The [TanStack Start example](https://github.com/ueberBrot/effect-api-query/tree/main/examples/tanstack-start) -includes separate RPC and HTTP views to demonstrate both factories. It verifies successful SSR, -hydration without duplicate reads, cached navigation, pagination, mutations, failures, and -cancellation. Its `/http-failure` route omits a failed query from dehydration so the browser can -refetch it. -See [Executable Examples](/effect-api-query/examples/) for commands and controls. - -The example serves RPC at `/rpc` and HTTP at `/api/$`. Both handlers share a demonstration user -directory, so writes invalidate both sets of query keys. The authorization header contains a -public demonstration value. Each request owns its cache and connections; disposing one request -leaves other requests usable. - -Server rendering uses request-owned connections built with Effect's public decoded-message client -and server. They execute the same application state as the network handlers. RPC execution avoids -HTTP round trips; HTTP loaders call the host's Web handler in process and retain HTTP Schema codecs. -The browser acquires separate network clients and reconnects both stream views after hydration. - -Local RPC middleware captures the request's demonstration authorization value. Per-call headers and -caller execution services cannot replace that authority. The example defaults that public value to -`allowed`; replace this demonstration policy with your application's authentication. Decoded-message -RPC execution bypasses transport Schema codecs. Keep a schema-aware protocol when wire validation -or codec effects are required. - -The example pairs Schema codecs for its generated query views and completes asynchronous -preparation before publishing or hydrating. Add a codec for each new hydratable view. See -[Hydrate Query Views](/effect-api-query/guides/hydrate-query-views/). +[Run the Start application](/effect-api-query/examples/) for both adapters. Copy the seam you need: + +| Task | Complete source | +| ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Router context and Query integration | [router.tsx](https://github.com/ueberBrot/effect-api-query/blob/main/examples/tanstack-start/src/router.tsx) | +| Paired codecs and snapshot preparation | [query-ssr.ts](https://github.com/ueberBrot/effect-api-query/blob/main/examples/tanstack-start/src/lib/query-ssr.ts), [snapshot-preparation.ts](https://github.com/ueberBrot/effect-api-query/blob/main/examples/tanstack-start/src/lib/snapshot-preparation.ts) | +| Request-owned clients and cleanup | [server-application.ts](https://github.com/ueberBrot/effect-api-query/blob/main/examples/tanstack-start/src/lib/server-application.ts) | +| HTTP failure followed by browser refetch | [http-failure.tsx](https://github.com/ueberBrot/effect-api-query/blob/main/examples/tanstack-start/src/routes/http-failure.tsx) | + +Server destinations must be trusted. The example uses request-owned server-local RPC execution; +its middleware captures server-owned authority that per-call headers or caller services cannot +replace. Decoded-message RPC bypasses transport Schema codecs, so use a schema-aware protocol +when wire validation or codec effects matter. Browser clients acquire separate network resources. diff --git a/apps/docs/src/content/docs/guides/vue-query.md b/apps/docs/src/content/docs/guides/vue-query.md index 9122b13..7e56e88 100644 --- a/apps/docs/src/content/docs/guides/vue-query.md +++ b/apps/docs/src/content/docs/guides/vue-query.md @@ -23,8 +23,8 @@ Keep captured values unchanged while their options can execute. See [Data Normalization](/effect-api-query/concepts/data-normalization/#keep-captured-inputs-unchanged) for the distinction between immutable keys and caller-owned input. -In component setup, use a computed options object. This example assumes `rpc.users.read` returns -a user with a `name`: +In component setup, use the `rpc` tree from your [ready client](/effect-api-query/getting-started/quick-start/). +Here `users.read` returns a user with a `name`: ```ts import type { InferDataFromTag, InferErrorFromTag } from '@tanstack/query-core' @@ -41,6 +41,11 @@ const options = computed(() => select: (user) => user.name, }), ) +``` + +In the same component setup, pass the tagged types to the native hook: + +```ts type UserKey = ReturnType const user = useQuery< InferDataFromTag, @@ -71,6 +76,9 @@ An explicit `initialData: undefined` overrides inherited QueryClient initial dat ## Own streams and disposal +Other observers of the same query keep it active. At owner disposal, drain accepted mutations +as well as local stream finalizers before releasing client resources. + Open streams expose their first value while fetching continues. Changing reactive input replaces the query; when its last observer leaves, query cancellation closes the stream iterator. Component unmount also releases observed streams. Keep client resources alive until these queries finish diff --git a/apps/docs/src/content/docs/guides/websocket-clients.md b/apps/docs/src/content/docs/guides/websocket-clients.md index 2a094bc..efb7ce4 100644 --- a/apps/docs/src/content/docs/guides/websocket-clients.md +++ b/apps/docs/src/content/docs/guides/websocket-clients.md @@ -10,120 +10,35 @@ and cancellation. ## Acquire the connection -Save this module as `socket-client.ts`, and replace `socketGroup` with your shared contract. -Keep the parent Scope alive while any returned utility is in use. The child Scope owns -the socket, protocol, ready client, and QueryClient cleanup. +Keep the flat ready client and its parent Scope alive. The +[complete acquisition recipe](https://github.com/ueberBrot/effect-api-query/blob/main/tests/fixtures/socket-client.ts) +owns the socket, protocol, client and QueryClient cleanup. In that application setup: ```ts -import { QueryClient } from '@tanstack/query-core' -import { Effect, Exit, Layer, Schema, Scope } from 'effect' -import { Rpc, RpcClient, RpcGroup, RpcSerialization } from 'effect/rpc' -import { Socket } from 'effect/socket' - -import { createRpcQueryUtils } from 'effect-api-query' - -export const socketGroup = RpcGroup.make( - Rpc.make('values.read', { payload: { id: Schema.Int }, success: Schema.Int }), - Rpc.make('values.watch', { - payload: { channel: Schema.String }, - success: Schema.Int, - stream: true, - }), -) - -export const acquireSocketQueries = Effect.fn('acquireSocketQueries')(function* ( - url: string, - owner: string, -) { - const clientScope = yield* Scope.fork(yield* Effect.scope, 'sequential') - const socket = yield* Socket.makeWebSocket(url).pipe(Scope.provide(clientScope)) - const protocol = yield* Layer.buildWithScope( - RpcClient.layerProtocolSocket().pipe( - Layer.provide(RpcSerialization.layerJson), - Layer.provide(Layer.succeed(Socket.Socket, socket)), - ), - clientScope, - ) - const client = yield* RpcClient.make(socketGroup, { flatten: true }).pipe( - Effect.provide(protocol), - Scope.provide(clientScope), - ) - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) - const rpc = createRpcQueryUtils(socketGroup, { client, keyPrefix: ['socket', owner] }) - yield* Scope.addFinalizer( - clientScope, - Effect.promise(async () => { - await queryClient.cancelQueries() - queryClient.clear() - }), - ) - return { client, rpc, queryClient, dispose: Scope.close(clientScope, Exit.void) } -}) +const rpc = createRpcQueryUtils(socketGroup, { client, keyPrefix: ['socket', owner] }) ``` -Supply a WebSocket constructor for the host. Browsers can use -`Socket.layerWebSocketConstructorGlobal`; Node applications can supply the existing -`NodeSocket.layerWebSocketConstructorWS` from `@effect/platform-node`. -The server must expose the matching group through `RpcServer.toHttpEffectWebsocket` -and use the same serialization. This recipe uses JSON on both ends. +The host supplies `Socket.layerWebSocketConstructorGlobal` in browsers or +`NodeSocket.layerWebSocketConstructorWS` in Node. The server uses the same group and +serialization through `RpcServer.toHttpEffectWebsocket`; this recipe uses JSON. ## Read and capture stream data -The following browser function runs two unary reads, then captures the first successful -value from each stream view. Run it within a caller-owned Scope. `fetchStreamSnapshot` -stops and drains local iterator work before returning, so an open stream can supply a -snapshot without waiting for completion. +Use generated `queryOptions` for unary reads and native observers for `streamedOptions` or +`liveOptions`. Independent keys share the connection without sharing cancellation. +The first stream emission becomes successful data while `fetchStatus` remains `fetching`. -```ts -import { Effect } from 'effect' -import { Socket } from 'effect/socket' - -import { fetchStreamSnapshot } from 'effect-api-query' - -import { acquireSocketQueries } from './socket-client.ts' - -export const readSocketSnapshot = Effect.fn('readSocketSnapshot')(function* (url: string) { - const application = yield* acquireSocketQueries(url, 'account-42').pipe( - Effect.provide(Socket.layerWebSocketConstructorGlobal), - ) - const { queryClient, rpc } = application - try { - const reads = yield* Effect.promise( - async () => - await Promise.all([ - queryClient.query(rpc.values.read.queryOptions({ input: { id: 1 } })), - queryClient.query(rpc.values.read.queryOptions({ input: { id: 2 } })), - ]), - ) - const input = { channel: 'clock' } - const history = yield* Effect.promise( - async () => - await fetchStreamSnapshot( - queryClient, - rpc.values.watch.streamedOptions({ input, maxChunks: 20 }), - { - timeoutMs: 5000, - }, - ), - ) - const latest = yield* Effect.promise( - async () => - await fetchStreamSnapshot(queryClient, rpc.values.watch.liveOptions({ input }), { - timeoutMs: 5000, - }), - ) - return { reads, history, latest } - } finally { - yield* application.dispose - } -}) -``` +In a server/request loader, await `fetchStreamSnapshot(queryClient, options, controls)` +with the generated history or live options. For this recipe, `input` is +`{ channel: 'clock' }`; history uses +`rpc.values.watch.streamedOptions({ input, maxChunks: 20 })`, while latest data uses +`rpc.values.watch.liveOptions({ input })`. Each capture has `timeoutMs: 5000` and settles +local cleanup before disposal. -For a long-lived UI, give the generated `streamedOptions` or `liveOptions` to a native -observer instead. Its first emission becomes successful query data while `fetchStatus` -remains `fetching`. Accumulated streams preserve history; live queries retain the latest -value. `maxChunks` controls cached history, and `rpcOptions.streamBufferSize` controls -the ready client's transport queue. They govern different resources. +The [complete typed snapshot recipe](https://github.com/ueberBrot/effect-api-query/blob/main/tests/fixtures/docs-websocket-use.ts) +includes concurrent reads and disposal. [Stream Snapshots](/effect-api-query/guides/stream-snapshots/) +explains exclusive capture and local finalizer drain. `maxChunks` bounds cached history; +`rpcOptions.streamBufferSize` bounds the transport queue. They govern different resources. ## Cancel and replace an owner @@ -133,7 +48,7 @@ concurrent unary calls continue on the same connection. TanStack retains its nat cancellation result: after a stream has published data, cancellation can settle its query promise successfully with cached data. -Call `application.dispose` before disposing the server or an enclosing runtime. The +Drain local work and call `application.dispose` before disposing the server or an enclosing runtime. The registered finalizer cancels queries and clears their cache before the client and socket close. For a replacement owner, await disposal, acquire a new application Scope and ready client, and construct a new QueryClient. The same semantic prefix can produce diff --git a/apps/docs/src/content/docs/index.mdx b/apps/docs/src/content/docs/index.mdx index f96532a..c34b6c8 100644 --- a/apps/docs/src/content/docs/index.mdx +++ b/apps/docs/src/content/docs/index.mdx @@ -20,31 +20,19 @@ controls its client, cache policies, and resource lifetime. ## What it provides -- Queries and mutations for unary RPCs and buffered HTTP endpoints without multipart. -- Mutations for buffered HTTP multipart uploads with explicit `FormData`. -- Infinite and conditional queries for both adapters. -- Accumulated streams and live queries for RPCs and supported HTTP SSE endpoints. -- Buffered HTTP metadata views containing decoded data, status, and raw headers. -- Semantic cache keys for prefetching, cache updates, and invalidation. -- Typed execution failures that retain the complete Effect Cause. +- Typed keys and options for reads, mutations, pagination and conditional queries. +- Accumulated and live RPC/SSE views, buffered HTTP metadata and multipart mutations. +- Decoded query data and execution failures preserving the complete Effect Cause. + +See [Feature Support](/effect-api-query/getting-started/feature-support/) for operation boundaries. ## Choose a path -- Create utilities and run a query with the [RPC quick start](/effect-api-query/getting-started/quick-start/) +- Start with an existing contract and ready client: [RPC quick start](/effect-api-query/getting-started/quick-start/) or [HTTP quick start](/effect-api-query/getting-started/http-quick-start/). -- [Run the examples](/effect-api-query/examples/) to compare standalone Vite hosting with - TanStack Start routes for both protocols. -- Check [feature support](/effect-api-query/getting-started/feature-support/) to choose operations - and plan your integration. -- Use the [React Query guide](/effect-api-query/guides/react-query/) for hooks and mutations. -- Use [Vue Query](/effect-api-query/guides/vue-query/), [Solid Query](/effect-api-query/guides/solid-query/), - [Svelte Query](/effect-api-query/guides/svelte-query/), or [Angular Query](/effect-api-query/guides/angular-query/) - for native reactive consumers. -- Use the [TanStack Start guide](/effect-api-query/guides/tanstack-start/) for loaders, SSR, and - hydration. -- Capture open streams with [Stream Snapshots](/effect-api-query/guides/stream-snapshots/), then - [hydrate query views](/effect-api-query/guides/hydrate-query-views/) with application-owned codecs. -- Follow [Switch Cache Owners](/effect-api-query/guides/switch-cache-owners/) and - [Refresh from Domain Events](/effect-api-query/guides/refresh-from-events/) for private cache workflows. -- Read [compatibility and limits](/effect-api-query/reference/compatibility-and-limits/) before - adopting the package. +- Consume options in [React](/effect-api-query/guides/react-query/), [Vue](/effect-api-query/guides/vue-query/), + [Solid](/effect-api-query/guides/solid-query/), [Svelte](/effect-api-query/guides/svelte-query/), + [Angular](/effect-api-query/guides/angular-query/), or [TanStack Start](/effect-api-query/guides/tanstack-start/). +- [Run the examples](/effect-api-query/examples/) for complete Vite React and Start applications. +- Look up [API contracts](/effect-api-query/reference/public-exports/) and + [compatibility limits](/effect-api-query/reference/compatibility-and-limits/). diff --git a/apps/docs/src/content/docs/reference/compatibility-and-limits.md b/apps/docs/src/content/docs/reference/compatibility-and-limits.md index 791d24b..989a44a 100644 --- a/apps/docs/src/content/docs/reference/compatibility-and-limits.md +++ b/apps/docs/src/content/docs/reference/compatibility-and-limits.md @@ -6,7 +6,7 @@ description: Supported versions, RPC and HTTP operations, cache identity, and ru ## Supported integrations See [Performance and Bundling](/effect-api-query/reference/performance/) for construction costs, -source measurements, and adapter versus application bundles. +construction workloads and adapter versus application bundles. The package declares one exact Effect peer and Query Core `>=5.103.1 <6`. It is ESM-only and targets ES2022. Use TypeScript with `strict: true`. The repository uses these versions: @@ -36,41 +36,29 @@ for the pinned Effect release and example dependencies. ## Capability matrix -**Generated** means the package supplies the typed builders and runtime behavior. **Tested** means -a public-contract fixture or executable example covers the integration. **Application-owned** means your application -supplies the client, policy, or lifecycle. **Deferred** means the adapter does not expose it. - -| Capability | RPC | HTTP | -| ------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | -| Ordinary queries and mutations | Generated for unary RPCs | Generated for buffered endpoints without multipart, regardless of method | -| Pagination | Generated `infiniteOptions`; pages map to payloads | Generated for buffered endpoints without multipart; pages map to decoded requests | -| Accumulated streams and live queries | Generated for streaming RPCs; tested cancellation and SSR snapshots | Generated for unambiguous SSE successes without multipart; tested failures and cleanup | -| Buffered multipart uploads | Application-owned transport and payload contract | Generated mutation-only builders; application supplies `FormData` | -| Streaming multipart | Application-owned transport and payload contract | Deferred; any streaming multipart request alternative omits the endpoint | -| Raw HTTP response modes | Outside the RPC contract | Raw responses omitted; metadata views retain decoded data and plain status/header snapshots | -| Conditional queries | Generated `skipToken` support for input-bearing queries | Generated `skipToken` support for input-bearing queries | -| Cache keys and invalidation prefixes | Generated `rpc` namespace and dotted tag paths | Generated `http` namespace, API identifier, and literal projected paths | -| Failure inspection | Generated wrapper preserves the failed Exit Cause | Generated wrapper preserves the failed Exit Cause and declaration identity | -| Query cancellation | Generated signal forwarding and stream iterator cleanup; transport support is application-owned | Generated signal forwarding; fetch abort is tested | -| Mutation cancellation | No upstream TanStack mutation abort signal; explicit cancellable command is application-owned and tested | No upstream TanStack mutation abort signal; domain cancellation is application-owned | -| Authentication, middleware, and residual services | Application-owned ready client and runner | Application-owned ready client and runner | -| React hooks and QueryClient | Tested public types and Vite React example | Tested public types and Vite React example | -| Vue Query | Native reactive accessors; application-owned lifecycle | Native reactive accessors; application-owned lifecycle | -| Solid Query | Native reactive accessors; application-owned lifecycle | Native reactive accessors; application-owned lifecycle | -| Svelte Query | Native reactive accessors for generated queries and streams; application-owned lifecycle | Native reactive accessors for buffered, metadata, and SSE views; application-owned lifecycle | -| Angular Query | Native reactive accessors for generated queries and streams; application-owned lifecycle | Native reactive accessors for buffered, metadata, and SSE views; application-owned lifecycle | -| SSR and hydration | Application-owned; tested Start route loading and stream snapshots | Application-owned; tested Start SSR, hydration, failed-query refetch, and request isolation | -| Host routes | Application-owned; tested standalone server and Start `/rpc` | Application-owned; tested standalone server and Start `/api/$` | -| Cache serialization and mutation invalidation | Application-owned | Application-owned | +| Operation | RPC | HTTP | +| ---------------------------------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------- | +| Ordinary queries, pagination, mutations | Unary RPCs | Buffered endpoints without multipart, regardless of method | +| Accumulated and live streams | Streaming RPCs | One unambiguous SSE success without multipart | +| Buffered multipart uploads | Client and payload contract owned by the application | Mutation-only; the application supplies `FormData` | +| Streaming multipart and raw byte streams | Client transport contract | Omitted endpoints | +| Response metadata | Declared RPC result | Buffered decoded data with plain status/header snapshots | +| Conditional reads | `skipToken` for input-bearing query builders | Same, including buffered metadata | +| Query cancellation | Signal forwarding and iterator cleanup; transport determines server interruption | Same; fetch can abort the pending request | +| Mutation cancellation | Explicit application command | Explicit application operation | +| Keys and failures | `rpc` namespace, dotted paths, full Cause | `http` namespace, literal paths, full Cause and declaration identity | + +Clients, authentication, middleware, residual services, framework providers, host routes, +SSR, serialization, mutation invalidation, and disposal belong to the application. The generated +Core options work with native framework wrappers; the framework guides describe their accessors +and inference limits. The package executes calls through the ready client. TanStack provides query cancellation signals but has no corresponding mutation signal for the adapter to forward. Configure request interception through client middleware and transport construction. Use an explicit domain operation when server cancellation must be observable; compensation is a separate operation. -The [public RPC consumer](https://github.com/ueberBrot/effect-api-query/blob/main/tests/types/public-contract.ts), -[public HTTP consumer](https://github.com/ueberBrot/effect-api-query/blob/main/tests/types/http-contract.ts), -and [executable examples](/effect-api-query/examples/) establish the tested scope. Streaming RPC +For complete integrations, [run the examples](/effect-api-query/examples/). Streaming RPC builders use TanStack's experimental `streamedQuery` helper. Use the ready client directly for calls that do not need TanStack Query. diff --git a/apps/docs/src/content/docs/reference/external-http-clients.md b/apps/docs/src/content/docs/reference/external-http-clients.md index a72f700..5db177b 100644 --- a/apps/docs/src/content/docs/reference/external-http-clients.md +++ b/apps/docs/src/content/docs/reference/external-http-clients.md @@ -44,12 +44,19 @@ Use a `bodySerializer` that returns `FormData`. Let Fetch set the multipart boun JSON serializer receives HTTP 415 from a multipart-only endpoint. The example upload includes path/query/header values, a text field, and a file with its filename and contents intact. +In the external client module, use the generated `paths` type and create the client: + ```ts import createClient from 'openapi-fetch' import type { paths } from './openapi-generated.ts' const external = createClient({ baseUrl: 'https://api.example.test' }) +``` + +In a loader or request handler, send encoded values and inspect the buffered result: + +```ts const report = await external.POST('/reports/{id}', { params: { path: { id: '5' }, @@ -62,7 +69,11 @@ if (report.error) { throw new Error(`Report failed with HTTP ${report.response.status}`) } const revision = report.response.headers.get('x-revision') +``` +In the upload handler, serialize the declared fields as `FormData`: + +```ts const uploaded = await external.POST('/reports/{id}/files', { params: { path: { id: '5' }, @@ -77,11 +88,15 @@ const uploaded = await external.POST('/reports/{id}/files', { return form }, }) +``` + +For raw SSE access, request a stream in that consumer: + +```ts const events = await external.GET('/reports/events/{channel}', { params: { path: { channel: 'values' } }, parseAs: 'stream', }) -export { events, report, revision, uploaded } ``` The external client's `Response` retains HTTP status and raw headers after buffering. The native diff --git a/apps/docs/src/content/docs/reference/factory.md b/apps/docs/src/content/docs/reference/factory.md index 4701d30..3df7323 100644 --- a/apps/docs/src/content/docs/reference/factory.md +++ b/apps/docs/src/content/docs/reference/factory.md @@ -30,5 +30,3 @@ path segment, or invalid encoder map. It performs no network request. Query options retain the normalized payload for execution. Keep captured values unchanged; see [Data Normalization](/effect-api-query/concepts/data-normalization/#keep-captured-inputs-unchanged) for the distinction between immutable keys and caller-owned input. - -The [public RPC consumer](https://github.com/ueberBrot/effect-api-query/blob/main/tests/types/public-contract.ts) checks factory inference and runner requirements. diff --git a/apps/docs/src/content/docs/reference/generated-builders.md b/apps/docs/src/content/docs/reference/generated-builders.md index 4f659dc..662afbe 100644 --- a/apps/docs/src/content/docs/reference/generated-builders.md +++ b/apps/docs/src/content/docs/reference/generated-builders.md @@ -214,7 +214,3 @@ busters; see [Switch Cache Owners](/effect-api-query/guides/switch-cache-owners/ Live queries always replace the cached value and therefore expose no `refetchMode`. Cancelling, unmounting, or superseding either stream closes its iterator and interrupts its Effect resources. - -The [public RPC consumer](https://github.com/ueberBrot/effect-api-query/blob/main/tests/types/public-contract.ts) checks the builder examples -and hook types. The [streaming tests](https://github.com/ueberBrot/effect-api-query/blob/main/tests/create-rpc-query-utils-streaming.test.ts) -verify accumulation, replacement, bounds, and cancellation. diff --git a/apps/docs/src/content/docs/reference/http-factory.md b/apps/docs/src/content/docs/reference/http-factory.md index 577f278..fd3bc6a 100644 --- a/apps/docs/src/content/docs/reference/http-factory.md +++ b/apps/docs/src/content/docs/reference/http-factory.md @@ -9,16 +9,18 @@ it from `effect-api-query`. For a step-by-step example, see [HTTP Queries and Mutations](/effect-api-query/guides/http-queries-and-mutations/). -Ordinary groups appear as `utils[groupIdentifier][endpointIdentifier]`. Top-level groups place -their endpoints at `utils[endpointIdentifier]`. Identifiers containing dots remain literal -properties. Every retained branch and endpoint has `key()`; buffered endpoints without multipart have -`queryKey`, `queryOptions`, `metadataKey`, `metadataOptions`, `mutationKey`, `mutationOptions`, -`infiniteKey`, and `infiniteOptions`. -An endpoint with any buffered multipart payload alternative exposes only `key`, `mutationKey`, and -`mutationOptions`, even when it also accepts plain payload alternatives. This classification applies -regardless of HTTP method; applications choose the builder for endpoints with query support. -An endpoint with exactly one SSE success and no multipart payload exposes `key`, `streamedKey`, -`streamedOptions`, `liveKey`, and `liveOptions`. +Ordinary groups project to `utils[groupIdentifier][endpointIdentifier]`; top-level groups +project endpoints to `utils[endpointIdentifier]`. Dots remain literal properties. Every retained +branch and endpoint has `key()`. + +| Endpoint | Builders beside `key()` | +| ------------------------------------------ | --------------------------------------------------------------------------------------- | +| Buffered without multipart | `queryKey/Options`, `metadataKey/Options`, `infiniteKey/Options`, `mutationKey/Options` | +| Any buffered multipart payload alternative | `mutationKey`, `mutationOptions` | +| Exactly one SSE success, no multipart | `streamedKey/Options`, `liveKey/Options` | + +Any multipart alternative makes a buffered endpoint mutation-only, including mixed plain and +multipart alternatives. HTTP method does not determine the builder for query-enabled endpoints. ## Factory options diff --git a/scripts/verify-docs-examples.mts b/scripts/verify-docs-examples.mts index d5f8c38..94c9f39 100644 --- a/scripts/verify-docs-examples.mts +++ b/scripts/verify-docs-examples.mts @@ -1,10 +1,19 @@ import { NodeRuntime, NodeServices } from '@effect/platform-node' import { Effect, FileSystem, Path } from 'effect' // fallow-ignore-file unused-file -import { strictEqual } from 'node:assert' +import { ok } from 'node:assert' import { decodeUtf8 } from './decode-utf8.mts' +const unindent = (lines: readonly string[]) => { + const indentation = Math.min( + ...lines + .filter((line) => line.trim().length > 0) + .map((line) => /^ */u.exec(line)?.[0].length ?? 0), + ) + return lines.map((line) => line.slice(indentation)).join('\n') +} + const program = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem const path = yield* Path.Path @@ -36,18 +45,33 @@ const program = Effect.gen(function* () { ), ) const samples = [...markdown.matchAll(/^```ts\n(?[\s\S]*?)^```/gmu)] - strictEqual(samples.length, fixtures.length, `${page} must contain its TypeScript fixtures`) - for (const [index, fixture] of fixtures.entries()) { + ok(samples.length > 0, `${page} must contain a TypeScript excerpt`) + const sources: string[][] = [] + for (const fixture of fixtures) { const source = decodeUtf8( yield* fs.readFile(yield* path.fromFileUrl(new URL(`../${fixture}.ts`, import.meta.url))), ) - strictEqual( - samples[index]?.groups?.['source']?.trimEnd(), + sources.push( source .replaceAll("from '#effect-api-query'", "from 'effect-api-query'") .replaceAll("from '../fixtures/user-events.ts'", "from './user-events.ts'") - .trimEnd(), - `${page} differs from ${fixture}.ts`, + .trimEnd() + .split('\n'), + ) + } + for (const [index, sample] of samples.entries()) { + const lines = sample.groups?.['source']?.trimEnd().split('\n') ?? [] + ok( + lines.some((line) => line.trim().length > 0), + `${page} excerpt ${index + 1} is empty`, + ) + ok( + sources.some((source) => + source.some( + (_, start) => unindent(source.slice(start, start + lines.length)) === unindent(lines), + ), + ), + `${page} excerpt ${index + 1} differs from its allowed fixtures`, ) } } From a6713c654d9393c398df1fb20027c38e48c29c5d Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Sun, 11 Oct 2026 10:54:23 +0200 Subject: [PATCH 89/97] Correct guide prerequisites and cache identity --- .intent/review-state.json | 49 ++++++++++--------- .../docs/getting-started/feature-support.md | 2 +- .../src/content/docs/guides/cache-filters.md | 21 +++++--- .../docs/guides/conditional-queries.md | 18 ++++--- .../docs/guides/switch-cache-owners.md | 22 ++++----- 5 files changed, 63 insertions(+), 49 deletions(-) diff --git a/.intent/review-state.json b/.intent/review-state.json index b831813..98f0758 100644 --- a/.intent/review-state.json +++ b/.intent/review-state.json @@ -3,7 +3,7 @@ "baseline": "5b91ddb1de13970365669c887b9d767ea417fbd4", "items": { "skill:skills/effect-api-query/SKILL.md": { - "fingerprint": "50481d0684c59c0a15b12a79773b153ca25541dbd300a47f01c1399aef0c5c87", + "fingerprint": "81509c0438c83775b26449057d3bf3e3aba7ef12df7a2e574675c1b482b1e681", "snapshot": { "GLOSSARY.md": "67de06543ad2fd9fd2917419e19d1f071e7b6eee53d48fca05e9dddfc739c683", "README.md": "7af677dd785f95bf08b4a9dbafd10f1b0a2cd9ce40d42edf359ede1e59de6fd3", @@ -15,16 +15,16 @@ "apps/docs/src/content/docs/concepts/semantic-keys.md": "ae21f39db492acdad4f576c6942408b628124da775e40fdde543a4c8341de5ec", "apps/docs/src/content/docs/examples.md": "217a98fef80d350becc9fb7472eb120af9ce528e091fa13840bc9531930e5d53", "apps/docs/src/content/docs/getting-started/compatibility-and-stability.md": "74f9d59c908fe6ab70ccfb8940c1f976e060b38a05cc6bbe75f72403d3b3ed7b", - "apps/docs/src/content/docs/getting-started/feature-support.md": "2f22499798336f7e1958e86bce92fb497445befd909d52b5bcfd5557f23cf2a0", + "apps/docs/src/content/docs/getting-started/feature-support.md": "47de4a30ab91559a5aa4cf211f9da13fa970ad444ee19a0c417efcb289e72bc1", "apps/docs/src/content/docs/getting-started/http-quick-start.md": "f73cc87d44999e85b942b3ef284bdd2d0cf5bbd84cb4e7e3f14e65da185ff81d", "apps/docs/src/content/docs/getting-started/installation.md": "0e7d6e608fa0ea049446fd3784350ba635503c0010c12342bc5774b6e7105152", "apps/docs/src/content/docs/getting-started/quick-start.md": "43e8837b84df91af5dfe1b989c6f2cd94557ffb038fd1c0692a5bc7342716b3c", "apps/docs/src/content/docs/guides/angular-query.md": "fcce47f32059d4164ae3a0b5fb1cf35922dd2bd5bb5b1dfa38a139339483c539", - "apps/docs/src/content/docs/guides/cache-filters.md": "6c892eea9e50d2b7f046efe2329e519f8585eeac84a9f5d84773f7aceb904b26", + "apps/docs/src/content/docs/guides/cache-filters.md": "ac17c5bfd460a70988bdb4d7fd702fc96a6d0df46b6e0cd3455ac60e0ab4fe22", "apps/docs/src/content/docs/guides/cache-management.md": "72a1943ca0c4171a5d10b5b1667bfbec8b8f07922a1114b40b3457fbdc5c708e", "apps/docs/src/content/docs/guides/cancellation.md": "eb846576e04d8e8fb3030352a8613664773f4140047e6da0ccd002fcb78a7a2e", "apps/docs/src/content/docs/guides/choose-stream-history.md": "6996df3a8b18c4fadbcc76a3635a8a5408f155b4068509d4801b38afa7f22f16", - "apps/docs/src/content/docs/guides/conditional-queries.md": "52ae3d0b5e41c7001254f4dc5ee6dd2244d003832d51d307d02397c6b60b8bac", + "apps/docs/src/content/docs/guides/conditional-queries.md": "78b84fb8bfe02c87178924522487ee698db95c71f9d84537ffe40134bcbdb492", "apps/docs/src/content/docs/guides/custom-key-encoders.md": "9a819f6c490234e46458d407e140fb3dc36cb7c67691a8574e8c2f6024c9c046", "apps/docs/src/content/docs/guides/handle-failures.md": "f9d8e9abcf8a29a6d57bb8b2140b8faf02e8550490405eab75a3c2721186f5fe", "apps/docs/src/content/docs/guides/http-queries-and-mutations.md": "928eb6def0170167e572ebd0e536bfc5cffaf6921c497da6367a6ecfec0841f7", @@ -38,7 +38,7 @@ "apps/docs/src/content/docs/guides/solid-query.md": "91151a68b0205da685a8e545fdc386cffa1c3ac76b6de9be73b19bfb470c19bc", "apps/docs/src/content/docs/guides/stream-snapshots.md": "91e22e85c11d8822e25e5abb7b872b78f07af79fdb16eb2690baf8226fdbd9f7", "apps/docs/src/content/docs/guides/svelte-query.md": "aeb8ba0254920677a3c0a0667d500a1186eb368ae56f16ff848f6edcae7903ac", - "apps/docs/src/content/docs/guides/switch-cache-owners.md": "5475234db1e23a3542f9caf005c767cbd3d36f8ba079bbb75c06890173aad180", + "apps/docs/src/content/docs/guides/switch-cache-owners.md": "cc209f13becec305af60c9f04fe29f85a2ca6db3bc11601cf09083bc2583d198", "apps/docs/src/content/docs/guides/tanstack-start.md": "6fae751389e9b5dfb843235059aea1a2fc885d62f797556de78a9ea77697ddcb", "apps/docs/src/content/docs/guides/vue-query.md": "aff0843b1f4fb76563695e9a1d6050fad202007c4be994b74aff774ab2e038d0", "apps/docs/src/content/docs/guides/websocket-clients.md": "1cd31655e433ced7b24795255cb03280b27734eda58afeb9730a42bfad433c43", @@ -195,19 +195,20 @@ "tests/types/tanstack-start-contract.ts": "51681e3d0d7504a614d490f18adb6f44db82d34b9d4d7cb9cf941aea33de284f", "tests/types/type-scale.ts": "a9b307c25c72f3c184eabc8e22ddaeea96feb331c0f8d748773d765d358dbfca" }, - "head": "75a6b099bdaf8a01dd343ca88aec3afbd7a54338", + "head": "98894c44dd3c64c5339eb54984c17043b2f18442", "outcome": "no-change", - "reason": "Human documentation now presents placed source excerpts and links to complete retained recipes. Public behavior, native builder use, safe identity, Cause handling, captured inputs, framework limits, view codecs and owner cleanup are unchanged. The existing skill and all six references already preserve those contracts and point to the four current framework guides; no agent guidance edit is warranted.", + "reason": "Only four human guide corrections changed: factory links now point to setup patterns for explicitly separate encoded filter contracts; owner callbacks reuse the actual Vite application and seed the response locale; metadata support is qualified as nonmultipart buffered HTTP. The existing cache-workflows reference already requires actual canonical encoding, originating owner guards, and every returned identity field, including locale. The HTTP reference already excludes metadata for multipart and stream endpoints. No shipped skill claim, task, activation description, prerequisite, version, or source mapping changes. Conditional Queries now names its own ready rpcQuery tree, numeric-id/default-locale user contract, string-channel/string-emission stream prerequisite, and params-only HTTP request prerequisite instead of claiming the quickstart tree supplies them. Existing skill guidance already requires the actual contract input shape and native skipping; no claim or task changes.", "evidence": [ - "Author baseline 75a6b099bdaf8a01dd343ca88aec3afbd7a54338; 38 human documentation files and scripts/verify-docs-examples.mts reviewed.", - "All retained src, tests, examples and dependency inputs are byte unchanged against 75a6b099. Existing eight doc/fixture mappings and both import substitutions remain; each shown TypeScript fence must match a contiguous source excerpt modulo common indentation.", - "Fresh vp run --no-cache docs-build passed excerpt parity, Astro zero diagnostics, 46 built pages and all internal links (0/3 cache hits); all 48 repository source links resolve.", - "Fresh vp run --no-cache api-types passed TypeScript 7.0.2 and 5.9.3 for the nine docs type recipes; vp run --no-cache typecheck passed 140 root files including the two shared WebSocket recipes (0/2 cache hits).", - "vp run skills-check validated the one unchanged authoritative skill; no fresh independent consumer or framework compiler certification is claimed for this editorial update." + "98894c44dd3c64c5339eb54984c17043b2f18442 plus the four current Markdown corrections", + "skills/effect-api-query/SKILL.md and references/cache-workflows.md and references/http.md read against actual application.ts, owner-cache.ts, user-writes.ts, contracts.ts, and reference/http-factory.md", + "vp run --no-cache docs-build: exit 0 with 46 pages, Pagefind index, valid internal links, Astro check 0 errors/warnings/hints and retained snippet parity verification", + "Scoped vp fmt --check: exit 0; native sync: Nothing to synchronize", + "The broad988 runtime/type checks are retained for unchanged source and fixtures; no fresh consumer session or new runtime/type suite was performed for these human-only corrections.", + "vp run --no-cache docs-build rerun after the fourth correction: exit 0, 46 pages and valid internal links; scoped format of all four files passed. Native sync: Nothing to synchronize." ] }, "planning:_artifacts": { - "fingerprint": "7a40392eef28658c865d9f895a34633d520f21dc0d9cb499f71a0f8d8f256f7e", + "fingerprint": "ca8a409c8509dd8edcf1565a0cd6d7a3bde0ab75e7441f3de1a1ab5f9f10feaf", "snapshot": { "GLOSSARY.md": "67de06543ad2fd9fd2917419e19d1f071e7b6eee53d48fca05e9dddfc739c683", "README.md": "7af677dd785f95bf08b4a9dbafd10f1b0a2cd9ce40d42edf359ede1e59de6fd3", @@ -222,16 +223,16 @@ "apps/docs/src/content/docs/concepts/semantic-keys.md": "ae21f39db492acdad4f576c6942408b628124da775e40fdde543a4c8341de5ec", "apps/docs/src/content/docs/examples.md": "217a98fef80d350becc9fb7472eb120af9ce528e091fa13840bc9531930e5d53", "apps/docs/src/content/docs/getting-started/compatibility-and-stability.md": "74f9d59c908fe6ab70ccfb8940c1f976e060b38a05cc6bbe75f72403d3b3ed7b", - "apps/docs/src/content/docs/getting-started/feature-support.md": "2f22499798336f7e1958e86bce92fb497445befd909d52b5bcfd5557f23cf2a0", + "apps/docs/src/content/docs/getting-started/feature-support.md": "47de4a30ab91559a5aa4cf211f9da13fa970ad444ee19a0c417efcb289e72bc1", "apps/docs/src/content/docs/getting-started/http-quick-start.md": "f73cc87d44999e85b942b3ef284bdd2d0cf5bbd84cb4e7e3f14e65da185ff81d", "apps/docs/src/content/docs/getting-started/installation.md": "0e7d6e608fa0ea049446fd3784350ba635503c0010c12342bc5774b6e7105152", "apps/docs/src/content/docs/getting-started/quick-start.md": "43e8837b84df91af5dfe1b989c6f2cd94557ffb038fd1c0692a5bc7342716b3c", "apps/docs/src/content/docs/guides/angular-query.md": "fcce47f32059d4164ae3a0b5fb1cf35922dd2bd5bb5b1dfa38a139339483c539", - "apps/docs/src/content/docs/guides/cache-filters.md": "6c892eea9e50d2b7f046efe2329e519f8585eeac84a9f5d84773f7aceb904b26", + "apps/docs/src/content/docs/guides/cache-filters.md": "ac17c5bfd460a70988bdb4d7fd702fc96a6d0df46b6e0cd3455ac60e0ab4fe22", "apps/docs/src/content/docs/guides/cache-management.md": "72a1943ca0c4171a5d10b5b1667bfbec8b8f07922a1114b40b3457fbdc5c708e", "apps/docs/src/content/docs/guides/cancellation.md": "eb846576e04d8e8fb3030352a8613664773f4140047e6da0ccd002fcb78a7a2e", "apps/docs/src/content/docs/guides/choose-stream-history.md": "6996df3a8b18c4fadbcc76a3635a8a5408f155b4068509d4801b38afa7f22f16", - "apps/docs/src/content/docs/guides/conditional-queries.md": "52ae3d0b5e41c7001254f4dc5ee6dd2244d003832d51d307d02397c6b60b8bac", + "apps/docs/src/content/docs/guides/conditional-queries.md": "78b84fb8bfe02c87178924522487ee698db95c71f9d84537ffe40134bcbdb492", "apps/docs/src/content/docs/guides/custom-key-encoders.md": "9a819f6c490234e46458d407e140fb3dc36cb7c67691a8574e8c2f6024c9c046", "apps/docs/src/content/docs/guides/handle-failures.md": "f9d8e9abcf8a29a6d57bb8b2140b8faf02e8550490405eab75a3c2721186f5fe", "apps/docs/src/content/docs/guides/http-queries-and-mutations.md": "928eb6def0170167e572ebd0e536bfc5cffaf6921c497da6367a6ecfec0841f7", @@ -245,7 +246,7 @@ "apps/docs/src/content/docs/guides/solid-query.md": "91151a68b0205da685a8e545fdc386cffa1c3ac76b6de9be73b19bfb470c19bc", "apps/docs/src/content/docs/guides/stream-snapshots.md": "91e22e85c11d8822e25e5abb7b872b78f07af79fdb16eb2690baf8226fdbd9f7", "apps/docs/src/content/docs/guides/svelte-query.md": "aeb8ba0254920677a3c0a0667d500a1186eb368ae56f16ff848f6edcae7903ac", - "apps/docs/src/content/docs/guides/switch-cache-owners.md": "5475234db1e23a3542f9caf005c767cbd3d36f8ba079bbb75c06890173aad180", + "apps/docs/src/content/docs/guides/switch-cache-owners.md": "cc209f13becec305af60c9f04fe29f85a2ca6db3bc11601cf09083bc2583d198", "apps/docs/src/content/docs/guides/tanstack-start.md": "6fae751389e9b5dfb843235059aea1a2fc885d62f797556de78a9ea77697ddcb", "apps/docs/src/content/docs/guides/vue-query.md": "aff0843b1f4fb76563695e9a1d6050fad202007c4be994b74aff774ab2e038d0", "apps/docs/src/content/docs/guides/websocket-clients.md": "1cd31655e433ced7b24795255cb03280b27734eda58afeb9730a42bfad433c43", @@ -402,14 +403,16 @@ "tests/types/tanstack-start-contract.ts": "51681e3d0d7504a614d490f18adb6f44db82d34b9d4d7cb9cf941aea33de284f", "tests/types/type-scale.ts": "a9b307c25c72f3c184eabc8e22ddaeea96feb331c0f8d748773d765d358dbfca" }, - "head": "75a6b099bdaf8a01dd343ca88aec3afbd7a54338", + "head": "98894c44dd3c64c5339eb54984c17043b2f18442", "outcome": "no-change", - "reason": "All three planning records remain accurate: the same ten developer tasks are covered by one skill and six references; source globs cover every edited human page and the verifier. Reactive-option failure still points to solid-query.md, metadata fresh-hit failure still points to query-defaults.md, and all active guidance links resolve. This is an editorial presentation change with no new task, seam, dependency, distribution or behavioral decision, so no artificial history entry or planning rewrite is needed.", + "reason": "The domain map still covers encoded partial filtering, originating cache ownership and exact response identity, and HTTP metadata/multipart capability boundaries. The spec still assigns source facts to actual declarations and application modules, with one skill and six conditional references. The tree preserves the same implemented skill, description, purpose, prerequisites, source mappings, and package-only distribution. All three records remain accurate; no artificial batch history, version, or taxonomy change is needed for correcting four human guide examples. Conditional Queries now names its own ready rpcQuery tree, numeric-id/default-locale user contract, string-channel/string-emission stream prerequisite, and params-only HTTP request prerequisite instead of claiming the quickstart tree supplies them. Existing skill guidance already requires the actual contract input shape and native skipping; no claim or task changes.", "evidence": [ - "Read _artifacts/domain_map.yaml, _artifacts/skill_spec.md and _artifacts/skill_tree.yaml completely; compared coverage/failure sources and existing guidance to the final source diff.", - "Native Intent 0.5.3 sync reports nothing to synchronize; actual review lists only this planning record and skills/effect-api-query/SKILL.md, with no source problem or unmapped source item.", - "Fresh docs build/source link checks, both API compilers, normal root typed check and structural skills-check passed; the original full programme base remains 5b91ddb1de13970365669c887b9d767ea417fbd4.", - "Prior coverage and maintainer-alignment history, distribution.mode:none, all source mappings and the 58 other stored review outcomes are preserved." + "_artifacts/domain_map.yaml, _artifacts/skill_spec.md, and _artifacts/skill_tree.yaml completely reviewed; no diff in those three documents", + "skills/effect-api-query/SKILL.md and affected cache/HTTP references completely reviewed and unchanged", + "The final native report identifies exactly the four corrected Markdown paths, the owned skill and planning items, and no mapping or planning problems", + "vp run --no-cache docs-build: 46 pages, all internal links valid; scoped format and git diff --check passed", + "Native sync reports Nothing to synchronize; no independent consumer task was rerun because supported skill behavior and scope are unchanged.", + "vp run --no-cache docs-build rerun after the fourth correction: exit 0, 46 pages and valid internal links; scoped format of all four files passed. Native sync: Nothing to synchronize." ] }, "source:vite.config.ts": { diff --git a/apps/docs/src/content/docs/getting-started/feature-support.md b/apps/docs/src/content/docs/getting-started/feature-support.md index c2626b4..e8ef05f 100644 --- a/apps/docs/src/content/docs/getting-started/feature-support.md +++ b/apps/docs/src/content/docs/getting-started/feature-support.md @@ -16,7 +16,7 @@ keys, functions, and options. | Contract | Available views | Details | | -------------------------------------- | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | Unary RPC / buffered nonmultipart HTTP | Query, infinite query, mutation | [RPC Builders](/effect-api-query/reference/generated-builders/), [HTTP Factory](/effect-api-query/reference/http-factory/) | -| Buffered HTTP | Separate decoded data/status/header metadata | [ETag workflow](/effect-api-query/guides/http-queries-and-mutations/#update-with-an-etag) | +| Buffered nonmultipart HTTP | Separate decoded data/status/header metadata | [ETag workflow](/effect-api-query/guides/http-queries-and-mutations/#update-with-an-etag) | | Buffered multipart HTTP | Mutation with explicit `FormData` | [Upload](/effect-api-query/guides/http-queries-and-mutations/#upload-a-file) | | RPC stream / unambiguous HTTP SSE | Accumulated history or latest value | [Choose Stream History](/effect-api-query/guides/choose-stream-history/) | diff --git a/apps/docs/src/content/docs/guides/cache-filters.md b/apps/docs/src/content/docs/guides/cache-filters.md index bbf4358..d084868 100644 --- a/apps/docs/src/content/docs/guides/cache-filters.md +++ b/apps/docs/src/content/docs/guides/cache-filters.md @@ -22,8 +22,8 @@ pending mutations. ## Filter RPC queries -In the contract module, suppose `users.get` and `users.watch` share these fields -(`Effect` and `Schema` are imported from `effect`): +These filters assume your own unary `users.get` and streaming `users.watch` declarations share +these payload fields. In their contract module, import `Effect` and `Schema` from `effect`: ```ts const payload = { @@ -32,8 +32,9 @@ const payload = { } ``` -Use the existing `rpc` tree from [RPC Quick Start](/effect-api-query/getting-started/quick-start/). -In a cache callback, select every locale for user 1 with an encoded partial filter: +Derive `rpc` from this group and its ready client using the factory setup in +[RPC Quick Start](/effect-api-query/getting-started/quick-start/). In a cache callback, select +every locale for user 1 with an encoded partial filter: ```ts const queryPrefix = [...rpc.users.get.key(), 'query'] as const @@ -71,10 +72,14 @@ remaining concrete suffix opaque; root, branch, leaf, and operation filters surv ## Filter HTTP queries -HTTP filters retain labelled request containers. Suppose `users.get` declares -`FiniteFromString` fields in params/query/payload and a decoded header record. Use the existing -`http` tree from [HTTP Quick Start](/effect-api-query/getting-started/http-quick-start/). -In a cache callback, choose the path parameter or header independently: +HTTP filters retain labelled request containers. These filters assume your own `users.get` +declares `id: Schema.FiniteFromString` in each of `params`, `query`, and `payload`, plus +`'X-Locale': Schema.String` in `headers`. Its `users.watch` SSE endpoint declares +`query: { id: Schema.FiniteFromString }`. + +Derive `http` from that HttpApi and its ready client using the factory setup in +[HTTP Quick Start](/effect-api-query/getting-started/http-quick-start/). In a cache callback, +choose the path parameter or header independently: ```ts const queryPrefix = [...http.users.get.key(), 'query'] as const diff --git a/apps/docs/src/content/docs/guides/conditional-queries.md b/apps/docs/src/content/docs/guides/conditional-queries.md index 6defecd..caa4c96 100644 --- a/apps/docs/src/content/docs/guides/conditional-queries.md +++ b/apps/docs/src/content/docs/guides/conditional-queries.md @@ -3,10 +3,14 @@ title: Conditional Queries description: Pause queries until their required input is available. --- -In component setup, import the native `useQuery` hook and use the ready utility tree from the -[RPC quick start](/effect-api-query/getting-started/quick-start/). `userId` is the selected ID, -which may be undefined. Use `{ input: skipToken }` when an input-bearing query has no valid input yet and needs TanStack -options such as `staleTime`, `select`, or `initialData`: +In component setup, import the native `useQuery` hook and use your application's ready utility +tree, named `rpcQuery` here. Its `users.get` RPC takes a numeric `id` with a default locale and +returns a user with an `id` and `name`. Follow the [RPC quick start](/effect-api-query/getting-started/quick-start/) +for the factory setup pattern. + +`userId` is the selected ID, which may be undefined. Use `{ input: skipToken }` when an +input-bearing query has no valid input yet and needs TanStack options such as `staleTime`, +`select`, or `initialData`: ```ts import { skipToken } from 'effect-api-query' @@ -23,7 +27,8 @@ const userOptions = rpcQuery.users.get.queryOptions({ const user = useQuery(userOptions) ``` -In the same component, the HTTP utility tree accepts a complete decoded request: +For an HTTP `users.get` endpoint with only a numeric `id` param and a user response, its complete +decoded request contains only `params`: ```ts const httpUserOptions = http.users.get.queryOptions({ @@ -53,7 +58,8 @@ including a conditional union. Keep the conditional input inside one builder cal has one consistent callback type. Concrete inputs and the literal `skipToken` retain their precise key and query-function types; conditional inputs preserve both possibilities. -For a declared `events.watch` stream with a channel input, build either view in that consumer: +If your RPC group also declares `events.watch` with a string `channel` input and string +emissions, build either view in that consumer: ```ts rpcQuery.events.watch.streamedOptions({ diff --git a/apps/docs/src/content/docs/guides/switch-cache-owners.md b/apps/docs/src/content/docs/guides/switch-cache-owners.md index 26cf6f1..cf0dda9 100644 --- a/apps/docs/src/content/docs/guides/switch-cache-owners.md +++ b/apps/docs/src/content/docs/guides/switch-cache-owners.md @@ -11,20 +11,20 @@ owns authorization. ## Capture one owner -Create the utility trees in the application module with already acquired clients and a -QueryClient reserved for this owner. Use a safe owner tuple in both factories: +In a consumer or mutation module, receive the ready `application` returned by +[`startViteReactApplication(options)`](https://github.com/ueberBrot/effect-api-query/blob/main/examples/vite-react/src/lib/application.ts). +Capture that application and its existing utility trees and QueryClient: ```ts -const keyPrefix = ownerKeyPrefix(identity) -const rpc = createRpcQueryUtils(ownerGroup, { client: rpcClient, keyPrefix, runPromiseExit }) -const http = createHttpApiQueryUtils(ownerApi, { client: httpClient, keyPrefix, runPromiseExit }) +const owner = application +const { queryClient, rpcQuery: rpc, httpQuery: http } = owner ``` -`ownerKeyPrefix` and `owner` below come from the application's -[owner-cache module](https://github.com/ueberBrot/effect-api-query/blob/main/examples/vite-react/src/lib/owner-cache.ts). -The [application module](https://github.com/ueberBrot/effect-api-query/blob/main/examples/vite-react/src/lib/application.ts) -acquires clients, restores directory data, and tracks accepted mutation requests. These are -application helpers; keep their Scopes and runtimes alive until the owner's work finishes. +The application uses `ownerKeyPrefix(identity)` from its +[owner-cache module](https://github.com/ueberBrot/effect-api-query/blob/main/examples/vite-react/src/lib/owner-cache.ts) +in both factories. It acquires clients, restores directory data, and exposes the original owner's +guards and tracked mutation options. Keep its Scopes and runtimes alive until the owner's work +finishes. In this owner's mutation options, guard each cache write and invalidation: @@ -47,7 +47,7 @@ const createUser = owner.trackMutationOptions( if (!owner.isActive()) { return } - queryClient.setQueryData(rpc.users.get.queryKey({ id: user.id }), user) + queryClient.setQueryData(rpc.users.get.queryKey({ id: user.id, locale: user.locale }), user) await invalidateUsers() }, onSettled: invalidateUsers, From eac8805641c1acde48c4414dda2abe7042d1b795 Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Sun, 11 Oct 2026 11:57:26 +0200 Subject: [PATCH 90/97] Simplify query preparation and strengthen native cache coverage --- .intent/review-state.json | 166 +++++------ _artifacts/domain_map.yaml | 5 +- _artifacts/skill_spec.md | 5 + .../docs/guides/conditional-queries.md | 4 +- .../content/docs/reference/http-factory.md | 2 +- .../src/lib/snapshot-preparation.ts | 39 +-- .../components/ui/effect-error-details.tsx | 13 +- examples/vite-react/src/lib/application.ts | 2 +- examples/vite-react/src/lib/owner-cache.ts | 32 +- examples/vite-react/src/lib/user-writes.ts | 97 +------ .../references/query-patterns.md | 4 +- src/core/operation.ts | 9 +- src/core/streamed-query.ts | 19 +- src/core/types.ts | 237 ++++++--------- src/core/utility-tree.ts | 66 ++--- src/http/create-utils.ts | 8 +- src/http/operation.ts | 8 +- src/http/streamed-query.ts | 124 +++----- src/rpc/create-utils.ts | 7 +- src/rpc/operation.ts | 3 +- src/rpc/streamed-query.ts | 72 ++--- tests/cache-recipes.test.ts | 273 +++++++++++++++++- ...reate-rpc-query-utils-stream-cache.test.ts | 112 ++++++- .../create-rpc-query-utils-streaming.test.ts | 91 +++--- tests/create-rpc-query-utils.test.ts | 2 +- tests/fixtures/optimistic-users.ts | 8 +- tests/http-cached-views.test.ts | 227 ++++++++++++++- tests/http-hydration.test.ts | 57 +++- tests/http-query-options.test.ts | 2 +- tests/types/example-owner-contract.ts | 46 +++ tests/types/http-contract.ts | 8 +- tests/types/http-stream-contract.ts | 2 +- tests/types/public-contract.ts | 18 +- vite.config.ts | 3 - 34 files changed, 1160 insertions(+), 611 deletions(-) create mode 100644 tests/types/example-owner-contract.ts diff --git a/.intent/review-state.json b/.intent/review-state.json index 98f0758..50b4c7d 100644 --- a/.intent/review-state.json +++ b/.intent/review-state.json @@ -3,7 +3,7 @@ "baseline": "5b91ddb1de13970365669c887b9d767ea417fbd4", "items": { "skill:skills/effect-api-query/SKILL.md": { - "fingerprint": "81509c0438c83775b26449057d3bf3e3aba7ef12df7a2e574675c1b482b1e681", + "fingerprint": "95fabe60cb96109e0e8cc310d7a57d01ecc397cbdd19820599bb4113868beb86", "snapshot": { "GLOSSARY.md": "67de06543ad2fd9fd2917419e19d1f071e7b6eee53d48fca05e9dddfc739c683", "README.md": "7af677dd785f95bf08b4a9dbafd10f1b0a2cd9ce40d42edf359ede1e59de6fd3", @@ -24,7 +24,7 @@ "apps/docs/src/content/docs/guides/cache-management.md": "72a1943ca0c4171a5d10b5b1667bfbec8b8f07922a1114b40b3457fbdc5c708e", "apps/docs/src/content/docs/guides/cancellation.md": "eb846576e04d8e8fb3030352a8613664773f4140047e6da0ccd002fcb78a7a2e", "apps/docs/src/content/docs/guides/choose-stream-history.md": "6996df3a8b18c4fadbcc76a3635a8a5408f155b4068509d4801b38afa7f22f16", - "apps/docs/src/content/docs/guides/conditional-queries.md": "78b84fb8bfe02c87178924522487ee698db95c71f9d84537ffe40134bcbdb492", + "apps/docs/src/content/docs/guides/conditional-queries.md": "7770390ad17843b0883968cf35c4aa39054fac7adeceacae731e8899e3cc4ff2", "apps/docs/src/content/docs/guides/custom-key-encoders.md": "9a819f6c490234e46458d407e140fb3dc36cb7c67691a8574e8c2f6024c9c046", "apps/docs/src/content/docs/guides/handle-failures.md": "f9d8e9abcf8a29a6d57bb8b2140b8faf02e8550490405eab75a3c2721186f5fe", "apps/docs/src/content/docs/guides/http-queries-and-mutations.md": "928eb6def0170167e572ebd0e536bfc5cffaf6921c497da6367a6ecfec0841f7", @@ -49,7 +49,7 @@ "apps/docs/src/content/docs/reference/external-http-clients.md": "4ed288ccf29f3bcdf2e3773172282eefe0ef0f84ed0fd1bc347e5cd388743aba", "apps/docs/src/content/docs/reference/factory.md": "7d972a9ff279ce80164aba793cbeb5a3e1621e6e16be97392b6faa7493de339c", "apps/docs/src/content/docs/reference/generated-builders.md": "4a6403a4d5f62eb222cecb71f6cfaee631854ac706c702b19388467e313dc4a4", - "apps/docs/src/content/docs/reference/http-factory.md": "ee3594ab263e20bfdda6896680760d72ca410942c24b4a92060a1bf23e263eba", + "apps/docs/src/content/docs/reference/http-factory.md": "13a238cb7de1fdd5b92f6bccc6061ff4cdfe49b23a5e0b52cc3126f031a6f39d", "apps/docs/src/content/docs/reference/performance.md": "2f68c31cae28de0a84b1ce77f065481d37899171b78d9593ff65e017ff75d97a", "apps/docs/src/content/docs/reference/public-exports.md": "500d864720857c9098b325d6018b20b5ad6c1e666ef326921721ffdf6435dcb2", "docs/adr/0001-target-effect-4-release-candidate.md": "44119ba9104f767c5804c5026c4a21a78a8367f6d9ff5a9035bb3f423969fbb3", @@ -94,7 +94,7 @@ "examples/tanstack-start/src/lib/query-ssr.ts": "0754abae5faf2f7562e1d86d27df0e14820a5ba9830146690ff1dc9f5beb3ec9", "examples/tanstack-start/src/lib/server-application.ts": "442278fc08c4beb9a69cf1ab7672f661385d9c49998db77ce2870d349ffdc8a1", "examples/tanstack-start/src/lib/server-host.ts": "1682ee37ea7551ec52c9530d6a4b476b967fa0572f5ddc26351ad427ce854e37", - "examples/tanstack-start/src/lib/snapshot-preparation.ts": "2dcdb78a86f0cc51240ecd672c03779139f408d29e639f850a544efd2d569755", + "examples/tanstack-start/src/lib/snapshot-preparation.ts": "b50f578ff0aaf5a6301df7323d0b3ea1e3fdcc27f65be0cf706a73d790203fd7", "examples/tanstack-start/src/router.tsx": "801aac19cff9a4e2a4acb0823a9ea5c28b4aea9245f7e26cb63548bb5234bd15", "examples/tanstack-start/tests/application.test.ts": "81f9b44df80dded727f1fedf51ffee2df2c7afd7f25a68668a263deb88cd3ec1", "examples/tanstack-start/tests/async-hydration.test.ts": "1f983917d2a469b83b876e882ad6a2ece4d28d360338ed614232807a168a1620", @@ -103,9 +103,9 @@ "examples/tanstack-start/tests/server-local.test.ts": "a2bbaf6cfad8701e2f8912a07c6a9f3d91a8d8ecb394a94c09d25289134576c9", "examples/tanstack-start/tests/server-render.test.tsx": "01061f209d72df769235c47bae552402e6c9a692c95e67267e4317b2e338b8f5", "examples/vite-react/src/app.tsx": "691f77aee6fb9f2b1f6f9df40a3afcf78f4af2d043d7b379f38c75c647a5f91e", - "examples/vite-react/src/lib/application.ts": "7e6ab21e6beaa78a65da1c2da076f956f77e4470fe87f81390e8e26195fdd9e1", - "examples/vite-react/src/lib/owner-cache.ts": "7e11ad168da93e5d2740766b40600a41301729e9f07433f8d3d7fdde06bc88bb", - "examples/vite-react/src/lib/user-writes.ts": "125662c3e9a17854d0dafa194d4d491d3982f211e1e4785b853438177418213a", + "examples/vite-react/src/lib/application.ts": "c3fe076813ec5797de9e93274b07d07312e830cfa0998ae2d174191bc1c7d3fb", + "examples/vite-react/src/lib/owner-cache.ts": "563f1758f6f387768ba8f068e02763fa6b1bd30ef84d59e8be1938adfec7eb26", + "examples/vite-react/src/lib/user-writes.ts": "46d8b7b5f02447344309a80f068bf045d71997d3dc40402dede9d835b6e36fdb", "examples/vite-react/src/main.tsx": "cdee6ca4095b58e1750015db473bda8651702a8ab32c408fa77961873db0e6fe", "examples/vite-react/tests/app.test.tsx": "ff86b42023eb40658dd6feccbfd1f236a75bf2831f40b02955ebc1306be6329c", "examples/vite-react/tests/commands.test.ts": "3e24222572c0f7a4b68cfc4069c545cbebf410cacd0063a328cead9ad5baab08", @@ -124,27 +124,27 @@ "skills/effect-api-query/references/frameworks-and-hosts.md": "8b39b303cf3a9ae3222a257f53df5f9696b80c54859765f2c4d7b39554b8719f", "skills/effect-api-query/references/http.md": "9625411753fcc3ce816512f80158a19aa1057518ab3bfde98720e0cc7f845b66", "skills/effect-api-query/references/hydration-and-ssr.md": "61e761b36ebcf56c7cc1b67b3d795edba52d0c0224c3e7964297ed6864e1b044", - "skills/effect-api-query/references/query-patterns.md": "33ea307b7042660da4ee4f6acea147d4218688a0256d82f3d582ff2e9cef4f9c", + "skills/effect-api-query/references/query-patterns.md": "5e20137ffe4b951ccaf41e869a7ac097633f91cb52ec44799ecdf47b4ba3883c", "skills/effect-api-query/references/rpc.md": "f77eb39bb1b3e1f5f8c56f4b827ecef350a255a23380d8b9515dc61b54165d5d", - "src/core/operation.ts": "98e3a2103ffc29003e10398d90c0b05840ee2b6e93c687942d2577b86246fe84", + "src/core/operation.ts": "3b00c20d4f9cfa38b22574953804e7dc4648e912df74bfd5469c657ff5cc6e35", "src/core/schema-key.ts": "6e3ec8e7aa089ab6d2a85e71880f9bf4985f572b28e0b4325fcb0eed00b5dfd3", "src/core/stream-snapshot.ts": "abd83051df4e7fd3e4de5f9147b029993d59e19e5dd72ee7b73d40ebae12acc7", - "src/core/streamed-query.ts": "094418739ed9216ef008960dd6aa4b99b261c375bd778c8679a58722d8a9eea5", - "src/core/types.ts": "c8e222c1d18736b2009fb67d4dcfb6985a87241ed2266d588decac32bd1b8f17", - "src/core/utility-tree.ts": "059fe4f87e72d367b38ca0d71d5313f5148e68fe1d3fc39010fed3d036f3c862", - "src/http/create-utils.ts": "56894da25c6a2c07ce5d251836b793c458ed869d7c0f29ce92c6d0fab89e6532", + "src/core/streamed-query.ts": "34ed5a18daa8c5aacdb5fbfede925b1bd84baef1f9e0580e0a8035b7cfde0eb1", + "src/core/types.ts": "ae7ad2d99e24cb7c98e54270ce2698ff8db4d21750e967d11ed4d9653a5e6264", + "src/core/utility-tree.ts": "5c0254aa71724e1a529fd090891fea5e853b0ff4e3ae0ae3eac265df1e8f9a83", + "src/http/create-utils.ts": "d989ce2b8c0b0fda6e3dfbec76babe33f30a60aef8f0028f3009b9367aeb21e2", "src/http/errors.ts": "dbe4aa9a4ce8c5c996f914387e9ec61568f774c5ea5a585c4f9480b13f97706b", - "src/http/operation.ts": "e0bb361315592289c816b03d0b318dc069c1611932593d778558c0a9d3f9568f", + "src/http/operation.ts": "be425a2829e1ab16d5b07b592ddde76d3e611b5670567030e17afe10ca4c202f", "src/http/request.ts": "2416d809ab164a68b67627456686e52a6a49a5173790e652d129dcd503b1d424", - "src/http/streamed-query.ts": "d0a2522a99a4068b0420cbbcc83f65f8bc2dc911637b031148844d32938e4606", + "src/http/streamed-query.ts": "ab4b9404db54fe5f77894c87bf7240474c4c7a00de12b3fe18914b67005e08af", "src/http/types.ts": "e63f305feae5452bbe125b46cbfc6ebaecabdf72c224ec6d4732daa798e250d9", "src/index.ts": "95300336b198d7cec9a0113597970f462cb431718be9f2aae0fd64402b6655a4", - "src/rpc/create-utils.ts": "e25a361bee900850650b7d0d5bfaa6d8555d7ef48382f4125bd48cd440ba1c1f", + "src/rpc/create-utils.ts": "74edcb07d0c13466cd1d1b1343eefb91110e3d1ffb299cc4a92310a025d690d8", "src/rpc/errors.ts": "7e4fbde05e6dd3647f8a12f524a659b5c378ee27092ae4e28151effb7b2feb88", - "src/rpc/operation.ts": "017dea3ba6f270d84c19b4afd5fb5624bd2414e65634b54ce0d3467ebd1b3aca", - "src/rpc/streamed-query.ts": "35f399311a6fc6928df469d2d47312d2e0d0e12c8ae6e0e36b059c0a17a74878", + "src/rpc/operation.ts": "a0b34e68b4054d55b10936737cfef1617fa12074610e5147c912cab101ba7c9e", + "src/rpc/streamed-query.ts": "4e2588fa629b4332a13f921f900ed2580343831adc6c9d86769c830f2ac66545", "src/rpc/types.ts": "f5fda172e9746512b1c51082eebaee7b5a16bd8b382d59b51ed80e07e8e552e3", - "tests/cache-recipes.test.ts": "2eee65e29e118b08bb0e17e4661f30e7b6e4d00f5f15bf55e070680a2eebb867", + "tests/cache-recipes.test.ts": "5d86c8f74e952aa6f614d9ba32b4f2680137d23b696d8c55cd07d0463e5b65b7", "tests/create-http-api-query-utils.test.ts": "a76295644389a37e1a00a9825d192cbd0dceeab7d43e7ad1196c171ef5059ad1", "tests/create-rpc-query-utils-configuration.test.ts": "839191fd31986c13f77585d191d643599426ce3294a930af421a26439b37b664", "tests/create-rpc-query-utils-execution.test.ts": "8556cd62ba1ecdcc130c9a5d35f165b60d18a9c6f3f8eed30822f213ecd57e73", @@ -152,26 +152,26 @@ "tests/create-rpc-query-utils-request-options.test.ts": "bcd76859a2830e522bdeb212be70f2e14be3b815da75a5caf3e596fd5fbd13e7", "tests/create-rpc-query-utils-server-local.test.ts": "45ee329a736bcb6b114727ef8874f3f34541ce6dc55d5e13ffc80be9c2af2ddd", "tests/create-rpc-query-utils-stream-baseline.test.ts": "cec0a8906b3512e38c0272dc07a4cac81402ac014549eb11f24a594d7b792616", - "tests/create-rpc-query-utils-stream-cache.test.ts": "c1a5588608e43b99bf98914d3e764d89e25d9f7dc47379e68316fd1f1332e935", + "tests/create-rpc-query-utils-stream-cache.test.ts": "b97fd6eb63c0e8c023f98a1420555288c3dd2f4ab500b0d3e0b1d3a5c41e1347", "tests/create-rpc-query-utils-stream-publication.test.ts": "cc3a25d02015b1410a8d1406249a846bf711f7b829fa7fccce1da90e600530d3", - "tests/create-rpc-query-utils-streaming.test.ts": "7b392709f029af806a6b42038c058dcd1645969581aafedb8696e8bf43e7507b", - "tests/create-rpc-query-utils.test.ts": "93589d8c857d716ce384a4a97d442d005f0501c890a524b69f6594f039fb530e", + "tests/create-rpc-query-utils-streaming.test.ts": "07a2af009d755f6025d5329f1edf4c1d40dc1ce090dffdadf023c5ba812353d0", + "tests/create-rpc-query-utils.test.ts": "786032f34d1b3386373901714de9f5e7f5a1d42cb7d29cf79cb9d224d17c0624", "tests/fixtures/async.ts": "f9ccc474ad87dd5aedebc5660d74386de6551dd5f8900d572c485c11054ff6b9", "tests/fixtures/docs-websocket-use.ts": "6e2aa0bb7b1a653ffc215bad7a39b9a79aa1a9b3707bb32b1e80f4636faac544", "tests/fixtures/effect-rpc.ts": "73be762e670e37e4cc3cfcc2b1d40724f23721726d75b45524ad0b3aba78eb61", "tests/fixtures/http-client.ts": "a0a77029f4c786c1616f24ce6aa44120de40e243d3dc5570d85d1767bb527a11", - "tests/fixtures/optimistic-users.ts": "0f8827553ee52ca4f4105c61006cf5a4fffce3e5170a3ca9e2216975f72a2a63", + "tests/fixtures/optimistic-users.ts": "34d06822585039f33d2f9df7bceb866d36acbeff7f8ca7ce30f25e5e37975ba3", "tests/fixtures/query-cache.ts": "ee6509aac5d0cf4f781652da10c2b1c3501a222ddd9a6b05f447e6a226548071", "tests/fixtures/rpc-transport-queries.ts": "46be1d1e6048d57d83e9ae64b00c8c0c11deec6a67d0d4b115cdc9aac7447158", "tests/fixtures/socket-client.ts": "d2d0a4a31011e2cfeec0e7e7ca996ad752a3a51db5675ac5efce54d284c8db89", "tests/fixtures/user-events.ts": "13d8167b811bec6319de96f7cb50410f586c18cc92677d7fd17488b16d15b329", "tests/fixtures/websocket-checks.ts": "7b8035264ece4e501e829b3e2b755f9d7b17c010ffa1036db6338a368ff6f93c", - "tests/http-cached-views.test.ts": "a1dc10f45dcc2d8a53c8b80e4205f6adb48646fba60a427b8531f02a26068725", + "tests/http-cached-views.test.ts": "3cdf4e8895fcb0e511ccdc6c49abfcc0318a82038844219ae88f32b28f2d3df6", "tests/http-execution.test.ts": "d5869dc2faef8f2edf0f721173f40b352f1b2d70ef19919214a342b3bd82ea18", - "tests/http-hydration.test.ts": "1873ccb57214eb9542927fd92204cff9e98d2cc0217fd78fbeeb20bebd5e05b7", + "tests/http-hydration.test.ts": "cead9c149e9aecbba7121b4e6bcfa3450368d24e6320ba1b8825545db747b98d", "tests/http-live-query.test.ts": "79df219cb204669520c584123f167479ea3fa514d899897bf2810a04e8e1ca65", "tests/http-multipart.test.ts": "ff816ffae79e827883cea1d1f99f5f763e95c9e856040627a34818df6569feb1", - "tests/http-query-options.test.ts": "bba47a56841732c7a3f3451ce100e9b864ab2fa6374bc7c8c912658e31c30a3a", + "tests/http-query-options.test.ts": "62db987483d1fa468a62da899a1938296f79e9ac37c8d57890f898b2d66bf0b2", "tests/http-semantic-keys.test.ts": "2e78788697bdf7efec374669d8123da57c93df3c368d41920decfcd175bc2359", "tests/http-transport-cancellation.test.ts": "e56f8571f949adc9d44f14b61e20edddb188a9565bf7a585859ffe73a851ac39", "tests/http-view-proof.test.ts": "4dc91c38efe5b0c643ef61c98dcdf4a89acf4ef4c1651030124d82ca6cb55a61", @@ -185,35 +185,31 @@ "tests/types/docs-rpc-quick-start.ts": "95deee30de122cbaa7b9cb28257c234aa1162a0b549a87cf50a80bd84e871081", "tests/types/docs-stream-snapshot.ts": "bc924cff90a9f9a8e416bf7fbdfedbd607c88e7a678435aaf20ef878862e06ff", "tests/types/docs-user-events.ts": "87647140d5434b95cc5389b1fea9919f20df7b6fcee4f00565836a4eae549a70", - "tests/types/http-contract.ts": "56bcbf5c82a1d0413e82d6db253c5bc0ae27a5e3121cd0fb28ea7bac9fcef1c8", - "tests/types/http-stream-contract.ts": "459968ad3ba9fc33c1435758b4520e8a8214c9d82db96b2d1704e86986919cf2", + "tests/types/example-owner-contract.ts": "fb10bd087ee582a64806d110404c366cb74e520b0b8cc11701348036b08b9622", + "tests/types/http-contract.ts": "3dd9e8c02b6c7039942fac3e578a3c2a848c2e1495efa87f658e025225b9cce9", + "tests/types/http-stream-contract.ts": "4c47b65a5088ae292bee39d5ec5df8df007aaa90c94fb2af5d01239c3f850017", "tests/types/http-type-scale.ts": "b456543b88316a2dd899f929efbffcf514e54e213e109b5f7d52eb785d2255aa", "tests/types/http-view-proof.ts": "f42a1ebf36eeab7a906b722f5d2c2af0aabe8d30ef378fc50386c16d464fb64e", - "tests/types/public-contract.ts": "1adc90bd7d8f359e9b159d61cb5a8b98caa56c66e46a1109154c61e5cd1c4014", + "tests/types/public-contract.ts": "475048059f5f509008a119a6f0305fca018f1fdcc19c35f63df070a6bc7b9bf5", "tests/types/server-local-client-contract.ts": "443a60788ce7d91a77459c99f07d3fc9bf70d5a64a83c05ece5c40cfac63d298", "tests/types/snapshot-contract.ts": "9e6a2edaddb7545f20d351511ed99be2828d4ca56779503bcc443963d8b73716", "tests/types/tanstack-start-contract.ts": "51681e3d0d7504a614d490f18adb6f44db82d34b9d4d7cb9cf941aea33de284f", "tests/types/type-scale.ts": "a9b307c25c72f3c184eabc8e22ddaeea96feb331c0f8d748773d765d358dbfca" }, - "head": "98894c44dd3c64c5339eb54984c17043b2f18442", + "head": "a7448d01cb97f369f87642ea3628d056641a6a48", "outcome": "no-change", - "reason": "Only four human guide corrections changed: factory links now point to setup patterns for explicitly separate encoded filter contracts; owner callbacks reuse the actual Vite application and seed the response locale; metadata support is qualified as nonmultipart buffered HTTP. The existing cache-workflows reference already requires actual canonical encoding, originating owner guards, and every returned identity field, including locale. The HTTP reference already excludes metadata for multipart and stream endpoints. No shipped skill claim, task, activation description, prerequisite, version, or source mapping changes. Conditional Queries now names its own ready rpcQuery tree, numeric-id/default-locale user contract, string-channel/string-emission stream prerequisite, and params-only HTTP request prerequisite instead of claiming the quickstart tree supplies them. Existing skill guidance already requires the actual contract input shape and native skipping; no claim or task changes.", + "reason": "The final runtime validation fixture removes a ternary whose two branches call the identical conditional-input builder. Normal commit formatting also expands the already-reviewed optimistic fixture tracker injection over several lines. Neither changes API behavior, task coverage, recommendations or source mappings.", "evidence": [ - "98894c44dd3c64c5339eb54984c17043b2f18442 plus the four current Markdown corrections", - "skills/effect-api-query/SKILL.md and references/cache-workflows.md and references/http.md read against actual application.ts, owner-cache.ts, user-writes.ts, contracts.ts, and reference/http-factory.md", - "vp run --no-cache docs-build: exit 0 with 46 pages, Pagefind index, valid internal links, Astro check 0 errors/warnings/hints and retained snippet parity verification", - "Scoped vp fmt --check: exit 0; native sync: Nothing to synchronize", - "The broad988 runtime/type checks are retained for unchanged source and fixtures; no fresh consumer session or new runtime/type suite was performed for these human-only corrections.", - "vp run --no-cache docs-build rerun after the fourth correction: exit 0, 46 pages and valid internal links; scoped format of all four files passed. Native sync: Nothing to synchronize." + "Reviewed a7448d0..working diff and actual fixtures against the preceding fingerprint snapshot. Uncached typecheck passes 140 files, and the streaming suite passes all 22 cases; its native dependencies rerun both TypeScript 7.0.2 and 5.9.3 fixture compilers. Earlier full focused suite passes 124 cases. Read all three planning records: domain map and specification already record input-field skipping, ten tasks and task evidence; skill tree maps both affected fixture files. Logs architecture-typecheck-final-2.log and architecture-streaming-final-2.log. No fresh consumer-session evaluation repeated for these equivalent test/format changes." ] }, "planning:_artifacts": { - "fingerprint": "ca8a409c8509dd8edcf1565a0cd6d7a3bde0ab75e7441f3de1a1ab5f9f10feaf", + "fingerprint": "0370ae3c659986920fc21a2121c8c4e2bb0b3f2b3f79d2c3c2da7efa33cc9bd6", "snapshot": { "GLOSSARY.md": "67de06543ad2fd9fd2917419e19d1f071e7b6eee53d48fca05e9dddfc739c683", "README.md": "7af677dd785f95bf08b4a9dbafd10f1b0a2cd9ce40d42edf359ede1e59de6fd3", - "_artifacts/domain_map.yaml": "47f3dda6368ce94f3dd14f6e49b7c1e26f54091e582a339aebb656bd3361664c", - "_artifacts/skill_spec.md": "3d590a87ae382859f39d028319e46e7dbccc16e69315e6a0b31cd4c31ec1cefd", + "_artifacts/domain_map.yaml": "c104af1d7610c2cc9d118719170f3a8d0c0cde7690933b1abc7cc124c88b18b0", + "_artifacts/skill_spec.md": "e87c0670bc924a1d710a496151ee6d549fdeba920afcede14b8bab432111ebb7", "_artifacts/skill_tree.yaml": "da82bd81b41250e02e263147afd7a12ce5379f86cf8b6a9e09895d1227df84fc", "apps/docs/src/content/docs/concepts/client-lifecycle.md": "fcbaca84f266060b946eed9cbf9d0b9815352b7b1d6ddaff5803b29484345380", "apps/docs/src/content/docs/concepts/data-normalization.md": "f1505ec3eb35a33fac289706c2cc318a2ffb80555e3302ca102e67e21c9012da", @@ -232,7 +228,7 @@ "apps/docs/src/content/docs/guides/cache-management.md": "72a1943ca0c4171a5d10b5b1667bfbec8b8f07922a1114b40b3457fbdc5c708e", "apps/docs/src/content/docs/guides/cancellation.md": "eb846576e04d8e8fb3030352a8613664773f4140047e6da0ccd002fcb78a7a2e", "apps/docs/src/content/docs/guides/choose-stream-history.md": "6996df3a8b18c4fadbcc76a3635a8a5408f155b4068509d4801b38afa7f22f16", - "apps/docs/src/content/docs/guides/conditional-queries.md": "78b84fb8bfe02c87178924522487ee698db95c71f9d84537ffe40134bcbdb492", + "apps/docs/src/content/docs/guides/conditional-queries.md": "7770390ad17843b0883968cf35c4aa39054fac7adeceacae731e8899e3cc4ff2", "apps/docs/src/content/docs/guides/custom-key-encoders.md": "9a819f6c490234e46458d407e140fb3dc36cb7c67691a8574e8c2f6024c9c046", "apps/docs/src/content/docs/guides/handle-failures.md": "f9d8e9abcf8a29a6d57bb8b2140b8faf02e8550490405eab75a3c2721186f5fe", "apps/docs/src/content/docs/guides/http-queries-and-mutations.md": "928eb6def0170167e572ebd0e536bfc5cffaf6921c497da6367a6ecfec0841f7", @@ -257,7 +253,7 @@ "apps/docs/src/content/docs/reference/external-http-clients.md": "4ed288ccf29f3bcdf2e3773172282eefe0ef0f84ed0fd1bc347e5cd388743aba", "apps/docs/src/content/docs/reference/factory.md": "7d972a9ff279ce80164aba793cbeb5a3e1621e6e16be97392b6faa7493de339c", "apps/docs/src/content/docs/reference/generated-builders.md": "4a6403a4d5f62eb222cecb71f6cfaee631854ac706c702b19388467e313dc4a4", - "apps/docs/src/content/docs/reference/http-factory.md": "ee3594ab263e20bfdda6896680760d72ca410942c24b4a92060a1bf23e263eba", + "apps/docs/src/content/docs/reference/http-factory.md": "13a238cb7de1fdd5b92f6bccc6061ff4cdfe49b23a5e0b52cc3126f031a6f39d", "apps/docs/src/content/docs/reference/performance.md": "2f68c31cae28de0a84b1ce77f065481d37899171b78d9593ff65e017ff75d97a", "apps/docs/src/content/docs/reference/public-exports.md": "500d864720857c9098b325d6018b20b5ad6c1e666ef326921721ffdf6435dcb2", "docs/adr/0001-target-effect-4-release-candidate.md": "44119ba9104f767c5804c5026c4a21a78a8367f6d9ff5a9035bb3f423969fbb3", @@ -302,7 +298,7 @@ "examples/tanstack-start/src/lib/query-ssr.ts": "0754abae5faf2f7562e1d86d27df0e14820a5ba9830146690ff1dc9f5beb3ec9", "examples/tanstack-start/src/lib/server-application.ts": "442278fc08c4beb9a69cf1ab7672f661385d9c49998db77ce2870d349ffdc8a1", "examples/tanstack-start/src/lib/server-host.ts": "1682ee37ea7551ec52c9530d6a4b476b967fa0572f5ddc26351ad427ce854e37", - "examples/tanstack-start/src/lib/snapshot-preparation.ts": "2dcdb78a86f0cc51240ecd672c03779139f408d29e639f850a544efd2d569755", + "examples/tanstack-start/src/lib/snapshot-preparation.ts": "b50f578ff0aaf5a6301df7323d0b3ea1e3fdcc27f65be0cf706a73d790203fd7", "examples/tanstack-start/src/router.tsx": "801aac19cff9a4e2a4acb0823a9ea5c28b4aea9245f7e26cb63548bb5234bd15", "examples/tanstack-start/tests/application.test.ts": "81f9b44df80dded727f1fedf51ffee2df2c7afd7f25a68668a263deb88cd3ec1", "examples/tanstack-start/tests/async-hydration.test.ts": "1f983917d2a469b83b876e882ad6a2ece4d28d360338ed614232807a168a1620", @@ -311,9 +307,9 @@ "examples/tanstack-start/tests/server-local.test.ts": "a2bbaf6cfad8701e2f8912a07c6a9f3d91a8d8ecb394a94c09d25289134576c9", "examples/tanstack-start/tests/server-render.test.tsx": "01061f209d72df769235c47bae552402e6c9a692c95e67267e4317b2e338b8f5", "examples/vite-react/src/app.tsx": "691f77aee6fb9f2b1f6f9df40a3afcf78f4af2d043d7b379f38c75c647a5f91e", - "examples/vite-react/src/lib/application.ts": "7e6ab21e6beaa78a65da1c2da076f956f77e4470fe87f81390e8e26195fdd9e1", - "examples/vite-react/src/lib/owner-cache.ts": "7e11ad168da93e5d2740766b40600a41301729e9f07433f8d3d7fdde06bc88bb", - "examples/vite-react/src/lib/user-writes.ts": "125662c3e9a17854d0dafa194d4d491d3982f211e1e4785b853438177418213a", + "examples/vite-react/src/lib/application.ts": "c3fe076813ec5797de9e93274b07d07312e830cfa0998ae2d174191bc1c7d3fb", + "examples/vite-react/src/lib/owner-cache.ts": "563f1758f6f387768ba8f068e02763fa6b1bd30ef84d59e8be1938adfec7eb26", + "examples/vite-react/src/lib/user-writes.ts": "46d8b7b5f02447344309a80f068bf045d71997d3dc40402dede9d835b6e36fdb", "examples/vite-react/src/main.tsx": "cdee6ca4095b58e1750015db473bda8651702a8ab32c408fa77961873db0e6fe", "examples/vite-react/tests/app.test.tsx": "ff86b42023eb40658dd6feccbfd1f236a75bf2831f40b02955ebc1306be6329c", "examples/vite-react/tests/commands.test.ts": "3e24222572c0f7a4b68cfc4069c545cbebf410cacd0063a328cead9ad5baab08", @@ -332,27 +328,27 @@ "skills/effect-api-query/references/frameworks-and-hosts.md": "8b39b303cf3a9ae3222a257f53df5f9696b80c54859765f2c4d7b39554b8719f", "skills/effect-api-query/references/http.md": "9625411753fcc3ce816512f80158a19aa1057518ab3bfde98720e0cc7f845b66", "skills/effect-api-query/references/hydration-and-ssr.md": "61e761b36ebcf56c7cc1b67b3d795edba52d0c0224c3e7964297ed6864e1b044", - "skills/effect-api-query/references/query-patterns.md": "33ea307b7042660da4ee4f6acea147d4218688a0256d82f3d582ff2e9cef4f9c", + "skills/effect-api-query/references/query-patterns.md": "5e20137ffe4b951ccaf41e869a7ac097633f91cb52ec44799ecdf47b4ba3883c", "skills/effect-api-query/references/rpc.md": "f77eb39bb1b3e1f5f8c56f4b827ecef350a255a23380d8b9515dc61b54165d5d", - "src/core/operation.ts": "98e3a2103ffc29003e10398d90c0b05840ee2b6e93c687942d2577b86246fe84", + "src/core/operation.ts": "3b00c20d4f9cfa38b22574953804e7dc4648e912df74bfd5469c657ff5cc6e35", "src/core/schema-key.ts": "6e3ec8e7aa089ab6d2a85e71880f9bf4985f572b28e0b4325fcb0eed00b5dfd3", "src/core/stream-snapshot.ts": "abd83051df4e7fd3e4de5f9147b029993d59e19e5dd72ee7b73d40ebae12acc7", - "src/core/streamed-query.ts": "094418739ed9216ef008960dd6aa4b99b261c375bd778c8679a58722d8a9eea5", - "src/core/types.ts": "c8e222c1d18736b2009fb67d4dcfb6985a87241ed2266d588decac32bd1b8f17", - "src/core/utility-tree.ts": "059fe4f87e72d367b38ca0d71d5313f5148e68fe1d3fc39010fed3d036f3c862", - "src/http/create-utils.ts": "56894da25c6a2c07ce5d251836b793c458ed869d7c0f29ce92c6d0fab89e6532", + "src/core/streamed-query.ts": "34ed5a18daa8c5aacdb5fbfede925b1bd84baef1f9e0580e0a8035b7cfde0eb1", + "src/core/types.ts": "ae7ad2d99e24cb7c98e54270ce2698ff8db4d21750e967d11ed4d9653a5e6264", + "src/core/utility-tree.ts": "5c0254aa71724e1a529fd090891fea5e853b0ff4e3ae0ae3eac265df1e8f9a83", + "src/http/create-utils.ts": "d989ce2b8c0b0fda6e3dfbec76babe33f30a60aef8f0028f3009b9367aeb21e2", "src/http/errors.ts": "dbe4aa9a4ce8c5c996f914387e9ec61568f774c5ea5a585c4f9480b13f97706b", - "src/http/operation.ts": "e0bb361315592289c816b03d0b318dc069c1611932593d778558c0a9d3f9568f", + "src/http/operation.ts": "be425a2829e1ab16d5b07b592ddde76d3e611b5670567030e17afe10ca4c202f", "src/http/request.ts": "2416d809ab164a68b67627456686e52a6a49a5173790e652d129dcd503b1d424", - "src/http/streamed-query.ts": "d0a2522a99a4068b0420cbbcc83f65f8bc2dc911637b031148844d32938e4606", + "src/http/streamed-query.ts": "ab4b9404db54fe5f77894c87bf7240474c4c7a00de12b3fe18914b67005e08af", "src/http/types.ts": "e63f305feae5452bbe125b46cbfc6ebaecabdf72c224ec6d4732daa798e250d9", "src/index.ts": "95300336b198d7cec9a0113597970f462cb431718be9f2aae0fd64402b6655a4", - "src/rpc/create-utils.ts": "e25a361bee900850650b7d0d5bfaa6d8555d7ef48382f4125bd48cd440ba1c1f", + "src/rpc/create-utils.ts": "74edcb07d0c13466cd1d1b1343eefb91110e3d1ffb299cc4a92310a025d690d8", "src/rpc/errors.ts": "7e4fbde05e6dd3647f8a12f524a659b5c378ee27092ae4e28151effb7b2feb88", - "src/rpc/operation.ts": "017dea3ba6f270d84c19b4afd5fb5624bd2414e65634b54ce0d3467ebd1b3aca", - "src/rpc/streamed-query.ts": "35f399311a6fc6928df469d2d47312d2e0d0e12c8ae6e0e36b059c0a17a74878", + "src/rpc/operation.ts": "a0b34e68b4054d55b10936737cfef1617fa12074610e5147c912cab101ba7c9e", + "src/rpc/streamed-query.ts": "4e2588fa629b4332a13f921f900ed2580343831adc6c9d86769c830f2ac66545", "src/rpc/types.ts": "f5fda172e9746512b1c51082eebaee7b5a16bd8b382d59b51ed80e07e8e552e3", - "tests/cache-recipes.test.ts": "2eee65e29e118b08bb0e17e4661f30e7b6e4d00f5f15bf55e070680a2eebb867", + "tests/cache-recipes.test.ts": "5d86c8f74e952aa6f614d9ba32b4f2680137d23b696d8c55cd07d0463e5b65b7", "tests/create-http-api-query-utils.test.ts": "a76295644389a37e1a00a9825d192cbd0dceeab7d43e7ad1196c171ef5059ad1", "tests/create-rpc-query-utils-configuration.test.ts": "839191fd31986c13f77585d191d643599426ce3294a930af421a26439b37b664", "tests/create-rpc-query-utils-execution.test.ts": "8556cd62ba1ecdcc130c9a5d35f165b60d18a9c6f3f8eed30822f213ecd57e73", @@ -360,26 +356,26 @@ "tests/create-rpc-query-utils-request-options.test.ts": "bcd76859a2830e522bdeb212be70f2e14be3b815da75a5caf3e596fd5fbd13e7", "tests/create-rpc-query-utils-server-local.test.ts": "45ee329a736bcb6b114727ef8874f3f34541ce6dc55d5e13ffc80be9c2af2ddd", "tests/create-rpc-query-utils-stream-baseline.test.ts": "cec0a8906b3512e38c0272dc07a4cac81402ac014549eb11f24a594d7b792616", - "tests/create-rpc-query-utils-stream-cache.test.ts": "c1a5588608e43b99bf98914d3e764d89e25d9f7dc47379e68316fd1f1332e935", + "tests/create-rpc-query-utils-stream-cache.test.ts": "b97fd6eb63c0e8c023f98a1420555288c3dd2f4ab500b0d3e0b1d3a5c41e1347", "tests/create-rpc-query-utils-stream-publication.test.ts": "cc3a25d02015b1410a8d1406249a846bf711f7b829fa7fccce1da90e600530d3", - "tests/create-rpc-query-utils-streaming.test.ts": "7b392709f029af806a6b42038c058dcd1645969581aafedb8696e8bf43e7507b", - "tests/create-rpc-query-utils.test.ts": "93589d8c857d716ce384a4a97d442d005f0501c890a524b69f6594f039fb530e", + "tests/create-rpc-query-utils-streaming.test.ts": "07a2af009d755f6025d5329f1edf4c1d40dc1ce090dffdadf023c5ba812353d0", + "tests/create-rpc-query-utils.test.ts": "786032f34d1b3386373901714de9f5e7f5a1d42cb7d29cf79cb9d224d17c0624", "tests/fixtures/async.ts": "f9ccc474ad87dd5aedebc5660d74386de6551dd5f8900d572c485c11054ff6b9", "tests/fixtures/docs-websocket-use.ts": "6e2aa0bb7b1a653ffc215bad7a39b9a79aa1a9b3707bb32b1e80f4636faac544", "tests/fixtures/effect-rpc.ts": "73be762e670e37e4cc3cfcc2b1d40724f23721726d75b45524ad0b3aba78eb61", "tests/fixtures/http-client.ts": "a0a77029f4c786c1616f24ce6aa44120de40e243d3dc5570d85d1767bb527a11", - "tests/fixtures/optimistic-users.ts": "0f8827553ee52ca4f4105c61006cf5a4fffce3e5170a3ca9e2216975f72a2a63", + "tests/fixtures/optimistic-users.ts": "34d06822585039f33d2f9df7bceb866d36acbeff7f8ca7ce30f25e5e37975ba3", "tests/fixtures/query-cache.ts": "ee6509aac5d0cf4f781652da10c2b1c3501a222ddd9a6b05f447e6a226548071", "tests/fixtures/rpc-transport-queries.ts": "46be1d1e6048d57d83e9ae64b00c8c0c11deec6a67d0d4b115cdc9aac7447158", "tests/fixtures/socket-client.ts": "d2d0a4a31011e2cfeec0e7e7ca996ad752a3a51db5675ac5efce54d284c8db89", "tests/fixtures/user-events.ts": "13d8167b811bec6319de96f7cb50410f586c18cc92677d7fd17488b16d15b329", "tests/fixtures/websocket-checks.ts": "7b8035264ece4e501e829b3e2b755f9d7b17c010ffa1036db6338a368ff6f93c", - "tests/http-cached-views.test.ts": "a1dc10f45dcc2d8a53c8b80e4205f6adb48646fba60a427b8531f02a26068725", + "tests/http-cached-views.test.ts": "3cdf4e8895fcb0e511ccdc6c49abfcc0318a82038844219ae88f32b28f2d3df6", "tests/http-execution.test.ts": "d5869dc2faef8f2edf0f721173f40b352f1b2d70ef19919214a342b3bd82ea18", - "tests/http-hydration.test.ts": "1873ccb57214eb9542927fd92204cff9e98d2cc0217fd78fbeeb20bebd5e05b7", + "tests/http-hydration.test.ts": "cead9c149e9aecbba7121b4e6bcfa3450368d24e6320ba1b8825545db747b98d", "tests/http-live-query.test.ts": "79df219cb204669520c584123f167479ea3fa514d899897bf2810a04e8e1ca65", "tests/http-multipart.test.ts": "ff816ffae79e827883cea1d1f99f5f763e95c9e856040627a34818df6569feb1", - "tests/http-query-options.test.ts": "bba47a56841732c7a3f3451ce100e9b864ab2fa6374bc7c8c912658e31c30a3a", + "tests/http-query-options.test.ts": "62db987483d1fa468a62da899a1938296f79e9ac37c8d57890f898b2d66bf0b2", "tests/http-semantic-keys.test.ts": "2e78788697bdf7efec374669d8123da57c93df3c368d41920decfcd175bc2359", "tests/http-transport-cancellation.test.ts": "e56f8571f949adc9d44f14b61e20edddb188a9565bf7a585859ffe73a851ac39", "tests/http-view-proof.test.ts": "4dc91c38efe5b0c643ef61c98dcdf4a89acf4ef4c1651030124d82ca6cb55a61", @@ -393,38 +389,34 @@ "tests/types/docs-rpc-quick-start.ts": "95deee30de122cbaa7b9cb28257c234aa1162a0b549a87cf50a80bd84e871081", "tests/types/docs-stream-snapshot.ts": "bc924cff90a9f9a8e416bf7fbdfedbd607c88e7a678435aaf20ef878862e06ff", "tests/types/docs-user-events.ts": "87647140d5434b95cc5389b1fea9919f20df7b6fcee4f00565836a4eae549a70", - "tests/types/http-contract.ts": "56bcbf5c82a1d0413e82d6db253c5bc0ae27a5e3121cd0fb28ea7bac9fcef1c8", - "tests/types/http-stream-contract.ts": "459968ad3ba9fc33c1435758b4520e8a8214c9d82db96b2d1704e86986919cf2", + "tests/types/example-owner-contract.ts": "fb10bd087ee582a64806d110404c366cb74e520b0b8cc11701348036b08b9622", + "tests/types/http-contract.ts": "3dd9e8c02b6c7039942fac3e578a3c2a848c2e1495efa87f658e025225b9cce9", + "tests/types/http-stream-contract.ts": "4c47b65a5088ae292bee39d5ec5df8df007aaa90c94fb2af5d01239c3f850017", "tests/types/http-type-scale.ts": "b456543b88316a2dd899f929efbffcf514e54e213e109b5f7d52eb785d2255aa", "tests/types/http-view-proof.ts": "f42a1ebf36eeab7a906b722f5d2c2af0aabe8d30ef378fc50386c16d464fb64e", - "tests/types/public-contract.ts": "1adc90bd7d8f359e9b159d61cb5a8b98caa56c66e46a1109154c61e5cd1c4014", + "tests/types/public-contract.ts": "475048059f5f509008a119a6f0305fca018f1fdcc19c35f63df070a6bc7b9bf5", "tests/types/server-local-client-contract.ts": "443a60788ce7d91a77459c99f07d3fc9bf70d5a64a83c05ece5c40cfac63d298", "tests/types/snapshot-contract.ts": "9e6a2edaddb7545f20d351511ed99be2828d4ca56779503bcc443963d8b73716", "tests/types/tanstack-start-contract.ts": "51681e3d0d7504a614d490f18adb6f44db82d34b9d4d7cb9cf941aea33de284f", "tests/types/type-scale.ts": "a9b307c25c72f3c184eabc8e22ddaeea96feb331c0f8d748773d765d358dbfca" }, - "head": "98894c44dd3c64c5339eb54984c17043b2f18442", + "head": "a7448d01cb97f369f87642ea3628d056641a6a48", "outcome": "no-change", - "reason": "The domain map still covers encoded partial filtering, originating cache ownership and exact response identity, and HTTP metadata/multipart capability boundaries. The spec still assigns source facts to actual declarations and application modules, with one skill and six conditional references. The tree preserves the same implemented skill, description, purpose, prerequisites, source mappings, and package-only distribution. All three records remain accurate; no artificial batch history, version, or taxonomy change is needed for correcting four human guide examples. Conditional Queries now names its own ready rpcQuery tree, numeric-id/default-locale user contract, string-channel/string-emission stream prerequisite, and params-only HTTP request prerequisite instead of claiming the quickstart tree supplies them. Existing skill guidance already requires the actual contract input shape and native skipping; no claim or task changes.", + "reason": "The final runtime validation fixture removes a ternary whose two branches call the identical conditional-input builder. Normal commit formatting also expands the already-reviewed optimistic fixture tracker injection over several lines. Neither changes API behavior, task coverage, recommendations or source mappings.", "evidence": [ - "_artifacts/domain_map.yaml, _artifacts/skill_spec.md, and _artifacts/skill_tree.yaml completely reviewed; no diff in those three documents", - "skills/effect-api-query/SKILL.md and affected cache/HTTP references completely reviewed and unchanged", - "The final native report identifies exactly the four corrected Markdown paths, the owned skill and planning items, and no mapping or planning problems", - "vp run --no-cache docs-build: 46 pages, all internal links valid; scoped format and git diff --check passed", - "Native sync reports Nothing to synchronize; no independent consumer task was rerun because supported skill behavior and scope are unchanged.", - "vp run --no-cache docs-build rerun after the fourth correction: exit 0, 46 pages and valid internal links; scoped format of all four files passed. Native sync: Nothing to synchronize." + "Reviewed a7448d0..working diff and actual fixtures against the preceding fingerprint snapshot. Uncached typecheck passes 140 files, and the streaming suite passes all 22 cases; its native dependencies rerun both TypeScript 7.0.2 and 5.9.3 fixture compilers. Earlier full focused suite passes 124 cases. Read all three planning records: domain map and specification already record input-field skipping, ten tasks and task evidence; skill tree maps both affected fixture files. Logs architecture-typecheck-final-2.log and architecture-streaming-final-2.log. No fresh consumer-session evaluation repeated for these equivalent test/format changes." ] }, "source:vite.config.ts": { - "fingerprint": "57dbfc7e8c7aef6e91365bfe7b602d9887ade22ddb5d4c3b1c76b3ea7926f512", + "fingerprint": "c7e24b88d6cc661478533082a2dee8d4736faf1debbe74b9275cd7f75ff137ef", "snapshot": { - "vite.config.ts": "7d73304228494989a4f2351b6a2f1a5503592bc33aef034ceefd1146e18e8cf9" + "vite.config.ts": "3a5ac2f87cfc9bca499b63c0d283319b91afd9b6db672b60d11bdebdff93138e" }, - "head": "1e235b1b5f4e4f6d6d34ce397ad0b573e6c5392d", + "head": "bf38d8556744d8e45d459208cab6fe9d1b78b356", "outcome": "no-change", - "reason": "Retired install/archive tasks and cache outputs are gone. Ordinary runtime tests depend on native pack and explicit public/server-local compiler fixtures; existing example/docs/browser/native package validation remains. A small optional source construction workload replaces the installed runner without timing or inventory gates. The current existing skill and planning record cover these boundaries.", + "reason": "Removed three redundant prose lines preceding the existing generic-erasure lint policy; all rules, tasks, checks and packaging behavior remain identical. No shipped guidance change follows.", "evidence": [ - "Base 1e235b1b5f4e4f6d6d34ce397ad0b573e6c5392d; vp run --no-cache quality passed: 38 files, 283 runtime tests, strict public and server-local fixtures on TS 7.0.2 and TS 5.9.3, native pack/attw/publint, Effect diagnostics and Fallow audits. Both example builds and docs-build passed (44 pages; docs parity and Astro diagnostics). Existing docs-e2e passed 14 Chromium/Firefox tests. Native skills-check validated the owned skill. Native source construction-baseline passed. Logs are under /tmp/effect-api-query-implementation/notes/131-*. No fresh independent consumer session ran for this reference/source-mapping update; retired installation/certification matrices were not repeated. The optional generic skill-creator validator rejected the existing Intent sources frontmatter field; native Intent validation passed and its required schema is preserved. Reviewed item: vite.config.ts." + "Actual bf38d855 diff is solely those three prose deletions. Uncached typecheck, compiler fixtures, ordinary runtime tests and native build prerequisite passed; no check was weakened." ] }, "source:.github/workflows/release.yml": { @@ -1146,6 +1138,18 @@ "Source 6893603: CI 38085762210 passed source quality, both example builds, documentation, and Chromium/Firefox/WebKit acceptance; saved final-mts-ci.json and final-mts-ci.log.", "Current batch: scoped vp run format, native vp run skills-check, and installed YAML parsing passed; vp exec intent maintainer sync reported nothing to synchronize." ] + }, + "source:examples/vite-react/src/components/ui/effect-error-details.tsx": { + "fingerprint": "82dda7c7b3d779819cd09ae80a8ac64320f96db657f3ec5a1fd41838dd7ca064", + "snapshot": { + "examples/vite-react/src/components/ui/effect-error-details.tsx": "da7e89579ebd7bc6d56d4dde19ea871673f6f441452d9ba6786e41956be66a1f" + }, + "head": "bf38d8556744d8e45d459208cab6fe9d1b78b356", + "outcome": "no-change", + "reason": "A private class-name constant replaces two identical alert style strings; rendered error labels, Cause detail and alert semantics are unchanged. This presentation-only deduplication does not change any developer integration task or shipped guidance.", + "evidence": [ + "Reviewed both render branches and their bf38d855 diff. Final uncached typecheck passes 140 files; scoped formatting passed. No application-wide UI sharing or dependency change." + ] } } } diff --git a/_artifacts/domain_map.yaml b/_artifacts/domain_map.yaml index 01b55c0..5b82548 100644 --- a/_artifacts/domain_map.yaml +++ b/_artifacts/domain_map.yaml @@ -20,7 +20,7 @@ skills: covers: - RPC construction and canonical identity - HTTP decoded requests, SSE, multipart mutations, and metadata - - Native defaults, filters, skipping, and pagination + - Native defaults, filters, input-field skipping, and pagination - Stream policies, snapshots, and request cleanup - Optimistic overlap, event refresh, and owner replacement - DTO and rich-value hydration @@ -28,7 +28,8 @@ skills: tasks: - Derive buffered, paginated, accumulated, live, and metadata options from a ready client. - Address exact stream policy keys and compose encoded partial native filters. - - Apply native defaults without losing selected or explicitly undefined seed behavior. + - Apply native defaults and conditional input without losing selected or explicitly undefined + seed behavior. - Capture open streams and drain preparation before request disposal. - Hydrate every selected view into its normal decoded cache representation. - Preserve neighboring writes and originating ownership during mutation overlap. diff --git a/_artifacts/skill_spec.md b/_artifacts/skill_spec.md index c056126..49e0d25 100644 --- a/_artifacts/skill_spec.md +++ b/_artifacts/skill_spec.md @@ -62,3 +62,8 @@ declarations and exports. tasks. Scoped formatting, skill validation, and native maintainer checks with the default and actual PR bases passed. Initial package publication remains pending. +- **API and architecture review (2026-10-11), based on `bf38d85`:** use one + conditional-input form across buffered, metadata, streamed, and live options. + Retain the ten tasks and existing source mappings. Compiler fixtures verify + contextual inference and mutation tracking; 124 in-memory runtime cases cover + cache views, retry attempts, defaults, hydration, cancellation, and ownership. diff --git a/apps/docs/src/content/docs/guides/conditional-queries.md b/apps/docs/src/content/docs/guides/conditional-queries.md index caa4c96..c236e95 100644 --- a/apps/docs/src/content/docs/guides/conditional-queries.md +++ b/apps/docs/src/content/docs/guides/conditional-queries.md @@ -79,9 +79,7 @@ skipped streams. Infinite queries accept a page-to-input mapper or `skipToken`, conditional union, with their required `initialPageParam` and `getNextPageParam`. Choose between the mapper and sentinel in `input`; each mapper result must be valid input. -When no caller options are needed, `queryOptions(skipToken)`, `streamedOptions(skipToken)`, and -`liveOptions(skipToken)` remain available as shorthand. A skipped query has no executable query -function, so manual `refetch()` cannot run it. Supply valid input to enable it. If a complete +A skipped query has no executable query function, so manual `refetch()` cannot run it. Supply valid input to enable it. If a complete request is available and you need manual refetch, use `enabled: false` instead. For HTTP pagination, map each page parameter to the complete request and keep stable filters in diff --git a/apps/docs/src/content/docs/reference/http-factory.md b/apps/docs/src/content/docs/reference/http-factory.md index fd3bc6a..57cfd79 100644 --- a/apps/docs/src/content/docs/reference/http-factory.md +++ b/apps/docs/src/content/docs/reference/http-factory.md @@ -94,7 +94,7 @@ Applicable TanStack options pass through. The package owns the key and function reject per-call `queryKeyHashFn` and `queryHash` with `EffectHttpApiQueryConfigError` code `UnsupportedQueryHash`. -Input-bearing `queryOptions` accepts a complete request, `queryOptions(skipToken)`, or +Input-bearing `queryOptions` accepts a complete request in `input` or `queryOptions({ input: skipToken, ...options })`. Import `skipToken` from `effect-api-query` or TanStack. Skipping preserves caller options and returns the exact sentinel as `queryFn`, with the operation-level key and no request identity. It performs no request encoding or client call. diff --git a/examples/tanstack-start/src/lib/snapshot-preparation.ts b/examples/tanstack-start/src/lib/snapshot-preparation.ts index df01926..6c37a19 100644 --- a/examples/tanstack-start/src/lib/snapshot-preparation.ts +++ b/examples/tanstack-start/src/lib/snapshot-preparation.ts @@ -67,34 +67,37 @@ export const makeSnapshotPreparation = ( ] const codecFor = (key: QueryKey) => codecs.find(({ prefix }) => hashKey(key.slice(0, prefix.length)) === hashKey(prefix))?.codec - const encode = Effect.fnUntraced(function* (queries: DehydratedState['queries']) { - return yield* Effect.forEach( + type ViewCodec = (typeof codecs)[number]['codec'] + type SnapshotData = DehydratedState['queries'][number]['state']['data'] + const transform = ( + queries: DehydratedState['queries'], + convert: ( + codec: ViewCodec, + data: SnapshotData, + ) => Effect.Effect, + ) => + Effect.forEach( Effect.fnUntraced(function* (query: DehydratedState['queries'][number]) { const codec = codecFor(query.queryKey) if (codec === undefined) { return yield* Effect.die(new TypeError('No Start snapshot codec for query')) } - const data = yield* Schema.encodeUnknownEffect(codec)(query.state.data) + const data = yield* convert(codec, query.state.data) return { ...query, state: { ...query.state, data } } }), )(queries) - }) - const decode = Effect.fnUntraced(function* (queries: DehydratedState['queries']) { - return yield* Effect.forEach( - Effect.fnUntraced(function* (query: DehydratedState['queries'][number]) { - const codec = codecFor(query.queryKey) - if (codec === undefined) { - return yield* Effect.die(new TypeError('No Start snapshot codec for query')) - } - const data = yield* Schema.decodeUnknownEffect(codec)(query.state.data) - return { ...query, state: { ...query.state, data } } - }), - )(queries) - }) return { encode: async (queries: DehydratedState['queries']) => - runPreparation(encode(queries).pipe(Effect.provideService(SnapshotEncoding, services))), + runPreparation( + transform(queries, (codec, data) => Schema.encodeUnknownEffect(codec)(data)).pipe( + Effect.provideService(SnapshotEncoding, services), + ), + ), decode: async (queries: DehydratedState['queries']) => - runPreparation(decode(queries).pipe(Effect.provideService(SnapshotDecoding, services))), + runPreparation( + transform(queries, (codec, data) => Schema.decodeUnknownEffect(codec)(data)).pipe( + Effect.provideService(SnapshotDecoding, services), + ), + ), } } diff --git a/examples/vite-react/src/components/ui/effect-error-details.tsx b/examples/vite-react/src/components/ui/effect-error-details.tsx index ba46dfc..465b071 100644 --- a/examples/vite-react/src/components/ui/effect-error-details.tsx +++ b/examples/vite-react/src/components/ui/effect-error-details.tsx @@ -1,16 +1,16 @@ import { Cause } from 'effect' import { isEffectHttpApiQueryError, isEffectRpcQueryError } from 'effect-api-query' +const alertClassName = + 'border border-l-4 border-destructive-900/80 border-l-destructive-600 bg-destructive-950/50 p-3 text-sm whitespace-pre-wrap text-destructive-200' + export const EffectErrorDetails = ({ error }: { readonly error: unknown }) => { if (isEffectRpcQueryError(error) || isEffectHttpApiQueryError(error)) { const operation = isEffectRpcQueryError(error) ? error.rpcTag : `${error.groupId}.${error.endpoint}` return ( -

+

{error.name} from {operation} ({error.operation})
{Cause.pretty(error.cause)} @@ -19,10 +19,7 @@ export const EffectErrorDetails = ({ error }: { readonly error: unknown }) => { } return error instanceof Error ? ( -

+

{error.name}: {error.message}

) : null diff --git a/examples/vite-react/src/lib/application.ts b/examples/vite-react/src/lib/application.ts index a765e38..73917c1 100644 --- a/examples/vite-react/src/lib/application.ts +++ b/examples/vite-react/src/lib/application.ts @@ -147,7 +147,7 @@ export const startViteReactApplication = async ({ rpcQuery, httpQuery, isActive: capturedOwner.isActive, - runMutation: capturedOwner.runMutation, + trackMutationOptions: capturedOwner.trackMutationOptions, }), invalidateUsers: async () => { if (!capturedOwner.isActive()) { diff --git a/examples/vite-react/src/lib/owner-cache.ts b/examples/vite-react/src/lib/owner-cache.ts index e492e82..21e17e9 100644 --- a/examples/vite-react/src/lib/owner-cache.ts +++ b/examples/vite-react/src/lib/owner-cache.ts @@ -1,9 +1,4 @@ -import type { - MutationFunction, - MutationOptions, - QueryClient, - QueryKey, -} from '@tanstack/react-query' +import type { QueryClient, QueryKey } from '@tanstack/react-query' import { Schema } from 'effect' const OwnerIdentity = Schema.Struct({ @@ -15,17 +10,6 @@ const OwnerIdentity = Schema.Struct({ export type ApplicationOwnerIdentity = typeof OwnerIdentity.Type export type DirectoryStorage = Pick -type TrackedMutationOptions = MutationOptions< - TData, - TError, - TVariables, - TOnMutateResult -> & { - readonly mutationFn: MutationFunction - readonly networkMode: 'always' - readonly retry: false -} - const Directory = Schema.Array( Schema.Struct({ id: Schema.Int, name: Schema.String, locale: Schema.String }), ) @@ -84,15 +68,17 @@ export const makeOwnerCache = ({ pendingMutations.delete(result) } } - const trackMutationOptions = ( - options: MutationOptions & { - readonly mutationFn: MutationFunction - }, - ): TrackedMutationOptions => ({ + const trackMutationOptions = ( + options: Options & { readonly mutationFn: (...args: Args) => Promise }, + ): Omit & { + readonly mutationFn: (...args: Args) => Promise + readonly networkMode: 'always' + readonly retry: false + } => ({ ...options, networkMode: 'always' as const, retry: false as const, - mutationFn: async (...args: Parameters>) => + mutationFn: async (...args: Args) => await runMutation(async () => await options.mutationFn(...args)), }) const saveDirectory = () => { diff --git a/examples/vite-react/src/lib/user-writes.ts b/examples/vite-react/src/lib/user-writes.ts index 23cd299..7d647b8 100644 --- a/examples/vite-react/src/lib/user-writes.ts +++ b/examples/vite-react/src/lib/user-writes.ts @@ -1,78 +1,23 @@ import { User, UserPage } from '@effect-api-query/contracts' -import type { InfiniteData, MutationOptions, QueryClient } from '@tanstack/react-query' +import type { InfiniteData, QueryClient } from '@tanstack/react-query' import type { ExampleHttpQueryUtils, ExampleRpcQueryUtils } from './application.ts' +import type { makeOwnerCache } from './owner-cache.ts' export interface UserWriteOwner { readonly queryClient: QueryClient readonly rpcQuery: Pick readonly httpQuery: Pick readonly isActive: () => boolean - readonly runMutation: (execute: () => Promise) => Promise + readonly trackMutationOptions: ReturnType['trackMutationOptions'] } -export interface CreateTransaction { - readonly commit: (user: User) => Promise - readonly rollback: () => void - readonly settle: () => Promise -} -export interface DeleteTransaction { - readonly commit: () => Promise - readonly rollback: () => void - readonly settle: () => Promise -} interface DirectorySnapshot { readonly rpcList: readonly User[] | undefined readonly httpList: readonly User[] | undefined readonly rpcPages: InfiniteData | undefined readonly httpPages: InfiniteData | undefined } -export type UserMutationOptions = MutationOptions< - Data, - Failure, - Input, - Transaction -> & - Required, 'mutationFn' | 'mutationKey'>> -export type RpcCreateOptions = ReturnType< - ExampleRpcQueryUtils['users']['create']['mutationOptions'] -> -export type HttpCreateOptions = ReturnType< - ExampleHttpQueryUtils['users']['create']['mutationOptions'] -> -export type RpcDeleteOptions = ReturnType< - ExampleRpcQueryUtils['users']['delete']['mutationOptions'] -> -export type HttpDeleteOptions = ReturnType< - ExampleHttpQueryUtils['users']['delete']['mutationOptions'] -> -export interface UserWrites { - readonly rpcCreate: () => UserMutationOptions< - User, - Parameters>[0], - Parameters[0], - CreateTransaction - > - readonly httpCreate: () => UserMutationOptions< - User, - Parameters>[0], - Parameters[0], - CreateTransaction - > - readonly rpcDelete: () => UserMutationOptions< - void, - Parameters>[0], - Parameters[0], - DeleteTransaction - > - readonly httpDelete: () => UserMutationOptions< - void, - Parameters>[0], - Parameters[0], - DeleteTransaction - > -} - const restoreList = ( current: readonly User[] | undefined, snapshot: readonly User[] | undefined, @@ -126,8 +71,8 @@ export const makeUserWrites = ({ rpcQuery, httpQuery, isActive, - runMutation, -}: UserWriteOwner): UserWrites => { + trackMutationOptions, +}: UserWriteOwner) => { let nextWrite = 0 const pending = new Set() const rpcList = rpcQuery.users.list.queryKey() @@ -257,8 +202,6 @@ export const makeUserWrites = ({ } const rpcCreate = () => { const options = rpcQuery.users.create.mutationOptions({ - networkMode: 'always', - retry: false, onMutate: startCreate, onSuccess: async (user, _input, transaction) => transaction.commit(user), onError: (_error, _input, transaction) => { @@ -266,16 +209,10 @@ export const makeUserWrites = ({ }, onSettled: async (_data, _error, _input, transaction) => transaction?.settle(), }) - return { - ...options, - mutationFn: async (...args: Parameters) => - runMutation(async () => options.mutationFn(...args)), - } + return trackMutationOptions(options) } const httpCreate = () => { const options = httpQuery.users.create.mutationOptions({ - networkMode: 'always', - retry: false, onMutate: async ({ payload }) => startCreate(payload), onSuccess: async (user, _input, transaction) => transaction.commit(user), onError: (_error, _input, transaction) => { @@ -283,11 +220,7 @@ export const makeUserWrites = ({ }, onSettled: async (_data, _error, _input, transaction) => transaction?.settle(), }) - return { - ...options, - mutationFn: async (...args: Parameters) => - runMutation(async () => options.mutationFn(...args)), - } + return trackMutationOptions(options) } let deletionBaseline: DirectorySnapshot | undefined const deletionGroups = new Map< @@ -386,8 +319,6 @@ export const makeUserWrites = ({ } const rpcDelete = () => { const options = rpcQuery.users.delete.mutationOptions({ - networkMode: 'always', - retry: false, onMutate: async ({ id }) => startDelete(id), onSuccess: async (_data, _input, transaction) => transaction.commit(), onError: (_error, _input, transaction) => { @@ -395,16 +326,10 @@ export const makeUserWrites = ({ }, onSettled: async (_data, _error, _input, transaction) => transaction?.settle(), }) - return { - ...options, - mutationFn: async (...args: Parameters) => - runMutation(async () => options.mutationFn(...args)), - } + return trackMutationOptions(options) } const httpDelete = () => { const options = httpQuery.users.delete.mutationOptions({ - networkMode: 'always', - retry: false, onMutate: async ({ params }) => startDelete(params.id), onSuccess: async (_data, _input, transaction) => transaction.commit(), onError: (_error, _input, transaction) => { @@ -412,11 +337,7 @@ export const makeUserWrites = ({ }, onSettled: async (_data, _error, _input, transaction) => transaction?.settle(), }) - return { - ...options, - mutationFn: async (...args: Parameters) => - runMutation(async () => options.mutationFn(...args)), - } + return trackMutationOptions(options) } return { rpcCreate, httpCreate, rpcDelete, httpDelete } } diff --git a/skills/effect-api-query/references/query-patterns.md b/skills/effect-api-query/references/query-patterns.md index 9ee52ac..95d9d4a 100644 --- a/skills/effect-api-query/references/query-patterns.md +++ b/skills/effect-api-query/references/query-patterns.md @@ -37,8 +37,8 @@ export function makeUseUser(client: RpcClient.RpcClient.Flat QueryFunction +} + export interface StreamingOperation extends OperationIdentity { readonly kind: 'Streaming' readonly input: OperationInput - readonly liveIdentity?: (options: Record) => readonly JsonValue[] - readonly streamedIdentity: (options: Record) => readonly JsonValue[] /** Consumes and validates stream policy, including for skipped requests. */ readonly prepareStream: ( options: Record, operation: 'live' | 'streamed', runner: RunPromiseExit, requestOptions: unknown, - ) => (input: unknown) => QueryFunction + ) => QueryPreparation } export type OperationDescription = UnaryOperation | MutationOperation | StreamingOperation diff --git a/src/core/streamed-query.ts b/src/core/streamed-query.ts index 9c8f265..67f7aa8 100644 --- a/src/core/streamed-query.ts +++ b/src/core/streamed-query.ts @@ -1,6 +1,6 @@ import { experimental_streamedQuery } from '@tanstack/query-core' import type { QueryFunctionContext } from '@tanstack/query-core' -import { Exit, Stream } from 'effect' +import { Exit, Predicate, Stream } from 'effect' import type { Cause } from 'effect' import type { RunPromiseExit } from './types' @@ -13,6 +13,23 @@ export type StreamQueryPolicy = } | { readonly _tag: 'Live' } +export const accumulatedPolicy = ( + options: Record, + invalid: (code: 'InvalidMaxChunks' | 'InvalidRefetchMode', message: string) => Error, +): Extract => { + const { maxChunks, refetchMode = 'reset' } = options + if ( + maxChunks !== undefined && + (!Predicate.isNumber(maxChunks) || !Number.isSafeInteger(maxChunks) || maxChunks <= 0) + ) { + throw invalid('InvalidMaxChunks', 'maxChunks must be a positive safe integer') + } + if (refetchMode !== 'reset' && refetchMode !== 'append' && refetchMode !== 'replace') { + throw invalid('InvalidRefetchMode', 'refetchMode must be reset, append, or replace') + } + return { _tag: 'Accumulated', maxChunks, refetchMode } +} + export interface MakeStreamQueryOptions { readonly policy: StreamQueryPolicy readonly source: Stream.Stream diff --git a/src/core/types.ts b/src/core/types.ts index 3997b08..b0e6fd2 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -121,6 +121,65 @@ export type SuppliedQueryOptions = > & Omit, 'input' | keyof AdapterOptions> +export type InputQueryBuilder< + Input, + Data, + Error, + Key extends QueryKey, + SkippedKey extends QueryKey, + AdapterOptions, +> = { + < + Selected = Data, + ActualInput extends Input = Input, + Supplied extends object = UninferredBuilderOptions, + >( + options: ExactBuilderOptions< + WithDefinedInitialData< + QueryInput< + Data, + Error, + Selected, + InputQueryOptions['queryKey'], + AdapterOptions + >, + Data + > & { readonly input: ActualInput }, + Supplied + >, + ): WithDefinedInitialData< + SuppliedQueryOptions< + InputQueryOptions, + Supplied, + AdapterOptions + >, + Data + > + < + Selected = Data, + ActualInput extends Input = Input, + Supplied extends object = UninferredBuilderOptions, + >( + options: ExactBuilderOptions< + WithUndefinedInitialData< + QueryInput< + Data, + Error, + Selected, + InputQueryOptions['queryKey'], + AdapterOptions + >, + Data + > & { readonly input: ActualInput }, + Supplied + >, + ): SuppliedQueryOptions< + InputQueryOptions, + Supplied, + AdapterOptions + > +} + /** Owns unary query inference; adapters supply their request, data, error, and key types. */ export type UnaryQueryBuilder< Input, @@ -155,150 +214,46 @@ export type UnaryQueryBuilder< >, ): SuppliedQueryOptions, Supplied, AdapterOptions> } - : { - < - Selected = Data, - ActualInput extends SkipToken = SkipToken, - Supplied extends object = UninferredBuilderOptions, - >( - options: ExactBuilderOptions< - WithDefinedInitialData< - QueryInput< - Data, - Error, - Selected, - InputQueryOptions['queryKey'], - AdapterOptions - >, - Data - > & { readonly input: ActualInput }, - Supplied - >, - ): WithDefinedInitialData< - SuppliedQueryOptions< - InputQueryOptions, - Supplied, - AdapterOptions - >, - Data - > - < - Selected = Data, - ActualInput extends SkipToken = SkipToken, - Supplied extends object = UninferredBuilderOptions, - >( - options: ExactBuilderOptions< - WithUndefinedInitialData< - QueryInput< - Data, - Error, - Selected, - InputQueryOptions['queryKey'], - AdapterOptions - >, - Data - > & { readonly input: ActualInput }, - Supplied - >, - ): SuppliedQueryOptions< - InputQueryOptions, - Supplied, - AdapterOptions - > - < - Selected = Data, - ActualInput extends Input = Input, - Supplied extends object = UninferredBuilderOptions, - >( - options: ExactBuilderOptions< - WithDefinedInitialData< - QueryInput< - Data, - Error, - Selected, - InputQueryOptions['queryKey'], - AdapterOptions - >, - Data - > & { readonly input: ActualInput }, - Supplied - >, - ): WithDefinedInitialData< - SuppliedQueryOptions< - InputQueryOptions, - Supplied, - AdapterOptions - >, - Data - > - < - Selected = Data, - ActualInput extends Input = Input, - Supplied extends object = UninferredBuilderOptions, - >( - options: ExactBuilderOptions< - WithUndefinedInitialData< - QueryInput< - Data, - Error, - Selected, - InputQueryOptions['queryKey'], - AdapterOptions - >, - Data - > & { readonly input: ActualInput }, - Supplied - >, - ): SuppliedQueryOptions< - InputQueryOptions, - Supplied, - AdapterOptions - > - ( - options: ExactBuilderOptions< - WithDefinedInitialData< - Omit< - ConditionalQueryOptions, - OwnedQueryOption - >, - Data - > & - AdapterOptions & { readonly input: Input | SkipToken }, - Supplied - >, - ): WithDefinedInitialData< - SuppliedQueryOptions< + : InputQueryBuilder & + InputQueryBuilder & { + ( + options: ExactBuilderOptions< + WithDefinedInitialData< + Omit< + ConditionalQueryOptions, + OwnedQueryOption + >, + Data + > & + AdapterOptions & { readonly input: Input | SkipToken }, + Supplied + >, + ): WithDefinedInitialData< + SuppliedQueryOptions< + ConditionalQueryOptions, + Supplied, + AdapterOptions + >, + Data + > + ( + options: ExactBuilderOptions< + WithUndefinedInitialData< + Omit< + ConditionalQueryOptions, + OwnedQueryOption + >, + Data + > & + AdapterOptions & { readonly input: Input | SkipToken }, + Supplied + >, + ): SuppliedQueryOptions< ConditionalQueryOptions, Supplied, AdapterOptions - >, - Data - > - ( - options: ExactBuilderOptions< - WithUndefinedInitialData< - Omit< - ConditionalQueryOptions, - OwnedQueryOption - >, - Data - > & - AdapterOptions & { readonly input: Input | SkipToken }, - Supplied - >, - ): SuppliedQueryOptions< - ConditionalQueryOptions, - Supplied, - AdapterOptions - > - ( - token: SkipToken, - ): SuppliedQueryOptions< - QueryOptions, - Record, - AdapterOptions - > - } + > + } export type InfiniteInput< Data, diff --git a/src/core/utility-tree.ts b/src/core/utility-tree.ts index 2a04b83..58a75ff 100644 --- a/src/core/utility-tree.ts +++ b/src/core/utility-tree.ts @@ -1,11 +1,12 @@ import { replaceEqualDeep, skipToken } from '@tanstack/query-core' -import type { QueryFunction, QueryFunctionContext } from '@tanstack/query-core' +import type { QueryFunctionContext } from '@tanstack/query-core' import { Effect, Exit, Predicate } from 'effect' import type { Cause } from 'effect' import type { BufferedOperation, OperationDescription, + QueryPreparation, RuntimeKeyEncoder, StreamingOperation, TreeErrors, @@ -232,10 +233,10 @@ const prepareQuery = ( } const prepareQueryOptions = (description: OperationDescription, argument: unknown) => { - const options: Record = - argument === skipToken - ? { input: skipToken } - : { ...(Predicate.isObject(argument) ? argument : undefined) } + if (argument !== undefined && !Predicate.isObject(argument)) { + throw new TypeError('Query options must be an object') + } + const options = { ...argument } for (const option of ['queryKeyHashFn', 'queryHash'] as const) { if (Object.hasOwn(options, option)) { throw description.unsupportedQueryHash(option) @@ -261,11 +262,11 @@ const createQueryBuilders = ( description: UnaryOperation | StreamingOperation, operationKey: readonly JsonValue[], keyEncoder: RuntimeKeyEncoder | undefined, - prepareExecution: ( + prepareOptions: ( options: Record, requestOptions: unknown, input: unknown, - ) => (input: unknown) => QueryFunction, + ) => QueryPreparation, prepareIdentity?: (options: Record) => readonly JsonValue[], ) => ({ key: (input?: unknown, policy?: Record) => { @@ -275,9 +276,8 @@ const createQueryBuilders = ( }, options: (argument?: unknown) => { const { input, options, requestOptions } = prepareQueryOptions(description, argument) - const identity = prepareIdentity?.(options) ?? [] // Stream policy is consumed and validated even when execution will be skipped. - const makeQuery = prepareExecution(options, requestOptions, input) + const { identity, makeQuery } = prepareOptions(options, requestOptions, input) if (description.input._tag !== 'Inputless' && input === skipToken) { return finalizeQueryOptions(options, operationKey, skipToken) } @@ -445,10 +445,13 @@ const createUnaryLeaf = ( description, queryOperationKey, keyEncoder, - (_options, requestOptions) => - (input) => - async ({ signal }: { readonly signal: AbortSignal }) => - execute(description, 'query', input, runPromiseExit, requestOptions, signal), + (_options, requestOptions) => ({ + identity: [], + makeQuery: + (input) => + async ({ signal }) => + execute(description, 'query', input, runPromiseExit, requestOptions, signal), + }), ) const { metadata } = description const metadataQuery = @@ -458,14 +461,16 @@ const createUnaryLeaf = ( description, freezeKey([...operationKey, 'metadata']), keyEncoder, - (options, requestOptions, input) => - prepareMetadataExecution( + (options, requestOptions, input) => ({ + identity: [], + makeQuery: prepareMetadataExecution( metadata, options, runPromiseExit, requestOptions, description.input._tag !== 'Inputless' && input === skipToken, ), + }), ) return Object.freeze({ @@ -487,24 +492,19 @@ const createStreamingLeaf = ( runPromiseExit: RunPromiseExit, ) => { const operationKey = freezeKey(keyParts) - const liveOperationKey = freezeKey([...operationKey, 'live']) - const streamedOperationKey = freezeKey([...operationKey, 'streamed']) - const live = createQueryBuilders( - description, - liveOperationKey, - keyEncoder, - (options, requestOptions) => - description.prepareStream(options, 'live', runPromiseExit, requestOptions), - description.liveIdentity, - ) - const streamed = createQueryBuilders( - description, - streamedOperationKey, - keyEncoder, - (options, requestOptions) => - description.prepareStream(options, 'streamed', runPromiseExit, requestOptions), - description.streamedIdentity, - ) + const createView = (operation: 'live' | 'streamed') => { + const prepare = (options: Record, requestOptions: unknown) => + description.prepareStream(options, operation, runPromiseExit, requestOptions) + return createQueryBuilders( + description, + freezeKey([...operationKey, operation]), + keyEncoder, + prepare, + (options) => prepare(options, undefined).identity, + ) + } + const live = createView('live') + const streamed = createView('streamed') return Object.freeze({ key: () => operationKey, diff --git a/src/http/create-utils.ts b/src/http/create-utils.ts index bf20467..664746e 100644 --- a/src/http/create-utils.ts +++ b/src/http/create-utils.ts @@ -15,7 +15,7 @@ export const createHttpApiQueryUtils = < ): HttpApiQueryUtils => { // SAFETY: Api extends HttpApi.Constraint; this erases endpoint type parameters // only for metadata traversal, preserving the original Api in the public result. - // oxlint-disable-next-line typescript/no-unsafe-type-assertion, anti-slop/no-chained-type-assertions + // oxlint-disable-next-line typescript/no-unsafe-type-assertion, anti-slop/require-safety-comment-for-type-assertion, anti-slop/no-chained-type-assertions const runtimeApi = api as unknown as HttpApi.Top const { operations, errors, keyEncoders } = compileHttpOperations( runtimeApi, @@ -24,7 +24,7 @@ export const createHttpApiQueryUtils = < ) // SAFETY: Public options require a runner when the ready client retains services; // the runtime tree forwards the same runner without changing its Context. - // oxlint-disable-next-line typescript/no-unsafe-type-assertion + // oxlint-disable-next-line typescript/no-unsafe-type-assertion, anti-slop/require-safety-comment-for-type-assertion const runPromiseExit = options.runPromiseExit as RunPromiseExit | undefined const tree = createUtilityTree(operations, { keyPrefix: options.keyPrefix, @@ -33,8 +33,6 @@ export const createHttpApiQueryUtils = < runPromiseExit, errors, }) - // SAFETY: The compiled operations retain this Api's endpoint identities and ready - // client. - // oxlint-disable-next-line typescript/no-unsafe-type-assertion + // oxlint-disable-next-line typescript/no-unsafe-type-assertion, anti-slop/require-safety-comment-for-type-assertion return tree as HttpApiQueryUtils } diff --git a/src/http/operation.ts b/src/http/operation.ts index 40645e5..7fb323a 100644 --- a/src/http/operation.ts +++ b/src/http/operation.ts @@ -14,11 +14,7 @@ import type { import { EffectHttpApiQueryConfigError, EffectHttpApiQueryError } from './errors' import type { HttpApiEndpointIdentity } from './errors' import { createHttpRequest } from './request' -import { - createHttpLiveIdentity, - createHttpStreamIdentity, - createHttpStreamPreparation, -} from './streamed-query' +import { createHttpStreamPreparation } from './streamed-query' type HttpOperation = (UnaryOperation | MutationOperation | StreamingOperation) & { readonly identity: HttpApiEndpointIdentity @@ -95,8 +91,6 @@ const extractHttpEndpoints = (api: HttpApi.Top, client: unknown): readonly HttpO ...common, ...request, kind: 'Streaming', - liveIdentity: createHttpLiveIdentity(identity), - streamedIdentity: createHttpStreamIdentity(identity), prepareStream: createHttpStreamPreparation(identity, (requestInput, sseOptions) => // oxlint-disable-next-line effecttsgo/any-unknown-in-error-context invoke(requestInput, 'decoded-only', sseOptions), diff --git a/src/http/streamed-query.ts b/src/http/streamed-query.ts index e9677ac..f4a9b8c 100644 --- a/src/http/streamed-query.ts +++ b/src/http/streamed-query.ts @@ -3,9 +3,8 @@ import type { Sse } from 'effect/encoding' import { HttpApiSchema } from 'effect/http-api' import type { StreamingOperation } from '../core/operation' -import { makeStreamQuery } from '../core/streamed-query' +import { accumulatedPolicy, makeStreamQuery } from '../core/streamed-query' import type { StreamQueryPolicy } from '../core/streamed-query' -import type { JsonValue } from '../core/types' import { EffectHttpApiQueryConfigError, EffectHttpApiQueryEmptyStreamError, @@ -30,51 +29,6 @@ const decoderOptions = ( return Object.freeze({ maxEventSize: limit }) } -const accumulatedPolicy = ( - identity: HttpApiEndpointIdentity, - options: Record, -): Extract => { - const { maxChunks, refetchMode = 'reset' } = options - if ( - maxChunks !== undefined && - (!Predicate.isNumber(maxChunks) || !Number.isSafeInteger(maxChunks) || maxChunks <= 0) - ) { - throw new EffectHttpApiQueryConfigError( - 'InvalidMaxChunks', - 'maxChunks must be a positive safe integer', - identity, - ) - } - if (refetchMode !== 'reset' && refetchMode !== 'append' && refetchMode !== 'replace') { - throw new EffectHttpApiQueryConfigError( - 'InvalidRefetchMode', - 'refetchMode must be reset, append, or replace', - identity, - ) - } - return { _tag: 'Accumulated', maxChunks, refetchMode } -} - -export const createHttpStreamIdentity = - (identity: HttpApiEndpointIdentity): StreamingOperation['streamedIdentity'] => - (options) => { - const { maxChunks, refetchMode } = accumulatedPolicy(identity, options) - const { maxEventSize } = decoderOptions(identity, options) - const policy: JsonValue = Object.freeze({ - maxChunks: maxChunks ?? null, - refetchMode, - maxEventSize, - }) - return [policy] - } - -export const createHttpLiveIdentity = - (identity: HttpApiEndpointIdentity): NonNullable => - (options) => { - const { maxEventSize } = decoderOptions(identity, options) - return [Object.freeze({ maxEventSize })] - } - export const createHttpStreamPreparation = ( identity: HttpApiEndpointIdentity, @@ -85,38 +39,56 @@ export const createHttpStreamPreparation = ): StreamingOperation['prepareStream'] => (options, operation, runPromiseExit) => { const policy: StreamQueryPolicy = - operation === 'live' ? { _tag: 'Live' } : accumulatedPolicy(identity, options) + operation === 'live' + ? { _tag: 'Live' } + : accumulatedPolicy( + options, + (code, message) => new EffectHttpApiQueryConfigError(code, message, identity), + ) const sseOptions = decoderOptions(identity, options) delete options['refetchMode'] delete options['maxChunks'] delete options['sseOptions'] - return (input) => - makeStreamQuery({ - policy, - source: Stream.unwrap( - // oxlint-disable-next-line effecttsgo/any-unknown-in-error-context - Effect.suspend(() => invoke(input, sseOptions)).pipe( - Effect.map((response) => { - if ( - Predicate.hasProperty(response, 'body') && - Predicate.hasProperty(response, 'headers') && - Stream.isStream(response.body) - ) { - return response.body.pipe( - Stream.map((body) => - HttpApiSchema.withHeaders({ body, headers: response.headers }), - ), - ) - } - if (!Stream.isStream(response)) { - throw new TypeError('HTTP SSE clients must return a Stream') - } - return response - }), + return { + identity: [ + Object.freeze({ + ...(policy._tag === 'Live' + ? undefined + : { + maxChunks: policy.maxChunks ?? null, + refetchMode: policy.refetchMode, + }), + maxEventSize: sseOptions.maxEventSize, + }), + ], + makeQuery: (input) => + makeStreamQuery({ + policy, + source: Stream.unwrap( + // oxlint-disable-next-line effecttsgo/any-unknown-in-error-context + Effect.suspend(() => invoke(input, sseOptions)).pipe( + Effect.map((response) => { + if ( + Predicate.hasProperty(response, 'body') && + Predicate.hasProperty(response, 'headers') && + Stream.isStream(response.body) + ) { + return response.body.pipe( + Stream.map((body) => + HttpApiSchema.withHeaders({ body, headers: response.headers }), + ), + ) + } + if (!Stream.isStream(response)) { + throw new TypeError('HTTP SSE clients must return a Stream') + } + return response + }), + ), ), - ), - executionError: (cause) => new EffectHttpApiQueryError(identity, operation, cause), - emptyError: () => new EffectHttpApiQueryEmptyStreamError(identity), - runPromiseExit, - }) + executionError: (cause) => new EffectHttpApiQueryError(identity, operation, cause), + emptyError: () => new EffectHttpApiQueryEmptyStreamError(identity), + runPromiseExit, + }), + } } diff --git a/src/rpc/create-utils.ts b/src/rpc/create-utils.ts index e8206a5..4bbb7ad 100644 --- a/src/rpc/create-utils.ts +++ b/src/rpc/create-utils.ts @@ -23,14 +23,14 @@ export const createRpcQueryUtils = < ): RpcQueryUtils => { // SAFETY: Group is a RpcGroup.Any; this erases its member union only for runtime // enumeration. The returned tree retains that union in the public generic type. - // oxlint-disable-next-line typescript/no-unsafe-type-assertion, anti-slop/no-chained-type-assertions + // oxlint-disable-next-line typescript/no-unsafe-type-assertion, anti-slop/require-safety-comment-for-type-assertion, anti-slop/no-chained-type-assertions const runtimeGroup = group as unknown as RpcGroup.RpcGroup // SAFETY: options.client is tied to this same Group by CreateRpcQueryUtilsOptions. const client = options.client as RpcClient.RpcClient.Flat const rpcs = extractRpcs(runtimeGroup, client) // SAFETY: Public options require a runner for any remaining client services. // The runtime tree preserves this runner unchanged while erasing its Context type. - // oxlint-disable-next-line typescript/no-unsafe-type-assertion + // oxlint-disable-next-line typescript/no-unsafe-type-assertion, anti-slop/require-safety-comment-for-type-assertion const runPromiseExit = options.runPromiseExit as RunPromiseExit | undefined const tree = createUtilityTree(rpcs, { keyPrefix: options.keyPrefix, @@ -39,7 +39,6 @@ export const createRpcQueryUtils = < runPromiseExit, errors: rpcTreeErrors, }) - // SAFETY: extractRpcs projects every Group member, preserving each tag/payload. - // oxlint-disable-next-line typescript/no-unsafe-type-assertion + // oxlint-disable-next-line typescript/no-unsafe-type-assertion, anti-slop/require-safety-comment-for-type-assertion return tree as RpcQueryUtils } diff --git a/src/rpc/operation.ts b/src/rpc/operation.ts index 3f97ef5..8b73731 100644 --- a/src/rpc/operation.ts +++ b/src/rpc/operation.ts @@ -5,7 +5,7 @@ import { RpcSchema } from 'effect/rpc' import type { OperationDescription, OperationInput, TreeErrors } from '../core/operation' import { createSchemaKeyEncoding } from '../core/schema-key' import { EffectRpcQueryConfigError, EffectRpcQueryError, EffectRpcQueryKeyError } from './errors' -import { createStreamIdentity, createStreamPreparation } from './streamed-query' +import { createStreamPreparation } from './streamed-query' const createRpcInput = (definition: Rpc.AnyWithProps): OperationInput => { const { payloadSchema, _tag: rpcTag } = definition @@ -94,7 +94,6 @@ const describeRpc = ( return { ...identity, kind: 'Streaming', - streamedIdentity: createStreamIdentity(rpcTag), prepareStream: createStreamPreparation({ tag: rpcTag, invoke: (input, options) => diff --git a/src/rpc/streamed-query.ts b/src/rpc/streamed-query.ts index 0615fd9..2344a5d 100644 --- a/src/rpc/streamed-query.ts +++ b/src/rpc/streamed-query.ts @@ -1,9 +1,8 @@ -import { Predicate, Stream } from 'effect' +import { Stream } from 'effect' import type { StreamingOperation } from '../core/operation' -import { makeStreamQuery } from '../core/streamed-query' +import { accumulatedPolicy, makeStreamQuery } from '../core/streamed-query' import type { StreamQueryPolicy } from '../core/streamed-query' -import type { JsonValue } from '../core/types' import { EffectRpcQueryConfigError, EffectRpcQueryEmptyStreamError, @@ -19,56 +18,39 @@ export interface RpcStreamInvocation { ) => Stream.Stream } -const accumulatedPolicy = ( - rpcTag: string, - options: Record, -): Extract => { - const { maxChunks, refetchMode = 'reset' } = options - if ( - maxChunks !== undefined && - (!Predicate.isNumber(maxChunks) || !Number.isSafeInteger(maxChunks) || maxChunks <= 0) - ) { - throw new EffectRpcQueryConfigError( - 'InvalidMaxChunks', - 'maxChunks must be a positive safe integer', - { rpcTag }, - ) - } - if (refetchMode !== 'reset' && refetchMode !== 'append' && refetchMode !== 'replace') { - throw new EffectRpcQueryConfigError( - 'InvalidRefetchMode', - 'refetchMode must be reset, append, or replace', - { rpcTag }, - ) - } - return { _tag: 'Accumulated' as const, maxChunks, refetchMode } -} - -export const createStreamIdentity = - (rpcTag: string): StreamingOperation['streamedIdentity'] => - (options) => { - const { maxChunks, refetchMode } = accumulatedPolicy(rpcTag, options) - const identity: JsonValue = Object.freeze({ maxChunks: maxChunks ?? null, refetchMode }) - return [identity] - } - /** Owns policy validation and execution for both accumulated and live queries. */ export const createStreamPreparation = (rpc: RpcStreamInvocation): StreamingOperation['prepareStream'] => (options, operation, runPromiseExit, requestOptions) => { const policy: StreamQueryPolicy = - operation === 'live' ? { _tag: 'Live' } : accumulatedPolicy(rpc.tag, options) + operation === 'live' + ? { _tag: 'Live' } + : accumulatedPolicy( + options, + (code, message) => new EffectRpcQueryConfigError(code, message, { rpcTag: rpc.tag }), + ) delete options['refetchMode'] delete options['maxChunks'] // SAFETY: takeRpcOptions extracted this unchanged from the typed public options. // oxlint-disable-next-line typescript/no-unsafe-type-assertion const rpcOptions = requestOptions as StreamingRpcOptions | undefined - return (input) => - makeStreamQuery({ - policy, - source: Stream.suspend(() => rpc.invoke(input, rpcOptions)), - executionError: (cause) => new EffectRpcQueryError(rpc.tag, operation, cause), - emptyError: () => new EffectRpcQueryEmptyStreamError(rpc.tag), - runPromiseExit, - }) + return { + identity: + policy._tag === 'Live' + ? [] + : [ + Object.freeze({ + maxChunks: policy.maxChunks ?? null, + refetchMode: policy.refetchMode, + }), + ], + makeQuery: (input) => + makeStreamQuery({ + policy, + source: Stream.suspend(() => rpc.invoke(input, rpcOptions)), + executionError: (cause) => new EffectRpcQueryError(rpc.tag, operation, cause), + emptyError: () => new EffectRpcQueryEmptyStreamError(rpc.tag), + runPromiseExit, + }), + } } diff --git a/tests/cache-recipes.test.ts b/tests/cache-recipes.test.ts index 98c5104..0c903cf 100644 --- a/tests/cache-recipes.test.ts +++ b/tests/cache-recipes.test.ts @@ -1,13 +1,28 @@ import { User } from '@effect-api-query/contracts' import { it } from '@effect/vitest' import { MutationObserver, QueryClient } from '@tanstack/query-core' -import { Effect, Exit, Schema, Scope, Stream } from 'effect' +import { Cause, Effect, Exit, Result, Schema, Scope, Stream } from 'effect' +import { HttpServer } from 'effect/http' +import { + HttpApi, + HttpApiBuilder, + HttpApiEndpoint, + HttpApiGroup, + HttpApiTest, +} from 'effect/http-api' import { Reactivity } from 'effect/reactivity' import { Rpc, RpcGroup } from 'effect/rpc' import { describe, expect, it as test } from 'vite-plus/test' -import { createRpcQueryUtils } from '#effect-api-query' +import { + createHttpApiQueryUtils, + createRpcQueryUtils, + isEffectHttpApiQueryError, + isEffectRpcQueryError, +} from '#effect-api-query' +import type { EffectHttpApiQueryError, EffectRpcQueryError } from '#effect-api-query' +import { captureFailure } from './fixtures/async.ts' import { makeRpcTestClient } from './fixtures/effect-rpc.ts' import { makeControlledUserWrites } from './fixtures/optimistic-users.ts' import { makeUserEventConsumer } from './fixtures/user-events.ts' @@ -104,10 +119,12 @@ describe('cache recipes', () => { }) const group = RpcGroup.make( Rpc.make('watch', { success: HistoryElement, stream: true }), + Rpc.make('latest', { success: Schema.UndefinedOr(Profile), stream: true }), Rpc.make('page', { payload: { offset: Schema.BigIntFromString }, success: ProfilePage }), ) const client = yield* makeRpcTestClient(group, { watch: () => Stream.make(profile, undefined, null), + latest: () => Stream.succeed(undefined), page: () => Effect.succeed({ rows: [profile], next: null }), }) const history = createRpcQueryUtils(group, { @@ -122,11 +139,49 @@ describe('cache recipes', () => { input: (cursor) => ({ offset: cursor.offset }), getNextPageParam: () => {}, }) + const latest = createRpcQueryUtils(group, { + client, + keyPrefix: ['hydration-views', 'live'], + }).latest.liveOptions() + const refreshed = new Profile({ name: 'Grace', credits: 42n, avatar: new Uint8Array([7]) }) + let browserReads = 0 + const browserClient = yield* makeRpcTestClient(group, { + watch: () => { + browserReads += 1 + return Stream.make(refreshed, undefined, null) + }, + latest: () => { + browserReads += 1 + return Stream.succeed(refreshed) + }, + page: () => + Effect.sync(() => { + browserReads += 1 + return { rows: [refreshed], next: null } + }), + }) + const browserHistory = createRpcQueryUtils(group, { + client: browserClient, + keyPrefix: ['hydration-views', 'history'], + }).watch.streamedOptions() + const browserLatest = createRpcQueryUtils(group, { + client: browserClient, + keyPrefix: ['hydration-views', 'live'], + }).latest.liveOptions() + const browserInfinite = createRpcQueryUtils(group, { + client: browserClient, + keyPrefix: ['hydration-views', 'infinite'], + }).page.infiniteOptions({ + initialPageParam: new Cursor({ offset: 0n }), + input: (cursor) => ({ offset: cursor.offset }), + getNextPageParam: () => {}, + }) const server = new QueryClient() const browser = new QueryClient() try { yield* Effect.promise(async () => await server.query(history)) yield* Effect.promise(async () => await server.infiniteQuery(infinite)) + expect(yield* Effect.promise(async () => await server.query(latest))).toBeNull() const json = yield* prepareViewSnapshot(server).pipe( Effect.provideService(SnapshotEncoding, { beforeEncode: Effect.void }), ) @@ -146,6 +201,25 @@ describe('cache recipes', () => { expect(page?.pageParams[0]).toBeInstanceOf(Cursor) expect(page?.pageParams[0]?.isStart()).toBe(true) expect(page?.pages[0]?.rows[0]?.summary()).toBe('Ada: 9007199254740993 credits') + expect(browser.getQueryData(browserLatest.queryKey)).toBeNull() + expect(browserReads).toBe(0) + const newHistory = yield* Effect.promise(async () => await browser.query(browserHistory)) + const newPages = yield* Effect.promise( + async () => await browser.infiniteQuery(browserInfinite), + ) + const newLatest = yield* Effect.promise(async () => await browser.query(browserLatest)) + expect(newHistory?.slice(1)).toStrictEqual([undefined, null]) + const [newFirst] = newHistory + if (!(newFirst instanceof Profile) || !(newLatest instanceof Profile)) { + throw new TypeError('Expected browser-refetched Profile instances') + } + expect(newFirst.summary()).toBe('Grace: 42 credits') + expect(newFirst.avatar).toStrictEqual(new Uint8Array([7])) + expect(newPages.pageParams[0]).toBeInstanceOf(Cursor) + expect(newPages.pageParams[0]?.isStart()).toBe(true) + expect(newPages.pages[0]?.rows[0]?.summary()).toBe('Grace: 42 credits') + expect(newLatest.summary()).toBe('Grace: 42 credits') + expect(browserReads).toBe(3) } finally { server.clear() browser.clear() @@ -153,3 +227,198 @@ describe('cache recipes', () => { }), ) }) + +it.effect.each(['eventual-success', 'retry-exhausted', 'not-found', 'disabled'] as const)( + 'bounds generated RPC and HTTP query attempts for %s', + (scenario) => + Effect.gen(function* () { + const errors = Schema.Literals(['retry-later', 'not-found']) + const failure: typeof errors.Type = scenario === 'not-found' ? 'not-found' : 'retry-later' + const failures = scenario === 'eventual-success' ? 2 : Infinity + const group = RpcGroup.make(Rpc.make('read', { success: Schema.String, error: errors })) + const api = HttpApi.make('retry-recipe').add( + HttpApiGroup.make('items').add( + HttpApiEndpoint.get('read', '/read', { success: Schema.String, error: errors }), + ), + ) + const attempts = { rpc: 0, http: 0 } + const read = (adapter: 'rpc' | 'http') => + Effect.suspend(() => { + attempts[adapter] += 1 + return attempts[adapter] <= failures ? Effect.fail(failure) : Effect.succeed('server') + }) + const rpcClient = yield* makeRpcTestClient(group, { read: () => read('rpc') }) + const httpClient = yield* HttpApiTest.groups(api, ['items']).pipe( + Effect.provide( + HttpApiBuilder.group(api, 'items', (handlers) => + handlers.handle('read', () => read('http')), + ), + ), + ) + const rpc = createRpcQueryUtils(group, { client: rpcClient, keyPrefix: ['retry-rpc'] }) + const http = createHttpApiQueryUtils(api, { client: httpClient, keyPrefix: ['retry-http'] }) + for (const adapter of ['rpc', 'http'] as const) { + const retryCounts: number[] = [] + const delayIndexes: number[] = [] + const retry = ( + count: number, + error: EffectRpcQueryError | EffectHttpApiQueryError, + ) => { + retryCounts.push(count) + if ((!isEffectRpcQueryError(error) && !isEffectHttpApiQueryError(error)) || count >= 2) { + return false + } + const reason = Cause.findError(error.cause) + return Result.isSuccess(reason) && reason.success === 'retry-later' + } + const policy = { + retry: scenario === 'disabled' ? false : retry, + retryDelay: (index: number) => { + delayIndexes.push(index) + return 0 + }, + } + const queryClient = new QueryClient() + try { + const result = yield* Effect.promise( + async () => + await captureFailure( + adapter === 'rpc' + ? queryClient.query(rpc.read.queryOptions(policy)) + : queryClient.query(http.items.read.queryOptions(policy)), + ), + ) + const expectedAttempts = scenario === 'not-found' || scenario === 'disabled' ? 1 : 3 + expect(attempts[adapter]).toBe(expectedAttempts) + const expectedCallbacks = { + disabled: { retries: [], delays: [0] }, + 'not-found': { retries: [0], delays: [0] }, + 'eventual-success': { retries: [0, 1], delays: [0, 1] }, + 'retry-exhausted': { retries: [0, 1, 2], delays: [0, 1, 2] }, + }[scenario] + expect(retryCounts).toStrictEqual(expectedCallbacks.retries) + expect(delayIndexes).toStrictEqual(expectedCallbacks.delays) + if (scenario === 'eventual-success') { + expect(result).toBe('server') + } else { + expect(result).toMatchObject({ + _tag: adapter === 'rpc' ? 'EffectRpcQueryError' : 'EffectHttpApiQueryError', + }) + } + } finally { + queryClient.clear() + } + } + }).pipe(Effect.provide(HttpServer.layerServices)), +) + +it.effect('merges global, registered and call-site defaults for native RPC and HTTP reads', () => + Effect.gen(function* () { + const group = RpcGroup.make( + Rpc.make('users.read', { + payload: { id: Schema.Int }, + success: Schema.String, + }), + ) + const api = HttpApi.make('defaults-recipe').add( + HttpApiGroup.make('users').add( + HttpApiEndpoint.get('read', '/read/:id', { + params: { id: Schema.Int }, + success: Schema.String, + }), + ), + ) + const calls = { rpc: 0, http: 0 } + const read = (adapter: 'rpc' | 'http') => + Effect.sync(() => { + calls[adapter] += 1 + return 'server' + }) + const rpcClient = yield* makeRpcTestClient(group, { 'users.read': () => read('rpc') }) + const httpClient = yield* HttpApiTest.groups(api, ['users']).pipe( + Effect.provide( + HttpApiBuilder.group(api, 'users', (handlers) => + handlers.handle('read', () => read('http')), + ), + ), + ) + const rpc = createRpcQueryUtils(group, { client: rpcClient, keyPrefix: ['defaults-rpc'] }) + const http = createHttpApiQueryUtils(api, { client: httpClient, keyPrefix: ['defaults-http'] }) + for (const adapter of ['rpc', 'http'] as const) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { staleTime: 30_000, gcTime: 600_000, retry: false }, + }, + }) + const rootKey = adapter === 'rpc' ? rpc.key() : http.key() + const branchKey = adapter === 'rpc' ? rpc.users.key() : http.users.key() + const leafKey = adapter === 'rpc' ? rpc.users.read.key() : http.users.read.key() + const key = + adapter === 'rpc' + ? rpc.users.read.queryKey({ id: 1 }) + : http.users.read.queryKey({ params: { id: 1 } }) + queryClient.setQueryDefaults(rootKey, { + staleTime: 60_000, + queryKeyHashFn: (queryKey) => `defaults:${JSON.stringify(queryKey)}`, + }) + queryClient.setQueryDefaults(branchKey, { retry: 2 }) + queryClient.setQueryDefaults([...leafKey, 'query'], { staleTime: 120_000 }) + try { + expect( + yield* Effect.promise(async () => + adapter === 'rpc' + ? await queryClient.query(rpc.users.read.queryOptions({ input: { id: 1 } })) + : await queryClient.query( + http.users.read.queryOptions({ input: { params: { id: 1 } } }), + ), + ), + ).toBe('server') + expect( + queryClient.getQueryCache().find({ queryKey: key, exact: true })?.options, + ).toMatchObject({ + staleTime: 120_000, + gcTime: 600_000, + retry: 2, + }) + queryClient.setQueryData(key, 'native') + expect( + yield* Effect.promise(async () => + adapter === 'rpc' + ? await queryClient.query(rpc.users.read.queryOptions({ input: { id: 1 } })) + : await queryClient.query( + http.users.read.queryOptions({ input: { params: { id: 1 } } }), + ), + ), + ).toBe('native') + expect(calls[adapter]).toBe(1) + expect( + yield* Effect.promise(async () => + adapter === 'rpc' + ? await queryClient.query( + rpc.users.read.queryOptions({ input: { id: 1 }, staleTime: 0, retry: false }), + ) + : await queryClient.query( + http.users.read.queryOptions({ + input: { params: { id: 1 } }, + staleTime: 0, + retry: false, + }), + ), + ), + ).toBe('server') + expect( + queryClient.getQueryCache().find({ queryKey: key, exact: true })?.options, + ).toMatchObject({ + staleTime: 0, + gcTime: 600_000, + retry: false, + }) + expect(calls[adapter]).toBe(2) + expect(queryClient.getQueryData(key)).toBe('server') + expect(queryClient.getQueryCache().getAll()).toHaveLength(1) + } finally { + queryClient.clear() + } + } + }).pipe(Effect.provide(HttpServer.layerServices)), +) diff --git a/tests/create-rpc-query-utils-stream-cache.test.ts b/tests/create-rpc-query-utils-stream-cache.test.ts index de87503..df7439d 100644 --- a/tests/create-rpc-query-utils-stream-cache.test.ts +++ b/tests/create-rpc-query-utils-stream-cache.test.ts @@ -61,12 +61,21 @@ it.effect.each(['global', 'prefix'] as const)( Rpc.make('watch', { success: Schema.Undefined, stream: true }), ) const finalized = yield* Deferred.make() + let closed = 0 const client = yield* makeRpcTestClient(group, { read: () => Effect.succeed('server'), watch: () => Stream.succeed(undefined).pipe( Stream.concat(Stream.never), - Stream.ensuring(Deferred.succeed(finalized, undefined)), + Stream.ensuring( + Effect.sync(() => { + closed += 1 + }).pipe( + Effect.flatMap(() => + closed === 2 ? Deferred.succeed(finalized, undefined) : Effect.void, + ), + ), + ), ), }) const rpc = createRpcQueryUtils(group, { client, keyPrefix: ['hashing'] }) @@ -87,13 +96,28 @@ it.effect.each(['global', 'prefix'] as const)( const options = rpc.watch.liveOptions() const observer = new QueryObserver(queryClient, options) const unsubscribe = observer.subscribe(() => {}) + const history = new QueryObserver(queryClient, rpc.watch.streamedOptions()) + const stopHistory = history.subscribe(() => {}) try { yield* waitForQueryState( queryClient, - () => observer.getCurrentResult().status === 'success', + () => + observer.getCurrentResult().status === 'success' && + history.getCurrentResult().status === 'success', ) expect(observer.getCurrentResult()).toMatchObject({ data: null, fetchStatus: 'fetching' }) expect(queryClient.getQueryData(rpc.watch.liveKey())).toBeNull() + expect(history.getCurrentResult()).toMatchObject({ + data: [undefined], + fetchStatus: 'fetching', + }) + expect(queryClient.getQueryData(rpc.watch.streamedKey())).toStrictEqual([undefined]) + queryClient.setQueryData(rpc.watch.liveKey(), null) + queryClient.setQueryData(rpc.watch.streamedKey(), [undefined, undefined]) + expect(queryClient.getQueryData(rpc.watch.streamedKey())).toStrictEqual([ + undefined, + undefined, + ]) expect( yield* Effect.promise(async () => await queryClient.query(rpc.read.queryOptions())), ).toBe('server') @@ -101,7 +125,21 @@ it.effect.each(['global', 'prefix'] as const)( expect( yield* Effect.promise(async () => await queryClient.query(rpc.read.queryOptions())), ).toBe('native') - expect(queryClient.getQueryCache().getAll()).toHaveLength(2) + const pages = rpc.read.infiniteOptions({ + initialPageParam: 0, + getNextPageParam: () => {}, + }) + expect( + yield* Effect.promise(async () => await queryClient.infiniteQuery(pages)), + ).toStrictEqual({ pages: ['server'], pageParams: [0] }) + queryClient.setQueryData(rpc.read.infiniteKey(), { + pages: ['native'], + pageParams: [0], + }) + expect( + yield* Effect.promise(async () => await queryClient.infiniteQuery(pages)), + ).toStrictEqual({ pages: ['native'], pageParams: [0] }) + expect(queryClient.getQueryCache().getAll()).toHaveLength(4) expect( queryClient .getQueryCache() @@ -110,6 +148,10 @@ it.effect.each(['global', 'prefix'] as const)( ).toBe(true) } finally { unsubscribe() + stopHistory() + yield* Effect.promise(async () => { + await queryClient.cancelQueries() + }) yield* Deferred.await(finalized) queryClient.clear() } @@ -158,3 +200,67 @@ it.effect('keeps concurrent retention views independent through completion', () } }), ) + +it.effect.each([{ order: [4, 2] }, { order: [2, 4] }] as const)( + 'fetches retention views in order $order and invalidates exact versus broad policies', + ({ order }) => + Effect.gen(function* () { + const group = RpcGroup.make(Rpc.make('watch', { success: Schema.Int, stream: true })) + let calls = 0 + const client = yield* makeRpcTestClient(group, { + watch: () => { + calls += 1 + return Stream.make(1, 2, 3, 4) + }, + }) + const rpc = createRpcQueryUtils(group, { client, keyPrefix: ['retention-order'] }) + const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity } } }) + try { + for (const maxChunks of order) { + expect( + yield* Effect.promise( + async () => await queryClient.query(rpc.watch.streamedOptions({ maxChunks })), + ), + ).toStrictEqual(maxChunks === 2 ? [3, 4] : [1, 2, 3, 4]) + } + const narrow = rpc.watch.streamedKey({ maxChunks: 2 }) + const wide = rpc.watch.streamedKey({ maxChunks: 4 }) + const append = rpc.watch.streamedOptions({ maxChunks: 2, refetchMode: 'append' }) + yield* Effect.promise(async () => await queryClient.query(append)) + yield* Effect.promise(async () => await queryClient.query(rpc.watch.liveOptions())) + queryClient.setQueryData(['unrelated'], 42) + yield* Effect.promise(async () => { + await queryClient.invalidateQueries({ + queryKey: narrow, + exact: true, + refetchType: 'none', + }) + }) + expect(queryClient.getQueryState(narrow)?.isInvalidated).toBe(true) + expect(queryClient.getQueryState(wide)?.isInvalidated).toBe(false) + expect(queryClient.getQueryState(append.queryKey)?.isInvalidated).toBe(false) + queryClient.setQueryData(narrow, [3, 4]) + yield* Effect.promise(async () => { + await queryClient.invalidateQueries({ + queryKey: [...rpc.watch.key(), 'streamed'], + refetchType: 'none', + }) + }) + for (const key of [narrow, wide, append.queryKey]) { + expect(queryClient.getQueryState(key)?.isInvalidated).toBe(true) + } + expect(queryClient.getQueryState(rpc.watch.liveKey())?.isInvalidated).toBe(false) + yield* Effect.promise(async () => { + await queryClient.invalidateQueries({ queryKey: rpc.watch.key(), refetchType: 'none' }) + }) + expect(queryClient.getQueryState(rpc.watch.liveKey())?.isInvalidated).toBe(true) + expect(queryClient.getQueryState(['unrelated'])?.isInvalidated).toBe(false) + expect(queryClient.getQueryData(narrow)).toStrictEqual([3, 4]) + expect(queryClient.getQueryData(wide)).toStrictEqual([1, 2, 3, 4]) + expect(queryClient.getQueryCache().getAll()).toHaveLength(5) + expect(calls).toBe(4) + } finally { + queryClient.clear() + } + }), +) diff --git a/tests/create-rpc-query-utils-streaming.test.ts b/tests/create-rpc-query-utils-streaming.test.ts index 41526a8..de69591 100644 --- a/tests/create-rpc-query-utils-streaming.test.ts +++ b/tests/create-rpc-query-utils-streaming.test.ts @@ -215,46 +215,55 @@ describe('createRpcQueryUtils streaming execution', () => { }), ) - it.effect('rejects invalid bounds synchronously, including skipped queries', () => - Effect.gen(function* () { - const Watch = Rpc.make('events.watch', { - payload: { channel: Schema.String }, - success: Schema.Finite, - stream: true, - }) - const group = RpcGroup.make(Watch) - const client = yield* makeRpcTestClient(group, { 'events.watch': () => Stream.make(1) }) - const utils = createRpcQueryUtils(group, { client, keyPrefix: ['bounded'] }) - for (const maxChunks of [ - 0, - -1, - 1.5, - Number.NaN, - Infinity, - -Infinity, - Number.MAX_SAFE_INTEGER + 1, - ]) { - for (const input of [{ channel: 'news' }, skipToken] as const) { - const buildOptions = () => - input === skipToken - ? utils.events.watch.streamedOptions({ input, maxChunks }) - : utils.events.watch.streamedOptions({ input, maxChunks }) - expect(buildOptions).toThrow(EffectRpcQueryConfigError) - expect(buildOptions).toThrow( - expect.objectContaining({ code: 'InvalidMaxChunks', rpcTag: 'events.watch' }), - ) + it.effect( + 'rejects invalid retention and refetch policies synchronously, including skipped queries', + () => + Effect.gen(function* () { + const Watch = Rpc.make('events.watch', { + payload: { channel: Schema.String }, + success: Schema.Finite, + stream: true, + }) + const group = RpcGroup.make(Watch) + const client = yield* makeRpcTestClient(group, { 'events.watch': () => Stream.make(1) }) + const utils = createRpcQueryUtils(group, { client, keyPrefix: ['bounded'] }) + for (const maxChunks of [ + 0, + -1, + 1.5, + Number.NaN, + Infinity, + -Infinity, + Number.MAX_SAFE_INTEGER + 1, + ]) { + for (const input of [{ channel: 'news' }, skipToken] as const) { + const buildOptions = () => utils.events.watch.streamedOptions({ input, maxChunks }) + expect(buildOptions).toThrow(EffectRpcQueryConfigError) + expect(buildOptions).toThrow( + expect.objectContaining({ code: 'InvalidMaxChunks', rpcTag: 'events.watch' }), + ) + } } - } - const skipped = utils.events.watch.streamedOptions({ input: skipToken, maxChunks: 1 }) - expect(skipped.queryFn).toBe(skipToken) - expect(skipped).not.toHaveProperty('maxChunks') - expect(() => - utils.events.watch.streamedOptions({ - input: { channel: 'news' }, - maxChunks: Number.MAX_SAFE_INTEGER, - }), - ).not.toThrow() - }), + for (const refetchMode of ['invalid', null, 1]) { + for (const input of [{ channel: 'news' }, skipToken] as const) { + const build = () => + // @ts-expect-error Invalid policies must reach runtime validation. + utils.events.watch.streamedOptions({ input, refetchMode }) + expect(build).toThrow( + expect.objectContaining({ code: 'InvalidRefetchMode', rpcTag: 'events.watch' }), + ) + } + } + const skipped = utils.events.watch.streamedOptions({ input: skipToken, maxChunks: 1 }) + expect(skipped.queryFn).toBe(skipToken) + expect(skipped).not.toHaveProperty('maxChunks') + expect(() => + utils.events.watch.streamedOptions({ + input: { channel: 'news' }, + maxChunks: Number.MAX_SAFE_INTEGER, + }), + ).not.toThrow() + }), ) it.effect('fails an empty live stream with its documented package error', () => @@ -728,11 +737,11 @@ describe('createRpcQueryUtils streaming execution', () => { keyPrefix: ['app'] as const, }) - expect(utils.events.watch.streamedOptions(skipToken)).toMatchObject({ + expect(utils.events.watch.streamedOptions({ input: skipToken })).toMatchObject({ queryFn: skipToken, queryKey: ['app', 'rpc', 'events', 'watch', 'streamed'], }) - expect(utils.events.watch.liveOptions(skipToken)).toMatchObject({ + expect(utils.events.watch.liveOptions({ input: skipToken })).toMatchObject({ queryFn: skipToken, queryKey: ['app', 'rpc', 'events', 'watch', 'live'], }) diff --git a/tests/create-rpc-query-utils.test.ts b/tests/create-rpc-query-utils.test.ts index 1cd2b5b..02aea41 100644 --- a/tests/create-rpc-query-utils.test.ts +++ b/tests/create-rpc-query-utils.test.ts @@ -155,7 +155,7 @@ describe(createRpcQueryUtils, () => { expect(skipToken).toBe(queryCoreSkipToken) expect(skipToken).toBe(reactQuerySkipToken) - const skipped = utils.users.get.queryOptions(skipToken) + const skipped = utils.users.get.queryOptions({ input: skipToken }) expect(skipped).toMatchObject({ queryFn: queryCoreSkipToken, queryKey: ['app', 'rpc', 'users', 'get', 'query'], diff --git a/tests/fixtures/optimistic-users.ts b/tests/fixtures/optimistic-users.ts index ed771ec..818ba48 100644 --- a/tests/fixtures/optimistic-users.ts +++ b/tests/fixtures/optimistic-users.ts @@ -207,7 +207,13 @@ export const makeControlledUserWrites = Effect.fnUntraced(function* ( }, }) const { isActive, runMutation, persistDirectory, trackMutationOptions } = owner - const userWrites = makeUserWrites({ queryClient, rpcQuery, httpQuery, isActive, runMutation }) + const userWrites = makeUserWrites({ + queryClient, + rpcQuery, + httpQuery, + isActive, + trackMutationOptions, + }) const invalidateUsers = async () => { if (!isActive()) { return diff --git a/tests/http-cached-views.test.ts b/tests/http-cached-views.test.ts index 16f7990..2daa769 100644 --- a/tests/http-cached-views.test.ts +++ b/tests/http-cached-views.test.ts @@ -1,5 +1,5 @@ import { it } from '@effect/vitest' -import { QueryClient } from '@tanstack/query-core' +import { QueryClient, QueryObserver } from '@tanstack/query-core' import { Cause, Deferred, Effect, Exit, Predicate, Schema, SchemaTransformation } from 'effect' import { HttpClient, HttpClientResponse } from 'effect/http' import { @@ -11,7 +11,7 @@ import { } from 'effect/http-api' import { expect } from 'vite-plus/test' -import { createHttpApiQueryUtils, isEffectHttpApiQueryError } from '#effect-api-query' +import { createHttpApiQueryUtils, isEffectHttpApiQueryError, skipToken } from '#effect-api-query' import { captureFailure } from './fixtures/async.ts' import { waitForQueryState } from './fixtures/query-cache.ts' @@ -331,3 +331,226 @@ it.effect( expect(Object.isFrozen(manual.headers)).toBe(false) }), ) + +it.effect.each(['global', 'prefix'] as const)( + 'uses %s HTTP hashing across unary, infinite and open stream views', + (mode) => + Effect.gen(function* () { + const finalized = yield* Deferred.make() + let closed = 0 + let reads = 0 + const client = yield* HttpApiClient.makeWith(api, { + baseUrl: 'https://example.test', + httpClient: HttpClient.make((request) => { + if (request.url.endsWith('/read')) { + reads += 1 + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json('server', { status: 203, headers: { etag: 'server' } }), + ), + ) + } + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('data: 1\n\n')) + }, + cancel() { + closed += 1 + if (closed === 2) { + Deferred.doneUnsafe(finalized, Exit.succeed(undefined)) + } + }, + }), + { headers: { 'content-type': 'text/event-stream' } }, + ), + ), + ) + }), + }) + const http = createHttpApiQueryUtils(api, { client, keyPrefix: ['hashing-http'] }) + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: Infinity, + queryKeyHashFn: (key) => `global:${JSON.stringify(key)}`, + }, + }, + }) + if (mode === 'prefix') { + queryClient.setQueryDefaults(http.key(), { + queryKeyHashFn: (key) => `prefix:${JSON.stringify(key)}`, + }) + } + const history = new QueryObserver(queryClient, http.items.watch.streamedOptions()) + const latest = new QueryObserver(queryClient, http.items.watch.liveOptions()) + const stopHistory = history.subscribe(() => {}) + const stopLatest = latest.subscribe(() => {}) + try { + yield* waitForQueryState( + queryClient, + () => + history.getCurrentResult().status === 'success' && + latest.getCurrentResult().status === 'success', + ) + expect(history.getCurrentResult()).toMatchObject({ data: [1], fetchStatus: 'fetching' }) + expect(latest.getCurrentResult()).toMatchObject({ data: 1, fetchStatus: 'fetching' }) + queryClient.setQueryData(http.items.watch.streamedKey(), [2]) + queryClient.setQueryData(http.items.watch.liveKey(), 2) + expect(queryClient.getQueryData(http.items.watch.streamedKey())).toStrictEqual([2]) + expect(queryClient.getQueryData(http.items.watch.liveKey())).toBe(2) + const unary = http.items.read.queryOptions() + const pages = http.items.read.infiniteOptions({ + initialPageParam: 0, + getNextPageParam: () => {}, + }) + const returned = HttpApiSchema.withHeaders({ body: 'server', headers: { etag: 'server' } }) + expect(yield* Effect.promise(async () => await queryClient.query(unary))).toStrictEqual( + returned, + ) + expect( + yield* Effect.promise(async () => await queryClient.infiniteQuery(pages)), + ).toStrictEqual({ pages: [returned], pageParams: [0] }) + const manual = HttpApiSchema.withHeaders({ body: 'native', headers: { etag: 'native' } }) + queryClient.setQueryData(http.items.read.queryKey(), manual) + queryClient.setQueryData(http.items.read.infiniteKey(), { + pages: [manual], + pageParams: [0], + }) + expect(yield* Effect.promise(async () => await queryClient.query(unary))).toStrictEqual( + manual, + ) + expect( + yield* Effect.promise(async () => await queryClient.infiniteQuery(pages)), + ).toStrictEqual({ pages: [manual], pageParams: [0] }) + expect(reads).toBe(2) + expect(queryClient.getQueryCache().getAll()).toHaveLength(4) + expect( + queryClient + .getQueryCache() + .getAll() + .every((query) => query.queryHash.startsWith(`${mode}:`)), + ).toBe(true) + } finally { + stopHistory() + stopLatest() + yield* Effect.promise(async () => { + await queryClient.cancelQueries() + }) + yield* Deferred.await(finalized) + queryClient.clear() + } + }), +) + +it.effect.each(['reset', 'append', 'replace'] as const)( + 'retains bounded HTTP %s histories with native refetch publication', + (refetchMode) => + Effect.gen(function* () { + let calls = 0 + const client = yield* makeClient(() => { + calls += 1 + return sse( + calls === 1 ? 'data: 1\n\ndata: 2\n\ndata: 3\n\n' : 'data: 4\n\ndata: 5\n\ndata: 6\n\n', + ) + }) + const http = createHttpApiQueryUtils(api, { client, keyPrefix: ['http-refetch'] }) + const options = http.items.watch.streamedOptions({ maxChunks: 2, refetchMode }) + const queryClient = new QueryClient() + const publications: unknown[] = [] + try { + expect(yield* Effect.promise(async () => await queryClient.query(options))).toStrictEqual([ + 2, 3, + ]) + const stop = queryClient.getQueryCache().subscribe((event) => { + if (event.type === 'updated' && event.action.type === 'success') { + publications.push(event.query.state.data) + } + }) + try { + expect(yield* Effect.promise(async () => await queryClient.query(options))).toStrictEqual( + [5, 6], + ) + expect(publications.length).toBeGreaterThan(0) + if (refetchMode === 'replace') { + expect(publications.every((data) => JSON.stringify(data) === '[5,6]')).toBe(true) + } else { + expect(publications).toContainEqual(refetchMode === 'reset' ? [4] : [3, 4]) + } + expect( + queryClient.getQueryData(http.items.watch.streamedKey({ maxChunks: 2, refetchMode })), + ).toStrictEqual([5, 6]) + expect(calls).toBe(2) + } finally { + stop() + } + } finally { + queryClient.clear() + } + }), +) + +it.effect('validates HTTP history policies before skipped queries acquire a request', () => + Effect.gen(function* () { + const inputApi = HttpApi.make('skipped-policies').add( + HttpApiGroup.make('events').add( + HttpApiEndpoint.get('watch', '/watch', { + query: { channel: Schema.String }, + success: HttpApiSchema.StreamSse({ data: Schema.Int }), + }), + ), + ) + let requests = 0 + const client = yield* HttpApiClient.makeWith(inputApi, { + baseUrl: 'https://example.test', + httpClient: HttpClient.make((request) => { + requests += 1 + return Effect.succeed(HttpClientResponse.fromWeb(request, sse('data: 1\n\n'))) + }), + }) + const http = createHttpApiQueryUtils(inputApi, { client, keyPrefix: ['skipped-http'] }) + for (const input of [{ query: { channel: 'news' } }, skipToken] as const) { + for (const policy of [ + { maxChunks: 0 }, + { maxChunks: Number.NaN }, + { maxChunks: Number.MAX_SAFE_INTEGER + 1 }, + { refetchMode: 'invalid' }, + { refetchMode: null }, + ]) { + const build = () => + // @ts-expect-error Invalid policies must reach runtime validation. + http.events.watch.streamedOptions({ input, ...policy }) + expect(build).toThrow( + expect.objectContaining({ + code: 'maxChunks' in policy ? 'InvalidMaxChunks' : 'InvalidRefetchMode', + apiId: 'skipped-policies', + endpoint: 'watch', + }), + ) + } + } + const queryClient = new QueryClient() + const observer = new QueryObserver( + queryClient, + http.events.watch.streamedOptions({ + input: skipToken, + initialData: [9], + maxChunks: 2, + refetchMode: 'append', + }), + ) + const stop = observer.subscribe(() => {}) + try { + expect(observer.getCurrentResult()).toMatchObject({ data: [9], fetchStatus: 'idle' }) + expect(requests).toBe(0) + } finally { + stop() + queryClient.clear() + } + }), +) diff --git a/tests/http-hydration.test.ts b/tests/http-hydration.test.ts index 2628aff..6757d5c 100644 --- a/tests/http-hydration.test.ts +++ b/tests/http-hydration.test.ts @@ -24,7 +24,7 @@ import { SnapshotEncoding, } from './types/docs-hydration-views.ts' -it.effect('hydrates rich HTTP metadata and refetches an alternate declared success', () => +it.effect('hydrates buffered and metadata views and refetches an alternate declared success', () => Effect.gen(function* () { const api = HttpApi.make('hydration-metadata').add( HttpApiGroup.make('profiles').add( @@ -106,7 +106,16 @@ it.effect('hydrates rich HTTP metadata and refetches an alternate declared succe client: browserClient, keyPrefix: ['hydration-views', 'metadata'], }).profiles.read.metadataOptions({ retry: false, staleTime: Infinity }) + const serverBuffered = createHttpApiQueryUtils(api, { + client: serverClient, + keyPrefix: ['hydration-views', 'buffered'], + }).profiles.read.queryOptions({ retry: false, staleTime: Infinity }) + const browserBuffered = createHttpApiQueryUtils(api, { + client: browserClient, + keyPrefix: ['hydration-views', 'buffered'], + }).profiles.read.queryOptions({ retry: false, staleTime: Infinity }) const initial = yield* Effect.promise(async () => await server.query(serverOptions)) + yield* Effect.promise(async () => await server.query(serverBuffered)) const json = yield* prepareViewSnapshot(server).pipe( Effect.provideService(SnapshotEncoding, encoding), ) @@ -119,6 +128,13 @@ it.effect('hydrates rich HTTP metadata and refetches an alternate declared succe Effect.provideService(SnapshotDecoding, decoding), ) const hydrated = yield* Effect.promise(async () => await browser.query(browserOptions)) + const hydratedBuffered = yield* Effect.promise(async () => await browser.query(browserBuffered)) + expect(hydratedBuffered).toStrictEqual(hydrated.data) + if (!(hydratedBuffered.body instanceof Profile)) { + throw new TypeError('Expected a hydrated buffered Profile') + } + expect(hydratedBuffered.body.summary()).toBe('Ada: 9007199254740993 credits') + expect(hydratedBuffered.body.avatar).toStrictEqual(new Uint8Array([0, 1, 255])) expect(browserReads).toBe(0) expect(hydrated.data).toStrictEqual( HttpApiSchema.withHeaders({ body: profile, headers: { 'x-version': 4 } }), @@ -138,6 +154,11 @@ it.effect('hydrates rich HTTP metadata and refetches an alternate declared succe exact: true, refetchType: 'none', }) + await browser.invalidateQueries({ + queryKey: browserBuffered.queryKey, + exact: true, + refetchType: 'none', + }) }) const refreshed = yield* Effect.promise(async () => await browser.query(browserOptions)) expect(refreshed.data.body).toBeInstanceOf(ArchivedProfile) @@ -145,6 +166,12 @@ it.effect('hydrates rich HTTP metadata and refetches an alternate declared succe expect(refreshed.data.headers['x-version']).toBe(7) expect(refreshed.status).toBe(203) expect(refreshed.headers['x-version']).toBe('7') + const refreshedBuffered = yield* Effect.promise( + async () => await browser.query(browserBuffered), + ) + expect(refreshedBuffered.body).toBeInstanceOf(ArchivedProfile) + expect(refreshedBuffered.body.summary()).toBe('Grace: 42 archived credits') + expect(refreshedBuffered.headers['x-version']).toBe(7) expect(Object.isFrozen(refreshed)).toBe(true) expect(Object.isFrozen(refreshed.headers)).toBe(true) const refreshedJson = yield* prepareViewSnapshot(browser).pipe( @@ -164,11 +191,11 @@ it.effect('hydrates rich HTTP metadata and refetches an alternate declared succe headers: { 'x-version': '7' }, data: { headers: { 'x-version': 7 } }, }) - expect([serverReads, browserReads, encoded, decoded]).toStrictEqual([1, 1, 2, 2]) + expect([serverReads, browserReads, encoded, decoded]).toStrictEqual([2, 2, 4, 4]) }).pipe(Effect.provide(HttpServer.layerServices)), ) -it.effect('hydrates a wrapped SSE snapshot and reconnects with independent browser resources', () => +it.effect('hydrates wrapped SSE live and history views with independent browser resources', () => Effect.gen(function* () { const api = HttpApi.make('hydration-sse').add( HttpApiGroup.make('profiles').add( @@ -252,12 +279,24 @@ it.effect('hydrates a wrapped SSE snapshot and reconnects with independent brows client: browserClient, keyPrefix: ['hydration-views', 'wrapped-live'], }).profiles.watch.liveOptions({ retry: false }) + const serverHistory = createHttpApiQueryUtils(api, { + client: serverClient, + keyPrefix: ['hydration-views', 'wrapped-history'], + }).profiles.watch.streamedOptions({ retry: false }) + const browserHistory = createHttpApiQueryUtils(api, { + client: browserClient, + keyPrefix: ['hydration-views', 'wrapped-history'], + }).profiles.watch.streamedOptions({ retry: false }) const initial = yield* Effect.promise( async () => await fetchStreamSnapshot(server, serverOptions), ) yield* Deferred.await(serverClosed) expect(initial.body).toBeInstanceOf(Profile) expect(initial.headers['x-version']).toBe(4) + const initialHistory = yield* Effect.promise( + async () => await fetchStreamSnapshot(server, serverHistory), + ) + expect(initialHistory).toStrictEqual([initial]) const json = yield* prepareViewSnapshot(server).pipe( Effect.provideService(SnapshotEncoding, { beforeEncode: Effect.void }), ) @@ -282,6 +321,11 @@ it.effect('hydrates a wrapped SSE snapshot and reconnects with independent brows expect(hydrated.body.summary()).toBe('Ada: 9007199254740993 credits') expect(hydrated.body.avatar).toStrictEqual(new Uint8Array([0, 1, 255])) expect(hydrated.headers['x-version']).toBe(4) + const hydratedHistory = yield* Effect.promise( + async () => await fetchStreamSnapshot(browser, browserHistory, { mode: 'cached' }), + ) + expect(hydratedHistory).toStrictEqual([hydrated]) + expect(browserReads).toBe(0) const fresh = yield* Effect.promise( async () => await fetchStreamSnapshot(browser, browserOptions), ) @@ -291,6 +335,10 @@ it.effect('hydrates a wrapped SSE snapshot and reconnects with independent brows ) expect(fresh.body).toBeUndefined() expect(fresh.headers['x-version']).toBe(7) + const freshHistory = yield* Effect.promise( + async () => await fetchStreamSnapshot(browser, browserHistory), + ) + expect(freshHistory).toStrictEqual([fresh]) const freshJson = yield* prepareViewSnapshot(browser).pipe( Effect.provideService(SnapshotEncoding, { beforeEncode: Effect.void }), ) @@ -303,7 +351,8 @@ it.effect('hydrates a wrapped SSE snapshot and reconnects with independent brows ) expect(restoredSnapshot).toHaveProperty('body', undefined) expect(restoredSnapshot?.headers['x-version']).toBe(7) - expect([serverReads, browserReads]).toStrictEqual([1, 1]) + expect(restored.getQueryData(browserHistory.queryKey)).toStrictEqual([fresh]) + expect([serverReads, browserReads]).toStrictEqual([2, 2]) expect(server.isFetching()).toBe(0) expect(browser.isFetching()).toBe(0) expect(server.getQueryCache().hasListeners()).toBe(false) diff --git a/tests/http-query-options.test.ts b/tests/http-query-options.test.ts index 0736b43..1db7568 100644 --- a/tests/http-query-options.test.ts +++ b/tests/http-query-options.test.ts @@ -207,7 +207,7 @@ describe('HTTP query options', () => { const unsubscribe = observer.subscribe(() => {}) const unsubscribePages = pages.subscribe(() => {}) try { - expect(utils.users.list.queryOptions(skipToken).queryFn).toBe(skipToken) + expect(utils.users.list.queryOptions({ input: skipToken }).queryFn).toBe(skipToken) expect(options).toMatchObject({ queryFn: skipToken, queryKey: ['test', 'http', 'pages', 'users', 'list', 'query'], diff --git a/tests/types/example-owner-contract.ts b/tests/types/example-owner-contract.ts new file mode 100644 index 0000000..f8be010 --- /dev/null +++ b/tests/types/example-owner-contract.ts @@ -0,0 +1,46 @@ +import { QueryClient } from '@tanstack/react-query' +import type { MutationFunctionContext } from '@tanstack/react-query' + +import { makeOwnerCache } from '../../examples/vite-react/src/lib/owner-cache.ts' + +const queryClient = new QueryClient() +const owner = makeOwnerCache({ + identity: { tenantId: 'a', userId: 'b', sessionGeneration: 0, permissionGeneration: 0 }, + queryClient, + directoryKeys: { rpc: ['rpc'], http: ['http'] }, +}) +const generated = owner.trackMutationOptions({ + mutationKey: ['create'] as const, + mutationFn: async (input: { readonly name: string }) => ({ id: 1, ...input }), + networkMode: 'online' as const, + retry: 3, +}) +generated.mutationKey satisfies readonly ['create'] +generated.mutationFn({ name: 'Ada' }) satisfies Promise<{ + readonly id: number + readonly name: string +}> +generated.networkMode satisfies 'always' +generated.retry satisfies false +// @ts-expect-error The tracked function preserves required variables. +generated.mutationFn() +// @ts-expect-error The generated function keeps its original arity. +generated.mutationFn({ name: 'Ada' }, {}) + +const native = owner.trackMutationOptions({ + mutationFn: async (input: number, context: MutationFunctionContext) => ({ + input, + client: context.client, + }), +}) +native.mutationFn(1, { + client: queryClient, + meta: undefined, +}) satisfies Promise<{ + readonly input: number + readonly client: QueryClient +}> +// @ts-expect-error The native function preserves its required context. +native.mutationFn(1) +// @ts-expect-error Tracking does not invent an absent key. +native.mutationKey diff --git a/tests/types/http-contract.ts b/tests/types/http-contract.ts index ef1886c..80edc1b 100644 --- a/tests/types/http-contract.ts +++ b/tests/types/http-contract.ts @@ -1114,7 +1114,7 @@ skippedMetadata.queryKey satisfies readonly [ ] const skippedMetadataHook = useQuery(skippedMetadata) true satisfies Assert> -getUser.metadataOptions(skipToken).queryFn satisfies SkipToken +getUser.metadataOptions({ input: skipToken }).queryFn satisfies SkipToken const conditionalMetadata = getUser.metadataOptions({ input: hasUser ? input : skipToken, select: (view) => view.status, @@ -1222,7 +1222,11 @@ const definedInitial = useQuery( ) true satisfies Assert> -const skippedDirect = getUser.queryOptions(skipToken) +// @ts-expect-error Query builders accept options objects. +getUser.queryOptions(skipToken) +// @ts-expect-error Metadata builders accept options objects. +getUser.metadataOptions(skipToken) +const skippedDirect = getUser.queryOptions({ input: skipToken }) skippedDirect.queryFn satisfies SkipToken const skippedObject = getUser.queryOptions({ input: skipToken, diff --git a/tests/types/http-stream-contract.ts b/tests/types/http-stream-contract.ts index 2b52c9e..645ad8b 100644 --- a/tests/types/http-stream-contract.ts +++ b/tests/types/http-stream-contract.ts @@ -221,7 +221,7 @@ const liveSkipped = utils.views.plain.liveOptions({ initialData: 1, }) liveSkipped.queryFn satisfies typeof skipToken -utils.views.plain.liveOptions(skipToken).queryFn satisfies typeof skipToken +utils.views.plain.liveOptions({ input: skipToken }).queryFn satisfies typeof skipToken const liveConditional = utils.views.plain.liveOptions({ input: Math.random() > 0.5 ? input : skipToken, initialData: () => 1, diff --git a/tests/types/public-contract.ts b/tests/types/public-contract.ts index 78c0ee1..c66e211 100644 --- a/tests/types/public-contract.ts +++ b/tests/types/public-contract.ts @@ -469,9 +469,9 @@ clientFailureUtils.events.watch.streamedOptions({ }, }) -const skippedStreamed = utils.events.watch.streamedOptions(skipToken) +const skippedStreamed = utils.events.watch.streamedOptions({ input: skipToken }) skippedStreamed.queryFn satisfies typeof queryCoreSkipToken -const skippedLive = utils.events.watch.liveOptions(skipToken) +const skippedLive = utils.events.watch.liveOptions({ input: skipToken }) skippedLive.queryFn satisfies typeof queryCoreSkipToken useQuery(skippedStreamed) useQuery(skippedLive) @@ -903,7 +903,13 @@ concreteQueryKey satisfies readonly ['app', 'rpc', 'users', 'get', 'query', Json const cachedState = queryClient.getQueryState(concreteQueryKey) cachedState?.error satisfies EffectRpcQueryError<'not-found'> | null | undefined -const skipped = utils.users.get.queryOptions(skipToken) +// @ts-expect-error Query builders accept options objects. +utils.users.get.queryOptions(skipToken) +// @ts-expect-error Stream builders accept options objects. +utils.events.watch.streamedOptions(skipToken) +// @ts-expect-error Live builders accept options objects. +utils.events.watch.liveOptions(skipToken) +const skipped = utils.users.get.queryOptions({ input: skipToken }) skipped.queryFn satisfies typeof queryCoreSkipToken const typedSkipToken: SkipToken = skipped.queryFn void typedSkipToken @@ -1024,19 +1030,19 @@ utils.health.ping.queryOptions(skipToken) utils.users.get.queryKey(skipToken) // @ts-expect-error mutation builders never accept the skip sentinel utils.users.get.mutationOptions(skipToken) -// @ts-expect-error the package owns the query function utils.users.get.queryOptions({ input: { id: 1 }, + // @ts-expect-error the package owns the query function queryFn: queryCoreSkipToken, }) -// @ts-expect-error the package owns the query key utils.users.get.queryOptions({ input: { id: 1 }, + // @ts-expect-error the package owns the query key queryKey: ['custom'], }) -// @ts-expect-error QueryClient defaults own hashing. utils.users.get.queryOptions({ input: { id: 1 }, + // @ts-expect-error QueryClient defaults own hashing. queryKeyHashFn: JSON.stringify, }) void utils.projects.watch.streamedOptions({}) diff --git a/vite.config.ts b/vite.config.ts index d007aad..a9a7749 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -205,9 +205,6 @@ export default defineConfig({ 'src/rpc/streamed-query.ts', ], rules: { - // These runtime adapters erase caller-defined schemas/options, then validate - // inputs with their declaration. A domain-specific replacement loses the - // generic public contract. 'anti-slop/no-unknown-parameters': 'off', 'anti-slop/no-unknown-returns': 'off', 'anti-slop/no-unsafe-dictionary-type': 'off', From bddb85a4bfdce2f30ed1572e82038c4ecf819fae Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Sun, 11 Oct 2026 12:19:12 +0200 Subject: [PATCH 91/97] Align RPC reference with object-input skipping --- .intent/review-state.json | 20 +++++++++---------- .../docs/reference/generated-builders.md | 5 ++--- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/.intent/review-state.json b/.intent/review-state.json index 50b4c7d..37051b3 100644 --- a/.intent/review-state.json +++ b/.intent/review-state.json @@ -3,7 +3,7 @@ "baseline": "5b91ddb1de13970365669c887b9d767ea417fbd4", "items": { "skill:skills/effect-api-query/SKILL.md": { - "fingerprint": "95fabe60cb96109e0e8cc310d7a57d01ecc397cbdd19820599bb4113868beb86", + "fingerprint": "707c8eff0f7f314a6782661ff584f7f7ea872abbd29132a55f92b6616569e6a3", "snapshot": { "GLOSSARY.md": "67de06543ad2fd9fd2917419e19d1f071e7b6eee53d48fca05e9dddfc739c683", "README.md": "7af677dd785f95bf08b4a9dbafd10f1b0a2cd9ce40d42edf359ede1e59de6fd3", @@ -48,7 +48,7 @@ "apps/docs/src/content/docs/reference/errors.md": "4454725375e05ea7a7d976d798c1c1d0c2731eefc79043f5e40dba88154069b1", "apps/docs/src/content/docs/reference/external-http-clients.md": "4ed288ccf29f3bcdf2e3773172282eefe0ef0f84ed0fd1bc347e5cd388743aba", "apps/docs/src/content/docs/reference/factory.md": "7d972a9ff279ce80164aba793cbeb5a3e1621e6e16be97392b6faa7493de339c", - "apps/docs/src/content/docs/reference/generated-builders.md": "4a6403a4d5f62eb222cecb71f6cfaee631854ac706c702b19388467e313dc4a4", + "apps/docs/src/content/docs/reference/generated-builders.md": "eabe68d7cbecc4a15db83a5c97da48321d2ffffbf2d6869ff5323a80b8f205db", "apps/docs/src/content/docs/reference/http-factory.md": "13a238cb7de1fdd5b92f6bccc6061ff4cdfe49b23a5e0b52cc3126f031a6f39d", "apps/docs/src/content/docs/reference/performance.md": "2f68c31cae28de0a84b1ce77f065481d37899171b78d9593ff65e017ff75d97a", "apps/docs/src/content/docs/reference/public-exports.md": "500d864720857c9098b325d6018b20b5ad6c1e666ef326921721ffdf6435dcb2", @@ -196,15 +196,15 @@ "tests/types/tanstack-start-contract.ts": "51681e3d0d7504a614d490f18adb6f44db82d34b9d4d7cb9cf941aea33de284f", "tests/types/type-scale.ts": "a9b307c25c72f3c184eabc8e22ddaeea96feb331c0f8d748773d765d358dbfca" }, - "head": "a7448d01cb97f369f87642ea3628d056641a6a48", + "head": "eac8805641c1acde48c4414dda2abe7042d1b795", "outcome": "no-change", - "reason": "The final runtime validation fixture removes a ternary whose two branches call the identical conditional-input builder. Normal commit formatting also expands the already-reviewed optimistic fixture tracker injection over several lines. Neither changes API behavior, task coverage, recommendations or source mappings.", + "reason": "The RPC reference now describes input: skipToken consistently with the existing shipped query-patterns guidance and current object-only public builders. The removed bare-sentinel claim was a human-reference error; the seven shipped skill files already use the correct form and require no rewrite.", "evidence": [ - "Reviewed a7448d0..working diff and actual fixtures against the preceding fingerprint snapshot. Uncached typecheck passes 140 files, and the streaming suite passes all 22 cases; its native dependencies rerun both TypeScript 7.0.2 and 5.9.3 fixture compilers. Earlier full focused suite passes 124 cases. Read all three planning records: domain map and specification already record input-field skipping, ten tasks and task evidence; skill tree maps both affected fixture files. Logs architecture-typecheck-final-2.log and architecture-streaming-final-2.log. No fresh consumer-session evaluation repeated for these equivalent test/format changes." + "At eac8805 plus the generated-builders.md correction: inspected the actual reference diff, src/core/utility-tree.ts preparation, public-contract.ts direct-form negative compiler fixtures and skills/effect-api-query/references/query-patterns.md. Uncached docs-build passed Astro diagnostics, 46-page build, search and internal links (reference-fix-docs-build.log). Existing final public compiler and 124 in-memory runtime receipts cover the unchanged API. No fresh consumer activation/task evaluation repeated for this reference-only correction; offline Skills CLI delivery separately proves seven exact files and thirteen resolved links, not consumer-task reliability." ] }, "planning:_artifacts": { - "fingerprint": "0370ae3c659986920fc21a2121c8c4e2bb0b3f2b3f79d2c3c2da7efa33cc9bd6", + "fingerprint": "718d9de9ccfd5dadc4476f9698693974234cd481254d50924ff0ff37b30d05d0", "snapshot": { "GLOSSARY.md": "67de06543ad2fd9fd2917419e19d1f071e7b6eee53d48fca05e9dddfc739c683", "README.md": "7af677dd785f95bf08b4a9dbafd10f1b0a2cd9ce40d42edf359ede1e59de6fd3", @@ -252,7 +252,7 @@ "apps/docs/src/content/docs/reference/errors.md": "4454725375e05ea7a7d976d798c1c1d0c2731eefc79043f5e40dba88154069b1", "apps/docs/src/content/docs/reference/external-http-clients.md": "4ed288ccf29f3bcdf2e3773172282eefe0ef0f84ed0fd1bc347e5cd388743aba", "apps/docs/src/content/docs/reference/factory.md": "7d972a9ff279ce80164aba793cbeb5a3e1621e6e16be97392b6faa7493de339c", - "apps/docs/src/content/docs/reference/generated-builders.md": "4a6403a4d5f62eb222cecb71f6cfaee631854ac706c702b19388467e313dc4a4", + "apps/docs/src/content/docs/reference/generated-builders.md": "eabe68d7cbecc4a15db83a5c97da48321d2ffffbf2d6869ff5323a80b8f205db", "apps/docs/src/content/docs/reference/http-factory.md": "13a238cb7de1fdd5b92f6bccc6061ff4cdfe49b23a5e0b52cc3126f031a6f39d", "apps/docs/src/content/docs/reference/performance.md": "2f68c31cae28de0a84b1ce77f065481d37899171b78d9593ff65e017ff75d97a", "apps/docs/src/content/docs/reference/public-exports.md": "500d864720857c9098b325d6018b20b5ad6c1e666ef326921721ffdf6435dcb2", @@ -400,11 +400,11 @@ "tests/types/tanstack-start-contract.ts": "51681e3d0d7504a614d490f18adb6f44db82d34b9d4d7cb9cf941aea33de284f", "tests/types/type-scale.ts": "a9b307c25c72f3c184eabc8e22ddaeea96feb331c0f8d748773d765d358dbfca" }, - "head": "a7448d01cb97f369f87642ea3628d056641a6a48", + "head": "eac8805641c1acde48c4414dda2abe7042d1b795", "outcome": "no-change", - "reason": "The final runtime validation fixture removes a ternary whose two branches call the identical conditional-input builder. Normal commit formatting also expands the already-reviewed optimistic fixture tracker injection over several lines. Neither changes API behavior, task coverage, recommendations or source mappings.", + "reason": "All three planning records remain accurate: domain_map.yaml owns input-field skipping among the same ten tasks; skill_spec.md keeps the six-reference scope and current validated architecture batch; skill_tree.yaml maps the corrected RPC reference to the same shipped skill. The correction adds no task, reference, package, source mapping or ownership decision.", "evidence": [ - "Reviewed a7448d0..working diff and actual fixtures against the preceding fingerprint snapshot. Uncached typecheck passes 140 files, and the streaming suite passes all 22 cases; its native dependencies rerun both TypeScript 7.0.2 and 5.9.3 fixture compilers. Earlier full focused suite passes 124 cases. Read all three planning records: domain map and specification already record input-field skipping, ten tasks and task evidence; skill tree maps both affected fixture files. Logs architecture-typecheck-final-2.log and architecture-streaming-final-2.log. No fresh consumer-session evaluation repeated for these equivalent test/format changes." + "Read _artifacts/domain_map.yaml, _artifacts/skill_spec.md and _artifacts/skill_tree.yaml against the one-file human-reference diff and current shipped query-patterns reference. Native maintainer sync produced no content change; uncached documentation checks/build passed (reference-fix-docs-build.log)." ] }, "source:vite.config.ts": { diff --git a/apps/docs/src/content/docs/reference/generated-builders.md b/apps/docs/src/content/docs/reference/generated-builders.md index 662afbe..04dd5bb 100644 --- a/apps/docs/src/content/docs/reference/generated-builders.md +++ b/apps/docs/src/content/docs/reference/generated-builders.md @@ -40,9 +40,8 @@ const options = rpcQuery.users.get.queryOptions({ }) ``` -`queryOptions`, `streamedOptions`, and `liveOptions` also accept the direct `skipToken` shorthand. -The object form preserves applicable caller options. The builder consumes `input` and, for -accumulated streams, `refetchMode` and `maxChunks`, removing them from the returned options. +The builder consumes `input` and, for accumulated streams, `refetchMode` and `maxChunks`, +removing them from the returned options. Skipped options retain the exact sentinel and operation-level key. `queryOptions`, `streamedOptions`, and `liveOptions` accept conditional `input` unions of payload From 512cd90dde322743890ce164f48d491bb1cdac69 Mon Sep 17 00:00:00 2001 From: Maurice de Bruyn Date: Sun, 11 Oct 2026 15:19:56 +0200 Subject: [PATCH 92/97] Optimize query execution and fix stream and cache ownership edge cases --- .fallowrc.jsonc | 12 - .intent/review-state.json | 669 ++++++++++++------ GLOSSARY.md | 2 +- _artifacts/skill_spec.md | 6 + apps/docs/public/package-version.js | 2 +- .../content/docs/concepts/semantic-keys.md | 2 + .../src/content/docs/guides/angular-query.md | 29 +- .../src/content/docs/guides/cancellation.md | 14 +- .../content/docs/guides/handle-failures.md | 6 +- .../src/content/docs/guides/query-defaults.md | 4 + .../src/content/docs/guides/retry-queries.md | 4 + .../src/content/docs/guides/solid-query.md | 31 +- .../src/content/docs/guides/svelte-query.md | 29 +- .../docs/src/content/docs/guides/vue-query.md | 31 +- .../reference/compatibility-and-limits.md | 41 +- .../docs/src/content/docs/reference/errors.md | 12 +- .../docs/reference/external-http-clients.md | 62 +- .../docs/reference/generated-builders.md | 2 + .../content/docs/reference/http-factory.md | 2 + ...let-call-sites-choose-query-or-mutation.md | 1 - ...-shaped-options-and-certify-react-first.md | 3 +- ...08-use-flat-prefix-matchable-key-tuples.md | 4 +- ...vite-plus-workspace-with-a-root-package.md | 4 +- ...ty-construction-through-private-modules.md | 15 +- e2e-docs/docs.spec.ts | 4 - e2e/conditional-query.spec.ts | 1 - e2e/example-application.ts | 1 - e2e/tanstack-start.spec.ts | 3 - examples/contracts/src/client.ts | 7 - examples/contracts/src/contracts.ts | 9 +- examples/server/src/commands.ts | 3 - examples/server/src/diagnostic-operations.ts | 18 +- examples/server/src/server.ts | 9 +- examples/server/src/web-handler.ts | 19 +- examples/server/tests/http-api.test.ts | 2 - examples/server/tests/server.test.ts | 38 +- examples/server/tests/web-handler.test.ts | 37 +- .../src/components/action-button.tsx | 1 - .../src/components/navigation-link.tsx | 1 - .../src/components/router-status.tsx | 2 +- .../src/hooks/use-slow-query-cancellation.ts | 11 +- examples/tanstack-start/src/lib/api-server.ts | 2 - .../tanstack-start/src/lib/application.ts | 32 +- .../src/lib/snapshot-preparation.ts | 15 +- .../tanstack-start/src/routes/failure.tsx | 4 +- .../src/routes/http-failure.tsx | 4 +- examples/tanstack-start/src/routes/http.tsx | 4 +- examples/tanstack-start/src/routes/index.tsx | 10 +- examples/tanstack-start/src/styles.css | 1 - .../tests/client-navigation.test.tsx | 1 - .../tanstack-start/tests/server-local.test.ts | 31 + .../tests/server-render.test.tsx | 7 - examples/tanstack-start/vite.config.ts | 2 - .../components/sections/commands-section.tsx | 10 +- .../src/components/sections/http-section.tsx | 4 +- .../components/sections/queries-section.tsx | 10 +- .../src/components/ui/action-button.tsx | 1 - .../src/hooks/use-slow-query-cancellation.ts | 11 +- examples/vite-react/src/lib/application.ts | 1 - examples/vite-react/src/lib/user-writes.ts | 203 ++++-- examples/vite-react/src/styles/tailwind.css | 1 - .../vite-react/tests/commands-ui.test.tsx | 124 ++++ examples/vite-react/tests/user-writes.test.ts | 163 ++++- playwright.docs.config.ts | 2 - skills/effect-api-query/SKILL.md | 16 +- .../references/cache-workflows.md | 7 +- .../references/frameworks-and-hosts.md | 11 +- .../references/query-patterns.md | 6 +- src/core/operation.ts | 3 - src/core/schema-key.ts | 6 - src/core/stream-snapshot.ts | 14 +- src/core/streamed-query.ts | 67 +- src/core/types.ts | 10 - src/core/utility-tree.ts | 46 +- src/http/create-utils.ts | 10 +- src/http/errors.ts | 2 +- src/http/operation.ts | 5 - src/http/request.ts | 42 +- src/http/streamed-query.ts | 6 + src/http/types.ts | 23 +- src/rpc/create-utils.ts | 11 +- src/rpc/errors.ts | 4 +- src/rpc/operation.ts | 7 - src/rpc/streamed-query.ts | 8 +- src/rpc/types.ts | 38 +- tests/create-http-api-query-utils.test.ts | 11 - ...eate-rpc-query-utils-configuration.test.ts | 11 - .../create-rpc-query-utils-execution.test.ts | 2 - tests/create-rpc-query-utils-keys.test.ts | 3 - ...ate-rpc-query-utils-stream-hashing.test.ts | 469 ++++++++++++ .../create-rpc-query-utils-streaming.test.ts | 6 - tests/fixtures/async.ts | 2 - tests/fixtures/effect-rpc.ts | 4 - tests/fixtures/http-client.ts | 2 - tests/fixtures/optimistic-users.ts | 29 +- tests/http-execution.test.ts | 1 - tests/http-multipart.test.ts | 1 - tests/http-query-options.test.ts | 1 - tests/http-semantic-keys.test.ts | 88 ++- tests/stream-snapshot.test.ts | 111 +++ tests/types/http-contract.ts | 2 - tests/types/http-stream-contract.ts | 33 +- tests/types/public-contract.ts | 57 +- vite.config.ts | 23 +- 104 files changed, 2056 insertions(+), 938 deletions(-) create mode 100644 examples/vite-react/tests/commands-ui.test.tsx create mode 100644 tests/create-rpc-query-utils-stream-hashing.test.ts diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 4970bc8..65972e1 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -1,8 +1,6 @@ { "$schema": "./node_modules/fallow/schema.json", - // Astro copies this browser entry from public/ and loads it through the - // Starlight head configuration, which static import analysis cannot follow. "entry": [ "scripts/measure-construction.mts", "scripts/verify-docs-examples.mts", @@ -13,7 +11,6 @@ "ignorePatterns": ["examples/tanstack-start/src/routeTree.gen.ts"], - // Framework tooling consumes these exports through convention-generated entries. "ignoreExports": [ { "file": "apps/docs/astro.config.mts", @@ -33,9 +30,6 @@ } ], - // Each executable example keeps its resource ownership and cancellation - // workflow and UI in place so readers can understand and copy the integration. - // Fingerprints expire when the duplicated code or occurrence count changes. "duplicates": { "ignoredClones": [ "dup:22704c46:2", @@ -60,8 +54,6 @@ ] }, - // Vite+ tasks, packing, and imported lint presets invoke tools Fallow cannot trace. - // Keep Effect peer and test dependencies available for the library and fixtures. "ignoreDependencies": [ "@arethetypeswrong/cli", "@effect/platform-node", @@ -75,8 +67,6 @@ "unplugin-unused" ], - // Health measures shipped library code in src/. Exclude docs, examples, - // tooling, and tests here; other audits retain their repository-wide scope. "health": { "ignore": [ "apps/**", @@ -93,8 +83,6 @@ "rules": { "private-type-leaks": "error", - // Public-entry suppressions are valid in the entry audit but necessarily - // look stale in production mode, where package entry exports are exempt. "stale-suppressions": "off" } } diff --git a/.intent/review-state.json b/.intent/review-state.json index 37051b3..0584b63 100644 --- a/.intent/review-state.json +++ b/.intent/review-state.json @@ -3,179 +3,181 @@ "baseline": "5b91ddb1de13970365669c887b9d767ea417fbd4", "items": { "skill:skills/effect-api-query/SKILL.md": { - "fingerprint": "707c8eff0f7f314a6782661ff584f7f7ea872abbd29132a55f92b6616569e6a3", + "fingerprint": "a980b4a6405f95f7c6bd8f0051885ec9ca40bfae8ddc7474db423014ce2381c2", "snapshot": { - "GLOSSARY.md": "67de06543ad2fd9fd2917419e19d1f071e7b6eee53d48fca05e9dddfc739c683", + "GLOSSARY.md": "ad19a69727108dd047208b6e76e05f5b1d241620ed75cb241a62fb4c068dc792", "README.md": "7af677dd785f95bf08b4a9dbafd10f1b0a2cd9ce40d42edf359ede1e59de6fd3", "apps/docs/src/content/docs/concepts/client-lifecycle.md": "fcbaca84f266060b946eed9cbf9d0b9815352b7b1d6ddaff5803b29484345380", "apps/docs/src/content/docs/concepts/data-normalization.md": "f1505ec3eb35a33fac289706c2cc318a2ffb80555e3302ca102e67e21c9012da", "apps/docs/src/content/docs/concepts/http-utility-tree.md": "ed03938167f31822b76efbeac381fd3f8d297061fe567928ee022f8dbfadb9b4", "apps/docs/src/content/docs/concepts/queries-and-mutations.md": "f62769112999ebe71a75aa3c22e73380e0f11d8c1f64c3125e2e5e8dba11997e", "apps/docs/src/content/docs/concepts/rpc-utility-tree.md": "fd5fcc232279e9577b8b2faf321a85e9a340e6090ea5887bbab208cca2d24a4e", - "apps/docs/src/content/docs/concepts/semantic-keys.md": "ae21f39db492acdad4f576c6942408b628124da775e40fdde543a4c8341de5ec", + "apps/docs/src/content/docs/concepts/semantic-keys.md": "e76e2443be443d6cda73166cd9a1d7d68db6813b9af4f98e6f1bdf7d7cc8c2e1", "apps/docs/src/content/docs/examples.md": "217a98fef80d350becc9fb7472eb120af9ce528e091fa13840bc9531930e5d53", "apps/docs/src/content/docs/getting-started/compatibility-and-stability.md": "74f9d59c908fe6ab70ccfb8940c1f976e060b38a05cc6bbe75f72403d3b3ed7b", "apps/docs/src/content/docs/getting-started/feature-support.md": "47de4a30ab91559a5aa4cf211f9da13fa970ad444ee19a0c417efcb289e72bc1", "apps/docs/src/content/docs/getting-started/http-quick-start.md": "f73cc87d44999e85b942b3ef284bdd2d0cf5bbd84cb4e7e3f14e65da185ff81d", "apps/docs/src/content/docs/getting-started/installation.md": "0e7d6e608fa0ea049446fd3784350ba635503c0010c12342bc5774b6e7105152", "apps/docs/src/content/docs/getting-started/quick-start.md": "43e8837b84df91af5dfe1b989c6f2cd94557ffb038fd1c0692a5bc7342716b3c", - "apps/docs/src/content/docs/guides/angular-query.md": "fcce47f32059d4164ae3a0b5fb1cf35922dd2bd5bb5b1dfa38a139339483c539", + "apps/docs/src/content/docs/guides/angular-query.md": "3093cdaccb8975047e0df2a56876d1258aff75f047aacaf5eac4ae4719d9affb", "apps/docs/src/content/docs/guides/cache-filters.md": "ac17c5bfd460a70988bdb4d7fd702fc96a6d0df46b6e0cd3455ac60e0ab4fe22", "apps/docs/src/content/docs/guides/cache-management.md": "72a1943ca0c4171a5d10b5b1667bfbec8b8f07922a1114b40b3457fbdc5c708e", - "apps/docs/src/content/docs/guides/cancellation.md": "eb846576e04d8e8fb3030352a8613664773f4140047e6da0ccd002fcb78a7a2e", + "apps/docs/src/content/docs/guides/cancellation.md": "922b18e192c0b75ccde3c7c2190f3c87336312f2d29afd73ff75dd0a90233ffb", "apps/docs/src/content/docs/guides/choose-stream-history.md": "6996df3a8b18c4fadbcc76a3635a8a5408f155b4068509d4801b38afa7f22f16", "apps/docs/src/content/docs/guides/conditional-queries.md": "7770390ad17843b0883968cf35c4aa39054fac7adeceacae731e8899e3cc4ff2", "apps/docs/src/content/docs/guides/custom-key-encoders.md": "9a819f6c490234e46458d407e140fb3dc36cb7c67691a8574e8c2f6024c9c046", - "apps/docs/src/content/docs/guides/handle-failures.md": "f9d8e9abcf8a29a6d57bb8b2140b8faf02e8550490405eab75a3c2721186f5fe", + "apps/docs/src/content/docs/guides/handle-failures.md": "2eded0844b67dd4a54e21000fa516cd1017652004413495557ce8453953203eb", "apps/docs/src/content/docs/guides/http-queries-and-mutations.md": "928eb6def0170167e572ebd0e536bfc5cffaf6921c497da6367a6ecfec0841f7", "apps/docs/src/content/docs/guides/hydrate-query-views.md": "d71f573e4c6e6815e51f7a63080b773655f90cf9ef07155d293f1550eac091d4", "apps/docs/src/content/docs/guides/hydrate-unary-data.md": "0cf27b204e709f8a753fd61fd1510073404611e53a9fd6e82e8122af7e68f2fa", "apps/docs/src/content/docs/guides/optimistic-writes.md": "b76e2336f6958e7482786312a15bb9c25a1608c659018c5b4d3162301e767bef", - "apps/docs/src/content/docs/guides/query-defaults.md": "212138d564907a75c46a1b8261f5964c63b6dc8a9cbbd74e68e9f5d41b9b5248", + "apps/docs/src/content/docs/guides/query-defaults.md": "bb27c896ddaf1a5583b192d5407eeb5f53c26c299e17697991573f3aece5e0a8", "apps/docs/src/content/docs/guides/react-query.md": "efa6c38843bbc5ebd827244628e7d2ddaa0f3d67924c99d8ff2a8dcb4b91aa7e", "apps/docs/src/content/docs/guides/refresh-from-events.md": "a8e680ae9060a573336829b94dbf77dd32385671d48cd4aa0f092a1374444cd6", - "apps/docs/src/content/docs/guides/retry-queries.md": "23fe305768b5779d87a21af4f56c49ef2f03fcc7654ec6ffaf9db46628e3cc11", - "apps/docs/src/content/docs/guides/solid-query.md": "91151a68b0205da685a8e545fdc386cffa1c3ac76b6de9be73b19bfb470c19bc", + "apps/docs/src/content/docs/guides/retry-queries.md": "8ff86e673cf47fb208f7f1ec34adfd6f57b5580bffc4af49835a563ed159a514", + "apps/docs/src/content/docs/guides/solid-query.md": "37fc9edbb245db2e5468649195baa450274e26a99d024be8c2088e96a3156d7d", "apps/docs/src/content/docs/guides/stream-snapshots.md": "91e22e85c11d8822e25e5abb7b872b78f07af79fdb16eb2690baf8226fdbd9f7", - "apps/docs/src/content/docs/guides/svelte-query.md": "aeb8ba0254920677a3c0a0667d500a1186eb368ae56f16ff848f6edcae7903ac", + "apps/docs/src/content/docs/guides/svelte-query.md": "47725e8230c8af9646f375d39cc312c6baa8ccd15a7e9842627755e324840385", "apps/docs/src/content/docs/guides/switch-cache-owners.md": "cc209f13becec305af60c9f04fe29f85a2ca6db3bc11601cf09083bc2583d198", "apps/docs/src/content/docs/guides/tanstack-start.md": "6fae751389e9b5dfb843235059aea1a2fc885d62f797556de78a9ea77697ddcb", - "apps/docs/src/content/docs/guides/vue-query.md": "aff0843b1f4fb76563695e9a1d6050fad202007c4be994b74aff774ab2e038d0", + "apps/docs/src/content/docs/guides/vue-query.md": "405bc7b3dc0fb149bac4084be17b18a31a695c4e5fa2fe2fee7cfe579579e439", "apps/docs/src/content/docs/guides/websocket-clients.md": "1cd31655e433ced7b24795255cb03280b27734eda58afeb9730a42bfad433c43", "apps/docs/src/content/docs/index.mdx": "a141fa2f98d38ea8042ad596c07369b81c9a27935805c17f552db0bc9dd22f6f", "apps/docs/src/content/docs/reference/browser-and-worker-hosts.md": "70743a4429a39937515c8d5fee79ca75714a92dd526f1093f9fa8ea12fbde984", - "apps/docs/src/content/docs/reference/compatibility-and-limits.md": "f19fe01d6ab2fc2065827c23ceea2720bb136f28a35c3ba3759507224bb8b6c9", - "apps/docs/src/content/docs/reference/errors.md": "4454725375e05ea7a7d976d798c1c1d0c2731eefc79043f5e40dba88154069b1", - "apps/docs/src/content/docs/reference/external-http-clients.md": "4ed288ccf29f3bcdf2e3773172282eefe0ef0f84ed0fd1bc347e5cd388743aba", + "apps/docs/src/content/docs/reference/compatibility-and-limits.md": "4f60753887b52c33a47b993f0aaaf75832edaeb09d082f2664e2e5e512e568a3", + "apps/docs/src/content/docs/reference/errors.md": "a3eda1d28bb90c85bcdccd9594ebb90cf588357d8cd80de39de5dca49b300085", + "apps/docs/src/content/docs/reference/external-http-clients.md": "79b38018269ac81fed55330214a277d07d3ee61d127b029de250f75272df3268", "apps/docs/src/content/docs/reference/factory.md": "7d972a9ff279ce80164aba793cbeb5a3e1621e6e16be97392b6faa7493de339c", - "apps/docs/src/content/docs/reference/generated-builders.md": "eabe68d7cbecc4a15db83a5c97da48321d2ffffbf2d6869ff5323a80b8f205db", - "apps/docs/src/content/docs/reference/http-factory.md": "13a238cb7de1fdd5b92f6bccc6061ff4cdfe49b23a5e0b52cc3126f031a6f39d", + "apps/docs/src/content/docs/reference/generated-builders.md": "a0e26877f37161a6f083528771035d2ab686b5b92e0fdfca3ebc92e779f89816", + "apps/docs/src/content/docs/reference/http-factory.md": "f922f468926a3b33029a7cf8c46564009514392771f3851ba8acd00cbdab612d", "apps/docs/src/content/docs/reference/performance.md": "2f68c31cae28de0a84b1ce77f065481d37899171b78d9593ff65e017ff75d97a", "apps/docs/src/content/docs/reference/public-exports.md": "500d864720857c9098b325d6018b20b5ad6c1e666ef326921721ffdf6435dcb2", "docs/adr/0001-target-effect-4-release-candidate.md": "44119ba9104f767c5804c5026c4a21a78a8367f6d9ff5a9035bb3f423969fbb3", - "docs/adr/0002-let-call-sites-choose-query-or-mutation.md": "3ababe863eabf9cc53c70629dfe65af4a82a77e91467e5082041b8c71f93f482", + "docs/adr/0002-let-call-sites-choose-query-or-mutation.md": "4244d48ff136eea562aa2f94a0b6b1ab58412796f3ece47a3cf5b43f746e11f5", "docs/adr/0003-project-dotted-rpc-tags-into-utility-paths.md": "a99856e94af4004484fed939d46ae4388363031cf396b07f04a92a7119fec2fe", - "docs/adr/0004-build-core-shaped-options-and-certify-react-first.md": "3e936951b2987c55d8ddff13e740e4070409c683294040744ed65a2f1cbcb16d", + "docs/adr/0004-build-core-shaped-options-and-certify-react-first.md": "a04939a70fc1438dfe7fc467b4f7f83d16135edb8a4397f6e160d00616b605cb", "docs/adr/0005-preserve-the-cause-in-one-error-wrapper.md": "1e04885542f22f166b5daa913ae2dae121c407baa775a2c4a16590f96857e3fe", "docs/adr/0006-use-canonical-semantic-json-for-payload-keys.md": "694d8665938af25abb91ada71ea9c95cb77dd4ea90019adb2f642e16a88948c7", "docs/adr/0007-keep-the-core-factory-lifecycle-neutral.md": "a83dc0d6061ac0328a3ee6075aa872a55feb4f0154edf6dae964e74276bb15b5", - "docs/adr/0008-use-flat-prefix-matchable-key-tuples.md": "d2a146eed07be984daca0a4cd8209eb1ca86734b162d5362b5663c50863cd526", + "docs/adr/0008-use-flat-prefix-matchable-key-tuples.md": "2a2b5a6971a1841748aa18c326fc2fd5df181572397001753fa44468617eab1a", "docs/adr/0009-build-an-eager-frozen-utility-tree.md": "bc3b08a3385f1dc8874d81f867f15ef6c30b203c94936fd991d2941fd5bacc27", "docs/adr/0010-reuse-tanstack-query-core-as-a-peer.md": "633a1ce44c8d58b29096f540871b61566352462e1d4bd5335162b3e08652527d", "docs/adr/0012-normalize-undefined-query-success-to-null.md": "5d076f168223f67a205138d3e258f1f3aac9dfe5d607ffe8efd12a71322ef022", "docs/adr/0014-require-query-stable-payload-construction.md": "2f4381fb3e7406304ff3b914db541419f799f8a71d1d527b37a9e971e9949e47", "docs/adr/0015-centralize-the-toolchain-in-vite-plus.md": "d2001383bc0f92363289c04b6b8df26acb5b0f5dfc0163b2a838f2eee8057b75", "docs/adr/0020-generate-accumulated-and-live-stream-queries.md": "5e4f5c92e08ffb769137e930908dded993f928a497b018f9c6e4fd4f12232727", - "docs/adr/0021-share-utility-construction-through-private-modules.md": "a7d6774a6652735e690e5099ccc7dcad10da416de482068f87b74e6063fe90a7", + "docs/adr/0021-share-utility-construction-through-private-modules.md": "821720a3eee166d4b7d42b6f5c3906bb29dff339b4815e9ed1c516bc5af66fd4", "docs/adr/0022-add-buffered-http-utilities-with-separate-key-roots.md": "f1fb5d91cd91a70b356bc98b84176920ec822315dbdeaf5e247921853598b62f", "docs/adr/0023-expose-buffered-multipart-http-mutations.md": "b72f20cb513d2a7db94b2dbe27576352048ba22288ad6eac6e80d1a7c81cefb8", "docs/adr/0024-project-http-streams-and-buffered-metadata.md": "2797a5a4a751c5e37c38aa454c14b99ce1615870ffa13cfcedb98cca8a0af633", "docs/adr/0025-use-public-decoded-message-clients-for-server-local-rpc.md": "36df8e1227af22018d5a9063699986ceb8dcfd4c8bb5615aa62557f2ec36e111", "docs/adr/0026-keep-rpc-transport-aggregation-in-the-application.md": "b73af29d8f73a36f61f110ad8778e045d37253fe42a8888883e78089b93e955e", - "examples/server/src/commands.ts": "62d60db1094eaaa3e927899431800daec6f9cd1f345509ec324f3369147d9e06", - "examples/server/src/diagnostic-operations.ts": "9ea6dd3f5243888b251bb64f534e3273253b79daf1c623edcc0f5034e627d049", + "examples/server/src/commands.ts": "58f415fe0ab9bb0127c96c11ef0540a29b97d231eebd3a487513f0820a46b3d5", + "examples/server/src/diagnostic-operations.ts": "ddf8a0f2367b91ca08d6aaaf58380a51b77a5c51a9a85412bd7e7080ff0453ee", "examples/server/src/domain.ts": "c00fced49e54d7bfff9045479e91aabdbd7c0868cc89afe00bae5473b17d2f82", "examples/server/src/http-handlers.ts": "601f37c428015e75e94cb282af356878aa49c887804cc29e2b4f0c1a47ca92f0", "examples/server/src/index.ts": "9e121809a5493c649e61e76fefec52a8c79a394983368060a95dc7000dcedc9a", "examples/server/src/main.ts": "de9520cc16e4bff9d02b8507ee70641936b1287959823429f7759c0f05fdc0a7", "examples/server/src/rpc-handlers.ts": "c2c57eb9a2ca5e602fff9f798c4d5165e91216c5b7e64e0a71220c4ba9316f3d", "examples/server/src/server-local-rpc.ts": "de2a05bba54042f1d2d5781023cb5d20eb63915dd2a52635e9debf83911b5421", - "examples/server/src/server.ts": "eab2d80d9e6ecc4c74b89a50223ff968bf5b98da72d9aece4e726c0cfd6832e8", + "examples/server/src/server.ts": "5c9bbd7131ba3c55bbd61255fa8da8a275fc97412e1c8cb54f063bde517991e9", "examples/server/src/users.ts": "15c363e2162f8dab4da1bfcf6324abbb3dc8ca34a8d8e036eb4db2481fe6be5b", - "examples/server/src/web-handler.ts": "55447fe9ec14213d3d69811be11c9d6d6b266d50cf37b57dd5d4870132905523", + "examples/server/src/web-handler.ts": "2de54d8d2264024887ca63c8c53b20e9f9260ca6605b791d1f91bcea8c8d1b07", "examples/server/tests/fixtures/websocket-server.ts": "b59863766bb60e5fb4de040d7d97ae2e67bca2ec44810ddc577f4cf5cb03129f", - "examples/server/tests/http-api.test.ts": "0e4ea6656cdc67d4fb29710cec1d10af505f7dbb3bcd3d85947f7ed19c220df9", + "examples/server/tests/http-api.test.ts": "6ad2951b74c45a73ea4980e5da65c7ed4f758ff43db34702f1224de934548bca", "examples/server/tests/rpc-transport-overhead.test.ts": "8d3dd40cfbbd0ce64b74f44f205f7f83163ea08dfc264a6deb1902e96b74a7fb", "examples/server/tests/rpc-websocket-consumer.test.ts": "16253ef720e8a85cb7c583bcf6f9883c5893423d6da952659731e5823fcf0a62", - "examples/server/tests/server.test.ts": "e36862bcc44433777c78e36355efea2779c8e866a0e63c9a5cf8fe2d5e86d673", - "examples/server/tests/web-handler.test.ts": "e32f61699348bb483adc15d9d2fc69663402767a7cfa893506c69758150313f0", - "examples/tanstack-start/src/lib/api-server.ts": "7a1d9e860dc1c77b17f8ec1dfa8207ae5e5b4d53b287e7c76e2ee4138b605779", - "examples/tanstack-start/src/lib/application.ts": "5f2eeb9920fa77b18dc7ad00a2688fe9225bfd2c57db35a5040b0a38e299ec71", + "examples/server/tests/server.test.ts": "44530f1e8b9b67f4d4bd8486c7bd7790240128fb161c1244857eee15c87a068d", + "examples/server/tests/web-handler.test.ts": "94d1b28810d908dcec8ca8299e56bb520f93804e8761b4d5e9c828087efae646", + "examples/tanstack-start/src/lib/api-server.ts": "b678d6edab872786fb92db328f60095d604c8d2f5205c4d4a462c3405a782bad", + "examples/tanstack-start/src/lib/application.ts": "609e2d9ce04f24b1a1795650a49bfac139b9a28e8158234630c0845d2ace6392", "examples/tanstack-start/src/lib/query-ssr.ts": "0754abae5faf2f7562e1d86d27df0e14820a5ba9830146690ff1dc9f5beb3ec9", "examples/tanstack-start/src/lib/server-application.ts": "442278fc08c4beb9a69cf1ab7672f661385d9c49998db77ce2870d349ffdc8a1", "examples/tanstack-start/src/lib/server-host.ts": "1682ee37ea7551ec52c9530d6a4b476b967fa0572f5ddc26351ad427ce854e37", - "examples/tanstack-start/src/lib/snapshot-preparation.ts": "b50f578ff0aaf5a6301df7323d0b3ea1e3fdcc27f65be0cf706a73d790203fd7", + "examples/tanstack-start/src/lib/snapshot-preparation.ts": "eb9d9e422626d5d392fd842610c26de5ae517a6b3bea5cc93ea423fc3f942ff9", "examples/tanstack-start/src/router.tsx": "801aac19cff9a4e2a4acb0823a9ea5c28b4aea9245f7e26cb63548bb5234bd15", "examples/tanstack-start/tests/application.test.ts": "81f9b44df80dded727f1fedf51ffee2df2c7afd7f25a68668a263deb88cd3ec1", "examples/tanstack-start/tests/async-hydration.test.ts": "1f983917d2a469b83b876e882ad6a2ece4d28d360338ed614232807a168a1620", - "examples/tanstack-start/tests/client-navigation.test.tsx": "e91e23be9c5695035462fd5b27a1c1d627bf6d1d0f6a8124d45ac1bc220db416", + "examples/tanstack-start/tests/client-navigation.test.tsx": "62af6b9c4ad00e2bd772a0eb75d175e11329079bc787874b0d87e7f22030620e", "examples/tanstack-start/tests/failure-dehydration.test.ts": "b9d135c54e8e70ff454903f8016e6560fabaa487095cb40cb8725ed73577f079", - "examples/tanstack-start/tests/server-local.test.ts": "a2bbaf6cfad8701e2f8912a07c6a9f3d91a8d8ecb394a94c09d25289134576c9", - "examples/tanstack-start/tests/server-render.test.tsx": "01061f209d72df769235c47bae552402e6c9a692c95e67267e4317b2e338b8f5", + "examples/tanstack-start/tests/server-local.test.ts": "2facfdbf572cf8ed1cf5f6104a503504ab0e979546aca3e28d9dbc853c64c6e5", + "examples/tanstack-start/tests/server-render.test.tsx": "77b82127f9e3ddea55b201f7b2080f930ef340269010502f1515f6691ecc4ab2", "examples/vite-react/src/app.tsx": "691f77aee6fb9f2b1f6f9df40a3afcf78f4af2d043d7b379f38c75c647a5f91e", - "examples/vite-react/src/lib/application.ts": "c3fe076813ec5797de9e93274b07d07312e830cfa0998ae2d174191bc1c7d3fb", + "examples/vite-react/src/lib/application.ts": "0467aaf770fdb75095898c78c8cb243a2092cebb2a31986610cd7945e7f6e993", "examples/vite-react/src/lib/owner-cache.ts": "563f1758f6f387768ba8f068e02763fa6b1bd30ef84d59e8be1938adfec7eb26", - "examples/vite-react/src/lib/user-writes.ts": "46d8b7b5f02447344309a80f068bf045d71997d3dc40402dede9d835b6e36fdb", + "examples/vite-react/src/lib/user-writes.ts": "a746bb972220cd55b6bc1b6ceb8161bc01993506430082560f104d63244ec16b", "examples/vite-react/src/main.tsx": "cdee6ca4095b58e1750015db473bda8651702a8ab32c408fa77961873db0e6fe", "examples/vite-react/tests/app.test.tsx": "ff86b42023eb40658dd6feccbfd1f236a75bf2831f40b02955ebc1306be6329c", + "examples/vite-react/tests/commands-ui.test.tsx": "70005a656b9356fce97924dff6d4be82d87893cf48a619c119ad4372843246f8", "examples/vite-react/tests/commands.test.ts": "3e24222572c0f7a4b68cfc4069c545cbebf410cacd0063a328cead9ad5baab08", "examples/vite-react/tests/owner-boundary.test.tsx": "d4ce58a002aac2b8f752936a65495558f89bff145d5cf2556f5c0d9286f8d6b3", "examples/vite-react/tests/owner-cache.test.ts": "8818e30fdd3071a2e5dc2d865161e0d9f40de1ff37813b0a396bb2e83dd08a1d", "examples/vite-react/tests/owner-switch.test.ts": "c997c9758269586591cbf55de4651c64091abc9061b56edb024dcbf9dbaa9c1d", - "examples/vite-react/tests/user-writes.test.ts": "2492f48018e7d8e684eae1c8d302acc9961a1bfbd60bf3214c113dc1b6fbe54a", + "examples/vite-react/tests/user-writes.test.ts": "e104afaa57a9dd47391ac1da08175e8f7dae03dfd151edf9c9fd570bba816ae3", "package.json": "dc41aa3f5b1633ffb2e6f6a0b7efb4e0119d74b9bc8c0003b1a8ee5fad854782", "pnpm-workspace.yaml": "58fd69fb2ebc237bf589893fd3150ec0dd25988aee57f465f5b373b038aee606", "scripts/decode-utf8.mts": "2e90c53e95c22b864a209a87c98084db6ec95e83f842007b21528d286d45c18c", "scripts/measure-construction.mts": "10522cb10ab51fd44d8990896a2a73e8bf4d4cd2646441e958b6180cb1afd147", "scripts/verify-docs-examples.mts": "b20cdbf909f68c177125ddbc69e86d4244571ac5faebe505fa43cdf2faa34d91", "scripts/verify-packed-consumer.mts": null, - "skills/effect-api-query/SKILL.md": "1939a0f7536f3e88e1e1106e8125019c11d6fdd4ada8ae4d010507f264b234e5", - "skills/effect-api-query/references/cache-workflows.md": "27bb96c48c84254ab8c379a529402f81cea3723c17d6396527c090a8c37e6f65", - "skills/effect-api-query/references/frameworks-and-hosts.md": "8b39b303cf3a9ae3222a257f53df5f9696b80c54859765f2c4d7b39554b8719f", + "skills/effect-api-query/SKILL.md": "ca0822b4c5172bab93179c6885ffc1a64f434fce30d7af14d307d5492eeb90ed", + "skills/effect-api-query/references/cache-workflows.md": "e62daab7af8b3118962d1c0d8b5212927ffb1fcf4abf44ad9489ae695a0ed4b6", + "skills/effect-api-query/references/frameworks-and-hosts.md": "169ff6847997b4cdf49c4984e87c393876973c0ca28e7b0b9c3deff4bbbd8a8b", "skills/effect-api-query/references/http.md": "9625411753fcc3ce816512f80158a19aa1057518ab3bfde98720e0cc7f845b66", "skills/effect-api-query/references/hydration-and-ssr.md": "61e761b36ebcf56c7cc1b67b3d795edba52d0c0224c3e7964297ed6864e1b044", - "skills/effect-api-query/references/query-patterns.md": "5e20137ffe4b951ccaf41e869a7ac097633f91cb52ec44799ecdf47b4ba3883c", + "skills/effect-api-query/references/query-patterns.md": "69d58f67745a6ead7d86729cb8d137a22c0ce4bbbb31c0cc016dcb3fa428c6cb", "skills/effect-api-query/references/rpc.md": "f77eb39bb1b3e1f5f8c56f4b827ecef350a255a23380d8b9515dc61b54165d5d", - "src/core/operation.ts": "3b00c20d4f9cfa38b22574953804e7dc4648e912df74bfd5469c657ff5cc6e35", - "src/core/schema-key.ts": "6e3ec8e7aa089ab6d2a85e71880f9bf4985f572b28e0b4325fcb0eed00b5dfd3", - "src/core/stream-snapshot.ts": "abd83051df4e7fd3e4de5f9147b029993d59e19e5dd72ee7b73d40ebae12acc7", - "src/core/streamed-query.ts": "34ed5a18daa8c5aacdb5fbfede925b1bd84baef1f9e0580e0a8035b7cfde0eb1", - "src/core/types.ts": "ae7ad2d99e24cb7c98e54270ce2698ff8db4d21750e967d11ed4d9653a5e6264", - "src/core/utility-tree.ts": "5c0254aa71724e1a529fd090891fea5e853b0ff4e3ae0ae3eac265df1e8f9a83", - "src/http/create-utils.ts": "d989ce2b8c0b0fda6e3dfbec76babe33f30a60aef8f0028f3009b9367aeb21e2", - "src/http/errors.ts": "dbe4aa9a4ce8c5c996f914387e9ec61568f774c5ea5a585c4f9480b13f97706b", - "src/http/operation.ts": "be425a2829e1ab16d5b07b592ddde76d3e611b5670567030e17afe10ca4c202f", - "src/http/request.ts": "2416d809ab164a68b67627456686e52a6a49a5173790e652d129dcd503b1d424", - "src/http/streamed-query.ts": "ab4b9404db54fe5f77894c87bf7240474c4c7a00de12b3fe18914b67005e08af", - "src/http/types.ts": "e63f305feae5452bbe125b46cbfc6ebaecabdf72c224ec6d4732daa798e250d9", + "src/core/operation.ts": "7dabe4d21070d20534643e5051060c84a09db5a7aa39858e75846043f8a537ea", + "src/core/schema-key.ts": "f86690bd0ac063199f3207ee398e87dd8210601d1496ebe21844099a597f1946", + "src/core/stream-snapshot.ts": "00bf4855cc745571ce440a9afcf7a52134450de9ced00007e4cd2cdab8e544d1", + "src/core/streamed-query.ts": "cb94aef5458ed04ea90aa7b60bc187d171f15f911658848354753c2029f6a17b", + "src/core/types.ts": "4fe7d3e2d9760fc8b226846c1e67fcd431dbf2f26b77d782ed66343f10c6afa1", + "src/core/utility-tree.ts": "d8d788f370002b3f6058949130438b1e1f6f6a3b2f9e5eac4489526940af5f21", + "src/http/create-utils.ts": "785dd9171fd9c6ab7619a9e3728f91a3e9d197c311a3a6c8a6dffc74904a4975", + "src/http/errors.ts": "95869413973faefd2958f4e48feada7d5700e3aa0c2545f56a7202c8b9609a46", + "src/http/operation.ts": "7679702ebb65b94e1d3bdd6fce80213248e50cda32038402852c8b80964b7369", + "src/http/request.ts": "9d31397ee4241319ae56260c65e7fb4973adcee974b55e43e2895e7524d39180", + "src/http/streamed-query.ts": "186879ad046d841960d9ead272958659c45becfe0fcd7fd35fc41f8c2056aaa3", + "src/http/types.ts": "57394bbce9225f8df5746b9ccfa37ff4d80e2ee8a770edf683082fa2f06d405f", "src/index.ts": "95300336b198d7cec9a0113597970f462cb431718be9f2aae0fd64402b6655a4", - "src/rpc/create-utils.ts": "74edcb07d0c13466cd1d1b1343eefb91110e3d1ffb299cc4a92310a025d690d8", - "src/rpc/errors.ts": "7e4fbde05e6dd3647f8a12f524a659b5c378ee27092ae4e28151effb7b2feb88", - "src/rpc/operation.ts": "a0b34e68b4054d55b10936737cfef1617fa12074610e5147c912cab101ba7c9e", - "src/rpc/streamed-query.ts": "4e2588fa629b4332a13f921f900ed2580343831adc6c9d86769c830f2ac66545", - "src/rpc/types.ts": "f5fda172e9746512b1c51082eebaee7b5a16bd8b382d59b51ed80e07e8e552e3", + "src/rpc/create-utils.ts": "c0db42f6d9962b6c2e291f2b3d060be16ba7fc93826fa00471db5d82503e27dc", + "src/rpc/errors.ts": "7048a786a55f2cd221788485ffa993277f47896110ffac742f74ee077382377e", + "src/rpc/operation.ts": "e46cb103c4a79506cecb34c9337640ec9ab917a488e9360c7535d3dad733ebf5", + "src/rpc/streamed-query.ts": "554ee2fc1a833c6c6f3d97c132da66b0b86d524be56c3589511b564303e81856", + "src/rpc/types.ts": "94a154bbc38d1996beb4fde2f11d2fabb86d6d4c2f47f6fce417dbd1ec2dfda2", "tests/cache-recipes.test.ts": "5d86c8f74e952aa6f614d9ba32b4f2680137d23b696d8c55cd07d0463e5b65b7", - "tests/create-http-api-query-utils.test.ts": "a76295644389a37e1a00a9825d192cbd0dceeab7d43e7ad1196c171ef5059ad1", - "tests/create-rpc-query-utils-configuration.test.ts": "839191fd31986c13f77585d191d643599426ce3294a930af421a26439b37b664", - "tests/create-rpc-query-utils-execution.test.ts": "8556cd62ba1ecdcc130c9a5d35f165b60d18a9c6f3f8eed30822f213ecd57e73", - "tests/create-rpc-query-utils-keys.test.ts": "e79abaa3659d9e212d1a1b3b923abe3493fcdb1d1ab5c92cd895df199043f9da", + "tests/create-http-api-query-utils.test.ts": "afb7890a6f94a12fa51c5e3403e2886d01fe3d7c267534339282ec3e8c338c96", + "tests/create-rpc-query-utils-configuration.test.ts": "d5e7b5ccd4ae102c6e7cf6b99bbd161dc029380b04e1728bf1d3714b1bfcbd36", + "tests/create-rpc-query-utils-execution.test.ts": "05a0de82218666f39cc6ac2d120f2b2e96885e9cfc9323e5ccc658ad996da2c5", + "tests/create-rpc-query-utils-keys.test.ts": "c6b6918e08c10f097634179e65d0e8eb481ff6833561b04de8e6a093104fee31", "tests/create-rpc-query-utils-request-options.test.ts": "bcd76859a2830e522bdeb212be70f2e14be3b815da75a5caf3e596fd5fbd13e7", "tests/create-rpc-query-utils-server-local.test.ts": "45ee329a736bcb6b114727ef8874f3f34541ce6dc55d5e13ffc80be9c2af2ddd", "tests/create-rpc-query-utils-stream-baseline.test.ts": "cec0a8906b3512e38c0272dc07a4cac81402ac014549eb11f24a594d7b792616", "tests/create-rpc-query-utils-stream-cache.test.ts": "b97fd6eb63c0e8c023f98a1420555288c3dd2f4ab500b0d3e0b1d3a5c41e1347", + "tests/create-rpc-query-utils-stream-hashing.test.ts": "025f15582fe2a667dc6383a406d5b42654ea7634acf3591803d9f379896a69bc", "tests/create-rpc-query-utils-stream-publication.test.ts": "cc3a25d02015b1410a8d1406249a846bf711f7b829fa7fccce1da90e600530d3", - "tests/create-rpc-query-utils-streaming.test.ts": "07a2af009d755f6025d5329f1edf4c1d40dc1ce090dffdadf023c5ba812353d0", + "tests/create-rpc-query-utils-streaming.test.ts": "4f38bc56aa04c96709a96f37ba697265cb7f1992e00947f4d18f41615394bf7f", "tests/create-rpc-query-utils.test.ts": "786032f34d1b3386373901714de9f5e7f5a1d42cb7d29cf79cb9d224d17c0624", - "tests/fixtures/async.ts": "f9ccc474ad87dd5aedebc5660d74386de6551dd5f8900d572c485c11054ff6b9", + "tests/fixtures/async.ts": "61c314b76032dc197a7568b0602740d06eee0462414516788c699f1a7263114a", "tests/fixtures/docs-websocket-use.ts": "6e2aa0bb7b1a653ffc215bad7a39b9a79aa1a9b3707bb32b1e80f4636faac544", - "tests/fixtures/effect-rpc.ts": "73be762e670e37e4cc3cfcc2b1d40724f23721726d75b45524ad0b3aba78eb61", - "tests/fixtures/http-client.ts": "a0a77029f4c786c1616f24ce6aa44120de40e243d3dc5570d85d1767bb527a11", - "tests/fixtures/optimistic-users.ts": "34d06822585039f33d2f9df7bceb866d36acbeff7f8ca7ce30f25e5e37975ba3", + "tests/fixtures/effect-rpc.ts": "8a92d7b5e815012f830b7e87a8492b2680d8a1b8fa866c3db25274458d432745", + "tests/fixtures/http-client.ts": "184cef6b4864d32f1225149161e12d4286517a34c74eb0ba0a2437ff5f333891", + "tests/fixtures/optimistic-users.ts": "0e5363c07c5022da24c9d7753cafe646eadb99da8929a07d6e4469516d93b5ec", "tests/fixtures/query-cache.ts": "ee6509aac5d0cf4f781652da10c2b1c3501a222ddd9a6b05f447e6a226548071", "tests/fixtures/rpc-transport-queries.ts": "46be1d1e6048d57d83e9ae64b00c8c0c11deec6a67d0d4b115cdc9aac7447158", "tests/fixtures/socket-client.ts": "d2d0a4a31011e2cfeec0e7e7ca996ad752a3a51db5675ac5efce54d284c8db89", "tests/fixtures/user-events.ts": "13d8167b811bec6319de96f7cb50410f586c18cc92677d7fd17488b16d15b329", "tests/fixtures/websocket-checks.ts": "7b8035264ece4e501e829b3e2b755f9d7b17c010ffa1036db6338a368ff6f93c", "tests/http-cached-views.test.ts": "3cdf4e8895fcb0e511ccdc6c49abfcc0318a82038844219ae88f32b28f2d3df6", - "tests/http-execution.test.ts": "d5869dc2faef8f2edf0f721173f40b352f1b2d70ef19919214a342b3bd82ea18", + "tests/http-execution.test.ts": "d7a2070dc186cae08e0c97d9d7045ceb2776b622fbe8fcbcd3ba65629b24ae75", "tests/http-hydration.test.ts": "cead9c149e9aecbba7121b4e6bcfa3450368d24e6320ba1b8825545db747b98d", "tests/http-live-query.test.ts": "79df219cb204669520c584123f167479ea3fa514d899897bf2810a04e8e1ca65", - "tests/http-multipart.test.ts": "ff816ffae79e827883cea1d1f99f5f763e95c9e856040627a34818df6569feb1", - "tests/http-query-options.test.ts": "62db987483d1fa468a62da899a1938296f79e9ac37c8d57890f898b2d66bf0b2", - "tests/http-semantic-keys.test.ts": "2e78788697bdf7efec374669d8123da57c93df3c368d41920decfcd175bc2359", + "tests/http-multipart.test.ts": "cfda4574a8b10bfcf1b38c060f37d6791d83d94a01640edc8f85c468e45cb3c4", + "tests/http-query-options.test.ts": "e6abd4020fb4fd11877802c42d07ffb6f66822120d409c885150e97177769386", + "tests/http-semantic-keys.test.ts": "df50b9a0cf6981594100c93ca4283eb8ca11e7aca4db8719b4d7bd9a4333cd16", "tests/http-transport-cancellation.test.ts": "e56f8571f949adc9d44f14b61e20edddb188a9565bf7a585859ffe73a851ac39", "tests/http-view-proof.test.ts": "4dc91c38efe5b0c643ef61c98dcdf4a89acf4ef4c1651030124d82ca6cb55a61", - "tests/stream-snapshot.test.ts": "c419902fad27b91205ff5e4b65510ce9ed0c3d0969300c8891f1bc314a7ce0e5", + "tests/stream-snapshot.test.ts": "86449ef5d0f1baf165a5c973fcd0a190b9fce8ae5b6e8732a4454ab0c9f6c132", "tests/types/docs-http-quick-start.ts": "5f7eef98be244ea392fc2c5b527858f159a08efebf6f2665725301aa8a910050", "tests/types/docs-hydration-async.ts": "de67135bf3da0dffdac47e9f2e49469edddac3d7d026fbd93d39aea6964c0dd8", "tests/types/docs-hydration-dto.ts": "ebcd5c3895e8fa40bff3b62e8e13213e98a3e1c4796d89979b23ecc64cdc13ff", @@ -186,200 +188,203 @@ "tests/types/docs-stream-snapshot.ts": "bc924cff90a9f9a8e416bf7fbdfedbd607c88e7a678435aaf20ef878862e06ff", "tests/types/docs-user-events.ts": "87647140d5434b95cc5389b1fea9919f20df7b6fcee4f00565836a4eae549a70", "tests/types/example-owner-contract.ts": "fb10bd087ee582a64806d110404c366cb74e520b0b8cc11701348036b08b9622", - "tests/types/http-contract.ts": "3dd9e8c02b6c7039942fac3e578a3c2a848c2e1495efa87f658e025225b9cce9", - "tests/types/http-stream-contract.ts": "4c47b65a5088ae292bee39d5ec5df8df007aaa90c94fb2af5d01239c3f850017", + "tests/types/http-contract.ts": "d5a8cc93a22186a6a4bdb3cc264f3739df6527e87bfc7c672f22b46fd18403cb", + "tests/types/http-stream-contract.ts": "24031e37ac8cada4b8e9d3b2f4ce6e08448780571d94c8002bd8b267caa320bd", "tests/types/http-type-scale.ts": "b456543b88316a2dd899f929efbffcf514e54e213e109b5f7d52eb785d2255aa", "tests/types/http-view-proof.ts": "f42a1ebf36eeab7a906b722f5d2c2af0aabe8d30ef378fc50386c16d464fb64e", - "tests/types/public-contract.ts": "475048059f5f509008a119a6f0305fca018f1fdcc19c35f63df070a6bc7b9bf5", + "tests/types/public-contract.ts": "5bf988c06b41bc009b143f57371f9b347af8a2d7ad6ff3c88f1d70833972c5fe", "tests/types/server-local-client-contract.ts": "443a60788ce7d91a77459c99f07d3fc9bf70d5a64a83c05ece5c40cfac63d298", "tests/types/snapshot-contract.ts": "9e6a2edaddb7545f20d351511ed99be2828d4ca56779503bcc443963d8b73716", "tests/types/tanstack-start-contract.ts": "51681e3d0d7504a614d490f18adb6f44db82d34b9d4d7cb9cf941aea33de284f", "tests/types/type-scale.ts": "a9b307c25c72f3c184eabc8e22ddaeea96feb331c0f8d748773d765d358dbfca" }, - "head": "eac8805641c1acde48c4414dda2abe7042d1b795", - "outcome": "no-change", - "reason": "The RPC reference now describes input: skipToken consistently with the existing shipped query-patterns guidance and current object-only public builders. The removed bare-sentinel claim was a human-reference error; the seven shipped skill files already use the correct form and require no rewrite.", + "head": "54b6521f3e37f9cb3ca4cd3812ab3c960eb2168a", + "outcome": "updated", + "reason": "Shortened repeated framework and cache guidance; corrected stream execution configuration failures, consistent QueryClient hashing and default queryHash restrictions. RPC/HTTP option forms, originating owners, snapshot drain and paired codecs retain their existing developer tasks.", "evidence": [ - "At eac8805 plus the generated-builders.md correction: inspected the actual reference diff, src/core/utility-tree.ts preparation, public-contract.ts direct-form negative compiler fixtures and skills/effect-api-query/references/query-patterns.md. Uncached docs-build passed Astro diagnostics, 46-page build, search and internal links (reference-fix-docs-build.log). Existing final public compiler and 124 in-memory runtime receipts cover the unchanged API. No fresh consumer activation/task evaluation repeated for this reference-only correction; offline Skills CLI delivery separately proves seven exact files and thirteen resolved links, not consumer-task reliability." + "Reviewed the actual source/guidance diff from 54b6521 and PR base 5b91ddb1de13970365669c887b9d767ea417fbd4, including src/core/streamed-query.ts, src/core/stream-snapshot.ts, src/http/request.ts, both transport type/error adapters, optimistic writes, commands and all six shipped references. All 160 hash regressions pass; independent final critic scored 8.5/10 with no actionable findings and 44 separate runtime probes.", + "Final uncached native tasks passed: typecheck (142 files), Effect diagnostics (162 files, zero errors/warnings), Fallow, test (41 files / 474 runtime cases), public API and server-local fixtures with TypeScript 7.0.2 and 5.9.3, native pack with attw/publint, both example builds, docs-check and 46-page docs-build with internal links. Chromium passed all 31 application and 7 documentation checks. Firefox/WebKit browser launches are unavailable locally because their installed binaries are missing; their HTTP-only documentation checks passed. Fresh installed-consumer activation/task evaluation was not performed; compiler and application evidence comes from this workspace." ] }, "planning:_artifacts": { - "fingerprint": "718d9de9ccfd5dadc4476f9698693974234cd481254d50924ff0ff37b30d05d0", + "fingerprint": "3407012ab66306152591a338a042af1eda310350ddc50c94108ba9da8cf4c8c8", "snapshot": { - "GLOSSARY.md": "67de06543ad2fd9fd2917419e19d1f071e7b6eee53d48fca05e9dddfc739c683", + "GLOSSARY.md": "ad19a69727108dd047208b6e76e05f5b1d241620ed75cb241a62fb4c068dc792", "README.md": "7af677dd785f95bf08b4a9dbafd10f1b0a2cd9ce40d42edf359ede1e59de6fd3", "_artifacts/domain_map.yaml": "c104af1d7610c2cc9d118719170f3a8d0c0cde7690933b1abc7cc124c88b18b0", - "_artifacts/skill_spec.md": "e87c0670bc924a1d710a496151ee6d549fdeba920afcede14b8bab432111ebb7", + "_artifacts/skill_spec.md": "468aa40b779f75664232df15da8c7804411afbfd2a48e536e11aae954d9379a4", "_artifacts/skill_tree.yaml": "da82bd81b41250e02e263147afd7a12ce5379f86cf8b6a9e09895d1227df84fc", "apps/docs/src/content/docs/concepts/client-lifecycle.md": "fcbaca84f266060b946eed9cbf9d0b9815352b7b1d6ddaff5803b29484345380", "apps/docs/src/content/docs/concepts/data-normalization.md": "f1505ec3eb35a33fac289706c2cc318a2ffb80555e3302ca102e67e21c9012da", "apps/docs/src/content/docs/concepts/http-utility-tree.md": "ed03938167f31822b76efbeac381fd3f8d297061fe567928ee022f8dbfadb9b4", "apps/docs/src/content/docs/concepts/queries-and-mutations.md": "f62769112999ebe71a75aa3c22e73380e0f11d8c1f64c3125e2e5e8dba11997e", "apps/docs/src/content/docs/concepts/rpc-utility-tree.md": "fd5fcc232279e9577b8b2faf321a85e9a340e6090ea5887bbab208cca2d24a4e", - "apps/docs/src/content/docs/concepts/semantic-keys.md": "ae21f39db492acdad4f576c6942408b628124da775e40fdde543a4c8341de5ec", + "apps/docs/src/content/docs/concepts/semantic-keys.md": "e76e2443be443d6cda73166cd9a1d7d68db6813b9af4f98e6f1bdf7d7cc8c2e1", "apps/docs/src/content/docs/examples.md": "217a98fef80d350becc9fb7472eb120af9ce528e091fa13840bc9531930e5d53", "apps/docs/src/content/docs/getting-started/compatibility-and-stability.md": "74f9d59c908fe6ab70ccfb8940c1f976e060b38a05cc6bbe75f72403d3b3ed7b", "apps/docs/src/content/docs/getting-started/feature-support.md": "47de4a30ab91559a5aa4cf211f9da13fa970ad444ee19a0c417efcb289e72bc1", "apps/docs/src/content/docs/getting-started/http-quick-start.md": "f73cc87d44999e85b942b3ef284bdd2d0cf5bbd84cb4e7e3f14e65da185ff81d", "apps/docs/src/content/docs/getting-started/installation.md": "0e7d6e608fa0ea049446fd3784350ba635503c0010c12342bc5774b6e7105152", "apps/docs/src/content/docs/getting-started/quick-start.md": "43e8837b84df91af5dfe1b989c6f2cd94557ffb038fd1c0692a5bc7342716b3c", - "apps/docs/src/content/docs/guides/angular-query.md": "fcce47f32059d4164ae3a0b5fb1cf35922dd2bd5bb5b1dfa38a139339483c539", + "apps/docs/src/content/docs/guides/angular-query.md": "3093cdaccb8975047e0df2a56876d1258aff75f047aacaf5eac4ae4719d9affb", "apps/docs/src/content/docs/guides/cache-filters.md": "ac17c5bfd460a70988bdb4d7fd702fc96a6d0df46b6e0cd3455ac60e0ab4fe22", "apps/docs/src/content/docs/guides/cache-management.md": "72a1943ca0c4171a5d10b5b1667bfbec8b8f07922a1114b40b3457fbdc5c708e", - "apps/docs/src/content/docs/guides/cancellation.md": "eb846576e04d8e8fb3030352a8613664773f4140047e6da0ccd002fcb78a7a2e", + "apps/docs/src/content/docs/guides/cancellation.md": "922b18e192c0b75ccde3c7c2190f3c87336312f2d29afd73ff75dd0a90233ffb", "apps/docs/src/content/docs/guides/choose-stream-history.md": "6996df3a8b18c4fadbcc76a3635a8a5408f155b4068509d4801b38afa7f22f16", "apps/docs/src/content/docs/guides/conditional-queries.md": "7770390ad17843b0883968cf35c4aa39054fac7adeceacae731e8899e3cc4ff2", "apps/docs/src/content/docs/guides/custom-key-encoders.md": "9a819f6c490234e46458d407e140fb3dc36cb7c67691a8574e8c2f6024c9c046", - "apps/docs/src/content/docs/guides/handle-failures.md": "f9d8e9abcf8a29a6d57bb8b2140b8faf02e8550490405eab75a3c2721186f5fe", + "apps/docs/src/content/docs/guides/handle-failures.md": "2eded0844b67dd4a54e21000fa516cd1017652004413495557ce8453953203eb", "apps/docs/src/content/docs/guides/http-queries-and-mutations.md": "928eb6def0170167e572ebd0e536bfc5cffaf6921c497da6367a6ecfec0841f7", "apps/docs/src/content/docs/guides/hydrate-query-views.md": "d71f573e4c6e6815e51f7a63080b773655f90cf9ef07155d293f1550eac091d4", "apps/docs/src/content/docs/guides/hydrate-unary-data.md": "0cf27b204e709f8a753fd61fd1510073404611e53a9fd6e82e8122af7e68f2fa", "apps/docs/src/content/docs/guides/optimistic-writes.md": "b76e2336f6958e7482786312a15bb9c25a1608c659018c5b4d3162301e767bef", - "apps/docs/src/content/docs/guides/query-defaults.md": "212138d564907a75c46a1b8261f5964c63b6dc8a9cbbd74e68e9f5d41b9b5248", + "apps/docs/src/content/docs/guides/query-defaults.md": "bb27c896ddaf1a5583b192d5407eeb5f53c26c299e17697991573f3aece5e0a8", "apps/docs/src/content/docs/guides/react-query.md": "efa6c38843bbc5ebd827244628e7d2ddaa0f3d67924c99d8ff2a8dcb4b91aa7e", "apps/docs/src/content/docs/guides/refresh-from-events.md": "a8e680ae9060a573336829b94dbf77dd32385671d48cd4aa0f092a1374444cd6", - "apps/docs/src/content/docs/guides/retry-queries.md": "23fe305768b5779d87a21af4f56c49ef2f03fcc7654ec6ffaf9db46628e3cc11", - "apps/docs/src/content/docs/guides/solid-query.md": "91151a68b0205da685a8e545fdc386cffa1c3ac76b6de9be73b19bfb470c19bc", + "apps/docs/src/content/docs/guides/retry-queries.md": "8ff86e673cf47fb208f7f1ec34adfd6f57b5580bffc4af49835a563ed159a514", + "apps/docs/src/content/docs/guides/solid-query.md": "37fc9edbb245db2e5468649195baa450274e26a99d024be8c2088e96a3156d7d", "apps/docs/src/content/docs/guides/stream-snapshots.md": "91e22e85c11d8822e25e5abb7b872b78f07af79fdb16eb2690baf8226fdbd9f7", - "apps/docs/src/content/docs/guides/svelte-query.md": "aeb8ba0254920677a3c0a0667d500a1186eb368ae56f16ff848f6edcae7903ac", + "apps/docs/src/content/docs/guides/svelte-query.md": "47725e8230c8af9646f375d39cc312c6baa8ccd15a7e9842627755e324840385", "apps/docs/src/content/docs/guides/switch-cache-owners.md": "cc209f13becec305af60c9f04fe29f85a2ca6db3bc11601cf09083bc2583d198", "apps/docs/src/content/docs/guides/tanstack-start.md": "6fae751389e9b5dfb843235059aea1a2fc885d62f797556de78a9ea77697ddcb", - "apps/docs/src/content/docs/guides/vue-query.md": "aff0843b1f4fb76563695e9a1d6050fad202007c4be994b74aff774ab2e038d0", + "apps/docs/src/content/docs/guides/vue-query.md": "405bc7b3dc0fb149bac4084be17b18a31a695c4e5fa2fe2fee7cfe579579e439", "apps/docs/src/content/docs/guides/websocket-clients.md": "1cd31655e433ced7b24795255cb03280b27734eda58afeb9730a42bfad433c43", "apps/docs/src/content/docs/index.mdx": "a141fa2f98d38ea8042ad596c07369b81c9a27935805c17f552db0bc9dd22f6f", "apps/docs/src/content/docs/reference/browser-and-worker-hosts.md": "70743a4429a39937515c8d5fee79ca75714a92dd526f1093f9fa8ea12fbde984", - "apps/docs/src/content/docs/reference/compatibility-and-limits.md": "f19fe01d6ab2fc2065827c23ceea2720bb136f28a35c3ba3759507224bb8b6c9", - "apps/docs/src/content/docs/reference/errors.md": "4454725375e05ea7a7d976d798c1c1d0c2731eefc79043f5e40dba88154069b1", - "apps/docs/src/content/docs/reference/external-http-clients.md": "4ed288ccf29f3bcdf2e3773172282eefe0ef0f84ed0fd1bc347e5cd388743aba", + "apps/docs/src/content/docs/reference/compatibility-and-limits.md": "4f60753887b52c33a47b993f0aaaf75832edaeb09d082f2664e2e5e512e568a3", + "apps/docs/src/content/docs/reference/errors.md": "a3eda1d28bb90c85bcdccd9594ebb90cf588357d8cd80de39de5dca49b300085", + "apps/docs/src/content/docs/reference/external-http-clients.md": "79b38018269ac81fed55330214a277d07d3ee61d127b029de250f75272df3268", "apps/docs/src/content/docs/reference/factory.md": "7d972a9ff279ce80164aba793cbeb5a3e1621e6e16be97392b6faa7493de339c", - "apps/docs/src/content/docs/reference/generated-builders.md": "eabe68d7cbecc4a15db83a5c97da48321d2ffffbf2d6869ff5323a80b8f205db", - "apps/docs/src/content/docs/reference/http-factory.md": "13a238cb7de1fdd5b92f6bccc6061ff4cdfe49b23a5e0b52cc3126f031a6f39d", + "apps/docs/src/content/docs/reference/generated-builders.md": "a0e26877f37161a6f083528771035d2ab686b5b92e0fdfca3ebc92e779f89816", + "apps/docs/src/content/docs/reference/http-factory.md": "f922f468926a3b33029a7cf8c46564009514392771f3851ba8acd00cbdab612d", "apps/docs/src/content/docs/reference/performance.md": "2f68c31cae28de0a84b1ce77f065481d37899171b78d9593ff65e017ff75d97a", "apps/docs/src/content/docs/reference/public-exports.md": "500d864720857c9098b325d6018b20b5ad6c1e666ef326921721ffdf6435dcb2", "docs/adr/0001-target-effect-4-release-candidate.md": "44119ba9104f767c5804c5026c4a21a78a8367f6d9ff5a9035bb3f423969fbb3", - "docs/adr/0002-let-call-sites-choose-query-or-mutation.md": "3ababe863eabf9cc53c70629dfe65af4a82a77e91467e5082041b8c71f93f482", + "docs/adr/0002-let-call-sites-choose-query-or-mutation.md": "4244d48ff136eea562aa2f94a0b6b1ab58412796f3ece47a3cf5b43f746e11f5", "docs/adr/0003-project-dotted-rpc-tags-into-utility-paths.md": "a99856e94af4004484fed939d46ae4388363031cf396b07f04a92a7119fec2fe", - "docs/adr/0004-build-core-shaped-options-and-certify-react-first.md": "3e936951b2987c55d8ddff13e740e4070409c683294040744ed65a2f1cbcb16d", + "docs/adr/0004-build-core-shaped-options-and-certify-react-first.md": "a04939a70fc1438dfe7fc467b4f7f83d16135edb8a4397f6e160d00616b605cb", "docs/adr/0005-preserve-the-cause-in-one-error-wrapper.md": "1e04885542f22f166b5daa913ae2dae121c407baa775a2c4a16590f96857e3fe", "docs/adr/0006-use-canonical-semantic-json-for-payload-keys.md": "694d8665938af25abb91ada71ea9c95cb77dd4ea90019adb2f642e16a88948c7", "docs/adr/0007-keep-the-core-factory-lifecycle-neutral.md": "a83dc0d6061ac0328a3ee6075aa872a55feb4f0154edf6dae964e74276bb15b5", - "docs/adr/0008-use-flat-prefix-matchable-key-tuples.md": "d2a146eed07be984daca0a4cd8209eb1ca86734b162d5362b5663c50863cd526", + "docs/adr/0008-use-flat-prefix-matchable-key-tuples.md": "2a2b5a6971a1841748aa18c326fc2fd5df181572397001753fa44468617eab1a", "docs/adr/0009-build-an-eager-frozen-utility-tree.md": "bc3b08a3385f1dc8874d81f867f15ef6c30b203c94936fd991d2941fd5bacc27", "docs/adr/0010-reuse-tanstack-query-core-as-a-peer.md": "633a1ce44c8d58b29096f540871b61566352462e1d4bd5335162b3e08652527d", "docs/adr/0012-normalize-undefined-query-success-to-null.md": "5d076f168223f67a205138d3e258f1f3aac9dfe5d607ffe8efd12a71322ef022", "docs/adr/0014-require-query-stable-payload-construction.md": "2f4381fb3e7406304ff3b914db541419f799f8a71d1d527b37a9e971e9949e47", "docs/adr/0015-centralize-the-toolchain-in-vite-plus.md": "d2001383bc0f92363289c04b6b8df26acb5b0f5dfc0163b2a838f2eee8057b75", "docs/adr/0020-generate-accumulated-and-live-stream-queries.md": "5e4f5c92e08ffb769137e930908dded993f928a497b018f9c6e4fd4f12232727", - "docs/adr/0021-share-utility-construction-through-private-modules.md": "a7d6774a6652735e690e5099ccc7dcad10da416de482068f87b74e6063fe90a7", + "docs/adr/0021-share-utility-construction-through-private-modules.md": "821720a3eee166d4b7d42b6f5c3906bb29dff339b4815e9ed1c516bc5af66fd4", "docs/adr/0022-add-buffered-http-utilities-with-separate-key-roots.md": "f1fb5d91cd91a70b356bc98b84176920ec822315dbdeaf5e247921853598b62f", "docs/adr/0023-expose-buffered-multipart-http-mutations.md": "b72f20cb513d2a7db94b2dbe27576352048ba22288ad6eac6e80d1a7c81cefb8", "docs/adr/0024-project-http-streams-and-buffered-metadata.md": "2797a5a4a751c5e37c38aa454c14b99ce1615870ffa13cfcedb98cca8a0af633", "docs/adr/0025-use-public-decoded-message-clients-for-server-local-rpc.md": "36df8e1227af22018d5a9063699986ceb8dcfd4c8bb5615aa62557f2ec36e111", "docs/adr/0026-keep-rpc-transport-aggregation-in-the-application.md": "b73af29d8f73a36f61f110ad8778e045d37253fe42a8888883e78089b93e955e", - "examples/server/src/commands.ts": "62d60db1094eaaa3e927899431800daec6f9cd1f345509ec324f3369147d9e06", - "examples/server/src/diagnostic-operations.ts": "9ea6dd3f5243888b251bb64f534e3273253b79daf1c623edcc0f5034e627d049", + "examples/server/src/commands.ts": "58f415fe0ab9bb0127c96c11ef0540a29b97d231eebd3a487513f0820a46b3d5", + "examples/server/src/diagnostic-operations.ts": "ddf8a0f2367b91ca08d6aaaf58380a51b77a5c51a9a85412bd7e7080ff0453ee", "examples/server/src/domain.ts": "c00fced49e54d7bfff9045479e91aabdbd7c0868cc89afe00bae5473b17d2f82", "examples/server/src/http-handlers.ts": "601f37c428015e75e94cb282af356878aa49c887804cc29e2b4f0c1a47ca92f0", "examples/server/src/index.ts": "9e121809a5493c649e61e76fefec52a8c79a394983368060a95dc7000dcedc9a", "examples/server/src/main.ts": "de9520cc16e4bff9d02b8507ee70641936b1287959823429f7759c0f05fdc0a7", "examples/server/src/rpc-handlers.ts": "c2c57eb9a2ca5e602fff9f798c4d5165e91216c5b7e64e0a71220c4ba9316f3d", "examples/server/src/server-local-rpc.ts": "de2a05bba54042f1d2d5781023cb5d20eb63915dd2a52635e9debf83911b5421", - "examples/server/src/server.ts": "eab2d80d9e6ecc4c74b89a50223ff968bf5b98da72d9aece4e726c0cfd6832e8", + "examples/server/src/server.ts": "5c9bbd7131ba3c55bbd61255fa8da8a275fc97412e1c8cb54f063bde517991e9", "examples/server/src/users.ts": "15c363e2162f8dab4da1bfcf6324abbb3dc8ca34a8d8e036eb4db2481fe6be5b", - "examples/server/src/web-handler.ts": "55447fe9ec14213d3d69811be11c9d6d6b266d50cf37b57dd5d4870132905523", + "examples/server/src/web-handler.ts": "2de54d8d2264024887ca63c8c53b20e9f9260ca6605b791d1f91bcea8c8d1b07", "examples/server/tests/fixtures/websocket-server.ts": "b59863766bb60e5fb4de040d7d97ae2e67bca2ec44810ddc577f4cf5cb03129f", - "examples/server/tests/http-api.test.ts": "0e4ea6656cdc67d4fb29710cec1d10af505f7dbb3bcd3d85947f7ed19c220df9", + "examples/server/tests/http-api.test.ts": "6ad2951b74c45a73ea4980e5da65c7ed4f758ff43db34702f1224de934548bca", "examples/server/tests/rpc-transport-overhead.test.ts": "8d3dd40cfbbd0ce64b74f44f205f7f83163ea08dfc264a6deb1902e96b74a7fb", "examples/server/tests/rpc-websocket-consumer.test.ts": "16253ef720e8a85cb7c583bcf6f9883c5893423d6da952659731e5823fcf0a62", - "examples/server/tests/server.test.ts": "e36862bcc44433777c78e36355efea2779c8e866a0e63c9a5cf8fe2d5e86d673", - "examples/server/tests/web-handler.test.ts": "e32f61699348bb483adc15d9d2fc69663402767a7cfa893506c69758150313f0", - "examples/tanstack-start/src/lib/api-server.ts": "7a1d9e860dc1c77b17f8ec1dfa8207ae5e5b4d53b287e7c76e2ee4138b605779", - "examples/tanstack-start/src/lib/application.ts": "5f2eeb9920fa77b18dc7ad00a2688fe9225bfd2c57db35a5040b0a38e299ec71", + "examples/server/tests/server.test.ts": "44530f1e8b9b67f4d4bd8486c7bd7790240128fb161c1244857eee15c87a068d", + "examples/server/tests/web-handler.test.ts": "94d1b28810d908dcec8ca8299e56bb520f93804e8761b4d5e9c828087efae646", + "examples/tanstack-start/src/lib/api-server.ts": "b678d6edab872786fb92db328f60095d604c8d2f5205c4d4a462c3405a782bad", + "examples/tanstack-start/src/lib/application.ts": "609e2d9ce04f24b1a1795650a49bfac139b9a28e8158234630c0845d2ace6392", "examples/tanstack-start/src/lib/query-ssr.ts": "0754abae5faf2f7562e1d86d27df0e14820a5ba9830146690ff1dc9f5beb3ec9", "examples/tanstack-start/src/lib/server-application.ts": "442278fc08c4beb9a69cf1ab7672f661385d9c49998db77ce2870d349ffdc8a1", "examples/tanstack-start/src/lib/server-host.ts": "1682ee37ea7551ec52c9530d6a4b476b967fa0572f5ddc26351ad427ce854e37", - "examples/tanstack-start/src/lib/snapshot-preparation.ts": "b50f578ff0aaf5a6301df7323d0b3ea1e3fdcc27f65be0cf706a73d790203fd7", + "examples/tanstack-start/src/lib/snapshot-preparation.ts": "eb9d9e422626d5d392fd842610c26de5ae517a6b3bea5cc93ea423fc3f942ff9", "examples/tanstack-start/src/router.tsx": "801aac19cff9a4e2a4acb0823a9ea5c28b4aea9245f7e26cb63548bb5234bd15", "examples/tanstack-start/tests/application.test.ts": "81f9b44df80dded727f1fedf51ffee2df2c7afd7f25a68668a263deb88cd3ec1", "examples/tanstack-start/tests/async-hydration.test.ts": "1f983917d2a469b83b876e882ad6a2ece4d28d360338ed614232807a168a1620", - "examples/tanstack-start/tests/client-navigation.test.tsx": "e91e23be9c5695035462fd5b27a1c1d627bf6d1d0f6a8124d45ac1bc220db416", + "examples/tanstack-start/tests/client-navigation.test.tsx": "62af6b9c4ad00e2bd772a0eb75d175e11329079bc787874b0d87e7f22030620e", "examples/tanstack-start/tests/failure-dehydration.test.ts": "b9d135c54e8e70ff454903f8016e6560fabaa487095cb40cb8725ed73577f079", - "examples/tanstack-start/tests/server-local.test.ts": "a2bbaf6cfad8701e2f8912a07c6a9f3d91a8d8ecb394a94c09d25289134576c9", - "examples/tanstack-start/tests/server-render.test.tsx": "01061f209d72df769235c47bae552402e6c9a692c95e67267e4317b2e338b8f5", + "examples/tanstack-start/tests/server-local.test.ts": "2facfdbf572cf8ed1cf5f6104a503504ab0e979546aca3e28d9dbc853c64c6e5", + "examples/tanstack-start/tests/server-render.test.tsx": "77b82127f9e3ddea55b201f7b2080f930ef340269010502f1515f6691ecc4ab2", "examples/vite-react/src/app.tsx": "691f77aee6fb9f2b1f6f9df40a3afcf78f4af2d043d7b379f38c75c647a5f91e", - "examples/vite-react/src/lib/application.ts": "c3fe076813ec5797de9e93274b07d07312e830cfa0998ae2d174191bc1c7d3fb", + "examples/vite-react/src/lib/application.ts": "0467aaf770fdb75095898c78c8cb243a2092cebb2a31986610cd7945e7f6e993", "examples/vite-react/src/lib/owner-cache.ts": "563f1758f6f387768ba8f068e02763fa6b1bd30ef84d59e8be1938adfec7eb26", - "examples/vite-react/src/lib/user-writes.ts": "46d8b7b5f02447344309a80f068bf045d71997d3dc40402dede9d835b6e36fdb", + "examples/vite-react/src/lib/user-writes.ts": "a746bb972220cd55b6bc1b6ceb8161bc01993506430082560f104d63244ec16b", "examples/vite-react/src/main.tsx": "cdee6ca4095b58e1750015db473bda8651702a8ab32c408fa77961873db0e6fe", "examples/vite-react/tests/app.test.tsx": "ff86b42023eb40658dd6feccbfd1f236a75bf2831f40b02955ebc1306be6329c", + "examples/vite-react/tests/commands-ui.test.tsx": "70005a656b9356fce97924dff6d4be82d87893cf48a619c119ad4372843246f8", "examples/vite-react/tests/commands.test.ts": "3e24222572c0f7a4b68cfc4069c545cbebf410cacd0063a328cead9ad5baab08", "examples/vite-react/tests/owner-boundary.test.tsx": "d4ce58a002aac2b8f752936a65495558f89bff145d5cf2556f5c0d9286f8d6b3", "examples/vite-react/tests/owner-cache.test.ts": "8818e30fdd3071a2e5dc2d865161e0d9f40de1ff37813b0a396bb2e83dd08a1d", "examples/vite-react/tests/owner-switch.test.ts": "c997c9758269586591cbf55de4651c64091abc9061b56edb024dcbf9dbaa9c1d", - "examples/vite-react/tests/user-writes.test.ts": "2492f48018e7d8e684eae1c8d302acc9961a1bfbd60bf3214c113dc1b6fbe54a", + "examples/vite-react/tests/user-writes.test.ts": "e104afaa57a9dd47391ac1da08175e8f7dae03dfd151edf9c9fd570bba816ae3", "package.json": "dc41aa3f5b1633ffb2e6f6a0b7efb4e0119d74b9bc8c0003b1a8ee5fad854782", "pnpm-workspace.yaml": "58fd69fb2ebc237bf589893fd3150ec0dd25988aee57f465f5b373b038aee606", "scripts/decode-utf8.mts": "2e90c53e95c22b864a209a87c98084db6ec95e83f842007b21528d286d45c18c", "scripts/measure-construction.mts": "10522cb10ab51fd44d8990896a2a73e8bf4d4cd2646441e958b6180cb1afd147", "scripts/verify-docs-examples.mts": "b20cdbf909f68c177125ddbc69e86d4244571ac5faebe505fa43cdf2faa34d91", "scripts/verify-packed-consumer.mts": null, - "skills/effect-api-query/SKILL.md": "1939a0f7536f3e88e1e1106e8125019c11d6fdd4ada8ae4d010507f264b234e5", - "skills/effect-api-query/references/cache-workflows.md": "27bb96c48c84254ab8c379a529402f81cea3723c17d6396527c090a8c37e6f65", - "skills/effect-api-query/references/frameworks-and-hosts.md": "8b39b303cf3a9ae3222a257f53df5f9696b80c54859765f2c4d7b39554b8719f", + "skills/effect-api-query/SKILL.md": "ca0822b4c5172bab93179c6885ffc1a64f434fce30d7af14d307d5492eeb90ed", + "skills/effect-api-query/references/cache-workflows.md": "e62daab7af8b3118962d1c0d8b5212927ffb1fcf4abf44ad9489ae695a0ed4b6", + "skills/effect-api-query/references/frameworks-and-hosts.md": "169ff6847997b4cdf49c4984e87c393876973c0ca28e7b0b9c3deff4bbbd8a8b", "skills/effect-api-query/references/http.md": "9625411753fcc3ce816512f80158a19aa1057518ab3bfde98720e0cc7f845b66", "skills/effect-api-query/references/hydration-and-ssr.md": "61e761b36ebcf56c7cc1b67b3d795edba52d0c0224c3e7964297ed6864e1b044", - "skills/effect-api-query/references/query-patterns.md": "5e20137ffe4b951ccaf41e869a7ac097633f91cb52ec44799ecdf47b4ba3883c", + "skills/effect-api-query/references/query-patterns.md": "69d58f67745a6ead7d86729cb8d137a22c0ce4bbbb31c0cc016dcb3fa428c6cb", "skills/effect-api-query/references/rpc.md": "f77eb39bb1b3e1f5f8c56f4b827ecef350a255a23380d8b9515dc61b54165d5d", - "src/core/operation.ts": "3b00c20d4f9cfa38b22574953804e7dc4648e912df74bfd5469c657ff5cc6e35", - "src/core/schema-key.ts": "6e3ec8e7aa089ab6d2a85e71880f9bf4985f572b28e0b4325fcb0eed00b5dfd3", - "src/core/stream-snapshot.ts": "abd83051df4e7fd3e4de5f9147b029993d59e19e5dd72ee7b73d40ebae12acc7", - "src/core/streamed-query.ts": "34ed5a18daa8c5aacdb5fbfede925b1bd84baef1f9e0580e0a8035b7cfde0eb1", - "src/core/types.ts": "ae7ad2d99e24cb7c98e54270ce2698ff8db4d21750e967d11ed4d9653a5e6264", - "src/core/utility-tree.ts": "5c0254aa71724e1a529fd090891fea5e853b0ff4e3ae0ae3eac265df1e8f9a83", - "src/http/create-utils.ts": "d989ce2b8c0b0fda6e3dfbec76babe33f30a60aef8f0028f3009b9367aeb21e2", - "src/http/errors.ts": "dbe4aa9a4ce8c5c996f914387e9ec61568f774c5ea5a585c4f9480b13f97706b", - "src/http/operation.ts": "be425a2829e1ab16d5b07b592ddde76d3e611b5670567030e17afe10ca4c202f", - "src/http/request.ts": "2416d809ab164a68b67627456686e52a6a49a5173790e652d129dcd503b1d424", - "src/http/streamed-query.ts": "ab4b9404db54fe5f77894c87bf7240474c4c7a00de12b3fe18914b67005e08af", - "src/http/types.ts": "e63f305feae5452bbe125b46cbfc6ebaecabdf72c224ec6d4732daa798e250d9", + "src/core/operation.ts": "7dabe4d21070d20534643e5051060c84a09db5a7aa39858e75846043f8a537ea", + "src/core/schema-key.ts": "f86690bd0ac063199f3207ee398e87dd8210601d1496ebe21844099a597f1946", + "src/core/stream-snapshot.ts": "00bf4855cc745571ce440a9afcf7a52134450de9ced00007e4cd2cdab8e544d1", + "src/core/streamed-query.ts": "cb94aef5458ed04ea90aa7b60bc187d171f15f911658848354753c2029f6a17b", + "src/core/types.ts": "4fe7d3e2d9760fc8b226846c1e67fcd431dbf2f26b77d782ed66343f10c6afa1", + "src/core/utility-tree.ts": "d8d788f370002b3f6058949130438b1e1f6f6a3b2f9e5eac4489526940af5f21", + "src/http/create-utils.ts": "785dd9171fd9c6ab7619a9e3728f91a3e9d197c311a3a6c8a6dffc74904a4975", + "src/http/errors.ts": "95869413973faefd2958f4e48feada7d5700e3aa0c2545f56a7202c8b9609a46", + "src/http/operation.ts": "7679702ebb65b94e1d3bdd6fce80213248e50cda32038402852c8b80964b7369", + "src/http/request.ts": "9d31397ee4241319ae56260c65e7fb4973adcee974b55e43e2895e7524d39180", + "src/http/streamed-query.ts": "186879ad046d841960d9ead272958659c45becfe0fcd7fd35fc41f8c2056aaa3", + "src/http/types.ts": "57394bbce9225f8df5746b9ccfa37ff4d80e2ee8a770edf683082fa2f06d405f", "src/index.ts": "95300336b198d7cec9a0113597970f462cb431718be9f2aae0fd64402b6655a4", - "src/rpc/create-utils.ts": "74edcb07d0c13466cd1d1b1343eefb91110e3d1ffb299cc4a92310a025d690d8", - "src/rpc/errors.ts": "7e4fbde05e6dd3647f8a12f524a659b5c378ee27092ae4e28151effb7b2feb88", - "src/rpc/operation.ts": "a0b34e68b4054d55b10936737cfef1617fa12074610e5147c912cab101ba7c9e", - "src/rpc/streamed-query.ts": "4e2588fa629b4332a13f921f900ed2580343831adc6c9d86769c830f2ac66545", - "src/rpc/types.ts": "f5fda172e9746512b1c51082eebaee7b5a16bd8b382d59b51ed80e07e8e552e3", + "src/rpc/create-utils.ts": "c0db42f6d9962b6c2e291f2b3d060be16ba7fc93826fa00471db5d82503e27dc", + "src/rpc/errors.ts": "7048a786a55f2cd221788485ffa993277f47896110ffac742f74ee077382377e", + "src/rpc/operation.ts": "e46cb103c4a79506cecb34c9337640ec9ab917a488e9360c7535d3dad733ebf5", + "src/rpc/streamed-query.ts": "554ee2fc1a833c6c6f3d97c132da66b0b86d524be56c3589511b564303e81856", + "src/rpc/types.ts": "94a154bbc38d1996beb4fde2f11d2fabb86d6d4c2f47f6fce417dbd1ec2dfda2", "tests/cache-recipes.test.ts": "5d86c8f74e952aa6f614d9ba32b4f2680137d23b696d8c55cd07d0463e5b65b7", - "tests/create-http-api-query-utils.test.ts": "a76295644389a37e1a00a9825d192cbd0dceeab7d43e7ad1196c171ef5059ad1", - "tests/create-rpc-query-utils-configuration.test.ts": "839191fd31986c13f77585d191d643599426ce3294a930af421a26439b37b664", - "tests/create-rpc-query-utils-execution.test.ts": "8556cd62ba1ecdcc130c9a5d35f165b60d18a9c6f3f8eed30822f213ecd57e73", - "tests/create-rpc-query-utils-keys.test.ts": "e79abaa3659d9e212d1a1b3b923abe3493fcdb1d1ab5c92cd895df199043f9da", + "tests/create-http-api-query-utils.test.ts": "afb7890a6f94a12fa51c5e3403e2886d01fe3d7c267534339282ec3e8c338c96", + "tests/create-rpc-query-utils-configuration.test.ts": "d5e7b5ccd4ae102c6e7cf6b99bbd161dc029380b04e1728bf1d3714b1bfcbd36", + "tests/create-rpc-query-utils-execution.test.ts": "05a0de82218666f39cc6ac2d120f2b2e96885e9cfc9323e5ccc658ad996da2c5", + "tests/create-rpc-query-utils-keys.test.ts": "c6b6918e08c10f097634179e65d0e8eb481ff6833561b04de8e6a093104fee31", "tests/create-rpc-query-utils-request-options.test.ts": "bcd76859a2830e522bdeb212be70f2e14be3b815da75a5caf3e596fd5fbd13e7", "tests/create-rpc-query-utils-server-local.test.ts": "45ee329a736bcb6b114727ef8874f3f34541ce6dc55d5e13ffc80be9c2af2ddd", "tests/create-rpc-query-utils-stream-baseline.test.ts": "cec0a8906b3512e38c0272dc07a4cac81402ac014549eb11f24a594d7b792616", "tests/create-rpc-query-utils-stream-cache.test.ts": "b97fd6eb63c0e8c023f98a1420555288c3dd2f4ab500b0d3e0b1d3a5c41e1347", + "tests/create-rpc-query-utils-stream-hashing.test.ts": "025f15582fe2a667dc6383a406d5b42654ea7634acf3591803d9f379896a69bc", "tests/create-rpc-query-utils-stream-publication.test.ts": "cc3a25d02015b1410a8d1406249a846bf711f7b829fa7fccce1da90e600530d3", - "tests/create-rpc-query-utils-streaming.test.ts": "07a2af009d755f6025d5329f1edf4c1d40dc1ce090dffdadf023c5ba812353d0", + "tests/create-rpc-query-utils-streaming.test.ts": "4f38bc56aa04c96709a96f37ba697265cb7f1992e00947f4d18f41615394bf7f", "tests/create-rpc-query-utils.test.ts": "786032f34d1b3386373901714de9f5e7f5a1d42cb7d29cf79cb9d224d17c0624", - "tests/fixtures/async.ts": "f9ccc474ad87dd5aedebc5660d74386de6551dd5f8900d572c485c11054ff6b9", + "tests/fixtures/async.ts": "61c314b76032dc197a7568b0602740d06eee0462414516788c699f1a7263114a", "tests/fixtures/docs-websocket-use.ts": "6e2aa0bb7b1a653ffc215bad7a39b9a79aa1a9b3707bb32b1e80f4636faac544", - "tests/fixtures/effect-rpc.ts": "73be762e670e37e4cc3cfcc2b1d40724f23721726d75b45524ad0b3aba78eb61", - "tests/fixtures/http-client.ts": "a0a77029f4c786c1616f24ce6aa44120de40e243d3dc5570d85d1767bb527a11", - "tests/fixtures/optimistic-users.ts": "34d06822585039f33d2f9df7bceb866d36acbeff7f8ca7ce30f25e5e37975ba3", + "tests/fixtures/effect-rpc.ts": "8a92d7b5e815012f830b7e87a8492b2680d8a1b8fa866c3db25274458d432745", + "tests/fixtures/http-client.ts": "184cef6b4864d32f1225149161e12d4286517a34c74eb0ba0a2437ff5f333891", + "tests/fixtures/optimistic-users.ts": "0e5363c07c5022da24c9d7753cafe646eadb99da8929a07d6e4469516d93b5ec", "tests/fixtures/query-cache.ts": "ee6509aac5d0cf4f781652da10c2b1c3501a222ddd9a6b05f447e6a226548071", "tests/fixtures/rpc-transport-queries.ts": "46be1d1e6048d57d83e9ae64b00c8c0c11deec6a67d0d4b115cdc9aac7447158", "tests/fixtures/socket-client.ts": "d2d0a4a31011e2cfeec0e7e7ca996ad752a3a51db5675ac5efce54d284c8db89", "tests/fixtures/user-events.ts": "13d8167b811bec6319de96f7cb50410f586c18cc92677d7fd17488b16d15b329", "tests/fixtures/websocket-checks.ts": "7b8035264ece4e501e829b3e2b755f9d7b17c010ffa1036db6338a368ff6f93c", "tests/http-cached-views.test.ts": "3cdf4e8895fcb0e511ccdc6c49abfcc0318a82038844219ae88f32b28f2d3df6", - "tests/http-execution.test.ts": "d5869dc2faef8f2edf0f721173f40b352f1b2d70ef19919214a342b3bd82ea18", + "tests/http-execution.test.ts": "d7a2070dc186cae08e0c97d9d7045ceb2776b622fbe8fcbcd3ba65629b24ae75", "tests/http-hydration.test.ts": "cead9c149e9aecbba7121b4e6bcfa3450368d24e6320ba1b8825545db747b98d", "tests/http-live-query.test.ts": "79df219cb204669520c584123f167479ea3fa514d899897bf2810a04e8e1ca65", - "tests/http-multipart.test.ts": "ff816ffae79e827883cea1d1f99f5f763e95c9e856040627a34818df6569feb1", - "tests/http-query-options.test.ts": "62db987483d1fa468a62da899a1938296f79e9ac37c8d57890f898b2d66bf0b2", - "tests/http-semantic-keys.test.ts": "2e78788697bdf7efec374669d8123da57c93df3c368d41920decfcd175bc2359", + "tests/http-multipart.test.ts": "cfda4574a8b10bfcf1b38c060f37d6791d83d94a01640edc8f85c468e45cb3c4", + "tests/http-query-options.test.ts": "e6abd4020fb4fd11877802c42d07ffb6f66822120d409c885150e97177769386", + "tests/http-semantic-keys.test.ts": "df50b9a0cf6981594100c93ca4283eb8ca11e7aca4db8719b4d7bd9a4333cd16", "tests/http-transport-cancellation.test.ts": "e56f8571f949adc9d44f14b61e20edddb188a9565bf7a585859ffe73a851ac39", "tests/http-view-proof.test.ts": "4dc91c38efe5b0c643ef61c98dcdf4a89acf4ef4c1651030124d82ca6cb55a61", - "tests/stream-snapshot.test.ts": "c419902fad27b91205ff5e4b65510ce9ed0c3d0969300c8891f1bc314a7ce0e5", + "tests/stream-snapshot.test.ts": "86449ef5d0f1baf165a5c973fcd0a190b9fce8ae5b6e8732a4454ab0c9f6c132", "tests/types/docs-http-quick-start.ts": "5f7eef98be244ea392fc2c5b527858f159a08efebf6f2665725301aa8a910050", "tests/types/docs-hydration-async.ts": "de67135bf3da0dffdac47e9f2e49469edddac3d7d026fbd93d39aea6964c0dd8", "tests/types/docs-hydration-dto.ts": "ebcd5c3895e8fa40bff3b62e8e13213e98a3e1c4796d89979b23ecc64cdc13ff", @@ -390,33 +395,35 @@ "tests/types/docs-stream-snapshot.ts": "bc924cff90a9f9a8e416bf7fbdfedbd607c88e7a678435aaf20ef878862e06ff", "tests/types/docs-user-events.ts": "87647140d5434b95cc5389b1fea9919f20df7b6fcee4f00565836a4eae549a70", "tests/types/example-owner-contract.ts": "fb10bd087ee582a64806d110404c366cb74e520b0b8cc11701348036b08b9622", - "tests/types/http-contract.ts": "3dd9e8c02b6c7039942fac3e578a3c2a848c2e1495efa87f658e025225b9cce9", - "tests/types/http-stream-contract.ts": "4c47b65a5088ae292bee39d5ec5df8df007aaa90c94fb2af5d01239c3f850017", + "tests/types/http-contract.ts": "d5a8cc93a22186a6a4bdb3cc264f3739df6527e87bfc7c672f22b46fd18403cb", + "tests/types/http-stream-contract.ts": "24031e37ac8cada4b8e9d3b2f4ce6e08448780571d94c8002bd8b267caa320bd", "tests/types/http-type-scale.ts": "b456543b88316a2dd899f929efbffcf514e54e213e109b5f7d52eb785d2255aa", "tests/types/http-view-proof.ts": "f42a1ebf36eeab7a906b722f5d2c2af0aabe8d30ef378fc50386c16d464fb64e", - "tests/types/public-contract.ts": "475048059f5f509008a119a6f0305fca018f1fdcc19c35f63df070a6bc7b9bf5", + "tests/types/public-contract.ts": "5bf988c06b41bc009b143f57371f9b347af8a2d7ad6ff3c88f1d70833972c5fe", "tests/types/server-local-client-contract.ts": "443a60788ce7d91a77459c99f07d3fc9bf70d5a64a83c05ece5c40cfac63d298", "tests/types/snapshot-contract.ts": "9e6a2edaddb7545f20d351511ed99be2828d4ca56779503bcc443963d8b73716", "tests/types/tanstack-start-contract.ts": "51681e3d0d7504a614d490f18adb6f44db82d34b9d4d7cb9cf941aea33de284f", "tests/types/type-scale.ts": "a9b307c25c72f3c184eabc8e22ddaeea96feb331c0f8d748773d765d358dbfca" }, - "head": "eac8805641c1acde48c4414dda2abe7042d1b795", - "outcome": "no-change", - "reason": "All three planning records remain accurate: domain_map.yaml owns input-field skipping among the same ten tasks; skill_spec.md keeps the six-reference scope and current validated architecture batch; skill_tree.yaml maps the corrected RPC reference to the same shipped skill. The correction adds no task, reference, package, source mapping or ownership decision.", + "head": "54b6521f3e37f9cb3ca4cd3812ab3c960eb2168a", + "outcome": "updated", + "reason": "Updated skill_spec batch history for the audited behavior and actual check results. Reviewed domain_map.yaml and skill_tree.yaml: the same ten existing tasks, source mappings, six references and package-only distribution remain accurate; no new domain or independently useful skill is introduced.", "evidence": [ - "Read _artifacts/domain_map.yaml, _artifacts/skill_spec.md and _artifacts/skill_tree.yaml against the one-file human-reference diff and current shipped query-patterns reference. Native maintainer sync produced no content change; uncached documentation checks/build passed (reference-fix-docs-build.log)." + "Reviewed all three shared planning records against the public builders, cache workflows, request lifecycle, hydration and framework guidance. Native maintainer sync reports nothing to synchronize.", + "Final uncached native tasks passed: typecheck (142 files), Effect diagnostics (162 files, zero errors/warnings), Fallow, test (41 files / 474 runtime cases), public API and server-local fixtures with TypeScript 7.0.2 and 5.9.3, native pack with attw/publint, both example builds, docs-check and 46-page docs-build with internal links. Chromium passed all 31 application and 7 documentation checks. Firefox/WebKit browser launches are unavailable locally because their installed binaries are missing; their HTTP-only documentation checks passed. Fresh installed-consumer activation/task evaluation was not performed; compiler and application evidence comes from this workspace." ] }, "source:vite.config.ts": { - "fingerprint": "c7e24b88d6cc661478533082a2dee8d4736faf1debbe74b9275cd7f75ff137ef", + "fingerprint": "bcbf70904051b7cfa515304490b57adddcebca804881f06ab864dc8f6eee7239", "snapshot": { - "vite.config.ts": "3a5ac2f87cfc9bca499b63c0d283319b91afd9b6db672b60d11bdebdff93138e" + "vite.config.ts": "b48031f5feeae54b990ee8621d7b46aa6134b40d9c0123dfbbfe38db7cee851e" }, - "head": "bf38d8556744d8e45d459208cab6fe9d1b78b356", + "head": "54b6521f3e37f9cb3ca4cd3812ab3c960eb2168a", "outcome": "no-change", - "reason": "Removed three redundant prose lines preceding the existing generic-erasure lint policy; all rules, tasks, checks and packaging behavior remain identical. No shipped guidance change follows.", + "reason": "Allows comment-free assertions and intentional empty catch blocks while preserving typed lint and Effect safety diagnostics. Consumer guidance and task coverage are unchanged.", "evidence": [ - "Actual bf38d855 diff is solely those three prose deletions. Uncached typecheck, compiler fixtures, ordinary runtime tests and native build prerequisite passed; no check was weakened." + "Reviewed the actual diff and current source at vite.config.ts against 54b6521 and the supplied PR base; relevant native task receipts are under /tmp/effect-api-query-audit-2026-10-11/.", + "Final uncached native tasks passed: typecheck (142 files), Effect diagnostics (162 files, zero errors/warnings), Fallow, test (41 files / 474 runtime cases), public API and server-local fixtures with TypeScript 7.0.2 and 5.9.3, native pack with attw/publint, both example builds, docs-check and 46-page docs-build with internal links. Chromium passed all 31 application and 7 documentation checks. Firefox/WebKit browser launches are unavailable locally because their installed binaries are missing; their HTTP-only documentation checks passed. Fresh installed-consumer activation/task evaluation was not performed; compiler and application evidence comes from this workspace." ] }, "source:.github/workflows/release.yml": { @@ -461,20 +468,16 @@ ] }, "source:playwright.docs.config.ts": { - "fingerprint": "88742c8a9cc26dfe39f3825c1cfc869c1caa5254015c82e42eb7a285d9e2b938", + "fingerprint": "9ec6628f761e4c0bfe5d7a1575878947a037f5b9a0b21bf7dc8c2fd8e2343c41", "snapshot": { - "playwright.docs.config.ts": "1e4d72ce40ce1c640abc1478f9897ffd5c2c3b03e28b012c85b79e5f89f4d7e2" + "playwright.docs.config.ts": "395f4dc3c46154c74a8134ed21460bc155d4886bdc7cf3525b1e128e11302065" }, - "head": "4ea1e4329da229c4c9853ddc2f6a50a6309afe9e", + "head": "54b6521f3e37f9cb3ca4cd3812ab3c960eb2168a", "outcome": "no-change", - "reason": "Config reads preserve missing versus empty docs URL and CI values while retaining the existing docs browser server and assertions. This repository-only configuration requires no shipped guidance change.", + "reason": "Removed private narrative and assertion commentary while retaining functional tool directives and the existing runtime behavior. The affected example or test continues to use the documented query, ownership and cancellation workflow; no additional skill task is needed.", "evidence": [ - "Base 4ea1e4329da229c4c9853ddc2f6a50a6309afe9e; actual changed sources and native review fingerprints in this report.", - "Uncached typecheck: 135 files, zero lint/type errors; strict Effect diagnostics:146 files, zero diagnostics; runtime:262 tests/34 files and both server compilers.", - "Fresh packed-package matrix:26 compiler validations,21 runtime fixtures per peer,4 frameworks,12 diagnostic cells,18 construction cases and6 bundles. Selected archive SHA512 437c863fbae5d097d5947330524c004f1bf10e33cb1334fc245818be36ccd1d969458c7ba1a0eb135326f4f7c4030cb22518bc8aa08d8b03e8d543353f9b2876.", - "Chromium/Firefox packed Window/Worker:4 tests pass; native app/docs production builds and docs parity pass.", - "Finite command probes:11 real-host cases pass, every owned child exits and temp directory is removed; six actual bundle gzip outputs match complete prior native level9 bytes and exclude platform-node.", - "Fallow, formatting and native skills validation pass. Native sync reports nothing to synchronize; guidance and all three planning documents remain byte-identical to the reviewed base. Root owns final managed all-engine and Release provenance acceptance." + "Reviewed the actual diff and current source at playwright.docs.config.ts against 54b6521 and the supplied PR base; relevant native task receipts are under /tmp/effect-api-query-audit-2026-10-11/.", + "Final uncached native tasks passed: typecheck (142 files), Effect diagnostics (162 files, zero errors/warnings), Fallow, test (41 files / 474 runtime cases), public API and server-local fixtures with TypeScript 7.0.2 and 5.9.3, native pack with attw/publint, both example builds, docs-check and 46-page docs-build with internal links. Chromium passed all 31 application and 7 documentation checks. Firefox/WebKit browser launches are unavailable locally because their installed binaries are missing; their HTTP-only documentation checks passed. Fresh installed-consumer activation/task evaluation was not performed; compiler and application evidence comes from this workspace." ] }, "source:tsconfig.json": { @@ -490,17 +493,16 @@ ] }, "source:.fallowrc.jsonc": { - "fingerprint": "2c6bb6ebb6e814610554e636e2d203764192734a848111efdedaf145cc0b62f6", + "fingerprint": "fe993f492101c539100e9141bf1e7a35ad325dff972d4c4b7b957ebe29a344b3", "snapshot": { - ".fallowrc.jsonc": "c0b0ec773be6b6247fd4e7a1db0229965323d01b40236e05f1fa7c3cbb0f95a1" + ".fallowrc.jsonc": "fdc2e4edfe73b037a8a08c28692035a357481a9bb69f18e29b6e8357be56d110" }, - "head": "2e4ecf18e93a4ee086d74102c3addb5e2029be56", + "head": "54b6521f3e37f9cb3ca4cd3812ab3c960eb2168a", "outcome": "no-change", - "reason": "The explicit ignored default-export path now names apps/docs/astro.config.mts. This preserves the existing framework-tooling export exception for the same unchanged configuration; native Fallow entry/production checks pass. No library integration task, shipped guidance, or planning source mapping changes.", + "reason": "Removed private explanatory comments without changing entries, exclusions or audit policy. Existing library guidance needs no separate tooling task.", "evidence": [ - "Author base 2e4ecf18e93a4ee086d74102c3addb5e2029be56. Rename apps/docs/astro.config.mjs to astro.config.mts, remove ts-check directive, and update only .fallowrc.jsonc ignoreExports path. Installed loader evidence: apps/docs/node_modules/astro/dist/core/config/config.js configPaths contains astro.config.mts.", - "vp run format apps/docs/astro.config.mts .fallowrc.jsonc passed. vp run fallow passed entry/production dead-code checks, duplicates task, and health threshold. vp run docs-build passed Astro check with zero errors/warnings/hints and build of 44 pages with valid internal links; unchanged documentation example parity was a native cache hit. Logs: /tmp/effect-api-query-implementation/notes/astro-mts-*.log.", - "Read _artifacts/domain_map.yaml, skill_spec.md, and skill_tree.yaml: existing library task/seam/ownership coverage and source globs remain accurate; no library behavior, guidance, dependency or installation change. Native sync changed nothing; review reports no owned skill/planning drift. No new independent consumer or browser execution is needed for this repository config rename." + "Reviewed the actual diff and current source at .fallowrc.jsonc against 54b6521 and the supplied PR base; relevant native task receipts are under /tmp/effect-api-query-audit-2026-10-11/.", + "Final uncached native tasks passed: typecheck (142 files), Effect diagnostics (162 files, zero errors/warnings), Fallow, test (41 files / 474 runtime cases), public API and server-local fixtures with TypeScript 7.0.2 and 5.9.3, native pack with attw/publint, both example builds, docs-check and 46-page docs-build with internal links. Chromium passed all 31 application and 7 documentation checks. Firefox/WebKit browser launches are unavailable locally because their installed binaries are missing; their HTTP-only documentation checks passed. Fresh installed-consumer activation/task evaluation was not performed; compiler and application evidence comes from this workspace." ] }, "source:.github/workflows/ci.yml": { @@ -982,16 +984,16 @@ ] }, "source:e2e/tanstack-start.spec.ts": { - "fingerprint": "ed1590bcfc4446b7fddb2924b2813b63d04124961f04f3ffb96fa2b719498b24", + "fingerprint": "326d8bf63a3852af4e5b276be477e3af235569ab15d12ed975e65c1507baed43", "snapshot": { - "e2e/tanstack-start.spec.ts": "b50a8fd958cce8e66c3081aa0e7d2a4e759e67580efc3d8da6d7c251b36c6fcc" + "e2e/tanstack-start.spec.ts": "ea1e5469e782796d5ab68b443b6d21bd460691d413ba15bb83fae5024fd1981e" }, - "head": "6893603eab70bd9d67e9848414eccc8a3421d870", + "head": "54b6521f3e37f9cb3ca4cd3812ab3c960eb2168a", "outcome": "no-change", - "reason": "The added reload assertion verifies that the shared directory mutation remains visible through server rendering. Existing hydration guidance already requires matching representations and independent server/browser owners; an acceptance assertion adds no new consumer contract.", + "reason": "Removed private narrative and assertion commentary while retaining functional tool directives and the existing runtime behavior. The affected example or test continues to use the documented query, ownership and cancellation workflow; no additional skill task is needed.", "evidence": [ - "Reviewed the complete test diff from 5b91ddb to 6893603 against skills/effect-api-query/references/hydration-and-ssr.md.", - "Source 6893603: CI 38085762210 passed source quality, both example builds, documentation, and Chromium/Firefox/WebKit acceptance; saved final-mts-ci.json and final-mts-ci.log." + "Reviewed the actual diff and current source at e2e/tanstack-start.spec.ts against 54b6521 and the supplied PR base; relevant native task receipts are under /tmp/effect-api-query-audit-2026-10-11/.", + "Final uncached native tasks passed: typecheck (142 files), Effect diagnostics (162 files, zero errors/warnings), Fallow, test (41 files / 474 runtime cases), public API and server-local fixtures with TypeScript 7.0.2 and 5.9.3, native pack with attw/publint, both example builds, docs-check and 46-page docs-build with internal links. Chromium passed all 31 application and 7 documentation checks. Firefox/WebKit browser launches are unavailable locally because their installed binaries are missing; their HTTP-only documentation checks passed. Fresh installed-consumer activation/task evaluation was not performed; compiler and application evidence comes from this workspace." ] }, "source:e2e/vite-react.spec.ts": { @@ -1008,29 +1010,29 @@ ] }, "source:examples/tanstack-start/src/routes/index.tsx": { - "fingerprint": "a56fc6c1a96f303b4dda65d866efc454add3b44c6e201b1a62a2e53b94a3e513", + "fingerprint": "a04a3037d3d997eff9b0aa1ec7c5915f79c7d28dcfbea1588338553b3e41d733", "snapshot": { - "examples/tanstack-start/src/routes/index.tsx": "857ac39eca9264b8df23814108092540aea6409b94019b6ed14c88262b1ca937" + "examples/tanstack-start/src/routes/index.tsx": "3118b095cfdde95ce04dd63f92a5213007e052d2fa0886ca0966ec0e7d6afcca" }, - "head": "6893603eab70bd9d67e9848414eccc8a3421d870", + "head": "54b6521f3e37f9cb3ca4cd3812ab3c960eb2168a", "outcome": "no-change", - "reason": "Routing stream capture through the request context tracks the same snapshot in its owning request lifecycle. Existing SSR guidance already requires capture/preparation settlement and local finalizers before disposing request clients.", + "reason": "Count loaded users directly across pages without allocating a flattened array; query data, ownership and displayed counts are unchanged, and existing framework guidance remains accurate.", "evidence": [ - "Reviewed the complete route diff from 5b91ddb to 6893603 and skills/effect-api-query/references/hydration-and-ssr.md.", - "Source 6893603: CI 38085762210 passed source quality, both example builds, documentation, and Chromium/Firefox/WebKit acceptance; saved final-mts-ci.json and final-mts-ci.log." + "Reviewed the actual diff and current source at examples/tanstack-start/src/routes/index.tsx against 54b6521 and the supplied PR base; relevant native task receipts are under /tmp/effect-api-query-audit-2026-10-11/.", + "Final uncached native tasks passed: typecheck (142 files), Effect diagnostics (162 files, zero errors/warnings), Fallow, test (41 files / 474 runtime cases), public API and server-local fixtures with TypeScript 7.0.2 and 5.9.3, native pack with attw/publint, both example builds, docs-check and 46-page docs-build with internal links. Chromium passed all 31 application and 7 documentation checks. Firefox/WebKit browser launches are unavailable locally because their installed binaries are missing; their HTTP-only documentation checks passed. Fresh installed-consumer activation/task evaluation was not performed; compiler and application evidence comes from this workspace." ] }, "source:examples/vite-react/src/components/sections/commands-section.tsx": { - "fingerprint": "a00dc770d73fd319e4da616e87460baaf5b0d415c7143437c3959b35fe2b20df", + "fingerprint": "59bb7e6fcf3553d5b5fc3b98b159aef67071397c70ad59892a746f826d74194d", "snapshot": { - "examples/vite-react/src/components/sections/commands-section.tsx": "113856c225c332dacf328f9d06b3ea98f3a56b288f5b1bdb9fa98c8d1e326cf3" + "examples/vite-react/src/components/sections/commands-section.tsx": "990c75cdfacc4391267e8b25948cddf12a0ae241a5444f02685c8f84a9f2aa39" }, - "head": "6893603eab70bd9d67e9848414eccc8a3421d870", - "outcome": "no-change", - "reason": "Start/cancel mutations now use the application mutation tracker and guard reconciliation with the originating owner. Existing cache guidance already distinguishes local retirement from remote command cancellation and preserves originating actions after awaits.", + "head": "54b6521f3e37f9cb3ca4cd3812ab3c960eb2168a", + "outcome": "updated", + "reason": "Command starts can retry after failed transport and reconcile accepted operations through status polling. The cancellation guide now covers this behavior and same-ID retries.", "evidence": [ - "Reviewed the complete component diff from 5b91ddb to 6893603 and skills/effect-api-query/references/cache-workflows.md.", - "Source 6893603: CI 38085762210 passed source quality, both example builds, documentation, and Chromium/Firefox/WebKit acceptance; saved final-mts-ci.json and final-mts-ci.log." + "Reviewed the actual diff and current source at examples/vite-react/src/components/sections/commands-section.tsx against 54b6521 and the supplied PR base; relevant native task receipts are under /tmp/effect-api-query-audit-2026-10-11/.", + "Final uncached native tasks passed: typecheck (142 files), Effect diagnostics (162 files, zero errors/warnings), Fallow, test (41 files / 474 runtime cases), public API and server-local fixtures with TypeScript 7.0.2 and 5.9.3, native pack with attw/publint, both example builds, docs-check and 46-page docs-build with internal links. Chromium passed all 31 application and 7 documentation checks. Firefox/WebKit browser launches are unavailable locally because their installed binaries are missing; their HTTP-only documentation checks passed. Fresh installed-consumer activation/task evaluation was not performed; compiler and application evidence comes from this workspace." ] }, "source:examples/vite-react/src/components/sections/diagnostics-section.tsx": { @@ -1047,16 +1049,16 @@ ] }, "source:examples/vite-react/src/components/sections/http-section.tsx": { - "fingerprint": "e0f0733aa85363147a8ad802fe4977a45bd920e82daa0d99884c7291a5953762", + "fingerprint": "e89b26c6c412fb39a50a6c96504fc8cf82ff54bb0bc346d30bd9a198f0b72485", "snapshot": { - "examples/vite-react/src/components/sections/http-section.tsx": "089876bb63fe88d62f0a5ec191e44e85bf93de1dbcd8b329f63b191b7911d0ca" + "examples/vite-react/src/components/sections/http-section.tsx": "c5b3bcbe155a6c7ace30a8a25bdad8739b669a0e3e16053ead57eb2108681e9d" }, - "head": "6893603eab70bd9d67e9848414eccc8a3421d870", + "head": "54b6521f3e37f9cb3ca4cd3812ab3c960eb2168a", "outcome": "no-change", - "reason": "HTTP creates/deletes now use the shared write coordinator, track the failure demonstration, and disable deletion of temporary IDs. Existing guidance already reserves individual optimistic entities, preserves neighboring writes, and checks the originating owner; request builders are unchanged.", + "reason": "Count loaded users directly across pages without allocating a flattened array; query data, ownership and displayed counts are unchanged, and existing framework guidance remains accurate.", "evidence": [ - "Reviewed the complete component diff from 5b91ddb to 6893603 against skills/effect-api-query/references/cache-workflows.md and references/http.md.", - "Source 6893603: CI 38085762210 passed source quality, both example builds, documentation, and Chromium/Firefox/WebKit acceptance; saved final-mts-ci.json and final-mts-ci.log." + "Reviewed the actual diff and current source at examples/vite-react/src/components/sections/http-section.tsx against 54b6521 and the supplied PR base; relevant native task receipts are under /tmp/effect-api-query-audit-2026-10-11/.", + "Final uncached native tasks passed: typecheck (142 files), Effect diagnostics (162 files, zero errors/warnings), Fallow, test (41 files / 474 runtime cases), public API and server-local fixtures with TypeScript 7.0.2 and 5.9.3, native pack with attw/publint, both example builds, docs-check and 46-page docs-build with internal links. Chromium passed all 31 application and 7 documentation checks. Firefox/WebKit browser launches are unavailable locally because their installed binaries are missing; their HTTP-only documentation checks passed. Fresh installed-consumer activation/task evaluation was not performed; compiler and application evidence comes from this workspace." ] }, "source:examples/vite-react/src/components/sections/mutations-section.tsx": { @@ -1099,16 +1101,16 @@ ] }, "source:examples/vite-react/src/hooks/use-slow-query-cancellation.ts": { - "fingerprint": "5d15e7915a554f041ccec537294f63a591016ef79a04d0a986cdf36fef89b710", + "fingerprint": "2aa37a46d7e10463590f37ee5f3ffc0eec7784ff7192363988340b2ecf1741d9", "snapshot": { - "examples/vite-react/src/hooks/use-slow-query-cancellation.ts": "6de14cf383862176674f3ac3cdc6651b54a3f63fe3fa866419d1274501a96057" + "examples/vite-react/src/hooks/use-slow-query-cancellation.ts": "f30bf0af875d66873e5c7f0f74ec757c2fd91264c69ba20ce34e3d676f84fa73" }, - "head": "6893603eab70bd9d67e9848414eccc8a3421d870", + "head": "54b6521f3e37f9cb3ca4cd3812ab3c960eb2168a", "outcome": "no-change", - "reason": "The helper receives an originating-owner activity guard and checks it before and after asynchronous status reads. Existing guidance already guards late side effects after owner replacement and treats remote command cancellation separately from local query cancellation.", + "reason": "Removed private narrative and assertion commentary while retaining functional tool directives and the existing runtime behavior. The affected example or test continues to use the documented query, ownership and cancellation workflow; no additional skill task is needed.", "evidence": [ - "Reviewed the complete hook diff from 5b91ddb to 6893603 against skills/effect-api-query/references/cache-workflows.md and references/frameworks-and-hosts.md.", - "Source 6893603: CI 38085762210 passed source quality, both example builds, documentation, and Chromium/Firefox/WebKit acceptance; saved final-mts-ci.json and final-mts-ci.log." + "Reviewed the actual diff and current source at examples/vite-react/src/hooks/use-slow-query-cancellation.ts against 54b6521 and the supplied PR base; relevant native task receipts are under /tmp/effect-api-query-audit-2026-10-11/.", + "Final uncached native tasks passed: typecheck (142 files), Effect diagnostics (162 files, zero errors/warnings), Fallow, test (41 files / 474 runtime cases), public API and server-local fixtures with TypeScript 7.0.2 and 5.9.3, native pack with attw/publint, both example builds, docs-check and 46-page docs-build with internal links. Chromium passed all 31 application and 7 documentation checks. Firefox/WebKit browser launches are unavailable locally because their installed binaries are missing; their HTTP-only documentation checks passed. Fresh installed-consumer activation/task evaluation was not performed; compiler and application evidence comes from this workspace." ] }, "source:tests/fixtures/tsconfig.server-local.json": { @@ -1150,6 +1152,253 @@ "evidence": [ "Reviewed both render branches and their bf38d855 diff. Final uncached typecheck passes 140 files; scoped formatting passed. No application-wide UI sharing or dependency change." ] + }, + "source:apps/docs/public/package-version.js": { + "fingerprint": "22bfa3892fa1b85ae21f2c484fa190b10dd35556e9a47ed5b39eb8c6d3bcd551", + "snapshot": { + "apps/docs/public/package-version.js": "f88677a24f7a65061aa41cabde4f6727a9f4c52adfac5ac4fde2b478909c3dc0" + }, + "head": "54b6521f3e37f9cb3ca4cd3812ab3c960eb2168a", + "outcome": "no-change", + "reason": "Removed fallback implementation commentary; a failed registry request still retains the build version. Chromium covers unpublished, failed and invalid responses, so existing user-facing guidance remains accurate.", + "evidence": [ + "Reviewed the actual diff and current source at apps/docs/public/package-version.js against 54b6521 and the supplied PR base; relevant native task receipts are under /tmp/effect-api-query-audit-2026-10-11/.", + "Final uncached native tasks passed: typecheck (142 files), Effect diagnostics (162 files, zero errors/warnings), Fallow, test (41 files / 474 runtime cases), public API and server-local fixtures with TypeScript 7.0.2 and 5.9.3, native pack with attw/publint, both example builds, docs-check and 46-page docs-build with internal links. Chromium passed all 31 application and 7 documentation checks. Firefox/WebKit browser launches are unavailable locally because their installed binaries are missing; their HTTP-only documentation checks passed. Fresh installed-consumer activation/task evaluation was not performed; compiler and application evidence comes from this workspace." + ] + }, + "source:docs/adr/0013-use-a-vite-plus-workspace-with-a-root-package.md": { + "fingerprint": "c18a01558cba99c73dd6bbc8381e3243a4a2f324069d47f6217c11a424cd615e", + "snapshot": { + "docs/adr/0013-use-a-vite-plus-workspace-with-a-root-package.md": "7617802c14fd07b0e147f8168381285b078eb752fbf9981848cbfe86e1f7792a" + }, + "head": "54b6521f3e37f9cb3ca4cd3812ab3c960eb2168a", + "outcome": "no-change", + "reason": "Removed obsolete beta and editor-detail wording while preserving the native Vite+ workspace decision; no consumer instruction changes.", + "evidence": [ + "Reviewed the actual diff and current source at docs/adr/0013-use-a-vite-plus-workspace-with-a-root-package.md against 54b6521 and the supplied PR base; relevant native task receipts are under /tmp/effect-api-query-audit-2026-10-11/.", + "Final uncached native tasks passed: typecheck (142 files), Effect diagnostics (162 files, zero errors/warnings), Fallow, test (41 files / 474 runtime cases), public API and server-local fixtures with TypeScript 7.0.2 and 5.9.3, native pack with attw/publint, both example builds, docs-check and 46-page docs-build with internal links. Chromium passed all 31 application and 7 documentation checks. Firefox/WebKit browser launches are unavailable locally because their installed binaries are missing; their HTTP-only documentation checks passed. Fresh installed-consumer activation/task evaluation was not performed; compiler and application evidence comes from this workspace." + ] + }, + "source:e2e-docs/docs.spec.ts": { + "fingerprint": "291a43d058c8b2a73c082aa28a440a29df8db3e98910189779b7aba7efd24296", + "snapshot": { + "e2e-docs/docs.spec.ts": "77011c58032a0b6c35e159c62a0e76935e70c1779bbc923c71b28dc10c8ff0b1" + }, + "head": "54b6521f3e37f9cb3ca4cd3812ab3c960eb2168a", + "outcome": "no-change", + "reason": "Removed private narrative and assertion commentary while retaining functional tool directives and the existing runtime behavior. The affected example or test continues to use the documented query, ownership and cancellation workflow; no additional skill task is needed.", + "evidence": [ + "Reviewed the actual diff and current source at e2e-docs/docs.spec.ts against 54b6521 and the supplied PR base; relevant native task receipts are under /tmp/effect-api-query-audit-2026-10-11/.", + "Final uncached native tasks passed: typecheck (142 files), Effect diagnostics (162 files, zero errors/warnings), Fallow, test (41 files / 474 runtime cases), public API and server-local fixtures with TypeScript 7.0.2 and 5.9.3, native pack with attw/publint, both example builds, docs-check and 46-page docs-build with internal links. Chromium passed all 31 application and 7 documentation checks. Firefox/WebKit browser launches are unavailable locally because their installed binaries are missing; their HTTP-only documentation checks passed. Fresh installed-consumer activation/task evaluation was not performed; compiler and application evidence comes from this workspace." + ] + }, + "source:e2e/conditional-query.spec.ts": { + "fingerprint": "6f0ab418799c94ae6ba540a6e12ca8a3115a5335418ee92b73af61b68e64798c", + "snapshot": { + "e2e/conditional-query.spec.ts": "cbcf8bd4084cada24635c7839a5bdf83e59115ad1175134d5d65eb37f0dcffa4" + }, + "head": "54b6521f3e37f9cb3ca4cd3812ab3c960eb2168a", + "outcome": "no-change", + "reason": "Removed private narrative and assertion commentary while retaining functional tool directives and the existing runtime behavior. The affected example or test continues to use the documented query, ownership and cancellation workflow; no additional skill task is needed.", + "evidence": [ + "Reviewed the actual diff and current source at e2e/conditional-query.spec.ts against 54b6521 and the supplied PR base; relevant native task receipts are under /tmp/effect-api-query-audit-2026-10-11/.", + "Final uncached native tasks passed: typecheck (142 files), Effect diagnostics (162 files, zero errors/warnings), Fallow, test (41 files / 474 runtime cases), public API and server-local fixtures with TypeScript 7.0.2 and 5.9.3, native pack with attw/publint, both example builds, docs-check and 46-page docs-build with internal links. Chromium passed all 31 application and 7 documentation checks. Firefox/WebKit browser launches are unavailable locally because their installed binaries are missing; their HTTP-only documentation checks passed. Fresh installed-consumer activation/task evaluation was not performed; compiler and application evidence comes from this workspace." + ] + }, + "source:e2e/example-application.ts": { + "fingerprint": "7aa0cef9f7e89bc6264535884d5e3d1bcd4bafdd701b3d78124768b1cd82f262", + "snapshot": { + "e2e/example-application.ts": "3ce3024b409c7c28e9e159fce2b724009dd846fbe6fba75ef3d7ab95dc507e99" + }, + "head": "54b6521f3e37f9cb3ca4cd3812ab3c960eb2168a", + "outcome": "no-change", + "reason": "Removed private narrative and assertion commentary while retaining functional tool directives and the existing runtime behavior. The affected example or test continues to use the documented query, ownership and cancellation workflow; no additional skill task is needed.", + "evidence": [ + "Reviewed the actual diff and current source at e2e/example-application.ts against 54b6521 and the supplied PR base; relevant native task receipts are under /tmp/effect-api-query-audit-2026-10-11/.", + "Final uncached native tasks passed: typecheck (142 files), Effect diagnostics (162 files, zero errors/warnings), Fallow, test (41 files / 474 runtime cases), public API and server-local fixtures with TypeScript 7.0.2 and 5.9.3, native pack with attw/publint, both example builds, docs-check and 46-page docs-build with internal links. Chromium passed all 31 application and 7 documentation checks. Firefox/WebKit browser launches are unavailable locally because their installed binaries are missing; their HTTP-only documentation checks passed. Fresh installed-consumer activation/task evaluation was not performed; compiler and application evidence comes from this workspace." + ] + }, + "source:examples/contracts/src/client.ts": { + "fingerprint": "edb8d760d8523f285a099edd65375f23a0bcab7321cddef564acd58d89f43241", + "snapshot": { + "examples/contracts/src/client.ts": "f645f690571d6f8d571ec8496607778c423fbc4b45848bf3cf725739e981953e" + }, + "head": "54b6521f3e37f9cb3ca4cd3812ab3c960eb2168a", + "outcome": "no-change", + "reason": "Removed private narrative and assertion commentary while retaining functional tool directives and the existing runtime behavior. The affected example or test continues to use the documented query, ownership and cancellation workflow; no additional skill task is needed.", + "evidence": [ + "Reviewed the actual diff and current source at examples/contracts/src/client.ts against 54b6521 and the supplied PR base; relevant native task receipts are under /tmp/effect-api-query-audit-2026-10-11/.", + "Final uncached native tasks passed: typecheck (142 files), Effect diagnostics (162 files, zero errors/warnings), Fallow, test (41 files / 474 runtime cases), public API and server-local fixtures with TypeScript 7.0.2 and 5.9.3, native pack with attw/publint, both example builds, docs-check and 46-page docs-build with internal links. Chromium passed all 31 application and 7 documentation checks. Firefox/WebKit browser launches are unavailable locally because their installed binaries are missing; their HTTP-only documentation checks passed. Fresh installed-consumer activation/task evaluation was not performed; compiler and application evidence comes from this workspace." + ] + }, + "source:examples/contracts/src/contracts.ts": { + "fingerprint": "347cc797a64e13c1476d2f2aaa027a5a23f6ea56d9695b88750fe83b1c3c5ed9", + "snapshot": { + "examples/contracts/src/contracts.ts": "11ba2f683cfeaa16947864a12c3bccc96104dbe0f6139efcd81e6fc865482641" + }, + "head": "54b6521f3e37f9cb3ca4cd3812ab3c960eb2168a", + "outcome": "no-change", + "reason": "RPC pagination now enforces the same integer cursor and 1..100 page-size bounds as the HTTP example. Existing ready-client/query-pattern guidance remains sufficient; server-local regression tests cover rejection.", + "evidence": [ + "Reviewed the actual diff and current source at examples/contracts/src/contracts.ts against 54b6521 and the supplied PR base; relevant native task receipts are under /tmp/effect-api-query-audit-2026-10-11/.", + "Final uncached native tasks passed: typecheck (142 files), Effect diagnostics (162 files, zero errors/warnings), Fallow, test (41 files / 474 runtime cases), public API and server-local fixtures with TypeScript 7.0.2 and 5.9.3, native pack with attw/publint, both example builds, docs-check and 46-page docs-build with internal links. Chromium passed all 31 application and 7 documentation checks. Firefox/WebKit browser launches are unavailable locally because their installed binaries are missing; their HTTP-only documentation checks passed. Fresh installed-consumer activation/task evaluation was not performed; compiler and application evidence comes from this workspace." + ] + }, + "source:examples/tanstack-start/src/components/action-button.tsx": { + "fingerprint": "d0b70e406fe9c1eadf6da4c873a867c67dd0704778b0025afe2570fe957053e6", + "snapshot": { + "examples/tanstack-start/src/components/action-button.tsx": "79c37de2669992148e645a70f1b783a1dd7d85ac8e5a9ec267843550eafd3926" + }, + "head": "54b6521f3e37f9cb3ca4cd3812ab3c960eb2168a", + "outcome": "no-change", + "reason": "Removed private narrative and assertion commentary while retaining functional tool directives and the existing runtime behavior. The affected example or test continues to use the documented query, ownership and cancellation workflow; no additional skill task is needed.", + "evidence": [ + "Reviewed the actual diff and current source at examples/tanstack-start/src/components/action-button.tsx against 54b6521 and the supplied PR base; relevant native task receipts are under /tmp/effect-api-query-audit-2026-10-11/.", + "Final uncached native tasks passed: typecheck (142 files), Effect diagnostics (162 files, zero errors/warnings), Fallow, test (41 files / 474 runtime cases), public API and server-local fixtures with TypeScript 7.0.2 and 5.9.3, native pack with attw/publint, both example builds, docs-check and 46-page docs-build with internal links. Chromium passed all 31 application and 7 documentation checks. Firefox/WebKit browser launches are unavailable locally because their installed binaries are missing; their HTTP-only documentation checks passed. Fresh installed-consumer activation/task evaluation was not performed; compiler and application evidence comes from this workspace." + ] + }, + "source:examples/tanstack-start/src/components/navigation-link.tsx": { + "fingerprint": "b54ce1620db2d94ed8d2f71f4900c2d8c17985ccca14093d9f419a353f67bd13", + "snapshot": { + "examples/tanstack-start/src/components/navigation-link.tsx": "a7d037126c9a648b3aa20ecb8af5f416982522f9da5703460b141bfb53124cf3" + }, + "head": "54b6521f3e37f9cb3ca4cd3812ab3c960eb2168a", + "outcome": "no-change", + "reason": "Removed private narrative and assertion commentary while retaining functional tool directives and the existing runtime behavior. The affected example or test continues to use the documented query, ownership and cancellation workflow; no additional skill task is needed.", + "evidence": [ + "Reviewed the actual diff and current source at examples/tanstack-start/src/components/navigation-link.tsx against 54b6521 and the supplied PR base; relevant native task receipts are under /tmp/effect-api-query-audit-2026-10-11/.", + "Final uncached native tasks passed: typecheck (142 files), Effect diagnostics (162 files, zero errors/warnings), Fallow, test (41 files / 474 runtime cases), public API and server-local fixtures with TypeScript 7.0.2 and 5.9.3, native pack with attw/publint, both example builds, docs-check and 46-page docs-build with internal links. Chromium passed all 31 application and 7 documentation checks. Firefox/WebKit browser launches are unavailable locally because their installed binaries are missing; their HTTP-only documentation checks passed. Fresh installed-consumer activation/task evaluation was not performed; compiler and application evidence comes from this workspace." + ] + }, + "source:examples/tanstack-start/src/components/router-status.tsx": { + "fingerprint": "0fd4520270cc3cdedb23abb639bf655c538744fc11ae2ca2771b4b81b6025a3a", + "snapshot": { + "examples/tanstack-start/src/components/router-status.tsx": "6e9b8fb3bcd3397aa1d447d12f000b264b214463327ac03319dd59d6f229e99d" + }, + "head": "54b6521f3e37f9cb3ca4cd3812ab3c960eb2168a", + "outcome": "no-change", + "reason": "Loading queries labels both RPC and HTTP work accurately. This UI text change adds no developer task or skill requirement.", + "evidence": [ + "Reviewed the actual diff and current source at examples/tanstack-start/src/components/router-status.tsx against 54b6521 and the supplied PR base; relevant native task receipts are under /tmp/effect-api-query-audit-2026-10-11/.", + "Final uncached native tasks passed: typecheck (142 files), Effect diagnostics (162 files, zero errors/warnings), Fallow, test (41 files / 474 runtime cases), public API and server-local fixtures with TypeScript 7.0.2 and 5.9.3, native pack with attw/publint, both example builds, docs-check and 46-page docs-build with internal links. Chromium passed all 31 application and 7 documentation checks. Firefox/WebKit browser launches are unavailable locally because their installed binaries are missing; their HTTP-only documentation checks passed. Fresh installed-consumer activation/task evaluation was not performed; compiler and application evidence comes from this workspace." + ] + }, + "source:examples/tanstack-start/src/hooks/use-slow-query-cancellation.ts": { + "fingerprint": "c85b302c983ad61446ac64bb9fb27735786b70210c22ab55f19ed23421a41aa4", + "snapshot": { + "examples/tanstack-start/src/hooks/use-slow-query-cancellation.ts": "3a1e71d2fd0886af948bfb78d510071186760b341e205ff7543cb3b8d422456d" + }, + "head": "54b6521f3e37f9cb3ca4cd3812ab3c960eb2168a", + "outcome": "no-change", + "reason": "Removed private narrative and assertion commentary while retaining functional tool directives and the existing runtime behavior. The affected example or test continues to use the documented query, ownership and cancellation workflow; no additional skill task is needed.", + "evidence": [ + "Reviewed the actual diff and current source at examples/tanstack-start/src/hooks/use-slow-query-cancellation.ts against 54b6521 and the supplied PR base; relevant native task receipts are under /tmp/effect-api-query-audit-2026-10-11/.", + "Final uncached native tasks passed: typecheck (142 files), Effect diagnostics (162 files, zero errors/warnings), Fallow, test (41 files / 474 runtime cases), public API and server-local fixtures with TypeScript 7.0.2 and 5.9.3, native pack with attw/publint, both example builds, docs-check and 46-page docs-build with internal links. Chromium passed all 31 application and 7 documentation checks. Firefox/WebKit browser launches are unavailable locally because their installed binaries are missing; their HTTP-only documentation checks passed. Fresh installed-consumer activation/task evaluation was not performed; compiler and application evidence comes from this workspace." + ] + }, + "source:examples/tanstack-start/src/routes/failure.tsx": { + "fingerprint": "5df8fdee6f55e2b69313237e1cadae26ec40dd05eff106d23fc0ef2752679c6b", + "snapshot": { + "examples/tanstack-start/src/routes/failure.tsx": "690a26c3ef52a28f5c89617a385de23d920840b591ea3b22f060673dd0d262d6" + }, + "head": "54b6521f3e37f9cb3ca4cd3812ab3c960eb2168a", + "outcome": "no-change", + "reason": "Removed private narrative and assertion commentary while retaining functional tool directives and the existing runtime behavior. The affected example or test continues to use the documented query, ownership and cancellation workflow; no additional skill task is needed.", + "evidence": [ + "Reviewed the actual diff and current source at examples/tanstack-start/src/routes/failure.tsx against 54b6521 and the supplied PR base; relevant native task receipts are under /tmp/effect-api-query-audit-2026-10-11/.", + "Final uncached native tasks passed: typecheck (142 files), Effect diagnostics (162 files, zero errors/warnings), Fallow, test (41 files / 474 runtime cases), public API and server-local fixtures with TypeScript 7.0.2 and 5.9.3, native pack with attw/publint, both example builds, docs-check and 46-page docs-build with internal links. Chromium passed all 31 application and 7 documentation checks. Firefox/WebKit browser launches are unavailable locally because their installed binaries are missing; their HTTP-only documentation checks passed. Fresh installed-consumer activation/task evaluation was not performed; compiler and application evidence comes from this workspace." + ] + }, + "source:examples/tanstack-start/src/routes/http-failure.tsx": { + "fingerprint": "5947742b2cc9d79e14228e7429d5ecc9dd71963448c8fec0e4bd0978705767d9", + "snapshot": { + "examples/tanstack-start/src/routes/http-failure.tsx": "a7ade1d3bd9973903ab7e49461f82cdf413766e3ff1c194a50be21423b5e2de1" + }, + "head": "54b6521f3e37f9cb3ca4cd3812ab3c960eb2168a", + "outcome": "no-change", + "reason": "Removed private narrative and assertion commentary while retaining functional tool directives and the existing runtime behavior. The affected example or test continues to use the documented query, ownership and cancellation workflow; no additional skill task is needed.", + "evidence": [ + "Reviewed the actual diff and current source at examples/tanstack-start/src/routes/http-failure.tsx against 54b6521 and the supplied PR base; relevant native task receipts are under /tmp/effect-api-query-audit-2026-10-11/.", + "Final uncached native tasks passed: typecheck (142 files), Effect diagnostics (162 files, zero errors/warnings), Fallow, test (41 files / 474 runtime cases), public API and server-local fixtures with TypeScript 7.0.2 and 5.9.3, native pack with attw/publint, both example builds, docs-check and 46-page docs-build with internal links. Chromium passed all 31 application and 7 documentation checks. Firefox/WebKit browser launches are unavailable locally because their installed binaries are missing; their HTTP-only documentation checks passed. Fresh installed-consumer activation/task evaluation was not performed; compiler and application evidence comes from this workspace." + ] + }, + "source:examples/tanstack-start/src/routes/http.tsx": { + "fingerprint": "17772c5479439557ae491d4836d8e8c3f0a4d66d6ab98339e3bb1fbe3393925e", + "snapshot": { + "examples/tanstack-start/src/routes/http.tsx": "e54309cb14321d61e9ec0f5b51b2ed6231be4acc4cfaa634cdddd8d5dce459a4" + }, + "head": "54b6521f3e37f9cb3ca4cd3812ab3c960eb2168a", + "outcome": "no-change", + "reason": "Count loaded users directly across pages without allocating a flattened array; query data, ownership and displayed counts are unchanged, and existing framework guidance remains accurate.", + "evidence": [ + "Reviewed the actual diff and current source at examples/tanstack-start/src/routes/http.tsx against 54b6521 and the supplied PR base; relevant native task receipts are under /tmp/effect-api-query-audit-2026-10-11/.", + "Final uncached native tasks passed: typecheck (142 files), Effect diagnostics (162 files, zero errors/warnings), Fallow, test (41 files / 474 runtime cases), public API and server-local fixtures with TypeScript 7.0.2 and 5.9.3, native pack with attw/publint, both example builds, docs-check and 46-page docs-build with internal links. Chromium passed all 31 application and 7 documentation checks. Firefox/WebKit browser launches are unavailable locally because their installed binaries are missing; their HTTP-only documentation checks passed. Fresh installed-consumer activation/task evaluation was not performed; compiler and application evidence comes from this workspace." + ] + }, + "source:examples/tanstack-start/src/styles.css": { + "fingerprint": "6511863c69c721f2f054b705fb32a59bece6f93029fa3d22d3f881df48470fa7", + "snapshot": { + "examples/tanstack-start/src/styles.css": "8fc6183bf34828b9909884e5eadefca6ebe8ee59bc0600389a22431ae7482856" + }, + "head": "54b6521f3e37f9cb3ca4cd3812ab3c960eb2168a", + "outcome": "no-change", + "reason": "Removed private narrative and assertion commentary while retaining functional tool directives and the existing runtime behavior. The affected example or test continues to use the documented query, ownership and cancellation workflow; no additional skill task is needed.", + "evidence": [ + "Reviewed the actual diff and current source at examples/tanstack-start/src/styles.css against 54b6521 and the supplied PR base; relevant native task receipts are under /tmp/effect-api-query-audit-2026-10-11/.", + "Final uncached native tasks passed: typecheck (142 files), Effect diagnostics (162 files, zero errors/warnings), Fallow, test (41 files / 474 runtime cases), public API and server-local fixtures with TypeScript 7.0.2 and 5.9.3, native pack with attw/publint, both example builds, docs-check and 46-page docs-build with internal links. Chromium passed all 31 application and 7 documentation checks. Firefox/WebKit browser launches are unavailable locally because their installed binaries are missing; their HTTP-only documentation checks passed. Fresh installed-consumer activation/task evaluation was not performed; compiler and application evidence comes from this workspace." + ] + }, + "source:examples/tanstack-start/vite.config.ts": { + "fingerprint": "c2f207176f82c378faf65195877ae337a8c4ed7b0d62ba4eb16356327e8766e8", + "snapshot": { + "examples/tanstack-start/vite.config.ts": "e33ed6de04e21fd8b43f95e2a29fdf5adae9007b24faf307ac85b5f51fbb4180" + }, + "head": "54b6521f3e37f9cb3ca4cd3812ab3c960eb2168a", + "outcome": "no-change", + "reason": "Removed private narrative and assertion commentary while retaining functional tool directives and the existing runtime behavior. The affected example or test continues to use the documented query, ownership and cancellation workflow; no additional skill task is needed.", + "evidence": [ + "Reviewed the actual diff and current source at examples/tanstack-start/vite.config.ts against 54b6521 and the supplied PR base; relevant native task receipts are under /tmp/effect-api-query-audit-2026-10-11/.", + "Final uncached native tasks passed: typecheck (142 files), Effect diagnostics (162 files, zero errors/warnings), Fallow, test (41 files / 474 runtime cases), public API and server-local fixtures with TypeScript 7.0.2 and 5.9.3, native pack with attw/publint, both example builds, docs-check and 46-page docs-build with internal links. Chromium passed all 31 application and 7 documentation checks. Firefox/WebKit browser launches are unavailable locally because their installed binaries are missing; their HTTP-only documentation checks passed. Fresh installed-consumer activation/task evaluation was not performed; compiler and application evidence comes from this workspace." + ] + }, + "source:examples/vite-react/src/components/sections/queries-section.tsx": { + "fingerprint": "6a00123ad89af1b93a07f046f5e3a8d06c9232996dfe15038468201d5d556064", + "snapshot": { + "examples/vite-react/src/components/sections/queries-section.tsx": "2f6d771e217f415d570a4483e44a398b49f3a1fb0afae7f7a9caebc8c6a2d41d" + }, + "head": "54b6521f3e37f9cb3ca4cd3812ab3c960eb2168a", + "outcome": "no-change", + "reason": "Count loaded users directly across pages without allocating a flattened array; query data, ownership and displayed counts are unchanged, and existing framework guidance remains accurate.", + "evidence": [ + "Reviewed the actual diff and current source at examples/vite-react/src/components/sections/queries-section.tsx against 54b6521 and the supplied PR base; relevant native task receipts are under /tmp/effect-api-query-audit-2026-10-11/.", + "Final uncached native tasks passed: typecheck (142 files), Effect diagnostics (162 files, zero errors/warnings), Fallow, test (41 files / 474 runtime cases), public API and server-local fixtures with TypeScript 7.0.2 and 5.9.3, native pack with attw/publint, both example builds, docs-check and 46-page docs-build with internal links. Chromium passed all 31 application and 7 documentation checks. Firefox/WebKit browser launches are unavailable locally because their installed binaries are missing; their HTTP-only documentation checks passed. Fresh installed-consumer activation/task evaluation was not performed; compiler and application evidence comes from this workspace." + ] + }, + "source:examples/vite-react/src/components/ui/action-button.tsx": { + "fingerprint": "1b0278c83a2efe4939ff4f81fb4cf741e3e65a0b5316df77dd14d7872b5df4f8", + "snapshot": { + "examples/vite-react/src/components/ui/action-button.tsx": "bdacb6e84d72e4e454c5a0062355a53062db3ebeb56873eb8bd5f5b7b0766fbf" + }, + "head": "54b6521f3e37f9cb3ca4cd3812ab3c960eb2168a", + "outcome": "no-change", + "reason": "Removed private narrative and assertion commentary while retaining functional tool directives and the existing runtime behavior. The affected example or test continues to use the documented query, ownership and cancellation workflow; no additional skill task is needed.", + "evidence": [ + "Reviewed the actual diff and current source at examples/vite-react/src/components/ui/action-button.tsx against 54b6521 and the supplied PR base; relevant native task receipts are under /tmp/effect-api-query-audit-2026-10-11/.", + "Final uncached native tasks passed: typecheck (142 files), Effect diagnostics (162 files, zero errors/warnings), Fallow, test (41 files / 474 runtime cases), public API and server-local fixtures with TypeScript 7.0.2 and 5.9.3, native pack with attw/publint, both example builds, docs-check and 46-page docs-build with internal links. Chromium passed all 31 application and 7 documentation checks. Firefox/WebKit browser launches are unavailable locally because their installed binaries are missing; their HTTP-only documentation checks passed. Fresh installed-consumer activation/task evaluation was not performed; compiler and application evidence comes from this workspace." + ] + }, + "source:examples/vite-react/src/styles/tailwind.css": { + "fingerprint": "bcd26043e6850ae901dc9de55c814583cc8d30cfa03e0d5c87e9a288ea08a03e", + "snapshot": { + "examples/vite-react/src/styles/tailwind.css": "8a15901ff92b03b284dc879a456e155235a5ccd4832153847ded0ffd0106933e" + }, + "head": "54b6521f3e37f9cb3ca4cd3812ab3c960eb2168a", + "outcome": "no-change", + "reason": "Removed private narrative and assertion commentary while retaining functional tool directives and the existing runtime behavior. The affected example or test continues to use the documented query, ownership and cancellation workflow; no additional skill task is needed.", + "evidence": [ + "Reviewed the actual diff and current source at examples/vite-react/src/styles/tailwind.css against 54b6521 and the supplied PR base; relevant native task receipts are under /tmp/effect-api-query-audit-2026-10-11/.", + "Final uncached native tasks passed: typecheck (142 files), Effect diagnostics (162 files, zero errors/warnings), Fallow, test (41 files / 474 runtime cases), public API and server-local fixtures with TypeScript 7.0.2 and 5.9.3, native pack with attw/publint, both example builds, docs-check and 46-page docs-build with internal links. Chromium passed all 31 application and 7 documentation checks. Firefox/WebKit browser launches are unavailable locally because their installed binaries are missing; their HTTP-only documentation checks passed. Fresh installed-consumer activation/task evaluation was not performed; compiler and application evidence comes from this workspace." + ] } } } diff --git a/GLOSSARY.md b/GLOSSARY.md index f8618a9..a273b0b 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -77,7 +77,7 @@ An `Error` produced when an HTTP Effect returns a failed `Exit`. It identifies t _Avoid_: Sanitized Cause, configuration error **Configuration error**: -A synchronous error that rejects invalid configuration for a utility tree or its option builders. +An error that rejects invalid utility configuration during preparation or execution. _Avoid_: RPC execution error **Key-generation error**: diff --git a/_artifacts/skill_spec.md b/_artifacts/skill_spec.md index 49e0d25..7daf172 100644 --- a/_artifacts/skill_spec.md +++ b/_artifacts/skill_spec.md @@ -67,3 +67,9 @@ declarations and exports. Retain the ten tasks and existing source mappings. Compiler fixtures verify contextual inference and mutation tracking; 124 in-memory runtime cases cover cache views, retry attempts, defaults, hydration, cancellation, and ownership. +- **Runtime and guidance audit (2026-10-11), based on `54b6521`:** qualify stream + hashing and execution errors, preserve refreshed page totals during overlapping + writes, and reconcile command starts after transport failures. Shorten repeated + guidance while retaining the ten tasks, domain map, and source mappings. All + 474 runtime cases and both public API compiler suites pass, alongside typed + lint, Effect diagnostics, dead-code checks, and documentation checks. diff --git a/apps/docs/public/package-version.js b/apps/docs/public/package-version.js index 0ddc8cf..ab8f1b4 100644 --- a/apps/docs/public/package-version.js +++ b/apps/docs/public/package-version.js @@ -24,7 +24,7 @@ updatePackageVersion(manifest.version) } catch { - // Keep the version embedded by the last documentation build. + return } } diff --git a/apps/docs/src/content/docs/concepts/semantic-keys.md b/apps/docs/src/content/docs/concepts/semantic-keys.md index 0fb2bba..8981218 100644 --- a/apps/docs/src/content/docs/concepts/semantic-keys.md +++ b/apps/docs/src/content/docs/concepts/semantic-keys.md @@ -24,6 +24,8 @@ The default process is: QueryClient owns hashing through its global and prefix defaults. Generated options omit hash fields so fetching, stream publications, and native `getQueryData`/`setQueryData` calls address the same cache entry. Builders reject per-call `queryKeyHashFn` and `queryHash` overrides. +Configure hashing before cache use and keep it stable while entries remain. A stream execution +rejects with `UnsupportedQueryHash` if changed hashing conflicts with a matching cached entry. Key objects cannot contain `__proto__` or `constructor` properties at any depth. This applies to prefixes, Schema output, and custom encoder output. diff --git a/apps/docs/src/content/docs/guides/angular-query.md b/apps/docs/src/content/docs/guides/angular-query.md index 426e581..99281b6 100644 --- a/apps/docs/src/content/docs/guides/angular-query.md +++ b/apps/docs/src/content/docs/guides/angular-query.md @@ -3,15 +3,13 @@ title: Angular Query description: Rebuild generated options from Angular signals and keep client lifetime in the application. --- -Pass generated options through the framework's reactive accessor. Each builder captures its -plain input when called, so create the options inside that accessor whenever the input can change. -The same rule applies to RPC and HTTP queries, metadata views, accumulated streams, and live queries. +Build generated options inside Angular's reactive accessor so input changes produce a new key +and query function. This applies to both factories and every generated query view. The examples target Angular 22.2.1 with Angular Query experimental 5.104.0 and Query Core 5.104.0. -Keep captured values unchanged while their options can execute. See -[Data Normalization](/effect-api-query/concepts/data-normalization/#keep-captured-inputs-unchanged) -for the distinction between immutable keys and caller-owned input. +Keep [captured inputs unchanged](/effect-api-query/concepts/data-normalization/#keep-captured-inputs-unchanged) +while their options can execute. ## Create queries in an injection context @@ -36,9 +34,7 @@ Use `injectInfiniteQuery` with `infiniteOptions` and `injectMutation` with `muta Supply each mutation's input through its execution variables. Angular query results expose signals such as `data()`, `error()`, and `fetchStatus()`. -Use the same accessor for `streamedOptions`, `liveOptions`, and HTTP `metadataOptions`. These -provide accumulated history, the latest emission, and decoded data with status/header snapshots, -respectively. +Use the same accessor for `streamedOptions`, `liveOptions`, and HTTP `metadataOptions`. ## Choose native options carefully @@ -48,16 +44,11 @@ initial-data function that may return `undefined` also produces optional result ## Own the lifecycle -Open streams expose their first value while fetching continues. Changing a generated query's key -replaces the observer's active query. Destroying an Angular component removes its observers; -the last observer of an abort-aware open stream triggers -cancellation. Keep the ready client and runtime alive until local stream finalizers finish. -Other observers of the same query retain their subscription. - -For an owner change, unmount the old tree, cancel and clear its QueryClient, await local cleanup, -then dispose its client resources. Mount the new tree with independently owned resources and a -safe key prefix. See [Switch Cache Owners](/effect-api-query/guides/switch-cache-owners/) and -[Client Lifecycle](/effect-api-query/concepts/client-lifecycle/). +Changing input or destroying a component removes an observer. An abort-aware open stream cancels +when its last observer leaves; other observers keep it active. Keep clients alive until local +finalizers and accepted mutations settle. Follow +[Client Lifecycle](/effect-api-query/concepts/client-lifecycle/) for disposal and +[Switch Cache Owners](/effect-api-query/guides/switch-cache-owners/) for owner replacement. Match Angular Query to the application's Query Core and use a compiler supported by its build tooling. Angular core, common, compiler, and platform-browser should share one release. diff --git a/apps/docs/src/content/docs/guides/cancellation.md b/apps/docs/src/content/docs/guides/cancellation.md index 9b6b2ad..1d9e273 100644 --- a/apps/docs/src/content/docs/guides/cancellation.md +++ b/apps/docs/src/content/docs/guides/cancellation.md @@ -74,7 +74,12 @@ const status = useQuery( input, enabled: start.status !== 'idle', refetchInterval: (query) => - query.state.data == null || query.state.data.state === 'running' ? 100 : false, + query.state.data?.state === 'running' || + query.state.status === 'error' || + query.state.data === undefined || + (query.state.data === null && start.isPending) + ? 100 + : false, }), ) ``` @@ -86,14 +91,17 @@ start.mutate(input) cancel.mutate(input) ``` -Disable start after its first attempt, and disable cancel before start, while cancellation is +Disable start while pending, after success, or when status confirms an accepted command. +After a failed start with no accepted command, retry with the same operation ID; an uncertain +response must not create a second command. Disable cancel before start, while cancellation is pending, or after a terminal status. The [complete command panel](https://github.com/ueberBrot/effect-api-query/blob/main/examples/vite-react/src/components/sections/commands-section.tsx) includes the UI. The cancel handler signals the worker and waits until it has stopped. The cancel mutation's `onSettled` callback then invalidates the generated status key. The active query refetches the -server's status even if the cancel request failed, and polling stops at a terminal state. +server's status even if the cancel request failed. Poll unresolved or failed status reads and +running commands. Stop at a terminal status, or when a settled start has no corresponding command. The application chooses this invalidation policy. A cancelled command returns `{ state: 'cancelled', ... }` successfully. TanStack therefore reports diff --git a/apps/docs/src/content/docs/guides/handle-failures.md b/apps/docs/src/content/docs/guides/handle-failures.md index cb23a96..6bc0b5d 100644 --- a/apps/docs/src/content/docs/guides/handle-failures.md +++ b/apps/docs/src/content/docs/guides/handle-failures.md @@ -22,7 +22,8 @@ Review the Cause for private messages and transport values before logging it. These error classes distinguish failure stages: -- `EffectRpcQueryConfigError` reports invalid factory or builder configuration synchronously. +- `EffectRpcQueryConfigError` reports invalid configuration: synchronously during preparation, + or during stream execution with incompatible QueryClient hashing. - `EffectRpcQueryKeyError` reports synchronous payload construction, encoding, or JSON canonicalization failures. - `EffectRpcQueryError` reports a failed RPC execution and preserves its Effect `Cause`. @@ -45,7 +46,8 @@ The package adds only declaration identity to execution-error metadata. The pres contain upstream request headers, bodies, concrete URLs, responses, or Schema issue values. Review those values before logging or exposing them. The package does not sanitize the Cause. -`EffectHttpApiQueryConfigError` reports invalid factory configuration synchronously. +`EffectHttpApiQueryConfigError` reports invalid configuration with the same preparation and +stream-execution timing as the RPC error. `EffectHttpApiQueryKeyError` reports synchronous query-key preparation failures, before HTTP execution. Mutations encode their request inside the ready client's Effect, so encoding failures become `EffectHttpApiQueryError` instead. Runner rejections and user or TanStack callback failures diff --git a/apps/docs/src/content/docs/guides/query-defaults.md b/apps/docs/src/content/docs/guides/query-defaults.md index b180037..a391d9b 100644 --- a/apps/docs/src/content/docs/guides/query-defaults.md +++ b/apps/docs/src/content/docs/guides/query-defaults.md @@ -97,6 +97,10 @@ options retain their own query function even if a prefix registers another one. per-call `queryHash` and `queryKeyHashFn` because native key-only reads and writes must resolve the same entry. +Streams also reject a default `queryHash` that differs from the configured key hasher's result. +Changing hash defaults while matching entries remain cached can make stream execution reject +with `UnsupportedQueryHash`. Retire the old cache before changing its hash policy. + Use the generated exact `queryKey(input)` or `metadataKey(input)` with native `getQueryData` and `setQueryData`. Their data tags preserve the unselected query data type: selecting a name changes the observer result, while a native write still supplies the complete user. A fresh native write diff --git a/apps/docs/src/content/docs/guides/retry-queries.md b/apps/docs/src/content/docs/guides/retry-queries.md index 326ef52..dadf059 100644 --- a/apps/docs/src/content/docs/guides/retry-queries.md +++ b/apps/docs/src/content/docs/guides/retry-queries.md @@ -145,6 +145,10 @@ For an infinite query, the later page-input mapper executes during fetching; err TanStack unchanged and need an explicit non-retry decision. Mutations encode input inside the ready client's Effect, so their encoding failures use the execution Cause. +Keep QueryClient hashing stable while entries remain cached. A stream that encounters a matching +entry under an incompatible hash policy rejects with configuration code `UnsupportedQueryHash`. +Retries cannot repair that policy; retire the old cache before using a different hash function. + Use [failure handling](/effect-api-query/guides/handle-failures/) for error-stage guards and [the error reference](/effect-api-query/reference/errors/) for stable metadata. See the [complete example](https://github.com/ueberBrot/effect-api-query/blob/main/tests/types/docs-retry.ts). diff --git a/apps/docs/src/content/docs/guides/solid-query.md b/apps/docs/src/content/docs/guides/solid-query.md index 6e00abb..79179a8 100644 --- a/apps/docs/src/content/docs/guides/solid-query.md +++ b/apps/docs/src/content/docs/guides/solid-query.md @@ -4,24 +4,18 @@ description: Rebuild generated options with a Solid accessor and keep client res --- Pass generated options from either factory to Solid Query's native hooks. Configure -`QueryClientProvider` and acquire the ready client before creating the utilities. Keep its `Scope` -and any serviceful runner alive while queries use them. +`QueryClientProvider` and acquire the ready client before creating the utilities. The examples target Solid 1.9.15 with Solid Query 5.104.0 and Query Core 5.104.0. -Match the native Query wrapper to the application's Query Core and use a compiler supported by -the framework's tooling. See the -[compatibility reference](/effect-api-query/reference/compatibility-and-limits/) for adapter capabilities. - ## Rebuild options when input changes Builders capture plain input when called. Build options inside Solid's options accessor so a changed input produces a new key and query function. Passing a builder's result once leaves the query attached to the original input. -Keep captured values unchanged while their options can execute. See -[Data Normalization](/effect-api-query/concepts/data-normalization/#keep-captured-inputs-unchanged) -for the distinction between immutable keys and caller-owned input. +Keep [captured inputs unchanged](/effect-api-query/concepts/data-normalization/#keep-captured-inputs-unchanged) +while their options can execute. In component setup, use `rpc` from your [ready client](/effect-api-query/getting-started/quick-start/) and pass the builder call as an accessor. Here `users.read` accepts an ID and returns a user @@ -58,16 +52,11 @@ An explicit `initialData: undefined` overrides inherited QueryClient initial dat ## Own streams and disposal -Other observers of the same query keep it active. At owner disposal, drain accepted mutations -as well as local stream finalizers before releasing client resources. - -Open streams expose their first value while fetching continues. Changing reactive input replaces -the query; when its last observer leaves, query cancellation closes the stream iterator. Solid -owner disposal also releases observed streams. Keep client resources alive until these queries -finish cancelling, then clear the owned QueryClient and close the client scope. Transport -interruption depends on the ready client. +Changing input or disposing the Solid owner removes an observer. An abort-aware open stream +cancels when its last observer leaves; other observers keep it active. Keep clients alive until +local finalizers and accepted mutations settle. Follow +[Client Lifecycle](/effect-api-query/concepts/client-lifecycle/) for disposal and +[Switch Cache Owners](/effect-api-query/guides/switch-cache-owners/) for owner replacement. +Transport interruption depends on the ready client. -Use `streamedOptions` for an accumulated history and `liveOptions` for its latest emission. HTTP -`metadataOptions` retains decoded data, response status, and header snapshots in a separate cache -entry. Follow [client lifecycle](/effect-api-query/concepts/client-lifecycle/) and -[cancellation](/effect-api-query/guides/cancellation/) when disposing the cache owner. +Match Solid Query to the application's Query Core and use a compiler supported by Solid's tooling. diff --git a/apps/docs/src/content/docs/guides/svelte-query.md b/apps/docs/src/content/docs/guides/svelte-query.md index 443f45e..b5c47d1 100644 --- a/apps/docs/src/content/docs/guides/svelte-query.md +++ b/apps/docs/src/content/docs/guides/svelte-query.md @@ -3,15 +3,13 @@ title: Svelte Query description: Rebuild generated options from Svelte reactive input and keep client lifetime in the application. --- -Pass generated options through the framework's reactive accessor. Each builder captures its -plain input when called, so create the options inside that accessor whenever the input can change. -The same rule applies to RPC and HTTP queries, metadata views, accumulated streams, and live queries. +Build generated options inside Svelte's reactive accessor so input changes produce a new key +and query function. This applies to both factories and every generated query view. The examples target Svelte 5.57.1 with Svelte Query 6.3.0 and Query Core 5.104.0. -Keep captured values unchanged while their options can execute. See -[Data Normalization](/effect-api-query/concepts/data-normalization/#keep-captured-inputs-unchanged) -for the distinction between immutable keys and caller-owned input. +Keep [captured inputs unchanged](/effect-api-query/concepts/data-normalization/#keep-captured-inputs-unchanged) +while their options can execute. ## Create native queries @@ -41,9 +39,7 @@ Use `createInfiniteQuery(() => rpc.users.list.infiniteOptions(...))` for paginat provide the input for each execution. Mutation options can be static when their callbacks and other settings do not depend on reactive state. -Use the same accessor for `streamedOptions`, `liveOptions`, and HTTP `metadataOptions`. These -provide accumulated history, the latest emission, and decoded data with status/header snapshots, -respectively. +Use the same accessor for `streamedOptions`, `liveOptions`, and HTTP `metadataOptions`. ## Choose native options carefully @@ -56,16 +52,11 @@ declaration checking reports an upstream `Box` import error. ## Own the lifecycle -Open streams expose their first value while fetching continues. Changing a generated query's key -replaces the observer's active query. Unmounting a Svelte component removes its observers; -the last observer of an abort-aware open stream triggers -cancellation. Keep the ready client and runtime alive until local stream finalizers finish. -Other observers of the same query retain their subscription. - -For an owner change, unmount the old tree, cancel and clear its QueryClient, await local cleanup, -then dispose its client resources. Mount the new tree with independently owned resources and a -safe key prefix. See [Switch Cache Owners](/effect-api-query/guides/switch-cache-owners/) and -[Client Lifecycle](/effect-api-query/concepts/client-lifecycle/). +Changing input or unmounting removes an observer. An abort-aware open stream cancels when its +last observer leaves; other observers keep it active. Keep clients alive until local finalizers +and accepted mutations settle. Follow +[Client Lifecycle](/effect-api-query/concepts/client-lifecycle/) for disposal and +[Switch Cache Owners](/effect-api-query/guides/switch-cache-owners/) for owner replacement. Match Svelte Query to the application's Query Core and use a compiler supported by its build tooling. Server rendering and hydration remain application integrations. diff --git a/apps/docs/src/content/docs/guides/vue-query.md b/apps/docs/src/content/docs/guides/vue-query.md index 7e56e88..73c8c03 100644 --- a/apps/docs/src/content/docs/guides/vue-query.md +++ b/apps/docs/src/content/docs/guides/vue-query.md @@ -4,24 +4,18 @@ description: Rebuild generated options with Vue computed and keep client resourc --- Pass generated options from either factory to Vue Query's native hooks. Configure `VueQueryPlugin` -and acquire the ready client before creating the utilities. Keep its `Scope` and any serviceful -runner alive while queries use them. +and acquire the ready client before creating the utilities. The examples target Vue 3.5.43 with Vue Query 5.104.0 and Query Core 5.104.0. -Match the native Query wrapper to the application's Query Core and use a compiler supported by -the framework's tooling. See the -[compatibility reference](/effect-api-query/reference/compatibility-and-limits/) for adapter capabilities. - ## Rebuild options when input changes Builders capture plain input when called. Build options inside Vue `computed` so a changed input produces a new key and query function. Passing a builder's result once leaves the query attached to the original input. -Keep captured values unchanged while their options can execute. See -[Data Normalization](/effect-api-query/concepts/data-normalization/#keep-captured-inputs-unchanged) -for the distinction between immutable keys and caller-owned input. +Keep [captured inputs unchanged](/effect-api-query/concepts/data-normalization/#keep-captured-inputs-unchanged) +while their options can execute. In component setup, use the `rpc` tree from your [ready client](/effect-api-query/getting-started/quick-start/). Here `users.read` returns a user with a `name`: @@ -76,16 +70,11 @@ An explicit `initialData: undefined` overrides inherited QueryClient initial dat ## Own streams and disposal -Other observers of the same query keep it active. At owner disposal, drain accepted mutations -as well as local stream finalizers before releasing client resources. - -Open streams expose their first value while fetching continues. Changing reactive input replaces -the query; when its last observer leaves, query cancellation closes the stream iterator. Component -unmount also releases observed streams. Keep client resources alive until these queries finish -cancelling, then clear the owned QueryClient and close the client scope. Transport interruption -depends on the ready client. +Changing input or unmounting removes an observer. An abort-aware open stream cancels when its +last observer leaves; other observers keep it active. Keep clients alive until local finalizers +and accepted mutations settle. Follow +[Client Lifecycle](/effect-api-query/concepts/client-lifecycle/) for disposal and +[Switch Cache Owners](/effect-api-query/guides/switch-cache-owners/) for owner replacement. +Transport interruption depends on the ready client. -Use `streamedOptions` for an accumulated history and `liveOptions` for its latest emission. HTTP -`metadataOptions` retains decoded data, response status, and header snapshots in a separate cache -entry. Follow [client lifecycle](/effect-api-query/concepts/client-lifecycle/) and -[cancellation](/effect-api-query/guides/cancellation/) when disposing the cache owner. +Match Vue Query to the application's Query Core and use a compiler supported by Vue's tooling. diff --git a/apps/docs/src/content/docs/reference/compatibility-and-limits.md b/apps/docs/src/content/docs/reference/compatibility-and-limits.md index 989a44a..2f79274 100644 --- a/apps/docs/src/content/docs/reference/compatibility-and-limits.md +++ b/apps/docs/src/content/docs/reference/compatibility-and-limits.md @@ -5,9 +5,6 @@ description: Supported versions, RPC and HTTP operations, cache identity, and ru ## Supported integrations -See [Performance and Bundling](/effect-api-query/reference/performance/) for construction costs, -construction workloads and adapter versus application bundles. - The package declares one exact Effect peer and Query Core `>=5.103.1 <6`. It is ESM-only and targets ES2022. Use TypeScript with `strict: true`. The repository uses these versions: @@ -77,27 +74,23 @@ not replace a ready Effect client. With Effect 4.0.0, single-element arrays in GET and form-urlencoded payloads can fail server decoding. Array query parameters and JSON array responses retain their usual representation. -## Runtime and cache behavior - -- The caller owns RPC or HTTP client acquisition, `Scope`, transport, Query Client, providers, router, SSR, - hydration, and disposal. -- The package provides no framework adapter, provider, or Node-specific integration helper. -- Query cancellation reaches the runner as an `AbortSignal`; stream cancellation closes the - AsyncIterator. Transport-level interruption depends on the client integration. -- Mutation cancellation is outside the generated API. -- Mutations do not invalidate queries automatically. Applications choose the affected key prefix. -- RPC and HTTP query successes that may be `undefined` become `null` because TanStack Query rejects - `undefined` query data. Live emissions follow this rule; accumulated stream elements and mutation - results keep their original success types. -- Cache identity must be strict JSON. Encoding services, redacted values, and multiple HTTP payload - alternatives require safe custom encoders for endpoints with query support. Binary query input needs - a JSON-safe projection. Multipart mutation-only endpoints require no encoder and reject encoder entries. -- Key encoders are synchronous; asynchronous and Effect-returning encoders are unsupported. -- RPC payload Schemas must be query-stable because key preparation and ready-client execution - construct the payload separately. HTTP builders accept decoded request input without RPC construction. -- Query options retain execution input. Keep captured values unchanged while they can run; immutable - keys do not clone or freeze arbitrary input or decoded data. See - [Data Normalization](/effect-api-query/concepts/data-normalization/#keep-captured-inputs-unchanged). +## Input and cache limits + +Cache identity must be strict JSON. Encoding services, redacted values, and multiple HTTP payload +alternatives require safe [custom encoders](/effect-api-query/guides/custom-key-encoders/) +for endpoints with query support. Binary query input needs a JSON-safe projection. Multipart +mutation-only endpoints require no encoder and reject encoder entries. Encoders are synchronous; +asynchronous and Effect-returning encoders are unsupported. + +RPC payload Schemas must be query-stable because preparation and ready-client execution construct +the payload separately. HTTP builders accept decoded request input without RPC construction. +Keep captured input unchanged while its options can run; frozen keys do not freeze arbitrary +input or decoded data. + +Buffered queries and live emissions normalize successful `undefined` to `null`. Accumulated stream +elements and mutation results preserve it. See [Data Normalization](/effect-api-query/concepts/data-normalization/) +for payload construction, input ownership and query data, and +[Performance and Bundling](/effect-api-query/reference/performance/) for construction costs. ## Server rendering and errors diff --git a/apps/docs/src/content/docs/reference/errors.md b/apps/docs/src/content/docs/reference/errors.md index be0af82..902e89c 100644 --- a/apps/docs/src/content/docs/reference/errors.md +++ b/apps/docs/src/content/docs/reference/errors.md @@ -5,7 +5,8 @@ description: Configuration, key-generation, and execution errors for RPC and HTT ## `EffectRpcQueryConfigError` -Thrown synchronously while configuring the utility tree or an option builder. Its `code` is one of: +Thrown synchronously for invalid factory or builder configuration. Stream execution also rejects +incompatible QueryClient hashing. Its `code` is one of: - `InvalidMaxChunks`: a streamed-query bound is not a positive safe integer. - `InvalidRefetchMode`: a streamed-query mode is not `reset`, `append`, or `replace`. @@ -14,7 +15,8 @@ Thrown synchronously while configuring the utility tree or an option builder. It - `RpcPathCollision` - `MissingKeyEncoder` - `UnknownKeyEncoder` -- `UnsupportedQueryHash`: an option builder received `queryKeyHashFn` or `queryHash`. +- `UnsupportedQueryHash`: a builder received `queryKeyHashFn` or `queryHash`, or stream execution + found a default hash inconsistent with its key hasher or a conflicting cached identity. It can also expose `rpcTag`, `path`, and an underlying `cause`. @@ -46,7 +48,8 @@ Thrown when a live query's stream completes before emitting a value. It exposes ## `EffectHttpApiQueryConfigError` -Thrown synchronously while configuring HTTP utilities. Its `code` is one of: +Thrown synchronously for invalid factory or builder configuration. Stream execution also rejects +incompatible QueryClient hashing. Its `code` is one of: - `InvalidMaxChunks`: a streamed-query bound is not a positive safe integer. - `InvalidRefetchMode`: a streamed-query mode is not `reset`, `append`, or `replace`. @@ -57,7 +60,8 @@ Thrown synchronously while configuring HTTP utilities. Its `code` is one of: - `MissingKeyEncoder` - `UnknownKeyEncoder` - `UnsupportedEndpointMetadata` -- `UnsupportedQueryHash`: an option builder received `queryKeyHashFn` or `queryHash`. +- `UnsupportedQueryHash`: a builder received `queryKeyHashFn` or `queryHash`, or stream execution + found a default hash inconsistent with its key hasher or a conflicting cached identity. It exposes `apiId` and, when available, `groupId`, `endpoint`, `method`, `path`, and an underlying `cause`. diff --git a/apps/docs/src/content/docs/reference/external-http-clients.md b/apps/docs/src/content/docs/reference/external-http-clients.md index 5db177b..cf53ea9 100644 --- a/apps/docs/src/content/docs/reference/external-http-clients.md +++ b/apps/docs/src/content/docs/reference/external-http-clients.md @@ -28,21 +28,10 @@ The external client's generated types add no runtime Schema decoding or validati | Response body | Plain object with `total: '17'` | Schema class with `total: 17` and methods | | Response headers | Raw strings on `response.headers` | Decoded declared header wrapper; raw strings in metadata view | -## Buffered requests and uploads +## Call the declared HTTP contract -The report contract below declares `POST /reports/:id`, a JSON payload, query and header fields, -and a 203 response. Its missing-report response has status 404 and a tagged JSON body. An update -uses `PATCH` and declares a 409 conflict. Read declared failures through `error` and inspect -`response.status`; the external client does not construct an Effect execution-error wrapper. - -Array query parameters use repeated names, including a single-element array. JSON array -responses retain their ordinary JSON representation. This scope does not include GET payloads -or form-urlencoded payloads. - -For `multipart/form-data`, map a binary schema to `Blob` with the generator's `transform` hook. -Use a `bodySerializer` that returns `FormData`. Let Fetch set the multipart boundary; the default -JSON serializer receives HTTP 415 from a multipart-only endpoint. The example upload includes -path/query/header values, a text field, and a file with its filename and contents intact. +Generate `paths` from your server's OpenAPI document. Its declared paths, headers and success/error +responses determine the external client interface; the utility tree's decoded input does not. In the external client module, use the generated `paths` type and create the client: @@ -54,7 +43,7 @@ import type { paths } from './openapi-generated.ts' const external = createClient({ baseUrl: 'https://api.example.test' }) ``` -In a loader or request handler, send encoded values and inspect the buffered result: +For a contract declaring `POST /reports/:id` with these fields, send encoded values: ```ts const report = await external.POST('/reports/{id}', { @@ -71,41 +60,26 @@ if (report.error) { const revision = report.response.headers.get('x-revision') ``` -In the upload handler, serialize the declared fields as `FormData`: +Read declared failures through `error` and inspect `response.status`; the external client does +not construct an Effect execution-error wrapper. Its `Response` keeps status and raw headers. +The native [metadata view](/effect-api-query/reference/http-factory/#buffered-metadata) instead +keeps decoded data and immutable plain status/header snapshots. -```ts -const uploaded = await external.POST('/reports/{id}/files', { - params: { - path: { id: '5' }, - query: { revision: '3' }, - header: { 'x-kind': 'document' }, - }, - body: { title: 'Notes', file: new File(['contents'], 'notes.txt') }, - bodySerializer: (body) => { - const form = new FormData() - form.set('title', body.title) - form.set('file', body.file) - return form - }, -}) -``` - -For raw SSE access, request a stream in that consumer: +Array query parameters use repeated names, including a single-element array. JSON array +responses retain their ordinary representation. GET and form-urlencoded payloads have separate +[upstream limits](/effect-api-query/reference/compatibility-and-limits/#capability-matrix). -```ts -const events = await external.GET('/reports/events/{channel}', { - params: { path: { channel: 'values' } }, - parseAs: 'stream', -}) -``` +## Serialize multipart uploads explicitly -The external client's `Response` retains HTTP status and raw headers after buffering. The native -HTTP metadata view retains decoded data with immutable plain status/header snapshots. These -are distinct representations. +Map a binary schema to `Blob` with the generator's `transform` hook. Supply a `bodySerializer` +that builds `FormData` from the declared text fields and files, preserving filenames. Let Fetch +set the multipart boundary; the default JSON serializer receives HTTP 415 from a multipart-only +endpoint. The ready Effect client instead takes `FormData` directly as its decoded payload; +see [Upload a File](/effect-api-query/guides/http-queries-and-mutations/#upload-a-file). ## SSE boundary -`parseAs: 'stream'` returns a raw `ReadableStream`. It does not parse events, decode +Request `parseAs: 'stream'` for a raw `ReadableStream`. It does not parse events, decode Schema values, or turn a reserved failure event into an error. Default JSON parsing fails on an SSE response. diff --git a/apps/docs/src/content/docs/reference/generated-builders.md b/apps/docs/src/content/docs/reference/generated-builders.md index 04dd5bb..012b62c 100644 --- a/apps/docs/src/content/docs/reference/generated-builders.md +++ b/apps/docs/src/content/docs/reference/generated-builders.md @@ -54,6 +54,8 @@ builders, and skipped options are unsuitable for suspense and prefetch-only hook Configure custom hashing through QueryClient global or prefix defaults. Builders reject per-call `queryKeyHashFn` and `queryHash` with `EffectRpcQueryConfigError` code `UnsupportedQueryHash`. +Keep hash defaults stable while entries remain cached. Stream execution also rejects with that +code when changed hashing conflicts with a matching cached entry. ## Request-local RPC options diff --git a/apps/docs/src/content/docs/reference/http-factory.md b/apps/docs/src/content/docs/reference/http-factory.md index 57cfd79..5f83696 100644 --- a/apps/docs/src/content/docs/reference/http-factory.md +++ b/apps/docs/src/content/docs/reference/http-factory.md @@ -93,6 +93,8 @@ Applicable TanStack options pass through. The package owns the key and function `input` before returning options. QueryClient global and prefix defaults own hashing. Builders reject per-call `queryKeyHashFn` and `queryHash` with `EffectHttpApiQueryConfigError` code `UnsupportedQueryHash`. +Keep hash defaults stable while entries remain cached. Stream execution also rejects with that +code when changed hashing conflicts with a matching cached entry. Input-bearing `queryOptions` accepts a complete request in `input` or `queryOptions({ input: skipToken, ...options })`. Import `skipToken` from `effect-api-query` or diff --git a/docs/adr/0002-let-call-sites-choose-query-or-mutation.md b/docs/adr/0002-let-call-sites-choose-query-or-mutation.md index aa139fb..9698eb9 100644 --- a/docs/adr/0002-let-call-sites-choose-query-or-mutation.md +++ b/docs/adr/0002-let-call-sites-choose-query-or-mutation.md @@ -14,6 +14,5 @@ semantics. Leaves expose no direct execution helper. ## Consequences -An optional branded classification may be added later without restricting the base utility tree. ADR 0018 adds infinite queries to unary leaves. ADR 0020 supersedes this decision's omission of streaming leaves. diff --git a/docs/adr/0004-build-core-shaped-options-and-certify-react-first.md b/docs/adr/0004-build-core-shaped-options-and-certify-react-first.md index 8ef5d93..59fa377 100644 --- a/docs/adr/0004-build-core-shaped-options-and-certify-react-first.md +++ b/docs/adr/0004-build-core-shaped-options-and-certify-react-first.md @@ -20,5 +20,4 @@ Callers may provide every other applicable Query Core option. ## Consequences -Applications own providers, router context, and SSR integration. A future reactive contract may add -a dedicated adapter entry point when core-shaped options are insufficient. +Applications own providers, reactive option rebuilding, router context, and SSR integration. diff --git a/docs/adr/0008-use-flat-prefix-matchable-key-tuples.md b/docs/adr/0008-use-flat-prefix-matchable-key-tuples.md index ec22e90..29ce730 100644 --- a/docs/adr/0008-use-flat-prefix-matchable-key-tuples.md +++ b/docs/adr/0008-use-flat-prefix-matchable-key-tuples.md @@ -8,8 +8,8 @@ Applications need one key hierarchy for exact cache access and broad TanStack pr ## Decision -Generated keys concatenate a required readonly, non-empty JSON prefix, the RPC path, an operation -discriminator, and a canonical query payload when present. Concrete query keys use TanStack's +Generated keys concatenate a required readonly, non-empty JSON prefix, the adapter namespace, +the RPC path, an operation discriminator, and a canonical query payload when present. Concrete query keys use TanStack's `DataTag`; prefix and mutation keys remain ordinary readonly keys. ## Consequences diff --git a/docs/adr/0013-use-a-vite-plus-workspace-with-a-root-package.md b/docs/adr/0013-use-a-vite-plus-workspace-with-a-root-package.md index 6a0a011..8ad37d5 100644 --- a/docs/adr/0013-use-a-vite-plus-workspace-with-a-root-package.md +++ b/docs/adr/0013-use-a-vite-plus-workspace-with-a-root-package.md @@ -10,10 +10,10 @@ initial release path. ## Decision Use a Vite+ pnpm workspace with the publishable `effect-api-query` package permanently at the root. -Keep examples private. Future public packages may live under `packages/`. +Keep examples private. ## Consequences -The workspace accepts Vite+'s beta status. Private examples share RPC contracts and hosting code. +Private examples share RPC contracts and hosting code. ADR 0019 supersedes this decision's original requirement that both browser examples use the standalone server. diff --git a/docs/adr/0021-share-utility-construction-through-private-modules.md b/docs/adr/0021-share-utility-construction-through-private-modules.md index 9e064db..ea37168 100644 --- a/docs/adr/0021-share-utility-construction-through-private-modules.md +++ b/docs/adr/0021-share-utility-construction-through-private-modules.md @@ -2,13 +2,10 @@ Status: Accepted. Clarifies ADR 0003, ADR 0005 through ADR 0007, and ADR 0009. Extended by [ADR 0022](0022-add-buffered-http-utilities-with-separate-key-roots.md) and [ADR 0023](0023-expose-buffered-multipart-http-mutations.md). -One root package and artifact expose the public factories; core and adapters remain private. -The utility-tree module owns atomic validation, canonical keys, options, skipping, and unary -execution through operation descriptions. Shared Query Core types contain no protocol declarations. +Share validation, canonical keys, options, and execution through private core modules. Adapters +own their protocol declarations, request preparation, ready-client invocation, and errors. Shared +Query Core contracts remain independent of protocol declarations. -Adapters own projection, Schema inspection, request preparation, ready-client invocation, request -options, mapped types, and errors. Preparation binds input to its key; later RPC pages only -construct payloads because they reuse the initial key. Streams retain their policy and cleanup. -This keeps cache behavior in shared modules and protocol-specific behavior in adapters without -changing public behavior. Tests exercise the public factories; public adapter registration and -subpath exports stay excluded. +This boundary gives common cache behavior one implementation while preserving each adapter's +input and execution semantics. Consumers use public factories from one root package; internal +modules and adapter registration remain private. diff --git a/e2e-docs/docs.spec.ts b/e2e-docs/docs.spec.ts index 3d02297..265a9a8 100644 --- a/e2e-docs/docs.spec.ts +++ b/e2e-docs/docs.spec.ts @@ -98,7 +98,6 @@ test('uses canonical repository links and serves branded icons', async ({ page, await page.locator('link[rel="shortcut icon"]').getAttribute('href'), await page.locator('.site-title img').getAttribute('src'), ] - // Keep each icon's request and assertions together to identify the failing URL. /* oxlint-disable eslint/no-await-in-loop */ for (const url of iconUrls) { expect(url).toMatch(/^\/effect-api-query\//u) @@ -120,7 +119,6 @@ test('shows the latest published version from the package registry', async ({ pa await page.goto(docsBase) const links = page.locator(`a[href="${packageUrl}"]`) await expect(links.first()).toHaveText('v1.2.3-beta.1') - // Check each DOM locator sequentially while this test owns the page. /* oxlint-disable eslint/no-await-in-loop */ for (const link of await links.all()) { await expect(link).toHaveText('v1.2.3-beta.1') @@ -145,7 +143,6 @@ for (const failure of ['unpublished package', 'network failure', 'invalid versio await request const links = page.locator(`a[href="${packageUrl}"]`) await expect(links.first()).toHaveText(`v${packageManifest.version}`) - // Check each DOM locator sequentially while this test owns the page. /* oxlint-disable eslint/no-await-in-loop */ for (const link of await links.all()) { await expect(link).toHaveText(`v${packageManifest.version}`) @@ -160,7 +157,6 @@ test('serves generated LLM documentation with the public URLs', async ({ request expect(index.ok()).toBe(true) const indexText = await index.text() expect(indexText).toContain('# effect-api-query') - // Fetch and inspect one generated document at a time to attribute broken links. /* oxlint-disable eslint/no-await-in-loop */ for (const filename of ['llms-small.txt', 'llms-full.txt']) { expect(indexText).toContain(`https://ueberbrot.github.io${docsBase}${filename}`) diff --git a/e2e/conditional-query.spec.ts b/e2e/conditional-query.spec.ts index 393ee48..55cd8bf 100644 --- a/e2e/conditional-query.spec.ts +++ b/e2e/conditional-query.spec.ts @@ -24,7 +24,6 @@ for (const [name, application] of [ const lookup = page.getByRole('region', { name: 'Conditional user lookup' }) const select = lookup.getByLabel('User to inspect') await expect(lookup.getByText('Choose a user to start the query.')).toBeVisible() - // A completed unrelated interaction gives a mistakenly active lookup time to reach the server. await page.getByRole('button', { name: 'Read cached directory' }).click() await expect(page.getByText('Cached directory: 12 users')).toBeVisible() expect(lookupRequests).toBe(0) diff --git a/e2e/example-application.ts b/e2e/example-application.ts index 3c0f8e9..5edd9e0 100644 --- a/e2e/example-application.ts +++ b/e2e/example-application.ts @@ -16,7 +16,6 @@ export const viteReactApplication = { url: 'http://127.0.0.1:4173', } satisfies ExampleApplication -/** Establishes deterministic server state through the same controls a user exercises. */ export const prepareExampleApplication = async ( page: Page, application: ExampleApplication, diff --git a/e2e/tanstack-start.spec.ts b/e2e/tanstack-start.spec.ts index 7c7e06e..1a6967b 100644 --- a/e2e/tanstack-start.spec.ts +++ b/e2e/tanstack-start.spec.ts @@ -9,7 +9,6 @@ import { } from './example-application.ts' test('Start rejects an oversized upload before the sender ends the request', async () => { - // Bridge Node's streaming request callbacks; fetch would buffer/end the upload. // oxlint-disable-next-line promise/avoid-new const status = await new Promise((resolve, reject) => { const request = nodeRequest( @@ -38,7 +37,6 @@ test('SSR keeps its RPC destination independent of incoming host headers', async redirectedRequests += 1 response.writeHead(500).end() }) - // Bridge the listener callback so the assertion uses its assigned TCP port. // oxlint-disable-next-line promise/avoid-new await new Promise((resolve) => { destination.listen(0, '127.0.0.1', resolve) @@ -56,7 +54,6 @@ test('SSR keeps its RPC destination independent of incoming host headers', async expect(await response.text()).toContain('Ada Lovelace') } finally { destination.closeAllConnections() - // Wait for Node's close callback before the next test starts another listener. // oxlint-disable-next-line promise/avoid-new await new Promise((resolve, reject) => { destination.close((error) => { diff --git a/examples/contracts/src/client.ts b/examples/contracts/src/client.ts index bdc9d09..66959bf 100644 --- a/examples/contracts/src/client.ts +++ b/examples/contracts/src/client.ts @@ -20,7 +20,6 @@ export interface StartedExampleRpcClient { ) => Promise> } -/** Acquires a caller-owned, scoped flat client for the example HTTP server. */ export const makeExampleRpcClient = Effect.fn('ExampleRpc.makeExampleRpcClient')(function* ( rpcUrl: string, ) { @@ -45,8 +44,6 @@ export const makeExampleRpcClient = Effect.fn('ExampleRpc.makeExampleRpcClient') return forwardClient(tag, payload, options) } - // SAFETY: ExampleRpcClient pairs this checked tag with SlowDiagnosticInput; - // Parameters erases that generic relationship inside the forwarding wrapper. // oxlint-disable-next-line typescript/no-unsafe-type-assertion const slowPayload = payload as SlowDiagnosticInput const suppliedOperationId = slowPayload.operationId @@ -57,7 +54,6 @@ export const makeExampleRpcClient = Effect.fn('ExampleRpc.makeExampleRpcClient') nextSlowOperation += 1 } - // The buffered HTTP protocol cannot carry a caller's interruption after sending a request. return client('diagnostics.slow', { ...slowPayload, operationId }, options).pipe( Effect.onInterrupt(() => client('diagnostics.cancel', { operationId }).pipe( @@ -67,13 +63,10 @@ export const makeExampleRpcClient = Effect.fn('ExampleRpc.makeExampleRpcClient') ) } - // SAFETY: Every tag forwards its original payload/options and result unchanged; - // the slow tag only adds cancellation while preserving the SDK's generic call signature. // oxlint-disable-next-line typescript/no-unsafe-type-assertion return cancellationAwareClient as ExampleRpcClient }) -/** Starts the ready RPC client resource shared by each executable application. */ export const startExampleRpcClient = async (rpcUrl: string): Promise => { const clientScope = await Effect.runPromise(Scope.make()) const runtime = ManagedRuntime.make(Layer.empty) diff --git a/examples/contracts/src/contracts.ts b/examples/contracts/src/contracts.ts index 7a70712..6bc651b 100644 --- a/examples/contracts/src/contracts.ts +++ b/examples/contracts/src/contracts.ts @@ -1,4 +1,3 @@ -// Related Schema classes define one RPC contract family. // oxlint-disable eslint/max-classes-per-file import { Effect, Schema } from 'effect' import { Rpc, RpcGroup, RpcMiddleware } from 'effect/rpc' @@ -23,7 +22,6 @@ export class UserPage extends Schema.Class('UserPage')({ users: Schema.Array(User), }) {} -// Effect Schema.TaggedError is a curried class factory; only instances use new. // oxlint-disable-next-line unicorn/throw-new-error export class DiagnosticFailure extends Schema.TaggedError()( 'DiagnosticFailure', @@ -31,14 +29,12 @@ export class DiagnosticFailure extends Schema.TaggedError()( reason: Schema.Literal('requested-failure'), }, ) { - // Effect Cause.pretty reads Error.message through the base error interface. // fallow-ignore-next-line unused-class-member override get message(): string { return this.reason } } -// Effect Schema.TaggedError is a curried class factory; only instances use new. // oxlint-disable-next-line unicorn/throw-new-error export class ExampleAuthorizationError extends Schema.TaggedError()( 'ExampleAuthorizationError', @@ -99,8 +95,8 @@ const UsersList = Rpc.make('users.list', { const UsersPage = Rpc.make('users.page', { payload: { - cursor: Schema.Int, - pageSize: Schema.Int, + cursor: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), + pageSize: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 100 })), }, success: UserPage, }) @@ -146,7 +142,6 @@ const DiagnosticsCancel = Rpc.make('diagnostics.cancel', { success: Schema.Void, }) -// The Schema value and its decoded type deliberately share the domain name. // oxlint-disable-next-line eslint/no-redeclare export const DiagnosticStatus = Schema.Struct({ interrupted: Schema.Int, diff --git a/examples/server/src/commands.ts b/examples/server/src/commands.ts index af01c16..e26dd4e 100644 --- a/examples/server/src/commands.ts +++ b/examples/server/src/commands.ts @@ -48,7 +48,6 @@ export const makeCommands = Effect.fn('ExampleRpc.makeCommands')(function* () { (effect, command) => effect.pipe(Effect.onInterrupt(() => finish(command, 'cancelled'))), ) - // Reserve the ID and attach its worker atomically with respect to interruption. const getOrCreate = Effect.fn('ExampleRpc.Commands.getOrCreate')(function* ( operationId: string, steps: number, @@ -64,13 +63,11 @@ export const makeCommands = Effect.fn('ExampleRpc.makeCommands')(function* () { completedSteps: 0, totalSteps: steps, }), - // Void represents a cancellation signal without payload. // oxlint-disable-next-line typescript/no-invalid-void-type cancellation: Deferred.makeUnsafe(), done: Deferred.makeUnsafe(), } commands.set(operationId, command) - // A cancel request may arrive before its start request. yield* steps === 0 ? Deferred.succeed(command.done, command.status) : Effect.forkIn(work(command), scope, { uninterruptible: false }) diff --git a/examples/server/src/diagnostic-operations.ts b/examples/server/src/diagnostic-operations.ts index c1a8dfa..c7c9eb1 100644 --- a/examples/server/src/diagnostic-operations.ts +++ b/examples/server/src/diagnostic-operations.ts @@ -1,12 +1,12 @@ import { DiagnosticFailure } from '@effect-api-query/contracts' import type { DiagnosticStatus, SlowDiagnosticInput } from '@effect-api-query/contracts' -import { Deferred, Effect, Ref } from 'effect' +import { Deferred, Effect, HashMap, Option, Ref } from 'effect' const initialStatus = (): DiagnosticStatus => ({ interrupted: 0, started: 0 }) const initialState = () => ({ total: initialStatus(), - operations: new Map(), + operations: HashMap.empty(), }) export const makeDiagnosticOperations = Effect.fn('ExampleRpc.makeDiagnosticOperations')( @@ -17,10 +17,13 @@ export const makeDiagnosticOperations = Effect.fn('ExampleRpc.makeDiagnosticOper const record = Effect.fn('ExampleDiagnostics.record')( (operationId: string, event: keyof DiagnosticStatus) => Ref.update(state, (current) => { - const previous = current.operations.get(operationId) ?? initialStatus() + const previous = Option.getOrElse( + HashMap.get(current.operations, operationId), + initialStatus, + ) return { total: { ...current.total, [event]: current.total[event] + 1 }, - operations: new Map(current.operations).set(operationId, { + operations: HashMap.set(current.operations, operationId, { ...previous, [event]: previous[event] + 1, }), @@ -48,7 +51,6 @@ export const makeDiagnosticOperations = Effect.fn('ExampleRpc.makeDiagnosticOper if (operations === undefined) { return Effect.void } - // Effect.forEach accepts the iterable first and its effectful callback second. // oxlint-disable-next-line unicorn/no-array-method-this-argument return Effect.forEach(operations, (cancellation) => Deferred.succeed(cancellation, undefined), @@ -59,7 +61,6 @@ export const makeDiagnosticOperations = Effect.fn('ExampleRpc.makeDiagnosticOper const reset = Effect.suspend(() => { const cancellations = [...active.values()].flatMap((operations) => [...operations]) active.clear() - // Effect.forEach accepts the iterable first and its effectful callback second. // oxlint-disable-next-line unicorn/no-array-method-this-argument return Effect.forEach(cancellations, (cancellation) => Deferred.succeed(cancellation, undefined), @@ -71,7 +72,6 @@ export const makeDiagnosticOperations = Effect.fn('ExampleRpc.makeDiagnosticOper operationId, }: SlowDiagnosticInput) { const id = operationId ?? 'anonymous' - // Void is the deliberate success channel of this Effect factory. // oxlint-disable-next-line typescript/no-invalid-void-type const cancellation = yield* Deferred.make() const operations = active.get(id) ?? new Set>() @@ -99,7 +99,9 @@ export const makeDiagnosticOperations = Effect.fn('ExampleRpc.makeDiagnosticOper fail: Effect.fail(new DiagnosticFailure({ reason: 'requested-failure' })), operationStatus: Effect.fn('ExampleDiagnostics.operationStatus')((operationId: string) => Ref.get(state).pipe( - Effect.map((current) => current.operations.get(operationId) ?? initialStatus()), + Effect.map((current) => + Option.getOrElse(HashMap.get(current.operations, operationId), initialStatus), + ), ), ), reset, diff --git a/examples/server/src/server.ts b/examples/server/src/server.ts index 5adeed0..a483bcb 100644 --- a/examples/server/src/server.ts +++ b/examples/server/src/server.ts @@ -5,7 +5,6 @@ import { createServer } from 'node:http' import { makeExampleWebHandler } from './web-handler.ts' -// Effect Schema.TaggedError is a curried class factory; only instances use new. // oxlint-disable-next-line unicorn/throw-new-error class ExampleRpcServerError extends Schema.TaggedError()( 'ExampleRpcServerError', @@ -35,7 +34,6 @@ export interface StartExampleRpcServerOptions { readonly port?: number } -/** Starts the standalone example server and closes it with the caller's Scope. */ export const startExampleRpcServer = Effect.fn('ExampleRpc.startExampleRpcServer')( (options: StartExampleRpcServerOptions = {}) => Effect.gen(function* () { @@ -80,7 +78,6 @@ export const startExampleRpcServer = Effect.fn('ExampleRpc.startExampleRpcServer return HttpServerResponse.empty({ status: 404 }) } - // Cancelling an oversized upload must leave its socket open to send the 413. const apiRequest = request.modify({}) Object.defineProperty(apiRequest, 'stream', { value: NodeStream.fromReadable({ @@ -98,7 +95,6 @@ export const startExampleRpcServer = Effect.fn('ExampleRpc.startExampleRpcServer return HttpServerResponse.setHeaders(response, corsHeaders(isHttpPath)) }) yield* server.serve(app) - // Close active sockets before the adapter interrupts their request fibers. yield* Effect.addFinalizer(() => Effect.sync(() => { nodeServer.closeAllConnections() @@ -114,8 +110,5 @@ export const startExampleRpcServer = Effect.fn('ExampleRpc.startExampleRpcServer rpcUrl: `${url}/rpc`, url, } satisfies RunningExampleRpcServer - }).pipe( - // Finish acquisition and cleanup registration before accepting interruption. - Effect.uninterruptible, - ), + }).pipe(Effect.uninterruptible), ) diff --git a/examples/server/src/web-handler.ts b/examples/server/src/web-handler.ts index 2910700..9c2b89e 100644 --- a/examples/server/src/web-handler.ts +++ b/examples/server/src/web-handler.ts @@ -21,14 +21,12 @@ const readRequestBody = async (request: Request): Promise { try { await reader.cancel() - } catch { - // Failed or already closed uploads no longer need cancellation. - } + } catch {} } const cancel = () => { void cancelReader() @@ -37,17 +35,23 @@ const readRequestBody = async (request: Request): Promise maxRequestBodyBytes - length) { cancel() return new Response(null, { status: 413 }) } + const required = length + chunk.value.byteLength + if (required > body.byteLength) { + const capacity = Math.min(maxRequestBodyBytes, Math.max(required, body.byteLength * 2)) + const expanded = new Uint8Array(capacity) + expanded.set(body.subarray(0, length)) + body = expanded + } body.set(chunk.value, length) length += chunk.value.byteLength } @@ -77,7 +81,6 @@ const withRequestBodyLimit = ) } -/** Builds the host-neutral HTTP RPC handler within the caller-owned Scope. */ const makeRpcWebHandler = Effect.fn('ExampleRpc.makeRpcWebHandler')(function* () { const scope = yield* Scope.Scope const rpcContext = yield* Layer.buildWithScope(rpcLayer, scope) @@ -102,12 +105,10 @@ const provideDomain = Effect.fn('ExampleDomain.provide')(function* ( return yield* Effect.provide(effect, context) }) -/** Builds the RPC handler within the caller-owned Scope. */ export const makeExampleRpcWebHandler = Effect.fn('ExampleRpc.makeExampleRpcWebHandler')(() => provideDomain(makeRpcWebHandler()), ) -/** Hosts RPC and HTTP contracts over one application state within the caller-owned Scope. */ const makeHostWebHandler = Effect.fn('ExampleServer.makeHostWebHandler')(function* () { const rpc = yield* makeRpcWebHandler() const httpEffect = yield* HttpRouter.toHttpEffect( diff --git a/examples/server/tests/http-api.test.ts b/examples/server/tests/http-api.test.ts index 2af433c..e1c6d8b 100644 --- a/examples/server/tests/http-api.test.ts +++ b/examples/server/tests/http-api.test.ts @@ -88,7 +88,6 @@ describe('example HTTP API', () => { const server = yield* startExampleRpcServer() const rpc = yield* makeExampleRpcClient(server.rpcUrl) const context = yield* Effect.context() - // This test drives external browser cancellation independently of the surrounding Effect. /* oxlint-disable effecttsgo/abort-controller-in-effect */ // @effect-diagnostics-next-line abortControllerInEffect:off const controller = new AbortController() @@ -231,7 +230,6 @@ describe('example HTTP API', () => { durationMs: 60_000, operationId: 'rpc-panel', }).pipe(Effect.forkChild) - // This test drives external browser cancellation independently of the surrounding Effect. /* oxlint-disable effecttsgo/abort-controller-in-effect */ // @effect-diagnostics-next-line abortControllerInEffect:off const controller = new AbortController() diff --git a/examples/server/tests/server.test.ts b/examples/server/tests/server.test.ts index 5329f87..7cef666 100644 --- a/examples/server/tests/server.test.ts +++ b/examples/server/tests/server.test.ts @@ -21,6 +21,8 @@ import { RpcClient } from 'effect/rpc' import dns from 'node:dns' import { request as nodeRequest } from 'node:http' +import { makeDiagnosticOperations } from '../src/diagnostic-operations.ts' + const waitForStatus = Effect.fn('TestExampleRpc.waitForStatus')(function* ( client: ExampleRpcClient, predicate: (status: DiagnosticStatus) => boolean, @@ -36,12 +38,41 @@ const waitForStatus = Effect.fn('TestExampleRpc.waitForStatus')(function* ( }) describe('example RPC server', () => { + it.effect('keeps captured diagnostic statuses intact across later operations and reset', () => + Effect.gen(function* () { + const diagnostics = yield* makeDiagnosticOperations() + const unused = yield* diagnostics.operationStatus('first') + yield* diagnostics.slow({ operationId: 'first', durationMs: 0 }) + const first = yield* diagnostics.operationStatus('first') + const total = yield* diagnostics.status + yield* diagnostics.slow({ operationId: 'first', durationMs: 0 }) + yield* diagnostics.slow({ operationId: 'second', durationMs: 0 }) + expect(yield* diagnostics.operationStatus('first')).toStrictEqual({ + started: 2, + interrupted: 0, + }) + expect(yield* diagnostics.operationStatus('second')).toStrictEqual({ + started: 1, + interrupted: 0, + }) + expect(yield* diagnostics.status).toStrictEqual({ started: 3, interrupted: 0 }) + yield* diagnostics.reset + expect(yield* diagnostics.operationStatus('first')).toStrictEqual({ + started: 0, + interrupted: 0, + }) + expect(yield* diagnostics.status).toStrictEqual({ started: 0, interrupted: 0 }) + expect(unused).toStrictEqual({ started: 0, interrupted: 0 }) + expect(first).toStrictEqual({ started: 1, interrupted: 0 }) + expect(total).toStrictEqual({ started: 1, interrupted: 0 }) + }), + ) + it.live('rejects oversized chunked bodies before the client finishes sending', () => Effect.gen(function* () { const server = yield* startExampleRpcServer() const status = yield* Effect.promise( async () => - // The callback/event API needs a Promise bridge in the ES2022 target. // oxlint-disable-next-line promise/avoid-new new Promise((resolve, reject) => { const request = nodeRequest(server.rpcUrl, { method: 'POST' }, (response) => { @@ -69,7 +100,6 @@ describe('example RPC server', () => { for (const path of ['//[', 'http://user:password@localhost/rpc']) { const status = yield* Effect.promise( async () => - // The callback/event API needs a Promise bridge in the ES2022 target. // oxlint-disable-next-line promise/avoid-new new Promise((resolve, reject) => { const request = nodeRequest(server.url, { method: 'POST', path }, (response) => { @@ -137,7 +167,6 @@ describe('example RPC server', () => { readonly annotations: unknown readonly message: unknown }[] = [] - // Void is the deliberate success channel of this Effect factory. // oxlint-disable-next-line typescript/no-invalid-void-type const collectingLogger = Logger.make((options) => { const entry = Logger.formatStructured.log(options) @@ -153,8 +182,6 @@ describe('example RPC server', () => { yield* client('users.list', undefined) expect(entries).toContainEqual({ - // Vitest's matcher returns any, widened here because entries are checked by the matcher. - // SAFETY: expect.objectContaining is consumed only as an expected test value. annotations: expect.objectContaining({ 'http.method': 'POST', 'http.status': 200, @@ -383,7 +410,6 @@ describe('example RPC server', () => { spy.mockRestore() }), ) - // Node's DNS callback is controlled here to pause real server startup. /* oxlint-disable promise/prefer-await-to-callbacks */ lookup.mockImplementationOnce( ( diff --git a/examples/server/tests/web-handler.test.ts b/examples/server/tests/web-handler.test.ts index a50623c..998eaec 100644 --- a/examples/server/tests/web-handler.test.ts +++ b/examples/server/tests/web-handler.test.ts @@ -25,7 +25,6 @@ describe('example RPC request body limit', () => { ) expect(failedResponse.status).toBe(400) - // This test drives external browser cancellation independently of the surrounding Effect. /* oxlint-disable effecttsgo/abort-controller-in-effect */ // @effect-diagnostics-next-line abortControllerInEffect:off const controller = new AbortController() @@ -68,6 +67,42 @@ describe('example RPC request body limit', () => { }), ) + it.effect('preserves earlier upload bytes when a producer reuses its chunk buffer', () => + Effect.gen(function* () { + const handler = yield* makeExampleRpcWebHandler() + const bytes = new Uint8Array(1024) + let chunks = 0 + const body = new ReadableStream( + { + pull(controller) { + if (chunks === 5) { + controller.close() + return + } + bytes.fill(32) + if (chunks === 0) { + bytes.set(new TextEncoder().encode('[]')) + } + controller.enqueue(bytes) + chunks += 1 + }, + }, + { highWaterMark: 0 }, + ) + const response = yield* Effect.promise(async () => + handler( + streamingRequest('http://localhost/rpc', { + method: 'POST', + body, + duplex: 'half', + }), + ), + ) + expect(response.status).toBe(200) + yield* Effect.promise(async () => response.arrayBuffer()) + }), + ) + it.effect( 'counts streamed bytes despite missing or misleading lengths and cancels overflow', () => diff --git a/examples/tanstack-start/src/components/action-button.tsx b/examples/tanstack-start/src/components/action-button.tsx index d0e5105..6ffcb6f 100644 --- a/examples/tanstack-start/src/components/action-button.tsx +++ b/examples/tanstack-start/src/components/action-button.tsx @@ -18,7 +18,6 @@ export const ActionButton = ({ ...props }: ActionButtonProps) => (