@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/LICENSE +21 -0
- package/README.md +382 -0
- package/lib/analysis/index.js.html +5406 -0
- package/lib/index.js +464 -0
- package/lib/index.js.map +1 -0
- package/lib/types/index.d.ts +501 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index2.d.ts +259 -0
- package/lib/types/index2.d.ts.map +1 -0
- package/package.json +60 -0
- package/src/define-feature.ts +420 -0
- package/src/index.ts +19 -0
- package/src/schema.ts +334 -0
- package/src/tests/feature.test.ts +1416 -0
- package/src/types.ts +159 -0
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
|
+
}
|