@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.
- package/README.md +304 -12
- package/dist/app-config/index.d.ts +39 -1
- package/dist/app-config/index.js.map +1 -1
- package/dist/authz/index.js +47 -391
- package/dist/authz/index.js.map +1 -1
- package/dist/{boss-D16fO2oG.d.ts → boss-BiDCbbAQ.d.ts} +60 -7
- package/dist/codegen/index.d.ts +157 -16
- package/dist/codegen/index.js +772 -166
- package/dist/codegen/index.js.map +1 -1
- package/dist/config/index.js +4 -4
- package/dist/config/index.js.map +1 -1
- package/dist/contract/index.d.ts +39 -16
- package/dist/contract/index.js +147 -23
- package/dist/contract/index.js.map +1 -1
- package/dist/db/index.d.ts +230 -35
- package/dist/db/index.js +316 -160
- package/dist/db/index.js.map +1 -1
- package/dist/env/index.d.ts +6 -442
- package/dist/env/index.js +13 -16
- package/dist/env/index.js.map +1 -1
- package/dist/env/loader.js +24 -1
- package/dist/env/loader.js.map +1 -1
- package/dist/error-CIZlYJlL.d.ts +157 -0
- package/dist/errors/index.d.ts +18 -1
- package/dist/errors/index.js +47 -391
- package/dist/errors/index.js.map +1 -1
- package/dist/event/index.d.ts +5 -5
- package/dist/event/index.js +202 -3
- package/dist/event/index.js.map +1 -1
- package/dist/event/sse/client.d.ts +18 -4
- package/dist/event/sse/client.js +94 -18
- package/dist/event/sse/client.js.map +1 -1
- package/dist/event/sse/index.d.ts +18 -5
- package/dist/event/sse/index.js +452 -114
- package/dist/event/sse/index.js.map +1 -1
- package/dist/event/ws/client.d.ts +3 -2
- package/dist/event/ws/index.d.ts +4 -3
- package/dist/job/index.d.ts +48 -3
- package/dist/job/index.js +131 -52
- package/dist/job/index.js.map +1 -1
- package/dist/logger/index.js +0 -12
- package/dist/logger/index.js.map +1 -1
- package/dist/middleware/index.js +52 -397
- package/dist/middleware/index.js.map +1 -1
- package/dist/nextjs/index.d.ts +75 -14
- package/dist/nextjs/index.js +200 -84
- package/dist/nextjs/index.js.map +1 -1
- package/dist/nextjs/server.d.ts +46 -8
- package/dist/nextjs/server.js +15 -1
- package/dist/nextjs/server.js.map +1 -1
- package/dist/ops/index.d.ts +61 -42
- package/dist/ops/index.js +648 -97
- package/dist/ops/index.js.map +1 -1
- package/dist/registry-C2KQCh0m.d.ts +473 -0
- package/dist/route/index.d.ts +5 -2
- package/dist/route/index.js +107 -28
- package/dist/route/index.js.map +1 -1
- package/dist/route/types.d.ts +7 -1
- package/dist/{router-Cy7rAfmj.d.ts → route-builder-DRW-MSCJ.d.ts} +51 -126
- package/dist/router-C57y-vz1.d.ts +184 -0
- package/dist/router-CX5uVjmU.d.ts +406 -0
- package/dist/server/index.d.ts +130 -7
- package/dist/server/index.js +1174 -164
- package/dist/server/index.js.map +1 -1
- package/dist/state/client.d.ts +206 -0
- package/dist/state/client.js +678 -0
- package/dist/state/client.js.map +1 -0
- package/dist/state/index.d.ts +443 -0
- package/dist/state/index.js +644 -0
- package/dist/state/index.js.map +1 -0
- package/dist/state/react.d.ts +49 -0
- package/dist/state/react.js +50 -0
- package/dist/state/react.js.map +1 -0
- package/dist/surface/index.d.ts +115 -0
- package/dist/surface/index.js +1013 -0
- package/dist/surface/index.js.map +1 -0
- package/dist/token-manager-Ckjt2-qq.d.ts +122 -0
- package/dist/{types-CfYVhIQ9.d.ts → types-5Rl11_Nb.d.ts} +43 -4
- package/dist/types-CKH0D3Dy.d.ts +441 -0
- package/dist/{types-ZQODsBft.d.ts → types-CurMab2Q.d.ts} +52 -2
- package/dist/{types-2AbaW4Ie.d.ts → types-DncqXfWN.d.ts} +4 -3
- package/docs/file-upload.md +195 -333
- package/package.json +41 -11
- package/src/cache/README.md +330 -0
- package/src/codegen/README.md +721 -0
- package/src/config/README.md +333 -0
- package/src/contract/README.md +442 -0
- package/src/db/README.md +589 -0
- package/src/db/manager/README.md +500 -0
- package/src/db/schema/README.md +354 -0
- package/src/db/transaction/README.md +845 -0
- package/src/env/README.md +787 -0
- package/src/errors/README.md +445 -0
- package/src/event/README.md +994 -0
- package/src/job/README.md +613 -0
- package/src/logger/README.md +321 -0
- package/src/middleware/README.md +634 -0
- package/src/nextjs/README.md +756 -0
- package/src/route/README.md +763 -0
- package/src/security/README.md +100 -0
- package/src/server/README.md +704 -0
- package/src/state/README.md +806 -0
- package/dist/token-manager-BT5EnUAR.d.ts +0 -278
- 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
|