create-fullstack-scaffold 0.4.25 → 0.5.1

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 (137) 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/drizzle.config.ts +10 -4
  5. package/template/eslint-rules/__tests__/no-merged-api-type-export.test.ts +96 -0
  6. package/template/eslint-rules/no-cross-module-service-import.js +4 -0
  7. package/template/eslint-rules/no-merged-api-type-export.js +190 -0
  8. package/template/eslint.config.js +3 -0
  9. package/template/package.json +9 -6
  10. package/template/scripts/sync-agent-hooks.mjs +189 -0
  11. package/template/src/admin/components/__tests__/StatsCard.test.tsx +2 -2
  12. package/template/src/admin/pages/ContentPage.tsx +1 -1
  13. package/template/src/admin/pages/DisputesPage.tsx +1 -1
  14. package/template/src/admin/pages/OrdersPage.tsx +1 -1
  15. package/template/src/admin/pages/TicketsPage.tsx +1 -1
  16. package/template/src/admin/pages/__tests__/ContentPage.test.tsx +5 -1
  17. package/template/src/admin/pages/__tests__/DashboardPage.test.tsx +62 -8
  18. package/template/src/admin/pages/__tests__/DisputesPage.test.tsx +8 -1
  19. package/template/src/admin/pages/__tests__/OrdersPage.test.tsx +4 -2
  20. package/template/src/admin/pages/__tests__/RegisterPage.test.tsx +40 -43
  21. package/template/src/admin/pages/__tests__/SettingsPage.test.tsx +83 -21
  22. package/template/src/admin/pages/__tests__/TicketsPage.test.tsx +3 -1
  23. package/template/src/admin/services/apiClient.ts +2 -3
  24. package/template/src/cli/modules/content/index.ts +3 -3
  25. package/template/src/cli/modules/dispute/index.ts +3 -3
  26. package/template/src/cli/modules/ticket/index.ts +3 -3
  27. package/template/src/cli/modules/todo/index.ts +1 -1
  28. package/template/src/cli/rpc/client.ts +3 -4
  29. package/template/src/cli/rpc/index.ts +1 -1
  30. package/template/src/client/App.tsx +3 -39
  31. package/template/src/client/AppRoutes.tsx +53 -0
  32. package/template/src/client/entry-server.tsx +75 -0
  33. package/template/src/client/main.tsx +3 -1
  34. package/template/src/client/pages/SearchPage.tsx +1 -1
  35. package/template/src/client/services/apiClient.ts +12 -4
  36. package/template/src/client/stores/__tests__/todoStore.test.ts +12 -3
  37. package/template/src/client/stores/entry-stores.ts +36 -0
  38. package/template/src/client/stores/notificationStore.ts +4 -4
  39. package/template/src/client/stores/todoStore.ts +2 -2
  40. package/template/src/merchant/pages/DisputesPage.tsx +3 -3
  41. package/template/src/merchant/pages/OrdersPage.tsx +1 -1
  42. package/template/src/merchant/pages/ProductsPage.tsx +1 -1
  43. package/template/src/merchant/pages/SettingsPage.tsx +1 -1
  44. package/template/src/server/__tests__/integration/isr-full-flow.test.ts +251 -0
  45. package/template/src/server/__tests__/integration/todos-api.test.ts +7 -4
  46. package/template/src/server/app.ts +6 -4
  47. package/template/src/server/core/__tests__/isr-cache-cf.test.ts +96 -0
  48. package/template/src/server/core/__tests__/isr-cache.test.ts +96 -92
  49. package/template/src/server/core/__tests__/isr-invalidation.test.ts +29 -4
  50. package/template/src/server/core/__tests__/isr-registry.test.ts +215 -0
  51. package/template/src/server/core/__tests__/isr-renderer.test.ts +121 -0
  52. package/template/src/server/core/isr-cache.ts +61 -18
  53. package/template/src/server/core/isr-invalidation.ts +6 -12
  54. package/template/src/server/core/isr-registry.ts +104 -0
  55. package/template/src/server/core/isr-renderer.ts +75 -0
  56. package/template/src/server/db/schema/contents.ts +28 -20
  57. package/template/src/server/db/schema/disputes.ts +30 -22
  58. package/template/src/server/db/schema/notifications.ts +20 -14
  59. package/template/src/server/db/schema/orders.ts +23 -16
  60. package/template/src/server/db/schema/plugins.ts +56 -38
  61. package/template/src/server/db/schema/products.ts +24 -17
  62. package/template/src/server/db/schema/tickets.ts +44 -31
  63. package/template/src/server/db/schema/todos.ts +26 -18
  64. package/template/src/server/entries/cloudflare.ts +97 -21
  65. package/template/src/server/entries/node.ts +0 -2
  66. package/template/src/server/index.ts +0 -1
  67. package/template/src/server/isr-modules.ts +10 -0
  68. package/template/src/server/module-admin/__tests__/admin-service.test.ts +36 -12
  69. package/template/src/server/module-admin/routes/admin-notification-routes.ts +2 -1
  70. package/template/src/server/module-admin/routes/admin-routes.ts +3 -0
  71. package/template/src/server/module-admin/routes/dashboard-routes.ts +3 -0
  72. package/template/src/server/module-admin/services/admin-service.ts +57 -13
  73. package/template/src/server/module-auth/routes/auth-routes.ts +3 -0
  74. package/template/src/server/module-auth/routes/profile-routes.ts +3 -0
  75. package/template/src/server/module-captcha/routes/captcha-routes.ts +3 -0
  76. package/template/src/server/module-chat/routes/chat-routes.ts +4 -1
  77. package/template/src/server/module-content/__tests__/content-route.test.ts +2 -1
  78. package/template/src/server/module-content/__tests__/content-service.test.ts +2 -1
  79. package/template/src/server/module-content/__tests__/isr.test.ts +138 -0
  80. package/template/src/server/module-content/isr.ts +63 -0
  81. package/template/src/server/module-content/routes/content-routes.ts +10 -10
  82. package/template/src/server/module-content/routes/public-content-routes.ts +8 -4
  83. package/template/src/server/module-content/routes/topics-routes.ts +3 -0
  84. package/template/src/server/module-content/services/content-service.ts +23 -10
  85. package/template/src/server/module-dispute/__tests__/dispute-route.test.ts +2 -1
  86. package/template/src/server/module-dispute/__tests__/dispute-service.test.ts +2 -1
  87. package/template/src/server/module-dispute/routes/dispute-routes.ts +11 -10
  88. package/template/src/server/module-dispute/services/dispute-service.ts +15 -3
  89. package/template/src/server/module-file/routes/file-routes.ts +3 -0
  90. package/template/src/server/module-merchant/routes/merchant-routes.ts +3 -0
  91. package/template/src/server/module-merchant/services/merchant-service.ts +8 -8
  92. package/template/src/server/module-notifications/routes/notification-routes.ts +5 -1
  93. package/template/src/server/module-order/__tests__/order-route.test.ts +8 -8
  94. package/template/src/server/module-order/__tests__/order-service.test.ts +4 -3
  95. package/template/src/server/module-order/routes/cart-routes.ts +3 -0
  96. package/template/src/server/module-order/routes/order-routes.ts +9 -4
  97. package/template/src/server/module-order/routes/orders-mock-routes.ts +3 -0
  98. package/template/src/server/module-order/services/order-service.ts +25 -13
  99. package/template/src/server/module-permission/__tests__/audit-log-service.test.ts +464 -0
  100. package/template/src/server/module-permission/__tests__/role-service.test.ts +348 -0
  101. package/template/src/server/module-permission/routes/audit-log-routes.ts +3 -0
  102. package/template/src/server/module-permission/routes/permission-routes.ts +3 -0
  103. package/template/src/server/module-permission/routes/role-routes.ts +3 -0
  104. package/template/src/server/module-plugin/routes/plugin-admin-routes.ts +3 -0
  105. package/template/src/server/module-plugin/routes/plugin-routes.ts +3 -0
  106. package/template/src/server/module-plugin/services/admin-plugin-service.ts +10 -18
  107. package/template/src/server/module-plugin/services/admin-stats-service.ts +28 -9
  108. package/template/src/server/module-plugin/services/plugin-query-service.ts +42 -66
  109. package/template/src/server/module-tenant/routes/tenant-routes.ts +5 -1
  110. package/template/src/server/module-ticket/__tests__/ticket-route.test.ts +2 -1
  111. package/template/src/server/module-ticket/__tests__/ticket-service.test.ts +4 -3
  112. package/template/src/server/module-ticket/routes/ticket-routes.ts +11 -10
  113. package/template/src/server/module-ticket/services/ticket-service.ts +16 -5
  114. package/template/src/server/module-todos/__tests__/isr.test.ts +105 -0
  115. package/template/src/server/module-todos/__tests__/todo-service.test.ts +12 -9
  116. package/template/src/server/module-todos/__tests__/todos-route-rpc.test.ts +6 -6
  117. package/template/src/server/module-todos/isr.ts +41 -0
  118. package/template/src/server/module-todos/routes/todos-routes.ts +12 -5
  119. package/template/src/server/module-todos/services/todo-service.ts +31 -10
  120. package/template/src/server/route-registry.ts +0 -4
  121. package/template/src/server/rpc-merge.ts +27 -0
  122. package/template/src/server/rpc-surface.ts +117 -0
  123. package/template/src/server/rpc-type-canary.ts +80 -0
  124. package/template/src/server/test-utils/test-client.ts +7 -9
  125. package/template/src/server/test-utils/test-isr-helper.ts +140 -0
  126. package/template/src/shared/modules/content/schemas.ts +14 -0
  127. package/template/src/shared/modules/dispute/schemas.ts +14 -0
  128. package/template/src/shared/modules/order/schemas.ts +9 -1
  129. package/template/src/shared/modules/ticket/schemas.ts +14 -0
  130. package/template/src/shared/modules/todos/index.ts +4 -0
  131. package/template/src/shared/modules/todos/schemas.ts +14 -0
  132. package/template/src/shared/schemas/index.ts +18 -0
  133. package/template/tsup.config.ts +48 -1
  134. package/template/vitest.config.ts +19 -3
  135. package/template/vitest.setup.ts +68 -5
  136. package/template/patches/typescript+5.9.3.patch +0 -24
  137. /package/template/patches/{hono+4.12.16.patch → hono+4.12.34.patch} +0 -0
@@ -1,46 +1,10 @@
1
- import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
2
- import { Layout } from './Layout'
3
- import { PresetProvider } from './contexts/PresetContext'
4
- import { getPresetUIConfig, type RouteDef } from './preset-ui-config'
1
+ import { BrowserRouter } from 'react-router-dom'
2
+ import { AppRoutes } from './AppRoutes'
5
3
 
6
4
  export const App: React.FC<{ presetId?: string }> = ({ presetId = 'todo' }) => {
7
- const config = getPresetUIConfig(presetId)
8
- const { theme, desktopNav, mobileTabs, defaultRoute, routes, layout, navigation } = config
9
-
10
5
  return (
11
6
  <BrowserRouter>
12
- <PresetProvider value={presetId}>
13
- <Layout
14
- preset={presetId}
15
- layout={layout}
16
- theme={theme}
17
- navigation={navigation}
18
- desktopNav={desktopNav}
19
- mobileTabs={mobileTabs}
20
- >
21
- <Routes>
22
- {defaultRoute !== '/' && (
23
- <Route path="/" element={<Navigate to={defaultRoute} replace />} />
24
- )}
25
- {routes
26
- .filter(
27
- (r): r is RouteDef & { component: NonNullable<RouteDef['component']> } =>
28
- r.component !== null
29
- )
30
- .map(route => (
31
- <Route key={route.path} path={route.path} element={<route.component />} />
32
- ))}
33
- <Route
34
- path="*"
35
- element={
36
- <div className="flex items-center justify-center min-h-[50vh] text-gray-400">
37
- 404 - Page not found
38
- </div>
39
- }
40
- />
41
- </Routes>
42
- </Layout>
43
- </PresetProvider>
7
+ <AppRoutes presetId={presetId} />
44
8
  </BrowserRouter>
45
9
  )
46
10
  }
@@ -0,0 +1,53 @@
1
+ import { Suspense } from 'react'
2
+ import { Routes, Route, Navigate } from 'react-router-dom'
3
+ import { Layout } from './Layout'
4
+ import { PresetProvider } from './contexts/PresetContext'
5
+ import { getPresetUIConfig, type RouteDef } from './preset-ui-config'
6
+
7
+ export const AppRoutes: React.FC<{ presetId?: string }> = ({ presetId = 'todo' }) => {
8
+ const config = getPresetUIConfig(presetId)
9
+ const { theme, desktopNav, mobileTabs, defaultRoute, routes, layout, navigation } = config
10
+
11
+ return (
12
+ <PresetProvider value={presetId}>
13
+ <Layout
14
+ preset={presetId}
15
+ layout={layout}
16
+ theme={theme}
17
+ navigation={navigation}
18
+ desktopNav={desktopNav}
19
+ mobileTabs={mobileTabs}
20
+ >
21
+ <Suspense
22
+ fallback={
23
+ <div className="flex items-center justify-center min-h-[50vh] text-gray-400">
24
+ Loading...
25
+ </div>
26
+ }
27
+ >
28
+ <Routes>
29
+ {defaultRoute !== '/' && (
30
+ <Route path="/" element={<Navigate to={defaultRoute} replace />} />
31
+ )}
32
+ {routes
33
+ .filter(
34
+ (r): r is RouteDef & { component: NonNullable<RouteDef['component']> } =>
35
+ r.component !== null
36
+ )
37
+ .map(route => (
38
+ <Route key={route.path} path={route.path} element={<route.component />} />
39
+ ))}
40
+ <Route
41
+ path="*"
42
+ element={
43
+ <div className="flex items-center justify-center min-h-[50vh] text-gray-400">
44
+ 404 - Page not found
45
+ </div>
46
+ }
47
+ />
48
+ </Routes>
49
+ </Suspense>
50
+ </Layout>
51
+ </PresetProvider>
52
+ )
53
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * @framework-baseline 1a361bf1bb0e5343
3
+ */
4
+
5
+ /**
6
+ * SSR Entry Point — used by ISR pipeline to render real React components.
7
+ *
8
+ * This module:
9
+ * 1. Pre-populates Zustand stores with ISR-fetched data (via generated entry-stores)
10
+ * 2. Renders the app to HTML string via renderToString
11
+ * 3. Restores stores after render
12
+ */
13
+
14
+ import React from 'react'
15
+ import { renderToString } from 'react-dom/server'
16
+ import { StaticRouter } from 'react-router-dom'
17
+ import { HelmetProvider } from 'react-helmet-async'
18
+
19
+ import { AppRoutes } from './AppRoutes'
20
+ import {
21
+ snapshotEntryStores,
22
+ seedEntryStores,
23
+ restoreEntryStores,
24
+ type SSRData,
25
+ } from './stores/entry-stores'
26
+
27
+ interface SSRRenderResult {
28
+ html: string
29
+ helmet: {
30
+ title: string
31
+ meta: string
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Render the app to HTML string for a given pathname.
37
+ * Pre-populates stores with ISR data so components render real content.
38
+ */
39
+ export function renderSSR(pathname: string, data: SSRData): SSRRenderResult {
40
+ // 1. Snapshot current store state (for cleanup)
41
+ const snapshot = snapshotEntryStores()
42
+
43
+ // 2. Pre-populate stores with ISR data (no-op when preset has no seedable stores)
44
+ seedEntryStores(data)
45
+
46
+ // 3. Set default preset
47
+ const preset = 'todo'
48
+
49
+ // 4. Render with helmet context to extract head tags
50
+ const helmetContext: Record<string, unknown> = {}
51
+
52
+ try {
53
+ const html = renderToString(
54
+ React.createElement(
55
+ HelmetProvider,
56
+ { context: helmetContext },
57
+ React.createElement(
58
+ StaticRouter,
59
+ { location: pathname },
60
+ React.createElement(AppRoutes, { presetId: preset })
61
+ )
62
+ )
63
+ )
64
+
65
+ // 5. Extract helmet data
66
+ const helmet = (helmetContext as { helmet?: Record<string, unknown> }).helmet || {}
67
+ const titleStr = helmet.title?.toString() || ''
68
+ const metaStr = helmet.meta?.toString() || ''
69
+
70
+ return { html, helmet: { title: titleStr, meta: metaStr } }
71
+ } finally {
72
+ // 6. Restore store state
73
+ restoreEntryStores(snapshot)
74
+ }
75
+ }
@@ -7,7 +7,9 @@ import './index.css'
7
7
 
8
8
  const preset = import.meta.env.VITE_PRESET || 'todo'
9
9
 
10
- if (preset !== 'saas') {
10
+ // Demo 登录令牌:仅供开发服务器开箱体验(e2e 也依赖);生产构建
11
+ // (import.meta.env.DEV === false)不注入,用户走正常注册/登录
12
+ if (preset !== 'saas' && import.meta.env.DEV) {
11
13
  try {
12
14
  const raw = localStorage.getItem('auth-token')
13
15
  const parsed = raw ? JSON.parse(raw) : null
@@ -28,7 +28,7 @@ export const SearchPage: React.FC = () => {
28
28
  if (searchQuery) {
29
29
  searchPlugins(searchQuery, 1)
30
30
  }
31
- }, [selectedCategory]) // eslint-disable-line react-hooks/exhaustive-deps
31
+ }, [selectedCategory, searchQuery, searchPlugins])
32
32
 
33
33
  const handleSearch = (e: FormEvent) => {
34
34
  e.preventDefault()
@@ -5,16 +5,20 @@
5
5
  * @impact 影响所有客户端 API 请求,需要用户登录后才能访问受保护的接口
6
6
  */
7
7
 
8
- import { hc } from 'hono/client'
9
8
  import { WSClientImpl } from '@shared/core/ws-client'
10
9
  import { SSEClientImpl } from '@shared/core/sse-client'
11
- import type { ClientApiType } from '@server/index'
10
+ import { createApiFacade } from '@server/rpc-surface'
12
11
 
13
- const baseUrl = import.meta.env.API_BASE_URL || window.location.origin
12
+ const isBrowser = typeof window !== 'undefined'
13
+
14
+ const baseUrl = isBrowser
15
+ ? import.meta.env.API_BASE_URL || window.location.origin
16
+ : 'http://localhost:3010'
14
17
 
15
18
  const TOKEN_KEY = 'auth-token'
16
19
 
17
20
  function getAuthToken(): string | null {
21
+ if (!isBrowser) return null
18
22
  try {
19
23
  const stored = localStorage.getItem(TOKEN_KEY)
20
24
  if (stored) {
@@ -31,6 +35,10 @@ function getAuthToken(): string | null {
31
35
  }
32
36
 
33
37
  const authenticatedFetch = (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
38
+ if (!isBrowser) {
39
+ return fetch(url, init)
40
+ }
41
+
34
42
  const token = getAuthToken()
35
43
  const headers = new Headers(init?.headers)
36
44
 
@@ -54,7 +62,7 @@ const authenticatedFetch = (url: string | URL | Request, init?: RequestInit): Pr
54
62
  })
55
63
  }
56
64
 
57
- export const apiClient = hc<ClientApiType>(baseUrl, {
65
+ export const apiClient = createApiFacade(baseUrl, {
58
66
  fetch: authenticatedFetch as typeof fetch,
59
67
  webSocket: url => {
60
68
  const token = getAuthToken()
@@ -117,7 +117,10 @@ describe('Todo Store', () => {
117
117
  describe('fetchTodos', () => {
118
118
  it('should fetch todos successfully with array response', async () => {
119
119
  const todos = [createMockTodo({ id: 1 }), createMockTodo({ id: 2 })]
120
- mockJson.mockResolvedValue({ success: true, data: todos })
120
+ mockJson.mockResolvedValue({
121
+ success: true,
122
+ data: { todos, total: todos.length, page: 1, limit: 20 },
123
+ })
121
124
 
122
125
  await useTodoStore.getState().fetchTodos()
123
126
 
@@ -128,7 +131,10 @@ describe('Todo Store', () => {
128
131
  })
129
132
 
130
133
  it('should handle empty array response', async () => {
131
- mockJson.mockResolvedValue({ success: true, data: [] })
134
+ mockJson.mockResolvedValue({
135
+ success: true,
136
+ data: { todos: [], total: 0, page: 1, limit: 20 },
137
+ })
132
138
 
133
139
  await useTodoStore.getState().fetchTodos()
134
140
 
@@ -176,7 +182,10 @@ describe('Todo Store', () => {
176
182
  const fetchPromise = useTodoStore.getState().fetchTodos()
177
183
  expect(useTodoStore.getState().loading).toBe(true)
178
184
 
179
- resolvePromise({ json: () => Promise.resolve({ success: true, data: [] }) })
185
+ resolvePromise({
186
+ json: () =>
187
+ Promise.resolve({ success: true, data: { todos: [], total: 0, page: 1, limit: 20 } }),
188
+ })
180
189
  await fetchPromise
181
190
 
182
191
  expect(useTodoStore.getState().loading).toBe(false)
@@ -0,0 +1,36 @@
1
+ /**
2
+ * @framework-baseline entry-stores-v1
3
+ *
4
+ * Entry SSR store 种子(全量版)。
5
+ * 本文件在模板仓库中为全量实现;CLI 脚手架时会按 preset 重新生成
6
+ * (src/generators/entry-stores.ts),preset 不含 todo 模块时为空实现,
7
+ * 避免 entry-server 悬空导入导致构建失败。
8
+ */
9
+
10
+ import { useTodoStore } from './todoStore'
11
+ import type { Todo } from '@shared/schemas'
12
+
13
+ export interface SSRData {
14
+ todos?: Todo[]
15
+ [key: string]: unknown
16
+ }
17
+
18
+ export interface EntryStoreSnapshot {
19
+ todos: Todo[]
20
+ loading: boolean
21
+ }
22
+
23
+ export function snapshotEntryStores(): EntryStoreSnapshot {
24
+ const state = useTodoStore.getState()
25
+ return { todos: state.todos, loading: state.loading }
26
+ }
27
+
28
+ export function seedEntryStores(data: SSRData): void {
29
+ if (data.todos) {
30
+ useTodoStore.setState({ todos: data.todos, loading: false })
31
+ }
32
+ }
33
+
34
+ export function restoreEntryStores(snapshot: EntryStoreSnapshot): void {
35
+ useTodoStore.setState({ todos: snapshot.todos, loading: snapshot.loading })
36
+ }
@@ -154,20 +154,20 @@ export const useNotificationStore = create<NotificationState>(set => ({
154
154
 
155
155
  conn.onStatusChange(status => {
156
156
  if (import.meta.env.DEV) {
157
- console.log('[SSE] Status changed:', status)
157
+ console.debug('[SSE] Status changed:', status)
158
158
  }
159
159
  set({ sseConnected: status === 'open' })
160
160
  })
161
161
 
162
162
  conn.on('connected', payload => {
163
163
  if (import.meta.env.DEV) {
164
- console.log('[SSE] Connected:', payload)
164
+ console.debug('[SSE] Connected:', payload)
165
165
  }
166
166
  })
167
167
 
168
168
  conn.on('notification', notification => {
169
169
  if (import.meta.env.DEV) {
170
- console.log('[SSE] Received notification:', notification)
170
+ console.debug('[SSE] Received notification:', notification)
171
171
  }
172
172
  set(state => {
173
173
  if (state.notifications.some(n => n.id === notification.id)) {
@@ -186,7 +186,7 @@ export const useNotificationStore = create<NotificationState>(set => ({
186
186
 
187
187
  sseClient = conn
188
188
  if (import.meta.env.DEV) {
189
- console.log('[SSE] Client initialized')
189
+ console.debug('[SSE] Client initialized')
190
190
  }
191
191
  } catch (error) {
192
192
  console.error('[SSE] Failed to connect:', error)
@@ -27,10 +27,10 @@ export const useTodoStore = create<TodoState>(set => ({
27
27
  fetchTodos: async () => {
28
28
  set({ loading: true, error: null })
29
29
  try {
30
- const response = await apiClient.api.todos.$get()
30
+ const response = await apiClient.api.todos.$get({ query: {} })
31
31
  const result = await response.json()
32
32
  if (result.success) {
33
- set({ todos: result.data, loading: false })
33
+ set({ todos: result.data.todos, loading: false })
34
34
  } else {
35
35
  set({ error: result.error, loading: false })
36
36
  }
@@ -15,7 +15,7 @@ export const DisputesPage: FC = () => {
15
15
  const fetchDisputes = useCallback(async () => {
16
16
  setLoading(true)
17
17
  try {
18
- // @ts-expect-error - Hono type depth limit in full template with 15+ modules; resolves in generated projects
18
+ // @ts-expect-error Hono RPC type depth exceeds TypeScript recursion limit in fullstack-admin preset with 15+ modules
19
19
  const response = await apiClient.api.merchant.disputes.$get()
20
20
  const result = await response.json()
21
21
  if (result.success === true && result.data) {
@@ -35,7 +35,7 @@ export const DisputesPage: FC = () => {
35
35
 
36
36
  const handleResolve = async (disputeId: string) => {
37
37
  try {
38
- // @ts-expect-error - Hono type depth limit in full template with 15+ modules; resolves in generated projects
38
+ // @ts-expect-error Hono RPC type depth exceeds TypeScript recursion limit in fullstack-admin preset with 15+ modules
39
39
  await apiClient.api.merchant.disputes[':id'].resolve.$post({
40
40
  param: { id: disputeId },
41
41
  })
@@ -47,7 +47,7 @@ export const DisputesPage: FC = () => {
47
47
 
48
48
  const handleClose = async (disputeId: string) => {
49
49
  try {
50
- // @ts-expect-error - Hono type depth limit in full template with 15+ modules; resolves in generated projects
50
+ // @ts-expect-error Hono RPC type depth exceeds TypeScript recursion limit in fullstack-admin preset with 15+ modules
51
51
  await apiClient.api.merchant.disputes[':id'].close.$post({
52
52
  param: { id: disputeId },
53
53
  })
@@ -16,7 +16,7 @@ export const OrdersPage: FC = () => {
16
16
  const fetchOrders = useCallback(async () => {
17
17
  setLoading(true)
18
18
  try {
19
- // @ts-expect-error - Hono type depth limit in full template with 15+ modules; resolves in generated projects
19
+ // @ts-expect-error Hono RPC type depth exceeds TypeScript recursion limit in fullstack-admin preset with 15+ modules
20
20
  const response = await apiClient.api.merchant.orders.$get()
21
21
  const result = await response.json()
22
22
  if (result.success === true && result.data) {
@@ -17,7 +17,7 @@ export const ProductsPage: FC = () => {
17
17
  const fetchProducts = useCallback(async () => {
18
18
  setLoading(true)
19
19
  try {
20
- // @ts-expect-error - Hono type depth limit in full template with 15+ modules; resolves in generated projects
20
+ // @ts-expect-error Hono RPC type depth exceeds TypeScript recursion limit in fullstack-admin preset with 15+ modules
21
21
  const response = await apiClient.api.merchant.products.$get()
22
22
  const result = await response.json()
23
23
  if (result.success === true && result.data) {
@@ -11,7 +11,7 @@ export const SettingsPage: FC = () => {
11
11
 
12
12
  const handleSave = async (values: unknown) => {
13
13
  try {
14
- // @ts-expect-error - Hono type depth limit in full template with 15+ modules; resolves in generated projects
14
+ // @ts-expect-error Hono RPC type depth exceeds TypeScript recursion limit in fullstack-admin preset with 15+ modules
15
15
  await apiClient.api.merchant.settings.$put({ json: values })
16
16
  message.success('Settings saved successfully')
17
17
  } catch {
@@ -0,0 +1,251 @@
1
+ /**
2
+ * ISR Full Flow Integration Test
3
+ *
4
+ * Simulates the complete request lifecycle:
5
+ * 1. First visit → miss → render → cache store → response
6
+ * 2. Second visit → fresh cache hit → instant response
7
+ * 3. After maxAge → stale → return cached + background revalidate
8
+ * 4. After purge → miss → re-render with new data
9
+ * 5. DB error → graceful fallback to shell
10
+ */
11
+
12
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
13
+ import { createISRCache } from '@server/core/isr-cache'
14
+ import { isrRegistry } from '@server/core/isr-registry'
15
+ import { renderISRPage } from '@server/core/isr-renderer'
16
+ import { purgePage, setISRCache, purgeAllPages } from '@server/core/isr-invalidation'
17
+
18
+ // We test with a mock module — no real DB needed
19
+ describe('ISR Full Flow Integration', () => {
20
+ let cache: ReturnType<typeof createISRCache>
21
+ let fetchCounter: { value: number }
22
+ let mockData: Array<{ id: number; title: string; status: string }>
23
+
24
+ beforeEach(() => {
25
+ mockData = [
26
+ { id: 1, title: 'Task 1', status: 'completed' },
27
+ { id: 2, title: 'Task 2', status: 'pending' },
28
+ ]
29
+ fetchCounter = { value: 0 }
30
+ cache = createISRCache({ maxAge: 1, staleWhileRevalidate: 2 })
31
+ setISRCache(cache)
32
+
33
+ // Register into GLOBAL registry (so purgeAllPages works)
34
+ isrRegistry.register({
35
+ module: 'test',
36
+ match: '/test',
37
+ fetch: async () => {
38
+ fetchCounter.value++
39
+ return { items: mockData }
40
+ },
41
+ meta: () => ({ title: 'Test Page', description: 'Test description' }),
42
+ })
43
+ })
44
+
45
+ afterEach(async () => {
46
+ await cache.purgePattern('isr:*')
47
+ })
48
+
49
+ /**
50
+ * Helper: simulate a full ISR request
51
+ */
52
+ async function isrRequest(
53
+ pathname: string,
54
+ template:
55
+ | string
56
+ | null = '<!DOCTYPE html><html><head><title>Old</title></head><body><div id="root"></div></body></html>'
57
+ ): Promise<{ html: string; status: string; fetched: number }> {
58
+ const fetchedBefore = fetchCounter.value
59
+
60
+ // 1. Check cache
61
+ const cached = await cache.lookup(pathname)
62
+
63
+ if (cached.status === 'fresh' && cached.html) {
64
+ return { html: cached.html, status: 'fresh', fetched: fetchedBefore }
65
+ }
66
+
67
+ if (cached.status === 'stale' && cached.html) {
68
+ // Background revalidate (simulated)
69
+ const entry = isrRegistry.match(pathname)!
70
+ const data = await entry.fetch(pathname, {})
71
+ const meta = entry.meta(data, pathname)
72
+ const html = renderISRPage({ template, meta })
73
+ await cache.store(pathname, html)
74
+ return { html: cached.html, status: 'stale', fetched: fetchCounter.value }
75
+ }
76
+
77
+ // Miss: render fresh
78
+ const entry = isrRegistry.match(pathname)
79
+ if (!entry) throw new Error(`No handler for ${pathname}`)
80
+
81
+ const data = await entry.fetch(pathname, {})
82
+ const meta = entry.meta(data, pathname)
83
+ const html = renderISRPage({ template, meta })
84
+ await cache.store(pathname, html)
85
+
86
+ return { html, status: 'miss', fetched: fetchCounter.value }
87
+ }
88
+
89
+ describe('Step 1: First visit (cold cache)', () => {
90
+ it('should render fresh HTML on first visit', async () => {
91
+ const result = await isrRequest('/test')
92
+
93
+ expect(result.status).toBe('miss')
94
+ expect(result.fetched).toBe(1)
95
+ expect(result.html).toContain('<title>Test Page</title>')
96
+ expect(result.html).toContain('name="generator" content="ISR-SSG"')
97
+ })
98
+
99
+ it('should store result in cache after first visit', async () => {
100
+ await isrRequest('/test')
101
+ const cached = await cache.lookup('/test')
102
+ expect(cached.status).toBe('fresh')
103
+ expect(cached.html).not.toBeNull()
104
+ })
105
+ })
106
+
107
+ describe('Step 2: Second visit (warm cache)', () => {
108
+ it('should serve from cache without re-fetching', async () => {
109
+ // First visit
110
+ await isrRequest('/test')
111
+ expect(fetchCounter.value).toBe(1)
112
+
113
+ // Second visit
114
+ const result = await isrRequest('/test')
115
+ expect(result.status).toBe('fresh')
116
+ expect(fetchCounter.value).toBe(1) // No additional fetch
117
+ expect(result.html).toContain('<title>Test Page</title>')
118
+ })
119
+
120
+ it('should be faster (no fetch call)', async () => {
121
+ await isrRequest('/test')
122
+ const fetchedBefore = fetchCounter.value
123
+
124
+ await isrRequest('/test')
125
+ expect(fetchCounter.value).toBe(fetchedBefore) // No new fetch
126
+ })
127
+ })
128
+
129
+ describe('Step 3: After maxAge (stale)', () => {
130
+ it('should return stale cache and background revalidate', async () => {
131
+ // First visit
132
+ await isrRequest('/test')
133
+
134
+ // Wait for cache to become stale (maxAge=1s)
135
+ await new Promise(r => setTimeout(r, 1200))
136
+
137
+ const result = await isrRequest('/test')
138
+ expect(result.status).toBe('stale')
139
+ // Should have revalidated in background
140
+ expect(fetchCounter.value).toBe(2)
141
+ })
142
+ })
143
+
144
+ describe('Step 4: After purge', () => {
145
+ it('should re-render after purge with updated data', async () => {
146
+ // First visit with v1 data
147
+ await isrRequest('/test')
148
+ expect(fetchCounter.value).toBe(1)
149
+
150
+ // Update data
151
+ mockData.push({ id: 3, title: 'Task 3', status: 'pending' })
152
+
153
+ // Purge
154
+ await purgePage('/test')
155
+
156
+ // Next visit should re-render
157
+ const result = await isrRequest('/test')
158
+ expect(result.status).toBe('miss')
159
+ expect(fetchCounter.value).toBe(2)
160
+ })
161
+
162
+ it('should support purgeAllPages', async () => {
163
+ await isrRequest('/test')
164
+
165
+ await purgeAllPages()
166
+
167
+ const cached = await cache.lookup('/test')
168
+ expect(cached.status).toBe('miss')
169
+ })
170
+ })
171
+
172
+ describe('Step 5: DB error handling', () => {
173
+ it('should handle fetch error gracefully', async () => {
174
+ // Replace fetcher with one that throws
175
+ isrRegistry.clear()
176
+ isrRegistry.register({
177
+ module: 'test',
178
+ match: '/test',
179
+ fetch: async () => {
180
+ throw new Error('DB unavailable')
181
+ },
182
+ meta: () => ({ title: 'Test', description: 'desc' }),
183
+ })
184
+
185
+ // The CF entry wraps this in try/catch, but at the module level
186
+ // the error propagates. The entry catches it and falls back.
187
+ await expect(isrRequest('/test')).rejects.toThrow('DB unavailable')
188
+ })
189
+
190
+ it('should serve stale cache even if fresh render fails', async () => {
191
+ // First render succeeds with good data
192
+ isrRegistry.clear()
193
+ isrRegistry.register({
194
+ module: 'test',
195
+ match: '/test',
196
+ fetch: async () => ({ items: mockData }),
197
+ meta: () => ({ title: 'Test', description: 'desc' }),
198
+ })
199
+
200
+ await isrRequest('/test')
201
+ const cached = await cache.lookup('/test')
202
+ expect(cached.status).toBe('fresh')
203
+
204
+ // Wait for stale
205
+ await new Promise(r => setTimeout(r, 1200))
206
+
207
+ // Even if data changes, stale cache is served
208
+ const result = await isrRequest('/test')
209
+ expect(result.status).toBe('stale')
210
+ expect(result.html).toContain('<title>Test</title>')
211
+ })
212
+ })
213
+
214
+ describe('Multiple routes', () => {
215
+ it('should cache routes independently', async () => {
216
+ isrRegistry.clear()
217
+ isrRegistry.registerMany([
218
+ {
219
+ module: 'a',
220
+ match: '/a',
221
+ fetch: async () => ({ items: [{ id: 1, title: 'A1', status: 'pending' }] }),
222
+ meta: () => ({ title: 'A', description: 'a' }),
223
+ },
224
+ {
225
+ module: 'b',
226
+ match: '/b',
227
+ fetch: async () => ({ items: [{ id: 2, title: 'B2', status: 'pending' }] }),
228
+ meta: () => ({ title: 'B', description: 'b' }),
229
+ },
230
+ ])
231
+
232
+ const aResult = await isrRequest('/a')
233
+ const bResult = await isrRequest('/b')
234
+
235
+ expect(aResult.html).toContain('<title>A</title>')
236
+ expect(bResult.html).toContain('<title>B</title>')
237
+
238
+ // Purge /a doesn't affect /b
239
+ await cache.purge('/a')
240
+ expect((await cache.lookup('/a')).status).toBe('miss')
241
+ expect((await cache.lookup('/b')).status).toBe('fresh')
242
+ })
243
+ })
244
+
245
+ describe('Non-ISR routes', () => {
246
+ it('should not match unregistered routes', () => {
247
+ expect(isrRegistry.match('/unknown')).toBeNull()
248
+ expect(isrRegistry.isISRRoute('/unknown')).toBe(false)
249
+ })
250
+ })
251
+ })