@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.
- package/README.md +183 -4
- package/dist/authz/index.js +1 -381
- package/dist/authz/index.js.map +1 -1
- package/dist/db/index.d.ts +173 -27
- package/dist/db/index.js +192 -57
- package/dist/db/index.js.map +1 -1
- package/dist/env/loader.js +24 -1
- package/dist/env/loader.js.map +1 -1
- package/dist/errors/index.js +1 -381
- package/dist/errors/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 +6 -387
- package/dist/middleware/index.js.map +1 -1
- package/dist/nextjs/index.d.ts +18 -1
- package/dist/nextjs/index.js +40 -1
- package/dist/nextjs/index.js.map +1 -1
- package/dist/nextjs/server.d.ts +34 -1
- package/dist/nextjs/server.js +14 -0
- package/dist/nextjs/server.js.map +1 -1
- package/dist/ops/index.d.ts +61 -6
- package/dist/ops/index.js +330 -30
- package/dist/ops/index.js.map +1 -1
- package/dist/server/index.js +24 -1
- package/dist/server/index.js.map +1 -1
- package/docs/file-upload.md +195 -333
- package/package.json +6 -5
- package/src/cache/README.md +330 -0
- package/src/codegen/README.md +516 -0
- package/src/config/README.md +326 -0
- package/src/contract/README.md +326 -0
- package/src/db/README.md +589 -0
- package/src/db/manager/README.md +500 -0
- package/src/db/schema/README.md +344 -0
- package/src/db/transaction/README.md +822 -0
- package/src/env/README.md +651 -0
- package/src/errors/README.md +429 -0
- package/src/event/README.md +736 -0
- package/src/job/README.md +514 -0
- package/src/logger/README.md +321 -0
- package/src/middleware/README.md +634 -0
- package/src/nextjs/README.md +608 -0
- package/src/route/README.md +738 -0
- package/src/security/README.md +100 -0
- 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
|