@pikku/inspector 0.12.67 → 0.12.69
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 +37 -0
- package/dist/add/add-auth.js +7 -7
- package/dist/error-codes.d.ts +1 -0
- package/dist/error-codes.js +2 -0
- package/dist/utils/find-zod-transform.d.ts +21 -0
- package/dist/utils/find-zod-transform.js +93 -0
- package/dist/utils/schema-generator.js +11 -0
- package/package.json +3 -3
- package/src/add/add-auth.test.ts +2 -2
- package/src/add/add-auth.ts +7 -7
- package/src/error-codes.ts +3 -0
- package/src/utils/find-zod-transform.test.ts +70 -0
- package/src/utils/find-zod-transform.ts +103 -0
- package/src/utils/schema-generator.test.ts +47 -0
- package/src/utils/schema-generator.ts +14 -0
- package/tsconfig.tsbuildinfo +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,40 @@
|
|
|
1
|
+
## 0.12.69
|
|
2
|
+
|
|
3
|
+
### Patch Changes
|
|
4
|
+
|
|
5
|
+
- 32d1280: Prefix the better-auth plugin factories with `pikku`: `pikkuActor`, `pikkuBan`,
|
|
6
|
+
`pikkuFabric`, `pikkuDelegatedAuth` and `pikkuCredentialOAuth`.
|
|
7
|
+
|
|
8
|
+
A `betterAuth({ plugins: [...] })` array mixes this package's plugins with
|
|
9
|
+
better-auth's own, and until now nothing at the call site told them apart —
|
|
10
|
+
`plugins: [actor(...), ban(), fabric(...), organization()]` reads as four
|
|
11
|
+
plugins from one place when only the last is better-auth's. The prefix says
|
|
12
|
+
which package a plugin came from where it is actually wired.
|
|
13
|
+
|
|
14
|
+
The old names are still exported as deprecated aliases bound to the same
|
|
15
|
+
functions, so no import has to change. Nothing about the plugins themselves
|
|
16
|
+
moved: the `id` each registers under — `pikku-ban`, `actor`, `fabric`,
|
|
17
|
+
`delegated-auth`, `credential-oauth` — is unchanged, so no deployed database or
|
|
18
|
+
session is affected.
|
|
19
|
+
|
|
20
|
+
The pieces that read a plugin's _export_ name rather than its id accept both:
|
|
21
|
+
`PLUGIN_REGISTRY` is keyed under the prefixed and the bare name, and the
|
|
22
|
+
`pikku validate` ban/actor checks and the `scaffold.userAdmin` ban check count
|
|
23
|
+
either spelling as wired. Their messages now point at the new names.
|
|
24
|
+
|
|
25
|
+
- Updated dependencies [a0ed1e8]
|
|
26
|
+
- @pikku/core@0.12.100
|
|
27
|
+
|
|
28
|
+
## 0.12.68
|
|
29
|
+
|
|
30
|
+
### Patch Changes
|
|
31
|
+
|
|
32
|
+
- ee9da9e: A `.transform()` in a schema used as a function's `input` now reports PKU491, naming the schema and the path to the transform, instead of surfacing downstream as PKU724 — the bad `#pikku/*` import error.
|
|
33
|
+
- Updated dependencies [ee9da9e]
|
|
34
|
+
- Updated dependencies [7a15c9c]
|
|
35
|
+
- Updated dependencies [ee9da9e]
|
|
36
|
+
- @pikku/core@0.12.99
|
|
37
|
+
|
|
1
38
|
## 0.12.67
|
|
2
39
|
|
|
3
40
|
### Patch Changes
|
package/dist/add/add-auth.js
CHANGED
|
@@ -86,7 +86,7 @@ const readPluginBindings = (source) => {
|
|
|
86
86
|
};
|
|
87
87
|
/**
|
|
88
88
|
* Read a `plugins: [...]` entry. better-auth plugins are factory calls —
|
|
89
|
-
* `bearer()`, `twoFactor({ ... })`, `
|
|
89
|
+
* `bearer()`, `twoFactor({ ... })`, `pikkuBan()`, or `plugins.bearer()` through a
|
|
90
90
|
* namespace import; non-call entries are ignored. `betterAuthExport` is the
|
|
91
91
|
* name better-auth exports the plugin under, and is absent for a plugin that
|
|
92
92
|
* came from somewhere else, which no policy here applies to.
|
|
@@ -148,10 +148,10 @@ const isInsideGlobalMiddlewareRegistration = (node) => {
|
|
|
148
148
|
* every one of its endpoints underneath. Pikku dropped that projection: user
|
|
149
149
|
* management is `@pikku/addon-admin`'s scoped RPCs, and the one capability
|
|
150
150
|
* `admin()` had that nothing else did — refusing a session to a banned user —
|
|
151
|
-
* is `
|
|
151
|
+
* is `pikkuBan()`.
|
|
152
152
|
*
|
|
153
153
|
* Thrown rather than warned because the failure it prevents is silent. An app
|
|
154
|
-
* that drops `admin()` without wiring `
|
|
154
|
+
* that drops `admin()` without wiring `pikkuBan()` keeps its ban columns and its
|
|
155
155
|
* UI, and simply stops enforcing bans.
|
|
156
156
|
*/
|
|
157
157
|
const UNSUPPORTED_ADMIN_PLUGIN = (sourceFile) => `better-auth's admin() plugin is not supported by pikku (${sourceFile}).
|
|
@@ -160,12 +160,12 @@ It authorizes against a 'user.role' column, while pikku authorizes on scopes —
|
|
|
160
160
|
wiring both means one has to be projected onto the other, and the projection is
|
|
161
161
|
coarser than the scopes it stands in for.
|
|
162
162
|
|
|
163
|
-
Use
|
|
163
|
+
Use pikkuBan() instead:
|
|
164
164
|
|
|
165
|
-
import {
|
|
166
|
-
betterAuth({ plugins: [
|
|
165
|
+
import { pikkuBan } from '@pikku/better-auth'
|
|
166
|
+
betterAuth({ plugins: [pikkuBan()] })
|
|
167
167
|
|
|
168
|
-
|
|
168
|
+
pikkuBan() keeps the part of admin() that pikku cannot supply from outside
|
|
169
169
|
better-auth — the banned/banReason/banExpires columns and the session hook that
|
|
170
170
|
refuses a banned user a session. Everything else admin() offered is already
|
|
171
171
|
scoped RPCs in @pikku/addon-admin: list, create, ban, remove, revoke sessions
|
package/dist/error-codes.d.ts
CHANGED
|
@@ -65,6 +65,7 @@ export declare enum ErrorCode {
|
|
|
65
65
|
AGENT_TOOL_MISSING_DESCRIPTION = "PKU154",
|
|
66
66
|
AGENT_SCORER_NOT_FOUND = "PKU155",
|
|
67
67
|
SCHEMA_AND_WIRING_COLOCATED = "PKU490",
|
|
68
|
+
SCHEMA_TRANSFORM_NOT_SUPPORTED = "PKU491",
|
|
68
69
|
SERVICES_NOT_DESTRUCTURED = "PKU410",
|
|
69
70
|
WIRES_NOT_DESTRUCTURED = "PKU411",
|
|
70
71
|
FUNCTION_DYNAMIC_IMPORT = "PKU498",
|
package/dist/error-codes.js
CHANGED
|
@@ -84,6 +84,8 @@ export var ErrorCode;
|
|
|
84
84
|
ErrorCode["AGENT_SCORER_NOT_FOUND"] = "PKU155";
|
|
85
85
|
// File structure errors
|
|
86
86
|
ErrorCode["SCHEMA_AND_WIRING_COLOCATED"] = "PKU490";
|
|
87
|
+
// Schema authoring errors
|
|
88
|
+
ErrorCode["SCHEMA_TRANSFORM_NOT_SUPPORTED"] = "PKU491";
|
|
87
89
|
// Optimization diagnostics
|
|
88
90
|
ErrorCode["SERVICES_NOT_DESTRUCTURED"] = "PKU410";
|
|
89
91
|
ErrorCode["WIRES_NOT_DESTRUCTURED"] = "PKU411";
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locate a `.transform()` anywhere inside a Zod schema.
|
|
3
|
+
*
|
|
4
|
+
* A transform is why a schema cannot be a function's `input`: the value the
|
|
5
|
+
* caller sends and the value the function receives are different, and a wire
|
|
6
|
+
* contract has to describe one shape. Zod says so twice, in two different
|
|
7
|
+
* places, and neither says it usefully on its own — `toJSONSchema` with
|
|
8
|
+
* `unrepresentable: 'any'` quietly yields `{}`, a schema that accepts
|
|
9
|
+
* everything, and `zodToTs` throws about TypeScript rather than about the
|
|
10
|
+
* wiring. Finding it up front lets the diagnostic name the schema, the path,
|
|
11
|
+
* and the call.
|
|
12
|
+
*
|
|
13
|
+
* Zod 4 models `x.transform(fn)` as a `pipe` whose `out` is a `transform`, so
|
|
14
|
+
* the walk looks for that node rather than for the method.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* The path to the first `.transform()` found — `''` when the transform is the
|
|
18
|
+
* schema itself, `'.user.email'` when it is nested — or `undefined` when there
|
|
19
|
+
* is none.
|
|
20
|
+
*/
|
|
21
|
+
export declare function findZodTransform(schema: unknown): string | undefined;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locate a `.transform()` anywhere inside a Zod schema.
|
|
3
|
+
*
|
|
4
|
+
* A transform is why a schema cannot be a function's `input`: the value the
|
|
5
|
+
* caller sends and the value the function receives are different, and a wire
|
|
6
|
+
* contract has to describe one shape. Zod says so twice, in two different
|
|
7
|
+
* places, and neither says it usefully on its own — `toJSONSchema` with
|
|
8
|
+
* `unrepresentable: 'any'` quietly yields `{}`, a schema that accepts
|
|
9
|
+
* everything, and `zodToTs` throws about TypeScript rather than about the
|
|
10
|
+
* wiring. Finding it up front lets the diagnostic name the schema, the path,
|
|
11
|
+
* and the call.
|
|
12
|
+
*
|
|
13
|
+
* Zod 4 models `x.transform(fn)` as a `pipe` whose `out` is a `transform`, so
|
|
14
|
+
* the walk looks for that node rather than for the method.
|
|
15
|
+
*/
|
|
16
|
+
/** Max nodes visited, so a pathological or cyclic schema cannot hang the walk. */
|
|
17
|
+
const MAX_NODES = 10_000;
|
|
18
|
+
const isNode = (value) => typeof value === 'object' &&
|
|
19
|
+
value !== null &&
|
|
20
|
+
typeof value._zod?.def?.type === 'string';
|
|
21
|
+
/**
|
|
22
|
+
* The path to the first `.transform()` found — `''` when the transform is the
|
|
23
|
+
* schema itself, `'.user.email'` when it is nested — or `undefined` when there
|
|
24
|
+
* is none.
|
|
25
|
+
*/
|
|
26
|
+
export function findZodTransform(schema) {
|
|
27
|
+
const seen = new Set();
|
|
28
|
+
let budget = MAX_NODES;
|
|
29
|
+
const walk = (node, path) => {
|
|
30
|
+
if (budget-- <= 0 || !isNode(node) || seen.has(node))
|
|
31
|
+
return undefined;
|
|
32
|
+
seen.add(node);
|
|
33
|
+
const def = node._zod.def;
|
|
34
|
+
if (def.type === 'transform')
|
|
35
|
+
return path;
|
|
36
|
+
const children = [];
|
|
37
|
+
switch (def.type) {
|
|
38
|
+
case 'object':
|
|
39
|
+
case 'interface':
|
|
40
|
+
for (const [key, child] of Object.entries(def.shape ?? {})) {
|
|
41
|
+
children.push([child, `${path}.${key}`]);
|
|
42
|
+
}
|
|
43
|
+
break;
|
|
44
|
+
case 'array':
|
|
45
|
+
case 'set':
|
|
46
|
+
children.push([def.element, `${path}[]`]);
|
|
47
|
+
break;
|
|
48
|
+
case 'tuple':
|
|
49
|
+
for (const [i, child] of (def.items ?? []).entries()) {
|
|
50
|
+
children.push([child, `${path}[${i}]`]);
|
|
51
|
+
}
|
|
52
|
+
if (def.rest)
|
|
53
|
+
children.push([def.rest, `${path}[]`]);
|
|
54
|
+
break;
|
|
55
|
+
case 'union':
|
|
56
|
+
for (const [i, child] of (def.options ?? []).entries()) {
|
|
57
|
+
children.push([child, `${path}|${i}`]);
|
|
58
|
+
}
|
|
59
|
+
break;
|
|
60
|
+
case 'intersection':
|
|
61
|
+
children.push([def.left, path], [def.right, path]);
|
|
62
|
+
break;
|
|
63
|
+
case 'record':
|
|
64
|
+
case 'map':
|
|
65
|
+
children.push([def.keyType, `${path}[key]`], [def.valueType, `${path}[]`]);
|
|
66
|
+
break;
|
|
67
|
+
case 'pipe':
|
|
68
|
+
// `.transform()` itself: report the pipe's own path, not `out`'s.
|
|
69
|
+
children.push([def.in, path], [def.out, path]);
|
|
70
|
+
break;
|
|
71
|
+
default:
|
|
72
|
+
// optional, nullable, default, prefault, nonoptional, readonly, catch,
|
|
73
|
+
// promise, lazy, success — every wrapper Zod spells `innerType`.
|
|
74
|
+
if (def.innerType)
|
|
75
|
+
children.push([def.innerType, path]);
|
|
76
|
+
if (def.getter) {
|
|
77
|
+
try {
|
|
78
|
+
children.push([def.getter(), path]);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// A lazy schema that cannot be forced tells us nothing; skip it.
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
for (const [child, childPath] of children) {
|
|
86
|
+
const found = walk(child, childPath);
|
|
87
|
+
if (found !== undefined)
|
|
88
|
+
return found;
|
|
89
|
+
}
|
|
90
|
+
return undefined;
|
|
91
|
+
};
|
|
92
|
+
return walk(schema, '');
|
|
93
|
+
}
|
|
@@ -6,6 +6,7 @@ import { createGenerator, RootlessError } from 'ts-json-schema-generator';
|
|
|
6
6
|
import { register } from 'tsx/esm/api';
|
|
7
7
|
import * as z from 'zod';
|
|
8
8
|
import { zodToTs, createAuxiliaryTypeStore } from 'zod-to-ts';
|
|
9
|
+
import { findZodTransform } from './find-zod-transform.js';
|
|
9
10
|
import { ErrorCode } from '../error-codes.js';
|
|
10
11
|
import { generateCustomTypes } from './custom-types-generator.js';
|
|
11
12
|
const PRIMITIVE_TYPES = new Set([
|
|
@@ -382,6 +383,16 @@ async function importWithRegister(sourceFile) {
|
|
|
382
383
|
}
|
|
383
384
|
}
|
|
384
385
|
function processZodSchema(schemaName, zodSchema, schemas, typesMap, auxiliaryTypeStore, printer, fakeSourceFile, logger) {
|
|
386
|
+
const transformPath = findZodTransform(zodSchema);
|
|
387
|
+
if (transformPath !== undefined) {
|
|
388
|
+
const where = transformPath === '' ? 'the schema itself' : transformPath;
|
|
389
|
+
throw new Error(`[${ErrorCode.SCHEMA_TRANSFORM_NOT_SUPPORTED}] '${schemaName}' contains a .transform() at ${where}.\n` +
|
|
390
|
+
'A transform makes the value sent and the value received two different ' +
|
|
391
|
+
'shapes, and a wire contract describes one: it cannot be expressed in ' +
|
|
392
|
+
'JSON Schema or in the generated TypeScript.\n' +
|
|
393
|
+
'Validate with the plain schema and apply the transform inside the ' +
|
|
394
|
+
'function, or move it to the output type.');
|
|
395
|
+
}
|
|
385
396
|
const schema = z.toJSONSchema(zodSchema, {
|
|
386
397
|
unrepresentable: 'any',
|
|
387
398
|
override: ({ zodSchema, jsonSchema }) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pikku/inspector",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.69",
|
|
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.100"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
|
-
"@pikku/core": "^0.12.
|
|
51
|
+
"@pikku/core": "^0.12.100",
|
|
52
52
|
"@types/node": "^24.13.3"
|
|
53
53
|
},
|
|
54
54
|
"engines": {
|
package/src/add/add-auth.test.ts
CHANGED
|
@@ -217,7 +217,7 @@ describe('addAuth inspector', () => {
|
|
|
217
217
|
}
|
|
218
218
|
})
|
|
219
219
|
|
|
220
|
-
test('refuses better-auth admin() and points at
|
|
220
|
+
test('refuses better-auth admin() and points at pikkuBan()', async () => {
|
|
221
221
|
const rootDir = await mkdtemp(join(tmpdir(), 'pikku-add-auth-admin-'))
|
|
222
222
|
const file = join(rootDir, 'auth.ts')
|
|
223
223
|
|
|
@@ -241,7 +241,7 @@ describe('addAuth inspector', () => {
|
|
|
241
241
|
() => inspect(makeLogger(criticals), [file], { rootDir }),
|
|
242
242
|
(error: Error) => {
|
|
243
243
|
assert.match(error.message, /admin\(\) plugin is not supported/)
|
|
244
|
-
assert.match(error.message, /
|
|
244
|
+
assert.match(error.message, /pikkuBan\(\)/)
|
|
245
245
|
assert.match(error.message, /@pikku\/addon-admin/)
|
|
246
246
|
return true
|
|
247
247
|
}
|
package/src/add/add-auth.ts
CHANGED
|
@@ -128,7 +128,7 @@ const readPluginBindings = (source: ts.SourceFile): PluginBindings => {
|
|
|
128
128
|
|
|
129
129
|
/**
|
|
130
130
|
* Read a `plugins: [...]` entry. better-auth plugins are factory calls —
|
|
131
|
-
* `bearer()`, `twoFactor({ ... })`, `
|
|
131
|
+
* `bearer()`, `twoFactor({ ... })`, `pikkuBan()`, or `plugins.bearer()` through a
|
|
132
132
|
* namespace import; non-call entries are ignored. `betterAuthExport` is the
|
|
133
133
|
* name better-auth exports the plugin under, and is absent for a plugin that
|
|
134
134
|
* came from somewhere else, which no policy here applies to.
|
|
@@ -194,10 +194,10 @@ const isInsideGlobalMiddlewareRegistration = (node: ts.Node): boolean => {
|
|
|
194
194
|
* every one of its endpoints underneath. Pikku dropped that projection: user
|
|
195
195
|
* management is `@pikku/addon-admin`'s scoped RPCs, and the one capability
|
|
196
196
|
* `admin()` had that nothing else did — refusing a session to a banned user —
|
|
197
|
-
* is `
|
|
197
|
+
* is `pikkuBan()`.
|
|
198
198
|
*
|
|
199
199
|
* Thrown rather than warned because the failure it prevents is silent. An app
|
|
200
|
-
* that drops `admin()` without wiring `
|
|
200
|
+
* that drops `admin()` without wiring `pikkuBan()` keeps its ban columns and its
|
|
201
201
|
* UI, and simply stops enforcing bans.
|
|
202
202
|
*/
|
|
203
203
|
const UNSUPPORTED_ADMIN_PLUGIN = (sourceFile: string): string =>
|
|
@@ -207,12 +207,12 @@ It authorizes against a 'user.role' column, while pikku authorizes on scopes —
|
|
|
207
207
|
wiring both means one has to be projected onto the other, and the projection is
|
|
208
208
|
coarser than the scopes it stands in for.
|
|
209
209
|
|
|
210
|
-
Use
|
|
210
|
+
Use pikkuBan() instead:
|
|
211
211
|
|
|
212
|
-
import {
|
|
213
|
-
betterAuth({ plugins: [
|
|
212
|
+
import { pikkuBan } from '@pikku/better-auth'
|
|
213
|
+
betterAuth({ plugins: [pikkuBan()] })
|
|
214
214
|
|
|
215
|
-
|
|
215
|
+
pikkuBan() keeps the part of admin() that pikku cannot supply from outside
|
|
216
216
|
better-auth — the banned/banReason/banExpires columns and the session hook that
|
|
217
217
|
refuses a banned user a session. Everything else admin() offered is already
|
|
218
218
|
scoped RPCs in @pikku/addon-admin: list, create, ban, remove, revoke sessions
|
package/src/error-codes.ts
CHANGED
|
@@ -98,6 +98,9 @@ export enum ErrorCode {
|
|
|
98
98
|
// File structure errors
|
|
99
99
|
SCHEMA_AND_WIRING_COLOCATED = 'PKU490',
|
|
100
100
|
|
|
101
|
+
// Schema authoring errors
|
|
102
|
+
SCHEMA_TRANSFORM_NOT_SUPPORTED = 'PKU491',
|
|
103
|
+
|
|
101
104
|
// Optimization diagnostics
|
|
102
105
|
SERVICES_NOT_DESTRUCTURED = 'PKU410',
|
|
103
106
|
WIRES_NOT_DESTRUCTURED = 'PKU411',
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import * as z from 'zod'
|
|
4
|
+
import { findZodTransform } from './find-zod-transform.js'
|
|
5
|
+
|
|
6
|
+
test('a schema with no transform is reported as clean', () => {
|
|
7
|
+
assert.equal(findZodTransform(z.object({ id: z.string() })), undefined)
|
|
8
|
+
assert.equal(findZodTransform(z.string().min(1).trim()), undefined)
|
|
9
|
+
// a refinement narrows the value, it does not change its shape
|
|
10
|
+
assert.equal(
|
|
11
|
+
findZodTransform(z.object({ id: z.string() }).refine(() => true)),
|
|
12
|
+
undefined
|
|
13
|
+
)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('a transform on the schema itself is found at the root', () => {
|
|
17
|
+
assert.equal(
|
|
18
|
+
findZodTransform(z.object({ id: z.string() }).transform((v) => v)),
|
|
19
|
+
''
|
|
20
|
+
)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('a transform on a field is found by path', () => {
|
|
24
|
+
assert.equal(
|
|
25
|
+
findZodTransform(
|
|
26
|
+
z.object({
|
|
27
|
+
id: z.string(),
|
|
28
|
+
when: z.string().transform((s) => new Date(s)),
|
|
29
|
+
})
|
|
30
|
+
),
|
|
31
|
+
'.when'
|
|
32
|
+
)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('a transform is found through wrappers, arrays and unions', () => {
|
|
36
|
+
assert.equal(
|
|
37
|
+
findZodTransform(
|
|
38
|
+
z.object({ tags: z.array(z.string().transform((s) => s.trim())) })
|
|
39
|
+
),
|
|
40
|
+
'.tags[]'
|
|
41
|
+
)
|
|
42
|
+
assert.equal(
|
|
43
|
+
findZodTransform(z.object({ n: z.string().transform(Number).optional() })),
|
|
44
|
+
'.n'
|
|
45
|
+
)
|
|
46
|
+
assert.equal(
|
|
47
|
+
findZodTransform(
|
|
48
|
+
z.object({ v: z.union([z.string(), z.number().transform(String)]) })
|
|
49
|
+
),
|
|
50
|
+
'.v|1'
|
|
51
|
+
)
|
|
52
|
+
assert.equal(
|
|
53
|
+
findZodTransform(
|
|
54
|
+
z.object({
|
|
55
|
+
m: z.record(
|
|
56
|
+
z.string(),
|
|
57
|
+
z.string().transform((s) => s)
|
|
58
|
+
),
|
|
59
|
+
})
|
|
60
|
+
),
|
|
61
|
+
'.m[]'
|
|
62
|
+
)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('a self-referencing schema terminates instead of recursing forever', () => {
|
|
66
|
+
const node: z.ZodType = z.lazy(() =>
|
|
67
|
+
z.object({ name: z.string(), children: z.array(node) })
|
|
68
|
+
)
|
|
69
|
+
assert.equal(findZodTransform(node), undefined)
|
|
70
|
+
})
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locate a `.transform()` anywhere inside a Zod schema.
|
|
3
|
+
*
|
|
4
|
+
* A transform is why a schema cannot be a function's `input`: the value the
|
|
5
|
+
* caller sends and the value the function receives are different, and a wire
|
|
6
|
+
* contract has to describe one shape. Zod says so twice, in two different
|
|
7
|
+
* places, and neither says it usefully on its own — `toJSONSchema` with
|
|
8
|
+
* `unrepresentable: 'any'` quietly yields `{}`, a schema that accepts
|
|
9
|
+
* everything, and `zodToTs` throws about TypeScript rather than about the
|
|
10
|
+
* wiring. Finding it up front lets the diagnostic name the schema, the path,
|
|
11
|
+
* and the call.
|
|
12
|
+
*
|
|
13
|
+
* Zod 4 models `x.transform(fn)` as a `pipe` whose `out` is a `transform`, so
|
|
14
|
+
* the walk looks for that node rather than for the method.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Max nodes visited, so a pathological or cyclic schema cannot hang the walk. */
|
|
18
|
+
const MAX_NODES = 10_000
|
|
19
|
+
|
|
20
|
+
interface ZodNode {
|
|
21
|
+
_zod?: { def?: Record<string, any> }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const isNode = (value: unknown): value is ZodNode =>
|
|
25
|
+
typeof value === 'object' &&
|
|
26
|
+
value !== null &&
|
|
27
|
+
typeof (value as ZodNode)._zod?.def?.type === 'string'
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The path to the first `.transform()` found — `''` when the transform is the
|
|
31
|
+
* schema itself, `'.user.email'` when it is nested — or `undefined` when there
|
|
32
|
+
* is none.
|
|
33
|
+
*/
|
|
34
|
+
export function findZodTransform(schema: unknown): string | undefined {
|
|
35
|
+
const seen = new Set<unknown>()
|
|
36
|
+
let budget = MAX_NODES
|
|
37
|
+
|
|
38
|
+
const walk = (node: unknown, path: string): string | undefined => {
|
|
39
|
+
if (budget-- <= 0 || !isNode(node) || seen.has(node)) return undefined
|
|
40
|
+
seen.add(node)
|
|
41
|
+
|
|
42
|
+
const def = node._zod!.def!
|
|
43
|
+
if (def.type === 'transform') return path
|
|
44
|
+
|
|
45
|
+
const children: Array<[unknown, string]> = []
|
|
46
|
+
switch (def.type) {
|
|
47
|
+
case 'object':
|
|
48
|
+
case 'interface':
|
|
49
|
+
for (const [key, child] of Object.entries(def.shape ?? {})) {
|
|
50
|
+
children.push([child, `${path}.${key}`])
|
|
51
|
+
}
|
|
52
|
+
break
|
|
53
|
+
case 'array':
|
|
54
|
+
case 'set':
|
|
55
|
+
children.push([def.element, `${path}[]`])
|
|
56
|
+
break
|
|
57
|
+
case 'tuple':
|
|
58
|
+
for (const [i, child] of (def.items ?? []).entries()) {
|
|
59
|
+
children.push([child, `${path}[${i}]`])
|
|
60
|
+
}
|
|
61
|
+
if (def.rest) children.push([def.rest, `${path}[]`])
|
|
62
|
+
break
|
|
63
|
+
case 'union':
|
|
64
|
+
for (const [i, child] of (def.options ?? []).entries()) {
|
|
65
|
+
children.push([child, `${path}|${i}`])
|
|
66
|
+
}
|
|
67
|
+
break
|
|
68
|
+
case 'intersection':
|
|
69
|
+
children.push([def.left, path], [def.right, path])
|
|
70
|
+
break
|
|
71
|
+
case 'record':
|
|
72
|
+
case 'map':
|
|
73
|
+
children.push(
|
|
74
|
+
[def.keyType, `${path}[key]`],
|
|
75
|
+
[def.valueType, `${path}[]`]
|
|
76
|
+
)
|
|
77
|
+
break
|
|
78
|
+
case 'pipe':
|
|
79
|
+
// `.transform()` itself: report the pipe's own path, not `out`'s.
|
|
80
|
+
children.push([def.in, path], [def.out, path])
|
|
81
|
+
break
|
|
82
|
+
default:
|
|
83
|
+
// optional, nullable, default, prefault, nonoptional, readonly, catch,
|
|
84
|
+
// promise, lazy, success — every wrapper Zod spells `innerType`.
|
|
85
|
+
if (def.innerType) children.push([def.innerType, path])
|
|
86
|
+
if (def.getter) {
|
|
87
|
+
try {
|
|
88
|
+
children.push([def.getter(), path])
|
|
89
|
+
} catch {
|
|
90
|
+
// A lazy schema that cannot be forced tells us nothing; skip it.
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
for (const [child, childPath] of children) {
|
|
96
|
+
const found = walk(child, childPath)
|
|
97
|
+
if (found !== undefined) return found
|
|
98
|
+
}
|
|
99
|
+
return undefined
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return walk(schema, '')
|
|
103
|
+
}
|
|
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict'
|
|
|
3
3
|
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs'
|
|
4
4
|
import { tmpdir } from 'os'
|
|
5
5
|
import { join } from 'path'
|
|
6
|
+
import { fileURLToPath } from 'url'
|
|
6
7
|
|
|
7
8
|
import { getInitialInspectorState } from '../inspector.js'
|
|
8
9
|
import { generateAllSchemas, schemaRuntimeFile } from './schema-generator.js'
|
|
@@ -223,3 +224,49 @@ describe('schemaRuntimeFile', () => {
|
|
|
223
224
|
assert.equal(schemaRuntimeFile(orphan), orphan)
|
|
224
225
|
})
|
|
225
226
|
})
|
|
227
|
+
|
|
228
|
+
describe('a Zod schema that cannot be a wire contract', () => {
|
|
229
|
+
test('names .transform() and its own error code, not a downstream one', async () => {
|
|
230
|
+
// A transform used to surface as PKU724 — the *bad `#pikku/*` import* error
|
|
231
|
+
// — because the schema failed to convert, its generated leaf never got
|
|
232
|
+
// written, and the type that leaf feeds resolved to an error type. The
|
|
233
|
+
// message sent you to an import block that was fine.
|
|
234
|
+
// Inside the package, so the fixture's `import 'zod'` resolves the same
|
|
235
|
+
// way a project's would; a tmpdir has no node_modules above it.
|
|
236
|
+
const dir = mkdtempSync(
|
|
237
|
+
join(fileURLToPath(new URL('../../', import.meta.url)), '.zod-transform-')
|
|
238
|
+
)
|
|
239
|
+
try {
|
|
240
|
+
const file = join(dir, 'schema.ts')
|
|
241
|
+
writeFileSync(
|
|
242
|
+
file,
|
|
243
|
+
`import * as z from 'zod'\n` +
|
|
244
|
+
`export const CreateAssessment = z.object({ patientId: z.string(), when: z.string().transform((s) => new Date(s)) })\n`
|
|
245
|
+
)
|
|
246
|
+
const state = getInitialInspectorState(dir) as any
|
|
247
|
+
state.schemaLookup.set('CreateAssessmentInput', {
|
|
248
|
+
variableName: 'CreateAssessment',
|
|
249
|
+
sourceFile: file,
|
|
250
|
+
vendor: 'zod',
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
const errors: string[] = []
|
|
254
|
+
await assert.rejects(() =>
|
|
255
|
+
generateAllSchemas(
|
|
256
|
+
{ ...logger, error: (m: string) => errors.push(m) },
|
|
257
|
+
{ tsconfig: join(projectDir, 'tsconfig.json') },
|
|
258
|
+
state
|
|
259
|
+
)
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
const reported = errors.join('\n')
|
|
263
|
+
assert.match(reported, /PKU491/)
|
|
264
|
+
assert.match(reported, /CreateAssessmentInput/)
|
|
265
|
+
assert.match(reported, /\.transform\(\)/)
|
|
266
|
+
assert.match(reported, /\.when/)
|
|
267
|
+
assert.doesNotMatch(reported, /PKU724/)
|
|
268
|
+
} finally {
|
|
269
|
+
rmSync(dir, { recursive: true, force: true })
|
|
270
|
+
}
|
|
271
|
+
})
|
|
272
|
+
})
|
|
@@ -12,6 +12,7 @@ import { createGenerator, RootlessError } from 'ts-json-schema-generator'
|
|
|
12
12
|
import { register } from 'tsx/esm/api'
|
|
13
13
|
import * as z from 'zod'
|
|
14
14
|
import { zodToTs, createAuxiliaryTypeStore } from 'zod-to-ts'
|
|
15
|
+
import { findZodTransform } from './find-zod-transform.js'
|
|
15
16
|
import type { FunctionsMeta } from '@pikku/core/services'
|
|
16
17
|
import type { JSONValue } from '@pikku/core/utils'
|
|
17
18
|
import type { HTTPWiringsMeta } from '@pikku/core/http'
|
|
@@ -508,6 +509,19 @@ function processZodSchema(
|
|
|
508
509
|
fakeSourceFile: ts.SourceFile,
|
|
509
510
|
logger: InspectorLogger
|
|
510
511
|
): void {
|
|
512
|
+
const transformPath = findZodTransform(zodSchema)
|
|
513
|
+
if (transformPath !== undefined) {
|
|
514
|
+
const where = transformPath === '' ? 'the schema itself' : transformPath
|
|
515
|
+
throw new Error(
|
|
516
|
+
`[${ErrorCode.SCHEMA_TRANSFORM_NOT_SUPPORTED}] '${schemaName}' contains a .transform() at ${where}.\n` +
|
|
517
|
+
'A transform makes the value sent and the value received two different ' +
|
|
518
|
+
'shapes, and a wire contract describes one: it cannot be expressed in ' +
|
|
519
|
+
'JSON Schema or in the generated TypeScript.\n' +
|
|
520
|
+
'Validate with the plain schema and apply the transform inside the ' +
|
|
521
|
+
'function, or move it to the output type.'
|
|
522
|
+
)
|
|
523
|
+
}
|
|
524
|
+
|
|
511
525
|
const schema = z.toJSONSchema(zodSchema, {
|
|
512
526
|
unrepresentable: 'any',
|
|
513
527
|
override: ({ zodSchema, jsonSchema }) => {
|