diff --git a/.changeset/deep-modules-topics-split.md b/.changeset/deep-modules-topics-split.md new file mode 100644 index 00000000..b06b6d2e --- /dev/null +++ b/.changeset/deep-modules-topics-split.md @@ -0,0 +1,9 @@ +--- +"@fveracoechea/operator": patch +--- + +Split the `deep-modules` skill into smaller topics, and point `tanstack-tools` at it. + +`deep-modules` now states the find-the-feature steps in its router, and moves sub-modules, the entry-file lint rule, and environment and per-request state into topics of their own. +An entry file may export one interface object, and a sub-module follows the same rule one level down. +In `tanstack-tools`, a feature's own components import its query slice through a relative path, and the `$` aggregator is for route loaders and other modules. diff --git a/skills/README.md b/skills/README.md index 6138484c..8c18482e 100644 --- a/skills/README.md +++ b/skills/README.md @@ -12,7 +12,7 @@ A `SKILL.md` is a router and stays under 150 lines, and every markdown file besi - **[adr](./adr/SKILL.md)** - Use when recording an architectural decision, when an existing ADR in docs/adr looks stale against the code, or when a change contradicts an ADR. - **[bun](./bun/SKILL.md)** - Use when writing or reviewing Bun code including runtime, bun test, Bun.serve, bundler, bun install, or scripts. -- **[deep-modules](./deep-modules/SKILL.md)** - Use when adding or changing a feature or capability in a TypeScript app: code for a domain concept, a screen and its data, where new or shared code goes, a module interface or sub-module, or a module test. +- **[deep-modules](./deep-modules/SKILL.md)** - Use when adding or changing a feature in a TypeScript app: a screen and its data, where new or shared code goes, a sub-module, or a module test. - **[no-slop](./no-slop/SKILL.md)** - Use when writing or editing prose, documentation, code comments, commit messages, or issue and pull request text. - **[operative](./operative/SKILL.md)** - Use when dispatched for production or rework with a fixed Operator brief. - **[operator](./operator/SKILL.md)** - Use when preparing a project for Operator, running approved work through a crew, or resuming an Operator session that ended. diff --git a/skills/deep-modules/SKILL.md b/skills/deep-modules/SKILL.md index 6d0861df..a654e527 100644 --- a/skills/deep-modules/SKILL.md +++ b/skills/deep-modules/SKILL.md @@ -1,41 +1,51 @@ --- name: deep-modules -description: 'Use when adding or changing a feature or capability in a TypeScript app: code for a domain concept, a screen and its data, where new or shared code goes, a module interface or sub-module, or a module test.' +description: 'Use when adding or changing a feature in a TypeScript app: a screen and its data, where new or shared code goes, a sub-module, or a module test.' --- # Deep modules Sort code by feature and capability, never by technical layer or by page. -Each feature and each capability is one folder under `src/modules//`, and the rest of the app reaches it through one small interface. -The folder holds every file the feature needs, whatever layer or environment the file belongs to. +Each feature and each capability is one folder under `src/modules//` that holds every file it needs, whatever layer or environment the file belongs to. - A **feature** is a domain concept the product names, such as invoices, payments or customers. Its module owns the calls to the backend, the reads, the transforms, the cache rules and the UI. - A **capability** is what features use and users never see, such as auth, the HTTP client for a backend, or a cache. - A **page** is a route. It owns the URL, the loader and the layout, and places the `UI` members of the features it shows. - A **layer** is a technical role, such as query options, selectors, hooks or an API client. A layer is never a module name. -Each module is a deep module in John Ousterhout's sense: a small interface in front of a large implementation. -The module pulls complexity down into itself, so each caller knows less. +Each module is a deep module: it pulls complexity down into itself, so each caller knows less. The `codebase-design` skill holds the words for this: depth, seam, leverage, locality and the deletion test. -Use its words. -**Name the feature first.** -Before you write code, name the domain noun the change is about, and find the module that owns it. -When code for that noun is spread across routes and layers with no module, the feature is missing its module. -[finding-features.md](finding-features.md) is the procedure, and every task starts with it. +## Name the feature first -**The caller learns one object.** -A route, a handler or another module reaches the module through its interface object, such as `Invoices`, `Auth` or `Cache`. -A module that only the browser reaches through server functions has `$functions.ts` as its interface, and no object. -A second exported name is a second interface, and the module gets shallower with each one. +Do this before you write code, on every task. + +1. Name the domain noun the change is about. Take it from the ticket, the glossary (`CONTEXT.md`), the wire schemas or the words on the screen. A page or a backend service is not a feature. +2. Search `src/` for the noun in file names, types, functions and strings, and list each place it appears. +3. The step is done when you can name the module that owns the change, or when you have a proposal for a new module with its interface object. + +When the noun is spread across routes and layers with no module, the feature is missing its module. +[finding-features.md](finding-features.md) has the signs of a missing module, a worked example, and what to do with the old code. + +## The caller learns one object per entry file + +A route, a handler or another module reaches a module through its entry files, and each entry file exports one PascalCase interface object, such as `Invoices`, `Auth` or `Cache`. +Beside the object, an entry file may export the types a caller needs to name its inputs and results, such as `Session` beside `Auth`. +Two other entry files carry no object: `schemas.ts` holds the wire schemas, and `$functions.ts` holds a TanStack Start module's server functions. +Any other exported name is a second interface, and the module gets shallower with each one. + +A sub-module follows the same rule one level down. +In `chat/turn/`, `main.ts` exports one object, `Turn`, and the rest of `chat/` reaches the turn only through it. +Routes see only `Chat`. +Each level hides its parts from the level above, so a sub-module makes the module deeper ([sub-modules.md](sub-modules.md)). + +## Build from the call site down -**Build from the call site down.** Write the line the caller will contain first, such as `` or `Auth.requireSession()`, then write the implementation behind it. Code written from the bottom up produces helpers that no module owns. ## Know the environment -The rules hold in every environment. The entry files differ, and [module-shape.md](module-shape.md) lists the set for each environment. - **Full-stack**, such as TanStack Start: one module owns both sides of the server boundary and its UI. The framework draws that boundary per file. @@ -44,8 +54,10 @@ The entry files differ, and [module-shape.md](module-shape.md) lists the set for ## Pick the task -- **Deciding which module owns a change, or finding a feature that has no module**: [finding-features.md](finding-features.md). -- **Creating a module, adding a file to one, placing a helper, or enforcing the entry points**: [module-shape.md](module-shape.md). +- **Creating a module, adding a file to one, placing a helper, or checking import direction**: [module-shape.md](module-shape.md). +- **Splitting a module into sub-modules, or adding a folder inside one**: [sub-modules.md](sub-modules.md). +- **Setting up the lint rule that guards the entry files**: [entry-file-lint.md](entry-file-lint.md). +- **Reading environment variables or keeping per-request state in a module**: [server-state.md](server-state.md). - **Designing or reviewing an interface, or deciding whether a module earns its place**: [interface-design.md](interface-design.md). - **Writing a test for a module, or for code that uses one**: [module-tests.md](module-tests.md). diff --git a/skills/deep-modules/entry-file-lint.md b/skills/deep-modules/entry-file-lint.md new file mode 100644 index 00000000..1a3655e1 --- /dev/null +++ b/skills/deep-modules/entry-file-lint.md @@ -0,0 +1,32 @@ +# Guard the entry files with lint + +## Reject an import that skips an entry file + +A `no-restricted-imports` pattern, in oxlint or ESLint, rejects every import from outside a module that skips its entry files: + +```jsonc +"no-restricted-imports": ["error", {"patterns": [{ + "group": [ + "@/modules/*/*", "@/modules/*/**", "**/modules/*/*", "**/modules/*/**", + "!@/modules/*/main", "!@/modules/*/main.server", "!@/modules/*/main.client", + "!@/modules/*/$functions", "!@/modules/*/schemas", "!@/modules/*/schemas.generated", + "!**/modules/*/main", "!**/modules/*/main.server", "!**/modules/*/main.client", + "!**/modules/*/$functions", "!**/modules/*/schemas", "!**/modules/*/schemas.generated" + ], + "message": "Import a module through its entry files, or put the helper on its interface object. Inside a module, use relative imports." +}]}] +``` + +Keep only the entry names the repo uses. +The pattern also rejects `@/modules/chat/turn/main`, so a sub-module stays private to its parent. +The pattern is done when a deliberate deep import fails lint, and lint passes again after you revert it. + +## Reject a route import from a module + +A second `no-restricted-imports` pattern on `src/modules/**` can reject `@/routes/**`, so a module never imports its callers. +Verify it the same way: a deliberate route import from a module fails lint. + +## Know what the patterns miss + +A relative import escapes both patterns. +Inside a module, a reviewer looks for an import that reaches past a sub-module's `main`, such as `../turn/turn-stream`. diff --git a/skills/deep-modules/finding-features.md b/skills/deep-modules/finding-features.md index 8807fa41..f34e4113 100644 --- a/skills/deep-modules/finding-features.md +++ b/skills/deep-modules/finding-features.md @@ -1,24 +1,20 @@ # Find the feature that owns a change -## Name the domain noun, not the page or the backend +The step itself, name the noun and search `src/` for it, is in [SKILL.md](SKILL.md). +This file is what you consult while you do it. -A feature is named after something the product and its users talk about: `invoices`, `payments`, `customers`. -Take the noun from the ticket, the glossary (`CONTEXT.md`), the names in the wire schemas, or the words on the screen. +## Tell a feature from a backend service -Two other names look like features and are not: - -- A page, such as a dashboard, is a place where several features appear. The route owns the URL, the loader and the layout, and it places each feature's `UI` members. -- A backend service, such as `billing-api`, serves several features. Its transport, session and error mapping are a capability. Each feature's operations belong to that feature. +A backend service, such as `billing-api`, serves several features. +Its transport, session and error mapping are a capability. +Each feature's operations belong to that feature. A change can touch two features. Name both, and give each part of the change to its owner. -## Search the tree for the noun before you write code - -Search `src/` for the noun in file names, types, functions and strings, and list each place it appears. -The step is done when you can name the module that owns the change, or when you have a proposal for a new module with its interface object. +## A feature can live in five places with no module -For example, in a billing app the noun `invoice` can appear in five places, none of them a module named for it: +In a billing app the noun `invoice` can appear in five places, none of them a module named for it: | Place | What it knows about invoices | | ------------------------------------------------- | -------------------------------------------------- | @@ -43,9 +39,7 @@ That is an `invoices` module the code base never named. ## Give a feature its module even when one route uses it -A feature module earns its place through what it hides, not through its number of callers. -Remove the module in your head, and see where its code would go. -When its reads, rules and UI would move into a route folder and two layer modules, the module passes the deletion test with one caller. +A feature module with one caller passes the deletion test in [interface-design.md](interface-design.md) when its reads, rules and UI would otherwise move into a route folder and two layer modules. A page's `-` folder keeps the pieces of that page's own layout, such as the grid that arranges the sections. Code that knows a domain noun goes in the feature's module from its first line, not after a second route asks for it. @@ -55,7 +49,7 @@ Code that knows a domain noun goes in the feature's module from its first line, - The transport, the session and the error mapping stay as a capability module that every feature uses. - Each feature module owns its operations, its reads, its transforms, its cache rules and its UI. - A repo that keeps one aggregator, such as a `$` query-options builder, lets the aggregator import each feature's part from the feature's entry file. The logic stays in the feature. -- The feature's UI reads its own data through a relative path, never back through the aggregator. The aggregator then depends on the feature in one direction only, and no cycle forms between the two modules. +- The aggregator is for route loaders and other modules. The feature's own UI reads its data through a relative path, so the aggregator depends on the feature in one direction only, and no cycle forms between the two modules. ## Follow the repo's ADR where it keeps a layer module @@ -66,6 +60,6 @@ Name the conflict in your proposal, so the team can amend the ADR (the `adr` ski ## Put new code in the feature's module, and propose the move for old code When the repo already spreads a feature across routes and layers, write the new code in the feature's module and create the module when it does not exist yet. -Keep the change you were asked for small. When the new code needs an operation that still lives in the old place, call it there, and add it to the move. -List the other files that belong to the feature, and give the list to the user as a proposed move, a follow-up ticket or a note in the pull request. +Every place from the search is either in this change or on the move list. +Give the move list to the user as a proposed move, a follow-up ticket or a note in the pull request. diff --git a/skills/deep-modules/interface-design.md b/skills/deep-modules/interface-design.md index 5e320361..10116d6d 100644 --- a/skills/deep-modules/interface-design.md +++ b/skills/deep-modules/interface-design.md @@ -9,23 +9,21 @@ The `codebase-design` skill, topic `DESIGN-IT-TWICE.md`, compares two or three s ## Keep a module only when its deletion would spread complexity to its callers Imagine you delete the module. -When the same logic would come back in several call sites, the module earns its place. +When the same logic would come back in its call sites, the module earns its place. When nothing would come back, the module is a pass-through, so delete it. The test measures what the module hides, not how many callers it has, so a feature with one route can pass it. -Machinery with no caller that would miss it is not ported, and it is not kept "for later". Apply the same test to each member of the interface object, and to each server function. -`Invoices.remove` that calls `Store.delete` with the same arguments hides nothing. -Give it more work, such as the cache refresh every caller does next, or remove it. -A feature module with one method per remote operation is the exception. -Each such method names one call the feature allows, and the depth sits in the transport those methods share. +A same-process pass-through goes: `Invoices.remove` that calls `Store.delete` with the same arguments hides nothing, so give it more work, such as the cache refresh every caller does next, or remove it. +A method that is the feature's public operation over a transport, such as an RPC or an HTTP call, stays even when it is one line. +It names one call the feature allows, and the depth sits in the transport those methods share. The transport is a capability, and its interface is general: a request method that any feature's operation calls. ## Ship the finished use case, not the steps A caller that calls `load`, then `validate`, then `save` knows the module's order of operations. Make that sequence one method, and keep the order inside. -A UI member is a finished screen part for the same reason, never a set of parts with the wiring left to the route. +A UI member is a finished screen part for the same reason, never a set of parts with the wiring left to the route, because a caller that assembles parts holds the module's state switches. ## Pull decisions down into the module diff --git a/skills/deep-modules/module-shape.md b/skills/deep-modules/module-shape.md index 5eaf8d4b..222e24b7 100644 --- a/skills/deep-modules/module-shape.md +++ b/skills/deep-modules/module-shape.md @@ -15,9 +15,8 @@ Every other file is private, and files inside the module import each other with A module has only the entry files its callers need. A server-only module is usually `main.ts` and `schemas.ts`, because the HTTP routes that call it live outside the module. An SPA module is `main.ts`, plus `schemas.ts` for the responses it parses. - The wire schemas are an interface of their own, because routes, handlers and fixtures parse against them. -An entry file may also export the types a caller needs to name the object's inputs and results, such as `Session` beside `Auth`. +[entry-file-lint.md](entry-file-lint.md) makes lint reject an import that skips an entry file. ### Let `$functions.ts` be the interface when every caller crosses the RPC @@ -32,35 +31,15 @@ Add `main.ts` when a route places the module's UI or calls it in the browser. This applies to a full-stack app, where one module holds code for both sides. -- `main.server.ts` holds a server-only face: `Auth`, `Cache`, `Http`. -- `main.ts` holds an isomorphic face, or the face the browser uses. TanStack Start renders every component in the route tree on the server, even under `ssr: 'data-only'`, so a `.client.ts` file on that path fails the SSR build. -- A module with two faces has two entry files, each with one object. +- `main.server.ts` holds a server-only interface object: `Auth`, `Cache`, `Http`. +- `main.ts` holds an isomorphic interface object, or the one the browser uses. TanStack Start renders every component in the route tree on the server, even under `ssr: 'data-only'`, so a `.client.ts` file on that path fails the SSR build. +- A module with an object for each side has two entry files, each with one object. The file name tells where code runs. `.server.ts` and `.client.ts` mark a file that belongs to one environment, and a plain `.ts` file runs on both. A `$` prefix marks a server function or middleware. A `.generated.ts` suffix marks generated output, which nobody edits by hand. -## Enforce the entry files with a lint rule - -A `no-restricted-imports` pattern, in oxlint or ESLint, rejects every import from outside that skips an entry file: - -```jsonc -"no-restricted-imports": ["error", {"patterns": [{ - "group": [ - "@/modules/*/*", "@/modules/*/**", "**/modules/*/*", "**/modules/*/**", - "!@/modules/*/main", "!@/modules/*/main.server", "!@/modules/*/main.client", - "!@/modules/*/$functions", "!@/modules/*/schemas", "!@/modules/*/schemas.generated", - "!**/modules/*/main", "!**/modules/*/main.server", "!**/modules/*/main.client", - "!**/modules/*/$functions", "!**/modules/*/schemas", "!**/modules/*/schemas.generated" - ], - "message": "Import a module through its entry files, or put the helper on its interface object. Inside a module, use relative imports." -}]}] -``` - -Keep only the entry names the repo uses. -The pattern is done when a deliberate deep import fails lint, and lint passes again after you revert it. - ## Declare the interface object directly ```ts @@ -79,9 +58,7 @@ The test fakes the collaborator at its entry file instead, as [module-tests.md]( ## Put what another module needs on the interface object A caller that needs a behaviour gets it as a method on the interface object. -Before you add the method, ask why the caller needs it. -When the caller does work that the module should own, move that work into the module as one finished method. -Exporting the pieces the caller would assemble makes the module shallower. +Before you add the method, ask why the caller needs it, and ship the finished use case that [interface-design.md](interface-design.md) describes. ## Expose the UI as finished use cases on a `UI` object @@ -96,29 +73,9 @@ export const InvoicesUI = { The route places the member whole: ``. Each member decides what it shows from its own state. -The parts a member composes stay inside the module, because a caller that assembles parts holds the module's state switches. Keep the module's `.tsx` files under its `ui/` folder. The internal compound, its provider and its parts, follows the `react-composition` skill. -## Make a folder inside a module a sub-module with one entry file - -A folder inside a module has its own `main.ts`, or `main.server.ts` in a full-stack app, and the rest of the module imports the folder only through it: - -```ts -// invoices/pdf/main.ts -export type {PdfPage} from './render' - -export const Pdf = { - render: renderInvoicePdf, - /** The file name the browser saves, built from the invoice number. */ - fileName: toFileName, -} -``` - -`invoices/ui/` imports `../pdf/main`, never `../pdf/layout`. -A relative path escapes the lint pattern above, so a reviewer looks for an import that reaches past a sibling folder's `main`. -Add the folder when a group of files hides something from the rest of the module, not to sort files by kind. - ## Point every import downward - A feature module uses infrastructure modules, such as `Http`, `Cache` and `Auth`. @@ -126,35 +83,5 @@ Add the folder when a group of files hides something from the rest of the module - A module imports the shared library (`src/lib`) and the app's shared components (`src/components`). - A module never imports a route or a handler. Routes and handlers are the callers. -A second `no-restricted-imports` pattern on `src/modules/**` can reject `@/routes/**`. `import/no-cycle` catches a cycle between files, not a cycle between two modules whose files form no loop. Check the direction yourself before you add an import from another module. - -## Read server configuration inside a method - -Declare environment variables in validated files, `.server.ts` for server-only values and `.client.ts` for values the bundler may inline, and read `process.env` or `import.meta.env` nowhere else. -Validate them at startup, so a bad value fails the boot. - -In a full-stack app, a module reads a server value inside the method that needs it, never at module load: - -```ts -/** `baseURL` per call: `ServerEnv` is read at call time, never at module load. */ -function billingRequest(session: Session) { - return { - baseURL: ServerEnv.BILLING_BASE_URL, - headers: {authorization: `Bearer ${session.token}`}, - } -} -``` - -A client-side test imports the route tree, and a top-level read of a server value throws before the test starts. -A connection that is expensive to open is a lazy singleton, opened on the first call. - -## Keep per-user state out of module scope - -This applies on a server, full-stack or server-only. -A variable at module level is shared by every request in the process. -A visitor's state lives in the session, in the request's context, or in the request's query client. - -In an SPA, the query cache holds server data and the URL holds what the URL owns. -A module-level store that copies either one is a second source of truth. diff --git a/skills/deep-modules/module-tests.md b/skills/deep-modules/module-tests.md index ee0c5f00..3d71690f 100644 --- a/skills/deep-modules/module-tests.md +++ b/skills/deep-modules/module-tests.md @@ -4,9 +4,11 @@ A test file sits beside the unit it covers and carries its name: `main.server.test.ts` beside `main.server.ts`. The test calls the interface object the way a route does, and asserts what the caller observes. -A module whose interface is `$functions.ts` is tested through its server functions, with a stand-in `createServerFn` that runs the validator and then the handler. When a behaviour is hard to reach through the interface, the module has the wrong shape, so fix the interface and not the test. +A module whose interface is `$functions.ts` is tested through its server functions. +Its own test uses a `createServerFn` stand-in that runs the validator and then the handler, so the test reaches the server code the RPC would run. + ## Fake another module at its entry file A test replaces a collaborator module at the same specifier its callers import: @@ -23,17 +25,17 @@ The module's own files run real in its tests. When one of them wraps an outside dependency, fake that dependency's module, not the file that wraps it. A fake at an internal file couples the test to the module's file layout, and the test breaks on a refactor that changes no behaviour. -## Stand in for server functions at the RPC boundary +## Stand in for `$functions` modules in a caller's test This applies to a full-stack app on TanStack Start. The test runner does not run the TanStack Start compiler, so an untransformed `createServerFn` has no transport. -The RPC boundary is the only seam, so fake each `$functions.ts` module once, in the shared test setup, with a stand-in that speaks the real wire. +The RPC boundary is the only seam, so a test of a route or another module fakes each `$functions.ts` module once, in the shared test setup, with a stand-in that speaks the real wire. The existing HTTP fakes then keep working. Give the stand-in one line per server function. A suite that needs more overrides the module for that file. -The module's own tests cover what the stand-in leaves out, such as identity headers. -A lint rule that bans module mocks stays on everywhere else, and is off for module tests and the stand-in only. +The module's own tests, with the `createServerFn` stand-in above, cover what this stand-in leaves out, such as identity headers. +`anti-slop/no-module-mocking` stays on everywhere else, and is off for module tests and this stand-in only. ## Test a UI member through the real route tree diff --git a/skills/deep-modules/server-state.md b/skills/deep-modules/server-state.md new file mode 100644 index 00000000..06179985 --- /dev/null +++ b/skills/deep-modules/server-state.md @@ -0,0 +1,30 @@ +# Keep configuration and request state out of load time + +## Read server configuration inside a method + +Declare environment variables in validated files, `.server.ts` for server-only values and `.client.ts` for values the bundler may inline, and read `process.env` or `import.meta.env` nowhere else. +Validate them at startup, so a bad value fails the boot. + +In a full-stack app, a module reads a server value inside the method that needs it, never at load time: + +```ts +/** `baseURL` per call: `ServerEnv` is read at call time, never at load time. */ +function billingRequest(session: Session) { + return { + baseURL: ServerEnv.BILLING_BASE_URL, + headers: {authorization: `Bearer ${session.token}`}, + } +} +``` + +A client-side test imports the route tree, and a top-level read of a server value throws before the test starts. +A connection that is expensive to open is a lazy singleton, opened on the first call. + +## Keep per-user state out of top-level variables + +This applies on a server, full-stack or server-only. +A top-level variable is shared by every request in the process. +A visitor's state lives in the session, in the request's context, or in the request's query client. + +In an SPA, the query cache holds server data and the URL holds what the URL owns. +A top-level store that copies either one is a second source of truth. diff --git a/skills/deep-modules/sub-modules.md b/skills/deep-modules/sub-modules.md new file mode 100644 index 00000000..2297d0ec --- /dev/null +++ b/skills/deep-modules/sub-modules.md @@ -0,0 +1,76 @@ +# Split a module into sub-modules + +A sub-module is a folder inside a module that follows the module rules one level down. +It makes the module deeper. +The app sees the parent's object, and the rest of the parent sees the sub-module's object. +Each file then knows less, and the folder tree names the parts of the module. + +## Give each sub-module one entry file that exports one object + +In a chat module, a turn is one message sent and the answer it streams back. +The turn is a sub-module of chat: + +```text +modules/chat/ + main.ts Chat, the object routes import + $functions.ts + schemas.ts + conversations.server.ts + ui/ + main.ts ChatUI + panel.tsx + chat-panel-provider.tsx + turn/ + main.ts Turn, the object the rest of chat imports + turn.ts POSTs the message, folds the frames into an answer + turn-stream.ts reads the event stream + errors.ts + turn-stream.test.ts +``` + +```ts +// chat/turn/main.ts +import {toAssistantFailure} from './errors' +import {runTurn} from './turn' + +export type {TurnStep} from './turn' + +/** One turn of a thread. `run` POSTs the message and reports the answer as it streams. */ +export const Turn = { + run: runTurn, + /** A failed request phrased for the user, whatever threw. */ + failure: toAssistantFailure, +} +``` + +```ts +// chat/ui/chat-panel-provider.tsx +import {Turn} from '../turn/main' +``` + +The sub-module follows the module rules: + +- `main.ts` exports one PascalCase object named after the folder, `turn/` as `Turn`, plus the types a caller names, such as `TurnStep`. A server-only sub-module uses `main.server.ts`. +- Every other file in the folder is private. The rest of `chat/` imports `../turn/main`, never `../turn/turn-stream`. +- Its files carry the same suffixes as a module's files, such as `.server.ts` and `.test.ts`. +- Imports point downward. `chat/` imports `turn/`, and `turn/` never imports `chat/main.ts`. It may parse against the parent's `schemas.ts`, reach a sibling sub-module through its `main`, and reach another module through its entry files. +- `ui/` is a sub-module too: `ui/main.ts` exports the `UI` object, and every `.tsx` behind it is private. + +## Keep a sub-module private to its parent + +A route reaches the turn through `Chat`, such as ``, and never learns that `Turn` exists. +When a caller outside the module needs the sub-module's behaviour, put a finished member on the parent's object. +The lint pattern in [entry-file-lint.md](entry-file-lint.md) rejects `@/modules/chat/turn/main` from outside the module. + +## Add a sub-module when a group of files hides something + +Without `turn/`, the panel provider imports the stream reader and the frame parsing, and learns the order: POST, read each frame, fold it into the answer, map the failure. +With it, the provider calls `Turn.run`, and a change to the stream format stays inside `turn/`. +That is the deletion test in [interface-design.md](interface-design.md), one level down: delete `turn/`, and its logic spreads across the files of `chat/`. + +A folder that only sorts files by kind, such as `hooks/` or `utils/`, hides nothing and fails the test. + +## Test a sub-module through its object + +A sub-module's tests sit beside the files they cover, such as `turn/turn-stream.test.ts`, and a test of the whole turn calls `Turn`. +A test of `Chat` runs `Turn` real, because a sub-module is one of the module's own files ([module-tests.md](module-tests.md)). diff --git a/skills/tanstack-tools/query-mutations.md b/skills/tanstack-tools/query-mutations.md index 8474ae07..f6ef0217 100644 --- a/skills/tanstack-tools/query-mutations.md +++ b/skills/tanstack-tools/query-mutations.md @@ -33,12 +33,17 @@ settings: { } ``` -The call site is then the factory alone, with no `useQueryClient()` and no import: +The call site is then the factory alone, with no `useQueryClient()`. +A component inside the feature's module imports the slice through a relative path: ```tsx -const save = useMutation($.insights.settings.put()) +import {insightsQueries} from '../queries' + +const save = useMutation(insightsQueries.settings.put()) ``` +A route loader or another module reads the same slice through the `$` aggregator, such as `$.insights.settings`. + ## Give a mutation-options factory only the operation's inputs Its parameters are what the operation varies on, an id or a scope. diff --git a/skills/tanstack-tools/router-creating-a-route.md b/skills/tanstack-tools/router-creating-a-route.md index ba9aa893..bef33858 100644 --- a/skills/tanstack-tools/router-creating-a-route.md +++ b/skills/tanstack-tools/router-creating-a-route.md @@ -53,7 +53,7 @@ routes/ ### Keep only the page's layout behind a `-` prefix A `-` prefixed sibling holds what arranges this one page, such as the grid that places its sections; the `-` keeps it out of the tree. -Code that knows a domain concept, such as its reads, its rules or the cards that show it, goes in that feature's module from its first line, even when one route uses it (`deep-modules`). +Where the rest of the page's code goes is in the `deep-modules` skill. ## Name a nested route by its folders, not by dots diff --git a/skills/tanstack-tools/start-server-functions.md b/skills/tanstack-tools/start-server-functions.md index 45d2e277..9af6f3c3 100644 --- a/skills/tanstack-tools/start-server-functions.md +++ b/skills/tanstack-tools/start-server-functions.md @@ -74,5 +74,6 @@ Exempt a long stream from the server idle timeout, or the runtime kills it mid-r ## Reach a server function from the data layer only -The query-options builder calls `$threads` and owns the key, the `queryFn`, the unwrap and the mapping. -Components read the builder, and anything else a screen needs (a refresh, a page size, a download href) is a member of the same slice. +The feature's query options call `$threads` and own the key, the `queryFn`, the unwrap and the mapping. +Anything else a screen needs (a refresh, a page size, a download href) is a member of the same slice. +The feature's own components import the slice through a relative path, and a `$` aggregator that re-exports it is for route loaders and other modules (`deep-modules`).