create-fullstack-scaffold 0.4.25 → 0.5.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.
Files changed (135) hide show
  1. package/dist/cli/index.js +432 -15
  2. package/dist/cli/index.js.map +1 -1
  3. package/package.json +10 -13
  4. package/template/eslint-rules/__tests__/no-merged-api-type-export.test.ts +96 -0
  5. package/template/eslint-rules/no-cross-module-service-import.js +4 -0
  6. package/template/eslint-rules/no-merged-api-type-export.js +190 -0
  7. package/template/eslint.config.js +3 -0
  8. package/template/package.json +9 -6
  9. package/template/scripts/sync-agent-hooks.mjs +189 -0
  10. package/template/src/admin/components/__tests__/StatsCard.test.tsx +2 -2
  11. package/template/src/admin/pages/ContentPage.tsx +1 -1
  12. package/template/src/admin/pages/DisputesPage.tsx +1 -1
  13. package/template/src/admin/pages/OrdersPage.tsx +1 -1
  14. package/template/src/admin/pages/TicketsPage.tsx +1 -1
  15. package/template/src/admin/pages/__tests__/ContentPage.test.tsx +5 -1
  16. package/template/src/admin/pages/__tests__/DashboardPage.test.tsx +62 -8
  17. package/template/src/admin/pages/__tests__/DisputesPage.test.tsx +8 -1
  18. package/template/src/admin/pages/__tests__/OrdersPage.test.tsx +4 -2
  19. package/template/src/admin/pages/__tests__/RegisterPage.test.tsx +40 -43
  20. package/template/src/admin/pages/__tests__/SettingsPage.test.tsx +83 -21
  21. package/template/src/admin/pages/__tests__/TicketsPage.test.tsx +3 -1
  22. package/template/src/admin/services/apiClient.ts +2 -3
  23. package/template/src/cli/modules/content/index.ts +3 -3
  24. package/template/src/cli/modules/dispute/index.ts +3 -3
  25. package/template/src/cli/modules/ticket/index.ts +3 -3
  26. package/template/src/cli/modules/todo/index.ts +1 -1
  27. package/template/src/cli/rpc/client.ts +3 -4
  28. package/template/src/cli/rpc/index.ts +1 -1
  29. package/template/src/client/App.tsx +3 -39
  30. package/template/src/client/AppRoutes.tsx +53 -0
  31. package/template/src/client/entry-server.tsx +75 -0
  32. package/template/src/client/main.tsx +3 -1
  33. package/template/src/client/pages/SearchPage.tsx +1 -1
  34. package/template/src/client/services/apiClient.ts +12 -4
  35. package/template/src/client/stores/__tests__/todoStore.test.ts +12 -3
  36. package/template/src/client/stores/entry-stores.ts +36 -0
  37. package/template/src/client/stores/notificationStore.ts +4 -4
  38. package/template/src/client/stores/todoStore.ts +2 -2
  39. package/template/src/merchant/pages/DisputesPage.tsx +3 -3
  40. package/template/src/merchant/pages/OrdersPage.tsx +1 -1
  41. package/template/src/merchant/pages/ProductsPage.tsx +1 -1
  42. package/template/src/merchant/pages/SettingsPage.tsx +1 -1
  43. package/template/src/server/__tests__/integration/isr-full-flow.test.ts +251 -0
  44. package/template/src/server/__tests__/integration/todos-api.test.ts +7 -4
  45. package/template/src/server/app.ts +6 -4
  46. package/template/src/server/core/__tests__/isr-cache.test.ts +96 -92
  47. package/template/src/server/core/__tests__/isr-invalidation.test.ts +29 -4
  48. package/template/src/server/core/__tests__/isr-registry.test.ts +215 -0
  49. package/template/src/server/core/__tests__/isr-renderer.test.ts +121 -0
  50. package/template/src/server/core/isr-cache.ts +6 -12
  51. package/template/src/server/core/isr-invalidation.ts +6 -12
  52. package/template/src/server/core/isr-registry.ts +104 -0
  53. package/template/src/server/core/isr-renderer.ts +75 -0
  54. package/template/src/server/db/schema/contents.ts +28 -20
  55. package/template/src/server/db/schema/disputes.ts +30 -22
  56. package/template/src/server/db/schema/notifications.ts +20 -14
  57. package/template/src/server/db/schema/orders.ts +23 -16
  58. package/template/src/server/db/schema/plugins.ts +56 -38
  59. package/template/src/server/db/schema/products.ts +24 -17
  60. package/template/src/server/db/schema/tickets.ts +44 -31
  61. package/template/src/server/db/schema/todos.ts +26 -18
  62. package/template/src/server/entries/cloudflare.ts +82 -19
  63. package/template/src/server/entries/node.ts +0 -2
  64. package/template/src/server/index.ts +0 -1
  65. package/template/src/server/isr-modules.ts +10 -0
  66. package/template/src/server/module-admin/__tests__/admin-service.test.ts +36 -12
  67. package/template/src/server/module-admin/routes/admin-notification-routes.ts +2 -1
  68. package/template/src/server/module-admin/routes/admin-routes.ts +3 -0
  69. package/template/src/server/module-admin/routes/dashboard-routes.ts +3 -0
  70. package/template/src/server/module-admin/services/admin-service.ts +57 -13
  71. package/template/src/server/module-auth/routes/auth-routes.ts +3 -0
  72. package/template/src/server/module-auth/routes/profile-routes.ts +3 -0
  73. package/template/src/server/module-captcha/routes/captcha-routes.ts +3 -0
  74. package/template/src/server/module-chat/routes/chat-routes.ts +4 -1
  75. package/template/src/server/module-content/__tests__/content-route.test.ts +2 -1
  76. package/template/src/server/module-content/__tests__/content-service.test.ts +2 -1
  77. package/template/src/server/module-content/__tests__/isr.test.ts +138 -0
  78. package/template/src/server/module-content/isr.ts +63 -0
  79. package/template/src/server/module-content/routes/content-routes.ts +10 -10
  80. package/template/src/server/module-content/routes/public-content-routes.ts +8 -4
  81. package/template/src/server/module-content/routes/topics-routes.ts +3 -0
  82. package/template/src/server/module-content/services/content-service.ts +23 -10
  83. package/template/src/server/module-dispute/__tests__/dispute-route.test.ts +2 -1
  84. package/template/src/server/module-dispute/__tests__/dispute-service.test.ts +2 -1
  85. package/template/src/server/module-dispute/routes/dispute-routes.ts +11 -10
  86. package/template/src/server/module-dispute/services/dispute-service.ts +15 -3
  87. package/template/src/server/module-file/routes/file-routes.ts +3 -0
  88. package/template/src/server/module-merchant/routes/merchant-routes.ts +3 -0
  89. package/template/src/server/module-merchant/services/merchant-service.ts +8 -8
  90. package/template/src/server/module-notifications/routes/notification-routes.ts +5 -1
  91. package/template/src/server/module-order/__tests__/order-route.test.ts +8 -8
  92. package/template/src/server/module-order/__tests__/order-service.test.ts +4 -3
  93. package/template/src/server/module-order/routes/cart-routes.ts +3 -0
  94. package/template/src/server/module-order/routes/order-routes.ts +9 -4
  95. package/template/src/server/module-order/routes/orders-mock-routes.ts +3 -0
  96. package/template/src/server/module-order/services/order-service.ts +25 -13
  97. package/template/src/server/module-permission/__tests__/audit-log-service.test.ts +464 -0
  98. package/template/src/server/module-permission/__tests__/role-service.test.ts +348 -0
  99. package/template/src/server/module-permission/routes/audit-log-routes.ts +3 -0
  100. package/template/src/server/module-permission/routes/permission-routes.ts +3 -0
  101. package/template/src/server/module-permission/routes/role-routes.ts +3 -0
  102. package/template/src/server/module-plugin/routes/plugin-admin-routes.ts +3 -0
  103. package/template/src/server/module-plugin/routes/plugin-routes.ts +3 -0
  104. package/template/src/server/module-plugin/services/admin-plugin-service.ts +10 -18
  105. package/template/src/server/module-plugin/services/admin-stats-service.ts +28 -9
  106. package/template/src/server/module-plugin/services/plugin-query-service.ts +42 -66
  107. package/template/src/server/module-tenant/routes/tenant-routes.ts +5 -1
  108. package/template/src/server/module-ticket/__tests__/ticket-route.test.ts +2 -1
  109. package/template/src/server/module-ticket/__tests__/ticket-service.test.ts +4 -3
  110. package/template/src/server/module-ticket/routes/ticket-routes.ts +11 -10
  111. package/template/src/server/module-ticket/services/ticket-service.ts +16 -5
  112. package/template/src/server/module-todos/__tests__/isr.test.ts +105 -0
  113. package/template/src/server/module-todos/__tests__/todo-service.test.ts +12 -9
  114. package/template/src/server/module-todos/__tests__/todos-route-rpc.test.ts +6 -6
  115. package/template/src/server/module-todos/isr.ts +41 -0
  116. package/template/src/server/module-todos/routes/todos-routes.ts +12 -5
  117. package/template/src/server/module-todos/services/todo-service.ts +31 -10
  118. package/template/src/server/route-registry.ts +0 -4
  119. package/template/src/server/rpc-merge.ts +27 -0
  120. package/template/src/server/rpc-surface.ts +117 -0
  121. package/template/src/server/rpc-type-canary.ts +80 -0
  122. package/template/src/server/test-utils/test-client.ts +7 -9
  123. package/template/src/server/test-utils/test-isr-helper.ts +140 -0
  124. package/template/src/shared/modules/content/schemas.ts +14 -0
  125. package/template/src/shared/modules/dispute/schemas.ts +14 -0
  126. package/template/src/shared/modules/order/schemas.ts +9 -1
  127. package/template/src/shared/modules/ticket/schemas.ts +14 -0
  128. package/template/src/shared/modules/todos/index.ts +4 -0
  129. package/template/src/shared/modules/todos/schemas.ts +14 -0
  130. package/template/src/shared/schemas/index.ts +18 -0
  131. package/template/tsup.config.ts +48 -1
  132. package/template/vitest.config.ts +19 -3
  133. package/template/vitest.setup.ts +68 -5
  134. package/template/patches/typescript+5.9.3.patch +0 -24
  135. /package/template/patches/{hono+4.12.16.patch → hono+4.12.34.patch} +0 -0
@@ -165,10 +165,14 @@ export const apiRoutes = new OpenAPIHono()
165
165
  if (!tenant) throw new NotFoundError('Tenant context')
166
166
  return c.json(success(tenant), 200)
167
167
  })
168
- .doc('/docs', {
168
+ // todos 模块已占用 /docs;tenant 的 OpenAPI 文档挂独立路径避免 shadow
169
+ .doc('/tenant/docs', {
169
170
  openapi: '3.0.0',
170
171
  info: {
171
172
  version: '1.0.0',
172
173
  title: 'Tenant Management API',
173
174
  },
174
175
  })
176
+
177
+ /** 模块级窄类型(深度 = 1 个模块)— 供 rpc-surface 门面使用,禁止再向上合并 */
178
+ export type TenantApiType = typeof apiRoutes
@@ -22,7 +22,8 @@ describe('Ticket Routes', () => {
22
22
  const data = await res.json()
23
23
  expect(data.success).toBe(true)
24
24
  if (data.success) {
25
- expect(Array.isArray(data.data)).toBe(true)
25
+ expect(data.data.tickets).toBeDefined()
26
+ expect(typeof data.data.total).toBe('number')
26
27
  }
27
28
  })
28
29
  })
@@ -17,7 +17,8 @@ describe('Ticket Service', () => {
17
17
  describe('getTickets', () => {
18
18
  it('should return all tickets', async () => {
19
19
  const result = await service.getTickets()
20
- expect(Array.isArray(result)).toBe(true)
20
+ expect(result.tickets).toBeDefined()
21
+ expect(typeof result.total).toBe('number')
21
22
  })
22
23
 
23
24
  it('should filter tickets by status', async () => {
@@ -33,8 +34,8 @@ describe('Ticket Service', () => {
33
34
  createdTicketIds.push(created.id)
34
35
 
35
36
  const result = await service.getTickets({ status: 'open' })
36
- expect(Array.isArray(result)).toBe(true)
37
- result.forEach(ticket => {
37
+ expect(Array.isArray(result.tickets)).toBe(true)
38
+ result.tickets.forEach(ticket => {
38
39
  expect(ticket.status).toBe('open')
39
40
  })
40
41
  })
@@ -1,4 +1,4 @@
1
- import { createRoute, z } from '@hono/zod-openapi'
1
+ import { createRoute } from '@hono/zod-openapi'
2
2
  import { OpenAPIHono } from '@hono/zod-openapi'
3
3
  import * as ticketService from '../services/ticket-service'
4
4
  import { successResponse, errorResponse, success, created } from '@server/utils/route-helpers'
@@ -9,7 +9,8 @@ import {
9
9
  TicketSchema,
10
10
  CreateTicketSchema,
11
11
  UpdateTicketSchema,
12
- TicketListSchema,
12
+ TicketListResponseSchema,
13
+ TicketListQuerySchema,
13
14
  TicketDeleteResultSchema,
14
15
  ReplyTicketSchema,
15
16
  } from '@shared/modules/ticket'
@@ -21,13 +22,10 @@ const listRoute = createRoute({
21
22
  security: [{ Bearer: [] }],
22
23
  middleware: [authMiddleware({ requiredPermissions: [Permission.TICKET_VIEW] })],
23
24
  request: {
24
- query: z.object({
25
- limit: z.coerce.number().int().positive().max(100).default(20),
26
- offset: z.coerce.number().int().min(0).default(0),
27
- }),
25
+ query: TicketListQuerySchema,
28
26
  },
29
27
  responses: {
30
- 200: successResponse(TicketListSchema, 'List all tickets'),
28
+ 200: successResponse(TicketListResponseSchema, 'List all tickets'),
31
29
  401: errorResponse('Unauthorized'),
32
30
  403: errorResponse('Forbidden'),
33
31
  500: errorResponse('Internal server error'),
@@ -167,9 +165,9 @@ const closeRoute = createRoute({
167
165
 
168
166
  export const ticketRoutes = new OpenAPIHono()
169
167
  .openapi(listRoute, async c => {
170
- const { limit, offset } = c.req.valid('query')
171
- const result = await ticketService.getTickets()
172
- return c.json(success(result.slice(offset, offset + limit)), 200)
168
+ const { page, limit } = c.req.valid('query')
169
+ const result = await ticketService.getTickets({ page, limit })
170
+ return c.json(success(result), 200)
173
171
  })
174
172
  .openapi(getRoute, async c => {
175
173
  const { id } = c.req.valid('param')
@@ -208,3 +206,6 @@ export const ticketRoutes = new OpenAPIHono()
208
206
  if (!result) throw new NotFoundError('Ticket', id)
209
207
  return c.json(success(result), 200)
210
208
  })
209
+
210
+ /** 模块级窄类型(深度 = 1 个模块)— 供 rpc-surface 门面使用,禁止再向上合并 */
211
+ export type TicketsApiType = typeof ticketRoutes
@@ -154,8 +154,13 @@ export async function getTickets(filters?: {
154
154
  status?: TicketStatus
155
155
  priority?: TicketPriority
156
156
  category?: TicketCategory
157
- }): Promise<Ticket[]> {
157
+ page?: number
158
+ limit?: number
159
+ }): Promise<{ tickets: Ticket[]; total: number; page: number; limit: number }> {
158
160
  const db = await getDb()
161
+ const page = filters?.page ?? 1
162
+ const limit = filters?.limit ?? 20
163
+ const offset = (page - 1) * limit
159
164
  const conditions = []
160
165
 
161
166
  if (filters?.status) {
@@ -169,7 +174,7 @@ export async function getTickets(filters?: {
169
174
  }
170
175
 
171
176
  const priorityOrder = { urgent: 0, high: 1, medium: 2, low: 3 }
172
- const rows =
177
+ const allRows =
173
178
  conditions.length > 0
174
179
  ? await db
175
180
  .select()
@@ -177,14 +182,20 @@ export async function getTickets(filters?: {
177
182
  .where(and(...conditions))
178
183
  : await db.select().from(tickets)
179
184
 
180
- const sorted = rows.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority])
185
+ const sorted = allRows.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority])
186
+ const rows = sorted.slice(offset, offset + limit)
181
187
 
182
188
  const result: Ticket[] = []
183
- for (const row of sorted) {
189
+ for (const row of rows) {
184
190
  result.push(await loadTicketWithReplies(row))
185
191
  }
186
192
 
187
- return result
193
+ return {
194
+ tickets: result,
195
+ total: sorted.length,
196
+ page,
197
+ limit,
198
+ }
188
199
  }
189
200
 
190
201
  export async function getTicketById(id: string): Promise<Ticket | null> {
@@ -0,0 +1,105 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+
3
+ // Mock the todo service BEFORE importing isr.ts
4
+ vi.mock('@server/module-todos/services/todo-service', () => ({
5
+ listTodos: vi.fn(),
6
+ }))
7
+
8
+ import { listTodos } from '@server/module-todos/services/todo-service'
9
+ import type { MockTodo } from '@server/test-utils/test-isr-helper'
10
+
11
+ // Side-effect: register ISR routes into global registry
12
+ import '@server/module-todos/isr'
13
+
14
+ const mockTodosData: MockTodo[] = [
15
+ {
16
+ id: 1,
17
+ title: 'Task A',
18
+ status: 'completed',
19
+ createdAt: '2024-01-01T00:00:00.000Z',
20
+ updatedAt: '2024-01-01T00:00:00.000Z',
21
+ },
22
+ {
23
+ id: 2,
24
+ title: 'Task B',
25
+ status: 'pending',
26
+ createdAt: '2024-01-02T00:00:00.000Z',
27
+ updatedAt: '2024-01-02T00:00:00.000Z',
28
+ },
29
+ {
30
+ id: 3,
31
+ title: 'Task C',
32
+ status: 'in_progress',
33
+ createdAt: '2024-01-03T00:00:00.000Z',
34
+ updatedAt: '2024-01-03T00:00:00.000Z',
35
+ },
36
+ ]
37
+
38
+ describe('Todos Module ISR', () => {
39
+ beforeEach(() => {
40
+ vi.clearAllMocks()
41
+ })
42
+
43
+ describe('route registration', () => {
44
+ it('should register /todos route', async () => {
45
+ vi.mocked(listTodos).mockResolvedValue({ todos: mockTodosData, total: 3, page: 1, limit: 20 })
46
+
47
+ // Dynamic import to trigger registration into fresh registry
48
+ // Since isr.ts registers into the global registry, we test the global one
49
+ const { isrRegistry } = await import('@server/core/isr-registry')
50
+ // The global registry already has todos from the side-effect import
51
+ expect(isrRegistry.match('/todos')).not.toBeNull()
52
+ expect(isrRegistry.match('/todos')!.module).toBe('todos')
53
+ })
54
+
55
+ it('should register / route', async () => {
56
+ const { isrRegistry } = await import('@server/core/isr-registry')
57
+ const entry = isrRegistry.match('/')
58
+ expect(entry).not.toBeNull()
59
+ expect(entry!.module).toBe('todos')
60
+ })
61
+ })
62
+
63
+ describe('data fetching', () => {
64
+ it('should fetch todos from service', async () => {
65
+ vi.mocked(listTodos).mockResolvedValue({ todos: mockTodosData, total: 3, page: 1, limit: 20 })
66
+
67
+ const { isrRegistry } = await import('@server/core/isr-registry')
68
+ const entry = isrRegistry.match('/todos')!
69
+ const data = (await entry.fetch('/todos', {})) as { todos: MockTodo[] }
70
+
71
+ expect(data.todos).toHaveLength(3)
72
+ expect(data.todos[0].title).toBe('Task A')
73
+ })
74
+
75
+ it('should handle empty todos', async () => {
76
+ vi.mocked(listTodos).mockResolvedValue({ todos: [], total: 0, page: 1, limit: 20 })
77
+
78
+ const { isrRegistry } = await import('@server/core/isr-registry')
79
+ const entry = isrRegistry.match('/todos')!
80
+ const data = (await entry.fetch('/todos', {})) as { todos: MockTodo[] }
81
+
82
+ expect(data.todos).toHaveLength(0)
83
+ })
84
+
85
+ it('should handle DB error gracefully', async () => {
86
+ vi.mocked(listTodos).mockRejectedValue(new Error('DB connection failed'))
87
+
88
+ const { isrRegistry } = await import('@server/core/isr-registry')
89
+ const entry = isrRegistry.match('/todos')!
90
+
91
+ await expect(entry.fetch('/todos', {})).rejects.toThrow('DB connection failed')
92
+ })
93
+ })
94
+
95
+ describe('meta tags', () => {
96
+ it('should return correct meta', async () => {
97
+ const { isrRegistry } = await import('@server/core/isr-registry')
98
+ const entry = isrRegistry.match('/todos')!
99
+ const meta = entry.meta({}, '/todos')
100
+
101
+ expect(meta.title).toContain('Todo List')
102
+ expect(meta.description).toContain('todos')
103
+ })
104
+ })
105
+ })
@@ -33,9 +33,12 @@ describe('Todo Service', () => {
33
33
  it('should return empty array when no todos exist', async () => {
34
34
  const result = await todoService.listTodos()
35
35
 
36
- expect(result).toEqual([])
37
- expect(Array.isArray(result)).toBe(true)
38
- expect(result.length).toBe(0)
36
+ expect(result.todos).toEqual([])
37
+ expect(Array.isArray(result.todos)).toBe(true)
38
+ expect(result.todos.length).toBe(0)
39
+ expect(result.total).toBe(0)
40
+ expect(result.page).toBe(1)
41
+ expect(result.limit).toBe(20)
39
42
  })
40
43
 
41
44
  it('should return all todos ordered by created_at DESC', async () => {
@@ -55,11 +58,11 @@ describe('Todo Service', () => {
55
58
 
56
59
  const result = await todoService.listTodos()
57
60
 
58
- expect(result).toHaveLength(2)
59
- expect(result[0].title).toBe('Todo 2')
60
- expect(result[0].status).toBe('completed')
61
- expect(result[1].title).toBe('Todo 1')
62
- expect(result[1].status).toBe('pending')
61
+ expect(result.todos).toHaveLength(2)
62
+ expect(result.todos[0].title).toBe('Todo 2')
63
+ expect(result.todos[0].status).toBe('completed')
64
+ expect(result.todos[1].title).toBe('Todo 1')
65
+ expect(result.todos[1].status).toBe('pending')
63
66
  })
64
67
  })
65
68
 
@@ -183,7 +186,7 @@ describe('Todo Service', () => {
183
186
  expect(checkResult.rows.length).toBe(0)
184
187
 
185
188
  const allTodos = await todoService.listTodos()
186
- expect(allTodos.length).toBe(0)
189
+ expect(allTodos.total).toBe(0)
187
190
  }
188
191
  })
189
192
 
@@ -438,13 +438,13 @@ describe('Todo Routes - Business Logic Tests', () => {
438
438
  await createMultipleTodos(3)
439
439
  const client = createTestClient(undefined, { headers: authHeaders })
440
440
 
441
- const res = await client.api.todos.$get({ headers: authHeaders })
441
+ const res = await client.api.todos.$get({ query: {} })
442
442
  const data = await res.json()
443
443
 
444
444
  expect(data.success).toBe(true)
445
- if (data.success && Array.isArray(data.data)) {
446
- expect(data.data).toHaveLength(3)
447
- const timestamps = data.data.map((t: { createdAt: string }) =>
445
+ if (data.success && 'data' in data) {
446
+ expect(data.data.todos).toHaveLength(3)
447
+ const timestamps = data.data.todos.map((t: { createdAt: string }) =>
448
448
  new Date(t.createdAt).getTime()
449
449
  )
450
450
  for (let i = 0; i < timestamps.length - 1; i++) {
@@ -456,12 +456,12 @@ describe('Todo Routes - Business Logic Tests', () => {
456
456
  it('should return empty array when no todos exist', async () => {
457
457
  const client = createTestClient(undefined, { headers: authHeaders })
458
458
 
459
- const res = await client.api.todos.$get({ headers: authHeaders })
459
+ const res = await client.api.todos.$get({ query: {} })
460
460
  const data = await res.json()
461
461
 
462
462
  expect(data.success).toBe(true)
463
463
  if (data.success) {
464
- expect(data.data).toEqual([])
464
+ expect(data.data.todos).toEqual([])
465
465
  }
466
466
  })
467
467
  })
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Todos module ISR routes (meta-only).
3
+ * Registered into isrRegistry at import time.
4
+ * Fetches data for SEO meta tags — body rendering handled by React SPA.
5
+ */
6
+
7
+ import { isrRegistry, type ISRRouteEntry } from '@server/core/isr-registry'
8
+ import { listTodos } from './services/todo-service'
9
+
10
+ interface TodoData {
11
+ todos: Array<{ id: number; title: string; status: string }>
12
+ }
13
+
14
+ async function fetchTodos(_pathname: string): Promise<TodoData> {
15
+ const { todos } = await listTodos({ limit: 20 })
16
+ return { todos }
17
+ }
18
+
19
+ function todoMeta(): { title: string; description: string } {
20
+ return {
21
+ title: 'Todo List - Biomimic App',
22
+ description: 'Manage your todos with real-time updates',
23
+ }
24
+ }
25
+
26
+ const todosEntries: ISRRouteEntry[] = [
27
+ {
28
+ module: 'todos',
29
+ match: '/todos',
30
+ fetch: fetchTodos,
31
+ meta: () => todoMeta(),
32
+ },
33
+ {
34
+ module: 'todos',
35
+ match: '/',
36
+ fetch: fetchTodos,
37
+ meta: () => todoMeta(),
38
+ },
39
+ ]
40
+
41
+ isrRegistry.registerMany(todosEntries)
@@ -11,20 +11,23 @@ import {
11
11
  TodoWithAttachmentsSchema,
12
12
  UploadFileSchema,
13
13
  AttachmentIdResponseSchema,
14
+ TodoListResponseSchema,
15
+ TodoListQuerySchema,
14
16
  } from '@shared/schemas'
15
17
  import { successResponse, errorResponse, success, created } from '@server/utils/route-helpers'
16
18
  import { getAuthUser } from '@server/utils/auth'
17
19
  import { NotFoundError, ValidationError } from '@server/utils/app-error'
18
20
  import { authMiddleware } from '@server/middleware/auth'
19
21
 
20
- const TodoListSchema = z.array(TodoSchema)
21
-
22
22
  const listRoute = createRoute({
23
23
  method: 'get',
24
24
  path: '/todos',
25
25
  tags: ['todos'],
26
+ request: {
27
+ query: TodoListQuerySchema,
28
+ },
26
29
  responses: {
27
- 200: successResponse(TodoListSchema, 'List all todos'),
30
+ 200: successResponse(TodoListResponseSchema, 'List all todos'),
28
31
  500: errorResponse('Internal server error'),
29
32
  },
30
33
  })
@@ -166,8 +169,9 @@ const deleteAttachmentRoute = createRoute({
166
169
 
167
170
  export const apiRoutes = new OpenAPIHono()
168
171
  .openapi(listRoute, async c => {
169
- const todos = await todoService.listTodos()
170
- return c.json(success(todos), 200)
172
+ const { page, limit } = c.req.valid('query')
173
+ const result = await todoService.listTodos({ page, limit })
174
+ return c.json(success(result), 200)
171
175
  })
172
176
  .openapi(getRoute, async c => {
173
177
  const { id } = c.req.valid('param')
@@ -254,3 +258,6 @@ export const apiRoutes = new OpenAPIHono()
254
258
  title: 'Todo API',
255
259
  },
256
260
  })
261
+
262
+ /** 模块级窄类型(深度 = 1 个模块)— 供 rpc-surface 门面使用,禁止再向上合并 */
263
+ export type TodosApiType = typeof apiRoutes
@@ -58,17 +58,38 @@ export async function seedTodosIfEmpty(): Promise<void> {
58
58
  }
59
59
  }
60
60
 
61
- export async function listTodos(): Promise<Todo[]> {
61
+ export async function listTodos(options?: {
62
+ page?: number
63
+ limit?: number
64
+ }): Promise<{ todos: Todo[]; total: number; page: number; limit: number }> {
62
65
  const db = await getDb()
63
- const rows = await db.select().from(todos).orderBy(desc(todos.createdAt))
64
- return rows.map((row: TodoTable) => ({
65
- id: row.id,
66
- title: row.title,
67
- description: row.description ?? undefined,
68
- status: row.status,
69
- createdAt: toISOString(row.createdAt),
70
- updatedAt: toISOString(row.updatedAt),
71
- }))
66
+ const page = options?.page ?? 1
67
+ const limit = options?.limit ?? 20
68
+ const offset = (page - 1) * limit
69
+
70
+ // SQL 分页(全表扫 + 内存 slice 在数据量增长后是性能反模式)
71
+ const rows = await db
72
+ .select()
73
+ .from(todos)
74
+ .orderBy(desc(todos.createdAt))
75
+ .limit(limit)
76
+ .offset(offset)
77
+ // db 为 LibSQL|D1 联合类型:select(config) 会重载坍缩,$count 两驱动同签名
78
+ const total = await db.$count(todos)
79
+
80
+ return {
81
+ todos: rows.map((row: TodoTable) => ({
82
+ id: row.id,
83
+ title: row.title,
84
+ description: row.description ?? undefined,
85
+ status: row.status,
86
+ createdAt: toISOString(row.createdAt),
87
+ updatedAt: toISOString(row.updatedAt),
88
+ })),
89
+ total,
90
+ page,
91
+ limit,
92
+ }
72
93
  }
73
94
 
74
95
  export async function getTodo(id: number): Promise<Todo | null> {
@@ -60,7 +60,3 @@ export const adminApiRoutes = new OpenAPIHono()
60
60
  .route('/api', pluginAdminRoutes)
61
61
  .route('/api', dashboardRoutes)
62
62
  .route('/api', tenantRoutes)
63
-
64
- // 导出类型
65
- export type ClientApiRoutes = typeof clientApiRoutes
66
- export type AdminApiRoutes = typeof adminApiRoutes
@@ -0,0 +1,27 @@
1
+ /**
2
+ * RPC 客户端对象合并(get-fallback Proxy)
3
+ *
4
+ * 用途:当同一路径段(如 'admin')由多个模块路由文件共同提供时,
5
+ * 把两个窄客户端的子对象合并成一个类型交集,避免在类型层面重新
6
+ * 链式 merge 触发 TS2589。
7
+ *
8
+ * 语义:primary 优先,secondary 兜底。两个来源的子路径约定互不重叠
9
+ * (重叠时取 primary 的),因此 get-fallback 足够且无需枚举键。
10
+ */
11
+
12
+ export function mergeRpcObjects<T extends object, U extends object>(
13
+ primary: T,
14
+ secondary: U
15
+ ): T & U {
16
+ return new Proxy({} as T & U, {
17
+ get(_target, prop) {
18
+ if (prop in primary) {
19
+ return Reflect.get(primary as object, prop, primary)
20
+ }
21
+ return Reflect.get(secondary as object, prop, secondary)
22
+ },
23
+ has(_target, prop) {
24
+ return prop in primary || prop in secondary
25
+ },
26
+ })
27
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * @framework-baseline rpc-surface-v1
3
+ *
4
+ * 按模块拆分的类型安全 RPC 门面(generator 管理文件)。
5
+ *
6
+ * 背景:此前客户端通过 `hc<ClientApiType>`(route-registry 里 11-13 个模块
7
+ * 链式 merge 后的类型)调用 API,类型实例化深度随模块数线性增长,触发
8
+ * TS2589,历史上甚至 patch 过 TypeScript 编译器(已被 no-ts-patch validator
9
+ * 禁止)。
10
+ *
11
+ * 本文件是替代方案:每个模块单独实例化一个窄客户端(深度 = 1 个模块),
12
+ * 组装成与旧 `apiClient.api.<segment>` 完全同形的门面对象。调用方
13
+ * (client / admin / test-utils / CLI)全部通过 createApiFacade() 获取实例,
14
+ * 运行时 URL 与旧实现一字不差(baseUrl + '/api' + 模块内部路径)。
15
+ *
16
+ * preset 裁剪时由 CLI generator 重新生成本文件(见 src/generators/rpc-surface.ts),
17
+ * 只包含所选模块的导入与门面条目。
18
+ */
19
+
20
+ import { hc } from 'hono/client'
21
+ import { mergeRpcObjects } from './rpc-merge'
22
+ import type { AuthApiType } from './module-auth/routes/auth-routes'
23
+ import type { ChatApiType } from './module-chat/routes/chat-routes'
24
+ import type { NotificationsApiType } from './module-notifications/routes/notification-routes'
25
+ import type { TodosApiType } from './module-todos/routes/todos-routes'
26
+ import type { PluginsApiType } from './module-plugin/routes/plugin-routes'
27
+ import type { PublicContentApiType } from './module-content/routes/public-content-routes'
28
+ import type { CartApiType } from './module-order/routes/cart-routes'
29
+ import type { OrdersMockApiType } from './module-order/routes/orders-mock-routes'
30
+ import type { TopicsApiType } from './module-content/routes/topics-routes'
31
+ import type { MerchantApiType } from './module-merchant/routes/merchant-routes'
32
+ import type { OrdersApiType } from './module-order/routes/order-routes'
33
+ import type { TicketsApiType } from './module-ticket/routes/ticket-routes'
34
+ import type { DisputesApiType } from './module-dispute/routes/dispute-routes'
35
+ import type { ContentsApiType } from './module-content/routes/content-routes'
36
+ import type { CaptchaApiType } from './module-captcha/routes/captcha-routes'
37
+ import type { PermissionsApiType } from './module-permission/routes/permission-routes'
38
+ import type { RolesApiType } from './module-permission/routes/role-routes'
39
+ import type { AuditLogsApiType } from './module-permission/routes/audit-log-routes'
40
+ import type { AdminRoutesApiType } from './module-admin/routes/admin-routes'
41
+ import type { DashboardApiType } from './module-admin/routes/dashboard-routes'
42
+ import type { TenantApiType } from './module-tenant/routes/tenant-routes'
43
+ import type { PluginAdminApiType } from './module-plugin/routes/plugin-admin-routes'
44
+ import type { FilesApiType } from './module-file/routes/file-routes'
45
+
46
+ /** hono hc 的选项类型(fetch / webSocket / sse / headers 等) */
47
+ export type RpcClientOptions = NonNullable<Parameters<typeof hc>[1]>
48
+
49
+ /**
50
+ * 创建按模块拆分的 RPC 门面。
51
+ * 返回对象与旧 `hc<MergedApiType>(baseUrl)` 的调用形态完全兼容:
52
+ * `facade.api.todos.$get()` 等价于旧的 `apiClient.api.todos.$get()`。
53
+ */
54
+ export function createApiFacade(baseUrl: string, options: RpcClientOptions = {}) {
55
+ const api = `${baseUrl.replace(/\/$/, '')}/api`
56
+
57
+ const authClient = hc<AuthApiType>(api, options)
58
+ const chatClient = hc<ChatApiType>(api, options)
59
+ const notificationsClient = hc<NotificationsApiType>(api, options)
60
+ const todosClient = hc<TodosApiType>(api, options)
61
+ const pluginsClient = hc<PluginsApiType>(api, options)
62
+ const publicContentClient = hc<PublicContentApiType>(api, options)
63
+ const cartClient = hc<CartApiType>(api, options)
64
+ const ordersMockClient = hc<OrdersMockApiType>(api, options)
65
+ const topicsClient = hc<TopicsApiType>(api, options)
66
+ const merchantClient = hc<MerchantApiType>(api, options)
67
+ const ordersClient = hc<OrdersApiType>(api, options)
68
+ const ticketsClient = hc<TicketsApiType>(api, options)
69
+ const disputesClient = hc<DisputesApiType>(api, options)
70
+ const contentsClient = hc<ContentsApiType>(api, options)
71
+ const captchaClient = hc<CaptchaApiType>(api, options)
72
+ const permissionsClient = hc<PermissionsApiType>(api, options)
73
+ const rolesClient = hc<RolesApiType>(api, options)
74
+ const auditLogsClient = hc<AuditLogsApiType>(api, options)
75
+ const adminRoutesClient = hc<AdminRoutesApiType>(api, options)
76
+ const dashboardClient = hc<DashboardApiType>(api, options)
77
+ const tenantClient = hc<TenantApiType>(api, options)
78
+ const pluginAdminClient = hc<PluginAdminApiType>(api, options)
79
+ const filesClient = hc<FilesApiType>(api, options)
80
+
81
+ return {
82
+ api: {
83
+ auth: authClient.auth,
84
+ chat: chatClient.chat,
85
+ notifications: notificationsClient.notifications,
86
+ todos: todosClient.todos,
87
+ plugins: mergeRpcObjects(pluginsClient.plugins, pluginAdminClient.plugins),
88
+ categories: mergeRpcObjects(pluginsClient.categories, pluginAdminClient.categories),
89
+ stats: mergeRpcObjects(pluginsClient.stats, pluginAdminClient.stats),
90
+ public: mergeRpcObjects(publicContentClient.public, filesClient.public),
91
+ 'generate-url': filesClient['generate-url'],
92
+ upload: filesClient['upload'],
93
+ private: filesClient['private'],
94
+ cart: cartClient.cart,
95
+ 'orders-mock': ordersMockClient['orders-mock'],
96
+ profile: topicsClient.profile,
97
+ topics: topicsClient.topics,
98
+ merchant: merchantClient.merchant,
99
+ orders: ordersClient.orders,
100
+ tickets: ticketsClient.tickets,
101
+ disputes: disputesClient.disputes,
102
+ contents: contentsClient.contents,
103
+ captcha: captchaClient.captcha,
104
+ 'verify-captcha': captchaClient['verify-captcha'],
105
+ permissions: permissionsClient.permissions,
106
+ roles: rolesClient.roles,
107
+ 'audit-logs': auditLogsClient['audit-logs'],
108
+ // 'admin' 段由 dashboard-routes 与 admin-routes(内部再嵌 6 个子路由)共同提供,
109
+ // 用运行时合并避免类型层 merge
110
+ admin: mergeRpcObjects(dashboardClient.admin, adminRoutesClient.admin),
111
+ tenants: tenantClient.tenants,
112
+ tenant: tenantClient.tenant,
113
+ },
114
+ }
115
+ }
116
+
117
+ export type ApiFacade = ReturnType<typeof createApiFacade>