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

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 (119) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +693 -279
  3. package/dist/app-config/index.d.ts +96 -0
  4. package/dist/app-config/index.js +58 -0
  5. package/dist/app-config/index.js.map +1 -0
  6. package/dist/authz/index.d.ts +34 -0
  7. package/dist/authz/index.js +430 -0
  8. package/dist/authz/index.js.map +1 -0
  9. package/dist/boss-BiDCbbAQ.d.ts +337 -0
  10. package/dist/cache/index.js +42 -30
  11. package/dist/cache/index.js.map +1 -1
  12. package/dist/codegen/index.d.ts +121 -13
  13. package/dist/codegen/index.js +212 -15
  14. package/dist/codegen/index.js.map +1 -1
  15. package/dist/config/index.d.ts +576 -30
  16. package/dist/config/index.js +136 -17
  17. package/dist/config/index.js.map +1 -1
  18. package/dist/contract/index.d.ts +221 -0
  19. package/dist/contract/index.js +558 -0
  20. package/dist/contract/index.js.map +1 -0
  21. package/dist/db/index.d.ts +704 -94
  22. package/dist/db/index.js +1105 -260
  23. package/dist/db/index.js.map +1 -1
  24. package/dist/define-middleware-CVKgqo8S.d.ts +167 -0
  25. package/dist/env/index.d.ts +26 -2
  26. package/dist/env/index.js +15 -5
  27. package/dist/env/index.js.map +1 -1
  28. package/dist/env/loader.d.ts +26 -19
  29. package/dist/env/loader.js +55 -25
  30. package/dist/env/loader.js.map +1 -1
  31. package/dist/errors/index.d.ts +10 -0
  32. package/dist/errors/index.js +40 -7
  33. package/dist/errors/index.js.map +1 -1
  34. package/dist/event/index.d.ts +33 -3
  35. package/dist/event/index.js +24 -3
  36. package/dist/event/index.js.map +1 -1
  37. package/dist/event/sse/client.d.ts +55 -3
  38. package/dist/event/sse/client.js +164 -45
  39. package/dist/event/sse/client.js.map +1 -1
  40. package/dist/event/sse/index.d.ts +12 -5
  41. package/dist/event/sse/index.js +280 -32
  42. package/dist/event/sse/index.js.map +1 -1
  43. package/dist/event/ws/client.d.ts +59 -0
  44. package/dist/event/ws/client.js +273 -0
  45. package/dist/event/ws/client.js.map +1 -0
  46. package/dist/event/ws/index.d.ts +94 -0
  47. package/dist/event/ws/index.js +272 -0
  48. package/dist/event/ws/index.js.map +1 -0
  49. package/dist/job/index.d.ts +48 -3
  50. package/dist/job/index.js +265 -74
  51. package/dist/job/index.js.map +1 -1
  52. package/dist/logger/index.d.ts +5 -0
  53. package/dist/logger/index.js +14 -12
  54. package/dist/logger/index.js.map +1 -1
  55. package/dist/middleware/index.d.ts +352 -10
  56. package/dist/middleware/index.js +1079 -13
  57. package/dist/middleware/index.js.map +1 -1
  58. package/dist/nextjs/index.d.ts +39 -3
  59. package/dist/nextjs/index.js +82 -29
  60. package/dist/nextjs/index.js.map +1 -1
  61. package/dist/nextjs/server.d.ts +69 -52
  62. package/dist/nextjs/server.js +140 -60
  63. package/dist/nextjs/server.js.map +1 -1
  64. package/dist/ops/index.d.ts +208 -0
  65. package/dist/ops/index.js +800 -0
  66. package/dist/ops/index.js.map +1 -0
  67. package/dist/route/index.d.ts +10 -694
  68. package/dist/route/index.js +112 -23
  69. package/dist/route/index.js.map +1 -1
  70. package/dist/route-builder-2ani2jEI.d.ts +553 -0
  71. package/dist/router-DJdpwuB6.d.ts +127 -0
  72. package/dist/security/index.d.ts +83 -0
  73. package/dist/security/index.js +173 -0
  74. package/dist/security/index.js.map +1 -0
  75. package/dist/server/index.d.ts +619 -31
  76. package/dist/server/index.js +2583 -360
  77. package/dist/server/index.js.map +1 -1
  78. package/dist/token-manager-BT5EnUAR.d.ts +278 -0
  79. package/dist/types-2AbaW4Ie.d.ts +205 -0
  80. package/dist/{types-BOPTApC2.d.ts → types-9oszaJqp.d.ts} +7 -2
  81. package/dist/types-ClQVomgV.d.ts +115 -0
  82. package/dist/types-ZQODsBft.d.ts +282 -0
  83. package/docs/file-upload.md +195 -333
  84. package/package.json +250 -208
  85. package/src/cache/README.md +330 -0
  86. package/src/codegen/README.md +516 -0
  87. package/src/config/README.md +326 -0
  88. package/src/contract/README.md +326 -0
  89. package/src/db/README.md +589 -0
  90. package/src/db/manager/README.md +500 -0
  91. package/src/db/schema/README.md +354 -0
  92. package/src/db/transaction/README.md +822 -0
  93. package/src/env/README.md +651 -0
  94. package/src/errors/README.md +429 -0
  95. package/src/event/README.md +741 -0
  96. package/src/job/README.md +613 -0
  97. package/src/logger/README.md +321 -0
  98. package/src/middleware/README.md +634 -0
  99. package/src/nextjs/README.md +608 -0
  100. package/src/route/README.md +738 -0
  101. package/src/security/README.md +100 -0
  102. package/src/server/README.md +704 -0
  103. package/dist/boss-DI1r4kTS.d.ts +0 -244
  104. package/dist/router-Di7ENoah.d.ts +0 -151
  105. package/dist/types-B-e_f2dQ.d.ts +0 -121
  106. package/docs/cache.md +0 -133
  107. package/docs/codegen.md +0 -74
  108. package/docs/database.md +0 -346
  109. package/docs/entity.md +0 -539
  110. package/docs/env.md +0 -477
  111. package/docs/errors.md +0 -319
  112. package/docs/event.md +0 -116
  113. package/docs/job.md +0 -131
  114. package/docs/logger.md +0 -108
  115. package/docs/middleware.md +0 -337
  116. package/docs/nextjs.md +0 -241
  117. package/docs/repository.md +0 -496
  118. package/docs/route.md +0 -497
  119. package/docs/server.md +0 -307
package/README.md CHANGED
@@ -1,393 +1,807 @@
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.3.3 (only for the Next.js bridge)
47
+ ```
48
+
49
+ Node `>=20.0.0`. ESM only.
50
+
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.
58
+
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.
61
+
62
+ ---
63
+
64
+ ## What does one feature look like?
65
+
66
+ Four files, always the same four, always in the same places:
67
+
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
74
+ ```
75
+
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.
79
+
80
+ ---
81
+
82
+ ## How does a route's type reach the browser?
83
+
84
+ Through TypeScript inference, not generated client code. One artifact is generated —
85
+ the route map the RPC proxy needs — and nothing else.
86
+
15
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
+ ```
105
+
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`.
16
131
 
17
- ## Quick Start
132
+ ---
18
133
 
19
- ### 1. Define Entity
134
+ ## Show me the whole thing in code
20
135
 
21
136
  ```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()
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';
141
+
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
+ }),
32
159
  });
33
160
 
34
- export type User = typeof users.$inferSelect;
35
- export type NewUser = typeof users.$inferInsert;
161
+ export type AppRouter = typeof appRouter;
162
+ ```
163
+
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
+ ```
172
+
173
+ ```typescript
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
177
+
178
+ export const { GET, POST } = createRpcProxy({ routeMap });
179
+ ```
180
+
181
+ ```typescript
182
+ // lib/api.ts — ⑥ (client-safe; no codegen)
183
+ import { createApi } from '@spfn/core/nextjs';
184
+ import type { AppRouter } from '@/server/router';
185
+
186
+ export const api = createApi<AppRouter>();
187
+
188
+ // the same client in a Server Component, a Client Component or a 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' } });
36
191
  ```
37
192
 
38
- ### 2. Create Repository
193
+ That the client is isomorphic does not make the three callers interchangeable. **A page's
194
+ initial data is awaited in the Server Component**, where
195
+ `api.getUser.fetchOptions({ next: { revalidate, tags } }).call(…)` participates in
196
+ Next.js caching and reaches the backend without a browser round trip. Fetching that same
197
+ first paint from a `'use client'` component inside a `useEffect` is the anti-pattern: it
198
+ ships a loading state and a second network hop for data the server already had. Client
199
+ Components and Server Actions are for what happens after the first paint — interaction
200
+ and mutation. Cache tags, revalidation and SSR cookie forwarding are in
201
+ [the Next.js bridge docs](https://superfunction.xyz/docs/packages/core/nextjs).
202
+
203
+ ---
204
+
205
+ ## How does a repository talk to the database?
206
+
207
+ Through `BaseRepository`. Extending it gives a repository two transaction-aware
208
+ connections — `this.db` (write/primary) and `this.readDb` (the replica, when one is
209
+ configured) — plus the CRUD set as protected methods.
39
210
 
40
211
  ```typescript
41
- // src/server/repositories/user.repository.ts
212
+ // server/repositories/order.ts
42
213
  import { BaseRepository } from '@spfn/core/db';
43
- import { users, type User, type NewUser } from '../entities/users';
214
+ import { desc } from 'drizzle-orm';
215
+ import { orders } from '../entities/order';
44
216
 
45
- export class UserRepository extends BaseRepository
217
+ export class OrderRepository extends BaseRepository
46
218
  {
47
- async findById(id: string): Promise<User | null>
219
+ findRecentFor(userId: string)
48
220
  {
49
- return this._findOne(users, { id });
221
+ return this._findMany(orders, { where: { userId }, orderBy: desc(orders.createdAt) });
50
222
  }
51
223
 
52
- async findByEmail(email: string): Promise<User | null>
224
+ place(data: { userId: string; total: number })
53
225
  {
54
- return this._findOne(users, { email });
226
+ return this._create(orders, data);
55
227
  }
228
+ }
56
229
 
57
- async findAll(): Promise<User[]>
58
- {
59
- return this._findMany(users);
60
- }
230
+ export const orderRepo = new OrderRepository();
231
+ ```
61
232
 
62
- async create(data: NewUser): Promise<User>
63
- {
64
- return this._create(users, data);
65
- }
233
+ Handlers never import drizzle query builders; repositories do. `_findMany` reads through
234
+ `this.readDb`, `_create` writes through `this.db`, and both getters resolve to the active
235
+ transaction's connection when there is one — so the same method is correct inside a
236
+ transaction and outside it. When a helper cannot express a query, drop to
237
+ `this.readDb.select()…` inside the repository rather than in the handler. The full
238
+ protected CRUD set is in [src/db](./src/db/README.md).
66
239
 
67
- async update(id: string, data: Partial<NewUser>): Promise<User | null>
68
- {
69
- return this._updateOne(users, { id }, data);
70
- }
240
+ ---
241
+
242
+ ## Where do transactions and their side effects go?
243
+
244
+ `Transactional()` covers the route case — commit on return, rollback on throw. Two rules
245
+ decide the rest.
246
+
247
+ **Nothing takes a `tx` parameter.** The transaction travels in AsyncLocalStorage, so
248
+ `this.db` inside a repository already resolves to it. A service that accepts `tx` and
249
+ threads it downward re-implements propagation that already happened, and the first caller
250
+ that forgets to pass it writes outside the transaction. For a service, script or job with
251
+ no route around it, open one with `runInTransaction(fn, options?)`;
252
+ `runWithTransaction(tx, txId, fn)` is the lower-level primitive that binds an existing
253
+ Drizzle transaction into the context.
71
254
 
72
- async delete(id: string): Promise<User | null>
255
+ **Side effects go on the commit hooks, not inline.** An event emitted or a mail sent from
256
+ inside the transaction still went out when the transaction later rolls back.
257
+
258
+ | Hook | When it runs | What it is for |
259
+ |---|---|---|
260
+ | `onBeforeCommit(fn)` | Inside the still-open transaction, just before commit — a throw aborts and rolls back | Last-moment invariant checks, and statements that must land in the same commit |
261
+ | `onAfterCommit(fn)` | After the root transaction commits, outside the transaction context; errors are logged, never thrown | Events, mail, cache invalidation — anything the outside world observes |
262
+ | `onAfterRollback(fn)` | After the root transaction rolls back, before the causing error propagates; errors are logged, never thrown | Undoing external work that cannot roll itself back, such as an object already uploaded |
263
+
264
+ All three import from `@spfn/core/db`, can be registered anywhere inside the transaction,
265
+ and bubble to the **root** transaction — a nested block's callbacks fire on the outermost
266
+ outcome, not on a savepoint's.
267
+
268
+ ```typescript
269
+ import { runInTransaction, onAfterCommit } from '@spfn/core/db';
270
+
271
+ export async function placeOrder(input: { userId: string; total: number })
272
+ {
273
+ return runInTransaction(async () =>
73
274
  {
74
- return this._deleteOne(users, { id });
75
- }
275
+ const order = await orderRepo.place(input); // no tx argument, at any depth
276
+ onAfterCommit(() => orderPlacedEvent.emit({ orderId: order.id }));
277
+
278
+ return order;
279
+ });
76
280
  }
77
281
  ```
78
282
 
79
- ### 3. Define Routes
283
+ ---
80
284
 
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';
285
+ ## What else can defineServerConfig configure?
286
+
287
+ `.port()` and `.routes()` are the two every app calls. The rest of the builder is how an
288
+ app wires its infrastructure without touching the server's boot sequence. Every method
289
+ returns the builder; `.build()` ends the chain.
290
+
291
+ | Method | What it configures |
292
+ |---|---|
293
+ | `.port(n)` / `.host(s)` | Where the server listens |
294
+ | `.routes(router)` | The `defineRouter` router to mount, with its own `.use()` and `.packages()` |
295
+ | `.jobs(router, config?)` | Background jobs — a `defineJobRouter`, plus pg-boss options |
296
+ | `.events(router, config?)` | SSE streaming — a `defineEventRouter`, served at `GET /events/stream` |
297
+ | `.websockets(router, config?)` | Bidirectional WebSockets — a `defineWSRouter`, served at `WS /ws` |
298
+ | `.workflows(router, config?)` | `@spfn/workflow` orchestration; the engine starts once the database is ready |
299
+ | `.lifecycle(hooks)` | Boot and shutdown hooks. Callable more than once; hooks run in registration order |
300
+ | `.migrations(opts)` | The migration boot gate — `{ allowPending: true }` lets a server start behind its migrations |
301
+ | `.database(opts)` | Connection and pool settings |
302
+ | `.infrastructure(opts)` | Which infrastructure is initialized at boot |
303
+ | `.healthCheck(opts)` | The health endpoint |
304
+ | `.serverTime(clock)` | The clock behind `GET /_core/time`; normally left at the default |
305
+ | `.middleware(opts)` | The built-in middleware — `ErrorHandler`, `RequestLogger` |
306
+ | `.use(handlers)` | Additional global Hono middleware |
307
+ | `.middlewares(named)` | Global middleware under names, so a route can `.skip([...])` it |
308
+ | `.cors(opts)` | CORS |
309
+ | `.rateLimit(opts)` | A global default limiter plus the named policies routes resolve against |
310
+ | `.proxyGuard(opts)` | Trusted-proxy signature and origin verification, resolved to a `clientType` |
311
+ | `.outboundFetch(opts)` | The SSRF policy `safeFetch` applies to outbound calls |
312
+ | `.timeout(opts)` / `.shutdown(opts)` | Request timeouts and graceful shutdown |
313
+ | `.debug(bool)` | Debug logging |
314
+
315
+ The options each one takes are in [src/server](./src/server/README.md).
87
316
 
88
- const userRepo = new UserRepository();
317
+ ---
89
318
 
90
- export const getUsers = route.get('/users')
91
- .handler(async () => {
92
- return userRepo.findAll();
93
- });
319
+ ## Which import path do I use for what?
320
+
321
+ There is **no root barrel**: `import … from '@spfn/core'` does not resolve. Every symbol
322
+ comes from a subpath, and the table below is the complete public surface — one row per
323
+ entry in `package.json` `exports`, with a single exclusion: `./client` is still listed in
324
+ `exports` but the build no longer emits it, so it has no row (see [Pitfalls](#pitfalls)).
325
+ Each module has its own README with the API detail.
326
+
327
+ | Import path | Purpose | Doc |
328
+ |-------------|---------|-----|
329
+ | `@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) |
330
+ | `@spfn/core/route/types` | Shared route types (`HttpMethod`, router type primitives). | [src/route](./src/route/README.md) |
331
+ | `@spfn/core/server` | Server entry: `defineServerConfig()` → `startServer()`, plus `createServerlessApp()` for Vercel. Middleware wiring, infra init, graceful shutdown. | [src/server](./src/server/README.md) |
332
+ | `@spfn/core/nextjs` | Client-safe: `createApi<AppRouter>()`, `ApiError`, client types. Never touches `next/headers`. | [src/nextjs](./src/nextjs/README.md) |
333
+ | `@spfn/core/nextjs/server` | Server-only: `createRpcProxy({ routeMap })`, `registerInterceptors`. Uses `next/headers`. | [src/nextjs](./src/nextjs/README.md) |
334
+ | `@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) |
335
+ | `@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) |
336
+ | `@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](https://github.com/fxylabs/spfn/blob/main/packages/core/src/db/migrations/index.ts) |
337
+ | `@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) |
338
+ | `@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) |
339
+ | `@spfn/core/middleware` | Built-in Hono middleware: `ErrorHandler`, `RequestLogger` and its masking helper. | [src/middleware](./src/middleware/README.md) |
340
+ | `@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) |
341
+ | `@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) |
342
+ | `@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](https://github.com/fxylabs/spfn/blob/main/packages/core/src/authz/index.ts) |
343
+ | `@spfn/core/env` | Schema-based environment validation, isomorphic. | [src/env](./src/env/README.md) |
344
+ | `@spfn/core/env/loader` | The **server-only** `.env` file loader (uses `node:fs`). | [src/env](./src/env/README.md) |
345
+ | `@spfn/core/config` | `@spfn/core`'s own validated env config (`env`, `envSchema`, `registry`), built on `@spfn/core/env`. | [src/config](./src/config/README.md) |
346
+ | `@spfn/core/app-config` | Reads `spfn.config.js` — the one committed place that says which ports and host the app is served on (`loadAppConfig`, `resolvePorts`, `resolveHost`, `PORT_DEFAULTS`). Deliberately side-effect free, so the CLI can import it before an app's environment exists. | [src/app-config/index.ts](https://github.com/fxylabs/spfn/blob/main/packages/core/src/app-config/index.ts) |
347
+ | `@spfn/core/logger` | Structured singleton `logger` with child loggers and level masking. No dependencies. | [src/logger](./src/logger/README.md) |
348
+ | `@spfn/core/cache` | Valkey/Redis singleton over ioredis (`getCache`, `getCacheRead`). Degrades to disabled rather than throwing. | [src/cache](./src/cache/README.md) |
349
+ | `@spfn/core/job` | Background jobs on pg-boss: a fluent `job()` builder, cron, run-once, event-driven, `defineJobRouter`. | [src/job](./src/job/README.md) |
350
+ | `@spfn/core/event` | Decoupled pub/sub (`defineEvent`, `defineEventRouter`, `eventRouteMap`). | [src/event](./src/event/README.md) |
351
+ | `@spfn/core/event/sse` | Server-side SSE handler and token manager. Server only. | [src/event](./src/event/README.md) |
352
+ | `@spfn/core/event/sse/client` | Browser SSE client (`EventSource`). | [src/event](./src/event/README.md) |
353
+ | `@spfn/core/event/ws` | Server-side WebSocket handler. Server only; needs the optional `ws` dependency. | [src/event](./src/event/README.md) |
354
+ | `@spfn/core/event/ws/client` | Browser WebSocket client. | [src/event](./src/event/README.md) |
355
+ | `@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) |
356
+ | `@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) |
357
+ | `@spfn/core/ops` | The operations surface `spfn ops` drives: `opsRoute`, `createOpsRouter`, `defineOpsModule`, and the manifest the CLI discovers commands from. Structure only — the router is always authenticated, and token verification lives in `@spfn/auth`. | [How do I operate the app from the terminal?](#how-do-i-operate-the-app-from-the-terminal) |
358
+
359
+ `db/manager`, `db/schema` and `db/transaction` are **not** package subpaths of their own.
360
+ They are internal modules re-exported by `@spfn/core/db` — import their symbols from
361
+ there.
94
362
 
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);
363
+ ---
102
364
 
103
- if (!user)
104
- {
105
- throw new Error('User not found');
106
- }
365
+ ## Do I have to run codegen?
107
366
 
108
- return user;
109
- });
367
+ Only for the RPC proxy, and only after you change routes.
110
368
 
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
- });
369
+ | Change | What to run |
370
+ |---|---|
371
+ | Added, renamed or removed a route | `pnpm codegen`, then commit the regenerated route map |
372
+ | Changed a route's input or output types | Nothing — the client infers from `AppRouter` |
373
+ | Changed an entity | `pnpm db:generate` for the migration |
123
374
 
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
- }
375
+ A route name that is missing from the merged route map produces a **404 from the proxy**,
376
+ not from your backend. That is almost always a codegen you did not re-run.
141
377
 
142
- return user;
143
- });
378
+ Generated files are output. Never hand-edit them.
379
+
380
+ ---
144
381
 
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);
382
+ ## Why does the server refuse to start after a package upgrade?
153
383
 
154
- if (!user)
155
- {
156
- throw new Error('User not found');
157
- }
384
+ Because the database is behind the code. A function package ships its own migrations, so
385
+ bumping `@spfn/auth` can add columns your database has never heard of. Before this check
386
+ existed, that server booted, passed its health check, and then failed every request
387
+ touching a new column with an opaque 500 — the error surfaced at the worst possible
388
+ moment, to whoever called first.
158
389
 
159
- return { success: true };
160
- });
390
+ `startServer()` now compares what each installed function package ships (and
391
+ `src/server/drizzle`, where present) against what the database records as applied, and
392
+ stops:
393
+
394
+ ```
395
+ Refusing to start: 1 pending migration(s) in @spfn/auth
396
+ @spfn/auth: 1 pending migration(s) (12/13 applied)
397
+ - 20260805143152_client_identity
398
+ Run: pnpm spfn db migrate
161
399
  ```
162
400
 
163
- ### 4. Configure Server
401
+ The check happens after the database connects and before anything is served, on the pool
402
+ the server already opened — no second connection, and no new failure mode. Three cases
403
+ never reach a refusal:
164
404
 
165
- ```typescript
166
- // src/server/server.config.ts
167
- import { defineServerConfig, defineRouter } from '@spfn/core/server';
168
- import * as userRoutes from './routes/users';
405
+ | Situation | What happens |
406
+ |---|---|
407
+ | The app initializes no database, or no package ships migrations | Skipped; boot proceeds as before |
408
+ | The database is configured but unreachable | `initDatabase()` already failed — the gate never runs, so an outage never reads as drift |
409
+ | The status query itself fails | Logged as "could not verify", boot proceeds |
169
410
 
170
- const appRouter = defineRouter({
171
- ...userRoutes
172
- });
411
+ To start anyway — a harness that migrates after boot, a rollout that must proceed —
412
+ set `SPFN_ALLOW_PENDING_MIGRATIONS=true`, pass `spfn dev --allow-pending-migrations`, or
413
+ declare it in config:
173
414
 
415
+ ```typescript
174
416
  export default defineServerConfig()
175
- .port(8790)
176
- .routes(appRouter)
417
+ .migrations({ allowPending: true })
177
418
  .build();
419
+ ```
178
420
 
179
- export type AppRouter = typeof appRouter;
421
+ All three log the pending list as a warning rather than silently continuing.
422
+
423
+ **A readiness probe sees the same thing.** When detailed health is on,
424
+ `GET /_core/health` carries a `migrations` object beside `services`:
425
+
426
+ ```json
427
+ {
428
+ "status": "ok",
429
+ "timestamp": "2026-08-06T09:00:00.000Z",
430
+ "services": { "database": { "status": "connected" }, "redis": { "status": "connected" } },
431
+ "migrations": {
432
+ "status": "up_to_date",
433
+ "pending": 0,
434
+ "checkedAt": "2026-08-06T09:00:00.000Z",
435
+ "targets": [{ "name": "@spfn/auth", "total": 13, "applied": 13, "pending": 0, "pendingTags": [] }]
436
+ }
437
+ }
180
438
  ```
181
439
 
182
- ### 5. Start Server
440
+ `status` is `unknown` when there was nothing to check or the check failed — never
441
+ conflated with `up_to_date`. The snapshot is recomputed at most once every 30 seconds, so
442
+ a probe polling every few seconds costs no extra round-trips. The overall health `status`
443
+ is deliberately left alone: reporting drift must not, by itself, pull a running
444
+ deployment out of rotation. A probe that wants that asserts `migrations.pending === 0`.
445
+
446
+ The serverless path (`createServerlessApp`) has no boot to gate — run
447
+ `spfn db migrate` as a deploy step there, as you already do for seeding.
448
+
449
+ ---
450
+
451
+ ## Which path does the health endpoint answer on?
452
+
453
+ `/_core/health`. That is the whole answer — `@spfn/core` registers nothing on `/health`.
454
+
455
+ `/_core/` belongs to `@spfn/core` the way `/_auth/` belongs to `@spfn/auth`: the endpoints
456
+ in it are registered before your routes, so no route you declare can take one. That is
457
+ what makes `/_core/health` the right target for a readiness probe, a Dockerfile
458
+ `HEALTHCHECK` or an uptime monitor — the answer does not depend on what your app defines.
183
459
 
184
460
  ```typescript
185
- // src/server/index.ts
186
- import { startServer } from '@spfn/core/server';
461
+ // the endpoint answers at /_core/health, and nowhere else
462
+ export default defineServerConfig().build();
187
463
 
188
- await startServer();
464
+ // an additional address, for a probe path you cannot change
465
+ export default defineServerConfig().healthCheck({ path: '/health' }).build();
466
+
467
+ // off → nothing answers anywhere
468
+ export default defineServerConfig().healthCheck({ enabled: false }).build();
189
469
  ```
190
470
 
191
- ### 6. Environment Variables
471
+ `healthCheck.path` adds a second address. It never moves `/_core/health`, and it is
472
+ registered before your routes like the canonical one — so an app route on the same path
473
+ will not run, and the server says so at boot.
192
474
 
193
- ```bash
194
- # .env
195
- DATABASE_URL=postgresql://localhost:5432/mydb
196
- PORT=8790
197
- ```
475
+ **`/health` is yours now.** Declare `GET /health` and it behaves like any other route of
476
+ yours. Declare nothing there and, for one release, a `GET` answers **410** naming
477
+ `/_core/health`, and the server warns once the first time it is hit — a readiness probe
478
+ failure shows an operator neither a response body nor a status text, so a bare 404 would
479
+ leave them nothing to search for. Upgrading an existing deployment?
480
+ [docs/guides/migration/health-endpoint.md](../../docs/guides/migration/health-endpoint.md).
481
+
482
+ Every built-in health address is registered before the `lifecycle.beforeRoutes` hook, and
483
+ a Hono middleware only wraps handlers registered after it. So a global guard your app adds
484
+ in that hook — which is what the hook is documented for — cannot close the endpoint a
485
+ probe depends on, and a probe reaches it unauthenticated. An app with `src/server/app.ts`
486
+ (level 3) wires its own server and gets no health endpoint at all.
198
487
 
199
488
  ---
200
489
 
201
- ## Architecture Overview
490
+ ## How does a client synchronize with the server clock?
491
+
492
+ Call `GET /_core/time` before minting a timestamped proof. It is a built-in,
493
+ unauthenticated and session-free operation registered before application middleware and
494
+ routes:
202
495
 
496
+ ```json
497
+ { "serverTimeMillis": 1750000000123 }
203
498
  ```
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
- └─────────────────────────────────────────┘
499
+
500
+ The response is a closed contract: `serverTimeMillis` is an integer Unix epoch in
501
+ milliseconds and no additional fields are declared. It always carries
502
+ `Cache-Control: no-store`; a cached clock reading is not a synchronization point.
503
+
504
+ The value is trustworthy only when the transport is trustworthy. Production clients must
505
+ call the endpoint over HTTPS and validate the server certificate. The endpoint does not
506
+ sign its response, compensate for network latency, choose an authentication skew margin,
507
+ or define client retry and persistence policy — those belong to the consuming protocol.
508
+
509
+ Tests can inject an exact clock without replacing global time:
510
+
511
+ ```typescript
512
+ const app = await createServer(defineServerConfig()
513
+ .serverTime({ clock: { now: () => 1750000000123 } })
514
+ .build());
218
515
  ```
219
516
 
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
517
+ `CORE_TIME_ROUTE`, `CORE_TIME_PATH`, `ServerTimeResponseSchema` and the
518
+ `ServerTimeResponse` type are exported from `@spfn/core/server` for separately deployed
519
+ client-contract exporters to consume from the same wire definition.
224
520
 
225
521
  ---
226
522
 
227
- ## Directory Structure
523
+ ## Can I deploy this to Vercel?
524
+
525
+ Yes, and it is a first-class target rather than a workaround. From your app:
228
526
 
527
+ ```bash
528
+ spfn add vercel
229
529
  ```
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
530
+
531
+ That scaffolds `src/app/api/backend/[[...route]]/route.ts` (a `hono/vercel` adapter) and
532
+ `vercel.json`. The SPFN app mounts under `/api/backend`, so the Next.js frontend and the
533
+ backend share one Vercel origin — point `SPFN_API_URL` at
534
+ `https://<your-domain>/api/backend`. It runs on the Node runtime, not edge, because SPFN
535
+ needs `pg` and native `bcrypt`.
536
+
537
+ The adapter is a thin wrapper around `createServerlessApp` from `@spfn/core/server`:
538
+
539
+ ```typescript
540
+ import { handle } from 'hono/vercel';
541
+ import { createServerlessApp } from '@spfn/core/server';
542
+ import serverConfig from '@/server/server.config';
543
+
544
+ const handler = async (req: Request) => handle(await createServerlessApp(serverConfig))(req);
545
+ export const GET = handler;
546
+ export const POST = handler;
242
547
  ```
243
548
 
549
+ It differs from `startServer()` in four ways, all forced by the platform:
550
+
551
+ | | Always-on (`startServer`) | Serverless (`createServerlessApp`) |
552
+ |---|---|---|
553
+ | Database init | welded to `serve()` | in the handler, once per warm container |
554
+ | Periodic DB health check | on | off — a timer only leaks across frozen invocations |
555
+ | Background job worker | runs in-process | **not started** — enqueuing works, nothing drains the queue |
556
+ | Seed and RBAC provisioning | per boot | a deploy-time step (`provisionInfrastructure`) |
557
+
558
+ The job worker is the one that bites. If your config declares jobs, the serverless path
559
+ logs a warning at startup: drain the queue from a scheduled endpoint (Vercel Cron calling
560
+ a route that processes a batch), or run jobs on an always-on target.
561
+
562
+ Background jobs, WebSocket events and the periodic health check all need the always-on
563
+ path: `spfn build && spfn start`, or the generated Docker files.
564
+
244
565
  ---
245
566
 
246
- ## Core Concepts
567
+ ## How do I point Claude Code or another AI coding agent at this?
247
568
 
248
- ### Route Definition
569
+ Put the contract in a file and let the agent read it, instead of describing the
570
+ architecture again in every prompt.
249
571
 
250
- ```typescript
251
- import { route } from '@spfn/core/route';
252
- import { Type } from '@sinclair/typebox';
572
+ The SPFN repository states it in `CONTRIBUTING.md`: what the repo is, the commands, the
573
+ vertical-slice pattern, and the rules that are not negotiable — never hand-edit generated
574
+ files, migrations come from the schema. One file answers to people and agents alike, so
575
+ there is no second copy to drift apart from the first.
253
576
 
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
- });
577
+ Each module README under `src/` is written for the same reader. When an agent is working
578
+ on database code, `src/db/README.md` is the page to give it.
264
579
 
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
- });
277
- ```
580
+ ---
581
+
582
+ ## How do I operate the app from the terminal?
278
583
 
279
- ### Repository Pattern
584
+ Develop operations the way you develop features — as routes — and let the `spfn ops` CLI
585
+ discover and invoke them. No admin dashboard, and no extra vocabulary: an ops command is a
586
+ vertical slice whose path lives under `/_ops/`.
280
587
 
281
588
  ```typescript
282
- import { BaseRepository } from '@spfn/core/db';
589
+ // src/server/ops.ts
590
+ import { Type } from '@sinclair/typebox';
591
+ import { createOpsRouter, opsRoute } from '@spfn/core/ops';
592
+ import { opsTokenAuth, requireOpsScope } from '@spfn/auth/server';
593
+
594
+ export const opsRouter = createOpsRouter({
595
+ listSignups: opsRoute.get('/signups') // GET /_ops/signups
596
+ .use([requireOpsScope('waitlist:read')])
597
+ .input({ query: Type.Object({ limit: Type.Optional(Type.Number()) }) })
598
+ .handler(async (c) => signupsRepository.list((await c.data()).query.limit)),
599
+ }, { auth: opsTokenAuth });
600
+
601
+ // src/server/router.ts — mounted like any package router, invisible to client types
602
+ export const appRouter = defineRouter({ ... }).packages([opsRouter]);
603
+ ```
283
604
 
284
- export class UserRepository extends BaseRepository
285
- {
286
- // Protected helpers available:
287
- // _findOne, _findMany, _create, _createMany
288
- // _updateOne, _updateMany, _deleteOne, _deleteMany
289
- // _count, _upsert
605
+ `opsRoute` is `route` with the `/_ops` namespace applied, so a definition carries only the
606
+ path this app owns — what that path looks like, how it nests, which segments are
607
+ parameters are the app's decisions. It exists from `@spfn/core` **0.3.0-beta.2**.
608
+ `createOpsRouter` injects the auth middleware into every route (there is no
609
+ unauthenticated variant) and serves `GET /_ops/_manifest` — the self-description the CLI
610
+ reads, with each command's TypeBox schemas as JSON Schema.
290
611
 
291
- async findActive()
292
- {
293
- return this._findMany(users, {
294
- where: { isActive: true },
295
- orderBy: desc(users.createdAt),
296
- limit: 10
297
- });
298
- }
299
- }
300
- ```
612
+ The manifest is registered ahead of the ops routes, so none of them can take its URL even
613
+ when one is a pattern like `/_ops/:name`. That ordering does not reach outside the ops
614
+ router: routes an app declares in its own router are registered before any package router,
615
+ so a pattern there that covers `/_ops/_manifest` — a `/*` catch-all, say — shadows the
616
+ manifest, exactly as it shadows every other package route.
301
617
 
302
- ### Transaction
618
+ Routes may be grouped in nested `defineRouter`s, and a group's own `.use()` middlewares
619
+ apply to its routes — always after the injected auth, so a group-wide guard reads a
620
+ request that has already been authenticated. A group-level middleware must be a named one;
621
+ wrap a factory to give it a name:
303
622
 
304
623
  ```typescript
305
- import { Transactional } from '@spfn/core/db';
624
+ const requireAdmin = defineMiddleware('opsAdminScope', requireOpsScope('admin:read'));
306
625
 
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
- });
626
+ export const opsRouter = createOpsRouter({
627
+ admin: defineRouter({ getStats, reindex }).use([requireAdmin]),
628
+ }, { auth: opsTokenAuth });
629
+ ```
314
630
 
315
- // Manual control
316
- import { runWithTransaction } from '@spfn/core/db';
631
+ Note that a group's `.use()` middleware carries its `skips` into every route in the group,
632
+ suppressing that server-level middleware there.
317
633
 
318
- await runWithTransaction(async () => {
319
- await userRepo.create(userData);
320
- await profileRepo.create(profileData);
321
- });
634
+ Three things are refused when the surface is defined, rather than discovered in
635
+ production: a route built with `route` instead of `opsRoute` (it would carry no
636
+ namespace), two routes sharing a command name (the manifest flattens nested groups into
637
+ one list, so the CLI could not tell them apart), and a group mounting `.packages()` (those
638
+ routes register with neither the namespace nor the auth injection). The command name
639
+ `getOpsManifest` and the path `/_ops/_manifest` are reserved.
640
+
641
+ ```bash
642
+ spfn ops list --app https://api.example.com # discover commands
643
+ spfn ops call listSignups --query limit=50 # invoke one
644
+ spfn ops call listSignups --describe # print its usage (--json for raw schemas)
322
645
  ```
323
646
 
324
- ### Schema Helpers
647
+ ### Can a package ship ops commands?
648
+
649
+ It can describe them. Whether they are reachable is your application's decision, made in
650
+ the `createOpsRouter` call — installing a package never adds anything to your ops surface.
651
+ Available from **0.3.0-beta.5**.
325
652
 
326
653
  ```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()
654
+ // in the package
655
+ export const ledgerOpsModule = defineOpsModule({
656
+ id: 'ledger',
657
+ source: '@acme/ledger',
658
+ contractVersion: '1.0.0',
659
+ summary: 'Ledger diagnostics',
660
+ commands: {
661
+ verify: {
662
+ summary: 'Verify ledger invariants',
663
+ effect: 'read', // read | write | destructive
664
+ scopes: ['ledger:read'],
665
+ route: opsRoute.get('/ledger/verify').handler(verifyLedger),
666
+ },
667
+ },
668
+ });
669
+
670
+ // in the application — nothing is mounted until this line names it
671
+ export const opsRouter = createOpsRouter({ listSignups }, {
672
+ auth: opsTokenAuth,
673
+ authorize: requireOpsScope,
674
+ modules: [ledgerOpsModule],
346
675
  });
347
676
  ```
348
677
 
678
+ A module command is named `<module>.<command>` (`ledger.verify`) and its route must live
679
+ under `/_ops/<module>/`. The scopes it declares become a server-side guard, run after
680
+ authentication — `authorize` is required as soon as any module is mounted, and it is passed
681
+ in rather than imported so core stays independent of `@spfn/auth`.
682
+
683
+ What is refused at definition time: a path that could decode its way out of the module's
684
+ namespace, two commands in one module that could answer the same URL, and an app route that
685
+ overlaps a mounted module's command. That last one matters because the alternative is a
686
+ surface where which command answers depends on route registration order.
687
+
688
+ The manifest gains a `modules` array and per-command `module`, `summary`, `effect` and
689
+ `scopes`. All of it is additive — an app that mounts no modules serves exactly the v1
690
+ manifest it served before.
691
+
692
+ ```bash
693
+ spfn ops modules # what is mounted, and from where
694
+ spfn ops list --module ledger # just that module's commands
695
+ spfn ops call ledger.compact --yes # effect=destructive needs this
696
+ ```
697
+
698
+ Authentication is an ops token from [`@spfn/auth`](../auth/README.md#ops-tokens-spfn-ops):
699
+ scoped, revocable, hash-stored, issued with `spfn ops token issue` against the running app
700
+ — the CLI signs in as an administrator, so issuance needs no database access. On macOS the
701
+ CLI keeps the token in the keychain (`spfn ops token store`), and resolution order is
702
+ `--token` → `SPFN_OPS_TOKEN` → keychain. The `--app` URL must be https, since every command
703
+ carries a secret; `http` is accepted only against a loopback host.
704
+
349
705
  ---
350
706
 
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 |
707
+ ## How is this different from NestJS or tRPC?
708
+
709
+ **NestJS** gives you a structured backend and leaves the structure to you: modules and
710
+ providers you design, validation and ORM of your choosing, DTOs you keep in sync with the
711
+ frontend by hand. It is the better fit when you need that freedom, or when your frontend
712
+ is not Next.js. `@spfn/core` fixes the shape instead — one vertical slice per feature,
713
+ TypeBox, Drizzle, and route types that reach the browser without a DTO layer. Its
714
+ ecosystem is small and young where NestJS's is large and mature.
715
+
716
+ **tRPC** solves a narrower problem, typed calls between client and server, and leaves
717
+ structure, persistence and auth to you. `@spfn/core` includes the typed-call layer as one
718
+ part of a whole backend.
369
719
 
370
720
  ---
371
721
 
372
- ## CLI Commands
722
+ ## Pitfalls
723
+
724
+ - **There is no root barrel.** `import … from '@spfn/core'` does not resolve. Import from
725
+ a subpath. The module table above is the complete surface.
726
+ - **Use `@spfn/core/nextjs` for the client, not `@spfn/core/client`.** `package.json`
727
+ still lists a `./client` export, but the build does not emit it — the entry is disabled
728
+ and there is no `src/client`. `createApi`, `ApiError` and every client type ship from
729
+ `@spfn/core/nextjs`. Treat `@spfn/core/client` as non-functional.
730
+ - **The client/server boundary is load-bearing.** `@spfn/core/nextjs/server` pulls in
731
+ `next/headers` and `next/server`; importing it from a Client Component breaks the
732
+ build. `@spfn/core/env/loader`, `@spfn/core/event/sse` and `@spfn/core/event/ws` are
733
+ server-only too. Client code uses the `*/client` and isomorphic entry points.
734
+ - **`db/manager`, `db/schema` and `db/transaction` are not subpaths.** Importing
735
+ `@spfn/core/db/transaction` fails to resolve. Import those symbols from `@spfn/core/db`.
736
+ - **The client needs no codegen; the proxy does.** A missing route name is a proxy 404.
737
+ - **Contracts are for clients TypeScript cannot reach.** `.contract()` and the build gate
738
+ exist for a mobile app or an external consumer, compiled and shipped separately. A web
739
+ client takes its types from `AppRouter` in the same build, so a removed response field
740
+ already breaks the compile. Do not put `.contract()` on a route only the web app calls.
741
+ - **The proxy decides the real HTTP method.** The browser only sends GET or POST to
742
+ `/api/rpc/...`; a PUT, PATCH or DELETE route still works because the method comes from
743
+ the route map.
744
+ - **A package upgrade is not done until `spfn db migrate` has run.** The server refuses to
745
+ start while a function package has migrations the database has not applied. That is the
746
+ gate working, not a bug — see
747
+ [Why does the server refuse to start after a package upgrade?](#why-does-the-server-refuse-to-start-after-a-package-upgrade)
748
+ - **Cache, events and jobs degrade quietly.** `@spfn/core/cache` runs disabled — its
749
+ getters return `undefined` — when there is no cache config or no `ioredis`, and
750
+ WebSocket events need the optional `ws` dependency. Do not write code expecting them to
751
+ throw.
373
752
 
374
- ```bash
375
- # Migration
376
- npx spfn db generate # Generate migration
377
- npx spfn db migrate # Apply migration
753
+ ---
378
754
 
379
- # Development
380
- pnpm spfn:dev # Start dev server (auto codegen)
755
+ ## FAQ
381
756
 
382
- # API Client Generation
383
- pnpm spfn codegen run
757
+ **Can I use @spfn/core without Next.js?**
758
+ Yes. `next` is an optional peer dependency and the Next.js bridge is two modules
759
+ (`@spfn/core/nextjs`, `@spfn/core/nextjs/server`). The server runs on Hono by itself. You
760
+ give up the typed client and the RPC proxy, which are the Next.js-side pieces.
384
761
 
385
- # Database Studio
386
- pnpm drizzle-kit studio
387
- ```
762
+ **Can I use Prisma instead of Drizzle?**
763
+ No. `drizzle-orm` is a required peer dependency (`>=1.0.0-rc.4 <2`) and the repository
764
+ layer is built on it.
765
+
766
+ **Do I need Redis?**
767
+ Only for features that use it. `ioredis` is an optional dependency, and without cache
768
+ configuration the cache module reports itself disabled instead of failing. The same holds
769
+ for `ws` and WebSocket events.
770
+
771
+ **Does @spfn/core require PostgreSQL specifically?**
772
+ Yes, 14 or later. The data layer is Drizzle on Postgres, with `postgres.js` as the
773
+ default driver; the provider is injectable, which is how PGlite works in tests.
774
+
775
+ **Why can't I import from `@spfn/core` directly?**
776
+ There is no `.` entry in `exports` — by design. Subpaths keep server-only code out of
777
+ client bundles, which a single barrel file cannot do.
778
+
779
+ **Which Node version?**
780
+ `>=20.0.0`. The package is ESM only.
781
+
782
+ **Where do my business rules go?**
783
+ In services and repositories, not in route handlers. A handler validates, calls, and
784
+ returns. `@spfn/core/authz` covers the one rule that is easy to forget: `requireOwner`
785
+ returns the resource only if it belongs to the requester, and answers "not found" either
786
+ way so the endpoint never reveals that someone else's record exists.
787
+
788
+ ---
789
+
790
+ ## Related packages
791
+
792
+ Other SPFN packages build on `@spfn/core`:
793
+
794
+ - [`@spfn/auth`](../auth/README.md) — sessions, social login and RBAC. Exports
795
+ `authRouteMap` and registers proxy interceptors automatically; merge its route map into
796
+ `createRpcProxy`.
797
+ - [`@spfn/mcp`](../mcp/README.md) — exposes your operations as MCP tools, so an agent can
798
+ run them instead of you building an admin dashboard.
799
+ - `@spfn/i18n`, `@spfn/storage`, `@spfn/notification`, `@spfn/cms`, `@spfn/monitor`,
800
+ `@spfn/migrate`, `@spfn/workflow` — see each package's README under
801
+ [`packages/`](../).
388
802
 
389
803
  ---
390
804
 
391
805
  ## License
392
806
 
393
- MIT
807
+ MIT © FXY Inc.