@omnifyjp/ts 3.12.5 → 3.13.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.
@@ -0,0 +1,59 @@
1
+ /**
2
+ * @omnify/ts — Payload Builder Generator (issue omnify-jp/omnify-go#55)
3
+ *
4
+ * Emits per-model form-state interfaces and payload-builder helpers
5
+ * alongside the existing zod schemas. Translatable fields are typed as
6
+ * `Record<Locale, string>` in the form state (matching the design
7
+ * system's `<Input translatable />` component shape) and the builder
8
+ * collapses them into the nested locale-keyed payload that the create /
9
+ * update zod schemas accept (see issue #53).
10
+ *
11
+ * Output (added to base/<Model>.ts):
12
+ *
13
+ * export interface <Model>CreateFormState { ... }
14
+ * export interface <Model>UpdateFormState { ... }
15
+ * export function empty<Model>CreateForm() { ... }
16
+ * export function build<Model>CreatePayload(form) { ... }
17
+ * export function build<Model>UpdatePayload(form) { ... }
18
+ *
19
+ * The shared `buildI18nPayload` utility is emitted exactly once at the
20
+ * codegen output root in `payload-helpers.ts` so per-model files can
21
+ * import it.
22
+ */
23
+ import type { GeneratorOptions, SchemaDefinition } from './types.js';
24
+ import { type MetadataFieldType } from './metadata-generator.js';
25
+ /** A single field as seen by the form-state / payload builder. */
26
+ export interface FormField {
27
+ /** Field key as it appears on the wire (snake_case, with `_id` suffix on FKs). */
28
+ fieldName: string;
29
+ /** TypeScript primitive used in the form-state interface. */
30
+ tsType: string;
31
+ /** Coarse type taxonomy from the metadata generator. */
32
+ metaType: MetadataFieldType;
33
+ /** Whether the YAML declared the field translatable. */
34
+ translatable: boolean;
35
+ /** Whether the field is required by the create schema. */
36
+ required: boolean;
37
+ /** Whether the YAML declared the field nullable. */
38
+ nullable: boolean;
39
+ /** Default value for `empty<Model>CreateForm()`, as a JS literal string. */
40
+ defaultLiteral: string;
41
+ /**
42
+ * When true, the field is intentionally omitted from the
43
+ * `empty<Model>CreateForm()` factory body. Used for optional enum
44
+ * reference fields where no sensible default exists — the interface
45
+ * marks the field `?:` so omitting the key is valid, and this is
46
+ * safer than emitting `undefined` under `exactOptionalPropertyTypes`.
47
+ * Issue omnify-jp/omnify-go#56.
48
+ */
49
+ omitFromEmpty?: boolean;
50
+ }
51
+ /** Resolved per-schema form metadata. */
52
+ export interface SchemaFormShape {
53
+ modelName: string;
54
+ fields: FormField[];
55
+ }
56
+ /** Build the form shape IR from a schema. */
57
+ export declare function buildFormShape(schema: SchemaDefinition, allSchemas: Record<string, SchemaDefinition>, options: GeneratorOptions): SchemaFormShape;
58
+ /** Render the full payload-builder section for a schema. */
59
+ export declare function formatPayloadBuilderSection(shape: SchemaFormShape, defaultLocale: string): string;
@@ -0,0 +1,339 @@
1
+ /**
2
+ * @omnify/ts — Payload Builder Generator (issue omnify-jp/omnify-go#55)
3
+ *
4
+ * Emits per-model form-state interfaces and payload-builder helpers
5
+ * alongside the existing zod schemas. Translatable fields are typed as
6
+ * `Record<Locale, string>` in the form state (matching the design
7
+ * system's `<Input translatable />` component shape) and the builder
8
+ * collapses them into the nested locale-keyed payload that the create /
9
+ * update zod schemas accept (see issue #53).
10
+ *
11
+ * Output (added to base/<Model>.ts):
12
+ *
13
+ * export interface <Model>CreateFormState { ... }
14
+ * export interface <Model>UpdateFormState { ... }
15
+ * export function empty<Model>CreateForm() { ... }
16
+ * export function build<Model>CreatePayload(form) { ... }
17
+ * export function build<Model>UpdatePayload(form) { ... }
18
+ *
19
+ * The shared `buildI18nPayload` utility is emitted exactly once at the
20
+ * codegen output root in `payload-helpers.ts` so per-model files can
21
+ * import it.
22
+ */
23
+ import { toSnakeCase } from './interface-generator.js';
24
+ import { classifyFieldType } from './metadata-generator.js';
25
+ import { toEnumMemberName } from './enum-generator.js';
26
+ // ---------------------------------------------------------------------------
27
+ // Build the IR
28
+ // ---------------------------------------------------------------------------
29
+ const PRIMITIVE_TS_DEFAULTS = {
30
+ string: "''",
31
+ text: "''",
32
+ number: '0',
33
+ boolean: 'false',
34
+ date: "''",
35
+ datetime: "''",
36
+ time: "''",
37
+ json: 'null',
38
+ enum: "''",
39
+ uuid: "''",
40
+ file: 'null',
41
+ compound: "''",
42
+ };
43
+ function tsTypeForField(metaType, prop) {
44
+ switch (metaType) {
45
+ case 'string':
46
+ case 'text':
47
+ case 'date':
48
+ case 'datetime':
49
+ case 'time':
50
+ case 'uuid':
51
+ return 'string';
52
+ case 'number':
53
+ return 'number';
54
+ case 'boolean':
55
+ return 'boolean';
56
+ case 'json':
57
+ return 'unknown';
58
+ case 'file':
59
+ return 'unknown';
60
+ case 'enum':
61
+ // Use the enum reference name when available so downstream form
62
+ // components get type narrowing on the union.
63
+ if (typeof prop.enum === 'string')
64
+ return prop.enum;
65
+ if (Array.isArray(prop.enum)) {
66
+ return prop.enum
67
+ .map((v) => `'${typeof v === 'string' ? v : v.value}'`)
68
+ .join(' | ');
69
+ }
70
+ return 'string';
71
+ default:
72
+ return 'string';
73
+ }
74
+ }
75
+ /**
76
+ * Resolve an enum's value list either from a plugin enum bank
77
+ * (customTypes.enums) or from a schema-defined enum (`kind: enum` schemas).
78
+ * Returns undefined if the enum reference can't be resolved at codegen time.
79
+ */
80
+ function resolveEnumValueList(prop, allSchemas, pluginEnums) {
81
+ if (typeof prop.enum === 'string') {
82
+ // Plugin enum first (customTypes.enums), then schema enum.
83
+ const fromPlugin = pluginEnums[prop.enum];
84
+ if (fromPlugin)
85
+ return fromPlugin;
86
+ const schemaEnum = allSchemas[prop.enum];
87
+ if (schemaEnum?.values && schemaEnum.values.length > 0) {
88
+ return schemaEnum.values.map((v) => v.value);
89
+ }
90
+ return undefined;
91
+ }
92
+ if (Array.isArray(prop.enum)) {
93
+ return prop.enum.map((v) => typeof v === 'string' ? v : v.value);
94
+ }
95
+ return undefined;
96
+ }
97
+ function defaultLiteralForField(metaType, prop, required, allSchemas, pluginEnums) {
98
+ // -----------------------------------------------------------------
99
+ // Enum fields — reference the enum member, not a string literal.
100
+ //
101
+ // `status: 'free'` is not assignable to `TableStatus` under strict
102
+ // TypeScript because enum types are nominal. We must emit
103
+ // `TableStatus.Free` instead. For inline enums (array of strings) the
104
+ // TS type is a literal union, so string literals are fine — that case
105
+ // falls through to the JSON.stringify path at the bottom.
106
+ //
107
+ // Issue omnify-jp/omnify-go#56.
108
+ // -----------------------------------------------------------------
109
+ if (metaType === 'enum' && typeof prop.enum === 'string') {
110
+ const enumName = prop.enum;
111
+ // Optional / nullable enum refs: omit from the factory body entirely.
112
+ // The form-state interface already marks the field `?:`, so skipping
113
+ // the key is a valid object literal under strict TS regardless of
114
+ // `exactOptionalPropertyTypes`.
115
+ if (!required) {
116
+ return { literal: '', omit: true };
117
+ }
118
+ // Pick the default value: honor YAML `default` first, else first
119
+ // declared enum member.
120
+ let rawValue;
121
+ if (prop.default !== undefined && prop.default !== null) {
122
+ rawValue = String(prop.default);
123
+ }
124
+ else {
125
+ const values = resolveEnumValueList(prop, allSchemas, pluginEnums);
126
+ if (values && values.length > 0) {
127
+ rawValue = values[0];
128
+ }
129
+ }
130
+ if (rawValue === undefined) {
131
+ // Can't resolve the enum values at codegen time (unlikely, but
132
+ // keeps the generator total). Fall back to omitting — the dev
133
+ // will get a TS error at form init and can fix it manually.
134
+ return { literal: '', omit: true };
135
+ }
136
+ return { literal: `${enumName}.${toEnumMemberName(rawValue)}` };
137
+ }
138
+ // Honor an explicit YAML default when present (non-enum path).
139
+ if (prop.default !== undefined && prop.default !== null) {
140
+ return { literal: JSON.stringify(prop.default) };
141
+ }
142
+ // Inline enum (array form) — the form-state tsType is a literal union,
143
+ // so a string literal is directly assignable. Use the first value.
144
+ if (metaType === 'enum') {
145
+ if (Array.isArray(prop.enum) && prop.enum.length > 0) {
146
+ const first = prop.enum[0];
147
+ const val = typeof first === 'string' ? first : first.value;
148
+ return { literal: JSON.stringify(val) };
149
+ }
150
+ return { literal: "''" };
151
+ }
152
+ if (metaType === 'boolean')
153
+ return { literal: 'false' };
154
+ return { literal: PRIMITIVE_TS_DEFAULTS[metaType] };
155
+ }
156
+ /** Build the form shape IR from a schema. */
157
+ export function buildFormShape(schema, allSchemas, options) {
158
+ const fields = [];
159
+ if (!schema.properties) {
160
+ return { modelName: schema.name, fields };
161
+ }
162
+ const propNames = schema.propertyOrder ?? Object.keys(schema.properties);
163
+ for (const propName of propNames) {
164
+ const prop = schema.properties[propName];
165
+ if (!prop)
166
+ continue;
167
+ // Skip inverse-side associations — they have no column.
168
+ if (prop.type === 'Association' &&
169
+ (prop.mappedBy || prop.relation === 'OneToMany' ||
170
+ prop.relation === 'ManyToMany' || prop.relation === 'MorphMany' ||
171
+ prop.relation === 'MorphTo' || prop.relation === 'MorphToMany' ||
172
+ prop.relation === 'MorphedByMany')) {
173
+ continue;
174
+ }
175
+ // Skip File for the form-state shape — file uploads have a different
176
+ // lifecycle (multipart form, temp tokens) handled outside the JSON
177
+ // payload builder.
178
+ if (prop.type === 'File')
179
+ continue;
180
+ const metaType = classifyFieldType(prop, allSchemas, options.customTypes.simple);
181
+ const isOwningAssoc = prop.type === 'Association' &&
182
+ (prop.relation === 'ManyToOne' || prop.relation === 'OneToOne') &&
183
+ !prop.mappedBy;
184
+ const fieldName = isOwningAssoc
185
+ ? `${toSnakeCase(propName)}_id`
186
+ : toSnakeCase(propName);
187
+ // rules.required overrides nullable inference
188
+ const required = prop.rules?.required === false
189
+ ? false
190
+ : prop.rules?.required === true
191
+ ? true
192
+ : !(prop.nullable ?? false);
193
+ const { literal, omit } = defaultLiteralForField(metaType, prop, required, allSchemas, options.customTypes.enums);
194
+ fields.push({
195
+ fieldName,
196
+ tsType: tsTypeForField(metaType, prop),
197
+ metaType,
198
+ translatable: prop.translatable === true,
199
+ required,
200
+ nullable: prop.nullable ?? false,
201
+ defaultLiteral: literal,
202
+ omitFromEmpty: omit,
203
+ });
204
+ }
205
+ return { modelName: schema.name, fields };
206
+ }
207
+ // ---------------------------------------------------------------------------
208
+ // Format the form-state interfaces + builder helpers
209
+ // ---------------------------------------------------------------------------
210
+ /** Render the full payload-builder section for a schema. */
211
+ export function formatPayloadBuilderSection(shape, defaultLocale) {
212
+ const { modelName, fields } = shape;
213
+ if (fields.length === 0)
214
+ return '';
215
+ const parts = [];
216
+ parts.push(`// ============================================================================\n`);
217
+ parts.push(`// Form State + Payload Builders (issue #55)\n`);
218
+ parts.push(`// ============================================================================\n\n`);
219
+ // ---- Create form state interface ----
220
+ parts.push(`/** Form-state shape for creating a ${modelName}. Translatable fields are\n` +
221
+ ` * flat per-locale maps; the builder collapses them into the wire shape. */\n`);
222
+ parts.push(`export interface ${modelName}CreateFormState {\n`);
223
+ for (const f of fields) {
224
+ if (f.translatable) {
225
+ parts.push(` ${f.fieldName}: Record<Locale, string>;\n`);
226
+ }
227
+ else {
228
+ const optional = !f.required;
229
+ parts.push(` ${f.fieldName}${optional ? '?' : ''}: ${f.tsType}${f.nullable ? ' | null' : ''};\n`);
230
+ }
231
+ }
232
+ parts.push(`}\n\n`);
233
+ // ---- Update form state interface ----
234
+ parts.push(`/** Form-state shape for updating a ${modelName}. Every field optional. */\n`);
235
+ parts.push(`export interface ${modelName}UpdateFormState {\n`);
236
+ for (const f of fields) {
237
+ if (f.translatable) {
238
+ parts.push(` ${f.fieldName}?: Record<Locale, string>;\n`);
239
+ }
240
+ else {
241
+ parts.push(` ${f.fieldName}?: ${f.tsType}${f.nullable ? ' | null' : ''};\n`);
242
+ }
243
+ }
244
+ parts.push(`}\n\n`);
245
+ // ---- empty<Model>CreateForm factory ----
246
+ parts.push(`/** Default-state factory for the create form. Pre-fills every locale key. */\n`);
247
+ parts.push(`export function empty${modelName}CreateForm(): ${modelName}CreateFormState {\n`);
248
+ parts.push(` return {\n`);
249
+ for (const f of fields) {
250
+ // Skip fields marked for omission (e.g. optional enum references
251
+ // with no sensible default — the interface marks them `?:`).
252
+ // Issue omnify-jp/omnify-go#56.
253
+ if (f.omitFromEmpty)
254
+ continue;
255
+ if (f.translatable) {
256
+ parts.push(` ${f.fieldName}: emptyLocaleMap(),\n`);
257
+ }
258
+ else {
259
+ parts.push(` ${f.fieldName}: ${f.defaultLiteral},\n`);
260
+ }
261
+ }
262
+ parts.push(` };\n`);
263
+ parts.push(`}\n\n`);
264
+ // ---- buildCreatePayload ----
265
+ parts.push(`/** Convert create form state into the payload that ` +
266
+ `base${modelName}CreateSchema accepts. */\n`);
267
+ parts.push(`export function build${modelName}CreatePayload(form: ${modelName}CreateFormState): Base${modelName}Create {\n`);
268
+ // Field-by-field assignment.
269
+ parts.push(` const payload: Record<string, unknown> = {\n`);
270
+ for (const f of fields) {
271
+ if (f.translatable) {
272
+ // Top-level mirror = default-locale value, trimmed.
273
+ parts.push(` ${f.fieldName}: (form.${f.fieldName}[${JSON.stringify(defaultLocale)}] ?? '').trim(),\n`);
274
+ }
275
+ else if (f.metaType === 'string' || f.metaType === 'text' || f.metaType === 'uuid') {
276
+ if (f.required) {
277
+ parts.push(` ${f.fieldName}: form.${f.fieldName}.trim(),\n`);
278
+ }
279
+ else {
280
+ // Drop empty optional strings.
281
+ parts.push(` ${f.fieldName}: form.${f.fieldName}?.toString().trim() || ${f.nullable ? 'null' : 'undefined'},\n`);
282
+ }
283
+ }
284
+ else if (f.metaType === 'date' || f.metaType === 'datetime') {
285
+ // Already a string in form state; pass through.
286
+ parts.push(` ${f.fieldName}: form.${f.fieldName},\n`);
287
+ }
288
+ else {
289
+ parts.push(` ${f.fieldName}: form.${f.fieldName},\n`);
290
+ }
291
+ }
292
+ parts.push(` };\n\n`);
293
+ // Spread per-locale sub-objects (issue #53 wire shape).
294
+ const translatableFieldNames = fields.filter((f) => f.translatable).map((f) => f.fieldName);
295
+ if (translatableFieldNames.length > 0) {
296
+ parts.push(` // Per-locale sub-objects (matches issue #53 wire shape).\n`);
297
+ parts.push(` const i18n = buildI18nPayload({\n`);
298
+ for (const name of translatableFieldNames) {
299
+ parts.push(` ${name}: form.${name},\n`);
300
+ }
301
+ parts.push(` });\n`);
302
+ parts.push(` Object.assign(payload, i18n);\n\n`);
303
+ }
304
+ parts.push(` return payload as Base${modelName}Create;\n`);
305
+ parts.push(`}\n\n`);
306
+ // ---- buildUpdatePayload ----
307
+ parts.push(`/** Convert update form state into the payload that ` +
308
+ `base${modelName}UpdateSchema accepts. Only fields present on \`form\` are emitted. */\n`);
309
+ parts.push(`export function build${modelName}UpdatePayload(form: ${modelName}UpdateFormState): Base${modelName}Update {\n`);
310
+ parts.push(` const payload: Record<string, unknown> = {};\n`);
311
+ for (const f of fields) {
312
+ if (f.translatable) {
313
+ parts.push(` if (form.${f.fieldName} !== undefined) {\n`);
314
+ parts.push(` payload.${f.fieldName} = (form.${f.fieldName}[${JSON.stringify(defaultLocale)}] ?? '').trim();\n`);
315
+ parts.push(` }\n`);
316
+ }
317
+ else if (f.metaType === 'string' || f.metaType === 'text' || f.metaType === 'uuid') {
318
+ parts.push(` if (form.${f.fieldName} !== undefined) {\n`);
319
+ parts.push(` payload.${f.fieldName} = form.${f.fieldName}?.toString().trim() ?? null;\n`);
320
+ parts.push(` }\n`);
321
+ }
322
+ else {
323
+ parts.push(` if (form.${f.fieldName} !== undefined) {\n`);
324
+ parts.push(` payload.${f.fieldName} = form.${f.fieldName};\n`);
325
+ parts.push(` }\n`);
326
+ }
327
+ }
328
+ if (translatableFieldNames.length > 0) {
329
+ parts.push(`\n // Per-locale sub-objects only for fields the user touched.\n`);
330
+ parts.push(` const localeInput: Record<string, Record<string, string> | undefined> = {};\n`);
331
+ for (const name of translatableFieldNames) {
332
+ parts.push(` if (form.${name} !== undefined) localeInput.${name} = form.${name};\n`);
333
+ }
334
+ parts.push(` Object.assign(payload, buildI18nPayload(localeInput));\n`);
335
+ }
336
+ parts.push(` return payload as Base${modelName}Update;\n`);
337
+ parts.push(`}\n`);
338
+ return parts.join('');
339
+ }
@@ -25,7 +25,7 @@ export declare function getExcludedFields(schema: SchemaDefinition): {
25
25
  export declare function formatZodSchemasSection(schemaName: string, zodSchemas: ZodPropertySchema[], displayNames: SchemaDisplayNames, excludedFields: {
26
26
  create: Set<string>;
27
27
  update: Set<string>;
28
- }): string;
28
+ }, schema: SchemaDefinition, options: GeneratorOptions): string;
29
29
  /**
30
30
  * Format user model file with Zod re-exports.
31
31
  */
@@ -409,10 +409,69 @@ export function getExcludedFields(schema) {
409
409
  }
410
410
  return { create: createExclude, update: updateExclude };
411
411
  }
412
+ /**
413
+ * Build the per-locale sub-object lines for a schema's translatable fields.
414
+ *
415
+ * For every property with `translatable: true`, we synthesize a "translation
416
+ * variant" of its zod schema by forcing `nullable: true` and clearing
417
+ * `rules.required`. This means:
418
+ * - `String` translatable fields drop the implicit `.min(1)` (translation
419
+ * rows for non-default locales may legitimately be empty).
420
+ * - `max(N)` / `length(N)` validators are preserved so client and server
421
+ * agree on bounds.
422
+ * - The variant ends in `.optional().nullable()` so missing translations
423
+ * don't break validation.
424
+ *
425
+ * Each locale (from `options.locales`) gets its own sub-object containing
426
+ * only the translatable fields. The sub-object itself is `.optional()` so
427
+ * a payload may omit any locale entirely. Implements omnify-jp/omnify-go#53.
428
+ *
429
+ * Returns the lines (without trailing newlines) ready to splice into the
430
+ * `z.object({ ... })` body of the create schema, or an empty array when
431
+ * the schema has no translatable fields.
432
+ */
433
+ function buildTranslatableLocaleSchemaLines(schema, options) {
434
+ if (!schema.properties || options.locales.length === 0)
435
+ return [];
436
+ // Collect translatable fields in declaration order so the generated
437
+ // sub-object body matches the rest of the file.
438
+ const translatableProps = [];
439
+ const propNames = schema.propertyOrder ?? Object.keys(schema.properties);
440
+ for (const propName of propNames) {
441
+ const propDef = schema.properties[propName];
442
+ if (!propDef || !propDef.translatable)
443
+ continue;
444
+ // Build the translation variant: nullable, no `required` rule. The
445
+ // existing zod generator handles the rest (max length, regex, etc.).
446
+ const variantDef = {
447
+ ...propDef,
448
+ nullable: true,
449
+ rules: propDef.rules ? { ...propDef.rules, required: false } : undefined,
450
+ };
451
+ const variantSchema = getZodSchemaForType(variantDef, propName, options);
452
+ if (!variantSchema)
453
+ continue;
454
+ translatableProps.push({
455
+ fieldName: toSnakeCase(propName),
456
+ schema: variantSchema,
457
+ });
458
+ }
459
+ if (translatableProps.length === 0)
460
+ return [];
461
+ const lines = [];
462
+ for (const locale of options.locales) {
463
+ lines.push(` ${locale}: z.object({`);
464
+ for (const tp of translatableProps) {
465
+ lines.push(` ${tp.fieldName}: ${tp.schema},`);
466
+ }
467
+ lines.push(` }).optional(),`);
468
+ }
469
+ return lines;
470
+ }
412
471
  /**
413
472
  * Format Zod schemas section for a base file.
414
473
  */
415
- export function formatZodSchemasSection(schemaName, zodSchemas, displayNames, excludedFields) {
474
+ export function formatZodSchemasSection(schemaName, zodSchemas, displayNames, excludedFields, schema, options) {
416
475
  const parts = [];
417
476
  const lowerName = schemaName.charAt(0).toLowerCase() + schemaName.slice(1);
418
477
  // I18n section
@@ -451,6 +510,14 @@ export function formatZodSchemasSection(schemaName, zodSchemas, displayNames, ex
451
510
  for (const prop of createFields) {
452
511
  parts.push(` ${prop.fieldName}: base${schemaName}Schemas.${prop.fieldName},\n`);
453
512
  }
513
+ // Per-locale sub-objects for translatable fields. The Update schema
514
+ // (which is `.partial()` of Create) inherits these for free, so the
515
+ // partial form correctly types each locale key as optional too.
516
+ // Issue omnify-jp/omnify-go#53.
517
+ const localeLines = buildTranslatableLocaleSchemaLines(schema, options);
518
+ for (const line of localeLines) {
519
+ parts.push(line + '\n');
520
+ }
454
521
  parts.push(`});\n\n`);
455
522
  // Update Schema
456
523
  parts.push(`/** Update schema for ${schemaName} */\n`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omnifyjp/ts",
3
- "version": "3.12.5",
3
+ "version": "3.13.1",
4
4
  "description": "TypeScript model type generator from Omnify schemas.json",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",