@pyreon/feature 0.0.1

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/src/schema.ts ADDED
@@ -0,0 +1,334 @@
1
+ /**
2
+ * Schema introspection utilities.
3
+ *
4
+ * Extracts field names, types, and metadata from Zod schemas at runtime
5
+ * without importing Zod types directly (duck-typed).
6
+ */
7
+
8
+ export interface FieldInfo {
9
+ /** Field name (key in the schema object). */
10
+ name: string
11
+ /** Inferred type: 'string' | 'number' | 'boolean' | 'date' | 'enum' | 'array' | 'object' | 'reference' | 'unknown'. */
12
+ type: FieldType
13
+ /** Whether the field is optional. */
14
+ optional: boolean
15
+ /** For enum fields, the list of allowed values. */
16
+ enumValues?: (string | number)[]
17
+ /** For reference fields, the name of the referenced feature. */
18
+ referenceTo?: string
19
+ /** Human-readable label derived from field name. */
20
+ label: string
21
+ }
22
+
23
+ export type FieldType =
24
+ | 'string'
25
+ | 'number'
26
+ | 'boolean'
27
+ | 'date'
28
+ | 'enum'
29
+ | 'array'
30
+ | 'object'
31
+ | 'reference'
32
+ | 'unknown'
33
+
34
+ /** Symbol used to tag reference schema objects. */
35
+ const REFERENCE_TAG = Symbol.for('pyreon:feature:reference')
36
+
37
+ /**
38
+ * Metadata carried by a reference schema.
39
+ */
40
+ export interface ReferenceSchema {
41
+ /** Marker symbol for detection. */
42
+ [key: symbol]: true
43
+ /** Name of the referenced feature. */
44
+ _featureName: string
45
+ /** Duck-typed Zod-like interface: validates as string | number. */
46
+ safeParse: (value: unknown) => {
47
+ success: boolean
48
+ error?: { issues: { message: string }[] }
49
+ }
50
+ /** Async variant for compatibility. */
51
+ safeParseAsync: (
52
+ value: unknown,
53
+ ) => Promise<{ success: boolean; error?: { issues: { message: string }[] } }>
54
+ /** Shape-like marker for schema introspection. */
55
+ _def: { typeName: string }
56
+ }
57
+
58
+ /**
59
+ * Check if a value is a reference schema created by `reference()`.
60
+ */
61
+ export function isReference(value: unknown): value is ReferenceSchema {
62
+ return (
63
+ value !== null &&
64
+ typeof value === 'object' &&
65
+ (value as Record<symbol, unknown>)[REFERENCE_TAG] === true
66
+ )
67
+ }
68
+
69
+ /**
70
+ * Create a reference field that links to another feature.
71
+ *
72
+ * Returns a Zod-compatible schema that validates as `string | number` and
73
+ * carries metadata about the referenced feature for form dropdowns and table links.
74
+ *
75
+ * @example
76
+ * ```ts
77
+ * import { defineFeature, reference } from '@pyreon/feature'
78
+ *
79
+ * const posts = defineFeature({
80
+ * name: 'posts',
81
+ * schema: z.object({
82
+ * title: z.string(),
83
+ * authorId: reference(users),
84
+ * }),
85
+ * api: '/api/posts',
86
+ * })
87
+ * ```
88
+ */
89
+ export function reference(feature: { name: string }): ReferenceSchema {
90
+ const featureName = feature.name
91
+
92
+ function validateRef(value: unknown): {
93
+ success: boolean
94
+ error?: { issues: { message: string }[] }
95
+ } {
96
+ if (typeof value === 'string' || typeof value === 'number') {
97
+ return { success: true }
98
+ }
99
+ return {
100
+ success: false,
101
+ error: {
102
+ issues: [
103
+ {
104
+ message: `Expected string or number reference to ${featureName}, got ${typeof value}`,
105
+ },
106
+ ],
107
+ },
108
+ }
109
+ }
110
+
111
+ return {
112
+ [REFERENCE_TAG]: true,
113
+ _featureName: featureName,
114
+ safeParse: validateRef,
115
+ safeParseAsync: async (value: unknown) => validateRef(value),
116
+ _def: { typeName: 'ZodString' },
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Convert a field name to a human-readable label.
122
+ * e.g., 'firstName' → 'First Name', 'created_at' → 'Created At'
123
+ */
124
+ function nameToLabel(name: string): string {
125
+ return name
126
+ .replace(/([a-z])([A-Z])/g, '$1 $2') // camelCase → camel Case
127
+ .replace(/[_-]/g, ' ') // snake_case/kebab-case → spaces
128
+ .replace(/\b\w/g, (c) => c.toUpperCase()) // capitalize words
129
+ }
130
+
131
+ /**
132
+ * Detect the field type from a Zod schema shape entry.
133
+ * Duck-typed — works with Zod v3 and v4 without importing Zod.
134
+ */
135
+ function detectFieldType(zodField: unknown): {
136
+ type: FieldType
137
+ optional: boolean
138
+ enumValues?: (string | number)[]
139
+ referenceTo?: string
140
+ } {
141
+ // Check for reference fields first
142
+ if (isReference(zodField)) {
143
+ return {
144
+ type: 'reference',
145
+ optional: false,
146
+ referenceTo: zodField._featureName,
147
+ }
148
+ }
149
+
150
+ if (!zodField || typeof zodField !== 'object') {
151
+ return { type: 'unknown', optional: false }
152
+ }
153
+
154
+ const field = zodField as Record<string, unknown>
155
+
156
+ // Check for optional wrapper (ZodOptional or ZodNullable)
157
+ let inner = field
158
+ let optional = false
159
+
160
+ // Zod v3: _def.typeName, Zod v4: _zod.def.type
161
+ const getTypeName = (obj: Record<string, unknown>): string | undefined => {
162
+ // v3 path
163
+ const def = obj._def as Record<string, unknown> | undefined
164
+ if (def?.typeName && typeof def.typeName === 'string') {
165
+ return def.typeName
166
+ }
167
+ // v4 path
168
+ const zod = obj._zod as Record<string, unknown> | undefined
169
+ const zodDef = zod?.def as Record<string, unknown> | undefined
170
+ if (zodDef?.type && typeof zodDef.type === 'string') {
171
+ return zodDef.type
172
+ }
173
+ return undefined
174
+ }
175
+
176
+ const typeName = getTypeName(inner)
177
+
178
+ // Unwrap optional/nullable
179
+ if (
180
+ typeName === 'ZodOptional' ||
181
+ typeName === 'ZodNullable' ||
182
+ typeName === 'optional' ||
183
+ typeName === 'nullable'
184
+ ) {
185
+ optional = true
186
+ const def = inner._def as Record<string, unknown> | undefined
187
+ const innerType =
188
+ def?.innerType ?? (inner._zod as Record<string, unknown>)?.def
189
+ if (innerType && typeof innerType === 'object') {
190
+ inner = innerType as Record<string, unknown>
191
+ }
192
+ }
193
+
194
+ const innerTypeName = getTypeName(inner) ?? typeName
195
+
196
+ // Map Zod type names to our FieldType
197
+ if (!innerTypeName) return { type: 'unknown', optional }
198
+
199
+ const typeMap: Record<string, FieldType> = {
200
+ ZodString: 'string',
201
+ ZodNumber: 'number',
202
+ ZodBoolean: 'boolean',
203
+ ZodDate: 'date',
204
+ ZodEnum: 'enum',
205
+ ZodNativeEnum: 'enum',
206
+ ZodArray: 'array',
207
+ ZodObject: 'object',
208
+ // v4 names
209
+ string: 'string',
210
+ number: 'number',
211
+ boolean: 'boolean',
212
+ date: 'date',
213
+ enum: 'enum',
214
+ array: 'array',
215
+ object: 'object',
216
+ }
217
+
218
+ const type = typeMap[innerTypeName] ?? 'string'
219
+
220
+ // Extract enum values
221
+ let enumValues: (string | number)[] | undefined
222
+ if (type === 'enum') {
223
+ const def = inner._def as Record<string, unknown> | undefined
224
+ if (def?.values && Array.isArray(def.values)) {
225
+ enumValues = def.values as (string | number)[]
226
+ }
227
+ // v4 path
228
+ const zodDef = (inner._zod as Record<string, unknown>)?.def as
229
+ | Record<string, unknown>
230
+ | undefined
231
+ if (zodDef?.values && Array.isArray(zodDef.values)) {
232
+ enumValues = zodDef.values as (string | number)[]
233
+ }
234
+ }
235
+
236
+ return { type, optional, enumValues }
237
+ }
238
+
239
+ /**
240
+ * Extract field information from a Zod object schema.
241
+ * Returns an array of FieldInfo objects describing each field.
242
+ *
243
+ * @example
244
+ * ```ts
245
+ * const schema = z.object({ name: z.string(), age: z.number().optional() })
246
+ * const fields = extractFields(schema)
247
+ * // [
248
+ * // { name: 'name', type: 'string', optional: false, label: 'Name' },
249
+ * // { name: 'age', type: 'number', optional: true, label: 'Age' },
250
+ * // ]
251
+ * ```
252
+ */
253
+ export function extractFields(schema: unknown): FieldInfo[] {
254
+ if (!schema || typeof schema !== 'object') return []
255
+
256
+ const s = schema as Record<string, unknown>
257
+
258
+ // Get the shape object from the schema
259
+ // Zod v3: schema._def.shape() or schema.shape
260
+ // Zod v4: schema._zod.def.shape or schema.shape
261
+ let shape: Record<string, unknown> | undefined
262
+
263
+ // Try schema.shape (works for both v3 and v4)
264
+ if (s.shape && typeof s.shape === 'object') {
265
+ shape = s.shape as Record<string, unknown>
266
+ }
267
+
268
+ // Try _def.shape (v3 — can be a function)
269
+ if (!shape) {
270
+ const def = s._def as Record<string, unknown> | undefined
271
+ if (def?.shape) {
272
+ shape =
273
+ typeof def.shape === 'function'
274
+ ? (def.shape as () => Record<string, unknown>)()
275
+ : (def.shape as Record<string, unknown>)
276
+ }
277
+ }
278
+
279
+ // Try _zod.def.shape (v4)
280
+ if (!shape) {
281
+ const zod = s._zod as Record<string, unknown> | undefined
282
+ const zodDef = zod?.def as Record<string, unknown> | undefined
283
+ if (zodDef?.shape && typeof zodDef.shape === 'object') {
284
+ shape = zodDef.shape as Record<string, unknown>
285
+ }
286
+ }
287
+
288
+ if (!shape) return []
289
+
290
+ return Object.entries(shape).map(([name, fieldSchema]) => {
291
+ const { type, optional, enumValues, referenceTo } =
292
+ detectFieldType(fieldSchema)
293
+ const info: FieldInfo = {
294
+ name,
295
+ type,
296
+ optional,
297
+ label: nameToLabel(name),
298
+ }
299
+ if (enumValues) info.enumValues = enumValues
300
+ if (referenceTo) info.referenceTo = referenceTo
301
+ return info
302
+ })
303
+ }
304
+
305
+ /**
306
+ * Generate default initial values from a schema's field types.
307
+ */
308
+ export function defaultInitialValues(
309
+ fields: FieldInfo[],
310
+ ): Record<string, unknown> {
311
+ const values: Record<string, unknown> = {}
312
+ for (const field of fields) {
313
+ switch (field.type) {
314
+ case 'string':
315
+ values[field.name] = ''
316
+ break
317
+ case 'number':
318
+ values[field.name] = 0
319
+ break
320
+ case 'boolean':
321
+ values[field.name] = false
322
+ break
323
+ case 'enum':
324
+ values[field.name] = field.enumValues?.[0] ?? ''
325
+ break
326
+ case 'date':
327
+ values[field.name] = ''
328
+ break
329
+ default:
330
+ values[field.name] = ''
331
+ }
332
+ }
333
+ return values
334
+ }