zodvex 0.7.2-beta.0 → 0.7.2
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/dist/cli/index.js +85 -36
- package/dist/cli/index.js.map +1 -1
- package/dist/codegen/index.js +64 -29
- package/dist/codegen/index.js.map +1 -1
- package/dist/core/index.js +13 -2
- package/dist/core/index.js.map +1 -1
- package/dist/index.js +13 -2
- package/dist/index.js.map +1 -1
- package/dist/internal/meta.d.ts +11 -0
- package/dist/internal/meta.d.ts.map +1 -1
- package/dist/internal/zx.d.ts +14 -0
- package/dist/internal/zx.d.ts.map +1 -1
- package/dist/legacy/index.js +13 -2
- package/dist/legacy/index.js.map +1 -1
- package/dist/mini/index.js +15 -4
- package/dist/mini/index.js.map +1 -1
- package/dist/mini/server/index.js +13 -2
- package/dist/mini/server/index.js.map +1 -1
- package/dist/public/cli/commands.d.ts +12 -1
- package/dist/public/cli/commands.d.ts.map +1 -1
- package/dist/public/codegen/discover.d.ts +7 -9
- package/dist/public/codegen/discover.d.ts.map +1 -1
- package/dist/public/codegen/generate.d.ts.map +1 -1
- package/dist/public/mini/zx.d.ts +2 -0
- package/dist/public/mini/zx.d.ts.map +1 -1
- package/dist/server/index.js +13 -2
- package/dist/server/index.js.map +1 -1
- package/package.json +1 -1
- package/src/internal/meta.ts +26 -0
- package/src/internal/zx.ts +18 -2
- package/src/public/cli/commands.ts +37 -15
- package/src/public/codegen/discover.ts +8 -26
- package/src/public/codegen/generate.ts +93 -36
- package/src/public/mini/zx.ts +3 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zodvex",
|
|
3
|
-
"version": "0.7.2
|
|
3
|
+
"version": "0.7.2",
|
|
4
4
|
"description": "Codec-first Zod v4 integration for Convex -- type-safe validation, encoding, DB wrapping, and codegen.",
|
|
5
5
|
"keywords": ["zod", "convex", "validators", "codec", "mapping", "schema", "validation"],
|
|
6
6
|
"homepage": "https://github.com/panzacoder/zodvex#readme",
|
package/src/internal/meta.ts
CHANGED
|
@@ -40,3 +40,29 @@ export function readMeta(target: unknown): ZodvexMeta | undefined {
|
|
|
40
40
|
}
|
|
41
41
|
return (target as Record<string, unknown>)[META_KEY] as ZodvexMeta | undefined
|
|
42
42
|
}
|
|
43
|
+
|
|
44
|
+
const CODEC_BRAND_KEY = '__zodvexCodecBrand'
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Attaches a provenance brand to a codec instance. Codegen reads this at
|
|
48
|
+
* discovery time to match a function-embedded codec to its importable twin
|
|
49
|
+
* by *declared* identity instead of inferring it from structure. Stored
|
|
50
|
+
* non-enumerably so it never leaks into user data, and survives
|
|
51
|
+
* `.optional()` / `.nullable()` wrapping (codegen unwraps to the codec).
|
|
52
|
+
* See `docs/decisions/2026-06-08-codec-provenance-brands.md`.
|
|
53
|
+
*/
|
|
54
|
+
export function attachCodecBrand(target: object, brand: string): void {
|
|
55
|
+
Object.defineProperty(target, CODEC_BRAND_KEY, {
|
|
56
|
+
value: brand,
|
|
57
|
+
enumerable: false,
|
|
58
|
+
writable: false,
|
|
59
|
+
configurable: false
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Reads a codec's provenance brand, or undefined if unbranded. */
|
|
64
|
+
export function readCodecBrand(target: unknown): string | undefined {
|
|
65
|
+
if (target == null || typeof target !== 'object') return undefined
|
|
66
|
+
const value = (target as Record<string, unknown>)[CODEC_BRAND_KEY]
|
|
67
|
+
return typeof value === 'string' ? value : undefined
|
|
68
|
+
}
|
package/src/internal/zx.ts
CHANGED
|
@@ -24,6 +24,7 @@ import type { GenericId } from 'convex/values'
|
|
|
24
24
|
import { z } from 'zod'
|
|
25
25
|
import { zodvexCodec } from './codec'
|
|
26
26
|
import { registryHelpers } from './ids'
|
|
27
|
+
import { attachCodecBrand } from './meta'
|
|
27
28
|
import { createSchemaUpdateSchema } from './modelSchemaBundle'
|
|
28
29
|
import { addSystemFields } from './schemaHelpers'
|
|
29
30
|
import type { ZodvexCodec } from './types'
|
|
@@ -129,6 +130,13 @@ function id<TableName extends string>(tableName: TableName): ZxId<TableName> {
|
|
|
129
130
|
* @param wire - Zod schema for the wire format (stored in Convex)
|
|
130
131
|
* @param runtime - Zod schema for the runtime format (used in code)
|
|
131
132
|
* @param transforms - Encode/decode functions
|
|
133
|
+
* @param opts.brand - Optional provenance brand. When set, codegen matches a
|
|
134
|
+
* function-embedded instance of this codec to its importable twin (an
|
|
135
|
+
* exported const or model field with the same brand) by *declared* identity
|
|
136
|
+
* rather than inferring it from structure — collision-free and namespaced
|
|
137
|
+
* across factories. Useful for codec factories like `tagged()` / `sensitive()`
|
|
138
|
+
* whose every call returns a fresh instance. See
|
|
139
|
+
* `docs/decisions/2026-06-08-codec-provenance-brands.md`.
|
|
132
140
|
*
|
|
133
141
|
* @example
|
|
134
142
|
* ```typescript
|
|
@@ -141,6 +149,11 @@ function id<TableName extends string>(tableName: TableName): ZxId<TableName> {
|
|
|
141
149
|
* encode: (value) => ({ encrypted: encrypt(value) })
|
|
142
150
|
* }
|
|
143
151
|
* )
|
|
152
|
+
*
|
|
153
|
+
* // Branded factory codec — codegen matches inline instances by brand
|
|
154
|
+
* function tagged(inner, name) {
|
|
155
|
+
* return zx.codec(wire(inner), runtime(inner), transforms, { brand: `tagged:${name}` })
|
|
156
|
+
* }
|
|
144
157
|
* ```
|
|
145
158
|
*/
|
|
146
159
|
function codec<W extends $ZodType, R extends $ZodType, WO = zoutput<W>, RI = zoutput<R>>(
|
|
@@ -149,9 +162,12 @@ function codec<W extends $ZodType, R extends $ZodType, WO = zoutput<W>, RI = zou
|
|
|
149
162
|
transforms: {
|
|
150
163
|
decode: (wire: WO) => RI
|
|
151
164
|
encode: (runtime: RI) => WO
|
|
152
|
-
}
|
|
165
|
+
},
|
|
166
|
+
opts?: { brand?: string }
|
|
153
167
|
): FullZodvexCodec<W, R> {
|
|
154
|
-
|
|
168
|
+
const built = zodvexCodec(wire, runtime, transforms) as unknown as FullZodvexCodec<W, R>
|
|
169
|
+
if (opts?.brand) attachCodecBrand(built as object, opts.brand)
|
|
170
|
+
return built
|
|
155
171
|
}
|
|
156
172
|
|
|
157
173
|
/**
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
1
2
|
import fs from 'node:fs'
|
|
2
3
|
import path from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
3
5
|
import { discoverModules } from '../codegen/discover'
|
|
4
6
|
import {
|
|
5
7
|
generateApiFile,
|
|
@@ -11,10 +13,7 @@ import {
|
|
|
11
13
|
/**
|
|
12
14
|
* One-shot codegen. Discovers modules, generates files.
|
|
13
15
|
*/
|
|
14
|
-
export async function generate(
|
|
15
|
-
convexDir?: string,
|
|
16
|
-
options?: { mini?: boolean; freshImports?: boolean }
|
|
17
|
-
): Promise<void> {
|
|
16
|
+
export async function generate(convexDir?: string, options?: { mini?: boolean }): Promise<void> {
|
|
18
17
|
const resolved = resolveConvexDir(convexDir)
|
|
19
18
|
const zodvexDir = path.join(resolved, '_zodvex')
|
|
20
19
|
|
|
@@ -23,7 +22,7 @@ export async function generate(
|
|
|
23
22
|
// codegen can't discover those modules — a chicken-and-egg problem.
|
|
24
23
|
writeStubApi(zodvexDir)
|
|
25
24
|
|
|
26
|
-
const result = await discoverModules(resolved
|
|
25
|
+
const result = await discoverModules(resolved)
|
|
27
26
|
|
|
28
27
|
const schemaContent = generateSchemaFile(result.models)
|
|
29
28
|
const apiContent = generateApiFile(
|
|
@@ -77,17 +76,14 @@ export async function dev(convexDir?: string, options?: { mini?: boolean }): Pro
|
|
|
77
76
|
}
|
|
78
77
|
|
|
79
78
|
if (debounceTimer) clearTimeout(debounceTimer)
|
|
80
|
-
debounceTimer = setTimeout(
|
|
79
|
+
debounceTimer = setTimeout(() => {
|
|
81
80
|
console.log('[zodvex] Regenerating...')
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
} catch (err) {
|
|
89
|
-
console.error('[zodvex] Generation failed:', (err as Error).message)
|
|
90
|
-
}
|
|
81
|
+
// Spawn a fresh `generate` subprocess rather than regenerating in-process.
|
|
82
|
+
// A long-lived watcher can't reliably re-import edited modules: Bun's
|
|
83
|
+
// loader caches ESM by resolved path and ignores query-string busting, so
|
|
84
|
+
// an in-process regen emits stale output. A fresh process starts with an
|
|
85
|
+
// empty module cache and always sees the latest source.
|
|
86
|
+
void regenerate(resolved, options)
|
|
91
87
|
}, 300)
|
|
92
88
|
})
|
|
93
89
|
|
|
@@ -99,6 +95,32 @@ export async function dev(convexDir?: string, options?: { mini?: boolean }): Pro
|
|
|
99
95
|
})
|
|
100
96
|
}
|
|
101
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Regenerate in a fresh subprocess. The dev watcher cannot re-import edited
|
|
100
|
+
* modules in-process under Bun (it caches ESM by resolved path and ignores
|
|
101
|
+
* query-string cache-busting), so each change spawns a one-shot `zodvex
|
|
102
|
+
* generate`, which starts with an empty module cache and always sees the
|
|
103
|
+
* latest source. Runtime-agnostic by construction.
|
|
104
|
+
*
|
|
105
|
+
* @internal Exported for tests; not part of the public API.
|
|
106
|
+
*/
|
|
107
|
+
export function regenerate(resolved: string, options?: { mini?: boolean }): Promise<void> {
|
|
108
|
+
const cliEntry = fileURLToPath(new URL('./index.js', import.meta.url))
|
|
109
|
+
const args = [cliEntry, 'generate', resolved]
|
|
110
|
+
if (options?.mini) args.push('--mini')
|
|
111
|
+
return new Promise(resolve => {
|
|
112
|
+
const child = spawn(process.execPath, args, { stdio: 'inherit' })
|
|
113
|
+
child.on('exit', code => {
|
|
114
|
+
if (code !== 0) console.error(`[zodvex] Regeneration exited with code ${code}`)
|
|
115
|
+
resolve()
|
|
116
|
+
})
|
|
117
|
+
child.on('error', err => {
|
|
118
|
+
console.error('[zodvex] Failed to spawn regeneration:', err.message)
|
|
119
|
+
resolve()
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
102
124
|
/** Writes minimal stub _zodvex/api.js + api.d.ts before discovery to break circular imports.
|
|
103
125
|
* Previous generations may contain stale imports that cause cycles during re-discovery. */
|
|
104
126
|
function writeStubApi(zodvexDir: string): void {
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import path from 'node:path'
|
|
2
|
-
import { pathToFileURL } from 'node:url'
|
|
3
2
|
import { globSync } from 'tinyglobby'
|
|
4
3
|
import { z } from 'zod'
|
|
5
4
|
import { readMeta, type ZodvexFunctionMeta, type ZodvexModelMeta } from '../../internal/meta'
|
|
@@ -21,10 +20,6 @@ import { zx } from '../../internal/zx'
|
|
|
21
20
|
import { registerDiscoveryHooks, writeGeneratedStubs } from './discovery-hooks'
|
|
22
21
|
import { findCodec } from './extractCodec'
|
|
23
22
|
|
|
24
|
-
// Per-process counter for cache-busting dynamic imports across successive
|
|
25
|
-
// discoverModules() calls. See the comment at the import call site below.
|
|
26
|
-
let discoveryRunCounter = 0
|
|
27
|
-
|
|
28
23
|
export type DiscoveredModel = {
|
|
29
24
|
exportName: string
|
|
30
25
|
tableName: string
|
|
@@ -272,18 +267,15 @@ export function walkFunctionCodecs(functions: DiscoveredFunction[]): FunctionEmb
|
|
|
272
267
|
* Imports each .ts/.js file, reads __zodvexMeta from exports,
|
|
273
268
|
* and builds a registry of models and functions.
|
|
274
269
|
*
|
|
270
|
+
* Each file is imported once per process. Watch mode (`zodvex dev`) does NOT
|
|
271
|
+
* re-import in-process between edits — query-string cache-busting is a
|
|
272
|
+
* Node-only trick that Bun's loader ignores (Bun caches ESM modules by
|
|
273
|
+
* resolved path), so the watcher spawns a fresh `generate` subprocess per
|
|
274
|
+
* change instead. See `regenerate()` in cli/commands.ts.
|
|
275
|
+
*
|
|
275
276
|
* @param convexDir Absolute path to the convex directory to scan.
|
|
276
|
-
* @param options.freshImports If true, append a per-run query string to each
|
|
277
|
-
* dynamic import so the ESM cache returns a fresh module record each call.
|
|
278
|
-
* Required for `zodvex dev` watch mode to pick up edits between regens.
|
|
279
|
-
* One-shot `generate` doesn't need it (each file is imported once per
|
|
280
|
-
* process). Default false — opt-in to avoid double-loading under test
|
|
281
|
-
* runners that intercept dynamic imports (Vitest/Vite SSR).
|
|
282
277
|
*/
|
|
283
|
-
export async function discoverModules(
|
|
284
|
-
convexDir: string,
|
|
285
|
-
options?: { freshImports?: boolean }
|
|
286
|
-
): Promise<DiscoveryResult> {
|
|
278
|
+
export async function discoverModules(convexDir: string): Promise<DiscoveryResult> {
|
|
287
279
|
const models: DiscoveredModel[] = []
|
|
288
280
|
const functions: DiscoveredFunction[] = []
|
|
289
281
|
const codecs: DiscoveredCodec[] = []
|
|
@@ -314,23 +306,13 @@ export async function discoverModules(
|
|
|
314
306
|
]
|
|
315
307
|
}).sort()
|
|
316
308
|
|
|
317
|
-
// See the freshImports JSDoc. Watch mode opts in; one-shot generate skips
|
|
318
|
-
// the query string so test runners that intercept dynamic imports (Vitest
|
|
319
|
-
// / Vite SSR) don't double-load and create cross-instance Zod confusion.
|
|
320
|
-
let cacheKey: string | null = null
|
|
321
|
-
if (options?.freshImports) {
|
|
322
|
-
discoveryRunCounter += 1
|
|
323
|
-
cacheKey = `${process.pid}-${discoveryRunCounter}`
|
|
324
|
-
}
|
|
325
|
-
|
|
326
309
|
try {
|
|
327
310
|
for (const file of files) {
|
|
328
311
|
const absPath = path.resolve(convexDir, file)
|
|
329
|
-
const importUrl = cacheKey ? `${pathToFileURL(absPath).href}?t=${cacheKey}` : absPath
|
|
330
312
|
|
|
331
313
|
let moduleExports: Record<string, unknown>
|
|
332
314
|
try {
|
|
333
|
-
moduleExports = await import(
|
|
315
|
+
moduleExports = await import(absPath)
|
|
334
316
|
} catch (err) {
|
|
335
317
|
console.warn(`[zodvex] Warning: Failed to import ${file}:`, (err as Error).message)
|
|
336
318
|
continue
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readCodecBrand } from '../../internal/meta'
|
|
1
2
|
import {
|
|
2
3
|
$ZodCodec,
|
|
3
4
|
$ZodNullable,
|
|
@@ -32,10 +33,24 @@ export type GeneratedFile = { js: string; dts: string }
|
|
|
32
33
|
* land on distinct fingerprints. Without this, codecs that differ only by
|
|
33
34
|
* a constraint collide and the ambiguity-resolution path can't tell them
|
|
34
35
|
* apart by structure alone.
|
|
36
|
+
*
|
|
37
|
+
* The transform bodies (decode/encode) are folded in too: two codecs with the
|
|
38
|
+
* same wire+runtime shape but different transform logic must NOT share a
|
|
39
|
+
* fingerprint, or the ambiguity path could reference a behaviorally-wrong
|
|
40
|
+
* codec. `.toString()` discriminates transform source (a residual gap remains
|
|
41
|
+
* for identical source with different captured closure config — documented).
|
|
35
42
|
*/
|
|
36
43
|
function fingerprintCodec(schema: $ZodType): string {
|
|
37
44
|
if (!(schema instanceof $ZodCodec)) return ''
|
|
38
|
-
|
|
45
|
+
const def = schema._zod.def as {
|
|
46
|
+
in: $ZodType
|
|
47
|
+
out: $ZodType
|
|
48
|
+
transform?: unknown
|
|
49
|
+
reverseTransform?: unknown
|
|
50
|
+
}
|
|
51
|
+
const transform = typeof def.transform === 'function' ? def.transform.toString() : ''
|
|
52
|
+
const reverse = typeof def.reverseTransform === 'function' ? def.reverseTransform.toString() : ''
|
|
53
|
+
return `${fingerprintLeaf(def.in)}|${fingerprintLeaf(def.out)}|${transform}|${reverse}`
|
|
39
54
|
}
|
|
40
55
|
|
|
41
56
|
function fingerprintLeaf(schema: $ZodType): string {
|
|
@@ -386,19 +401,29 @@ export function generateApiFile(
|
|
|
386
401
|
|
|
387
402
|
// Resolve function-embedded codecs against existing codecs by structural fingerprint.
|
|
388
403
|
// Factory-created codecs (like tagged(), encrypted()) produce new instances each call,
|
|
389
|
-
// so identity matching fails. Fingerprinting matches by wire+runtime
|
|
404
|
+
// so identity matching fails. Fingerprinting matches by wire+runtime+transform,
|
|
390
405
|
// allowing us to reference the model/standalone codec instead of importing the function.
|
|
391
406
|
// This avoids circular imports: functions.ts → _zodvex/api.ts → functionFile.ts → functions.ts
|
|
392
407
|
//
|
|
393
|
-
// Ambiguity handling: when multiple codecs share a fingerprint,
|
|
394
|
-
//
|
|
395
|
-
//
|
|
396
|
-
//
|
|
397
|
-
//
|
|
398
|
-
//
|
|
408
|
+
// Ambiguity handling: when multiple codecs share a fingerprint they are, by the
|
|
409
|
+
// fingerprint contract, behaviorally interchangeable — so any one is a correct
|
|
410
|
+
// reference. We prefer a same-source-file candidate, otherwise we pick the
|
|
411
|
+
// stable-sorted-first so output is byte-identical across discovery orders
|
|
412
|
+
// (hotpot MR 206). We MUST NOT fall through to inline serialization: a codec
|
|
413
|
+
// inlined via zodToSource loses its transform (emits the wire schema only),
|
|
414
|
+
// which silently breaks the client encode/decode path with no build signal.
|
|
415
|
+
//
|
|
416
|
+
// Zero candidates means the codec exists only inside the function (no model
|
|
417
|
+
// twin, not exported standalone) and has no importable reference. We collect
|
|
418
|
+
// every such case and hard-error after the loop — a loud build-time failure
|
|
419
|
+
// beats silent boundary corruption.
|
|
399
420
|
if (functionCodecs) {
|
|
400
|
-
type
|
|
401
|
-
const fingerprintMap = new Map<string,
|
|
421
|
+
type Candidate = { ref: CodecRef; sourceFile: string | undefined; brand: string | undefined }
|
|
422
|
+
const fingerprintMap = new Map<string, Candidate[]>()
|
|
423
|
+
// brand → importable candidates that declared it. Brand is the author's
|
|
424
|
+
// explicit identity (see docs/decisions/2026-06-08-codec-provenance-brands.md),
|
|
425
|
+
// matched ahead of the structural fingerprint and namespaced across factories.
|
|
426
|
+
const brandMap = new Map<string, Candidate[]>()
|
|
402
427
|
|
|
403
428
|
// Build a lookup from codec schema → source file when we know it. Standalone
|
|
404
429
|
// exported codecs carry a sourceFile in their CodecRef; model-embedded codecs
|
|
@@ -411,56 +436,88 @@ export function generateApiFile(
|
|
|
411
436
|
codecSchemaToSourceFile.set(mc.codec, mc.modelSourceFile)
|
|
412
437
|
}
|
|
413
438
|
|
|
414
|
-
// codecMap iteration: insertion order. With sorted input collections
|
|
415
|
-
// above, this is already deterministic — but we still need ambiguity
|
|
416
|
-
// tracking so we don't silently let "last wins" pick a random candidate.
|
|
417
439
|
for (const [codecSchema, ref] of codecMap) {
|
|
418
|
-
const fp = fingerprintCodec(codecSchema)
|
|
419
|
-
if (!fp) continue
|
|
420
440
|
const sourceFile = codecSchemaToSourceFile.get(codecSchema)
|
|
421
|
-
const
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
fingerprintMap.
|
|
441
|
+
const brand = readCodecBrand(codecSchema)
|
|
442
|
+
const candidate: Candidate = { ref, sourceFile, brand }
|
|
443
|
+
const fp = fingerprintCodec(codecSchema)
|
|
444
|
+
if (fp) {
|
|
445
|
+
const existing = fingerprintMap.get(fp)
|
|
446
|
+
if (existing) existing.push(candidate)
|
|
447
|
+
else fingerprintMap.set(fp, [candidate])
|
|
448
|
+
}
|
|
449
|
+
if (brand) {
|
|
450
|
+
const existing = brandMap.get(brand)
|
|
451
|
+
if (existing) existing.push(candidate)
|
|
452
|
+
else brandMap.set(brand, [candidate])
|
|
426
453
|
}
|
|
427
454
|
}
|
|
428
455
|
|
|
456
|
+
const undiscoverable: { fn: string; path: string }[] = []
|
|
457
|
+
|
|
429
458
|
for (const fc of functionCodecs) {
|
|
430
459
|
if (codecMap.has(fc.codec)) continue
|
|
431
460
|
|
|
432
|
-
|
|
433
|
-
|
|
461
|
+
// 1. Brand match — declared identity. Collision-free and namespaced: a
|
|
462
|
+
// branded codec resolves only against codecs that declared the same brand.
|
|
463
|
+
const brand = readCodecBrand(fc.codec)
|
|
464
|
+
let candidates: Candidate[] | undefined = brand ? brandMap.get(brand) : undefined
|
|
465
|
+
|
|
466
|
+
// 2. Fingerprint fallback — structural identity. Exclude any candidate that
|
|
467
|
+
// declared a *different* brand (a cross-factory match would violate the
|
|
468
|
+
// brand's intent); unbranded candidates are always eligible.
|
|
434
469
|
if (!candidates || candidates.length === 0) {
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
470
|
+
const fp = fingerprintCodec(fc.codec)
|
|
471
|
+
candidates = (fp ? fingerprintMap.get(fp) : undefined)?.filter(
|
|
472
|
+
c => c.brand === undefined || c.brand === brand
|
|
438
473
|
)
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (!candidates || candidates.length === 0) {
|
|
477
|
+
undiscoverable.push({ fn: fc.functionExportName, path: fc.accessPath })
|
|
439
478
|
continue
|
|
440
479
|
}
|
|
441
480
|
|
|
442
|
-
let chosen: CodecRef
|
|
481
|
+
let chosen: CodecRef
|
|
443
482
|
if (candidates.length === 1) {
|
|
444
483
|
chosen = candidates[0].ref
|
|
445
484
|
} else {
|
|
446
|
-
//
|
|
447
|
-
//
|
|
448
|
-
//
|
|
485
|
+
// Prefer a unique same-source-file candidate; otherwise pick the
|
|
486
|
+
// stable-sorted-first. Either way we reference a real codec — never
|
|
487
|
+
// inline a transform-less husk.
|
|
449
488
|
const sameFile = candidates.filter(c => c.sourceFile === fc.functionSourceFile)
|
|
450
489
|
if (sameFile.length === 1) {
|
|
451
490
|
chosen = sameFile[0].ref
|
|
452
491
|
} else {
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
492
|
+
const sorted = [...candidates].sort((a, b) =>
|
|
493
|
+
`${a.sourceFile ?? ''}|${a.ref.exportName}`.localeCompare(
|
|
494
|
+
`${b.sourceFile ?? ''}|${b.ref.exportName}`
|
|
495
|
+
)
|
|
457
496
|
)
|
|
497
|
+
chosen = sorted[0].ref
|
|
498
|
+
// Branded cohorts are interchangeable by the author's explicit
|
|
499
|
+
// declaration — only flag ambiguity for inferred (unbranded) matches.
|
|
500
|
+
if (!brand) {
|
|
501
|
+
console.warn(
|
|
502
|
+
`[zodvex] Note: Codec in ${fc.functionExportName}() (${fc.accessPath}) matches ${candidates.length} fingerprint-equivalent codecs ` +
|
|
503
|
+
`(${candidates.map(c => c.ref.exportName).join(', ')}). Referencing '${chosen.exportName}'. ` +
|
|
504
|
+
`Export the codec standalone or give it a brand if you want the reference to be explicit.`
|
|
505
|
+
)
|
|
506
|
+
}
|
|
458
507
|
}
|
|
459
508
|
}
|
|
460
509
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
510
|
+
codecMap.set(fc.codec, chosen)
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (undiscoverable.length > 0) {
|
|
514
|
+
const list = undiscoverable.map(u => ` - ${u.fn}() at ${u.path}`).join('\n')
|
|
515
|
+
throw new Error(
|
|
516
|
+
`[zodvex] ${undiscoverable.length} codec(s) in function args/returns have no importable reference ` +
|
|
517
|
+
`(not exported standalone, not embedded in a model). The generated client cannot encode or decode ` +
|
|
518
|
+
`them, which silently breaks the codec boundary. Export each codec standalone (or add it to a model) ` +
|
|
519
|
+
`so codegen can import it:\n${list}`
|
|
520
|
+
)
|
|
464
521
|
}
|
|
465
522
|
}
|
|
466
523
|
|
package/src/public/mini/zx.ts
CHANGED
|
@@ -27,9 +27,10 @@ function codec<W extends $ZodType, R extends $ZodType>(
|
|
|
27
27
|
transforms: {
|
|
28
28
|
decode: (wire: any) => any
|
|
29
29
|
encode: (runtime: any) => any
|
|
30
|
-
}
|
|
30
|
+
},
|
|
31
|
+
opts?: { brand?: string }
|
|
31
32
|
): ZodvexCodec<W, R> {
|
|
32
|
-
return _zx.codec(wire, runtime, transforms) as unknown as ZodvexCodec<W, R>
|
|
33
|
+
return _zx.codec(wire, runtime, transforms, opts) as unknown as ZodvexCodec<W, R>
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
/** The zx namespace typed for zod-mini compatibility. */
|