weifuwu 0.14.1 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1423 -23
- package/README.zh.md +15 -12
- package/dist/iii/client.d.ts +2 -0
- package/dist/iii/index.d.ts +4 -0
- package/dist/iii/register-worker.d.ts +10 -0
- package/dist/iii/rest.d.ts +3 -0
- package/dist/iii/stream.d.ts +79 -0
- package/dist/iii/types.d.ts +132 -0
- package/dist/iii/worker.d.ts +2 -0
- package/dist/iii/ws.d.ts +28 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +966 -10
- package/package.json +1 -1
- package/docs/agent.md +0 -44
- package/docs/ai.md +0 -93
- package/docs/extra.md +0 -67
- package/docs/graphql.md +0 -61
- package/docs/logdb.md +0 -145
- package/docs/messager.md +0 -48
- package/docs/middleware.md +0 -131
- package/docs/opencode.md +0 -252
- package/docs/postgres.md +0 -355
- package/docs/router.md +0 -80
- package/docs/tenant.md +0 -174
- package/docs/tsx.md +0 -199
- package/docs/user.md +0 -167
package/README.md
CHANGED
|
@@ -32,6 +32,7 @@ Everything follows the same `(req, ctx) => Response` contract. The Router handle
|
|
|
32
32
|
- **Email** — SMTP or custom transport
|
|
33
33
|
- **Health check** — configurable `/health` endpoint
|
|
34
34
|
- **Environment** — `loadEnv()` — `.env` file loader into `process.env`
|
|
35
|
+
- **iii** — optional module bringing Worker/Function/Trigger service paradigm, `registerWorker()` WebSocket SDK, and built-in `stream::*` functions
|
|
35
36
|
- **Test utilities** — `createTestServer()` — one-line test server setup
|
|
36
37
|
|
|
37
38
|
## Quick start
|
|
@@ -81,34 +82,16 @@ serve(app.handler(), { port: 3000 })
|
|
|
81
82
|
node app.ts
|
|
82
83
|
```
|
|
83
84
|
|
|
84
|
-
##
|
|
85
|
-
|
|
86
|
-
| Module | Doc | Description |
|
|
87
|
-
|--------|-----|-------------|
|
|
88
|
-
| **Router** | [docs/router.md](./docs/router.md) | Routes, middleware, WebSocket, error handling |
|
|
89
|
-
| **Middleware** | [docs/middleware.md](./docs/middleware.md) | auth, cors, logger, rateLimit, compress, validate, upload, cookie, static |
|
|
90
|
-
| **PostgreSQL** | [docs/postgres.md](./docs/postgres.md) | Schema builder, CRUD, DDL, transactions, PgModule |
|
|
91
|
-
| **Auth & User** | [docs/user.md](./docs/user.md) | Password, JWT, OAuth2 Server, Social Login cookbook |
|
|
92
|
-
| **React SSR** | [docs/tsx.md](./docs/tsx.md) | pages, layouts, loaders, Tailwind, shadcn/ui |
|
|
93
|
-
| **AI** | [docs/ai.md](./docs/ai.md) | `aiStream()`, `runWorkflow()` |
|
|
94
|
-
| **AI Agent** | [docs/agent.md](./docs/agent.md) | Chat, tool-use, RAG knowledge |
|
|
95
|
-
| **Opencode** | [docs/opencode.md](./docs/opencode.md) | Programming assistant, skills, sessions, permissions |
|
|
96
|
-
| **Messager** | [docs/messager.md](./docs/messager.md) | Real-time chat, channels, WebSocket, agent routing |
|
|
97
|
-
| **GraphQL** | [docs/graphql.md](./docs/graphql.md) | GraphQL endpoint with GraphiQL |
|
|
98
|
-
| **Tenant BaaS** | [docs/tenant.md](./docs/tenant.md) | Dynamic tables, auto REST + GraphQL, row isolation |
|
|
99
|
-
| **LogDB** | [docs/logdb.md](./docs/logdb.md) | Structured event logging with partitioning, metadata search, REST API |
|
|
100
|
-
| **Extra** | [docs/extra.md](./docs/extra.md) | Health check, i18n, email, test utilities |
|
|
101
|
-
|
|
102
|
-
### Infrastructure
|
|
85
|
+
## Infrastructure
|
|
103
86
|
|
|
104
87
|
| Module | Import | What it gives you |
|
|
105
88
|
|--------|--------|-------------------|
|
|
106
89
|
| PostgreSQL | `postgres(options?)` | Connection pool + schema builder + CRUD (`read`/`readMany`, `insertMany`, `update`/`updateMany`, `delete`/`deleteMany`) + where helpers (`eq`, `gte`, `contains`, `and`, `or`) + transactions |
|
|
107
90
|
| Redis | `redis(options?)` | ioredis client injected as `ctx.redis` |
|
|
108
91
|
| Queue | `queue(options?)` | Redis-backed job queue with cron scheduling |
|
|
109
|
-
| Deploy | `deploy(config)` | Self-hosted PaaS,
|
|
92
|
+
| Deploy | `deploy(config)` | Self-hosted PaaS: multi-app proxy, zero-downtime updates, auto SSL |
|
|
110
93
|
|
|
111
|
-
|
|
94
|
+
## Mountable modules
|
|
112
95
|
|
|
113
96
|
All use the same pattern — `const m = module(options)` → `app.use('/path', m.router())`:
|
|
114
97
|
|
|
@@ -123,8 +106,10 @@ All use the same pattern — `const m = module(options)` → `app.use('/path', m
|
|
|
123
106
|
| `graphql(handler)` | GraphQL endpoint | — |
|
|
124
107
|
| `logdb(options)` | Structured event logging | `log()`, `migrate()`, `clean()`, `close()` |
|
|
125
108
|
| `health(options?)` | Health check | — |
|
|
109
|
+
| `iii(options?)` | Worker/Function/Trigger service paradigm | `migrate()`, `trigger()`, `addWorker()`, `listWorkers()`, `listFunctions()`, `listTriggers()`, `shutdown()` |
|
|
110
|
+
| `registerWorker(url)` | Pure WebSocket SDK (browser/Node) | `registerFunction()`, `registerTrigger()`, `trigger()`, `shutdown()` |
|
|
126
111
|
|
|
127
|
-
|
|
112
|
+
## Middleware (all `(req, ctx, next) => Response`)
|
|
128
113
|
|
|
129
114
|
| Middleware | Description |
|
|
130
115
|
|-----------|-------------|
|
|
@@ -137,7 +122,7 @@ All use the same pattern — `const m = module(options)` → `app.use('/path', m
|
|
|
137
122
|
| `upload(options?)` | Multipart file upload |
|
|
138
123
|
| `i18n(options)` | Internationalization — `ctx.t()`, locale detection |
|
|
139
124
|
|
|
140
|
-
|
|
125
|
+
## Utility functions
|
|
141
126
|
|
|
142
127
|
| Function | Description |
|
|
143
128
|
|----------|-------------|
|
|
@@ -153,6 +138,1421 @@ All use the same pattern — `const m = module(options)` → `app.use('/path', m
|
|
|
153
138
|
| `eq()`, `gte()`, `contains()`, `and()` ... | WHERE clause helpers — same API as Drizzle |
|
|
154
139
|
| `PgModule` | Base class for DB-backed modules |
|
|
155
140
|
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
# iii — Worker / Function / Trigger
|
|
144
|
+
|
|
145
|
+
Optional module that organizes service logic as **Worker + Function + Trigger**, plus a pure WebSocket SDK for connecting remote workers. Built-in `stream::*` functions for hierarchical real-time data.
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
import { serve, Router, iii, createWorker as Worker, registerWorker } from 'weifuwu'
|
|
149
|
+
|
|
150
|
+
// Engine
|
|
151
|
+
const engine = iii({ pg, redis })
|
|
152
|
+
const app = new Router()
|
|
153
|
+
app.use('/iii', engine.router())
|
|
154
|
+
serve(app.handler(), { port: 3000, websocket: app.websocketHandler() })
|
|
155
|
+
|
|
156
|
+
// Local worker
|
|
157
|
+
const w = new Worker('orders')
|
|
158
|
+
w.registerFunction('orders::create', async (payload) => {
|
|
159
|
+
return db.query('INSERT INTO orders ...', [payload.items])
|
|
160
|
+
})
|
|
161
|
+
w.registerTrigger({
|
|
162
|
+
type: 'http', function_id: 'orders::create',
|
|
163
|
+
config: { method: 'POST', path: '/orders' },
|
|
164
|
+
})
|
|
165
|
+
engine.addWorker(w)
|
|
166
|
+
|
|
167
|
+
// Invoke via Engine
|
|
168
|
+
await engine.trigger({ function_id: 'orders::create', payload: { items: ['apple'] } })
|
|
169
|
+
|
|
170
|
+
// Remote worker (browser or another process)
|
|
171
|
+
const rw = registerWorker('ws://host:3000/iii/worker')
|
|
172
|
+
rw.registerFunction('ui::notify', (p) => new Notification(p.title))
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## Built-in functions
|
|
176
|
+
|
|
177
|
+
| Function | Description |
|
|
178
|
+
|----------|-------------|
|
|
179
|
+
| `stream::set(stream_name, group_id, item_id, data)` | Write + persist + notify subscribers |
|
|
180
|
+
| `stream::get(stream_name, group_id, item_id)` | Read single item |
|
|
181
|
+
| `stream::delete(stream_name, group_id, item_id)` | Delete + notify |
|
|
182
|
+
| `stream::list(stream_name, group_id)` | List all items in a group |
|
|
183
|
+
| `stream::list_groups(stream_name)` | List all groups in a stream |
|
|
184
|
+
| `stream::list_all()` | List all streams with metadata |
|
|
185
|
+
| `stream::send(stream_name, group_id, type, data, id?)` | Push event without persisting |
|
|
186
|
+
| `stream::update(stream_name, group_id, item_id, ops)` | Atomic operations (set/merge/increment/decrement/append/remove) |
|
|
187
|
+
|
|
188
|
+
## Storage backends
|
|
189
|
+
|
|
190
|
+
| Config | Persistence | Cross-process broadcast |
|
|
191
|
+
|--------|-------------|------------------------|
|
|
192
|
+
| `iii({})` | In-memory Map | — |
|
|
193
|
+
| `iii({ pg })` | PG table `_iii_stream` | — |
|
|
194
|
+
| `iii({ redis })` | Redis Hash | Redis pub/sub |
|
|
195
|
+
| `iii({ pg, redis })` | PG table | Redis pub/sub |
|
|
196
|
+
|
|
197
|
+
## Trigger actions
|
|
198
|
+
|
|
199
|
+
| Action | Behavior |
|
|
200
|
+
|--------|----------|
|
|
201
|
+
| `'sync'` (default) | Wait for result |
|
|
202
|
+
| `'void'` | Fire-and-forget, no result |
|
|
203
|
+
|
|
204
|
+
```ts
|
|
205
|
+
// Sync
|
|
206
|
+
const result = await engine.trigger({ function_id: 'math::add', payload: { a: 2, b: 3 } })
|
|
207
|
+
// → { c: 5 }
|
|
208
|
+
|
|
209
|
+
// Void
|
|
210
|
+
await engine.trigger({ function_id: 'notifications::send', payload: {...}, action: 'void' })
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
## REST API (mounted at `/iii`)
|
|
214
|
+
|
|
215
|
+
| Path | Description |
|
|
216
|
+
|------|-------------|
|
|
217
|
+
| `GET /iii/workers` | List connected workers |
|
|
218
|
+
| `GET /iii/functions` | List registered functions |
|
|
219
|
+
| `GET /iii/triggers` | List registered triggers |
|
|
220
|
+
| `POST /iii/trigger/:fnId` | Invoke a function |
|
|
221
|
+
| `WS /iii/worker` | Remote worker connection |
|
|
222
|
+
|
|
223
|
+
---
|
|
224
|
+
|
|
225
|
+
# Router
|
|
226
|
+
|
|
227
|
+
```ts
|
|
228
|
+
import { serve, Router } from 'weifuwu'
|
|
229
|
+
|
|
230
|
+
const app = new Router()
|
|
231
|
+
.use((req, ctx, next) => {
|
|
232
|
+
console.log(`${req.method} ${new URL(req.url).pathname}`)
|
|
233
|
+
return next(req, ctx)
|
|
234
|
+
})
|
|
235
|
+
.get('/hello/:name', (req, ctx) =>
|
|
236
|
+
Response.json({ message: `Hello, ${ctx.params.name}!` }),
|
|
237
|
+
)
|
|
238
|
+
.post('/data', async (req, ctx) => {
|
|
239
|
+
const body = await req.json()
|
|
240
|
+
return Response.json(body, { status: 201 })
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
serve(app.handler(), { port: 3000 })
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
## WebSocket
|
|
247
|
+
|
|
248
|
+
```json
|
|
249
|
+
{ "type": "message", "channel_id": 1, "content": "Hi" }
|
|
250
|
+
{ "type": "typing", "channel_id": 1, "is_typing": true }
|
|
251
|
+
{ "type": "read", "channel_id": 1, "last_message_id": 42 }
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
## Error handling
|
|
255
|
+
|
|
256
|
+
```ts
|
|
257
|
+
const app = new Router()
|
|
258
|
+
.onError((err, req, ctx) =>
|
|
259
|
+
Response.json({ error: err.message }, { status: 500 }),
|
|
260
|
+
)
|
|
261
|
+
.get('/crash', () => { throw new Error('boom') })
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
## Graceful shutdown
|
|
265
|
+
|
|
266
|
+
```ts
|
|
267
|
+
import { serve } from 'weifuwu'
|
|
268
|
+
import type { Server } from 'weifuwu'
|
|
269
|
+
|
|
270
|
+
const ac = new AbortController()
|
|
271
|
+
let server: Server
|
|
272
|
+
|
|
273
|
+
process.on('SIGTERM', () => {
|
|
274
|
+
ac.abort()
|
|
275
|
+
server.stop()
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
server = serve((req, ctx) => new Response('Hello'), {
|
|
279
|
+
port: 3000,
|
|
280
|
+
signal: ac.signal,
|
|
281
|
+
})
|
|
282
|
+
await server.ready
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
### Using with WebSocket
|
|
286
|
+
|
|
287
|
+
```ts
|
|
288
|
+
const app = new Router().ws('/chat', { … })
|
|
289
|
+
const server = serve(app.handler(), {
|
|
290
|
+
port: 3000,
|
|
291
|
+
signal: ac.signal,
|
|
292
|
+
websocket: app.websocketHandler(),
|
|
293
|
+
})
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
---
|
|
297
|
+
|
|
298
|
+
# Middleware
|
|
299
|
+
|
|
300
|
+
## Auth
|
|
301
|
+
|
|
302
|
+
```ts
|
|
303
|
+
import { auth } from 'weifuwu'
|
|
304
|
+
|
|
305
|
+
// Static bearer token
|
|
306
|
+
app.use(auth({ token: 'sk-123' }))
|
|
307
|
+
|
|
308
|
+
// Custom verify (JWT, DB, etc.) — return object to set ctx.user
|
|
309
|
+
app.use(auth({
|
|
310
|
+
verify: async (token) => {
|
|
311
|
+
const user = await db.findUserByToken(token)
|
|
312
|
+
return user ? { sub: user.id, role: user.role } : null
|
|
313
|
+
},
|
|
314
|
+
}))
|
|
315
|
+
|
|
316
|
+
// Proxy validation to external auth service
|
|
317
|
+
app.get('/protected', auth({ proxy: 'http://auth:3000/validate' }), handler)
|
|
318
|
+
|
|
319
|
+
// Custom header
|
|
320
|
+
app.use(auth({ header: 'X-API-Key', token: 'my-key' }))
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
## CORS
|
|
324
|
+
|
|
325
|
+
```ts
|
|
326
|
+
import { cors } from 'weifuwu'
|
|
327
|
+
|
|
328
|
+
app.use(cors()) // allow all
|
|
329
|
+
app.use(cors({ origin: ['https://example.com'] })) // whitelist
|
|
330
|
+
app.use(cors({ origin: (o) => o.endsWith('.trusted.com') ? o : false }))
|
|
331
|
+
app.use(cors({ credentials: true, maxAge: 3600 }))
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
## Logger
|
|
335
|
+
|
|
336
|
+
```ts
|
|
337
|
+
import { logger } from 'weifuwu'
|
|
338
|
+
|
|
339
|
+
app.use(logger()) // GET /hello 200 5ms
|
|
340
|
+
app.use(logger({ format: 'combined' })) // with query params
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
## Rate limit
|
|
344
|
+
|
|
345
|
+
```ts
|
|
346
|
+
import { rateLimit } from 'weifuwu'
|
|
347
|
+
|
|
348
|
+
app.use(rateLimit({ max: 100, window: 60_000 })) // 100 req/min
|
|
349
|
+
app.get('/api', rateLimit({ max: 10 }), handler) // per-route
|
|
350
|
+
|
|
351
|
+
// Custom key (by API key, user ID, etc.)
|
|
352
|
+
app.use(rateLimit({
|
|
353
|
+
max: 1000,
|
|
354
|
+
key: (req) => req.headers.get('x-api-key') ?? 'anonymous',
|
|
355
|
+
}))
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
## Compression
|
|
359
|
+
|
|
360
|
+
```ts
|
|
361
|
+
import { compress } from 'weifuwu'
|
|
362
|
+
|
|
363
|
+
app.use(compress()) // brotli > gzip > deflate
|
|
364
|
+
app.use(compress({ threshold: 2048 })) // only compress > 2KB
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
## Validation
|
|
368
|
+
|
|
369
|
+
```ts
|
|
370
|
+
import { z } from 'zod'
|
|
371
|
+
import { validate } from 'weifuwu'
|
|
372
|
+
|
|
373
|
+
const CreateUser = z.object({
|
|
374
|
+
name: z.string().min(1),
|
|
375
|
+
email: z.string().email(),
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
router.post('/users',
|
|
379
|
+
validate({ body: CreateUser }),
|
|
380
|
+
(req, ctx) => {
|
|
381
|
+
// ctx.parsed.body — typed & validated
|
|
382
|
+
},
|
|
383
|
+
)
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
## File upload
|
|
387
|
+
|
|
388
|
+
```ts
|
|
389
|
+
import { upload } from 'weifuwu'
|
|
390
|
+
|
|
391
|
+
router.post('/upload',
|
|
392
|
+
upload({ dir: './uploads', maxFileSize: 10_485_760 }),
|
|
393
|
+
(req, ctx) => {
|
|
394
|
+
// ctx.parsed.files.avatar → { name, type, size, path }
|
|
395
|
+
// ctx.parsed.fields.title → 'hello'
|
|
396
|
+
},
|
|
397
|
+
)
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
## Cookie
|
|
401
|
+
|
|
402
|
+
```ts
|
|
403
|
+
import { getCookies, setCookie, deleteCookie } from 'weifuwu'
|
|
404
|
+
|
|
405
|
+
// Read
|
|
406
|
+
const cookies = getCookies(req) // { session: 'abc' }
|
|
407
|
+
|
|
408
|
+
// Set (immutable — returns new Response)
|
|
409
|
+
let res = new Response('ok')
|
|
410
|
+
res = setCookie(res, 'session', 'token', { httpOnly: true, secure: true, maxAge: 3600 })
|
|
411
|
+
|
|
412
|
+
// Delete
|
|
413
|
+
res = deleteCookie(res, 'session')
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
## Static files
|
|
417
|
+
|
|
418
|
+
```ts
|
|
419
|
+
import { serveStatic } from 'weifuwu'
|
|
420
|
+
|
|
421
|
+
router.get('/static/*', serveStatic('./public'))
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
Features: MIME type detection (20+ types), ETag + If-None-Match (304), directory index (index.html), path traversal protection, Cache-Control.
|
|
425
|
+
|
|
426
|
+
---
|
|
427
|
+
|
|
428
|
+
# PostgreSQL
|
|
429
|
+
|
|
430
|
+
Built-in PostgreSQL client — connection management, type-safe DDL, transactions, and module lifecycle.
|
|
431
|
+
|
|
432
|
+
```ts
|
|
433
|
+
import { serve, Router, postgres } from 'weifuwu'
|
|
434
|
+
|
|
435
|
+
const app = new Router()
|
|
436
|
+
const pg = postgres() // reads DATABASE_URL
|
|
437
|
+
app.use(pg) // injects ctx.sql into handlers
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
## Type-safe DDL with schema builder
|
|
441
|
+
|
|
442
|
+
Define tables declaratively with type inference — no raw SQL for common operations, no Zod needed:
|
|
443
|
+
|
|
444
|
+
```ts
|
|
445
|
+
import { pgTable, serial, uuid, text, integer, boolean, timestamptz, jsonb, sql, timestamps } from 'weifuwu'
|
|
446
|
+
|
|
447
|
+
const users = pgTable('_users', {
|
|
448
|
+
id: serial('id').primaryKey(),
|
|
449
|
+
name: text('name').notNull(),
|
|
450
|
+
email: text('email').unique().notNull(),
|
|
451
|
+
age: integer('age'),
|
|
452
|
+
active: boolean('active').default(true),
|
|
453
|
+
...timestamps(), // adds created_at + updated_at with defaults
|
|
454
|
+
metadata: jsonb<{ role: string }>('metadata'),
|
|
455
|
+
})
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
Supports 11 column types:
|
|
459
|
+
| Builder | DDL | TS Type |
|
|
460
|
+
|---------|-----|---------|
|
|
461
|
+
| `serial()` | `SERIAL` | `number` |
|
|
462
|
+
| `uuid()` | `UUID` | `string` |
|
|
463
|
+
| `text()` | `TEXT` | `string` |
|
|
464
|
+
| `integer()` | `INTEGER` | `number` |
|
|
465
|
+
| `boolean()` | `BOOLEAN` | `boolean` |
|
|
466
|
+
| `timestamptz()` | `TIMESTAMPTZ` | `string` |
|
|
467
|
+
| `jsonb<T>()` | `JSONB` | `T` |
|
|
468
|
+
| `textArray()` | `TEXT[]` | `string[]` |
|
|
469
|
+
| `vector(name, dims)` | `vector(N)` | `number[]` |
|
|
470
|
+
| `timestamps()` | two TIMESTAMPTZ columns | `{ created_at, updated_at }` |
|
|
471
|
+
|
|
472
|
+
Column constraints chainable: `.primaryKey()`, `.notNull()`, `.nullable()`, `.default(value | sql\`...\`)`, `.unique()`, `.references(table, column?, onDelete?)`.
|
|
473
|
+
|
|
474
|
+
## DDL execution
|
|
475
|
+
|
|
476
|
+
```ts
|
|
477
|
+
await users.create() // CREATE TABLE IF NOT EXISTS
|
|
478
|
+
await users.create({ // WITH PARTITION BY RANGE
|
|
479
|
+
partitionBy: partitionBy('range', 'created_at'),
|
|
480
|
+
})
|
|
481
|
+
await users.createIndex('email') // CREATE INDEX
|
|
482
|
+
await users.createUniqueIndex('slug') // CREATE UNIQUE INDEX
|
|
483
|
+
await users.createIndex('created_at', { desc: true })
|
|
484
|
+
await users.createIndex(['a', 'b']) // multi-column
|
|
485
|
+
await users.createIndex('embedding', { // pgvector HNSW
|
|
486
|
+
type: 'hnsw', operator: 'vector_cosine_ops',
|
|
487
|
+
})
|
|
488
|
+
await users.drop({ cascade: true })
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
## Type-safe CRUD with BoundTable
|
|
492
|
+
|
|
493
|
+
Two usage paths — use `pg.table()` when you have a `pg` handle, or `pgTable()` with explicit `sql`:
|
|
494
|
+
|
|
495
|
+
The `BoundTable` follows a clean CRUD naming — singular for one, plural for many:
|
|
496
|
+
|
|
497
|
+
```ts
|
|
498
|
+
// pg.table() — auto-binds sql, no need to pass it
|
|
499
|
+
const users = pg.table('_users', {
|
|
500
|
+
id: serial('id').primaryKey(),
|
|
501
|
+
name: text('name').notNull(),
|
|
502
|
+
email: text('email').unique(),
|
|
503
|
+
active: boolean('active').default(true),
|
|
504
|
+
...timestamps(),
|
|
505
|
+
})
|
|
506
|
+
|
|
507
|
+
// Create — single
|
|
508
|
+
const user = await users.insert({ name: 'Alice', email: 'alice@test.com' })
|
|
509
|
+
// → { id: 1, name: 'Alice', ... }
|
|
510
|
+
|
|
511
|
+
// Create — many
|
|
512
|
+
const batch = await users.insertMany([
|
|
513
|
+
{ name: 'Alice' },
|
|
514
|
+
{ name: 'Bob' },
|
|
515
|
+
{ name: 'Charlie' },
|
|
516
|
+
])
|
|
517
|
+
// → [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }, { id: 3, name: 'Charlie' }]
|
|
518
|
+
|
|
519
|
+
// Read — by id
|
|
520
|
+
const found = await users.read(1)
|
|
521
|
+
|
|
522
|
+
// Read — with selected columns
|
|
523
|
+
const partial = await users.read(1, { select: ['id', 'name'] })
|
|
524
|
+
|
|
525
|
+
// Read — many with optional filtering + pagination
|
|
526
|
+
const { count, data } = await users.readMany({ role: 'admin' })
|
|
527
|
+
// count is total matching rows, data is the page
|
|
528
|
+
const { data: sorted } = await users.readMany({ active: true }, { orderBy: { name: 'asc' } })
|
|
529
|
+
const { data: page } = await users.readMany(undefined, { limit: 10, offset: 0 })
|
|
530
|
+
const { data: filtered } = await users.readMany(
|
|
531
|
+
{ role: 'admin' },
|
|
532
|
+
{ orderBy: { name: 'desc' }, limit: 5 },
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
// Read — complex conditions with where helpers
|
|
536
|
+
import { eq, gte, lt, contains, and } from 'weifuwu'
|
|
537
|
+
const { count, data } = await users.readMany(
|
|
538
|
+
and(
|
|
539
|
+
eq('role', 'admin'),
|
|
540
|
+
gte('created_at', '2026-01-01'),
|
|
541
|
+
contains('metadata', { region: 'us' }),
|
|
542
|
+
),
|
|
543
|
+
{ orderBy: { name: 'asc' } },
|
|
544
|
+
)
|
|
545
|
+
// Array shorthand — implicit AND
|
|
546
|
+
const { data } = await users.readMany(
|
|
547
|
+
[eq('role', 'admin'), gte('created_at', '2026-01-01')],
|
|
548
|
+
{ limit: 10 },
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
// Update — single row by id (auto-sets updated_at if column exists)
|
|
552
|
+
const updated = await users.update(1, { name: 'Bob' })
|
|
553
|
+
// → { id: 1, name: 'Bob', email: 'alice@test.com', ... }
|
|
554
|
+
|
|
555
|
+
// Update — many with Partial where
|
|
556
|
+
const count = await users.updateMany({ role: 'guest' }, { role: 'user' })
|
|
557
|
+
|
|
558
|
+
// Update — many with SQL where
|
|
559
|
+
await users.updateMany(gte('age', 65), { role: 'retired' })
|
|
560
|
+
|
|
561
|
+
// Delete — single row by id, returns deleted row
|
|
562
|
+
const deleted = await users.delete(1)
|
|
563
|
+
// → { id: 1, name: 'Bob', ... } or undefined
|
|
564
|
+
|
|
565
|
+
// Delete — many
|
|
566
|
+
const deleted = await users.deleteMany({ active: false })
|
|
567
|
+
|
|
568
|
+
// Read — select specific columns
|
|
569
|
+
const { data } = await users.readMany(
|
|
570
|
+
{ role: 'admin' },
|
|
571
|
+
{ select: ['id', 'name', 'email'], limit: 10 },
|
|
572
|
+
)
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
### Upsert
|
|
576
|
+
|
|
577
|
+
```ts
|
|
578
|
+
// Insert or update on conflict
|
|
579
|
+
const user = await users.upsert(
|
|
580
|
+
{ email: 'alice@test.com', name: 'Alice' },
|
|
581
|
+
'email', // conflict target — column(s) with unique constraint
|
|
582
|
+
)
|
|
583
|
+
// ON CONFLICT (email) DO UPDATE SET "name" = EXCLUDED."name" RETURNING *
|
|
584
|
+
```
|
|
585
|
+
|
|
586
|
+
Supports composite conflict targets:
|
|
587
|
+
|
|
588
|
+
```ts
|
|
589
|
+
await members.upsert(
|
|
590
|
+
{ channel_id: 1, member_id: 42, role: 'admin' },
|
|
591
|
+
['channel_id', 'member_id'],
|
|
592
|
+
)
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
### Count
|
|
596
|
+
|
|
597
|
+
```ts
|
|
598
|
+
const total = await users.count() // all rows
|
|
599
|
+
const admins = await users.count({ role: 'admin' }) // with Partial filter
|
|
600
|
+
const recent = await users.count(gte('created_at', from)) // with SQL condition
|
|
601
|
+
```
|
|
602
|
+
|
|
603
|
+
### Soft delete
|
|
604
|
+
|
|
605
|
+
If a table has a `deleted_at` column, `delete()` and `deleteMany()` set the timestamp instead of removing the row:
|
|
606
|
+
|
|
607
|
+
```ts
|
|
608
|
+
const users = pg.table('_users', {
|
|
609
|
+
id: serial('id').primaryKey(),
|
|
610
|
+
name: text('name'),
|
|
611
|
+
deleted_at: timestamptz('deleted_at'), // enables soft delete
|
|
612
|
+
})
|
|
613
|
+
|
|
614
|
+
await users.delete(1) // SET deleted_at = NOW() WHERE id = 1
|
|
615
|
+
await users.deleteMany({ role: 'guest' })
|
|
616
|
+
|
|
617
|
+
// readMany auto-filters soft-deleted rows
|
|
618
|
+
const { data } = await users.readMany() // WHERE deleted_at IS NULL
|
|
619
|
+
|
|
620
|
+
// Include soft-deleted rows
|
|
621
|
+
const { data } = await users.readMany(undefined, { withDeleted: true })
|
|
622
|
+
|
|
623
|
+
// Hard delete (bypass soft delete)
|
|
624
|
+
await users.hardDelete(1)
|
|
625
|
+
await users.hardDeleteMany({ role: 'guest' })
|
|
626
|
+
```
|
|
627
|
+
|
|
628
|
+
### Timestamps
|
|
629
|
+
|
|
630
|
+
The `timestamps()` macro adds `created_at` and `updated_at` columns with `NOT NULL DEFAULT NOW()`.
|
|
631
|
+
|
|
632
|
+
`update()` automatically appends `"updated_at" = NOW()` to the SET clause when the column exists — no need to pass it manually.
|
|
633
|
+
|
|
634
|
+
### Where helpers
|
|
635
|
+
|
|
636
|
+
Importable functions for composing complex WHERE clauses. Works with `readMany`, `updateMany`, `deleteMany`, and `count` — pass as the first argument (single `SQL` or `SQL[]` for implicit AND):
|
|
637
|
+
|
|
638
|
+
```ts
|
|
639
|
+
import { eq, ne, gt, gte, lt, lte, isNull, isNotNull, like, contains, in_, and, or, not } from 'weifuwu'
|
|
640
|
+
|
|
641
|
+
// Single condition
|
|
642
|
+
const { data } = await users.readMany(gte('created_at', '2026-01-01'))
|
|
643
|
+
|
|
644
|
+
// Array = implicit AND
|
|
645
|
+
const { data } = await users.readMany([
|
|
646
|
+
eq('role', 'admin'),
|
|
647
|
+
gte('created_at', '2026-01-01'),
|
|
648
|
+
contains('metadata', { region: 'us' }),
|
|
649
|
+
])
|
|
650
|
+
|
|
651
|
+
// Explicit AND/OR composition
|
|
652
|
+
const { data } = await users.readMany(
|
|
653
|
+
or(
|
|
654
|
+
and(eq('role', 'admin'), eq('status', 'active')),
|
|
655
|
+
eq('role', 'superadmin'),
|
|
656
|
+
),
|
|
657
|
+
{ orderBy: { name: 'asc' }, limit: 10 },
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
// Also works with updateMany and deleteMany
|
|
661
|
+
await users.updateMany(gte('age', 65), { role: 'retired' })
|
|
662
|
+
await users.deleteMany(eq('status', 'archived'))
|
|
663
|
+
```
|
|
664
|
+
|
|
665
|
+
| Helper | SQL | Example |
|
|
666
|
+
|--------|-----|---------|
|
|
667
|
+
| `eq(col, val)` | `= $1` | `eq('level', 'error')` |
|
|
668
|
+
| `ne(col, val)` | `!= $1` | `ne('status', 'archived')` |
|
|
669
|
+
| `gt(col, val)` | `> $1` | `gt('age', 18)` |
|
|
670
|
+
| `gte(col, val)` | `>= $1` | `gte('created_at', '2026-01-01')` |
|
|
671
|
+
| `lt(col, val)` | `< $1` | `lt('id', beforeId)` |
|
|
672
|
+
| `lte(col, val)` | `<= $1` | `lte('score', 100)` |
|
|
673
|
+
| `isNull(col)` | `IS NULL` | `isNull('deleted_at')` |
|
|
674
|
+
| `isNotNull(col)` | `IS NOT NULL` | `isNotNull('email')` |
|
|
675
|
+
| `like(col, pattern)` | `LIKE $1` | `like('name', 'Alice%')` |
|
|
676
|
+
| `contains(col, obj)` | `@> $1::jsonb` | `contains('metadata', { service: 'auth' })` |
|
|
677
|
+
| `in_(col, arr)` | `= ANY($1)` | `in_('id', [1, 2, 3])` |
|
|
678
|
+
| `and(...conds)` | `(... AND ...)` | `and(eq('a', 1), eq('b', 2))` |
|
|
679
|
+
| `or(...conds)` | `(... OR ...)` | `or(eq('a', 1), eq('b', 2))` |
|
|
680
|
+
| `not(cond)` | `NOT (...)` | `not(eq('status', 'archived'))` |
|
|
681
|
+
|
|
682
|
+
### Complex queries use raw SQL
|
|
683
|
+
|
|
684
|
+
```ts
|
|
685
|
+
app.get('/users/stats', async (req, ctx) => {
|
|
686
|
+
const rows = await ctx.sql`
|
|
687
|
+
SELECT u.*, count(p.id) as posts
|
|
688
|
+
FROM ${users} u LEFT JOIN posts p ON p.user_id = u.id
|
|
689
|
+
GROUP BY u.id
|
|
690
|
+
`
|
|
691
|
+
return Response.json(rows)
|
|
692
|
+
})
|
|
693
|
+
```
|
|
694
|
+
|
|
695
|
+
### Transactions
|
|
696
|
+
|
|
697
|
+
```ts
|
|
698
|
+
const result = await pg.transaction(async (tx) => {
|
|
699
|
+
const [user] = await tx`INSERT INTO "_users" (...) VALUES (...) RETURNING *`
|
|
700
|
+
const [wallet] = await tx`INSERT INTO "_wallets" ("user_id") VALUES (${user.id}) RETURNING *`
|
|
701
|
+
return { user, wallet }
|
|
702
|
+
})
|
|
703
|
+
```
|
|
704
|
+
|
|
705
|
+
Use BoundTable methods inside transactions with `withSql()`:
|
|
706
|
+
|
|
707
|
+
```ts
|
|
708
|
+
const users = pg.table('_users', { ... })
|
|
709
|
+
const wallets = pg.table('_wallets', { ... })
|
|
710
|
+
|
|
711
|
+
const result = await pg.transaction(async (tx) => {
|
|
712
|
+
const txUsers = users.withSql(tx)
|
|
713
|
+
const txWallets = wallets.withSql(tx)
|
|
714
|
+
|
|
715
|
+
const user = await txUsers.insert({ name: 'Alice' })
|
|
716
|
+
await txWallets.insert({ user_id: user.id })
|
|
717
|
+
return user
|
|
718
|
+
})
|
|
719
|
+
```
|
|
720
|
+
|
|
721
|
+
### Connection lifecycle
|
|
722
|
+
|
|
723
|
+
```ts
|
|
724
|
+
const pg = postgres() // reads DATABASE_URL
|
|
725
|
+
const pg = postgres('postgres://...') // explicit connection
|
|
726
|
+
const pg = postgres({
|
|
727
|
+
connection: 'postgres://...',
|
|
728
|
+
max: 10, // pool size
|
|
729
|
+
ssl: { rejectUnauthorized: false }, // SSL options
|
|
730
|
+
idle_timeout: 30, // idle timeout (s)
|
|
731
|
+
connect_timeout: 10, // connection timeout (s)
|
|
732
|
+
closeTimeout: 5, // close grace period (s)
|
|
733
|
+
signal: ac.signal, // abort → sql.end()
|
|
734
|
+
})
|
|
735
|
+
await pg.close()
|
|
736
|
+
```
|
|
737
|
+
|
|
738
|
+
### Module base class
|
|
739
|
+
|
|
740
|
+
Every database module extends `PgModule`:
|
|
741
|
+
|
|
742
|
+
```ts
|
|
743
|
+
import { PgModule } from 'weifuwu'
|
|
744
|
+
|
|
745
|
+
class MyModule extends PgModule {
|
|
746
|
+
constructor(pg: PostgresClient) {
|
|
747
|
+
super(pg) // sets this.sql = pg.sql
|
|
748
|
+
}
|
|
749
|
+
async migrate() { /* override */ }
|
|
750
|
+
|
|
751
|
+
// Built-in helpers
|
|
752
|
+
// this.table(name, builders) — create a BoundTable
|
|
753
|
+
// this.transaction(fn) — run in a transaction
|
|
754
|
+
// close() — calls pg.close() automatically
|
|
755
|
+
}
|
|
756
|
+
```
|
|
757
|
+
|
|
758
|
+
---
|
|
759
|
+
|
|
760
|
+
# Auth & User
|
|
761
|
+
|
|
762
|
+
```ts
|
|
763
|
+
import { serve, Router, postgres, user } from 'weifuwu'
|
|
764
|
+
|
|
765
|
+
const app = new Router()
|
|
766
|
+
const pg = postgres()
|
|
767
|
+
await pg.migrate()
|
|
768
|
+
|
|
769
|
+
const auth = user({ pg, jwtSecret: process.env.JWT_SECRET! })
|
|
770
|
+
|
|
771
|
+
// POST /auth/register { email, password, name }
|
|
772
|
+
// POST /auth/login { email, password }
|
|
773
|
+
// GET /auth/oauth/authorize?client_id=...&redirect_uri=...&response_type=code
|
|
774
|
+
// POST /auth/oauth/consent
|
|
775
|
+
// POST /auth/oauth/token (grant_type=authorization_code|client_credentials)
|
|
776
|
+
app.use('/auth', auth.router())
|
|
777
|
+
|
|
778
|
+
// Protected routes — verifies JWT, sets ctx.user
|
|
779
|
+
app.get('/me', auth.middleware(), async (req, ctx) => {
|
|
780
|
+
return Response.json(ctx.user)
|
|
781
|
+
// { id, email, name, role }
|
|
782
|
+
})
|
|
783
|
+
```
|
|
784
|
+
|
|
785
|
+
Password hashing uses `crypto.scryptSync` + `timingSafeEqual` (Node.js built-in, zero deps). JWT tokens use the `jsonwebtoken` package. The users table (`_users` by default) is auto-created on first `migrate()`.
|
|
786
|
+
|
|
787
|
+
## OAuth2 Server
|
|
788
|
+
|
|
789
|
+
Enable OAuth2 Server to let third-party apps (SPA, mobile, microservices) authenticate users through your app.
|
|
790
|
+
|
|
791
|
+
```ts
|
|
792
|
+
const auth = user({
|
|
793
|
+
pg,
|
|
794
|
+
jwtSecret: process.env.JWT_SECRET!,
|
|
795
|
+
oauth2: { server: true },
|
|
796
|
+
})
|
|
797
|
+
|
|
798
|
+
await auth.migrate() // creates _users + _oauth2_clients + _oauth2_codes + _oauth2_tokens
|
|
799
|
+
|
|
800
|
+
// Register a client app (programmatic — CLI, admin UI, seed script)
|
|
801
|
+
const client = await auth.registerClient({
|
|
802
|
+
name: 'My SPA',
|
|
803
|
+
redirectUris: ['https://myapp.com/callback'],
|
|
804
|
+
})
|
|
805
|
+
// → { clientId, clientSecret, name, redirectUris }
|
|
806
|
+
|
|
807
|
+
// Use auth middleware to protect routes — OAuth2 JWT tokens work seamlessly
|
|
808
|
+
app.get('/api/data', auth.middleware(), handler)
|
|
809
|
+
```
|
|
810
|
+
|
|
811
|
+
### Supported Grant Types
|
|
812
|
+
|
|
813
|
+
| Grant | Use Case | PKCE |
|
|
814
|
+
|-------|----------|------|
|
|
815
|
+
| `authorization_code` (with client_secret) | Server-side apps | Optional |
|
|
816
|
+
| `authorization_code` (with `code_challenge`/`code_verifier`) | SPA / Mobile apps | Required |
|
|
817
|
+
| `client_credentials` | Machine-to-machine | — |
|
|
818
|
+
|
|
819
|
+
### Flow (Authorization Code + PKCE)
|
|
820
|
+
|
|
821
|
+
```
|
|
822
|
+
1. Third-party app redirects user:
|
|
823
|
+
GET /oauth/authorize?client_id=xxx&redirect_uri=https://app.com/cb
|
|
824
|
+
&response_type=code&code_challenge=S256&state=yyy
|
|
825
|
+
|
|
826
|
+
2. User not logged in → 302 to /login?redirect=... → auto returns to consent page after login
|
|
827
|
+
|
|
828
|
+
3. User confirms consent → POST /oauth/consent { approve: true, client_id, ... }
|
|
829
|
+
302 redirect_uri?code=xxx&state=yyy
|
|
830
|
+
|
|
831
|
+
4. Third-party app POST /oauth/token
|
|
832
|
+
{ grant_type: authorization_code, code, client_id, client_secret,
|
|
833
|
+
redirect_uri, code_verifier }
|
|
834
|
+
→ { access_token, token_type: "Bearer", expires_in, refresh_token }
|
|
835
|
+
|
|
836
|
+
5. access_token is a standard JWT — auth.middleware() and auth.verify() work with it directly
|
|
837
|
+
```
|
|
838
|
+
|
|
839
|
+
### Client Management
|
|
840
|
+
|
|
841
|
+
```ts
|
|
842
|
+
const client = await auth.registerClient({ name, redirectUris })
|
|
843
|
+
const found = await auth.getClient(client.clientId)
|
|
844
|
+
await auth.revokeClient(client.clientId)
|
|
845
|
+
```
|
|
846
|
+
|
|
847
|
+
### Using OAuth2 Tokens with the Built-in Auth Middleware
|
|
848
|
+
|
|
849
|
+
The `access_token` issued by the OAuth2 Server shares the same `jwtSecret` and compatible payload (`sub`, `email`, `role`) as password-login JWTs, so `auth()` can verify OAuth2 tokens without any modifications:
|
|
850
|
+
|
|
851
|
+
```ts
|
|
852
|
+
import { auth } from 'weifuwu'
|
|
853
|
+
|
|
854
|
+
// Same auth() middleware validates both password-login JWTs and OAuth2 JWTs
|
|
855
|
+
app.get('/api', auth({ verify: (token) => auth.verify(token) }), handler)
|
|
856
|
+
```
|
|
857
|
+
|
|
858
|
+
For `client_credentials` tokens (machine-to-machine), `verify()` returns `null` since no user is associated.
|
|
859
|
+
|
|
860
|
+
### Social Login (GitHub) — Cookbook
|
|
861
|
+
|
|
862
|
+
`user()` does not bundle social login (to avoid third-party dependencies), but adding a GitHub login with the low-level API takes ~30 lines:
|
|
863
|
+
|
|
864
|
+
```ts
|
|
865
|
+
import { user } from 'weifuwu'
|
|
866
|
+
import jwt from 'jsonwebtoken'
|
|
867
|
+
|
|
868
|
+
const auth = user({ pg, jwtSecret })
|
|
869
|
+
|
|
870
|
+
// 1. Redirect to GitHub authorization
|
|
871
|
+
app.get('/auth/github', () => {
|
|
872
|
+
const url = new URL('https://github.com/login/oauth/authorize')
|
|
873
|
+
url.searchParams.set('client_id', process.env.GH_CLIENT_ID!)
|
|
874
|
+
url.searchParams.set('redirect_uri', 'http://localhost:3000/auth/github/callback')
|
|
875
|
+
url.searchParams.set('scope', 'user:email')
|
|
876
|
+
return Response.redirect(url.href)
|
|
877
|
+
})
|
|
878
|
+
|
|
879
|
+
// 2. GitHub callback → fetch user info → register/login
|
|
880
|
+
app.get('/auth/github/callback', async (req) => {
|
|
881
|
+
const { code } = Object.fromEntries(new URL(req.url).searchParams)
|
|
882
|
+
if (!code) return new Response('Missing code', { status: 400 })
|
|
883
|
+
|
|
884
|
+
// Exchange code for token
|
|
885
|
+
const tokenRes = await fetch('https://github.com/login/oauth/access_token', {
|
|
886
|
+
method: 'POST',
|
|
887
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
888
|
+
body: JSON.stringify({
|
|
889
|
+
client_id: process.env.GH_CLIENT_ID,
|
|
890
|
+
client_secret: process.env.GH_CLIENT_SECRET,
|
|
891
|
+
code,
|
|
892
|
+
}),
|
|
893
|
+
})
|
|
894
|
+
const { access_token } = await tokenRes.json() as any
|
|
895
|
+
|
|
896
|
+
// Fetch user info from GitHub
|
|
897
|
+
const userRes = await fetch('https://api.github.com/user', {
|
|
898
|
+
headers: { Authorization: `Bearer ${access_token}` },
|
|
899
|
+
})
|
|
900
|
+
const ghUser = await userRes.json() as any
|
|
901
|
+
|
|
902
|
+
// Find or create local user
|
|
903
|
+
const existing = await pg.sql`SELECT * FROM "_users" WHERE email = ${ghUser.email}`
|
|
904
|
+
let localUser = existing[0]
|
|
905
|
+
|
|
906
|
+
if (!localUser) {
|
|
907
|
+
localUser = await auth.register({
|
|
908
|
+
email: ghUser.email,
|
|
909
|
+
password: crypto.randomUUID(), // Random password — user can only log in via GitHub
|
|
910
|
+
name: ghUser.name ?? ghUser.login,
|
|
911
|
+
})
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// Sign JWT (same format as user())
|
|
915
|
+
const token = jwt.sign(
|
|
916
|
+
{ sub: localUser.id, email: localUser.email, role: localUser.role ?? 'user' },
|
|
917
|
+
process.env.JWT_SECRET!,
|
|
918
|
+
{ expiresIn: '24h' },
|
|
919
|
+
)
|
|
920
|
+
return Response.json({ token })
|
|
921
|
+
})
|
|
922
|
+
```
|
|
923
|
+
|
|
924
|
+
The same pattern works for Google, WeChat, or any OAuth2 provider.
|
|
925
|
+
|
|
926
|
+
---
|
|
927
|
+
|
|
928
|
+
# React SSR with tsx()
|
|
929
|
+
|
|
930
|
+
```ts
|
|
931
|
+
import { serve, Router } from 'weifuwu'
|
|
932
|
+
import { tsx } from 'weifuwu/tsx'
|
|
933
|
+
|
|
934
|
+
const app = new Router()
|
|
935
|
+
app.use('/', await tsx({ dir: './ui/' }))
|
|
936
|
+
|
|
937
|
+
serve(app.handler(), { port: 3000, websocket: app.websocketHandler() })
|
|
938
|
+
```
|
|
939
|
+
|
|
940
|
+
### Directory structure
|
|
941
|
+
|
|
942
|
+
```
|
|
943
|
+
ui/
|
|
944
|
+
├── pages/ ← page files
|
|
945
|
+
│ ├── page.tsx → GET / (React component, default export)
|
|
946
|
+
│ ├── layout.tsx → root layout (HTML shell, receives req/ctx, NOT hydrated)
|
|
947
|
+
│ ├── not-found.tsx → 404 error page (rendered for unmatched routes, wrapped in layout)
|
|
948
|
+
│ ├── about/page.tsx → GET /about
|
|
949
|
+
│ ├── blog/[slug]/
|
|
950
|
+
│ │ ├── page.tsx → GET /blog/:slug
|
|
951
|
+
│ │ ├── load.ts → data fetching (server-only, default export)
|
|
952
|
+
│ │ └── route.ts → POST /blog/:slug (API, named exports POST/PUT/DELETE/...)
|
|
953
|
+
│ ├── blog/layout.tsx → /blog/* layout (UI structure, receives children, hydrated)
|
|
954
|
+
│ └── api/search/
|
|
955
|
+
│ └── route.ts → GET /api/search (standalone API, no page.tsx needed)
|
|
956
|
+
└── components/ ← component files (auto-detected by HMR)
|
|
957
|
+
└── button.tsx
|
|
958
|
+
```
|
|
959
|
+
|
|
960
|
+
### Development mode
|
|
961
|
+
|
|
962
|
+
tsx() runs in development mode automatically when `NODE_ENV !== 'production'`:
|
|
963
|
+
|
|
964
|
+
- **File watching** — chokidar watches the `dir` directory for `.tsx`/`.ts` changes
|
|
965
|
+
- Page files in `pages/` → single-file recompilation + registry update
|
|
966
|
+
- Component files in `components/` → full rebuild of all pages
|
|
967
|
+
- New files are detected automatically
|
|
968
|
+
- **Live reload** — Compiled via esbuild `write: false` + `vm.Script.runInContext` (no disk writes, no `node --watch` conflict)
|
|
969
|
+
- **WebSocket auto-refresh** — `/__weifuwu/livereload` endpoint pushes reload signals; browser refreshes automatically
|
|
970
|
+
- **`node --watch` compatible** — External files (`app.ts`, `middleware/`) handled by `--watch` restart; `ui/` changes handled by tsx() without conflict
|
|
971
|
+
|
|
972
|
+
```bash
|
|
973
|
+
node app.ts # development (auto-reload + live refresh)
|
|
974
|
+
NODE_ENV=production node app.ts # production
|
|
975
|
+
```
|
|
976
|
+
|
|
977
|
+
### Tailwind CSS
|
|
978
|
+
|
|
979
|
+
tsx() includes built-in Tailwind CSS v4 support. If an `app.css` file exists in the `dir` directory, it is compiled automatically through PostCSS + `@tailwindcss/postcss`. If no `app.css` is found, one is created automatically:
|
|
980
|
+
|
|
981
|
+
```css
|
|
982
|
+
@import "tailwindcss";
|
|
983
|
+
```
|
|
984
|
+
|
|
985
|
+
Write `className` directly in your components — no CLI, no configuration:
|
|
986
|
+
|
|
987
|
+
```tsx
|
|
988
|
+
export default function Home() {
|
|
989
|
+
return <h1 className="text-3xl font-bold text-blue-600">Hello</h1>
|
|
990
|
+
}
|
|
991
|
+
```
|
|
992
|
+
|
|
993
|
+
### `@` alias
|
|
994
|
+
|
|
995
|
+
If your project has a `tsconfig.json` or `jsconfig.json` with `compilerOptions.paths`, tsx() reads it automatically and passes aliases to all esbuild builds (SSR compilation, hydration bundles, and hot reload):
|
|
996
|
+
|
|
997
|
+
```json
|
|
998
|
+
{
|
|
999
|
+
"compilerOptions": {
|
|
1000
|
+
"paths": {
|
|
1001
|
+
"@/*": ["./ui/*"]
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
```
|
|
1006
|
+
|
|
1007
|
+
### shadcn/ui
|
|
1008
|
+
|
|
1009
|
+
tsx() works with [shadcn/ui](https://ui.shadcn.com) out of the box.
|
|
1010
|
+
|
|
1011
|
+
```bash
|
|
1012
|
+
npx shadcn@latest init
|
|
1013
|
+
# Style: your preference
|
|
1014
|
+
# Base color: your preference
|
|
1015
|
+
# CSS file path: ui/app.css
|
|
1016
|
+
# Import alias: @/ → ./ui/
|
|
1017
|
+
```
|
|
1018
|
+
|
|
1019
|
+
### page.tsx — page component
|
|
1020
|
+
|
|
1021
|
+
```tsx
|
|
1022
|
+
export default function Page({ params, query }: {
|
|
1023
|
+
params: { slug: string }
|
|
1024
|
+
query: Record<string, string>
|
|
1025
|
+
}) {
|
|
1026
|
+
return <article><h1>{params.slug}</h1></article>
|
|
1027
|
+
}
|
|
1028
|
+
```
|
|
1029
|
+
|
|
1030
|
+
### load.ts — data fetching (server-only)
|
|
1031
|
+
|
|
1032
|
+
```ts
|
|
1033
|
+
export default async function load({ params, query }: {
|
|
1034
|
+
params: Record<string, string>
|
|
1035
|
+
query: Record<string, string>
|
|
1036
|
+
}) {
|
|
1037
|
+
const data = await db.query(params.slug)
|
|
1038
|
+
return { data } // merged into props passed to page.tsx
|
|
1039
|
+
}
|
|
1040
|
+
```
|
|
1041
|
+
|
|
1042
|
+
### layout.tsx
|
|
1043
|
+
|
|
1044
|
+
**Root layout** (`pages/layout.tsx`) — receives `{ children, req, ctx }`:
|
|
1045
|
+
|
|
1046
|
+
```tsx
|
|
1047
|
+
export default function RootLayout({ children, req, ctx }: {
|
|
1048
|
+
children: React.ReactNode
|
|
1049
|
+
req: Request
|
|
1050
|
+
ctx: Context
|
|
1051
|
+
}) {
|
|
1052
|
+
return (
|
|
1053
|
+
<html>
|
|
1054
|
+
<head><title>App</title></head>
|
|
1055
|
+
<body><div id="__weifuwu_root">{children}</div></body>
|
|
1056
|
+
</html>
|
|
1057
|
+
)
|
|
1058
|
+
}
|
|
1059
|
+
```
|
|
1060
|
+
|
|
1061
|
+
**Nested layouts** (`pages/blog/layout.tsx`) — receives only `{ children }`.
|
|
1062
|
+
|
|
1063
|
+
### route.ts — API (co-located with page)
|
|
1064
|
+
|
|
1065
|
+
```ts
|
|
1066
|
+
export const POST: Handler = async (req, ctx) => {
|
|
1067
|
+
const body = await req.json()
|
|
1068
|
+
return Response.json({ ...body, slug: ctx.params.slug })
|
|
1069
|
+
}
|
|
1070
|
+
```
|
|
1071
|
+
|
|
1072
|
+
### not-found.tsx — 404 page
|
|
1073
|
+
|
|
1074
|
+
```tsx
|
|
1075
|
+
export default function NotFound() {
|
|
1076
|
+
return <h1 class="text-4xl">404 – Not Found</h1>
|
|
1077
|
+
}
|
|
1078
|
+
```
|
|
1079
|
+
|
|
1080
|
+
---
|
|
1081
|
+
|
|
1082
|
+
# AI: Streaming & Workflow
|
|
1083
|
+
|
|
1084
|
+
## AI streaming
|
|
1085
|
+
|
|
1086
|
+
Server-sent event streaming via the Vercel AI SDK:
|
|
1087
|
+
|
|
1088
|
+
```ts
|
|
1089
|
+
import { serve, Router, aiStream } from 'weifuwu'
|
|
1090
|
+
import { openai } from '@ai-sdk/openai'
|
|
1091
|
+
|
|
1092
|
+
const app = new Router()
|
|
1093
|
+
const chat = await aiStream(async (req, ctx) => {
|
|
1094
|
+
const { messages } = await req.json()
|
|
1095
|
+
return { model: openai('gpt-4o'), messages }
|
|
1096
|
+
})
|
|
1097
|
+
app.use('/chat', chat.router())
|
|
1098
|
+
|
|
1099
|
+
serve(app.handler(), { port: 3000 })
|
|
1100
|
+
```
|
|
1101
|
+
|
|
1102
|
+
## runWorkflow
|
|
1103
|
+
|
|
1104
|
+
Multi-step DAG execution engine — packaged as a single AI SDK `Tool`. Use it with `streamText()` or `generateText()` when the LLM needs conditional logic, loops, or multi-step tool orchestration.
|
|
1105
|
+
|
|
1106
|
+
```ts
|
|
1107
|
+
import { tool, streamText } from 'ai'
|
|
1108
|
+
import { runWorkflow } from 'weifuwu'
|
|
1109
|
+
import { z } from 'zod'
|
|
1110
|
+
|
|
1111
|
+
const tools = {
|
|
1112
|
+
queryUser: tool({
|
|
1113
|
+
description: 'Query user info',
|
|
1114
|
+
inputSchema: z.object({ userId: z.string() }),
|
|
1115
|
+
execute: async ({ userId }) => ({ id: userId, email: 'user@test.com', name: 'Test' }),
|
|
1116
|
+
}),
|
|
1117
|
+
sendEmail: tool({
|
|
1118
|
+
description: 'Send an email',
|
|
1119
|
+
inputSchema: z.object({ to: z.string(), subject: z.string() }),
|
|
1120
|
+
execute: async ({ to, subject }) => ({ sent: true }),
|
|
1121
|
+
}),
|
|
1122
|
+
runWF: runWorkflow({ tools: { queryUser, sendEmail } }),
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
const result = await streamText({
|
|
1126
|
+
model,
|
|
1127
|
+
tools,
|
|
1128
|
+
messages: [{ role: 'user', content: 'Query user 123, send welcome email if exists' }],
|
|
1129
|
+
})
|
|
1130
|
+
```
|
|
1131
|
+
|
|
1132
|
+
### Node types
|
|
1133
|
+
|
|
1134
|
+
7 built-in node types for defining the execution graph:
|
|
1135
|
+
|
|
1136
|
+
| Node | Purpose | Input |
|
|
1137
|
+
|------|---------|-------|
|
|
1138
|
+
| `call` | Call a registered AI SDK Tool | `{ tool: "name", args: {...} }` |
|
|
1139
|
+
| `set` | Assign a variable | `{ name: "x", value: 42 }` |
|
|
1140
|
+
| `get` | Read a variable | `{ name: "x" }` |
|
|
1141
|
+
| `eval` | Evaluate an expression | `{ expression: "$var.x + 1" }` |
|
|
1142
|
+
| `if` | Conditional branch | `{ conditions: [{ test: ..., body: [nodes] }] }` |
|
|
1143
|
+
| `while` | Loop | `{ condition: "$var.i < 5" }, body: [nodes]` |
|
|
1144
|
+
| `http` | HTTP request | `{ url: "https://...", method: "GET" }` |
|
|
1145
|
+
|
|
1146
|
+
### Reference syntax
|
|
1147
|
+
|
|
1148
|
+
| Pattern | Meaning | Example |
|
|
1149
|
+
|---------|---------|---------|
|
|
1150
|
+
| `$var.x` | Variable `x` | `$var.counter` |
|
|
1151
|
+
| `$nodes.u.output` | Full output of node `u` | `$nodes.u.output` |
|
|
1152
|
+
| `$nodes.u.output.field` | Specific field | `$nodes.u.output.email` |
|
|
1153
|
+
| `$input.userId` | Input param | `$input.userId` |
|
|
1154
|
+
|
|
1155
|
+
---
|
|
1156
|
+
|
|
1157
|
+
# AI Agent
|
|
1158
|
+
|
|
1159
|
+
Server-side AI agents with OpenAI-compatible API. Built-in chat, tool-use (tool-calling), and knowledge (RAG) types. Works out of the box with Ollama or any OpenAI-compatible provider.
|
|
1160
|
+
|
|
1161
|
+
```ts
|
|
1162
|
+
import { agent } from 'weifuwu'
|
|
1163
|
+
|
|
1164
|
+
const agents = agent({ pg })
|
|
1165
|
+
|
|
1166
|
+
await agents.migrate()
|
|
1167
|
+
app.use('/api', agents.router())
|
|
1168
|
+
```
|
|
1169
|
+
|
|
1170
|
+
| Type | Description | Execution |
|
|
1171
|
+
|------|-------------|-----------|
|
|
1172
|
+
| `chat` | Pure conversation | `streamText()` / `generateText()` |
|
|
1173
|
+
| `tool-use` | Tool-calling agent | `streamText({ tools })` |
|
|
1174
|
+
|
|
1175
|
+
### Knowledge (RAG)
|
|
1176
|
+
|
|
1177
|
+
Add documents to any agent — `searchKnowledge` tool auto-injected:
|
|
1178
|
+
|
|
1179
|
+
```ts
|
|
1180
|
+
await agents.addKnowledge(agentId, 'Title', 'Document content...')
|
|
1181
|
+
```
|
|
1182
|
+
|
|
1183
|
+
### Streaming
|
|
1184
|
+
|
|
1185
|
+
```http
|
|
1186
|
+
POST /agents/:id/run { input: "hello", stream: true }
|
|
1187
|
+
→ event-stream (fullStream SSE: text-delta, tool-call, tool-result, finish)
|
|
1188
|
+
```
|
|
1189
|
+
|
|
1190
|
+
### Programmatic API
|
|
1191
|
+
|
|
1192
|
+
```ts
|
|
1193
|
+
const result = await agents.run(agentId, { input: 'hello', stream: false })
|
|
1194
|
+
// { output: "Hello!", elapsed: 1234 }
|
|
1195
|
+
```
|
|
1196
|
+
|
|
1197
|
+
---
|
|
1198
|
+
|
|
1199
|
+
# GraphQL
|
|
1200
|
+
|
|
1201
|
+
Dynamic GraphQL schema generated per-request based on the authenticated tenant's tables.
|
|
1202
|
+
|
|
1203
|
+
```graphql
|
|
1204
|
+
type Article {
|
|
1205
|
+
id: ID!
|
|
1206
|
+
title: String!
|
|
1207
|
+
content: String
|
|
1208
|
+
status: String
|
|
1209
|
+
comments(limit: Int, offset: Int): [Comment!]!
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
type Query {
|
|
1213
|
+
articles(limit: Int, offset: Int): [Article!]!
|
|
1214
|
+
getArticle(id: ID!): Article
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
type Mutation {
|
|
1218
|
+
createArticle(data: CreateArticleInput!): Article!
|
|
1219
|
+
updateArticle(id: ID!, data: PatchArticleInput!): Article!
|
|
1220
|
+
deleteArticle(id: ID!): Boolean!
|
|
1221
|
+
}
|
|
1222
|
+
```
|
|
1223
|
+
|
|
1224
|
+
Built with `graphql-js` native constructors (`GraphQLObjectType`), no SDL generation, no `makeExecutableSchema`.
|
|
1225
|
+
|
|
1226
|
+
---
|
|
1227
|
+
|
|
1228
|
+
# Tenant BaaS
|
|
1229
|
+
|
|
1230
|
+
Built-in multi-tenant backend-as-a-service — define tables at runtime via API, get RESTful CRUD + GraphQL automatically, with row-level tenant isolation.
|
|
1231
|
+
|
|
1232
|
+
```ts
|
|
1233
|
+
import { serve, Router, postgres, user, tenant } from 'weifuwu'
|
|
1234
|
+
|
|
1235
|
+
const pg = postgres()
|
|
1236
|
+
const u = user({ pg, jwtSecret: process.env.JWT_SECRET! })
|
|
1237
|
+
const t = tenant({ pg, usersTable: '_users' })
|
|
1238
|
+
|
|
1239
|
+
await pg.migrate()
|
|
1240
|
+
await u.migrate()
|
|
1241
|
+
await t.migrate() // creates _tenants, _tenant_members, _user_tables
|
|
1242
|
+
|
|
1243
|
+
const app = new Router()
|
|
1244
|
+
app.use('/auth', u.router())
|
|
1245
|
+
app.use('/api', u.middleware()) // → ctx.user
|
|
1246
|
+
app.use('/api', t.middleware()) // → ctx.tenant
|
|
1247
|
+
app.use('/api', t.router()) // → management + data CRUD
|
|
1248
|
+
app.use('/graphql', t.graphql()) // → dynamic GraphQL
|
|
1249
|
+
```
|
|
1250
|
+
|
|
1251
|
+
## System tables
|
|
1252
|
+
|
|
1253
|
+
| Table | Purpose |
|
|
1254
|
+
|-------|---------|
|
|
1255
|
+
| `_tenants` | Tenant records (`id TEXT PK DEFAULT gen_random_uuid()`, `name`, `created_at`) |
|
|
1256
|
+
| `_tenant_members` | User-tenant membership (`tenant_id`, `user_id`, `role`) |
|
|
1257
|
+
| `_user_tables` | Dynamic table definitions (`tenant_id`, `slug`, `fields JSONB`) |
|
|
1258
|
+
|
|
1259
|
+
## Dynamic table API
|
|
1260
|
+
|
|
1261
|
+
Create a table at runtime:
|
|
1262
|
+
|
|
1263
|
+
```json
|
|
1264
|
+
POST /api/tables
|
|
1265
|
+
{
|
|
1266
|
+
"slug": "articles",
|
|
1267
|
+
"fields": [
|
|
1268
|
+
{ "name": "title", "type": "string", "required": true },
|
|
1269
|
+
{ "name": "content", "type": "text" },
|
|
1270
|
+
{ "name": "status", "type": "enum", "options": ["draft", "published"], "default": "draft" },
|
|
1271
|
+
{ "name": "views", "type": "integer", "default": 0 },
|
|
1272
|
+
{ "name": "embedding", "type": "vector", "dimensions": 1536, "index": "hnsw" }
|
|
1273
|
+
]
|
|
1274
|
+
}
|
|
1275
|
+
```
|
|
1276
|
+
|
|
1277
|
+
## Field types
|
|
1278
|
+
|
|
1279
|
+
| type | PostgreSQL | Index support |
|
|
1280
|
+
|------|-----------|---------------|
|
|
1281
|
+
| `string` | `TEXT` | `true`, `unique` |
|
|
1282
|
+
| `integer` | `INTEGER` | `true`, `desc`, `unique` |
|
|
1283
|
+
| `float` | `DOUBLE PRECISION` | `true`, `desc` |
|
|
1284
|
+
| `boolean` | `BOOLEAN` | `true` |
|
|
1285
|
+
| `text` | `TEXT` | `true` |
|
|
1286
|
+
| `datetime` | `TIMESTAMPTZ` | `true`, `desc` |
|
|
1287
|
+
| `date` | `DATE` | `true`, `desc` |
|
|
1288
|
+
| `enum` | `TEXT` (with validation) | `true` |
|
|
1289
|
+
| `json` | `JSONB` | `gin` |
|
|
1290
|
+
| `vector` | `vector(n)` (pgvector) | `hnsw` (HNSW, vector_cosine_ops) |
|
|
1291
|
+
|
|
1292
|
+
## RESTful API
|
|
1293
|
+
|
|
1294
|
+
All routes require `ctx.tenant` (set by `t.middleware()`). All queries automatically filter by `tenant_id`.
|
|
1295
|
+
|
|
1296
|
+
| Route | Method | Description |
|
|
1297
|
+
|-------|--------|-------------|
|
|
1298
|
+
| `/sys/tenants` | POST | Create tenant, caller becomes admin |
|
|
1299
|
+
| `/sys/tenants` | GET | List user's tenants |
|
|
1300
|
+
| `/sys/tenants/invite` | POST | Invite user by email (admin) |
|
|
1301
|
+
| `/sys/tenants/members/:userId` | DELETE | Remove member (admin) |
|
|
1302
|
+
| `/sys/tables` | POST/GET | Create / list dynamic tables |
|
|
1303
|
+
| `/sys/tables/:slug` | GET/PATCH/DELETE | Get schema / add fields / drop table |
|
|
1304
|
+
| `/:slug` | GET | List rows (limit, offset, sort) |
|
|
1305
|
+
| `/:slug` | POST | Create row |
|
|
1306
|
+
| `/:slug/:id` | GET/PATCH/DELETE | Get / update / delete row |
|
|
1307
|
+
| `/:slug/:id/:_nested` | GET | List related rows (has_many / M2M) |
|
|
1308
|
+
| `/:slug/:id/:_nested` | POST | Create related row (auto-fills relation field) |
|
|
1309
|
+
|
|
1310
|
+
---
|
|
1311
|
+
|
|
1312
|
+
# Messager
|
|
1313
|
+
|
|
1314
|
+
Real-time chat with channels, WebSocket, and agent routing.
|
|
1315
|
+
|
|
1316
|
+
```ts
|
|
1317
|
+
import { messager, agent } from 'weifuwu'
|
|
1318
|
+
|
|
1319
|
+
const agents = agent({ pg })
|
|
1320
|
+
const msg = messager({ pg, agents })
|
|
1321
|
+
|
|
1322
|
+
await msg.migrate()
|
|
1323
|
+
app.use('/api', msg.router())
|
|
1324
|
+
app.ws('/ws', u.middleware(), msg.wsHandler())
|
|
1325
|
+
```
|
|
1326
|
+
|
|
1327
|
+
## Channels
|
|
1328
|
+
|
|
1329
|
+
```http
|
|
1330
|
+
POST /channels name, type (channel|dm), members
|
|
1331
|
+
GET /channels
|
|
1332
|
+
GET /channels/:id
|
|
1333
|
+
```
|
|
1334
|
+
|
|
1335
|
+
## Messages
|
|
1336
|
+
|
|
1337
|
+
```http
|
|
1338
|
+
GET /channels/:id/messages ?limit=50&before={id}
|
|
1339
|
+
POST /channels/:id/messages content, sender_type, type
|
|
1340
|
+
POST /channels/:id/read last_message_id
|
|
1341
|
+
```
|
|
1342
|
+
|
|
1343
|
+
## WebSocket
|
|
1344
|
+
|
|
1345
|
+
```json
|
|
1346
|
+
{ "type": "message", "channel_id": 1, "content": "Hi" }
|
|
1347
|
+
{ "type": "typing", "channel_id": 1, "is_typing": true }
|
|
1348
|
+
{ "type": "read", "channel_id": 1, "last_message_id": 42 }
|
|
1349
|
+
```
|
|
1350
|
+
|
|
1351
|
+
## Programmatic send
|
|
1352
|
+
|
|
1353
|
+
```ts
|
|
1354
|
+
await msg.send(channelId, 'System message', { sender_type: 'system' })
|
|
1355
|
+
```
|
|
1356
|
+
|
|
1357
|
+
---
|
|
1358
|
+
|
|
1359
|
+
# LogDB — Structured Event Logging
|
|
1360
|
+
|
|
1361
|
+
PostgreSQL-backed structured logging with monthly partitioning, metadata search, and built-in REST API.
|
|
1362
|
+
|
|
1363
|
+
```ts
|
|
1364
|
+
import { serve, Router, logdb, postgres } from 'weifuwu'
|
|
1365
|
+
|
|
1366
|
+
const pg = postgres()
|
|
1367
|
+
const logger = logdb({ pg })
|
|
1368
|
+
|
|
1369
|
+
await logger.migrate() // create table + partitions
|
|
1370
|
+
app.use('/logs', logger.router()) // mount REST API
|
|
1371
|
+
```
|
|
1372
|
+
|
|
1373
|
+
## Module API
|
|
1374
|
+
|
|
1375
|
+
```ts
|
|
1376
|
+
const logger = logdb({
|
|
1377
|
+
pg: PostgresClient,
|
|
1378
|
+
table?: string // default: '_log_entries'
|
|
1379
|
+
})
|
|
1380
|
+
```
|
|
1381
|
+
|
|
1382
|
+
| Method | Returns | Description |
|
|
1383
|
+
|--------|---------|-------------|
|
|
1384
|
+
| `log(input)` | `LogEntry` | Insert a log entry programmatically |
|
|
1385
|
+
| `router()` | `Router` | REST API routes: `POST /`, `GET /`, `GET /:id` |
|
|
1386
|
+
| `migrate()` | `Promise<void>` | Create partitioned table + month partitions |
|
|
1387
|
+
| `clean(n)` | `Promise<number>` | Drop partitions older than `n` months |
|
|
1388
|
+
| `close()` | `Promise<void>` | Close database connection |
|
|
1389
|
+
|
|
1390
|
+
## Log entries
|
|
1391
|
+
|
|
1392
|
+
```ts
|
|
1393
|
+
interface LogEntryInput {
|
|
1394
|
+
level: string // info, warn, error, debug
|
|
1395
|
+
source: string // api, ui, system, or custom
|
|
1396
|
+
message: string
|
|
1397
|
+
metadata?: Record<string, unknown>
|
|
1398
|
+
}
|
|
1399
|
+
```
|
|
1400
|
+
|
|
1401
|
+
## REST API
|
|
1402
|
+
|
|
1403
|
+
| Method | Path | Description |
|
|
1404
|
+
|--------|------|-------------|
|
|
1405
|
+
| `POST /` | Create a log entry | Returns `LogEntry` with status 201 |
|
|
1406
|
+
| `GET /` | Query log entries | Returns `{ entries: LogEntry[], total: number }` |
|
|
1407
|
+
| `GET /:id` | Get single entry | Returns `LogEntry` or 404 |
|
|
1408
|
+
|
|
1409
|
+
### Query parameters (`GET /`)
|
|
1410
|
+
|
|
1411
|
+
| Param | Example | Description |
|
|
1412
|
+
|-------|---------|-------------|
|
|
1413
|
+
| `level` | `?level=error` | Filter by level (exact match) |
|
|
1414
|
+
| `source` | `?source=api` | Filter by source (exact match) |
|
|
1415
|
+
| `after` | `?after=2026-01-01` | Entries on or after this timestamp |
|
|
1416
|
+
| `before` | `?before=2026-03-01` | Entries before this timestamp |
|
|
1417
|
+
| `meta.*` | `?meta.service=auth&meta.env=prod` | Filter by metadata key/value |
|
|
1418
|
+
| `limit` | `?limit=20` | Page size (default: 50) |
|
|
1419
|
+
| `offset` | `?offset=40` | Page offset (default: 0) |
|
|
1420
|
+
|
|
1421
|
+
## Partitioning
|
|
1422
|
+
|
|
1423
|
+
Logs are stored in a PostgreSQL range-partitioned table by `created_at`. Partitions are pre-created for the current month + 12 months ahead. This keeps each partition small, enables partition-pruning for time-range queries, and allows instant retention via `DROP TABLE`.
|
|
1424
|
+
|
|
1425
|
+
### Retention
|
|
1426
|
+
|
|
1427
|
+
```ts
|
|
1428
|
+
// Drop all partitions older than 12 months
|
|
1429
|
+
const dropped = await logger.clean(12)
|
|
1430
|
+
console.log(`Dropped ${dropped} old partitions`)
|
|
1431
|
+
```
|
|
1432
|
+
|
|
1433
|
+
The `migrate()` method creates the parent table and pre-creates partitions. The `log()` method checks for the current month's partition and creates it if missing — safe across month boundaries without re-running migration.
|
|
1434
|
+
|
|
1435
|
+
---
|
|
1436
|
+
|
|
1437
|
+
# Opencode
|
|
1438
|
+
|
|
1439
|
+
AI programming assistant — chat with LLM agents that have access to filesystem tools, skills, and isolated session workspaces.
|
|
1440
|
+
|
|
1441
|
+
```ts
|
|
1442
|
+
import { serve, Router, postgres, opencode } from 'weifuwu'
|
|
1443
|
+
|
|
1444
|
+
const app = new Router()
|
|
1445
|
+
const pg = postgres()
|
|
1446
|
+
const oc = await opencode({ pg, permissions: { ... } })
|
|
1447
|
+
|
|
1448
|
+
await oc.migrate()
|
|
1449
|
+
app.use('/opencode', await oc.router())
|
|
1450
|
+
app.ws('/opencode', oc.wsHandler())
|
|
1451
|
+
|
|
1452
|
+
serve(app.handler(), { port: 3000, websocket: app.websocketHandler() })
|
|
1453
|
+
```
|
|
1454
|
+
|
|
1455
|
+
### Tools
|
|
1456
|
+
|
|
1457
|
+
| Tool | Description |
|
|
1458
|
+
|------|-------------|
|
|
1459
|
+
| `bash` | Execute shell commands in the workspace |
|
|
1460
|
+
| `read` | Read files with offset/limit |
|
|
1461
|
+
| `write` | Create or overwrite files |
|
|
1462
|
+
| `edit` | Exact string replacements |
|
|
1463
|
+
| `grep` | Regex content search |
|
|
1464
|
+
| `glob` | Glob pattern file search |
|
|
1465
|
+
| `web` | Fetch URL content |
|
|
1466
|
+
| `question` | Ask the user for input |
|
|
1467
|
+
| `skill` | Load a skill on demand |
|
|
1468
|
+
|
|
1469
|
+
### Permissions
|
|
1470
|
+
|
|
1471
|
+
Control tool access per conversation:
|
|
1472
|
+
|
|
1473
|
+
```ts
|
|
1474
|
+
const oc = await opencode({
|
|
1475
|
+
pg,
|
|
1476
|
+
permissions: {
|
|
1477
|
+
bash: { allow: true },
|
|
1478
|
+
read: { allow: true },
|
|
1479
|
+
write: { allow: false },
|
|
1480
|
+
edit: { allow: false },
|
|
1481
|
+
skill: { '*': { allow: true }, 'internal-*': { allow: false } },
|
|
1482
|
+
},
|
|
1483
|
+
})
|
|
1484
|
+
```
|
|
1485
|
+
|
|
1486
|
+
---
|
|
1487
|
+
|
|
1488
|
+
# Health, i18n, Email & Testing
|
|
1489
|
+
|
|
1490
|
+
## Health check
|
|
1491
|
+
|
|
1492
|
+
```ts
|
|
1493
|
+
import { serve, Router, health } from 'weifuwu'
|
|
1494
|
+
|
|
1495
|
+
const app = new Router()
|
|
1496
|
+
app.use(health()) // GET /health → 200
|
|
1497
|
+
app.use(health({ path: '/healthz' })) // custom path
|
|
1498
|
+
app.use(health({
|
|
1499
|
+
check: async () => { await db.sql`SELECT 1` }, // fail → 503
|
|
1500
|
+
}))
|
|
1501
|
+
serve(app.handler(), { port: 3000 })
|
|
1502
|
+
```
|
|
1503
|
+
|
|
1504
|
+
Returns a `Router` — mount with `app.use()`.
|
|
1505
|
+
|
|
1506
|
+
## Internationalization
|
|
1507
|
+
|
|
1508
|
+
```ts
|
|
1509
|
+
import { i18n } from 'weifuwu'
|
|
1510
|
+
|
|
1511
|
+
app.use(i18n({ dir: './locales', defaultLocale: 'en' }))
|
|
1512
|
+
|
|
1513
|
+
// In any handler after i18n middleware:
|
|
1514
|
+
app.get('/hello', (req, ctx) => {
|
|
1515
|
+
const msg = ctx.t('greeting', { name: 'World' })
|
|
1516
|
+
return Response.json({ message: msg, locale: ctx.locale })
|
|
1517
|
+
})
|
|
1518
|
+
```
|
|
1519
|
+
|
|
1520
|
+
Locale detection: `Cookie: locale=zh` → `Accept-Language: zh-CN` → `defaultLocale`.
|
|
1521
|
+
|
|
1522
|
+
## Email
|
|
1523
|
+
|
|
1524
|
+
```ts
|
|
1525
|
+
import { mailer } from 'weifuwu'
|
|
1526
|
+
|
|
1527
|
+
// SMTP transport
|
|
1528
|
+
const mail = mailer({
|
|
1529
|
+
transport: 'smtp://user:pass@smtp.example.com',
|
|
1530
|
+
from: 'noreply@example.com',
|
|
1531
|
+
})
|
|
1532
|
+
await mail.send({ to: 'user@example.com', subject: 'Welcome', html: '<h1>Hi!</h1>' })
|
|
1533
|
+
await mail.close()
|
|
1534
|
+
|
|
1535
|
+
// Custom transport (Resend, SES, SendGrid, etc.)
|
|
1536
|
+
const mail2 = mailer({
|
|
1537
|
+
send: async (msg) => { await resend.emails.send(msg) },
|
|
1538
|
+
})
|
|
1539
|
+
await mail2.send({ to: 'user@example.com', subject: 'Hi', text: 'Hello' })
|
|
1540
|
+
await mail2.close()
|
|
1541
|
+
```
|
|
1542
|
+
|
|
1543
|
+
## Test utilities
|
|
1544
|
+
|
|
1545
|
+
```ts
|
|
1546
|
+
import { createTestServer } from 'weifuwu'
|
|
1547
|
+
|
|
1548
|
+
const { server, url } = await createTestServer(app.handler())
|
|
1549
|
+
const res = await fetch(`${url}/api/users`)
|
|
1550
|
+
assert.equal(res.status, 200)
|
|
1551
|
+
server.stop()
|
|
1552
|
+
```
|
|
1553
|
+
|
|
1554
|
+
---
|
|
1555
|
+
|
|
156
1556
|
## License
|
|
157
1557
|
|
|
158
1558
|
MIT
|