@pikku/inspector 0.12.43 → 0.12.44
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 +168 -0
- package/dist/add/add-credential.js +2 -0
- package/dist/add/add-keyed-wiring.js +2 -0
- package/dist/add/add-workflow.js +9 -4
- package/dist/utils/schema-generator.js +19 -4
- package/dist/utils/workflow/dsl/deserialize-dsl-workflow.js +373 -91
- package/dist/utils/workflow/dsl/extract-dsl-workflow.js +218 -99
- package/dist/utils/workflow/graph/convert-dsl-to-graph.js +62 -5
- package/dist/utils/workflow/graph/workflow-graph.types.d.ts +3 -3
- package/package.json +3 -3
- package/run-tests.sh +1 -1
- package/src/add/add-credential.ts +2 -0
- package/src/add/add-keyed-wiring.ts +2 -0
- package/src/add/add-workflow-edge-cases.test.ts +201 -0
- package/src/add/add-workflow-fanout.test.ts +60 -0
- package/src/add/add-workflow-on-error.test.ts +118 -0
- package/src/add/add-workflow-return-switch.test.ts +121 -0
- package/src/add/add-workflow-silent-drops.test.ts +163 -0
- package/src/add/add-workflow-sleep-expression.test.ts +97 -0
- package/src/add/add-workflow.ts +9 -4
- package/src/add/config-value-metadata.test.ts +169 -0
- package/src/utils/schema-generator.test.ts +164 -0
- package/src/utils/schema-generator.ts +26 -3
- package/src/utils/workflow/dsl/deserialize-dsl-workflow.ts +480 -119
- package/src/utils/workflow/dsl/deserialize-fanout-body.test.ts +71 -0
- package/src/utils/workflow/dsl/deserialize-fanout-fidelity.test.ts +99 -0
- package/src/utils/workflow/dsl/deserialize-graph-next.test.ts +161 -0
- package/src/utils/workflow/dsl/deserialize-roundtrip-bindings.test.ts +121 -0
- package/src/utils/workflow/dsl/deserialize-roundtrip-values.test.ts +116 -0
- package/src/utils/workflow/dsl/deserialize-roundtrip.test.ts +145 -0
- package/src/utils/workflow/dsl/extract-dsl-workflow.ts +245 -105
- package/src/utils/workflow/graph/convert-dsl-to-graph.ts +67 -10
- package/src/utils/workflow/graph/fanout-node-identity.test.ts +105 -0
- package/src/utils/workflow/graph/workflow-graph.types.ts +3 -2
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { strict as assert } from 'assert'
|
|
2
|
+
import { describe, test } from 'node:test'
|
|
3
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { inspect } from '../inspector.js'
|
|
7
|
+
import type { InspectorLogger } from '../types.js'
|
|
8
|
+
|
|
9
|
+
const STEPS = [
|
|
10
|
+
"import { pikkuSessionlessFunc } from '@pikku/core'",
|
|
11
|
+
"export const notify = pikkuSessionlessFunc({ func: async () => ({ id: 'n' }) })",
|
|
12
|
+
].join('\n')
|
|
13
|
+
|
|
14
|
+
async function run(body: string) {
|
|
15
|
+
const diags: string[] = []
|
|
16
|
+
const logger = {
|
|
17
|
+
debug: () => {},
|
|
18
|
+
info: () => {},
|
|
19
|
+
warn: () => {},
|
|
20
|
+
error: () => {},
|
|
21
|
+
diagnostic: (...a: any[]) => diags.push(a.map(String).join(' ')),
|
|
22
|
+
critical: (...a: any[]) => diags.push(a.map(String).join(' ')),
|
|
23
|
+
hasCriticalErrors: () => false,
|
|
24
|
+
} as unknown as InspectorLogger
|
|
25
|
+
|
|
26
|
+
const rootDir = await mkdtemp(join(tmpdir(), 'pikku-sleep-'))
|
|
27
|
+
const stepFile = join(rootDir, 'w.steps.ts')
|
|
28
|
+
const wfFile = join(rootDir, 'w.workflow.ts')
|
|
29
|
+
await writeFile(stepFile, STEPS)
|
|
30
|
+
await writeFile(
|
|
31
|
+
wfFile,
|
|
32
|
+
[
|
|
33
|
+
"import { pikkuWorkflowFunc } from '@pikku/core/workflow'",
|
|
34
|
+
'export const wf = pikkuWorkflowFunc(async (_, data: any, { workflow }) => {',
|
|
35
|
+
body,
|
|
36
|
+
'})',
|
|
37
|
+
].join('\n')
|
|
38
|
+
)
|
|
39
|
+
try {
|
|
40
|
+
const state: any = await inspect(logger, [stepFile, wfFile], { rootDir })
|
|
41
|
+
const gm = state.workflows.graphMeta
|
|
42
|
+
const graph = (gm instanceof Map ? [...gm.values()] : Object.values(gm))[0]
|
|
43
|
+
return { graph: graph as any, diags }
|
|
44
|
+
} finally {
|
|
45
|
+
await rm(rootDir, { recursive: true, force: true })
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('sleep with a duration that is not a literal', () => {
|
|
50
|
+
test('a variable duration inside a loop is kept, not rejected', async () => {
|
|
51
|
+
const { graph, diags } = await run(
|
|
52
|
+
[
|
|
53
|
+
' for (const interval of data.intervals) {',
|
|
54
|
+
' await workflow.sleep(`Wait ${interval}`, interval)',
|
|
55
|
+
" await workflow.do('Notify', 'notify', {})",
|
|
56
|
+
' }',
|
|
57
|
+
' return { sent: true }',
|
|
58
|
+
].join('\n')
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
assert.deepEqual(
|
|
62
|
+
diags.filter((d) => d.includes('sleep')),
|
|
63
|
+
[],
|
|
64
|
+
'a duration only known at runtime is legal — the closure evaluates it'
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
const sleepNode = Object.values(graph?.nodes ?? {}).find(
|
|
68
|
+
(n: any) => n.flow === 'sleep'
|
|
69
|
+
) as any
|
|
70
|
+
assert.ok(sleepNode, 'the sleep must still appear in the graph')
|
|
71
|
+
assert.equal(
|
|
72
|
+
sleepNode.expression,
|
|
73
|
+
'interval',
|
|
74
|
+
'its source text is recorded so the graph can show what it waits on'
|
|
75
|
+
)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('a literal duration still round-trips as a value', async () => {
|
|
79
|
+
const { graph, diags } = await run(
|
|
80
|
+
[
|
|
81
|
+
" await workflow.sleep('Wait', '5s')",
|
|
82
|
+
' return { sent: true }',
|
|
83
|
+
].join('\n')
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
assert.deepEqual(diags, [])
|
|
87
|
+
const sleepNode = Object.values(graph?.nodes ?? {}).find(
|
|
88
|
+
(n: any) => n.flow === 'sleep'
|
|
89
|
+
) as any
|
|
90
|
+
assert.equal(sleepNode?.duration, '5s')
|
|
91
|
+
assert.equal(
|
|
92
|
+
sleepNode?.expression,
|
|
93
|
+
undefined,
|
|
94
|
+
'a literal is a value, not an expression'
|
|
95
|
+
)
|
|
96
|
+
})
|
|
97
|
+
})
|
package/src/add/add-workflow.ts
CHANGED
|
@@ -73,8 +73,8 @@ function hasInlineSteps(steps: WorkflowStepMeta[]): boolean {
|
|
|
73
73
|
if (c.steps && hasInlineSteps(c.steps)) return true
|
|
74
74
|
}
|
|
75
75
|
if (step.defaultSteps && hasInlineSteps(step.defaultSteps)) return true
|
|
76
|
-
} else if (step.type === 'fanout' && step.
|
|
77
|
-
if (hasInlineSteps(
|
|
76
|
+
} else if (step.type === 'fanout' && step.body) {
|
|
77
|
+
if (hasInlineSteps(step.body)) return true
|
|
78
78
|
} else if (step.type === 'parallel' && step.children) {
|
|
79
79
|
if (hasInlineSteps(step.children)) return true
|
|
80
80
|
}
|
|
@@ -92,6 +92,11 @@ export function collectInvokedRPCs(
|
|
|
92
92
|
for (const step of steps) {
|
|
93
93
|
if (step.type === 'rpc' && step.rpcName) {
|
|
94
94
|
rpcs.add(step.rpcName)
|
|
95
|
+
// The compensation handler is invoked at runtime too, so it has to be
|
|
96
|
+
// wired like any other step.
|
|
97
|
+
if (step.options?.onError) {
|
|
98
|
+
rpcs.add(step.options.onError)
|
|
99
|
+
}
|
|
95
100
|
} else if (step.type === 'branch') {
|
|
96
101
|
for (const branch of step.branches) {
|
|
97
102
|
collectInvokedRPCs(branch.steps, rpcs)
|
|
@@ -102,8 +107,8 @@ export function collectInvokedRPCs(
|
|
|
102
107
|
if (c.steps) collectInvokedRPCs(c.steps, rpcs)
|
|
103
108
|
}
|
|
104
109
|
if (step.defaultSteps) collectInvokedRPCs(step.defaultSteps, rpcs)
|
|
105
|
-
} else if (step.type === 'fanout' && step.
|
|
106
|
-
collectInvokedRPCs(
|
|
110
|
+
} else if (step.type === 'fanout' && step.body) {
|
|
111
|
+
collectInvokedRPCs(step.body, rpcs)
|
|
107
112
|
} else if (step.type === 'parallel' && step.children) {
|
|
108
113
|
collectInvokedRPCs(step.children, rpcs)
|
|
109
114
|
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { strict as assert } from 'assert'
|
|
2
|
+
import { describe, test } from 'node:test'
|
|
3
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
import { inspect } from '../inspector.js'
|
|
7
|
+
import type { ErrorCode } from '../error-codes.js'
|
|
8
|
+
import type { InspectorLogger } from '../types.js'
|
|
9
|
+
|
|
10
|
+
// Fixtures must sit inside the package so `zod` resolves — the schema-vendor
|
|
11
|
+
// check rejects a schema it cannot trace to a supported library, and the
|
|
12
|
+
// definition is then never recorded at all.
|
|
13
|
+
const fixtureRoot = join(
|
|
14
|
+
fileURLToPath(new URL('.', import.meta.url)),
|
|
15
|
+
'__fixtures'
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
const makeLogger = (criticals: Array<{ code: ErrorCode; message: string }>) =>
|
|
19
|
+
({
|
|
20
|
+
debug: () => {},
|
|
21
|
+
info: () => {},
|
|
22
|
+
warn: () => {},
|
|
23
|
+
error: () => {},
|
|
24
|
+
diagnostic: ({ code, message }) => {
|
|
25
|
+
criticals.push({ code, message })
|
|
26
|
+
},
|
|
27
|
+
critical: (code: ErrorCode, message: string) => {
|
|
28
|
+
criticals.push({ code, message })
|
|
29
|
+
},
|
|
30
|
+
hasCriticalErrors: () => criticals.length > 0,
|
|
31
|
+
}) satisfies InspectorLogger
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* `docsUrl` is only useful if it survives inspection — a field declared on the
|
|
35
|
+
* wiring but dropped by the inspector is invisible to every consumer
|
|
36
|
+
* downstream, which is exactly how `displayName` went unused.
|
|
37
|
+
*/
|
|
38
|
+
describe('config value metadata (docsUrl)', () => {
|
|
39
|
+
test('carries docsUrl from wireSecret into the definition', async () => {
|
|
40
|
+
const rootDir = await mkdtemp(`${fixtureRoot}-secret-`)
|
|
41
|
+
const file = join(rootDir, 'secret.ts')
|
|
42
|
+
|
|
43
|
+
await writeFile(
|
|
44
|
+
file,
|
|
45
|
+
[
|
|
46
|
+
"import { wireSecret } from '@pikku/core'",
|
|
47
|
+
"import { z } from 'zod'",
|
|
48
|
+
'export const StripeKeySchema = z.string()',
|
|
49
|
+
'wireSecret({',
|
|
50
|
+
" name: 'stripeKey',",
|
|
51
|
+
" displayName: 'Stripe Secret Key',",
|
|
52
|
+
" secretId: 'STRIPE_SECRET_KEY',",
|
|
53
|
+
" docsUrl: 'https://dashboard.stripe.com/apikeys',",
|
|
54
|
+
' schema: StripeKeySchema,',
|
|
55
|
+
'})',
|
|
56
|
+
].join('\n')
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
const criticals: Array<{ code: ErrorCode; message: string }> = []
|
|
60
|
+
try {
|
|
61
|
+
const state = await inspect(makeLogger(criticals), [file], { rootDir })
|
|
62
|
+
const def = state.secrets.definitions.find(
|
|
63
|
+
(d) => d.secretId === 'STRIPE_SECRET_KEY'
|
|
64
|
+
)
|
|
65
|
+
assert.ok(def, 'expected the secret definition to be inspected')
|
|
66
|
+
assert.equal(def!.docsUrl, 'https://dashboard.stripe.com/apikeys')
|
|
67
|
+
} finally {
|
|
68
|
+
await rm(rootDir, { recursive: true, force: true })
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('leaves docsUrl undefined when not declared', async () => {
|
|
73
|
+
const rootDir = await mkdtemp(`${fixtureRoot}-bare-`)
|
|
74
|
+
const file = join(rootDir, 'secret.ts')
|
|
75
|
+
|
|
76
|
+
await writeFile(
|
|
77
|
+
file,
|
|
78
|
+
[
|
|
79
|
+
"import { wireSecret } from '@pikku/core'",
|
|
80
|
+
"import { z } from 'zod'",
|
|
81
|
+
'export const ApiKeySchema = z.string()',
|
|
82
|
+
'wireSecret({',
|
|
83
|
+
" name: 'apiKey',",
|
|
84
|
+
" displayName: 'API Key',",
|
|
85
|
+
" secretId: 'API_KEY',",
|
|
86
|
+
' schema: ApiKeySchema,',
|
|
87
|
+
'})',
|
|
88
|
+
].join('\n')
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
const criticals: Array<{ code: ErrorCode; message: string }> = []
|
|
92
|
+
try {
|
|
93
|
+
const state = await inspect(makeLogger(criticals), [file], { rootDir })
|
|
94
|
+
const def = state.secrets.definitions.find(
|
|
95
|
+
(d) => d.secretId === 'API_KEY'
|
|
96
|
+
)
|
|
97
|
+
assert.ok(def, 'expected the secret definition to be inspected')
|
|
98
|
+
assert.equal(def!.docsUrl, undefined)
|
|
99
|
+
} finally {
|
|
100
|
+
await rm(rootDir, { recursive: true, force: true })
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('carries docsUrl from wireVariable into the definition', async () => {
|
|
105
|
+
const rootDir = await mkdtemp(`${fixtureRoot}-variable-`)
|
|
106
|
+
const file = join(rootDir, 'variable.ts')
|
|
107
|
+
|
|
108
|
+
await writeFile(
|
|
109
|
+
file,
|
|
110
|
+
[
|
|
111
|
+
"import { wireVariable } from '@pikku/core'",
|
|
112
|
+
"import { z } from 'zod'",
|
|
113
|
+
'export const ConsoleUrlSchema = z.string()',
|
|
114
|
+
'wireVariable({',
|
|
115
|
+
" name: 'consoleUrl',",
|
|
116
|
+
" displayName: 'Console URL',",
|
|
117
|
+
" variableId: 'CONSOLE_URL',",
|
|
118
|
+
" docsUrl: 'https://example.com/docs/console-url',",
|
|
119
|
+
' schema: ConsoleUrlSchema,',
|
|
120
|
+
'})',
|
|
121
|
+
].join('\n')
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
const criticals: Array<{ code: ErrorCode; message: string }> = []
|
|
125
|
+
try {
|
|
126
|
+
const state = await inspect(makeLogger(criticals), [file], { rootDir })
|
|
127
|
+
const def = state.variables.definitions.find(
|
|
128
|
+
(d) => d.variableId === 'CONSOLE_URL'
|
|
129
|
+
)
|
|
130
|
+
assert.ok(def, 'expected the variable definition to be inspected')
|
|
131
|
+
assert.equal(def!.docsUrl, 'https://example.com/docs/console-url')
|
|
132
|
+
} finally {
|
|
133
|
+
await rm(rootDir, { recursive: true, force: true })
|
|
134
|
+
}
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test('carries docsUrl from wireCredential into the definition', async () => {
|
|
138
|
+
const rootDir = await mkdtemp(`${fixtureRoot}-credential-`)
|
|
139
|
+
const file = join(rootDir, 'credential.ts')
|
|
140
|
+
|
|
141
|
+
await writeFile(
|
|
142
|
+
file,
|
|
143
|
+
[
|
|
144
|
+
"import { wireCredential } from '@pikku/core'",
|
|
145
|
+
"import { z } from 'zod'",
|
|
146
|
+
'export const TokenSchema = z.object({ token: z.string() })',
|
|
147
|
+
'wireCredential({',
|
|
148
|
+
" name: 'githubToken',",
|
|
149
|
+
" displayName: 'GitHub Token',",
|
|
150
|
+
" type: 'wire',",
|
|
151
|
+
" docsUrl: 'https://github.com/settings/tokens',",
|
|
152
|
+
' schema: TokenSchema,',
|
|
153
|
+
'})',
|
|
154
|
+
].join('\n')
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
const criticals: Array<{ code: ErrorCode; message: string }> = []
|
|
158
|
+
try {
|
|
159
|
+
const state = await inspect(makeLogger(criticals), [file], { rootDir })
|
|
160
|
+
const def = state.credentials.definitions.find(
|
|
161
|
+
(d) => d.name === 'githubToken'
|
|
162
|
+
)
|
|
163
|
+
assert.ok(def, 'expected the credential definition to be inspected')
|
|
164
|
+
assert.equal(def!.docsUrl, 'https://github.com/settings/tokens')
|
|
165
|
+
} finally {
|
|
166
|
+
await rm(rootDir, { recursive: true, force: true })
|
|
167
|
+
}
|
|
168
|
+
})
|
|
169
|
+
})
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { test, describe, before, after } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs'
|
|
4
|
+
import { tmpdir } from 'os'
|
|
5
|
+
import { join } from 'path'
|
|
6
|
+
|
|
7
|
+
import { getInitialInspectorState } from '../inspector.js'
|
|
8
|
+
import { generateAllSchemas } from './schema-generator.js'
|
|
9
|
+
|
|
10
|
+
const debugLines: string[] = []
|
|
11
|
+
const logger = {
|
|
12
|
+
debug(message: string) {
|
|
13
|
+
debugLines.push(message)
|
|
14
|
+
},
|
|
15
|
+
info() {},
|
|
16
|
+
warn() {},
|
|
17
|
+
error() {},
|
|
18
|
+
} as any
|
|
19
|
+
|
|
20
|
+
const UNRELATED_FILE_COUNT = 300
|
|
21
|
+
|
|
22
|
+
let projectDir: string
|
|
23
|
+
|
|
24
|
+
before(() => {
|
|
25
|
+
projectDir = mkdtempSync(join(tmpdir(), 'pikku-schema-gen-'))
|
|
26
|
+
mkdirSync(join(projectDir, 'src'))
|
|
27
|
+
writeFileSync(
|
|
28
|
+
join(projectDir, 'tsconfig.json'),
|
|
29
|
+
JSON.stringify({
|
|
30
|
+
compilerOptions: {
|
|
31
|
+
target: 'ESNext',
|
|
32
|
+
module: 'NodeNext',
|
|
33
|
+
moduleResolution: 'NodeNext',
|
|
34
|
+
strict: true,
|
|
35
|
+
skipLibCheck: true,
|
|
36
|
+
},
|
|
37
|
+
include: ['src/**/*.ts'],
|
|
38
|
+
})
|
|
39
|
+
)
|
|
40
|
+
writeFileSync(
|
|
41
|
+
join(projectDir, 'src', 'types.ts'),
|
|
42
|
+
'export type Thing = { id: string; count: number }\n'
|
|
43
|
+
)
|
|
44
|
+
// Files the tsconfig picks up but no generated custom type references. They
|
|
45
|
+
// stand in for the bulk of a real project, which the schema program has no
|
|
46
|
+
// reason to load.
|
|
47
|
+
for (let i = 0; i < UNRELATED_FILE_COUNT; i += 1) {
|
|
48
|
+
writeFileSync(
|
|
49
|
+
join(projectDir, 'src', `unrelated-${i}.ts`),
|
|
50
|
+
`export type Unrelated${i} = { a: string; b: number; c: boolean }\n`
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
after(() => {
|
|
56
|
+
rmSync(projectDir, { recursive: true, force: true })
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('generateAllSchemas', () => {
|
|
60
|
+
// NOTE: this must be the first test in the file. The module-level caches are
|
|
61
|
+
// per-process, so only the first call actually builds a program — a later call
|
|
62
|
+
// reuses it and would measure a few MB no matter what.
|
|
63
|
+
//
|
|
64
|
+
// The schema generator builds a second ts.Program over the whole tsconfig —
|
|
65
|
+
// on a real tree that is ~2.5k files and ~600MB, and it used to be pinned at
|
|
66
|
+
// module scope for the life of the process. Combined with the main inspector
|
|
67
|
+
// program it pushed peak heap past 2GB and OOM'd `pikku all` (#982).
|
|
68
|
+
//
|
|
69
|
+
// Only the program is released; cachedTSSchemas (the small result cache that
|
|
70
|
+
// powers the same-process fast path) is deliberately kept.
|
|
71
|
+
test('does not retain the schema ts.Program after returning', async (t) => {
|
|
72
|
+
if (typeof global.gc !== 'function') {
|
|
73
|
+
t.skip('requires --expose-gc')
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const state = getInitialInspectorState(projectDir) as any
|
|
78
|
+
// A distinct type keeps this off the cachedTSSchemas fast path, so the
|
|
79
|
+
// program is genuinely rebuilt (and therefore genuinely retainable) here.
|
|
80
|
+
state.functions.typesMap.addCustomType(
|
|
81
|
+
'RetentionProbeInput',
|
|
82
|
+
'{ probe: string; n: number }',
|
|
83
|
+
[]
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
global.gc!()
|
|
87
|
+
const before = process.memoryUsage().heapUsed
|
|
88
|
+
|
|
89
|
+
await generateAllSchemas(
|
|
90
|
+
logger,
|
|
91
|
+
{ tsconfig: join(projectDir, 'tsconfig.json') },
|
|
92
|
+
state
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
global.gc!()
|
|
96
|
+
const retainedMB = (process.memoryUsage().heapUsed - before) / 1024 / 1024
|
|
97
|
+
|
|
98
|
+
// A retained program (with its SourceFiles and TypeChecker) measures tens of
|
|
99
|
+
// MB even for this one-file fixture; the schemas themselves are a few KB.
|
|
100
|
+
assert.ok(
|
|
101
|
+
retainedMB < 10,
|
|
102
|
+
`expected the schema program to be released, but ${retainedMB.toFixed(1)}MB is still retained after GC`
|
|
103
|
+
)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
// The virtual file explicitly imports every type it needs, so it is a complete
|
|
107
|
+
// root on its own. Rooting the program at the whole tsconfig instead made it
|
|
108
|
+
// load the entire project (2572 files on a real tree vs 870), for no gain in
|
|
109
|
+
// what could be resolved (#982).
|
|
110
|
+
test('scopes the schema program to the virtual file import closure', async () => {
|
|
111
|
+
debugLines.length = 0
|
|
112
|
+
|
|
113
|
+
const state = getInitialInspectorState(projectDir) as any
|
|
114
|
+
state.functions.typesMap.addCustomType(
|
|
115
|
+
'ScopingProbeInput',
|
|
116
|
+
'{ scope: string }',
|
|
117
|
+
[]
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
await generateAllSchemas(
|
|
121
|
+
logger,
|
|
122
|
+
{ tsconfig: join(projectDir, 'tsconfig.json') },
|
|
123
|
+
state
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
const line = debugLines.find((l) => l.includes('Created schema program'))
|
|
127
|
+
assert.ok(
|
|
128
|
+
line,
|
|
129
|
+
`expected a "Created schema program" debug line, got: ${debugLines.join(' | ')}`
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
const fileCount = Number(line!.match(/(\d+) files/)?.[1])
|
|
133
|
+
assert.ok(
|
|
134
|
+
Number.isFinite(fileCount),
|
|
135
|
+
`could not parse a file count out of: ${line}`
|
|
136
|
+
)
|
|
137
|
+
// The virtual file plus types.ts plus the default lib. Rooting at the whole
|
|
138
|
+
// tsconfig would pull in all UNRELATED_FILE_COUNT files on top of that.
|
|
139
|
+
assert.ok(
|
|
140
|
+
fileCount < UNRELATED_FILE_COUNT,
|
|
141
|
+
`expected the schema program to skip the ${UNRELATED_FILE_COUNT} unreferenced files, but it loaded ${fileCount}`
|
|
142
|
+
)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test('produces a schema for a custom type', async () => {
|
|
146
|
+
const state = getInitialInspectorState(projectDir) as any
|
|
147
|
+
state.functions.typesMap.addCustomType(
|
|
148
|
+
'ThingInput',
|
|
149
|
+
'{ id: string; count: number }',
|
|
150
|
+
[]
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
const schemas = await generateAllSchemas(
|
|
154
|
+
logger,
|
|
155
|
+
{ tsconfig: join(projectDir, 'tsconfig.json') },
|
|
156
|
+
state
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
assert.ok(
|
|
160
|
+
Object.keys(schemas).length > 0,
|
|
161
|
+
'expected at least one generated schema'
|
|
162
|
+
)
|
|
163
|
+
})
|
|
164
|
+
})
|
|
@@ -87,7 +87,9 @@ const inspectorVersion: string = (() => {
|
|
|
87
87
|
try {
|
|
88
88
|
const pkgUrl = new URL('../../package.json', import.meta.url)
|
|
89
89
|
const pkg = JSON.parse(readFileSync(pkgUrl, 'utf-8'))
|
|
90
|
-
return typeof pkg.version === 'string'
|
|
90
|
+
return typeof pkg.version === 'string'
|
|
91
|
+
? pkg.version
|
|
92
|
+
: `v${SCHEMA_CACHE_VERSION}`
|
|
91
93
|
} catch {
|
|
92
94
|
return `v${SCHEMA_CACHE_VERSION}`
|
|
93
95
|
}
|
|
@@ -98,7 +100,10 @@ const inspectorVersion: string = (() => {
|
|
|
98
100
|
// inspector version (schema-format changes ship with a version bump).
|
|
99
101
|
function tsSchemaCacheKey(
|
|
100
102
|
customTypesContent: string,
|
|
101
|
-
config: {
|
|
103
|
+
config: {
|
|
104
|
+
schemasFromTypes?: string[]
|
|
105
|
+
schema?: { additionalProperties?: boolean }
|
|
106
|
+
}
|
|
102
107
|
): string {
|
|
103
108
|
return createHash('sha1')
|
|
104
109
|
.update(`v${SCHEMA_CACHE_VERSION}\0`)
|
|
@@ -145,6 +150,7 @@ function writeDiskTSSchemas(
|
|
|
145
150
|
}
|
|
146
151
|
|
|
147
152
|
function createProgramWithVirtualFile(
|
|
153
|
+
logger: InspectorLogger,
|
|
148
154
|
tsconfig: string,
|
|
149
155
|
virtualFilePath: string,
|
|
150
156
|
virtualFileContent: string
|
|
@@ -165,7 +171,11 @@ function createProgramWithVirtualFile(
|
|
|
165
171
|
}
|
|
166
172
|
|
|
167
173
|
const resolvedVirtualPath = resolve(virtualFilePath)
|
|
168
|
-
|
|
174
|
+
// The virtual file imports every type it references, so it is a complete root
|
|
175
|
+
// on its own and TypeScript pulls in exactly the transitive closure it needs.
|
|
176
|
+
// Rooting at cachedParsedConfig.fileNames instead loaded the whole project
|
|
177
|
+
// (2572 files vs 870 on a real tree) without widening what could be resolved.
|
|
178
|
+
const fileNames = [resolvedVirtualPath]
|
|
169
179
|
|
|
170
180
|
const defaultHost = ts.createCompilerHost(cachedParsedConfig.options)
|
|
171
181
|
const customHost: ts.CompilerHost = {
|
|
@@ -200,6 +210,7 @@ function createProgramWithVirtualFile(
|
|
|
200
210
|
},
|
|
201
211
|
}
|
|
202
212
|
|
|
213
|
+
const startProgram = performance.now()
|
|
203
214
|
const program = ts.createProgram(
|
|
204
215
|
fileNames,
|
|
205
216
|
cachedParsedConfig.options,
|
|
@@ -207,6 +218,9 @@ function createProgramWithVirtualFile(
|
|
|
207
218
|
cachedSchemaProgram // reuse previous program for incremental compilation
|
|
208
219
|
)
|
|
209
220
|
cachedSchemaProgram = program
|
|
221
|
+
logger.debug(
|
|
222
|
+
`Created schema program in ${(performance.now() - startProgram).toFixed(0)}ms (${program.getSourceFiles().length} files)`
|
|
223
|
+
)
|
|
210
224
|
return program
|
|
211
225
|
}
|
|
212
226
|
|
|
@@ -274,6 +288,7 @@ function generateTSSchemas(
|
|
|
274
288
|
'__pikku_virtual_types__.ts'
|
|
275
289
|
)
|
|
276
290
|
const program = createProgramWithVirtualFile(
|
|
291
|
+
logger,
|
|
277
292
|
tsconfig,
|
|
278
293
|
virtualFilePath,
|
|
279
294
|
customTypesContent
|
|
@@ -614,6 +629,14 @@ export async function generateAllSchemas(
|
|
|
614
629
|
zodSchemas
|
|
615
630
|
)
|
|
616
631
|
|
|
632
|
+
// Release the program once the schemas are out of it. It spans the whole
|
|
633
|
+
// tsconfig (~2.5k files, ~600MB on a large tree) and was previously pinned at
|
|
634
|
+
// module scope for the life of the process, so it stacked on top of the main
|
|
635
|
+
// inspector program and pushed `pikku all` past a 2GB heap (#982). The result
|
|
636
|
+
// cache below is what the same-process fast path actually needs; the program
|
|
637
|
+
// only bought incremental reuse on a rebuild, at a cost that dwarfs it.
|
|
638
|
+
cachedSchemaProgram = undefined
|
|
639
|
+
|
|
617
640
|
cachedCustomTypesContent = customTypesContent
|
|
618
641
|
cachedTSSchemas = tsSchemas
|
|
619
642
|
|