zodvex 0.7.5 → 0.7.6

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.5",
3
+ "version": "0.7.6",
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",
@@ -408,7 +408,7 @@ export class ZodvexQueryChain<TableInfo extends GenericTableInfo, Doc = Document
408
408
  * If the table has a decoded type in DecodedDocs, use it.
409
409
  * Otherwise fall back to DocumentByInfo (wire types = runtime types for tables without codecs).
410
410
  */
411
- type ResolveDecodedDoc<
411
+ export type ResolveDecodedDoc<
412
412
  DataModel extends GenericDataModel,
413
413
  DecodedDocs extends Record<string, any>,
414
414
  TableName extends TableNamesInDataModel<DataModel>
@@ -0,0 +1,242 @@
1
+ import type {
2
+ DataModelFromSchemaDefinition,
3
+ DocumentByInfo,
4
+ GenericDatabaseReader,
5
+ GenericTableInfo,
6
+ IndexNames,
7
+ IndexRange,
8
+ NamedIndex,
9
+ NamedTableInfo,
10
+ SchemaDefinition,
11
+ TableNamesInDataModel
12
+ } from 'convex/server'
13
+ import { mergedStream, type QueryStream, stream } from 'convex-helpers/server/stream'
14
+ import type { ResolveDecodedDoc, ZodvexDatabaseReader, ZodvexIndexRangeBuilder } from './db'
15
+ import type { ZodTableMap } from './schema'
16
+ import {
17
+ $ZodCodec,
18
+ $ZodDefault,
19
+ $ZodNullable,
20
+ $ZodObject,
21
+ $ZodOptional,
22
+ $ZodType,
23
+ $ZodUnion
24
+ } from './zod-core'
25
+
26
+ /**
27
+ * Typed convex-helpers stream interop for the secure DatabaseReader (#78).
28
+ *
29
+ * convex-helpers' `stream()` / `mergedStream()` enable honest pagination over
30
+ * set-valued equality predicates (`roomId IN {a, b, c}`): fan out one
31
+ * substream per value over an index and k-way merge them with index-key
32
+ * cursors that stay valid across all substreams.
33
+ *
34
+ * `stream()` nominally requires a `GenericDatabaseReader`, which the zodvex
35
+ * secure reader doesn't implement (its `query()` returns the decoded chain).
36
+ * This module provides the typed entry point so call sites never cast:
37
+ *
38
+ * - The unavoidable cast lives in ONE audited place (`zodvexStream`), pinned
39
+ * by tests that exercise the duck-typed surface stream() relies on
40
+ * (`db.query(table).withIndex(...).order(...)` + async iteration), so a
41
+ * chain-surface change fails loudly in zodvex CI rather than silently
42
+ * downstream.
43
+ * - Item/page types are the DECODED doc types (codec outputs applied), which
44
+ * is what the zodvex chain actually yields — not convex-helpers' raw
45
+ * `DocumentByName` types.
46
+ *
47
+ * Rules semantics: streams are rules-preserving. Every streamed row flows
48
+ * through the secure chain (decode + read rules evaluated per row
49
+ * mid-stream). If a read rule denies a row inside a substream, the row is
50
+ * simply never yielded — the merged index-key cursor never includes it, so
51
+ * there are no holes and no stuck cursors. Rules act as a backstop; fan-out
52
+ * queries should already narrow to authorized index ranges.
53
+ */
54
+
55
+ /** Convex stream items must be non-nullish (mirrors convex-helpers' unexported GenericStreamItem). */
56
+ type StreamItem = NonNullable<unknown>
57
+
58
+ /**
59
+ * A stream of DECODED documents. Alias of convex-helpers' `QueryStream` with
60
+ * the item type corrected to what the zodvex chain actually yields, so
61
+ * `paginate()` / `collect()` / iteration return decoded docs.
62
+ */
63
+ export type ZodvexQueryStream<T extends StreamItem> = QueryStream<T>
64
+
65
+ /** Ordered stream over decoded documents — mergeable and paginatable. */
66
+ export interface ZodvexOrderedStreamQuery<T extends StreamItem> extends QueryStream<T> {}
67
+
68
+ /** Stream query with an index applied; `.order()` yields the mergeable stream. */
69
+ export interface ZodvexStreamQuery<T extends StreamItem> extends QueryStream<T> {
70
+ order(order: 'asc' | 'desc'): ZodvexOrderedStreamQuery<T>
71
+ }
72
+
73
+ /**
74
+ * Entry point for a single table's stream. Mirrors convex-helpers'
75
+ * `StreamQueryInitializer` surface, but `withIndex` uses zodvex's
76
+ * decoded-aware index range builder (codec fields accept decoded values,
77
+ * e.g. Date) and terminal types are decoded docs.
78
+ */
79
+ export interface ZodvexStreamQueryInitializer<
80
+ TableInfo extends GenericTableInfo,
81
+ T extends StreamItem
82
+ > extends ZodvexStreamQuery<T> {
83
+ fullTableScan(): ZodvexStreamQuery<T>
84
+ withIndex<IndexName extends IndexNames<TableInfo>>(
85
+ indexName: IndexName,
86
+ indexRange?: (
87
+ q: ZodvexIndexRangeBuilder<DocumentByInfo<TableInfo>, T, NamedIndex<TableInfo, IndexName>>
88
+ ) => IndexRange
89
+ ): ZodvexStreamQuery<T>
90
+ }
91
+
92
+ /** A defineZodSchema() result — a Convex schema carrying the zod table map. */
93
+ export type ZodvexStreamableSchema = SchemaDefinition<any, boolean> & {
94
+ __zodTableMap: ZodTableMap
95
+ }
96
+
97
+ /** Extracts the phantom decoded-doc map carried by defineZodSchema results. */
98
+ type DecodedDocsOf<Schema> = Schema extends { __decodedDocs: infer DD extends Record<string, any> }
99
+ ? DD
100
+ : Record<string, any>
101
+
102
+ /**
103
+ * The stream-flavored secure reader: same fluent surface as convex-helpers'
104
+ * `stream(db, schema)`, typed against decoded documents.
105
+ */
106
+ export interface ZodvexStreamDatabaseReader<Schema extends ZodvexStreamableSchema> {
107
+ query<TableName extends TableNamesInDataModel<DataModelFromSchemaDefinition<Schema>>>(
108
+ tableName: TableName
109
+ ): ZodvexStreamQueryInitializer<
110
+ NamedTableInfo<DataModelFromSchemaDefinition<Schema>, TableName>,
111
+ NonNullable<
112
+ ResolveDecodedDoc<DataModelFromSchemaDefinition<Schema>, DecodedDocsOf<Schema>, TableName>
113
+ >
114
+ >
115
+ }
116
+
117
+ /**
118
+ * Creates a typed convex-helpers stream over the zodvex secure reader —
119
+ * no cast at call sites.
120
+ *
121
+ * Every streamed row flows through the secure chain: codec decode plus any
122
+ * read rules / audit wrappers attached to the reader. Use with
123
+ * `zodvexMergedStream` to paginate fan-out queries over set-valued equality
124
+ * predicates.
125
+ *
126
+ * @example
127
+ * ```ts
128
+ * const substreams = rooms.map(roomId =>
129
+ * zodvexStream(ctx.db, schema)
130
+ * .query('visits')
131
+ * .withIndex('tenantId_roomId_status', q => q.eq('tenantId', tenantId).eq('roomId', roomId))
132
+ * .order('asc')
133
+ * )
134
+ * return zodvexMergedStream(substreams, ['status', '_creationTime']).paginate(opts)
135
+ * ```
136
+ */
137
+ export function zodvexStream<Schema extends ZodvexStreamableSchema>(
138
+ db: ZodvexDatabaseReader<DataModelFromSchemaDefinition<Schema>, any>,
139
+ schema: Schema
140
+ ): ZodvexStreamDatabaseReader<Schema> {
141
+ // THE one audited cast (#78). The secure reader doesn't nominally implement
142
+ // GenericDatabaseReader, but it is duck-type compatible with the surface
143
+ // stream() uses: db.query(table).withIndex(index, range).order(order) plus
144
+ // async iteration. That surface is pinned by __tests__/stream.test.ts.
145
+ const rawDb = db as unknown as GenericDatabaseReader<DataModelFromSchemaDefinition<Schema>>
146
+ return stream(rawDb, schema) as unknown as ZodvexStreamDatabaseReader<Schema>
147
+ }
148
+
149
+ /**
150
+ * Merge multiple zodvex streams into a single stream ordered by
151
+ * `orderByIndexFields`, suitable for `.paginate()` with cursors that stay
152
+ * valid across all substreams. Thin wrapper over convex-helpers'
153
+ * `mergedStream` that keeps decoded item types and rejects codec-backed
154
+ * merge keys.
155
+ *
156
+ * Codec-backed fields are FORBIDDEN in `orderByIndexFields`: the merge
157
+ * comparator reads index-key values off the yielded (decoded) documents,
158
+ * while the underlying Convex index is ordered by wire values — decoded
159
+ * comparisons can mis-order the merge and produce invalid cursors. Pin codec
160
+ * fields with `.eq()` inside each substream and order by non-codec fields
161
+ * (e.g. '_creationTime') instead.
162
+ */
163
+ export function zodvexMergedStream<T extends StreamItem>(
164
+ streams: ZodvexQueryStream<T>[],
165
+ orderByIndexFields: string[]
166
+ ): ZodvexQueryStream<T> {
167
+ assertNoCodecOrderFields(streams, orderByIndexFields)
168
+ return mergedStream(streams, orderByIndexFields)
169
+ }
170
+
171
+ /** Unwraps Optional/Nullable/Default wrappers to reach the structural type. */
172
+ function unwrapOuter(schema: $ZodType): $ZodType {
173
+ let current: $ZodType = schema
174
+ for (let i = 0; i < 10; i++) {
175
+ if (
176
+ current instanceof $ZodOptional ||
177
+ current instanceof $ZodNullable ||
178
+ current instanceof $ZodDefault
179
+ ) {
180
+ current = current._zod.def.innerType
181
+ continue
182
+ }
183
+ break
184
+ }
185
+ return current
186
+ }
187
+
188
+ /**
189
+ * Walks a (possibly dot-separated) field path through a doc schema and
190
+ * reports whether it crosses a codec boundary — i.e. whether the decoded
191
+ * value at that path differs from the wire value the index is ordered by.
192
+ * Union doc schemas (union tables) flag the field if ANY variant is
193
+ * codec-backed.
194
+ */
195
+ function fieldIsCodecBacked(schema: $ZodType, segments: string[]): boolean {
196
+ const current = unwrapOuter(schema)
197
+ if (current instanceof $ZodCodec) return true
198
+ if (segments.length === 0) return false
199
+ if (current instanceof $ZodObject) {
200
+ const fieldSchema = (current._zod.def.shape as Record<string, $ZodType | undefined>)[
201
+ segments[0]
202
+ ]
203
+ return fieldSchema ? fieldIsCodecBacked(fieldSchema, segments.slice(1)) : false
204
+ }
205
+ if (current instanceof $ZodUnion) {
206
+ const options = current._zod.def.options as readonly $ZodType[]
207
+ return options.some(option => fieldIsCodecBacked(option, segments))
208
+ }
209
+ return false
210
+ }
211
+
212
+ /**
213
+ * Best-effort guard: for every stream that exposes `reflect()` (any stream
214
+ * built via zodvexStream(...).query(...)), look up the table's doc schema in
215
+ * the zodvex table map and reject codec-backed `orderByIndexFields`.
216
+ * Derived streams (filterWith/map) don't reflect and pass through unchecked.
217
+ */
218
+ function assertNoCodecOrderFields(
219
+ streams: ZodvexQueryStream<any>[],
220
+ orderByIndexFields: string[]
221
+ ): void {
222
+ for (const s of streams) {
223
+ const reflect = (s as { reflect?: () => { schema?: unknown; table?: string } }).reflect
224
+ if (typeof reflect !== 'function') continue
225
+ const { schema, table } = reflect.call(s) ?? {}
226
+ const tableMap = (schema as { __zodTableMap?: ZodTableMap } | undefined)?.__zodTableMap
227
+ const docSchema = table ? tableMap?.[table]?.doc : undefined
228
+ if (!docSchema) continue
229
+ for (const field of orderByIndexFields) {
230
+ if (field === '_creationTime' || field === '_id') continue
231
+ if (fieldIsCodecBacked(docSchema, field.split('.'))) {
232
+ throw new Error(
233
+ `zodvexMergedStream: orderByIndexFields contains codec-backed field '${field}' ` +
234
+ `(table '${table}'). The merge comparator reads decoded values off yielded docs, ` +
235
+ `but the underlying index is ordered by wire values — codec-backed merge keys can ` +
236
+ `mis-order results and produce invalid cursors. Pin codec fields with .eq() inside ` +
237
+ `each substream and order by non-codec fields (e.g. '_creationTime') instead.`
238
+ )
239
+ }
240
+ }
241
+ }
242
+ }
@@ -77,5 +77,16 @@ export type {
77
77
  export * from '../../internal/schema'
78
78
  // Schema helpers (pure Zod, no server deps)
79
79
  export { addSystemFields } from '../../internal/schemaHelpers'
80
+ // Typed convex-helpers stream interop for the secure reader (#78)
81
+ export {
82
+ type ZodvexOrderedStreamQuery,
83
+ type ZodvexQueryStream,
84
+ type ZodvexStreamableSchema,
85
+ type ZodvexStreamDatabaseReader,
86
+ type ZodvexStreamQuery,
87
+ type ZodvexStreamQueryInitializer,
88
+ zodvexMergedStream,
89
+ zodvexStream
90
+ } from '../../internal/stream'
80
91
  // Table creation and helpers (named — hide union internals)
81
92
  export { zodDoc, zodDocOrNull, zodTable } from '../../legacy/tables'