@pikku/inspector 0.12.25 → 0.12.27
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 +102 -0
- package/dist/add/add-auth.js +11 -0
- package/dist/add/add-functions.js +6 -1
- package/dist/inspector.js +18 -3
- package/dist/types.d.ts +19 -0
- package/dist/utils/extract-function-name.js +20 -3
- package/dist/utils/schema-generator.d.ts +1 -0
- package/dist/utils/schema-generator.js +76 -0
- package/dist/utils/workflow/derive-workflow-plan.d.ts +20 -0
- package/dist/utils/workflow/derive-workflow-plan.js +78 -0
- package/dist/utils/workflow/graph/finalize-workflows.js +15 -0
- package/dist/utils/workflow/graph/workflow-graph.types.d.ts +14 -1
- package/dist/visit.d.ts +1 -0
- package/dist/visit.js +14 -1
- package/package.json +2 -2
- package/src/add/add-auth.test.ts +50 -0
- package/src/add/add-auth.ts +14 -0
- package/src/add/add-functions.ts +6 -1
- package/src/add/pii-check.test.ts +3 -1
- package/src/inspector.ts +24 -2
- package/src/types.ts +19 -0
- package/src/utils/extract-function-name.ts +21 -3
- package/src/utils/schema-generator.ts +92 -0
- package/src/utils/workflow/derive-workflow-plan.test.ts +122 -0
- package/src/utils/workflow/derive-workflow-plan.ts +90 -0
- package/src/utils/workflow/graph/finalize-workflows.ts +15 -0
- package/src/utils/workflow/graph/workflow-graph.types.ts +18 -1
- package/src/visit.ts +25 -1
- package/tsconfig.tsbuildinfo +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,105 @@
|
|
|
1
|
+
## 0.12.27
|
|
2
|
+
|
|
3
|
+
### Patch Changes
|
|
4
|
+
|
|
5
|
+
- 41ff485: fix(inspector): register functions in a dedicated pass before wiring resolution
|
|
6
|
+
|
|
7
|
+
The deterministic-codegen change sorted `program.getSourceFiles()` so generated
|
|
8
|
+
output is byte-identical across runs. But function registration (`addFunctions`)
|
|
9
|
+
ran in the same sweep as wiring resolution (`visitRoutes`), so once traversal
|
|
10
|
+
became alphabetical, a wiring file could be visited before the file that defines
|
|
11
|
+
the function it references — e.g. an addon contract (`hello.contracts.ts`)
|
|
12
|
+
before `hello.functions.ts` — producing a spurious `PKU559` ("No function
|
|
13
|
+
metadata found for channel handler").
|
|
14
|
+
|
|
15
|
+
Function registration now runs in its own pass (`visitFunctions`) over the
|
|
16
|
+
sorted files, completing before any transport/wiring resolution, so resolution
|
|
17
|
+
no longer depends on source-file order. Also sort the `register.gen.ts` schema
|
|
18
|
+
imports (driven by a `Set`) so that file is stable too, and opt the PII-check
|
|
19
|
+
tests into the now-opt-in classification scan.
|
|
20
|
+
|
|
21
|
+
- d2078c8: fix(inspector): make codegen output deterministic across runs
|
|
22
|
+
|
|
23
|
+
Two sources of non-reproducible `pikku all` output are fixed:
|
|
24
|
+
1. **Random placeholder ids.** Anonymous/unnamed functions and inline (non-exported) permissions were given a `__temp_${randomUUID()}` id, so a referenced-but-not-exported `pikkuPermission` const (e.g. `permissions: { admin: [requiresPlatformAdmin] }`) produced a fresh UUID in the generated meta on every run. The placeholder is now derived deterministically from the call expression's source location (relative path + start offset), still `__temp_`-prefixed so downstream name resolution is unchanged.
|
|
25
|
+
2. **Unstable file-traversal order.** The two inspector sweeps iterated `program.getSourceFiles()` in glob + import-graph order, which varies run to run, so meta keys (and anything serialized in insertion order) were emitted in a different order each time — making a plain `git diff` of generated files look like functions were appearing/vanishing when the set was identical. Source files are now sorted by file name before the sweeps.
|
|
26
|
+
|
|
27
|
+
Net effect: byte-identical generated output across repeated runs with no source changes (verified across the full `.pikku` tree of a 331-function project).
|
|
28
|
+
|
|
29
|
+
- e6fd12b: perf(inspector,cli): persist generated TS schemas to disk across runs
|
|
30
|
+
|
|
31
|
+
`generateAllSchemas` already cached its `ts-json-schema-generator` output
|
|
32
|
+
in-memory (keyed by the generated custom-types content), so the 2nd and 3rd
|
|
33
|
+
inspector passes within a single `pikku all` were near-free. But the cache
|
|
34
|
+
never survived the process, so every fresh `pikku all` paid the full cold cost
|
|
35
|
+
of building a second TS program + running ts-json-schema-generator — on a
|
|
36
|
+
331-function project that's ~2.2s, the single largest line item of a run.
|
|
37
|
+
|
|
38
|
+
The cache is now also persisted to disk (`node_modules/.cache/pikku/ts-schemas.json`,
|
|
39
|
+
gitignored by convention), keyed by a hash of the custom-types content plus the
|
|
40
|
+
generator options that affect output. A warm `pikku all` whose function types
|
|
41
|
+
are unchanged loads the schemas from disk and skips schema generation entirely;
|
|
42
|
+
the cold first pass drops by ~3.4s in practice (it also primes the in-memory
|
|
43
|
+
cache for the re-inspect passes). Zod schemas are still regenerated every run
|
|
44
|
+
(already ~1ms each). Output is byte-identical to a cold run (verified across the
|
|
45
|
+
full generated tree). The key is derived from the same content the in-memory
|
|
46
|
+
cache uses, so any type change busts it. It also folds in the `@pikku/inspector`
|
|
47
|
+
package version, so upgrading the inspector (the channel a schema-format change
|
|
48
|
+
ships through) auto-invalidates every cache; `SCHEMA_CACHE_VERSION` remains a
|
|
49
|
+
manual lever for in-development format changes between releases.
|
|
50
|
+
|
|
51
|
+
Opt-out: omit `schemaConfig.cacheDir` (the CLI sets it by default).
|
|
52
|
+
|
|
53
|
+
- 244d892: perf(cli,inspector): make the data-classification scan opt-in (`pikku all --security`)
|
|
54
|
+
|
|
55
|
+
`pikku all` was spending the bulk of its wall-clock on the data-classification
|
|
56
|
+
leak check. For every function, on every inspector pass, it called
|
|
57
|
+
`checker.getReturnTypeOfSignature` to infer the handler's return type and scan it
|
|
58
|
+
for `Private`/`Pii`/`Secret` brands — the single most expensive type-checker
|
|
59
|
+
operation. On a 331-function project that was ~7.3s (≈half the total), repeated
|
|
60
|
+
across all three inspector passes, even though the scan only emits diagnostics
|
|
61
|
+
and never affects generated output.
|
|
62
|
+
|
|
63
|
+
The scan is a security lint, not codegen, so it's now **off by default** and gated
|
|
64
|
+
behind a new `--security` flag (or `security: true` in the config). A plain
|
|
65
|
+
`pikku all` skips return-type inference entirely; run `pikku all --security`
|
|
66
|
+
(optionally with `--fail-on-error`) in CI/pre-deploy to enforce it. On the
|
|
67
|
+
331-function project this cut `pikku all` from ~15.3s to ~9.6s.
|
|
68
|
+
|
|
69
|
+
Also: the `all` command now reads back the run's recorded per-step durations and,
|
|
70
|
+
under `PIKKU_TIMING=1`, prints a slowest-first timing table — making it easy to
|
|
71
|
+
see where codegen time goes without adding any hot-path instrumentation.
|
|
72
|
+
|
|
73
|
+
- 940c253: Populate `plannedSteps` and `deterministic` on serialized DSL workflow graphs. For a DSL workflow with no loops (fanout), the inspector now records every named step in source order, so a UI can render the run's step skeleton up front without executing it or hand-listing steps. `deterministic` is `true` only for a flat, loopless, branch-free workflow (exact sequence known ahead of time); a branchy-but-loopless workflow lists all possible steps with `deterministic: false`; any fanout makes the count runtime-dependent so neither field is emitted (just `deterministic: false`). Only `source: 'dsl'` workflows are planned — `complex` step trees omit inline branches and flatten loops, so their plans would misreport determinism. The runtime already threads these fields from workflow meta onto each run via `getRun`.
|
|
74
|
+
- Updated dependencies [4be205f]
|
|
75
|
+
- Updated dependencies [061c717]
|
|
76
|
+
- Updated dependencies [2c55e13]
|
|
77
|
+
- Updated dependencies [c745c26]
|
|
78
|
+
- Updated dependencies [57900b5]
|
|
79
|
+
- Updated dependencies [72694f6]
|
|
80
|
+
- @pikku/core@0.12.39
|
|
81
|
+
|
|
82
|
+
## 0.12.26
|
|
83
|
+
|
|
84
|
+
### Patch Changes
|
|
85
|
+
|
|
86
|
+
- ed548d5: fix(auth): skip the generated global `betterAuthSession()` when the user registers their own
|
|
87
|
+
|
|
88
|
+
The CLI's `auth.gen.ts` unconditionally wired a global
|
|
89
|
+
`addHTTPMiddleware('*', [betterAuthSession()])` (default map) on the stateful
|
|
90
|
+
path. A project that needs a customized session bridge — `mapSession`,
|
|
91
|
+
`impersonation`, `apiKey` — had to register a second global
|
|
92
|
+
`betterAuthSession({...})`, leaving two in the chain; the generated default ran
|
|
93
|
+
first and short-circuited (`if (session) next()`) so the custom one never took
|
|
94
|
+
effect.
|
|
95
|
+
|
|
96
|
+
The inspector now records `state.auth.hasUserSessionMiddleware` when it sees a
|
|
97
|
+
user-authored **global** `betterAuthSession` registration (route-scoped and
|
|
98
|
+
`.gen.ts` registrations are ignored, so regeneration never self-suppresses).
|
|
99
|
+
The CLI omits its own global `betterAuthSession()` from `auth.gen.ts` when that
|
|
100
|
+
flag is set — exactly one session bridge in the chain, the user's. Mirrors the
|
|
101
|
+
existing stateless skip (`userStatelessSession`, #754).
|
|
102
|
+
|
|
1
103
|
## 0.12.25
|
|
2
104
|
|
|
3
105
|
### Patch Changes
|
package/dist/add/add-auth.js
CHANGED
|
@@ -137,6 +137,17 @@ export const addAuth = (logger, node, _checker, state) => {
|
|
|
137
137
|
isInsideGlobalMiddlewareRegistration(node)) {
|
|
138
138
|
state.auth.userStatelessSession = true;
|
|
139
139
|
}
|
|
140
|
+
// Same rule for the stateful variant: a user-registered global
|
|
141
|
+
// betterAuthSession(...) (custom mapSession/impersonation/apiKey) means the CLI
|
|
142
|
+
// must NOT auto-generate its own default one — the generated one runs first and
|
|
143
|
+
// pre-empts the user's via the `if (session) next()` short-circuit. Stateful
|
|
144
|
+
// analogue of the betterAuthStatelessSession skip above.
|
|
145
|
+
if (ts.isIdentifier(expression) &&
|
|
146
|
+
expression.text === 'betterAuthSession' &&
|
|
147
|
+
!node.getSourceFile().fileName.endsWith('.gen.ts') &&
|
|
148
|
+
isInsideGlobalMiddlewareRegistration(node)) {
|
|
149
|
+
state.auth.hasUserSessionMiddleware = true;
|
|
150
|
+
}
|
|
140
151
|
if (!ts.isIdentifier(expression) || expression.text !== 'pikkuBetterAuth')
|
|
141
152
|
return;
|
|
142
153
|
const sourceFile = node.getSourceFile().fileName;
|
|
@@ -647,7 +647,12 @@ export const addFunctions = (logger, node, checker, state, options) => {
|
|
|
647
647
|
// secret → never returned by any exposed function (sessioned or not)
|
|
648
648
|
// private → only visible to authenticated (sessioned) users; ok for pikkuFunc
|
|
649
649
|
// public → safe for sessionless functions
|
|
650
|
-
|
|
650
|
+
// Opt-in only: inferring every handler's return type (getReturnTypeOfSignature)
|
|
651
|
+
// is the single most expensive checker operation and dominates `pikku all`
|
|
652
|
+
// wall-clock. The classification leak scan is a security lint, not codegen, so
|
|
653
|
+
// it runs ONLY when explicitly requested (`pikku all --security`) — see the
|
|
654
|
+
// classificationCheck option. Default codegen skips it entirely.
|
|
655
|
+
if (options.classificationCheck) {
|
|
651
656
|
const sig = checker.getSignatureFromDeclaration(handler);
|
|
652
657
|
if (sig) {
|
|
653
658
|
const rawRet = checker.getReturnTypeOfSignature(sig);
|
package/dist/inspector.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as ts from 'typescript';
|
|
2
2
|
import { performance } from 'perf_hooks';
|
|
3
3
|
import { resolve } from 'path';
|
|
4
|
-
import { visitSetup, visitRoutes } from './visit.js';
|
|
4
|
+
import { visitSetup, visitFunctions, visitRoutes } from './visit.js';
|
|
5
5
|
import { TypesMap } from './types-map.js';
|
|
6
6
|
import { getFilesAndMethods } from './utils/get-files-and-methods.js';
|
|
7
7
|
import { findCommonAncestor } from './utils/find-root-dir.js';
|
|
@@ -222,10 +222,17 @@ export const inspect = async (logger, routeFiles, options = {}) => {
|
|
|
222
222
|
// node_modules under rootDir (e.g. a locally-installed addon) is a
|
|
223
223
|
// dependency, not project source — scanning it double-counts the addon's
|
|
224
224
|
// own application types (CoreConfig/Services/SingletonServices).
|
|
225
|
+
// Sort by file name so the sweeps populate state in a stable order. The
|
|
226
|
+
// program's own file order depends on glob + import-graph resolution, which
|
|
227
|
+
// varies run to run — leaving generated meta keys (and anything serialized
|
|
228
|
+
// in insertion order) non-reproducible across identical `pikku all` runs.
|
|
229
|
+
// Safe because function registration is a dedicated pass (visitFunctions)
|
|
230
|
+
// that completes before any order-sensitive wiring resolution in visitRoutes.
|
|
225
231
|
const sourceFiles = program
|
|
226
232
|
.getSourceFiles()
|
|
227
233
|
.filter((sf) => sf.fileName.startsWith(rootDir) &&
|
|
228
|
-
!sf.fileName.includes('/node_modules/'))
|
|
234
|
+
!sf.fileName.includes('/node_modules/'))
|
|
235
|
+
.sort((a, b) => a.fileName < b.fileName ? -1 : a.fileName > b.fileName ? 1 : 0);
|
|
229
236
|
logger.debug(`Got source files in ${(performance.now() - startSourceFiles).toFixed(2)}ms`);
|
|
230
237
|
const state = getInitialInspectorState(rootDir);
|
|
231
238
|
// First sweep: add all functions
|
|
@@ -238,7 +245,15 @@ export const inspect = async (logger, routeFiles, options = {}) => {
|
|
|
238
245
|
// Load addon function metadata so wirings can reference addon functions
|
|
239
246
|
await loadAddonFunctionsMeta(logger, state);
|
|
240
247
|
if (!options.setupOnly) {
|
|
241
|
-
//
|
|
248
|
+
// Function sweep: register every function before transports/wirings resolve
|
|
249
|
+
// them, so resolution doesn't depend on source-file order.
|
|
250
|
+
const startFunctions = performance.now();
|
|
251
|
+
for (const sourceFile of sourceFiles) {
|
|
252
|
+
const sourceOptions = { ...options, sourceFile };
|
|
253
|
+
ts.forEachChild(sourceFile, (child) => visitFunctions(logger, checker, child, state, sourceOptions));
|
|
254
|
+
}
|
|
255
|
+
logger.debug(`Visit functions phase completed in ${(performance.now() - startFunctions).toFixed(0)}ms`);
|
|
256
|
+
// Transport sweep: add all transports/wirings
|
|
242
257
|
const startRoutes = performance.now();
|
|
243
258
|
for (const sourceFile of sourceFiles) {
|
|
244
259
|
const sourceOptions = { ...options, sourceFile };
|
package/dist/types.d.ts
CHANGED
|
@@ -220,6 +220,13 @@ export type InspectorOptions = Partial<{
|
|
|
220
220
|
schema?: {
|
|
221
221
|
additionalProperties?: boolean;
|
|
222
222
|
};
|
|
223
|
+
/**
|
|
224
|
+
* Directory for the on-disk TS-schema cache. When set, generated TS schemas
|
|
225
|
+
* are persisted here keyed by a hash of the custom-types content, so a warm
|
|
226
|
+
* `pikku all` whose function types are unchanged skips ts-json-schema-generator
|
|
227
|
+
* entirely (the single largest cold-run cost). Omit to disable disk caching.
|
|
228
|
+
*/
|
|
229
|
+
cacheDir?: string;
|
|
223
230
|
};
|
|
224
231
|
openAPI: {
|
|
225
232
|
additionalInfo: OpenAPISpecInfo;
|
|
@@ -227,6 +234,12 @@ export type InspectorOptions = Partial<{
|
|
|
227
234
|
tags: string[];
|
|
228
235
|
manifest: VersionManifest;
|
|
229
236
|
oldProgram: ts.Program | undefined;
|
|
237
|
+
/**
|
|
238
|
+
* Run the data-classification leak scan (Private/Pii/Secret brands in function
|
|
239
|
+
* return types). Off by default — it forces return-type inference on every
|
|
240
|
+
* function, which is expensive. Enabled via `pikku all --security`.
|
|
241
|
+
*/
|
|
242
|
+
classificationCheck: boolean;
|
|
230
243
|
}>;
|
|
231
244
|
export interface InspectorLogger {
|
|
232
245
|
info: (message: string) => void;
|
|
@@ -436,6 +449,12 @@ export interface InspectorState {
|
|
|
436
449
|
* own default-map stateless middleware, which would otherwise pre-empt the
|
|
437
450
|
* user's custom mapSession (pikkujs/pikku#754). */
|
|
438
451
|
userStatelessSession?: boolean;
|
|
452
|
+
/** True when a user (non-generated) file already registers a global
|
|
453
|
+
* `betterAuthSession(...)`. The CLI then skips auto-generating its own
|
|
454
|
+
* default stateful middleware, which would otherwise run first and pre-empt
|
|
455
|
+
* the user's config (mapSession/impersonation/apiKey). Stateful analogue of
|
|
456
|
+
* `userStatelessSession`. */
|
|
457
|
+
hasUserSessionMiddleware?: boolean;
|
|
439
458
|
};
|
|
440
459
|
secrets: {
|
|
441
460
|
definitions: SecretDefinitions;
|
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
import * as ts from 'typescript';
|
|
2
|
-
import {
|
|
2
|
+
import { createHash } from 'crypto';
|
|
3
|
+
import { relative } from 'path';
|
|
3
4
|
import { formatVersionedId } from '@pikku/core';
|
|
5
|
+
/**
|
|
6
|
+
* Deterministic placeholder id for an anonymous/unnamed pikku function or
|
|
7
|
+
* permission. Derived from the call expression's source location (relative path
|
|
8
|
+
* + start position) so `pikku all` produces byte-identical output across runs —
|
|
9
|
+
* a `randomUUID()` here made generated meta non-reproducible. Still `__temp_`
|
|
10
|
+
* prefixed so downstream resolution (which keys off that prefix) is unchanged.
|
|
11
|
+
*/
|
|
12
|
+
function tempFuncId(callExpr, rootDir) {
|
|
13
|
+
const sourceFile = callExpr.getSourceFile();
|
|
14
|
+
const relPath = relative(rootDir, sourceFile.fileName);
|
|
15
|
+
const hash = createHash('sha1')
|
|
16
|
+
.update(`${relPath}:${callExpr.getStart()}`)
|
|
17
|
+
.digest('hex')
|
|
18
|
+
.slice(0, 16);
|
|
19
|
+
return `__temp_${hash}`;
|
|
20
|
+
}
|
|
4
21
|
export function makeContextBasedId(wiringType, ...segments) {
|
|
5
22
|
return [wiringType, ...segments].join(':');
|
|
6
23
|
}
|
|
@@ -88,7 +105,7 @@ export function extractFunctionName(callExpr, checker, rootDir) {
|
|
|
88
105
|
result.pikkuFuncId = decl.name.text;
|
|
89
106
|
}
|
|
90
107
|
if (!result.pikkuFuncId) {
|
|
91
|
-
result.pikkuFuncId =
|
|
108
|
+
result.pikkuFuncId = tempFuncId(callExpr, rootDir);
|
|
92
109
|
}
|
|
93
110
|
populateNameByPriority(result);
|
|
94
111
|
return result;
|
|
@@ -335,7 +352,7 @@ export function extractFunctionName(callExpr, checker, rootDir) {
|
|
|
335
352
|
result.pikkuFuncId = result.exportedName;
|
|
336
353
|
}
|
|
337
354
|
else {
|
|
338
|
-
result.pikkuFuncId =
|
|
355
|
+
result.pikkuFuncId = tempFuncId(callExpr, rootDir);
|
|
339
356
|
}
|
|
340
357
|
if (result.version !== null) {
|
|
341
358
|
// Strip trailing VN suffix if it matches the version (e.g. createCardV1 + version:1 → createCard@v1)
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import * as ts from 'typescript';
|
|
2
|
+
import { createHash } from 'crypto';
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
2
4
|
import { dirname, join, resolve } from 'path';
|
|
3
5
|
import { createGenerator, RootlessError } from 'ts-json-schema-generator';
|
|
4
6
|
import { register } from 'tsx/esm/api';
|
|
@@ -55,6 +57,61 @@ let cachedParsedConfig;
|
|
|
55
57
|
let cachedTsconfigPath;
|
|
56
58
|
let cachedCustomTypesContent;
|
|
57
59
|
let cachedTSSchemas;
|
|
60
|
+
const SCHEMA_CACHE_VERSION = 1;
|
|
61
|
+
// This package's own version — folded into the cache key so that upgrading
|
|
62
|
+
// @pikku/inspector (the channel through which a schema-format change ships)
|
|
63
|
+
// auto-invalidates every on-disk cache, without relying on someone remembering
|
|
64
|
+
// to bump SCHEMA_CACHE_VERSION. Read once; falls back to the constant if the
|
|
65
|
+
// package.json can't be located (e.g. an unexpected bundling layout).
|
|
66
|
+
const inspectorVersion = (() => {
|
|
67
|
+
try {
|
|
68
|
+
const pkgUrl = new URL('../../package.json', import.meta.url);
|
|
69
|
+
const pkg = JSON.parse(readFileSync(pkgUrl, 'utf-8'));
|
|
70
|
+
return typeof pkg.version === 'string' ? pkg.version : `v${SCHEMA_CACHE_VERSION}`;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return `v${SCHEMA_CACHE_VERSION}`;
|
|
74
|
+
}
|
|
75
|
+
})();
|
|
76
|
+
// Key the TS-schema cache on everything that affects its output: the generated
|
|
77
|
+
// custom-types source, the generator options that change schema shape, and the
|
|
78
|
+
// inspector version (schema-format changes ship with a version bump).
|
|
79
|
+
function tsSchemaCacheKey(customTypesContent, config) {
|
|
80
|
+
return createHash('sha1')
|
|
81
|
+
.update(`v${SCHEMA_CACHE_VERSION}\0`)
|
|
82
|
+
.update(`pkg:${inspectorVersion}\0`)
|
|
83
|
+
.update(`ap:${config.schema?.additionalProperties ? 1 : 0}\0`)
|
|
84
|
+
.update(`ft:${(config.schemasFromTypes ?? []).join(',')}\0`)
|
|
85
|
+
.update(customTypesContent)
|
|
86
|
+
.digest('hex');
|
|
87
|
+
}
|
|
88
|
+
function schemaCacheFile(cacheDir) {
|
|
89
|
+
return join(cacheDir, 'ts-schemas.json');
|
|
90
|
+
}
|
|
91
|
+
function readDiskTSSchemas(logger, cacheDir, key) {
|
|
92
|
+
const file = schemaCacheFile(cacheDir);
|
|
93
|
+
if (!existsSync(file))
|
|
94
|
+
return null;
|
|
95
|
+
try {
|
|
96
|
+
const parsed = JSON.parse(readFileSync(file, 'utf-8'));
|
|
97
|
+
if (parsed?.key === key && parsed.schemas)
|
|
98
|
+
return parsed.schemas;
|
|
99
|
+
}
|
|
100
|
+
catch (e) {
|
|
101
|
+
logger.debug(`Ignoring unreadable TS-schema cache: ${e.message}`);
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
function writeDiskTSSchemas(logger, cacheDir, key, schemas) {
|
|
106
|
+
const file = schemaCacheFile(cacheDir);
|
|
107
|
+
try {
|
|
108
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
109
|
+
writeFileSync(file, JSON.stringify({ key, schemas }));
|
|
110
|
+
}
|
|
111
|
+
catch (e) {
|
|
112
|
+
logger.debug(`Failed to persist TS-schema cache: ${e.message}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
58
115
|
function createProgramWithVirtualFile(tsconfig, virtualFilePath, virtualFileContent) {
|
|
59
116
|
const configPath = resolve(tsconfig);
|
|
60
117
|
// Cache the parsed tsconfig — it doesn't change between runs
|
|
@@ -330,12 +387,31 @@ export async function generateAllSchemas(logger, config, state) {
|
|
|
330
387
|
const zodSchemas = await generateZodSchemas(logger, state.schemaLookup, state.functions.typesMap);
|
|
331
388
|
const requiredTypes = new Set();
|
|
332
389
|
const customTypesContent = generateCustomTypes(state.functions.typesMap, requiredTypes);
|
|
390
|
+
// Fast path: same process, types unchanged — reuse the in-memory result.
|
|
333
391
|
if (cachedTSSchemas && cachedCustomTypesContent === customTypesContent) {
|
|
334
392
|
logger.debug('Reusing cached TS schemas (types unchanged)');
|
|
335
393
|
return { ...cachedTSSchemas, ...zodSchemas };
|
|
336
394
|
}
|
|
395
|
+
// Disk path: a prior `pikku all` left a cache whose key matches the current
|
|
396
|
+
// custom types — load it and skip ts-json-schema-generator (the dominant
|
|
397
|
+
// cold-run cost). Zod schemas are always regenerated (cheap, ~1ms/schema).
|
|
398
|
+
const cacheKey = config.cacheDir
|
|
399
|
+
? tsSchemaCacheKey(customTypesContent, config)
|
|
400
|
+
: null;
|
|
401
|
+
if (config.cacheDir && cacheKey) {
|
|
402
|
+
const disk = readDiskTSSchemas(logger, config.cacheDir, cacheKey);
|
|
403
|
+
if (disk) {
|
|
404
|
+
logger.debug('Reusing on-disk TS schemas (types unchanged across runs)');
|
|
405
|
+
cachedCustomTypesContent = customTypesContent;
|
|
406
|
+
cachedTSSchemas = disk;
|
|
407
|
+
return { ...disk, ...zodSchemas };
|
|
408
|
+
}
|
|
409
|
+
}
|
|
337
410
|
const tsSchemas = generateTSSchemas(logger, config.tsconfig, customTypesContent, state.functions.typesMap, state.functions.meta, state.http.meta, config.schemasFromTypes, config.schema?.additionalProperties, zodSchemas);
|
|
338
411
|
cachedCustomTypesContent = customTypesContent;
|
|
339
412
|
cachedTSSchemas = tsSchemas;
|
|
413
|
+
if (config.cacheDir && cacheKey) {
|
|
414
|
+
writeDiskTSSchemas(logger, config.cacheDir, cacheKey, tsSchemas);
|
|
415
|
+
}
|
|
340
416
|
return { ...tsSchemas, ...zodSchemas };
|
|
341
417
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { WorkflowStepMeta, WorkflowPlannedStep } from '@pikku/core/workflow';
|
|
2
|
+
/**
|
|
3
|
+
* Derive the static UI plan for a DSL workflow from its extracted step tree.
|
|
4
|
+
*
|
|
5
|
+
* `plannedSteps` is the ordered list of every named step the workflow can run,
|
|
6
|
+
* so a frontend can render the step skeleton up front without executing the
|
|
7
|
+
* workflow or hand-listing steps. It is populated whenever the workflow has NO
|
|
8
|
+
* loops (fanout) — loops make the step COUNT runtime-dependent, so a workflow
|
|
9
|
+
* containing any fanout gets neither field.
|
|
10
|
+
*
|
|
11
|
+
* `deterministic` is true only when the exact executed sequence is known up
|
|
12
|
+
* front: a flat list of named steps with no branches/switches (which pick a
|
|
13
|
+
* path at runtime) and no loops. A branchy-but-loopless workflow is therefore
|
|
14
|
+
* `deterministic: false` but still gets `plannedSteps` (the full set of
|
|
15
|
+
* possible steps, in source order).
|
|
16
|
+
*/
|
|
17
|
+
export declare function deriveWorkflowPlan(steps: WorkflowStepMeta[]): {
|
|
18
|
+
deterministic: boolean;
|
|
19
|
+
plannedSteps?: WorkflowPlannedStep[];
|
|
20
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Derive the static UI plan for a DSL workflow from its extracted step tree.
|
|
3
|
+
*
|
|
4
|
+
* `plannedSteps` is the ordered list of every named step the workflow can run,
|
|
5
|
+
* so a frontend can render the step skeleton up front without executing the
|
|
6
|
+
* workflow or hand-listing steps. It is populated whenever the workflow has NO
|
|
7
|
+
* loops (fanout) — loops make the step COUNT runtime-dependent, so a workflow
|
|
8
|
+
* containing any fanout gets neither field.
|
|
9
|
+
*
|
|
10
|
+
* `deterministic` is true only when the exact executed sequence is known up
|
|
11
|
+
* front: a flat list of named steps with no branches/switches (which pick a
|
|
12
|
+
* path at runtime) and no loops. A branchy-but-loopless workflow is therefore
|
|
13
|
+
* `deterministic: false` but still gets `plannedSteps` (the full set of
|
|
14
|
+
* possible steps, in source order).
|
|
15
|
+
*/
|
|
16
|
+
export function deriveWorkflowPlan(steps) {
|
|
17
|
+
if (containsLoop(steps)) {
|
|
18
|
+
return { deterministic: false };
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
deterministic: !containsConditional(steps),
|
|
22
|
+
plannedSteps: collectNamedSteps(steps),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
/** Any fanout (loop) anywhere in the tree — including inside branches/switches. */
|
|
26
|
+
function containsLoop(steps) {
|
|
27
|
+
return steps.some((step) => {
|
|
28
|
+
switch (step.type) {
|
|
29
|
+
case 'fanout':
|
|
30
|
+
return true;
|
|
31
|
+
case 'branch':
|
|
32
|
+
return (step.branches.some((b) => containsLoop(b.steps)) ||
|
|
33
|
+
(step.elseSteps ? containsLoop(step.elseSteps) : false));
|
|
34
|
+
case 'switch':
|
|
35
|
+
return (step.cases.some((c) => containsLoop(c.steps)) ||
|
|
36
|
+
(step.defaultSteps ? containsLoop(step.defaultSteps) : false));
|
|
37
|
+
default:
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
/** Any branch/switch — the run takes a runtime-decided path. */
|
|
43
|
+
function containsConditional(steps) {
|
|
44
|
+
return steps.some((step) => step.type === 'branch' || step.type === 'switch');
|
|
45
|
+
}
|
|
46
|
+
/** Flatten named steps (rpc/inline/sleep/parallel children) in source order. */
|
|
47
|
+
function collectNamedSteps(steps) {
|
|
48
|
+
const planned = [];
|
|
49
|
+
for (const step of steps) {
|
|
50
|
+
switch (step.type) {
|
|
51
|
+
case 'rpc':
|
|
52
|
+
case 'inline':
|
|
53
|
+
case 'sleep':
|
|
54
|
+
planned.push({ stepName: step.stepName });
|
|
55
|
+
break;
|
|
56
|
+
case 'parallel':
|
|
57
|
+
for (const child of step.children) {
|
|
58
|
+
planned.push({ stepName: child.stepName });
|
|
59
|
+
}
|
|
60
|
+
break;
|
|
61
|
+
case 'branch':
|
|
62
|
+
for (const b of step.branches)
|
|
63
|
+
planned.push(...collectNamedSteps(b.steps));
|
|
64
|
+
if (step.elseSteps)
|
|
65
|
+
planned.push(...collectNamedSteps(step.elseSteps));
|
|
66
|
+
break;
|
|
67
|
+
case 'switch':
|
|
68
|
+
for (const c of step.cases)
|
|
69
|
+
planned.push(...collectNamedSteps(c.steps));
|
|
70
|
+
if (step.defaultSteps) {
|
|
71
|
+
planned.push(...collectNamedSteps(step.defaultSteps));
|
|
72
|
+
}
|
|
73
|
+
break;
|
|
74
|
+
// set / return / cancel / filter / arrayPredicate produce no named step
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return planned;
|
|
78
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { isVersionedId, formatVersionedId, parseVersionedId } from '@pikku/core';
|
|
2
2
|
import { canonicalJSON, hashString } from '../../hash.js';
|
|
3
3
|
import { convertDslToGraph } from './convert-dsl-to-graph.js';
|
|
4
|
+
import { deriveWorkflowPlan } from '../derive-workflow-plan.js';
|
|
4
5
|
export function finalizeWorkflows(state) {
|
|
5
6
|
const { workflows, functions } = state;
|
|
6
7
|
const functionsMeta = functions.meta;
|
|
@@ -9,6 +10,20 @@ export function finalizeWorkflows(state) {
|
|
|
9
10
|
stampVersionsOnGraph(graph, functionsMeta);
|
|
10
11
|
computeStepHashes(graph, functionsMeta);
|
|
11
12
|
graph.graphHash = computeGraphHash(graph);
|
|
13
|
+
// Predictable (loopless) DSL workflows carry their full step list so a UI
|
|
14
|
+
// can render the skeleton up front without executing the run. Only DSL is
|
|
15
|
+
// gated: a complex workflow's step tree is incomplete (inline JS branches
|
|
16
|
+
// aren't captured) and flattens loops into plain steps, so its plan would
|
|
17
|
+
// lie about determinism.
|
|
18
|
+
if (graph.source === 'dsl') {
|
|
19
|
+
const { deterministic, plannedSteps } = deriveWorkflowPlan(meta.steps);
|
|
20
|
+
graph.deterministic = deterministic;
|
|
21
|
+
// Omit an empty list — a deterministic workflow with no plannedSteps is
|
|
22
|
+
// simply one with no named steps (e.g. a bare return).
|
|
23
|
+
if (plannedSteps?.length) {
|
|
24
|
+
graph.plannedSteps = plannedSteps;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
12
27
|
workflows.graphMeta[name] = graph;
|
|
13
28
|
}
|
|
14
29
|
for (const graph of Object.values(workflows.graphMeta)) {
|
|
@@ -69,7 +69,7 @@ export interface NodeOptions {
|
|
|
69
69
|
* Flow node types for control flow (no RPC call)
|
|
70
70
|
*/
|
|
71
71
|
export type FlowType = 'sleep' | 'branch' | 'parallel' | 'fanout' | 'inline' | 'switch' | 'filter' | 'arrayPredicate' | 'return' | 'cancel' | 'set';
|
|
72
|
-
import type { ContextVariable, WorkflowContext } from '@pikku/core/workflow';
|
|
72
|
+
import type { ContextVariable, WorkflowContext, WorkflowPlannedStep } from '@pikku/core/workflow';
|
|
73
73
|
export type { ContextVariable, WorkflowContext };
|
|
74
74
|
/**
|
|
75
75
|
* Base node properties shared by all node types
|
|
@@ -151,6 +151,19 @@ export interface SerializedWorkflowGraph {
|
|
|
151
151
|
entryNodeIds: string[];
|
|
152
152
|
/** Hash of graph topology (nodes, edges, input mappings) */
|
|
153
153
|
graphHash?: string;
|
|
154
|
+
/**
|
|
155
|
+
* True when the exact executed step sequence is known up front: a loopless
|
|
156
|
+
* DSL workflow with no branches/switches. Lets a UI render the run as a fixed
|
|
157
|
+
* pipeline. Loops (fanout) → omitted; branchy-but-loopless → false.
|
|
158
|
+
*/
|
|
159
|
+
deterministic?: boolean;
|
|
160
|
+
/**
|
|
161
|
+
* Every named step the workflow can run, in source order — so a frontend can
|
|
162
|
+
* render the step skeleton before the run starts. Populated for any loopless
|
|
163
|
+
* DSL workflow (a branchy one lists all possible steps); omitted when the step
|
|
164
|
+
* count is runtime-dependent (any fanout).
|
|
165
|
+
*/
|
|
166
|
+
plannedSteps?: WorkflowPlannedStep[];
|
|
154
167
|
/** Wire entry points (HTTP, channel, queue, etc.) that trigger this workflow */
|
|
155
168
|
wires?: WorkflowWires;
|
|
156
169
|
}
|
package/dist/visit.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as ts from 'typescript';
|
|
2
2
|
import type { InspectorState, InspectorLogger, InspectorOptions } from './types.js';
|
|
3
3
|
export declare const visitSetup: (logger: InspectorLogger, checker: ts.TypeChecker, node: ts.Node, state: InspectorState, options: InspectorOptions) => void;
|
|
4
|
+
export declare const visitFunctions: (logger: InspectorLogger, checker: ts.TypeChecker, node: ts.Node, state: InspectorState, options: InspectorOptions) => void;
|
|
4
5
|
export declare const visitRoutes: (logger: InspectorLogger, checker: ts.TypeChecker, node: ts.Node, state: InspectorState, options: InspectorOptions) => void;
|
package/dist/visit.js
CHANGED
|
@@ -41,12 +41,25 @@ export const visitSetup = (logger, checker, node, state, options) => {
|
|
|
41
41
|
addWorkflow(logger, node, checker, state, options);
|
|
42
42
|
ts.forEachChild(node, (child) => visitSetup(logger, checker, child, state, options));
|
|
43
43
|
};
|
|
44
|
+
// Register every pikku function before transports/wirings are resolved, so that
|
|
45
|
+
// resolution (e.g. a channel handler referencing a function defined in another
|
|
46
|
+
// file) is independent of source-file traversal order. Runs between visitSetup
|
|
47
|
+
// and visitRoutes.
|
|
48
|
+
export const visitFunctions = (logger, checker, node, state, options) => {
|
|
49
|
+
const nextOptions = ts.isSourceFile(node)
|
|
50
|
+
? { ...options, sourceFile: node }
|
|
51
|
+
: options;
|
|
52
|
+
addFunctions(logger, node, checker, state, nextOptions);
|
|
53
|
+
ts.forEachChild(node, (child) => visitFunctions(logger, checker, child, state, nextOptions));
|
|
54
|
+
};
|
|
44
55
|
export const visitRoutes = (logger, checker, node, state, options) => {
|
|
45
56
|
const nextOptions = ts.isSourceFile(node)
|
|
46
57
|
? { ...options, sourceFile: node }
|
|
47
58
|
: options;
|
|
48
59
|
checkAddonBans(logger, node, checker, state, nextOptions);
|
|
49
|
-
addFunctions
|
|
60
|
+
// NOTE: addFunctions runs in its own earlier pass (visitFunctions) so that
|
|
61
|
+
// every function is registered before any wiring (channels, CLI, etc.)
|
|
62
|
+
// resolves it — wiring resolution must not depend on source-file order.
|
|
50
63
|
addAuth(logger, node, checker, state, nextOptions);
|
|
51
64
|
addSecret(logger, node, checker, state, nextOptions);
|
|
52
65
|
addCredential(logger, node, checker, state, nextOptions);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pikku/inspector",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.27",
|
|
4
4
|
"author": "yasser.fadl@gmail.com",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"type": "module",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
37
|
"@openapi-contrib/json-schema-to-openapi-schema": "^4.3.1",
|
|
38
|
-
"@pikku/core": "^0.12.
|
|
38
|
+
"@pikku/core": "^0.12.39",
|
|
39
39
|
"openapi-types": "^12.1.3",
|
|
40
40
|
"path-to-regexp": "^8.3.0",
|
|
41
41
|
"ts-json-schema-generator": "^2.5.0",
|
package/src/add/add-auth.test.ts
CHANGED
|
@@ -616,4 +616,54 @@ describe('addAuth inspector', () => {
|
|
|
616
616
|
await rm(rootDir, { recursive: true, force: true })
|
|
617
617
|
}
|
|
618
618
|
})
|
|
619
|
+
|
|
620
|
+
test('user-registered global betterAuthSession sets hasUserSessionMiddleware', async () => {
|
|
621
|
+
const rootDir = await mkdtemp(join(tmpdir(), 'pikku-add-auth-session-'))
|
|
622
|
+
const file = join(rootDir, 'middleware.ts')
|
|
623
|
+
await writeFile(
|
|
624
|
+
file,
|
|
625
|
+
[
|
|
626
|
+
"import { addHTTPMiddleware } from '#pikku'",
|
|
627
|
+
"import { betterAuthSession } from '@pikku/better-auth'",
|
|
628
|
+
"addHTTPMiddleware('*', [",
|
|
629
|
+
' betterAuthSession({',
|
|
630
|
+
' impersonation: { loadUser: (id: string) => ({ id }) },',
|
|
631
|
+
' }),',
|
|
632
|
+
'])',
|
|
633
|
+
].join('\n')
|
|
634
|
+
)
|
|
635
|
+
const criticals: Array<{ code: ErrorCode; message: string }> = []
|
|
636
|
+
try {
|
|
637
|
+
const state = await inspect(makeLogger(criticals), [file], { rootDir })
|
|
638
|
+
assert.equal(state.auth.hasUserSessionMiddleware, true)
|
|
639
|
+
} finally {
|
|
640
|
+
await rm(rootDir, { recursive: true, force: true })
|
|
641
|
+
}
|
|
642
|
+
})
|
|
643
|
+
|
|
644
|
+
test('betterAuthSession in a .gen.ts file does NOT set hasUserSessionMiddleware', async () => {
|
|
645
|
+
// Critical: the CLI's own auth.gen.ts contains addHTTPMiddleware('*',
|
|
646
|
+
// [betterAuthSession()]). It must not count as a user registration, or it
|
|
647
|
+
// would suppress itself and leave the chain with no session middleware.
|
|
648
|
+
const rootDir = await mkdtemp(join(tmpdir(), 'pikku-add-auth-session-gen-'))
|
|
649
|
+
const file = join(rootDir, 'auth.gen.ts')
|
|
650
|
+
await writeFile(
|
|
651
|
+
file,
|
|
652
|
+
[
|
|
653
|
+
"import { addHTTPMiddleware } from '#pikku'",
|
|
654
|
+
"import { betterAuthSession } from '@pikku/better-auth'",
|
|
655
|
+
"addHTTPMiddleware('*', [betterAuthSession()])",
|
|
656
|
+
].join('\n')
|
|
657
|
+
)
|
|
658
|
+
const criticals: Array<{ code: ErrorCode; message: string }> = []
|
|
659
|
+
try {
|
|
660
|
+
const state = await inspect(makeLogger(criticals), [file], { rootDir })
|
|
661
|
+
assert.ok(
|
|
662
|
+
!state.auth.hasUserSessionMiddleware,
|
|
663
|
+
'generated file must not self-trigger the skip'
|
|
664
|
+
)
|
|
665
|
+
} finally {
|
|
666
|
+
await rm(rootDir, { recursive: true, force: true })
|
|
667
|
+
}
|
|
668
|
+
})
|
|
619
669
|
})
|