@bakery-framework/core 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +19 -0
- package/README.md +89 -0
- package/package.json +69 -0
- package/src/cache/index.ts +8 -0
- package/src/cache/lru.ts +41 -0
- package/src/cache/shared-db.ts +51 -0
- package/src/cache/string.ts +150 -0
- package/src/cache/tiered.ts +493 -0
- package/src/client/globals.d.ts +74 -0
- package/src/client/livereload.ts +437 -0
- package/src/client/utils.ts +315 -0
- package/src/compiler/compiler.ts +263 -0
- package/src/compiler/dev-service.ts +660 -0
- package/src/compiler/index.ts +2 -0
- package/src/compiler/prompt-tracker.ts +36 -0
- package/src/compiler/tsconfig-sync.ts +71 -0
- package/src/core/bakery.ts +96 -0
- package/src/core/cache-version.ts +119 -0
- package/src/core/config.ts +296 -0
- package/src/core/context.ts +121 -0
- package/src/core/index.ts +61 -0
- package/src/core/init.ts +90 -0
- package/src/core/jsx.ts +152 -0
- package/src/core/paths.ts +24 -0
- package/src/core/plugins.ts +120 -0
- package/src/core/port.ts +73 -0
- package/src/global.d.ts +374 -0
- package/src/handlers/assets/google-font.ts +225 -0
- package/src/handlers/assets/image.ts +136 -0
- package/src/handlers/assets/nm.ts +73 -0
- package/src/handlers/assets/public.ts +17 -0
- package/src/handlers/assets/static.ts +86 -0
- package/src/handlers/assets/ts.ts +61 -0
- package/src/handlers/assets/tsx.ts +106 -0
- package/src/handlers/assets/virtual-asset.ts +104 -0
- package/src/handlers/core/$base.ts +256 -0
- package/src/handlers/core/$dynamic.ts +285 -0
- package/src/handlers/core/$error.ts +301 -0
- package/src/handlers/core/$middleware.ts +71 -0
- package/src/handlers/core/$mounts.ts +84 -0
- package/src/handlers/core/$registry.ts +153 -0
- package/src/handlers/core/$routing.ts +205 -0
- package/src/handlers/core/$static.ts +100 -0
- package/src/handlers/core/$websocket.ts +52 -0
- package/src/handlers/index.ts +21 -0
- package/src/handlers/routes/api.ts +95 -0
- package/src/handlers/routes/html.ts +95 -0
- package/src/handlers/routes/livereload.ts +54 -0
- package/src/handlers/routes/proxy.ts +74 -0
- package/src/logger/clients.ts +12 -0
- package/src/logger/index.ts +3 -0
- package/src/logger/logger.ts +375 -0
- package/src/logger/serve-log.ts +206 -0
- package/src/plugins/index.ts +15 -0
- package/src/plugins/routes.ts +110 -0
- package/src/plugins/types.ts +19 -0
- package/src/router.ts +351 -0
- package/src/session.ts +556 -0
- package/src/shared.d.ts +63 -0
- package/src/startup.ts +154 -0
- package/src/types.d.ts +111 -0
- package/src/utils/common/case.ts +11 -0
- package/src/utils/common/index.ts +5 -0
- package/src/utils/common/json.ts +35 -0
- package/src/utils/common/match.ts +6 -0
- package/src/utils/common/misc.ts +53 -0
- package/src/utils/common/try.ts +6 -0
- package/src/utils/constants.ts +153 -0
- package/src/utils/fs.ts +621 -0
- package/src/utils/http/body.ts +65 -0
- package/src/utils/http/csrf.ts +111 -0
- package/src/utils/http/dom.ts +238 -0
- package/src/utils/http/escape.ts +8 -0
- package/src/utils/http/etag.ts +318 -0
- package/src/utils/http/html.ts +525 -0
- package/src/utils/http/index.ts +8 -0
- package/src/utils/http/ip.ts +32 -0
- package/src/utils/http/response.ts +129 -0
- package/src/utils/index.ts +4 -0
- package/src/utils/isomorphic/case.ts +52 -0
- package/src/utils/isomorphic/escape.ts +43 -0
- package/src/utils/isomorphic/index.ts +15 -0
- package/src/utils/isomorphic/is.ts +36 -0
- package/src/utils/isomorphic/match.ts +50 -0
- package/src/utils/isomorphic/math.ts +11 -0
- package/src/utils/isomorphic/misc.ts +22 -0
- package/src/utils/isomorphic/stringify.ts +42 -0
- package/src/utils/isomorphic/try.ts +94 -0
- package/src/utils/jsonc.ts +10 -0
- package/src/utils/shared-pool.ts +193 -0
- package/tsconfig.app.json +34 -0
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
import type { Statement } from 'bun:sqlite'
|
|
2
|
+
import { Bakery } from '../core/bakery'
|
|
3
|
+
import { Logger } from '../logger'
|
|
4
|
+
import { LRUCache } from './lru'
|
|
5
|
+
import { cacheDb as db } from './shared-db'
|
|
6
|
+
|
|
7
|
+
export { db }
|
|
8
|
+
|
|
9
|
+
export type Milliseconds = number & {}
|
|
10
|
+
|
|
11
|
+
export interface TieredCacheOptions<V> {
|
|
12
|
+
memoryThreshold: number
|
|
13
|
+
evictRatio?: number
|
|
14
|
+
flushInterval?: Milliseconds
|
|
15
|
+
reviver?: (data: any) => V
|
|
16
|
+
shouldPersist?: (value: V) => boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type CacheEntry<V> = { value: V; accessedAt: number }
|
|
20
|
+
|
|
21
|
+
interface DbRow {
|
|
22
|
+
key: string
|
|
23
|
+
value: string
|
|
24
|
+
accessedAt: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface DbCount {
|
|
28
|
+
count: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type Flushable = { flushAllToDisk(): void; close(): void }
|
|
32
|
+
const registry: Flushable[] = []
|
|
33
|
+
export function registerCache(cache: Flushable): void {
|
|
34
|
+
registry.push(cache)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Test seam — see `__setTestDb` / `__setTestConfig` for the pattern.
|
|
39
|
+
*
|
|
40
|
+
* Detaches the live registry so a test can drive `flushAllCaches()` against its
|
|
41
|
+
* own caches without flushing and closing the process's real ones (`Strings`,
|
|
42
|
+
* the session tier), which every later test file in the run still needs.
|
|
43
|
+
* Returns what it removed; hand it back to `__restoreCaches`.
|
|
44
|
+
*/
|
|
45
|
+
export function __detachCaches(): Flushable[] {
|
|
46
|
+
return registry.splice(0, registry.length)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function __restoreCaches(caches: Flushable[]): void {
|
|
50
|
+
registry.length = 0
|
|
51
|
+
registry.push(...caches)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class TieredCache<K extends string | number, V> {
|
|
55
|
+
private memoryStore = new Map<K, CacheEntry<V>>()
|
|
56
|
+
private dirtyKeys = new Set<K>()
|
|
57
|
+
private flushTimer?: ReturnType<typeof setInterval>
|
|
58
|
+
private opts: TieredCacheOptions<V>
|
|
59
|
+
private tableName: string
|
|
60
|
+
private stmt: Record<string, Statement>
|
|
61
|
+
private stmtCache = new LRUCache<string, Statement>(
|
|
62
|
+
import.meta.env.THREAD_WORKER ? 15 : 50,
|
|
63
|
+
// An evicted statement still holds a native handle; close() only finalizes
|
|
64
|
+
// whatever survived to the end.
|
|
65
|
+
(_sql, stmt) => {
|
|
66
|
+
try {
|
|
67
|
+
stmt.finalize()
|
|
68
|
+
} catch {
|
|
69
|
+
// Already finalized, or its database is closed. Either way the native
|
|
70
|
+
// handle this eviction exists to release is gone.
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
constructor(tableId: string, options: TieredCacheOptions<V>) {
|
|
76
|
+
this.tableName = tableId.replace(/[^a-zA-Z0-9_]/g, '')
|
|
77
|
+
const scaledThreshold = Math.max(
|
|
78
|
+
50,
|
|
79
|
+
Math.floor(
|
|
80
|
+
options.memoryThreshold / (import.meta.env.THREAD_WORKER ? 4 : 1),
|
|
81
|
+
),
|
|
82
|
+
)
|
|
83
|
+
this.opts = {
|
|
84
|
+
evictRatio: 0.1,
|
|
85
|
+
flushInterval: 10000,
|
|
86
|
+
...options,
|
|
87
|
+
memoryThreshold: scaledThreshold,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
db.run(`
|
|
91
|
+
CREATE TABLE IF NOT EXISTS ${this.tableName} (
|
|
92
|
+
key TEXT PRIMARY KEY,
|
|
93
|
+
value TEXT,
|
|
94
|
+
accessedAt INTEGER
|
|
95
|
+
)
|
|
96
|
+
`)
|
|
97
|
+
|
|
98
|
+
// prettier-ignore
|
|
99
|
+
this.stmt = {
|
|
100
|
+
insert: db.prepare(
|
|
101
|
+
`INSERT OR REPLACE INTO ${this.tableName} (key, value, accessedAt) VALUES (?, ?, ?)`,
|
|
102
|
+
),
|
|
103
|
+
delete: db.prepare(`DELETE FROM ${this.tableName} WHERE key = ?`),
|
|
104
|
+
select: db.prepare(
|
|
105
|
+
`SELECT value, accessedAt FROM ${this.tableName} WHERE key = ?`,
|
|
106
|
+
),
|
|
107
|
+
// `has` and `getAccessedAt` used `select`, which drags the whole JSON
|
|
108
|
+
// blob out of SQLite and marshals it into JS only to be thrown away.
|
|
109
|
+
// Projecting just what each needs is O(1) instead of O(value).
|
|
110
|
+
exists: db.prepare(
|
|
111
|
+
`SELECT 1 AS present FROM ${this.tableName} WHERE key = ?`,
|
|
112
|
+
),
|
|
113
|
+
accessedAt: db.prepare(
|
|
114
|
+
`SELECT accessedAt FROM ${this.tableName} WHERE key = ?`,
|
|
115
|
+
),
|
|
116
|
+
keys: db.prepare(`SELECT key FROM ${this.tableName}`),
|
|
117
|
+
all: db.prepare(`SELECT key, value FROM ${this.tableName}`),
|
|
118
|
+
prune: db.prepare(`DELETE FROM ${this.tableName} WHERE accessedAt < ?`),
|
|
119
|
+
expired: db.prepare(
|
|
120
|
+
`SELECT key FROM ${this.tableName} WHERE accessedAt < ?`,
|
|
121
|
+
),
|
|
122
|
+
expiredParam: db.prepare(
|
|
123
|
+
`SELECT key FROM ${this.tableName} WHERE accessedAt < ? AND (json_array_length(value, ?) IS NULL OR json_array_length(value, ?) = 0)`,
|
|
124
|
+
),
|
|
125
|
+
pruneParam: db.prepare(
|
|
126
|
+
`DELETE FROM ${this.tableName} WHERE accessedAt < ? AND (json_array_length(value, ?) IS NULL OR json_array_length(value, ?) = 0)`,
|
|
127
|
+
),
|
|
128
|
+
count: db.prepare(`SELECT COUNT(*) as count FROM ${this.tableName}`),
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (this.opts.flushInterval !== undefined) {
|
|
132
|
+
this.flushTimer = setInterval(
|
|
133
|
+
() => this.flushToDisk(),
|
|
134
|
+
this.opts.flushInterval,
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
registerCache(this)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
get count(): number {
|
|
142
|
+
return (this.stmt.count.get() as DbCount | null)?.count ?? 0
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
get memorySize(): number {
|
|
146
|
+
return this.memoryStore.size
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
get isDirty(): boolean {
|
|
150
|
+
return this.dirtyKeys.size > 0
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
has(key: K): boolean {
|
|
154
|
+
if (this.memoryStore.has(key)) return true
|
|
155
|
+
return this.stmt.exists.get(String(key)) !== null
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
get(key: K): V | undefined {
|
|
159
|
+
if (this.memoryStore.has(key)) {
|
|
160
|
+
const entry = this.memoryStore.get(key)!
|
|
161
|
+
entry.accessedAt = Date.now()
|
|
162
|
+
this.memoryStore.delete(key)
|
|
163
|
+
this.memoryStore.set(key, entry)
|
|
164
|
+
return entry.value
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const row = this.stmt.select.get(String(key)) as Pick<
|
|
168
|
+
DbRow,
|
|
169
|
+
'value' | 'accessedAt'
|
|
170
|
+
> | null
|
|
171
|
+
if (!row) return undefined
|
|
172
|
+
|
|
173
|
+
const parsed = JSON.parse(row.value)
|
|
174
|
+
const value: V = this.opts.reviver ? this.opts.reviver(parsed) : parsed
|
|
175
|
+
|
|
176
|
+
this.memoryStore.delete(key)
|
|
177
|
+
this.memoryStore.set(key, { value, accessedAt: Date.now() })
|
|
178
|
+
this.dirtyKeys.delete(key)
|
|
179
|
+
this.enforceMemoryLimit()
|
|
180
|
+
|
|
181
|
+
return value
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
set(key: K, value: V): this {
|
|
185
|
+
this.memoryStore.delete(key)
|
|
186
|
+
this.memoryStore.set(key, { value, accessedAt: Date.now() })
|
|
187
|
+
this.dirtyKeys.add(key)
|
|
188
|
+
this.enforceMemoryLimit()
|
|
189
|
+
return this
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
delete(key: K): boolean {
|
|
193
|
+
const fromRAM = this.memoryStore.delete(key)
|
|
194
|
+
this.dirtyKeys.delete(key)
|
|
195
|
+
const info = this.stmt.delete.run(String(key))
|
|
196
|
+
return fromRAM || info.changes > 0
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
getAccessedAt(key: K): number | undefined {
|
|
200
|
+
if (this.memoryStore.has(key)) return this.memoryStore.get(key)!.accessedAt
|
|
201
|
+
return (
|
|
202
|
+
this.stmt.accessedAt.get(String(key)) as Pick<DbRow, 'accessedAt'> | null
|
|
203
|
+
)?.accessedAt
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
prune(maxAgeMs: number, ignoreJsonArrayPath?: string): number {
|
|
207
|
+
const cutoff = Date.now() - maxAgeMs
|
|
208
|
+
|
|
209
|
+
const expired: { key: string }[] = ignoreJsonArrayPath
|
|
210
|
+
? (this.stmt.expiredParam.all(
|
|
211
|
+
cutoff,
|
|
212
|
+
ignoreJsonArrayPath,
|
|
213
|
+
ignoreJsonArrayPath,
|
|
214
|
+
) as any)
|
|
215
|
+
: (this.stmt.expired.all(cutoff) as any)
|
|
216
|
+
|
|
217
|
+
for (const { key } of expired) {
|
|
218
|
+
this.memoryStore.delete(key as K)
|
|
219
|
+
this.dirtyKeys.delete(key as K)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const info = ignoreJsonArrayPath
|
|
223
|
+
? this.stmt.pruneParam.run(
|
|
224
|
+
cutoff,
|
|
225
|
+
ignoreJsonArrayPath,
|
|
226
|
+
ignoreJsonArrayPath,
|
|
227
|
+
)
|
|
228
|
+
: this.stmt.prune.run(cutoff)
|
|
229
|
+
|
|
230
|
+
return info.changes
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
exceedsMemoryLimit(): boolean {
|
|
234
|
+
return this.memoryStore.size > this.opts.memoryThreshold
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
*keys(): IterableIterator<K> {
|
|
238
|
+
const seen = new Set<K>()
|
|
239
|
+
for (const key of this.memoryStore.keys()) {
|
|
240
|
+
yield key
|
|
241
|
+
seen.add(key)
|
|
242
|
+
}
|
|
243
|
+
for (const row of this.stmt.keys.iterate() as IterableIterator<{
|
|
244
|
+
key: string
|
|
245
|
+
}>) {
|
|
246
|
+
const key = row.key as K
|
|
247
|
+
if (!seen.has(key)) yield key
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
*values(): IterableIterator<V> {
|
|
252
|
+
const seen = new Set<K>()
|
|
253
|
+
for (const [key, entry] of this.memoryStore) {
|
|
254
|
+
yield entry.value
|
|
255
|
+
seen.add(key)
|
|
256
|
+
}
|
|
257
|
+
for (const row of this.stmt.all.iterate() as IterableIterator<DbRow>) {
|
|
258
|
+
if (seen.has(row.key as K)) continue
|
|
259
|
+
try {
|
|
260
|
+
const parsed = JSON.parse(row.value)
|
|
261
|
+
yield this.opts.reviver ? this.opts.reviver(parsed) : parsed
|
|
262
|
+
} catch {
|
|
263
|
+
// Skip a corrupt row rather than aborting iteration over the rest of
|
|
264
|
+
// the cache. The cache is disposable; one bad entry is not worth a throw.
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
*entries(): IterableIterator<[K, V]> {
|
|
270
|
+
const seen = new Set<K>()
|
|
271
|
+
for (const [key, entry] of this.memoryStore) {
|
|
272
|
+
yield [key, entry.value]
|
|
273
|
+
seen.add(key)
|
|
274
|
+
}
|
|
275
|
+
for (const row of this.stmt.all.iterate() as IterableIterator<DbRow>) {
|
|
276
|
+
if (seen.has(row.key as K)) continue
|
|
277
|
+
try {
|
|
278
|
+
const parsed = JSON.parse(row.value)
|
|
279
|
+
const value: V = this.opts.reviver ? this.opts.reviver(parsed) : parsed
|
|
280
|
+
yield [row.key as K, value]
|
|
281
|
+
} catch {
|
|
282
|
+
// As above: a corrupt row is skipped, not fatal to the iteration.
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
[Symbol.iterator](): IterableIterator<[K, V]> {
|
|
288
|
+
return this.entries()
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
private getOrPrepareStmt(sql: string): Statement {
|
|
292
|
+
let stmt = this.stmtCache.get(sql)
|
|
293
|
+
if (!stmt) {
|
|
294
|
+
stmt = db.prepare(sql)
|
|
295
|
+
this.stmtCache.set(sql, stmt)
|
|
296
|
+
}
|
|
297
|
+
return stmt
|
|
298
|
+
}
|
|
299
|
+
search(options: {
|
|
300
|
+
search?: string
|
|
301
|
+
page: number
|
|
302
|
+
pageSize: number
|
|
303
|
+
sortBy: string
|
|
304
|
+
sortOrder: 'ASC' | 'DESC'
|
|
305
|
+
}): {
|
|
306
|
+
rows: V[]
|
|
307
|
+
totalRows: number
|
|
308
|
+
page: number
|
|
309
|
+
pageSize: number
|
|
310
|
+
totalPages: number
|
|
311
|
+
} {
|
|
312
|
+
// Paging happens in SQL, so the dirty memory tier has to reach disk first.
|
|
313
|
+
// That was a third, hand-written copy of `flushToDisk` — same shape, but
|
|
314
|
+
// looping over `stmt.insert` instead of `commitKey`, which is exactly the
|
|
315
|
+
// bypass `flushAllToDisk` documents below having already been fixed once.
|
|
316
|
+
// There is nothing search needs that the predicate would deny it: an entry
|
|
317
|
+
// the predicate rejects is one the 10s interval flush deletes anyway, so
|
|
318
|
+
// including it here only made the listing depend on which flush won the
|
|
319
|
+
// race.
|
|
320
|
+
this.flushToDisk()
|
|
321
|
+
|
|
322
|
+
const pattern = options.search ? `%${options.search}%` : ''
|
|
323
|
+
const where = pattern ? 'WHERE key LIKE ? OR LOWER(value) LIKE ?' : ''
|
|
324
|
+
const params: any[] = pattern ? [pattern, pattern] : []
|
|
325
|
+
|
|
326
|
+
let orderBy = ''
|
|
327
|
+
switch (options.sortBy) {
|
|
328
|
+
case 'id':
|
|
329
|
+
orderBy = 'ORDER BY key'
|
|
330
|
+
break
|
|
331
|
+
case 'keys':
|
|
332
|
+
orderBy = `ORDER BY json_array_length(value, '$.persistKeys')`
|
|
333
|
+
break
|
|
334
|
+
case 'created':
|
|
335
|
+
case 'accessed':
|
|
336
|
+
case 'match':
|
|
337
|
+
orderBy = 'ORDER BY accessedAt'
|
|
338
|
+
break
|
|
339
|
+
default:
|
|
340
|
+
orderBy = 'ORDER BY accessedAt'
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const dir = options.sortOrder === 'ASC' ? 'ASC' : 'DESC'
|
|
344
|
+
orderBy += ` ${dir}, key ${dir}`
|
|
345
|
+
|
|
346
|
+
const countStmt = this.getOrPrepareStmt(
|
|
347
|
+
`SELECT COUNT(*) as count FROM ${this.tableName} ${where}`,
|
|
348
|
+
)
|
|
349
|
+
const totalRows = (countStmt.get(...params) as DbCount | null)?.count ?? 0
|
|
350
|
+
|
|
351
|
+
const totalPages = Math.max(1, Math.ceil(totalRows / options.pageSize))
|
|
352
|
+
const page = Math.min(options.page, totalPages)
|
|
353
|
+
const offset = Math.max(0, (page - 1) * options.pageSize)
|
|
354
|
+
|
|
355
|
+
const dataStmt = this.getOrPrepareStmt(
|
|
356
|
+
`SELECT value FROM ${this.tableName} ${where} ${orderBy} LIMIT ? OFFSET ?`,
|
|
357
|
+
)
|
|
358
|
+
const rows = dataStmt.all(...params, options.pageSize, offset) as Pick<
|
|
359
|
+
DbRow,
|
|
360
|
+
'value'
|
|
361
|
+
>[]
|
|
362
|
+
|
|
363
|
+
return {
|
|
364
|
+
rows: rows.map(r => {
|
|
365
|
+
const parsed = JSON.parse(r.value)
|
|
366
|
+
return this.opts.reviver ? this.opts.reviver(parsed) : parsed
|
|
367
|
+
}),
|
|
368
|
+
totalRows,
|
|
369
|
+
page,
|
|
370
|
+
pageSize: options.pageSize,
|
|
371
|
+
totalPages,
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
destroyMemoryAndFlush(): void {
|
|
376
|
+
this.flushToDisk()
|
|
377
|
+
this.memoryStore.clear()
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
flushToDisk(): void {
|
|
381
|
+
if (this.dirtyKeys.size === 0) return
|
|
382
|
+
const keys = new Set(this.dirtyKeys)
|
|
383
|
+
this.dirtyKeys.clear()
|
|
384
|
+
db.transaction(() => {
|
|
385
|
+
for (const key of keys) this.commitKey(key)
|
|
386
|
+
})()
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
flushAllToDisk(): void {
|
|
390
|
+
if (this.memoryStore.size === 0) return
|
|
391
|
+
db.transaction(() => {
|
|
392
|
+
// Through `commitKey`, not a raw insert. This is the shutdown flush, and
|
|
393
|
+
// it used to ignore `shouldPersist` — so the interval flush and eviction
|
|
394
|
+
// dropped a non-persisting entry while a clean stop wrote it. For the
|
|
395
|
+
// session tier that predicate is "has persisted keys or has data", so
|
|
396
|
+
// every shutdown wrote up to a thousand empty sessions, which then
|
|
397
|
+
// inflated `Session.count` (read by the dashboard and the analytics
|
|
398
|
+
// gauge) until the 15-minute pruner caught up. `search()` had a third
|
|
399
|
+
// copy of this loop with the same bypass; it now calls `flushToDisk`.
|
|
400
|
+
for (const key of this.memoryStore.keys()) this.commitKey(key)
|
|
401
|
+
this.dirtyKeys.clear()
|
|
402
|
+
})()
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
close(): void {
|
|
406
|
+
clearInterval(this.flushTimer)
|
|
407
|
+
this.flushTimer = undefined
|
|
408
|
+
for (const stmt of this.stmtCache.values()) stmt.finalize()
|
|
409
|
+
this.stmtCache.clear()
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
[Symbol.dispose](): void {
|
|
413
|
+
this.close()
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
private commitKey(key: K): void {
|
|
417
|
+
const entry = this.memoryStore.get(key)
|
|
418
|
+
if (!entry) return
|
|
419
|
+
// `shouldPersist?.(v)` is `undefined` when the option is absent, and
|
|
420
|
+
// `!undefined` is `true` — so a cache configured without a predicate took
|
|
421
|
+
// the delete branch for every key. Since `enforceMemoryLimit` commits as
|
|
422
|
+
// it evicts, that made eviction destroy the value rather than demote it
|
|
423
|
+
// to disk. Absence of the option means "persist everything".
|
|
424
|
+
const shouldPersist = this.opts.shouldPersist
|
|
425
|
+
if (shouldPersist && !shouldPersist(entry.value)) {
|
|
426
|
+
this.stmt.delete.run(String(key))
|
|
427
|
+
} else {
|
|
428
|
+
this.stmt.insert.run(
|
|
429
|
+
String(key),
|
|
430
|
+
JSON.stringify(entry.value),
|
|
431
|
+
entry.accessedAt,
|
|
432
|
+
)
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
private enforceMemoryLimit(): void {
|
|
437
|
+
if (!this.exceedsMemoryLimit()) return
|
|
438
|
+
|
|
439
|
+
const count = Math.floor(
|
|
440
|
+
this.opts.memoryThreshold * (this.opts.evictRatio ?? 0.1),
|
|
441
|
+
)
|
|
442
|
+
const toEvict: K[] = []
|
|
443
|
+
for (const key of this.memoryStore.keys()) {
|
|
444
|
+
if (toEvict.length >= count) break
|
|
445
|
+
toEvict.push(key)
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
db.transaction(() => {
|
|
449
|
+
for (const key of toEvict) {
|
|
450
|
+
this.commitKey(key)
|
|
451
|
+
this.memoryStore.delete(key)
|
|
452
|
+
this.dirtyKeys.delete(key)
|
|
453
|
+
}
|
|
454
|
+
})()
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const logger = new Logger('tiered-cache')
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Flush every registered cache to disk and release its timers and statements.
|
|
462
|
+
*
|
|
463
|
+
* Registered as a `Bakery.shutdownHooks` entry, which runs *before*
|
|
464
|
+
* `PluginHooks.onShutdown()`. It deliberately does **not** close the shared
|
|
465
|
+
* database: this hook is registered at module-evaluation time and therefore
|
|
466
|
+
* sits at index 0, while a plugin's shutdown hook is registered later during
|
|
467
|
+
* plugin setup. Closing the handle here invalidated it for every plugin that
|
|
468
|
+
* writes on the way out — the analytics flush bound the same `cacheDb`, so
|
|
469
|
+
* every statement threw into an outer catch and up to a minute of page hits
|
|
470
|
+
* plus the whole history delta was discarded on every clean stop, silently.
|
|
471
|
+
*
|
|
472
|
+
* `closeCacheDb()` is the separate final step; `runShutdownSequence()` in the
|
|
473
|
+
* CLI calls it after the plugin hooks.
|
|
474
|
+
*/
|
|
475
|
+
export function flushAllCaches(): void {
|
|
476
|
+
logger.log('Flushing caches and shutting down...', 'info')
|
|
477
|
+
for (const cache of registry) {
|
|
478
|
+
cache.flushAllToDisk()
|
|
479
|
+
cache.close()
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
let cacheDbClosed = false
|
|
484
|
+
|
|
485
|
+
/** Close the shared cache database. Must run after every writer is done. */
|
|
486
|
+
export function closeCacheDb(): void {
|
|
487
|
+
if (cacheDbClosed) return
|
|
488
|
+
cacheDbClosed = true
|
|
489
|
+
db.close()
|
|
490
|
+
logger.log('Sync complete. Database closed cleanly.', 'info')
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
Bakery.onShutdown(flushAllCaches)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The browser globals, as types.
|
|
3
|
+
*
|
|
4
|
+
* Every name here is bound onto `globalThis` by `client/utils.ts`. This file
|
|
5
|
+
* used to *restate* each one's shape by hand, and four of them had drifted from
|
|
6
|
+
* the code they describe:
|
|
7
|
+
*
|
|
8
|
+
* - `Try.throw` was declared with a synchronous overload it never had
|
|
9
|
+
* (`tryThrow` is `Promise.try(...).catch(...)`, so it always returns a
|
|
10
|
+
* Promise) — browser code writing `const n: number = Try.throw(() => 1)`
|
|
11
|
+
* typechecked and silently received a Promise.
|
|
12
|
+
* - `Case` lost its callable form: the implementation is
|
|
13
|
+
* `Object.assign(function Case(type, str) {…}, {…})`, so `Case('kebab', s)`
|
|
14
|
+
* is real but was untypeable.
|
|
15
|
+
* - `request` was `(url, method?, body?)` against an implementation of
|
|
16
|
+
* `(url, init: RequestJson | string = {}, bodyData?)` — the object-init form
|
|
17
|
+
* was unreachable from the types.
|
|
18
|
+
* - `randomId` was `() => string` against an implementation taking `length = 8`.
|
|
19
|
+
*
|
|
20
|
+
* So the shapes are no longer restated: each global is a `typeof import(...)`
|
|
21
|
+
* of the module that actually provides it, which fixes all four at once and
|
|
22
|
+
* cannot drift again. `typeof import(...)` inside a `.d.ts` is a type query —
|
|
23
|
+
* it is erased, and adds no runtime module edge into the browser bundle.
|
|
24
|
+
*
|
|
25
|
+
* Two globals genuinely cannot be derived, and stay hand-written:
|
|
26
|
+
* `server` (injected into a Vue SFC's module scope by the compiler, so no
|
|
27
|
+
* module exports it) and `Bakery` (an object literal written inline inside
|
|
28
|
+
* `client/utils.ts`'s `Object.assign(globalThis, …)` call, so there is no
|
|
29
|
+
* exported member to query).
|
|
30
|
+
*/
|
|
31
|
+
import type { MapOf } from '../types'
|
|
32
|
+
|
|
33
|
+
declare global {
|
|
34
|
+
/** Server-side exports from `<script server>` — available at runtime in `<script setup>` and templates. */
|
|
35
|
+
const server: { [key: string]: any }
|
|
36
|
+
|
|
37
|
+
const matchDefault: typeof import('../utils/isomorphic/match').matchDefault
|
|
38
|
+
var match: typeof import('../utils/isomorphic/match').match
|
|
39
|
+
var is: typeof import('../utils/isomorphic/is').is
|
|
40
|
+
var Try: typeof import('../utils/isomorphic/try').Try
|
|
41
|
+
var tryCatch: typeof import('../utils/isomorphic/try').tryCatch
|
|
42
|
+
var Case: typeof import('../utils/isomorphic/case').Case
|
|
43
|
+
var Math2: typeof import('../utils/isomorphic/math').Math2
|
|
44
|
+
var throws: typeof import('../utils/isomorphic/misc').throws
|
|
45
|
+
var assert: typeof import('../utils/isomorphic/misc').assert
|
|
46
|
+
var any: typeof import('../utils/isomorphic/misc').any
|
|
47
|
+
var repeat: typeof import('../utils/isomorphic/misc').repeat
|
|
48
|
+
var escapeHTML: typeof import('../utils/isomorphic/escape').escapeHtml
|
|
49
|
+
|
|
50
|
+
var request: typeof import('./utils').request
|
|
51
|
+
var randomId: typeof import('./utils').randomId
|
|
52
|
+
|
|
53
|
+
// Not derivable: written inline in client/utils.ts's Object.assign call
|
|
54
|
+
// rather than exported, so there is no module member to take `typeof` of.
|
|
55
|
+
// Verified against that literal — `version` is the BAKERY_VERSION define,
|
|
56
|
+
// `virtual` is `async virtual(path: string)`, `params` is generic.
|
|
57
|
+
var Bakery: {
|
|
58
|
+
version: string
|
|
59
|
+
virtual(path: string): Promise<any>
|
|
60
|
+
params<T = MapOf<any>>(): T
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// `ImportMeta.env` is declared once, by global.d.ts's ImportMetaEnv. This
|
|
64
|
+
// file used to redeclare it with an incompatible shape, and to carry
|
|
65
|
+
// verbatim copies of Wrapped / MapOf / JsonResponse / ISFunction — all four
|
|
66
|
+
// now live in shared.d.ts, which is where anything the browser and the
|
|
67
|
+
// server both need belongs.
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// A `declare module '@client/utils'` block used to sit here re-exporting
|
|
71
|
+
// '../utils'. It never resolved (TS2307 — relative specifiers inside an
|
|
72
|
+
// ambient module declaration do not resolve from the containing file), and
|
|
73
|
+
// nothing in the codebase imports '@client/utils' from TypeScript: it is a
|
|
74
|
+
// runtime importMap entry the browser resolves, mapped to /_client/utils.js.
|