@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
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,63 @@
|
|
|
1
|
+
## 0.12.50
|
|
2
|
+
|
|
3
|
+
### Patch Changes
|
|
4
|
+
|
|
5
|
+
- b89d3b3: Bring the knowledge base into OSS: a package, a CLI gate, a console browser and a skill
|
|
6
|
+
|
|
7
|
+
`knowledge/` is where a project records the things `pikku meta` cannot tell you —
|
|
8
|
+
what a slice is for, which rule was chosen and what it rules out, what is still an
|
|
9
|
+
open question. Tables, routes, schemas and permissions are generated, so a note
|
|
10
|
+
that repeats them is a copy that will drift, and the profile refuses the sections
|
|
11
|
+
where that happens.
|
|
12
|
+
- **`@pikku/knowledge`** (new) reads the notes, builds the link graph in both
|
|
13
|
+
directions, and validates the app-project profile: every note typed, every
|
|
14
|
+
section indexed, every slice carrying a third-person gherkin scenario and at
|
|
15
|
+
most three entities, and every `resource:` URI resolving against the generated
|
|
16
|
+
meta. The resource check fails closed on drift and open on ignorance — a prefix
|
|
17
|
+
whose meta is absent is skipped rather than called dangling.
|
|
18
|
+
- **`pikku knowledge validate`** and **`pikku knowledge index`** replace the dead
|
|
19
|
+
three-flat-files check. Both exit non-zero on an inconsistent base, so a
|
|
20
|
+
pipeline can stop on one; `index` refreshes each `index.md` listing while
|
|
21
|
+
leaving the prose around it alone, and now gives a section that holds only
|
|
22
|
+
sub-sections an index of its own instead of leaving it unreachable.
|
|
23
|
+
- **The console** gains a read-only Knowledge page: notes grouped by section,
|
|
24
|
+
a rendered document with its tags, resources, links in both directions and the
|
|
25
|
+
findings against it, and intra-bundle markdown links that open the linked note
|
|
26
|
+
instead of leaving the page. Read-only by design — a note is edited in the repo,
|
|
27
|
+
in the same commit as the code it describes.
|
|
28
|
+
- **The `pikku-knowledge` skill** documents the format for agents, and Fabric
|
|
29
|
+
builds on it rather than restating it.
|
|
30
|
+
- **`@pikku/inspector`**: a zod schema imported from a built workspace package
|
|
31
|
+
resolved to that package's `.d.ts`, which has no runtime exports at all, so
|
|
32
|
+
every schema in it was reported missing. The emitted JS beside it is imported
|
|
33
|
+
instead.
|
|
34
|
+
|
|
35
|
+
- b89d3b3: Invalidate the TS-schema cache when a type it was built from changes
|
|
36
|
+
|
|
37
|
+
The on-disk schema cache was keyed on the synthesized custom-types source, the
|
|
38
|
+
generator options and the inspector version — but not on the types it actually
|
|
39
|
+
resolves. A named type (anything a function declares as its input or output)
|
|
40
|
+
appears in that source only as a name; its definition is read out of a project
|
|
41
|
+
file or a dependency's `.d.ts`. Change the shape without changing the name and
|
|
42
|
+
the key is identical, so pikku serves a schema for a type that no longer looks
|
|
43
|
+
like that, and requests are validated against it.
|
|
44
|
+
|
|
45
|
+
The stale schema also outlives `rm -rf .pikku`, because the cache lives in
|
|
46
|
+
`node_modules/.cache/pikku/ts-schemas.json`.
|
|
47
|
+
|
|
48
|
+
Codegen now records the files each schema set was derived from, storing their
|
|
49
|
+
mtime and size beside it, and regenerates when any of them moves or disappears.
|
|
50
|
+
The schema program is rooted at the virtual types file, so its source files are
|
|
51
|
+
exactly the transitive closure the schemas depend on. A cache written by an
|
|
52
|
+
earlier version carries no dep list and is treated as stale. The check costs
|
|
53
|
+
about 3ms per cache hit, and also guards the in-process cache, so `pikku dev`
|
|
54
|
+
picks up a type edit without a restart.
|
|
55
|
+
|
|
56
|
+
- Updated dependencies [384e484]
|
|
57
|
+
- Updated dependencies [b5a73fb]
|
|
58
|
+
- Updated dependencies [6be5ab0]
|
|
59
|
+
- @pikku/core@0.12.72
|
|
60
|
+
|
|
1
61
|
## 0.12.49
|
|
2
62
|
|
|
3
63
|
### Patch Changes
|
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
import type { JSONValue } from '@pikku/core';
|
|
2
2
|
import type { InspectorLogger, InspectorState } from '../types.js';
|
|
3
|
+
/** One file the generated schemas were derived from, as of when they were generated. */
|
|
4
|
+
export type SchemaDep = {
|
|
5
|
+
path: string;
|
|
6
|
+
mtimeMs: number;
|
|
7
|
+
size: number;
|
|
8
|
+
};
|
|
9
|
+
export declare function readDiskTSSchemas(logger: InspectorLogger, cacheDir: string, key: string): {
|
|
10
|
+
schemas: Record<string, JSONValue>;
|
|
11
|
+
deps: SchemaDep[];
|
|
12
|
+
} | null;
|
|
13
|
+
export declare function writeDiskTSSchemas(logger: InspectorLogger, cacheDir: string, key: string, schemas: Record<string, JSONValue>, deps: SchemaDep[]): void;
|
|
14
|
+
/**
|
|
15
|
+
* Where the RUNTIME value of a schema lives, given where TypeScript resolved it.
|
|
16
|
+
*
|
|
17
|
+
* A schema imported from a built workspace package resolves to that package's
|
|
18
|
+
* declaration file, and importing a `.d.ts` yields a module with no exports at
|
|
19
|
+
* all — every one of them is a type. The value is in the emitted JS beside it, so
|
|
20
|
+
* that is what gets imported.
|
|
21
|
+
*
|
|
22
|
+
* Falls back to the original path when no sibling JS exists, which keeps the
|
|
23
|
+
* "could not find exported schema" error pointing at the file the developer
|
|
24
|
+
* actually wrote.
|
|
25
|
+
*/
|
|
26
|
+
export declare const schemaRuntimeFile: (sourceFile: string) => string;
|
|
3
27
|
export declare function generateAllSchemas(logger: InspectorLogger, config: {
|
|
4
28
|
tsconfig: string;
|
|
5
29
|
schemasFromTypes?: string[];
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as ts from 'typescript';
|
|
2
2
|
import { createHash } from 'crypto';
|
|
3
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync, } from 'fs';
|
|
4
4
|
import { dirname, join, resolve } from 'path';
|
|
5
5
|
import { createGenerator, RootlessError } from 'ts-json-schema-generator';
|
|
6
6
|
import { register } from 'tsx/esm/api';
|
|
@@ -57,6 +57,7 @@ let cachedParsedConfig;
|
|
|
57
57
|
let cachedTsconfigPath;
|
|
58
58
|
let cachedCustomTypesContent;
|
|
59
59
|
let cachedTSSchemas;
|
|
60
|
+
let cachedTSSchemaDeps;
|
|
60
61
|
const SCHEMA_CACHE_VERSION = 1;
|
|
61
62
|
// This package's own version — folded into the cache key so that upgrading
|
|
62
63
|
// @pikku/inspector (the channel through which a schema-format change ships)
|
|
@@ -90,25 +91,93 @@ function tsSchemaCacheKey(customTypesContent, config) {
|
|
|
90
91
|
function schemaCacheFile(cacheDir) {
|
|
91
92
|
return join(cacheDir, 'ts-schemas.json');
|
|
92
93
|
}
|
|
93
|
-
|
|
94
|
+
/**
|
|
95
|
+
* The files a set of generated schemas was actually derived from.
|
|
96
|
+
*
|
|
97
|
+
* The cache key alone cannot answer whether the schemas are stale. It hashes the
|
|
98
|
+
* *synthesized* custom-types source, but a named type — `KnowledgeBundle`,
|
|
99
|
+
* `DbSchema`, anything a function declares as its input or output — appears
|
|
100
|
+
* there only as a name; its definition is resolved out of a project file or a
|
|
101
|
+
* dependency's `.d.ts`. Edit one of those and the key is unchanged, so a stale
|
|
102
|
+
* schema is served for a type that no longer looks like that. The failure is
|
|
103
|
+
* silent and outlives `rm -rf .pikku`, because this cache lives in
|
|
104
|
+
* `node_modules/.cache`.
|
|
105
|
+
*
|
|
106
|
+
* The program is rooted at the virtual file, so its source files are exactly the
|
|
107
|
+
* transitive closure the schemas depend on — nothing wider, nothing missed.
|
|
108
|
+
* Default-lib files are skipped: they change only when TypeScript itself does,
|
|
109
|
+
* which the inspector version in the key already covers.
|
|
110
|
+
*/
|
|
111
|
+
function fingerprintSchemaDeps(program) {
|
|
112
|
+
const deps = [];
|
|
113
|
+
for (const sourceFile of program.getSourceFiles()) {
|
|
114
|
+
if (program.isSourceFileDefaultLibrary(sourceFile))
|
|
115
|
+
continue;
|
|
116
|
+
try {
|
|
117
|
+
const stat = statSync(sourceFile.fileName);
|
|
118
|
+
deps.push({
|
|
119
|
+
path: sourceFile.fileName,
|
|
120
|
+
mtimeMs: stat.mtimeMs,
|
|
121
|
+
size: stat.size,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// The virtual root has no file on disk. Its content is the custom-types
|
|
126
|
+
// source, which the cache key already hashes.
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return deps;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Whether every file the schemas were derived from is still exactly as it was.
|
|
133
|
+
*
|
|
134
|
+
* mtime-and-size rather than a content hash: this runs on the cache-hit path,
|
|
135
|
+
* where the whole point is to answer in milliseconds without building a TS
|
|
136
|
+
* program. Anything that rewrites a file — a dependency rebuild, a checkout, an
|
|
137
|
+
* edit — moves its mtime, and a missing file reads as changed, so the answer errs
|
|
138
|
+
* toward regenerating.
|
|
139
|
+
*/
|
|
140
|
+
function schemaDepsUnchanged(deps) {
|
|
141
|
+
if (!Array.isArray(deps))
|
|
142
|
+
return false;
|
|
143
|
+
for (const dep of deps) {
|
|
144
|
+
if (!dep || typeof dep.path !== 'string')
|
|
145
|
+
return false;
|
|
146
|
+
try {
|
|
147
|
+
const stat = statSync(dep.path);
|
|
148
|
+
if (stat.mtimeMs !== dep.mtimeMs || stat.size !== dep.size)
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
export function readDiskTSSchemas(logger, cacheDir, key) {
|
|
94
158
|
const file = schemaCacheFile(cacheDir);
|
|
95
159
|
if (!existsSync(file))
|
|
96
160
|
return null;
|
|
97
161
|
try {
|
|
98
162
|
const parsed = JSON.parse(readFileSync(file, 'utf-8'));
|
|
99
|
-
if (parsed?.key
|
|
100
|
-
return
|
|
163
|
+
if (parsed?.key !== key || !parsed.schemas)
|
|
164
|
+
return null;
|
|
165
|
+
if (!schemaDepsUnchanged(parsed.deps)) {
|
|
166
|
+
logger.debug('Discarding TS-schema cache: a type it was built from changed');
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
return { schemas: parsed.schemas, deps: parsed.deps };
|
|
101
170
|
}
|
|
102
171
|
catch (e) {
|
|
103
172
|
logger.debug(`Ignoring unreadable TS-schema cache: ${e.message}`);
|
|
104
173
|
}
|
|
105
174
|
return null;
|
|
106
175
|
}
|
|
107
|
-
function writeDiskTSSchemas(logger, cacheDir, key, schemas) {
|
|
176
|
+
export function writeDiskTSSchemas(logger, cacheDir, key, schemas, deps) {
|
|
108
177
|
const file = schemaCacheFile(cacheDir);
|
|
109
178
|
try {
|
|
110
179
|
mkdirSync(dirname(file), { recursive: true });
|
|
111
|
-
writeFileSync(file, JSON.stringify({ key, schemas }));
|
|
180
|
+
writeFileSync(file, JSON.stringify({ key, schemas, deps }));
|
|
112
181
|
}
|
|
113
182
|
catch (e) {
|
|
114
183
|
logger.debug(`Failed to persist TS-schema cache: ${e.message}`);
|
|
@@ -196,7 +265,7 @@ function generateTSSchemas(logger, tsconfig, customTypesContent, typesMap, funct
|
|
|
196
265
|
// so that failed Zod conversions fall through to TS schema generation.
|
|
197
266
|
const uncoveredSchemas = [...schemasSet].filter((s) => !PRIMITIVE_TYPES.has(s) && !generatedZodSchemas?.[s]);
|
|
198
267
|
if (uncoveredSchemas.length === 0) {
|
|
199
|
-
return {};
|
|
268
|
+
return { schemas: {}, deps: [] };
|
|
200
269
|
}
|
|
201
270
|
logger.debug(`generateTSSchemas needed for ${uncoveredSchemas.length} types: ${uncoveredSchemas.slice(0, 3).join(', ')}${uncoveredSchemas.length > 3 ? '...' : ''}`);
|
|
202
271
|
const virtualFilePath = join(dirname(resolve(tsconfig)), '__pikku_virtual_types__.ts');
|
|
@@ -239,8 +308,29 @@ function generateTSSchemas(logger, tsconfig, customTypesContent, typesMap, funct
|
|
|
239
308
|
logger.error(`[${ErrorCode.SCHEMA_GENERATION_ERROR}] Error generating schema: ${schema}. Message: ${e.message}. Type info: ${customType ? `type=${customType.type}` : 'not in typesMap'}`);
|
|
240
309
|
}
|
|
241
310
|
});
|
|
242
|
-
|
|
311
|
+
// Fingerprinted here, while the program is still alive — it is released as soon
|
|
312
|
+
// as the schemas are out of it.
|
|
313
|
+
return { schemas, deps: fingerprintSchemaDeps(program) };
|
|
243
314
|
}
|
|
315
|
+
/**
|
|
316
|
+
* Where the RUNTIME value of a schema lives, given where TypeScript resolved it.
|
|
317
|
+
*
|
|
318
|
+
* A schema imported from a built workspace package resolves to that package's
|
|
319
|
+
* declaration file, and importing a `.d.ts` yields a module with no exports at
|
|
320
|
+
* all — every one of them is a type. The value is in the emitted JS beside it, so
|
|
321
|
+
* that is what gets imported.
|
|
322
|
+
*
|
|
323
|
+
* Falls back to the original path when no sibling JS exists, which keeps the
|
|
324
|
+
* "could not find exported schema" error pointing at the file the developer
|
|
325
|
+
* actually wrote.
|
|
326
|
+
*/
|
|
327
|
+
export const schemaRuntimeFile = (sourceFile) => {
|
|
328
|
+
const match = /\.d\.([cm]?)ts$/.exec(sourceFile);
|
|
329
|
+
if (!match)
|
|
330
|
+
return sourceFile;
|
|
331
|
+
const emitted = sourceFile.replace(/\.d\.([cm]?)ts$/, '.$1js');
|
|
332
|
+
return existsSync(emitted) ? emitted : sourceFile;
|
|
333
|
+
};
|
|
244
334
|
/**
|
|
245
335
|
* Import all source files in parallel using tsx's register() API.
|
|
246
336
|
*
|
|
@@ -333,7 +423,7 @@ async function generateZodSchemas(logger, schemaLookup, typesMap) {
|
|
|
333
423
|
}
|
|
334
424
|
// Collect unique source files and batch-import them in parallel
|
|
335
425
|
const uniqueSourceFiles = [
|
|
336
|
-
...new Set([...schemaLookup.values()].map((ref) => ref.sourceFile)),
|
|
426
|
+
...new Set([...schemaLookup.values()].map((ref) => schemaRuntimeFile(ref.sourceFile))),
|
|
337
427
|
];
|
|
338
428
|
logger.debug(`[TIMING] Zod schemas: ${schemaLookup.size} schemas from ${uniqueSourceFiles.length} files`);
|
|
339
429
|
const importStart = performance.now();
|
|
@@ -343,11 +433,12 @@ async function generateZodSchemas(logger, schemaLookup, typesMap) {
|
|
|
343
433
|
// Track schemas that need per-file tsImport fallback
|
|
344
434
|
const fallbackSchemas = [];
|
|
345
435
|
for (const [schemaName, ref] of schemaLookup.entries()) {
|
|
346
|
-
const
|
|
436
|
+
const runtimeFile = schemaRuntimeFile(ref.sourceFile);
|
|
437
|
+
const mod = importedModules?.get(runtimeFile);
|
|
347
438
|
if (mod) {
|
|
348
439
|
const zodSchema = mod[ref.variableName];
|
|
349
440
|
if (!zodSchema) {
|
|
350
|
-
errors.push(`Could not find exported schema '${ref.variableName}' in ${
|
|
441
|
+
errors.push(`Could not find exported schema '${ref.variableName}' in ${runtimeFile} for ${schemaName}. Available exports: ${Object.keys(mod).join(', ')}`);
|
|
351
442
|
continue;
|
|
352
443
|
}
|
|
353
444
|
try {
|
|
@@ -368,11 +459,12 @@ async function generateZodSchemas(logger, schemaLookup, typesMap) {
|
|
|
368
459
|
if (fallbackSchemas.length > 0) {
|
|
369
460
|
logger.debug(`Falling back to register() import for ${fallbackSchemas.length} schema(s)`);
|
|
370
461
|
for (const [schemaName, ref] of fallbackSchemas) {
|
|
462
|
+
const runtimeFile = schemaRuntimeFile(ref.sourceFile);
|
|
371
463
|
try {
|
|
372
|
-
const module = await importWithRegister(
|
|
464
|
+
const module = await importWithRegister(runtimeFile);
|
|
373
465
|
const zodSchema = module[ref.variableName];
|
|
374
466
|
if (!zodSchema) {
|
|
375
|
-
errors.push(`Could not find exported schema '${ref.variableName}' in ${
|
|
467
|
+
errors.push(`Could not find exported schema '${ref.variableName}' in ${runtimeFile} for ${schemaName}. Available exports: ${Object.keys(module).join(', ')}`);
|
|
376
468
|
continue;
|
|
377
469
|
}
|
|
378
470
|
processZodSchema(schemaName, zodSchema, schemas, typesMap, auxiliaryTypeStore, printer, fakeSourceFile, logger);
|
|
@@ -395,8 +487,13 @@ export async function generateAllSchemas(logger, config, state) {
|
|
|
395
487
|
const zodSchemas = await generateZodSchemas(logger, state.schemaLookup, state.functions.typesMap);
|
|
396
488
|
const requiredTypes = new Set();
|
|
397
489
|
const customTypesContent = generateCustomTypes(state.functions.typesMap, requiredTypes);
|
|
398
|
-
// Fast path: same process, types unchanged — reuse the in-memory result.
|
|
399
|
-
|
|
490
|
+
// Fast path: same process, types unchanged — reuse the in-memory result. The
|
|
491
|
+
// dep check is what makes this correct under `pikku dev`, where the process
|
|
492
|
+
// outlives the edits: the custom-types source is identical whenever a named
|
|
493
|
+
// type changes shape without changing its name.
|
|
494
|
+
if (cachedTSSchemas &&
|
|
495
|
+
cachedCustomTypesContent === customTypesContent &&
|
|
496
|
+
schemaDepsUnchanged(cachedTSSchemaDeps)) {
|
|
400
497
|
logger.debug('Reusing cached TS schemas (types unchanged)');
|
|
401
498
|
return { ...cachedTSSchemas, ...zodSchemas };
|
|
402
499
|
}
|
|
@@ -411,11 +508,12 @@ export async function generateAllSchemas(logger, config, state) {
|
|
|
411
508
|
if (disk) {
|
|
412
509
|
logger.debug('Reusing on-disk TS schemas (types unchanged across runs)');
|
|
413
510
|
cachedCustomTypesContent = customTypesContent;
|
|
414
|
-
cachedTSSchemas = disk;
|
|
415
|
-
|
|
511
|
+
cachedTSSchemas = disk.schemas;
|
|
512
|
+
cachedTSSchemaDeps = disk.deps;
|
|
513
|
+
return { ...disk.schemas, ...zodSchemas };
|
|
416
514
|
}
|
|
417
515
|
}
|
|
418
|
-
const tsSchemas = generateTSSchemas(logger, config.tsconfig, customTypesContent, state.functions.typesMap, state.functions.meta, state.http.meta, config.schemasFromTypes, config.schema?.additionalProperties, zodSchemas);
|
|
516
|
+
const { schemas: tsSchemas, deps: tsSchemaDeps } = generateTSSchemas(logger, config.tsconfig, customTypesContent, state.functions.typesMap, state.functions.meta, state.http.meta, config.schemasFromTypes, config.schema?.additionalProperties, zodSchemas);
|
|
419
517
|
// Release the program once the schemas are out of it. It spans the whole
|
|
420
518
|
// tsconfig (~2.5k files, ~600MB on a large tree) and was previously pinned at
|
|
421
519
|
// module scope for the life of the process, so it stacked on top of the main
|
|
@@ -425,8 +523,9 @@ export async function generateAllSchemas(logger, config, state) {
|
|
|
425
523
|
cachedSchemaProgram = undefined;
|
|
426
524
|
cachedCustomTypesContent = customTypesContent;
|
|
427
525
|
cachedTSSchemas = tsSchemas;
|
|
526
|
+
cachedTSSchemaDeps = tsSchemaDeps;
|
|
428
527
|
if (config.cacheDir && cacheKey) {
|
|
429
|
-
writeDiskTSSchemas(logger, config.cacheDir, cacheKey, tsSchemas);
|
|
528
|
+
writeDiskTSSchemas(logger, config.cacheDir, cacheKey, tsSchemas, tsSchemaDeps);
|
|
430
529
|
}
|
|
431
530
|
return { ...tsSchemas, ...zodSchemas };
|
|
432
531
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pikku/inspector",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.50",
|
|
4
4
|
"description": "TypeScript inspector for Pikku — discovers functions and wirings to drive code generation",
|
|
5
5
|
"author": "yasser.fadl@gmail.com",
|
|
6
6
|
"license": "BUSL-1.1",
|
|
@@ -45,10 +45,10 @@
|
|
|
45
45
|
"zod-to-ts": "^2.1.0"
|
|
46
46
|
},
|
|
47
47
|
"peerDependencies": {
|
|
48
|
-
"@pikku/core": "^0.12.
|
|
48
|
+
"@pikku/core": "^0.12.72"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
|
-
"@pikku/core": "^0.12.
|
|
51
|
+
"@pikku/core": "^0.12.72",
|
|
52
52
|
"@types/node": "^24.11.0"
|
|
53
53
|
},
|
|
54
54
|
"engines": {
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { test, describe } from 'node:test'
|
|
2
|
+
import { strict as assert } from 'node:assert'
|
|
3
|
+
import {
|
|
4
|
+
mkdtempSync,
|
|
5
|
+
rmSync,
|
|
6
|
+
statSync,
|
|
7
|
+
utimesSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from 'node:fs'
|
|
10
|
+
import { tmpdir } from 'node:os'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
import {
|
|
13
|
+
readDiskTSSchemas,
|
|
14
|
+
writeDiskTSSchemas,
|
|
15
|
+
type SchemaDep,
|
|
16
|
+
} from './schema-generator.js'
|
|
17
|
+
import type { InspectorLogger } from '../types.js'
|
|
18
|
+
|
|
19
|
+
const silentLogger = {
|
|
20
|
+
debug: () => {},
|
|
21
|
+
info: () => {},
|
|
22
|
+
warn: () => {},
|
|
23
|
+
error: () => {},
|
|
24
|
+
} as unknown as InspectorLogger
|
|
25
|
+
|
|
26
|
+
const KEY = 'test-key'
|
|
27
|
+
const SCHEMAS = { Thing: { type: 'object' } }
|
|
28
|
+
|
|
29
|
+
/** A cache directory and one source file the cached schemas claim to derive from. */
|
|
30
|
+
function makeCache(sourceContent: string): {
|
|
31
|
+
cacheDir: string
|
|
32
|
+
source: string
|
|
33
|
+
deps: SchemaDep[]
|
|
34
|
+
cleanup: () => void
|
|
35
|
+
} {
|
|
36
|
+
const dir = mkdtempSync(join(tmpdir(), 'pikku-schema-cache-'))
|
|
37
|
+
const source = join(dir, 'thing.ts')
|
|
38
|
+
writeFileSync(source, sourceContent)
|
|
39
|
+
const stat = statSync(source)
|
|
40
|
+
return {
|
|
41
|
+
cacheDir: dir,
|
|
42
|
+
source,
|
|
43
|
+
deps: [{ path: source, mtimeMs: stat.mtimeMs, size: stat.size }],
|
|
44
|
+
cleanup: () => rmSync(dir, { recursive: true, force: true }),
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('TS schema disk cache', () => {
|
|
49
|
+
test('serves the cached schemas while their sources are untouched', () => {
|
|
50
|
+
const { cacheDir, deps, cleanup } = makeCache('export interface Thing {}')
|
|
51
|
+
try {
|
|
52
|
+
writeDiskTSSchemas(silentLogger, cacheDir, KEY, SCHEMAS, deps)
|
|
53
|
+
const hit = readDiskTSSchemas(silentLogger, cacheDir, KEY)
|
|
54
|
+
assert.deepEqual(hit?.schemas, SCHEMAS)
|
|
55
|
+
} finally {
|
|
56
|
+
cleanup()
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('discards the cache when a type it was built from changes', () => {
|
|
61
|
+
const { cacheDir, source, deps, cleanup } = makeCache(
|
|
62
|
+
'export interface Thing {}'
|
|
63
|
+
)
|
|
64
|
+
try {
|
|
65
|
+
writeDiskTSSchemas(silentLogger, cacheDir, KEY, SCHEMAS, deps)
|
|
66
|
+
// The shape changes but the name does not, so the cache key — which hashes
|
|
67
|
+
// only the synthesized custom-types source — is identical. This is the case
|
|
68
|
+
// that used to serve a stale schema, and it survived `rm -rf .pikku`
|
|
69
|
+
// because the cache lives under node_modules/.cache.
|
|
70
|
+
writeFileSync(source, 'export interface Thing { added: string }')
|
|
71
|
+
assert.equal(readDiskTSSchemas(silentLogger, cacheDir, KEY), null)
|
|
72
|
+
} finally {
|
|
73
|
+
cleanup()
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('discards the cache when a source is rewritten to the same size', () => {
|
|
78
|
+
const { cacheDir, source, deps, cleanup } = makeCache('export type A = 1')
|
|
79
|
+
try {
|
|
80
|
+
writeDiskTSSchemas(silentLogger, cacheDir, KEY, SCHEMAS, deps)
|
|
81
|
+
// Same byte count, different type: size alone would call this unchanged, so
|
|
82
|
+
// the mtime half of the fingerprint is what has to catch it.
|
|
83
|
+
writeFileSync(source, 'export type A = 2')
|
|
84
|
+
utimesSync(source, new Date(), new Date(Date.now() + 5000))
|
|
85
|
+
assert.equal(readDiskTSSchemas(silentLogger, cacheDir, KEY), null)
|
|
86
|
+
} finally {
|
|
87
|
+
cleanup()
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('discards the cache when a source it was built from is deleted', () => {
|
|
92
|
+
const { cacheDir, source, deps, cleanup } = makeCache('export type A = 1')
|
|
93
|
+
try {
|
|
94
|
+
writeDiskTSSchemas(silentLogger, cacheDir, KEY, SCHEMAS, deps)
|
|
95
|
+
rmSync(source)
|
|
96
|
+
assert.equal(readDiskTSSchemas(silentLogger, cacheDir, KEY), null)
|
|
97
|
+
} finally {
|
|
98
|
+
cleanup()
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('discards a cache written before deps were tracked', () => {
|
|
103
|
+
const { cacheDir, cleanup } = makeCache('export type A = 1')
|
|
104
|
+
try {
|
|
105
|
+
writeFileSync(
|
|
106
|
+
join(cacheDir, 'ts-schemas.json'),
|
|
107
|
+
JSON.stringify({ key: KEY, schemas: SCHEMAS })
|
|
108
|
+
)
|
|
109
|
+
assert.equal(readDiskTSSchemas(silentLogger, cacheDir, KEY), null)
|
|
110
|
+
} finally {
|
|
111
|
+
cleanup()
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('still misses on a different key', () => {
|
|
116
|
+
const { cacheDir, deps, cleanup } = makeCache('export type A = 1')
|
|
117
|
+
try {
|
|
118
|
+
writeDiskTSSchemas(silentLogger, cacheDir, KEY, SCHEMAS, deps)
|
|
119
|
+
assert.equal(readDiskTSSchemas(silentLogger, cacheDir, 'other'), null)
|
|
120
|
+
} finally {
|
|
121
|
+
cleanup()
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
})
|
|
@@ -5,7 +5,7 @@ import { tmpdir } from 'os'
|
|
|
5
5
|
import { join } from 'path'
|
|
6
6
|
|
|
7
7
|
import { getInitialInspectorState } from '../inspector.js'
|
|
8
|
-
import { generateAllSchemas } from './schema-generator.js'
|
|
8
|
+
import { generateAllSchemas, schemaRuntimeFile } from './schema-generator.js'
|
|
9
9
|
|
|
10
10
|
const debugLines: string[] = []
|
|
11
11
|
const logger = {
|
|
@@ -162,3 +162,64 @@ describe('generateAllSchemas', () => {
|
|
|
162
162
|
)
|
|
163
163
|
})
|
|
164
164
|
})
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* A schema imported from a built workspace package resolves to that package's
|
|
168
|
+
* declaration file, and a `.d.ts` has no runtime exports at all — every one of
|
|
169
|
+
* them is a type. Importing it to read the zod object yields an empty module and
|
|
170
|
+
* every schema in it is reported missing, so the emitted JS beside it is what has
|
|
171
|
+
* to be imported.
|
|
172
|
+
*/
|
|
173
|
+
describe('schemaRuntimeFile', () => {
|
|
174
|
+
test('a source file the developer wrote is imported as it is', () => {
|
|
175
|
+
const written = join(projectDir, 'src', 'types.ts')
|
|
176
|
+
assert.equal(schemaRuntimeFile(written), written)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
test('a declaration file resolves to the JS emitted beside it', () => {
|
|
180
|
+
writeFileSync(
|
|
181
|
+
join(projectDir, 'src', 'reindex.d.ts'),
|
|
182
|
+
'export declare const a: number\n'
|
|
183
|
+
)
|
|
184
|
+
writeFileSync(join(projectDir, 'src', 'reindex.js'), 'export const a = 1\n')
|
|
185
|
+
assert.equal(
|
|
186
|
+
schemaRuntimeFile(join(projectDir, 'src', 'reindex.d.ts')),
|
|
187
|
+
join(projectDir, 'src', 'reindex.js')
|
|
188
|
+
)
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
test('.d.mts resolves to the ESM file beside it', () => {
|
|
192
|
+
writeFileSync(
|
|
193
|
+
join(projectDir, 'src', 'esm.d.mts'),
|
|
194
|
+
'export declare const a: number\n'
|
|
195
|
+
)
|
|
196
|
+
writeFileSync(join(projectDir, 'src', 'esm.mjs'), 'export const a = 1\n')
|
|
197
|
+
assert.equal(
|
|
198
|
+
schemaRuntimeFile(join(projectDir, 'src', 'esm.d.mts')),
|
|
199
|
+
join(projectDir, 'src', 'esm.mjs')
|
|
200
|
+
)
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
test('.d.cts resolves to the CommonJS file beside it', () => {
|
|
204
|
+
// Its own test rather than a second assertion above: the two extensions are
|
|
205
|
+
// separate branches, and one covering both is one that passes while half of
|
|
206
|
+
// it is broken.
|
|
207
|
+
writeFileSync(
|
|
208
|
+
join(projectDir, 'src', 'commonjs.d.cts'),
|
|
209
|
+
'export declare const a: number\n'
|
|
210
|
+
)
|
|
211
|
+
writeFileSync(join(projectDir, 'src', 'commonjs.cjs'), 'exports.a = 1\n')
|
|
212
|
+
assert.equal(
|
|
213
|
+
schemaRuntimeFile(join(projectDir, 'src', 'commonjs.d.cts')),
|
|
214
|
+
join(projectDir, 'src', 'commonjs.cjs')
|
|
215
|
+
)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
test('a declaration file with no emitted JS keeps its own path', () => {
|
|
219
|
+
// So the "could not find exported schema" error still names a file the
|
|
220
|
+
// developer can open, rather than one that was never written.
|
|
221
|
+
const orphan = join(projectDir, 'src', 'types-only.d.ts')
|
|
222
|
+
writeFileSync(orphan, 'export declare const a: number\n')
|
|
223
|
+
assert.equal(schemaRuntimeFile(orphan), orphan)
|
|
224
|
+
})
|
|
225
|
+
})
|