create-fullstack-scaffold 0.5.0 → 0.5.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-fullstack-scaffold",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "create-fullstack-scaffold": "dist/cli/index.js"
@@ -1,13 +1,21 @@
1
- import { defineConfig } from 'drizzle-kit';
2
- import { getDatabaseConfig } from './src/server/db/config';
1
+ import { defineConfig } from 'drizzle-kit'
2
+ import { mkdirSync } from 'node:fs'
3
3
 
4
- const config = getDatabaseConfig();
4
+ // fresh clone 时 data/ 不存在,libsql 直接报 error 14(连不上库文件)。
5
+ // 在配置加载阶段就确保目录存在,令 `npm run db:push` / `npm run dev`
6
+ // 真正开箱即用(README 的 zero-config 承诺)
7
+ mkdirSync('data', { recursive: true })
8
+ import { getDatabaseConfig } from './src/server/db/config'
9
+
10
+ const config = getDatabaseConfig()
5
11
 
6
12
  export default defineConfig({
7
13
  schema: './src/server/db/schema/index.ts',
8
14
  out: './drizzle',
9
15
  dialect: 'sqlite',
10
16
  dbCredentials: {
17
+ // 与运行时保持同一库文件:drizzle-kit 在无 NODE_ENV 下执行时
18
+ // config.sqlitePath 会解析为 development.db,这里不能写死 app.db
11
19
  url: config.sqlitePath || './data/app.db',
12
20
  },
13
- });
21
+ })
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "biomimic-todo-app",
3
3
  "private": true,
4
- "version": "0.5.0",
4
+ "version": "0.5.2",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "biomimic": "./dist/cli/index.js"
@@ -66,9 +66,7 @@ export function getAppConfig(): AppConfig {
66
66
  database: {
67
67
  driver: isCloudflare ? 'd1' : dbDriver,
68
68
  sqlitePath:
69
- typeof process !== 'undefined'
70
- ? process.env.SQLITE_PATH || `./data/${nodeEnv}.db`
71
- : undefined,
69
+ typeof process !== 'undefined' ? process.env.SQLITE_PATH || './data/app.db' : undefined,
72
70
  mysqlHost: typeof process !== 'undefined' ? process.env.MYSQL_HOST || 'localhost' : undefined,
73
71
  mysqlPort:
74
72
  typeof process !== 'undefined' ? parseInt(process.env.MYSQL_PORT || '3306', 10) : undefined,
@@ -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
+ })
@@ -130,6 +130,33 @@ class CloudflareCacheStore implements ISRCacheStore {
130
130
  return { html, createdAt, revalidateAt }
131
131
  }
132
132
 
133
+ // Cache API 没有 list():用专用索引键记录所有已缓存 pathname(JSON 数组),
134
+ // purgePattern 据此清剿。跨 isolate 各自记账,最终一致(各自清理各自的键,
135
+ // 键相同即覆盖,无重复副作用)。
136
+ private static readonly INDEX_KEY = 'isr:__index__'
137
+
138
+ private async readIndex(): Promise<string[]> {
139
+ const cache = await this.getCache()
140
+ const res = await cache.match(this.toUrl(CloudflareCacheStore.INDEX_KEY))
141
+ if (!res) return []
142
+ try {
143
+ const parsed = JSON.parse(await res.text())
144
+ return Array.isArray(parsed) ? parsed : []
145
+ } catch {
146
+ return []
147
+ }
148
+ }
149
+
150
+ private async writeIndex(keys: string[]): Promise<void> {
151
+ const cache = await this.getCache()
152
+ // 索引不必常驻新鲜——purge 类操作低频,短 TTL 控制体积
153
+ const body = JSON.stringify([...new Set(keys)])
154
+ await cache.put(
155
+ this.toUrl(CloudflareCacheStore.INDEX_KEY),
156
+ new Response(body, { headers: { 'Content-Type': 'application/json' } })
157
+ )
158
+ }
159
+
133
160
  async set(key: string, html: string, options: Required<ISRCacheOptions>): Promise<void> {
134
161
  const cache = await this.getCache()
135
162
  const url = this.toUrl(key)
@@ -147,26 +174,48 @@ class CloudflareCacheStore implements ISRCacheStore {
147
174
  })
148
175
 
149
176
  await cache.put(url, response)
177
+
178
+ const index = await this.readIndex()
179
+ if (!index.includes(key)) {
180
+ index.push(key)
181
+ await this.writeIndex(index)
182
+ }
150
183
  }
151
184
 
152
185
  async purge(key: string): Promise<void> {
153
186
  const cache = await this.getCache()
154
187
  const url = this.toUrl(key)
155
188
  await cache.delete(url)
189
+
190
+ const index = await this.readIndex()
191
+ if (index.includes(key)) {
192
+ await this.writeIndex(index.filter(k => k !== key))
193
+ }
156
194
  }
157
195
 
158
196
  async purgePattern(pattern: string): Promise<void> {
197
+ // 此前为永远不执行的死代码(allKeys 恒空)——CF 上内容更新后
198
+ // 陈旧详情页会一直服务到自然过期。现按索引清单真清剿。
199
+ const regex = new RegExp('^' + this.escapeForPattern(pattern).replace(/\*/g, '.*') + '$')
200
+ const index = await this.readIndex()
159
201
  const cache = await this.getCache()
160
- const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$')
161
- const allKeys: Request[] = []
202
+ const survivors: string[] = []
162
203
 
163
- for (const request of allKeys) {
164
- const url = new URL(request.url)
165
- const key = `isr:${url.pathname}`
204
+ for (const key of index) {
166
205
  if (regex.test(key)) {
167
- await cache.delete(request)
206
+ await cache.delete(this.toUrl(key))
207
+ } else {
208
+ survivors.push(key)
168
209
  }
169
210
  }
211
+ if (survivors.length !== index.length) {
212
+ await this.writeIndex(survivors)
213
+ }
214
+ }
215
+
216
+ private escapeForPattern(pattern: string): string {
217
+ // 转义除 * 外的正则元字符,防 'content/1+2' 之类键名误匹配
218
+ return pattern.replace(/[.+?^${}()|[\]]/g, '\\$&')
170
219
  }
171
220
  }
172
221
 
@@ -135,17 +135,30 @@ export default {
135
135
  },
136
136
  }
137
137
 
138
- async function regeneratePage(
138
+ // 单飞:同 isolate 内并发 stale 请求共享同一次重建,防缓存击穿
139
+ const inflightRevalidations = new Map<string, Promise<void>>()
140
+
141
+ function regeneratePage(
139
142
  pathname: string,
140
143
  env: CloudflareBindings,
141
144
  request: Request
142
145
  ): Promise<void> {
143
- try {
144
- const html = await renderISRForRoute(pathname, env, request)
145
- await isrCache.store(pathname, html)
146
- } catch (error) {
147
- console.error('ISR regeneration failed:', error)
148
- }
146
+ const existing = inflightRevalidations.get(pathname)
147
+ if (existing) return existing
148
+
149
+ const task = (async () => {
150
+ try {
151
+ const html = await renderISRForRoute(pathname, env, request)
152
+ await isrCache.store(pathname, html)
153
+ } catch (error) {
154
+ console.error('ISR regeneration failed:', error)
155
+ } finally {
156
+ inflightRevalidations.delete(pathname)
157
+ }
158
+ })()
159
+
160
+ inflightRevalidations.set(pathname, task)
161
+ return task
149
162
  }
150
163
 
151
164
  async function renderISRForRoute(
@@ -202,7 +202,9 @@ export async function startServer() {
202
202
  await initializeDatabase()
203
203
  bootstrapLog.info({}, 'Database ready')
204
204
  } catch (err) {
205
- bootstrapLog.error({ err }, 'Database initialization failed')
205
+ // pino 走异步 thread-stream transport,process.exit 前不 flush——
206
+ // 生产致命错误必须同步 console.error,否则静默死(exit 1 零输出)
207
+ console.error('Database initialization failed:', err)
206
208
  process.exit(1)
207
209
  }
208
210