@spfn/core 0.3.0-beta.10 → 0.3.0-beta.12

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.
@@ -62,13 +62,112 @@ type InferRouteInput<TRoute> = TRoute extends RouteDef<infer TInput, any, any> ?
62
62
  * ```
63
63
  */
64
64
  type InferRouteOutput<TRoute> = TRoute extends RouteDef<any, any, infer TResponse> ? TResponse : never;
65
+ /**
66
+ * True only for `any`.
67
+ *
68
+ * `any` satisfies both branches of a conditional type, so a router whose routes are
69
+ * unresolved (`Router<any>`) would otherwise flatten into "every name exists" and
70
+ * accept a misspelled route name. A route record that is not known contributes no
71
+ * names instead.
72
+ */
73
+ type IsAny<T> = 0 extends (1 & T) ? true : false;
74
+ /**
75
+ * True when `T` is a union of two or more members.
76
+ */
77
+ type IsUnion<T, TCopy = T> = T extends unknown ? ([TCopy] extends [T] ? false : true) : false;
78
+ /**
79
+ * One leaf of a router tree: the name a route is registered under, its definition, and
80
+ * the path it was reached by.
81
+ *
82
+ * A route is identified by its own key whatever depth it sits at — `registerRoutes`
83
+ * mounts a nested route under that key, and the contract collector reports a collision
84
+ * on it. Nesting groups the source; it does not qualify the name. The trail is carried
85
+ * so that one name reached through two branches stays two leaves.
86
+ */
87
+ interface RouteLeaf<TName extends PropertyKey, TDef, TTrail extends PropertyKey[]> {
88
+ name: TName;
89
+ def: TDef;
90
+ trail: TTrail;
91
+ }
92
+ /** Decrements the nesting budget; `PrevDepth[0]` is `never`, which ends the walk. */
93
+ type PrevDepth = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
94
+ /**
95
+ * How many levels of nesting are followed.
96
+ *
97
+ * A budget rather than open recursion: a router type that refers to itself would
98
+ * otherwise never terminate, and TypeScript would report an instantiation-depth error
99
+ * in whichever component happened to read a route type. A route below the budget is not
100
+ * visible to these types; ten levels is far past any hand-written tree.
101
+ */
102
+ type MaxNesting = 10;
103
+ /**
104
+ * Collect every route in a route record as a leaf, descending into nested routers
105
+ * exactly as `registerRoutes` does.
106
+ */
107
+ type RouteLeaves<TRoutes, TTrail extends PropertyKey[] = [], TDepth extends number = MaxNesting> = [
108
+ TDepth
109
+ ] extends [never] ? never : IsAny<TRoutes> extends true ? never : {
110
+ [K in keyof TRoutes]-?: RouteLeafOf<K, Exclude<TRoutes[K], undefined>, [...TTrail, K], TDepth>;
111
+ }[keyof TRoutes];
112
+ /**
113
+ * A nested router contributes its own leaves; anything else is a leaf.
114
+ *
115
+ * `{ _routes: ... }` is the runtime's own test for a router (`isRouter`), and a
116
+ * `RouteDef` carries `handler`, `_input` and `_response` but never `_routes`, so a
117
+ * route cannot be mistaken for a router.
118
+ */
119
+ type RouteLeafOf<TName extends PropertyKey, TValue, TTrail extends PropertyKey[], TDepth extends number> = TValue extends {
120
+ _routes: infer TNested;
121
+ } ? RouteLeaves<TNested, TTrail, PrevDepth[TDepth]> : RouteLeaf<TName, TValue, TTrail>;
122
+ /** Every name the tree declares. */
123
+ type LeafNames<TLeaves> = Extract<TLeaves extends RouteLeaf<infer TName, any, any> ? TName : never, PropertyKey>;
124
+ /**
125
+ * The leaves declaring exactly this name.
126
+ *
127
+ * Exactly, not assignably: a record typed `Record<string, RouteDef>` contributes the
128
+ * name `string`, which every literal name is assignable to, and a loose sibling must
129
+ * not make each of its siblings look doubly declared.
130
+ */
131
+ type LeavesNamed<TLeaves, TName extends PropertyKey> = TLeaves extends RouteLeaf<infer TLeafName, any, any> ? [TLeafName] extends [TName] ? [TName] extends [TLeafName] ? TLeaves : never : never : never;
132
+ /** The definition a leaf carries. */
133
+ type LeafDef<TLeaves> = TLeaves extends RouteLeaf<any, infer TDef, any> ? TDef : never;
134
+ /** Where a leaf was found — one distinct trail per place the name is declared. */
135
+ type LeafTrail<TLeaves> = TLeaves extends RouteLeaf<any, any, infer TTrail> ? TTrail : never;
136
+ /**
137
+ * Flatten the leaves into one namespace of name → definition.
138
+ *
139
+ * A name that two branches both declare is left out rather than resolved to one of
140
+ * them: the tree gives it no single meaning, and picking a winner silently would hand
141
+ * a component another route's types. Naming it then fails the `K` constraint on
142
+ * `RouterOutput`/`RouterInput`, on the line that named it. Two places is counted by
143
+ * trail, so one slot holding a union of route definitions stays one route and still
144
+ * infers the union.
145
+ *
146
+ * The result is a fresh mapped type, so no key carries an optional or readonly modifier
147
+ * out of the source record — a route key that turned optional would change what
148
+ * `InferRouteInput` returns for it.
149
+ */
150
+ type FlattenLeaves<TLeaves> = {
151
+ [TName in LeafNames<TLeaves> as UnambiguousName<TLeaves, TName>]: LeafDef<LeavesNamed<TLeaves, TName>>;
152
+ };
153
+ /** The name itself when one place declares it, and nothing when two do. */
154
+ type UnambiguousName<TLeaves, TName extends PropertyKey> = IsUnion<LeafTrail<LeavesNamed<TLeaves, TName>>> extends true ? never : TName;
65
155
  /**
66
156
  * Extract routes from Router type
67
- * Router<TRoutes> has routes in `_routes` property
157
+ *
158
+ * `Router<TRoutes>` holds its routes in `_routes`, and a value in that record is either
159
+ * a route or another router. Nested routers are flattened away, so every route appears
160
+ * here under its own name at one level — the same set of names the server registers.
161
+ *
162
+ * Exported so that the typed client (`Client`, in `./builder`) names a route the same
163
+ * way `RouterOutput` and `RouterInput` do; the two must not diverge, or a route would
164
+ * type in one place and not the other. It stays internal to the package: the
165
+ * `@spfn/core/nextjs` entry point exports a fixed list of type names, and this is not
166
+ * one of them.
68
167
  */
69
168
  type ExtractRoutes<TRouter> = TRouter extends {
70
169
  _routes: infer TRoutes;
71
- } ? TRoutes : TRouter;
170
+ } ? FlattenLeaves<RouteLeaves<TRoutes>> : FlattenLeaves<RouteLeaves<TRouter>>;
72
171
  /**
73
172
  * Extract output type for a specific route from router
74
173
  *
@@ -88,6 +187,23 @@ type ExtractRoutes<TRouter> = TRouter extends {
88
187
  * // Extract item type from paginated response
89
188
  * type Example = RouterOutput<AppRouter, 'listExamples'>['items'][number];
90
189
  * ```
190
+ *
191
+ * A route declared inside a nested router is named the same way. The server registers
192
+ * a nested route under its own key, so `K` is the flat set of route names in the tree
193
+ * and the depth the route was declared at never appears here:
194
+ *
195
+ * ```typescript
196
+ * const appRouter = defineRouter({
197
+ * getRoot,
198
+ * examples: defineRouter({ listExamples, createExample }),
199
+ * });
200
+ *
201
+ * type ListData = RouterOutput<typeof appRouter, 'listExamples'>;
202
+ * ```
203
+ *
204
+ * Two branches declaring the same name make that name ambiguous, and it is left out of
205
+ * `K` rather than resolved to one of them — naming it is a compile error here instead
206
+ * of the wrong route's types reaching a component.
91
207
  */
92
208
  type RouterOutput<TRouter, K extends keyof ExtractRoutes<TRouter>> = InferRouteOutput<ExtractRoutes<TRouter>[K]>;
93
209
  /**
@@ -106,6 +222,9 @@ type RouterOutput<TRouter, K extends keyof ExtractRoutes<TRouter>> = InferRouteO
106
222
  * // ...
107
223
  * }
108
224
  * ```
225
+ *
226
+ * `K` is the flat set of route names, so a route declared inside a nested router is
227
+ * named by its own key here too — see {@link RouterOutput}.
109
228
  */
110
229
  type RouterInput<TRouter, K extends keyof ExtractRoutes<TRouter>> = InferRouteInput<ExtractRoutes<TRouter>[K]>;
111
230
  /**
@@ -247,4 +366,4 @@ interface CallOptions {
247
366
  };
248
367
  }
249
368
 
250
- export type { ApiConfig as A, CallOptions as C, InferRouteInput as I, RequestInterceptor as R, SetCookie as S, ResponseInterceptor as a, InferRouteOutput as b, CookieOptions as c, RouterInput as d, RouterOutput as e, StructuredInput as f };
369
+ export type { ApiConfig as A, CallOptions as C, ExtractRoutes as E, InferRouteInput as I, RequestInterceptor as R, SetCookie as S, ResponseInterceptor as a, InferRouteOutput as b, CookieOptions as c, RouterInput as d, RouterOutput as e, StructuredInput as f };
@@ -229,8 +229,8 @@ export const uploadImage = route.post('/images')
229
229
 
230
230
  ### `@spfn/storage` (recommended)
231
231
 
232
- SPFN ships provider-agnostic object storage — S3-compatible services (S3, R2, MinIO,
233
- Wasabi), Google Cloud Storage, and the local filesystem behind one interface. Prefer it
232
+ SPFN ships provider-agnostic object storage — S3-compatible services (S3, R2, Wasabi,
233
+ SeaweedFS), Google Cloud Storage, and the local filesystem behind one interface. Prefer it
234
234
  over calling a provider SDK directly: every object operation validates its key before it
235
235
  reaches the provider (rejecting `..` segments, leading `/`, backslashes, control
236
236
  characters and URLs), so a key built from user input cannot escape its prefix.
@@ -281,7 +281,7 @@ Three constraints decide whether this is safe:
281
281
  | Constraint | Behaviour |
282
282
  |---|---|
283
283
  | `contentLength` (exact size) | Signed on **both** S3-compatible and GCS. A mismatched size fails. |
284
- | `maxBytes` (upper bound) | Enforced on **GCS only**. A presigned PUT cannot sign a size range, so S3, R2, MinIO and Wasabi **ignore it silently**. |
284
+ | `maxBytes` (upper bound) | Enforced on **GCS only**. A presigned PUT cannot sign a size range, so S3, R2 and Wasabi **ignore it silently**. |
285
285
  | Local filesystem provider | Presigned upload is **not supported** — `getUploadUrl()` throws. Use the direct `upload()` path in local dev. |
286
286
 
287
287
  A client can declare one size and send another, so a server-side check of a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spfn/core",
3
- "version": "0.3.0-beta.10",
3
+ "version": "0.3.0-beta.12",
4
4
  "description": "Full-stack TypeScript backend for Next.js: file-based typed routes, Drizzle entities and repositories, PostgreSQL transactions and a generated end-to-end client, in one fixed vertical slice per feature",
5
5
  "type": "module",
6
6
  "exports": {
@@ -127,7 +127,7 @@ call `generators['route-map'](config)`.
127
127
 
128
128
  ## Built-in: `@spfn/core:route-map`
129
129
 
130
- Parses your router + route files and emits a `routeName → {method, path}` map. This map is
130
+ Loads your router and emits a `routeName → {method, path}` map. This map is
131
131
  what the **RPC proxy** (`createRpcProxy` in `@spfn/core/nextjs/server`) uses to turn
132
132
  `api.getUser.call(...)` into an HTTP request — it needs `method` and `path` without
133
133
  importing server code into the client bundle.
@@ -139,18 +139,91 @@ importing server code into the client bundle.
139
139
  | `name` | `'@spfn/core:route-map'` | yes | — | Generator identifier (literal). |
140
140
  | `routerPath` | `string` | yes | — | Router file, relative to project root. Throws at construction if missing. |
141
141
  | `outputPath` | `string` | no | `'./src/generated/route-map.ts'` | Where the map is written (parent dirs auto-created). |
142
- | `additionalRouteDirs` | `string[]` | no | `[]` | Extra route dirs to watch (each becomes `${dir}/**/*.ts`). |
142
+ | `additionalRouteDirs` | `string[]` | no | `[]` | **Deprecated and ignored** for collection; still added to `watchPatterns` as `${dir}/**/*.ts`. |
143
+
144
+ ### What it reads
145
+
146
+ The router module is loaded with jiti (`appRouter` → `default` → `router`) and walked the
147
+ way `registerRoutes` walks it. How the router is *written* does not matter: a one-line
148
+ `defineRouter({ createUser })`, an aliased key `create: createUser`, and a second
149
+ `defineRouter(` in the same file all produce the same map.
150
+
151
+ - A **nested router** recurses to any depth and its routes land in the map **flat, under
152
+ their own keys** — the parent's key names the grouping, not the route, exactly as the
153
+ server registers them.
154
+ - `method` and `path` come from the `RouteDef`, not from a pattern over the source. A
155
+ route not registered in the router is absent, because there is nothing registering it.
156
+ - **`.packages()` routers are left out**, at every depth. A package publishes its own
157
+ route map and the app merges them (`{ ...routeMap, ...authRouteMap }`). Their *names*
158
+ are still read, because that merge is what a collision turns on — see below.
159
+ - A name that is not a JS identifier (`'get-user'`) is emitted quoted, and `__proto__` is
160
+ emitted as a computed key, so the generated file parses and the map keeps every route as
161
+ an own property.
162
+ - Imports are resolved by Node **and by the `compilerOptions.paths` of the nearest
163
+ `tsconfig.json` that declares them**, searched from the router file's own directory up to
164
+ the project root, `extends` chains included — so the `@/*` alias every spfn app has
165
+ resolves while the router loads, whether it lives in the root config (a Next.js app) or in
166
+ `src/server/tsconfig.json` (what `spfn init` scaffolds, and the config `spfn build`
167
+ compiles the server with). The search stops at the project root: a tsconfig above it
168
+ belongs to whatever contains the project. A config that declares no `paths` is passed over
169
+ rather than ending the search. `baseUrl` is honoured, so the scaffold's `"baseUrl": "../.."`
170
+ resolves against the project root as tsc resolves it. The failure names the specifier that
171
+ did not resolve.
172
+ - `NODE_ENV` is pinned to `production` when the shell left it unset, so the same router
173
+ does not load two ways depending on how codegen was invoked. A value the shell *did* set
174
+ is left alone.
143
175
 
144
- ### What it parses
176
+ ### When it refuses
145
177
 
146
- - **Router file** (`routerPath`): relative `import { ... } from './...'` statements, and the
147
- route names listed inside the **first** `defineRouter({ ... })` call (top-level
148
- identifiers, comments stripped).
149
- - **Route files**: only the exact pattern
150
- `export const <name> = route.<get|post|put|patch|delete>('<path>')…` (single/double/back
151
- quotes). The leading `route.<method>('<path>')` call is what's matched.
152
- - Only routes whose `name` also appears in `defineRouter({...})` end up in the output
153
- (declared-but-unregistered routes are dropped).
178
+ Unlike the old source parser, which dropped what it could not read, the generator throws
179
+ — logged always, and fatal on the `build` trigger and on `spfn codegen run`:
180
+
181
+ - The module will not load. An import that did not resolve names the specifier; anything
182
+ else names the cause and says a route module must be importable without side effects. It
183
+ never falls back to parsing.
184
+ - No router is found under `appRouter`, `default` or `router` (the message lists all three).
185
+ - The router file registers a route **conditionally** (`...(flag ? { admin } : {})`), which
186
+ would put the flag's value into the map and leave `api.admin.call()` 404ing wherever the
187
+ flag differs. This is the same guard `@spfn/core:contract` uses, and it reads **the router
188
+ the generator was pointed at** — the export the loader found it under, plus the routers
189
+ that export mounts by name from the same file, since a nested router's routes land in the
190
+ same flat map. A second `defineRouter(` the app router never reaches is not scanned: it
191
+ registers nothing in production. The source is read with TypeScript's parser, so a JSDoc
192
+ spelling `defineRouter({ ... })` in prose, a comment holding an old router and a brace
193
+ inside a string are not router source. Only a spread whose expression **reads a
194
+ condition** is refused — a ternary, `&&`, `||`, `??`. A call is allowed
195
+ (`...metadataRoutes(config, resource)`): loading yields exactly the routes it returns.
196
+ Past the guard, by construction: a conditional spread inside an imported module, a router
197
+ built by a factory, a condition hoisted to a variable before the spread, and a
198
+ `.packages()` list assembled conditionally. `NODE_ENV` pinning stands in for the guard on
199
+ those, but only when the shell left it unset: `spfn build` pins `production`, so what ships
200
+ is right, while `spfn dev` sets `development` and the watcher regenerates under it. The
201
+ generated header names the `NODE_ENV` the map was written under, so that difference is a
202
+ line in the diff rather than a route that vanishes at build.
203
+ - Two routes reach the same name (the message names both places).
204
+ - An **app route and a package route** reach the same name, where that package publishes a
205
+ route map. Both register at runtime, and the app's proxy merges
206
+ `{ ...routeMap, ...authRouteMap }` — so the package's route wins the name while the
207
+ generated types still describe the app's. Two *package* routers sharing a name is not
208
+ refused: which one wins is the app's merge order, which this generator neither sees nor
209
+ writes. Nor is a collision with a router that publishes **no** map — one declared with
210
+ `defineUnmappedRouter`, whose routes are reached by the URL something was handed rather
211
+ than by name: the ops surface (`createOpsRouter`), `@spfn/monitor`, `@spfn/cms` and the
212
+ tracking routes of `@spfn/notification`. Nothing of theirs merges over the app's map, so
213
+ an app route named after one of their routes overwrites nothing and must not fail the
214
+ build.
215
+ - A route's `method` is not an `HttpMethod`. `registerRoutes` lowercases the method before
216
+ registering, so `method: 'get'` serves fine and would emit a map that fails `tsc`.
217
+
218
+ A **missing router file** is still only a warning: the generator returns and writes nothing.
219
+
220
+ **Warned about and left out, not refused**: a route with no method or path (reachable by
221
+ registering a route before `.handler()` was called), and a router entry that is neither a
222
+ route nor a router. `registerRoutes` warns and skips both, so a map without them still names
223
+ every route the server answers — which is all a refusal would have been protecting.
224
+
225
+ What a *package* router carries is never refused — an entry the app developer cannot fix
226
+ is skipped, exactly as `registerRoutes` skips it.
154
227
 
155
228
  ### Generated output
156
229
 
@@ -199,10 +272,10 @@ generator's own surface.
199
272
 
200
273
  | | `route-map` | `contract` |
201
274
  |---|---|---|
202
- | Reads the router by | parsing the source | **loading the module** and walking `RouteDef`s |
275
+ | Reads the router by | loading the module and walking `RouteDef`s | the same |
203
276
  | Covers | every registered route | only routes carrying `.contract()` |
204
277
  | `runOn` | `watch`, `manual`, `build` | `watch`, `build`, `manual` |
205
- | Can fail a build | no | **yes**, on the `build` trigger only |
278
+ | Can fail a build | **yes** — any refusal, on every trigger the CLI fails on | **yes**; a broken contract only on the `build` trigger |
206
279
 
207
280
  Loading rather than parsing is what makes the contract correct: real routes build schemas from
208
281
  imported values (`EmailSchema`, `FileSchema()`, constants) that a source parser cannot resolve. It
@@ -216,8 +289,10 @@ costs a module import and no infrastructure — `@spfn/auth`'s 43 routes load in
216
289
  - No router export is found under the configured or default names.
217
290
  - Two contracted routes share a name, or a contracted route has no method, path, `since` or
218
291
  `response`.
219
- - `defineRouter({...})` contains a computed spread (`...(flag ? { route } : {})`), which would make
220
- the contract describe whichever way the generator happened to run.
292
+ - The router the generator was pointed at contains a conditional spread
293
+ (`...(flag ? { route } : {})`), which would make the contract describe whichever way the
294
+ generator happened to run. Same guard as `route-map` — see its bullet for exactly what is
295
+ scanned and what is allowed.
221
296
  - On the `build` trigger only: the contract breaks the newest released snapshot.
222
297
 
223
298
  `spfn dev` generates but never refuses — being unable to hold a half-finished route mid-edit would
@@ -232,7 +307,7 @@ Provided by the `spfn` CLI (`@spfn/cli`), not by `@spfn/core` itself:
232
307
  ```bash
233
308
  spfn codegen init # scaffold a .spfnrc.ts
234
309
  spfn codegen list # list resolved generators + their watch patterns (alias: ls)
235
- spfn codegen run # run all generators once (CodegenOrchestrator.generateAll, trigger 'manual')
310
+ spfn codegen run # run all generators once (CodegenOrchestrator.generateAll, trigger 'manual'; a failure exits 1)
236
311
  spfn dev # dev server + codegen in watch mode
237
312
  spfn build # runs codegen once before building (trigger 'build'; a failure exits 1)
238
313
 
@@ -245,6 +320,11 @@ spfn contract list # released snapshots (alias: ls)
245
320
  (The `spfn init` project scaffold writes a `.spfnrc.ts` preconfigured with the route-map
246
321
  generator and adds a `"codegen": "spfn codegen run"` npm script.)
247
322
 
323
+ `spfn codegen run` **exits 1 when a generator refuses**, and stops at the one that refused
324
+ — the run that just failed to regenerate a map must not look like the run that did. `spfn
325
+ dev` is unaffected: watch mode logs the failure and keeps going, because being unable to
326
+ hold a half-finished route mid-edit would make the feature unusable.
327
+
248
328
  ---
249
329
 
250
330
  ## Programmatic usage
@@ -405,11 +485,26 @@ regeneration when it's absent (build/manual passes don't set it).
405
485
  wrong (or no) `method`/`path`. `spfn build` runs codegen first; if you build by other
406
486
  means, run `spfn codegen run` yourself. Treat the file as generated (it's marked
407
487
  `DO NOT EDIT`).
408
- - **route-map only matches a specific route syntax.** Routes must be
409
- `export const x = route.<method>('<literal path>')`. Dynamic paths, methods other than
410
- get/post/put/patch/delete, or routes not listed in `defineRouter({...})` won't appear.
411
488
  - **A route must be in `defineRouter({...})` to be emitted.** Defining `export const foo =
412
- route.get(...)` but not registering `foo` in the router drops it from the map.
489
+ route.get(...)` but not registering `foo` in the router drops it from the map — the
490
+ generator walks the router, so an unregistered route does not exist as far as it is
491
+ concerned. How the registration is *spelled* no longer matters.
492
+ - **route-map loads your router, so route modules must import cleanly.** Reading a
493
+ required environment value at module scope now fails `spfn build` and `spfn codegen run`,
494
+ not just `spfn dev`. Move that read inside the handler. Top-level `await` in a route or
495
+ router module does not load either — jiti transforms to CJS — the same limit
496
+ `@spfn/core:contract` has always had.
497
+ - **A path alias resolves only if a tsconfig between the router and the project root maps
498
+ it.** Both loading generators read `compilerOptions.paths` from the nearest such
499
+ `tsconfig.json` (following `extends`) and hand it to jiti, so `@/server/routes/users`
500
+ resolves from the root config or from `src/server/tsconfig.json`. A mapping that lives
501
+ *above* the project root — a monorepo's solution-style config — is not read; a `paths`
502
+ entry whose `*` is not a trailing `/*` is skipped (jiti matches a prefix, not a template);
503
+ and a `baseUrl` with no `paths` resolves nothing (an alias map cannot say "try this
504
+ directory for every bare specifier"). Several targets for one pattern are supported: the
505
+ first that exists on disk is used, as tsc and tsup use it. Each skip is warned about, an
506
+ `extends` target TypeScript could not read is warned about instead of silently dropping
507
+ its `paths`, and an import that then fails to resolve names the specifier.
413
508
  - **Custom generators must default-export a factory.** `createGeneratorsFromConfig` calls
414
509
  `module.default()` (a zero-arg function) for `{ path }` entries. Exporting the Generator
415
510
  object directly, or a factory that needs args, won't load.
@@ -505,12 +600,12 @@ interface RouteMapGeneratorConfig
505
600
  name: '@spfn/core:route-map';
506
601
  routerPath: string;
507
602
  outputPath?: string; // default './src/generated/route-map.ts'
508
- additionalRouteDirs?: string[];
603
+ additionalRouteDirs?: string[]; // deprecated: watched, never collected from
509
604
  }
510
605
  ```
511
606
 
512
607
  ## Related
513
608
 
514
- - [@spfn/core/route](../route/README.md) — `route.*` / `defineRouter` (the source parsed)
609
+ - [@spfn/core/route](../route/README.md) — `route.*` / `defineRouter` (the router loaded and walked)
515
610
  - [@spfn/core/nextjs](../nextjs/README.md) — RPC proxy (`createRpcProxy`) that consumes `routeMap`
516
611
  - [@spfn/core/env](../env/README.md) — environment configuration
@@ -285,7 +285,13 @@ JSON body and upload the file through its own uncontracted route.
285
285
  generator refuses a `defineRouter({...})` containing a computed spread (`...(flag ? {x} : {})`);
286
286
  a spread of a plain identifier (`...baseRoutes`) is fine.
287
287
  - **`NODE_ENV` is pinned when unset.** The generator sets it to `production` before loading, so a
288
- schema that reads the environment cannot make the contract depend on the shell it ran in.
288
+ schema that reads the environment cannot make the contract depend on the shell it ran in. The pin
289
+ is process-wide and shared with `@spfn/core:route-map`, so both generators in one run read the
290
+ same environment whichever `.spfnrc.ts` lists first.
291
+ - **Path aliases come from the project root's `tsconfig.json`.** `compilerOptions.paths` is read
292
+ (`extends` followed) and handed to jiti, so a router importing `@/server/routes/users` loads. A
293
+ mapping held in any other tsconfig is not read, and the failure names the specifier that did not
294
+ resolve.
289
295
  - **A failed build still rewrites `current.json`.** That is deliberate — the regenerated file is
290
296
  what the gate compared, so `git diff` shows exactly what broke.
291
297
  - **Constraint changes count as type changes.** Narrowing `maxLength`, an `enum` or a `format` is
@@ -122,8 +122,28 @@ const created = await api.createUser.call({ body: { name: 'A' } }); // POST /api
122
122
  function createApi<TRouter extends Router<any>>(config?: ApiConfig): Client<TRouter>;
123
123
  ```
124
124
 
125
- Returns a `Proxy`: every property access (`api.getUser`, `api.foo.bar` for nested routers)
126
- yields a `RouteClient` builder. Calls are made via `.call(input)`.
125
+ Returns a `Proxy`: every property access (`api.getUser`) yields a `RouteClient` builder.
126
+ Calls are made via `.call(input)`.
127
+
128
+ The client is one **flat** namespace, holding exactly the names the server registers. A
129
+ route declared inside a nested `defineRouter` is reached by its own key — `registerRoutes`
130
+ mounts it under that key, the proxy resolves that name against the route map, and
131
+ `RouterOutput` takes the same name:
132
+
133
+ ```typescript
134
+ const appRouter = defineRouter({
135
+ getRoot,
136
+ users: defineRouter({ getUser }),
137
+ });
138
+
139
+ await api.getUser.call({ params: { id: '123' } }); // ✅ GET /api/rpc/getUser
140
+ await api.users.getUser.call({ ... }); // ❌ compile error — `users` is a group
141
+ ```
142
+
143
+ There is no dot notation: a property access never yields another proxy, so a group key
144
+ names nothing on either side. A name two branches both declare is left out of the client
145
+ rather than resolved to one of them, and `.packages()` routes stay out on purpose — call
146
+ them through the package's own client (`authApi`, `cmsApi`).
127
147
 
128
148
  ### Structured input
129
149
 
@@ -452,6 +472,27 @@ type BodyInput = RouterInput<AppRouter, 'createExample'>['body'];
452
472
  `InferRouteOutput` take a single `RouteDef`. All inference is compile-time only (zero runtime
453
473
  cost).
454
474
 
475
+ The route-name key is the **flat** set of names in the router tree. A route declared inside a
476
+ nested `defineRouter` is named by its own key here, exactly as the server registers it — the
477
+ group key is not part of the name and does not appear:
478
+
479
+ ```typescript
480
+ const appRouter = defineRouter({
481
+ getRoot,
482
+ examples: defineRouter({ listExamples, createExample }),
483
+ });
484
+
485
+ type ListData = RouterOutput<typeof appRouter, 'listExamples'>; // not 'examples.listExamples'
486
+ ```
487
+
488
+ A name two branches both declare is left out of that set rather than resolved to one of them,
489
+ so naming it is a compile error where it is named. Routes mounted with `.packages()` stay out
490
+ of the set on purpose — call them through the package's own client.
491
+
492
+ `Client<AppRouter>` — what `createApi` returns — is built from the same flat set, so a route
493
+ is named identically whether you call it (`api.listExamples`) or take its type
494
+ (`RouterOutput<AppRouter, 'listExamples'>`).
495
+
455
496
  ---
456
497
 
457
498
  ## Error handling
@@ -435,6 +435,12 @@ export const appRouter = defineRouter({ ...userRoutes, ...postRoutes });
435
435
  export type AppRouter = typeof appRouter;
436
436
  ```
437
437
 
438
+ Nesting groups the source, not the name: a nested route is registered — and typed, through
439
+ `RouterOutput`/`RouterInput` and the `createApi` client — under its own key (`get`, `list`),
440
+ never under `users.get`. `api.get.call({...})` is the call; `api.users` is not a route and
441
+ does not compile. Two branches declaring one name is therefore a collision, which the contract
442
+ collector refuses and the client types leave out of the namespace.
443
+
438
444
  ### `.packages([...])` — mount package routers
439
445
 
440
446
  Attach routers from SPFN packages (`@spfn/auth`, `@spfn/cms`, …). Package routes **are**
@@ -452,6 +458,20 @@ export const appRouter = defineRouter({ getRoot, getStatus })
452
458
 
453
459
  `.packages()` also flattens any nested package routers the given routers themselves declared.
454
460
 
461
+ **A name a package route already uses is not available to an app route.** Both register, at
462
+ their own paths, and the app's proxy merges the two maps as `{ ...routeMap, ...authRouteMap }`
463
+ — so the package entry wins the name while the generated types still describe the app's
464
+ route. `spfn codegen run` refuses rather than generating that, naming both sides.
465
+
466
+ A package that publishes **no** route map is the exception, and declares itself one with
467
+ `defineUnmappedRouter`: its routes are reached by the URL something was handed rather than by
468
+ name, so nothing merges over the app's map and an app route may share a name with one of them
469
+ freely. `createOpsRouter` builds the ops surface that way — `spfn ops` invokes a command over
470
+ its URL — as do `@spfn/monitor`, `@spfn/cms` and the tracking routes of `@spfn/notification`.
471
+ `defineRouter` is the default because the obligation runs the other way: a package that does
472
+ publish a map must not declare itself unmapped, or a collision that really would overwrite an
473
+ app route generates silently.
474
+
455
475
  ### `.use([...])` — router-level global middleware
456
476
 
457
477
  Named middleware applied to **every** route in the router (and package routers), unless a