@spfn/core 0.3.0-beta.3 → 0.3.0-beta.31

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 (104) hide show
  1. package/README.md +304 -12
  2. package/dist/app-config/index.d.ts +39 -1
  3. package/dist/app-config/index.js.map +1 -1
  4. package/dist/authz/index.js +47 -391
  5. package/dist/authz/index.js.map +1 -1
  6. package/dist/{boss-D16fO2oG.d.ts → boss-BiDCbbAQ.d.ts} +60 -7
  7. package/dist/codegen/index.d.ts +157 -16
  8. package/dist/codegen/index.js +772 -166
  9. package/dist/codegen/index.js.map +1 -1
  10. package/dist/config/index.js +4 -4
  11. package/dist/config/index.js.map +1 -1
  12. package/dist/contract/index.d.ts +39 -16
  13. package/dist/contract/index.js +147 -23
  14. package/dist/contract/index.js.map +1 -1
  15. package/dist/db/index.d.ts +230 -35
  16. package/dist/db/index.js +316 -160
  17. package/dist/db/index.js.map +1 -1
  18. package/dist/env/index.d.ts +6 -442
  19. package/dist/env/index.js +13 -16
  20. package/dist/env/index.js.map +1 -1
  21. package/dist/env/loader.js +24 -1
  22. package/dist/env/loader.js.map +1 -1
  23. package/dist/error-CIZlYJlL.d.ts +157 -0
  24. package/dist/errors/index.d.ts +18 -1
  25. package/dist/errors/index.js +47 -391
  26. package/dist/errors/index.js.map +1 -1
  27. package/dist/event/index.d.ts +5 -5
  28. package/dist/event/index.js +202 -3
  29. package/dist/event/index.js.map +1 -1
  30. package/dist/event/sse/client.d.ts +18 -4
  31. package/dist/event/sse/client.js +94 -18
  32. package/dist/event/sse/client.js.map +1 -1
  33. package/dist/event/sse/index.d.ts +18 -5
  34. package/dist/event/sse/index.js +452 -114
  35. package/dist/event/sse/index.js.map +1 -1
  36. package/dist/event/ws/client.d.ts +3 -2
  37. package/dist/event/ws/index.d.ts +4 -3
  38. package/dist/job/index.d.ts +48 -3
  39. package/dist/job/index.js +131 -52
  40. package/dist/job/index.js.map +1 -1
  41. package/dist/logger/index.js +0 -12
  42. package/dist/logger/index.js.map +1 -1
  43. package/dist/middleware/index.js +52 -397
  44. package/dist/middleware/index.js.map +1 -1
  45. package/dist/nextjs/index.d.ts +75 -14
  46. package/dist/nextjs/index.js +200 -84
  47. package/dist/nextjs/index.js.map +1 -1
  48. package/dist/nextjs/server.d.ts +46 -8
  49. package/dist/nextjs/server.js +15 -1
  50. package/dist/nextjs/server.js.map +1 -1
  51. package/dist/ops/index.d.ts +61 -42
  52. package/dist/ops/index.js +648 -97
  53. package/dist/ops/index.js.map +1 -1
  54. package/dist/registry-C2KQCh0m.d.ts +473 -0
  55. package/dist/route/index.d.ts +5 -2
  56. package/dist/route/index.js +107 -28
  57. package/dist/route/index.js.map +1 -1
  58. package/dist/route/types.d.ts +7 -1
  59. package/dist/{router-Cy7rAfmj.d.ts → route-builder-DRW-MSCJ.d.ts} +51 -126
  60. package/dist/router-C57y-vz1.d.ts +184 -0
  61. package/dist/router-CX5uVjmU.d.ts +406 -0
  62. package/dist/server/index.d.ts +130 -7
  63. package/dist/server/index.js +1174 -164
  64. package/dist/server/index.js.map +1 -1
  65. package/dist/state/client.d.ts +206 -0
  66. package/dist/state/client.js +678 -0
  67. package/dist/state/client.js.map +1 -0
  68. package/dist/state/index.d.ts +443 -0
  69. package/dist/state/index.js +644 -0
  70. package/dist/state/index.js.map +1 -0
  71. package/dist/state/react.d.ts +49 -0
  72. package/dist/state/react.js +50 -0
  73. package/dist/state/react.js.map +1 -0
  74. package/dist/surface/index.d.ts +115 -0
  75. package/dist/surface/index.js +1013 -0
  76. package/dist/surface/index.js.map +1 -0
  77. package/dist/token-manager-Ckjt2-qq.d.ts +122 -0
  78. package/dist/{types-CfYVhIQ9.d.ts → types-5Rl11_Nb.d.ts} +43 -4
  79. package/dist/types-CKH0D3Dy.d.ts +441 -0
  80. package/dist/{types-ZQODsBft.d.ts → types-CurMab2Q.d.ts} +52 -2
  81. package/dist/{types-2AbaW4Ie.d.ts → types-DncqXfWN.d.ts} +4 -3
  82. package/docs/file-upload.md +195 -333
  83. package/package.json +41 -11
  84. package/src/cache/README.md +330 -0
  85. package/src/codegen/README.md +721 -0
  86. package/src/config/README.md +333 -0
  87. package/src/contract/README.md +442 -0
  88. package/src/db/README.md +589 -0
  89. package/src/db/manager/README.md +500 -0
  90. package/src/db/schema/README.md +354 -0
  91. package/src/db/transaction/README.md +845 -0
  92. package/src/env/README.md +787 -0
  93. package/src/errors/README.md +445 -0
  94. package/src/event/README.md +994 -0
  95. package/src/job/README.md +613 -0
  96. package/src/logger/README.md +321 -0
  97. package/src/middleware/README.md +634 -0
  98. package/src/nextjs/README.md +756 -0
  99. package/src/route/README.md +763 -0
  100. package/src/security/README.md +100 -0
  101. package/src/server/README.md +704 -0
  102. package/src/state/README.md +806 -0
  103. package/dist/token-manager-BT5EnUAR.d.ts +0 -278
  104. package/dist/types-9oszaJqp.d.ts +0 -250
@@ -0,0 +1,442 @@
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/event` | `.contract()` on `defineEvent` and `defineEventRouter`, `EventContract`, `EventRouterContract` |
32
+ | `@spfn/core/contract` | Everything below: collect, compare, snapshots, usage, the gate |
33
+ | `@spfn/core/codegen` | `@spfn/core:contract` generator, `ContractGeneratorConfig` |
34
+
35
+ ## 1. Declare the promise
36
+
37
+ ```ts
38
+ import { Type } from '@sinclair/typebox';
39
+ import { route } from '@spfn/core/route';
40
+
41
+ export const getUser = route.get('/users/:id')
42
+ .input({ params: Type.Object({ id: Type.String() }) })
43
+ .contract({
44
+ since: '1.2.0',
45
+ auth: 'clientProofV1',
46
+ requiresSession: true,
47
+ response: Type.Object({
48
+ id: Type.String(),
49
+ name: Type.String(),
50
+ email: Type.Optional(Type.String()),
51
+ }),
52
+ })
53
+ .handler(async (c) => { /* … */ });
54
+ ```
55
+
56
+ | Field | Meaning |
57
+ |-------|---------|
58
+ | `response` | Response shape, as a TypeBox schema. **Declared, not inferred.** An operation with no body declares `Type.Null()`. |
59
+ | `since` | Contract version the operation first appeared in. |
60
+ | `auth` | `'none'` (called before any key exists — enrollment, login) or `'clientProofV1'`. Defaults to `'none'`. |
61
+ | `requiresSession` | Whether the call carries a session. Defaults to `false`. |
62
+ | `deprecatedIn` | Contract version the operation was announced for removal in. Optional. |
63
+ | `removedIn` | Contract version the operation was removed in. Optional — see below. |
64
+
65
+ A route without `.contract()` is untouched — it simply never appears in the contract.
66
+
67
+ **`removedIn` outlives the route it names.** A client generated before the removal still calls the
68
+ operation, and a route that simply disappears tells that client nothing — the call fails and no
69
+ record says the operation went, or when. Keeping the route alive with `removedIn` set is what turns
70
+ a disappearance into an announcement. The three markers are one deprecation path: mark
71
+ `deprecatedIn` and the build still passes; wait while deployed clients roll over; remove and record
72
+ `removedIn`. How long the middle step runs depends on how fast clients update, which is policy and
73
+ not something the contract decides.
74
+
75
+ ## 1b. Declare the contract version
76
+
77
+ ```ts
78
+ export const appRouter = defineRouter({ getUser, listItems })
79
+ .contractVersion('1.2.0')
80
+ .packages([authRouter]);
81
+ ```
82
+
83
+ This is the version's **source**. A released snapshot is named from it — `writeSnapshot` reads
84
+ `document.contractVersion` and writes `contracts/released/1.2.0.json` — so the filename follows the
85
+ code rather than the code having to be told what the filename said. It is also what lets a running
86
+ server announce the version it serves, which a filename cannot do.
87
+
88
+ Without it the generator still writes `current.json` and still runs the compatibility gate. What it
89
+ cannot do is cut a release, and `writeSnapshot` refuses with a message saying so.
90
+
91
+ A value that is not `major.minor.patch` throws where it is declared, rather than much later in a
92
+ build step: releases are ordered by this string, and a version that cannot be ordered cannot gate
93
+ anything.
94
+
95
+ **Why response shape is declared and not inferred.** A TypeScript-type extractor was considered and
96
+ rejected: generics, conditional and utility types make it emit silently wrong types, and a wrong
97
+ contract is worse than none.
98
+
99
+ **Why there is no separate `.output()`.** An early design split the two, on the reasoning that
100
+ response validation helps every route while a public promise applies to a few. The first half is
101
+ false — web has a compile-time contract already — and the split needed a rule saying "`.contract()`
102
+ without `.output()` is a build error". Two things that must always appear together are one thing.
103
+
104
+ ## 2. Register the generator
105
+
106
+ ```ts
107
+ // .spfnrc.ts
108
+ import { defineConfig, defineGenerator } from '@spfn/core/codegen';
109
+ import type { ContractGeneratorConfig } from '@spfn/core/codegen';
110
+
111
+ export default defineConfig({
112
+ generators: [
113
+ defineGenerator<ContractGeneratorConfig>({
114
+ name: '@spfn/core:route-map',
115
+ routerPath: './src/server/router.ts',
116
+ outputPath: './src/generated/route-map.ts',
117
+ }),
118
+ defineGenerator<ContractGeneratorConfig>({
119
+ name: '@spfn/core:contract',
120
+ routerPath: './src/server/router.ts',
121
+ outputDir: './contracts',
122
+ }),
123
+ ],
124
+ });
125
+ ```
126
+
127
+ | Option | Default | Meaning |
128
+ |--------|---------|---------|
129
+ | `routerPath` | — | Router file, relative to the project root. Required. |
130
+ | `routerExport` | `appRouter`, then `default`, then `router` | Export holding the `defineRouter()` result. |
131
+ | `eventRouterPath` | — | Event router file, relative to the project root. Set it to publish contracted SSE events — see [Events](#events). |
132
+ | `eventRouterExport` | `eventRouter`, then `default` | Export holding the `defineEventRouter()` result. |
133
+ | `outputDir` | `./contracts` | Directory holding `current.json`, `released/` and `usage/`. |
134
+ | `additionalRouteDirs` | `[]` | Extra directories to watch, for routes outside `src/server/routes`. |
135
+
136
+ Hanging the contract off codegen is the point: `spfn build` and `spfn dev` run it, so "forgot to
137
+ regenerate the contract" stops being a failure mode — the same job route-map codegen already does
138
+ for web.
139
+
140
+ **`dev` generates; `build` also gates.** Refusing a half-finished route mid-edit would make the
141
+ feature unusable, so the gate runs on the `build` trigger only.
142
+
143
+ ## 3. Cut a release
144
+
145
+ ```bash
146
+ spfn contract check # regenerate, compare against the newest snapshot
147
+ spfn contract release 1.3.0 # write contracts/released/1.3.0.json — commit it
148
+ spfn contract list # what has been released
149
+ ```
150
+
151
+ ```
152
+ contracts/
153
+ current.json regenerated every build, committed
154
+ released/
155
+ 1.2.0.json the promise 1.2.0 made. Never edited.
156
+ 1.3.0.json
157
+ usage/
158
+ ios-2.4.1.json what a released app actually calls
159
+ ```
160
+
161
+ **Every release writes a snapshot.** The gate compares against the newest snapshot alone, which is
162
+ sound only because compatibility is transitive down an unbroken chain. A release that skipped its
163
+ snapshot puts a gap in the chain and silently widens what passes. `spfn contract release` therefore
164
+ refuses a version that is not newer than the newest one on disk, so a gap cannot be filled in later
165
+ behind the gate's back.
166
+
167
+ Each snapshot carries the SHA-256 of its own document. A hand-edited snapshot fails the gate rather
168
+ than quietly moving the baseline.
169
+
170
+ ## The case table
171
+
172
+ Everything the gate does is this table. Tests derive from it one-to-one.
173
+
174
+ ### Operations
175
+
176
+ | Change | Previous snapshot exists | No snapshot yet |
177
+ |--------|--------------------------|-----------------|
178
+ | operation added | pass | pass |
179
+ | operation removed | **usage check** (below) | pass |
180
+ | path changed | refuse | pass |
181
+ | method changed | refuse | pass |
182
+
183
+ An operation is identified by its **name** — the key it holds in the router — not by method and
184
+ path. That is what lets a moved path be reported as a broken promise instead of read as one
185
+ operation vanishing and another appearing. Names must therefore be unique across the whole router
186
+ tree; two contracted routes sharing a name is refused at generation time.
187
+
188
+ ### Events
189
+
190
+ A native app subscribes to SSE events by name and decodes each payload itself. Without the events
191
+ in the contract both are hand-written, and both break at runtime — the stream answers 400 with
192
+ `validEvents`, or a decoder throws — instead of at build. Contracted events close that gap.
193
+
194
+ | Change | Previous snapshot exists | No snapshot yet |
195
+ |--------|--------------------------|-----------------|
196
+ | event added | pass | pass |
197
+ | event removed | **usage check**, against the usage file's `events` | pass |
198
+ | stream path changed | refuse (`events.stream-path-changed`) | pass |
199
+ | auth mode changed | refuse (`events.auth-changed`) | pass |
200
+ | payload field added | pass | pass |
201
+ | payload field removed | refuse (`event.payload.field-removed`) | pass |
202
+ | payload required → optional | refuse (`event.payload.field-became-optional`) | pass |
203
+ | payload optional → required | pass | pass |
204
+ | payload type or constraint changed | refuse (`event.payload.type-changed`) | pass |
205
+ | baseline has no `events` section | pass — nothing was promised | pass |
206
+ | current has no `events` section | every baseline event is **removed** | pass |
207
+
208
+ A payload flows server → client, so it is judged under the **response** rules — the same walk,
209
+ with the violation kinds renamed.
210
+
211
+ **Declaring.** Opt in per event, and once on the router:
212
+
213
+ ```ts
214
+ // src/server/events.ts
215
+ import { defineEvent, defineEventRouter } from '@spfn/core/event';
216
+
217
+ const sessionActivity = defineEvent('session.activity', Type.Object({ sessionId: Type.String() }))
218
+ .contract({ since: '1.0.0' }); // deprecatedIn?, removedIn? as on routes
219
+
220
+ export const eventRouter = defineEventRouter({ sessionActivity })
221
+ .contract({ auth: 'tokenExchange' }); // streamPath?: string, default '/events/stream'
222
+ ```
223
+
224
+ and point the generator at the file with `eventRouterPath: './src/server/events.ts'`. An event
225
+ without `.contract()` is untouched and never appears. A router with no contracted event produces no
226
+ `events` section at all, so an app that contracts none writes exactly the document it wrote before.
227
+
228
+ `.contract()` on an event may be called once — an event is defined once and shared, so a second call
229
+ throws instead of one declaration silently winning. A router `streamPath` that does not start with
230
+ `/` throws where it is declared.
231
+
232
+ **Refused at collection**, each with a message saying what to do:
233
+
234
+ - contracted events on a router with no `.contract()` of its own — the contract cannot say whether
235
+ a client must exchange a token first;
236
+ - a contracted event without a payload schema (`defineEvent(name)`) — its frame carries no `data`;
237
+ declare `Type.Object({})` and emit `{}` instead;
238
+ - a contracted event under the key `connected`, `ping`, `subscription.granted` or
239
+ `subscription.revoked` — those are the stream's own frames;
240
+ - a contracted event with no `since`.
241
+
242
+ **What a reader of the document needs.**
243
+
244
+ ```json
245
+ "events": {
246
+ "streamPath": "/events/stream",
247
+ "tokenPath": "/events/token",
248
+ "auth": "tokenExchange",
249
+ "items": [{ "name": "sessionActivity", "since": "1.0.0", "payload": { "type": "object", "…": "…" } }]
250
+ }
251
+ ```
252
+
253
+ - `name` is the **router key** — what a client lists in `?events=` and what the frame's `event:`
254
+ field carries. `defineEvent`'s first argument never reaches the wire and is not published. The same
255
+ `EventDef` registered under two keys is two items.
256
+ - A frame's `data` is the JSON `{ "event": name, "data": payload }`, with `payload` matching the
257
+ item's schema.
258
+ - `connected` (once, when the stream opens), `ping` (keep-alive), `subscription.granted` and
259
+ `subscription.revoked` (an event activated or deactivated on the open connection) frames are the
260
+ stream's own and are not items.
261
+ - With `auth: "tokenExchange"` the client first posts to `tokenPath`, then opens
262
+ `streamPath?events=…&token=…`. `tokenPath` is derived from `streamPath` exactly as the server
263
+ derives it.
264
+ - An `authorize` hook may narrow which contracted events a given connection receives. That is
265
+ per-connection policy, not a promise the contract makes, and the document does not describe it.
266
+
267
+ **The boot check.** The generator reads the event router only, never the server config. So
268
+ `createServer` checks the router's contract against `.events()` before anything is served: the
269
+ contract's `streamPath` (default `/events/stream`) must equal the path `.events()` registers, and
270
+ `auth: 'tokenExchange'` must match `.events({ auth: { enabled: true } })`. A mismatch refuses to
271
+ boot:
272
+
273
+ ```
274
+ SSE event router contract declares streamPath /sse but .events() registers /events/stream; the contract would describe a path the server does not serve
275
+ ```
276
+
277
+ A router without `.contract()` is not checked.
278
+
279
+ **Conditional registration is refused here too.** `defineEventRouter({ ...(flag ? { x } : {}) })`
280
+ in the event router file stops the generator, for the same reason as a conditional route.
281
+
282
+ ### Request — safe when the server grows more tolerant
283
+
284
+ | Change | Result | Why |
285
+ |--------|--------|-----|
286
+ | field added (optional) | pass | an old app need not send it |
287
+ | field added (required) | **refuse** | every old app's request is now rejected |
288
+ | field removed | pass | an old app may still send it; the server ignores it |
289
+ | required → optional | pass | the server accepts more |
290
+ | optional → required | **refuse** | apps that never sent it break |
291
+ | type changed | **refuse** | |
292
+
293
+ ### Response — the direction is reversed
294
+
295
+ | Change | Result | Why |
296
+ |--------|--------|-----|
297
+ | field added | pass | an old app ignores what it does not know |
298
+ | field removed | **refuse** | an old app was reading it |
299
+ | required → optional | **refuse** | an app that counted on it always arriving breaks |
300
+ | optional → required | pass | it only becomes more certain |
301
+ | type changed | **refuse** | |
302
+
303
+ **Optional runs in opposite directions on the two sides.** A request is safe when the server grows
304
+ more tolerant; a response is safe when the server grows more certain. Collapsing both into one rule
305
+ necessarily gets one of them backwards.
306
+
307
+ A field is judged at its own level. A required field nested inside a newly added *optional* object
308
+ is fine: an app that never sends the object is never asked for it.
309
+
310
+ Interceptor fields are compared under the request rules. A web client never sends them — middleware
311
+ fills them in — but a client that talks to the route directly does, so they are part of the
312
+ published request shape.
313
+
314
+ ### No snapshot yet
315
+
316
+ Everything passes, **with a warning**. "This is the first contract" and "the release forgot to write
317
+ a snapshot" produce the same empty directory, and only a person can tell them apart.
318
+
319
+ ## Removing an operation
320
+
321
+ A removal is decided against `contracts/usage/<platform>-<appVersion>.json`, which each released
322
+ client writes:
323
+
324
+ ```json
325
+ { "platform": "ios", "appVersion": "2.4.1", "operations": ["getUser", "listItems"], "events": ["sessionActivity"] }
326
+ ```
327
+
328
+ `events` lists the contracted events the client subscribes to. A file without the key subscribes
329
+ to none — an app released before events were contracted cannot be subscribed to one — and a value
330
+ that is not an array of names is refused like a bad `operations`. A removed event is judged by the
331
+ table below exactly as a removed operation, and a client still listing it is reported as
332
+ `usage.still-subscribed` with its platform and version.
333
+
334
+ | Situation | Verdict |
335
+ |-----------|---------|
336
+ | usage directory missing, or holds no file | **refuse** — undecidable, which is not a pass |
337
+ | any file unreadable or the wrong shape | **refuse**, naming the file |
338
+ | every file read, nobody calls it | pass — the only pass |
339
+ | some app calls it | refuse, naming **which platform and version** |
340
+
341
+ The rule this table exists for: **an unreadable file and "nobody calls it" are different answers.**
342
+ An empty scan result reading as a pass is how a removal check quietly stops checking anything.
343
+
344
+ Usage files are read only when something is actually removed. An app that removes nothing never
345
+ needs one to exist.
346
+
347
+ ## What the gate does not check
348
+
349
+ Named here rather than left to be discovered:
350
+
351
+ - **`auth`, `requiresSession` and `since` changes.** Moving an operation from `none` to
352
+ `clientProofV1` breaks every released client, and the gate does not stop it. It was outside the
353
+ approved case table; adding it is a deliberate decision, not a quiet extension.
354
+ - **Runtime response validation.** Whether a handler actually returns what it declared is a separate
355
+ feature from generating a contract, and integration tests already cover it for contracted routes.
356
+ - **WebSocket.** The contract covers REST operations and SSE events; a WebSocket router is not
357
+ contracted.
358
+ - **`since` and `deprecatedIn` changes on an event**, and per-connection narrowing by `authorize`.
359
+ - **How a client produces its usage file.** That belongs to the client's toolchain.
360
+ - **Multipart routes are refused, not checked.** See below.
361
+
362
+ ## Multipart is outside a contract
363
+
364
+ A contracted route that declares `formData` — on its `.input()` or its `.interceptor()` — is
365
+ refused at collection:
366
+
367
+ ```
368
+ Contracted route "uploadAvatar" declares input.formData, which a contract cannot describe.
369
+ ```
370
+
371
+ Multipart is a transport-format problem rather than a type problem: the contract describes JSON
372
+ values, and a file part has no spelling among them. The refusal is loud on purpose. Quietly
373
+ leaving the section out would produce a contract that still claimed to describe the operation,
374
+ and a client generated from it would look right until the request reached the server.
375
+
376
+ An uncontracted multipart route is untouched — `formData` is a normal part of `.input()` and only
377
+ `.contract()` on the same route is refused. Either drop `.contract()`, or move the operation to a
378
+ JSON body and upload the file through its own uncontracted route.
379
+
380
+ ## Pitfalls
381
+
382
+ - **Route modules must be importable without side effects.** The contract is read from the loaded
383
+ router, not parsed from source — real routes build schemas from imported values (`EmailSchema`,
384
+ `FileSchema()`, constants) that no source parser can resolve. Loading costs a module import and no
385
+ infrastructure: `@spfn/auth`'s 43 routes load in ~0.6s with no `DATABASE_URL` and no `CACHE_URL`.
386
+ A module that opens a connection at import time would break that, and the generator refuses loudly
387
+ rather than skipping the module.
388
+ - **Contracted routes are registered unconditionally.** A route behind a feature flag or an
389
+ environment check makes the contract describe whichever way the generator happened to run. The
390
+ generator refuses a `defineRouter({...})` containing a computed spread (`...(flag ? {x} : {})`);
391
+ a spread of a plain identifier (`...baseRoutes`) is fine.
392
+ - **`NODE_ENV` is pinned when unset.** The generator sets it to `production` before loading, so a
393
+ schema that reads the environment cannot make the contract depend on the shell it ran in. The pin
394
+ is process-wide and shared with `@spfn/core:route-map`, so both generators in one run read the
395
+ same environment whichever `.spfnrc.ts` lists first.
396
+ - **Path aliases come from the project root's `tsconfig.json`.** `compilerOptions.paths` is read
397
+ (`extends` followed) and handed to jiti, so a router importing `@/server/routes/users` loads. A
398
+ mapping held in any other tsconfig is not read, and the failure names the specifier that did not
399
+ resolve.
400
+ - **A failed build still rewrites `current.json`.** That is deliberate — the regenerated file is
401
+ what the gate compared, so `git diff` shows exactly what broke.
402
+ - **Constraint changes count as type changes.** Narrowing `maxLength`, an `enum` or a `format` is
403
+ refused. That is stricter than the table's "type changed" row strictly requires and it refuses in
404
+ the recoverable direction: a stopped build is fixed by cutting a version, a break that passes
405
+ reaches a shipped app.
406
+
407
+ ## Public API
408
+
409
+ ```ts
410
+ // @spfn/core/route
411
+ route.get('/x').contract({ since, response, auth?, requiresSession?, deprecatedIn?, removedIn? })
412
+ defineRouter({...}).contractVersion('1.2.0') // the version's source
413
+ type RouteContract, RouteAuthProfile
414
+
415
+ // @spfn/core/event
416
+ defineEvent(name, schema).contract({ since, deprecatedIn?, removedIn? })
417
+ defineEventRouter({...}).contract({ auth, streamPath? })
418
+ type EventContract, EventRouterContract
419
+
420
+ // @spfn/core/contract
421
+ collectContractDocument(router, eventRouter?) // Router (+ event router) → ContractDocument
422
+ compareDocuments(before, after) // → { violations, removedOperations, removedEvents }
423
+ compareOperation(before, after) // → ContractViolation[]
424
+ checkContract(contractsDir, current) // the gate → { baselineVersion, violations, warnings }
425
+ formatViolations(violations) // → the message a failing build prints
426
+
427
+ readCurrentDocument(dir) / writeCurrentDocument(dir, document)
428
+ listSnapshots(dir) / newestSnapshot(dir) / readSnapshot(file) / writeSnapshot(dir, document)
429
+ readUsageRecords(usageDir) / callersOf(operation, records) / subscribersOf(event, records)
430
+ compareVersions(a, b)
431
+ canonicalize / stableStringify / stableStringifyPretty / stableDigest
432
+
433
+ // @spfn/core/codegen
434
+ createContractGenerator(config) // registered as '@spfn/core:contract'
435
+ assertUnconditionalRegistration(path, source)
436
+ ```
437
+
438
+ ## Related
439
+
440
+ - [`../route/README.md`](../route/README.md) — the route DSL `.contract()` hangs off
441
+ - [`../codegen/README.md`](../codegen/README.md) — the generator system it plugs into
442
+ - [`../../../auth/README.md`](../../../auth/README.md) — the `clientProofV1` auth profile