@craft-ts/mcp 0.8.1 → 0.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/content/best-practices.md +1 -1
- package/content/docs-index.json +18 -18
- package/package.json +2 -2
- package/skills/craft-ts-architecture-tests/SKILL.md +1 -1
- package/skills/craft-ts-routes/SKILL.md +29 -33
- package/skills/craft-ts-routes/references/di-checks.md +34 -80
- package/skills/craft-ts-routes/references/eslint-workflow.md +19 -34
- package/skills/craft-ts-routes/references/pending-and-exceptions.md +4 -10
- package/skills/craft-ts-routes/references/scaling-and-pitfalls.md +34 -87
- package/skills/craft-ts-service-migration/SKILL.md +5 -4
- package/skills/migrate-to-craft-ts/SKILL.md +1 -2
package/content/docs-index.json
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
{
|
|
13
13
|
"path": "/guide/advanced/effect",
|
|
14
14
|
"title": "Using Effect with CraftTS",
|
|
15
|
-
"body": "# Using Effect with CraftTS\n\nEffect belongs in CraftTS when the problem is a **domain program**: composing\nservices, modelling typed failures, controlling resources, or running an\noperation that crosses an I/O boundary. CraftTS remains responsible for\ncomponents, fine-grained rendering, reactive state and resource lifecycles.\n\nThe integration is deliberately a boundary, not a second UI runtime:\n\n```text\nCraft component / template\n ↓\nCraft primitive or generator\n ↓\nEffect<A, E, R>\n ↓\nLayer<R> provided by the Craft injector\n```\n\nIf a value is only local UI state, keep it in Craft. If it is a domain operation\nwith typed errors or services, define it as an Effect and adapt it at the Craft\nboundary.\n\n## The short decision\n\n| Need | Use | Why |\n| -------------------------------------------------- | -------------------------------- | ---------------------------------------------------------------- |\n| Toggle, draft, selection or other local UI value | `state` | Craft owns reactive UI state |\n| Read data with an Effect loader | `queryEffect` | loading, caching, cancellation and exceptions are Craft concerns |\n| Derive a reactive value from a synchronous Effect | `computedEffect` | runs a `SyncOp` Effect in place — a value, not a resource |\n| Expose a synchronous Effect as a callable method | `methodEffect` | the Effect counterpart of `craftMethod` |\n| Run a synchronous Effect in a lower-level position | `syncEffect` | `params`, a `craftMethod`, a `state` updater |\n| Write data with an Effect loader | `mutationEffect` | explicit writes and mutation reactions |\n| Run an explicit command | `asyncProcessEffect` | export, refresh, share action or other non-resource process |\n| Provide Effect services | `provideLayer` | app and route injectors own Layer scope |\n| Select a service from a Craft factory | `effectService` | records the Effect service dependency and selected members |\n| Yield one Effect in a Craft generator | `runEffect` | low-level bridge with typed Craft exceptions |\n| Validate data with Effect Schema | `Schema.toStandardSchemaV1(...)` | uses Craft's schema boundary without coupling core to Effect |\n\nThere is intentionally no `stateEffect`. A reactive value is not made better by\nbeing an Effect. Use `state` for the value, and use Effect for the computation\nthat loads or changes it.\n\n## Install the packages\n\n```shell\nnpm i @craft-ts/core@beta @craft-ts/component@beta @craft-ts/effect@beta\nnpm i effect@rc\n```\n\nKeep the three Craft packages on the same version. `@craft-ts/effect` declares\n`effect` as a peer dependency.\n\n## Run Effect diagnostics\n\nFor an Effect-enabled project, make the diagnostics command part of the normal\nfeedback loop:\n\n```shell\nnpm run effect-check\n# or, from the repository root:\nnode tools/run-effect-tsgo.mjs diagnostics \\\n --project apps/demo-effect/tsconfig.json \\\n --severity error,warning,message\n```\n\nThe repository wrapper prints the resolved project scope, the TypeScript\nprogram file count and source candidates excluded by the project configuration,\nthen asks EffectTS to print per-file progress. It refuses a zero-file program,\nwhich catches a wrong `--project`, an over-broad `exclude`, or an `include` that\nmisses the intended source tree before the check can look green.\n\nReview warnings in two groups: the existing baseline that is accepted for the\nproject, and warnings introduced by the current change. Keep the command's\nscope explicit in CI and link a diagnostic back to this page when handing the\nresult to an agent. `--project=...` and `-p ...` are both supported.\n\n## Install the bridge once\n\nThe bridge teaches Craft's generator driver how to execute a yielded Effect.\nInstall it at application bootstrap:\n\n```typescript\nimport { provideAppInitializer } from '@craft-ts/core';\nimport { installCraftEffectBridge } from '@craft-ts/effect';\n\nexport const appConfig = craftAppConfig({\n providers: [\n provideAppInitializer(() => {\n installCraftEffectBridge();\n }),\n ],\n});\n```\n\nIn a test, install it in `beforeEach` and call the returned disposer in\n`afterEach`. Do not install a new bridge in every loader or component.\n\n## Keep components in Craft\n\nA Craft component still has a generator factory and a typed template. The\ncomponent should call a domain operation, not resolve its repository or start a\nfiber from a click handler:\n\n```typescript\nimport { button, craftComponent, p } from '@craft-ts/component';\nimport { queryEffect } from '@craft-ts/effect';\nimport { loadUserProfile } from './profile-domain';\n\nexport const Profile = craftComponent(\n 'Profile',\n {},\n function* () {\n const profile = yield* queryEffect('profile', {\n params: () => 'user-ada',\n loader: ({ params }) => loadUserProfile(params),\n });\n\n return { profile };\n },\n ({ profile }) => [\n p(function* () {\n const user = yield* profile.value();\n return user?.name ?? 'Loading…';\n }),\n button(\n 'reload',\n {\n *click() {\n yield* profile.reload();\n },\n },\n 'Reload',\n ),\n ],\n);\n```\n\nThe template consumes Craft readers. It does not subscribe to an Effect, call\n`Effect.runPromise`, or convert a Promise into a signal manually.\n\n## Define the domain in Effect\n\nUse Effect for domain contracts and implementations. Tagged errors are values in\nthe `E` channel:\n\n```typescript\nimport { Context, Data, Effect, Layer } from 'effect';\n\nexport class UserNotFound extends Data.TaggedError('UserNotFound')<{\n readonly userId: string;\n}> {}\n\nexport type UserRepository = {\n readonly byId: (userId: string) => Effect.Effect<User, UserNotFound>;\n};\n\nexport class UserRepositoryService extends Context.Service<\n UserRepositoryService,\n UserRepository\n>()('app/UserRepository') {}\n\nexport const UserRepositoryLive = Layer.sync(UserRepositoryService)(() => ({\n byId: (userId) => findUserInDatabase(userId),\n}));\n\nexport const loadUserProfile = Effect.fnUntraced(function* (userId: string) {\n const repository = yield* UserRepositoryService;\n return yield* repository.byId(userId);\n});\n```\n\nThe resulting program carries its success value, its typed failures and its\nrequirements. A Craft component only needs `loadUserProfile`; it does not need\nto know which Layer implements `UserRepositoryService`.\n\n## Choose the right adapter\n\n### `queryEffect`: Effect-backed reads\n\n```typescript\nconst users =\n yield *\n queryEffect('users', {\n params: () => ({ filter: search() }),\n loader: ({ params }) => listUsers(params),\n });\n```\n\nUse it when the result is server or domain state. Craft owns `status`, loading,\nprevious value, cancellation and reloading. The loader returns\n`Effect<Value, Error, Requirements>`.\n\nThe `params` factory and `method` are synchronous. They may read Craft\ndependencies, but must not create an Effect or read an Effect service. The\nloader is the only Effect-aware callback:\n\n```typescript\nconst users =\n yield *\n queryEffect('users', {\n params: function* () {\n const input = yield* searchInput();\n return resolveSearchParams(input);\n },\n loader: ({ params }) => listUsers(params),\n });\n```\n\nThe Effect ESLint rule enforces this boundary. A **declared-synchronous** Effect\nis allowed here through `syncEffect(...)`; for an input that has to suspend, use\na `queryEffect` and feed its settled value to this one.\n\n### `mutationEffect`: Effect-backed writes\n\n```typescript\nconst saveUser =\n yield *\n mutationEffect('saveUser', {\n method: (input: UserInput) => input,\n loader: ({ params }) => saveUserEffect(params),\n });\n```\n\nTrigger it with `yield* saveUser.mutate(input)`. Use the normal Craft\n`insertReactOnMutation` insertion to reload a query or apply an optimistic patch.\nThe mutation `method` only maps its arguments to synchronous params. The\n`loader` is the only Effect-aware callback.\n\n### `asyncProcessEffect`: explicit commands\n\n```typescript\nconst exportUsers =\n yield *\n asyncProcessEffect('exportUsers', {\n method: (filter: Filter) => filter,\n loader: ({ params }) => exportUsersEffect(params),\n });\n\nyield * exportUsers.method(currentFilter);\n```\n\nThe `asyncProcessEffect` method follows the same rule: it returns plain params;\nthe loader owns the asynchronous Effect program.\n\nUse it for an operation with a lifecycle but without a query cache or mutation\nrelationship.\n\n### `methodEffect`: synchronous callable methods\n\nUse `methodEffect` when a domain operation is a synchronous Effect and should be\nexposed as a callable method rather than as a resource:\n\n```typescript\nconst formatPrice = methodEffect('formatPrice', (cents: number) =>\n Effect.gen(function* () {\n yield* SyncOp;\n return `${(cents / 100).toFixed(2)} €`;\n }),\n);\n\nformatPrice(1499); // '14.99 €'\n```\n\nIt is the Effect-aware convenience form of `craftMethod`. The `SyncOp`\nrequirement is mandatory because the method returns immediately. For an Effect\nthat can suspend, use `asyncProcessEffect`, `mutationEffect`, or\n`queryEffect`.\n\n### `computedEffect`: a derived value, not a resource\n\n`computedEffect` runs a **synchronous** Effect in place and hands back a value.\nIt is the adapter for a derivation — a formatted price, a validity flag — where\n`queryEffect` would wrap the answer in a resource with a loading state nothing\ncan ever be in:\n\n```typescript\nimport { computedEffect } from '@craft-ts/effect';\n\nconst totalLabel = computedEffect('totalLabel', function* () {\n const lines = yield* cartLines();\n return cartTotalLabel(lines); // returns the Effect, never runs it\n});\n```\n\nThe factory reads Craft dependencies with `yield*` and **returns** an Effect;\nthe adapter runs it in place against the nearest `provideLayer(...)`. Read the\nresult like any `craftComputed` — no `value`, no `isLoading`, no\n`pendingNode`.\n\nThe Effect it returns must be declared synchronous — `Effect<A, E, SyncOp>` —\nfor the same reason `syncEffect` requires it: a computation is asked for its\nvalue now and cannot suspend to produce it, so one whose `R` does not carry\n`SyncOp` is refused at the call site. See [Run a synchronous member from a\ncomputed](#run-a-synchronous-member-from-a-computed) for what `SyncOp` is and\nthe three mechanisms that check the claim.\n\nUse `syncEffect` instead when the synchronous Effect is not the whole\nderivation — inside a `craftMethod`, a `params`, or a `state` updater.\n\n### `runEffect`: the low-level form\n\nUse `runEffect` when an Effect is yielded directly by a guard, resolver or Craft\nprogram and you need its typed errors to be visible to Craft:\n\n```typescript\nimport { runEffect } from '@craft-ts/effect';\n\nconst user = yield * runEffect(loadUserProfile(userId));\n```\n\nThe adapter is the right choice for most component resources. A bare\n`yield* someEffect` may execute at runtime, but it does not advertise the\nEffect's `E` channel to Craft's route-exception analysis. `runEffect` does.\n\n## Provide services with `Layer`\n\n`provideLayer` attaches a built Effect context to a Craft injector:\n\n```typescript\nexport const appConfig = craftAppConfig({\n providers: [provideLayer(Layer.mergeAll(UserRepositoryLive, SessionLive))],\n});\n```\n\nUse one merged Layer per injector level. A route can add a narrower Layer:\n\n```typescript\nconst routes = craftRoutes('app', [\n {\n path: 'team',\n ...loadCraftComponent(() => import('./team'), [\n provideLayer(TeamContextLive),\n ] as const),\n },\n]);\n```\n\nThe parent context is reused and the child Layer is added for that route. Its\nEffect scope is closed with the route injector.\n\nFor compile-time coverage, compare the program's `Effect.Services<...>` with\nthe values provided by the app and route:\n\n```typescript\ntype Check = EffectRequirementsCheckedDI<\n Effect.Services<typeof loadTeamOverview>,\n | AppProvidedEffectServices\n | ProvidedEffectServicesOfRoute<typeof routes._routes, 'team'>\n>;\ntype CanRunCheck = CanRun<Check>;\n```\n\nSee [route-scoped Layers in the Learn path](/learn-effect/06-layers-routing) for\nthe full `AppProvidedDependencyValuesOf` setup.\n\n## Understand the error mapping\n\nThe bridge keeps Effect's distinctions intact:\n\n| Effect outcome | Craft outcome | Handle it with |\n| -------------------------- | ------------------------------------- | --------------------------------------- |\n| `Effect.succeed(value)` | resource value / generator result | normal rendering |\n| typed `Effect.fail(error)` | Craft exception keyed by `error._tag` | `matchNode`, `catchTag`, route handlers |\n| `Effect.die(defect)` | technical error | error boundary / monitoring |\n| interruption | cancellation | normally no user-facing handler |\n\nUse exhaustive matching for business errors:\n\n```typescript\nmatchNode.exhaustive(resource.exception, '_tag', {\n UserNotFound: () => p('No user was found.'),\n Unauthorized: () => p('Your session has expired.'),\n});\n```\n\nThe error union is only visible to the compiler when the Effect crosses through\n`queryEffect`, `mutationEffect`, `asyncProcessEffect` or `runEffect`.\n\n## Use Effect Schema at data boundaries\n\n`@craft-ts/core` accepts Standard Schema. Effect Schema participates through one\nconversion call:\n\n```typescript\nimport { Schema } from 'effect';\n\nconst UserInput = Schema.toStandardSchemaV1(\n Schema.Struct({\n name: Schema.String,\n email: Schema.String,\n }),\n);\n\nconst saveUser =\n yield *\n mutationEffect('saveUser', {\n methodSchema: UserInput,\n method: (input) => input,\n loader: ({ params }) => saveUserEffect(params),\n });\n```\n\nThis schema interop does not require `@craft-ts/effect`; it follows the Standard\nSchema contract. Use the [schema validation guide](/guide/state/schema-validation#effect-schema)\nfor async decoding and loader result validation.\n\n## Select an Effect service from Craft\n\nMost components should consume a domain operation. A Craft service or adapter\nthat really needs an Effect service can select only the members it uses:\n\n```typescript\nconst { byId } =\n yield * effectService(UserRepositoryService, ({ byId }) => ({ byId }));\n```\n\nThe selection narrows the graph and keeps generic member signatures intact. It\ndoes not replace `Layer`; the service still comes from the nearest\n`provideLayer(...)`.\n\n## Run a synchronous member from a computed\n\n`params`, `craftComputed(...)` and `craftMethod(...)` run on Craft's synchronous\ndriver: they complete on one tick and cannot wait. `Effect<A, E, R>` does not say\nwhether running an Effect will suspend, and a service member makes it worse — a\n`Layer` closes over its dependencies at construction, so a network call and a\npure calculation both surface as `R = never`.\n\nDeclare the difference in `R`, the one channel Effect accumulates:\n\n```typescript\nexport type CartPricingShape = {\n readonly fetchCatalog: (skus: readonly string[]) => Effect.Effect<Catalog>;\n readonly lineTotal: (line: CartLine) => Effect.Effect<number, never, SyncOp>;\n};\n```\n\n`SyncOp` is a phantom requirement: nothing provides it, it costs nothing at\nruntime, and `Effect<A, E, never>` is assignable to `Effect<A, E, SyncOp>` — so\ndeclaring it in the shape is enough, the implementation needs no ceremony. Where\n`R` is inferred (a standalone `Effect.gen` calling nothing already marked), add\n`yield* SyncOp` to the body.\n\nRun it with `syncEffect(...)`, which resolves in place instead of suspending:\n\n```typescript\nconst totalLabel = craftComputed('totalLabel', function* () {\n const cents = yield* syncEffect(cartTotal(yield* lines()));\n return yield* syncEffect(formatPrice(cents));\n});\n```\n\nRequirements other than `SyncOp` travel through untouched — the level in force\nsatisfies them exactly as it does for a loader. The only thing checked at the\ntype level is that `SyncOp` is among them.\n\nThe declaration is a claim, and three independent mechanisms check it: the type\nrefuses an undeclared Effect at the call; `craft-ts/sync-effect-body` reads the\nbody, every branch at once, and rejects one that yields something async; and at\nruntime `syncEffect` goes through `Effect.runSyncExitWith`, which cannot suspend\n— a broken promise throws `CraftEffectNotSynchronous` at the first call rather\nthan freezing the UI.\n\nFull walkthrough: [Declare a synchronous member](/learn-effect/03-effect-domain#declare-a-synchronous-member).\n\n## Testing\n\nUse `mockEffectService` for a focused Layer:\n\n```typescript\nconst repository = mockEffectService(UserRepositoryService, {\n byId: () => Effect.succeed(expectedUser),\n});\n```\n\nCombine it with Craft's register-based tests. The Effect mock covers the Effect\nservice; the Craft register covers every Craft dependency and boundary. An\nunstubbed member fails with `UnstubbedEffectMember` instead of silently returning\nan incomplete value.\n\nSee [testing with Effect](/learn-effect/08-testing) and [browser\nboundaries](/guide/testing/browser-boundaries).\n\n## Package map\n\n| Package | Responsibility |\n| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `@craft-ts/component` | functional Craft components and typed templates |\n| `@craft-ts/core` | Craft primitives, services, routing, forms, testing and the current server-function registry |\n| `@craft-ts/effect` | Effect bridge, `Layer` providers, Effect-aware primitives, service selection, mocks and server execution helpers |\n| `@craft-ts/i18n-effect` | the Effect adapter over an `@craft-ts/i18n` runtime: `provideI18nRuntime`, `translateEffect`, `I18nEffectService` — see [i18n with Effect](/guide/i18n/effect) |\n| `effect` | `Effect`, `Context.Service`, `Layer`, `Schema`, tagged errors and the Effect runtime |\n| `@effect/platform-*` | Effect-native platform adapters; used by the current server-function experiment |\n| `@craft-ts/dev-tools` | generators, migration tools, graph and architecture checks |\n\nInstall only the packages needed by the layer you are building. For example,\nEffect Schema validation can be used with `@craft-ts/core` alone; the bridge and\nEffect-aware resource adapters require `@craft-ts/effect`.\n\n## Server functions: current POC\n\nThe current server-function integration is a **proof of concept**, not a final\nAPI. It currently combines:\n\n- `serverFunction` and `createServerFunctionClient` from `@craft-ts/core`;\n- `executeEffect` and `effectServerMiddleware` from `@craft-ts/effect`;\n- `Effect`/`Layer` on the server;\n- a local HTTP transport and `@effect/platform-node` in the demo.\n\nThe client must import only the server function's type, while the server owns\nthe implementation and server-only Layers. Authentication and authorization\nmust be checked again on the server; a client Layer is never a security boundary.\n\nSee the [server functions POC chapter](/learn-effect/09-server-functions) and\nthe [running demo](https://github.com/craft-ts/craft-ts/tree/main/apps/demo-with-server-function).\nExpect the transport, file conventions, middleware API and production\nintegration to change before this becomes a stable feature.\n\n## Common mistakes\n\n- **Putting every value in Effect:** keep local UI state in `state` and URL state\n in `queryParams`.\n- **Subscribing in a component:** return an Effect from a resource adapter and\n let Craft own loading and cancellation.\n- **Using `Effect.die` for a business case:** use a tagged error in `E` so the UI\n can handle it exhaustively.\n- **Providing a Layer inside a loader:** provide it at app or route scope so its\n lifetime and requirements are visible.\n- **Trusting client context in a server function:** treat it as a claim and\n verify it on the server.\n- **Using a bare `yield* effect` in a route program:** use `runEffect` so Craft\n sees the typed exception union.\n- **Declaring a member `SyncOp` to get it into a computed:** the marker states a\n fact, it does not create one. If the member can suspend, move the work to a\n loader — the runtime will refuse it anyway.\n\n## See also\n\n- [Learn CraftTS with Effect](/learn-effect/)\n- [Which primitive should I use?](/guide/concepts/choose-primitive)\n- [Exceptions as values](/guide/concepts/exceptions)\n- [Program operators](/guide/advanced/program-operators)\n- [Effect Schema](/guide/state/schema-validation#effect-schema)\n- [Effect integration tests](/learn-effect/08-testing)\n"
|
|
15
|
+
"body": "# Using Effect with CraftTS\n\nEffect belongs in CraftTS when the problem is a **domain program**: composing\nservices, modelling typed failures, controlling resources, or running an\noperation that crosses an I/O boundary. CraftTS remains responsible for\ncomponents, fine-grained rendering, reactive state and resource lifecycles.\n\nThe integration is deliberately a boundary, not a second UI runtime:\n\n```text\nCraft component / template\n ↓\nCraft primitive or generator\n ↓\nEffect<A, E, R>\n ↓\nLayer<R> provided by the Craft injector\n```\n\nIf a value is only local UI state, keep it in Craft. If it is a domain operation\nwith typed errors or services, define it as an Effect and adapt it at the Craft\nboundary.\n\n## The short decision\n\n| Need | Use | Why |\n| -------------------------------------------------- | -------------------------------- | ---------------------------------------------------------------- |\n| Toggle, draft, selection or other local UI value | `state` | Craft owns reactive UI state |\n| Read data with an Effect loader | `queryEffect` | loading, caching, cancellation and exceptions are Craft concerns |\n| Derive a reactive value from a synchronous Effect | `computedEffect` | runs a `SyncOp` Effect in place — a value, not a resource |\n| Expose a synchronous Effect as a callable method | `methodEffect` | the Effect counterpart of `craftMethod` |\n| Run a synchronous Effect in a lower-level position | `syncEffect` | `params`, a `craftMethod`, a `state` updater |\n| Guard a state-machine transition with an Effect | `transitionGuardEffect` | a declared-synchronous Effect service decision |\n| Write data with an Effect loader | `mutationEffect` | explicit writes and mutation reactions |\n| Run an explicit command | `asyncProcessEffect` | export, refresh, share action or other non-resource process |\n| Provide Effect services | `provideLayer` | app and route injectors own Layer scope |\n| Select a service from a Craft factory | `effectService` | records the Effect service dependency and selected members |\n| Yield one Effect in a Craft generator | `runEffect` | low-level bridge with typed Craft exceptions |\n| Validate data with Effect Schema | `Schema.toStandardSchemaV1(...)` | uses Craft's schema boundary without coupling core to Effect |\n\nThere is intentionally no `stateEffect`. A reactive value is not made better by\nbeing an Effect. Use `state` for the value, and use Effect for the computation\nthat loads or changes it.\n\n## Install the packages\n\n```shell\nnpm i @craft-ts/core@beta @craft-ts/component@beta @craft-ts/effect@beta\nnpm i effect@rc\n```\n\nKeep the three Craft packages on the same version. `@craft-ts/effect` declares\n`effect` as a peer dependency.\n\n## Run Effect diagnostics\n\nFor an Effect-enabled project, make the diagnostics command part of the normal\nfeedback loop:\n\n```shell\nnpm run effect-check\n# or, from the repository root:\nnode tools/run-effect-tsgo.mjs diagnostics \\\n --project apps/demo-effect/tsconfig.json \\\n --severity error,warning,message\n```\n\nThe repository wrapper prints the resolved project scope, the TypeScript\nprogram file count and source candidates excluded by the project configuration,\nthen asks EffectTS to print per-file progress. It refuses a zero-file program,\nwhich catches a wrong `--project`, an over-broad `exclude`, or an `include` that\nmisses the intended source tree before the check can look green.\n\nReview warnings in two groups: the existing baseline that is accepted for the\nproject, and warnings introduced by the current change. Keep the command's\nscope explicit in CI and link a diagnostic back to this page when handing the\nresult to an agent. `--project=...` and `-p ...` are both supported.\n\n## Install the bridge once\n\nThe bridge teaches Craft's generator driver how to execute a yielded Effect.\nInstall it at application bootstrap:\n\n```typescript\nimport { provideAppInitializer } from '@craft-ts/core';\nimport { installCraftEffectBridge } from '@craft-ts/effect';\n\nexport const appConfig = craftAppConfig({\n providers: [\n provideAppInitializer(() => {\n installCraftEffectBridge();\n }),\n ],\n});\n```\n\nIn a test, install it in `beforeEach` and call the returned disposer in\n`afterEach`. Do not install a new bridge in every loader or component.\n\n## Keep components in Craft\n\nA Craft component still has a generator factory and a typed template. The\ncomponent should call a domain operation, not resolve its repository or start a\nfiber from a click handler:\n\n```typescript\nimport { button, craftComponent, p } from '@craft-ts/component';\nimport { queryEffect } from '@craft-ts/effect';\nimport { loadUserProfile } from './profile-domain';\n\nexport const Profile = craftComponent(\n 'Profile',\n {},\n function* () {\n const profile = yield* queryEffect('profile', {\n params: () => 'user-ada',\n loader: ({ params }) => loadUserProfile(params),\n });\n\n return { profile };\n },\n ({ profile }) => [\n p(function* () {\n const user = yield* profile.value();\n return user?.name ?? 'Loading…';\n }),\n button(\n 'reload',\n {\n *click() {\n yield* profile.reload();\n },\n },\n 'Reload',\n ),\n ],\n);\n```\n\nThe template consumes Craft readers. It does not subscribe to an Effect, call\n`Effect.runPromise`, or convert a Promise into a signal manually.\n\n## Define the domain in Effect\n\nUse Effect for domain contracts and implementations. Tagged errors are values in\nthe `E` channel:\n\n```typescript\nimport { Context, Data, Effect, Layer } from 'effect';\n\nexport class UserNotFound extends Data.TaggedError('UserNotFound')<{\n readonly userId: string;\n}> {}\n\nexport type UserRepository = {\n readonly byId: (userId: string) => Effect.Effect<User, UserNotFound>;\n};\n\nexport class UserRepositoryService extends Context.Service<\n UserRepositoryService,\n UserRepository\n>()('app/UserRepository') {}\n\nexport const UserRepositoryLive = Layer.sync(UserRepositoryService)(() => ({\n byId: (userId) => findUserInDatabase(userId),\n}));\n\nexport const loadUserProfile = Effect.fnUntraced(function* (userId: string) {\n const repository = yield* UserRepositoryService;\n return yield* repository.byId(userId);\n});\n```\n\nThe resulting program carries its success value, its typed failures and its\nrequirements. A Craft component only needs `loadUserProfile`; it does not need\nto know which Layer implements `UserRepositoryService`.\n\n## Choose the right adapter\n\n### `queryEffect`: Effect-backed reads\n\n```typescript\nconst users =\n yield *\n queryEffect('users', {\n params: () => ({ filter: search() }),\n loader: ({ params }) => listUsers(params),\n });\n```\n\nUse it when the result is server or domain state. Craft owns `status`, loading,\nprevious value, cancellation and reloading. The loader returns\n`Effect<Value, Error, Requirements>`.\n\nThe `params` factory and `method` are synchronous. They may read Craft\ndependencies, but must not create an Effect or read an Effect service. The\nloader is the only Effect-aware callback:\n\n```typescript\nconst users =\n yield *\n queryEffect('users', {\n params: function* () {\n const input = yield* searchInput();\n return resolveSearchParams(input);\n },\n loader: ({ params }) => listUsers(params),\n });\n```\n\nThe Effect ESLint rule enforces this boundary. A **declared-synchronous** Effect\nis allowed here through `syncEffect(...)`; for an input that has to suspend, use\na `queryEffect` and feed its settled value to this one.\n\n### `mutationEffect`: Effect-backed writes\n\n```typescript\nconst saveUser =\n yield *\n mutationEffect('saveUser', {\n method: (input: UserInput) => input,\n loader: ({ params }) => saveUserEffect(params),\n });\n```\n\nTrigger it with `yield* saveUser.mutate(input)`. Use the normal Craft\n`insertReactOnMutation` insertion to reload a query or apply an optimistic patch.\nThe mutation `method` only maps its arguments to synchronous params. The\n`loader` is the only Effect-aware callback.\n\n### `asyncProcessEffect`: explicit commands\n\n```typescript\nconst exportUsers =\n yield *\n asyncProcessEffect('exportUsers', {\n method: (filter: Filter) => filter,\n loader: ({ params }) => exportUsersEffect(params),\n });\n\nyield * exportUsers.method(currentFilter);\n```\n\nThe `asyncProcessEffect` method follows the same rule: it returns plain params;\nthe loader owns the asynchronous Effect program.\n\nUse it for an operation with a lifecycle but without a query cache or mutation\nrelationship.\n\n### `methodEffect`: synchronous callable methods\n\nUse `methodEffect` when a domain operation is a synchronous Effect and should be\nexposed as a callable method rather than as a resource:\n\n```typescript\nconst formatPrice = methodEffect('formatPrice', (cents: number) =>\n Effect.gen(function* () {\n yield* SyncOp;\n return `${(cents / 100).toFixed(2)} €`;\n }),\n);\n\nformatPrice(1499); // '14.99 €'\n```\n\nIt is the Effect-aware convenience form of `craftMethod`. The `SyncOp`\nrequirement is mandatory because the method returns immediately. For an Effect\nthat can suspend, use `asyncProcessEffect`, `mutationEffect`, or\n`queryEffect`.\n\n### `computedEffect`: a derived value, not a resource\n\n`computedEffect` runs a **synchronous** Effect in place and hands back a value.\nIt is the adapter for a derivation — a formatted price, a validity flag — where\n`queryEffect` would wrap the answer in a resource with a loading state nothing\ncan ever be in:\n\n```typescript\nimport { computedEffect } from '@craft-ts/effect';\n\nconst totalLabel = computedEffect('totalLabel', function* () {\n const lines = yield* cartLines();\n return cartTotalLabel(lines); // returns the Effect, never runs it\n});\n```\n\nThe factory reads Craft dependencies with `yield*` and **returns** an Effect;\nthe adapter runs it in place against the nearest `provideLayer(...)`. Read the\nresult like any `craftComputed` — no `value`, no `isLoading`, no\n`pendingNode`.\n\nThe Effect it returns must be declared synchronous — `Effect<A, E, SyncOp>` —\nfor the same reason `syncEffect` requires it: a computation is asked for its\nvalue now and cannot suspend to produce it, so one whose `R` does not carry\n`SyncOp` is refused at the call site. See [Run a synchronous member from a\ncomputed](#run-a-synchronous-member-from-a-computed) for what `SyncOp` is and\nthe three mechanisms that check the claim.\n\nUse `syncEffect` instead when the synchronous Effect is not the whole\nderivation — inside a `craftMethod`, a `params`, or a `state` updater.\n\n### `runEffect`: the low-level form\n\nUse `runEffect` when an Effect is yielded directly by a guard, resolver or Craft\nprogram and you need its typed errors to be visible to Craft:\n\n```typescript\nimport { runEffect } from '@craft-ts/effect';\n\nconst user = yield * runEffect(loadUserProfile(userId));\n```\n\nThe adapter is the right choice for most component resources. A bare\n`yield* someEffect` may execute at runtime, but it does not advertise the\nEffect's `E` channel to Craft's route-exception analysis. `runEffect` does.\n\n## Provide services with `Layer`\n\n`provideLayer` attaches a built Effect context to a Craft injector:\n\n```typescript\nexport const appConfig = craftAppConfig({\n providers: [provideLayer(Layer.mergeAll(UserRepositoryLive, SessionLive))],\n});\n```\n\nUse one merged Layer per injector level. A route can add a narrower Layer:\n\n```typescript\nconst routes = craftRoutes('app', [\n {\n path: 'team',\n ...loadCraftComponent(() => import('./team'), [\n provideLayer(TeamContextLive),\n ] as const),\n },\n]);\n```\n\nThe parent context is reused and the child Layer is added for that route. Its\nEffect scope is closed with the route injector.\n\nFor compile-time coverage, compare the program's `Effect.Services<...>` with\nthe values provided by the app and route:\n\n```typescript\ntype Check = EffectRequirementsCheckedDI<\n Effect.Services<typeof loadTeamOverview>,\n | AppProvidedEffectServices\n | ProvidedEffectServicesOfRoute<typeof routes._routes, 'team'>\n>;\ntype CanRunCheck = CanRun<Check>;\n```\n\nSee [route-scoped Layers in the Learn path](/learn-effect/06-layers-routing) for\nthe full `AppProvidedDependencyValuesOf` setup.\n\n## Understand the error mapping\n\nThe bridge keeps Effect's distinctions intact:\n\n| Effect outcome | Craft outcome | Handle it with |\n| -------------------------- | ------------------------------------- | --------------------------------------- |\n| `Effect.succeed(value)` | resource value / generator result | normal rendering |\n| typed `Effect.fail(error)` | Craft exception keyed by `error._tag` | `matchNode`, `catchTag`, route handlers |\n| `Effect.die(defect)` | technical error | error boundary / monitoring |\n| interruption | cancellation | normally no user-facing handler |\n\nUse exhaustive matching for business errors:\n\n```typescript\nmatchNode.exhaustive(resource.exception, '_tag', {\n UserNotFound: () => p('No user was found.'),\n Unauthorized: () => p('Your session has expired.'),\n});\n```\n\nThe error union is only visible to the compiler when the Effect crosses through\n`queryEffect`, `mutationEffect`, `asyncProcessEffect` or `runEffect`.\n\n## Use Effect Schema at data boundaries\n\n`@craft-ts/core` accepts Standard Schema. Effect Schema participates through one\nconversion call:\n\n```typescript\nimport { Schema } from 'effect';\n\nconst UserInput = Schema.toStandardSchemaV1(\n Schema.Struct({\n name: Schema.String,\n email: Schema.String,\n }),\n);\n\nconst saveUser =\n yield *\n mutationEffect('saveUser', {\n methodSchema: UserInput,\n method: (input) => input,\n loader: ({ params }) => saveUserEffect(params),\n });\n```\n\nThis schema interop does not require `@craft-ts/effect`; it follows the Standard\nSchema contract. Use the [schema validation guide](/guide/state/schema-validation#effect-schema)\nfor async decoding and loader result validation.\n\n## Select an Effect service from Craft\n\nMost components should consume a domain operation. A Craft service or adapter\nthat really needs an Effect service can select only the members it uses:\n\n```typescript\nconst { byId } =\n yield * effectService(UserRepositoryService, ({ byId }) => ({ byId }));\n```\n\nThe selection narrows the graph and keeps generic member signatures intact. It\ndoes not replace `Layer`; the service still comes from the nearest\n`provideLayer(...)`.\n\n## Run a synchronous member from a computed\n\n`params`, `craftComputed(...)` and `craftMethod(...)` run on Craft's synchronous\ndriver: they complete on one tick and cannot wait. `Effect<A, E, R>` does not say\nwhether running an Effect will suspend, and a service member makes it worse — a\n`Layer` closes over its dependencies at construction, so a network call and a\npure calculation both surface as `R = never`.\n\nDeclare the difference in `R`, the one channel Effect accumulates:\n\n```typescript\nexport type CartPricingShape = {\n readonly fetchCatalog: (skus: readonly string[]) => Effect.Effect<Catalog>;\n readonly lineTotal: (line: CartLine) => Effect.Effect<number, never, SyncOp>;\n};\n```\n\n`SyncOp` is a phantom requirement: nothing provides it, it costs nothing at\nruntime, and `Effect<A, E, never>` is assignable to `Effect<A, E, SyncOp>` — so\ndeclaring it in the shape is enough, the implementation needs no ceremony. Where\n`R` is inferred (a standalone `Effect.gen` calling nothing already marked), add\n`yield* SyncOp` to the body.\n\nRun it with `syncEffect(...)`, which resolves in place instead of suspending:\n\n```typescript\nconst totalLabel = craftComputed('totalLabel', function* () {\n const cents = yield* syncEffect(cartTotal(yield* lines()));\n return yield* syncEffect(formatPrice(cents));\n});\n```\n\nRequirements other than `SyncOp` travel through untouched — the level in force\nsatisfies them exactly as it does for a loader. The only thing checked at the\ntype level is that `SyncOp` is among them.\n\nThe declaration is a claim, and three independent mechanisms check it: the type\nrefuses an undeclared Effect at the call; `craft-ts/sync-effect-body` reads the\nbody, every branch at once, and rejects one that yields something async; and at\nruntime `syncEffect` goes through `Effect.runSyncExitWith`, which cannot suspend\n— a broken promise throws `CraftEffectNotSynchronous` at the first call rather\nthan freezing the UI.\n\nFull walkthrough: [Declare a synchronous member](/learn-effect/03-effect-domain#declare-a-synchronous-member).\n\n## Testing\n\nUse `mockEffectService` for a focused Layer:\n\n```typescript\nconst repository = mockEffectService(UserRepositoryService, {\n byId: () => Effect.succeed(expectedUser),\n});\n```\n\nCombine it with Craft's register-based tests. The Effect mock covers the Effect\nservice; the Craft register covers every Craft dependency and boundary. An\nunstubbed member fails with `UnstubbedEffectMember` instead of silently returning\nan incomplete value.\n\nSee [testing with Effect](/learn-effect/08-testing) and [browser\nboundaries](/guide/testing/browser-boundaries).\n\n## Package map\n\n| Package | Responsibility |\n| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `@craft-ts/component` | functional Craft components and typed templates |\n| `@craft-ts/core` | Craft primitives, services, routing, forms, testing and the current server-function registry |\n| `@craft-ts/effect` | Effect bridge, `Layer` providers, Effect-aware primitives, service selection, mocks and server execution helpers |\n| `@craft-ts/i18n-effect` | the Effect adapter over an `@craft-ts/i18n` runtime: `provideI18nRuntime`, `translateEffect`, `I18nEffectService` — see [i18n with Effect](/guide/i18n/effect) |\n| `effect` | `Effect`, `Context.Service`, `Layer`, `Schema`, tagged errors and the Effect runtime |\n| `@effect/platform-*` | Effect-native platform adapters; used by the current server-function experiment |\n| `@craft-ts/dev-tools` | generators, migration tools, graph and architecture checks |\n\nInstall only the packages needed by the layer you are building. For example,\nEffect Schema validation can be used with `@craft-ts/core` alone; the bridge and\nEffect-aware resource adapters require `@craft-ts/effect`.\n\n## Server functions: current POC\n\nThe current server-function integration is a **proof of concept**, not a final\nAPI. It currently combines:\n\n- `serverFunction` and `createServerFunctionClient` from `@craft-ts/core`;\n- `executeEffect` and `effectServerMiddleware` from `@craft-ts/effect`;\n- `Effect`/`Layer` on the server;\n- a local HTTP transport and `@effect/platform-node` in the demo.\n\nThe client must import only the server function's type, while the server owns\nthe implementation and server-only Layers. Authentication and authorization\nmust be checked again on the server; a client Layer is never a security boundary.\n\nSee the [server functions POC chapter](/learn-effect/09-server-functions) and\nthe [running demo](https://github.com/craft-ts/craft-ts/tree/main/apps/demo-with-server-function).\nExpect the transport, file conventions, middleware API and production\nintegration to change before this becomes a stable feature.\n\n## Common mistakes\n\n- **Putting every value in Effect:** keep local UI state in `state` and URL state\n in `queryParams`.\n- **Subscribing in a component:** return an Effect from a resource adapter and\n let Craft own loading and cancellation.\n- **Using `Effect.die` for a business case:** use a tagged error in `E` so the UI\n can handle it exhaustively.\n- **Providing a Layer inside a loader:** provide it at app or route scope so its\n lifetime and requirements are visible.\n- **Trusting client context in a server function:** treat it as a claim and\n verify it on the server.\n- **Using a bare `yield* effect` in a route program:** use `runEffect` so Craft\n sees the typed exception union.\n- **Declaring a member `SyncOp` to get it into a computed:** the marker states a\n fact, it does not create one. If the member can suspend, move the work to a\n loader — the runtime will refuse it anyway.\n\n## See also\n\n- [Learn CraftTS with Effect](/learn-effect/)\n- [Which primitive should I use?](/guide/concepts/choose-primitive)\n- [Exceptions as values](/guide/concepts/exceptions)\n- [Program operators](/guide/advanced/program-operators)\n- [Effect Schema](/guide/state/schema-validation#effect-schema)\n- [Effect integration tests](/learn-effect/08-testing)\n"
|
|
16
16
|
},
|
|
17
17
|
{
|
|
18
18
|
"path": "/guide/advanced/observability",
|
|
@@ -167,7 +167,7 @@
|
|
|
167
167
|
{
|
|
168
168
|
"path": "/guide/concepts/insertions",
|
|
169
169
|
"title": "Insertions",
|
|
170
|
-
"body": "# Insertions\n\nAn insertion is a function that receives a primitive's internals and returns\nwhat to expose on it. It is how behaviour gets attached to state — and how it\ngets reused.\n\n**Use one** whenever a primitive needs methods, computed values, or a ready-made\nbehaviour like storage persistence.\nEvery primitive accepts one insertion directly. For several insertions, prefer\nthe typed helper for that primitive; see\n[Typed insertion pipes](/guide/concepts/insertion-pipes). Use `craftPipe` when\nyou need a universal pipe or an explicit nested context.\n\n## The common case\n\nThe library's insertions and the ones you write are the same shape, so they\ncompose in the same pipe:\n\n```typescript\nimport {\n craftUnique,\n insertStoragePersister,\n insertPaginationPlaceholderData,\n insertReactOnMutation,\n insertQueryPipe,\n insertStatePipe,\n query,\n} from '@craft-ts/core';\n\nconst users = yield* query(\n 'users',\n {\n params: pagination,\n identifier: (params) => `${params.page}-${params.pageSize}`,\n loader: function* ({ params }) {\n return yield* ApiService.getDataList(params);\n },\n },\n insertQueryPipe(\n insertStoragePersister(craftUnique({\n storeName: 'app',\n key: 'users',\n })),\n insertPaginationPlaceholderData({ initialValue: [] as User[] }),\n insertReactOnMutation(deleteUser, {\n filter: ({ mutationIdentifier, queryResource }) =>\n !!queryResource.value()?.some((u) => u.id === mutationIdentifier),\n optimisticUpdate: ({ queryResource, mutationIdentifier }) =>\n removeOne({\n entities: queryResource.value(),\n id: mutationIdentifier,\n }),\n }),\n ),\n);\n```\n\nThe typed helper supplies the query context to each member and keeps the\nprimitive call free of context plumbing.\n\n## Deep projections of query values\n\nWhen a query returns an object, use `insertDeepYieldableValue()` when its\nproperties are consumed by the template. The insertion targets `value` only,\nso the primitive keeps its normal API while the resolved object exposes lazy,\nyieldable property readers:\n\n```typescript\nimport {\n insertDeepYieldableValue,\n query,\n} from '@craft-ts/core';\n\nconst productQuery = yield* query(\n 'productDetails',\n {\n method: (id: string) => id,\n loader: ({ params }) => api.getProduct(params),\n },\n insertDeepYieldableValue(),\n);\n\n// In a template: productQuery.value.name\n// In a generator: yield* productQuery.value.name()\n```\n\nFor an identified query, the same insertion is applied to the selected\nresource values:\n\n```typescript\nconst product = productQuery.select(productId);\nif (product) {\n yield* product.value.name();\n}\n```\n\nThis is deliberately different from `insertDeepYieldable()`, which adapts the\nprimitive's root value and is still useful for object-valued `state`.\n\n::: tip A single insertion needs no pipe\nPass it directly:\n\n```typescript\nconst user = yield* query('user', config, insertStoragePersister({ … }));\n```\n\n:::\n\n## Writing your own\n\nThere is nothing special about a library insertion. Yours is a function of the\nsame shape:\n\n```typescript\nconst counter = yield* state(\n 'counter',\n 0,\n insertStatePipe(\n ({ update, set }) => ({\n increment: () => update((c) => c + 1),\n reset: () => set(0),\n }),\n ({ state }) => ({\n isOdd: craftComputed(function* () {\n return (yield* state()) % 2 === 1;\n }),\n }),\n ),\n);\n```\n\nExtract it to a named function the moment two primitives want the same\nbehaviour — that is the whole extension mechanism.\n\nA member can also be a `function*`, in which case it can `yield*` services and\nthose dependencies fold into the enclosing graph. A `craftComputed` or generator\nmethod must yield every reader it does not own — including this primitive's\n`state()` / `update()` / sibling methods on `insertions`.\n\n## What piping guarantees\n\nPiping is strictly equivalent to attaching members one by one:\n\n- members run **left to right**;\n- each member sees the previous members' outputs on `context.insertions`;\n- the outputs are the **intersection** of all members' — on a key conflict, the\n rightmost wins at runtime;\n- tracked dependencies are the **union** of all members', so `ExtractDeps` sees\n every one;\n- each member is **wrapped individually**, so correlation-id tracking and app\n snapshots observe them separately.\n\n## Nesting\n\nPipes nest freely, including inside `insertSelect` — each level re-passes its\nown context:\n\n```typescript\nconst board = yield* state(\n 'board',\n { ui: { activeColor: 'black' }, grid: createInitialGrid() },\n insertStatePipe(\n insertStoragePersister(craftUnique({\n storeName: 'app',\n key: 'board',\n })),\n () => ({ resetAll$: source$<void>('resetAll$') }),\n insertSelect('grid', (gridContext) =>\n craftPipe(\n gridContext,\n ({ state, update }) => ({\n addRow: () => update((grid) => [...grid, createNextRow(grid)]),\n rowIndexes: craftComputed(function* () {\n return (yield* state()).map((_row, index) => index);\n }),\n }),\n insertSelect('row', ({ update }) => ({\n /* … */\n })),\n ),\n ),\n ),\n);\n```\n\n## Pitfalls\n\n**Choosing the wrong pipe.** Use the primitive-specific helper for a direct\ncomposition. `craftPipe` still requires an explicit context and is the right\nchoice for universal or nested compositions.\n\n**Two members exporting the same key.** The rightmost wins silently at runtime.\nName your outputs so they don't collide.\n\n::: details Why the context is explicit\nIt is what makes one universal pipe possible for all five primitives. The outer\n`(context) => …` lambda is contextually typed *by the primitive*, so TypeScript\nknows the exact context shape before it resolves the `craftPipe` call. Inline\nlambdas keep full contextual typing, higher-order factories like\n`insertReactOnMutation(...)` match as before, and the primitive's `Exceptions`\ninference is never degraded.\n:::\n\n## See Also\n\n- [Anatomy of a primitive](/guide/concepts/primitive-anatomy)\n- [Injectable runtime context](/guide/concepts/primitive-anatomy#injectable-runtime-context) —\n recovering `set` / `update` / `patch` from DI, including for WebMCP\n- [Selecting](/guide/state/select) — `insertSelect` and nested insertions\n- [Reacting to mutations](/guide/state/react-on-mutation)\n"
|
|
170
|
+
"body": "# Insertions\n\nAn insertion is a function that receives a primitive's internals and returns\nwhat to expose on it. It is how behaviour gets attached to state — and how it\ngets reused.\n\n**Use one** whenever a primitive needs methods, computed values, or a ready-made\nbehaviour like storage persistence.\nEvery primitive accepts one insertion directly. For several insertions, prefer\nthe typed helper for that primitive; see\n[Typed insertion pipes](/guide/concepts/insertion-pipes). Use `craftPipe` when\nyou need a universal pipe or an explicit nested context.\n\n## The common case\n\nThe library's insertions and the ones you write are the same shape, so they\ncompose in the same pipe:\n\n```typescript\nimport {\n craftUnique,\n insertStoragePersister,\n insertPaginationPlaceholderData,\n insertReactOnMutation,\n insertQueryPipe,\n insertStatePipe,\n query,\n} from '@craft-ts/core';\n\nconst users = yield* query(\n 'users',\n {\n params: pagination,\n identifier: (params) => `${params.page}-${params.pageSize}`,\n loader: function* ({ params }) {\n return yield* ApiService.getDataList(params);\n },\n },\n insertQueryPipe(\n insertStoragePersister(craftUnique({\n storeName: 'app',\n key: 'users',\n })),\n insertPaginationPlaceholderData({ initialValue: [] as User[] }),\n insertReactOnMutation(deleteUser, {\n filter: ({ mutationIdentifier, queryResource }) =>\n !!queryResource.value()?.some((u) => u.id === mutationIdentifier),\n optimisticUpdate: ({ queryResource, mutationIdentifier }) =>\n removeOne({\n entities: queryResource.value(),\n id: mutationIdentifier,\n }),\n }),\n ),\n);\n```\n\nThe typed helper supplies the query context to each member and keeps the\nprimitive call free of context plumbing.\n\n## Deep-yieldable collection items\n\nWhen a component reads several properties from the same `forNode` item, expose\nan explicit deep-yieldable view of the collection. The original collection\nkeeps its existing contract, while the named view gives the item callback lazy\nreaders for the item's properties:\n\nBefore:\n\n```typescript\nforNode(catalog.products, { track: (product) => product.id }, (product) =>\n article([\n span(function* () {\n return (yield* product()).category;\n }),\n span(function* () {\n return (yield* product()).name;\n }),\n ]),\n);\n```\n\nAfter:\n\n```typescript\nimport { insertDeepYieldable, state } from '@craft-ts/core';\n\nconst catalog = yield* state(\n 'catalog',\n { products },\n insertDeepYieldable('products'),\n);\n\nforNode(\n catalog.deepYieldableProducts,\n { track: (product) => product.id },\n (product) => article([span(product.category), span(product.name)]),\n);\n```\n\n`insertDeepYieldable('products')` leaves `catalog.products` unchanged and adds\n`catalog.deepYieldableProducts`. `forNode` applies the item projection only to\nthe named deep reader; ordinary lists keep their existing `yield* item()`\ncontract. The `craft-ts/prefer-deep-yieldable-for-item` ESLint rule warns when\nthe before pattern is repeated in a component template.\n\n## Deep projections of query values\n\nWhen a query returns an object, use `insertDeepYieldableValue()` when its\nproperties are consumed by the template. The insertion targets `value` only,\nso the primitive keeps its normal API while the resolved object exposes lazy,\nyieldable property readers:\n\n```typescript\nimport {\n insertDeepYieldableValue,\n query,\n} from '@craft-ts/core';\n\nconst productQuery = yield* query(\n 'productDetails',\n {\n method: (id: string) => id,\n loader: ({ params }) => api.getProduct(params),\n },\n insertDeepYieldableValue(),\n);\n\n// In a template: productQuery.value.name\n// In a generator: yield* productQuery.value.name()\n```\n\nFor an identified query, the same insertion is applied to the selected\nresource values:\n\n```typescript\nconst product = productQuery.select(productId);\nif (product) {\n yield* product.value.name();\n}\n```\n\nThis is deliberately different from `insertDeepYieldable()`, which adapts the\nprimitive's root value and is still useful for object-valued `state`.\n\n::: tip A single insertion needs no pipe\nPass it directly:\n\n```typescript\nconst user = yield* query('user', config, insertStoragePersister({ … }));\n```\n\n:::\n\n## Writing your own\n\nThere is nothing special about a library insertion. Yours is a function of the\nsame shape:\n\n```typescript\nconst counter = yield* state(\n 'counter',\n 0,\n insertStatePipe(\n ({ update, set }) => ({\n increment: () => update((c) => c + 1),\n reset: () => set(0),\n }),\n ({ state }) => ({\n isOdd: craftComputed(function* () {\n return (yield* state()) % 2 === 1;\n }),\n }),\n ),\n);\n```\n\nExtract it to a named function the moment two primitives want the same\nbehaviour — that is the whole extension mechanism.\n\nA member can also be a `function*`, in which case it can `yield*` services and\nthose dependencies fold into the enclosing graph. A `craftComputed` or generator\nmethod must yield every reader it does not own — including this primitive's\n`state()` / `update()` / sibling methods on `insertions`.\n\n## What piping guarantees\n\nPiping is strictly equivalent to attaching members one by one:\n\n- members run **left to right**;\n- each member sees the previous members' outputs on `context.insertions`;\n- the outputs are the **intersection** of all members' — on a key conflict, the\n rightmost wins at runtime;\n- tracked dependencies are the **union** of all members', so `ExtractDeps` sees\n every one;\n- each member is **wrapped individually**, so correlation-id tracking and app\n snapshots observe them separately.\n\n## Nesting\n\nPipes nest freely, including inside `insertSelect` — each level re-passes its\nown context:\n\n```typescript\nconst board = yield* state(\n 'board',\n { ui: { activeColor: 'black' }, grid: createInitialGrid() },\n insertStatePipe(\n insertStoragePersister(craftUnique({\n storeName: 'app',\n key: 'board',\n })),\n () => ({ resetAll$: source$<void>('resetAll$') }),\n insertSelect('grid', (gridContext) =>\n craftPipe(\n gridContext,\n ({ state, update }) => ({\n addRow: () => update((grid) => [...grid, createNextRow(grid)]),\n rowIndexes: craftComputed(function* () {\n return (yield* state()).map((_row, index) => index);\n }),\n }),\n insertSelect('row', ({ update }) => ({\n /* … */\n })),\n ),\n ),\n ),\n);\n```\n\n## Pitfalls\n\n**Choosing the wrong pipe.** Use the primitive-specific helper for a direct\ncomposition. `craftPipe` still requires an explicit context and is the right\nchoice for universal or nested compositions.\n\n**Two members exporting the same key.** The rightmost wins silently at runtime.\nName your outputs so they don't collide.\n\n::: details Why the context is explicit\nIt is what makes one universal pipe possible for all five primitives. The outer\n`(context) => …` lambda is contextually typed *by the primitive*, so TypeScript\nknows the exact context shape before it resolves the `craftPipe` call. Inline\nlambdas keep full contextual typing, higher-order factories like\n`insertReactOnMutation(...)` match as before, and the primitive's `Exceptions`\ninference is never degraded.\n:::\n\n## See Also\n\n- [Anatomy of a primitive](/guide/concepts/primitive-anatomy)\n- [Injectable runtime context](/guide/concepts/primitive-anatomy#injectable-runtime-context) —\n recovering `set` / `update` / `patch` from DI, including for WebMCP\n- [Selecting](/guide/state/select) — `insertSelect` and nested insertions\n- [Reacting to mutations](/guide/state/react-on-mutation)\n"
|
|
171
171
|
},
|
|
172
172
|
{
|
|
173
173
|
"path": "/guide/concepts/mental-model",
|
|
@@ -182,7 +182,7 @@
|
|
|
182
182
|
{
|
|
183
183
|
"path": "/guide/create-project",
|
|
184
184
|
"title": "Create a CraftTS project",
|
|
185
|
-
"body": "# Create a CraftTS project\n\nUse `craft create` to generate a framework-independent CraftTS application\nwith routing, a typed API example, linting, tests, and the architecture\ncontract already wired up.\n\n## Prerequisites\n\nThe beta toolchain requires Node.js 20.19 or newer. The `craft` executable is\npublished by `@craft-ts/dev-tools`; it is not provided by the unrelated npm\npackage named `craft`.\n\nFor a new project, invoke the executable explicitly through `npx`:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create my-app\n```\n\nThe first `--yes` belongs to `npx`: it accepts the temporary package\ninstallation. The command remains interactive because `craft create` itself\nwas not given `--yes`.\n\nThe command uses the published `beta` package. A checkout of CraftTS can\ncontain a newer creation flow than the version currently published on npm;\ncheck the resolved version with `npm view @craft-ts/dev-tools@beta version` if\nthe prompts shown by your terminal do not match this page.\n\n## Interactive creation\n\nRun the command in a real terminal without `craft create --yes`:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create my-app\n```\n\nThe generator presents menus in this order:\n\n- the application type: frontend-only or full-stack;\n- for a full-stack app, the backend runtime: `promise` or `effect` (EffectTS\n v4 is recommended);\n- the frontend runtime: `plain` or `effect`;\n- type-safe i18n, its locales, and its default locale;\n- the design system;\n- typed CSS;\n- a standalone or Nx workspace;\n- integrations for Codex, Cursor, or Cloud Code.\n\nThe frontend and backend choices are independent. To create a plain browser\napplication whose server functions use Effect v4, choose `plain` for the\nfrontend and `effect` for the backend.\n\nUse `↑`/`↓` to move and `Enter` to confirm a single choice. For locales and\nagent integrations, use `Space` to select or deselect several items, then\n`Enter` to confirm. The project directory remains a text field because it is\na free-form path. If the directory is omitted, the generator asks for it too:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create\n```\n\nThe agent question is a multi-selection list. Use `↑`/`↓` to move, `Space` to\nselect or deselect an integration, and `Enter` to confirm. Codex starts\nselected, preserving the default used by scripted creation. Every starter\nreceives an `AGENTS.md` project guide describing its selected runtimes and\nfeatures; selected integrations additionally receive their editor-specific\nproject instructions and skills.\n\n## Agent-assisted creation\n\nWhen an agent starts a new project, it should first ask what kind of\napplication is being built and what its main features are, without collecting\ndetailed requirements yet. If those features imply a backend, it should\npropose EffectTS v4 for the backend and explain that its typed services, Layers\nand errors fit CraftTS's typed server boundary. The user can confirm that\nstack, reject it, or name another backend; the agent must not add an EffectTS\nbackend after an explicit rejection.\n\nThe agent should create a domain-ready but empty starter with the design\nsystem, typed CSS and strict i18n enabled, and without the explanatory demo\npages:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create my-app \\\n --yes --no-demos --domain app \\\n --frontend-runtime=plain --backend-runtime=effect \\\n --i18n=strict --design-system=basic --typed-css \\\n --references=all --agents=codex\n```\n\nUse `--backend-runtime=none` when the user declines a backend, or the explicit\nrequested backend when it is supported. When no Effect runtime is selected,\nuse `--references=craft-ts` instead of `--references=all`. The `--no-demos`\nstarter still\ncontains the architecture/tooling baseline and a domain boundary, but no\nprefilled product pages or demo content.\n\n### Creating inside an existing Git repository\n\nAn existing `.git` directory makes the destination non-empty. Generate into\nthe current repository with `--force`:\n\n```bash\ncd pet-foster-family\nnpx --yes --package @craft-ts/dev-tools@beta craft create . --force\n```\n\n`--force` only permits writing into a non-empty destination; it does not turn\noff the configuration prompts. Review generated file changes before\ncommitting when the repository already contains application code.\n\nDuring the interactive flow, reference sources are vendored automatically with\n`git subtree`:\n\n- CraftTS sources go into `.references/craft-ts`;\n- EffectTS sources are also vendored when an Effect frontend or backend is\n selected;\n- the sources are committed in the project repository for agents without\n replacing the installed npm packages.\n\nThere is no reference confirmation prompt. The same defaults apply in\nnon-interactive mode: CraftTS is vendored, and EffectTS is vendored whenever an\nEffect frontend or backend is selected. Use `--references=none` to opt out, or\n`--references=craft-ts` / `--references=all` to choose explicitly.\n\nThe vendored repositories are read-only reference material for coding agents\nonly. The generated application always imports the published CraftTS and\nEffectTS npm packages from `package.json`; it does not use `file:` dependencies\nor TypeScript/Vite aliases to the references. Use `npm run update:references`\nto run `git subtree pull` and refresh the recorded source SHA.\n\n## Non-interactive creation\n\nPass `--yes` after `create` to use defaults and disable all prompts. Combine it\nwith explicit options when the generated configuration must be reproducible:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create my-app \\\n --yes --effect=none --agents=codex\n```\n\nFor a minimal plain starter:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create my-app \\\n --yes --effect=none --i18n=none --design-system=none --no-typed-css \\\n --agents=none\n```\n\nTo create a backend-only Effect project and vendor both reference sources:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create my-app \\\n --yes --frontend-runtime=plain --backend-runtime=effect \\\n --references=all\n```\n\nThe main configuration options are:\n\n| Option | Values | Purpose |\n| -------------------- | ------------------------------------- | ------------------------------------------------------------------------- |\n| `--effect` | `v4`, `none` | Select the Effect v4 or plain starter |\n| `--frontend-runtime` | `plain`, `effect` | Choose the frontend runtime |\n| `--backend-runtime` | `none`, `promise`, `effect` | Choose server functions |\n| `--effect-scope` | `none`, `frontend`, `backend`, `both` | Set Effect placement |\n| `--agents` | comma-separated names or `none` | Add editor-specific agent integrations; `AGENTS.md` is always generated |\n| `--i18n` | `strict`, `loose`, `none` | Configure type-safe i18n |\n| `--design-system` | `basic`, `none` | Include the design-system starter |\n| `--typed-css` | flag / `--no-typed-css` | Enable or disable typed CSS |\n| `--workspace` | `standalone`, `nx` | Choose the workspace layout |\n| `--references` | `none`, `craft-ts`, `all` | Include source references (default: CraftTS, plus EffectTS when selected) |\n| `--no-demos` | flag | Generate a domain feature without explanatory demo pages |\n| `--domain` | slug | Name the first domain feature when using `--no-demos` |\n| `--force` | flag | Allow an existing non-empty destination |\n| `--json` | flag | Print the effective configuration as JSON |\n\nUse `craft create --help` to see the complete list:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create --help\n```\n\nFor a domain-first starting point, omit the explanatory home/services/about\npages and name the feature explicitly:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create pet-foster \\\n --yes --no-demos --domain animal --frontend-runtime=effect \\\n --backend-runtime=effect\n```\n\nThe generated feature lives under `src/app/features/animal/`. Add a form to\nthat feature with the existing primitives and its unit/submission test:\n\n```bash\ncraft add form animal\n# advanced nested/schema variant:\ncraft add form animal --advanced\n```\n\n## After generation\n\nThe generator creates a Git repository when the destination is not already\ninside another repository. When references are enabled, it adds them as\ntracked Git subtrees and creates the minimal Git history required by\n`git subtree` when the destination is a new repository. The generated\n`.gitignore` excludes `node_modules/`, build outputs, and test reports.\n\nInstall dependencies and start the generated application:\n\n```bash\ncd my-app\nnpm install\nnpm run dev\n```\n\nThe generated project also includes the following checks:\n\n```bash\nnpm run lint\nnpm run typecheck\nnpm test\nnpm run architecture\nnpm run build\n```\n\n### Generated architecture rules\n\nThe generated `eslint.config.mjs` imports `@craft-ts/dev-tools/eslint-rules`\nand activates the selected `recommended` or `effect` preset. These presets\nenforce the same architecture as the generated project guide:\n\n- remote reads and writes stay directly in query or mutation loaders; they\n must not be hidden in `craftMethod`;\n- resource loaders infer their result instead of using casts such as\n `as PromiseLike<...>`;\n- route-visible filters, search, sort and pagination use route-level\n `queryParams`, not component-local `state`;\n- template event handlers emit one `source$`; query, mutation and state react\n through `on$` instead of chaining imperative method calls.\n\nThe generated agent skill repeats these boundaries so new features follow the\nsame rules. Run `npm run lint` after generation to verify the project.\n\nWith a backend, `src/server/application.ts` owns the registry and runtime\nLayer, while `src/server/node-http.ts` is only the Node stream adapter.\n`server.ts` re-exports both for compatibility. In the backend-only Effect\nprofile, the browser remains plain CraftTS; Effect services, middleware and\nerror projections stay under the server boundary.\n\n## Troubleshooting\n\n### `could not determine executable to run`\n\nIf the error mentions `craft@0.1.0`, `npx` resolved the unrelated public npm\npackage named `craft`. Use the explicit `--package @craft-ts/dev-tools@beta`\nform shown above.\n\nIf `@craft-ts/dev-tools` is already installed in the project, its local binary\ncan also be called with:\n\n```bash\nnpx craft create my-app\n```\n\nThe explicit form is still the safest command when bootstrapping a project\nthat has no `package.json` yet.\n"
|
|
185
|
+
"body": "# Create a CraftTS project\n\nUse `craft create` to generate a framework-independent CraftTS application\nwith routing, a typed API example, linting, tests, and the architecture\ncontract already wired up.\n\n## Prerequisites\n\nThe beta toolchain requires Node.js 20.19 or newer. The `craft` executable is\npublished by `@craft-ts/dev-tools`; it is not provided by the unrelated npm\npackage named `craft`.\n\nFor a new project, invoke the executable explicitly through `npx`:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create my-app\n```\n\nThe first `--yes` belongs to `npx`: it accepts the temporary package\ninstallation. The command remains interactive because `craft create` itself\nwas not given `--yes`.\n\nThe command uses the published `beta` package. A checkout of CraftTS can\ncontain a newer creation flow than the version currently published on npm;\ncheck the resolved version with `npm view @craft-ts/dev-tools@beta version` if\nthe prompts shown by your terminal do not match this page.\n\n## Interactive creation\n\nRun the command in a real terminal without `craft create --yes`:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create my-app\n```\n\nThe generator presents menus in this order:\n\n- the application type: frontend-only or full-stack;\n- for a full-stack app, the backend runtime: `promise` or `effect` (EffectTS\n v4 is recommended);\n- the frontend runtime: `plain` or `effect`;\n- type-safe i18n, its locales, and its default locale;\n- the design system;\n- typed CSS;\n- a standalone or Nx workspace;\n- integrations for Codex, Cursor, or Claude Code.\n\nThe frontend and backend choices are independent. To create a plain browser\napplication whose server functions use Effect v4, choose `plain` for the\nfrontend and `effect` for the backend.\n\nUse `↑`/`↓` to move and `Enter` to confirm a single choice. For locales and\nagent integrations, use `Space` to select or deselect several items, then\n`Enter` to confirm. The project directory remains a text field because it is\na free-form path. If the directory is omitted, the generator asks for it too:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create\n```\n\nThe agent question is a multi-selection list. Use `↑`/`↓` to move, `Space` to\nselect or deselect an integration, and `Enter` to confirm. Codex starts\nselected, preserving the default used by scripted creation. Every starter\nreceives an `AGENTS.md` project guide describing its selected runtimes and\nfeatures; selected integrations additionally receive their editor-specific\nproject instructions and skills. Claude Code receives `CLAUDE.md` and skills\nunder `.claude/skills/`.\n\n## Agent-assisted creation\n\nWhen an agent starts a new project, it should first ask what kind of\napplication is being built and what its main features are, without collecting\ndetailed requirements yet. If those features imply a backend, it should\npropose EffectTS v4 for the backend and explain that its typed services, Layers\nand errors fit CraftTS's typed server boundary. The user can confirm that\nstack, reject it, or name another backend; the agent must not add an EffectTS\nbackend after an explicit rejection.\n\nThe agent should create a domain-ready but empty starter with the design\nsystem, typed CSS and strict i18n enabled, and without the explanatory demo\npages:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create my-app \\\n --yes --no-demos --domain app \\\n --frontend-runtime=plain --backend-runtime=effect \\\n --i18n=strict --design-system=basic --typed-css \\\n --references=all --agents=codex\n```\n\nUse `--backend-runtime=none` when the user declines a backend, or the explicit\nrequested backend when it is supported. When no Effect runtime is selected,\nuse `--references=craft-ts` instead of `--references=all`. The `--no-demos`\nstarter still\ncontains the architecture/tooling baseline and a domain boundary, but no\nprefilled product pages or demo content.\n\n### Creating inside an existing Git repository\n\nAn existing `.git` directory makes the destination non-empty. Generate into\nthe current repository with `--force`:\n\n```bash\ncd pet-foster-family\nnpx --yes --package @craft-ts/dev-tools@beta craft create . --force\n```\n\n`--force` only permits writing into a non-empty destination; it does not turn\noff the configuration prompts. Review generated file changes before\ncommitting when the repository already contains application code.\n\nDuring the interactive flow, reference sources are vendored automatically with\n`git subtree`:\n\n- CraftTS sources go into `.references/craft-ts`;\n- EffectTS sources are also vendored when an Effect frontend or backend is\n selected;\n- the sources are committed in the project repository for agents without\n replacing the installed npm packages.\n\nThere is no reference confirmation prompt. The same defaults apply in\nnon-interactive mode: CraftTS is vendored, and EffectTS is vendored whenever an\nEffect frontend or backend is selected. Use `--references=none` to opt out, or\n`--references=craft-ts` / `--references=all` to choose explicitly.\n\nThe vendored repositories are read-only reference material for coding agents\nonly. The generated application always imports the published CraftTS and\nEffectTS npm packages from `package.json`; it does not use `file:` dependencies\nor TypeScript/Vite aliases to the references. Use `npm run update:references`\nto run `git subtree pull` and refresh the recorded source SHA.\n\n## Non-interactive creation\n\nPass `--yes` after `create` to use defaults and disable all prompts. Combine it\nwith explicit options when the generated configuration must be reproducible:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create my-app \\\n --yes --effect=none --agents=codex\n```\n\nFor a minimal plain starter:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create my-app \\\n --yes --effect=none --i18n=none --design-system=none --no-typed-css \\\n --agents=none\n```\n\nTo create a backend-only Effect project and vendor both reference sources:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create my-app \\\n --yes --frontend-runtime=plain --backend-runtime=effect \\\n --references=all\n```\n\nThe main configuration options are:\n\n| Option | Values | Purpose |\n| -------------------- | ------------------------------------- | ------------------------------------------------------------------------- |\n| `--effect` | `v4`, `none` | Select the Effect v4 or plain starter |\n| `--frontend-runtime` | `plain`, `effect` | Choose the frontend runtime |\n| `--backend-runtime` | `none`, `promise`, `effect` | Choose server functions |\n| `--effect-scope` | `none`, `frontend`, `backend`, `both` | Set Effect placement |\n| `--agents` | comma-separated names or `none` | Add editor-specific agent integrations; `AGENTS.md` is always generated |\n| `--i18n` | `strict`, `loose`, `none` | Configure type-safe i18n |\n| `--design-system` | `basic`, `none` | Include the design-system starter |\n| `--typed-css` | flag / `--no-typed-css` | Enable or disable typed CSS |\n| `--workspace` | `standalone`, `nx` | Choose the workspace layout |\n| `--references` | `none`, `craft-ts`, `all` | Include source references (default: CraftTS, plus EffectTS when selected) |\n| `--no-demos` | flag | Generate a domain feature without explanatory demo pages |\n| `--domain` | slug | Name the first domain feature when using `--no-demos` |\n| `--force` | flag | Allow an existing non-empty destination |\n| `--json` | flag | Print the effective configuration as JSON |\n\nUse `craft create --help` to see the complete list:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create --help\n```\n\nFor a domain-first starting point, omit the explanatory home/services/about\npages and name the feature explicitly:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create pet-foster \\\n --yes --no-demos --domain animal --frontend-runtime=effect \\\n --backend-runtime=effect\n```\n\nThe generated feature lives under `src/app/features/animal/`. Add a form to\nthat feature with the existing primitives and its unit/submission test:\n\n```bash\ncraft add form animal\n# advanced nested/schema variant:\ncraft add form animal --advanced\n```\n\n## After generation\n\nThe generator creates a Git repository when the destination is not already\ninside another repository. When references are enabled, it adds them as\ntracked Git subtrees and creates the minimal Git history required by\n`git subtree` when the destination is a new repository. The generated\n`.gitignore` excludes `node_modules/`, build outputs, and test reports.\n\nInstall dependencies and start the generated application:\n\n```bash\ncd my-app\nnpm install\nnpm run dev\n```\n\nThe generated project also includes the following checks:\n\n```bash\nnpm run lint\nnpm run typecheck\nnpm test\nnpm run architecture\nnpm run build\n```\n\n### Generated architecture rules\n\nThe generated `eslint.config.mjs` imports `@craft-ts/dev-tools/eslint-rules`\nand activates the selected `recommended` or `effect` preset. These presets\nenforce the same architecture as the generated project guide:\n\n- remote reads and writes stay directly in query or mutation loaders; they\n must not be hidden in `craftMethod`;\n- `query`, `mutation` and `asyncProcess` loaders are generator functions;\n express asynchronous work with `yield*`, never with `async` or a native\n `Promise` return;\n- resource loaders infer their result instead of using casts such as\n `as PromiseLike<...>`;\n- route-visible filters, search, sort and pagination use route-level\n `queryParams`, not component-local `state`;\n- template event handlers emit one `source$`; query, mutation and state react\n through `on$` instead of chaining imperative method calls.\n\nThe generated agent skill repeats these boundaries so new features follow the\nsame rules. Run `npm run lint` after generation to verify the project.\n\nWith a backend, `src/server/application.ts` owns the registry and runtime\nLayer, while `src/server/node-http.ts` is only the Node stream adapter.\n`server.ts` re-exports both for compatibility. In the backend-only Effect\nprofile, the browser remains plain CraftTS; Effect services, middleware and\nerror projections stay under the server boundary.\n\n## Troubleshooting\n\n### `could not determine executable to run`\n\nIf the error mentions `craft@0.1.0`, `npx` resolved the unrelated public npm\npackage named `craft`. Use the explicit `--package @craft-ts/dev-tools@beta`\nform shown above.\n\nIf `@craft-ts/dev-tools` is already installed in the project, its local binary\ncan also be called with:\n\n```bash\nnpx craft create my-app\n```\n\nThe explicit form is still the safest command when bootstrapping a project\nthat has no `package.json` yet.\n"
|
|
186
186
|
},
|
|
187
187
|
{
|
|
188
188
|
"path": "/guide/deployment",
|
|
@@ -192,7 +192,7 @@
|
|
|
192
192
|
{
|
|
193
193
|
"path": "/guide/deployment/alchemy",
|
|
194
194
|
"title": "Alchemy provider",
|
|
195
|
-
"body": "# Alchemy provider\n\n::: warning Experimental —
|
|
195
|
+
"body": "# Alchemy provider\n\n::: warning Experimental — validate in a non-production account first\nThis provider is the least validated part of the deployment tooling. The\npresets, the plan, the credential checks and the refusal paths are covered by\ntests through a runtime port, so what CraftTS _decides_ is verified.\n\nWhat is **not** verified is the last hop: the adapter that invokes Alchemy's\ncurrent CLI has never run against a real Cloudflare or AWS account from this\nrepository. Treat your first deployment as the validation of that adapter —\nrun `deploy preview` first and read the plan.\n:::\n\n`@craft-ts/deploy-alchemy` deploys a CraftTS manifest to Cloudflare or AWS\nthrough [Alchemy](https://alchemy.run). It is an optional package: Alchemy\nnever appears in the dependencies of a CraftTS application, and a project that\npublishes a static artefact somewhere else never installs it.\n\nAlchemy is a **provider of infrastructure**, not a runtime. It can create the\nresources, the bindings, the permissions and the state, where a publication\nprovider only uploads an artefact. Both read the same manifest.\n\n## Install\n\n```bash\nnpm install --save-dev @craft-ts/deploy-alchemy alchemy\n```\n\nThe CLI resolves `@craft-ts/deploy-<name>` from the project being deployed, so\nnothing else has to be configured. A provider living elsewhere is pointed at\nwith `--provider-module`.\n\n## Credentials\n\nCredentials are read from the environment. The tooling checks that they are\nset, never reads their value beyond that, and never writes one to disk.\n\n| Platform | Variables |\n| ------------ | ------------------------------------------------------------------------------------------------------- |\n| `cloudflare` | `CLOUDFLARE_API_TOKEN` (or `CLOUDFLARE_API_KEY`), `CLOUDFLARE_ACCOUNT_ID` (or `ALCHEMY_PROFILE`) |\n| `aws` | `AWS_ACCESS_KEY_ID` (or `AWS_PROFILE`, `AWS_ROLE_ARN`), `AWS_REGION` (or `AWS_DEFAULT_REGION`) |\n\nAlchemy 2 uses its provider state store and profile configuration. The adapter\ndoes not require the legacy `ALCHEMY_PASSWORD` variable.\n\n## State and stages\n\nAlchemy reconciles against a recorded state, which is what lets a preview tell\na creation from an update. Every resource name carries the application **and**\nthe stage:\n\n```text\ndemo-production-worker\ndemo-preview-42-worker\n```\n\nA stage is passed with `--stage`; it defaults to the `environment` of the\nmanifest. Two stages never share a resource, so a preview deployment cannot\noverwrite production.\n\n## Preview before mutating\n\n```bash\nnpx craft-ts deploy preview --provider alchemy --stage staging\n```\n\n```text\nplan: alchemy → stage staging (2 resource(s))\n create cloudflare:KV.Namespace demo-staging-sessions\n binding: SESSIONS\n update cloudflare:Worker demo-staging-worker\n entrypoint: dist/apps/demo/worker.js\n assets: dist/apps/demo\n note: Alchemy 2.0.0-beta.76, stage `staging`.\nPreview only: nothing was created, updated or deleted.\n```\n\nThe preview opens Alchemy in its read phase, so it resolves the recorded state\nand creates nothing. A resource Alchemy still records that the manifest no\nlonger declares appears as `delete`: hiding it would understate the change.\n\n## Deploy\n\n```bash\nnpx craft-ts deploy --provider alchemy --stage staging --yes\n```\n\n`deploy` runs, in order: the manifest check, the provider check (credentials,\nartefacts, presets), then the same preview. It refuses to apply until `--yes`\napproves the plan, and reports `CRAFT_DEPLOY_DEPLOY_NOT_CONFIRMED` otherwise.\n\nOutputs are printed as `<resource>.<key>`, and the first `url` output becomes\nthe deployment URL:\n\n```text\nurl: https://demo-staging.workers.dev\noutput demo-staging-sessions.id: 5f1c…\noutput demo-staging-worker.url: https://demo-staging.workers.dev\nDeployed to stage `staging` with alchemy.\n```\n\nUse `--json` to get `{ applied, plan, result, diagnostics }` for a CI step.\n\n## What each manifest becomes\n\n| Manifest | Resources |\n| ------------------------ | ----------------------------------------------------- |\n| `static` on `cloudflare` | `Website.StaticSite` |\n| `worker` on `cloudflare` | binding resources, then `Worker` |\n| `static` on `aws` | `Website.StaticSite` |\n| `lambda` on `aws` | `Lambda.Function` with its built-in Function URL |\n| `node` on `aws` | refused; use the Docker provider or an image preset |\n\nBindings map to the resource their `type` names — `kv`, `r2`, `d1`, `queue`,\n`durable_object`. A binding typed `secret` is never created: its value must\nalready exist in the Alchemy state or the environment, and the plan says so\ninstead of carrying it. Any other type is refused with\n`CRAFT_DEPLOY_PROVIDER_UNSUPPORTED_RESOURCE` rather than silently dropped.\n\nThe Function URL keeps the `{ id, input, context }` protocol of a\nserver-function, so the same function behaves as it does locally.\n\n## Rollback\n\nAlchemy has no \"undo\": a rollback is a deployment of the previous artefact.\n\n1. Check out the commit whose artefact was healthy, or restore its\n `dist/<app>/craft-deployment-manifest.json`.\n2. Rebuild it: the manifest is byte-identical for a given input, so a rebuild\n of the same commit produces the same declaration.\n3. `npx craft-ts deploy preview --provider alchemy --stage <stage>` and read\n the plan: a rollback shows `update` on the resources that moved forward.\n4. Apply it with `--yes`.\n\nTwo things do not roll back on their own and have to be handled explicitly: a\nresource deleted by a finalize is recreated empty, and a stateful binding such\nas a KV namespace or a bucket keeps the data written by the newer version.\nRoll a stateful change back through the data, not through the deployment.\n\n## What stays in CraftTS, what is delegated\n\nCraftTS owns the manifest, the checks, the resolved artefact and the plan\nshape. It decides _what_ has to exist, and it refuses to deploy a manifest that\ndoes not pass `craft-ts check`.\n\nAlchemy owns the resources, the state, the credentials handling and the\nreconciliation. It decides _how_ what CraftTS declared comes to exist.\n\nThe adapter generates a temporary Alchemy 2 stack and invokes the installed\nAlchemy CLI. `ALCHEMY_RESOURCE_EXPORTS` maps each planned resource type to the\ncurrent module and nested export used in that generated stack.\n\n## Limits\n\n- The adapter over the Alchemy API has never run against a live account, as\n stated at the top of this page.\n- The Fargate fallback runs the artefact as a container: the image build stays\n outside CraftTS.\n- Alchemy has no preset here for the platforms a publication provider already\n covers (`vercel`, `netlify`, `firebase`, `github-pages`).\n"
|
|
196
196
|
},
|
|
197
197
|
{
|
|
198
198
|
"path": "/guide/deployment/diagnostics",
|
|
@@ -322,12 +322,12 @@
|
|
|
322
322
|
{
|
|
323
323
|
"path": "/guide/routing/eslint-rules",
|
|
324
324
|
"title": "ESLint rules",
|
|
325
|
-
"body": "# ESLint rules\n\nThe rule set is not decoration: several checks in this documentation only work\nbecause a rule generated or maintained the code they read. Others enforce the\narchitecture — no hidden runtime dependencies or direct transport calls — and most of them\n**autofix**.\n\n**Install them once** when you set up routing and type-safe DI.\n**Then lean on the quick fixes** rather than writing the boilerplate by hand.\n\n::: warning An ESLint error is not a compile error\nA missing autofix does not break the build. If you skip the quick fix after\nchanging a component's DI shape, `main.ts` keeps reading a stale `GenDeps_*` and\ncan miss a real DI error. Run `eslint --fix` in CI.\n:::\n\nThe plugin is exposed from `@craft-ts/dev-tools/eslint-rules`.\n\nFor a project using `@craft-ts/effect`, the published preset enables the Craft\nrules and the Effect adapter rule in one entry:\n\n```ts\nimport craftRules from '@craft-ts/dev-tools/eslint-rules';\n\nexport default [\n {\n files: ['**/*.ts'],\n ...craftRules.configs.effect,\n },\n];\n```\n\nUse `craftRules.configs.recommended` for projects that do not use Effect.\n\nAdd it to your ESLint flat config:\n\n```ts\nimport craftRules from '@craft-ts/dev-tools/eslint-rules';\n\nexport default [\n // keep your existing ESLint config entries\n {\n files: ['**/*.ts'],\n plugins: {\n 'craft-ts': craftRules,\n },\n rules: {\n 'craft-ts/prefer-craft-template-blocks': 'error',\n 'craft-ts/no-render-writes': 'error',\n 'craft-ts/require-reactive-template-bindings': 'error',\n 'craft-ts/no-craft-use': 'error',\n 'craft-ts/no-craft-component-return-type': 'error',\n 'craft-ts/require-craft-component-for-exported-node-factory': 'error',\n 'craft-ts/no-raw-craft-router-url': 'error',\n 'craft-ts/no-type-assertions-in-template': 'error',\n 'craft-ts/no-ephemeral-template-form-state': 'error',\n 'craft-ts/template-element-name-unique': 'error',\n 'craft-ts/no-craft-computed-side-effects': 'error',\n 'craft-ts/require-craft-method-for-yieldable-callback': 'error',\n 'craft-ts/prefer-direct-yieldable-callback': 'error',\n 'craft-ts/require-yieldable-reactive-read': 'error',\n 'craft-ts/require-yieldable-template-method': 'error',\n 'craft-ts/require-yieldable-insertion-write': 'error',\n 'craft-ts/no-craft-service-component-same-file': 'error',\n 'craft-ts/max-craft-declarations-per-file': 'error',\n 'craft-ts/prefer-craft-http-transport': 'error',\n 'craft-ts/no-injection-token': 'error',\n 'craft-ts/require-primitive-derived-property': 'error',\n 'craft-ts/no-reused-primitive-method': 'error',\n 'craft-ts/no-async-await': 'error',\n 'craft-ts/no-throw': 'error',\n 'craft-ts/no-imperative-craft-resource-trigger': 'error',\n 'craft-ts/no-imperative-craft-method-actions': 'error',\n 'craft-ts/no-remote-work-in-craft-method': 'error',\n 'craft-ts/no-type-assertions-in-resource-loader': 'error',\n 'craft-ts/no-imperative-template-action-chain': 'error',\n 'craft-ts/prefer-route-query-params-for-filter-state': 'warn',\n 'craft-ts/no-imperative-storage-in-craft-method': 'error',\n 'craft-ts/no-transition-actions': 'error',\n 'craft-ts/require-craft-resource-trigger-yield': 'error',\n 'craft-ts/require-assert-exhaustive-route-exceptions': 'error',\n 'craft-ts/require-craft-exception-handler': 'error',\n 'craft-ts/require-exception-component-di-check': 'error',\n 'craft-ts/require-pending-component-di-check': 'error',\n 'craft-ts/require-child-route-mount-check': 'error',\n 'craft-ts/require-lazy-load-with-retry': 'error',\n 'craft-ts/require-cascade-route-di-check': 'error',\n 'craft-ts/global-exception-registry-match': 'error',\n },\n },\n];\n```\n\nWhat each rule does:\n\n- `craft-ts/prefer-craft-template-blocks`: keeps `craftComponent(...)` templates declarative by rejecting ternaries, logical expressions, negations, and imperative control flow; use `ifNode(...)`, `matchNode.exhaustive(...)`, `forNode(...)`, or `deferNode(...)`\n- `craft-ts/no-render-writes`: rejects detectable `set()`, `update()`, and `mutate()` calls in component templates and render bindings while allowing DOM event and `onXxx` output callbacks\n- `craft-ts/require-reactive-template-bindings`: requires signals, named Craft values, and component inputs to be read inside granular binding callbacks instead of during VNode construction; static values remain valid\n- `craft-ts/no-craft-use`: forbids the synchronous `craftUse(...)` escape hatch in Craft TypeScript files; use a generator and delegate the reader with `yield*` instead\n- `craft-ts/require-craft-component-for-exported-node-factory`: requires an exported function that directly returns a Craft node, such as `button(...)`, to be declared with `craftComponent(...)` so Craft directives and composition remain available\n\nSmall node factories are valid when they stay private to the file:\n\n```ts\nfunction filterButton(filter: TodoFilter, label: string) {\n return button('todoFilterButton', { type: 'button' }, label);\n}\n```\n\nOnce the function is exported, use a Craft component so directives and\ncomposition can be applied at the module boundary:\n\n```ts\n// ❌ craft-ts/require-craft-component-for-exported-node-factory\nexport function filterButton(filter: TodoFilter, label: string) {\n return button('todoFilterButton', { type: 'button' }, label);\n}\n\n// ✅\nexport const FilterButton = craftComponent(\n 'FilterButton',\n {},\n (filter: Input<TodoFilter>, label: Input<string>) => ({ filter, label }),\n ({ label }) => button('todoFilterButton', { type: 'button' }, label),\n);\n```\n\nThe rule also follows named exports such as `export { filterButton }` and\nchecks exported arrow functions.\n\n- `craft-ts/no-type-assertions-in-template`: forbids `as ...` and angle-bracket type assertions in Craft templates; fix the type in the logic factory or expose a correctly typed derived value\n- `craft-ts/no-ephemeral-template-form-state`: forbids `let` / `const` / `var` in the fourth argument of `craftComponent(...)` and `craftDirective(...)` (inline or a same-file identifier). Declare that state in the logic factory with `state()` or `craftComputed()` instead\n- `craft-ts/template-element-name-unique`: requires named HTML helpers to use a static, unique local name within a component; use the object-first helper form for unnamed elements such as `p({ id: 'hint' }, ...)`\n- `craft-ts/no-craft-computed-side-effects`: forbids writes and asynchronous work inside `craftComputed`; only reactive reads and `settled(...)` are allowed. The graph-wide counterpart is [`assertCraftComputedPure`](/guide/testing/architecture#assertcraftcomputedpure).\n- `craft-ts/no-effect-outside-loaders`: keeps `params`, methods, `craftComputed(...)`, and `craftEffect(...)` synchronous by allowing Effect values and Effect service reads only in Effect loaders; `no-effect-in-params` remains as a compatibility alias\n- `craft-ts/sync-effect-body`: keeps a body declared synchronous (`SyncOp` in its requirements) free of anything that may suspend — async constructors such as `Effect.sleep`/`Effect.promise`, and members nothing declares synchronous. Type-aware: the ESLint parser must use `projectService: true` or a TypeScript `project`\n- `craft-ts/no-explicit-effect-type`: lets `Effect.gen` infer its complete type instead of repeating an explicit Effect annotation; contracts declared in interfaces and type aliases remain allowed\n- `craft-ts/prefer-inline-effect-insertion`: keeps the `queryEffect` insertion factory inline so its resource and exception types are inferred without a separate `InsertionParams` context alias\n- `craft-ts/prefer-inline-route-providers`: inlines a route provider tuple used only once by `loadCraftComponent(...)`, preserving the route-level type proof\n- `craft-ts/prefer-craft-reactivity`: rejects authored signal/computed/effect/resource APIs, explicit `.subscribe()` calls, and RxJS `Subject`/`BehaviorSubject`/`ReplaySubject`; use `state`, `craftComputed`, `craftEffect`, `query`, and named `source$`/`on$` flows\n- `craft-ts/prefer-craft-service`: keeps services in the `craftService(...)` model\n- `craft-ts/no-craft-service-component-same-file`: forbids declaring `craftService(...)` and `craftComponent(...)` in the same file; a route-level service provider combined with a lazy-loaded component can break lazy loading, so keep them in separate files\n- `craft-ts/max-craft-declarations-per-file`: reports the third and subsequent `craftComponent(...)`, `craftService(...)`, or `craftDirective(...)` declaration of the same kind in a file; keep Craft entities split across focused files\n- `craft-ts/no-injection-token`: forbids authored `InjectionToken` contracts; declare them with `craftService({ name, providedIn: 'abstract' }, abstract<Contract>())`\n- `craft-ts/prefer-craft-http-client`: forbids direct transport usage in favor of `CraftHttpClient`\n- `craft-ts/prefer-craft-http-transport`: forbids direct `fetch()` and `XMLHttpRequest`; use `query()` for reads or `mutation()` for writes with `CraftHttpClient`\n- `craft-ts/prefer-craft-input-output`: keeps component inputs and outputs in the `Input`/`Output` model used by `craftComponent(...)`\n- `craft-ts/require-primitive-derived-property`: requires a `computed` or `craftComputed` that only depends on one primitive in the same component/service to be exposed by that primitive's insertion; simple cases are autofixed\n- `craft-ts/no-reused-primitive-method`: requires an exposed primitive insertion method to have one call site per file; create a context-specific method for each distinct use\n- `craft-ts/no-async-await`: forbids `async` functions, `await`, and `for await...of`; use generator-based Craft primitives, `craftSleep`, and `CraftHttpClient` instead\n- `craft-ts/no-throw`: forbids `throw` in Craft code and offers a Quick Fix that returns `craftException({ _tag: 'UNEXPECTED_ERROR' }, { error: ... })`; keep technical boundaries and tests outside this rule when their contracts require thrown errors\n- `craft-ts/no-imperative-craft-resource-trigger`: forbids `query.call(...)`, `mutation.mutate(...)`, and `asyncProcess.method(...)` in a `craftEffect` dependency graph, including through `craftGen(...)`. The graph-wide counterpart, including `state` / `source$` writes, is [`assertCraftEffectNoImperativeSync`](/guide/testing/architecture#assertcrafteffectnoimperativesync).\n- `craft-ts/no-imperative-craft-method-actions`: forbids composing multiple imperative actions in a `craftMethod`; emit a `source$` event and let the affected query react with `insertReactOnMutation(...)` instead. A handler such as `event.preventDefault()` followed by one `mutation.mutate(...)` remains valid.\n- `craft-ts/no-remote-work-in-craft-method`: forbids `CraftHttpClient.*(...)` inside `craftMethod`; define the request directly in the `query` or `mutation` loader so the resource owns its remote lifecycle.\n- `craft-ts/no-type-assertions-in-resource-loader`: forbids `as ...` and angle-bracket assertions inside `query`, `mutation`, and `asyncProcess` loaders; repair the request or adapter typing instead of forcing a `PromiseLike<...>` contract.\n- `craft-ts/no-imperative-template-action-chain`: forbids chaining multiple Craft actions in one template event callback; emit one `source$` event and let the query, mutation, and state react through `on$`.\n- `craft-ts/prefer-route-query-params-for-filter-state`: warns when a local `state()` is used directly or through a local derivation as `params` for `query`, `queryEffect`, `asyncProcess`, or `asyncProcessEffect`; use `queryParams()` for values that should survive reloads and be represented in the URL. The graph-wide counterpart, which also sees cross-file dependencies, is [`assertResourceParamsPreferQueryParams`](/guide/testing/architecture/resource-params-query-state).\n- `craft-ts/no-imperative-storage-in-craft-method`: forbids direct storage access and imperative location changes in a `craftMethod`; use `insertReactOnMutation(...)` with `optimisticUpdate: () => undefined` to clear the affected query and let its persistence follow the query state.\n- `craft-ts/no-transition-actions`: forbids `query.call(...)`, `mutation.mutate(...)`, and `asyncProcess.method(...)` inside `transitionStep(...)`; validate the event and emit a source, then let the resource react to that source.\n- `craft-ts/require-craft-resource-trigger-yield`: requires those triggers to use `yield*` inside generator functions, while ordinary UI callbacks may keep imperative calls\n- `craft-ts/require-craft-method-for-yieldable-callback`: requires callbacks returned by a `craftComponent` factory to wrap yieldable Craft method calls in `craftMethod(...)`\n- `craft-ts/prefer-direct-yieldable-callback`: replaces a template generator or generator method that only delegates `yield* callback()` with the callback reference itself (`callback` or `object.method`)\n- `craft-ts/require-yieldable-reactive-read`: requires Craft reactive readers to be delegated with `yield*` inside generator functions; a function that reads a Craft reader must itself be a generator\n- `craft-ts/require-yieldable-template-method`: requires yieldable Craft method calls in a `craftComponent` template to be delegated with `yield*`, or passed as a reference (`click: counter.increment`)\n- `craft-ts/require-yieldable-insertion-write`: requires `set(...)`, `patch(...)`, and `update(...)` to be delegated with `yield*` when they are used inside a generator method\n- `craft-ts/require-assert-exhaustive-route-exceptions`: adds the collection-level `assertExhaustiveRouteExceptions(...)` safety net\n- `craft-ts/require-craft-exception-handler`: enforces `craftExceptionHandler(function* (...) {})`; simple handlers are autofixed and ambiguous raw redirects are reported for manual migration\n- `craft-ts/require-exception-component-di-check`: generates O(1) `RouteExceptionComponentCheckedDI` checks for `renderComponent`, route-level `errorComponent`, `withErrorComponent`, `withRouteLoadError`, and route-local `provideRouteLoadErrorComponent`\n- `craft-ts/require-pending-component-di-check`: generates the independent `RouteCheckedDI` check for each `pendingComponent`\n- `craft-ts/no-raw-class`: forbids a `class:` binding that is a string, a template literal or a function, in any file that imports `@craft-ts/style`. A class assembled at render time is a visual state nothing recorded, so the [visual matrix](/guide/style/testing) would enumerate what the sheets declare while the DOM shows something else. Move the rule into the sheet and bind the class it returns; make the variation an axis and set a `data-*` attribute\n- `craft-ts/no-raw-css-value`: forbids a string or number literal as an argument to a `@craft-ts/style` helper — `p('12px')`, `bg('red')`. If the scale is missing the step, add it to the scale; if the value genuinely cannot be proven, `unsafeLength('13px', reason)` compiles and makes the debt countable in the [graph](/guide/style/testing#what-the-graph-adds)\n- `craft-ts/no-free-has`: forbids a hand-written `:has()` in styles. It reaches across the component boundary, so what a component looks like depends on markup it does not own — a state the matrix cannot enumerate. Use the `descendant` axis, which is a closed set and carries its own test driver\n- `craft-ts/style-file-boundary`: restricts a `*.style.ts` to style-vocabulary imports. The [build plugin](/guide/style/setup) imports the file in Node to read what it registered, so an application import would run application code at build time\n- `craft-ts/craft-css-token-registry`: reports a custom property registered with `@property` by two different components. A custom property may have only one owner; two silently fight over its syntax and initial value\n- `craft-ts/require-effect-adapters`: requires the Effect-aware adapters — `queryEffect`, `mutationEffect`, `asyncProcessEffect` — instead of the plain primitives in an Effect application. See [Choose the right adapter](/guide/advanced/effect#choose-the-right-adapter)\n- `craft-ts/craft-signal-source-name-match`: requires `signalSource(name, ...)` to take a string literal matching the variable, class property or object property it is assigned to, so the name in a trace is the name in the source. A computed name defeats the [architecture graph](/guide/testing/architecture), which reads these names statically\n- `craft-ts/require-child-route-mount-check`: adds the missing `assertChildRouteMounts(...)` call + import (Quick Fix) for any `craftRoutes(...)` collection that mounts lazy `loadChildren`, so a `.withParent`-pinned child mounted under the wrong path is a compile error\n- `craft-ts/require-lazy-load-with-retry`: wraps route `loadComponent` and `loadChildren` imports with the generated `withRetry(...)` loader helper while preserving a statically analyzable import specifier\n- `craft-ts/require-cascade-route-di-check`: rejects any `craftRoutes(...)` collection without a same-file `ValidateCascadeRoutesFile + CanRun` proof; its autofix adds the conservative `<never, Router>` context, which should be adjusted when the mount inherits providers\n- `craft-ts/global-exception-registry-match`: keeps `CraftGlobalExceptionRegistry` synchronized with handlers delegating to `globalError()`\n- `craft-ts/prefer-craft-router-link`: requires `CraftRouterLink` for internal `a(..., { href: ... })` navigation; external URLs, fragment links, downloads, `_blank`, and links marked with `data-navigation: 'external'` remain native\n- `craft-ts/no-raw-craft-router-url`: rejects reading `CraftRouter.url`; use the typed route parameter helper generated by `craftRoutes(...)` instead of parsing the URL\n- `craft-ts/no-craft-component-return-type`: rejects explicit annotations on `craftComponent(...)` results so dependency and template inference remains intact\n\n### Accessibility (`craft-ts/a11y`)\n\nSpread `craftRules.configs.a11y.rules` to enable the WCAG 2.2 AA preset as\n`error`. The rules walk **all** hyperscript in the file (`craftTemplate`,\nextracted factories, `h('tag')`), not only `craftComponent` argument 3.\n\n- `prefer-named-html-helpers`: forbids `h('img')` / `h('button')` when a named helper exists\n- `require-interactive-local-name`: requires a string-literal first argument on interactive helpers; the local name is the third segment of `data-craft-name=\"${component}:${tag}:${localName}\"`\n- `img-has-alt`, `iframe-has-title`, `button-has-type`, `anchor-has-href`\n- `control-has-accessible-name`, `label-has-associated-control`, `heading-has-content`\n- `no-noninteractive-element-interactions`, `no-positive-tabindex`\n- `valid-aria`, `role-has-required-aria`, `target-blank-noopener`\n- `prefer-relative-heading`, `require-route-heading-outline`,\n `require-outlet-heading-section`, `no-heading-level-skip`\n- `require-focus-visible`, `require-reduced-motion` (CSS of `craftComponent`)\n\nSee [Accessibility](/guide/components/accessibility).\n\nThe two migration rules also expose a VS Code ESLint Quick Fix suggestion that inserts a temporary local disable comment with the intended migration note when you need to unblock a file before doing the full refactor.\n\nThe template and reactivity rules are intentionally diagnostic-only: replacing a\nresource or subscription can change lifecycle and error semantics, so the rule\npoints at the Craft primitive without applying a potentially unsafe rewrite.\n\n### Why templates use blocks\n\nCraft template blocks preserve the branch structure in the type-level render\ncontract. A ternary or `condition && node` produces only a computed value, so\nthe type checker cannot assert which branch renders which content. Keep derived\nvalues and business decisions in the component's state/query layer, then make\nthe template express visibility explicitly:\n\n```ts\nifNode(\n isReady,\n () => p('Ready'),\n () => p('Loading…'),\n);\n\nmatchNode.exhaustive(query.exceptions, '_tag', {\n NOT_FOUND: () => p('Not found'),\n FORBIDDEN: () => p('Forbidden'),\n});\n```\n\nThis rule is for Craft's TypeScript templates. It does not rewrite external\ntemplate languages.\n\nThe same restriction applies to boolean expressions. A negation is still\napplication logic, even when it is used only for a DOM property:\n\n```ts\n// Incorrect: the template derives the disabled state.\nbutton(\n {\n disabled: function* () {\n return !(yield* machine.canGoBack());\n },\n },\n 'Back',\n);\n\n// Correct: derive it in the logic factory and bind the result.\nconst backDisabled = craftComputed('backDisabled', function* () {\n return !(yield* history.canGoBack());\n});\nreturn { backDisabled };\n```\n\nKeep the template to layout and binding. Move labels, formatted values,\nvalidation state, and other decisions into `state()` or `craftComputed()`.\n\n### Derived values belong to their primitive\n\nWhen a computed reads only one local primitive, declare it in that primitive's\ninsertion. This keeps the dependency visible and lets pending/exception\nboundaries name the actual source:\n\n```ts\nconst users =\n yield *\n query('users', config, ({ resource }) => ({\n total: craftComputed('total', function* () {\n return (yield* settled(resource)).length;\n }),\n }));\n```\n\nDo not create `craftComputed('total', ...)` beside the query when the\ncomputation depends only on `users`.\n\n### Keep casts and synchronous reads out of templates\n\nCraft templates reject both `as ...` / angle-bracket assertions and\n`craftUse(...)`. Fix the type or perform the synchronous-to-reactive\nconversion in the component logic, then expose a typed reader or generator to\nthe template:\n\n```ts\nconst typedStep = machine.stepState as unknown as () => { step: Step };\nreturn { typedStep };\n\n// Template: no cast and no craftUse.\nmatchNode.exhaustive(typedStep, 'step', steps);\n```\n\n`no-craft-use` applies to Craft TypeScript files, not only the fourth\n`craftComponent(...)` argument. A synchronous integration boundary may opt out\nlocally when its external API cannot consume a generator, but application\nstate and templates should use `yield*`.\n\n### Form and accessibility diagnostics\n\nThe accessibility preset also checks the static structure of hyperscript:\n\n- give every `label` an `htmlFor` matching the control `id`, or wrap the control;\n- give named controls and helpers a unique string local name;\n- use `button` or `a` for interactions instead of adding `click` to a `div`;\n- add a `prefers-reduced-motion` branch whenever component CSS defines an\n animation or transition.\n\nThese checks run on Craft TypeScript templates and extracted helper factories,\nso moving markup into a local function does not bypass them.\n\n### Reactive values belong in binding callbacks\n\n`require-reactive-template-bindings` uses TypeScript type information to find\nreactive reads. Reading a signal while constructing a VNode would make it a\ndependency of the structural component render, so the rule rejects this form:\n\n```ts\n// Incorrect: count is read by the component template.\np(`Count: ${count()}`);\nbutton({ disabled: isDisabled() }, 'Save');\ndiv({ class: { active: isActive() } });\n```\n\nKeep each read inside the callback owned by its DOM binding. Pass a yieldable\nreader, or use a generator when the binding must format:\n\n```ts\np(count);\np(function* () {\n return `Count: ${yield* count()}`;\n});\nbutton({ disabled: isDisabled }, 'Save');\ndiv({ class: isActiveClass });\n```\n\nLiteral and otherwise static values are still allowed, as are reads performed\nfrom DOM events and `onXxx` output callbacks. Because the rule is type-aware,\nthe ESLint parser must use `projectService: true` or a TypeScript `project`.\n\n### Pass simple yieldable callbacks directly\n\n`prefer-direct-yieldable-callback` removes a generator wrapper when the\ntemplate only delegates one zero-argument callback. It handles both a value\nbinding and a generator method:\n\n```ts\n// Before: redundant wrappers around the callbacks.\nbutton(\n {\n *click() {\n yield* press();\n },\n },\n function* () {\n return yield* label();\n },\n);\n\n// After `eslint --fix`.\nbutton({ click: press }, label);\n```\n\nMember callbacks are supported as well when the access is static and has no\narguments:\n\n```ts\n// Before.\nspan(function* () {\n return yield* counter.increment();\n});\n\n// After.\nspan(counter.increment);\n```\n\nThe rule leaves callbacks with parameters, extra statements, or additional\ncomputation unchanged. In those cases the generator contains behavior that\ncannot be represented by passing the callback reference alone.\n\n### Yield insertion writes from generator methods\n\n`require-yieldable-insertion-write` requires `set(...)`, `patch(...)`, and\n`update(...)` calls to be delegated with `yield*` when they are used inside a\ngenerator method:\n\n```ts\nnextPage: function* () {\n const current = yield* state();\n return yield* patch({ page: current.page + 1 });\n},\n```\n\nInsertion callbacks that are not generators may return a write directly; the\ninsertion wrapper consumes that result for them.\n\n## What generates what\n\nThree rules do more than complain — they write code you would otherwise\nmaintain by hand:\n\n| Rule | Generates |\n| -------------------------------------------- | ------------------------------------------------------------- |\n| `require-cascade-route-di-check` | the same-file DI proof for a `craftRoutes(...)` collection |\n| `require-assert-exhaustive-route-exceptions` | the collection-level exhaustiveness assert |\n| `require-child-route-mount-check` | the `assertChildRouteMounts(...)` call and its import |\n| `require-lazy-load-with-retry` | the `withRetry(...)` wrapper on lazy route imports |\n| `prefer-direct-yieldable-callback` | replaces redundant generators with direct callback references |\n\n## Adopting them progressively\n\nOn an existing codebase, enable them in waves rather than all at once:\n\n1. **The route safety nets** — the `require-*` rules. Mostly autofixable. They\n generate the proofs; [architecture tests](/guide/testing/architecture#assertroutediproofs)\n (`assertRouteDiProofs`) fail CI if a proof is later removed or left unarmed.\n2. **The architecture rules last** — `prefer-craft-service`,\n `no-craft-service-component-same-file`, `prefer-craft-http-client`,\n `require-yieldable-reactive-read`,\n `require-yieldable-template-method`, `require-yieldable-insertion-write`.\n These ask for real refactors.\n\nThe four style rules — `no-raw-class`, `no-raw-css-value`, `no-free-has`,\n`style-file-boundary` — are in `craftRules.configs.recommended` at `'error'`,\nand they are **gated on the import**: they fire only in files that import\n`@craft-ts/style`. A component you have not migrated is not claiming the\nguarantee, so nothing reports it. The day a file starts using the design system\nis the day it starts being held to it — which is why enabling them on an\nunmigrated codebase costs nothing.\n\nThe two migration rules also expose a VS Code quick fix that inserts a temporary\nlocal disable comment with the intended migration note, so you can unblock a\nfile before doing the full refactor.\n\n## See Also\n\n- [Routing setup](/guide/routing/setup) — where these rules are installed\n- [CLI automation](/guide/routing/automation) — the codemods they complement\n- [Architecture rules](/guide/testing/architecture) — graph-wide constraints ESLint cannot see\n- [Activating the style system](/guide/style/setup) — what the four style rules are guarding\n"
|
|
325
|
+
"body": "# ESLint rules\n\nThe rule set is not decoration: several checks in this documentation only work\nbecause a rule generated or maintained the code they read. Others enforce the\narchitecture — no hidden runtime dependencies or direct transport calls — and most of them\n**autofix**.\n\n**Install them once** when you set up routing and type-safe DI.\n**Then lean on the quick fixes** rather than writing the boilerplate by hand.\n\n::: warning An ESLint error is not a compile error\nA missing autofix does not break the build. If you skip the quick fix after\nchanging a component's DI shape, `main.ts` keeps reading a stale `GenDeps_*` and\ncan miss a real DI error. Run `eslint --fix` in CI.\n:::\n\nThe plugin is exposed from `@craft-ts/dev-tools/eslint-rules`.\n\nFor a project using `@craft-ts/effect`, the published preset enables the Craft\nrules and the Effect adapter rule in one entry:\n\n```ts\nimport craftRules from '@craft-ts/dev-tools/eslint-rules';\n\nexport default [\n {\n files: ['**/*.ts'],\n ...craftRules.configs.effect,\n },\n];\n```\n\nUse `craftRules.configs.recommended` for projects that do not use Effect.\n\nAdd it to your ESLint flat config:\n\n```ts\nimport craftRules from '@craft-ts/dev-tools/eslint-rules';\n\nexport default [\n // keep your existing ESLint config entries\n {\n files: ['**/*.ts'],\n plugins: {\n 'craft-ts': craftRules,\n },\n rules: {\n 'craft-ts/prefer-craft-template-blocks': 'error',\n 'craft-ts/no-render-writes': 'error',\n 'craft-ts/require-reactive-template-bindings': 'error',\n 'craft-ts/no-craft-use': 'error',\n 'craft-ts/no-craft-component-return-type': 'error',\n 'craft-ts/require-craft-component-for-exported-node-factory': 'error',\n 'craft-ts/no-raw-craft-router-url': 'error',\n 'craft-ts/no-type-assertions-in-template': 'error',\n 'craft-ts/no-ephemeral-template-form-state': 'error',\n 'craft-ts/template-element-name-unique': 'error',\n 'craft-ts/no-craft-computed-side-effects': 'error',\n 'craft-ts/require-craft-method-for-yieldable-callback': 'error',\n 'craft-ts/prefer-direct-yieldable-callback': 'error',\n 'craft-ts/prefer-deep-yieldable-for-item': 'warn',\n 'craft-ts/require-yieldable-reactive-read': 'error',\n 'craft-ts/require-yieldable-template-method': 'error',\n 'craft-ts/require-yieldable-insertion-write': 'error',\n 'craft-ts/no-craft-service-component-same-file': 'error',\n 'craft-ts/max-craft-declarations-per-file': 'error',\n 'craft-ts/prefer-craft-http-transport': 'error',\n 'craft-ts/no-injection-token': 'error',\n 'craft-ts/require-primitive-derived-property': 'error',\n 'craft-ts/no-reused-primitive-method': 'error',\n 'craft-ts/no-async-await': 'error',\n 'craft-ts/no-throw': 'error',\n 'craft-ts/no-imperative-craft-resource-trigger': 'error',\n 'craft-ts/no-imperative-craft-method-actions': 'error',\n 'craft-ts/no-remote-work-in-craft-method': 'error',\n 'craft-ts/no-type-assertions-in-resource-loader': 'error',\n 'craft-ts/no-imperative-template-action-chain': 'error',\n 'craft-ts/prefer-route-query-params-for-filter-state': 'warn',\n 'craft-ts/no-imperative-storage-in-craft-method': 'error',\n 'craft-ts/no-transition-actions': 'error',\n 'craft-ts/require-craft-resource-trigger-yield': 'error',\n 'craft-ts/require-assert-exhaustive-route-exceptions': 'error',\n 'craft-ts/require-craft-exception-handler': 'error',\n 'craft-ts/require-exception-component-di-check': 'error',\n 'craft-ts/require-pending-component-di-check': 'error',\n 'craft-ts/require-child-route-mount-check': 'error',\n 'craft-ts/require-lazy-load-with-retry': 'error',\n 'craft-ts/global-exception-registry-match': 'error',\n },\n },\n];\n```\n\nWhat each rule does:\n\n- `craft-ts/prefer-craft-template-blocks`: keeps `craftComponent(...)` templates declarative by rejecting ternaries, logical expressions, negations, and imperative control flow; use `ifNode(...)`, `matchNode.exhaustive(...)`, `forNode(...)`, or `deferNode(...)`\n- `craft-ts/no-render-writes`: rejects detectable `set()`, `update()`, and `mutate()` calls in component templates and render bindings while allowing DOM event and `onXxx` output callbacks\n- `craft-ts/require-reactive-template-bindings`: requires signals, named Craft values, and component inputs to be read inside granular binding callbacks instead of during VNode construction; static values remain valid\n- `craft-ts/no-craft-use`: forbids the synchronous `craftUse(...)` escape hatch in Craft TypeScript files; use a generator and delegate the reader with `yield*` instead\n- `craft-ts/require-craft-component-for-exported-node-factory`: requires an exported function that directly returns a Craft node, such as `button(...)`, to be declared with `craftComponent(...)` so Craft directives and composition remain available\n\nSmall node factories are valid when they stay private to the file:\n\n```ts\nfunction filterButton(filter: TodoFilter, label: string) {\n return button('todoFilterButton', { type: 'button' }, label);\n}\n```\n\nOnce the function is exported, use a Craft component so directives and\ncomposition can be applied at the module boundary:\n\n```ts\n// ❌ craft-ts/require-craft-component-for-exported-node-factory\nexport function filterButton(filter: TodoFilter, label: string) {\n return button('todoFilterButton', { type: 'button' }, label);\n}\n\n// ✅\nexport const FilterButton = craftComponent(\n 'FilterButton',\n {},\n (filter: Input<TodoFilter>, label: Input<string>) => ({ filter, label }),\n ({ label }) => button('todoFilterButton', { type: 'button' }, label),\n);\n```\n\nThe rule also follows named exports such as `export { filterButton }` and\nchecks exported arrow functions.\n\n- `craft-ts/no-type-assertions-in-template`: forbids `as ...` and angle-bracket type assertions in Craft templates; fix the type in the logic factory or expose a correctly typed derived value\n- `craft-ts/no-ephemeral-template-form-state`: forbids `let` / `const` / `var` in the fourth argument of `craftComponent(...)` and `craftDirective(...)` (inline or a same-file identifier). Declare that state in the logic factory with `state()` or `craftComputed()` instead\n- `craft-ts/template-element-name-unique`: requires named HTML helpers to use a static, unique local name within a component; use the object-first helper form for unnamed elements such as `p({ id: 'hint' }, ...)`\n- `craft-ts/no-craft-computed-side-effects`: forbids writes and asynchronous work inside `craftComputed`; only reactive reads and `settled(...)` are allowed. The graph-wide counterpart is [`assertCraftComputedPure`](/guide/testing/architecture#assertcraftcomputedpure).\n- `craft-ts/no-effect-outside-loaders`: keeps `params`, methods, `craftComputed(...)`, and `craftEffect(...)` synchronous by allowing Effect values and Effect service reads only in Effect loaders; `no-effect-in-params` remains as a compatibility alias\n- `craft-ts/sync-effect-body`: keeps a body declared synchronous (`SyncOp` in its requirements) free of anything that may suspend — async constructors such as `Effect.sleep`/`Effect.promise`, and members nothing declares synchronous. Type-aware: the ESLint parser must use `projectService: true` or a TypeScript `project`\n- `craft-ts/no-explicit-effect-type`: lets `Effect.gen` infer its complete type instead of repeating an explicit Effect annotation; contracts declared in interfaces and type aliases remain allowed\n- `craft-ts/prefer-inline-effect-insertion`: keeps the `queryEffect` insertion factory inline so its resource and exception types are inferred without a separate `InsertionParams` context alias\n- `craft-ts/prefer-inline-route-providers`: inlines a route provider tuple used only once by `loadCraftComponent(...)`, preserving the route-level type proof\n- `craft-ts/prefer-craft-reactivity`: rejects authored signal/computed/effect/resource APIs, explicit `.subscribe()` calls, and RxJS `Subject`/`BehaviorSubject`/`ReplaySubject`; use `state`, `craftComputed`, `craftEffect`, `query`, and named `source$`/`on$` flows\n- `craft-ts/prefer-craft-service`: keeps services in the `craftService(...)` model\n- `craft-ts/no-craft-service-component-same-file`: forbids declaring `craftService(...)` and `craftComponent(...)` in the same file; a route-level service provider combined with a lazy-loaded component can break lazy loading, so keep them in separate files\n- `craft-ts/max-craft-declarations-per-file`: reports the third and subsequent `craftComponent(...)`, `craftService(...)`, or `craftDirective(...)` declaration of the same kind in a file; keep Craft entities split across focused files\n- `craft-ts/no-injection-token`: forbids authored `InjectionToken` contracts; declare them with `craftService({ name, providedIn: 'abstract' }, abstract<Contract>())`\n- `craft-ts/prefer-craft-http-client`: forbids direct transport usage in favor of `CraftHttpClient`\n- `craft-ts/prefer-craft-http-transport`: forbids direct `fetch()` and `XMLHttpRequest` because they bypass typed responses and exceptions, tracing, cancellation, and the architecture graph; use `query()` for reads or `mutation()` for writes with `CraftHttpClient`, or `CraftBinaryHttpClient` for raw binary bodies\n- `craft-ts/prefer-craft-input-output`: keeps component inputs and outputs in the `Input`/`Output` model used by `craftComponent(...)`\n- `craft-ts/require-primitive-derived-property`: requires a `computed` or `craftComputed` that only depends on one primitive in the same component/service to be exposed by that primitive's insertion; simple cases are autofixed\n- `craft-ts/no-reused-primitive-method`: requires an exposed primitive insertion method to have one call site per file; create a context-specific method for each distinct use\n- `craft-ts/no-async-await`: forbids `async` functions, `await`, and `for await...of` because native Promise suspension hides Craft dependencies and can lose cancellation or exception tracking; use generator-based Craft primitives, `craftSleep`, and `CraftHttpClient` instead\n- `craft-ts/require-generator-resource-loader`: requires `query`, `mutation`, and `asyncProcess` loaders to be generator functions because a plain or async return hides remote dependencies from the resource lifecycle; use `yield*` to keep each suspension tracked\n- `craft-ts/no-throw`: forbids `throw` in Craft code because it bypasses the typed resource exception channel, and offers a Quick Fix that returns `craftException({ _tag: 'UNEXPECTED_ERROR' }, { error: ... })`; keep technical boundaries and tests outside this rule when their contracts require thrown errors\n- `craft-ts/no-imperative-craft-resource-trigger`: forbids `query.call(...)`, `mutation.mutate(...)`, and `asyncProcess.method(...)` in a `craftEffect` dependency graph, including through `craftGen(...)`. The graph-wide counterpart, including `state` / `source$` writes, is [`assertCraftEffectNoImperativeSync`](/guide/testing/architecture#assertcrafteffectnoimperativesync).\n- `craft-ts/no-imperative-craft-method-actions`: forbids composing multiple imperative actions in a `craftMethod`; emit a `source$` event and let the affected query react with `insertReactOnMutation(...)` instead. A handler such as `event.preventDefault()` followed by one `mutation.mutate(...)` remains valid.\n- `craft-ts/no-remote-work-in-craft-method`: forbids `CraftHttpClient.*(...)` inside `craftMethod` because that action boundary does not own request loading, cancellation, exceptions, or graph dependencies; define the request directly in the `query` or `mutation` loader.\n- `craft-ts/no-type-assertions-in-resource-loader`: forbids `as ...` and angle-bracket assertions inside `query`, `mutation`, and `asyncProcess` loaders because assertions only silence TypeScript and can hide Promise, response, or transport mismatches; repair the request or adapter typing instead.\n- `craft-ts/no-imperative-template-action-chain`: forbids chaining multiple Craft actions in one template event callback; emit one `source$` event and let the query, mutation, and state react through `on$`.\n- `craft-ts/prefer-route-query-params-for-filter-state`: warns when a local `state()` is used directly or through a local derivation as `params` for `query`, `queryEffect`, `asyncProcess`, or `asyncProcessEffect`; use `queryParams()` for values that should survive reloads and be represented in the URL. The graph-wide counterpart, which also sees cross-file dependencies, is [`assertResourceParamsPreferQueryParams`](/guide/testing/architecture/resource-params-query-state).\n- `craft-ts/no-imperative-storage-in-craft-method`: forbids direct storage access and imperative location changes in a `craftMethod`; use `insertReactOnMutation(...)` with `optimisticUpdate: () => undefined` to clear the affected query and let its persistence follow the query state.\n- `craft-ts/no-transition-actions`: forbids `query.call(...)`, `mutation.mutate(...)`, and `asyncProcess.method(...)` inside `transitionStep(...)`; validate the event and emit a source, then let the resource react to that source.\n- `craft-ts/require-craft-resource-trigger-yield`: requires those triggers to use `yield*` inside generator functions, while ordinary UI callbacks may keep imperative calls\n- `craft-ts/require-craft-method-for-yieldable-callback`: requires callbacks returned by a `craftComponent` factory to wrap yieldable Craft method calls in `craftMethod(...)`\n- `craft-ts/prefer-direct-yieldable-callback`: replaces a template generator or generator method that only delegates `yield* callback()` with the callback reference itself (`callback` or `object.method`)\n- `craft-ts/prefer-deep-yieldable-for-item`: warns when a `forNode` item is read repeatedly through `yield* item()` property accesses; expose a named `insertDeepYieldable('property')` collection and use direct item property readers\n- `craft-ts/require-yieldable-reactive-read`: requires Craft reactive readers to be delegated with `yield*` inside generator functions; a function that reads a Craft reader must itself be a generator\n- `craft-ts/require-yieldable-template-method`: requires yieldable Craft method calls in a `craftComponent` template to be delegated with `yield*`, or passed as a reference (`click: counter.increment`)\n- `craft-ts/require-yieldable-insertion-write`: requires `set(...)`, `patch(...)`, and `update(...)` to be delegated with `yield*` when they are used inside a generator method\n- `craft-ts/require-assert-exhaustive-route-exceptions`: adds the collection-level `assertExhaustiveRouteExceptions(...)` safety net\n- `craft-ts/require-craft-exception-handler`: enforces `craftExceptionHandler(function* (...) {})`; simple handlers are autofixed and ambiguous raw redirects are reported for manual migration\n- `craft-ts/require-exception-component-di-check`: generates O(1) `RouteExceptionComponentCheckedDI` checks for `renderComponent`, route-level `errorComponent`, `withErrorComponent`, `withRouteLoadError`, and route-local `provideRouteLoadErrorComponent`\n- `craft-ts/require-pending-component-di-check`: generates the independent `RouteCheckedDI` check for each `pendingComponent`\n- `craft-ts/no-raw-class`: forbids a `class:` binding that is a string, a template literal or a function, in any file that imports `@craft-ts/style`. A class assembled at render time is a visual state nothing recorded, so the [visual matrix](/guide/style/testing) would enumerate what the sheets declare while the DOM shows something else. Move the rule into the sheet and bind the class it returns; make the variation an axis and set a `data-*` attribute\n- `craft-ts/no-raw-css-value`: forbids a string or number literal as an argument to a `@craft-ts/style` helper — `p('12px')`, `bg('red')`. If the scale is missing the step, add it to the scale; if the value genuinely cannot be proven, `unsafeLength('13px', reason)` compiles and makes the debt countable in the [graph](/guide/style/testing#what-the-graph-adds)\n- `craft-ts/no-free-has`: forbids a hand-written `:has()` in styles. It reaches across the component boundary, so what a component looks like depends on markup it does not own — a state the matrix cannot enumerate. Use the `descendant` axis, which is a closed set and carries its own test driver\n- `craft-ts/style-file-boundary`: restricts a `*.style.ts` to style-vocabulary imports. The [build plugin](/guide/style/setup) imports the file in Node to read what it registered, so an application import would run application code at build time\n- `craft-ts/craft-css-token-registry`: reports a custom property registered with `@property` by two different components. A custom property may have only one owner; two silently fight over its syntax and initial value\n- `craft-ts/require-effect-adapters`: requires the Effect-aware adapters — `queryEffect`, `mutationEffect`, `asyncProcessEffect`, and `transitionGuardEffect` — instead of the plain primitives and `transitionGuard` in an Effect application. See [Choose the right adapter](/guide/advanced/effect#choose-the-right-adapter)\n- `craft-ts/craft-signal-source-name-match`: requires `signalSource(name, ...)` to take a string literal matching the variable, class property or object property it is assigned to, so the name in a trace is the name in the source. A computed name defeats the [architecture graph](/guide/testing/architecture), which reads these names statically\n- `craft-ts/require-child-route-mount-check`: adds the missing `assertChildRouteMounts(...)` call + import (Quick Fix) for any `craftRoutes(...)` collection that mounts lazy `loadChildren`, so a `.withParent`-pinned child mounted under the wrong path is a compile error\n- `craft-ts/require-lazy-load-with-retry`: wraps route `loadComponent` and `loadChildren` imports with the generated `withRetry(...)` loader helper while preserving a statically analyzable import specifier\n- `craft-ts/global-exception-registry-match`: keeps `CraftGlobalExceptionRegistry` synchronized with handlers delegating to `globalError()`\n- `craft-ts/prefer-craft-router-link`: requires `CraftRouterLink` for internal `a(..., { href: ... })` navigation; external URLs, fragment links, downloads, `_blank`, and links marked with `data-navigation: 'external'` remain native\n- `craft-ts/no-raw-craft-router-url`: rejects reading `CraftRouter.url`; use the typed route parameter helper generated by `craftRoutes(...)` instead of parsing the URL\n- `craft-ts/no-craft-component-return-type`: rejects explicit annotations on `craftComponent(...)` results so dependency and template inference remains intact\n\n## Promise and transport boundaries\n\nThese rules protect the same boundary: asynchronous work must remain visible to\nthe Craft resource that owns it. A native `Promise` may eventually resolve, but\nit does not describe which Craft dependencies were read, where suspension\noccurred, or which resource should be cancelled and receive the exception.\n\n### Keep resource loaders generator-based\n\n```ts\n// Incorrect: the native Promise hides the request from the Craft lifecycle.\nquery('usersQuery', {\n loader: async () => (await fetch('/api/users')).json(),\n});\n\n// Correct: the resource owns a tracked, yieldable request.\nquery('usersQuery', {\n loader: function* () {\n return yield* CraftHttpClient.get(({ response }) => ({\n url: '/api/users',\n success: response<User[]>(),\n }));\n },\n});\n```\n\n`no-async-await` rejects `async`, `await`, and `for await...of` in Craft code.\n`require-generator-resource-loader` additionally checks that `query`,\n`mutation`, and `asyncProcess` loaders are generators. Use `yield*` for Craft\noperations so every suspension stays tracked.\n\n### Keep transport and types honest\n\n```ts\n// Incorrect: direct fetch bypasses Craft response/error tracking.\nconst result = await fetch('/api/users');\n\n// Correct: use the Craft client in the owning resource loader.\nreturn yield* CraftHttpClient.get(({ response }) => ({\n url: '/api/users',\n success: response<User>(),\n}));\n```\n\nFor a raw binary body, use `CraftBinaryHttpClient.put(...)`; do not use a type\nassertion to force `CraftHttpClient` to accept a `Blob`. An assertion only\nsilences TypeScript — it does not change the runtime value or transport.\nThat is why `prefer-craft-http-transport` and\n`no-type-assertions-in-resource-loader` report these patterns.\n\nExpected failures should use `craftException(...)` so they remain typed and\navailable through the resource's exception state. `no-throw` keeps technical\nthrows limited to explicit adapter boundaries, where they can be translated\ninto the Craft exception channel.\n\n### Accessibility (`craft-ts/a11y`)\n\nSpread `craftRules.configs.a11y.rules` to enable the WCAG 2.2 AA preset as\n`error`. The rules walk **all** hyperscript in the file (`craftTemplate`,\nextracted factories, `h('tag')`), not only `craftComponent` argument 3.\n\n- `prefer-named-html-helpers`: forbids `h('img')` / `h('button')` when a named helper exists\n- `require-interactive-local-name`: requires a string-literal first argument on interactive helpers; the local name is the third segment of `data-craft-name=\"${component}:${tag}:${localName}\"`\n- `img-has-alt`, `iframe-has-title`, `button-has-type`, `anchor-has-href`\n- `control-has-accessible-name`, `label-has-associated-control`, `heading-has-content`\n- `no-noninteractive-element-interactions`, `no-positive-tabindex`\n- `valid-aria`, `role-has-required-aria`, `target-blank-noopener`\n- `prefer-relative-heading`, `require-route-heading-outline`,\n `require-outlet-heading-section`, `no-heading-level-skip`\n- `require-focus-visible`, `require-reduced-motion` (CSS of `craftComponent`)\n\nSee [Accessibility](/guide/components/accessibility).\n\nThe two migration rules also expose a VS Code ESLint Quick Fix suggestion that inserts a temporary local disable comment with the intended migration note when you need to unblock a file before doing the full refactor.\n\nThe template and reactivity rules are intentionally diagnostic-only: replacing a\nresource or subscription can change lifecycle and error semantics, so the rule\npoints at the Craft primitive without applying a potentially unsafe rewrite.\n\n### Why templates use blocks\n\nCraft template blocks preserve the branch structure in the type-level render\ncontract. A ternary or `condition && node` produces only a computed value, so\nthe type checker cannot assert which branch renders which content. Keep derived\nvalues and business decisions in the component's state/query layer, then make\nthe template express visibility explicitly:\n\n```ts\nifNode(\n isReady,\n () => p('Ready'),\n () => p('Loading…'),\n);\n\nmatchNode.exhaustive(query.exceptions, '_tag', {\n NOT_FOUND: () => p('Not found'),\n FORBIDDEN: () => p('Forbidden'),\n});\n```\n\nThis rule is for Craft's TypeScript templates. It does not rewrite external\ntemplate languages.\n\nThe same restriction applies to boolean expressions. A negation is still\napplication logic, even when it is used only for a DOM property:\n\n```ts\n// Incorrect: the template derives the disabled state.\nbutton(\n {\n disabled: function* () {\n return !(yield* machine.canGoBack());\n },\n },\n 'Back',\n);\n\n// Correct: derive it in the logic factory and bind the result.\nconst backDisabled = craftComputed('backDisabled', function* () {\n return !(yield* history.canGoBack());\n});\nreturn { backDisabled };\n```\n\nKeep the template to layout and binding. Move labels, formatted values,\nvalidation state, and other decisions into `state()` or `craftComputed()`.\n\n### Derived values belong to their primitive\n\nWhen a computed reads only one local primitive, declare it in that primitive's\ninsertion. This keeps the dependency visible and lets pending/exception\nboundaries name the actual source:\n\n```ts\nconst users =\n yield *\n query('users', config, ({ resource }) => ({\n total: craftComputed('total', function* () {\n return (yield* settled(resource)).length;\n }),\n }));\n```\n\nDo not create `craftComputed('total', ...)` beside the query when the\ncomputation depends only on `users`.\n\n### Keep casts and synchronous reads out of templates\n\nCraft templates reject both `as ...` / angle-bracket assertions and\n`craftUse(...)`. Fix the type or perform the synchronous-to-reactive\nconversion in the component logic, then expose a typed reader or generator to\nthe template:\n\n```ts\nconst typedStep = machine.stepState as unknown as () => { step: Step };\nreturn { typedStep };\n\n// Template: no cast and no craftUse.\nmatchNode.exhaustive(typedStep, 'step', steps);\n```\n\n`no-craft-use` applies to Craft TypeScript files, not only the fourth\n`craftComponent(...)` argument. A synchronous integration boundary may opt out\nlocally when its external API cannot consume a generator, but application\nstate and templates should use `yield*`.\n\n### Form and accessibility diagnostics\n\nThe accessibility preset also checks the static structure of hyperscript:\n\n- give every `label` an `htmlFor` matching the control `id`, or wrap the control;\n- give named controls and helpers a unique string local name;\n- use `button` or `a` for interactions instead of adding `click` to a `div`;\n- add a `prefers-reduced-motion` branch whenever component CSS defines an\n animation or transition.\n\nThese checks run on Craft TypeScript templates and extracted helper factories,\nso moving markup into a local function does not bypass them.\n\n### Reactive values belong in binding callbacks\n\n`require-reactive-template-bindings` uses TypeScript type information to find\nreactive reads. Reading a signal while constructing a VNode would make it a\ndependency of the structural component render, so the rule rejects this form:\n\n```ts\n// Incorrect: count is read by the component template.\np(`Count: ${count()}`);\nbutton({ disabled: isDisabled() }, 'Save');\ndiv({ class: { active: isActive() } });\n```\n\nKeep each read inside the callback owned by its DOM binding. Pass a yieldable\nreader, or use a generator when the binding must format:\n\n```ts\np(count);\np(function* () {\n return `Count: ${yield* count()}`;\n});\nbutton({ disabled: isDisabled }, 'Save');\ndiv({ class: isActiveClass });\n```\n\nLiteral and otherwise static values are still allowed, as are reads performed\nfrom DOM events and `onXxx` output callbacks. Because the rule is type-aware,\nthe ESLint parser must use `projectService: true` or a TypeScript `project`.\n\n### Pass simple yieldable callbacks directly\n\n`prefer-direct-yieldable-callback` removes a generator wrapper when the\ntemplate only delegates one zero-argument callback. It handles both a value\nbinding and a generator method:\n\n```ts\n// Before: redundant wrappers around the callbacks.\nbutton(\n {\n *click() {\n yield* press();\n },\n },\n function* () {\n return yield* label();\n },\n);\n\n// After `eslint --fix`.\nbutton({ click: press }, label);\n```\n\nMember callbacks are supported as well when the access is static and has no\narguments:\n\n```ts\n// Before.\nspan(function* () {\n return yield* counter.increment();\n});\n\n// After.\nspan(counter.increment);\n```\n\nThe rule leaves callbacks with parameters, extra statements, or additional\ncomputation unchanged. In those cases the generator contains behavior that\ncannot be represented by passing the callback reference alone.\n\n### Prefer deep-yieldable `forNode` items\n\n`prefer-deep-yieldable-for-item` detects when a component reads several\nproperties from the same `forNode` item through repeated `yield* item()` calls.\nKeep the original collection available, and expose a named deep-yieldable\nview for the component:\n\n```ts\nimport { insertDeepYieldable, state } from '@craft-ts/core';\n\n// Before: every property read yields the whole item again.\nforNode(catalog.products, { track: (product) => product.id }, (product) =>\n article([\n span(function* () {\n return (yield* product()).category;\n }),\n span(function* () {\n return (yield* product()).name;\n }),\n ]),\n);\n\n// After: the named view keeps each property read lazy and reactive.\nconst catalog = yield* state(\n 'catalog',\n { products },\n insertDeepYieldable('products'),\n);\n\nforNode(\n catalog.deepYieldableProducts,\n { track: (product) => product.id },\n (product) => article([span(product.category), span(product.name)]),\n);\n```\n\nThe rule is diagnostic-only because choosing the insertion belongs to the\nprimitive that owns the collection. `insertDeepYieldable('products')` leaves\n`catalog.products` unchanged and adds `catalog.deepYieldableProducts`.\n\n### Yield insertion writes from generator methods\n\n`require-yieldable-insertion-write` requires `set(...)`, `patch(...)`, and\n`update(...)` calls to be delegated with `yield*` when they are used inside a\ngenerator method:\n\n```ts\nnextPage: function* () {\n const current = yield* state();\n return yield* patch({ page: current.page + 1 });\n},\n```\n\nInsertion callbacks that are not generators may return a write directly; the\ninsertion wrapper consumes that result for them.\n\n## What generates what\n\nThree rules do more than complain — they write code you would otherwise\nmaintain by hand:\n\n| Rule | Generates |\n| -------------------------------------------- | ------------------------------------------------------------- |\n| `require-assert-exhaustive-route-exceptions` | the collection-level exhaustiveness assert |\n| `require-child-route-mount-check` | the `assertChildRouteMounts(...)` call and its import |\n| `require-lazy-load-with-retry` | the `withRetry(...)` wrapper on lazy route imports |\n| `prefer-direct-yieldable-callback` | replaces redundant generators with direct callback references |\n\n## Adopting them progressively\n\nOn an existing codebase, enable them in waves rather than all at once:\n\n1. **The route safety nets** — the `require-*` rules. Mostly autofixable. They\n generate the proofs; [architecture tests](/guide/testing/architecture#assertroutediproofs)\n (`assertRouteDiProofs`) fail CI if a proof is later removed or left unarmed.\n2. **The architecture rules last** — `prefer-craft-service`,\n `no-craft-service-component-same-file`, `prefer-craft-http-client`,\n `require-yieldable-reactive-read`,\n `require-yieldable-template-method`, `require-yieldable-insertion-write`.\n These ask for real refactors.\n\nThe four style rules — `no-raw-class`, `no-raw-css-value`, `no-free-has`,\n`style-file-boundary` — are in `craftRules.configs.recommended` at `'error'`,\nand they are **gated on the import**: they fire only in files that import\n`@craft-ts/style`. A component you have not migrated is not claiming the\nguarantee, so nothing reports it. The day a file starts using the design system\nis the day it starts being held to it — which is why enabling them on an\nunmigrated codebase costs nothing.\n\nThe two migration rules also expose a VS Code quick fix that inserts a temporary\nlocal disable comment with the intended migration note, so you can unblock a\nfile before doing the full refactor.\n\n## See Also\n\n- [Routing setup](/guide/routing/setup) — where these rules are installed\n- [CLI automation](/guide/routing/automation) — the codemods they complement\n- [Architecture rules](/guide/testing/architecture) — graph-wide constraints ESLint cannot see\n- [Activating the style system](/guide/style/setup) — what the four style rules are guarding\n"
|
|
326
326
|
},
|
|
327
327
|
{
|
|
328
328
|
"path": "/guide/routing/exception-handling",
|
|
329
329
|
"title": "Route exception handling",
|
|
330
|
-
"body": "# Route exception handling\n\nWhen a guard, a matcher or a resolver raises a declared exception, this page is\nwhere you say what happens next: redirect, render a dedicated component, stay\nput, or carry on. One map per route resolves the **union** of every code those\nthree steps can produce — and the compiler checks that the map is exactly\ncomplete, no more and no less.\n\n**Use it when** a route's guards, matchers or resolvers can fail in ways the user\nshould see.\n**Not when** the failure is local to one primitive — read it off\n`exceptions()` instead, see\n[Exceptions as values](/guide/concepts/exceptions).\n\n::: warning Breaking change\nEvery handler must use `craftExceptionHandler(function* (...) {})`. Internal\nredirects use `yield* redirectTo({ to, params, queryParams, viewTransition })`;\nopaque URLs or prebuilt `UrlTree` values use `redirectUrl(...)`.\n`renderComponent`, route-level `errorComponent` and `withErrorComponent` accept\nonly `{ component | loadComponent, componentDeps }` descriptors. Bare handler\nfunctions, `redirect(...)` and bare error components are rejected.\n:::\n\n## The common case\n\n```ts\nUSER_DISABLED: craftExceptionHandler(function* ({ renderComponent }) {\n return renderComponent({\n loadComponent: () =>\n import('./user-disabled-error-page').then(\n (m) => m.UserDisabledErrorPage,\n ),\n componentDeps:\n {} as import('./user-disabled-error-page').GenDeps_UserDisabledErrorPage,\n });\n}),\n\nNOT_AUTHENTICATED: craftExceptionHandler(function* ({ redirectTo }) {\n return yield* redirectTo({\n to: 'auth/login',\n queryParams: { reason: 'session-expired' },\n });\n}),\n```\n\n`canActivate` / `canMatch` / `resolve` stay your **writing** API — each may raise\na typed [`craftException`](/guide/routing/guards#exceptions). Instead of an\ninline resolver map per guard, a single **`handleExceptions`** map on the route\nresolves the union of every code reachable from those three steps. The\nnon-blocking [`CraftRouterOutlet`](/guide/routing/pending-ui) applies the chosen\noutcome **after the URL has committed**, so a slow guard never freezes\nnavigation.\n\nThe route result also exposes a route-scoped signal helper per code, such as\n`injectDemoUserIdUserDisabledException()`. It returns the exact exception and\npayload for the locally rendered branch, and is cleared on the next navigation.\n\n## A full route, end to end\n\n```ts\nconst { profileQuery } = query('profileQuery', {\n params: () => true,\n loader: function* () {\n return yield* CraftHttpClient.get(({ response }) => ({\n url: '/api/profile',\n success: response<Profile>(),\n exceptions: [\n function* ({ status, code }) {\n if (!(yield* status(403))) return;\n if (!(yield* code('USER_DISABLED'))) return;\n return craftException({ _tag: 'USER_DISABLED' });\n },\n ],\n }));\n },\n});\n\ncraftRoute(\n 'user/:userId',\n {\n loadComponent: ({ withRetry }) => withRetry(import('./user-detail')),\n componentDeps: {} as import('./user-detail').GenDeps_UserDetail,\n canMatch: function* () {\n const ff = yield* FeatureFlags();\n return ff.userPageEnabled ? true : craftException({ _tag: 'FEATURE_OFF' });\n },\n canActivate: function* () {\n const user = yield* Auth();\n return user.value() ?? craftException({ _tag: 'NOT_AUTHENTICATED' });\n },\n resolve: craftResolve(function* () {\n return yield* craftUntilSettled(profileQuery);\n }),\n },\n {\n FEATURE_OFF: craftExceptionHandler(function* ({ redirectTo }) {\n return yield* redirectTo({ to: 'home' });\n }),\n NOT_AUTHENTICATED: craftExceptionHandler(function* ({ redirectTo, phase }) {\n return yield* redirectTo({\n to: 'login',\n queryParams: phase === 'active' ? { reason: 'session-expired' } : {},\n });\n }),\n USER_DISABLED: craftExceptionHandler(function* ({ globalError }) {\n return globalError();\n }),\n HttpError: craftExceptionHandler(function* ({ globalError }) {\n return globalError();\n }),\n },\n),\n```\n\n`canActivate` / `canMatch` are bare generator functions — there is no guard wrapper and no inline\n`resolvers` argument. Every reachable code flows to the third `craftRoute(...)` argument.\n\n## Handler context\n\nEvery handler receives a `CraftExceptionHandlerContext` typed for its exception code:\n\n| Field | Type / purpose |\n| ----------------- | -------------------------------------------------------------------------------------------- |\n| `exception` | The complete typed `craftException`, including `code`, `scope`, and `payload`. |\n| `payload` | The typed payload passed as the second argument of `craftException(...)`. |\n| `phase` | `'enter'` during initial activation, `'active'` during a live guard re-check. |\n| `router` | The active `Router` instance. |\n| `createUrlTree` | Bound `Router.createUrlTree`, useful for building a redirect with query params or fragments. |\n| `navigate` | Bound `Router.navigate`. Imperative; prefer returning `yield* redirectTo(...)`. |\n| `navigateByUrl` | Bound `Router.navigateByUrl`. Imperative; prefer a redirect outcome. |\n| `redirectTo` | Typed internal redirect checked against `META_PATHS`; yields `CraftRouter`. |\n| `redirectUrl` | Explicit escape hatch for an opaque string URL or `UrlTree`. |\n| `renderComponent` | Builds an outcome that renders a dedicated component. |\n| `globalError` | Delegates rendering to the application-wide error component. |\n| `stay` | Restores the previous URL and keeps the triggering page. |\n| `noop` | Continues to the target despite the exception. Resolve data remains `undefined`. |\n\nA handler is always a synchronous generator wrapped with `craftExceptionHandler`. It may resolve\nservices but cannot suspend with `craftUntilSettled` / `craftUntilDefined`.\n\n## Outcomes\n\nEach handler receives a context and returns an outcome constructor:\n\n| Outcome | Effect |\n| ----------------------------- | -------------------------------------------------------------------------------------------------------- |\n| `yield* redirectTo(input)` | Navigate to a registered internal route with typed params/query params/view transition. |\n| `redirectUrl(target)` | Navigate to an opaque string URL or `UrlTree`. |\n| `renderComponent(descriptor)` | Render a DI-checked `{ component \\| loadComponent, componentDeps }` descriptor. |\n| `globalError()` | Render the application-wide error component (see [global error component](./global-error-component.md)). |\n| `stay()` | Cancel the navigation; restore the previous URL (stay on the triggering page). |\n| `noop()` | Render the target anyway, with `resolve` data left `undefined`. |\n\nThe context also carries the typed `exception`, its `payload`, the native `redirect`\nhelpers (`createUrlTree` / `navigate` / `navigateByUrl`), and the navigation `phase` (see below). A\nhandler may be a **generator** that `yield*`s craft services before its outcome.\n\n## Examples\n\n### Typed payload and `UrlTree`\n\nUse `redirectTo(...)` for registered application routes and `redirectUrl(...)` for a prebuilt\n`UrlTree`:\n\n```ts\n{\n NOT_AUTHENTICATED: craftExceptionHandler(function* ({ redirectTo }) {\n return yield* redirectTo({ to: 'auth/login' });\n }),\n RATE_LIMITED: craftExceptionHandler(function* ({ payload, redirectTo }) {\n return yield* redirectTo({\n to: 'cooldown',\n queryParams: { retryAfter: String(payload.retryAfter) },\n });\n }),\n}\n```\n\nHere `payload` is inferred from `craftException({ _tag: 'RATE_LIMITED' }, { retryAfter: 30 })`.\n\n### Initial entry versus live guard\n\n```ts\n{\n NOT_AUTHENTICATED: craftExceptionHandler(function* ({ phase, redirectTo }) {\n return yield* redirectTo({\n to: 'login',\n queryParams: phase === 'active' ? { reason: 'session-expired' } : {},\n });\n }),\n}\n```\n\n### Local, global, stay, and noop outcomes\n\n```ts\n{\n ACCOUNT_LOCKED: craftExceptionHandler(function* ({ renderComponent }) {\n return renderComponent({\n component: AccountLockedPage,\n componentDeps: {} as import('./account-locked-page').GenDeps_AccountLockedPage,\n });\n }),\n MAINTENANCE: craftExceptionHandler(function* ({ renderComponent }) {\n return renderComponent({\n loadComponent: () => import('./maintenance-page').then((m) => m.MaintenancePage),\n componentDeps: {} as import('./maintenance-page').GenDeps_MaintenancePage,\n });\n }),\n HttpError: craftExceptionHandler(function* ({ globalError }) { return globalError(); }),\n UNSAVED_CHANGES: craftExceptionHandler(function* ({ stay }) { return stay(); }),\n OPTIONAL_PROFILE_UNAVAILABLE: craftExceptionHandler(function* ({ noop }) { return noop(); }),\n}\n```\n\nThe descriptor is checked independently with the O(1)\n`RouteExceptionComponentCheckedDI`; it is not added to `ValidateCascadeRoutesFile`.\n[Architecture tests](/guide/testing/architecture#assertroutediproofs) fail if\nthat proof is missing or not armed with `CanRun`.\n\n### Handler using a craft service\n\n```ts\n{\n FORBIDDEN_ROLE: craftExceptionHandler(function* ({ redirectUrl }) {\n const config = yield* RedirectConfig();\n return redirectUrl(config.unauthorizedUrl);\n }),\n}\n```\n\nDependencies yielded by handlers participate in route DI checking, like dependencies yielded by\nguards and resolvers.\n\n## Exhaustiveness\n\nThe union is only resolvable once the whole collection is inferred, so exhaustiveness is asserted\n**after** `craftRoutes` (mirroring the cascade DI check) rather than inline on each route:\n\n```ts\nexport const { demoRoutes } = craftRoutes('demo', [\n /* … */\n]);\n\n// Compile error if any route's handleExceptions misses — or over-covers — a reachable code.\nassertExhaustiveRouteExceptions(demoRoutes);\n```\n\n[Architecture tests](/guide/testing/architecture#assertroutediproofs) fail if a\n`craftRoutes(...)` collection has no `assertExhaustiveRouteExceptions`.\n\nA missing code (e.g. `resolve` can throw `USER_DISABLED` but no handler) **and** an extra code (a\nhandler for a code nothing can produce) are both type errors, naming the offending route + codes.\n\n## Pitfalls\n\n**`HttpError` appears or disappears depending on the `craftUntilSettled` form.**\nThis is the most common surprise:\n\n- `craftUntilSettled(CraftHttpClient.get(...))` **excludes** `HttpError` from the\n routable union and rethrows it. The outlet sends that navigation error to the\n global error component.\n- `craftUntilSettled(queryRef)` routes every exception the query exposes. When\n its loader returns a `CraftHttpClient` request, that **includes** `HttpError`,\n so the route must declare an explicit handler such as\n `HttpError: craftExceptionHandler(function* ({ globalError }) { return globalError(); })`.\n\nDeclared business exceptions remain routable in both forms.\n\n**A handler cannot suspend.** It may `yield*` services, but not\n`craftUntilSettled` / `craftUntilDefined`.\n\n**Over-covering is an error too.** A handler for a code nothing can produce fails\nthe exhaustiveness assert, same as a missing one.\n\n::: details The `phase` field\n`phase` distinguishes the initial activation (`'enter'`) from a reactive\nre-evaluation (`'active'`) of a live `canActivate` guard (see [live\nguards](/guide/routing/guards#reactive-guards)). Use it to soften a reaction\nmid-session — a different redirect reason on session expiry, say — or ignore the\nreactive phase entirely with `noop()`.\n:::\n\n## See Also\n\n- [Exceptions as values](/guide/concepts/exceptions) — the concept\n- [Route guards](/guide/routing/guards) — where exceptions are raised\n- [Global error component](/guide/routing/global-error-component)\n- [Architecture rules](/guide/testing/architecture) — `assertRouteDiProofs` keeps the exhaustiveness assert in place\n"
|
|
330
|
+
"body": "# Route exception handling\n\nWhen a guard, a matcher or a resolver raises a declared exception, this page is\nwhere you say what happens next: redirect, render a dedicated component, stay\nput, or carry on. One map per route resolves the **union** of every code those\nthree steps can produce — and the compiler checks that the map is exactly\ncomplete, no more and no less.\n\n**Use it when** a route's guards, matchers or resolvers can fail in ways the user\nshould see.\n**Not when** the failure is local to one primitive — read it off\n`exceptions()` instead, see\n[Exceptions as values](/guide/concepts/exceptions).\n\n::: warning Breaking change\nEvery handler must use `craftExceptionHandler(function* (...) {})`. Internal\nredirects use `yield* redirectTo({ to, params, queryParams, viewTransition })`;\nopaque URLs or prebuilt `UrlTree` values use `redirectUrl(...)`.\n`renderComponent`, route-level `errorComponent` and `withErrorComponent` accept\nonly `{ component | loadComponent, componentDeps }` descriptors. Bare handler\nfunctions, `redirect(...)` and bare error components are rejected.\n:::\n\n## The common case\n\n```ts\nUSER_DISABLED: craftExceptionHandler(function* ({ renderComponent }) {\n return renderComponent({\n loadComponent: () =>\n import('./user-disabled-error-page').then(\n (m) => m.UserDisabledErrorPage,\n ),\n componentDeps:\n {} as import('./user-disabled-error-page').GenDeps_UserDisabledErrorPage,\n });\n}),\n\nNOT_AUTHENTICATED: craftExceptionHandler(function* ({ redirectTo }) {\n return yield* redirectTo({\n to: 'auth/login',\n queryParams: { reason: 'session-expired' },\n });\n}),\n```\n\n`canActivate` / `canMatch` / `resolve` stay your **writing** API — each may raise\na typed [`craftException`](/guide/routing/guards#exceptions). Instead of an\ninline resolver map per guard, a single **`handleExceptions`** map on the route\nresolves the union of every code reachable from those three steps. The\nnon-blocking [`CraftRouterOutlet`](/guide/routing/pending-ui) applies the chosen\noutcome **after the URL has committed**, so a slow guard never freezes\nnavigation.\n\nThe route result also exposes a route-scoped signal helper per code, such as\n`injectDemoUserIdUserDisabledException()`. It returns the exact exception and\npayload for the locally rendered branch, and is cleared on the next navigation.\n\n## A full route, end to end\n\n```ts\nconst { profileQuery } = query('profileQuery', {\n params: () => true,\n loader: function* () {\n return yield* CraftHttpClient.get(({ response }) => ({\n url: '/api/profile',\n success: response<Profile>(),\n exceptions: [\n function* ({ status, code }) {\n if (!(yield* status(403))) return;\n if (!(yield* code('USER_DISABLED'))) return;\n return craftException({ _tag: 'USER_DISABLED' });\n },\n ],\n }));\n },\n});\n\ncraftRoute(\n 'user/:userId',\n {\n loadComponent: ({ withRetry }) => withRetry(import('./user-detail')),\n componentDeps: {} as import('./user-detail').GenDeps_UserDetail,\n canMatch: function* () {\n const ff = yield* FeatureFlags();\n return ff.userPageEnabled ? true : craftException({ _tag: 'FEATURE_OFF' });\n },\n canActivate: function* () {\n const user = yield* Auth();\n return user.value() ?? craftException({ _tag: 'NOT_AUTHENTICATED' });\n },\n resolve: craftResolve(function* () {\n return yield* craftUntilSettled(profileQuery);\n }),\n },\n {\n FEATURE_OFF: craftExceptionHandler(function* ({ redirectTo }) {\n return yield* redirectTo({ to: 'home' });\n }),\n NOT_AUTHENTICATED: craftExceptionHandler(function* ({ redirectTo, phase }) {\n return yield* redirectTo({\n to: 'login',\n queryParams: phase === 'active' ? { reason: 'session-expired' } : {},\n });\n }),\n USER_DISABLED: craftExceptionHandler(function* ({ globalError }) {\n return globalError();\n }),\n HttpError: craftExceptionHandler(function* ({ globalError }) {\n return globalError();\n }),\n },\n),\n```\n\n`canActivate` / `canMatch` are bare generator functions — there is no guard wrapper and no inline\n`resolvers` argument. Every reachable code flows to the third `craftRoute(...)` argument.\n\n## Handler context\n\nEvery handler receives a `CraftExceptionHandlerContext` typed for its exception code:\n\n| Field | Type / purpose |\n| ----------------- | -------------------------------------------------------------------------------------------- |\n| `exception` | The complete typed `craftException`, including `code`, `scope`, and `payload`. |\n| `payload` | The typed payload passed as the second argument of `craftException(...)`. |\n| `phase` | `'enter'` during initial activation, `'active'` during a live guard re-check. |\n| `router` | The active `Router` instance. |\n| `createUrlTree` | Bound `Router.createUrlTree`, useful for building a redirect with query params or fragments. |\n| `navigate` | Bound `Router.navigate`. Imperative; prefer returning `yield* redirectTo(...)`. |\n| `navigateByUrl` | Bound `Router.navigateByUrl`. Imperative; prefer a redirect outcome. |\n| `redirectTo` | Typed internal redirect checked against `META_PATHS`; yields `CraftRouter`. |\n| `redirectUrl` | Explicit escape hatch for an opaque string URL or `UrlTree`. |\n| `renderComponent` | Builds an outcome that renders a dedicated component. |\n| `globalError` | Delegates rendering to the application-wide error component. |\n| `stay` | Restores the previous URL and keeps the triggering page. |\n| `noop` | Continues to the target despite the exception. Resolve data remains `undefined`. |\n\nA handler is always a synchronous generator wrapped with `craftExceptionHandler`. It may resolve\nservices but cannot suspend with `craftUntilSettled` / `craftUntilDefined`.\n\n## Outcomes\n\nEach handler receives a context and returns an outcome constructor:\n\n| Outcome | Effect |\n| ----------------------------- | -------------------------------------------------------------------------------------------------------- |\n| `yield* redirectTo(input)` | Navigate to a registered internal route with typed params/query params/view transition. |\n| `redirectUrl(target)` | Navigate to an opaque string URL or `UrlTree`. |\n| `renderComponent(descriptor)` | Render a DI-checked `{ component \\| loadComponent, componentDeps }` descriptor. |\n| `globalError()` | Render the application-wide error component (see [global error component](./global-error-component.md)). |\n| `stay()` | Cancel the navigation; restore the previous URL (stay on the triggering page). |\n| `noop()` | Render the target anyway, with `resolve` data left `undefined`. |\n\nThe context also carries the typed `exception`, its `payload`, the native `redirect`\nhelpers (`createUrlTree` / `navigate` / `navigateByUrl`), and the navigation `phase` (see below). A\nhandler may be a **generator** that `yield*`s craft services before its outcome.\n\n## Examples\n\n### Typed payload and `UrlTree`\n\nUse `redirectTo(...)` for registered application routes and `redirectUrl(...)` for a prebuilt\n`UrlTree`:\n\n```ts\n{\n NOT_AUTHENTICATED: craftExceptionHandler(function* ({ redirectTo }) {\n return yield* redirectTo({ to: 'auth/login' });\n }),\n RATE_LIMITED: craftExceptionHandler(function* ({ payload, redirectTo }) {\n return yield* redirectTo({\n to: 'cooldown',\n queryParams: { retryAfter: String(payload.retryAfter) },\n });\n }),\n}\n```\n\nHere `payload` is inferred from `craftException({ _tag: 'RATE_LIMITED' }, { retryAfter: 30 })`.\n\n### Initial entry versus live guard\n\n```ts\n{\n NOT_AUTHENTICATED: craftExceptionHandler(function* ({ phase, redirectTo }) {\n return yield* redirectTo({\n to: 'login',\n queryParams: phase === 'active' ? { reason: 'session-expired' } : {},\n });\n }),\n}\n```\n\n### Local, global, stay, and noop outcomes\n\n```ts\n{\n ACCOUNT_LOCKED: craftExceptionHandler(function* ({ renderComponent }) {\n return renderComponent({\n component: AccountLockedPage,\n componentDeps: {} as import('./account-locked-page').GenDeps_AccountLockedPage,\n });\n }),\n MAINTENANCE: craftExceptionHandler(function* ({ renderComponent }) {\n return renderComponent({\n loadComponent: () => import('./maintenance-page').then((m) => m.MaintenancePage),\n componentDeps: {} as import('./maintenance-page').GenDeps_MaintenancePage,\n });\n }),\n HttpError: craftExceptionHandler(function* ({ globalError }) { return globalError(); }),\n UNSAVED_CHANGES: craftExceptionHandler(function* ({ stay }) { return stay(); }),\n OPTIONAL_PROFILE_UNAVAILABLE: craftExceptionHandler(function* ({ noop }) { return noop(); }),\n}\n```\n\nThe descriptor is checked independently with the O(1)\n`RouteExceptionComponentCheckedDI`; it is not part of the routed component's\n`RouteCheckedDI` proof.\n[Architecture tests](/guide/testing/architecture#assertroutediproofs) fail if\nthat proof is missing or not armed with `CanRun`.\n\n### Handler using a craft service\n\n```ts\n{\n FORBIDDEN_ROLE: craftExceptionHandler(function* ({ redirectUrl }) {\n const config = yield* RedirectConfig();\n return redirectUrl(config.unauthorizedUrl);\n }),\n}\n```\n\nDependencies yielded by handlers participate in route DI checking, like dependencies yielded by\nguards and resolvers.\n\n## Exhaustiveness\n\nThe union is only resolvable once the whole collection is inferred, so exhaustiveness is asserted\n**after** `craftRoutes` rather than inline on each route:\n\n```ts\nexport const { demoRoutes } = craftRoutes('demo', [\n /* … */\n]);\n\n// Compile error if any route's handleExceptions misses — or over-covers — a reachable code.\nassertExhaustiveRouteExceptions(demoRoutes);\n```\n\n[Architecture tests](/guide/testing/architecture#assertroutediproofs) fail if a\n`craftRoutes(...)` collection has no `assertExhaustiveRouteExceptions`.\n\nA missing code (e.g. `resolve` can throw `USER_DISABLED` but no handler) **and** an extra code (a\nhandler for a code nothing can produce) are both type errors, naming the offending route + codes.\n\n## Pitfalls\n\n**`HttpError` appears or disappears depending on the `craftUntilSettled` form.**\nThis is the most common surprise:\n\n- `craftUntilSettled(CraftHttpClient.get(...))` **excludes** `HttpError` from the\n routable union and rethrows it. The outlet sends that navigation error to the\n global error component.\n- `craftUntilSettled(queryRef)` routes every exception the query exposes. When\n its loader returns a `CraftHttpClient` request, that **includes** `HttpError`,\n so the route must declare an explicit handler such as\n `HttpError: craftExceptionHandler(function* ({ globalError }) { return globalError(); })`.\n\nDeclared business exceptions remain routable in both forms.\n\n**A handler cannot suspend.** It may `yield*` services, but not\n`craftUntilSettled` / `craftUntilDefined`.\n\n**Over-covering is an error too.** A handler for a code nothing can produce fails\nthe exhaustiveness assert, same as a missing one.\n\n::: details The `phase` field\n`phase` distinguishes the initial activation (`'enter'`) from a reactive\nre-evaluation (`'active'`) of a live `canActivate` guard (see [live\nguards](/guide/routing/guards#reactive-guards)). Use it to soften a reaction\nmid-session — a different redirect reason on session expiry, say — or ignore the\nreactive phase entirely with `noop()`.\n:::\n\n## See Also\n\n- [Exceptions as values](/guide/concepts/exceptions) — the concept\n- [Route guards](/guide/routing/guards) — where exceptions are raised\n- [Global error component](/guide/routing/global-error-component)\n- [Architecture rules](/guide/testing/architecture) — `assertRouteDiProofs` keeps the exhaustiveness assert in place\n"
|
|
331
331
|
},
|
|
332
332
|
{
|
|
333
333
|
"path": "/guide/routing/global-error-component",
|
|
@@ -337,12 +337,12 @@
|
|
|
337
337
|
{
|
|
338
338
|
"path": "/guide/routing/guards",
|
|
339
339
|
"title": "Route guards",
|
|
340
|
-
"body": "# Route guards\n\nA guard is a bare `function*` on `canActivate` / `canMatch`. It yields what it\nneeds, and returns either a value or a `craftException` describing why the route\nmust not render.\n\n**Use one when** access to a route depends on state: authentication, a role, a\nfeature flag, an onboarding step.\n**Not when** the answer is a redirect with no condition — that is a static\nroute.\n\nGuards here are **reusable and parameterised**, they **compose** inside a single\n`canActivate` / `canMatch`, and their failure cases are resolved\n**exhaustively** — an unhandled case is a **type error**.\n\n> **A guard is just a generator function.** `canActivate` / `canMatch` take a bare\n> `function* () { … }` directly — there is no `craftCanActivate` / `craftCanMatch` wrapper and no\n> inline `resolvers` argument. Every reachable `craftException` is resolved by a single, exhaustive\n> **[`handleExceptions`](/guide/concepts/exceptions)** map on the route, applied **after the URL commits**\n> by the non-blocking [`CraftRouterOutlet`](/guide/routing/pending-ui).\n\n## The problem\n\nA `craftRoutes` `canActivate` accepts a single function (or generator function). To apply several\nauthorization rules — role, account state, feature flag… — you have to inline everything into one\ngenerator and hand-roll each rejection by returning a `createUrlTree(...)`:\n\n```ts\ncanActivate: function* () {\n const { user } = yield* CraftAuth(undefined, ({ user }) => ({ user }));\n if (!user()) {\n return createUrlTree(['/auth/login']); // not authenticated\n }\n if (user()!.role !== 'admin') {\n return createUrlTree(['/unauthorized']); // wrong role\n }\n const { pizzeria } = yield* CraftAuth(undefined, ({ pizzeria }) => ({ pizzeria }));\n if (pizzeria()) {\n return createUrlTree(['/dashboard']); // already onboarded\n }\n return true;\n}\n```\n\nThe rules are not reusable, the redirect logic is tangled with the checks, and nothing forces you to\nhandle every rejection — forget a branch and it silently falls through.\n\n## The solution: `craftGen` + a composing generator guard\n\nSplit the two concerns:\n\n- **`craftGen`** authors a reusable, parameterised guard. It either returns a success value or a\n typed [`craftException`](#exceptions).\n- The route's **`canActivate` generator** composes guards with `yield*`; the route's exhaustive\n [`handleExceptions`](/guide/concepts/exceptions) map must cover **exactly** the reachable exception codes.\n\nFor a focused overview of `craftGen` itself and why it is useful, see\n[`craftGen`](/guide/concepts/generators).\n\n```ts\nimport {\n craftException,\n craftGen,\n craftResolve,\n CraftHttpClient,\n query,\n craftRoute,\n craftUntilSettled,\n} from '@craft-ts/core';\n\n// Reusable guards — each returns a success value | craftException(...)\nconst roleGuard = craftGen(\n (...roles: Role[]) =>\n function* () {\n const { user } = yield* CraftAuth(undefined, ({ user }) => ({\n user,\n }));\n if (!user()) return craftException({ _tag: 'NOT_AUTHENTICATED' });\n return roles.includes(user()!.role)\n ? true\n : craftException({ _tag: 'FORBIDDEN_ROLE' });\n },\n);\n\nconst noPizzeriaGuard = craftGen(\n () =>\n function* () {\n const { pizzeria } = yield* CraftAuth(undefined, ({ pizzeria }) => ({\n pizzeria,\n }));\n return pizzeria() ? craftException({ _tag: 'HAS_PIZZERIA' }) : true;\n },\n);\n\nconst { pizzeriaDraftQuery } = query('pizzeriaDraftQuery', {\n params: () => true,\n loader: function* () {\n return yield* CraftHttpClient.get(({ response }) => ({\n url: '/api/pizzerias/draft',\n success: response<PizzeriaDraft>(),\n exceptions: [\n function* ({ status }) {\n if (!(yield* status(404))) return;\n return craftException({ _tag: 'PIZZERIA_DRAFT_UNAVAILABLE' });\n },\n ],\n }));\n },\n});\n\ncraftRoute(\n 'new',\n {\n title: 'Create Pizzeria',\n canActivate: function* () {\n yield* roleGuard(ROLES.PIZZERIA_ADMIN); // short-circuits on exception\n yield* noPizzeriaGuard();\n return true;\n },\n resolve: craftResolve(function* () {\n return yield* craftUntilSettled(pizzeriaDraftQuery);\n }),\n loadComponent: ({ withRetry }) =>\n withRetry(\n import('./pages/admin-pizzeria-form-page/admin-pizzeria-form-page'),\n ).then((m) => m.AdminPizzeriaFormPage),\n componentDeps:\n {} as import('./pages/admin-pizzeria-form-page/admin-pizzeria-form-page').GenDeps_AdminPizzeriaFormPage,\n },\n {\n // Resolved centrally — exhaustive over canActivate ∪ canMatch ∪ resolve.\n NOT_AUTHENTICATED: craftExceptionHandler(function* ({ redirectTo }) {\n return yield* redirectTo({ to: 'auth/login' });\n }),\n FORBIDDEN_ROLE: craftExceptionHandler(function* ({ redirectTo }) {\n return yield* redirectTo({ to: 'unauthorized' });\n }),\n HAS_PIZZERIA: craftExceptionHandler(function* ({ redirectTo }) {\n return yield* redirectTo({ to: 'pizzerias/admin' });\n }),\n PIZZERIA_DRAFT_UNAVAILABLE: craftExceptionHandler(function* ({\n globalError,\n }) {\n return globalError();\n }),\n HttpError: craftExceptionHandler(function* ({ globalError }) {\n return globalError();\n }),\n },\n);\n\n// After the collection is defined, assert every route handles exactly its codes:\n// assertExhaustiveRouteExceptions(adminRoutes);\n```\n\n## Reactive guards\n\nWhile a route is active, its `canActivate` invariant stays **under observation** (live guards, on by\ndefault). If a signal the guard reads changes — e.g. the user logs out and `Auth` becomes `null` —\nthe guard re-evaluates synchronously and applies [`handleExceptions`](/guide/concepts/exceptions) with\n`phase: 'active'`, so the target is never left rendered in an incoherent state. The reactive phase\nnever re-runs `resolve` (no new pending). Opt out per route with `reactiveGuards: false`.\n\n## How composition works\n\n`craftGen(factory)` returns a factory you invoke and delegate to with `yield*`:\n\n- The guard's **dependency yields** (`CraftAuth`, `CraftRouter`, …) flow up to the\n route exactly as in a plain generator guard, so [cascade DI tracking](/guide/routing/setup)\n still sees them.\n- As soon as a composed guard produces a `craftException`, the enclosing generator\n **short-circuits**: `yield* roleGuard(...)` interrupts the whole `function*`, and the exception is\n propagated to the route's guard boundary — no `if`/`return` plumbing in the composing guard.\n- The set of exceptions each guard can produce is tracked **at the type level**, so the route's\n `handleExceptions` map knows precisely which codes it must handle.\n\nOrder matters: guards run top-to-bottom and the first exception wins (fail-fast).\n\n## The handler context\n\nEach route exception handler receives the typed exception and payload, the navigation phase, the\ntyped `Router` helpers, and the five outcome constructors. See\n[Centralised Exception Handling](/guide/concepts/exceptions#handler-context) for the exhaustive list\nand examples.\n\nUse `redirectTo(...)` for typed internal routes:\n\n```ts\n{\n RATE_LIMITED: craftExceptionHandler(function* ({ payload, redirectTo }) {\n return yield* redirectTo({\n to: 'cooldown',\n queryParams: { retryAfter: String(payload.retryAfter) },\n });\n }),\n}\n```\n\nA handler returns a `CraftExceptionOutcome` via `redirectTo`, `redirectUrl`, `renderComponent`,\n`globalError`, `stay`, or `noop`. The `payload` is taken from `craftException({ _tag }, payload)`'s second argument\nand typed per code.\n\n## Handlers can yield services\n\nA handler may be a **generator** that `yield*`s craft services before building the redirect — for\nexample to read the login URL from a config service. Those yields are tracked exactly like the\nguards' own dependencies, so a service used only at redirect-time still flows into the route's\n[cascade DI](/guide/routing/setup) (yield an unprovided service and it surfaces as a\nmissing-provider error on the route):\n\n```ts\ncraftRoute(\n 'admin',\n {\n canActivate: function* () {\n yield* roleGuard(ROLES.ADMIN);\n return true;\n },\n },\n {\n // Generator handler — `RedirectConfig` becomes a tracked route dependency.\n FORBIDDEN_ROLE: craftExceptionHandler(function* ({ redirectUrl }) {\n const { unauthorizedUrl } = yield* RedirectConfig();\n return redirectUrl(unauthorizedUrl);\n }),\n },\n);\n```\n\nEvery handler uses the generator wrapper, including handlers that do not yield a service.\n\n## Exhaustiveness\n\nThe handler map is typed over the reachable codes, so **every** reachable code must be handled — a\nmissing one is a type error:\n\n```ts\ncraftRoute(\n 'admin',\n { canActivate: guard },\n {\n FORBIDDEN_ROLE: craftExceptionHandler(function* ({ redirectTo }) {\n return yield* redirectTo({ to: 'unauthorized' });\n }),\n // Type error: Property 'HAS_PIZZERIA' is missing.\n },\n);\n```\n\nAdd a guard that can raise a new code, and every route using it stops compiling until its handler is\nadded. A typo'd code is caught the same way because the correctly-spelled key is then missing.\n\n## Guarded data still flows through\n\nA `canActivate` guard's **success value** (anything other than `true`/`UrlTree`/…) becomes the\nroute's [guarded data](/guide/routing/route-providers) — `craftException` returns are never\ntreated as data:\n\n```ts\nconst authGuard = craftGen(\n () =>\n function* () {\n const user = yield* Auth();\n const userValue = user.value();\n return userValue\n ? userValue\n : craftException({ _tag: 'NOT_AUTHENTICATED' });\n },\n);\n\ncraftRoute(\n 'query/:userId',\n {\n componentDeps: {} as import('./query').GenDeps_GlobalQuery,\n loadComponent: ({ withRetry }) => withRetry(import('./query')),\n canActivate: function* () {\n return yield* authGuard(); // success value = the user\n },\n },\n {\n NOT_AUTHENTICATED: craftExceptionHandler(function* ({ redirectUrl }) {\n return redirectUrl('/login-form');\n }),\n },\n).withProviders(({ GuardedData }) => [\n provideUser(function* () {\n return (yield* GuardedData())(); // Signal<User> → User\n }),\n]);\n```\n\n## `canMatch`\n\n`canMatch` is the sibling of `canActivate` — same composition and exhaustive resolution through\n`handleExceptions`. Unlike `canActivate`, a `canMatch` guard produces no guarded data.\n\n```ts\nconst featureFlagGuard = craftGen(\n (flag: string) =>\n function* () {\n const { flags } = yield* CraftConfig();\n return flags[flag] ? true : craftException({ _tag: 'FLAG_DISABLED' });\n },\n);\n\ncraftRoute(\n 'beta',\n {\n componentDeps: {} as import('./beta').GenDeps_Beta,\n loadComponent: ({ withRetry }) => withRetry(import('./beta')),\n canMatch: function* () {\n yield* featureFlagGuard('beta');\n return true;\n },\n },\n {\n FLAG_DISABLED: craftExceptionHandler(function* ({ redirectUrl }) {\n return redirectUrl('/home');\n }),\n },\n);\n```\n\n## Async guards {#async-guards}\n\nThe guards above are **synchronous** — every `craftGen` resolves in one pass. To decide based on data\nthat has to be _fetched first_, suspend the composing guard with `craftUntilSettled` (or `craftUntilDefined`).\nThe guard stays a normal generator: `yield* a(); const x = yield* craftUntilSettled(...); yield* b()`\ncomposes across the await, and the awaited operation's `craftException`s flow into the same\nexhaustive `handleExceptions` map — the compiler still forces you to handle every reachable code.\n\n### `craftUntilSettled` — await a resource or an HTTP call\n\n`craftUntilSettled` takes either a craft **resource** (`query` / `mutation` / `asyncProcess`) or a\n`CraftHttpClient.*` **call** and suspends until it settles, then returns its success value.\n\n```ts\ncraftRoute(\n 'users/:userId',\n {\n componentDeps: {} as import('./user').GenDeps_User,\n loadComponent: ({ withRetry }) => withRetry(import('./user')),\n canActivate: function* (route) {\n const userId = route.params['userId'];\n\n // (a) Await an HTTP call directly — no named resource needed. Its declared\n // `exceptions` flow into the route's handleExceptions below.\n const user = yield* craftUntilSettled(\n CraftHttpClient.get(({ response }) => ({\n url: `/api/users/${userId}`,\n success: response<User>(),\n exceptions: [\n function* ({ status, code }) {\n if (!(yield* status(400))) return;\n if (!(yield* code('PASSWORD_REQUIRED'))) return;\n return craftException({\n _tag: 'PASSWORD_REQUIRED',\n scope: 'UsersFeature',\n });\n },\n ],\n })),\n );\n\n return user.active ? true : craftException({ _tag: 'INACTIVE_USER' });\n },\n },\n {\n // Both the guard's own exception AND the HTTP call's exception are required.\n INACTIVE_USER: craftExceptionHandler(function* ({ redirectUrl }) {\n return redirectUrl('/inactive');\n }),\n PASSWORD_REQUIRED: craftExceptionHandler(function* ({ redirectUrl }) {\n return redirectUrl('/password');\n }),\n },\n);\n```\n\nThe **resource** form is identical — pass the ref (an inline `query(name, ...)` works, though it is\nreactive; prefer the HTTP form for one-shots):\n\n```ts\nconst user =\n yield *\n craftUntilSettled(\n query('user', {\n params: () => userId,\n loader: ({ params }) => fetchUser(params),\n }).user,\n );\n```\n\n**Settle semantics & exception routing:**\n\n- A resource settles when its `status` reaches `'resolved'` or `'error'`. A loader `craftException`\n **short-circuits** to `handleExceptions`; a thrown loader error is **rethrown**; otherwise the\n resolved value is returned.\n- An HTTP call's declared business `exceptions` short-circuit to `handleExceptions`. The generic\n transport-level `HttpError` (`scope: 'HttpClient'`) is **rethrown** — a network failure is not a\n resolvable business case. (An opt-in `HttpError` handler may come later.)\n- The awaited HTTP endpoint is tracked as a route dependency automatically, exactly like one used in\n a component or loader.\n\n### `craftUntilDefined` — await a readiness signal\n\n`craftUntilDefined(signal)` suspends until `signal()` is no longer `undefined`, then returns its\nnon-nullable value. There is no exception channel — use it to wait on a plain readiness signal.\n\n```ts\nconst session = yield * craftUntilDefined(sessionService.current);\n```\n\n### Notes\n\n- A guard that never reaches an `craftUntilSettled` / `craftUntilDefined` await still resolves **synchronously**\n (no forced microtask) — existing synchronous guards are unchanged.\n- This works for both `canActivate` and `canMatch`; the outlet drives the guard to settlement after\n the URL commits.\n\n## Exceptions {#exceptions}\n\nGuards fail with `craftException({ _tag }, payload?)` — the same typed-exception primitive used by\n`query` / `mutation`:\n\n```ts\ncraftException({ _tag: 'FORBIDDEN_ROLE' });\ncraftException({ _tag: 'RATE_LIMITED' }, { retryAfter: 30 }); // payload reaches the handler\n```\n\nThe `code` drives both the exhaustiveness check and the handler lookup; the optional payload is\ntyped and forwarded to the handler.\n\n## When to reach for it\n\n`craftGen` + a `canActivate` / `canMatch` generator fit **sequential, fail-fast gates resolved at a\nsingle boundary**: authorization, account-state checks, feature flags, action preconditions.\n\nIt is **not** the right tool when you want to **collect and surface multiple failures**\nreactively — that is what `query` / `mutation` `hasException` and the form-submit exception model are\nfor. Guards stop at the first failure and hand off to a handler.\n\n## See Also\n\n- [Route Providers](/guide/routing/route-providers) — consume guarded data in route providers\n- [Setup](/guide/routing/setup) — the app-wide cascade DI check\n- [craftService](/guide/app/craft-service) — services yielded inside guards\n"
|
|
340
|
+
"body": "# Route guards\n\nA guard is a bare `function*` on `canActivate` / `canMatch`. It yields what it\nneeds, and returns either a value or a `craftException` describing why the route\nmust not render.\n\n**Use one when** access to a route depends on state: authentication, a role, a\nfeature flag, an onboarding step.\n**Not when** the answer is a redirect with no condition — that is a static\nroute.\n\nGuards here are **reusable and parameterised**, they **compose** inside a single\n`canActivate` / `canMatch`, and their failure cases are resolved\n**exhaustively** — an unhandled case is a **type error**.\n\n> **A guard is just a generator function.** `canActivate` / `canMatch` take a bare\n> `function* () { … }` directly — there is no `craftCanActivate` / `craftCanMatch` wrapper and no\n> inline `resolvers` argument. Every reachable `craftException` is resolved by a single, exhaustive\n> **[`handleExceptions`](/guide/concepts/exceptions)** map on the route, applied **after the URL commits**\n> by the non-blocking [`CraftRouterOutlet`](/guide/routing/pending-ui).\n\n## The problem\n\nA `craftRoutes` `canActivate` accepts a single function (or generator function). To apply several\nauthorization rules — role, account state, feature flag… — you have to inline everything into one\ngenerator and hand-roll each rejection by returning a `createUrlTree(...)`:\n\n```ts\ncanActivate: function* () {\n const { user } = yield* CraftAuth(undefined, ({ user }) => ({ user }));\n if (!user()) {\n return createUrlTree(['/auth/login']); // not authenticated\n }\n if (user()!.role !== 'admin') {\n return createUrlTree(['/unauthorized']); // wrong role\n }\n const { pizzeria } = yield* CraftAuth(undefined, ({ pizzeria }) => ({ pizzeria }));\n if (pizzeria()) {\n return createUrlTree(['/dashboard']); // already onboarded\n }\n return true;\n}\n```\n\nThe rules are not reusable, the redirect logic is tangled with the checks, and nothing forces you to\nhandle every rejection — forget a branch and it silently falls through.\n\n## The solution: `craftGen` + a composing generator guard\n\nSplit the two concerns:\n\n- **`craftGen`** authors a reusable, parameterised guard. It either returns a success value or a\n typed [`craftException`](#exceptions).\n- The route's **`canActivate` generator** composes guards with `yield*`; the route's exhaustive\n [`handleExceptions`](/guide/concepts/exceptions) map must cover **exactly** the reachable exception codes.\n\nFor a focused overview of `craftGen` itself and why it is useful, see\n[`craftGen`](/guide/concepts/generators).\n\n```ts\nimport {\n craftException,\n craftGen,\n craftResolve,\n CraftHttpClient,\n query,\n craftRoute,\n craftUntilSettled,\n} from '@craft-ts/core';\n\n// Reusable guards — each returns a success value | craftException(...)\nconst roleGuard = craftGen(\n (...roles: Role[]) =>\n function* () {\n const { user } = yield* CraftAuth(undefined, ({ user }) => ({\n user,\n }));\n if (!user()) return craftException({ _tag: 'NOT_AUTHENTICATED' });\n return roles.includes(user()!.role)\n ? true\n : craftException({ _tag: 'FORBIDDEN_ROLE' });\n },\n);\n\nconst noPizzeriaGuard = craftGen(\n () =>\n function* () {\n const { pizzeria } = yield* CraftAuth(undefined, ({ pizzeria }) => ({\n pizzeria,\n }));\n return pizzeria() ? craftException({ _tag: 'HAS_PIZZERIA' }) : true;\n },\n);\n\nconst { pizzeriaDraftQuery } = query('pizzeriaDraftQuery', {\n params: () => true,\n loader: function* () {\n return yield* CraftHttpClient.get(({ response }) => ({\n url: '/api/pizzerias/draft',\n success: response<PizzeriaDraft>(),\n exceptions: [\n function* ({ status }) {\n if (!(yield* status(404))) return;\n return craftException({ _tag: 'PIZZERIA_DRAFT_UNAVAILABLE' });\n },\n ],\n }));\n },\n});\n\ncraftRoute(\n 'new',\n {\n title: 'Create Pizzeria',\n canActivate: function* () {\n yield* roleGuard(ROLES.PIZZERIA_ADMIN); // short-circuits on exception\n yield* noPizzeriaGuard();\n return true;\n },\n resolve: craftResolve(function* () {\n return yield* craftUntilSettled(pizzeriaDraftQuery);\n }),\n loadComponent: ({ withRetry }) =>\n withRetry(\n import('./pages/admin-pizzeria-form-page/admin-pizzeria-form-page'),\n ).then((m) => m.AdminPizzeriaFormPage),\n componentDeps:\n {} as import('./pages/admin-pizzeria-form-page/admin-pizzeria-form-page').GenDeps_AdminPizzeriaFormPage,\n },\n {\n // Resolved centrally — exhaustive over canActivate ∪ canMatch ∪ resolve.\n NOT_AUTHENTICATED: craftExceptionHandler(function* ({ redirectTo }) {\n return yield* redirectTo({ to: 'auth/login' });\n }),\n FORBIDDEN_ROLE: craftExceptionHandler(function* ({ redirectTo }) {\n return yield* redirectTo({ to: 'unauthorized' });\n }),\n HAS_PIZZERIA: craftExceptionHandler(function* ({ redirectTo }) {\n return yield* redirectTo({ to: 'pizzerias/admin' });\n }),\n PIZZERIA_DRAFT_UNAVAILABLE: craftExceptionHandler(function* ({\n globalError,\n }) {\n return globalError();\n }),\n HttpError: craftExceptionHandler(function* ({ globalError }) {\n return globalError();\n }),\n },\n);\n\n// After the collection is defined, assert every route handles exactly its codes:\n// assertExhaustiveRouteExceptions(adminRoutes);\n```\n\n## Reactive guards\n\nWhile a route is active, its `canActivate` invariant stays **under observation** (live guards, on by\ndefault). If a signal the guard reads changes — e.g. the user logs out and `Auth` becomes `null` —\nthe guard re-evaluates synchronously and applies [`handleExceptions`](/guide/concepts/exceptions) with\n`phase: 'active'`, so the target is never left rendered in an incoherent state. The reactive phase\nnever re-runs `resolve` (no new pending). Opt out per route with `reactiveGuards: false`.\n\n## How composition works\n\n`craftGen(factory)` returns a factory you invoke and delegate to with `yield*`:\n\n- The guard's **dependency yields** (`CraftAuth`, `CraftRouter`, …) flow up to the\n route exactly as in a plain generator guard, so [route DI tracking](/guide/routing/setup)\n still sees them.\n- As soon as a composed guard produces a `craftException`, the enclosing generator\n **short-circuits**: `yield* roleGuard(...)` interrupts the whole `function*`, and the exception is\n propagated to the route's guard boundary — no `if`/`return` plumbing in the composing guard.\n- The set of exceptions each guard can produce is tracked **at the type level**, so the route's\n `handleExceptions` map knows precisely which codes it must handle.\n\nOrder matters: guards run top-to-bottom and the first exception wins (fail-fast).\n\n## The handler context\n\nEach route exception handler receives the typed exception and payload, the navigation phase, the\ntyped `Router` helpers, and the five outcome constructors. See\n[Centralised Exception Handling](/guide/concepts/exceptions#handler-context) for the exhaustive list\nand examples.\n\nUse `redirectTo(...)` for typed internal routes:\n\n```ts\n{\n RATE_LIMITED: craftExceptionHandler(function* ({ payload, redirectTo }) {\n return yield* redirectTo({\n to: 'cooldown',\n queryParams: { retryAfter: String(payload.retryAfter) },\n });\n }),\n}\n```\n\nA handler returns a `CraftExceptionOutcome` via `redirectTo`, `redirectUrl`, `renderComponent`,\n`globalError`, `stay`, or `noop`. The `payload` is taken from `craftException({ _tag }, payload)`'s second argument\nand typed per code.\n\n## Handlers can yield services\n\nA handler may be a **generator** that `yield*`s craft services before building the redirect — for\nexample to read the login URL from a config service. Those yields are tracked exactly like the\nguards' own dependencies, so a service used only at redirect-time still flows into the route's\n[route DI](/guide/routing/setup) (yield an unprovided service and it surfaces as a\nmissing-provider error on the route):\n\n```ts\ncraftRoute(\n 'admin',\n {\n canActivate: function* () {\n yield* roleGuard(ROLES.ADMIN);\n return true;\n },\n },\n {\n // Generator handler — `RedirectConfig` becomes a tracked route dependency.\n FORBIDDEN_ROLE: craftExceptionHandler(function* ({ redirectUrl }) {\n const { unauthorizedUrl } = yield* RedirectConfig();\n return redirectUrl(unauthorizedUrl);\n }),\n },\n);\n```\n\nEvery handler uses the generator wrapper, including handlers that do not yield a service.\n\n## Exhaustiveness\n\nThe handler map is typed over the reachable codes, so **every** reachable code must be handled — a\nmissing one is a type error:\n\n```ts\ncraftRoute(\n 'admin',\n { canActivate: guard },\n {\n FORBIDDEN_ROLE: craftExceptionHandler(function* ({ redirectTo }) {\n return yield* redirectTo({ to: 'unauthorized' });\n }),\n // Type error: Property 'HAS_PIZZERIA' is missing.\n },\n);\n```\n\nAdd a guard that can raise a new code, and every route using it stops compiling until its handler is\nadded. A typo'd code is caught the same way because the correctly-spelled key is then missing.\n\n## Guarded data still flows through\n\nA `canActivate` guard's **success value** (anything other than `true`/`UrlTree`/…) becomes the\nroute's [guarded data](/guide/routing/route-providers) — `craftException` returns are never\ntreated as data:\n\n```ts\nconst authGuard = craftGen(\n () =>\n function* () {\n const user = yield* Auth();\n const userValue = user.value();\n return userValue\n ? userValue\n : craftException({ _tag: 'NOT_AUTHENTICATED' });\n },\n);\n\ncraftRoute(\n 'query/:userId',\n {\n componentDeps: {} as import('./query').GenDeps_GlobalQuery,\n loadComponent: ({ withRetry }) => withRetry(import('./query')),\n canActivate: function* () {\n return yield* authGuard(); // success value = the user\n },\n },\n {\n NOT_AUTHENTICATED: craftExceptionHandler(function* ({ redirectUrl }) {\n return redirectUrl('/login-form');\n }),\n },\n).withProviders(({ GuardedData }) => [\n provideUser(function* () {\n return (yield* GuardedData())(); // Signal<User> → User\n }),\n]);\n```\n\n## `canMatch`\n\n`canMatch` is the sibling of `canActivate` — same composition and exhaustive resolution through\n`handleExceptions`. Unlike `canActivate`, a `canMatch` guard produces no guarded data.\n\n```ts\nconst featureFlagGuard = craftGen(\n (flag: string) =>\n function* () {\n const { flags } = yield* CraftConfig();\n return flags[flag] ? true : craftException({ _tag: 'FLAG_DISABLED' });\n },\n);\n\ncraftRoute(\n 'beta',\n {\n componentDeps: {} as import('./beta').GenDeps_Beta,\n loadComponent: ({ withRetry }) => withRetry(import('./beta')),\n canMatch: function* () {\n yield* featureFlagGuard('beta');\n return true;\n },\n },\n {\n FLAG_DISABLED: craftExceptionHandler(function* ({ redirectUrl }) {\n return redirectUrl('/home');\n }),\n },\n);\n```\n\n## Async guards {#async-guards}\n\nThe guards above are **synchronous** — every `craftGen` resolves in one pass. To decide based on data\nthat has to be _fetched first_, suspend the composing guard with `craftUntilSettled` (or `craftUntilDefined`).\nThe guard stays a normal generator: `yield* a(); const x = yield* craftUntilSettled(...); yield* b()`\ncomposes across the await, and the awaited operation's `craftException`s flow into the same\nexhaustive `handleExceptions` map — the compiler still forces you to handle every reachable code.\n\n### `craftUntilSettled` — await a resource or an HTTP call\n\n`craftUntilSettled` takes either a craft **resource** (`query` / `mutation` / `asyncProcess`) or a\n`CraftHttpClient.*` **call** and suspends until it settles, then returns its success value.\n\n```ts\ncraftRoute(\n 'users/:userId',\n {\n componentDeps: {} as import('./user').GenDeps_User,\n loadComponent: ({ withRetry }) => withRetry(import('./user')),\n canActivate: function* (route) {\n const userId = route.params['userId'];\n\n // (a) Await an HTTP call directly — no named resource needed. Its declared\n // `exceptions` flow into the route's handleExceptions below.\n const user = yield* craftUntilSettled(\n CraftHttpClient.get(({ response }) => ({\n url: `/api/users/${userId}`,\n success: response<User>(),\n exceptions: [\n function* ({ status, code }) {\n if (!(yield* status(400))) return;\n if (!(yield* code('PASSWORD_REQUIRED'))) return;\n return craftException({\n _tag: 'PASSWORD_REQUIRED',\n scope: 'UsersFeature',\n });\n },\n ],\n })),\n );\n\n return user.active ? true : craftException({ _tag: 'INACTIVE_USER' });\n },\n },\n {\n // Both the guard's own exception AND the HTTP call's exception are required.\n INACTIVE_USER: craftExceptionHandler(function* ({ redirectUrl }) {\n return redirectUrl('/inactive');\n }),\n PASSWORD_REQUIRED: craftExceptionHandler(function* ({ redirectUrl }) {\n return redirectUrl('/password');\n }),\n },\n);\n```\n\nThe **resource** form is identical — pass the ref (an inline `query(name, ...)` works, though it is\nreactive; prefer the HTTP form for one-shots):\n\n```ts\nconst user =\n yield *\n craftUntilSettled(\n query('user', {\n params: () => userId,\n loader: ({ params }) => fetchUser(params),\n }).user,\n );\n```\n\n**Settle semantics & exception routing:**\n\n- A resource settles when its `status` reaches `'resolved'` or `'error'`. A loader `craftException`\n **short-circuits** to `handleExceptions`; a thrown loader error is **rethrown**; otherwise the\n resolved value is returned.\n- An HTTP call's declared business `exceptions` short-circuit to `handleExceptions`. The generic\n transport-level `HttpError` (`scope: 'HttpClient'`) is **rethrown** — a network failure is not a\n resolvable business case. (An opt-in `HttpError` handler may come later.)\n- The awaited HTTP endpoint is tracked as a route dependency automatically, exactly like one used in\n a component or loader.\n\n### `craftUntilDefined` — await a readiness signal\n\n`craftUntilDefined(signal)` suspends until `signal()` is no longer `undefined`, then returns its\nnon-nullable value. There is no exception channel — use it to wait on a plain readiness signal.\n\n```ts\nconst session = yield * craftUntilDefined(sessionService.current);\n```\n\n### Notes\n\n- A guard that never reaches an `craftUntilSettled` / `craftUntilDefined` await still resolves **synchronously**\n (no forced microtask) — existing synchronous guards are unchanged.\n- This works for both `canActivate` and `canMatch`; the outlet drives the guard to settlement after\n the URL commits.\n\n## Exceptions {#exceptions}\n\nGuards fail with `craftException({ _tag }, payload?)` — the same typed-exception primitive used by\n`query` / `mutation`:\n\n```ts\ncraftException({ _tag: 'FORBIDDEN_ROLE' });\ncraftException({ _tag: 'RATE_LIMITED' }, { retryAfter: 30 }); // payload reaches the handler\n```\n\nThe `code` drives both the exhaustiveness check and the handler lookup; the optional payload is\ntyped and forwarded to the handler.\n\n## When to reach for it\n\n`craftGen` + a `canActivate` / `canMatch` generator fit **sequential, fail-fast gates resolved at a\nsingle boundary**: authorization, account-state checks, feature flags, action preconditions.\n\nIt is **not** the right tool when you want to **collect and surface multiple failures**\nreactively — that is what `query` / `mutation` `hasException` and the form-submit exception model are\nfor. Guards stop at the first failure and hand off to a handler.\n\n## See Also\n\n- [Route Providers](/guide/routing/route-providers) — consume guarded data in route providers\n- [Setup](/guide/routing/setup) — per-route DI checks\n- [craftService](/guide/app/craft-service) — services yielded inside guards\n"
|
|
341
341
|
},
|
|
342
342
|
{
|
|
343
343
|
"path": "/guide/routing/pending-ui",
|
|
344
344
|
"title": "Non-blocking navigation",
|
|
345
|
-
"body": "# Non-blocking navigation\n\nBy default, a slow guard or resolver can leave the current screen unchanged with\nno feedback. `CraftRouterOutlet()` commits the URL immediately and shows a\npending component only if the wait is actually noticeable.\n\n**Use it when** guards or resolvers do real work — an HTTP call, a permission\ncheck.\nFor synchronous routes, the outlet renders the target immediately.\n\n`CraftRouterOutlet()` provides **non-blocking** navigation:\nthe URL commits immediately, a pending component appears only if the guard/resolve chain is slow,\nand the target component is mounted **only on success** — never while an exception is being\nresolved.\n\n## Setup\n\nCall the outlet inside a Craft component tree:\n\n\n\nRoutes with no craft guard or resolver render immediately.\n\n## Lifecycle\n\nFor a route with a craft chain, on navigation the outlet lets the URL commit immediately (no\nblocking guard), then runs **three phases** while the chain is in flight — so a fast navigation\nnever flashes a blank screen or a loader:\n\n1. **stay** — for `stayMs` (default `300`) the **previous page is kept on screen**. The chain runs\n in the background; if it settles within this window, the outlet transitions **straight to the\n target** (no blank, no loader);\n2. **blank** — for the next `blankMs` (default `300`), a **blank** surface, signalling the page is\n changing;\n3. **pending** — the **pending component** (loader) is shown until the chain settles.\n\nOn success the outlet writes the resolved data and mounts the **target**; on exception it applies\nthe route's [`handleExceptions`](/guide/concepts/exceptions) outcome.\n\nLazy JavaScript load failures (`loadComponent` / `loadChildren`) happen before the outlet can mount\nthe target route. Configure [`withRouteLoadError`](/guide/routing/route-load-errors) to retry those failures\nand render a recovery screen while keeping the browser URL on the intended route. A slow JavaScript\ndownload or retry does not currently activate this pending timeline; dedicated loading UI for that\nearlier phase is a planned evolution.\n\n```\nclick → URL committed\n ├─ 0 → stayMs ........ PREVIOUS page kept ─(resolved)─▶ target\n ├─ stayMs → +blankMs . BLANK page ─(resolved)─▶ target\n └─ beyond ............ LOADER (min pendingMinMs) ─(resolved / redirect)─▶ target / redirect\n```\n\n`pendingMinMs` adds anti-flicker: once the loader is shown, it stays visible for at least that long,\nso a chain that settles right after it appears does not blink it in and out.\n\nThe previous page is kept **alive** (not re-created) during `stay`: the outlet renders through a\nsingle component slot it leaves untouched until the phase changes, so the old component instance\nkeeps its state for the duration of the window.\n\n## Configuration\n\nThe loading and error features are plain feature objects. The recommended place\nfor them is **directly in `provideCraftRouter(...)`**:\n\n```ts\nprovideCraftRouter(\n appRoutes.toRoutes(),\n withCraftViewTransitions(), // craft loading feature (see below)\n withErrorComponent({\n component: MyGlobalErrorScreen,\n componentDeps: {} as import('./global-error').GenDeps_MyGlobalErrorScreen,\n }),\n withRouteLoadError({\n component: MyRouteLoadErrorScreen,\n componentDeps:\n {} as import('./route-load-error').GenDeps_MyRouteLoadErrorScreen,\n retry: { attempts: 1, delayMs: 250 },\n }),\n withTransitionTimings({ stayMs: 300, blankMs: 300, pendingMinMs: 500 }),\n withLoadingText(() => computed(() => translate('common.loading'))),\n withPendingComponent(MyBrandedSpinner),\n),\n```\n\nMost loading features still work standalone via `provideCraftLoading(...)` if you prefer to keep them\nin a separate provider. Keep `withRouteLoadError(...)` in `provideCraftRouter(...)`: it also\nregisters a navigation error handler and an internal recovery route.\n\n```ts\nprovideCraftLoading(\n withTransitionTimings({ stayMs: 300, blankMs: 300, pendingMinMs: 500 }),\n withLoadingText(() => computed(() => translate('common.loading'))),\n withPendingComponent(MyBrandedSpinner),\n withErrorComponent({\n component: MyGlobalErrorScreen,\n componentDeps: {} as import('./global-error').GenDeps_MyGlobalErrorScreen,\n }),\n),\n```\n\n| Feature | Token | Default |\n| -------------------------- | --------------------------------------------------------------------- | --------------------------------- |\n| `withPendingComponent` | `CRAFT_PENDING_COMPONENT` | `DefaultCraftPendingComponent` |\n| `withLoadingText` | `CRAFT_LOADING_TEXT` | locale-aware (en/fr, fallback en) |\n| `withTransitionTimings` | `CRAFT_STAY_MS` / `CRAFT_BLANK_MS` / `CRAFT_PENDING_MIN_MS` | `300` / `300` / `0` |\n| `withErrorComponent` | `CRAFT_ERROR_COMPONENT` | `null` |\n| `withRouteLoadError` | `CRAFT_ROUTE_LOAD_ERROR_COMPONENT` / `CRAFT_ROUTE_LOAD_RETRY` | `null` / one retry after 250 ms |\n| `withCraftViewTransitions` | `CRAFT_VIEW_TRANSITIONS_ENABLED` / `CRAFT_VIEW_TRANSITION_SKIP_BLANK` | `false` / `false` |\n| `withA11yNavigationFocus` | `CRAFT_A11Y_NAVIGATION_FOCUS` | `false` |\n\nThe default pending component renders `CRAFT_LOADING_TEXT`, which reads `LOCALE_ID` and picks a\nbuilt-in translation (`Loading…` / `Chargement…`).\n\n## Per-route overrides\n\nAny route may override the defaults via route fields that are stripped before\nthe runtime route is emitted:\n\n```ts\ncraftRoute('user/:userId', {\n // …\n stayMs: 150, // shorten the \"keep previous page\" window\n blankMs: 0, // skip the blank phase → straight to loader\n pendingComponent: () => import('./user-skeleton'),\n // reactiveGuards: false, // opt out of live guards (on by default)\n}),\n```\n\n## View Transitions\n\nThe default view-transition feature brackets **only the synchronous URL commit**\nin `document.startViewTransition()`. With the non-blocking outlet that is the\nwrong instant: the target\ncomponent mounts **after** the guard/resolve chain settles, so a shared-element morph captures\n`previous page → (stay/loader)` and the real `previous → target` morph is lost — worse, a full-screen\nloader becomes the captured \"old\" frame.\n\n`withCraftViewTransitions()` hands the morph to the **outlet** instead: it drives\n`document.startViewTransition()` around its **own** swaps (`previous page → skeleton → target`), so the\nmorph survives even a slow chain. It guards `prefers-reduced-motion`, falls back to a plain swap when\nthe API is missing, and is overridable in tests via the `CRAFT_START_VIEW_TRANSITION` seam.\n\n```ts\nprovideCraftRouter(\n appRoutes.toRoutes(),\n withCraftViewTransitions(),\n),\n```\n\n### Shared element across a slow chain\n\nFor the morph to bridge a slow navigation, **something** carrying the shared element's\n`view-transition-name` must stay on screen while the chain runs — the **pending skeleton**. A route\nopts in by **declaring the shared-element payload shape** with `viewTransitionPayload<T>()` — the\nview-transition analogue of how `queryParams` declares a route's query-params shape. This:\n\n- makes a typed `viewTransition: T | null` payload **required** on every `craftRouterLink` / `navigate`\n targeting it (`null` is an explicit opt-out);\n- exposes a route-generated, fully-typed `injectXxxViewTransition(): Signal<T | null>` helper;\n- tells the outlet to **skip the blank phase** (a blank would break the morph): `stay → pending → loaded`.\n\n```ts\nexport const { photosRoutes, injectPhotosPhotoIdViewTransition } = craftRoutes(\n 'photos',\n [\n craftRoute(\n ':photoId',\n {\n componentDeps:\n {} as import('./photo-detail').GenDeps_PhotoDetailComponent,\n loadComponent: ({ withRetry }) => withRetry(import('./photo-detail')),\n withLoaderViewTransitionImage: viewTransitionPayload<{\n name: string;\n image: string | null;\n }>(),\n pendingComponent: () => import('./photo-skeleton'),\n // The skeleton's DI is verified separately (see \"Verifying the skeleton's DI\").\n canActivate: function* () {\n /* slow guard */\n },\n },\n {\n /* … */\n },\n ),\n ],\n).withParent<ParentRoutes<'photos'>>();\n```\n\nThis collection is a lazy child mounted via `loadChildren` (kept out of the parent's cascade DI budget).\nBecause its components depend on the `:photoId` param **and** the declared view-transition payload, it is\nonly correct under the `photos` route — so it is **pinned** to that mount with\n`.withParent<ParentRoutes<'photos'>>()`, and the parent enforces it with `assertChildRouteMounts(...)`.\nSee [Pinning a lazy child to its mount path](/guide/routing/setup#pinning-a-lazy-child-to-its-mount-path-withparent-assertchildroutemounts).\n\nThe link passes a payload of the **declared type** (required, and shape-checked):\n\n```ts\na({}, 'Photo').pipe(\n CraftRouterLink({\n to: 'photos/:photoId',\n params: { photoId: photo.id },\n viewTransition: { name: 'photo-' + photo.id, image: photo.preview },\n }),\n);\n```\n\nThe skeleton (and/or the target) reads it through the **route-generated typed helper** and wears the\nmatching `view-transition-name`:\n\n```ts\nexport default class PhotoSkeleton {\n protected readonly photoId = injectPhotosPhotoIdParams();\n // Signal<{ name: string; image: string | null } | null> — typed by the route.\n private readonly viewTransition = injectPhotosPhotoIdViewTransition();\n protected readonly image = computed(\n () => this.viewTransition()?.image ?? null,\n );\n // template: <span [style.view-transition-name]=\"'photo-' + photoId()\"> … </span>\n}\n```\n\n> The global, untyped `injectCraftViewTransition(): Signal<unknown>` still exists for ad-hoc reads, but\n> prefer the route-generated helper when you have a declared payload.\n\nThe payload travels in navigation `state`, so it is **lost on reload or direct URL access**\n— there is no previous page to morph from in that case anyway; the app stays functional (skeleton\nwithout the preview image, then the target). Pass `withCraftViewTransitions({ skipBlank: true })` to\nskip the blank phase for **every** route, not just opted-in ones.\n\n### Verifying the skeleton's DI\n\nThe pending skeleton is a real component that injects dependencies (route params, the typed payload,\nmonitoring, …), but the aggregated cascade (`ValidateCascadeRoutesFile`) only sees the **target**\ncomponent — it never descends into `pendingComponent`. So the skeleton is verified **directly**, with\nthe per-component, O(1) [`RouteCheckedDI`](/guide/routing/setup#escape-hatch-the-o-1-per-route-check) escape\nhatch (not a second aggregated pass — that would add to the instantiation-count budget the cascade is\nalready spending):\n\n```ts\ntype _CheckTargetDI = ValidateCascadeRoutesFile<\n AppNames,\n AppValues,\n typeof photosRoutes\n>;\ntype _CanRunTarget = CanRun<_CheckTargetDI>;\n\n// The skeleton injects the `:photoId` param and the typed payload — both\n// auto-provided by the route, so list those service names as available; the\n// parent context (`AppValues` here) is the same one the cascade check uses.\ntype _CheckPendingDI = RouteCheckedDI<\n import('./photo-skeleton').GenDeps_PhotoSkeletonComponent,\n 'PhotosPhotoIdParams' | 'PhotosPhotoIdViewTransition',\n AppValues,\n 'pending component: photos/:photoId'\n>;\ntype _CanRunPending = CanRun<_CheckPendingDI>;\n```\n\nA service the skeleton injects but nothing provides becomes a TypeScript error on `_CanRunPending`\n(`The X service is not provided in pending component: photos/:photoId`). The\n`craft-ts/require-pending-component-di-check` ESLint rule **generates and refreshes this whole block**\nfrom `pendingComponent` on `--fix` — resolving the skeleton's `GenDeps_*`, deriving the auto-provided\nservice names from the route's path params + payload, and borrowing the parent context from the\ncollection's own `ValidateCascadeRoutesFile` — so you never hand-write or stale it.\n\n[Architecture tests](/guide/testing/architecture#assertroutediproofs) (`assertRouteDiProofs`) fail\nif that pending proof is missing or not armed with `CanRun`.\n\n## See Also\n\n- [Route exception handling](/guide/routing/exception-handling)\n- [Route guards](/guide/routing/guards) — what the outlet is waiting on\n- [Global error component](/guide/routing/global-error-component)\n- [Architecture rules](/guide/testing/architecture) — `assertRouteDiProofs` keeps the pending-component proof armed\n"
|
|
345
|
+
"body": "# Non-blocking navigation\n\nBy default, a slow guard or resolver can leave the current screen unchanged with\nno feedback. `CraftRouterOutlet()` commits the URL immediately and shows a\npending component only if the wait is actually noticeable.\n\n**Use it when** guards or resolvers do real work — an HTTP call, a permission\ncheck.\nFor synchronous routes, the outlet renders the target immediately.\n\n`CraftRouterOutlet()` provides **non-blocking** navigation:\nthe URL commits immediately, a pending component appears only if the guard/resolve chain is slow,\nand the target component is mounted **only on success** — never while an exception is being\nresolved.\n\n## Setup\n\nCall the outlet inside a Craft component tree:\n\n\n\nRoutes with no craft guard or resolver render immediately.\n\n## Lifecycle\n\nFor a route with a craft chain, on navigation the outlet lets the URL commit immediately (no\nblocking guard), then runs **three phases** while the chain is in flight — so a fast navigation\nnever flashes a blank screen or a loader:\n\n1. **stay** — for `stayMs` (default `300`) the **previous page is kept on screen**. The chain runs\n in the background; if it settles within this window, the outlet transitions **straight to the\n target** (no blank, no loader);\n2. **blank** — for the next `blankMs` (default `300`), a **blank** surface, signalling the page is\n changing;\n3. **pending** — the **pending component** (loader) is shown until the chain settles.\n\nOn success the outlet writes the resolved data and mounts the **target**; on exception it applies\nthe route's [`handleExceptions`](/guide/concepts/exceptions) outcome.\n\nLazy JavaScript load failures (`loadComponent` / `loadChildren`) happen before the outlet can mount\nthe target route. Configure [`withRouteLoadError`](/guide/routing/route-load-errors) to retry those failures\nand render a recovery screen while keeping the browser URL on the intended route. A slow JavaScript\ndownload or retry does not currently activate this pending timeline; dedicated loading UI for that\nearlier phase is a planned evolution.\n\n```\nclick → URL committed\n ├─ 0 → stayMs ........ PREVIOUS page kept ─(resolved)─▶ target\n ├─ stayMs → +blankMs . BLANK page ─(resolved)─▶ target\n └─ beyond ............ LOADER (min pendingMinMs) ─(resolved / redirect)─▶ target / redirect\n```\n\n`pendingMinMs` adds anti-flicker: once the loader is shown, it stays visible for at least that long,\nso a chain that settles right after it appears does not blink it in and out.\n\nThe previous page is kept **alive** (not re-created) during `stay`: the outlet renders through a\nsingle component slot it leaves untouched until the phase changes, so the old component instance\nkeeps its state for the duration of the window.\n\n## Configuration\n\nThe loading and error features are plain feature objects. The recommended place\nfor them is **directly in `provideCraftRouter(...)`**:\n\n```ts\nprovideCraftRouter(\n appRoutes.toRoutes(),\n withCraftViewTransitions(), // craft loading feature (see below)\n withErrorComponent({\n component: MyGlobalErrorScreen,\n componentDeps: {} as import('./global-error').GenDeps_MyGlobalErrorScreen,\n }),\n withRouteLoadError({\n component: MyRouteLoadErrorScreen,\n componentDeps:\n {} as import('./route-load-error').GenDeps_MyRouteLoadErrorScreen,\n retry: { attempts: 1, delayMs: 250 },\n }),\n withTransitionTimings({ stayMs: 300, blankMs: 300, pendingMinMs: 500 }),\n withLoadingText(() => computed(() => translate('common.loading'))),\n withPendingComponent(MyBrandedSpinner),\n),\n```\n\nMost loading features still work standalone via `provideCraftLoading(...)` if you prefer to keep them\nin a separate provider. Keep `withRouteLoadError(...)` in `provideCraftRouter(...)`: it also\nregisters a navigation error handler and an internal recovery route.\n\n```ts\nprovideCraftLoading(\n withTransitionTimings({ stayMs: 300, blankMs: 300, pendingMinMs: 500 }),\n withLoadingText(() => computed(() => translate('common.loading'))),\n withPendingComponent(MyBrandedSpinner),\n withErrorComponent({\n component: MyGlobalErrorScreen,\n componentDeps: {} as import('./global-error').GenDeps_MyGlobalErrorScreen,\n }),\n),\n```\n\n| Feature | Token | Default |\n| -------------------------- | --------------------------------------------------------------------- | --------------------------------- |\n| `withPendingComponent` | `CRAFT_PENDING_COMPONENT` | `DefaultCraftPendingComponent` |\n| `withLoadingText` | `CRAFT_LOADING_TEXT` | locale-aware (en/fr, fallback en) |\n| `withTransitionTimings` | `CRAFT_STAY_MS` / `CRAFT_BLANK_MS` / `CRAFT_PENDING_MIN_MS` | `300` / `300` / `0` |\n| `withErrorComponent` | `CRAFT_ERROR_COMPONENT` | `null` |\n| `withRouteLoadError` | `CRAFT_ROUTE_LOAD_ERROR_COMPONENT` / `CRAFT_ROUTE_LOAD_RETRY` | `null` / one retry after 250 ms |\n| `withCraftViewTransitions` | `CRAFT_VIEW_TRANSITIONS_ENABLED` / `CRAFT_VIEW_TRANSITION_SKIP_BLANK` | `false` / `false` |\n| `withA11yNavigationFocus` | `CRAFT_A11Y_NAVIGATION_FOCUS` | `false` |\n\nThe default pending component renders `CRAFT_LOADING_TEXT`, which reads `LOCALE_ID` and picks a\nbuilt-in translation (`Loading…` / `Chargement…`).\n\n## Per-route overrides\n\nAny route may override the defaults via route fields that are stripped before\nthe runtime route is emitted:\n\n```ts\ncraftRoute('user/:userId', {\n // …\n stayMs: 150, // shorten the \"keep previous page\" window\n blankMs: 0, // skip the blank phase → straight to loader\n pendingComponent: () => import('./user-skeleton'),\n // reactiveGuards: false, // opt out of live guards (on by default)\n}),\n```\n\n## View Transitions\n\nThe default view-transition feature brackets **only the synchronous URL commit**\nin `document.startViewTransition()`. With the non-blocking outlet that is the\nwrong instant: the target\ncomponent mounts **after** the guard/resolve chain settles, so a shared-element morph captures\n`previous page → (stay/loader)` and the real `previous → target` morph is lost — worse, a full-screen\nloader becomes the captured \"old\" frame.\n\n`withCraftViewTransitions()` hands the morph to the **outlet** instead: it drives\n`document.startViewTransition()` around its **own** swaps (`previous page → skeleton → target`), so the\nmorph survives even a slow chain. It guards `prefers-reduced-motion`, falls back to a plain swap when\nthe API is missing, and is overridable in tests via the `CRAFT_START_VIEW_TRANSITION` seam.\n\n```ts\nprovideCraftRouter(\n appRoutes.toRoutes(),\n withCraftViewTransitions(),\n),\n```\n\n### Shared element across a slow chain\n\nFor the morph to bridge a slow navigation, **something** carrying the shared element's\n`view-transition-name` must stay on screen while the chain runs — the **pending skeleton**. A route\nopts in by **declaring the shared-element payload shape** with `viewTransitionPayload<T>()` — the\nview-transition analogue of how `queryParams` declares a route's query-params shape. This:\n\n- makes a typed `viewTransition: T | null` payload **required** on every `craftRouterLink` / `navigate`\n targeting it (`null` is an explicit opt-out);\n- exposes a route-generated, fully-typed `injectXxxViewTransition(): Signal<T | null>` helper;\n- tells the outlet to **skip the blank phase** (a blank would break the morph): `stay → pending → loaded`.\n\n```ts\nexport const { photosRoutes, injectPhotosPhotoIdViewTransition } = craftRoutes(\n 'photos',\n [\n craftRoute(\n ':photoId',\n {\n componentDeps:\n {} as import('./photo-detail').GenDeps_PhotoDetailComponent,\n loadComponent: ({ withRetry }) => withRetry(import('./photo-detail')),\n withLoaderViewTransitionImage: viewTransitionPayload<{\n name: string;\n image: string | null;\n }>(),\n pendingComponent: () => import('./photo-skeleton'),\n // The skeleton's DI is verified separately (see \"Verifying the skeleton's DI\").\n canActivate: function* () {\n /* slow guard */\n },\n },\n {\n /* … */\n },\n ),\n ],\n).withParent<ParentRoutes<'photos'>>();\n```\n\nThis collection is a lazy child mounted via `loadChildren`; its routed\ncomponents carry their own per-route DI checks.\nBecause its components depend on the `:photoId` param **and** the declared view-transition payload, it is\nonly correct under the `photos` route — so it is **pinned** to that mount with\n`.withParent<ParentRoutes<'photos'>>()`, and the parent enforces it with `assertChildRouteMounts(...)`.\nSee [Pinning a lazy child to its mount path](/guide/routing/setup#pinning-a-lazy-child-to-its-mount-path-withparent-assertchildroutemounts).\n\nThe link passes a payload of the **declared type** (required, and shape-checked):\n\n```ts\na({}, 'Photo').pipe(\n CraftRouterLink({\n to: 'photos/:photoId',\n params: { photoId: photo.id },\n viewTransition: { name: 'photo-' + photo.id, image: photo.preview },\n }),\n);\n```\n\nThe skeleton receives `photoId` as a route-bound input and reads the payload through the\n**route-generated typed helper**:\n\n```ts\nimport { input } from '@angular/core';\n\nexport default class PhotoSkeleton {\n protected readonly photoId = input.required<string>();\n // Signal<{ name: string; image: string | null } | null> — typed by the route.\n private readonly viewTransition = injectPhotosPhotoIdViewTransition();\n protected readonly image = computed(\n () => this.viewTransition()?.image ?? null,\n );\n // template: <span [style.view-transition-name]=\"'photo-' + photoId()\"> … </span>\n}\n```\n\n> The global, untyped `injectCraftViewTransition(): Signal<unknown>` still exists for ad-hoc reads, but\n> prefer the route-generated helper when you have a declared payload.\n\nThe payload travels in navigation `state`, so it is **lost on reload or direct URL access**\n— there is no previous page to morph from in that case anyway; the app stays functional (skeleton\nwithout the preview image, then the target). Pass `withCraftViewTransitions({ skipBlank: true })` to\nskip the blank phase for **every** route, not just opted-in ones.\n\n### Verifying the skeleton's DI\n\nThe pending skeleton is a real component that injects dependencies (route params, the typed payload,\nmonitoring, …). It is verified independently with the per-component, O(1)\n[`RouteCheckedDI`](/guide/routing/setup) check:\n\n```ts\n// The skeleton injects the `:photoId` param and the typed payload — both\n// auto-provided by the route, so list those service names as available; the\n// parent context (`AppValues` here) is the same one the route uses.\ntype _CheckPendingDI = RouteCheckedDI<\n import('./photo-skeleton').GenDeps_PhotoSkeletonComponent,\n 'PhotosPhotoIdParams' | 'PhotosPhotoIdViewTransition',\n AppValues,\n 'pending component: photos/:photoId'\n>;\ntype _CanRunPending = CanRun<_CheckPendingDI>;\n```\n\nA service the skeleton injects but nothing provides becomes a TypeScript error on `_CanRunPending`\n(`The X service is not provided in pending component: photos/:photoId`). The\n`craft-ts/require-pending-component-di-check` ESLint rule **generates and refreshes this whole block**\nfrom `pendingComponent` on `--fix` — resolving the skeleton's dependency metadata and deriving the\nauto-provided service names from the route's path params + payload.\n\n[Architecture tests](/guide/testing/architecture#assertroutediproofs) (`assertRouteDiProofs`) fail\nif that pending proof is missing or not armed with `CanRun`.\n\n## See Also\n\n- [Route exception handling](/guide/routing/exception-handling)\n- [Route guards](/guide/routing/guards) — what the outlet is waiting on\n- [Global error component](/guide/routing/global-error-component)\n- [Architecture rules](/guide/testing/architecture) — `assertRouteDiProofs` keeps the pending-component proof armed\n"
|
|
346
346
|
},
|
|
347
347
|
{
|
|
348
348
|
"path": "/guide/routing/route-load-errors",
|
|
@@ -352,22 +352,22 @@
|
|
|
352
352
|
{
|
|
353
353
|
"path": "/guide/routing/route-providers",
|
|
354
354
|
"title": "Route providers",
|
|
355
|
-
"body": "# Route providers\n\nA route can provide services built from **its own URL** — the `:userId` in the\npath, its `data`, its query params, the value its guard resolved — with full\ntype-safe dependency tracking.\n\n**Use it when** a subtree's services depend on which route rendered them: a\n\"current project\" service, a tenant-scoped API client.\n**Not when** the dependency is global — provide it at the app level instead.\n\nBuild route-level providers from a route's **own auto-provisioned tokens** — path params,\n`data`, `queryParams`, and `canActivate` guarded data — with full, type-safe dependency tracking.\n\n## The problem\n\nA `craftRoutes` route auto-provisions route-scoped services. For a route `query/:userId` in the\n`demo` collection, `craftRoutes` generates helpers such as `DemoUserIdParams` and the\nyieldable `DemoQueryUserIdGuardedData`.\n\nThe params helper is useful **inside a component** and is consumed with `yield*`, exactly like a\nCraft service. Guarded data is consumed from a generator with\n`yield* DemoQueryUserIdGuardedData()`. Route `data` is intentionally not exported as a collection-level\n`inject…Data` helper; inside `withProviders`, consume it through the local `Data` generator. This\nalso lets you take the value resolved by `canActivate` and feed it into a provider that the routed\ncomponent injects.\n\n## The solution: `craftRoute(...).withProviders(...)`\n\n`craftRoute(path, definition)` authors a single route and returns a builder with a `.withProviders(...)`\nmethod. The callback receives **route-scoped service generators**, one per auto-provisioned token\nthat exists on the route, and returns a normal providers array.\n\n```ts\nimport {\n abstract,\n craftRoutes,\n craftService,\n query,\n craftRoute,\n} from '@craft-ts/core';\n\ntype User = { name: string };\n\n// 1. An abstract contract — implemented per route.\nconst { UserRequirement, provideUser } = craftService(\n { name: 'User', scope: 'abstract' },\n abstract<User>(),\n);\n\n// 2. A guard that resolves the user.\nconst { Auth } = craftService({ name: 'Auth', providedIn: 'global' }, function* () {\n const auth = yield* query('auth', {\n params: () => true,\n loader: async () => ({}) as User,\n });\n return auth;\n});\n\nexport const { demoRoutes } = craftRoutes('demo', [\n craftRoute('query/:userId', {\n componentDeps: {} as import('./query').GenDeps_GlobalQuery,\n loadComponent: ({ withRetry }) => withRetry(import('./query')),\n canActivate: function* () {\n const user = yield* Auth();\n const userValue = user.value();\n if (!userValue) {\n return false;\n }\n return safeUser; // becomes the route's guarded data\n },\n }).withProviders(({ GuardedData }) => [\n provideUser(function* () {\n const guarded = yield* GuardedData(); // Signal<User>\n return guarded();\n }),\n ]),\n]);\n```\n\nThe routed component can now yield `User()` from its Craft component factory and receive the value\nthat the guard resolved — without ever touching the fully-qualified route helper.\n\n## The helpers object\n\nThe `.withProviders(...)` callback receives an object with **route-local short names** for every\nauto-provisioned token present on the route:\n\n| Helper | Present when… | Yields |\n| --------------- | --------------------------- | -------------------------------------- |\n| `GuardedData` | the route has `canActivate` | `Signal<GuardData>` |\n| `<Param>Params` | per path param | `Signal<string>` (e.g. `UserIdParams`) |\n| `QueryParams` | the route has `queryParams` | the query-params state |\n| `Data` | the route has `data` | `Signal<RouteData>` |\n\nNames are **scoped to the single route**, so the collection prefix and route path are dropped:\n`GuardedData`, not `DemoQueryUserIdGuardedData`. The path-param name is kept to keep\nmultiple params distinct (`UserIdParams`, `TeamIdParams`, …).\n\nEach helper is a generator you consume with `yield*`, exactly like a service's `X()`:\n\n```ts\n.withProviders(({ UserIdParams, QueryParams }) => [\n provideSomething(function* () {\n const userId = yield* UserIdParams(); // Signal<string>\n const qp = yield* QueryParams(); // query-params state\n return { userId, qp };\n }),\n])\n```\n\nAt collection level, a path parameter uses the same service-shaped name. For example, a\n`craftRoutes('demo', [{ path: 'users/:userId', ... }])` collection exposes `DemoUserIdParams`:\n\n```ts\nimport { DemoUserIdParams } from './demo.routes';\n\nconst userId = yield* DemoUserIdParams(); // Signal<string>\n```\n\
|
|
355
|
+
"body": "# Route providers\n\nA route can provide services built from **its own URL** — the `:userId` in the\npath, its `data`, its query params, the value its guard resolved — with full\ntype-safe dependency tracking.\n\n**Use it when** a subtree's services depend on which route rendered them: a\n\"current project\" service, a tenant-scoped API client.\n**Not when** the dependency is global — provide it at the app level instead.\n\nBuild route-level providers from a route's **own auto-provisioned tokens** — path params,\n`data`, `queryParams`, and `canActivate` guarded data — with full, type-safe dependency tracking.\n\n## The problem\n\nA `craftRoutes` route auto-provisions route-scoped services. For a route `query/:userId` in the\n`demo` collection, `craftRoutes` generates helpers such as `DemoUserIdParams` and the\nyieldable `DemoQueryUserIdGuardedData`.\n\nThe params helper is useful **inside a component** and is consumed with `yield*`, exactly like a\nCraft service. Guarded data is consumed from a generator with\n`yield* DemoQueryUserIdGuardedData()`. Route `data` is intentionally not exported as a collection-level\n`inject…Data` helper; inside `withProviders`, consume it through the local `Data` generator. This\nalso lets you take the value resolved by `canActivate` and feed it into a provider that the routed\ncomponent injects.\n\n## The solution: `craftRoute(...).withProviders(...)`\n\n`craftRoute(path, definition)` authors a single route and returns a builder with a `.withProviders(...)`\nmethod. The callback receives **route-scoped service generators**, one per auto-provisioned token\nthat exists on the route, and returns a normal providers array.\n\n```ts\nimport {\n abstract,\n craftRoutes,\n craftService,\n query,\n craftRoute,\n} from '@craft-ts/core';\n\ntype User = { name: string };\n\n// 1. An abstract contract — implemented per route.\nconst { UserRequirement, provideUser } = craftService(\n { name: 'User', scope: 'abstract' },\n abstract<User>(),\n);\n\n// 2. A guard that resolves the user.\nconst { Auth } = craftService({ name: 'Auth', providedIn: 'global' }, function* () {\n const auth = yield* query('auth', {\n params: () => true,\n loader: async () => ({}) as User,\n });\n return auth;\n});\n\nexport const { demoRoutes } = craftRoutes('demo', [\n craftRoute('query/:userId', {\n componentDeps: {} as import('./query').GenDeps_GlobalQuery,\n loadComponent: ({ withRetry }) => withRetry(import('./query')),\n canActivate: function* () {\n const user = yield* Auth();\n const userValue = user.value();\n if (!userValue) {\n return false;\n }\n return safeUser; // becomes the route's guarded data\n },\n }).withProviders(({ GuardedData }) => [\n provideUser(function* () {\n const guarded = yield* GuardedData(); // Signal<User>\n return guarded();\n }),\n ]),\n]);\n```\n\nThe routed component can now yield `User()` from its Craft component factory and receive the value\nthat the guard resolved — without ever touching the fully-qualified route helper.\n\n## The helpers object\n\nThe `.withProviders(...)` callback receives an object with **route-local short names** for every\nauto-provisioned token present on the route:\n\n| Helper | Present when… | Yields |\n| --------------- | --------------------------- | -------------------------------------- |\n| `GuardedData` | the route has `canActivate` | `Signal<GuardData>` |\n| `<Param>Params` | per path param | `Signal<string>` (e.g. `UserIdParams`) |\n| `QueryParams` | the route has `queryParams` | the query-params state |\n| `Data` | the route has `data` | `Signal<RouteData>` |\n\nNames are **scoped to the single route**, so the collection prefix and route path are dropped:\n`GuardedData`, not `DemoQueryUserIdGuardedData`. The path-param name is kept to keep\nmultiple params distinct (`UserIdParams`, `TeamIdParams`, …).\n\nEach helper is a generator you consume with `yield*`, exactly like a service's `X()`:\n\n```ts\n.withProviders(({ UserIdParams, QueryParams }) => [\n provideSomething(function* () {\n const userId = yield* UserIdParams(); // Signal<string>\n const qp = yield* QueryParams(); // query-params state\n return { userId, qp };\n }),\n])\n```\n\nAt collection level, a path parameter uses the same service-shaped name. For example, a\n`craftRoutes('demo', [{ path: 'users/:userId', ... }])` collection exposes `DemoUserIdParams`:\n\n```ts\nimport { DemoUserIdParams } from './demo.routes';\n\nconst userId = yield* DemoUserIdParams(); // Signal<string>\n```\n\nPath parameters are exposed only through the service-shaped `DemoUserIdParams()` helper, so URL\nparameters participate in Craft's normal yieldable DI graph.\n\n## Pairing with an abstract service\n\n`craftRoute(...).withProviders(...)` shines with `scope: 'abstract'` services. The abstract service\ndeclares a contract; each route provides a concrete implementation derived from that route's data.\n\nAbstract services now expose a `provideX(factory)` helper that takes a **generator factory**, tracks\neverything it yields, and binds the result to the requirement token. See\n[craftService → Abstract Providers](/guide/app/craft-service#abstract-providers).\n\n```ts\nconst { User, provideUser } = craftService(\n { name: 'User', scope: 'abstract' },\n abstract<User>(),\n);\n\n// In a route:\n.withProviders(({ GuardedData }) => [\n provideUser(function* () {\n return (yield* GuardedData())();\n }),\n])\n\n// In the routed component factory:\nconst user = yield* User(); // User\n```\n\n## Dependency tracking & route DI\n\nEverything yielded inside a `withProviders` factory is tracked at the type level and folded into the\nroute's dependency graph used by [`RouteCheckedDI`](/guide/routing/setup):\n\n- The route's **auto-provisioned** tokens (guarded data, params, query params, data) are recognized\n as provided by the route itself — yielding them is always valid.\n- Any **other** service yielded inside the factory that is not provided by the route or the app\n surfaces as a missing-provider error, e.g.:\n\n ```\n The SomeService service is not provided in path: \"query/:userId\"\n ```\n\n- The provider's own name (`User` above) is registered as **self-provided**, so a component on that\n route can depend on it without a separate provider declaration.\n\nThis means the pattern is safe by construction: you cannot wire a route provider against data the\nroute does not actually expose.\n\n## Plain providers still work\n\n`.withProviders(...)` is additive. A route can still declare a plain `providers` array, and\nboth are merged (auto-provisioned services first, then `providers`, then the `withProviders`\nfactory output):\n\n```ts\ncraftRoute('admin', {\n componentDeps: {} as import('./admin').GenDeps_Admin,\n loadComponent: ({ withRetry }) => withRetry(import('./admin')),\n providers: [SomeCraftProvider], // plain array, untyped helpers\n}).withProviders(({ Data }) => [\n /* factory-built providers with tracking */\n]);\n```\n\nUnder the hood the builder stores the factory on a dedicated `providersFn` field, kept separate from\nthe route's `providers` array.\n\n## See Also\n\n- [Setup](/guide/routing/setup) — per-route DI checks\n- [craftService](/guide/app/craft-service) — `abstract` scope, `provideX`, requirements\n"
|
|
356
356
|
},
|
|
357
357
|
{
|
|
358
358
|
"path": "/guide/routing/scaling",
|
|
359
359
|
"title": "Scaling routes",
|
|
360
|
-
"body": "# Scaling routes\n\n`ValidateCascadeRoutesFile` walks every route in a collection **at the type\nlevel**, so a single routes file has a finite budget before TypeScript's\ninstantiation ceiling. This page is about what happens at that ceiling, and how\nto organise routes so you never reach it.\n\n::: tip You don't need this yet\nIf your app has one routes file with a handful of routes,\n[Setup](/guide/routing/setup) is enough. Come back when a file grows past a few\ndozen routes, or when you see `TS2589`.\n:::\n\n## Large route files — the cascade DI depth limit\n\n`ValidateCascadeRoutesFile<…, typeof appRoutes>` walks **every** route in the collection at the type\nlevel. TypeScript caps how deeply it will instantiate a recursive type, so a single collection has a\n**finite route budget**. Past it (in practice a few dozen routes, sooner if routes carry guards /\n`resolve` / `handleExceptions`), the check overflows:\n\n```\nTS2589: Type instantiation is excessively deep and possibly infinite.\n app.routes.ts → ValidateCascadeRoutesFile<never, CraftRouter, typeof appRoutes>\n```\n\n::: warning Watch out for the knock-on collapse\nA `TS2589` makes TypeScript abandon that type and fall back to `any`, which **poisons inference of\nneighbouring `const`s in the same file**. The visible symptoms are misleading: `craftRoute(...)` calls\ncollapse to `RouteWithProvidersBuilder<{ path }>`, the `craftRoutes(...)` helpers go missing\n(`Property 'injectXxx' does not exist`), and `craftRouterLink` targets type as `never`. The root\ncause is the overflowing check, not those routes.\n:::\n\n**Solution — split into a lazy child collection, and keep its own DI check.** The cascade check\nreads only the _current_ collection's metadata; it does **not** descend into `loadChildren`. So move\nthe extra routes into their own `craftRoutes(...)` file and reference it via `loadChildren`. That\nkeeps the parent file under budget — **but a child collection ships with _no_ DI checking unless you\nadd one**, so re-declare the check in the child file to keep DI sound. [Architecture\ntests](/guide/testing/architecture#assertroutediproofs) fail if that child proof is missing.\n\n```ts\n// feature.routes.ts — its own lazy collection\nimport {\n craftRoutes,\n craftRoute,\n type CanRun,\n type ValidateCascadeRoutesFile,\n} from '@craft-ts/core';\nimport type { CraftRouter } from '@craft-ts/core';\n\nexport const { featureRoutes } = craftRoutes('feature', [\n craftRoute('', {\n componentDeps: {} as import('./feature').GenDeps_Feature,\n loadComponent: ({ withRetry }) => withRetry(import('./feature')),\n // guards / resolve / handleExceptions …\n }),\n]);\n\n// DI safety for THIS collection — `app.routes.ts` does NOT cover loadChildren.\n// Same parent context the parent route runs under: app-level `CraftRouter` by value,\n// no extra named providers.\ntype _CheckFeatureDI = ValidateCascadeRoutesFile<\n never,\n CraftRouter,\n typeof featureRoutes\n>;\ntype _CanRunFeature = CanRun<_CheckFeatureDI>;\n```\n\n```ts\n// app.routes.ts — a cheap loadChildren entry, outside the parent's budget\n{\n path: 'feature',\n loadChildren: ({ withRetry }) =>\n withRetry(import('./feature.routes')).then((m) => m.featureRoutes),\n},\n```\n\nA missing provider in the child collection now surfaces as a TypeScript error **in the child file**,\nexactly like the main one:\n\n```\nThe SomeService service is not provided in path: \"\"\n```\n\nIf a single feature is itself large, repeat the split, or break one big collection into several\n`craftRoutes(...)` collections each with its own check — every check then validates a smaller slice\nand stays under the depth limit. The takeaway: **DI is always verified — never drop the check; move\nit next to the routes it covers.**\n\n### Why the budget exists (the mechanism)\n\n`ValidateCascadeRoutesFile` recurses over the route tuple **4 routes per step**, so a file of `N`\nroutes recurses to depth `N / 4`. Two distinct TypeScript ceilings are in play:\n\n- **Instantiation _depth_** (the `TS2589` \"excessively deep\" error). The 4-at-a-time unrolling is what\n fights this: it quarters the recursion depth, so the wall moves from ~50 routes to a few hundred —\n but it is still a per-file ceiling.\n- **Total instantiation _count_**. Each route pays one full `RouteCheckedDI` instantiation (walking its\n `GenDeps`, the `missingProvider` map, the parent context). The total cost is therefore roughly\n **`N × cost-per-route`**, and a route carrying guards / `resolve` / `handleExceptions` costs several\n times more than a trivial one. This is why \"a few dozen\" is only a rough figure — the real budget is\n in route-_cost_, not route-_count_.\n\n## Scaling to hundreds of routes\n\nThe split above is not a one-off patch — it is the architecture. Organise routes as a **tree of feature\nfiles joined by `loadChildren`** (which you want anyway for code-splitting):\n\n```\napp.routes.ts # \"manifest\": ~N cheap { path, loadChildren } entries\n├── billing.routes.ts # ~15–20 leaf routes + its own check\n├── admin.routes.ts # ~15–20 leaf routes + its own check\n└── reporting.routes.ts # if itself large → re-split into sub-loadChildren (level 3+)\n```\n\n- A `{ path, loadChildren }` entry has no `componentDeps`, so it is **nearly free** in the parent's\n cascade check — the manifest can list dozens of them.\n- Each feature file pays the budget for **its own leaves only**. ~500 routes ÷ ~17 per file ≈ ~30\n files; two levels are plenty, and you can nest further without limit.\n- **Every `craftRoutes(...)` file re-declares its own check** (see the iron rule above). With many\n files this is easy to forget and fails silently, so enable\n `craft-ts/require-cascade-route-di-check`.\n\n::: tip Threading the parent DI context\nThe child check's parent context (`ParentNames`, `ParentValues`) is everything provided **at its mount\npoint** — app providers **plus** every ancestor route's providers. When no ancestor adds `providers`,\nthis is just the app context (`<never, CraftRouter, …>`, as in the examples above), identical in every file.\nWhen an ancestor route _does_ add providers, re-export its cumulative context and union your own onto it:\n\n```ts\n// billing.routes.ts (mounted under a route with providers: [provideBilling()])\nexport type BillingChildNames = AppProvidedNames | 'BillingService';\nexport type BillingChildValues = AppProvidedValues;\n\n// sub-billing.routes.ts\ntype _Check = ValidateCascadeRoutesFile<\n BillingChildNames,\n BillingChildValues,\n typeof subRoutes\n>;\n```\n\nForgetting to fold in an ancestor's provider makes the child check wrong (a real missing-provider bug\nslips through, or a provided service is flagged as missing), so keep the re-export next to the route\nthat adds the providers.\n:::\n\n### Escape hatch — the `O(1)`-per-route check\n\nIf a single file genuinely must hold a large flat list (no natural `loadChildren` boundary), switch it\nfrom the aggregated `ValidateCascadeRoutesFile` to the **per-route** `RouteCheckedDI`. It validates one\ncomponent at a time with **no recursion between routes**, so it never hits the depth ceiling and scales\nto thousands of routes in one file — at the cost of one check block per component instead of one per\nfile:\n\n```ts\nimport { type CanRun, type RouteCheckedDI } from '@craft-ts/core';\n\ntype _CheckItem0 = RouteCheckedDI<\n import('./item-0').GenDeps_Item0Component,\n AppProvidedNames,\n AppProvidedValues,\n 'Item0Component'\n>;\ntype _CanRunItem0 = CanRun<_CheckItem0>;\n// …one pair per route component\n```\n\nPrefer the tree-of-`loadChildren` approach (it also lazy-loads); reach for `RouteCheckedDI` only when a\nsingle big file is unavoidable.\n\n## Pinning a lazy child to its mount path (`.withParent` + `assertChildRouteMounts`)\n\nSplitting into `loadChildren` keeps each file under budget, but nothing yet guarantees a child is wired\nunder the **right** parent route. A child whose components rely on a specific mount — its `:photoId`\nparam, a declared view-transition payload, an ancestor's `providers` — is only correct under that path.\nMount it elsewhere and its DI assumptions break silently.\n\nPin a collection to its mount path with `.withParent<ParentRoutes<'path'>>()`, then enforce it once in\nthe parent with `assertChildRouteMounts(parentRoutes)`:\n\n```ts\n// view-transitions.routes.ts — the child declares where it belongs\nimport { craftRoutes, craftRoute, type ParentRoutes } from '@craft-ts/core';\n\nexport const { viewTransitionsRoutes } = craftRoutes('viewTransitions', [\n craftRoute(':photoId', {\n componentDeps: {} as import('./photo-detail').GenDeps_PhotoDetailComponent,\n loadComponent: ({ withRetry }) => withRetry(import('./photo-detail')),\n // …\n }),\n]).withParent<ParentRoutes<'view-transitions'>>();\n```\n\n```typescript\n// app.routes.ts — the parent enforces placement (scoped to this file)\nimport { assertChildRouteMounts, craftRoutes } from '@craft-ts/core';\n\nexport const { demoRoutes } = craftRoutes('demo', [\n {\n path: 'view-transitions',\n loadChildren: ({ withRetry }) =>\n withRetry(import('./view-transitions.routes')).then(\n (m) => m.viewTransitionsRoutes,\n ),\n },\n]);\n\nassertChildRouteMounts(demoRoutes);\n```\n\n\n\nMount the pinned collection under any other path and the **parent file** fails to compile:\n\n```\ncraftRoutes(...).withParent<ParentRoutes<'view-transitions'>>() must be\nloadChildren-mounted under the route with path 'view-transitions', not 'admin'\n```\n\nNotes:\n\n- **Opt-in.** A collection without `.withParent` is _unpinned_ and mountable anywhere — fully backward\n compatible. Pin only the children whose placement actually matters.\n- **Scoped to the parent.** `assertChildRouteMounts` reads the parent's **own** routes (`_routes`) — it\n does **not** descend into / re-validate the child (already checked in its own file), so it adds nothing\n to the child's instantiation budget.\n- **Type-only.** `.withParent<…>()` returns the same object at runtime; `ParentRoutes<'path'>` carries no\n value, only the path string — so importing it creates no runtime coupling between the files.\n- **Enforced by ESLint.** `craft-ts/require-child-route-mount-check` adds the missing\n `assertChildRouteMounts(...)` call + import on `--fix`. (Whether a child opts in with `.withParent`\n stays your decision — it expresses the \"this belongs here\" intent the rule can't guess.)\n\n::: details Design notes — two approaches we rejected\nReaching the standalone-assert design above took two dead ends, both defeated by TypeScript's\ninstantiation ceiling. They're recorded here because the failure modes are instructive.\n\n**1. Enforcing placement inside `craftRoutes(...)` itself.** The first attempt wove the mount check into\nthe `routes` argument type of **every** `craftRoutes(...)` call, so a wrong mount would error right at\nthe route literal. It type-checked — but the extra per-collection instantiation tipped an\nalready-at-ceiling file into `TS2589`, and even a 2-route child with **no** `loadChildren` paid the cost\n(every collection runs the same inference). The lesson: the check must be **scoped to the parent that\nactually mounts children** — a standalone `assertChildRouteMounts(...)` reading the raw `_routes` — not\nfolded into the hot `craftRoutes` inference that every file pays on every build.\n\n**2. A `loadChildrenType` carrier to speed up the check.** To avoid inferring the child's type through the\ndynamic `import('./x').then((m) => m.xRoutes)`, we tried an explicit\n`loadChildrenType: {} as typeof import('./x').xRoutes` field on the lazy route. In isolation it built\nfine — but applied across the board it **materialises the child's full type (components included)**,\nwhich creates a **circular reference** for any child whose components inject the _parent's_ route data\n(`TS2615` \"circularly references itself\" + `TS2589`): `parent → typeof childRoutes → child components →\ninject parent data → parent`. Since the dynamic-import resolution it replaced was both cycle-safe and —\nonce measured against build-time noise — no slower, the carrier was dropped. `assertChildRouteMounts`\nresolves the child's pin through the existing `loadChildren` instead.\n:::\n\n## See Also\n\n- [Setup](/guide/routing/setup)\n- [Architecture rules](/guide/testing/architecture) — `assertRouteDiProofs` catches a split file with no check\n- [Route providers](/guide/routing/route-providers)\n"
|
|
360
|
+
"body": "# Scaling routes\n\n`RouteCheckedDI` validates one routed component at a time. Its cost does not\ngrow with the number of sibling routes, so a large route file keeps the same\nDI safety as a small one.\n\n::: tip Keep route ownership clear\nUse `loadChildren` when a feature deserves its own lazy boundary or team\nownership. Every route file remains independently checked.\n:::\n\n## Large route files\n\nThe per-route check does not recursively instantiate the complete route tuple.\nAdd one `RouteCheckedDI` / `CanRun` pair for each routed component:\n\n```ts\nimport { type CanRun, type RouteCheckedDI } from '@craft-ts/core';\n\ntype _CheckItem0 = RouteCheckedDI<\n import('./item-0').GenDeps_Item0Component,\n AppProvidedNames,\n AppProvidedValues,\n 'Item0Component'\n>;\ntype _CanRunItem0 = CanRun<_CheckItem0>;\n\ntype _CheckItem1 = RouteCheckedDI<\n import('./item-1').GenDeps_Item1Component,\n AppProvidedNames,\n AppProvidedValues,\n 'Item1Component'\n>;\ntype _CanRunItem1 = CanRun<_CheckItem1>;\n```\n\nIf a component starts depending on a service that is not provided, or expects\nan input that the route does not supply, its `CanRun` alias becomes a\nTypeScript error in the route file.\n\n## Scaling to hundreds of routes\n\nOrganise routes as a tree of feature files joined by `loadChildren` when that\nimproves lazy loading or ownership:\n\n```\napp.routes.ts\n├── billing.routes.ts\n├── admin.routes.ts\n└── reporting.routes.ts\n```\n\nThe parent registers a child collection with a lazy entry:\n\n```ts\n{\n path: 'billing',\n loadChildren: ({ withRetry }) =>\n withRetry(import('./billing.routes')).then((m) => m.billingRoutes),\n},\n```\n\nThe child file declares its own routes and its own per-route checks. A parent\nproof never covers a component inside a `loadChildren` collection.\n\n::: tip Threading the parent DI context\nThe second and third `RouteCheckedDI` parameters are the names and values\nprovided at the route's mount point — app providers plus ancestor route\nproviders. When an ancestor adds providers, re-export that cumulative context\nand pass it to the child route checks.\n:::\n\n## Pinning a lazy child to its mount path (`.withParent` + `assertChildRouteMounts`)\n\nSplitting into `loadChildren` keeps route ownership clear, but nothing yet\nguarantees a child is wired under the right parent route. A child whose\ncomponents rely on a specific mount — its `:photoId` param, a declared\nview-transition payload, or an ancestor's providers — is only correct under\nthat path.\n\nPin a collection to its mount path with `.withParent<ParentRoutes<'path'>>()`,\nthen enforce it once in the parent with\n`assertChildRouteMounts(parentRoutes)`:\n\n```ts\n// view-transitions.routes.ts — the child declares where it belongs\nimport { craftRoutes, craftRoute, type ParentRoutes } from '@craft-ts/core';\n\nexport const { viewTransitionsRoutes } = craftRoutes('viewTransitions', [\n craftRoute(':photoId', {\n componentDeps: {} as import('./photo-detail').GenDeps_PhotoDetailComponent,\n loadComponent: ({ withRetry }) => withRetry(import('./photo-detail')),\n }),\n]).withParent<ParentRoutes<'view-transitions'>>();\n```\n\n```ts\n// app.routes.ts — the parent enforces placement\nimport { assertChildRouteMounts, craftRoutes } from '@craft-ts/core';\n\nexport const { demoRoutes } = craftRoutes('demo', [\n {\n path: 'view-transitions',\n loadChildren: ({ withRetry }) =>\n withRetry(import('./view-transitions.routes')).then(\n (m) => m.viewTransitionsRoutes,\n ),\n },\n]);\n\nassertChildRouteMounts(demoRoutes);\n```\n\nMounting the pinned collection under another path fails in the parent file:\n\n```\ncraftRoutes(...).withParent<ParentRoutes<'view-transitions'>>() must be\nloadChildren-mounted under the route with path 'view-transitions', not 'admin'\n```\n\nNotes:\n\n- A collection without `.withParent` is unpinned and can be mounted anywhere.\n- `assertChildRouteMounts` reads only the parent's own routes; it does not\n re-validate the child.\n- `.withParent<…>()` is type-only and creates no runtime coupling.\n- `craft-ts/require-child-route-mount-check` adds the missing\n `assertChildRouteMounts(...)` call and import on `--fix`.\n\n## See Also\n\n- [Setup](/guide/routing/setup)\n- [Architecture rules](/guide/testing/architecture) — `assertRouteDiProofs` catches a routed component with no check\n- [Route providers](/guide/routing/route-providers)\n"
|
|
361
361
|
},
|
|
362
362
|
{
|
|
363
363
|
"path": "/guide/routing/setup",
|
|
364
364
|
"title": "Routing setup",
|
|
365
|
-
"body": "# Routing setup\n\nSix steps turn plain route definitions into routes the compiler checks: a\nmissing provider, a misspelled input or a route pointing at nothing becomes a\nbuild error instead of a blank screen. Architecture tests then keep those\nproofs from quietly disappearing.\n\n**Do this once per app**, then let [the CLI](/guide/routing/automation) write\nnew routes for you.\n\nThis guide assumes an app that consumes `@craft-ts/core`.\n\n::: tip Prefer the guided version\n[Learn step 9](/learn/09-routing) walks through the same setup on a single\nroute, with the reasoning attached.\n:::\n\n## Prerequisites\n\nInstall the runtime package and the dev tooling in your app:\n\n```bash\nnpm install @craft-ts/core\nnpm install -D @craft-ts/dev-tools\n```\n\n## 1. Add a
|
|
365
|
+
"body": "# Routing setup\n\nSix steps turn plain route definitions into routes the compiler checks: a\nmissing provider, a misspelled input or a route pointing at nothing becomes a\nbuild error instead of a blank screen. Architecture tests then keep those\nproofs from quietly disappearing.\n\n**Do this once per app**, then let [the CLI](/guide/routing/automation) write\nnew routes for you.\n\nThis guide assumes an app that consumes `@craft-ts/core`.\n\n::: tip Prefer the guided version\n[Learn step 9](/learn/09-routing) walks through the same setup on a single\nroute, with the reasoning attached.\n:::\n\n## Prerequisites\n\nInstall the runtime package and the dev tooling in your app:\n\n```bash\nnpm install @craft-ts/core\nnpm install -D @craft-ts/dev-tools\n```\n\n## 1. Add a DI check to every routed component\n\nDI is checked next to the route it covers. Every routed component must pair its\n`RouteCheckedDI` check with `CanRun`; checks do not cross a `loadChildren`\nboundary.\n\n\n\n\n`RouteCheckedDI` compares:\n\n- the dependencies declared by the routed component\n- the providers available from the app, parent mount, route and component\n\nIf a route depends on a service that is not provided, or if a routed component expects an input that\nthe route does not supply, `_CanRunApp` turns that mismatch into a TypeScript error in the routes file.\n\nTypical errors look like:\n\n- `The Counter service is not provided in path: \"some-path\"`\n- `Input \"userId\" is not provided in path: \"some-path\"`\n\n## 2. Define routes with `craftRoute` and collect them with `craftRoutes`\n\nDo not export a plain untyped routes array directly. Define each typed route with\n`craftRoute(...)`, collect them with `craftRoutes(...)`, and declare `componentDeps` on each route\ncomponent.\n\n::: warning Breaking rename\nThe former `route(...)` helper has been renamed to `craftRoute(...)`. There is no compatibility alias:\nupdate both the import and every call site.\n:::\n\n```ts\nimport { craftRoute, craftRoutes } from '@craft-ts/core';\n\nexport const { appRoutes } = craftRoutes('app', [\n craftRoute('', {\n loadComponent: ({ withRetry }) => withRetry(import('./test')),\n componentDeps: {} as import('./test').GenDeps_TestComponent,\n }),\n]);\n```\n\nThe important part is:\n\n```ts\ncomponentDeps: {} as import('./test').GenDeps_TestComponent,\n```\n\nThat line connects the component dependency metadata to its per-route DI check.\n\n### Prefer the route CLI for day-to-day authoring\n\nThe CLI is the primary writing façade while the generated result remains ordinary editable TypeScript:\n\n```bash\nnpx craft route add\nnpx craft route add /users/:userId --component src/app/users/user-detail.ts#UserDetailComponent\nnpx craft route add /users/:userId --create-component users/user-detail\n```\n\nBy default it detects the project and `craftRoutes` collections, creates one lazy routes file\nper feature, adds `componentDeps`, `withRetry`, `.withParent`, the parent mount assertion and the\nsame-file DI check, then runs ESLint and TypeScript diagnostics. Use `--dry-run` to inspect the plan,\n`--yes` for non-interactive scripts and `--json` for machine-readable output.\n\nStatic redirects stay in the selected collection:\n\n```bash\nnpx craft route add /old-users --redirect-to /users --parent src/app/app.routes.ts#appRoutes\n```\n\nExisting flat groups can be split explicitly:\n\n```bash\nnpx craft route split \\\n --parent src/app/app.routes.ts#appRoutes \\\n --prefix users \\\n --target src/app/users/users.routes.ts\n```\n\nThe split command only moves statically analyzable routes. It reports local declarations or dynamic\npaths without mutating files, so business logic is never guessed.\n\nThen wire the crafted routes into your application config:\n\n```ts\nimport { craftAppConfig, provideCraftRouter } from '@craft-ts/core';\nimport { appRoutes } from './app.routes';\n\nexport const appConfig = craftAppConfig({\n providers: [provideCraftRouter(appRoutes.toRoutes())],\n});\n```\n\nNotes:\n\n- `appRoutes.toRoutes()` gives the router the real runtime routes.\n- Route params are bound to component inputs by name; there is nothing to opt into.\n- `provideCraftRouter(...)` also takes the craft loading features\n (`withErrorComponent`, `withRouteLoadError`, `withTransitionTimings`, …) in\n the same call, e.g.\n `provideCraftRouter(appRoutes.toRoutes(), withErrorComponent({ component: MyGlobalErrorScreen }))`.\n- Render `CraftRouterOutlet()` from `@craft-ts/component` inside your Craft\n component tree: the URL commits immediately and the outlet drives the\n pending UI and centralised exception handling.\n (The features also work standalone via `provideCraftLoading(...)`.)\n `withRouteLoadError(...)` must stay in `provideCraftRouter(...)` because it also registers an\n a navigation error handler and an internal recovery route. See\n [Non-blocking navigation & pending UI](/guide/routing/pending-ui) and\n [Route Load Errors](/guide/routing/route-load-errors).\n- For lazy routes, `loadChildren` should return the named route tree exported by the child collection, for example `childRoutes.childRoutes`.\n\n### When a routes file gets big\n\n`RouteCheckedDI` checks one component at a time, so its cost does not grow with\nthe number of sibling routes. Split routes with `loadChildren` when code\nsplitting or ownership boundaries make that useful — each child still needs\nits own per-route checks. See **[Scaling routes](/guide/routing/scaling)**.\n\n## 3. Generate dependency metadata\n\nAdd a script in your app:\n\n```json\n{\n \"scripts\": {\n \"craft:brand\": \"craft-brand --root src\"\n }\n}\n```\n\nThen run:\n\n```bash\nnpm run craft:brand\n```\n\nThis is the step that creates the initial `GenDeps_*` aliases in your component files, for example:\n\n```ts\nexport type GenDeps_TestComponent = GetDeps<{\n deps: {\n TaskList: GetServiceDependencies<typeof TaskList>;\n };\n provided: {};\n publicProperties: GetPublicComponentProperties<TestComponent>;\n}>;\n```\n\nAdjust `--root` to your real source root:\n\n- `src` for an application\n- `projects/my-app/src` for a workspace app\n- `libs/my-feature/src` for a library\n\nIf you use a project-level `craft-brand.config.ts`, you can extend the script:\n\n```json\n{\n \"scripts\": {\n \"craft:brand\": \"craft-brand --root src --config ./craft-brand.config.ts\"\n }\n}\n```\n\n## 4. Install the ESLint rules\n\nSeveral checks in this guide rely on code a rule generates or keeps in sync —\n`GenDeps_*` aliases, the same-file DI proof, the exhaustiveness assert. Others\nenforce the architecture itself.\n\nInstalling the plugin and the rule list is its own page:\n**[ESLint rules](/guide/routing/eslint-rules)**.\n\n## 5. When a component changes, regenerate `GenDeps` with the Quick Fix\n\nAfter changing a component's DI-related shape, refresh its generated alias.\n\nTypical triggers:\n\n- adding or removing `inject(...)`\n- changing constructor injection\n- changing component `imports`\n- changing `providers`\n- changing `viewProviders`\n\nRecommended workflow:\n\n- first generation or bulk refactor: `npm run craft:brand`\n- one file without `GenDeps_*`: run the dependency generator for the relevant source root\n- one file with `GenDeps_*`: run `eslint --fix` for the file\n- CLI alternative for one file: `eslint --fix src/app/feature/my-component.ts`\n\nImportant limits:\n\n- the Quick Fix only handles the current file\n- if you rename the component class, rerun the generator so the `GenDeps_*` alias name stays aligned\n\n:::warning\nAn Eslint error does not trigger a compilation error, so make sure to run the Quick Fix or `eslint --fix` after changing a component's DI shape. Otherwise, `main.ts` will not see the updated `GenDeps_*` and may miss real DI errors.\n:::\n\n## 6. Make the DI contract enforceable\n\nThe proofs in this guide are unused type aliases unless they stay in the file:\ncomment out a `CanRun` and the project still compiles. That is the one fragile\nstep in an otherwise compile-time guarantee.\n\nArchitecture tests close it. `assertRouteDiProofs` walks the static graph and\nfails unless every routed component — including lazy `loadChildren`\ncollections — every pending or error screen, and every `craftAppConfig` error\nsurface is hooked to an armed mapper. TypeScript still judges whether a\ndependency is provided; the architecture suite judges whether that judgement\nwas invoked.\n\nCopy the demo layout (`apps/demo/architecture/`) and add:\n\n```typescript\nit('requires a DI proof on every routed component and app-config error screen', () => {\n assertRouteDiProofs(graph.graph);\n});\n```\n\nFull setup — analysis tsconfig, catalog, Nx target — is on\n[Architecture rules](/guide/testing/architecture).\n\n## See Also\n\n- [CLI automation](/guide/routing/automation) — let the CLI write routes for you\n- [Architecture rules](/guide/testing/architecture) — `assertRouteDiProofs` keeps the proofs armed\n- [Route guards](/guide/routing/guards) — the next thing you'll add\n- [Scaling routes](/guide/routing/scaling) — when one routes file gets too big\n"
|
|
366
366
|
},
|
|
367
367
|
{
|
|
368
368
|
"path": "/guide/state/async-process",
|
|
369
369
|
"title": "asyncProcess",
|
|
370
|
-
"body": "# asyncProcess\n\n`asyncProcess` runs an async operation and tracks its status, for work that is\nneither a server read nor a server write.\n\n**Use it when** you need to know whether something asynchronous is running: a\ndebounced search, a share sheet, a file export, a delay, a browser API call.\n**Not when** you fetch ([`query`](/guide/state/server-state)) or write\n([`mutation`](/guide/state/mutations)) — those give you caching, params\nreactivity and mutation wiring on top.\n\n## The common case\n\n```typescript\nimport { asyncProcess, craftComputed } from '@craft-ts/core';\n\nconst { delay } =\n yield *\n asyncProcess('delay', {\n method: (successResult: string) => successResult,\n loader: async ({ params: successResult }) => {\n await new Promise((resolve) => setTimeout(resolve, 300));\n return successResult;\n },\n });\n\n// In a tracked generator, consume the trigger with yield*.\nyield * delay.method('success');\n\ndelay.status(); // 'idle' | 'loading' | 'resolved' | 'exception'\ndelay.isLoading();\ndelay.hasValue();\ndelay.value(); // never throws\n```\n\n::: warning `method` always takes exactly one parameter\nPass an object when you need several values.\n:::\n\n## Wrapping a browser API\n\nThis is the case `asyncProcess` exists for — turning a promise-returning native\nAPI into something with an observable status:\n\n```typescript\nconst { shareContent } =\n yield *\n asyncProcess(\n 'shareContent',\n {\n method: (payload: { title: string; url: string }) => payload,\n loader: function* ({ params }) {\n return (yield* BrowserNavigator.share(params)) as Promise<undefined>;\n },\n },\n ({ resource }) => ({\n isMenuOpen: craftComputed(function* () {\n return (yield* resource.status()) === 'loading';\n }),\n }),\n );\n\nyield * shareContent.method({ title: 'Hello AI!', url: 'https://example.com' });\nyield* shareContent.isMenuOpen();\n```\n\nYielding the browser API through a service — rather than touching `navigator`\ndirectly — is also what makes it mockable in tests. See\n[Browser boundaries](/guide/testing/browser-boundaries).\n\n## Triggering from an event\n\nUse a [`source$`](/guide/reactivity/source) when the process should run on an\nevent rather than on a call — which is also where debouncing belongs:\n\n```typescript\nimport { on$, source$ } from '@craft-ts/core';\n\nconst searchSource = source$<string>('searchSource');\n\nconst { delayedSearch } =\n yield *\n asyncProcess('delayedSearch', {\n method: on$(searchSource, (term) => term),\n loader: async ({ params: term }) => {\n await new Promise((resolve) => setTimeout(resolve, 300));\n return term;\n },\n });\n\nsearchSource.emit('query text'); // runs automatically\n\ndelayedSearch.source; // ReadonlySource\ndelayedSearch.status();\n```\n\n## Exceptions\n\nSplit by origin, exactly like `query` and `mutation` — `params` for what\n`method` rejected, `loader` for what the operation produced:\n\n```typescript\nconst { loadUser } =\n yield *\n asyncProcess('loadUser', {\n method: (value: string) =>\n value.length < 3\n ? craftException(\n { _tag: 'SEARCH_TERM_TOO_SHORT' },\n { min: 3, received: value.length },\n )\n : value,\n loader: async ({ params }) =>\n params === 'blocked'\n ? craftException({ _tag: 'USER_ACCESS_FORBIDDEN' }, { id: params })\n : { id: params, name: 'John Doe' },\n });\n\nyield * loadUser.method('ab');\nloadUser.hasException(); // true\nloadUser.exceptions().params?.SEARCH_TERM_TOO_SHORT;\n\nyield * loadUser.method('blocked');\nloadUser.exceptions().loader?.USER_ACCESS_FORBIDDEN;\n```\n\n## Pitfalls\n\n**`method` needs its one parameter**, even when you have nothing to pass.\n\n`value()` is safe to read in templates and computed signals — it returns\n`undefined` when the process has no resolved value.\n\n**Reaching for it to fetch data.** If it's an HTTP read, `query` gives you\nreactive `params` and mutation wiring you'd otherwise rebuild by hand.\n\n::: details Advanced — parallel runs by identifier\n`identifier` keeps one resource per key so several runs coexist:\n\n```typescript\nconst { debouncedById } =\n yield *\n asyncProcess('debouncedById', {\n method: (payload: { successResult: string; id: string }) => payload,\n identifier: ({ id }) => id,\n loader: async ({ params: { successResult } }) => {\n await new Promise((resolve) => setTimeout(resolve, 300));\n return successResult;\n },\n });\n\nyield * debouncedById.method({ id: '1', successResult: data1 });\nyield * debouncedById.method({ id: '2', successResult: data2 });\n\ndebouncedById.select('1')?.value(); // data1\ndebouncedById.select('2')?.value(); // data2\n```\n\n:::\n\n::: details Advanced — yielding dependencies\n`method` and `loader`
|
|
370
|
+
"body": "# asyncProcess\n\n`asyncProcess` runs an async operation and tracks its status, for work that is\nneither a server read nor a server write.\n\n**Use it when** you need to know whether something asynchronous is running: a\ndebounced search, a share sheet, a file export, a delay, a browser API call.\n**Not when** you fetch ([`query`](/guide/state/server-state)) or write\n([`mutation`](/guide/state/mutations)) — those give you caching, params\nreactivity and mutation wiring on top.\n\n## The common case\n\n```typescript\nimport { asyncProcess, craftComputed } from '@craft-ts/core';\n\nconst { delay } =\n yield *\n asyncProcess('delay', {\n method: (successResult: string) => successResult,\n loader: async ({ params: successResult }) => {\n await new Promise((resolve) => setTimeout(resolve, 300));\n return successResult;\n },\n });\n\n// In a tracked generator, consume the trigger with yield*.\nyield * delay.method('success');\n\ndelay.status(); // 'idle' | 'loading' | 'resolved' | 'exception'\ndelay.isLoading();\ndelay.hasValue();\ndelay.value(); // never throws\n```\n\n::: warning `method` always takes exactly one parameter\nPass an object when you need several values.\n:::\n\n## Wrapping a browser API\n\nThis is the case `asyncProcess` exists for — turning a promise-returning native\nAPI into something with an observable status:\n\n```typescript\nconst { shareContent } =\n yield *\n asyncProcess(\n 'shareContent',\n {\n method: (payload: { title: string; url: string }) => payload,\n loader: function* ({ params }) {\n return (yield* BrowserNavigator.share(params)) as Promise<undefined>;\n },\n },\n ({ resource }) => ({\n isMenuOpen: craftComputed(function* () {\n return (yield* resource.status()) === 'loading';\n }),\n }),\n );\n\nyield * shareContent.method({ title: 'Hello AI!', url: 'https://example.com' });\nyield* shareContent.isMenuOpen();\n```\n\nYielding the browser API through a service — rather than touching `navigator`\ndirectly — is also what makes it mockable in tests. See\n[Browser boundaries](/guide/testing/browser-boundaries).\n\n## Triggering from an event\n\nUse a [`source$`](/guide/reactivity/source) when the process should run on an\nevent rather than on a call — which is also where debouncing belongs:\n\n```typescript\nimport { on$, source$ } from '@craft-ts/core';\n\nconst searchSource = source$<string>('searchSource');\n\nconst { delayedSearch } =\n yield *\n asyncProcess('delayedSearch', {\n method: on$(searchSource, (term) => term),\n loader: async ({ params: term }) => {\n await new Promise((resolve) => setTimeout(resolve, 300));\n return term;\n },\n });\n\nsearchSource.emit('query text'); // runs automatically\n\ndelayedSearch.source; // ReadonlySource\ndelayedSearch.status();\n```\n\n## Exceptions\n\nSplit by origin, exactly like `query` and `mutation` — `params` for what\n`method` rejected, `loader` for what the operation produced:\n\n```typescript\nconst { loadUser } =\n yield *\n asyncProcess('loadUser', {\n method: (value: string) =>\n value.length < 3\n ? craftException(\n { _tag: 'SEARCH_TERM_TOO_SHORT' },\n { min: 3, received: value.length },\n )\n : value,\n loader: async ({ params }) =>\n params === 'blocked'\n ? craftException({ _tag: 'USER_ACCESS_FORBIDDEN' }, { id: params })\n : { id: params, name: 'John Doe' },\n });\n\nyield * loadUser.method('ab');\nloadUser.hasException(); // true\nloadUser.exceptions().params?.SEARCH_TERM_TOO_SHORT;\n\nyield * loadUser.method('blocked');\nloadUser.exceptions().loader?.USER_ACCESS_FORBIDDEN;\n```\n\n## Pitfalls\n\n**`method` needs its one parameter**, even when you have nothing to pass.\n\n`value()` is safe to read in templates and computed signals — it returns\n`undefined` when the process has no resolved value.\n\n**Reaching for it to fetch data.** If it's an HTTP read, `query` gives you\nreactive `params` and mutation wiring you'd otherwise rebuild by hand.\n\n::: details Advanced — parallel runs by identifier\n`identifier` keeps one resource per key so several runs coexist:\n\n```typescript\nconst { debouncedById } =\n yield *\n asyncProcess('debouncedById', {\n method: (payload: { successResult: string; id: string }) => payload,\n identifier: ({ id }) => id,\n loader: async ({ params: { successResult } }) => {\n await new Promise((resolve) => setTimeout(resolve, 300));\n return successResult;\n },\n });\n\nyield * debouncedById.method({ id: '1', successResult: data1 });\nyield * debouncedById.method({ id: '2', successResult: data2 });\n\ndebouncedById.select('1')?.value(); // data1\ndebouncedById.select('2')?.value(); // data2\n```\n\n:::\n\n::: details Advanced — yielding dependencies\n`method` and `loader` are generators, and `providers` scopes dependencies to\nthis process alone. A loader must not be `async` or return a native `Promise`:\nuse `yield*` for asynchronous Craft operations:\n\n```typescript\nconst { loadProfile } =\n yield *\n asyncProcess('loadProfile', {\n providers: [provideAsyncLogger(), provideProfileGateway()],\n method: function* (userId: string) {\n yield* AsyncLogger.log(`load:${userId}`);\n return userId;\n },\n loader: function* ({ params }) {\n return yield* ProfileGateway.load(params);\n },\n });\n```\n\n:::\n\n::: tip Advanced — injectable writes\nInsertion methods provide `injectAsyncProcessMethodRuntimeContext()`, and the\nprocess value itself is published to\n`providePrimitiveResourceRuntimeObserver`. Both expose `get`, `set`, `update`,\nand `patch` for wrappers, WebMCP tools, and other advanced patterns. See\n[Anatomy of a primitive](/guide/concepts/primitive-anatomy#injectable-runtime-context).\n:::\n\n## See Also\n\n- [Which primitive should I use?](/guide/concepts/choose-primitive)\n- [Browser boundaries](/guide/testing/browser-boundaries) — mocking native APIs\n- [Anatomy of a primitive](/guide/concepts/primitive-anatomy)\n"
|
|
371
371
|
},
|
|
372
372
|
{
|
|
373
373
|
"path": "/guide/state/collections",
|
|
@@ -387,7 +387,7 @@
|
|
|
387
387
|
{
|
|
388
388
|
"path": "/guide/state/mutations",
|
|
389
389
|
"title": "Mutations",
|
|
390
|
-
"body": "# Mutations\n\n`mutation` is `query`'s counterpart for writes: same shape, triggered\nexplicitly, owning its own loading and failure state.\n\n**Use it when** you send something to a server — POST, PUT, PATCH, DELETE.\n**Not when** you read ([`query`](/guide/state/server-state)) or run an async\naction that isn't a server write\n([`asyncProcess`](/guide/state/async-process)).\n\n## The common case\n\n```typescript\nimport { CraftHttpClient, mutation } from '@craft-ts/core';\n\nconst { createUser } =\n yield *\n mutation('createUser', {\n method: (payload: { name: string; email: string }) => payload,\n loader: function* ({ params: user }) {\n return yield* CraftHttpClient.post(({ response }) => ({\n url: '/api/users',\n body: user,\n success: response<User>(),\n }));\n },\n });\n\n// In a tracked generator, consume the trigger with yield*.\nyield * createUser.mutate({ name: 'John', email: 'john@example.com' });\n\ncreateUser.isLoading();\ncreateUser.value(); // never throws\ncreateUser.exception();\n```\n\n`method` is the entry point: it takes what the caller passes and returns what\nthe loader receives as `params`. It is also where you reject bad input before any\nrequest happens.\n\n::: tip\n`value()` is safe to read in templates and computed signals: it returns\n`undefined` when the mutation has no resolved value.\n:::\n\n## Connecting it to the read side\n\nA mutation on its own leaves your list stale. Declare the link on the query\nrather than reloading by hand:\n\n```typescript\ninsertReactOnMutation(createUser, { reload: { onMutationSuccess: true } });\n```\n\nThat, plus optimistic updates, is on\n[Reacting to mutations](/guide/state/react-on-mutation).\n\n## Triggering from an event\n\nUse a [`source$`](/guide/reactivity/source) as the trigger instead of calling\n`.mutate(...)`:\n\n```typescript\nconst deleteUserSource = source$<{ name: string; email: string; id: string }>();\n\nconst { deleteUser } =\n yield *\n mutation('deleteUser', {\n method: on$(deleteUserSource, (payload) => payload),\n loader: function* ({ params: user }) {\n return yield* CraftHttpClient.delete(({ response }) => ({\n url: '/api/users',\n body: user,\n success: response<User>(),\n }));\n },\n });\n\ndeleteUserSource.emit({ name: 'John', email: 'john@example.com', id: '5' });\n```\n\n## Rejecting bad input, and reading exceptions\n\n`exceptions()` is split by **origin** — `params` for what `method` rejected\nbefore any request, `loader` for what the request produced — and typed from the\ncodes you declared:\n\n```typescript\nconst { deleteUser } =\n yield *\n mutation('deleteUser', {\n method: (payload: { userId: string }) =>\n payload.userId.length < 18\n ? craftException(\n { _tag: 'INVALID_ID' },\n { min: 18, received: payload.userId.length },\n )\n : payload.userId,\n loader: function* ({ params }) {\n return yield* CraftHttpClient.delete(({ response }) => ({\n url: '/api/user',\n body: params,\n success: response<User>(),\n exceptions: [\n function* ({ status }) {\n if (!(yield* status(403))) return;\n return craftException(\n { _tag: 'USER_ACCESS_FORBIDDEN' },\n { payload: params },\n );\n },\n ],\n }));\n },\n });\n\nyield * deleteUser.mutate({ userId: 'ab' });\ndeleteUser.hasException(); // true\ndeleteUser.exceptions().params?.INVALID_ID;\n\nyield * deleteUser.mutate({ userId: '12345-12344_27365453-2625434357282827' });\ndeleteUser.exceptions().loader?.USER_ACCESS_FORBIDDEN;\n```\n\nReturning a `craftException` from `method` means the loader never runs.\n\n## Pitfalls\n\n**One in-flight run replaces the previous one** unless you declare an\n`identifier` (below). Deleting three rows at once without one gives you the\nstate of the last delete only.\n\n**No value is available yet.** Check `hasValue()` or handle the `undefined`\nresult while the mutation is loading or in exception.\n\n::: details Advanced — parallel mutations by identifier\n`identifier` keeps one resource per key, so each row tracks its own state:\n\n```typescript\nconst { deleteUser } =\n yield *\n mutation('deleteUser', {\n method: (payload: { name: string; email: string; id: string }) => payload,\n identifier: ({ id }) => id,\n loader: function* ({ params: user }) {\n return yield* CraftHttpClient.delete(({ response }) => ({\n url: '/api/users',\n body: user,\n success: response<User>(),\n }));\n },\n });\n\nyield * deleteUser.mutate({ name: 'John', email: 'john@example.com', id: '5' });\n\ndeleteUser.select('5')?.isLoading();\ndeleteUser.select('5')?.exception();\ndeleteUser.select('5')?.value();\n```\n\n:::\n\n::: details Advanced — yielding dependencies\n`method`, `loader` and the insertion can all be generators, and `providers`\nscopes dependencies to this mutation alone:\n\n```typescript\nconst { saveUser } =\n yield *\n mutation('saveUser', {\n providers: [provideMutationLogger(), provideUserApiService()],\n method: function* (user: { id: string; name: string }) {\n yield* MutationLogger.log(`mutate:${user.id}`);\n return user;\n },\n loader: function* ({ params }) {\n return yield* UserApiService.save(params);\n },\n });\n```\n\nInside `craftMutations(...)`, `providers` stays on each `mutation(name, ...)`\nconfig, not on the wrapper:\n\n```typescript\nconst userFeature = craft(\n { name: 'userFeature', providedIn: 'root' },\n craftMutations(() => ({\n saveUser: mutation('saveUser', {\n providers: [provideMutationLogger(), provideUserApiService()],\n method: function* (user: { id: string; name: string }) {\n yield* MutationLogger.log(`mutate:${user.id}`);\n return user;\n },\n loader: function* ({ params }) {\n return yield* UserApiService.save(params);\n },\n }).saveUser,\n })),\n);\n```\n\n:::\n\n::: tip Advanced — injectable writes\nInsertion methods provide `injectMutationMethodRuntimeContext()`, and the\nmutation value itself is published to\n`providePrimitiveResourceRuntimeObserver`. Both expose `get`, `set`, `update`,\nand `patch` for wrappers, WebMCP tools, and other advanced patterns. See\n[Anatomy of a primitive](/guide/concepts/primitive-anatomy#injectable-runtime-context).\n:::\n\n## See Also\n\n- [query](/guide/state/server-state) — the read side\n- [Reacting to mutations](/guide/state/react-on-mutation)\n- [Submitting a form](/guide/forms/submit) — wiring a form to a mutation\n"
|
|
390
|
+
"body": "# Mutations\n\n`mutation` is `query`'s counterpart for writes: same shape, triggered\nexplicitly, owning its own loading and failure state.\n\n**Use it when** you send something to a server — POST, PUT, PATCH, DELETE.\n**Not when** you read ([`query`](/guide/state/server-state)) or run an async\naction that isn't a server write\n([`asyncProcess`](/guide/state/async-process)).\n\n## The common case\n\n```typescript\nimport { CraftHttpClient, mutation } from '@craft-ts/core';\n\nconst { createUser } =\n yield *\n mutation('createUser', {\n method: (payload: { name: string; email: string }) => payload,\n loader: function* ({ params: user }) {\n return yield* CraftHttpClient.post(({ response }) => ({\n url: '/api/users',\n body: user,\n success: response<User>(),\n }));\n },\n });\n\n// In a tracked generator, consume the trigger with yield*.\nyield * createUser.mutate({ name: 'John', email: 'john@example.com' });\n\ncreateUser.isLoading();\ncreateUser.value(); // never throws\ncreateUser.exception();\n```\n\n`method` is the entry point: it takes what the caller passes and returns what\nthe loader receives as `params`. It is also where you reject bad input before any\nrequest happens.\n\n::: tip\n`value()` is safe to read in templates and computed signals: it returns\n`undefined` when the mutation has no resolved value.\n:::\n\n## Connecting it to the read side\n\nA mutation on its own leaves your list stale. Declare the link on the query\nrather than reloading by hand:\n\n```typescript\ninsertReactOnMutation(createUser, { reload: { onMutationSuccess: true } });\n```\n\nThat, plus optimistic updates, is on\n[Reacting to mutations](/guide/state/react-on-mutation).\n\n## Triggering from an event\n\nUse a [`source$`](/guide/reactivity/source) as the trigger instead of calling\n`.mutate(...)`:\n\n```typescript\nconst deleteUserSource = source$<{ name: string; email: string; id: string }>();\n\nconst { deleteUser } =\n yield *\n mutation('deleteUser', {\n method: on$(deleteUserSource, (payload) => payload),\n loader: function* ({ params: user }) {\n return yield* CraftHttpClient.delete(({ response }) => ({\n url: '/api/users',\n body: user,\n success: response<User>(),\n }));\n },\n });\n\ndeleteUserSource.emit({ name: 'John', email: 'john@example.com', id: '5' });\n```\n\n## Rejecting bad input, and reading exceptions\n\n`exceptions()` is split by **origin** — `params` for what `method` rejected\nbefore any request, `loader` for what the request produced — and typed from the\ncodes you declared:\n\n```typescript\nconst { deleteUser } =\n yield *\n mutation('deleteUser', {\n method: (payload: { userId: string }) =>\n payload.userId.length < 18\n ? craftException(\n { _tag: 'INVALID_ID' },\n { min: 18, received: payload.userId.length },\n )\n : payload.userId,\n loader: function* ({ params }) {\n return yield* CraftHttpClient.delete(({ response }) => ({\n url: '/api/user',\n body: params,\n success: response<User>(),\n exceptions: [\n function* ({ status }) {\n if (!(yield* status(403))) return;\n return craftException(\n { _tag: 'USER_ACCESS_FORBIDDEN' },\n { payload: params },\n );\n },\n ],\n }));\n },\n });\n\nyield * deleteUser.mutate({ userId: 'ab' });\ndeleteUser.hasException(); // true\ndeleteUser.exceptions().params?.INVALID_ID;\n\nyield * deleteUser.mutate({ userId: '12345-12344_27365453-2625434357282827' });\ndeleteUser.exceptions().loader?.USER_ACCESS_FORBIDDEN;\n```\n\nReturning a `craftException` from `method` means the loader never runs.\n\n## Pitfalls\n\n**One in-flight run replaces the previous one** unless you declare an\n`identifier` (below). Deleting three rows at once without one gives you the\nstate of the last delete only.\n\n**No value is available yet.** Check `hasValue()` or handle the `undefined`\nresult while the mutation is loading or in exception.\n\n::: details Advanced — parallel mutations by identifier\n`identifier` keeps one resource per key, so each row tracks its own state:\n\n```typescript\nconst { deleteUser } =\n yield *\n mutation('deleteUser', {\n method: (payload: { name: string; email: string; id: string }) => payload,\n identifier: ({ id }) => id,\n loader: function* ({ params: user }) {\n return yield* CraftHttpClient.delete(({ response }) => ({\n url: '/api/users',\n body: user,\n success: response<User>(),\n }));\n },\n });\n\nyield * deleteUser.mutate({ name: 'John', email: 'john@example.com', id: '5' });\n\ndeleteUser.select('5')?.isLoading();\ndeleteUser.select('5')?.exception();\ndeleteUser.select('5')?.value();\n```\n\n:::\n\n::: details Advanced — yielding dependencies\n`method`, `loader` and the insertion can all be generators, and `providers`\nscopes dependencies to this mutation alone. A loader must not be `async` or\nreturn a native `Promise`; use `yield*` for asynchronous Craft operations:\n\n```typescript\nconst { saveUser } =\n yield *\n mutation('saveUser', {\n providers: [provideMutationLogger(), provideUserApiService()],\n method: function* (user: { id: string; name: string }) {\n yield* MutationLogger.log(`mutate:${user.id}`);\n return user;\n },\n loader: function* ({ params }) {\n return yield* UserApiService.save(params);\n },\n });\n```\n\nInside `craftMutations(...)`, `providers` stays on each `mutation(name, ...)`\nconfig, not on the wrapper:\n\n```typescript\nconst userFeature = craft(\n { name: 'userFeature', providedIn: 'root' },\n craftMutations(() => ({\n saveUser: mutation('saveUser', {\n providers: [provideMutationLogger(), provideUserApiService()],\n method: function* (user: { id: string; name: string }) {\n yield* MutationLogger.log(`mutate:${user.id}`);\n return user;\n },\n loader: function* ({ params }) {\n return yield* UserApiService.save(params);\n },\n }).saveUser,\n })),\n);\n```\n\n:::\n\n::: tip Advanced — injectable writes\nInsertion methods provide `injectMutationMethodRuntimeContext()`, and the\nmutation value itself is published to\n`providePrimitiveResourceRuntimeObserver`. Both expose `get`, `set`, `update`,\nand `patch` for wrappers, WebMCP tools, and other advanced patterns. See\n[Anatomy of a primitive](/guide/concepts/primitive-anatomy#injectable-runtime-context).\n:::\n\n## See Also\n\n- [query](/guide/state/server-state) — the read side\n- [Reacting to mutations](/guide/state/react-on-mutation)\n- [Submitting a form](/guide/forms/submit) — wiring a form to a mutation\n"
|
|
391
391
|
},
|
|
392
392
|
{
|
|
393
393
|
"path": "/guide/state/pagination-placeholder",
|
|
@@ -422,12 +422,12 @@
|
|
|
422
422
|
{
|
|
423
423
|
"path": "/guide/state/server-state",
|
|
424
424
|
"title": "query",
|
|
425
|
-
"body": "# query\n\n`query` fetches data and owns its whole lifecycle — loading, resolved,\nexception — re-running itself when its inputs change.\n\n**Use it when** you display data that lives on a server.\n**Not when** you write to the server ([`mutation`](/guide/state/mutations)) or\nrun a one-off async action that isn't a fetch\n([`asyncProcess`](/guide/state/async-process)).\n\n::: warning One source of truth\nDon't copy a query's result into a `state`. The query _is_ the state.\nDon't reload it from a `craftEffect` either — put the inputs in `params` so\nthe loader re-runs when they change.\n:::\n\n## The common case\n\n```typescript\nimport { CraftHttpClient, craftComputed, craftUse, query, settled } from '@craft-ts/core';\n\nconst { userQuery } =\n yield *\n query('userQuery', {\n params: () => ({ userId: currentUserId() }),\n loader: function* ({ params }) {\n return yield* CraftHttpClient.get(({ response }) => ({\n url: `/api/users/${params.userId}`,\n success: response<User>(),\n }));\n },\n });\n```\n\n`params` is reactive: when what it returns changes, the loader runs again. The\nresult carries the full async state:\n\n```typescript\nuserQuery.value(); // User | undefined — never throws\nuserQuery.isLoading(); // boolean\nuserQuery.status(); // 'idle' | 'loading' | 'resolved' | 'exception'\nuserQuery.exception(); // craftException | undefined\n```\n\n::: tip\n`value()` is safe to read in templates and computed signals: it returns\n`undefined` when the query has no resolved value.\n:::\n\n## Reading only settled data\n\nUse `settledValue` when a template or derived computation requires a real\nvalue. It suspends to the nearest `pendingNode` while the first value is\nunavailable, propagates query exceptions to a `catchNode`, and keeps the\nprevious value during a reload.\n\n```typescript\nconst userName = craftComputed('userName', function* () {\n return (yield* settled(userQuery)).name;\n});\n\nconst user = craftUse(userQuery.settledValue());\n```\n\nInsertion contexts keep the existing fallback behaviour of `state()`. Use\n`settledState()` when `yield*` (or `craftUse`) should return a non-nullable\nvalue and suspend until the current resource is available.\n\n## Triggering it yourself\n\nWhen the trigger is a user action rather than a reactive input, use `method`\ninstead of `params`:\n\n```typescript\nconst { searchQuery } =\n yield *\n query('searchQuery', {\n method: (term: string) => term,\n loader: function* ({ params: term }) {\n return yield* CraftHttpClient.get(({ response }) => ({\n url: `/api/search?q=${term}`,\n success: response<Array<{ id: string; title: string }>>(),\n }));\n },\n });\n\n// In a tracked generator, consume the trigger with yield*.\nyield * searchQuery.call('craft');\n```\n\nFrom an ordinary UI callback, the imperative form remains valid:\n`click: () => searchQuery.call(term)`. Do not put either form in a\n`craftEffect` dependency graph; use reactive `params` for data loading.\n\n## Adding derived values\n\nSame insertion mechanism as any primitive:\n\n```typescript\nconst { todosQuery } =\n yield *\n query(\n 'todosQuery',\n {\n params: () => ({ completed: showCompleted() }),\n loader: async ({ params }) =>\n (await fetch(`/api/todos?completed=${params.completed}`)).json(),\n },\n ({ value, isLoading }) => ({\n count: craftComputed(function* () {\n return (yield* value())?.length ?? 0;\n }),\n isEmpty: craftComputed(function* () {\n return !(yield* isLoading()) && (yield* value())?.length === 0;\n }),\n }),\n );\n\nyield* todosQuery.count();\n```\n\nAn insertion can also be a `function*` when it needs to yield services.\n\n## Enriching every item in a list\n\nWhen a query returns an array, `insertQuerySelect` attaches an insertion to each\nselected item. The selector keeps the item type, so derived values can use its\nproperties without casting:\n\n```typescript\nimport { craftComputed as computed } from '@craft-ts/core';\nimport { CraftHttpClient, insertQuerySelect, query } from '@craft-ts/core';\n\ntype User = {\n id: string;\n firstName: string;\n lastName: string;\n role: 'admin' | 'member';\n};\n\nconst { usersQuery } =\n yield *\n query(\n 'usersQuery',\n {\n params: () => ({ teamId: currentTeamId() }),\n loader: function* ({ params }) {\n return yield* CraftHttpClient.get(({ response }) => ({\n url: `/api/teams/${params.teamId}/users`,\n success: response<User[]>(),\n }));\n },\n },\n insertQuerySelect('user', ({ state }) => ({\n displayName: craftComputed(function* () {\n const user = yield* state();\n return `${user.firstName} ${user.lastName}`;\n }),\n roleLabel: craftComputed(function* () {\n return (yield* state()).role === 'admin' ? 'Administrator' : 'Member';\n }),\n })),\n );\n\n// `selectUser` targets one item in the returned array.\nconst firstUser = usersQuery.selectUser(0);\nyield* firstUser?.displayName(); // 'Ada Lovelace'\nyield* firstUser?.roleLabel(); // 'Administrator'\n```\n\nThe same pattern supports selecting a nested object property with\n`insertQuerySelect`, while preserving the selected property's type.\n\n## Avoiding the flicker when inputs change\n\n**This is already the default.** When `params` change, the previous value stays\nvisible until the new one resolves, so a paginated list never blanks out\nmid-navigation.\n\nYou only touch the option to turn it **off**:\n\n```typescript\nquery('postsQuery', {\n params: () => ({ page: currentPage() }),\n preservePreviousValue: () => false, // clear the value while loading\n loader: async ({ params }) =>\n (await fetch(`/api/posts?page=${params.page}`)).json(),\n});\n```\n\n::: tip Not consulted for parallel queries\nWith an `identifier`, each key keeps its own resource, so there is no \"previous\nvalue\" to preserve — the option is ignored on that path.\n:::\n\n## Reacting to a mutation\n\nRather than reloading by hand after a write, declare the link:\n\n```typescript\nimport {\n insertQueryPipe,\n insertReactOnMutation,\n insertStoragePersister,\n} from '@craft-ts/core';\n\nconst userQuery = yield* query(\n 'userQuery',\n {\n params: () => ({ userId: currentUserId() }),\n loader: /* … */,\n },\n insertQueryPipe(\n insertReactOnMutation(updateUserMutation, {\n // apply the change immediately, before the server answers\n optimisticPatch: {\n name: ({ mutationParams }) => mutationParams.name,\n email: ({ mutationParams }) => mutationParams.email,\n },\n // and go get the truth back if the mutation failed\n reload: { onMutationException: true },\n }),\n insertStoragePersister(craftUnique({\n storeName: 'demo-app',\n key: 'user-query',\n })),\n ),\n);\n```\n\nFull options on [Reacting to mutations](/guide/state/react-on-mutation).\n\n## Exceptions\n\n`exceptions()` is split by **origin** and typed from the codes you declared —\n`params` for what your `method` rejected before any request, `loader` for what\nthe request produced:\n\n```typescript\nimport { craftException, query } from '@craft-ts/core';\n\nconst { userQuery } =\n yield *\n query('userQuery', {\n method: (value: string) =>\n value.length < 3\n ? craftException(\n { _tag: 'SEARCH_TERM_TOO_SHORT' },\n { min: 3, received: value.length },\n )\n : value,\n loader: async ({ params }) =>\n params === 'forbidden'\n ? craftException({ _tag: 'USER_ACCESS_FORBIDDEN' }, { id: params })\n : { id: params, name: 'John Doe' },\n });\n\nyield * userQuery.call('ab');\nuserQuery.hasException(); // true\nuserQuery.exceptions().params?.SEARCH_TERM_TOO_SHORT;\n\nyield * userQuery.call('forbidden');\nuserQuery.exceptions().loader?.USER_ACCESS_FORBIDDEN;\n```\n\nReturning a `craftException` from `method` means the loader never runs — you\ndon't send a request you already know will fail.\n\n## Pitfalls\n\n**No value is available yet.** Check `hasValue()` or handle the `undefined`\nresult while the query is loading or in exception.\n\n**`params` must be cheap and pure.** It runs inside a reactive computation; side\neffects belong in the loader.\n\n::: details Advanced — parallel queries by identifier\n`identifier` keeps one resource per key, so several runs coexist instead of\nreplacing each other:\n\n```typescript\nconst userId = signal<number | undefined>(undefined);\n\nconst { userQuery } =\n yield *\n query('userQuery', {\n params: userId,\n identifier: (id) => id,\n loader: function* ({ params }) {\n return yield* CraftHttpClient.get(({ response }) => ({\n url: `/api/users/${params}`,\n success: response<User>(),\n }));\n },\n });\n\nuserId.set(1);\nuserId.set(2);\n\nuserQuery.select('1').value(); // user 1\nuserQuery.select('2').value(); // user 2\n```\n\n:::\n\n::: details Advanced — typed HTTP exceptions\nLoader exceptions are matched declaratively: each matcher yields predicates on\nthe response and returns a `craftException` when it recognises the failure.\n\n```typescript\nloader: function* ({ params }) {\n return yield* CraftHttpClient.get(({ response }) => ({\n url: `/api/users/${params}`,\n success: response<User>(),\n exceptions: [\n function* ({ status, code, content }) {\n if (!(yield* status(400))) return;\n if (!(yield* code('PASSWORD_REQUIRED'))) return;\n if (!(yield* content('Password is required'))) return;\n\n return craftException({\n _tag: 'PASSWORD_REQUIRED',\n scope: 'UsersFeatureForDependencies',\n });\n },\n function* ({ body, header }) {\n const payload = yield* body<{\n errors?: Array<{ field: 'password' }>;\n }>();\n\n if (!payload.errors?.some((error) => error.field === 'password')) return;\n if (!(yield* header('x-error-kind', 'validation'))) return;\n\n return craftException({\n _tag: 'VALIDATION_HEADER_ERROR',\n scope: 'UsersFeatureForDependencies',\n });\n },\n ],\n }));\n}\n```\n\nWorking source:\n[exceptions demo](https://github.com/craft-ts/craft-ts/blob/main/apps/demo/src/app/examples/primitives/exceptions/exceptions.ts).\n:::\n\n::: details Advanced — yielding dependencies from `params`\n`params` can be a generator, and so can an insertion:\n\n```typescript\nconst { userQuery } =\n yield *\n query(\n 'userQuery',\n {\n providers: [provideUserService(), provideUserApiService()],\n params: function* () {\n return yield* UserService.userId();\n },\n loader: function* ({ params: userId }) {\n return yield* UserApiService.get(userId);\n },\n },\n function* () {\n const queryTools = yield* QueryTools();\n return { queryKey: `${queryTools.prefix()}:details` };\n },\n );\n```\n\n:::\n\n::: tip Advanced — injectable writes\nInsertion methods provide `injectQueryMethodRuntimeContext()`, and the query\nvalue itself is published to `providePrimitiveResourceRuntimeObserver`. Both\nexpose `get`, `set`, `update`, and `patch`, so wrappers, WebMCP tools, and\nother advanced patterns can seed or replace a result without going through the\ninsertion callback. See\n[Anatomy of a primitive](/guide/concepts/primitive-anatomy#injectable-runtime-context).\n:::\n\n## See Also\n\n- [Mutations](/guide/state/mutations) — the write side\n- [Reacting to mutations](/guide/state/react-on-mutation)\n- [Anatomy of a primitive](/guide/concepts/primitive-anatomy)\n"
|
|
425
|
+
"body": "# query\n\n`query` fetches data and owns its whole lifecycle — loading, resolved,\nexception — re-running itself when its inputs change.\n\n**Use it when** you display data that lives on a server.\n**Not when** you write to the server ([`mutation`](/guide/state/mutations)) or\nrun a one-off async action that isn't a fetch\n([`asyncProcess`](/guide/state/async-process)).\n\n::: warning One source of truth\nDon't copy a query's result into a `state`. The query _is_ the state.\nDon't reload it from a `craftEffect` either — put the inputs in `params` so\nthe loader re-runs when they change.\n:::\n\n## The common case\n\n```typescript\nimport { CraftHttpClient, craftComputed, craftUse, query, settled } from '@craft-ts/core';\n\nconst { userQuery } =\n yield *\n query('userQuery', {\n params: () => ({ userId: currentUserId() }),\n loader: function* ({ params }) {\n return yield* CraftHttpClient.get(({ response }) => ({\n url: `/api/users/${params.userId}`,\n success: response<User>(),\n }));\n },\n });\n```\n\n## Uploading a raw binary body\n\n`CraftHttpClient` is the JSON transport: it serializes `payload` as JSON. For\nan upload whose body is already a `Blob`, `ArrayBuffer`, `FormData`, or another\n`BodyInit`, use `CraftBinaryHttpClient.put(...)` instead:\n\n```typescript\nimport {\n CraftBinaryHttpClient,\n mutation,\n response,\n} from '@craft-ts/core';\n\ntype UploadResult = { id: string };\n\nconst { uploadFile } =\n yield *\n mutation('uploadFile', {\n method: (file: Blob) => file,\n loader: function* ({ params: file }) {\n return yield* CraftBinaryHttpClient.put(({ response }) => ({\n url: '/api/files',\n payload: file,\n success: response<UploadResult>(),\n }));\n },\n });\n```\n\nThe request remains owned by the mutation: loading state, cancellation,\nresponse decoding, typed exceptions, tracing, and dependency tracking work the\nsame way as with `CraftHttpClient`. `CraftBinaryHttpClient` currently exposes\n`PUT` for raw bodies; use `CraftHttpClient.post(...)`, `.put(...)`, or\n`.patch(...)` when the server expects a JSON payload.\n\nDo not replace this with `fetch(...)` in the loader. Direct transport loses the\nCraft request lifecycle and is rejected by `prefer-craft-http-transport`.\n\n`params` is reactive: when what it returns changes, the loader runs again. The\nresult carries the full async state:\n\n```typescript\nuserQuery.value(); // User | undefined — never throws\nuserQuery.isLoading(); // boolean\nuserQuery.status(); // 'idle' | 'loading' | 'resolved' | 'exception'\nuserQuery.exception(); // craftException | undefined\n```\n\n::: tip\n`value()` is safe to read in templates and computed signals: it returns\n`undefined` when the query has no resolved value.\n:::\n\n## Reading only settled data\n\nUse `settledValue` when a template or derived computation requires a real\nvalue. It suspends to the nearest `pendingNode` while the first value is\nunavailable, propagates query exceptions to a `catchNode`, and keeps the\nprevious value during a reload.\n\n```typescript\nconst userName = craftComputed('userName', function* () {\n return (yield* settled(userQuery)).name;\n});\n\nconst user = craftUse(userQuery.settledValue());\n```\n\nInsertion contexts keep the existing fallback behaviour of `state()`. Use\n`settledState()` when `yield*` (or `craftUse`) should return a non-nullable\nvalue and suspend until the current resource is available.\n\n## Triggering it yourself\n\nWhen the trigger is a user action rather than a reactive input, use `method`\ninstead of `params`:\n\n```typescript\nconst { searchQuery } =\n yield *\n query('searchQuery', {\n method: (term: string) => term,\n loader: function* ({ params: term }) {\n return yield* CraftHttpClient.get(({ response }) => ({\n url: `/api/search?q=${term}`,\n success: response<Array<{ id: string; title: string }>>(),\n }));\n },\n });\n\n// In a tracked generator, consume the trigger with yield*.\nyield * searchQuery.call('craft');\n```\n\nFrom an ordinary UI callback, the imperative form remains valid:\n`click: () => searchQuery.call(term)`. Do not put either form in a\n`craftEffect` dependency graph; use reactive `params` for data loading.\n\n## Adding derived values\n\nSame insertion mechanism as any primitive:\n\n```typescript\nconst { todosQuery } =\n yield *\n query(\n 'todosQuery',\n {\n params: () => ({ completed: showCompleted() }),\n loader: async ({ params }) =>\n (await fetch(`/api/todos?completed=${params.completed}`)).json(),\n },\n ({ value, isLoading }) => ({\n count: craftComputed(function* () {\n return (yield* value())?.length ?? 0;\n }),\n isEmpty: craftComputed(function* () {\n return !(yield* isLoading()) && (yield* value())?.length === 0;\n }),\n }),\n );\n\nyield* todosQuery.count();\n```\n\nAn insertion can also be a `function*` when it needs to yield services.\n\n## Enriching every item in a list\n\nWhen a query returns an array, `insertQuerySelect` attaches an insertion to each\nselected item. The selector keeps the item type, so derived values can use its\nproperties without casting:\n\n```typescript\nimport { craftComputed as computed } from '@craft-ts/core';\nimport { CraftHttpClient, insertQuerySelect, query } from '@craft-ts/core';\n\ntype User = {\n id: string;\n firstName: string;\n lastName: string;\n role: 'admin' | 'member';\n};\n\nconst { usersQuery } =\n yield *\n query(\n 'usersQuery',\n {\n params: () => ({ teamId: currentTeamId() }),\n loader: function* ({ params }) {\n return yield* CraftHttpClient.get(({ response }) => ({\n url: `/api/teams/${params.teamId}/users`,\n success: response<User[]>(),\n }));\n },\n },\n insertQuerySelect('user', ({ state }) => ({\n displayName: craftComputed(function* () {\n const user = yield* state();\n return `${user.firstName} ${user.lastName}`;\n }),\n roleLabel: craftComputed(function* () {\n return (yield* state()).role === 'admin' ? 'Administrator' : 'Member';\n }),\n })),\n );\n\n// `selectUser` targets one item in the returned array.\nconst firstUser = usersQuery.selectUser(0);\nyield* firstUser?.displayName(); // 'Ada Lovelace'\nyield* firstUser?.roleLabel(); // 'Administrator'\n```\n\nThe same pattern supports selecting a nested object property with\n`insertQuerySelect`, while preserving the selected property's type.\n\n## Avoiding the flicker when inputs change\n\n**This is already the default.** When `params` change, the previous value stays\nvisible until the new one resolves, so a paginated list never blanks out\nmid-navigation.\n\nYou only touch the option to turn it **off**:\n\n```typescript\nquery('postsQuery', {\n params: () => ({ page: currentPage() }),\n preservePreviousValue: () => false, // clear the value while loading\n loader: async ({ params }) =>\n (await fetch(`/api/posts?page=${params.page}`)).json(),\n});\n```\n\n::: tip Not consulted for parallel queries\nWith an `identifier`, each key keeps its own resource, so there is no \"previous\nvalue\" to preserve — the option is ignored on that path.\n:::\n\n## Reacting to a mutation\n\nRather than reloading by hand after a write, declare the link:\n\n```typescript\nimport {\n insertQueryPipe,\n insertReactOnMutation,\n insertStoragePersister,\n} from '@craft-ts/core';\n\nconst userQuery = yield* query(\n 'userQuery',\n {\n params: () => ({ userId: currentUserId() }),\n loader: /* … */,\n },\n insertQueryPipe(\n insertReactOnMutation(updateUserMutation, {\n // apply the change immediately, before the server answers\n optimisticPatch: {\n name: ({ mutationParams }) => mutationParams.name,\n email: ({ mutationParams }) => mutationParams.email,\n },\n // and go get the truth back if the mutation failed\n reload: { onMutationException: true },\n }),\n insertStoragePersister(craftUnique({\n storeName: 'demo-app',\n key: 'user-query',\n })),\n ),\n);\n```\n\nFull options on [Reacting to mutations](/guide/state/react-on-mutation).\n\n## Exceptions\n\n`exceptions()` is split by **origin** and typed from the codes you declared —\n`params` for what your `method` rejected before any request, `loader` for what\nthe request produced:\n\n```typescript\nimport { craftException, query } from '@craft-ts/core';\n\nconst { userQuery } =\n yield *\n query('userQuery', {\n method: (value: string) =>\n value.length < 3\n ? craftException(\n { _tag: 'SEARCH_TERM_TOO_SHORT' },\n { min: 3, received: value.length },\n )\n : value,\n loader: async ({ params }) =>\n params === 'forbidden'\n ? craftException({ _tag: 'USER_ACCESS_FORBIDDEN' }, { id: params })\n : { id: params, name: 'John Doe' },\n });\n\nyield * userQuery.call('ab');\nuserQuery.hasException(); // true\nuserQuery.exceptions().params?.SEARCH_TERM_TOO_SHORT;\n\nyield * userQuery.call('forbidden');\nuserQuery.exceptions().loader?.USER_ACCESS_FORBIDDEN;\n```\n\nReturning a `craftException` from `method` means the loader never runs — you\ndon't send a request you already know will fail.\n\n## Pitfalls\n\n**No value is available yet.** Check `hasValue()` or handle the `undefined`\nresult while the query is loading or in exception.\n\n**`params` must be cheap and pure.** It runs inside a reactive computation; side\neffects belong in the loader.\n\n::: details Advanced — parallel queries by identifier\n`identifier` keeps one resource per key, so several runs coexist instead of\nreplacing each other:\n\n```typescript\nconst userId = signal<number | undefined>(undefined);\n\nconst { userQuery } =\n yield *\n query('userQuery', {\n params: userId,\n identifier: (id) => id,\n loader: function* ({ params }) {\n return yield* CraftHttpClient.get(({ response }) => ({\n url: `/api/users/${params}`,\n success: response<User>(),\n }));\n },\n });\n\nuserId.set(1);\nuserId.set(2);\n\nuserQuery.select('1').value(); // user 1\nuserQuery.select('2').value(); // user 2\n```\n\n:::\n\n::: details Advanced — typed HTTP exceptions\nLoader exceptions are matched declaratively: each matcher yields predicates on\nthe response and returns a `craftException` when it recognises the failure.\n\n```typescript\nloader: function* ({ params }) {\n return yield* CraftHttpClient.get(({ response }) => ({\n url: `/api/users/${params}`,\n success: response<User>(),\n exceptions: [\n function* ({ status, code, content }) {\n if (!(yield* status(400))) return;\n if (!(yield* code('PASSWORD_REQUIRED'))) return;\n if (!(yield* content('Password is required'))) return;\n\n return craftException({\n _tag: 'PASSWORD_REQUIRED',\n scope: 'UsersFeatureForDependencies',\n });\n },\n function* ({ body, header }) {\n const payload = yield* body<{\n errors?: Array<{ field: 'password' }>;\n }>();\n\n if (!payload.errors?.some((error) => error.field === 'password')) return;\n if (!(yield* header('x-error-kind', 'validation'))) return;\n\n return craftException({\n _tag: 'VALIDATION_HEADER_ERROR',\n scope: 'UsersFeatureForDependencies',\n });\n },\n ],\n }));\n}\n```\n\nWorking source:\n[exceptions demo](https://github.com/craft-ts/craft-ts/blob/main/apps/demo/src/app/examples/primitives/exceptions/exceptions.ts).\n:::\n\n::: details Advanced — yielding dependencies from `params`\n`params` can be a generator, and so can an insertion:\n\n```typescript\nconst { userQuery } =\n yield *\n query(\n 'userQuery',\n {\n providers: [provideUserService(), provideUserApiService()],\n params: function* () {\n return yield* UserService.userId();\n },\n loader: function* ({ params: userId }) {\n return yield* UserApiService.get(userId);\n },\n },\n function* () {\n const queryTools = yield* QueryTools();\n return { queryKey: `${queryTools.prefix()}:details` };\n },\n );\n```\n\n:::\n\n::: tip Advanced — injectable writes\nInsertion methods provide `injectQueryMethodRuntimeContext()`, and the query\nvalue itself is published to `providePrimitiveResourceRuntimeObserver`. Both\nexpose `get`, `set`, `update`, and `patch`, so wrappers, WebMCP tools, and\nother advanced patterns can seed or replace a result without going through the\ninsertion callback. See\n[Anatomy of a primitive](/guide/concepts/primitive-anatomy#injectable-runtime-context).\n:::\n\n## See Also\n\n- [Mutations](/guide/state/mutations) — the write side\n- [Reacting to mutations](/guide/state/react-on-mutation)\n- [Anatomy of a primitive](/guide/concepts/primitive-anatomy)\n"
|
|
426
426
|
},
|
|
427
427
|
{
|
|
428
428
|
"path": "/guide/state/state-machines",
|
|
429
429
|
"title": "State machines",
|
|
430
|
-
"body": "# State machines\n\n`craftStateMachine` models a finite workflow as a named, typed, reactive\nprimitive. It is useful when a feature has a small set of meaningful modes —\nfor example, an editor that is either reading or editing, a form that moves\nthrough validation and submission, or a resource that moves through loading,\nsuccess and failure.\n\nThe important part is not only that the machine has states. It is that the\nstates and the transitions are **100% declarative**: the machine describes\nwhich events can enter each state, and Craft derives the current state from\nthose declarations at runtime.\n\n## A different perspective on transitions\n\nMany state-machine APIs describe a transition from the current state:\n\n```text\nwhile in reading, when edit happens, go to editing\n```\n\nThat perspective makes the target explicit in the transition itself. You look\nat the `reading` state's handlers to discover where an `edit` event goes.\n\nCraft reverses the perspective. Each entry in the transitions record describes\n**how to enter that step**. The record key is the target step, and `transit()`\ninside that step's block means “attempt to enter this step”. It does not take a\nstate name because the surrounding key already supplies it.\n\nIf you are used to XState or a similar state-machine API, the difference is\nthe direction in which you read the same workflow graph. You may usually start\nfrom `reading` and ask “where does `edit` go?”. In Craft, you start from\n`editing` and ask “which event makes the machine enter `editing`?”. The graph\nis still explicit; its declarations are owned by their destination step.\n\n```typescript\nfunction* (context, transit) {\n return {\n reading: transitionStep(function* () {\n yield* initStateMachine(() => transit());\n yield* on$(context.commit$, () => transit());\n yield* on$(context.cancel$, () => transit());\n }),\n\n editing: transitionStep(function* () {\n yield* on$(context.edit$, () => transit());\n }),\n };\n}\n```\n\nReading this declaration tells you immediately:\n\n- `reading` is entered during initialisation, after `commit`, or after\n `cancel`;\n- `editing` is entered after `edit`.\n\nThere is no `currentStep = ...`, no imperative transition table, and no string\nsuch as `transit('editing')`. The destination is the step whose block declared\nthe event. If an event attempts to enter the step that is already active, the\nattempt is a no-op.\n\nThis makes the transition logic especially easy to inspect: to answer “when\ncan the machine enter `reading`?”, read the `reading` block and look at the\nevents it listens to. The machine's transition behavior is visible in the\ndeclarations themselves.\n\nThe same principle applies to the steps themselves. Each step registered in a\n`craftStateMachine` can be 100% declarative: its context can be assembled from\nCraft primitives, its event reactions can be expressed with `on$`, and its\nview can be selected from the typed step context. A step does not need an\nimperative “enter” function that manually changes the machine or coordinates\nthe rest of the feature.\n\n## The text editor example\n\nThe demo application contains a complete [declarative text editor\nexample](https://github.com/craft-ts/craft-ts/blob/main/apps/demo/src/app/examples/primitives/state-machine/text-editor.ts).\nIt has two steps:\n\n```text\nreading ← initialisation, commit, cancel\nediting ← edit\n```\n\nThe machine's context owns the events and the text state. The transitions only\ndeclare which events enter which step:\n\n```typescript\nconst machine =\n yield *\n craftStateMachine(\n 'textEditor',\n\n function* () {\n const edit$ = yield* source$<void>('text.edit');\n const commit$ = yield* source$<void>('text.commit');\n const cancel$ = yield* source$<void>('text.cancel');\n\n const text = yield* state(\n 'text',\n { committedValue: '', value: '' },\n insertStatePipe(insertDeepYieldable(), ({ patch }) => ({\n change: (value: string) => patch(() => ({ value })),\n commit: on$(commit$, () =>\n patch((current) => ({ committedValue: current.value })),\n ),\n cancel: on$(cancel$, () =>\n patch((current) => ({ value: current.committedValue })),\n ),\n })),\n );\n\n return { edit$, commit$, cancel$, text };\n },\n\n function* (context, transit) {\n return {\n reading: transitionStep(function* () {\n yield* initStateMachine(() => transit());\n yield* on$(context.commit$, () => transit());\n yield* on$(context.cancel$, () => transit());\n }),\n editing: transitionStep(function* () {\n yield* on$(context.edit$, () => transit());\n }),\n };\n },\n\n function* ({ text, cancel$, commit$, edit$ }) {\n return {\n reading: { text, edit$ },\n editing: { text, commit$, cancel$ },\n };\n },\n );\n```\n\nThe first factory creates the shared context. The second factory declares the\nmachine's steps and their incoming events. The third factory gives each step a\ntyped context for its view: the reading view can edit, while the editing view\ncan commit or cancel.\n\n`insertDeepYieldable()` makes the object-valued `text` state deeply readable.\nThe template can therefore bind to `reading.text.value` and\n`reading.text.committedValue` without creating a separate `craftComputed` for\neach property.\n\n## Rendering the current step\n\nThe machine exposes `currentStep` as a union of step names and\n`currentStepWithContext` as a discriminated union. Use the latter when each\nstep needs different data or actions:\n\n```typescript\nmatchNode.exhaustive(machine.currentStepWithContext, 'step', {\n reading: (reading) =>\n div([\n p(['Committed value: ', reading.text.committedValue]),\n p(['Current value: ', reading.text.value]),\n button({ click: () => reading.edit$.emit() }, 'Edit'),\n ]),\n\n editing: (editing) =>\n div([\n input({\n value: editing.text.value,\n input: function* (event) {\n yield* editing.text.change(event.target.value);\n },\n }),\n button({ click: () => editing.commit$.emit() }, 'Commit'),\n button({ click: () => editing.cancel$.emit() }, 'Cancel'),\n ]),\n});\n```\n\n`matchNode.exhaustive` checks that every step is handled, and narrows the\nhandler argument to that step's context. Adding a new step therefore produces\ncompile-time feedback both in the machine's transition record and in the\nrendering code.\n\nIf the view only needs the name, use the shorter scalar form:\n\n```typescript\nmatchNode.exhaustive(machine.currentStep, {\n reading: () => p('Reading'),\n editing: () => p('Editing'),\n});\n```\n\n## Guards\n\nThe event declaration says when a transition is attempted. A\n`transitionGuard` says whether that attempt is accepted. Guards can be local to\none step, global to the machine, or attached to one particular attempt:\n\n```typescript\nediting: transitionStep(function* () {\n yield* on$(context.edit$, () =>\n transit().pipe(\n transitionGuard(({ context }) => context.form.isValid()),\n ),\n );\n}),\n```\n\nA guard can also be a generator and yield Craft services. Those dependencies\nbecome part of the machine's dependency graph. This keeps the condition\ndeclarative as well: the transition is still described by its event and its\naccepted predicate, rather than by an imperative event handler that manually\ncoordinates state.\n\n## Composition and extensions\n\nThe final machine insertion has the same role as an insertion on `state`,\n`query`, or `mutation`. Use it for derived values, view helpers, selectors, or\nreusable behavior. For several machine insertions, use\n`insertStateMachinePipe`:\n\n```typescript\nconst machine =\n yield *\n craftStateMachine(\n 'editor',\n contextFactory,\n transitions,\n stepContextFactory,\n insertStateMachinePipe(\n withStateMachineHistory({\n persist: { storeName: 'demo', key: 'editor' },\n }),\n withBackNavigation(),\n ({ currentStep }) => ({\n isReading: craftComputed('isReading', function* () {\n return (yield* currentStep()) === 'reading';\n }),\n }),\n ),\n );\n```\n\nHistory, back/forward navigation, and derived flags are therefore extensions\nof the machine rather than hidden responsibilities of its core. The machine\nremains focused on declaring steps and the events that enter them.\n\n## When to use a state machine\n\nUse `craftStateMachine` when:\n\n- the feature has a finite set of named workflow steps;\n- different steps expose different actions or view data;\n- events, recomputations, or initialisation determine when a step is entered;\n- exhaustive handling of steps is valuable;\n- guards or reusable workflow extensions belong on the state-machine boundary.\n\nFor a single independent value, use [`state`](/guide/state/local-state). For a\nserver read or write, use [`query`](/guide/state/server-state) or\n[`mutation`](/guide/state/mutations). A state machine can compose those\nprimitives in its context when the workflow needs them.\n\n## API summary\n\n| API | Role |\n| ------------------------ | ------------------------------------------------------ |\n| `craftStateMachine` | Creates the named state-machine primitive |\n| `transitionStep` | Declares how one step is entered |\n| `transit()` | Creates an attempt to enter the surrounding step |\n| `initStateMachine` | Declares the attempt that establishes the initial step |\n| `transitionGuard` | Accepts or rejects a transition attempt |\n| `currentStep` | Reactive union of step names |\n| `currentStepWithContext` | Reactive discriminated union of step contexts |\n| `insertStateMachinePipe` | Composes machine insertions |\n\n## See also\n\n- [Local state](/guide/state/local-state)\n- [Typed insertion pipes](/guide/concepts/insertion-pipes)\n- [Fine-grained reactivity](/guide/components/fine-grained-reactivity)\n- [The text editor example on GitHub](https://github.com/craft-ts/craft-ts/blob/main/apps/demo/src/app/examples/primitives/state-machine/text-editor.ts)\n"
|
|
430
|
+
"body": "# State machines\n\n`craftStateMachine` models a finite workflow as a named, typed, reactive\nprimitive. It is useful when a feature has a small set of meaningful modes —\nfor example, an editor that is either reading or editing, a form that moves\nthrough validation and submission, or a resource that moves through loading,\nsuccess and failure.\n\nThe important part is not only that the machine has states. It is that the\nstates and the transitions are **100% declarative**: the machine describes\nwhich events can enter each state, and Craft derives the current state from\nthose declarations at runtime.\n\n## A different perspective on transitions\n\nMany state-machine APIs describe a transition from the current state:\n\n```text\nwhile in reading, when edit happens, go to editing\n```\n\nThat perspective makes the target explicit in the transition itself. You look\nat the `reading` state's handlers to discover where an `edit` event goes.\n\nCraft reverses the perspective. Each entry in the transitions record describes\n**how to enter that step**. The record key is the target step, and `transit()`\ninside that step's block means “attempt to enter this step”. It does not take a\nstate name because the surrounding key already supplies it.\n\nIf you are used to XState or a similar state-machine API, the difference is\nthe direction in which you read the same workflow graph. You may usually start\nfrom `reading` and ask “where does `edit` go?”. In Craft, you start from\n`editing` and ask “which event makes the machine enter `editing`?”. The graph\nis still explicit; its declarations are owned by their destination step.\n\n```typescript\nfunction* (context, transit) {\n return {\n reading: transitionStep(function* () {\n yield* initStateMachine(() => transit());\n yield* on$(context.commit$, () => transit());\n yield* on$(context.cancel$, () => transit());\n }),\n\n editing: transitionStep(function* () {\n yield* on$(context.edit$, () => transit());\n }),\n };\n}\n```\n\nReading this declaration tells you immediately:\n\n- `reading` is entered during initialisation, after `commit`, or after\n `cancel`;\n- `editing` is entered after `edit`.\n\nThere is no `currentStep = ...`, no imperative transition table, and no string\nsuch as `transit('editing')`. The destination is the step whose block declared\nthe event. If an event attempts to enter the step that is already active, the\nattempt is a no-op.\n\nThis makes the transition logic especially easy to inspect: to answer “when\ncan the machine enter `reading`?”, read the `reading` block and look at the\nevents it listens to. The machine's transition behavior is visible in the\ndeclarations themselves.\n\nThe same principle applies to the steps themselves. Each step registered in a\n`craftStateMachine` can be 100% declarative: its context can be assembled from\nCraft primitives, its event reactions can be expressed with `on$`, and its\nview can be selected from the typed step context. A step does not need an\nimperative “enter” function that manually changes the machine or coordinates\nthe rest of the feature.\n\n## The text editor example\n\nThe demo application contains a complete [declarative text editor\nexample](https://github.com/craft-ts/craft-ts/blob/main/apps/demo/src/app/examples/primitives/state-machine/text-editor.ts).\nIt has two steps:\n\n```text\nreading ← initialisation, commit, cancel\nediting ← edit\n```\n\nThe machine's context owns the events and the text state. The transitions only\ndeclare which events enter which step:\n\n```typescript\nconst machine =\n yield *\n craftStateMachine(\n 'textEditor',\n\n function* () {\n const edit$ = yield* source$<void>('text.edit');\n const commit$ = yield* source$<void>('text.commit');\n const cancel$ = yield* source$<void>('text.cancel');\n\n const text = yield* state(\n 'text',\n { committedValue: '', value: '' },\n insertStatePipe(insertDeepYieldable(), ({ patch }) => ({\n change: (value: string) => patch(() => ({ value })),\n commit: on$(commit$, () =>\n patch((current) => ({ committedValue: current.value })),\n ),\n cancel: on$(cancel$, () =>\n patch((current) => ({ value: current.committedValue })),\n ),\n })),\n );\n\n return { edit$, commit$, cancel$, text };\n },\n\n function* (context, transit) {\n return {\n reading: transitionStep(function* () {\n yield* initStateMachine(() => transit());\n yield* on$(context.commit$, () => transit());\n yield* on$(context.cancel$, () => transit());\n }),\n editing: transitionStep(function* () {\n yield* on$(context.edit$, () => transit());\n }),\n };\n },\n\n function* ({ text, cancel$, commit$, edit$ }) {\n return {\n reading: { text, edit$ },\n editing: { text, commit$, cancel$ },\n };\n },\n );\n```\n\nThe first factory creates the shared context. The second factory declares the\nmachine's steps and their incoming events. The third factory gives each step a\ntyped context for its view: the reading view can edit, while the editing view\ncan commit or cancel.\n\n`insertDeepYieldable()` makes the object-valued `text` state deeply readable.\nThe template can therefore bind to `reading.text.value` and\n`reading.text.committedValue` without creating a separate `craftComputed` for\neach property.\n\n## Rendering the current step\n\nThe machine exposes `currentStep` as a union of step names and\n`currentStepWithContext` as a discriminated union. Use the latter when each\nstep needs different data or actions:\n\n```typescript\nmatchNode.exhaustive(machine.currentStepWithContext, 'step', {\n reading: (reading) =>\n div([\n p(['Committed value: ', reading.text.committedValue]),\n p(['Current value: ', reading.text.value]),\n button({ click: () => reading.edit$.emit() }, 'Edit'),\n ]),\n\n editing: (editing) =>\n div([\n input({\n value: editing.text.value,\n input: function* (event) {\n yield* editing.text.change(event.target.value);\n },\n }),\n button({ click: () => editing.commit$.emit() }, 'Commit'),\n button({ click: () => editing.cancel$.emit() }, 'Cancel'),\n ]),\n});\n```\n\n`matchNode.exhaustive` checks that every step is handled, and narrows the\nhandler argument to that step's context. Adding a new step therefore produces\ncompile-time feedback both in the machine's transition record and in the\nrendering code.\n\nIf the view only needs the name, use the shorter scalar form:\n\n```typescript\nmatchNode.exhaustive(machine.currentStep, {\n reading: () => p('Reading'),\n editing: () => p('Editing'),\n});\n```\n\n## Guards\n\nThe event declaration says when a transition is attempted. A\n`transitionGuard` says whether that attempt is accepted. Guards can be local to\none step, global to the machine, or attached to one particular attempt:\n\n```typescript\nediting: transitionStep(function* () {\n yield* on$(context.edit$, () =>\n transit().pipe(\n transitionGuard(({ context }) => context.form.isValid()),\n ),\n );\n}),\n```\n\nA guard can also be a generator and yield Craft services. Those dependencies\nbecome part of the machine's dependency graph. This keeps the condition\ndeclarative as well: the transition is still described by its event and its\naccepted predicate, rather than by an imperative event handler that manually\ncoordinates state.\n\n### Effect services in a guard\n\nIn an Effect-enabled frontend, use `transitionGuardEffect` when the decision is\ndeclared synchronous and needs an Effect service. It is the Effect-aware form\nof `transitionGuard`:\n\n```typescript\nimport { Context, Effect, Layer } from 'effect';\nimport { SyncOp, transitionGuardEffect } from '@craft-ts/effect';\n\ntype CheckoutPolicyShape = {\n readonly canSubmit: (total: number) => Effect.Effect<boolean, never, SyncOp>;\n};\n\nclass CheckoutPolicy extends Context.Service<\n CheckoutPolicy,\n CheckoutPolicyShape\n>()('app/CheckoutPolicy') {}\n\nconst CheckoutPolicyLive = Layer.succeed(CheckoutPolicy, {\n canSubmit: (total) =>\n Effect.gen(function* () {\n yield* SyncOp;\n return total > 0;\n }),\n});\n\nediting: transitionStep(function* () {\n yield* on$(context.submit$, () =>\n transit().pipe(\n transitionGuardEffect(() =>\n Effect.gen(function* () {\n yield* SyncOp;\n const policy = yield* CheckoutPolicy;\n return yield* policy.canSubmit(context.total());\n }),\n ),\n ),\n );\n});\n```\n\n`transitionGuardEffect` runs through `syncEffect`, so `SyncOp` is required at\ncompile time and an Effect that suspends is rejected. The Effect service used by\nthe guard is also carried into the machine's dependency graph. Provide its\nimplementation with `provideLayer(...)` at the application, component or\nroute scope:\n\n```typescript\nconst routes = craftRoutes('checkout', [\n {\n path: '',\n ...loadCraftComponent(\n () => import('./checkout').then(({ default: component }) => component),\n [provideLayer(CheckoutPolicyLive)] as const,\n ),\n },\n]);\n```\n\nFor an asynchronous policy check, do not put the Effect in a guard. Use\n`asyncProcessEffect`, `queryEffect` or `mutationEffect`, then model the\n`pending`, success and failure outcomes as explicit machine steps. A transition\nguard must answer synchronously; an asynchronous decision is a workflow state.\n\n## Composition and extensions\n\nThe final machine insertion has the same role as an insertion on `state`,\n`query`, or `mutation`. Use it for derived values, view helpers, selectors, or\nreusable behavior. For several machine insertions, use\n`insertStateMachinePipe`:\n\n```typescript\nconst machine =\n yield *\n craftStateMachine(\n 'editor',\n contextFactory,\n transitions,\n stepContextFactory,\n insertStateMachinePipe(\n withStateMachineHistory({\n persist: { storeName: 'demo', key: 'editor' },\n }),\n withBackNavigation(),\n ({ currentStep }) => ({\n isReading: craftComputed('isReading', function* () {\n return (yield* currentStep()) === 'reading';\n }),\n }),\n ),\n );\n```\n\nHistory, back/forward navigation, and derived flags are therefore extensions\nof the machine rather than hidden responsibilities of its core. The machine\nremains focused on declaring steps and the events that enter them.\n\n## When to use a state machine\n\nUse `craftStateMachine` when:\n\n- the feature has a finite set of named workflow steps;\n- different steps expose different actions or view data;\n- events, recomputations, or initialisation determine when a step is entered;\n- exhaustive handling of steps is valuable;\n- guards or reusable workflow extensions belong on the state-machine boundary.\n\nFor a single independent value, use [`state`](/guide/state/local-state). For a\nserver read or write, use [`query`](/guide/state/server-state) or\n[`mutation`](/guide/state/mutations). A state machine can compose those\nprimitives in its context when the workflow needs them.\n\n## API summary\n\n| API | Role |\n| ------------------------ | ------------------------------------------------------ |\n| `craftStateMachine` | Creates the named state-machine primitive |\n| `transitionStep` | Declares how one step is entered |\n| `transit()` | Creates an attempt to enter the surrounding step |\n| `initStateMachine` | Declares the attempt that establishes the initial step |\n| `transitionGuard` | Accepts or rejects a transition attempt |\n| `transitionGuardEffect` | Effect-aware guard for declared-synchronous Effects |\n| `currentStep` | Reactive union of step names |\n| `currentStepWithContext` | Reactive discriminated union of step contexts |\n| `insertStateMachinePipe` | Composes machine insertions |\n\n## See also\n\n- [Local state](/guide/state/local-state)\n- [Typed insertion pipes](/guide/concepts/insertion-pipes)\n- [Fine-grained reactivity](/guide/components/fine-grained-reactivity)\n- [The text editor example on GitHub](https://github.com/craft-ts/craft-ts/blob/main/apps/demo/src/app/examples/primitives/state-machine/text-editor.ts)\n"
|
|
431
431
|
},
|
|
432
432
|
{
|
|
433
433
|
"path": "/guide/state/url-state",
|
|
@@ -472,7 +472,7 @@
|
|
|
472
472
|
{
|
|
473
473
|
"path": "/guide/testing/architecture",
|
|
474
474
|
"title": "Architecture rules",
|
|
475
|
-
"body": "# Architecture rules\n\nArchitecture tests answer one question:\n\n> **Is the dependency shape of the app still allowed?**\n\nThey read the static Craft graph — routes, services, components, primitives and\ntheir edges — without starting the application. That makes them useful for\nrules that are about relationships, ownership or declarations rather than\nruntime behaviour.\n\n## Choose the right kind of test\n\n| If you want to verify… | Use… | Example |\n| --- | --- | --- |\n| one unit computes the right result | [service tests](/guide/testing/services) | a service returns the expected value |\n| one component renders and reacts correctly | [component tests](/guide/testing/components) | a button disables after a click |\n| two parts of the app are allowed to depend on each other | architecture tests | `checkout` must not depend on `admin` |\n| a complete user journey works in a browser | `e2e/` tests | a user can create and then see a task |\n\nUse an architecture rule when the requirement sounds like one of these:\n\n- **must not depend on** — a feature must not reach into another feature;\n- **must be owned once** — an HTTP endpoint or persisted identity has one owner;\n- **must declare a relationship** — a mutation must refresh a query;\n- **must remain pure** — reading a computed value must not perform work.\n\nA green architecture suite does not prove that a button works. It proves that\nthe app still respects the boundaries that make that button maintainable.\n\n::: tip Start with the graph-wide baseline\nAdd `assertDeclarativeArchitecture(graph.graph)` first. It checks the core\ninvariants that are easiest to break during a refactor: unique identities,\nunique HTTP ownership, pure `craftComputed` values, no dependency cycles and\ndeclared mutation reactions. Add focused rules when your application has an\nadditional boundary, such as route DI, folder ownership or URL-backed resource\nparams.\n:::\n\n## What a rule looks like\n\nA rule is an ordinary Vitest assertion. Look up a node, inspect its graph\nrelationships or call a built-in assertion, then let CI protect the invariant:\n\n```typescript\nit('keeps checkout away from admin internals', () => {\n noExclusiveLink(graph.route('/checkout'), graph.route('/admin'));\n});\n```\n\nThe rest of this page explains the graph, the setup and the built-in rules.\n\n## Import\n\n```typescript\nimport {\n analyzeDependencyGraph,\n architectureCatalogToTypeScript,\n assertCraftComputedPure,\n assertCraftEffectNoImperativeSync,\n assertCraftEffectNoNetwork,\n assertCraftUnique,\n assertDeclarativeArchitecture,\n assertHttpEndpointUnique,\n assertInsertSelectUnique,\n assertInteractiveElementNamed,\n assertMutationHasReactOn,\n assertNoDependencyCycles,\n assertPathBoundaries,\n assertPrimitiveLoaderRequirements,\n assertQueryMutationHasServerState,\n assertResourceParamsPreferQueryParams,\n assertPersistedPrimitiveHasUnique,\n assertRouteComponentsInSeparateFiles,\n assertRouteDiProofs,\n buildArchitectureCatalog,\n createArchitectureGraph,\n noExclusiveLink,\n} from '@craft-ts/dev-tools';\n```\n\n## Mental model\n\n`analyzeDependencyGraph` reads the application sources with the TypeScript\nprogram — routes, services, components, HTTP calls, `craftUnique` identities,\nroute DI proofs (`CanRun`, `ValidateCascadeRoutesFile`, `RouteCheckedDI`) —\nand builds a graph of nodes and edges.\n\n`createArchitectureGraph` wraps that graph with typed lookups. Names come from\na generated **catalog** (`as const`): autocomplete, and a type error when a\nrenamed symbol disappears.\n\nA rule is then a Vitest assertion on those lookups. The suite lives next to\n`e2e/`, in an `architecture/` folder, and runs in Node — no `TestBed`, no\nbrowser.\n\nESLint already forbids local slips (`inject`, raw `HttpClient`) and can generate\nthe route proof blocks. Architecture tests catch **graph-wide** slips those\nrules cannot see: a feature leaking into another, an endpoint called from two\nAPIs, a duplicate storage key, a route or `app.config` error screen whose DI\nproof was never armed. See [ESLint rules](/guide/routing/eslint-rules).\n\n## Setting it up\n\nThe demo app is the working reference: `apps/demo/architecture/`, run with\n`npx nx architecture demo`. Commands are listed in `apps/demo/README.md`.\nCopy that layout, or scaffold it with the migrator (Vitest, Node):\n\n```shell\nnpx craft-migrate-architecture \\\n --project tsconfig.app.json \\\n --root src \\\n --write\n```\n\nThat writes `tsconfig.graph.json`, `tsconfig.architecture.json`,\n`vitest.architecture.config.ts`, the `architecture/` suite (loader, catalog,\nbaseline rules, and an `architecture.spec.ts`), an\nNx `architecture` target or a `package.json` script, and ignores the generated\ncatalog in the nearest flat ESLint config. `--write` overwrites the scaffold.\n`--check` fails when the suite is missing or the generated tooling files\ndrifted. `craft-migrate --write` runs this as its last step.\n\nKeep the rules and app-specific lookups in one `architecture.spec.ts` file when\nthe graph is expensive to analyze. `loadArchitectureGraph()` caches only within\none Vitest worker; separate spec files rebuild the TypeScript graph separately.\nThe three demo apps use this single-file layout, which performs one graph\nanalysis per app run.\n\n### 1. Analysis tsconfig\n\nPoint analysis at **every application source file**. `tsconfig.app.json` often\nlists only `main.ts`; the graph would then miss routes, services and components.\n\n```json\n{\n \"extends\": \"./tsconfig.json\",\n \"compilerOptions\": {\n \"skipLibCheck\": true\n },\n \"include\": [\"src/**/*.ts\"],\n \"exclude\": [\"src/**/*.spec.ts\", \"src/**/*.test.ts\"]\n}\n```\n\n### 2. Suite tsconfig\n\nA second project compiles only the architecture folder, with Node and Vitest\ntypes:\n\n```json\n{\n \"extends\": \"./tsconfig.json\",\n \"compilerOptions\": {\n \"types\": [\"node\", \"vitest/globals\"],\n \"module\": \"esnext\",\n \"moduleResolution\": \"bundler\"\n },\n \"include\": [\"architecture/**/*.ts\"]\n}\n```\n\nReference it from the app `tsconfig.json` `references` array so the IDE\ntypechecks the suite.\n\n### 3. Vitest, at the app root\n\nKeep the config next to `project.json` — **not** inside `architecture/`. A nested\n`vitest.config.ts` is picked up by the Nx Vitest plugin and breaks the app's\nunit-test target.\n\n```typescript\n/// <reference types=\"vitest\" />\nimport { defineConfig } from 'vite';\n\nexport default defineConfig(() => ({\n root: import.meta.dirname,\n cacheDir: '../../node_modules/.vite/apps/demo-architecture',\n plugins: [],\n resolve: {\n tsconfigPaths: true,\n },\n test: {\n name: 'demo-architecture',\n watch: false,\n globals: true,\n environment: 'node',\n testTimeout: 180_000,\n hookTimeout: 180_000,\n include: ['architecture/**/*.spec.ts'],\n },\n}));\n```\n\nAnalysis of a real app takes seconds, not milliseconds. Size the timeouts\naccordingly; `beforeAll` uses `hookTimeout`.\n\n### 4. Load the graph, rewrite the catalog\n\n```typescript\nimport { writeFileSync } from 'node:fs';\nimport { join, resolve } from 'node:path';\nimport {\n analyzeDependencyGraph,\n architectureCatalogToTypeScript,\n buildArchitectureCatalog,\n createArchitectureGraph,\n} from '@craft-ts/dev-tools';\nimport { architectureCatalog } from './catalog';\n\nconst workspaceRoot = resolve(import.meta.dirname, '../../..');\nconst catalogPath = join(import.meta.dirname, 'catalog.ts');\n\nexport function loadArchitectureGraph() {\n const graph = analyzeDependencyGraph({\n rootDir: workspaceRoot,\n tsConfigFilePath: 'apps/your-app/tsconfig.graph.json',\n });\n writeFileSync(\n catalogPath,\n `// Generated. Do not edit.\\n${architectureCatalogToTypeScript(buildArchitectureCatalog(graph))}`,\n );\n return createArchitectureGraph(graph, architectureCatalog);\n}\n```\n\nThe imported catalog is what TypeScript autocompletes against. The rewrite\nkeeps it in sync with the sources: after a rename, the next typecheck of the\nsuite fails until the lookups are updated.\n\nIgnore the generated catalog in ESLint. Commit it so the first clone\ntypechecks.\n\nBootstrap with `npx craft-graph --project apps/your-app/tsconfig.graph.json --root . --out apps/your-app/architecture/catalog --format json`.\nRename the generated `catalog.architecture.ts` to `catalog.ts`. After that,\nloading the graph keeps it current.\n\n### 5. Nx target\n\n```json\n{\n \"architecture\": {\n \"executor\": \"nx:run-commands\",\n \"options\": {\n \"command\": \"npx vitest run --config vitest.architecture.config.ts\",\n \"cwd\": \"apps/your-app\"\n },\n \"inputs\": [\n \"{projectRoot}/src/**/*.ts\",\n \"{projectRoot}/architecture/**/*.ts\",\n \"{projectRoot}/tsconfig.graph.json\"\n ],\n \"cache\": true\n }\n}\n```\n\n```shell\nnpx nx architecture your-app\n```\n\n## Looking up nodes\n\nPass the catalog into `createArchitectureGraph` and names become unions.\nA missing name throws `Unknown service '…'`. Two nodes sharing a name throw\nuntil you pass a relative file path.\n\n```typescript\ngraph.route('craft/query/:userId');\ngraph.service('UsersApiOnError');\ngraph.service('ApiService', 'users/api.service.ts'); // homonym\ngraph.component('ListWithPagination');\ngraph.providedOn('UserList');\ngraph.httpEndpoint('GET', 'users');\ngraph.unique('{\"key\":\"user-query\",\"storeName\":\"demo-app\"}');\ngraph.services({ browserBoundary: true, providedIn: 'global' });\ngraph.usingHttp();\ngraph.dependingOnBrowserBoundary();\ngraph.craftMethods();\n```\n\n| Lookup | Returns |\n| ------------------------------ | ---------------------------------------------------- |\n| `route(path, file?)` | one route node |\n| `service(name, file?)` | one service node |\n| `component(name, file?)` | one component node |\n| `providedOn(name)` | every node that `provides` that service |\n| `httpEndpoint(method, url)` | one HTTP endpoint |\n| `unique(canonicalJson)` | one `craftUnique` identity |\n| `services({ browserBoundary, scope })` | filtered services |\n| `usingHttp()` | nodes that call `CraftHttpClient` |\n| `dependingOnBrowserBoundary()` | nodes that depend on a `browserBoundary` service |\n| `uniques()` / `httpEndpoints()` / `craftMethods()` | all nodes of that kind |\n\nEach node exposes `providers()`, `provider(name)`, `outgoing(kind?)`,\n`incoming(kind?)` and `httpEndpoints()`. Edge kinds include `depends-on`,\n`provides`, `calls`, `loads`, `renders`, `reads`, `writes`, `checks`,\n`triggers`.\n\n`unique(...)` takes the **canonical JSON** of the identity object: keys sorted\nin depth. `{ storeName, key }` and `{ key, storeName }` index as the same\nstring.\n\nFor adding a TypeScript backend with its own typed nodes and relations, see\n[Extensible architecture graph](/guide/testing/extensible-architecture-graph).\n\n## Built-in helpers\n\nThe declarative baseline is the aggregate set of graph-wide checks below.\nImport them all, then either call each one or\n`assertDeclarativeArchitecture` for the aggregate checks together.\nThe demo suite keeps all checks in `apps/demo/architecture/architecture.spec.ts`\nso the graph is loaded once. Run it with `npx nx architecture demo`.\n\nEach rule has a focused page with the invariant it protects, the failure it\nprevents and the smallest useful test. Start with the [declarative\nbaseline](/guide/testing/architecture/declarative-baseline), then add the\nrules that express your application's boundaries.\n\n| Helper | Fails when |\n| --- | --- |\n| [`assertCraftUnique`](/guide/testing/architecture/unique-identities) | the same `craftUnique` identity appears twice, or the argument is not a static literal |\n| [`assertHttpEndpointUnique`](/guide/testing/architecture/http-endpoint-ownership) | the same HTTP verb+URL is called from more than one site |\n| [`assertCraftComputedPure`](/guide/testing/architecture/computed-purity) | a `craftComputed` `calls` a method or `writes` a `source$` |\n| [`assertPrimitiveMethodsUsedOnce`](/guide/testing/architecture/primitive-method-usage) | an exposed primitive insertion method is used from more than one call site |\n| [`assertNoUnusedPrimitiveMethods`](/guide/testing/architecture/unused-primitive-method) | an exposed primitive insertion method has no call site anywhere in the project |\n| [`assertNoDependencyCycles`](/guide/testing/architecture/dependency-cycles) | a directed cycle exists on `depends-on` (services, components, computeds) |\n| [`assertMutationHasReactOn`](/guide/testing/architecture/mutation-reactions) | a `mutation` has no query `insertReactOnMutation` edge (`allow` skips named fire-and-forget mutations) |\n| [`assertDeclarativeArchitecture`](/guide/testing/architecture/declarative-baseline) | any of the baseline checks fail |\n| [`assertRouteDiProofs`](/guide/testing/architecture/route-di-proofs) | a routed component, pending UI or error screen has no armed `CanRun` mapper, a collection is missing `assertExhaustiveRouteExceptions`, or `app.config.ts` registers a global / route-load error screen without its `RouteExceptionComponentCheckedDI` |\n| [`assertRouteComponentsInSeparateFiles`](/guide/testing/architecture/route-component-files) | a route loads its page component from the routing file, or multiple routed page components share one component file |\n| [`assertPathBoundaries`](/guide/testing/architecture/path-boundaries) | a `depends-on` (or opted-in `calls`) crosses a folder allowlist / denylist |\n| [`noExclusiveLink(a, b)`](/guide/testing/architecture/exclusive-links) | the only path between two branches is a leak, not a shared kernel |\n| [`assertPersistedPrimitiveHasUnique`](/guide/testing/architecture/persisted-identities) | `insertStoragePersister` is used without wrapping the identity in `craftUnique` |\n| [`assertInsertSelectUnique`](/guide/testing/architecture/insert-select-keys) | the same `insertSelect` key appears twice on one host primitive |\n| [`assertCraftEffectNoNetwork`](/guide/testing/architecture/craft-effect-network) | a `craftEffect` `calls` HTTP or a `mutation` |\n| [`assertCraftEffectNoImperativeSync`](/guide/testing/architecture/craft-effect-imperative-sync) | a `craftEffect` writes a `state` / `source$` or triggers a `query` / `mutation` / `asyncProcess` |\n| [`assertInteractiveElementNamed`](/guide/testing/architecture/interactive-element-names) | an interactive element lacks a literal name or duplicates a `data-craft-name` |\n| [`assertQueryMutationHasServerState`](/guide/testing/architecture/server-state-loader) | a `query` or `mutation` does not reach an allowed server-state boundary |\n| [`assertPrimitiveLoaderRequirements`](/guide/testing/architecture/primitive-loader-requirements) | an Effect-aware primitive does not declare an allowed dependency boundary |\n| [`assertResourceParamsPreferQueryParams`](/guide/testing/architecture/resource-params-query-state) | a `query` or `asyncProcess` params graph depends on a `state` instead of URL-backed `queryParams` |\n\n### `noExclusiveLink`\n\nForbids edges that exist only because two branches touch each other. A shared\nkernel — auth, HTTP client, browser boundaries — is allowed. Membership stops\nat other `provides` sites, so a leak into a third feature is not reclassified\nas shared.\n\n```typescript\nit('keeps exclusive feature branches from linking', () => {\n const [userList] = graph.providedOn('UserList');\n const [userMutation] = graph.providedOn('UserMutation');\n expect(userList).toBeDefined();\n expect(userMutation).toBeDefined();\n noExclusiveLink(userList, userMutation);\n});\n```\n\nThe same helper works on routes: `noExclusiveLink(graph.route('/admin'), graph.route('/checkout'))`.\n\n### `assertPathBoundaries`\n\nNx `depConstraints` tag **projects** and forbid TypeScript imports. This helper\ntags **folders** on the Craft graph and forbids `depends-on` (optionally\n`calls`) between them — including inside one app, where module-boundary ESLint\ndoes not run. Same intention, different altitude: [Craft graph vs\nNx](/guide/testing/craft-graph-vs-nx).\n\nPaths are relative to `graph.rootDir`. `*` is one segment, `**` is any depth,\n`:name` captures a segment. The same capture in `source` and `onlyDependOn` /\n`forbidTarget` must match, so a feature can depend on itself but not on\nsiblings.\n\n`onlyDependOn` is an allowlist; `forbidTarget` is a denylist. When both are\nset, the target must match the allowlist **and** miss the denylist. Nodes whose\npath matches no `source` are unconstrained. Edges without a `filePath` on\neither end, and structural edges (`provides`, `loads`, `renders`, `contains`),\nare ignored.\n\n```typescript\nit('keeps features and UI in their folders', () => {\n assertPathBoundaries(graph.graph, {\n constraints: [\n {\n source: 'src/app/features/:feature/**',\n onlyDependOn: [\n 'src/app/features/:feature/**',\n 'src/app/shared/**',\n 'src/app/ui/**',\n ],\n },\n {\n source: 'src/app/ui/**',\n onlyDependOn: ['src/app/ui/**', 'src/app/shared/**'],\n forbidTarget: ['src/app/data/**'],\n },\n ],\n });\n});\n```\n\nSibling features are an allowlist job (`onlyDependOn` includes\n`features/:feature/**`). A denylist `features/**` would also forbid self.\n\n### `assertCraftUnique`\n\nEach `craftUnique(...)` identity must appear once, and the argument must be a\nstatic literal — otherwise the graph cannot tell two call sites apart. Used\nwith [persistence](/guide/state/persistence) so two queries cannot silently\nshare a storage key.\n\n```typescript\nit('requires craftUnique identities to appear once', () => {\n assertCraftUnique(graph.graph);\n});\n```\n\nA duplicate or a non-literal argument fails the test with the file:line of\neach call site.\n\n### `assertHttpEndpointUnique`\n\nA `GET users` node is one verb + one URL. Two call sites — two services, or\nthe same service twice — fail the test. Distinct pairs (`GET users` and\n`POST users`, or `GET orders`) are allowed.\n\n```typescript\nit('owns each HTTP endpoint once', () => {\n assertHttpEndpointUnique(graph.graph);\n});\n```\n\nThis is the graph-wide counterpart of `craftUnique`. Wrapping `CraftHttpClient`\nin `craftUnique` is not required: the identity is the verb+URL.\n\n### `assertCraftComputedPure`\n\nA `craftComputed` may only **read**. Outgoing `calls` (a `craftMethod`,\n`increment`, `mutate`, …) and `writes` (`source$.emit` / `.set`) fail.\n\nLocal slips are also caught by ESLint\n`craft-ts/no-craft-computed-side-effects`. The graph catches a computed that\ncalls a method declared in another binding.\n\n```typescript\nit('keeps craftComputed free of methods and source$ writes', () => {\n assertCraftComputedPure(graph.graph);\n});\n```\n\n### `assertNoDependencyCycles`\n\nDirected cycles on `depends-on` only: service A → B → A, two `craftComputed`\nthat yield each other, a self-`yield*`. `provides`, `contains`, `loads` and\n`renders` are structure, not a cycle of use. A shared kernel (Left → Auth,\nRight → Auth) is not a cycle.\n\n```typescript\nit('forbids depends-on cycles', () => {\n assertNoDependencyCycles(graph.graph);\n});\n```\n\n### `assertDeclarativeArchitecture`\n\nRuns the aggregate checks above and joins their messages. Pass `{ allow }`\nthrough to `assertMutationHasReactOn` for fire-and-forget mutations.\n\n```typescript\nit('keeps the app declarative', () => {\n assertDeclarativeArchitecture(graph.graph, { allow: ['logout'] });\n});\n```\n\n### `assertRouteDiProofs`\n\nThe routing DI contract is type-level by design. `CanRun`,\n`ValidateCascadeRoutesFile`, `RouteCheckedDI` and\n`RouteExceptionComponentCheckedDI` are unused aliases unless they stay in the\nfile: comment one out and TypeScript still compiles. That is the one fragile\nstep in an otherwise compile-time guarantee.\n\nThis helper makes that step a test failure. It walks the static graph and\nrequires every routed component — including lazy `loadChildren` collections,\nwhich a parent proof never covers — every pending or error screen, and every\n`craftAppConfig` error surface to be hooked to an armed mapper. A mapper\nwithout `CanRun` is dead: the graph indexes it, then this rule fails.\nTypeScript still judges whether a dependency is provided; the architecture\nsuite judges whether that judgement was invoked.\n\n```typescript\nit('requires a DI proof on every routed component and app-config error screen', () => {\n assertRouteDiProofs(graph.graph);\n});\n```\n\nA missing proof, an unarmed mapper, a pending/error screen without its own\n`RouteCheckedDI`, a collection without `assertExhaustiveRouteExceptions`, or an\n`app.config.ts` that registers `provideCraftGlobalErrorComponent` /\n`provideCraftRouteLoadErrorComponent` (or `withErrorComponent` /\n`withRouteLoadError`) without an armed `RouteExceptionComponentCheckedDI` fails\nwith the file:line of the hole.\n\n### `assertRouteComponentsInSeparateFiles`\n\nRoute definitions describe navigation and loading; page components live in\ntheir own files. This assertion compares the route file with every component\ntarget discovered through `component`, `loadComponent` or a lazy `import()`,\nthen rejects multiple routed page components that share one component file.\n\n```typescript\nit('keeps route definitions separate from page components', () => {\n assertRouteComponentsInSeparateFiles(graph.graph);\n});\n```\n\nThe rule checks the page file boundary only. It does not restrict components\nrendered inside a page, and it does not require one route collection per file.\n\n### `assertMutationHasReactOn`\n\nA mutation that no query reacts to is the graph-wide form of\n[the button that knows which lists to refresh](/guide/state/react-on-mutation).\nThe analyzer records `insertReactOnMutation` as a `triggers` edge from the\nmutation to the query — including when the insertion is nested in\n`insertQueryPipe`. This helper fails on every `mutation` primitive that has no\nsuch edge.\n\nFire-and-forget writes (logout, a form submit with no cache, a demo that\nrefreshes by incrementing local state) pass an `allow` list of mutation names:\n\n```typescript\nit('requires a query to react to each mutation', () => {\n assertMutationHasReactOn(graph.graph, { allow: ['logout'] });\n});\n```\n\n### `assertPersistedPrimitiveHasUnique`\n\n`assertCraftUnique` says an identity appears once. This helper says a persisted\nprimitive *has* an identity: `insertStoragePersister` / `insertLocalStoragePersister`\nmust take `craftUnique(...)`. A raw `{ key, storeName }` indexes the primitive\nas persisted and fails here.\n\n```typescript\nit('requires craftUnique on every persisted primitive', () => {\n assertPersistedPrimitiveHasUnique(graph.graph);\n});\n```\n\nSee [Persistence](/guide/state/persistence).\n\n### `assertInsertSelectUnique`\n\n`insertSelect('cell')` names a slice on its host `state` / `query`. Two\nsiblings with the same key on the same host stomp each other. The same key on\ntwo different hosts is allowed — each list can have a `cell`.\n\n```typescript\nit('keeps insertSelect keys unique on each host', () => {\n assertInsertSelectUnique(graph.graph);\n});\n```\n\nSee [Selecting](/guide/state/select).\n\n### `assertCraftEffectNoNetwork`\n\nA `craftEffect` that `calls` `CraftHttpClient` or a `mutation` is a `query` or\n`mutation` in disguise. Reads of local `state` stay valid.\n\n```typescript\nit('keeps craftEffect off HTTP and mutations', () => {\n assertCraftEffectNoNetwork(graph.graph);\n});\n```\n\n### `assertCraftEffectNoImperativeSync`\n\nA `craftEffect` that writes another `state` or `source$`, or that calls\n`query.call` / `mutation.mutate` / `asyncProcess.method`, is glue that should\nbe a sourced `state` or reactive `params` instead. Logging, focus, and other\nI/O that does not push into a Craft primitive stay valid. ESLint\n`craft-ts/no-imperative-craft-resource-trigger` catches the resource-trigger\nhalf in the editor; this helper is the graph-wide counterpart, including\nstate writes.\n\n```typescript\nit('keeps craftEffect from pushing into other primitives', () => {\n assertCraftEffectNoImperativeSync(graph.graph);\n});\n```\n\n### `assertInteractiveElementNamed`\n\n`button('increment', {}, '+')` stamps `data-craft-name=\"increment\"`. Type-level\nproofs and DOM tests already key off that name. This helper makes the first\nstring **mandatory** on clickable and fillable elements, and **unique in the\napp**: two `button('save')` in two components fail, and so does\n`button({ click() {} }, 'Save')`. ESLint `craft-ts/require-interactive-local-name`\nis the editor counterpart for the missing / non-static cases.\n\n```typescript\nit('requires a unique literal data-craft-name on every interactive element', () => {\n assertInteractiveElementNamed(graph.graph);\n});\n```\n\n## Writing your own rules\n\nStart from a node you care about and assert what should be true of its\nneighbourhood. The demo suite does this for routes and HTTP; the same pattern\ncovers any invariant you can see on the graph.\n\n### A route provides the feature service\n\n```typescript\nit('indexes demo routes and provided feature services', () => {\n expect(graph.route('craft/query/:userId').kind).toBe('route');\n expect(graph.providedOn('UserList').map((node) => node.label)).toEqual(\n expect.arrayContaining([expect.stringMatching(/ListWithPagination/)]),\n );\n});\n```\n\n### An HTTP endpoint has a single owner\n\n```typescript\nit('indexes the users HTTP endpoint', () => {\n expect(graph.httpEndpoint('GET', 'users').label).toBe('GET users');\n expect(graph.usingHttp().map((node) => node.label)).toEqual(\n expect.arrayContaining(['UsersApiOnError']),\n );\n});\n```\n\n### HTTP only from a browser boundary\n\n[Browser boundaries](/guide/testing/browser-boundaries) are the line to the\nnetwork. A rule can require that `CraftHttpClient` is only yielded from a\nservice marked `browserBoundary: true`:\n\n```typescript\nit('only browser-boundary services call HTTP', () => {\n const boundaryIds = new Set(\n graph.services({ browserBoundary: true }).map((node) => node.id),\n );\n const leaked = graph\n .usingHttp()\n .filter((node) => node.kind === 'service' && !boundaryIds.has(node.id));\n expect(leaked.map((node) => node.label)).toEqual([]);\n});\n```\n\n### A persisted identity exists\n\n```typescript\nit('looks up a persisted unique identity', () => {\n expect(\n graph.unique('{\"key\":\"user-query\",\"storeName\":\"demo-app\"}').kind,\n ).toBe('unique');\n});\n```\n\nIf the lookup throws, the identity left the graph — the key changed, or\n`craftUnique` was removed.\n\nAnything you can express with `outgoing` / `incoming` is a rule: “this\n`craftMethod` is either called or writes a `source$`, never both”, “this\ncomponent does not `depends-on` that service”, “only `providedIn: 'global'` services\nappear under `usingTemporal()`”. Keep the assertion next to a comment that\nstates the product invariant, not the graph traversal.\n\n## Inspecting the graph\n\n`npx craft-graph` (also `npx craft graph`) writes the same analysis to disk\nwithout running tests:\n\n```shell\nnpx craft-graph \\\n --project apps/your-app/tsconfig.graph.json \\\n --root . \\\n --out craft-dependency-graph \\\n --format all\n```\n\n| `--format` | Writes |\n| ---------- | ------------------------------------------------------------------- |\n| `json` | the raw graph + a `.architecture.ts` catalog |\n| `mermaid` | a `.mmd` diagram |\n| `html` | a standalone explorer (no server, no runtime) |\n| `both` | JSON + catalog + Mermaid |\n| `all` | JSON + catalog + Mermaid + HTML |\n\n`--include <text>` restricts analysis to matching source paths. Use the HTML\nexplorer to see a route expand into components and services before you write\nthe assertion.\n\n## Pitfalls\n\n**The analysis tsconfig must include the app, not just `main.ts`.** An empty\ngraph with a passing `usingHttp()` is the usual symptom.\n\n**Do not nest `vitest.config.ts` under `architecture/`.** Put\n`vitest.architecture.config.ts` at the app root.\n\n**The catalog lags by one run.** Lookups are typed against the committed file.\nAfter adding a route or service, run the suite once so the rewrite lands, then\nthe new name typechecks.\n\n**Homonyms need a file path.** `graph.service('ApiService')` throws\n`Ambiguous service 'ApiService'` when two files export that name. Pass\n`'users/api.service.ts'`.\n\n**`craftUnique` must be a literal.** A computed `{ storeName, key }` indexes as\n`static: false` and `assertCraftUnique` fails — the graph cannot prove\nuniqueness.\n\n**A commented `CanRun` still type-checks.** Unused aliases are not errors.\n`assertRouteDiProofs` is the CI counterpart — that is the whole point of the\nhelper.\n\n**These tests are not e2e.** They never boot the app. Pair them with\n[service](/guide/testing/services) and [component](/guide/testing/components)\ntests for behaviour, and with ESLint for local architecture.\n\n## See Also\n\n- [Craft graph vs Nx](/guide/testing/craft-graph-vs-nx) — what each graph can\n and cannot see\n- [Testing services](/guide/testing/services) — the runtime graph of one service\n- [Browser boundaries](/guide/testing/browser-boundaries) — the nodes\n `browserBoundary: true` refers to\n- [Persistence](/guide/state/persistence) — why `craftUnique` identities must be\n unique\n- [ESLint rules](/guide/routing/eslint-rules) — local architecture, autofixed\n- [Routing setup](/guide/routing/setup) — the proofs this helper keeps armed\n- [Learn: test what you wrote](/learn/10-testing)\n"
|
|
475
|
+
"body": "# Architecture rules\n\nArchitecture tests answer one question:\n\n> **Is the dependency shape of the app still allowed?**\n\nThey read the static Craft graph — routes, services, components, primitives and\ntheir edges — without starting the application. That makes them useful for\nrules that are about relationships, ownership or declarations rather than\nruntime behaviour.\n\n## Choose the right kind of test\n\n| If you want to verify… | Use… | Example |\n| --- | --- | --- |\n| one unit computes the right result | [service tests](/guide/testing/services) | a service returns the expected value |\n| one component renders and reacts correctly | [component tests](/guide/testing/components) | a button disables after a click |\n| two parts of the app are allowed to depend on each other | architecture tests | `checkout` must not depend on `admin` |\n| a complete user journey works in a browser | `e2e/` tests | a user can create and then see a task |\n\nUse an architecture rule when the requirement sounds like one of these:\n\n- **must not depend on** — a feature must not reach into another feature;\n- **must be owned once** — an HTTP endpoint or persisted identity has one owner;\n- **must declare a relationship** — a mutation must refresh a query;\n- **must remain pure** — reading a computed value must not perform work.\n\nA green architecture suite does not prove that a button works. It proves that\nthe app still respects the boundaries that make that button maintainable.\n\n::: tip Start with the graph-wide baseline\nAdd `assertDeclarativeArchitecture(graph.graph)` first. It checks the core\ninvariants that are easiest to break during a refactor: unique identities,\nunique HTTP ownership, pure `craftComputed` values, no dependency cycles and\ndeclared mutation reactions. Add focused rules when your application has an\nadditional boundary, such as route DI, folder ownership or URL-backed resource\nparams.\n:::\n\n## What a rule looks like\n\nA rule is an ordinary Vitest assertion. Look up a node, inspect its graph\nrelationships or call a built-in assertion, then let CI protect the invariant:\n\n```typescript\nit('keeps checkout away from admin internals', () => {\n noExclusiveLink(graph.route('/checkout'), graph.route('/admin'));\n});\n```\n\nThe rest of this page explains the graph, the setup and the built-in rules.\n\n## Import\n\n```typescript\nimport {\n analyzeDependencyGraph,\n architectureCatalogToTypeScript,\n assertCraftComputedPure,\n assertCraftEffectNoImperativeSync,\n assertCraftEffectNoNetwork,\n assertCraftUnique,\n assertDeclarativeArchitecture,\n assertHttpEndpointUnique,\n assertInsertSelectUnique,\n assertInteractiveElementNamed,\n assertMutationHasReactOn,\n assertNoDependencyCycles,\n assertPathBoundaries,\n assertPrimitiveLoaderRequirements,\n assertQueryMutationHasServerState,\n assertResourceParamsPreferQueryParams,\n assertPersistedPrimitiveHasUnique,\n assertRouteComponentsInSeparateFiles,\n assertRouteDiProofs,\n buildArchitectureCatalog,\n createArchitectureGraph,\n noExclusiveLink,\n} from '@craft-ts/dev-tools';\n```\n\n## Mental model\n\n`analyzeDependencyGraph` reads the application sources with the TypeScript\nprogram — routes, services, components, HTTP calls, `craftUnique` identities,\nroute DI proofs (`CanRun`, `RouteCheckedDI`) —\nand builds a graph of nodes and edges.\n\n`createArchitectureGraph` wraps that graph with typed lookups. Names come from\na generated **catalog** (`as const`): autocomplete, and a type error when a\nrenamed symbol disappears.\n\nA rule is then a Vitest assertion on those lookups. The suite lives next to\n`e2e/`, in an `architecture/` folder, and runs in Node — no `TestBed`, no\nbrowser.\n\nESLint already forbids local slips (`inject`, raw `HttpClient`) and can generate\nthe route proof blocks. Architecture tests catch **graph-wide** slips those\nrules cannot see: a feature leaking into another, an endpoint called from two\nAPIs, a duplicate storage key, a route or `app.config` error screen whose DI\nproof was never armed. See [ESLint rules](/guide/routing/eslint-rules).\n\n## Setting it up\n\nThe demo app is the working reference: `apps/demo/architecture/`, run with\n`npx nx architecture demo`. Commands are listed in `apps/demo/README.md`.\nCopy that layout, or scaffold it with the migrator (Vitest, Node):\n\n```shell\nnpx craft-migrate-architecture \\\n --project tsconfig.app.json \\\n --root src \\\n --write\n```\n\nThat writes `tsconfig.graph.json`, `tsconfig.architecture.json`,\n`vitest.architecture.config.ts`, the `architecture/` suite (loader, catalog,\nbaseline rules, and an `architecture.spec.ts`), an\nNx `architecture` target or a `package.json` script, and ignores the generated\ncatalog in the nearest flat ESLint config. `--write` overwrites the scaffold.\n`--check` fails when the suite is missing or the generated tooling files\ndrifted. `craft-migrate --write` runs this as its last step.\n\nKeep the rules and app-specific lookups in one `architecture.spec.ts` file when\nthe graph is expensive to analyze. `loadArchitectureGraph()` caches only within\none Vitest worker; separate spec files rebuild the TypeScript graph separately.\nThe three demo apps use this single-file layout, which performs one graph\nanalysis per app run.\n\n### 1. Analysis tsconfig\n\nPoint analysis at **every application source file**. `tsconfig.app.json` often\nlists only `main.ts`; the graph would then miss routes, services and components.\n\n```json\n{\n \"extends\": \"./tsconfig.json\",\n \"compilerOptions\": {\n \"skipLibCheck\": true\n },\n \"include\": [\"src/**/*.ts\"],\n \"exclude\": [\"src/**/*.spec.ts\", \"src/**/*.test.ts\"]\n}\n```\n\n### 2. Suite tsconfig\n\nA second project compiles only the architecture folder, with Node and Vitest\ntypes:\n\n```json\n{\n \"extends\": \"./tsconfig.json\",\n \"compilerOptions\": {\n \"types\": [\"node\", \"vitest/globals\"],\n \"module\": \"esnext\",\n \"moduleResolution\": \"bundler\"\n },\n \"include\": [\"architecture/**/*.ts\"]\n}\n```\n\nReference it from the app `tsconfig.json` `references` array so the IDE\ntypechecks the suite.\n\n### 3. Vitest, at the app root\n\nKeep the config next to `project.json` — **not** inside `architecture/`. A nested\n`vitest.config.ts` is picked up by the Nx Vitest plugin and breaks the app's\nunit-test target.\n\n```typescript\n/// <reference types=\"vitest\" />\nimport { defineConfig } from 'vite';\n\nexport default defineConfig(() => ({\n root: import.meta.dirname,\n cacheDir: '../../node_modules/.vite/apps/demo-architecture',\n plugins: [],\n resolve: {\n tsconfigPaths: true,\n },\n test: {\n name: 'demo-architecture',\n watch: false,\n globals: true,\n environment: 'node',\n testTimeout: 180_000,\n hookTimeout: 180_000,\n include: ['architecture/**/*.spec.ts'],\n },\n}));\n```\n\nAnalysis of a real app takes seconds, not milliseconds. Size the timeouts\naccordingly; `beforeAll` uses `hookTimeout`.\n\n### 4. Load the graph, rewrite the catalog\n\n```typescript\nimport { writeFileSync } from 'node:fs';\nimport { join, resolve } from 'node:path';\nimport {\n analyzeDependencyGraph,\n architectureCatalogToTypeScript,\n buildArchitectureCatalog,\n createArchitectureGraph,\n} from '@craft-ts/dev-tools';\nimport { architectureCatalog } from './catalog';\n\nconst workspaceRoot = resolve(import.meta.dirname, '../../..');\nconst catalogPath = join(import.meta.dirname, 'catalog.ts');\n\nexport function loadArchitectureGraph() {\n const graph = analyzeDependencyGraph({\n rootDir: workspaceRoot,\n tsConfigFilePath: 'apps/your-app/tsconfig.graph.json',\n });\n writeFileSync(\n catalogPath,\n `// Generated. Do not edit.\\n${architectureCatalogToTypeScript(buildArchitectureCatalog(graph))}`,\n );\n return createArchitectureGraph(graph, architectureCatalog);\n}\n```\n\nThe imported catalog is what TypeScript autocompletes against. The rewrite\nkeeps it in sync with the sources: after a rename, the next typecheck of the\nsuite fails until the lookups are updated.\n\nIgnore the generated catalog in ESLint. Commit it so the first clone\ntypechecks.\n\nBootstrap with `npx craft-graph --project apps/your-app/tsconfig.graph.json --root . --out apps/your-app/architecture/catalog --format json`.\nRename the generated `catalog.architecture.ts` to `catalog.ts`. After that,\nloading the graph keeps it current.\n\n### 5. Nx target\n\n```json\n{\n \"architecture\": {\n \"executor\": \"nx:run-commands\",\n \"options\": {\n \"command\": \"npx vitest run --config vitest.architecture.config.ts\",\n \"cwd\": \"apps/your-app\"\n },\n \"inputs\": [\n \"{projectRoot}/src/**/*.ts\",\n \"{projectRoot}/architecture/**/*.ts\",\n \"{projectRoot}/tsconfig.graph.json\"\n ],\n \"cache\": true\n }\n}\n```\n\n```shell\nnpx nx architecture your-app\n```\n\n## Looking up nodes\n\nPass the catalog into `createArchitectureGraph` and names become unions.\nA missing name throws `Unknown service '…'`. Two nodes sharing a name throw\nuntil you pass a relative file path.\n\n```typescript\ngraph.route('craft/query/:userId');\ngraph.service('UsersApiOnError');\ngraph.service('ApiService', 'users/api.service.ts'); // homonym\ngraph.component('ListWithPagination');\ngraph.providedOn('UserList');\ngraph.httpEndpoint('GET', 'users');\ngraph.unique('{\"key\":\"user-query\",\"storeName\":\"demo-app\"}');\ngraph.services({ browserBoundary: true, providedIn: 'global' });\ngraph.usingHttp();\ngraph.dependingOnBrowserBoundary();\ngraph.craftMethods();\n```\n\n| Lookup | Returns |\n| ------------------------------ | ---------------------------------------------------- |\n| `route(path, file?)` | one route node |\n| `service(name, file?)` | one service node |\n| `component(name, file?)` | one component node |\n| `providedOn(name)` | every node that `provides` that service |\n| `httpEndpoint(method, url)` | one HTTP endpoint |\n| `unique(canonicalJson)` | one `craftUnique` identity |\n| `services({ browserBoundary, scope })` | filtered services |\n| `usingHttp()` | nodes that call `CraftHttpClient` |\n| `dependingOnBrowserBoundary()` | nodes that depend on a `browserBoundary` service |\n| `uniques()` / `httpEndpoints()` / `craftMethods()` | all nodes of that kind |\n\nEach node exposes `providers()`, `provider(name)`, `outgoing(kind?)`,\n`incoming(kind?)` and `httpEndpoints()`. Edge kinds include `depends-on`,\n`provides`, `calls`, `loads`, `renders`, `reads`, `writes`, `checks`,\n`triggers`.\n\n`unique(...)` takes the **canonical JSON** of the identity object: keys sorted\nin depth. `{ storeName, key }` and `{ key, storeName }` index as the same\nstring.\n\nFor adding a TypeScript backend with its own typed nodes and relations, see\n[Extensible architecture graph](/guide/testing/extensible-architecture-graph).\n\n## Built-in helpers\n\nThe declarative baseline is the aggregate set of graph-wide checks below.\nImport them all, then either call each one or\n`assertDeclarativeArchitecture` for the aggregate checks together.\nThe demo suite keeps all checks in `apps/demo/architecture/architecture.spec.ts`\nso the graph is loaded once. Run it with `npx nx architecture demo`.\n\nEach rule has a focused page with the invariant it protects, the failure it\nprevents and the smallest useful test. Start with the [declarative\nbaseline](/guide/testing/architecture/declarative-baseline), then add the\nrules that express your application's boundaries.\n\n| Helper | Fails when |\n| --- | --- |\n| [`assertCraftUnique`](/guide/testing/architecture/unique-identities) | the same `craftUnique` identity appears twice, or the argument is not a static literal |\n| [`assertHttpEndpointUnique`](/guide/testing/architecture/http-endpoint-ownership) | the same HTTP verb+URL is called from more than one site |\n| [`assertCraftComputedPure`](/guide/testing/architecture/computed-purity) | a `craftComputed` `calls` a method or `writes` a `source$` |\n| [`assertPrimitiveMethodsUsedOnce`](/guide/testing/architecture/primitive-method-usage) | an exposed primitive insertion method is used from more than one call site |\n| [`assertNoUnusedPrimitiveMethods`](/guide/testing/architecture/unused-primitive-method) | an exposed primitive insertion method has no call site anywhere in the project |\n| [`assertNoDependencyCycles`](/guide/testing/architecture/dependency-cycles) | a directed cycle exists on `depends-on` (services, components, computeds) |\n| [`assertMutationHasReactOn`](/guide/testing/architecture/mutation-reactions) | a `mutation` has no query `insertReactOnMutation` edge (`allow` skips named fire-and-forget mutations) |\n| [`assertDeclarativeArchitecture`](/guide/testing/architecture/declarative-baseline) | any of the baseline checks fail |\n| [`assertRouteDiProofs`](/guide/testing/architecture/route-di-proofs) | a routed component, pending UI or error screen has no armed `CanRun` mapper, a collection is missing `assertExhaustiveRouteExceptions`, or `app.config.ts` registers a global / route-load error screen without its `RouteExceptionComponentCheckedDI` |\n| [`assertRouteComponentsInSeparateFiles`](/guide/testing/architecture/route-component-files) | a route loads its page component from the routing file, or multiple routed page components share one component file |\n| [`assertPathBoundaries`](/guide/testing/architecture/path-boundaries) | a `depends-on` (or opted-in `calls`) crosses a folder allowlist / denylist |\n| [`noExclusiveLink(a, b)`](/guide/testing/architecture/exclusive-links) | the only path between two branches is a leak, not a shared kernel |\n| [`assertPersistedPrimitiveHasUnique`](/guide/testing/architecture/persisted-identities) | `insertStoragePersister` is used without wrapping the identity in `craftUnique` |\n| [`assertInsertSelectUnique`](/guide/testing/architecture/insert-select-keys) | the same `insertSelect` key appears twice on one host primitive |\n| [`assertCraftEffectNoNetwork`](/guide/testing/architecture/craft-effect-network) | a `craftEffect` `calls` HTTP or a `mutation` |\n| [`assertCraftEffectNoImperativeSync`](/guide/testing/architecture/craft-effect-imperative-sync) | a `craftEffect` writes a `state` / `source$` or triggers a `query` / `mutation` / `asyncProcess` |\n| [`assertInteractiveElementNamed`](/guide/testing/architecture/interactive-element-names) | an interactive element lacks a literal name or duplicates a `data-craft-name` |\n| [`assertQueryMutationHasServerState`](/guide/testing/architecture/server-state-loader) | a `query` or `mutation` does not reach an allowed server-state boundary |\n| [`assertPrimitiveLoaderRequirements`](/guide/testing/architecture/primitive-loader-requirements) | an Effect-aware primitive does not declare an allowed dependency boundary |\n| [`assertResourceParamsPreferQueryParams`](/guide/testing/architecture/resource-params-query-state) | a `query` or `asyncProcess` params graph depends on a `state` instead of URL-backed `queryParams` |\n\n### `noExclusiveLink`\n\nForbids edges that exist only because two branches touch each other. A shared\nkernel — auth, HTTP client, browser boundaries — is allowed. Membership stops\nat other `provides` sites, so a leak into a third feature is not reclassified\nas shared.\n\n```typescript\nit('keeps exclusive feature branches from linking', () => {\n const [userList] = graph.providedOn('UserList');\n const [userMutation] = graph.providedOn('UserMutation');\n expect(userList).toBeDefined();\n expect(userMutation).toBeDefined();\n noExclusiveLink(userList, userMutation);\n});\n```\n\nThe same helper works on routes: `noExclusiveLink(graph.route('/admin'), graph.route('/checkout'))`.\n\n### `assertPathBoundaries`\n\nNx `depConstraints` tag **projects** and forbid TypeScript imports. This helper\ntags **folders** on the Craft graph and forbids `depends-on` (optionally\n`calls`) between them — including inside one app, where module-boundary ESLint\ndoes not run. Same intention, different altitude: [Craft graph vs\nNx](/guide/testing/craft-graph-vs-nx).\n\nPaths are relative to `graph.rootDir`. `*` is one segment, `**` is any depth,\n`:name` captures a segment. The same capture in `source` and `onlyDependOn` /\n`forbidTarget` must match, so a feature can depend on itself but not on\nsiblings.\n\n`onlyDependOn` is an allowlist; `forbidTarget` is a denylist. When both are\nset, the target must match the allowlist **and** miss the denylist. Nodes whose\npath matches no `source` are unconstrained. Edges without a `filePath` on\neither end, and structural edges (`provides`, `loads`, `renders`, `contains`),\nare ignored.\n\n```typescript\nit('keeps features and UI in their folders', () => {\n assertPathBoundaries(graph.graph, {\n constraints: [\n {\n source: 'src/app/features/:feature/**',\n onlyDependOn: [\n 'src/app/features/:feature/**',\n 'src/app/shared/**',\n 'src/app/ui/**',\n ],\n },\n {\n source: 'src/app/ui/**',\n onlyDependOn: ['src/app/ui/**', 'src/app/shared/**'],\n forbidTarget: ['src/app/data/**'],\n },\n ],\n });\n});\n```\n\nSibling features are an allowlist job (`onlyDependOn` includes\n`features/:feature/**`). A denylist `features/**` would also forbid self.\n\n### `assertCraftUnique`\n\nEach `craftUnique(...)` identity must appear once, and the argument must be a\nstatic literal — otherwise the graph cannot tell two call sites apart. Used\nwith [persistence](/guide/state/persistence) so two queries cannot silently\nshare a storage key.\n\n```typescript\nit('requires craftUnique identities to appear once', () => {\n assertCraftUnique(graph.graph);\n});\n```\n\nA duplicate or a non-literal argument fails the test with the file:line of\neach call site.\n\n### `assertHttpEndpointUnique`\n\nA `GET users` node is one verb + one URL. Two call sites — two services, or\nthe same service twice — fail the test. Distinct pairs (`GET users` and\n`POST users`, or `GET orders`) are allowed.\n\n```typescript\nit('owns each HTTP endpoint once', () => {\n assertHttpEndpointUnique(graph.graph);\n});\n```\n\nThis is the graph-wide counterpart of `craftUnique`. Wrapping `CraftHttpClient`\nin `craftUnique` is not required: the identity is the verb+URL.\n\n### `assertCraftComputedPure`\n\nA `craftComputed` may only **read**. Outgoing `calls` (a `craftMethod`,\n`increment`, `mutate`, …) and `writes` (`source$.emit` / `.set`) fail.\n\nLocal slips are also caught by ESLint\n`craft-ts/no-craft-computed-side-effects`. The graph catches a computed that\ncalls a method declared in another binding.\n\n```typescript\nit('keeps craftComputed free of methods and source$ writes', () => {\n assertCraftComputedPure(graph.graph);\n});\n```\n\n### `assertNoDependencyCycles`\n\nDirected cycles on `depends-on` only: service A → B → A, two `craftComputed`\nthat yield each other, a self-`yield*`. `provides`, `contains`, `loads` and\n`renders` are structure, not a cycle of use. A shared kernel (Left → Auth,\nRight → Auth) is not a cycle.\n\n```typescript\nit('forbids depends-on cycles', () => {\n assertNoDependencyCycles(graph.graph);\n});\n```\n\n### `assertDeclarativeArchitecture`\n\nRuns the aggregate checks above and joins their messages. Pass `{ allow }`\nthrough to `assertMutationHasReactOn` for fire-and-forget mutations.\n\n```typescript\nit('keeps the app declarative', () => {\n assertDeclarativeArchitecture(graph.graph, { allow: ['logout'] });\n});\n```\n\n### `assertRouteDiProofs`\n\nThe routing DI contract is type-level by design. `CanRun`, `RouteCheckedDI` and\n`RouteExceptionComponentCheckedDI` are unused aliases unless they stay in the\nfile: comment one out and TypeScript still compiles. That is the one fragile\nstep in an otherwise compile-time guarantee.\n\nThis helper makes that step a test failure. It walks the static graph and\nrequires every routed component — including lazy `loadChildren` collections,\nwhich a parent proof never covers — every pending or error screen, and every\n`craftAppConfig` error surface to be hooked to an armed mapper. A mapper\nwithout `CanRun` is dead: the graph indexes it, then this rule fails.\nTypeScript still judges whether a dependency is provided; the architecture\nsuite judges whether that judgement was invoked.\n\n```typescript\nit('requires a DI proof on every routed component and app-config error screen', () => {\n assertRouteDiProofs(graph.graph);\n});\n```\n\nA missing proof, an unarmed mapper, a pending/error screen without its own\n`RouteCheckedDI`, a collection without `assertExhaustiveRouteExceptions`, or an\n`app.config.ts` that registers `provideCraftGlobalErrorComponent` /\n`provideCraftRouteLoadErrorComponent` (or `withErrorComponent` /\n`withRouteLoadError`) without an armed `RouteExceptionComponentCheckedDI` fails\nwith the file:line of the hole.\n\n### `assertRouteComponentsInSeparateFiles`\n\nRoute definitions describe navigation and loading; page components live in\ntheir own files. This assertion compares the route file with every component\ntarget discovered through `component`, `loadComponent` or a lazy `import()`,\nthen rejects multiple routed page components that share one component file.\n\n```typescript\nit('keeps route definitions separate from page components', () => {\n assertRouteComponentsInSeparateFiles(graph.graph);\n});\n```\n\nThe rule checks the page file boundary only. It does not restrict components\nrendered inside a page, and it does not require one route collection per file.\n\n### `assertMutationHasReactOn`\n\nA mutation that no query reacts to is the graph-wide form of\n[the button that knows which lists to refresh](/guide/state/react-on-mutation).\nThe analyzer records `insertReactOnMutation` as a `triggers` edge from the\nmutation to the query — including when the insertion is nested in\n`insertQueryPipe`. This helper fails on every `mutation` primitive that has no\nsuch edge.\n\nFire-and-forget writes (logout, a form submit with no cache, a demo that\nrefreshes by incrementing local state) pass an `allow` list of mutation names:\n\n```typescript\nit('requires a query to react to each mutation', () => {\n assertMutationHasReactOn(graph.graph, { allow: ['logout'] });\n});\n```\n\n### `assertPersistedPrimitiveHasUnique`\n\n`assertCraftUnique` says an identity appears once. This helper says a persisted\nprimitive *has* an identity: `insertStoragePersister` / `insertLocalStoragePersister`\nmust take `craftUnique(...)`. A raw `{ key, storeName }` indexes the primitive\nas persisted and fails here.\n\n```typescript\nit('requires craftUnique on every persisted primitive', () => {\n assertPersistedPrimitiveHasUnique(graph.graph);\n});\n```\n\nSee [Persistence](/guide/state/persistence).\n\n### `assertInsertSelectUnique`\n\n`insertSelect('cell')` names a slice on its host `state` / `query`. Two\nsiblings with the same key on the same host stomp each other. The same key on\ntwo different hosts is allowed — each list can have a `cell`.\n\n```typescript\nit('keeps insertSelect keys unique on each host', () => {\n assertInsertSelectUnique(graph.graph);\n});\n```\n\nSee [Selecting](/guide/state/select).\n\n### `assertCraftEffectNoNetwork`\n\nA `craftEffect` that `calls` `CraftHttpClient` or a `mutation` is a `query` or\n`mutation` in disguise. Reads of local `state` stay valid.\n\n```typescript\nit('keeps craftEffect off HTTP and mutations', () => {\n assertCraftEffectNoNetwork(graph.graph);\n});\n```\n\n### `assertCraftEffectNoImperativeSync`\n\nA `craftEffect` that writes another `state` or `source$`, or that calls\n`query.call` / `mutation.mutate` / `asyncProcess.method`, is glue that should\nbe a sourced `state` or reactive `params` instead. Logging, focus, and other\nI/O that does not push into a Craft primitive stay valid. ESLint\n`craft-ts/no-imperative-craft-resource-trigger` catches the resource-trigger\nhalf in the editor; this helper is the graph-wide counterpart, including\nstate writes.\n\n```typescript\nit('keeps craftEffect from pushing into other primitives', () => {\n assertCraftEffectNoImperativeSync(graph.graph);\n});\n```\n\n### `assertInteractiveElementNamed`\n\n`button('increment', {}, '+')` stamps `data-craft-name=\"increment\"`. Type-level\nproofs and DOM tests already key off that name. This helper makes the first\nstring **mandatory** on clickable and fillable elements, and **unique in the\napp**: two `button('save')` in two components fail, and so does\n`button({ click() {} }, 'Save')`. ESLint `craft-ts/require-interactive-local-name`\nis the editor counterpart for the missing / non-static cases.\n\n```typescript\nit('requires a unique literal data-craft-name on every interactive element', () => {\n assertInteractiveElementNamed(graph.graph);\n});\n```\n\n## Writing your own rules\n\nStart from a node you care about and assert what should be true of its\nneighbourhood. The demo suite does this for routes and HTTP; the same pattern\ncovers any invariant you can see on the graph.\n\n### A route provides the feature service\n\n```typescript\nit('indexes demo routes and provided feature services', () => {\n expect(graph.route('craft/query/:userId').kind).toBe('route');\n expect(graph.providedOn('UserList').map((node) => node.label)).toEqual(\n expect.arrayContaining([expect.stringMatching(/ListWithPagination/)]),\n );\n});\n```\n\n### An HTTP endpoint has a single owner\n\n```typescript\nit('indexes the users HTTP endpoint', () => {\n expect(graph.httpEndpoint('GET', 'users').label).toBe('GET users');\n expect(graph.usingHttp().map((node) => node.label)).toEqual(\n expect.arrayContaining(['UsersApiOnError']),\n );\n});\n```\n\n### HTTP only from a browser boundary\n\n[Browser boundaries](/guide/testing/browser-boundaries) are the line to the\nnetwork. A rule can require that `CraftHttpClient` is only yielded from a\nservice marked `browserBoundary: true`:\n\n```typescript\nit('only browser-boundary services call HTTP', () => {\n const boundaryIds = new Set(\n graph.services({ browserBoundary: true }).map((node) => node.id),\n );\n const leaked = graph\n .usingHttp()\n .filter((node) => node.kind === 'service' && !boundaryIds.has(node.id));\n expect(leaked.map((node) => node.label)).toEqual([]);\n});\n```\n\n### A persisted identity exists\n\n```typescript\nit('looks up a persisted unique identity', () => {\n expect(\n graph.unique('{\"key\":\"user-query\",\"storeName\":\"demo-app\"}').kind,\n ).toBe('unique');\n});\n```\n\nIf the lookup throws, the identity left the graph — the key changed, or\n`craftUnique` was removed.\n\nAnything you can express with `outgoing` / `incoming` is a rule: “this\n`craftMethod` is either called or writes a `source$`, never both”, “this\ncomponent does not `depends-on` that service”, “only `providedIn: 'global'` services\nappear under `usingTemporal()`”. Keep the assertion next to a comment that\nstates the product invariant, not the graph traversal.\n\n## Inspecting the graph\n\n`npx craft-graph` (also `npx craft graph`) writes the same analysis to disk\nwithout running tests:\n\n```shell\nnpx craft-graph \\\n --project apps/your-app/tsconfig.graph.json \\\n --root . \\\n --out craft-dependency-graph \\\n --format all\n```\n\n| `--format` | Writes |\n| ---------- | ------------------------------------------------------------------- |\n| `json` | the raw graph + a `.architecture.ts` catalog |\n| `mermaid` | a `.mmd` diagram |\n| `html` | a standalone explorer (no server, no runtime) |\n| `both` | JSON + catalog + Mermaid |\n| `all` | JSON + catalog + Mermaid + HTML |\n\n`--include <text>` restricts analysis to matching source paths. Use the HTML\nexplorer to see a route expand into components and services before you write\nthe assertion.\n\n## Pitfalls\n\n**The analysis tsconfig must include the app, not just `main.ts`.** An empty\ngraph with a passing `usingHttp()` is the usual symptom.\n\n**Do not nest `vitest.config.ts` under `architecture/`.** Put\n`vitest.architecture.config.ts` at the app root.\n\n**The catalog lags by one run.** Lookups are typed against the committed file.\nAfter adding a route or service, run the suite once so the rewrite lands, then\nthe new name typechecks.\n\n**Homonyms need a file path.** `graph.service('ApiService')` throws\n`Ambiguous service 'ApiService'` when two files export that name. Pass\n`'users/api.service.ts'`.\n\n**`craftUnique` must be a literal.** A computed `{ storeName, key }` indexes as\n`static: false` and `assertCraftUnique` fails — the graph cannot prove\nuniqueness.\n\n**A commented `CanRun` still type-checks.** Unused aliases are not errors.\n`assertRouteDiProofs` is the CI counterpart — that is the whole point of the\nhelper.\n\n**These tests are not e2e.** They never boot the app. Pair them with\n[service](/guide/testing/services) and [component](/guide/testing/components)\ntests for behaviour, and with ESLint for local architecture.\n\n## See Also\n\n- [Craft graph vs Nx](/guide/testing/craft-graph-vs-nx) — what each graph can\n and cannot see\n- [Testing services](/guide/testing/services) — the runtime graph of one service\n- [Browser boundaries](/guide/testing/browser-boundaries) — the nodes\n `browserBoundary: true` refers to\n- [Persistence](/guide/state/persistence) — why `craftUnique` identities must be\n unique\n- [ESLint rules](/guide/routing/eslint-rules) — local architecture, autofixed\n- [Routing setup](/guide/routing/setup) — the proofs this helper keeps armed\n- [Learn: test what you wrote](/learn/10-testing)\n"
|
|
476
476
|
},
|
|
477
477
|
{
|
|
478
478
|
"path": "/guide/testing/architecture/computed-purity",
|
|
@@ -727,7 +727,7 @@
|
|
|
727
727
|
{
|
|
728
728
|
"path": "/reference",
|
|
729
729
|
"title": "API index",
|
|
730
|
-
"body": "# API index\n\nEvery documented export, with the page that covers it. Use <kbd>Ctrl</kbd>/<kbd>⌘</kbd>+<kbd>F</kbd>.\n\nFor an explanation rather than a lookup, start from the [Guide](/guide/).\nCoding agents: [llms.txt](https://craft-ts.github.io/craft/llms.txt) and\n[coding agents](/resources/ai-agents).\n\n## Primitives\n\n| Symbol | What it does | Page |\n| ------------------- | -------------------------------------------------------- | --------------------------------------------- |\n| `state` | Signal-based state you own | [Local state](/guide/state/local-state) |\n| `craftStateMachine` | Declarative finite-state workflow | [State machines](/guide/state/state-machines) |\n| `query` | Server data, re-fetched from reactive `params` | [query](/guide/state/server-state) |\n| `mutation` | Server write, triggered explicitly | [Mutations](/guide/state/mutations) |\n| `queryParams` | State that lives in the URL query string | [queryParams](/guide/state/url-state) |\n| `asyncProcess` | One-off async operation with lifecycle state | [asyncProcess](/guide/state/async-process) |\n| `craftUse` | Drives a primitive outside a generator (component field) | [Learn 1](/learn/01-first-state) |\n\nNot sure which one: [Which primitive should I use?](/guide/concepts/choose-primitive)\n\n## Runtime context\n\nTyped helpers that recover `get` / `set` / `update` / `patch` from DI, for\nwrappers, WebMCP tools, and other advanced patterns. Everyday insertions\nalready receive those methods as arguments — see\n[Anatomy of a primitive](/guide/concepts/primitive-anatomy#injectable-runtime-context).\n\n| Symbol | What it does | Page |\n| ----------------------------------------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------- |\n| `injectStateMethodRuntimeContext` | `state` writes inside an insertion method | [Anatomy](/guide/concepts/primitive-anatomy#injectable-runtime-context) |\n| `injectQueryMethodRuntimeContext` | `query` writes inside an insertion method | [Anatomy](/guide/concepts/primitive-anatomy#injectable-runtime-context) |\n| `injectMutationMethodRuntimeContext` | `mutation` writes inside an insertion method | [Anatomy](/guide/concepts/primitive-anatomy#injectable-runtime-context) |\n| `injectQueryParamsMethodRuntimeContext` | `queryParams` writes inside an insertion method | [Anatomy](/guide/concepts/primitive-anatomy#injectable-runtime-context) |\n| `injectAsyncProcessMethodRuntimeContext` | `asyncProcess` writes inside an insertion method | [Anatomy](/guide/concepts/primitive-anatomy#injectable-runtime-context) |\n| `injectPrimitiveMethodRuntimeContext` | Same context, untyped `kind` | [Anatomy](/guide/concepts/primitive-anatomy#injectable-runtime-context) |\n| `providePrimitiveResourceRuntimeObserver` | Observes `query` / `mutation` / `asyncProcess` / `queryParams` values | [Anatomy](/guide/concepts/primitive-anatomy#injectable-runtime-context) |\n\n## Composition\n\n| Symbol | What it does | Page |\n| ------------------------ | ----------------------------------------------- | -------------------------------------------------------- |\n| `craftPipe` | Composes several insertions into one | [Insertions](/guide/concepts/insertions) |\n| `craftYieldRecord` | Resolves a record of primitive generators | [craftService](/guide/app/craft-service) |\n| `insertStatePipe` | Composes several `state` insertions | [Typed insertion pipes](/guide/concepts/insertion-pipes) |\n| `insertQueryPipe` | Composes several `query` insertions | [Typed insertion pipes](/guide/concepts/insertion-pipes) |\n| `insertMutationPipe` | Composes several `mutation` insertions | [Typed insertion pipes](/guide/concepts/insertion-pipes) |\n| `insertQueryParamsPipe` | Composes several `queryParams` insertions | [Typed insertion pipes](/guide/concepts/insertion-pipes) |\n| `insertAsyncProcessPipe` | Composes several `asyncProcess` insertions | [Typed insertion pipes](/guide/concepts/insertion-pipes) |\n| `insertStateMachinePipe` | Composes several `craftStateMachine` insertions | [Typed insertion pipes](/guide/concepts/insertion-pipes) |\n| `craftGen` | A standalone tracked generator | [Generators](/guide/concepts/generators) |\n| `craftMatch` | Exhaustive pattern matching | [Pattern matching](/guide/advanced/pattern-matching) |\n| `.pipe(...)` | Program operators on a craft generator | [Program operators](/guide/advanced/program-operators) |\n| `catchTag`, `retry` | Operators for `.pipe(...)` | [Program operators](/guide/advanced/program-operators) |\n\n## Insertions\n\n| Symbol | What it does | Page |\n| --------------------------------- | ----------------------------------------------- | ------------------------------------------------------------- |\n| `insertSelect` | Derives a slice of a primitive | [Selecting](/guide/state/select) |\n| `insertEntities` | Entity collection storage and updates | [Collections](/guide/state/collections) |\n| `insertStoragePersister` | Persists through the configured storage backend | [Persistence](/guide/state/persistence) |\n| `insertReactOnMutation` | Reloads / optimistically patches on a mutation | [React on mutation](/guide/state/react-on-mutation) |\n| `insertPaginationPlaceholderData` | Placeholder rows while a page loads | [Pagination placeholder](/guide/state/pagination-placeholder) |\n\n## Forms\n\n| Symbol | What it does | Page |\n| --------------------------------------------------------------------------- | ------------------------------------------ | ------------------------------------- |\n| `insertForm` | Derives a form from a `state` | [Forms](/guide/forms/) |\n| `insertFormAttributes` | Validators, `disable`, `hidden` | [Forms](/guide/forms/) |\n| `insertSelectFormTree` | Targets a field sub-tree | [Nested forms](/guide/forms/nested) |\n| `insertSubFormField` | A nested sub-form | [Nested forms](/guide/forms/nested) |\n| `insertFormSubmit` | Wires submission to a mutation | [Submitting](/guide/forms/submit) |\n| `insertNoopTypingAnchor` | Type anchor required per field tree | [Forms](/guide/forms/) |\n| `CraftFieldDirective` | Binds a typed field to a Craft DOM node | [Forms](/guide/forms/) |\n| `fieldErrorNode.exhaustive` / `.partial` | Exhaustive or partial validation rendering | [Forms](/guide/forms/) |\n| `cRequired`, `cEmail`, `cMin`/`cMax`, `cMinLength`/`cMaxLength`, `cPattern` | Built-in validators | [Validators](/guide/forms/validation) |\n| `cValidate`, `cAsyncValidate` | Custom and async validators | [Validators](/guide/forms/validation) |\n\n## Services and DI\n\n| Symbol | What it does | Page |\n| --------------------------- | ------------------------------------------ | ------------------------------------------------- |\n| `craftService` | Declares a named, scoped service | [craftService](/guide/app/craft-service) |\n| `abstract` | Declares a contract with no implementation | [Abstract services](/guide/app/abstract-services) |\n| `X.OmitInputs` | Opts out of a service's input bindings | [Public API](/guide/app/expose-api) |\n| `onAppStart` | Startup callback owned by a service | [App start](/guide/app/app-start) |\n| `craftLazy` | Defers a service's instantiation | [Lazy services](/guide/app/lazy-services) |\n| `craftRegisterFor` | Registry-driven service resolution | [Register](/guide/app/register) |\n| `provideCraftTargetWrapper` | Wraps craft targets at a provider boundary | [Target wrapper](/guide/app/target-wrapper) |\n| `provideTemplateTrace` | Wraps effective template renders | [Observability](/guide/advanced/observability) |\n| `provideCraftRouterTrace` | Wraps Router events and Craft route stages | [Observability](/guide/advanced/observability) |\n| `provideCraftHttpTrace` | Wraps CraftHttpClient requests | [Observability](/guide/advanced/observability) |\n| `craftAppConfig` | Application config with the routing graph | [Routing setup](/guide/routing/setup) |\n\n## Routing\n\n| Symbol | What it does | Page |\n| ---------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | ----------------------------------------------------- |\n| `craftRoute`, `craftRoutes` | Declares typed routes and collections | [Setup](/guide/routing/setup) |\n| `ValidateCascadeRoutesFile`, `CanRun` | Compile-time DI check for a routes file | [Setup](/guide/routing/setup) |\n| `RouteCheckedDI` | Per-route `O(1)` variant of the check | [Scaling routes](/guide/routing/scaling) |\n| `.withParent`, `ParentRoutes`, `assertChildRouteMounts` | Pins a child collection to its mount | [Scaling routes](/guide/routing/scaling) |\n| `withRetry` | Retryable lazy `loadComponent` / `loadChildren` | [Setup](/guide/routing/setup) |\n| `provideCraftRouter`, `provideCraftLoading` | Router with craft loading features | [Pending UI](/guide/routing/pending-ui) |\n| `withA11yNavigationFocus`, `CraftTitleStrategy` | Focus after nav; route `title` → document | [Accessibility](/guide/components/accessibility) |\n| `heading`, `headingSection`, `headingRoot`, `skipLink`, `liveRegion`, `fieldControl`, `disclosureControl`, `buttonControl`, `clickFocus` | Relative outline, skip link, live regions, accessible control props, focus | [Accessibility](/guide/components/accessibility) |\n| `withErrorComponent`, `withRouteLoadError`, `withTransitionTimings` | Router features | [Route load errors](/guide/routing/route-load-errors) |\n| `CraftRouterOutlet` | Non-blocking outlet | [Pending UI](/guide/routing/pending-ui) |\n| `craftRouterLink` | Type-safe navigation target | [Setup](/guide/routing/setup) |\n| `assertExhaustiveRouteExceptions` | Exhaustiveness proof for route exceptions | [Exceptions](/guide/concepts/exceptions) |\n\n## Server rendering\n\n| Symbol | What it does | Page |\n| ---------------------------------------------------------- | ----------------------------------------------------------------------- | -------------------------------------------------- |\n| `renderCraft`, `renderToString` | Renders an isolated request to HTML, CSS, and a transfer snapshot | [SSR and hydration](/guide/advanced/ssr-hydration) |\n| `startCraft` | Hydrates an SSR host or mounts a fresh client application automatically | [SSR and hydration](/guide/advanced/ssr-hydration) |\n| `hydrateCraft` | Restores transferred state and claims the existing browser DOM | [SSR and hydration](/guide/advanced/ssr-hydration) |\n| `pendingNode({ ssr })` | Declares `block`, `fallback`, or `client` behavior for suspended data | [SSR and hydration](/guide/advanced/ssr-hydration) |\n| `CRAFT_SSR_POLICY` | Route-level default SSR policy | [SSR and hydration](/guide/advanced/ssr-hydration) |\n| `CraftUnhandledSsrResolutionError`, `CraftSsrTimeoutError` | Reports missing policies and timed-out blocking sources | [SSR and hydration](/guide/advanced/ssr-hydration) |\n\n## Exceptions\n\n| Symbol | What it does | Page |\n| ---------------------------------- | ---------------------------------------- | --------------------------------------------------------------- |\n| `craftException` | Creates a declared, typed exception | [Exceptions](/guide/concepts/exceptions) |\n| `craftExceptionHandler` | Handles route exceptions | [Exceptions](/guide/concepts/exceptions) |\n| `.exceptions()`, `.hasException()` | Reads a primitive's exceptions by origin | [query](/guide/state/server-state) |\n| `globalError()` | Delegates to the global error component | [Global error component](/guide/routing/global-error-component) |\n\n## Reactivity\n\n| Symbol | What it does | Page |\n| -------------------- | ---------------------------------- | ------------------------------------------------------------ |\n| `craftComputed` | Tracked `computed` | [craftComputed](/guide/reactivity/craft-computed) |\n| `craftEffect` | Tracked `effect` | [craftEffect](/guide/reactivity/craft-effect) |\n| `craftMethod` | A tracked method on a primitive | [craftMethod](/guide/reactivity/craft-method) |\n| `source$` | An imperative event source | [source$](/guide/reactivity/source) |\n| `on$` | Binds a method to a source | [on$](/guide/reactivity/on) |\n| `fromEventToSource$` | DOM event → source | [fromEventToSource$](/guide/reactivity/from-event-to-source) |\n| `sourceFromEvent` | Event-driven source helper | [sourceFromEvent](/guide/reactivity/source-from-event) |\n| `afterRecomputation` | Runs after a recomputation settles | [afterRecomputation](/guide/reactivity/after-recomputation) |\n\n## HTTP and boundaries\n\n| Symbol | What it does | Page |\n| ---------------------------------------------------------------------- | --------------------------------------------------------- | ------------------------------------------------------- |\n| `CraftHttpClient` | Tracked HTTP client with typed exceptions | [query](/guide/state/server-state) |\n| `browserBoundary` | Marks a service as a browser boundary | [Browser boundaries](/guide/testing/browser-boundaries) |\n| `BrowserDocument`, `BrowserDocument.setLang`, `BrowserDocument.setDir` | Reads and updates document title, language, and direction | [Browser boundaries](/guide/testing/browser-boundaries) |\n| `Console` | Yieldable console, overridable for tracing | [Observability](/guide/advanced/observability) |\n\n## Testing\n\n| Symbol | What it does | Page |\n| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------- |\n| `setupCraftServiceTestingByRegister` | Sets up a service from a full register | [Testing services](/guide/testing/services) |\n| `boundaryOnly` | Keeps the graph real, mocks boundaries | [Browser boundaries](/guide/testing/browser-boundaries) |\n| `mockHttpRequestForRoute` | Mocks endpoints for a route | [Browser boundaries](/guide/testing/browser-boundaries) |\n| `ComponentTemplateOf`, `ComponentLogicOutputOf`, `SetupTestComponentTemplate` | Resolves component logic and validates a template at compile time | [Type-level tests](/guide/testing/type-level) |\n| `TemplateHasElement`, `TemplateRendersNamedElementWhen`, `TemplateNamedElementRendersStateWhen`, `TemplateNamedElementDelegatesToContext`, `TemplateRenderAvailableActionWhen` | Proves what a template renders and uses | [Type-level tests](/guide/testing/type-level) |\n| `Expect`, `Equal` | Turns a type-level result into a compile-time assertion | [Type-level tests](/guide/testing/type-level) |\n| `createArchitectureGraph`, `noExclusiveLink`, `assertCraftUnique`, `assertHttpEndpointUnique`, `assertCraftComputedPure`, `assertNoDependencyCycles`, `assertDeclarativeArchitecture`, `assertRouteDiProofs`, `assertPathBoundaries`, `assertMutationHasReactOn`, `assertPrimitiveLoaderRequirements`, `assertQueryMutationHasServerState`, `assertResourceParamsPreferQueryParams`, `assertPersistedPrimitiveHasUnique`, `assertInsertSelectUnique`, `assertCraftEffectNoNetwork`, `assertCraftEffectNoImperativeSync`, `assertInteractiveElementNamed` | Typed lookups and declarative architecture helpers | [Architecture rules](/guide/testing/architecture) |\n\n## Effect integration\n\n`@craft-ts/effect`, in full. The guide is [Effect\nintegration](/guide/advanced/effect).\n\n| Symbol | What it does | Page |\n| ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | -------------------------------------------------------------------------------------- |\n| `installCraftEffectBridge` | Installs both bridges once, at bootstrap | [Install the bridge](/guide/advanced/effect#install-the-bridge-once) |\n| `queryEffect`, `mutationEffect`, `asyncProcessEffect`, `computedEffect`, `methodEffect` | The Effect-backed adapters of the Craft primitives | [Choose the right adapter](/guide/advanced/effect#choose-the-right-adapter) |\n| `runEffect`, `CraftEffectInterrupted` | Yields one Effect and maps its exit onto Craft's channels | [runEffect](/guide/advanced/effect#runeffect-the-low-level-form) |\n| `syncEffect`, `SyncOp`, `CraftEffectNotSynchronous`, `NotDeclaredSynchronous` | Declares and runs an Effect that never suspends | [Synchronous members](/guide/advanced/effect#run-a-synchronous-member-from-a-computed) |\n| `provideLayer` | Attaches a built Effect context to a Craft injector | [Provide services with Layer](/guide/advanced/effect#provide-services-with-layer) |\n| `effectService`, `SelectedMembers` | Selects a service from a Craft factory, recording the dependency | [Select a service](/guide/advanced/effect#select-an-effect-service-from-craft) |\n| `mockEffectService`, `UnstubbedEffectMember` | A focused Layer for tests; an unstubbed member fails loudly | [Testing](/guide/advanced/effect#testing) |\n| `EffectRequirementsCheckedDI`, `ProvidedEffectServicesOf`, `ProvidedEffectServicesOfRoute` | The route-level proof that every requirement is provided | [Provide services with Layer](/guide/advanced/effect#provide-services-with-layer) |\n| `effectServerMiddleware`, `executeEffect`, `EffectServerMiddleware`, `EffectServerMiddlewareContext` | Effect middleware and execution for server functions | [Server functions POC](/guide/advanced/effect#server-functions-current-poc) |\n\n### Lower-level exports\n\nPublic, but rarely needed directly. They exist for wrappers, generated code and\ntooling rather than for application code.\n\n| Symbol | What it is |\n| -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `composeEffect` | Composes yieldable Effect middleware in declaration order, without continuations. `effectServerMiddleware` is the everyday door. |\n| `runYieldedEffect` | The single-Effect runner the bridge itself calls. Use `runEffect`, which keeps the call site blamable. |\n| `assertNoRequirements`, `AssertNoRequirements`, `MissingRequirements`, `RealRequirements`, `CraftPhantomRequirement` | Moves the `R = never` check to the **yield site**, so an unmet requirement points at the offending line instead of surfacing at runtime. `CraftPhantomRequirement` is what excludes `SyncOp` from that check. |\n| `CRAFT_EFFECT_LEVEL`, `resolveEffectLevel`, `CraftEffectLevel` | The per-injector Effect level: the built context, a `MemoMap` forked from the parent's, and a scope closed with the injector. Read it when writing your own provider; `provideLayer` is the normal way in. |\n| `AsEffect`, `CraftProgramSuccess`, `CraftProgramExceptions` | A **type-only projection** of a Craft program onto `Effect<A, E>`. It changes no runtime behaviour; it exists so a hover tooltip reads `Effect<User, UserNotFound>` instead of a raw generator type. |\n| `installCraftSyncEffectBridge` | Already installed by `installCraftEffectBridge`. Call it directly only in a host that installs the synchronous bridge alone. |\n\n## Typed styles\n\n`@craft-ts/style` is a **build step**: none of these symbols emit anything\nwithout `craftStyle` from `@craft-ts/style/vite` in the Vite config. See\n[Activating the style system](/guide/style/setup).\n\n| Symbol | What it does | Page |\n| ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------- | ------------------------------------------------------------------------------ |\n| `craftStyle`, `emitStyles`, `renderCss`, `styleDump`, `findStyleModules` | The build-time emitter and its artefacts (`@craft-ts/style/vite`) | [Activating the style system](/guide/style/setup) |\n| `definePalette`, `darkOf`, `palette` | Colour tokens carrying both of their values, plus the default set | [Defining a design system](/guide/style/define) |\n| `defineBreakpoints`, `at`, `above`, `below` | The viewport axis, as an ordered one | [Defining a design system](/guide/style/define) |\n| `defineStateAxis`, `defineAxis`, `onlyVarsOfKind`, `axisPoint` | Attribute-driven axes, with an optional write constraint | [Defining a design system](/guide/style/define) |\n| `defineContainer` | A container axis, closed at the element that declares the container | [Defining a design system](/guide/style/define) |\n| `scheme`, `motion`, `forcedColors`, `contrast`, `scrollState`, `descendant` | The standard axes, driven by the user agent or by element state | [Axes and the matrix](/guide/style/variants) |\n| `cssVars`, `kind`, `assign`, `set` | Typed custom properties, registered through `@property` | [Tokens and variables](/guide/style/tokens) |\n| `space`, `unit`, `radii`, `radius`, `lineWidth`, `num`, `text`, `font` | The closed value scales — no value is a string | [Tokens and variables](/guide/style/tokens) |\n| `unsafeLength`, `unsafeAssume` | The marked escape hatches; both propagate `unproven` | [Tokens and variables](/guide/style/tokens) |\n| `craftStyles`, `when` | A sheet, and conjunction by nesting | [Axes and the matrix](/guide/style/variants) |\n| `requires`, `provides`, `declares`, `seal`, `scrollPort`, `noClipping`, `containerType`, `clipOverflow` | Context obligations, and where they become an error | [Context obligations](/guide/style/obligations) |\n| `visualMatrix`, `applyScenario`, `branch`, `contentCases`, `assertExhaustiveVisualMatrix`, `baselinesIn` | The scenario matrix (`@craft-ts/style-testing`) | [Testing visual states](/guide/style/testing) |\n| `matrixSizeByComponent`, `impactedClasses`, `varsWrittenBy`, `danglingVars`, `unproven`, `extractionGaps`, `undischargedObligations` | Graph queries over the style dump (`@craft-ts/dev-tools`) | [Testing visual states](/guide/style/testing) |\n| `style_impact`, `style_matrix`, `style_debt` | The same questions as MCP tools | [Testing visual states](/guide/style/testing#the-same-questions-from-an-agent) |\n\n## Internationalisation\n\n`@craft-ts/i18n` is the CraftTS i18n integration: the catalogue stays a plain\nTypeScript value, and a token may resolve a Craft service or parse its\nparameter with a Standard Schema. The package imports core for types only.\n\n| Symbol | What it does | Page |\n| ------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------------------------- |\n| `defineCatalog`, `msg`, `plural` | The catalogue, its messages, and per-locale plural categories | [The catalogue](/guide/i18n/catalog) |\n| `defineLocale`, `defineLocaleLike` | The reference locale, and every other one checked against it | [The catalogue](/guide/i18n/catalog) |\n| `number`, `integer`, `percent`, `compactNumber`, `money`, `dateShort`, `dateLong`, `dateTime`, `relativeTime` | The shipped semantic tokens, formatted through `Intl` | [Tokens](/guide/i18n/tokens) |\n| `defineToken`, `defineTokenFactory`, `formatters`, `TokenFormatter`, `FormatterContext` | Project tokens, and the factory the shipped ones are built from | [Tokens](/guide/i18n/tokens) |\n| `createI18nRuntime`, `translate` / `t`, `setLocale`, `locale` | The runtime and its one active locale | [The runtime](/guide/i18n/runtime) |\n| `TranslationDependencies`, `StaticTranslationKey` | The services a message resolves, and the keys `t` can render alone | [The runtime](/guide/i18n/runtime#di-inside-a-translation) |\n| `TokenSchema`, `TokenSchemaInput`, `TokenSchemaOutput`, `TokenFactory` | Declaring a parameter with a Standard Schema | [Tokens](/guide/i18n/tokens) |\n| `bind`, `createReactiveTranslator` | A translator that re-reads when the locale state changes | [The runtime](/guide/i18n/runtime#reactive-translation) |\n| `createI18nLoader`, `loadLocale` | Lazy locales, cached by id, evicted on failure | [The runtime](/guide/i18n/runtime#lazy-locales) |\n| `validateCatalog`, `assertValidCatalog`, `validateLocaleParity`, `assertLocaleParity` | The checks behind `npm run i18n:check` (also `@craft-ts/i18n/testing`) | [The catalogue](/guide/i18n/catalog#checking-outside-the-typechecker) |\n| `serializeCatalog`, `serializeToken` | JSON-safe delivery shape; refuses a token that resolves a service | [The catalogue](/guide/i18n/catalog) |\n| `I18nRuntimeError` | `LOCALE_NOT_LOADED`, `MISSING_PARAM`, `INVALID_PARAM`, `CRAFT_INJECTION_REQUIRED`, … | [The runtime](/guide/i18n/runtime) |\n| `provideI18nRuntime`, `translateEffect`, `I18nEffectService` | The Effect adapter (`@craft-ts/i18n-effect`) | [With Effect](/guide/i18n/effect) |\n\n## Tooling\n\n| Command / rule | What it does | Page |\n| ---------------------------------- | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------- |\n| `npx craft route add` | Scaffolds a typed route | [Automation](/guide/routing/automation) |\n| `npx craft route split` | Splits a flat collection | [Scaling routes](/guide/routing/scaling) |\n| `npx craft route verify` | Optional compiler-fixture suite for the type machinery | [Automation](/guide/routing/automation#compiler-fixture-suite-optional) |\n| `craft-brand --root src` | Generates and refreshes `GenDeps_*` | [Brand config](/guide/routing/setup#generated-dependencies) |\n| `@craft-ts/dev-tools/eslint-rules` | The ESLint rule set | [ESLint rules](/guide/routing/eslint-rules) · [Accessibility](/guide/components/accessibility) |\n| `npx craft-graph` | Writes the static Craft graph | [Architecture rules](/guide/testing/architecture) · [Craft graph vs Nx](/guide/testing/craft-graph-vs-nx) |\n| `npx nx architecture <app>` | Runs the app's architecture Vitest suite | [Architecture rules](/guide/testing/architecture) · [Craft graph vs Nx](/guide/testing/craft-graph-vs-nx) |\n| Live page MCP `page` | Drive the open `ng serve` tab (dev only) | [Live page MCP](/guide/ai/dev-page) |\n| Template migrator | Migrates templates to craft components | [Template migrator](/guide/components/template-migrator) |\n\n## Deployment\n\n::: warning Experimental\nThe deployment tooling is not settled: these symbols and commands can still\nchange between minor versions. See the\n[deployment guide](/guide/deployment/) for what exists today.\n:::\n\n| Symbol / command | What it does | Page |\n| ---------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | ------------------------------------------------ |\n| `defineCraftDeployment` | Declares the deployment of an application in `craft.deploy.ts` | [Manifest reference](/guide/deployment/manifest) |\n| `checkCraftDeployment`, `checkCraftDeploymentArtifact` | Runs the manifest, module graph and artefact checks | [Diagnostics](/guide/deployment/diagnostics) |\n| `resolveCraftDeploymentManifest`, `serializeCraftDeploymentManifest`, `parseCraftDeploymentManifest` | Resolves, writes and reads the provider-neutral artefact form | [Manifest reference](/guide/deployment/manifest) |\n| `CraftDeploymentProvider`, `CRAFT_DEPLOYMENT_PROVIDERS` | The provider contract and the capability matrix | [Providers](/guide/deployment/providers) |\n| `npx craft-ts check` | Validates a deployment before building | [Deployment overview](/guide/deployment/) |\n| `npx craft-ts manifest` | Writes `dist/<app>/craft-deployment-manifest.json` | [Deployment overview](/guide/deployment/) |\n| `npx craft-ts deploy preview` | Shows what a provider would change, without changing it | [Alchemy provider](/guide/deployment/alchemy) |\n| `npx craft-ts deploy` | Applies that plan once `--yes` approves it | [Alchemy provider](/guide/deployment/alchemy) |\n| `createCraftDeploymentProvider` | The single factory a provider package exports | [Providers](/guide/deployment/providers) |\n| `createAlchemyDeploymentProvider`, `planAlchemyDeployment` | The Alchemy provider and its Cloudflare/AWS planning | [Alchemy provider](/guide/deployment/alchemy) |\n| `npx craft-ts providers` | Prints the provider capability matrix | [Providers](/guide/deployment/providers) |\n"
|
|
730
|
+
"body": "# API index\n\nEvery documented export, with the page that covers it. Use <kbd>Ctrl</kbd>/<kbd>⌘</kbd>+<kbd>F</kbd>.\n\nFor an explanation rather than a lookup, start from the [Guide](/guide/).\nCoding agents: [llms.txt](https://craft-ts.github.io/craft/llms.txt) and\n[coding agents](/resources/ai-agents).\n\n## Primitives\n\n| Symbol | What it does | Page |\n| ------------------- | -------------------------------------------------------- | --------------------------------------------- |\n| `state` | Signal-based state you own | [Local state](/guide/state/local-state) |\n| `craftStateMachine` | Declarative finite-state workflow | [State machines](/guide/state/state-machines) |\n| `query` | Server data, re-fetched from reactive `params` | [query](/guide/state/server-state) |\n| `mutation` | Server write, triggered explicitly | [Mutations](/guide/state/mutations) |\n| `queryParams` | State that lives in the URL query string | [queryParams](/guide/state/url-state) |\n| `asyncProcess` | One-off async operation with lifecycle state | [asyncProcess](/guide/state/async-process) |\n| `craftUse` | Drives a primitive outside a generator (component field) | [Learn 1](/learn/01-first-state) |\n\nNot sure which one: [Which primitive should I use?](/guide/concepts/choose-primitive)\n\n## Runtime context\n\nTyped helpers that recover `get` / `set` / `update` / `patch` from DI, for\nwrappers, WebMCP tools, and other advanced patterns. Everyday insertions\nalready receive those methods as arguments — see\n[Anatomy of a primitive](/guide/concepts/primitive-anatomy#injectable-runtime-context).\n\n| Symbol | What it does | Page |\n| ----------------------------------------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------- |\n| `injectStateMethodRuntimeContext` | `state` writes inside an insertion method | [Anatomy](/guide/concepts/primitive-anatomy#injectable-runtime-context) |\n| `injectQueryMethodRuntimeContext` | `query` writes inside an insertion method | [Anatomy](/guide/concepts/primitive-anatomy#injectable-runtime-context) |\n| `injectMutationMethodRuntimeContext` | `mutation` writes inside an insertion method | [Anatomy](/guide/concepts/primitive-anatomy#injectable-runtime-context) |\n| `injectQueryParamsMethodRuntimeContext` | `queryParams` writes inside an insertion method | [Anatomy](/guide/concepts/primitive-anatomy#injectable-runtime-context) |\n| `injectAsyncProcessMethodRuntimeContext` | `asyncProcess` writes inside an insertion method | [Anatomy](/guide/concepts/primitive-anatomy#injectable-runtime-context) |\n| `injectPrimitiveMethodRuntimeContext` | Same context, untyped `kind` | [Anatomy](/guide/concepts/primitive-anatomy#injectable-runtime-context) |\n| `providePrimitiveResourceRuntimeObserver` | Observes `query` / `mutation` / `asyncProcess` / `queryParams` values | [Anatomy](/guide/concepts/primitive-anatomy#injectable-runtime-context) |\n\n## Composition\n\n| Symbol | What it does | Page |\n| ------------------------ | ----------------------------------------------- | -------------------------------------------------------- |\n| `craftPipe` | Composes several insertions into one | [Insertions](/guide/concepts/insertions) |\n| `craftYieldRecord` | Resolves a record of primitive generators | [craftService](/guide/app/craft-service) |\n| `insertStatePipe` | Composes several `state` insertions | [Typed insertion pipes](/guide/concepts/insertion-pipes) |\n| `insertQueryPipe` | Composes several `query` insertions | [Typed insertion pipes](/guide/concepts/insertion-pipes) |\n| `insertMutationPipe` | Composes several `mutation` insertions | [Typed insertion pipes](/guide/concepts/insertion-pipes) |\n| `insertQueryParamsPipe` | Composes several `queryParams` insertions | [Typed insertion pipes](/guide/concepts/insertion-pipes) |\n| `insertAsyncProcessPipe` | Composes several `asyncProcess` insertions | [Typed insertion pipes](/guide/concepts/insertion-pipes) |\n| `insertStateMachinePipe` | Composes several `craftStateMachine` insertions | [Typed insertion pipes](/guide/concepts/insertion-pipes) |\n| `craftGen` | A standalone tracked generator | [Generators](/guide/concepts/generators) |\n| `craftMatch` | Exhaustive pattern matching | [Pattern matching](/guide/advanced/pattern-matching) |\n| `.pipe(...)` | Program operators on a craft generator | [Program operators](/guide/advanced/program-operators) |\n| `catchTag`, `retry` | Operators for `.pipe(...)` | [Program operators](/guide/advanced/program-operators) |\n\n## Insertions\n\n| Symbol | What it does | Page |\n| --------------------------------- | ----------------------------------------------- | ------------------------------------------------------------- |\n| `insertSelect` | Derives a slice of a primitive | [Selecting](/guide/state/select) |\n| `insertEntities` | Entity collection storage and updates | [Collections](/guide/state/collections) |\n| `insertStoragePersister` | Persists through the configured storage backend | [Persistence](/guide/state/persistence) |\n| `insertReactOnMutation` | Reloads / optimistically patches on a mutation | [React on mutation](/guide/state/react-on-mutation) |\n| `insertPaginationPlaceholderData` | Placeholder rows while a page loads | [Pagination placeholder](/guide/state/pagination-placeholder) |\n\n## Forms\n\n| Symbol | What it does | Page |\n| --------------------------------------------------------------------------- | ------------------------------------------ | ------------------------------------- |\n| `insertForm` | Derives a form from a `state` | [Forms](/guide/forms/) |\n| `insertFormAttributes` | Validators, `disable`, `hidden` | [Forms](/guide/forms/) |\n| `insertSelectFormTree` | Targets a field sub-tree | [Nested forms](/guide/forms/nested) |\n| `insertSubFormField` | A nested sub-form | [Nested forms](/guide/forms/nested) |\n| `insertFormSubmit` | Wires submission to a mutation | [Submitting](/guide/forms/submit) |\n| `insertNoopTypingAnchor` | Type anchor required per field tree | [Forms](/guide/forms/) |\n| `CraftFieldDirective` | Binds a typed field to a Craft DOM node | [Forms](/guide/forms/) |\n| `fieldErrorNode.exhaustive` / `.partial` | Exhaustive or partial validation rendering | [Forms](/guide/forms/) |\n| `cRequired`, `cEmail`, `cMin`/`cMax`, `cMinLength`/`cMaxLength`, `cPattern` | Built-in validators | [Validators](/guide/forms/validation) |\n| `cValidate`, `cAsyncValidate` | Custom and async validators | [Validators](/guide/forms/validation) |\n\n## Services and DI\n\n| Symbol | What it does | Page |\n| --------------------------- | ------------------------------------------ | ------------------------------------------------- |\n| `craftService` | Declares a named, scoped service | [craftService](/guide/app/craft-service) |\n| `abstract` | Declares a contract with no implementation | [Abstract services](/guide/app/abstract-services) |\n| `X.OmitInputs` | Opts out of a service's input bindings | [Public API](/guide/app/expose-api) |\n| `onAppStart` | Startup callback owned by a service | [App start](/guide/app/app-start) |\n| `craftLazy` | Defers a service's instantiation | [Lazy services](/guide/app/lazy-services) |\n| `craftRegisterFor` | Registry-driven service resolution | [Register](/guide/app/register) |\n| `provideCraftTargetWrapper` | Wraps craft targets at a provider boundary | [Target wrapper](/guide/app/target-wrapper) |\n| `provideTemplateTrace` | Wraps effective template renders | [Observability](/guide/advanced/observability) |\n| `provideCraftRouterTrace` | Wraps Router events and Craft route stages | [Observability](/guide/advanced/observability) |\n| `provideCraftHttpTrace` | Wraps CraftHttpClient requests | [Observability](/guide/advanced/observability) |\n| `craftAppConfig` | Application config with the routing graph | [Routing setup](/guide/routing/setup) |\n\n## Routing\n\n| Symbol | What it does | Page |\n| ---------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | ----------------------------------------------------- |\n| `craftRoute`, `craftRoutes` | Declares typed routes and collections | [Setup](/guide/routing/setup) |\n| `RouteCheckedDI`, `CanRun` | Compile-time DI check for a routed component | [Setup](/guide/routing/setup) |\n| `.withParent`, `ParentRoutes`, `assertChildRouteMounts` | Pins a child collection to its mount | [Scaling routes](/guide/routing/scaling) |\n| `withRetry` | Retryable lazy `loadComponent` / `loadChildren` | [Setup](/guide/routing/setup) |\n| `provideCraftRouter`, `provideCraftLoading` | Router with craft loading features | [Pending UI](/guide/routing/pending-ui) |\n| `withA11yNavigationFocus`, `CraftTitleStrategy` | Focus after nav; route `title` → document | [Accessibility](/guide/components/accessibility) |\n| `heading`, `headingSection`, `headingRoot`, `skipLink`, `liveRegion`, `fieldControl`, `disclosureControl`, `buttonControl`, `clickFocus` | Relative outline, skip link, live regions, accessible control props, focus | [Accessibility](/guide/components/accessibility) |\n| `withErrorComponent`, `withRouteLoadError`, `withTransitionTimings` | Router features | [Route load errors](/guide/routing/route-load-errors) |\n| `CraftRouterOutlet` | Non-blocking outlet | [Pending UI](/guide/routing/pending-ui) |\n| `craftRouterLink` | Type-safe navigation target | [Setup](/guide/routing/setup) |\n| `assertExhaustiveRouteExceptions` | Exhaustiveness proof for route exceptions | [Exceptions](/guide/concepts/exceptions) |\n\n## Server rendering\n\n| Symbol | What it does | Page |\n| ---------------------------------------------------------- | ----------------------------------------------------------------------- | -------------------------------------------------- |\n| `renderCraft`, `renderToString` | Renders an isolated request to HTML, CSS, and a transfer snapshot | [SSR and hydration](/guide/advanced/ssr-hydration) |\n| `startCraft` | Hydrates an SSR host or mounts a fresh client application automatically | [SSR and hydration](/guide/advanced/ssr-hydration) |\n| `hydrateCraft` | Restores transferred state and claims the existing browser DOM | [SSR and hydration](/guide/advanced/ssr-hydration) |\n| `pendingNode({ ssr })` | Declares `block`, `fallback`, or `client` behavior for suspended data | [SSR and hydration](/guide/advanced/ssr-hydration) |\n| `CRAFT_SSR_POLICY` | Route-level default SSR policy | [SSR and hydration](/guide/advanced/ssr-hydration) |\n| `CraftUnhandledSsrResolutionError`, `CraftSsrTimeoutError` | Reports missing policies and timed-out blocking sources | [SSR and hydration](/guide/advanced/ssr-hydration) |\n\n## Exceptions\n\n| Symbol | What it does | Page |\n| ---------------------------------- | ---------------------------------------- | --------------------------------------------------------------- |\n| `craftException` | Creates a declared, typed exception | [Exceptions](/guide/concepts/exceptions) |\n| `craftExceptionHandler` | Handles route exceptions | [Exceptions](/guide/concepts/exceptions) |\n| `.exceptions()`, `.hasException()` | Reads a primitive's exceptions by origin | [query](/guide/state/server-state) |\n| `globalError()` | Delegates to the global error component | [Global error component](/guide/routing/global-error-component) |\n\n## Reactivity\n\n| Symbol | What it does | Page |\n| -------------------- | ---------------------------------- | ------------------------------------------------------------ |\n| `craftComputed` | Tracked `computed` | [craftComputed](/guide/reactivity/craft-computed) |\n| `craftEffect` | Tracked `effect` | [craftEffect](/guide/reactivity/craft-effect) |\n| `craftMethod` | A tracked method on a primitive | [craftMethod](/guide/reactivity/craft-method) |\n| `source$` | An imperative event source | [source$](/guide/reactivity/source) |\n| `on$` | Binds a method to a source | [on$](/guide/reactivity/on) |\n| `fromEventToSource$` | DOM event → source | [fromEventToSource$](/guide/reactivity/from-event-to-source) |\n| `sourceFromEvent` | Event-driven source helper | [sourceFromEvent](/guide/reactivity/source-from-event) |\n| `afterRecomputation` | Runs after a recomputation settles | [afterRecomputation](/guide/reactivity/after-recomputation) |\n\n## HTTP and boundaries\n\n| Symbol | What it does | Page |\n| ---------------------------------------------------------------------- | --------------------------------------------------------- | ------------------------------------------------------- |\n| `CraftHttpClient` | Tracked HTTP client with typed exceptions | [query](/guide/state/server-state) |\n| `CraftBinaryHttpClient` | Tracked raw-body HTTP PUT for binary uploads | [query](/guide/state/server-state) |\n| `browserBoundary` | Marks a service as a browser boundary | [Browser boundaries](/guide/testing/browser-boundaries) |\n| `BrowserDocument`, `BrowserDocument.setLang`, `BrowserDocument.setDir` | Reads and updates document title, language, and direction | [Browser boundaries](/guide/testing/browser-boundaries) |\n| `Console` | Yieldable console, overridable for tracing | [Observability](/guide/advanced/observability) |\n\n## Testing\n\n| Symbol | What it does | Page |\n| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------- |\n| `setupCraftServiceTestingByRegister` | Sets up a service from a full register | [Testing services](/guide/testing/services) |\n| `boundaryOnly` | Keeps the graph real, mocks boundaries | [Browser boundaries](/guide/testing/browser-boundaries) |\n| `mockHttpRequestForRoute` | Mocks endpoints for a route | [Browser boundaries](/guide/testing/browser-boundaries) |\n| `ComponentTemplateOf`, `ComponentLogicOutputOf`, `SetupTestComponentTemplate` | Resolves component logic and validates a template at compile time | [Type-level tests](/guide/testing/type-level) |\n| `TemplateHasElement`, `TemplateRendersNamedElementWhen`, `TemplateNamedElementRendersStateWhen`, `TemplateNamedElementDelegatesToContext`, `TemplateRenderAvailableActionWhen` | Proves what a template renders and uses | [Type-level tests](/guide/testing/type-level) |\n| `Expect`, `Equal` | Turns a type-level result into a compile-time assertion | [Type-level tests](/guide/testing/type-level) |\n| `createArchitectureGraph`, `noExclusiveLink`, `assertCraftUnique`, `assertHttpEndpointUnique`, `assertCraftComputedPure`, `assertNoDependencyCycles`, `assertDeclarativeArchitecture`, `assertRouteDiProofs`, `assertPathBoundaries`, `assertMutationHasReactOn`, `assertPrimitiveLoaderRequirements`, `assertQueryMutationHasServerState`, `assertResourceParamsPreferQueryParams`, `assertPersistedPrimitiveHasUnique`, `assertInsertSelectUnique`, `assertCraftEffectNoNetwork`, `assertCraftEffectNoImperativeSync`, `assertInteractiveElementNamed` | Typed lookups and declarative architecture helpers | [Architecture rules](/guide/testing/architecture) |\n\n## Effect integration\n\n`@craft-ts/effect`, in full. The guide is [Effect\nintegration](/guide/advanced/effect).\n\n| Symbol | What it does | Page |\n| ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | -------------------------------------------------------------------------------------- |\n| `installCraftEffectBridge` | Installs both bridges once, at bootstrap | [Install the bridge](/guide/advanced/effect#install-the-bridge-once) |\n| `queryEffect`, `mutationEffect`, `asyncProcessEffect`, `computedEffect`, `methodEffect` | The Effect-backed adapters of the Craft primitives | [Choose the right adapter](/guide/advanced/effect#choose-the-right-adapter) |\n| `runEffect`, `CraftEffectInterrupted` | Yields one Effect and maps its exit onto Craft's channels | [runEffect](/guide/advanced/effect#runeffect-the-low-level-form) |\n| `syncEffect`, `SyncOp`, `CraftEffectNotSynchronous`, `NotDeclaredSynchronous` | Declares and runs an Effect that never suspends | [Synchronous members](/guide/advanced/effect#run-a-synchronous-member-from-a-computed) |\n| `transitionGuardEffect` | Guards a state-machine transition with a synchronous Effect | [State-machine guards](/guide/state/state-machines#effect-services-in-a-guard) |\n| `provideLayer` | Attaches a built Effect context to a Craft injector | [Provide services with Layer](/guide/advanced/effect#provide-services-with-layer) |\n| `effectService`, `SelectedMembers` | Selects a service from a Craft factory, recording the dependency | [Select a service](/guide/advanced/effect#select-an-effect-service-from-craft) |\n| `mockEffectService`, `UnstubbedEffectMember` | A focused Layer for tests; an unstubbed member fails loudly | [Testing](/guide/advanced/effect#testing) |\n| `EffectRequirementsCheckedDI`, `ProvidedEffectServicesOf`, `ProvidedEffectServicesOfRoute` | The route-level proof that every requirement is provided | [Provide services with Layer](/guide/advanced/effect#provide-services-with-layer) |\n| `effectServerMiddleware`, `executeEffect`, `EffectServerMiddleware`, `EffectServerMiddlewareContext` | Effect middleware and execution for server functions | [Server functions POC](/guide/advanced/effect#server-functions-current-poc) |\n\n### Lower-level exports\n\nPublic, but rarely needed directly. They exist for wrappers, generated code and\ntooling rather than for application code.\n\n| Symbol | What it is |\n| -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `composeEffect` | Composes yieldable Effect middleware in declaration order, without continuations. `effectServerMiddleware` is the everyday door. |\n| `runYieldedEffect` | The single-Effect runner the bridge itself calls. Use `runEffect`, which keeps the call site blamable. |\n| `assertNoRequirements`, `AssertNoRequirements`, `MissingRequirements`, `RealRequirements`, `CraftPhantomRequirement` | Moves the `R = never` check to the **yield site**, so an unmet requirement points at the offending line instead of surfacing at runtime. `CraftPhantomRequirement` is what excludes `SyncOp` from that check. |\n| `CRAFT_EFFECT_LEVEL`, `resolveEffectLevel`, `CraftEffectLevel` | The per-injector Effect level: the built context, a `MemoMap` forked from the parent's, and a scope closed with the injector. Read it when writing your own provider; `provideLayer` is the normal way in. |\n| `AsEffect`, `CraftProgramSuccess`, `CraftProgramExceptions` | A **type-only projection** of a Craft program onto `Effect<A, E>`. It changes no runtime behaviour; it exists so a hover tooltip reads `Effect<User, UserNotFound>` instead of a raw generator type. |\n| `installCraftSyncEffectBridge` | Already installed by `installCraftEffectBridge`. Call it directly only in a host that installs the synchronous bridge alone. |\n\n## Typed styles\n\n`@craft-ts/style` is a **build step**: none of these symbols emit anything\nwithout `craftStyle` from `@craft-ts/style/vite` in the Vite config. See\n[Activating the style system](/guide/style/setup).\n\n| Symbol | What it does | Page |\n| ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------- | ------------------------------------------------------------------------------ |\n| `craftStyle`, `emitStyles`, `renderCss`, `styleDump`, `findStyleModules` | The build-time emitter and its artefacts (`@craft-ts/style/vite`) | [Activating the style system](/guide/style/setup) |\n| `definePalette`, `darkOf`, `palette` | Colour tokens carrying both of their values, plus the default set | [Defining a design system](/guide/style/define) |\n| `defineBreakpoints`, `at`, `above`, `below` | The viewport axis, as an ordered one | [Defining a design system](/guide/style/define) |\n| `defineStateAxis`, `defineAxis`, `onlyVarsOfKind`, `axisPoint` | Attribute-driven axes, with an optional write constraint | [Defining a design system](/guide/style/define) |\n| `defineContainer` | A container axis, closed at the element that declares the container | [Defining a design system](/guide/style/define) |\n| `scheme`, `motion`, `forcedColors`, `contrast`, `scrollState`, `descendant` | The standard axes, driven by the user agent or by element state | [Axes and the matrix](/guide/style/variants) |\n| `cssVars`, `kind`, `assign`, `set` | Typed custom properties, registered through `@property` | [Tokens and variables](/guide/style/tokens) |\n| `space`, `unit`, `radii`, `radius`, `lineWidth`, `num`, `text`, `font` | The closed value scales — no value is a string | [Tokens and variables](/guide/style/tokens) |\n| `unsafeLength`, `unsafeAssume` | The marked escape hatches; both propagate `unproven` | [Tokens and variables](/guide/style/tokens) |\n| `craftStyles`, `when` | A sheet, and conjunction by nesting | [Axes and the matrix](/guide/style/variants) |\n| `requires`, `provides`, `declares`, `seal`, `scrollPort`, `noClipping`, `containerType`, `clipOverflow` | Context obligations, and where they become an error | [Context obligations](/guide/style/obligations) |\n| `visualMatrix`, `applyScenario`, `branch`, `contentCases`, `assertExhaustiveVisualMatrix`, `baselinesIn` | The scenario matrix (`@craft-ts/style-testing`) | [Testing visual states](/guide/style/testing) |\n| `matrixSizeByComponent`, `impactedClasses`, `varsWrittenBy`, `danglingVars`, `unproven`, `extractionGaps`, `undischargedObligations` | Graph queries over the style dump (`@craft-ts/dev-tools`) | [Testing visual states](/guide/style/testing) |\n| `style_impact`, `style_matrix`, `style_debt` | The same questions as MCP tools | [Testing visual states](/guide/style/testing#the-same-questions-from-an-agent) |\n\n## Internationalisation\n\n`@craft-ts/i18n` is the CraftTS i18n integration: the catalogue stays a plain\nTypeScript value, and a token may resolve a Craft service or parse its\nparameter with a Standard Schema. The package imports core for types only.\n\n| Symbol | What it does | Page |\n| ------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------------------------- |\n| `defineCatalog`, `msg`, `plural` | The catalogue, its messages, and per-locale plural categories | [The catalogue](/guide/i18n/catalog) |\n| `defineLocale`, `defineLocaleLike` | The reference locale, and every other one checked against it | [The catalogue](/guide/i18n/catalog) |\n| `number`, `integer`, `percent`, `compactNumber`, `money`, `dateShort`, `dateLong`, `dateTime`, `relativeTime` | The shipped semantic tokens, formatted through `Intl` | [Tokens](/guide/i18n/tokens) |\n| `defineToken`, `defineTokenFactory`, `formatters`, `TokenFormatter`, `FormatterContext` | Project tokens, and the factory the shipped ones are built from | [Tokens](/guide/i18n/tokens) |\n| `createI18nRuntime`, `translate` / `t`, `setLocale`, `locale` | The runtime and its one active locale | [The runtime](/guide/i18n/runtime) |\n| `TranslationDependencies`, `StaticTranslationKey` | The services a message resolves, and the keys `t` can render alone | [The runtime](/guide/i18n/runtime#di-inside-a-translation) |\n| `TokenSchema`, `TokenSchemaInput`, `TokenSchemaOutput`, `TokenFactory` | Declaring a parameter with a Standard Schema | [Tokens](/guide/i18n/tokens) |\n| `bind`, `createReactiveTranslator` | A translator that re-reads when the locale state changes | [The runtime](/guide/i18n/runtime#reactive-translation) |\n| `createI18nLoader`, `loadLocale` | Lazy locales, cached by id, evicted on failure | [The runtime](/guide/i18n/runtime#lazy-locales) |\n| `validateCatalog`, `assertValidCatalog`, `validateLocaleParity`, `assertLocaleParity` | The checks behind `npm run i18n:check` (also `@craft-ts/i18n/testing`) | [The catalogue](/guide/i18n/catalog#checking-outside-the-typechecker) |\n| `serializeCatalog`, `serializeToken` | JSON-safe delivery shape; refuses a token that resolves a service | [The catalogue](/guide/i18n/catalog) |\n| `I18nRuntimeError` | `LOCALE_NOT_LOADED`, `MISSING_PARAM`, `INVALID_PARAM`, `CRAFT_INJECTION_REQUIRED`, … | [The runtime](/guide/i18n/runtime) |\n| `provideI18nRuntime`, `translateEffect`, `I18nEffectService` | The Effect adapter (`@craft-ts/i18n-effect`) | [With Effect](/guide/i18n/effect) |\n\n## Tooling\n\n| Command / rule | What it does | Page |\n| ---------------------------------- | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------- |\n| `npx craft route add` | Scaffolds a typed route | [Automation](/guide/routing/automation) |\n| `npx craft route split` | Splits a flat collection | [Scaling routes](/guide/routing/scaling) |\n| `npx craft route verify` | Optional compiler-fixture suite for the type machinery | [Automation](/guide/routing/automation#compiler-fixture-suite-optional) |\n| `craft-brand --root src` | Generates and refreshes `GenDeps_*` | [Brand config](/guide/routing/setup#generated-dependencies) |\n| `@craft-ts/dev-tools/eslint-rules` | The ESLint rule set | [ESLint rules](/guide/routing/eslint-rules) · [Accessibility](/guide/components/accessibility) |\n| `npx craft-graph` | Writes the static Craft graph | [Architecture rules](/guide/testing/architecture) · [Craft graph vs Nx](/guide/testing/craft-graph-vs-nx) |\n| `npx nx architecture <app>` | Runs the app's architecture Vitest suite | [Architecture rules](/guide/testing/architecture) · [Craft graph vs Nx](/guide/testing/craft-graph-vs-nx) |\n| Live page MCP `page` | Drive the open `ng serve` tab (dev only) | [Live page MCP](/guide/ai/dev-page) |\n| Template migrator | Migrates templates to craft components | [Template migrator](/guide/components/template-migrator) |\n\n## Deployment\n\n::: warning Experimental\nThe deployment tooling is not settled: these symbols and commands can still\nchange between minor versions. See the\n[deployment guide](/guide/deployment/) for what exists today.\n:::\n\n| Symbol / command | What it does | Page |\n| ---------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | ------------------------------------------------ |\n| `defineCraftDeployment` | Declares the deployment of an application in `craft.deploy.ts` | [Manifest reference](/guide/deployment/manifest) |\n| `checkCraftDeployment`, `checkCraftDeploymentArtifact` | Runs the manifest, module graph and artefact checks | [Diagnostics](/guide/deployment/diagnostics) |\n| `resolveCraftDeploymentManifest`, `serializeCraftDeploymentManifest`, `parseCraftDeploymentManifest` | Resolves, writes and reads the provider-neutral artefact form | [Manifest reference](/guide/deployment/manifest) |\n| `CraftDeploymentProvider`, `CRAFT_DEPLOYMENT_PROVIDERS` | The provider contract and the capability matrix | [Providers](/guide/deployment/providers) |\n| `npx craft-ts check` | Validates a deployment before building | [Deployment overview](/guide/deployment/) |\n| `npx craft-ts manifest` | Writes `dist/<app>/craft-deployment-manifest.json` | [Deployment overview](/guide/deployment/) |\n| `npx craft-ts deploy preview` | Shows what a provider would change, without changing it | [Alchemy provider](/guide/deployment/alchemy) |\n| `npx craft-ts deploy` | Applies that plan once `--yes` approves it | [Alchemy provider](/guide/deployment/alchemy) |\n| `createCraftDeploymentProvider` | The single factory a provider package exports | [Providers](/guide/deployment/providers) |\n| `createAlchemyDeploymentProvider`, `planAlchemyDeployment` | The Alchemy provider and its Cloudflare/AWS planning | [Alchemy provider](/guide/deployment/alchemy) |\n| `npx craft-ts providers` | Prints the provider capability matrix | [Providers](/guide/deployment/providers) |\n"
|
|
731
731
|
},
|
|
732
732
|
{
|
|
733
733
|
"path": "/resources/ai-agents",
|
|
@@ -748,7 +748,7 @@
|
|
|
748
748
|
{
|
|
749
749
|
"path": "/resources/effect-compatibility",
|
|
750
750
|
"title": "Effect compatibility and maturity",
|
|
751
|
-
"body": "# Effect compatibility and maturity\n\nThis page describes the current repository contract. It is a decision aid for\nteams evaluating CraftTS, not a promise that beta APIs will remain unchanged.\n\n## Compatibility matrix\n\n| Area | Current contract | Status |\n| --- | --- | --- |\n| Craft runtime | `@craft-ts/core` `0.7.0-beta.11` | Beta |\n| Craft components | `@craft-ts/component` on the same Craft version | Beta |\n| Effect bridge | `@craft-ts/effect` `0.7.0-beta.11` | Beta / experimental integration |\n| Effect runtime | `effect` `^4.0.0-rc.
|
|
751
|
+
"body": "# Effect compatibility and maturity\n\nThis page describes the current repository contract. It is a decision aid for\nteams evaluating CraftTS, not a promise that beta APIs will remain unchanged.\n\n## Compatibility matrix\n\n| Area | Current contract | Status |\n| --- | --- | --- |\n| Craft runtime | `@craft-ts/core` `0.7.0-beta.11` | Beta |\n| Craft components | `@craft-ts/component` on the same Craft version | Beta |\n| Effect bridge | `@craft-ts/effect` `0.7.0-beta.11` | Beta / experimental integration |\n| Effect runtime | `effect` `^4.0.0-rc.112` | Effect 4 release candidate required by current Alchemy |\n| Effect 3 projects | No compatibility contract | Migrate or isolate before adopting |\n| Node.js | 20.19+ or 22.12+ | Required by the current docs |\n| TypeScript | Use the version supported by the selected Craft beta; verify with the project lockfile | Toolchain-sensitive |\n| Browser application | Vite demo and jsdom tests are covered | Experimental but executable |\n| SSR | No product SSR renderer in this release | Not ready |\n| Server functions | Local transport and middleware experiment | Proof of concept |\n| Migration tooling | `craft-migrate` for Craft concepts | No complete Effect-specific migration |\n\nInstall all Craft packages from the same beta channel. `@craft-ts/effect` also\ndeclares `effect` as a peer dependency, so the Effect version is part of the\napplication's compatibility surface.\n\n## Maturity by capability\n\n| Capability | What is covered today | Adoption guidance |\n| --- | --- | --- |\n| Effect domain programs | `Effect`, tagged errors, `Context.Service`, `Layer` | Good candidate for a pilot |\n| Effect-backed reads and writes | `queryEffect`, `mutationEffect`, `asyncProcessEffect` | Pilot with real tests and a narrow feature |\n| Synchronous Effect members in a computation | `SyncOp`, `computedEffect`, `syncEffect` | Declare the members that never suspend, then reuse them in `craftComputed` and `params` |\n| Layer scoping | application, route, component and primitive providers | Use after the basic boundary is understood |\n| Typed error mapping | `E` becomes Craft exceptions; defects stay technical errors | Suitable for explicit UI error handling |\n| Effect service mocks | `mockEffectService` plus Craft registers | Suitable for focused tests |\n| Static Effect graph | Effect services, operations and Layers are collected | Useful for architecture rules; still evolving |\n| Server functions | `executeEffect`, middleware and local HTTP demo | Keep behind an experimental boundary |\n| SSR and deployment integration | Not shipped as a product contract | Do not make it a prerequisite for adoption |\n\n## How to read this table\n\nThe safest first adoption is browser-side, one feature, with an existing Effect\ndomain and an application Layer provided by Craft. Defer SSR-specific decisions\nand server functions until their contracts are stable.\n\nSee [Adopting CraftTS progressively](/resources/effect-adoption) for a staged\nplan and [the quickstart's verification section](/learn-effect/00-start-here#5-verify-the-boundary)\nfor the executable Effect demo checks.\n"
|
|
752
752
|
},
|
|
753
753
|
{
|
|
754
754
|
"path": "/resources/examples",
|