@tamagui/compiler-core 0.0.0-bootstrap.0 → 3.0.0-beta.1093.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 (98) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +57 -1
  3. package/dist/cjs/ast.cjs +108 -0
  4. package/dist/cjs/contracts.cjs +61 -0
  5. package/dist/cjs/diagnostics.cjs +68 -0
  6. package/dist/cjs/evaluate.cjs +602 -0
  7. package/dist/cjs/graph.cjs +335 -0
  8. package/dist/cjs/hash.cjs +48 -0
  9. package/dist/cjs/index.cjs +43 -0
  10. package/dist/cjs/ir.cjs +32 -0
  11. package/dist/cjs/lower.cjs +232 -0
  12. package/dist/cjs/materialize.cjs +222 -0
  13. package/dist/cjs/normalize.cjs +628 -0
  14. package/dist/cjs/output.cjs +148 -0
  15. package/dist/cjs/planCache.cjs +262 -0
  16. package/dist/cjs/session.cjs +127 -0
  17. package/dist/cjs/yuku.cjs +166 -0
  18. package/dist/cjs/zero.cjs +389 -0
  19. package/dist/esm/ast.mjs +79 -0
  20. package/dist/esm/ast.mjs.map +1 -0
  21. package/dist/esm/contracts.mjs +34 -0
  22. package/dist/esm/contracts.mjs.map +1 -0
  23. package/dist/esm/diagnostics.mjs +44 -0
  24. package/dist/esm/diagnostics.mjs.map +1 -0
  25. package/dist/esm/evaluate.mjs +576 -0
  26. package/dist/esm/evaluate.mjs.map +1 -0
  27. package/dist/esm/graph.mjs +313 -0
  28. package/dist/esm/graph.mjs.map +1 -0
  29. package/dist/esm/hash.mjs +26 -0
  30. package/dist/esm/hash.mjs.map +1 -0
  31. package/dist/esm/index.mjs +30 -0
  32. package/dist/esm/ir.mjs +12 -0
  33. package/dist/esm/ir.mjs.map +1 -0
  34. package/dist/esm/lower.mjs +210 -0
  35. package/dist/esm/lower.mjs.map +1 -0
  36. package/dist/esm/materialize.mjs +203 -0
  37. package/dist/esm/materialize.mjs.map +1 -0
  38. package/dist/esm/normalize.mjs +604 -0
  39. package/dist/esm/normalize.mjs.map +1 -0
  40. package/dist/esm/output.mjs +119 -0
  41. package/dist/esm/output.mjs.map +1 -0
  42. package/dist/esm/planCache.mjs +232 -0
  43. package/dist/esm/planCache.mjs.map +1 -0
  44. package/dist/esm/session.mjs +108 -0
  45. package/dist/esm/session.mjs.map +1 -0
  46. package/dist/esm/yuku.mjs +144 -0
  47. package/dist/esm/yuku.mjs.map +1 -0
  48. package/dist/esm/zero.mjs +354 -0
  49. package/dist/esm/zero.mjs.map +1 -0
  50. package/package.json +42 -4
  51. package/src/ast.ts +106 -0
  52. package/src/contracts.ts +150 -0
  53. package/src/diagnostics.ts +96 -0
  54. package/src/evaluate.ts +875 -0
  55. package/src/graph.ts +425 -0
  56. package/src/hash.ts +36 -0
  57. package/src/index.ts +15 -0
  58. package/src/ir.ts +156 -0
  59. package/src/lower.ts +435 -0
  60. package/src/materialize.ts +383 -0
  61. package/src/normalize.ts +834 -0
  62. package/src/output.ts +175 -0
  63. package/src/planCache.ts +362 -0
  64. package/src/session.ts +192 -0
  65. package/src/yuku.ts +197 -0
  66. package/src/zero.ts +609 -0
  67. package/types/ast.d.ts +11 -0
  68. package/types/ast.d.ts.map +1 -0
  69. package/types/contracts.d.ts +99 -0
  70. package/types/contracts.d.ts.map +1 -0
  71. package/types/diagnostics.d.ts +31 -0
  72. package/types/diagnostics.d.ts.map +1 -0
  73. package/types/evaluate.d.ts +48 -0
  74. package/types/evaluate.d.ts.map +1 -0
  75. package/types/graph.d.ts +51 -0
  76. package/types/graph.d.ts.map +1 -0
  77. package/types/hash.d.ts +8 -0
  78. package/types/hash.d.ts.map +1 -0
  79. package/types/index.d.ts +16 -0
  80. package/types/index.d.ts.map +1 -0
  81. package/types/ir.d.ts +120 -0
  82. package/types/ir.d.ts.map +1 -0
  83. package/types/lower.d.ts +99 -0
  84. package/types/lower.d.ts.map +1 -0
  85. package/types/materialize.d.ts +121 -0
  86. package/types/materialize.d.ts.map +1 -0
  87. package/types/normalize.d.ts +7 -0
  88. package/types/normalize.d.ts.map +1 -0
  89. package/types/output.d.ts +32 -0
  90. package/types/output.d.ts.map +1 -0
  91. package/types/planCache.d.ts +123 -0
  92. package/types/planCache.d.ts.map +1 -0
  93. package/types/session.d.ts +52 -0
  94. package/types/session.d.ts.map +1 -0
  95. package/types/yuku.d.ts +4 -0
  96. package/types/yuku.d.ts.map +1 -0
  97. package/types/zero.d.ts +99 -0
  98. package/types/zero.d.ts.map +1 -0
package/src/output.ts ADDED
@@ -0,0 +1,175 @@
1
+ import {
2
+ GenMapping,
3
+ addSegment,
4
+ setSourceContent,
5
+ toEncodedMap,
6
+ } from '@jridgewell/gen-mapping'
7
+ import MagicString from 'magic-string'
8
+
9
+ import type { ResolvedModuleId, SourceSpan } from './contracts'
10
+ import { contentHash } from './hash'
11
+
12
+ export interface SourceEdit {
13
+ /** UTF-16 source-string index, inclusive. */
14
+ start: number
15
+ /** UTF-16 source-string index, exclusive. Equal to start for an insertion. */
16
+ end: number
17
+ content: string
18
+ origin: SourceSpan
19
+ }
20
+
21
+ export interface CompilerSourceMap {
22
+ version: 3
23
+ file?: string
24
+ names: readonly string[]
25
+ sources: readonly (string | null)[]
26
+ sourcesContent: readonly (string | null)[]
27
+ mappings: string
28
+ }
29
+
30
+ export interface AppliedLoweredModule {
31
+ changed: boolean
32
+ code: string
33
+ map: CompilerSourceMap | null
34
+ }
35
+
36
+ export interface ApplicableLoweredModulePlan {
37
+ id: ResolvedModuleId
38
+ sourceHash: string
39
+ edits: readonly SourceEdit[]
40
+ }
41
+
42
+ function compareEdits(
43
+ left: SourceEdit & { index: number },
44
+ right: SourceEdit & { index: number }
45
+ ): number {
46
+ return left.start - right.start || left.end - right.end || left.index - right.index
47
+ }
48
+
49
+ export function sourceContentHash(source: string): string {
50
+ return contentHash(source)
51
+ }
52
+
53
+ export function validateSourceEdits(source: string, edits: readonly SourceEdit[]): void {
54
+ const sorted = edits.map((edit, index) => ({ ...edit, index })).sort(compareEdits)
55
+ let previousEnd = 0
56
+ for (const edit of sorted) {
57
+ if (
58
+ !Number.isInteger(edit.start) ||
59
+ !Number.isInteger(edit.end) ||
60
+ edit.start < 0 ||
61
+ edit.end < edit.start ||
62
+ edit.end > source.length
63
+ ) {
64
+ throw new Error(`Invalid UTF-16 source edit [${edit.start}, ${edit.end})`)
65
+ }
66
+ if (edit.start < previousEnd) {
67
+ throw new Error(`Overlapping UTF-16 source edit at ${edit.start}`)
68
+ }
69
+ previousEnd = Math.max(previousEnd, edit.end)
70
+ }
71
+ }
72
+
73
+ interface Position {
74
+ line: number
75
+ column: number
76
+ }
77
+
78
+ function sourcePositions(source: string): Position[] {
79
+ const positions: Position[] = new Array(source.length + 1)
80
+ let line = 0
81
+ let column = 0
82
+ for (let index = 0; index <= source.length; index++) {
83
+ positions[index] = { line, column }
84
+ if (index === source.length) break
85
+ if (source.charCodeAt(index) === 10) {
86
+ line++
87
+ column = 0
88
+ } else {
89
+ column++
90
+ }
91
+ }
92
+ return positions
93
+ }
94
+
95
+ function mappedSourceMap(
96
+ source: string,
97
+ id: ResolvedModuleId,
98
+ edits: readonly (SourceEdit & { index: number })[]
99
+ ): CompilerSourceMap {
100
+ const map = new GenMapping()
101
+ setSourceContent(map, id, source)
102
+ const positions = sourcePositions(source)
103
+ let generatedLine = 0
104
+ let generatedColumn = 0
105
+ let sourceCursor = 0
106
+
107
+ const emit = (content: string, sourceStart: number, sourceEnd: number) => {
108
+ const available = Math.max(1, sourceEnd - sourceStart)
109
+ for (let offset = 0; offset < content.length; offset++) {
110
+ const originalIndex = Math.min(sourceStart + offset, sourceStart + available - 1)
111
+ const original = positions[originalIndex]!
112
+ addSegment(map, generatedLine, generatedColumn, id, original.line, original.column)
113
+ if (content.charCodeAt(offset) === 10) {
114
+ generatedLine++
115
+ generatedColumn = 0
116
+ } else {
117
+ generatedColumn++
118
+ }
119
+ }
120
+ }
121
+
122
+ for (const edit of edits) {
123
+ emit(source.slice(sourceCursor, edit.start), sourceCursor, edit.start)
124
+ if (edit.origin.id !== id) {
125
+ throw new Error(`Source edit for ${id} must use a local mapping origin`)
126
+ }
127
+ emit(
128
+ edit.content,
129
+ edit.origin.start,
130
+ edit.start === edit.end ? edit.origin.start + 1 : edit.origin.end
131
+ )
132
+ sourceCursor = edit.end
133
+ }
134
+ emit(source.slice(sourceCursor), sourceCursor, source.length)
135
+ const encoded = toEncodedMap(map)
136
+ return {
137
+ version: 3,
138
+ file: encoded.file ?? undefined,
139
+ names: encoded.names,
140
+ sources: encoded.sources,
141
+ sourcesContent: encoded.sourcesContent ?? [source],
142
+ mappings: encoded.mappings,
143
+ }
144
+ }
145
+
146
+ /** The only compiler-core path that applies source edits and owns their source map. */
147
+ export function applyLoweredModule(
148
+ source: string,
149
+ id: ResolvedModuleId,
150
+ plan: ApplicableLoweredModulePlan
151
+ ): AppliedLoweredModule {
152
+ if (plan.id !== id) {
153
+ throw new Error(`Lowered module plan ${plan.id} cannot be applied to ${id}`)
154
+ }
155
+ if (plan.sourceHash !== sourceContentHash(source)) {
156
+ throw new Error(`Lowered module plan for ${id} does not match the supplied source`)
157
+ }
158
+ if (plan.edits.length === 0) return { changed: false, code: source, map: null }
159
+ validateSourceEdits(source, plan.edits)
160
+
161
+ const output = new MagicString(source)
162
+ const sorted = plan.edits.map((edit, index) => ({ ...edit, index })).sort(compareEdits)
163
+ for (const edit of sorted) {
164
+ if (edit.start === edit.end) {
165
+ output.appendLeft(edit.start, edit.content)
166
+ } else {
167
+ output.overwrite(edit.start, edit.end, edit.content)
168
+ }
169
+ }
170
+ return {
171
+ changed: true,
172
+ code: output.toString(),
173
+ map: mappedSourceMap(source, id, sorted),
174
+ }
175
+ }
@@ -0,0 +1,362 @@
1
+ import { createHash, randomBytes } from 'node:crypto'
2
+ import { readFileSync, statSync } from 'node:fs'
3
+ import {
4
+ mkdir,
5
+ readdir,
6
+ readFile,
7
+ rename,
8
+ stat,
9
+ unlink,
10
+ writeFile,
11
+ } from 'node:fs/promises'
12
+ import { dirname, join } from 'node:path'
13
+
14
+ import type { HostModuleInput, ResolvedModuleId } from './contracts'
15
+ import { contentHash, stableStringify } from './hash'
16
+ import { LOWERED_MODULE_PLAN_VERSION, type LoweredModulePlan } from './lower'
17
+ import { moduleContentHash } from './graph'
18
+
19
+ export const PLAN_CACHE_SCHEMA_VERSION = 1
20
+
21
+ /**
22
+ * Entries are content addressed, so every edit of a shared module writes new
23
+ * files and nothing ever replaces the old ones. Twenty edits of one module a
24
+ * hundred consumers import leaves two thousand dead entries that would live
25
+ * forever. A pruned entry is just a miss, and a miss recompiles, so dropping
26
+ * entries can never be wrong - only slower.
27
+ *
28
+ * The cap has to sit well above the module count of a real project, or an
29
+ * unchanged rebuild of a project larger than the cap would miss on every module
30
+ * the last build evicted. Twenty thousand entries is a few hundred MB at the
31
+ * high end and comfortably holds a large app plus several generations of edits.
32
+ *
33
+ * It is a soft cap: pruning runs between batches of writes, so a store can sit
34
+ * up to one batch above it. Bounding growth is the point, not an exact size.
35
+ */
36
+ export const PLAN_CACHE_MAX_ENTRIES = 20_000
37
+
38
+ /**
39
+ * The one on-disk cache location for a project. Metro's plan manifest already
40
+ * lives under it, and the build-time benchmark's "cold" state is defined as
41
+ * deleting it, so everything the compiler persists belongs here and nowhere
42
+ * else.
43
+ */
44
+ export function tamaguiCacheRoot(projectRoot: string): string {
45
+ return join(projectRoot, 'node_modules', '.cache', 'tamagui')
46
+ }
47
+
48
+ export function defaultPlanCacheRoot(projectRoot: string, target: string): string {
49
+ return join(tamaguiCacheRoot(projectRoot), 'plans', target)
50
+ }
51
+
52
+ /**
53
+ * Everything a plan depends on that is not a module: the compiler build, the
54
+ * evaluated Tamagui config and component registry, the platform, and the
55
+ * structural pass. `stamp` comes from `loadCompilerProject`'s `cacheStamp`.
56
+ */
57
+ export interface PlanCacheIdentity {
58
+ stamp: string
59
+ target: string
60
+ structuralPassHash: string
61
+ }
62
+
63
+ export interface PlanCacheEntry {
64
+ schemaVersion: typeof PLAN_CACHE_SCHEMA_VERSION
65
+ moduleId: ResolvedModuleId
66
+ /** Digest of this module plus its whole non-external import closure. */
67
+ closureDigest: string
68
+ plan: LoweredModulePlan
69
+ }
70
+
71
+ export interface ModuleClosureNode {
72
+ contentHash: string
73
+ /** Non-external dependencies only: the edges symbol resolution can cross. */
74
+ dependencies: readonly ResolvedModuleId[]
75
+ }
76
+
77
+ export type ModuleClosureLookup = (id: ResolvedModuleId) => ModuleClosureNode | null
78
+
79
+ /**
80
+ * Reads a closure node straight off host module records, for callers that have
81
+ * not built a ProjectGraph (Metro's prepass skips it entirely on a full hit).
82
+ */
83
+ export function moduleClosureNode(
84
+ input: HostModuleInput,
85
+ options?: { includeExternal?: boolean }
86
+ ): ModuleClosureNode {
87
+ return {
88
+ contentHash: moduleContentHash(input),
89
+ dependencies: input.imports
90
+ .filter(({ external }) => options?.includeExternal || !external)
91
+ .map(({ resolvedId }) => resolvedId),
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Closure nodes for ids the graph does not own: package entries. Their bytes
97
+ * decide which static configs lowering resolved against (configured
98
+ * `components` and discovered modules alike), so a package bump changes the
99
+ * digest of every module that imports it. Ids that are not files hash to a
100
+ * constant so they never make a closure incomplete.
101
+ */
102
+ export function createExternalClosureLookup(): (
103
+ id: ResolvedModuleId
104
+ ) => ModuleClosureNode {
105
+ const memo = new Map<string, { size: number; mtimeMs: number; contentHash: string }>()
106
+ return (id) => {
107
+ const file = id.split(/[?#]/, 1)[0]
108
+ if (!file.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(file)) {
109
+ return { contentHash: 'external', dependencies: [] }
110
+ }
111
+ try {
112
+ const stat = statSync(file)
113
+ const cached = memo.get(file)
114
+ if (cached && cached.size === stat.size && cached.mtimeMs === stat.mtimeMs) {
115
+ return { contentHash: cached.contentHash, dependencies: [] }
116
+ }
117
+ const hash = createHash('sha256').update(readFileSync(file)).digest('hex')
118
+ memo.set(file, { size: stat.size, mtimeMs: stat.mtimeMs, contentHash: hash })
119
+ return { contentHash: hash, dependencies: [] }
120
+ } catch {
121
+ return { contentHash: 'external', dependencies: [] }
122
+ }
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Identity of a module's whole compile input: itself plus every module reachable
128
+ * from it over non-external imports, each by content hash.
129
+ *
130
+ * This is deliberately the import closure rather than the dependency set a
131
+ * successful compile recorded. The recorded set is only known after compiling,
132
+ * and it omits the module that made a value bail, so a dependency edit that
133
+ * turns a bailout into a static value would not invalidate its consumer, which
134
+ * is exactly how a stale style ships. The import closure is the same edge set
135
+ * `ProjectGraph.affectedBy` propagates over in memory, so it can never
136
+ * invalidate less than a live session would.
137
+ *
138
+ * Null when any module in the closure is unknown: an unknown input is a miss,
139
+ * never a guess.
140
+ */
141
+ export function moduleClosureDigest(
142
+ id: ResolvedModuleId,
143
+ lookup: ModuleClosureLookup,
144
+ memo?: Map<ResolvedModuleId, string | null>
145
+ ): string | null {
146
+ const cached = memo?.get(id)
147
+ if (cached !== undefined) return cached
148
+ const reached = new Map<ResolvedModuleId, string>()
149
+ const queue: ResolvedModuleId[] = [id]
150
+ let complete = true
151
+ while (queue.length) {
152
+ const current = queue.pop()!
153
+ if (reached.has(current)) continue
154
+ const node = lookup(current)
155
+ if (!node) {
156
+ complete = false
157
+ break
158
+ }
159
+ reached.set(current, node.contentHash)
160
+ for (const dependency of node.dependencies) queue.push(dependency)
161
+ }
162
+ const digest = complete
163
+ ? contentHash(
164
+ [...reached]
165
+ .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
166
+ .map(([current, hash]) => `${current}\0${hash}`)
167
+ .join('\0')
168
+ )
169
+ : null
170
+ memo?.set(id, digest)
171
+ return digest
172
+ }
173
+
174
+ export function planCacheKey(
175
+ identity: PlanCacheIdentity,
176
+ id: ResolvedModuleId,
177
+ closureDigest: string
178
+ ): string {
179
+ return contentHash(
180
+ stableStringify({
181
+ schema: PLAN_CACHE_SCHEMA_VERSION,
182
+ plan: LOWERED_MODULE_PLAN_VERSION,
183
+ stamp: identity.stamp,
184
+ target: identity.target,
185
+ structuralPassHash: identity.structuralPassHash,
186
+ id,
187
+ closureDigest,
188
+ })
189
+ )
190
+ }
191
+
192
+ /**
193
+ * Content-addressed JSON entries on disk, one file per key. There is no
194
+ * manifest on purpose: a manifest is what makes a cache all-or-nothing, and the
195
+ * point of these caches is that editing one module leaves every other module's
196
+ * entry valid.
197
+ */
198
+ export class JsonFileCache {
199
+ #hits = 0
200
+ #misses = 0
201
+ #writes = 0
202
+ #writesSincePrune = 0
203
+ #prunedThisProcess = false
204
+ readonly #created = new Set<string>()
205
+
206
+ constructor(
207
+ readonly root: string,
208
+ readonly schemaVersion: number,
209
+ readonly maxEntries: number = PLAN_CACHE_MAX_ENTRIES
210
+ ) {}
211
+
212
+ get stats(): { hits: number; misses: number; writes: number } {
213
+ return { hits: this.#hits, misses: this.#misses, writes: this.#writes }
214
+ }
215
+
216
+ resetStats(): void {
217
+ this.#hits = 0
218
+ this.#misses = 0
219
+ this.#writes = 0
220
+ }
221
+
222
+ #path(key: string): string {
223
+ return join(this.root, `v${this.schemaVersion}`, key.slice(0, 2), `${key}.json`)
224
+ }
225
+
226
+ /**
227
+ * Null on anything short of an entry `validate` fully accepts. A miss
228
+ * recompiles; nothing here repairs or partially trusts an entry, and a later
229
+ * successful compile overwrites the bad file.
230
+ */
231
+ async read<T>(key: string, validate: (value: unknown) => T | null): Promise<T | null> {
232
+ let parsed: unknown
233
+ try {
234
+ parsed = JSON.parse(await readFile(this.#path(key), 'utf8'))
235
+ } catch {
236
+ this.#misses++
237
+ return null
238
+ }
239
+ const entry = validate(parsed)
240
+ if (entry === null) {
241
+ this.#misses++
242
+ return null
243
+ }
244
+ this.#hits++
245
+ return entry
246
+ }
247
+
248
+ async write(key: string, value: unknown): Promise<void> {
249
+ const path = this.#path(key)
250
+ const directory = dirname(path)
251
+ // a full first build writes one entry per module, so the directory syscall
252
+ // is worth remembering
253
+ if (!this.#created.has(directory)) {
254
+ await mkdir(directory, { recursive: true })
255
+ this.#created.add(directory)
256
+ }
257
+ const temporaryPath = `${path}.${process.pid}-${randomBytes(6).toString('hex')}.tmp`
258
+ await writeFile(temporaryPath, `${stableStringify(value)}\n`, 'utf8')
259
+ await rename(temporaryPath, path)
260
+ this.#writes++
261
+ // Once per process, plus once per cap-sized batch inside a long-lived one.
262
+ // Checking only the batch counter would never prune the common case, where
263
+ // every build is a fresh process writing far fewer entries than the cap.
264
+ this.#writesSincePrune++
265
+ const batch = Math.max(1, Math.floor(this.maxEntries / 4))
266
+ if (!this.#prunedThisProcess || this.#writesSincePrune >= batch) {
267
+ this.#prunedThisProcess = true
268
+ this.#writesSincePrune = 0
269
+ await this.#prune()
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Drops the oldest entries once the store exceeds its cap, oldest by the later
275
+ * of read and write time so an entry a build keeps hitting is not treated as
276
+ * dead on filesystems that maintain access times.
277
+ */
278
+ async #prune(): Promise<void> {
279
+ const versionRoot = join(this.root, `v${this.schemaVersion}`)
280
+ let shards: string[]
281
+ try {
282
+ shards = await readdir(versionRoot)
283
+ } catch {
284
+ return
285
+ }
286
+ const paths: string[] = []
287
+ for (const shard of shards) {
288
+ const directory = join(versionRoot, shard)
289
+ let files: string[]
290
+ try {
291
+ files = await readdir(directory)
292
+ } catch {
293
+ continue
294
+ }
295
+ for (const file of files) {
296
+ if (file.endsWith('.json')) paths.push(join(directory, file))
297
+ }
298
+ }
299
+ // names are enough to know whether pruning is needed, so a store under its
300
+ // cap costs one readdir per shard and no stat at all
301
+ if (paths.length <= this.maxEntries) return
302
+ const entries: { path: string; usedAt: number }[] = []
303
+ for (const path of paths) {
304
+ try {
305
+ const stats = await stat(path)
306
+ entries.push({ path, usedAt: Math.max(stats.atimeMs, stats.mtimeMs) })
307
+ } catch {
308
+ // a concurrent build may have pruned it already
309
+ }
310
+ }
311
+ entries.sort((left, right) => left.usedAt - right.usedAt)
312
+ for (const entry of entries.slice(0, Math.max(0, entries.length - this.maxEntries))) {
313
+ await unlink(entry.path).catch(() => {})
314
+ }
315
+ }
316
+ }
317
+
318
+ /** Per-module lowering plans, keyed by `planCacheKey`. */
319
+ export class ModulePlanCache {
320
+ readonly #files: JsonFileCache
321
+
322
+ constructor(readonly root: string) {
323
+ this.#files = new JsonFileCache(root, PLAN_CACHE_SCHEMA_VERSION)
324
+ }
325
+
326
+ get stats(): { hits: number; misses: number; writes: number } {
327
+ return this.#files.stats
328
+ }
329
+
330
+ resetStats(): void {
331
+ this.#files.resetStats()
332
+ }
333
+
334
+ read(
335
+ key: string,
336
+ id: ResolvedModuleId,
337
+ closureDigest: string
338
+ ): Promise<PlanCacheEntry | null> {
339
+ return this.#files.read(key, (value) => {
340
+ const entry = value as PlanCacheEntry | null
341
+ const plan = entry?.plan
342
+ return entry?.schemaVersion === PLAN_CACHE_SCHEMA_VERSION &&
343
+ entry.moduleId === id &&
344
+ entry.closureDigest === closureDigest &&
345
+ plan &&
346
+ plan.version === LOWERED_MODULE_PLAN_VERSION &&
347
+ plan.id === id &&
348
+ typeof plan.sourceHash === 'string' &&
349
+ typeof plan.css === 'string' &&
350
+ Array.isArray(plan.edits) &&
351
+ Array.isArray(plan.diagnostics) &&
352
+ Array.isArray(plan.dependencies) &&
353
+ !!plan.stats
354
+ ? entry
355
+ : null
356
+ })
357
+ }
358
+
359
+ write(key: string, entry: PlanCacheEntry): Promise<void> {
360
+ return this.#files.write(key, entry)
361
+ }
362
+ }