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.
- package/dist/cli/index.js +432 -15
- package/dist/cli/index.js.map +1 -1
- package/package.json +10 -13
- package/template/drizzle.config.ts +10 -4
- package/template/eslint-rules/__tests__/no-merged-api-type-export.test.ts +96 -0
- package/template/eslint-rules/no-cross-module-service-import.js +4 -0
- package/template/eslint-rules/no-merged-api-type-export.js +190 -0
- package/template/eslint.config.js +3 -0
- package/template/package.json +9 -6
- package/template/scripts/sync-agent-hooks.mjs +189 -0
- package/template/src/admin/components/__tests__/StatsCard.test.tsx +2 -2
- package/template/src/admin/pages/ContentPage.tsx +1 -1
- package/template/src/admin/pages/DisputesPage.tsx +1 -1
- package/template/src/admin/pages/OrdersPage.tsx +1 -1
- package/template/src/admin/pages/TicketsPage.tsx +1 -1
- package/template/src/admin/pages/__tests__/ContentPage.test.tsx +5 -1
- package/template/src/admin/pages/__tests__/DashboardPage.test.tsx +62 -8
- package/template/src/admin/pages/__tests__/DisputesPage.test.tsx +8 -1
- package/template/src/admin/pages/__tests__/OrdersPage.test.tsx +4 -2
- package/template/src/admin/pages/__tests__/RegisterPage.test.tsx +40 -43
- package/template/src/admin/pages/__tests__/SettingsPage.test.tsx +83 -21
- package/template/src/admin/pages/__tests__/TicketsPage.test.tsx +3 -1
- package/template/src/admin/services/apiClient.ts +2 -3
- package/template/src/cli/modules/content/index.ts +3 -3
- package/template/src/cli/modules/dispute/index.ts +3 -3
- package/template/src/cli/modules/ticket/index.ts +3 -3
- package/template/src/cli/modules/todo/index.ts +1 -1
- package/template/src/cli/rpc/client.ts +3 -4
- package/template/src/cli/rpc/index.ts +1 -1
- package/template/src/client/App.tsx +3 -39
- package/template/src/client/AppRoutes.tsx +53 -0
- package/template/src/client/entry-server.tsx +75 -0
- package/template/src/client/main.tsx +3 -1
- package/template/src/client/pages/SearchPage.tsx +1 -1
- package/template/src/client/services/apiClient.ts +12 -4
- package/template/src/client/stores/__tests__/todoStore.test.ts +12 -3
- package/template/src/client/stores/entry-stores.ts +36 -0
- package/template/src/client/stores/notificationStore.ts +4 -4
- package/template/src/client/stores/todoStore.ts +2 -2
- package/template/src/merchant/pages/DisputesPage.tsx +3 -3
- package/template/src/merchant/pages/OrdersPage.tsx +1 -1
- package/template/src/merchant/pages/ProductsPage.tsx +1 -1
- package/template/src/merchant/pages/SettingsPage.tsx +1 -1
- package/template/src/server/__tests__/integration/isr-full-flow.test.ts +251 -0
- package/template/src/server/__tests__/integration/todos-api.test.ts +7 -4
- package/template/src/server/app.ts +6 -4
- package/template/src/server/core/__tests__/isr-cache-cf.test.ts +96 -0
- package/template/src/server/core/__tests__/isr-cache.test.ts +96 -92
- package/template/src/server/core/__tests__/isr-invalidation.test.ts +29 -4
- package/template/src/server/core/__tests__/isr-registry.test.ts +215 -0
- package/template/src/server/core/__tests__/isr-renderer.test.ts +121 -0
- package/template/src/server/core/isr-cache.ts +61 -18
- package/template/src/server/core/isr-invalidation.ts +6 -12
- package/template/src/server/core/isr-registry.ts +104 -0
- package/template/src/server/core/isr-renderer.ts +75 -0
- package/template/src/server/db/schema/contents.ts +28 -20
- package/template/src/server/db/schema/disputes.ts +30 -22
- package/template/src/server/db/schema/notifications.ts +20 -14
- package/template/src/server/db/schema/orders.ts +23 -16
- package/template/src/server/db/schema/plugins.ts +56 -38
- package/template/src/server/db/schema/products.ts +24 -17
- package/template/src/server/db/schema/tickets.ts +44 -31
- package/template/src/server/db/schema/todos.ts +26 -18
- package/template/src/server/entries/cloudflare.ts +97 -21
- package/template/src/server/entries/node.ts +0 -2
- package/template/src/server/index.ts +0 -1
- package/template/src/server/isr-modules.ts +10 -0
- package/template/src/server/module-admin/__tests__/admin-service.test.ts +36 -12
- package/template/src/server/module-admin/routes/admin-notification-routes.ts +2 -1
- package/template/src/server/module-admin/routes/admin-routes.ts +3 -0
- package/template/src/server/module-admin/routes/dashboard-routes.ts +3 -0
- package/template/src/server/module-admin/services/admin-service.ts +57 -13
- package/template/src/server/module-auth/routes/auth-routes.ts +3 -0
- package/template/src/server/module-auth/routes/profile-routes.ts +3 -0
- package/template/src/server/module-captcha/routes/captcha-routes.ts +3 -0
- package/template/src/server/module-chat/routes/chat-routes.ts +4 -1
- package/template/src/server/module-content/__tests__/content-route.test.ts +2 -1
- package/template/src/server/module-content/__tests__/content-service.test.ts +2 -1
- package/template/src/server/module-content/__tests__/isr.test.ts +138 -0
- package/template/src/server/module-content/isr.ts +63 -0
- package/template/src/server/module-content/routes/content-routes.ts +10 -10
- package/template/src/server/module-content/routes/public-content-routes.ts +8 -4
- package/template/src/server/module-content/routes/topics-routes.ts +3 -0
- package/template/src/server/module-content/services/content-service.ts +23 -10
- package/template/src/server/module-dispute/__tests__/dispute-route.test.ts +2 -1
- package/template/src/server/module-dispute/__tests__/dispute-service.test.ts +2 -1
- package/template/src/server/module-dispute/routes/dispute-routes.ts +11 -10
- package/template/src/server/module-dispute/services/dispute-service.ts +15 -3
- package/template/src/server/module-file/routes/file-routes.ts +3 -0
- package/template/src/server/module-merchant/routes/merchant-routes.ts +3 -0
- package/template/src/server/module-merchant/services/merchant-service.ts +8 -8
- package/template/src/server/module-notifications/routes/notification-routes.ts +5 -1
- package/template/src/server/module-order/__tests__/order-route.test.ts +8 -8
- package/template/src/server/module-order/__tests__/order-service.test.ts +4 -3
- package/template/src/server/module-order/routes/cart-routes.ts +3 -0
- package/template/src/server/module-order/routes/order-routes.ts +9 -4
- package/template/src/server/module-order/routes/orders-mock-routes.ts +3 -0
- package/template/src/server/module-order/services/order-service.ts +25 -13
- package/template/src/server/module-permission/__tests__/audit-log-service.test.ts +464 -0
- package/template/src/server/module-permission/__tests__/role-service.test.ts +348 -0
- package/template/src/server/module-permission/routes/audit-log-routes.ts +3 -0
- package/template/src/server/module-permission/routes/permission-routes.ts +3 -0
- package/template/src/server/module-permission/routes/role-routes.ts +3 -0
- package/template/src/server/module-plugin/routes/plugin-admin-routes.ts +3 -0
- package/template/src/server/module-plugin/routes/plugin-routes.ts +3 -0
- package/template/src/server/module-plugin/services/admin-plugin-service.ts +10 -18
- package/template/src/server/module-plugin/services/admin-stats-service.ts +28 -9
- package/template/src/server/module-plugin/services/plugin-query-service.ts +42 -66
- package/template/src/server/module-tenant/routes/tenant-routes.ts +5 -1
- package/template/src/server/module-ticket/__tests__/ticket-route.test.ts +2 -1
- package/template/src/server/module-ticket/__tests__/ticket-service.test.ts +4 -3
- package/template/src/server/module-ticket/routes/ticket-routes.ts +11 -10
- package/template/src/server/module-ticket/services/ticket-service.ts +16 -5
- package/template/src/server/module-todos/__tests__/isr.test.ts +105 -0
- package/template/src/server/module-todos/__tests__/todo-service.test.ts +12 -9
- package/template/src/server/module-todos/__tests__/todos-route-rpc.test.ts +6 -6
- package/template/src/server/module-todos/isr.ts +41 -0
- package/template/src/server/module-todos/routes/todos-routes.ts +12 -5
- package/template/src/server/module-todos/services/todo-service.ts +31 -10
- package/template/src/server/route-registry.ts +0 -4
- package/template/src/server/rpc-merge.ts +27 -0
- package/template/src/server/rpc-surface.ts +117 -0
- package/template/src/server/rpc-type-canary.ts +80 -0
- package/template/src/server/test-utils/test-client.ts +7 -9
- package/template/src/server/test-utils/test-isr-helper.ts +140 -0
- package/template/src/shared/modules/content/schemas.ts +14 -0
- package/template/src/shared/modules/dispute/schemas.ts +14 -0
- package/template/src/shared/modules/order/schemas.ts +9 -1
- package/template/src/shared/modules/ticket/schemas.ts +14 -0
- package/template/src/shared/modules/todos/index.ts +4 -0
- package/template/src/shared/modules/todos/schemas.ts +14 -0
- package/template/src/shared/schemas/index.ts +18 -0
- package/template/tsup.config.ts +48 -1
- package/template/vitest.config.ts +19 -3
- package/template/vitest.setup.ts +68 -5
- package/template/patches/typescript+5.9.3.patch +0 -24
- /package/template/patches/{hono+4.12.16.patch → hono+4.12.34.patch} +0 -0
|
@@ -28,9 +28,12 @@ describe('Integration: Todos API (Real Database)', () => {
|
|
|
28
28
|
it('should handle complete todo lifecycle', async () => {
|
|
29
29
|
const client = createTestClient(undefined, { headers: authHeaders })
|
|
30
30
|
|
|
31
|
-
const listRes = await client.api.todos.$get({
|
|
31
|
+
const listRes = await client.api.todos.$get({ query: {} })
|
|
32
32
|
const listData = await listRes.json()
|
|
33
|
-
expect(listData).toMatchObject({
|
|
33
|
+
expect(listData).toMatchObject({
|
|
34
|
+
success: true,
|
|
35
|
+
data: { todos: [], total: 0, page: 1, limit: 20 },
|
|
36
|
+
})
|
|
34
37
|
|
|
35
38
|
const createRes = await client.api.todos.$post(
|
|
36
39
|
{
|
|
@@ -102,10 +105,10 @@ describe('Integration: Todos API (Real Database)', () => {
|
|
|
102
105
|
expect(res.status).toBe(201)
|
|
103
106
|
})
|
|
104
107
|
|
|
105
|
-
const listRes = await client.api.todos.$get({
|
|
108
|
+
const listRes = await client.api.todos.$get({ query: {} })
|
|
106
109
|
const listData = await listRes.json()
|
|
107
110
|
if (listData.success && 'data' in listData) {
|
|
108
|
-
expect(listData.data).toHaveLength(10)
|
|
111
|
+
expect(listData.data.todos).toHaveLength(10)
|
|
109
112
|
}
|
|
110
113
|
})
|
|
111
114
|
})
|
|
@@ -42,7 +42,11 @@ export function createApp<T extends AppBindings = AppBindings>(_options: CreateA
|
|
|
42
42
|
return c.json({ status: 'ok', timestamp: new Date().toISOString(), db: 'not configured' })
|
|
43
43
|
}
|
|
44
44
|
})
|
|
45
|
-
|
|
45
|
+
|
|
46
|
+
// 测试辅助端点:仅开发/测试环境注册。生产(含 Cloudflare 构建——tsup 将
|
|
47
|
+
// process.env.NODE_ENV 静态替换为 "production")不暴露这个清库端点。
|
|
48
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
49
|
+
app.post('/api/__test__/cleanup', async c => {
|
|
46
50
|
try {
|
|
47
51
|
const { cleanupTestDatabase } = await import('./db/test-setup')
|
|
48
52
|
await cleanupTestDatabase()
|
|
@@ -52,6 +56,7 @@ export function createApp<T extends AppBindings = AppBindings>(_options: CreateA
|
|
|
52
56
|
return c.json({ success: false as const, message: 'Failed to cleanup database' }, 500)
|
|
53
57
|
}
|
|
54
58
|
})
|
|
59
|
+
}
|
|
55
60
|
|
|
56
61
|
autoRegisterRealtime(app as unknown as Parameters<typeof autoRegisterRealtime>[0])
|
|
57
62
|
|
|
@@ -100,6 +105,3 @@ export function createApp<T extends AppBindings = AppBindings>(_options: CreateA
|
|
|
100
105
|
|
|
101
106
|
return app
|
|
102
107
|
}
|
|
103
|
-
export type AdminApiType = typeof adminApiRoutes
|
|
104
|
-
export type ClientApiType = typeof clientApiRoutes
|
|
105
|
-
export type AppType = ReturnType<typeof createApp>
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @framework-baseline 4fce8de3d919d2fb
|
|
3
|
+
|
|
4
|
+
* CloudflareCacheStore 单测:索引清单(manifest)驱动的 purgePattern。
|
|
5
|
+
*
|
|
6
|
+
* 背景:Cache API 没有 list(),purgePattern 曾是恒不执行的死代码——
|
|
7
|
+
* CF 生产上内容更新后陈旧详情页服务到自然过期。现实现用 isr:__index__
|
|
8
|
+
* 键记录已缓存 pathname,pattern 据此真清剿。此文件验证该行为。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
12
|
+
|
|
13
|
+
/** 极简 Cache API mock:Map<urlString, Response> */
|
|
14
|
+
function makeFakeCaches() {
|
|
15
|
+
const store = new Map<string, Response>()
|
|
16
|
+
const cache = {
|
|
17
|
+
async match(req: Request | string) {
|
|
18
|
+
const key = typeof req === 'string' ? req : req.url
|
|
19
|
+
return store.get(key) ?? undefined
|
|
20
|
+
},
|
|
21
|
+
async put(req: Request | string, res: Response) {
|
|
22
|
+
const key = typeof req === 'string' ? req : req.url
|
|
23
|
+
store.set(key, res)
|
|
24
|
+
},
|
|
25
|
+
async delete(req: Request | string) {
|
|
26
|
+
const key = typeof req === 'string' ? req : req.url
|
|
27
|
+
return store.delete(key)
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
return { caches: { open: async () => cache }, store }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('CloudflareCacheStore purgePattern (manifest)', () => {
|
|
34
|
+
let fake: ReturnType<typeof makeFakeCaches>
|
|
35
|
+
let originalCaches: unknown
|
|
36
|
+
|
|
37
|
+
beforeEach(async () => {
|
|
38
|
+
fake = makeFakeCaches()
|
|
39
|
+
originalCaches = (globalThis as { caches?: unknown }).caches
|
|
40
|
+
;(globalThis as { caches?: unknown }).caches = fake.caches as never
|
|
41
|
+
const mod = await import('@server/core/isr-cache')
|
|
42
|
+
// 重新构造以绑定 mock caches
|
|
43
|
+
const { createISRCache } = mod
|
|
44
|
+
const cache = createISRCache({ maxAge: 60, staleWhileRevalidate: 60 })
|
|
45
|
+
;(globalThis as Record<string, unknown>).__testCache = cache
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
;(globalThis as { caches?: unknown }).caches = originalCaches as never
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
function getCache() {
|
|
53
|
+
return (globalThis as Record<string, unknown>).__testCache as {
|
|
54
|
+
store: (key: string, html: string) => Promise<void>
|
|
55
|
+
purgePattern: (pattern: string) => Promise<void>
|
|
56
|
+
lookup: (pathname: string) => Promise<{ status: string }>
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
it('purgePattern 清剿匹配键并保留其余(索引驱动,非死代码)', async () => {
|
|
61
|
+
const c = getCache()
|
|
62
|
+
await c.store('/', 'home')
|
|
63
|
+
await c.store('/content', 'list')
|
|
64
|
+
await c.store('/content/123', 'detail-123')
|
|
65
|
+
await c.store('/content/456', 'detail-456')
|
|
66
|
+
await c.store('/todos', 'todos')
|
|
67
|
+
|
|
68
|
+
await c.purgePattern('isr:/content/*')
|
|
69
|
+
|
|
70
|
+
expect((await c.lookup('/content/123')).status).toBe('miss')
|
|
71
|
+
expect((await c.lookup('/content/456')).status).toBe('miss')
|
|
72
|
+
// 列表页本身不匹配 isr:/content/*(无尾斜杠通配不到裸键)
|
|
73
|
+
expect((await c.lookup('/todos')).status).toBe('fresh')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('正则元字符键名不误伤(escapeForPattern)', async () => {
|
|
77
|
+
const c = getCache()
|
|
78
|
+
await c.store('/content/1+2', 'plus')
|
|
79
|
+
await c.store('/content/abc', 'plain')
|
|
80
|
+
|
|
81
|
+
// '1+2' 中的 + 若未转义会被当量词,误匹配 '12' 等——这里验证只清目标
|
|
82
|
+
await c.purgePattern('isr:/content/1+2')
|
|
83
|
+
|
|
84
|
+
expect((await c.lookup('/content/1+2')).status).toBe('miss')
|
|
85
|
+
expect((await c.lookup('/content/abc')).status).toBe('fresh')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('purge 单键后索引同步收缩,后续 purgePattern 不再删幽灵键', async () => {
|
|
89
|
+
const c = getCache()
|
|
90
|
+
await c.store('/a', 'a')
|
|
91
|
+
await c.store('/b', 'b')
|
|
92
|
+
await c.purgePattern('isr:/a')
|
|
93
|
+
// 无异常即通过(索引写回路径被走到)
|
|
94
|
+
expect((await c.lookup('/b')).status).toBe('fresh')
|
|
95
|
+
})
|
|
96
|
+
})
|
|
@@ -1,117 +1,121 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @framework-baseline
|
|
3
|
-
* @framework-modify
|
|
4
|
-
* @reason 移除未使用的 vi import,修复 TypeScript strict 检查
|
|
5
|
-
* @impact 不影响功能,仅清理代码
|
|
2
|
+
* @framework-baseline 68a6e13bffb67f13
|
|
6
3
|
*/
|
|
7
4
|
|
|
8
5
|
import { describe, it, expect, beforeEach } from 'vitest'
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
describe('isISRRoute', () => {
|
|
12
|
-
it('matches static ISR routes', () => {
|
|
13
|
-
expect(isISRRoute('/')).toBe(true)
|
|
14
|
-
expect(isISRRoute('/todos')).toBe(true)
|
|
15
|
-
expect(isISRRoute('/content')).toBe(true)
|
|
16
|
-
expect(isISRRoute('/notifications')).toBe(true)
|
|
17
|
-
expect(isISRRoute('/websocket')).toBe(true)
|
|
18
|
-
})
|
|
19
|
-
|
|
20
|
-
it('matches dynamic content routes', () => {
|
|
21
|
-
expect(isISRRoute('/content/123')).toBe(true)
|
|
22
|
-
expect(isISRRoute('/content/content-456')).toBe(true)
|
|
23
|
-
expect(isISRRoute('/content/abc-def')).toBe(true)
|
|
24
|
-
})
|
|
25
|
-
|
|
26
|
-
it('does not match non-ISR routes', () => {
|
|
27
|
-
expect(isISRRoute('/api/todos')).toBe(false)
|
|
28
|
-
expect(isISRRoute('/api/health')).toBe(false)
|
|
29
|
-
expect(isISRRoute('/assets/main.js')).toBe(false)
|
|
30
|
-
expect(isISRRoute('/admin/dashboard')).toBe(false)
|
|
31
|
-
expect(isISRRoute('/health')).toBe(false)
|
|
32
|
-
expect(isISRRoute('/vite.svg')).toBe(false)
|
|
33
|
-
})
|
|
34
|
-
})
|
|
35
|
-
|
|
36
|
-
describe('generateCacheKey', () => {
|
|
37
|
-
it('normalizes root path', () => {
|
|
38
|
-
expect(generateCacheKey('/')).toBe('isr:/index')
|
|
39
|
-
})
|
|
40
|
-
|
|
41
|
-
it('generates keys for regular paths', () => {
|
|
42
|
-
expect(generateCacheKey('/todos')).toBe('isr:/todos')
|
|
43
|
-
expect(generateCacheKey('/content/123')).toBe('isr:/content/123')
|
|
44
|
-
})
|
|
6
|
+
import { createISRCache } from '@server/core/isr-cache'
|
|
45
7
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
})
|
|
49
|
-
})
|
|
50
|
-
|
|
51
|
-
describe('ISRCache (memory)', () => {
|
|
52
|
-
let cache: ISRCache
|
|
8
|
+
describe('ISR Cache', () => {
|
|
9
|
+
let cache: ReturnType<typeof createISRCache>
|
|
53
10
|
|
|
54
11
|
beforeEach(() => {
|
|
55
|
-
cache = createISRCache({ maxAge: 1, staleWhileRevalidate:
|
|
12
|
+
cache = createISRCache({ maxAge: 1, staleWhileRevalidate: 2 })
|
|
56
13
|
})
|
|
57
14
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
15
|
+
describe('lookup — no cache', () => {
|
|
16
|
+
it('should return miss for uncached path', async () => {
|
|
17
|
+
const result = await cache.lookup('/todos')
|
|
18
|
+
expect(result.status).toBe('miss')
|
|
19
|
+
expect(result.html).toBeNull()
|
|
20
|
+
})
|
|
62
21
|
})
|
|
63
22
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
23
|
+
describe('store → lookup fresh', () => {
|
|
24
|
+
it('should return fresh after store', async () => {
|
|
25
|
+
await cache.store('/todos', '<html>todos</html>')
|
|
26
|
+
const result = await cache.lookup('/todos')
|
|
27
|
+
expect(result.status).toBe('fresh')
|
|
28
|
+
expect(result.html).toBe('<html>todos</html>')
|
|
29
|
+
})
|
|
69
30
|
})
|
|
70
31
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
32
|
+
describe('store → wait → lookup stale', () => {
|
|
33
|
+
it('should return stale after maxAge', async () => {
|
|
34
|
+
await cache.store('/todos', '<html>todos</html>')
|
|
35
|
+
// Wait beyond maxAge (1s) but within staleWhileRevalidate (1+2=3s)
|
|
36
|
+
await new Promise(r => setTimeout(r, 1200))
|
|
37
|
+
const result = await cache.lookup('/todos')
|
|
38
|
+
expect(result.status).toBe('stale')
|
|
39
|
+
expect(result.html).toBe('<html>todos</html>')
|
|
40
|
+
})
|
|
41
|
+
})
|
|
77
42
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
43
|
+
describe('store → wait → lookup miss (expired)', () => {
|
|
44
|
+
it('should return miss after maxAge + staleWhileRevalidate', async () => {
|
|
45
|
+
await cache.store('/todos', '<html>todos</html>')
|
|
46
|
+
// Wait beyond both maxAge and staleWhileRevalidate
|
|
47
|
+
await new Promise(r => setTimeout(r, 3500))
|
|
48
|
+
const result = await cache.lookup('/todos')
|
|
49
|
+
expect(result.status).toBe('miss')
|
|
50
|
+
expect(result.html).toBeNull()
|
|
51
|
+
})
|
|
81
52
|
})
|
|
82
53
|
|
|
83
|
-
|
|
84
|
-
|
|
54
|
+
describe('purge', () => {
|
|
55
|
+
it('should purge specific path', async () => {
|
|
56
|
+
await cache.store('/todos', '<html>todos</html>')
|
|
57
|
+
await cache.store('/content', '<html>content</html>')
|
|
85
58
|
|
|
86
|
-
|
|
59
|
+
await cache.purge('/todos')
|
|
87
60
|
|
|
88
|
-
|
|
61
|
+
expect((await cache.lookup('/todos')).status).toBe('miss')
|
|
62
|
+
expect((await cache.lookup('/content')).status).toBe('fresh')
|
|
63
|
+
})
|
|
89
64
|
|
|
90
|
-
|
|
91
|
-
|
|
65
|
+
it('should not error on purge of uncached path', async () => {
|
|
66
|
+
await expect(cache.purge('/never-cached')).resolves.not.toThrow()
|
|
67
|
+
})
|
|
92
68
|
})
|
|
93
69
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
70
|
+
describe('purgePattern', () => {
|
|
71
|
+
it('should purge matching pattern', async () => {
|
|
72
|
+
await cache.store('/content', '<html>list</html>')
|
|
73
|
+
await cache.store('/content/article-1', '<html>a1</html>')
|
|
74
|
+
await cache.store('/content/article-2', '<html>a2</html>')
|
|
75
|
+
await cache.store('/todos', '<html>todos</html>')
|
|
76
|
+
|
|
77
|
+
await cache.purgePattern('isr:/content/*')
|
|
78
|
+
|
|
79
|
+
// /content (exact) should still be cached
|
|
80
|
+
expect((await cache.lookup('/content')).status).toBe('fresh')
|
|
81
|
+
// /content/* should be purged
|
|
82
|
+
expect((await cache.lookup('/content/article-1')).status).toBe('miss')
|
|
83
|
+
expect((await cache.lookup('/content/article-2')).status).toBe('miss')
|
|
84
|
+
// /todos should be unaffected
|
|
85
|
+
expect((await cache.lookup('/todos')).status).toBe('fresh')
|
|
86
|
+
})
|
|
102
87
|
})
|
|
103
88
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
89
|
+
describe('full lifecycle', () => {
|
|
90
|
+
it('miss → store → fresh → purge → miss', async () => {
|
|
91
|
+
// Step 1: Initial miss
|
|
92
|
+
let result = await cache.lookup('/todos')
|
|
93
|
+
expect(result.status).toBe('miss')
|
|
94
|
+
|
|
95
|
+
// Step 2: Store
|
|
96
|
+
await cache.store('/todos', '<html>todos v1</html>')
|
|
97
|
+
|
|
98
|
+
// Step 3: Fresh hit
|
|
99
|
+
result = await cache.lookup('/todos')
|
|
100
|
+
expect(result.status).toBe('fresh')
|
|
101
|
+
expect(result.html).toBe('<html>todos v1</html>')
|
|
102
|
+
|
|
103
|
+
// Step 4: Purge
|
|
104
|
+
await cache.purge('/todos')
|
|
105
|
+
|
|
106
|
+
// Step 5: Miss again
|
|
107
|
+
result = await cache.lookup('/todos')
|
|
108
|
+
expect(result.status).toBe('miss')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('store v1 → purge → store v2 → returns v2', async () => {
|
|
112
|
+
await cache.store('/todos', '<html>v1</html>')
|
|
113
|
+
await cache.purge('/todos')
|
|
114
|
+
await cache.store('/todos', '<html>v2</html>')
|
|
115
|
+
|
|
116
|
+
const result = await cache.lookup('/todos')
|
|
117
|
+
expect(result.status).toBe('fresh')
|
|
118
|
+
expect(result.html).toBe('<html>v2</html>')
|
|
119
|
+
})
|
|
116
120
|
})
|
|
117
121
|
})
|
|
@@ -15,6 +15,25 @@ import {
|
|
|
15
15
|
} from '@server/core/isr-invalidation'
|
|
16
16
|
import { createISRCache } from '@server/core/isr-cache'
|
|
17
17
|
|
|
18
|
+
// Side-effect: 注册 ISR 路由到全局 registry(purgeAllPages 遍历它)。
|
|
19
|
+
// todos/content 在部分 preset 中不存在 —— 均为可选导入
|
|
20
|
+
const todosIsrModule = '@server/module-todos/isr'
|
|
21
|
+
let hasTodosModule = false
|
|
22
|
+
try {
|
|
23
|
+
await import(/* @vite-ignore */ todosIsrModule)
|
|
24
|
+
hasTodosModule = true
|
|
25
|
+
} catch {
|
|
26
|
+
// preset 无 todos 模块(forum/xbrowser 等)
|
|
27
|
+
}
|
|
28
|
+
const contentIsrModule = '@server/module-content/isr'
|
|
29
|
+
let hasContentModule = false
|
|
30
|
+
try {
|
|
31
|
+
await import(/* @vite-ignore */ contentIsrModule)
|
|
32
|
+
hasContentModule = true
|
|
33
|
+
} catch {
|
|
34
|
+
// preset 无 content 模块
|
|
35
|
+
}
|
|
36
|
+
|
|
18
37
|
describe('isr-invalidation', () => {
|
|
19
38
|
it('returns null cache when not set', () => {
|
|
20
39
|
expect(getISRCache()).toBeNull()
|
|
@@ -63,10 +82,16 @@ describe('isr-invalidation', () => {
|
|
|
63
82
|
|
|
64
83
|
await purgeAllPages()
|
|
65
84
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
85
|
+
if (hasTodosModule) {
|
|
86
|
+
// '/' 与 '/todos' 路由由 todos 模块注册,仅含 todos 的 preset 可断言
|
|
87
|
+
expect((await cache.lookup('/')).status).toBe('miss')
|
|
88
|
+
expect((await cache.lookup('/todos')).status).toBe('miss')
|
|
89
|
+
}
|
|
90
|
+
if (hasContentModule) {
|
|
91
|
+
// content 相关页仅在 preset 含 content 模块时被注册、才可被清理
|
|
92
|
+
expect((await cache.lookup('/content')).status).toBe('miss')
|
|
93
|
+
expect((await cache.lookup('/content/123')).status).toBe('miss')
|
|
94
|
+
}
|
|
70
95
|
})
|
|
71
96
|
})
|
|
72
97
|
})
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @framework-baseline 560bce7dbeafc84a
|
|
3
|
+
*
|
|
4
|
+
* @framework-modify
|
|
5
|
+
* @reason prettier 格式化导致内容哈希与初始基准漂移,补记说明
|
|
6
|
+
* @impact 仅格式,无逻辑改动
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect, beforeEach } from 'vitest'
|
|
10
|
+
import { createISRRegistry } from '@server/core/isr-registry'
|
|
11
|
+
|
|
12
|
+
describe('ISR Registry', () => {
|
|
13
|
+
let registry: ReturnType<typeof createISRRegistry>
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
registry = createISRRegistry()
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
describe('register', () => {
|
|
20
|
+
it('should register a single route', () => {
|
|
21
|
+
registry.register({
|
|
22
|
+
module: 'todos',
|
|
23
|
+
match: '/todos',
|
|
24
|
+
fetch: async () => ({}),
|
|
25
|
+
meta: () => ({ title: 'Todos', description: 'Todo list' }),
|
|
26
|
+
})
|
|
27
|
+
expect(registry.getAll()).toHaveLength(1)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('should register multiple routes', () => {
|
|
31
|
+
registry.registerMany([
|
|
32
|
+
{
|
|
33
|
+
module: 'todos',
|
|
34
|
+
match: '/todos',
|
|
35
|
+
fetch: async () => ({}),
|
|
36
|
+
meta: () => ({ title: 'Todos', description: '' }),
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
module: 'content',
|
|
40
|
+
match: '/content',
|
|
41
|
+
fetch: async () => ({}),
|
|
42
|
+
meta: () => ({ title: 'Content', description: '' }),
|
|
43
|
+
},
|
|
44
|
+
])
|
|
45
|
+
expect(registry.getAll()).toHaveLength(2)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('should update existing entry on re-register', () => {
|
|
49
|
+
registry.register({
|
|
50
|
+
module: 'todos',
|
|
51
|
+
match: '/todos',
|
|
52
|
+
fetch: async () => ({}),
|
|
53
|
+
meta: () => ({ title: 'v1', description: '' }),
|
|
54
|
+
})
|
|
55
|
+
registry.register({
|
|
56
|
+
module: 'todos',
|
|
57
|
+
match: '/todos',
|
|
58
|
+
fetch: async () => ({}),
|
|
59
|
+
meta: () => ({ title: 'v2', description: '' }),
|
|
60
|
+
})
|
|
61
|
+
expect(registry.getAll()).toHaveLength(1)
|
|
62
|
+
expect(registry.getAll()[0].meta({}, '')).toEqual({ title: 'v2', description: '' })
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
describe('match — exact paths', () => {
|
|
67
|
+
it('should match exact path', () => {
|
|
68
|
+
registry.register({
|
|
69
|
+
module: 'todos',
|
|
70
|
+
match: '/todos',
|
|
71
|
+
fetch: async () => ({}),
|
|
72
|
+
meta: () => ({ title: '', description: '' }),
|
|
73
|
+
})
|
|
74
|
+
const entry = registry.match('/todos')
|
|
75
|
+
expect(entry).not.toBeNull()
|
|
76
|
+
expect(entry!.module).toBe('todos')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('should not match wrong exact path', () => {
|
|
80
|
+
registry.register({
|
|
81
|
+
module: 'todos',
|
|
82
|
+
match: '/todos',
|
|
83
|
+
fetch: async () => ({}),
|
|
84
|
+
meta: () => ({ title: '', description: '' }),
|
|
85
|
+
})
|
|
86
|
+
expect(registry.match('/orders')).toBeNull()
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('should match root /', () => {
|
|
90
|
+
registry.register({
|
|
91
|
+
module: 'home',
|
|
92
|
+
match: '/',
|
|
93
|
+
fetch: async () => ({}),
|
|
94
|
+
meta: () => ({ title: '', description: '' }),
|
|
95
|
+
})
|
|
96
|
+
expect(registry.match('/')).not.toBeNull()
|
|
97
|
+
expect(registry.match('/todos')).toBeNull()
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('match — prefix paths', () => {
|
|
102
|
+
it('should match prefix /content/', () => {
|
|
103
|
+
registry.register({
|
|
104
|
+
module: 'content',
|
|
105
|
+
match: '/content/',
|
|
106
|
+
fetch: async () => ({}),
|
|
107
|
+
meta: () => ({ title: '', description: '' }),
|
|
108
|
+
})
|
|
109
|
+
expect(registry.match('/content/content-1')).not.toBeNull()
|
|
110
|
+
expect(registry.match('/content/any-id')).not.toBeNull()
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('should not match prefix for unrelated path', () => {
|
|
114
|
+
registry.register({
|
|
115
|
+
module: 'content',
|
|
116
|
+
match: '/content/',
|
|
117
|
+
fetch: async () => ({}),
|
|
118
|
+
meta: () => ({ title: '', description: '' }),
|
|
119
|
+
})
|
|
120
|
+
expect(registry.match('/todos')).toBeNull()
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('should prefer exact match over prefix', () => {
|
|
124
|
+
registry.register({
|
|
125
|
+
module: 'content-detail',
|
|
126
|
+
match: '/content/',
|
|
127
|
+
fetch: async () => ({}),
|
|
128
|
+
meta: () => ({ title: 'detail', description: '' }),
|
|
129
|
+
})
|
|
130
|
+
registry.register({
|
|
131
|
+
module: 'content-list',
|
|
132
|
+
match: '/content',
|
|
133
|
+
fetch: async () => ({}),
|
|
134
|
+
meta: () => ({ title: 'list', description: '' }),
|
|
135
|
+
})
|
|
136
|
+
const exact = registry.match('/content')
|
|
137
|
+
const prefix = registry.match('/content/article-1')
|
|
138
|
+
expect(exact!.module).toBe('content-list')
|
|
139
|
+
expect(prefix!.module).toBe('content-detail')
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
describe('match — function matchers', () => {
|
|
144
|
+
it('should match via function', () => {
|
|
145
|
+
registry.register({
|
|
146
|
+
module: 'custom',
|
|
147
|
+
match: pathname => pathname.startsWith('/shop/'),
|
|
148
|
+
fetch: async () => ({}),
|
|
149
|
+
meta: () => ({ title: '', description: '' }),
|
|
150
|
+
})
|
|
151
|
+
expect(registry.match('/shop/product-1')).not.toBeNull()
|
|
152
|
+
expect(registry.match('/shop/')).not.toBeNull()
|
|
153
|
+
expect(registry.match('/other')).toBeNull()
|
|
154
|
+
})
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
describe('isISRRoute', () => {
|
|
158
|
+
it('should return true for registered routes', () => {
|
|
159
|
+
registry.register({
|
|
160
|
+
module: 'todos',
|
|
161
|
+
match: '/todos',
|
|
162
|
+
fetch: async () => ({}),
|
|
163
|
+
meta: () => ({ title: '', description: '' }),
|
|
164
|
+
})
|
|
165
|
+
expect(registry.isISRRoute('/todos')).toBe(true)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('should return false for unregistered routes', () => {
|
|
169
|
+
expect(registry.isISRRoute('/unknown')).toBe(false)
|
|
170
|
+
})
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
describe('getExactPaths', () => {
|
|
174
|
+
it('should return all exact path strings', () => {
|
|
175
|
+
registry.registerMany([
|
|
176
|
+
{
|
|
177
|
+
module: 'a',
|
|
178
|
+
match: '/a',
|
|
179
|
+
fetch: async () => ({}),
|
|
180
|
+
meta: () => ({ title: '', description: '' }),
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
module: 'b',
|
|
184
|
+
match: '/b/',
|
|
185
|
+
fetch: async () => ({}),
|
|
186
|
+
meta: () => ({ title: '', description: '' }),
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
module: 'c',
|
|
190
|
+
match: '/c',
|
|
191
|
+
fetch: async () => ({}),
|
|
192
|
+
meta: () => ({ title: '', description: '' }),
|
|
193
|
+
},
|
|
194
|
+
])
|
|
195
|
+
const paths = registry.getExactPaths()
|
|
196
|
+
expect(paths).toContain('/a')
|
|
197
|
+
expect(paths).toContain('/b/')
|
|
198
|
+
expect(paths).toContain('/c')
|
|
199
|
+
expect(paths).toHaveLength(3)
|
|
200
|
+
})
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
describe('clear', () => {
|
|
204
|
+
it('should remove all entries', () => {
|
|
205
|
+
registry.register({
|
|
206
|
+
module: 'a',
|
|
207
|
+
match: '/a',
|
|
208
|
+
fetch: async () => ({}),
|
|
209
|
+
meta: () => ({ title: '', description: '' }),
|
|
210
|
+
})
|
|
211
|
+
registry.clear()
|
|
212
|
+
expect(registry.getAll()).toHaveLength(0)
|
|
213
|
+
})
|
|
214
|
+
})
|
|
215
|
+
})
|