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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zodvex",
3
- "version": "0.7.2-beta.0",
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",
@@ -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
+ }
@@ -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
- return zodvexCodec(wire, runtime, transforms) as unknown as FullZodvexCodec<W, R>
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, { freshImports: options?.freshImports })
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(async () => {
79
+ debounceTimer = setTimeout(() => {
81
80
  console.log('[zodvex] Regenerating...')
82
- try {
83
- // freshImports: bust the ESM module cache so the regen sees the edit
84
- // that triggered this tick. Without it, Node/Bun returns the cached
85
- // module record from the first generate() call and the output looks
86
- // stale even though the watcher fired.
87
- await generate(resolved, { ...options, freshImports: true })
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(importUrl)
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
- return `${fingerprintLeaf(schema._zod.def.in)}|${fingerprintLeaf(schema._zod.def.out)}`
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 schema structure,
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, we cannot
394
- // pick "the right one" by insertion order that's how the same source file
395
- // produced different output on macOS vs Linux (hotpot MR 206). Instead we
396
- // track all candidates, then per function-codec lookup we prefer a
397
- // same-source-file candidate when one exists, otherwise we skip the reuse
398
- // (the codec falls through to inline serialization).
408
+ // Ambiguity handling: when multiple codecs share a fingerprint they are, by the
409
+ // fingerprint contract, behaviorally interchangeableso 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 FingerprintCandidate = { ref: CodecRef; sourceFile: string | undefined }
401
- const fingerprintMap = new Map<string, FingerprintCandidate[]>()
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 existing = fingerprintMap.get(fp)
422
- if (existing) {
423
- existing.push({ ref, sourceFile })
424
- } else {
425
- fingerprintMap.set(fp, [{ ref, sourceFile }])
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
- const fp = fingerprintCodec(fc.codec)
433
- const candidates = fp ? fingerprintMap.get(fp) : undefined
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
- console.warn(
436
- `[zodvex] Warning: Codec in ${fc.functionExportName}() (${fc.accessPath}) has no matching model or exported codec. ` +
437
- `Export it standalone for full client-side codec support.`
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 | undefined
481
+ let chosen: CodecRef
443
482
  if (candidates.length === 1) {
444
483
  chosen = candidates[0].ref
445
484
  } else {
446
- // Multiple candidates pick one only if exactly one has the same
447
- // source file as the function. Otherwise leave it for inline
448
- // serialization so we don't gamble on insertion order.
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
- console.warn(
454
- `[zodvex] Warning: Codec in ${fc.functionExportName}() (${fc.accessPath}) matches ${candidates.length} candidates with the same fingerprint ` +
455
- `(${candidates.map(c => c.ref.exportName).join(', ')}). Cannot pick a canonical reference — emitting inline. ` +
456
- `Export the codec standalone to disambiguate.`
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
- if (chosen) {
462
- codecMap.set(fc.codec, chosen)
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
 
@@ -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. */