@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.
- package/CHANGELOG.md +60 -0
- package/dist/utils/schema-generator.d.ts +24 -0
- package/dist/utils/schema-generator.js +118 -19
- package/package.json +3 -3
- package/src/utils/schema-cache.test.ts +124 -0
- package/src/utils/schema-generator.test.ts +62 -1
- package/src/utils/schema-generator.ts +138 -21
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import * as ts from 'typescript'
|
|
2
2
|
import { createHash } from 'crypto'
|
|
3
|
-
import {
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
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 ${
|
|
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(
|
|
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 ${
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
755
|
+
writeDiskTSSchemas(
|
|
756
|
+
logger,
|
|
757
|
+
config.cacheDir,
|
|
758
|
+
cacheKey,
|
|
759
|
+
tsSchemas,
|
|
760
|
+
tsSchemaDeps
|
|
761
|
+
)
|
|
645
762
|
}
|
|
646
763
|
|
|
647
764
|
return { ...tsSchemas, ...zodSchemas }
|