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 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
- ## Documentation
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, see [deploy.md](./deploy.md) |
92
+ | Deploy | `deploy(config)` | Self-hosted PaaS: multi-app proxy, zero-downtime updates, auto SSL |
110
93
 
111
- ### Mountable modules
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
- ### Middleware (all `(req, ctx, next) => Response`)
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
- ### Utility functions
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