@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 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
@@ -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({ ... })`, `ban()`, or `plugins.bearer()` through a
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 `ban()`.
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 `ban()` keeps its ban columns and its ban
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 ban() instead:
163
+ Use pikkuBan() instead:
164
164
 
165
- import { ban } from '@pikku/better-auth'
166
- betterAuth({ plugins: [ban()] })
165
+ import { pikkuBan } from '@pikku/better-auth'
166
+ betterAuth({ plugins: [pikkuBan()] })
167
167
 
168
- ban() keeps the part of admin() that pikku cannot supply from outside
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
@@ -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",
@@ -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.67",
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.97"
48
+ "@pikku/core": "^0.12.100"
49
49
  },
50
50
  "devDependencies": {
51
- "@pikku/core": "^0.12.97",
51
+ "@pikku/core": "^0.12.100",
52
52
  "@types/node": "^24.13.3"
53
53
  },
54
54
  "engines": {
@@ -217,7 +217,7 @@ describe('addAuth inspector', () => {
217
217
  }
218
218
  })
219
219
 
220
- test('refuses better-auth admin() and points at ban()', async () => {
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, /ban\(\)/)
244
+ assert.match(error.message, /pikkuBan\(\)/)
245
245
  assert.match(error.message, /@pikku\/addon-admin/)
246
246
  return true
247
247
  }
@@ -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({ ... })`, `ban()`, or `plugins.bearer()` through a
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 `ban()`.
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 `ban()` keeps its ban columns and its ban
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 ban() instead:
210
+ Use pikkuBan() instead:
211
211
 
212
- import { ban } from '@pikku/better-auth'
213
- betterAuth({ plugins: [ban()] })
212
+ import { pikkuBan } from '@pikku/better-auth'
213
+ betterAuth({ plugins: [pikkuBan()] })
214
214
 
215
- ban() keeps the part of admin() that pikku cannot supply from outside
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
@@ -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 }) => {