@spfn/core 0.3.0-beta.28 → 0.3.0-beta.29

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.
@@ -52,14 +52,16 @@ Types:
52
52
  - `OrchestratorOptions`
53
53
  - `RouteMapGeneratorConfig` (config shape for the built-in route-map generator)
54
54
  - `ContractGeneratorConfig` (config shape for the built-in contract generator)
55
- - `ContractGeneratorError`, `ConditionalRegistrationError`, `assertUnconditionalRegistration`
55
+ - `StatesGeneratorConfig` (config shape for the built-in states generator)
56
+ - `ContractGeneratorError`, `StatesGeneratorError`, `ConditionalRegistrationError`,
57
+ `assertUnconditionalRegistration`
56
58
  - `RouteContractMapping`, `ResourceRoutes`, `ClientGenerationOptions`, `GenerationStats`
57
59
  (legacy client-generation types — exported but not used by any shipped generator)
58
60
 
59
61
  > **Renamed: `defineCodegenConfig` → `defineConfig`.** The old name does **not** exist.
60
62
  > Older docs also show an `api-client` / `createApi`-emitting generator and
61
63
  > `codegen.config.ts` — those are **removed**. The built-in generators today are
62
- > `@spfn/core:route-map` and `@spfn/core:contract`, configured in `.spfnrc.ts`. Do not
64
+ > `@spfn/core:route-map`, `@spfn/core:contract` and `@spfn/core:states`, configured in `.spfnrc.ts`. Do not
63
65
  > import `defineCodegenConfig` or configure a `name: 'api-client'` generator.
64
66
 
65
67
  ---
@@ -320,6 +322,83 @@ make the feature unusable.
320
322
 
321
323
  ---
322
324
 
325
+ ## Built-in: `@spfn/core:states`
326
+
327
+ Loads your router and emits the ids and the manifest of every state it mounts —
328
+ `src/generated/states.ts`, a file with no import that a browser root and a server component
329
+ can both execute. `createStateClient<typeof states>` is a type import, erased at build, and
330
+ importing the router itself is not an option (each state's `read()` closes over repositories
331
+ and the database), so this file is the only runtime record of which states the app declared.
332
+ Compare its `stateManifest` with the `manifest` of `GET /state`: an id the server's manifest
333
+ lacks is a deploy skew between the Next.js server and the API, reported at the root instead
334
+ of surfacing as `state.<id>` being `undefined` inside a screen. See
335
+ [`../state/README.md`](../state/README.md) → "Declared ids at runtime".
336
+
337
+ ### `StatesGeneratorConfig`
338
+
339
+ | Field | Type | Required | Default | Description |
340
+ |-------|------|----------|---------|-------------|
341
+ | `name` | `'@spfn/core:states'` | yes | — | Generator identifier (literal). |
342
+ | `routerPath` | `string` | yes | — | Router file, relative to project root — the one `route-map` reads. Throws at construction if missing. |
343
+ | `outputPath` | `string` | no | `'./src/generated/states.ts'` | Where the file is written (parent dirs auto-created). |
344
+
345
+ ### What it reads
346
+
347
+ The router module is loaded exactly as `route-map` loads it (`appRouter` → `default` →
348
+ `router`, tsconfig aliases, `NODE_ENV` pinned), then walked:
349
+
350
+ - **The marker.** `defineStateRouter` sets a non-enumerable `Router._stateManifest` on the
351
+ router it derives — the very object the snapshot route answers as `manifest` — so the ids
352
+ written here and the manifest the server serves come from one value. Each state router
353
+ contributes its entries in declaration order; `key` lists the key fields in schema order.
354
+ - **Mounted routers at every depth.** Every nested router under `routes` and every entry of
355
+ `_packageRouters`, recursively — app `routes` first, then `_packageRouters` in mount order.
356
+ A router reached by two trails (`.packages()` copies a mounted router's own package routers
357
+ up) is collected once: the walk is keyed on the router object.
358
+ - **Chained routers.** `.use()`, `.packages()` and `.contractVersion()` carry the marker onto
359
+ the router they rebuild, so `states.router.use([audit])` is still found.
360
+ - An id that is not a JS identifier is emitted quoted, through the same key rule `route-map`
361
+ uses.
362
+
363
+ ### When it refuses
364
+
365
+ Throws `StatesGeneratorError` (or the loader's and the guard's errors) — logged on `watch`,
366
+ fatal on `build` and on `spfn codegen run`:
367
+
368
+ - The module will not load, or no router is found under the three export names.
369
+ - The router registers **conditionally** — same `ConditionalRegistrationError` guard as
370
+ `route-map`.
371
+ - **Two state routers declare the same id.** The message names both trails.
372
+ - **No state router is mounted** anywhere in the walk. The generator is configured but nothing
373
+ it reads mounts a `defineStateRouter(...).router`; the message names the router path. Mount
374
+ the state router or drop the generator.
375
+
376
+ A mounted state router that declares **no state** — `defineStateRouter({})` — is not refused: it
377
+ is an app that has declared none yet. The file is written with `stateIds = [] as const` and an
378
+ empty `stateManifest`, which matches the empty manifest the server serves.
379
+
380
+ A **missing router file** is only a warning: the generator returns and writes nothing.
381
+
382
+ ### Generated output
383
+
384
+ ```typescript
385
+ // src/generated/states.ts — DO NOT EDIT (header names the NODE_ENV it was written under)
386
+ export const stateIds = ['consent', 'member', 'workspaces'] as const;
387
+
388
+ export type StateId = (typeof stateIds)[number];
389
+
390
+ export const stateManifest: Readonly<Record<StateId, { readonly key: readonly string[]; readonly write: boolean }>> = {
391
+ consent: { key: [], write: true },
392
+ member: { key: ['workspaceId'], write: false },
393
+ workspaces: { key: [], write: false },
394
+ };
395
+ ```
396
+
397
+ `watchPatterns` are `[routerPath, 'src/server/states.ts', 'src/server/states/**/*.ts',
398
+ 'src/server/routes/**/*.ts']` and `runOn` is `['watch', 'manual', 'build']`.
399
+
400
+ ---
401
+
323
402
  ## CLI
324
403
 
325
404
  Provided by the `spfn` CLI (`@spfn/cli`), not by `@spfn/core` itself:
@@ -625,10 +704,18 @@ interface RouteMapGeneratorConfig
625
704
  outputPath?: string; // default './src/generated/route-map.ts'
626
705
  additionalRouteDirs?: string[]; // deprecated: watched, never collected from
627
706
  }
707
+
708
+ interface StatesGeneratorConfig
709
+ {
710
+ name: '@spfn/core:states';
711
+ routerPath: string;
712
+ outputPath?: string; // default './src/generated/states.ts'
713
+ }
628
714
  ```
629
715
 
630
716
  ## Related
631
717
 
632
718
  - [@spfn/core/route](../route/README.md) — `route.*` / `defineRouter` (the router loaded and walked)
633
719
  - [@spfn/core/nextjs](../nextjs/README.md) — RPC proxy (`createRpcProxy`) that consumes `routeMap`
720
+ - [@spfn/core/state](../state/README.md) — `defineStateRouter`, whose manifest `@spfn/core:states` writes
634
721
  - [@spfn/core/env](../env/README.md) — environment configuration
@@ -196,6 +196,11 @@ With the default `path` `/state`:
196
196
 
197
197
  `data` is present exactly when `.data()` is declared, in every response and frame.
198
198
 
199
+ The derived router also carries the manifest as a non-enumerable `Router._stateManifest` — the
200
+ object `getStates` answers as `manifest`, carried through `.use()` / `.packages()` /
201
+ `.contractVersion()` — which `@spfn/core:states` reads to write the declared ids
202
+ ([Declared ids at runtime](#declared-ids-at-runtime)).
203
+
199
204
  Route names are `get<Id>State` / `put<Id>State` with the id's first letter upper-cased, so an
200
205
  id must match `^[a-z][a-zA-Z0-9]*$` — `defineState` refuses another.
201
206
 
@@ -498,6 +503,68 @@ The manifest may also come from `initial` — the snapshot the server rendered w
498
503
  client knows it from construction: nothing above waits, a refused `subscribe` goes to `onError` at
499
504
  once, and the first `subscribe` opens the stream without reading the snapshot first.
500
505
 
506
+ ### Declared ids at runtime
507
+
508
+ The manifest says what the **server** serves; nothing the browser executes says what the **app**
509
+ declared — `typeof states` is erased at build, and importing the router would pull every state's
510
+ `read()`, with its repositories and database, into the layout. The `@spfn/core:states` generator
511
+ writes those ids and their manifest into a file with no import:
512
+
513
+ ```typescript
514
+ // .spfnrc.ts
515
+ import { defineConfig, defineGenerator } from '@spfn/core/codegen';
516
+ import type { StatesGeneratorConfig } from '@spfn/core/codegen';
517
+
518
+ export default defineConfig({
519
+ generators: [
520
+ defineGenerator<StatesGeneratorConfig>({
521
+ name: '@spfn/core:states',
522
+ routerPath: './src/server/router.ts', // the router route-map reads
523
+ outputPath: './src/generated/states.ts', // the default
524
+ }),
525
+ ],
526
+ });
527
+ ```
528
+
529
+ An app lists its `@spfn/core:route-map` generator in the same `generators` array.
530
+
531
+ ```typescript
532
+ // src/generated/states.ts — DO NOT EDIT
533
+ export const stateIds = ['consent', 'member', 'workspaces'] as const;
534
+ export type StateId = (typeof stateIds)[number];
535
+ export const stateManifest: Readonly<Record<StateId, { readonly key: readonly string[]; readonly write: boolean }>> = {
536
+ consent: { key: [], write: true },
537
+ member: { key: ['workspaceId'], write: false },
538
+ workspaces: { key: [], write: false },
539
+ };
540
+ ```
541
+
542
+ An app root compares it with the snapshot it read before rendering, so a deploy skew between the
543
+ Next.js server and the API is reported there instead of as `state.<id>` being `undefined` in a screen:
544
+
545
+ ```tsx
546
+ const snapshot = await api.getStates.call({ query });
547
+ const skewed = stateIds.filter(id => JSON.stringify(snapshot.manifest[id]) !== JSON.stringify(stateManifest[id]));
548
+
549
+ if (skewed.length > 0)
550
+ {
551
+ return <DeploySkew states={skewed} />;
552
+ }
553
+ ```
554
+
555
+ That goes from declared to served. The reverse — an id the server serves that this build lacks —
556
+ indexes `stateManifest` with a `string`, which does not compile under `strict`.
557
+ `Array.prototype.includes` is not a type guard, so declare one:
558
+
559
+ ```typescript
560
+ const isStateId = (id: string): id is StateId => (stateIds as readonly string[]).includes(id);
561
+
562
+ const unknownToThisBuild = Object.keys(snapshot.manifest).filter(id => !isStateId(id));
563
+ ```
564
+
565
+ The generator refuses a router that mounts no state router, and two state routers declaring one
566
+ id; see [`../codegen/README.md`](../codegen/README.md) → "Built-in: `@spfn/core:states`".
567
+
501
568
  ### `createStateClient<typeof states>({ sse, api?, onError?, retryDelay?, maxRetries?, initial? })`
502
569
 
503
570
  `initial: { snapshot, query? }` is the snapshot a server render read — `api.getStates`'s answer —