@spfn/core 0.3.0-beta.4 → 0.3.0-beta.6

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.
Files changed (45) hide show
  1. package/README.md +183 -4
  2. package/dist/authz/index.js +1 -381
  3. package/dist/authz/index.js.map +1 -1
  4. package/dist/db/index.d.ts +173 -27
  5. package/dist/db/index.js +192 -57
  6. package/dist/db/index.js.map +1 -1
  7. package/dist/env/loader.js +24 -1
  8. package/dist/env/loader.js.map +1 -1
  9. package/dist/errors/index.js +1 -381
  10. package/dist/errors/index.js.map +1 -1
  11. package/dist/logger/index.js +0 -12
  12. package/dist/logger/index.js.map +1 -1
  13. package/dist/middleware/index.js +6 -387
  14. package/dist/middleware/index.js.map +1 -1
  15. package/dist/nextjs/index.d.ts +18 -1
  16. package/dist/nextjs/index.js +40 -1
  17. package/dist/nextjs/index.js.map +1 -1
  18. package/dist/nextjs/server.d.ts +34 -1
  19. package/dist/nextjs/server.js +14 -0
  20. package/dist/nextjs/server.js.map +1 -1
  21. package/dist/ops/index.d.ts +61 -6
  22. package/dist/ops/index.js +330 -30
  23. package/dist/ops/index.js.map +1 -1
  24. package/dist/server/index.js +24 -1
  25. package/dist/server/index.js.map +1 -1
  26. package/docs/file-upload.md +195 -333
  27. package/package.json +6 -5
  28. package/src/cache/README.md +330 -0
  29. package/src/codegen/README.md +516 -0
  30. package/src/config/README.md +326 -0
  31. package/src/contract/README.md +326 -0
  32. package/src/db/README.md +589 -0
  33. package/src/db/manager/README.md +500 -0
  34. package/src/db/schema/README.md +344 -0
  35. package/src/db/transaction/README.md +822 -0
  36. package/src/env/README.md +651 -0
  37. package/src/errors/README.md +429 -0
  38. package/src/event/README.md +736 -0
  39. package/src/job/README.md +514 -0
  40. package/src/logger/README.md +321 -0
  41. package/src/middleware/README.md +634 -0
  42. package/src/nextjs/README.md +608 -0
  43. package/src/route/README.md +738 -0
  44. package/src/security/README.md +100 -0
  45. package/src/server/README.md +704 -0
@@ -0,0 +1,326 @@
1
+ # @spfn/core/config — @spfn/core's own validated env config
2
+
3
+ A **prebuilt env registry** for the variables `@spfn/core` itself consumes (database,
4
+ cache, server, fetch, Next.js integration). It is a thin application of `@spfn/core/env`:
5
+ it owns one fixed schema (`coreEnvSchema`), builds a registry from it, and exports the
6
+ validated proxy. It does **not** add new env machinery — for the schema DSL, parsers,
7
+ registry semantics, and file loading, see [@spfn/core/env](../env/README.md).
8
+
9
+ ## Import paths
10
+
11
+ ```typescript
12
+ // The validated proxy + the schema + the registry — all from one entry point.
13
+ import { env, envSchema, registry } from '@spfn/core/config';
14
+ ```
15
+
16
+ There is a single entry point (`@spfn/core/config`). It re-exports nothing from
17
+ `@spfn/core/env`; if you need `defineEnvSchema`, parsers, `loadEnv`, type guards, etc.,
18
+ import them from `@spfn/core/env` / `@spfn/core/env/loader` directly.
19
+
20
+ ---
21
+
22
+ ## Public API (complete)
23
+
24
+ The module exports exactly three values:
25
+
26
+ | Export | Type | What it is |
27
+ |--------|------|------------|
28
+ | `env` | `InferEnvType<typeof coreEnvSchema>` (a **Proxy**) | The validated config object. Read variables off it. |
29
+ | `envSchema` | `EnvSchemaCollection` | The schema object (`coreEnvSchema`), re-exported under the name `envSchema`. Read `.description` / `.default` / `.examples` per key. |
30
+ | `registry` | `EnvRegistry` | The `EnvRegistry` instance backing `env` (`createEnvRegistry(coreEnvSchema)`). Exposes `.validate()`, `.validateAll()`, `.reset()`, `.register(...)`. |
31
+
32
+ `env` is literally `registry.validate()` — they are the same proxy.
33
+
34
+ > **No** `getEnv` / `loadConfig` / `config()` / `getConfig` function exists, and the schema
35
+ > is **not** something you pass in — it is fixed. To define your *own* app's variables,
36
+ > build a separate schema/registry with `@spfn/core/env`; do not try to extend
37
+ > `coreEnvSchema` by mutation.
38
+
39
+ ---
40
+
41
+ ## Quick Start
42
+
43
+ ```typescript
44
+ import { env } from '@spfn/core/config';
45
+
46
+ const poolMax: number = env.DB_POOL_MAX; // number (default 10)
47
+ const logLevel = env.SPFN_LOG_LEVEL; // 'debug' | 'info' | 'warn' | 'error' | 'fatal'
48
+ const apiUrl: string = env.SPFN_API_URL; // string (required)
49
+ const appUrl = env.SPFN_APP_URL; // string | undefined (optional)
50
+ ```
51
+
52
+ In the **SPFN server** entry point, call `loadEnv()` (from `@spfn/core/env/loader`)
53
+ **before** reading any value, so `process.env` is populated from the `.env*` files:
54
+
55
+ ```typescript
56
+ import { loadEnv } from '@spfn/core/env/loader';
57
+ import { env } from '@spfn/core/config';
58
+
59
+ loadEnv(); // populate process.env (server reads .env.server too)
60
+ console.log(env.PORT); // validated lazily, here
61
+ ```
62
+
63
+ In **Next.js** code, do **not** call `loadEnv()` — Next.js loads `.env*` itself. Just read
64
+ from `env`.
65
+
66
+ ---
67
+
68
+ ## Validation is lazy (read this before relying on startup checks)
69
+
70
+ `env` is the Proxy returned by `registry.validate()`. Importing `@spfn/core/config` does
71
+ **not** read or validate any variable value. Each variable is read from `process.env`,
72
+ defaulted, and run through its `validator` **at the moment you access the property**.
73
+
74
+ ```typescript
75
+ import { env } from '@spfn/core/config'; // imports the proxy — validates nothing yet
76
+ import { loadEnv } from '@spfn/core/env/loader';
77
+
78
+ loadEnv();
79
+ env.SPFN_API_URL; // read + validated HERE; throws if missing (required) or invalid
80
+ ```
81
+
82
+ Consequences:
83
+
84
+ - A missing **required** variable (`SPFN_API_URL`, `NEXT_PUBLIC_SPFN_API_URL`) does not
85
+ throw on import — it throws on first access (`Error('Environment validation failed')`).
86
+ - You may import `env` before `loadEnv()` runs; values are picked up lazily.
87
+ - For an **eager** all-at-once check (CI / startup gate), call `registry.validateAll()`
88
+ (returns `{ errors, warnings }`) or pass `registry` to `validateAllEnv([...])` from
89
+ `@spfn/core/env`. `SKIP_ENV_VALIDATION=true` skips only the lazy `required` check, not
90
+ these eager checks.
91
+
92
+ See [@spfn/core/env](../env/README.md) for the full registry / proxy / `SKIP_ENV_VALIDATION`
93
+ semantics — this module inherits all of it unchanged.
94
+
95
+ ---
96
+
97
+ ## Reading schema metadata
98
+
99
+ ```typescript
100
+ import { envSchema } from '@spfn/core/config';
101
+
102
+ envSchema.DB_POOL_MAX.description; // 'Maximum number of database connections in pool'
103
+ envSchema.DB_POOL_MAX.default; // 10
104
+ envSchema.DB_POOL_MAX.examples; // [10, 20, 50]
105
+ ```
106
+
107
+ `envSchema` is the schema object, not the values. Use `env` for actual (validated) values.
108
+
109
+ ---
110
+
111
+ ## Environment variables (the fixed `coreEnvSchema`)
112
+
113
+ All variables below are exactly what `coreEnvSchema` declares. URL variables marked with a
114
+ validator **throw on access** if the value is present but malformed.
115
+
116
+ ### Core
117
+
118
+ | Variable | Type | Default | Notes |
119
+ |----------|------|---------|-------|
120
+ | `NODE_ENV` | `'local' \| 'development' \| 'staging' \| 'production' \| 'test'` | `'local'` | `nextjs: true` |
121
+
122
+ ### Database — connection URLs (all optional, all `sensitive`, validated as postgres URLs)
123
+
124
+ | Variable | Type | Required | Validator |
125
+ |----------|------|----------|-----------|
126
+ | `DATABASE_URL` | `string` | No | `parsePostgresUrl` |
127
+ | `DATABASE_WRITE_URL` | `string` | No | `parsePostgresUrl` |
128
+ | `DATABASE_READ_URL` | `string` | No | `parsePostgresUrl` |
129
+
130
+ ### Database — connection pool
131
+
132
+ | Variable | Type | Default |
133
+ |----------|------|---------|
134
+ | `DB_POOL_MAX` | `number` | `10` |
135
+ | `DB_POOL_IDLE_TIMEOUT` | `number` | `30` (seconds) |
136
+
137
+ ### Database — retry
138
+
139
+ | Variable | Type | Default |
140
+ |----------|------|---------|
141
+ | `DB_RETRY_MAX` | `number` | `3` |
142
+ | `DB_RETRY_INITIAL_DELAY` | `number` | `100` (ms) |
143
+ | `DB_RETRY_MAX_DELAY` | `number` | `10000` (ms) |
144
+ | `DB_RETRY_FACTOR` | `number` | `2` |
145
+
146
+ ### Database — health check
147
+
148
+ | Variable | Type | Default |
149
+ |----------|------|---------|
150
+ | `DB_HEALTH_CHECK_ENABLED` | `boolean` | `true` |
151
+ | `DB_HEALTH_CHECK_INTERVAL` | `number` | `60000` (ms) |
152
+ | `DB_HEALTH_CHECK_RECONNECT` | `boolean` | `true` |
153
+ | `DB_HEALTH_CHECK_MAX_RETRIES` | `number` | `3` |
154
+ | `DB_HEALTH_CHECK_RETRY_INTERVAL` | `number` | `5000` (ms) |
155
+
156
+ ### Database — monitoring
157
+
158
+ | Variable | Type | Default |
159
+ |----------|------|---------|
160
+ | `DB_MONITORING_ENABLED` | `boolean` | `false` |
161
+ | `DB_MONITORING_SLOW_THRESHOLD` | `number` | `1000` (ms) |
162
+ | `DB_MONITORING_LOG_QUERIES` | `boolean` | `false` |
163
+
164
+ ### Database — transaction / development
165
+
166
+ | Variable | Type | Default |
167
+ |----------|------|---------|
168
+ | `TRANSACTION_TIMEOUT` | `number` | `30000` (ms) |
169
+ | `DB_DEBUG_TRACE` | `boolean` | `false` |
170
+
171
+ ### Drizzle ORM
172
+
173
+ | Variable | Type | Default |
174
+ |----------|------|---------|
175
+ | `DRIZZLE_SCHEMA_PATH` | `string` | `'./src/server/entities/config.ts'` |
176
+ | `DRIZZLE_OUT_DIR` | `string` | `'./drizzle'` |
177
+
178
+ ### Logger
179
+
180
+ | Variable | Type | Default |
181
+ |----------|------|---------|
182
+ | `SPFN_LOG_LEVEL` | `'debug' \| 'info' \| 'warn' \| 'error' \| 'fatal'` | `'info'` |
183
+
184
+ ### Cache (Redis/Valkey) — all optional
185
+
186
+ | Variable | Type | `sensitive` | Validator |
187
+ |----------|------|-------------|-----------|
188
+ | `CACHE_URL` | `string` | yes | `parseRedisUrl` |
189
+ | `CACHE_WRITE_URL` | `string` | yes | `parseRedisUrl` |
190
+ | `CACHE_READ_URL` | `string` | yes | `parseRedisUrl` |
191
+ | `CACHE_SENTINEL_HOSTS` | `string` | no | — (comma-separated hosts) |
192
+ | `CACHE_CLUSTER_NODES` | `string` | no | — (comma-separated nodes) |
193
+ | `CACHE_MASTER_NAME` | `string` | no | — (Sentinel master name) |
194
+ | `CACHE_PASSWORD` | `string` | yes | — |
195
+ | `CACHE_TLS_REJECT_UNAUTHORIZED` | `boolean` | — | default `true` |
196
+
197
+ > These four patterns are mutually exclusive at the connection layer (single / master-replica
198
+ > / sentinel / cluster) — the schema does not enforce that; it only validates individual values.
199
+
200
+ ### Server
201
+
202
+ | Variable | Type | Default |
203
+ |----------|------|---------|
204
+ | `PORT` | `number` | `4000` |
205
+ | `HOST` | `string` | `'localhost'` |
206
+ | `SERVER_TIMEOUT` | `number` | `120000` (ms) |
207
+ | `SERVER_KEEPALIVE_TIMEOUT` | `number` | `65000` (ms) |
208
+ | `SERVER_HEADERS_TIMEOUT` | `number` | `60000` (ms) |
209
+ | `SHUTDOWN_TIMEOUT` | `number` | `280000` (ms) — must be < k8s `terminationGracePeriodSeconds` minus preStop sleep |
210
+
211
+ ### Fetch (outbound HTTP via Node `undici`)
212
+
213
+ | Variable | Type | Default |
214
+ |----------|------|---------|
215
+ | `FETCH_CONNECT_TIMEOUT` | `number` | `10000` (ms) |
216
+ | `FETCH_HEADERS_TIMEOUT` | `number` | `300000` (ms) |
217
+ | `FETCH_BODY_TIMEOUT` | `number` | `300000` (ms) |
218
+
219
+ ### Next.js integration
220
+
221
+ | Variable | Type | Required | Validator | Notes |
222
+ |----------|------|----------|-----------|-------|
223
+ | `SPFN_API_URL` | `string` (`envUrl`) | **Yes** | URL | `nextjs: true`; Next.js → backend |
224
+ | `NEXT_PUBLIC_SPFN_API_URL` | `string` (`envUrl`) | **Yes** | URL | `nextjs: true`; client-exposed |
225
+ | `SPFN_APP_URL` | `string` (`envUrl`) | No | URL | `nextjs: true`; SPFN server → Next.js |
226
+ | `RPC_PROXY_TIMEOUT` | `number` | No (default `120000`) | — | `nextjs: true`; keep < `FETCH_HEADERS_TIMEOUT` |
227
+
228
+ > `SPFN_API_URL` and `NEXT_PUBLIC_SPFN_API_URL` are both `envUrl` and both **required** —
229
+ > a missing or non-URL value throws on first access of that property.
230
+
231
+ ---
232
+
233
+ ## Pitfalls & anti-patterns
234
+
235
+ - **Don't expect validation on import.** Importing `@spfn/core/config` validates nothing;
236
+ the proxy validates each variable on property access. A missing `SPFN_API_URL` /
237
+ `NEXT_PUBLIC_SPFN_API_URL` throws when you *read* it, not when you import `env`. For a
238
+ startup gate, call `registry.validateAll()` explicitly.
239
+ - **`SPFN_API_URL` and `NEXT_PUBLIC_SPFN_API_URL` are both required and both URLs.** Setting
240
+ only one of them will throw on access of the other. They are validated as URLs (`envUrl`),
241
+ so a non-URL string fails too.
242
+ - **Don't call `loadEnv()` inside Next.js.** Next.js already loads `.env*`. Calling it there
243
+ (with `server: true`) would pull `.env.server` secrets into the Next.js process. In the
244
+ **SPFN server** entry point you *do* call `loadEnv()` before touching `env`.
245
+ - **The schema is fixed — don't try to add app variables here.** `coreEnvSchema` covers only
246
+ `@spfn/core`'s own variables. For your application's variables, define a separate schema
247
+ and registry via `@spfn/core/env` (`defineEnvSchema` + `createEnvRegistry`); validate both
248
+ registries together with `validateAllEnv([coreRegistry, appRegistry])`.
249
+ - **`env` vs `envSchema`.** `env.DB_POOL_MAX` is the resolved value (`number`);
250
+ `envSchema.DB_POOL_MAX` is the schema entry (read `.default` / `.description` / `.examples`).
251
+ Don't read values off `envSchema` or metadata off `env`.
252
+ - **`CACHE_*` / `DATABASE_*` URLs are validated.** A present-but-malformed value throws via
253
+ `parseRedisUrl` / `parsePostgresUrl` on access — these are not free-form strings.
254
+ - **No config-specific loader.** This module re-exports nothing from `@spfn/core/env`. Import
255
+ `loadEnv` from `@spfn/core/env/loader`, parsers/guards from `@spfn/core/env`.
256
+
257
+ ---
258
+
259
+ ## Complete example
260
+
261
+ ```typescript
262
+ // SPFN server entry point
263
+ import { loadEnv } from '@spfn/core/env/loader';
264
+ import { env, registry } from '@spfn/core/config';
265
+
266
+ loadEnv(); // populate process.env from .env* (server includes .env.server)
267
+
268
+ // Optional eager gate: fail fast on missing/invalid required vars at startup.
269
+ const { errors, warnings } = registry.validateAll();
270
+ for (const w of warnings) console.warn(`${w.key}: ${w.message}`);
271
+ if (errors.length)
272
+ {
273
+ for (const e of errors) console.error(`${e.key}: ${e.message}`);
274
+ process.exit(1);
275
+ }
276
+
277
+ // Lazily-validated reads:
278
+ const server =
279
+ {
280
+ port: env.PORT, // 4000 (default)
281
+ host: env.HOST, // 'localhost' (default)
282
+ requestTimeout: env.SERVER_TIMEOUT, // 120000 (default)
283
+ };
284
+
285
+ const db =
286
+ {
287
+ url: env.DATABASE_URL, // string | undefined
288
+ poolMax: env.DB_POOL_MAX, // 10 (default)
289
+ monitoring: env.DB_MONITORING_ENABLED // false (default)
290
+ };
291
+
292
+ const apiUrl = env.SPFN_API_URL; // required URL — throws if unset/invalid
293
+ ```
294
+
295
+ ```typescript
296
+ // Next.js — no loadEnv()
297
+ import { env } from '@spfn/core/config';
298
+
299
+ const apiUrl = env.NEXT_PUBLIC_SPFN_API_URL; // required URL, client-exposed
300
+ ```
301
+
302
+ ```typescript
303
+ // Reset the validated flag (tests only)
304
+ import { registry } from '@spfn/core/config';
305
+ registry.reset();
306
+ ```
307
+
308
+ ---
309
+
310
+ ## Types reference
311
+
312
+ ```typescript
313
+ // env's element types come straight from coreEnvSchema via InferEnvType:
314
+ type NodeEnv = 'local' | 'development' | 'staging' | 'production' | 'test';
315
+ type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'fatal';
316
+ // `typeof env` = InferEnvType<typeof coreEnvSchema> (see @spfn/core/env)
317
+ ```
318
+
319
+ ## Related
320
+
321
+ - [@spfn/core/env](../env/README.md) — the schema DSL, parsers, registry, type guards, and
322
+ `loadEnv` that this module is built on. **Read this for everything not specific to
323
+ `coreEnvSchema`.**
324
+ - [@spfn/core/logger](../logger/README.md) — consumes `SPFN_LOG_LEVEL`.
325
+ - [@spfn/core/db](../db/README.md) — consumes the `DATABASE_*` / `DB_*` variables.
326
+ - [@spfn/core](../../README.md) — main package documentation.
@@ -0,0 +1,326 @@
1
+ # @spfn/core/contract — Route contracts and the backward-compatibility gate
2
+
3
+ A **contract** is what a route promises to a client the server cannot redeploy: a mobile app in the
4
+ store, an external API consumer. `.contract()` declares that promise on the route, a codegen plugin
5
+ writes every promise into `contracts/current.json`, and the build refuses a change that would break
6
+ an already-released client.
7
+
8
+ ```
9
+ route.get(...).contract({...}) → contracts/current.json → compared against contracts/released/<version>.json
10
+ ```
11
+
12
+ ## The failure this exists to stop
13
+
14
+ The server drops a response field. Every server test passes. The app in the store draws a blank
15
+ screen on its next launch. Today the only thing preventing that is somebody remembering.
16
+
17
+ The request half of a route already exists at runtime — method, path and the TypeBox input schema
18
+ live on `RouteDef`. The response half does not: `_response` on `RouteDef` is a type-inference slot
19
+ that disappears after compilation, so no check could ever be written against it. `.contract()`
20
+ supplies the missing runtime value.
21
+
22
+ **A web client does not need this.** `createApi<AppRouter>()` derives its types from the router in
23
+ the same build and the same deploy, so a removed response field breaks the TypeScript compile.
24
+ The clients that need a contract are the ones compiled and shipped separately.
25
+
26
+ ## Import paths
27
+
28
+ | Path | Contents |
29
+ |------|----------|
30
+ | `@spfn/core/route` | `.contract()` on the route builder, `RouteContract`, `RouteAuthProfile` |
31
+ | `@spfn/core/contract` | Everything below: collect, compare, snapshots, usage, the gate |
32
+ | `@spfn/core/codegen` | `@spfn/core:contract` generator, `ContractGeneratorConfig` |
33
+
34
+ ## 1. Declare the promise
35
+
36
+ ```ts
37
+ import { Type } from '@sinclair/typebox';
38
+ import { route } from '@spfn/core/route';
39
+
40
+ export const getUser = route.get('/users/:id')
41
+ .input({ params: Type.Object({ id: Type.String() }) })
42
+ .contract({
43
+ since: '1.2.0',
44
+ auth: 'clientProofV1',
45
+ requiresSession: true,
46
+ response: Type.Object({
47
+ id: Type.String(),
48
+ name: Type.String(),
49
+ email: Type.Optional(Type.String()),
50
+ }),
51
+ })
52
+ .handler(async (c) => { /* … */ });
53
+ ```
54
+
55
+ | Field | Meaning |
56
+ |-------|---------|
57
+ | `response` | Response shape, as a TypeBox schema. **Declared, not inferred.** An operation with no body declares `Type.Null()`. |
58
+ | `since` | Contract version the operation first appeared in. |
59
+ | `auth` | `'none'` (called before any key exists — enrollment, login) or `'clientProofV1'`. Defaults to `'none'`. |
60
+ | `requiresSession` | Whether the call carries a session. Defaults to `false`. |
61
+ | `deprecatedIn` | Contract version the operation was announced for removal in. Optional. |
62
+ | `removedIn` | Contract version the operation was removed in. Optional — see below. |
63
+
64
+ A route without `.contract()` is untouched — it simply never appears in the contract.
65
+
66
+ **`removedIn` outlives the route it names.** A client generated before the removal still calls the
67
+ operation, and a route that simply disappears tells that client nothing — the call fails and no
68
+ record says the operation went, or when. Keeping the route alive with `removedIn` set is what turns
69
+ a disappearance into an announcement. The three markers are one deprecation path: mark
70
+ `deprecatedIn` and the build still passes; wait while deployed clients roll over; remove and record
71
+ `removedIn`. How long the middle step runs depends on how fast clients update, which is policy and
72
+ not something the contract decides.
73
+
74
+ ## 1b. Declare the contract version
75
+
76
+ ```ts
77
+ export const appRouter = defineRouter({ getUser, listItems })
78
+ .contractVersion('1.2.0')
79
+ .packages([authRouter]);
80
+ ```
81
+
82
+ This is the version's **source**. A released snapshot is named from it — `writeSnapshot` reads
83
+ `document.contractVersion` and writes `contracts/released/1.2.0.json` — so the filename follows the
84
+ code rather than the code having to be told what the filename said. It is also what lets a running
85
+ server announce the version it serves, which a filename cannot do.
86
+
87
+ Without it the generator still writes `current.json` and still runs the compatibility gate. What it
88
+ cannot do is cut a release, and `writeSnapshot` refuses with a message saying so.
89
+
90
+ A value that is not `major.minor.patch` throws where it is declared, rather than much later in a
91
+ build step: releases are ordered by this string, and a version that cannot be ordered cannot gate
92
+ anything.
93
+
94
+ **Why response shape is declared and not inferred.** A TypeScript-type extractor was considered and
95
+ rejected: generics, conditional and utility types make it emit silently wrong types, and a wrong
96
+ contract is worse than none.
97
+
98
+ **Why there is no separate `.output()`.** An early design split the two, on the reasoning that
99
+ response validation helps every route while a public promise applies to a few. The first half is
100
+ false — web has a compile-time contract already — and the split needed a rule saying "`.contract()`
101
+ without `.output()` is a build error". Two things that must always appear together are one thing.
102
+
103
+ ## 2. Register the generator
104
+
105
+ ```ts
106
+ // .spfnrc.ts
107
+ import { defineConfig, defineGenerator } from '@spfn/core/codegen';
108
+ import type { ContractGeneratorConfig } from '@spfn/core/codegen';
109
+
110
+ export default defineConfig({
111
+ generators: [
112
+ defineGenerator<ContractGeneratorConfig>({
113
+ name: '@spfn/core:route-map',
114
+ routerPath: './src/server/router.ts',
115
+ outputPath: './src/generated/route-map.ts',
116
+ }),
117
+ defineGenerator<ContractGeneratorConfig>({
118
+ name: '@spfn/core:contract',
119
+ routerPath: './src/server/router.ts',
120
+ outputDir: './contracts',
121
+ }),
122
+ ],
123
+ });
124
+ ```
125
+
126
+ | Option | Default | Meaning |
127
+ |--------|---------|---------|
128
+ | `routerPath` | — | Router file, relative to the project root. Required. |
129
+ | `routerExport` | `appRouter`, then `default`, then `router` | Export holding the `defineRouter()` result. |
130
+ | `outputDir` | `./contracts` | Directory holding `current.json`, `released/` and `usage/`. |
131
+ | `additionalRouteDirs` | `[]` | Extra directories to watch, for routes outside `src/server/routes`. |
132
+
133
+ Hanging the contract off codegen is the point: `spfn build` and `spfn dev` run it, so "forgot to
134
+ regenerate the contract" stops being a failure mode — the same job route-map codegen already does
135
+ for web.
136
+
137
+ **`dev` generates; `build` also gates.** Refusing a half-finished route mid-edit would make the
138
+ feature unusable, so the gate runs on the `build` trigger only.
139
+
140
+ ## 3. Cut a release
141
+
142
+ ```bash
143
+ spfn contract check # regenerate, compare against the newest snapshot
144
+ spfn contract release 1.3.0 # write contracts/released/1.3.0.json — commit it
145
+ spfn contract list # what has been released
146
+ ```
147
+
148
+ ```
149
+ contracts/
150
+ current.json regenerated every build, committed
151
+ released/
152
+ 1.2.0.json the promise 1.2.0 made. Never edited.
153
+ 1.3.0.json
154
+ usage/
155
+ ios-2.4.1.json what a released app actually calls
156
+ ```
157
+
158
+ **Every release writes a snapshot.** The gate compares against the newest snapshot alone, which is
159
+ sound only because compatibility is transitive down an unbroken chain. A release that skipped its
160
+ snapshot puts a gap in the chain and silently widens what passes. `spfn contract release` therefore
161
+ refuses a version that is not newer than the newest one on disk, so a gap cannot be filled in later
162
+ behind the gate's back.
163
+
164
+ Each snapshot carries the SHA-256 of its own document. A hand-edited snapshot fails the gate rather
165
+ than quietly moving the baseline.
166
+
167
+ ## The case table
168
+
169
+ Everything the gate does is this table. Tests derive from it one-to-one.
170
+
171
+ ### Operations
172
+
173
+ | Change | Previous snapshot exists | No snapshot yet |
174
+ |--------|--------------------------|-----------------|
175
+ | operation added | pass | pass |
176
+ | operation removed | **usage check** (below) | pass |
177
+ | path changed | refuse | pass |
178
+ | method changed | refuse | pass |
179
+
180
+ An operation is identified by its **name** — the key it holds in the router — not by method and
181
+ path. That is what lets a moved path be reported as a broken promise instead of read as one
182
+ operation vanishing and another appearing. Names must therefore be unique across the whole router
183
+ tree; two contracted routes sharing a name is refused at generation time.
184
+
185
+ ### Request — safe when the server grows more tolerant
186
+
187
+ | Change | Result | Why |
188
+ |--------|--------|-----|
189
+ | field added (optional) | pass | an old app need not send it |
190
+ | field added (required) | **refuse** | every old app's request is now rejected |
191
+ | field removed | pass | an old app may still send it; the server ignores it |
192
+ | required → optional | pass | the server accepts more |
193
+ | optional → required | **refuse** | apps that never sent it break |
194
+ | type changed | **refuse** | |
195
+
196
+ ### Response — the direction is reversed
197
+
198
+ | Change | Result | Why |
199
+ |--------|--------|-----|
200
+ | field added | pass | an old app ignores what it does not know |
201
+ | field removed | **refuse** | an old app was reading it |
202
+ | required → optional | **refuse** | an app that counted on it always arriving breaks |
203
+ | optional → required | pass | it only becomes more certain |
204
+ | type changed | **refuse** | |
205
+
206
+ **Optional runs in opposite directions on the two sides.** A request is safe when the server grows
207
+ more tolerant; a response is safe when the server grows more certain. Collapsing both into one rule
208
+ necessarily gets one of them backwards.
209
+
210
+ A field is judged at its own level. A required field nested inside a newly added *optional* object
211
+ is fine: an app that never sends the object is never asked for it.
212
+
213
+ Interceptor fields are compared under the request rules. A web client never sends them — middleware
214
+ fills them in — but a client that talks to the route directly does, so they are part of the
215
+ published request shape.
216
+
217
+ ### No snapshot yet
218
+
219
+ Everything passes, **with a warning**. "This is the first contract" and "the release forgot to write
220
+ a snapshot" produce the same empty directory, and only a person can tell them apart.
221
+
222
+ ## Removing an operation
223
+
224
+ A removal is decided against `contracts/usage/<platform>-<appVersion>.json`, which each released
225
+ client writes:
226
+
227
+ ```json
228
+ { "platform": "ios", "appVersion": "2.4.1", "operations": ["getUser", "listItems"] }
229
+ ```
230
+
231
+ | Situation | Verdict |
232
+ |-----------|---------|
233
+ | usage directory missing, or holds no file | **refuse** — undecidable, which is not a pass |
234
+ | any file unreadable or the wrong shape | **refuse**, naming the file |
235
+ | every file read, nobody calls it | pass — the only pass |
236
+ | some app calls it | refuse, naming **which platform and version** |
237
+
238
+ The rule this table exists for: **an unreadable file and "nobody calls it" are different answers.**
239
+ An empty scan result reading as a pass is how a removal check quietly stops checking anything.
240
+
241
+ Usage files are read only when something is actually removed. An app that removes nothing never
242
+ needs one to exist.
243
+
244
+ ## What the gate does not check
245
+
246
+ Named here rather than left to be discovered:
247
+
248
+ - **`auth`, `requiresSession` and `since` changes.** Moving an operation from `none` to
249
+ `clientProofV1` breaks every released client, and the gate does not stop it. It was outside the
250
+ approved case table; adding it is a deliberate decision, not a quiet extension.
251
+ - **Runtime response validation.** Whether a handler actually returns what it declared is a separate
252
+ feature from generating a contract, and integration tests already cover it for contracted routes.
253
+ - **WebSocket and SSE.** The contract covers REST operations only.
254
+ - **How a client produces its usage file.** That belongs to the client's toolchain.
255
+ - **Multipart routes are refused, not checked.** See below.
256
+
257
+ ## Multipart is outside a contract
258
+
259
+ A contracted route that declares `formData` — on its `.input()` or its `.interceptor()` — is
260
+ refused at collection:
261
+
262
+ ```
263
+ Contracted route "uploadAvatar" declares input.formData, which a contract cannot describe.
264
+ ```
265
+
266
+ Multipart is a transport-format problem rather than a type problem: the contract describes JSON
267
+ values, and a file part has no spelling among them. The refusal is loud on purpose. Quietly
268
+ leaving the section out would produce a contract that still claimed to describe the operation,
269
+ and a client generated from it would look right until the request reached the server.
270
+
271
+ An uncontracted multipart route is untouched — `formData` is a normal part of `.input()` and only
272
+ `.contract()` on the same route is refused. Either drop `.contract()`, or move the operation to a
273
+ JSON body and upload the file through its own uncontracted route.
274
+
275
+ ## Pitfalls
276
+
277
+ - **Route modules must be importable without side effects.** The contract is read from the loaded
278
+ router, not parsed from source — real routes build schemas from imported values (`EmailSchema`,
279
+ `FileSchema()`, constants) that no source parser can resolve. Loading costs a module import and no
280
+ infrastructure: `@spfn/auth`'s 43 routes load in ~0.6s with no `DATABASE_URL` and no `CACHE_URL`.
281
+ A module that opens a connection at import time would break that, and the generator refuses loudly
282
+ rather than skipping the module.
283
+ - **Contracted routes are registered unconditionally.** A route behind a feature flag or an
284
+ environment check makes the contract describe whichever way the generator happened to run. The
285
+ generator refuses a `defineRouter({...})` containing a computed spread (`...(flag ? {x} : {})`);
286
+ a spread of a plain identifier (`...baseRoutes`) is fine.
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.
289
+ - **A failed build still rewrites `current.json`.** That is deliberate — the regenerated file is
290
+ what the gate compared, so `git diff` shows exactly what broke.
291
+ - **Constraint changes count as type changes.** Narrowing `maxLength`, an `enum` or a `format` is
292
+ refused. That is stricter than the table's "type changed" row strictly requires and it refuses in
293
+ the recoverable direction: a stopped build is fixed by cutting a version, a break that passes
294
+ reaches a shipped app.
295
+
296
+ ## Public API
297
+
298
+ ```ts
299
+ // @spfn/core/route
300
+ route.get('/x').contract({ since, response, auth?, requiresSession?, deprecatedIn?, removedIn? })
301
+ defineRouter({...}).contractVersion('1.2.0') // the version's source
302
+ type RouteContract, RouteAuthProfile
303
+
304
+ // @spfn/core/contract
305
+ collectContractDocument(router) // Router → ContractDocument
306
+ compareDocuments(before, after) // → { violations, removedOperations }
307
+ compareOperation(before, after) // → ContractViolation[]
308
+ checkContract(contractsDir, current) // the gate → { baselineVersion, violations, warnings }
309
+ formatViolations(violations) // → the message a failing build prints
310
+
311
+ readCurrentDocument(dir) / writeCurrentDocument(dir, document)
312
+ listSnapshots(dir) / newestSnapshot(dir) / readSnapshot(file) / writeSnapshot(dir, document)
313
+ readUsageRecords(usageDir) / callersOf(operation, records)
314
+ compareVersions(a, b)
315
+ canonicalize / stableStringify / stableStringifyPretty / stableDigest
316
+
317
+ // @spfn/core/codegen
318
+ createContractGenerator(config) // registered as '@spfn/core:contract'
319
+ assertUnconditionalRegistration(path, source)
320
+ ```
321
+
322
+ ## Related
323
+
324
+ - [`../route/README.md`](../route/README.md) — the route DSL `.contract()` hangs off
325
+ - [`../codegen/README.md`](../codegen/README.md) — the generator system it plugs into
326
+ - [`../../../auth/README.md`](../../../auth/README.md) — the `clientProofV1` auth profile