@pikku/inspector 0.12.49 → 0.12.50

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.
@@ -1,6 +1,12 @@
1
1
  import * as ts from 'typescript'
2
2
  import { createHash } from 'crypto'
3
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
3
+ import {
4
+ existsSync,
5
+ mkdirSync,
6
+ readFileSync,
7
+ statSync,
8
+ writeFileSync,
9
+ } from 'fs'
4
10
  import { dirname, join, resolve } from 'path'
5
11
  import { createGenerator, RootlessError } from 'ts-json-schema-generator'
6
12
  import { register } from 'tsx/esm/api'
@@ -75,6 +81,7 @@ let cachedParsedConfig: ts.ParsedCommandLine | undefined
75
81
  let cachedTsconfigPath: string | undefined
76
82
  let cachedCustomTypesContent: string | undefined
77
83
  let cachedTSSchemas: Record<string, JSONValue> | undefined
84
+ let cachedTSSchemaDeps: SchemaDep[] | undefined
78
85
 
79
86
  const SCHEMA_CACHE_VERSION = 1
80
87
 
@@ -118,32 +125,102 @@ function schemaCacheFile(cacheDir: string): string {
118
125
  return join(cacheDir, 'ts-schemas.json')
119
126
  }
120
127
 
121
- function readDiskTSSchemas(
128
+ /** One file the generated schemas were derived from, as of when they were generated. */
129
+ export type SchemaDep = { path: string; mtimeMs: number; size: number }
130
+
131
+ /**
132
+ * The files a set of generated schemas was actually derived from.
133
+ *
134
+ * The cache key alone cannot answer whether the schemas are stale. It hashes the
135
+ * *synthesized* custom-types source, but a named type — `KnowledgeBundle`,
136
+ * `DbSchema`, anything a function declares as its input or output — appears
137
+ * there only as a name; its definition is resolved out of a project file or a
138
+ * dependency's `.d.ts`. Edit one of those and the key is unchanged, so a stale
139
+ * schema is served for a type that no longer looks like that. The failure is
140
+ * silent and outlives `rm -rf .pikku`, because this cache lives in
141
+ * `node_modules/.cache`.
142
+ *
143
+ * The program is rooted at the virtual file, so its source files are exactly the
144
+ * transitive closure the schemas depend on — nothing wider, nothing missed.
145
+ * Default-lib files are skipped: they change only when TypeScript itself does,
146
+ * which the inspector version in the key already covers.
147
+ */
148
+ function fingerprintSchemaDeps(program: ts.Program): SchemaDep[] {
149
+ const deps: SchemaDep[] = []
150
+ for (const sourceFile of program.getSourceFiles()) {
151
+ if (program.isSourceFileDefaultLibrary(sourceFile)) continue
152
+ try {
153
+ const stat = statSync(sourceFile.fileName)
154
+ deps.push({
155
+ path: sourceFile.fileName,
156
+ mtimeMs: stat.mtimeMs,
157
+ size: stat.size,
158
+ })
159
+ } catch {
160
+ // The virtual root has no file on disk. Its content is the custom-types
161
+ // source, which the cache key already hashes.
162
+ }
163
+ }
164
+ return deps
165
+ }
166
+
167
+ /**
168
+ * Whether every file the schemas were derived from is still exactly as it was.
169
+ *
170
+ * mtime-and-size rather than a content hash: this runs on the cache-hit path,
171
+ * where the whole point is to answer in milliseconds without building a TS
172
+ * program. Anything that rewrites a file — a dependency rebuild, a checkout, an
173
+ * edit — moves its mtime, and a missing file reads as changed, so the answer errs
174
+ * toward regenerating.
175
+ */
176
+ function schemaDepsUnchanged(deps: unknown): boolean {
177
+ if (!Array.isArray(deps)) return false
178
+ for (const dep of deps as SchemaDep[]) {
179
+ if (!dep || typeof dep.path !== 'string') return false
180
+ try {
181
+ const stat = statSync(dep.path)
182
+ if (stat.mtimeMs !== dep.mtimeMs || stat.size !== dep.size) return false
183
+ } catch {
184
+ return false
185
+ }
186
+ }
187
+ return true
188
+ }
189
+
190
+ export function readDiskTSSchemas(
122
191
  logger: InspectorLogger,
123
192
  cacheDir: string,
124
193
  key: string
125
- ): Record<string, JSONValue> | null {
194
+ ): { schemas: Record<string, JSONValue>; deps: SchemaDep[] } | null {
126
195
  const file = schemaCacheFile(cacheDir)
127
196
  if (!existsSync(file)) return null
128
197
  try {
129
198
  const parsed = JSON.parse(readFileSync(file, 'utf-8'))
130
- if (parsed?.key === key && parsed.schemas) return parsed.schemas
199
+ if (parsed?.key !== key || !parsed.schemas) return null
200
+ if (!schemaDepsUnchanged(parsed.deps)) {
201
+ logger.debug(
202
+ 'Discarding TS-schema cache: a type it was built from changed'
203
+ )
204
+ return null
205
+ }
206
+ return { schemas: parsed.schemas, deps: parsed.deps }
131
207
  } catch (e) {
132
208
  logger.debug(`Ignoring unreadable TS-schema cache: ${(e as Error).message}`)
133
209
  }
134
210
  return null
135
211
  }
136
212
 
137
- function writeDiskTSSchemas(
213
+ export function writeDiskTSSchemas(
138
214
  logger: InspectorLogger,
139
215
  cacheDir: string,
140
216
  key: string,
141
- schemas: Record<string, JSONValue>
217
+ schemas: Record<string, JSONValue>,
218
+ deps: SchemaDep[]
142
219
  ): void {
143
220
  const file = schemaCacheFile(cacheDir)
144
221
  try {
145
222
  mkdirSync(dirname(file), { recursive: true })
146
- writeFileSync(file, JSON.stringify({ key, schemas }))
223
+ writeFileSync(file, JSON.stringify({ key, schemas, deps }))
147
224
  } catch (e) {
148
225
  logger.debug(`Failed to persist TS-schema cache: ${(e as Error).message}`)
149
226
  }
@@ -234,7 +311,7 @@ function generateTSSchemas(
234
311
  additionalTypes?: string[],
235
312
  additionalProperties: boolean = false,
236
313
  generatedZodSchemas?: Record<string, JSONValue>
237
- ): Record<string, JSONValue> {
314
+ ): { schemas: Record<string, JSONValue>; deps: SchemaDep[] } {
238
315
  const schemasSet = new Set(typesMap.customTypes.keys())
239
316
  for (const { inputs, outputs } of Object.values(functionMeta)) {
240
317
  const types = [...(inputs || []), ...(outputs || [])]
@@ -277,7 +354,7 @@ function generateTSSchemas(
277
354
  (s) => !PRIMITIVE_TYPES.has(s) && !generatedZodSchemas?.[s]
278
355
  )
279
356
  if (uncoveredSchemas.length === 0) {
280
- return {}
357
+ return { schemas: {}, deps: [] }
281
358
  }
282
359
  logger.debug(
283
360
  `generateTSSchemas needed for ${uncoveredSchemas.length} types: ${uncoveredSchemas.slice(0, 3).join(', ')}${uncoveredSchemas.length > 3 ? '...' : ''}`
@@ -335,7 +412,28 @@ function generateTSSchemas(
335
412
  }
336
413
  })
337
414
 
338
- return schemas
415
+ // Fingerprinted here, while the program is still alive — it is released as soon
416
+ // as the schemas are out of it.
417
+ return { schemas, deps: fingerprintSchemaDeps(program) }
418
+ }
419
+
420
+ /**
421
+ * Where the RUNTIME value of a schema lives, given where TypeScript resolved it.
422
+ *
423
+ * A schema imported from a built workspace package resolves to that package's
424
+ * declaration file, and importing a `.d.ts` yields a module with no exports at
425
+ * all — every one of them is a type. The value is in the emitted JS beside it, so
426
+ * that is what gets imported.
427
+ *
428
+ * Falls back to the original path when no sibling JS exists, which keeps the
429
+ * "could not find exported schema" error pointing at the file the developer
430
+ * actually wrote.
431
+ */
432
+ export const schemaRuntimeFile = (sourceFile: string): string => {
433
+ const match = /\.d\.([cm]?)ts$/.exec(sourceFile)
434
+ if (!match) return sourceFile
435
+ const emitted = sourceFile.replace(/\.d\.([cm]?)ts$/, '.$1js')
436
+ return existsSync(emitted) ? emitted : sourceFile
339
437
  }
340
438
 
341
439
  /**
@@ -472,7 +570,9 @@ async function generateZodSchemas(
472
570
 
473
571
  // Collect unique source files and batch-import them in parallel
474
572
  const uniqueSourceFiles = [
475
- ...new Set([...schemaLookup.values()].map((ref) => ref.sourceFile)),
573
+ ...new Set(
574
+ [...schemaLookup.values()].map((ref) => schemaRuntimeFile(ref.sourceFile))
575
+ ),
476
576
  ]
477
577
  logger.debug(
478
578
  `[TIMING] Zod schemas: ${schemaLookup.size} schemas from ${uniqueSourceFiles.length} files`
@@ -492,12 +592,13 @@ async function generateZodSchemas(
492
592
  const fallbackSchemas: [string, SchemaRef][] = []
493
593
 
494
594
  for (const [schemaName, ref] of schemaLookup.entries()) {
495
- const mod = importedModules?.get(ref.sourceFile)
595
+ const runtimeFile = schemaRuntimeFile(ref.sourceFile)
596
+ const mod = importedModules?.get(runtimeFile)
496
597
  if (mod) {
497
598
  const zodSchema = mod[ref.variableName]
498
599
  if (!zodSchema) {
499
600
  errors.push(
500
- `Could not find exported schema '${ref.variableName}' in ${ref.sourceFile} for ${schemaName}. Available exports: ${Object.keys(mod).join(', ')}`
601
+ `Could not find exported schema '${ref.variableName}' in ${runtimeFile} for ${schemaName}. Available exports: ${Object.keys(mod).join(', ')}`
501
602
  )
502
603
  continue
503
604
  }
@@ -531,12 +632,13 @@ async function generateZodSchemas(
531
632
  `Falling back to register() import for ${fallbackSchemas.length} schema(s)`
532
633
  )
533
634
  for (const [schemaName, ref] of fallbackSchemas) {
635
+ const runtimeFile = schemaRuntimeFile(ref.sourceFile)
534
636
  try {
535
- const module = await importWithRegister(ref.sourceFile)
637
+ const module = await importWithRegister(runtimeFile)
536
638
  const zodSchema = module[ref.variableName]
537
639
  if (!zodSchema) {
538
640
  errors.push(
539
- `Could not find exported schema '${ref.variableName}' in ${ref.sourceFile} for ${schemaName}. Available exports: ${Object.keys(module).join(', ')}`
641
+ `Could not find exported schema '${ref.variableName}' in ${runtimeFile} for ${schemaName}. Available exports: ${Object.keys(module).join(', ')}`
540
642
  )
541
643
  continue
542
644
  }
@@ -595,8 +697,15 @@ export async function generateAllSchemas(
595
697
  requiredTypes
596
698
  )
597
699
 
598
- // Fast path: same process, types unchanged — reuse the in-memory result.
599
- if (cachedTSSchemas && cachedCustomTypesContent === customTypesContent) {
700
+ // Fast path: same process, types unchanged — reuse the in-memory result. The
701
+ // dep check is what makes this correct under `pikku dev`, where the process
702
+ // outlives the edits: the custom-types source is identical whenever a named
703
+ // type changes shape without changing its name.
704
+ if (
705
+ cachedTSSchemas &&
706
+ cachedCustomTypesContent === customTypesContent &&
707
+ schemaDepsUnchanged(cachedTSSchemaDeps)
708
+ ) {
600
709
  logger.debug('Reusing cached TS schemas (types unchanged)')
601
710
  return { ...cachedTSSchemas, ...zodSchemas }
602
711
  }
@@ -612,12 +721,13 @@ export async function generateAllSchemas(
612
721
  if (disk) {
613
722
  logger.debug('Reusing on-disk TS schemas (types unchanged across runs)')
614
723
  cachedCustomTypesContent = customTypesContent
615
- cachedTSSchemas = disk
616
- return { ...disk, ...zodSchemas }
724
+ cachedTSSchemas = disk.schemas
725
+ cachedTSSchemaDeps = disk.deps
726
+ return { ...disk.schemas, ...zodSchemas }
617
727
  }
618
728
  }
619
729
 
620
- const tsSchemas = generateTSSchemas(
730
+ const { schemas: tsSchemas, deps: tsSchemaDeps } = generateTSSchemas(
621
731
  logger,
622
732
  config.tsconfig,
623
733
  customTypesContent,
@@ -639,9 +749,16 @@ export async function generateAllSchemas(
639
749
 
640
750
  cachedCustomTypesContent = customTypesContent
641
751
  cachedTSSchemas = tsSchemas
752
+ cachedTSSchemaDeps = tsSchemaDeps
642
753
 
643
754
  if (config.cacheDir && cacheKey) {
644
- writeDiskTSSchemas(logger, config.cacheDir, cacheKey, tsSchemas)
755
+ writeDiskTSSchemas(
756
+ logger,
757
+ config.cacheDir,
758
+ cacheKey,
759
+ tsSchemas,
760
+ tsSchemaDeps
761
+ )
645
762
  }
646
763
 
647
764
  return { ...tsSchemas, ...zodSchemas }