@spfn/core 0.2.0-beta.9 → 0.3.0-beta.2

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 (95) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +467 -305
  3. package/dist/authz/index.d.ts +34 -0
  4. package/dist/authz/index.js +810 -0
  5. package/dist/authz/index.js.map +1 -0
  6. package/dist/{boss-DI1r4kTS.d.ts → boss-D16fO2oG.d.ts} +41 -1
  7. package/dist/cache/index.js +42 -30
  8. package/dist/cache/index.js.map +1 -1
  9. package/dist/codegen/index.d.ts +121 -13
  10. package/dist/codegen/index.js +212 -15
  11. package/dist/codegen/index.js.map +1 -1
  12. package/dist/config/index.d.ts +615 -6
  13. package/dist/config/index.js +124 -5
  14. package/dist/config/index.js.map +1 -1
  15. package/dist/contract/index.d.ts +220 -0
  16. package/dist/contract/index.js +558 -0
  17. package/dist/contract/index.js.map +1 -0
  18. package/dist/db/index.d.ts +528 -85
  19. package/dist/db/index.js +831 -122
  20. package/dist/db/index.js.map +1 -1
  21. package/dist/define-middleware-DfDP39Nq.d.ts +167 -0
  22. package/dist/env/index.d.ts +26 -2
  23. package/dist/env/index.js +15 -5
  24. package/dist/env/index.js.map +1 -1
  25. package/dist/env/loader.d.ts +26 -19
  26. package/dist/env/loader.js +32 -25
  27. package/dist/env/loader.js.map +1 -1
  28. package/dist/errors/index.d.ts +10 -0
  29. package/dist/errors/index.js +418 -5
  30. package/dist/errors/index.js.map +1 -1
  31. package/dist/event/index.d.ts +33 -3
  32. package/dist/event/index.js +24 -3
  33. package/dist/event/index.js.map +1 -1
  34. package/dist/event/sse/client.d.ts +42 -3
  35. package/dist/event/sse/client.js +128 -45
  36. package/dist/event/sse/client.js.map +1 -1
  37. package/dist/event/sse/index.d.ts +12 -5
  38. package/dist/event/sse/index.js +280 -32
  39. package/dist/event/sse/index.js.map +1 -1
  40. package/dist/event/ws/client.d.ts +59 -0
  41. package/dist/event/ws/client.js +273 -0
  42. package/dist/event/ws/client.js.map +1 -0
  43. package/dist/event/ws/index.d.ts +94 -0
  44. package/dist/event/ws/index.js +272 -0
  45. package/dist/event/ws/index.js.map +1 -0
  46. package/dist/job/index.d.ts +2 -2
  47. package/dist/job/index.js +155 -42
  48. package/dist/job/index.js.map +1 -1
  49. package/dist/logger/index.d.ts +5 -0
  50. package/dist/logger/index.js +14 -0
  51. package/dist/logger/index.js.map +1 -1
  52. package/dist/middleware/index.d.ts +347 -9
  53. package/dist/middleware/index.js +1462 -15
  54. package/dist/middleware/index.js.map +1 -1
  55. package/dist/nextjs/index.d.ts +2 -2
  56. package/dist/nextjs/index.js +42 -28
  57. package/dist/nextjs/index.js.map +1 -1
  58. package/dist/nextjs/server.d.ts +35 -51
  59. package/dist/nextjs/server.js +126 -60
  60. package/dist/nextjs/server.js.map +1 -1
  61. package/dist/ops/index.d.ts +152 -0
  62. package/dist/ops/index.js +500 -0
  63. package/dist/ops/index.js.map +1 -0
  64. package/dist/route/index.d.ts +8 -694
  65. package/dist/route/index.js +111 -22
  66. package/dist/route/index.js.map +1 -1
  67. package/dist/router-Qbssr11H.d.ts +676 -0
  68. package/dist/security/index.d.ts +83 -0
  69. package/dist/security/index.js +173 -0
  70. package/dist/security/index.js.map +1 -0
  71. package/dist/server/index.d.ts +491 -22
  72. package/dist/server/index.js +1887 -308
  73. package/dist/server/index.js.map +1 -1
  74. package/dist/token-manager-BT5EnUAR.d.ts +278 -0
  75. package/dist/types-2AbaW4Ie.d.ts +205 -0
  76. package/dist/{types-BOPTApC2.d.ts → types-9oszaJqp.d.ts} +7 -2
  77. package/dist/types-D1c57Ko-.d.ts +115 -0
  78. package/dist/types-ZQODsBft.d.ts +282 -0
  79. package/package.json +244 -208
  80. package/dist/router-Di7ENoah.d.ts +0 -151
  81. package/dist/types-B-e_f2dQ.d.ts +0 -121
  82. package/docs/cache.md +0 -133
  83. package/docs/codegen.md +0 -74
  84. package/docs/database.md +0 -346
  85. package/docs/entity.md +0 -539
  86. package/docs/env.md +0 -477
  87. package/docs/errors.md +0 -319
  88. package/docs/event.md +0 -116
  89. package/docs/job.md +0 -131
  90. package/docs/logger.md +0 -108
  91. package/docs/middleware.md +0 -337
  92. package/docs/nextjs.md +0 -241
  93. package/docs/repository.md +0 -496
  94. package/docs/route.md +0 -497
  95. package/docs/server.md +0 -307
package/README.md CHANGED
@@ -1,393 +1,555 @@
1
1
  # @spfn/core
2
2
 
3
- Type-safe Node.js backend framework built on Hono + Drizzle ORM.
3
+ > **The backend runtime for taking an AI-built app from prototype to production**
4
4
 
5
- [![npm version](https://badge.fury.io/js/@spfn%2Fcore.svg)](https://www.npmjs.com/package/@spfn/core)
6
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
- [![TypeScript](https://img.shields.io/badge/TypeScript-5.3-blue)](https://www.typescriptlang.org/)
5
+ `@spfn/core` is the package every SPFN app is built on. It decides the shape of a
6
+ feature — an entity, a repository, a route, a router registration — so that neither you
7
+ nor your coding agent has to invent one per feature. Everything else in SPFN
8
+ (`@spfn/auth`, `@spfn/mcp`, `@spfn/storage`, …) plugs into it.
8
9
 
9
- > **Beta Release**: Core APIs are stable but may have minor changes before 1.0.
10
+ Fixing the shape is an answer to [architecture drift](https://superfunction.xyz/architecture-drift)
11
+ — a codebase acquiring several structures because each feature was arranged freshly. The
12
+ usual answers detect it after the fact; this one leaves nothing to decide. It removes
13
+ drift that comes from structural choice, and nothing else: two services can still
14
+ implement the same rule differently inside a correct shape.
10
15
 
11
- ## Installation
16
+ 📖 **[superfunction.xyz](https://superfunction.xyz)** — docs and the full-stack tutorial ·
17
+ [repository README](https://github.com/fxylabs/spfn) for the whole framework.
18
+
19
+ > **Status — Beta (`0.x`).** The public API is stabilizing but may still change between
20
+ > minor releases before `1.0`. Pin your version and install from the `@beta` tag.
21
+
22
+ ---
23
+
24
+ ## What is @spfn/core?
25
+
26
+ A TypeScript backend runtime with four parts, used together:
27
+
28
+ - **A route DSL** — `route.get(path).input({...}).handler(...)`, validated at runtime by
29
+ TypeBox and typed at compile time from the same schema.
30
+ - **A data layer** — PostgreSQL through Drizzle ORM, with `BaseRepository`, schema
31
+ helpers, and transactions that propagate automatically.
32
+ - **A server** — a Hono app you start as a long-lived process, or mount as serverless
33
+ functions on Vercel.
34
+ - **A Next.js bridge** — an RPC proxy and a typed client, so a route's input and output
35
+ types are the same object on both sides of the network.
36
+
37
+ What it is not: a frontend framework, an ORM, or a place to keep your business rules.
38
+ Next.js owns the frontend, Drizzle owns the SQL, and your services own the rules.
39
+
40
+ ---
41
+
42
+ ## How do I install it?
12
43
 
13
44
  ```bash
14
- pnpm add @spfn/core
45
+ pnpm add @spfn/core@beta drizzle-orm@1.0.0-rc.4 postgres pg
46
+ # optional peer: next ^16.2.11 (only for the Next.js bridge)
15
47
  ```
16
48
 
17
- ## Quick Start
49
+ Node `>=20.0.0`. ESM only.
18
50
 
19
- ### 1. Define Entity
51
+ > **Declare Drizzle and the Postgres drivers in your own app, not just in SPFN.**
52
+ > `@spfn/core` takes `drizzle-orm` as a peer dependency, and Drizzle changes how it
53
+ > resolves types depending on which driver packages are present. If your app does not
54
+ > pin the same ORM and drivers, pnpm can install a second copy of Drizzle — and then
55
+ > `BaseRepository` generics collapse to `unknown` and your RPC responses lose their
56
+ > types. `spfn create` adds these for you; add them by hand only when wiring SPFN into
57
+ > an app that already exists.
20
58
 
21
- ```typescript
22
- // src/server/entities/users.ts
23
- import { pgTable, text, boolean } from 'drizzle-orm/pg-core';
24
- import { id, timestamps } from '@spfn/core/db';
25
-
26
- export const users = pgTable('users', {
27
- id: id(),
28
- email: text('email').notNull().unique(),
29
- name: text('name').notNull(),
30
- isActive: boolean('is_active').notNull().default(true),
31
- ...timestamps()
32
- });
59
+ Optional dependencies, installed only if you use the feature: `ioredis` (cache) and
60
+ `ws` (WebSocket events). `pg-boss` ships as a direct dependency for background jobs.
33
61
 
34
- export type User = typeof users.$inferSelect;
35
- export type NewUser = typeof users.$inferInsert;
36
- ```
62
+ ---
37
63
 
38
- ### 2. Create Repository
64
+ ## What does one feature look like?
39
65
 
40
- ```typescript
41
- // src/server/repositories/user.repository.ts
42
- import { BaseRepository } from '@spfn/core/db';
43
- import { users, type User, type NewUser } from '../entities/users';
66
+ Four files, always the same four, always in the same places:
44
67
 
45
- export class UserRepository extends BaseRepository
46
- {
47
- async findById(id: string): Promise<User | null>
48
- {
49
- return this._findOne(users, { id });
50
- }
51
-
52
- async findByEmail(email: string): Promise<User | null>
53
- {
54
- return this._findOne(users, { email });
55
- }
56
-
57
- async findAll(): Promise<User[]>
58
- {
59
- return this._findMany(users);
60
- }
61
-
62
- async create(data: NewUser): Promise<User>
63
- {
64
- return this._create(users, data);
65
- }
66
-
67
- async update(id: string, data: Partial<NewUser>): Promise<User | null>
68
- {
69
- return this._updateOne(users, { id }, data);
70
- }
71
-
72
- async delete(id: string): Promise<User | null>
73
- {
74
- return this._deleteOne(users, { id });
75
- }
76
- }
68
+ ```
69
+ src/server/
70
+ entities/order.ts # the data shape (Drizzle table)
71
+ repositories/order.ts # persistence (extends BaseRepository)
72
+ routes/orders.ts # the validated API contract
73
+ router.ts # registration
77
74
  ```
78
75
 
79
- ### 3. Define Routes
76
+ The point is not that this arrangement is uniquely correct. The point is that it is
77
+ decided. An agent asked for "add orders" twice produces the same code twice, because
78
+ there is nothing left to choose.
80
79
 
81
- ```typescript
82
- // src/server/routes/users.ts
83
- import { route } from '@spfn/core/route';
84
- import { Transactional } from '@spfn/core/db';
85
- import { Type } from '@sinclair/typebox';
86
- import { UserRepository } from '../repositories/user.repository';
80
+ ---
87
81
 
88
- const userRepo = new UserRepository();
82
+ ## How does a route's type reach the browser?
89
83
 
90
- export const getUsers = route.get('/users')
91
- .handler(async () => {
92
- return userRepo.findAll();
93
- });
84
+ Through TypeScript inference, not generated client code. One artifact is generated —
85
+ the route map the RPC proxy needs — and nothing else.
94
86
 
95
- export const getUser = route.get('/users/:id')
96
- .input({
97
- params: Type.Object({ id: Type.String() })
98
- })
99
- .handler(async (c) => {
100
- const { params } = await c.data();
101
- const user = await userRepo.findById(params.id);
87
+ ```
88
+ ① route DSL ② defineRouter ③ defineServerConfig → startServer
89
+ route.get('/users/:id') defineRouter({ getUser, defineServerConfig()
90
+ .input({ params }) createUser }) .routes(appRouter).build()
91
+ .handler(c => …) export type AppRouter startServer() → Hono on :8790
92
+ = typeof appRouter ▲
93
+ TypeBox = runtime validation + compile-time types │ registerRoutes mounts routes
94
+ ▼ │
95
+ ④ codegen (@spfn/core:route-map) ──► routeMap = { getUser: { method:'GET', path:'/users/:id' }, … }
96
+
97
+
98
+ ⑤ Next.js RPC proxy ⑥ typed client
99
+ app/api/rpc/[routeName]/route.ts lib/api.ts
100
+ createRpcProxy({ routeMap }) createApi<AppRouter>() (no codegen for the client)
101
+ GET/POST /api/rpc/{routeName} api.getUser.call({ params:{ id } })
102
+ resolves real method+path from routeMap └─ fully typed input + output
103
+ forwards to backend, runs interceptors
104
+ ```
102
105
 
103
- if (!user)
104
- {
105
- throw new Error('User not found');
106
- }
107
-
108
- return user;
109
- });
110
-
111
- export const createUser = route.post('/users')
112
- .input({
113
- body: Type.Object({
114
- email: Type.String({ format: 'email' }),
115
- name: Type.String({ minLength: 1 })
116
- })
117
- })
118
- .use([Transactional()])
119
- .handler(async (c) => {
120
- const { body } = await c.data();
121
- return userRepo.create(body);
122
- });
123
-
124
- export const updateUser = route.patch('/users/:id')
125
- .input({
126
- params: Type.Object({ id: Type.String() }),
127
- body: Type.Object({
128
- email: Type.Optional(Type.String({ format: 'email' })),
129
- name: Type.Optional(Type.String({ minLength: 1 }))
130
- })
131
- })
132
- .use([Transactional()])
133
- .handler(async (c) => {
134
- const { params, body } = await c.data();
135
- const user = await userRepo.update(params.id, body);
136
-
137
- if (!user)
138
- {
139
- throw new Error('User not found');
140
- }
141
-
142
- return user;
143
- });
144
-
145
- export const deleteUser = route.delete('/users/:id')
146
- .input({
147
- params: Type.Object({ id: Type.String() })
148
- })
149
- .use([Transactional()])
150
- .handler(async (c) => {
151
- const { params } = await c.data();
152
- const user = await userRepo.delete(params.id);
153
-
154
- if (!user)
155
- {
156
- throw new Error('User not found');
157
- }
106
+ 1. **Define a route** with `route.<method>(path).input({...}).handler(c => …)` from
107
+ `@spfn/core/route`. The TypeBox schemas in `.input()` do double duty: they validate
108
+ the request at runtime and give the handler (`await c.data()`) and the client their
109
+ compile-time types. The handler's **return type is inferred** — you never write a
110
+ response type.
111
+ 2. **Compose routes** with `defineRouter({ … })` and export
112
+ `type AppRouter = typeof appRouter`. That type is the single source of truth for the
113
+ client.
114
+ 3. **Boot the server** with `defineServerConfig().routes(appRouter).build()` and
115
+ `startServer()` from `@spfn/core/server`. It wires `ErrorHandler` and
116
+ `RequestLogger`, initializes the database and cache, mounts the routes, and starts
117
+ jobs and events. A request goes: Hono match → global middleware → route middleware
118
+ (`.use([...])`, e.g. `Transactional()`) input validation → handler → response.
119
+ 4. **Generate the route map** with `pnpm codegen`. It emits
120
+ `routeName → { method, path }` — the only thing the proxy needs to find the real
121
+ backend endpoint.
122
+ 5. **Mount the RPC proxy** in Next.js as the `app/api/rpc/[routeName]/route.ts`
123
+ catch-all. The browser only ever sends `GET` (no body) or `POST` (body or formData)
124
+ to `/api/rpc/{routeName}`; the proxy looks up `routeMap[routeName]`, substitutes
125
+ `:params`, and forwards with the real method. Package route maps (`authRouteMap`,
126
+ `eventRouteMap`) merge into the same map.
127
+ 6. **Call it** through `createApi<AppRouter>()`. The client is a `Proxy` over the
128
+ `AppRouter` type — typed in and out, with no runtime cost for the types. Errors
129
+ arrive as `ApiError`, or as the original error class when that class is registered in
130
+ the client's `errorRegistry`.
158
131
 
159
- return { success: true };
160
- });
161
- ```
132
+ ---
162
133
 
163
- ### 4. Configure Server
134
+ ## Show me the whole thing in code
164
135
 
165
136
  ```typescript
166
- // src/server/server.config.ts
167
- import { defineServerConfig, defineRouter } from '@spfn/core/server';
168
- import * as userRoutes from './routes/users';
137
+ // server/router.ts — ① + ②
138
+ import { defineRouter, route } from '@spfn/core/route';
139
+ import { Transactional } from '@spfn/core/db';
140
+ import { Type } from '@sinclair/typebox';
169
141
 
170
- const appRouter = defineRouter({
171
- ...userRoutes
142
+ export const appRouter = defineRouter({
143
+ getUser: route.get('/users/:id')
144
+ .input({ params: Type.Object({ id: Type.String() }) })
145
+ .handler(async (c) =>
146
+ {
147
+ const { params } = await c.data(); // params.id: string
148
+ return { id: params.id, name: 'John' }; // return type inferred
149
+ }),
150
+
151
+ createUser: route.post('/users')
152
+ .input({ body: Type.Object({ name: Type.String() }) })
153
+ .use([Transactional()]) // commit on return, rollback on throw
154
+ .handler(async (c) =>
155
+ {
156
+ const { body } = await c.data();
157
+ return { id: '2', name: body.name };
158
+ }),
172
159
  });
173
160
 
174
- export default defineServerConfig()
175
- .port(8790)
176
- .routes(appRouter)
177
- .build();
178
-
179
161
  export type AppRouter = typeof appRouter;
180
162
  ```
181
163
 
182
- ### 5. Start Server
164
+ ```typescript
165
+ // server/index.ts — ③
166
+ import { defineServerConfig, startServer } from '@spfn/core/server';
167
+ import { appRouter } from './router';
168
+
169
+ export default defineServerConfig().port(8790).routes(appRouter).build();
170
+ await startServer(); // Hono on :8790
171
+ ```
183
172
 
184
173
  ```typescript
185
- // src/server/index.ts
186
- import { startServer } from '@spfn/core/server';
174
+ // app/api/rpc/[routeName]/route.ts — ⑤ (server-only)
175
+ import { createRpcProxy } from '@spfn/core/nextjs/server';
176
+ import { routeMap } from '@/generated/route-map'; // ④ codegen output
187
177
 
188
- await startServer();
178
+ export const { GET, POST } = createRpcProxy({ routeMap });
189
179
  ```
190
180
 
191
- ### 6. Environment Variables
181
+ ```typescript
182
+ // lib/api.ts — ⑥ (client-safe; no codegen)
183
+ import { createApi } from '@spfn/core/nextjs';
184
+ import type { AppRouter } from '@/server/router';
192
185
 
193
- ```bash
194
- # .env
195
- DATABASE_URL=postgresql://localhost:5432/mydb
196
- PORT=8790
186
+ export const api = createApi<AppRouter>();
187
+
188
+ // anywhere — server component, client component, or server action:
189
+ const user = await api.getUser.call({ params: { id: '123' } }); // typed { id, name }
190
+ const made = await api.createUser.call({ body: { name: 'A' } });
197
191
  ```
198
192
 
199
193
  ---
200
194
 
201
- ## Architecture Overview
195
+ ## Which import path do I use for what?
196
+
197
+ There is **no root barrel**: `import … from '@spfn/core'` does not resolve. Every symbol
198
+ comes from a subpath, and the table below is the complete public surface — one row per
199
+ entry in `package.json` `exports`. Each module has its own README with the API detail.
200
+
201
+ | Import path | Purpose | Doc |
202
+ |-------------|---------|-----|
203
+ | `@spfn/core/route` | The route DSL (`route.get(...).input(...).handler(...)`) plus `defineRouter`, `registerRoutes`, `defineMiddleware`. The core of the core. | [src/route](./src/route/README.md) |
204
+ | `@spfn/core/route/types` | Shared route types (`HttpMethod`, router type primitives). | [src/route](./src/route/README.md) |
205
+ | `@spfn/core/server` | Server entry: `defineServerConfig()` → `startServer()`, plus `createServerlessApp()` for Vercel. Middleware wiring, infra init, graceful shutdown. | [src/server](./src/server/README.md) |
206
+ | `@spfn/core/nextjs` | Client-safe: `createApi<AppRouter>()`, `ApiError`, client types. Never touches `next/headers`. | [src/nextjs](./src/nextjs/README.md) |
207
+ | `@spfn/core/nextjs/server` | Server-only: `createRpcProxy({ routeMap })`, `registerInterceptors`. Uses `next/headers`. | [src/nextjs](./src/nextjs/README.md) |
208
+ | `@spfn/core/db` | PostgreSQL through Drizzle: CRUD helpers, `BaseRepository`, schema helpers, transactions, Postgres error mapping. One entry point for all of it. | [src/db](./src/db/README.md) |
209
+ | `@spfn/core/db` → manager | Connection lifecycle, pool, primary/replica, health check, reconnect (`initDatabase`, `getDatabase`). Re-exported from `@spfn/core/db`. | [src/db/manager](./src/db/manager/README.md) |
210
+ | `@spfn/core/db` → migrations | Which migrations each installed function package ships, and which the database has applied (`collectMigrationStatus`, `discoverFunctionMigrations`). What `spfn db status`, the boot gate and health all read. Re-exported from `@spfn/core/db`. | [src/db/migrations](./src/db/migrations/index.ts) |
211
+ | `@spfn/core/db` → schema | Drizzle column helpers (`id`, `uuid`, `timestamps`, `foreignKey`, `enumText`, `typedJsonb`, `softDelete`, …). Re-exported from `@spfn/core/db`. | [src/db/schema](./src/db/schema/README.md) |
212
+ | `@spfn/core/db` → transaction | `Transactional()` middleware and `runInTransaction`; the transaction reaches every repository through AsyncLocalStorage. Re-exported from `@spfn/core/db`. | [src/db/transaction](./src/db/transaction/README.md) |
213
+ | `@spfn/core/middleware` | Built-in Hono middleware: `ErrorHandler`, `RequestLogger` and its masking helper. | [src/middleware](./src/middleware/README.md) |
214
+ | `@spfn/core/errors` | Serializable HTTP and database error classes, plus `ErrorRegistry` so an error survives the trip to the client as its own class. | [src/errors](./src/errors/README.md) |
215
+ | `@spfn/core/security` | `safeFetch` — a drop-in `fetch` hardened against SSRF, including DNS rebinding, by pinning the connection to a validated IP. | [src/security](./src/security/README.md) |
216
+ | `@spfn/core/authz` | Ownership guards. `requireOwner(resource, userId)` makes "load it, then check it belongs to the requester" one call, so a handler cannot forget it. | [src/authz/index.ts](./src/authz/index.ts) |
217
+ | `@spfn/core/env` | Schema-based environment validation, isomorphic. | [src/env](./src/env/README.md) |
218
+ | `@spfn/core/env/loader` | The **server-only** `.env` file loader (uses `node:fs`). | [src/env](./src/env/README.md) |
219
+ | `@spfn/core/config` | `@spfn/core`'s own validated env config (`env`, `envSchema`, `registry`), built on `@spfn/core/env`. | [src/config](./src/config/README.md) |
220
+ | `@spfn/core/logger` | Structured singleton `logger` with child loggers and level masking. No dependencies. | [src/logger](./src/logger/README.md) |
221
+ | `@spfn/core/cache` | Valkey/Redis singleton over ioredis (`getCache`, `getCacheRead`). Degrades to disabled rather than throwing. | [src/cache](./src/cache/README.md) |
222
+ | `@spfn/core/job` | Background jobs on pg-boss: a fluent `job()` builder, cron, run-once, event-driven, `defineJobRouter`. | [src/job](./src/job/README.md) |
223
+ | `@spfn/core/event` | Decoupled pub/sub (`defineEvent`, `defineEventRouter`, `eventRouteMap`). | [src/event](./src/event/README.md) |
224
+ | `@spfn/core/event/sse` | Server-side SSE handler and token manager. Server only. | [src/event](./src/event/README.md) |
225
+ | `@spfn/core/event/sse/client` | Browser SSE client (`EventSource`). | [src/event](./src/event/README.md) |
226
+ | `@spfn/core/event/ws` | Server-side WebSocket handler. Server only; needs the optional `ws` dependency. | [src/event](./src/event/README.md) |
227
+ | `@spfn/core/event/ws/client` | Browser WebSocket client. | [src/event](./src/event/README.md) |
228
+ | `@spfn/core/codegen` | The codegen orchestrator and the built-in generators: `@spfn/core:route-map` for the proxy's route map, `@spfn/core:contract` for the client contract. | [src/codegen](./src/codegen/README.md) |
229
+ | `@spfn/core/contract` | Route contracts for clients that ship separately: collect, snapshot, and the build gate that refuses a breaking change. | [src/contract](./src/contract/README.md) |
230
+
231
+ `db/manager`, `db/schema` and `db/transaction` are **not** package subpaths of their own.
232
+ They are internal modules re-exported by `@spfn/core/db` — import their symbols from
233
+ there.
202
234
 
203
- ```
204
- ┌─────────────────────────────────────────┐
205
- │ Routes Layer │ API endpoints + validation
206
- │ route.get('/users/:id').handler(...) │
207
- └──────────────┬──────────────────────────┘
208
-
209
- ┌──────────────▼──────────────────────────┐
210
- │ Repository Layer │ Business logic + data access
211
- │ class UserRepository extends Base... │
212
- └──────────────┬──────────────────────────┘
213
-
214
- ┌──────────────▼──────────────────────────┐
215
- │ Entity Layer │ Database schema (Drizzle)
216
- │ pgTable('users', { id, email, ... }) │
217
- └─────────────────────────────────────────┘
218
- ```
235
+ ---
236
+
237
+ ## Do I have to run codegen?
238
+
239
+ Only for the RPC proxy, and only after you change routes.
240
+
241
+ | Change | What to run |
242
+ |---|---|
243
+ | Added, renamed or removed a route | `pnpm codegen`, then commit the regenerated route map |
244
+ | Changed a route's input or output types | Nothing — the client infers from `AppRouter` |
245
+ | Changed an entity | `pnpm db:generate` for the migration |
219
246
 
220
- **Key Principles:**
221
- - **Route**: Thin layer, input validation only
222
- - **Repository**: All business logic and DB access
223
- - **Entity**: Schema definition only, no logic
247
+ A route name that is missing from the merged route map produces a **404 from the proxy**,
248
+ not from your backend. That is almost always a codegen you did not re-run.
249
+
250
+ Generated files are output. Never hand-edit them.
224
251
 
225
252
  ---
226
253
 
227
- ## Directory Structure
254
+ ## Why does the server refuse to start after a package upgrade?
255
+
256
+ Because the database is behind the code. A function package ships its own migrations, so
257
+ bumping `@spfn/auth` can add columns your database has never heard of. Before this check
258
+ existed, that server booted, passed its health check, and then failed every request
259
+ touching a new column with an opaque 500 — the error surfaced at the worst possible
260
+ moment, to whoever called first.
261
+
262
+ `startServer()` now compares what each installed function package ships (and
263
+ `src/server/drizzle`, where present) against what the database records as applied, and
264
+ stops:
228
265
 
229
266
  ```
230
- src/server/
231
- ├── entities/ # Database schema
232
- │ ├── users.ts
233
- │ └── index.ts
234
- ├── repositories/ # Data access + business logic
235
- │ ├── user.repository.ts
236
- │ └── index.ts
237
- ├── routes/ # API routes
238
- │ ├── users.ts
239
- │ └── index.ts
240
- ├── server.config.ts # Server configuration
241
- └── index.ts # Entry point
267
+ Refusing to start: 1 pending migration(s) in @spfn/auth
268
+ @spfn/auth: 1 pending migration(s) (12/13 applied)
269
+ - 20260805143152_client_identity
270
+ Run: pnpm spfn db migrate
242
271
  ```
243
272
 
244
- ---
273
+ The check happens after the database connects and before anything is served, on the pool
274
+ the server already opened — no second connection, and no new failure mode. Three cases
275
+ never reach a refusal:
245
276
 
246
- ## Core Concepts
277
+ | Situation | What happens |
278
+ |---|---|
279
+ | The app initializes no database, or no package ships migrations | Skipped; boot proceeds as before |
280
+ | The database is configured but unreachable | `initDatabase()` already failed — the gate never runs, so an outage never reads as drift |
281
+ | The status query itself fails | Logged as "could not verify", boot proceeds |
247
282
 
248
- ### Route Definition
283
+ To start anyway — a harness that migrates after boot, a rollout that must proceed —
284
+ set `SPFN_ALLOW_PENDING_MIGRATIONS=true`, pass `spfn dev --allow-pending-migrations`, or
285
+ declare it in config:
249
286
 
250
287
  ```typescript
251
- import { route } from '@spfn/core/route';
252
- import { Type } from '@sinclair/typebox';
253
-
254
- // GET with params and query
255
- route.get('/users/:id')
256
- .input({
257
- params: Type.Object({ id: Type.String() }),
258
- query: Type.Object({ include: Type.Optional(Type.String()) })
259
- })
260
- .handler(async (c) => {
261
- const { params, query } = await c.data();
262
- // params.id, query.include are fully typed
263
- });
264
-
265
- // POST with body
266
- route.post('/users')
267
- .input({
268
- body: Type.Object({
269
- email: Type.String(),
270
- name: Type.String()
271
- })
272
- })
273
- .handler(async (c) => {
274
- const { body } = await c.data();
275
- // body.email, body.name are fully typed
276
- });
288
+ export default defineServerConfig()
289
+ .migrations({ allowPending: true })
290
+ .build();
277
291
  ```
278
292
 
279
- ### Repository Pattern
293
+ All three log the pending list as a warning rather than silently continuing.
280
294
 
281
- ```typescript
282
- import { BaseRepository } from '@spfn/core/db';
295
+ **A readiness probe sees the same thing.** When detailed health is on, `GET /health`
296
+ carries a `migrations` object beside `services`:
283
297
 
284
- export class UserRepository extends BaseRepository
298
+ ```json
285
299
  {
286
- // Protected helpers available:
287
- // _findOne, _findMany, _create, _createMany
288
- // _updateOne, _updateMany, _deleteOne, _deleteMany
289
- // _count, _upsert
290
-
291
- async findActive()
292
- {
293
- return this._findMany(users, {
294
- where: { isActive: true },
295
- orderBy: desc(users.createdAt),
296
- limit: 10
297
- });
298
- }
300
+ "status": "ok",
301
+ "timestamp": "2026-08-06T09:00:00.000Z",
302
+ "services": { "database": { "status": "connected" }, "redis": { "status": "connected" } },
303
+ "migrations": {
304
+ "status": "up_to_date",
305
+ "pending": 0,
306
+ "checkedAt": "2026-08-06T09:00:00.000Z",
307
+ "targets": [{ "name": "@spfn/auth", "total": 13, "applied": 13, "pending": 0, "pendingTags": [] }]
308
+ }
299
309
  }
300
310
  ```
301
311
 
302
- ### Transaction
312
+ `status` is `unknown` when there was nothing to check or the check failed — never
313
+ conflated with `up_to_date`. The snapshot is recomputed at most once every 30 seconds, so
314
+ a probe polling every few seconds costs no extra round-trips. The overall health `status`
315
+ is deliberately left alone: reporting drift must not, by itself, pull a running
316
+ deployment out of rotation. A probe that wants that asserts `migrations.pending === 0`.
303
317
 
304
- ```typescript
305
- import { Transactional } from '@spfn/core/db';
318
+ The serverless path (`createServerlessApp`) has no boot to gate — run
319
+ `spfn db migrate` as a deploy step there, as you already do for seeding.
306
320
 
307
- // Middleware-based (recommended)
308
- route.post('/users')
309
- .use([Transactional()])
310
- .handler(async (c) => {
311
- // Auto commit on success
312
- // Auto rollback on error
313
- });
321
+ ---
314
322
 
315
- // Manual control
316
- import { runWithTransaction } from '@spfn/core/db';
323
+ ## Can I deploy this to Vercel?
317
324
 
318
- await runWithTransaction(async () => {
319
- await userRepo.create(userData);
320
- await profileRepo.create(profileData);
321
- });
325
+ Yes, and it is a first-class target rather than a workaround. From your app:
326
+
327
+ ```bash
328
+ spfn add vercel
322
329
  ```
323
330
 
324
- ### Schema Helpers
331
+ That scaffolds `src/app/api/backend/[[...route]]/route.ts` (a `hono/vercel` adapter) and
332
+ `vercel.json`. The SPFN app mounts under `/api/backend`, so the Next.js frontend and the
333
+ backend share one Vercel origin — point `SPFN_API_URL` at
334
+ `https://<your-domain>/api/backend`. It runs on the Node runtime, not edge, because SPFN
335
+ needs `pg` and native `bcrypt`.
336
+
337
+ The adapter is a thin wrapper around `createServerlessApp` from `@spfn/core/server`:
325
338
 
326
339
  ```typescript
327
- import {
328
- id, // bigserial primary key
329
- uuid, // uuid primary key
330
- timestamps, // createdAt, updatedAt
331
- foreignKey, // required FK with cascade
332
- optionalForeignKey, // nullable FK
333
- softDelete, // deletedAt, deletedBy
334
- enumText, // type-safe enum
335
- typedJsonb // type-safe JSONB
336
- } from '@spfn/core/db';
337
-
338
- export const posts = pgTable('posts', {
339
- id: id(),
340
- title: text('title').notNull(),
341
- authorId: foreignKey('author', () => users.id),
342
- status: enumText('status', ['draft', 'published']).default('draft'),
343
- metadata: typedJsonb<{ views: number }>('metadata'),
344
- ...timestamps(),
345
- ...softDelete()
346
- });
340
+ import { handle } from 'hono/vercel';
341
+ import { createServerlessApp } from '@spfn/core/server';
342
+ import serverConfig from '@/server/server.config';
343
+
344
+ const handler = async (req: Request) => handle(await createServerlessApp(serverConfig))(req);
345
+ export const GET = handler;
346
+ export const POST = handler;
347
347
  ```
348
348
 
349
+ It differs from `startServer()` in four ways, all forced by the platform:
350
+
351
+ | | Always-on (`startServer`) | Serverless (`createServerlessApp`) |
352
+ |---|---|---|
353
+ | Database init | welded to `serve()` | in the handler, once per warm container |
354
+ | Periodic DB health check | on | off — a timer only leaks across frozen invocations |
355
+ | Background job worker | runs in-process | **not started** — enqueuing works, nothing drains the queue |
356
+ | Seed and RBAC provisioning | per boot | a deploy-time step (`provisionInfrastructure`) |
357
+
358
+ The job worker is the one that bites. If your config declares jobs, the serverless path
359
+ logs a warning at startup: drain the queue from a scheduled endpoint (Vercel Cron calling
360
+ a route that processes a batch), or run jobs on an always-on target.
361
+
362
+ Background jobs, WebSocket events and the periodic health check all need the always-on
363
+ path: `spfn build && spfn start`, or the generated Docker files.
364
+
349
365
  ---
350
366
 
351
- ## Module Documentation
352
-
353
- | Module | Description |
354
- |--------|-------------|
355
- | [Route](./docs/route.md) | Route definition, validation, response patterns |
356
- | [Database](./docs/database.md) | Connection, helpers, transactions |
357
- | [Entity](./docs/entity.md) | Schema definition, column helpers |
358
- | [Repository](./docs/repository.md) | BaseRepository, CRUD patterns |
359
- | [Middleware](./docs/middleware.md) | Named middleware, skip control |
360
- | [Server](./docs/server.md) | Configuration, lifecycle hooks |
361
- | [Errors](./docs/errors.md) | Error types, handling patterns |
362
- | [Environment](./docs/env.md) | Type-safe environment variables |
363
- | [Codegen](./docs/codegen.md) | API client generation |
364
- | [Cache](./docs/cache.md) | Redis caching |
365
- | [Event](./docs/event.md) | Event system |
366
- | [Job](./docs/job.md) | Background jobs |
367
- | [Logger](./docs/logger.md) | Logging |
368
- | [Next.js](./docs/nextjs.md) | Next.js integration, RPC proxy |
367
+ ## How do I point Claude Code or another AI coding agent at this?
368
+
369
+ Put the contract in a file and let the agent read it, instead of describing the
370
+ architecture again in every prompt.
371
+
372
+ The SPFN repository states it in `CONTRIBUTING.md`: what the repo is, the commands, the
373
+ vertical-slice pattern, and the rules that are not negotiable — never hand-edit generated
374
+ files, migrations come from the schema. One file answers to people and agents alike, so
375
+ there is no second copy to drift apart from the first.
376
+
377
+ Each module README under `src/` is written for the same reader. When an agent is working
378
+ on database code, `src/db/README.md` is the page to give it.
369
379
 
370
380
  ---
371
381
 
372
- ## CLI Commands
382
+ ## How do I operate the app from the terminal?
373
383
 
374
- ```bash
375
- # Migration
376
- npx spfn db generate # Generate migration
377
- npx spfn db migrate # Apply migration
384
+ Develop operations the way you develop features — as routes — and let the `spfn ops` CLI
385
+ discover and invoke them. No admin dashboard, and no extra vocabulary: an ops command is a
386
+ vertical slice whose path lives under `/_ops/`.
387
+
388
+ ```typescript
389
+ // src/server/ops.ts
390
+ import { Type } from '@sinclair/typebox';
391
+ import { createOpsRouter, opsRoute } from '@spfn/core/ops';
392
+ import { opsTokenAuth, requireOpsScope } from '@spfn/auth/server';
393
+
394
+ export const opsRouter = createOpsRouter({
395
+ listSignups: opsRoute.get('/signups') // GET /_ops/signups
396
+ .use([requireOpsScope('waitlist:read')])
397
+ .input({ query: Type.Object({ limit: Type.Optional(Type.Number()) }) })
398
+ .handler(async (c) => signupsRepository.list((await c.data()).query.limit)),
399
+ }, { auth: opsTokenAuth });
400
+
401
+ // src/server/router.ts — mounted like any package router, invisible to client types
402
+ export const appRouter = defineRouter({ ... }).packages([opsRouter]);
403
+ ```
404
+
405
+ `opsRoute` is `route` with the `/_ops` namespace applied, so a definition carries only the
406
+ path this app owns — what that path looks like, how it nests, which segments are
407
+ parameters are the app's decisions. It exists from `@spfn/core` **0.3.0-beta.2**.
408
+ `createOpsRouter` injects the auth middleware into every route (there is no
409
+ unauthenticated variant) and serves `GET /_ops/_manifest` — the self-description the CLI
410
+ reads, with each command's TypeBox schemas as JSON Schema.
411
+
412
+ The manifest is registered ahead of the ops routes, so none of them can take its URL even
413
+ when one is a pattern like `/_ops/:name`. That ordering does not reach outside the ops
414
+ router: routes an app declares in its own router are registered before any package router,
415
+ so a pattern there that covers `/_ops/_manifest` — a `/*` catch-all, say — shadows the
416
+ manifest, exactly as it shadows every other package route.
417
+
418
+ Routes may be grouped in nested `defineRouter`s, and a group's own `.use()` middlewares
419
+ apply to its routes — always after the injected auth, so a group-wide guard reads a
420
+ request that has already been authenticated. A group-level middleware must be a named one;
421
+ wrap a factory to give it a name:
422
+
423
+ ```typescript
424
+ const requireAdmin = defineMiddleware('opsAdminScope', requireOpsScope('admin:read'));
425
+
426
+ export const opsRouter = createOpsRouter({
427
+ admin: defineRouter({ getStats, reindex }).use([requireAdmin]),
428
+ }, { auth: opsTokenAuth });
429
+ ```
378
430
 
379
- # Development
380
- pnpm spfn:dev # Start dev server (auto codegen)
431
+ Note that a group's `.use()` middleware carries its `skips` into every route in the group,
432
+ suppressing that server-level middleware there.
381
433
 
382
- # API Client Generation
383
- pnpm spfn codegen run
434
+ Three things are refused when the surface is defined, rather than discovered in
435
+ production: a route built with `route` instead of `opsRoute` (it would carry no
436
+ namespace), two routes sharing a command name (the manifest flattens nested groups into
437
+ one list, so the CLI could not tell them apart), and a group mounting `.packages()` (those
438
+ routes register with neither the namespace nor the auth injection). The command name
439
+ `getOpsManifest` and the path `/_ops/_manifest` are reserved.
384
440
 
385
- # Database Studio
386
- pnpm drizzle-kit studio
441
+ ```bash
442
+ spfn ops list --app https://api.example.com # discover commands
443
+ spfn ops call listSignups --query limit=50 # invoke one
444
+ spfn ops call listSignups --describe # print its usage (--json for raw schemas)
387
445
  ```
388
446
 
447
+ Authentication is an ops token from [`@spfn/auth`](../auth/README.md#ops-tokens-spfn-ops):
448
+ scoped, revocable, hash-stored, issued with `spfn ops token issue` where database access
449
+ already exists — a deployed app has no token-creation endpoint. On macOS the CLI keeps the
450
+ token in the keychain (`spfn ops token store`), and resolution order is `--token` →
451
+ `SPFN_OPS_TOKEN` → keychain.
452
+
453
+ ---
454
+
455
+ ## How is this different from NestJS or tRPC?
456
+
457
+ **NestJS** gives you a structured backend and leaves the structure to you: modules and
458
+ providers you design, validation and ORM of your choosing, DTOs you keep in sync with the
459
+ frontend by hand. It is the better fit when you need that freedom, or when your frontend
460
+ is not Next.js. `@spfn/core` fixes the shape instead — one vertical slice per feature,
461
+ TypeBox, Drizzle, and route types that reach the browser without a DTO layer. Its
462
+ ecosystem is small and young where NestJS's is large and mature.
463
+
464
+ **tRPC** solves a narrower problem, typed calls between client and server, and leaves
465
+ structure, persistence and auth to you. `@spfn/core` includes the typed-call layer as one
466
+ part of a whole backend.
467
+
468
+ ---
469
+
470
+ ## Pitfalls
471
+
472
+ - **There is no root barrel.** `import … from '@spfn/core'` does not resolve. Import from
473
+ a subpath. The module table above is the complete surface.
474
+ - **Use `@spfn/core/nextjs` for the client, not `@spfn/core/client`.** `package.json`
475
+ still lists a `./client` export, but the build does not emit it — the entry is disabled
476
+ and there is no `src/client`. `createApi`, `ApiError` and every client type ship from
477
+ `@spfn/core/nextjs`. Treat `@spfn/core/client` as non-functional.
478
+ - **The client/server boundary is load-bearing.** `@spfn/core/nextjs/server` pulls in
479
+ `next/headers` and `next/server`; importing it from a Client Component breaks the
480
+ build. `@spfn/core/env/loader`, `@spfn/core/event/sse` and `@spfn/core/event/ws` are
481
+ server-only too. Client code uses the `*/client` and isomorphic entry points.
482
+ - **`db/manager`, `db/schema` and `db/transaction` are not subpaths.** Importing
483
+ `@spfn/core/db/transaction` fails to resolve. Import those symbols from `@spfn/core/db`.
484
+ - **The client needs no codegen; the proxy does.** A missing route name is a proxy 404.
485
+ - **Contracts are for clients TypeScript cannot reach.** `.contract()` and the build gate
486
+ exist for a mobile app or an external consumer, compiled and shipped separately. A web
487
+ client takes its types from `AppRouter` in the same build, so a removed response field
488
+ already breaks the compile. Do not put `.contract()` on a route only the web app calls.
489
+ - **The proxy decides the real HTTP method.** The browser only sends GET or POST to
490
+ `/api/rpc/...`; a PUT, PATCH or DELETE route still works because the method comes from
491
+ the route map.
492
+ - **A package upgrade is not done until `spfn db migrate` has run.** The server refuses to
493
+ start while a function package has migrations the database has not applied. That is the
494
+ gate working, not a bug — see
495
+ [Why does the server refuse to start after a package upgrade?](#why-does-the-server-refuse-to-start-after-a-package-upgrade)
496
+ - **Cache, events and jobs degrade quietly.** `@spfn/core/cache` runs disabled — its
497
+ getters return `undefined` — when there is no cache config or no `ioredis`, and
498
+ WebSocket events need the optional `ws` dependency. Do not write code expecting them to
499
+ throw.
500
+
501
+ ---
502
+
503
+ ## FAQ
504
+
505
+ **Can I use @spfn/core without Next.js?**
506
+ Yes. `next` is an optional peer dependency and the Next.js bridge is two modules
507
+ (`@spfn/core/nextjs`, `@spfn/core/nextjs/server`). The server runs on Hono by itself. You
508
+ give up the typed client and the RPC proxy, which are the Next.js-side pieces.
509
+
510
+ **Can I use Prisma instead of Drizzle?**
511
+ No. `drizzle-orm` is a required peer dependency (`>=1.0.0-rc.4 <2`) and the repository
512
+ layer is built on it.
513
+
514
+ **Do I need Redis?**
515
+ Only for features that use it. `ioredis` is an optional dependency, and without cache
516
+ configuration the cache module reports itself disabled instead of failing. The same holds
517
+ for `ws` and WebSocket events.
518
+
519
+ **Does @spfn/core require PostgreSQL specifically?**
520
+ Yes, 14 or later. The data layer is Drizzle on Postgres, with `postgres.js` as the
521
+ default driver; the provider is injectable, which is how PGlite works in tests.
522
+
523
+ **Why can't I import from `@spfn/core` directly?**
524
+ There is no `.` entry in `exports` — by design. Subpaths keep server-only code out of
525
+ client bundles, which a single barrel file cannot do.
526
+
527
+ **Which Node version?**
528
+ `>=20.0.0`. The package is ESM only.
529
+
530
+ **Where do my business rules go?**
531
+ In services and repositories, not in route handlers. A handler validates, calls, and
532
+ returns. `@spfn/core/authz` covers the one rule that is easy to forget: `requireOwner`
533
+ returns the resource only if it belongs to the requester, and answers "not found" either
534
+ way so the endpoint never reveals that someone else's record exists.
535
+
536
+ ---
537
+
538
+ ## Related packages
539
+
540
+ Other SPFN packages build on `@spfn/core`:
541
+
542
+ - [`@spfn/auth`](../auth/README.md) — sessions, social login and RBAC. Exports
543
+ `authRouteMap` and registers proxy interceptors automatically; merge its route map into
544
+ `createRpcProxy`.
545
+ - [`@spfn/mcp`](../mcp/README.md) — exposes your operations as MCP tools, so an agent can
546
+ run them instead of you building an admin dashboard.
547
+ - `@spfn/i18n`, `@spfn/storage`, `@spfn/notification`, `@spfn/cms`, `@spfn/monitor`,
548
+ `@spfn/migrate`, `@spfn/workflow` — see each package's README under
549
+ [`packages/`](../).
550
+
389
551
  ---
390
552
 
391
553
  ## License
392
554
 
393
- MIT
555
+ MIT © FXY Inc.