@omnifyjp/omnify 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,246 @@
1
+ /**
2
+ * Input resolver for omnify-ts.
3
+ *
4
+ * Accepts a `schemas.json` source spec and returns a local file path the
5
+ * caller can read directly. Three source types are supported:
6
+ *
7
+ * - **Local** (path/relative path) — read directly from disk.
8
+ * - **HTTP/HTTPS** (URL) — fetch, cache under `.omnify/cache/`, and pin
9
+ * the SHA-256 hash in `.omnify/input.lock.json` for reproducible builds.
10
+ * - **NPM** (package specifier like `@org/pkg/schemas.json`) — resolve via
11
+ * Node module resolution from the consumer project's node_modules.
12
+ *
13
+ * Lockfile semantics (HTTP only):
14
+ * - Default mode: re-fetch when the cache is stale, auto-update the
15
+ * lockfile, and warn the user when the upstream hash changes so they
16
+ * can review and commit the lockfile change.
17
+ * - `--frozen-lockfile` mode: fail loudly if the lockfile is missing,
18
+ * points at a different URL, or its hash differs from upstream. Use
19
+ * this in CI to guarantee build reproducibility.
20
+ *
21
+ * Local and NPM specs do not need an omnify lockfile because:
22
+ * - Local files are read every run; there is no fetch step that could
23
+ * introduce drift.
24
+ * - NPM packages are already version-pinned by `package-lock.json` /
25
+ * `pnpm-lock.yaml` / `yarn.lock`.
26
+ */
27
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, } from 'node:fs';
28
+ import { createHash } from 'node:crypto';
29
+ import { createRequire } from 'node:module';
30
+ import { dirname, isAbsolute, join, relative, resolve as resolvePath, } from 'node:path';
31
+ const defaultFetcher = (url) => fetch(url);
32
+ // ============================================================================
33
+ // Input kind detection
34
+ // ============================================================================
35
+ /**
36
+ * Decide which kind of source a spec string is. Heuristic — explicit
37
+ * prefixes win, ambiguous bare names default to local. Unscoped npm
38
+ * packages are intentionally NOT supported because they collide with
39
+ * relative path semantics; users should publish under a scope.
40
+ */
41
+ export function sniffInputKind(spec) {
42
+ if (/^https?:\/\//i.test(spec))
43
+ return 'http';
44
+ if (spec.startsWith('@'))
45
+ return 'npm';
46
+ return 'local';
47
+ }
48
+ function lockfilePath(configDir) {
49
+ return join(configDir, '.omnify', 'input.lock.json');
50
+ }
51
+ function cachePath(configDir) {
52
+ return join(configDir, '.omnify', 'cache', 'schemas.json');
53
+ }
54
+ function readLockfile(path) {
55
+ if (!existsSync(path))
56
+ return undefined;
57
+ try {
58
+ const raw = readFileSync(path, 'utf-8');
59
+ const parsed = JSON.parse(raw);
60
+ if (parsed.version !== 1) {
61
+ throw new Error(`unsupported lockfile version ${parsed.version} at ${path}; expected 1`);
62
+ }
63
+ return parsed;
64
+ }
65
+ catch (err) {
66
+ throw new Error(`failed to read input lockfile ${path}: ${err.message}`);
67
+ }
68
+ }
69
+ function writeLockfile(path, entry) {
70
+ mkdirSync(dirname(path), { recursive: true });
71
+ // Trailing newline so the file is git-friendly.
72
+ writeFileSync(path, JSON.stringify(entry, null, 2) + '\n', 'utf-8');
73
+ }
74
+ // ============================================================================
75
+ // Hashing
76
+ // ============================================================================
77
+ function sha256(data) {
78
+ return createHash('sha256').update(data, 'utf-8').digest('hex');
79
+ }
80
+ function sha256File(path) {
81
+ return sha256(readFileSync(path, 'utf-8'));
82
+ }
83
+ function shortHash(h) {
84
+ return h.slice(0, 12);
85
+ }
86
+ // ============================================================================
87
+ // Per-kind resolvers
88
+ // ============================================================================
89
+ function resolveLocal(spec, configDir) {
90
+ const localPath = isAbsolute(spec) ? spec : resolvePath(configDir, spec);
91
+ if (!existsSync(localPath)) {
92
+ throw new Error(`omnify-ts: local schemas.json not found at ${localPath}\n` +
93
+ `(spec: ${spec}, resolved from: ${configDir})\n` +
94
+ `Run "omnify generate" in the backend project to create it, or fix the path.`);
95
+ }
96
+ return {
97
+ localPath,
98
+ kind: 'local',
99
+ sha256: sha256File(localPath),
100
+ };
101
+ }
102
+ function resolveNpm(spec, configDir) {
103
+ // Use Node module resolution rooted at the consumer project's
104
+ // package.json. createRequire wants a file path; the file doesn't have
105
+ // to exist as long as the directory does.
106
+ const require = createRequire(join(configDir, 'noop.js'));
107
+ let localPath;
108
+ try {
109
+ localPath = require.resolve(spec);
110
+ }
111
+ catch (err) {
112
+ const pkgName = extractPackageName(spec);
113
+ throw new Error(`omnify-ts: npm schemas.json not resolvable: ${spec}\n` +
114
+ `Did you run "npm install --save-dev ${pkgName}" in ${configDir}?\n` +
115
+ `Original error: ${err.message}`);
116
+ }
117
+ return {
118
+ localPath,
119
+ kind: 'npm',
120
+ sha256: sha256File(localPath),
121
+ };
122
+ }
123
+ /**
124
+ * Extract the package name from an npm spec like
125
+ * `@org/pkg/path/to/file.json` → `@org/pkg`. Used for friendly error
126
+ * messages.
127
+ */
128
+ function extractPackageName(spec) {
129
+ if (spec.startsWith('@')) {
130
+ // Scoped: @org/pkg[/...]
131
+ const parts = spec.split('/');
132
+ return parts.length >= 2 ? `${parts[0]}/${parts[1]}` : spec;
133
+ }
134
+ // Bare (we don't really support these but extract first segment anyway)
135
+ return spec.split('/')[0];
136
+ }
137
+ async function resolveHttp(spec, opts, fetcher) {
138
+ const lockPath = lockfilePath(opts.configDir);
139
+ const cachedFile = cachePath(opts.configDir);
140
+ const existingLock = readLockfile(lockPath);
141
+ const lockMatchesSpec = existingLock?.input === spec;
142
+ // Fast path: cache exists, lockfile matches spec, hash on disk matches
143
+ // lockfile, and we are not forced to update. No network call.
144
+ if (!opts.forceUpdate &&
145
+ existingLock &&
146
+ lockMatchesSpec &&
147
+ existsSync(cachedFile)) {
148
+ const cachedHash = sha256File(cachedFile);
149
+ if (cachedHash === existingLock.sha256) {
150
+ return {
151
+ localPath: cachedFile,
152
+ kind: 'http',
153
+ sha256: cachedHash,
154
+ };
155
+ }
156
+ // Cache exists but is corrupted/edited. Fall through to re-fetch unless
157
+ // we are in frozen mode, in which case the lockfile mismatch is fatal.
158
+ if (opts.frozen) {
159
+ throw new Error(`omnify-ts: --frozen-lockfile: cached schemas.json at ${cachedFile} ` +
160
+ `has been modified (hash ${shortHash(cachedHash)} vs lockfile ` +
161
+ `${shortHash(existingLock.sha256)}). Refusing to silently re-fetch ` +
162
+ `in CI mode. Delete the cache and re-run without --frozen-lockfile ` +
163
+ `to refresh the lockfile.`);
164
+ }
165
+ }
166
+ // We need to fetch. In frozen mode, fail unless we already have a
167
+ // matching lockfile entry; the cache may have been wiped by CI but the
168
+ // lockfile is the source of truth so we still know what hash to expect.
169
+ if (opts.frozen && !lockMatchesSpec) {
170
+ if (!existingLock) {
171
+ throw new Error(`omnify-ts: --frozen-lockfile requires ${lockPath} to exist.\n` +
172
+ `Run "omnify-ts" once without --frozen-lockfile to generate the ` +
173
+ `lockfile, then commit it.`);
174
+ }
175
+ throw new Error(`omnify-ts: --frozen-lockfile: lockfile at ${lockPath} pins input ` +
176
+ `"${existingLock.input}" but config has "${spec}". ` +
177
+ `Run without --frozen-lockfile to refresh.`);
178
+ }
179
+ console.log(`[omnify-ts] Fetching ${spec}`);
180
+ const response = await fetcher(spec);
181
+ if (!response.ok) {
182
+ throw new Error(`omnify-ts: HTTP ${response.status} ${response.statusText} fetching ${spec}`);
183
+ }
184
+ const body = await response.text();
185
+ const remoteHash = sha256(body);
186
+ // In frozen mode, the bytes we just downloaded MUST match the lockfile.
187
+ // Anything else means upstream changed since the lockfile was generated
188
+ // and the developer hasn't acknowledged the new schemas yet.
189
+ if (opts.frozen) {
190
+ // existingLock + lockMatchesSpec are guaranteed by the check above.
191
+ if (existingLock.sha256 !== remoteHash) {
192
+ throw new Error(`omnify-ts: --frozen-lockfile: upstream schemas.json hash ` +
193
+ `${shortHash(remoteHash)} does not match lockfile ` +
194
+ `${shortHash(existingLock.sha256)}.\n` +
195
+ `Upstream changed since the lockfile was generated. ` +
196
+ `Run "omnify-ts --update" locally, review the diff, and commit ` +
197
+ `${relative(opts.configDir, lockPath)}.`);
198
+ }
199
+ }
200
+ else if (existingLock && lockMatchesSpec && existingLock.sha256 !== remoteHash) {
201
+ // Auto-update mode: warn so the developer notices the change at review
202
+ // time and commits the new lockfile + regenerated types together.
203
+ console.warn(`[omnify-ts] WARNING: upstream schemas.json changed ` +
204
+ `(${shortHash(existingLock.sha256)} → ${shortHash(remoteHash)}). ` +
205
+ `Lockfile updated; review and commit the diff.`);
206
+ }
207
+ // Persist cache + lockfile.
208
+ mkdirSync(dirname(cachedFile), { recursive: true });
209
+ writeFileSync(cachedFile, body, 'utf-8');
210
+ writeLockfile(lockPath, {
211
+ version: 1,
212
+ input: spec,
213
+ source: 'http',
214
+ sha256: remoteHash,
215
+ fetchedAt: new Date().toISOString(),
216
+ cachedAt: relative(opts.configDir, cachedFile),
217
+ });
218
+ return {
219
+ localPath: cachedFile,
220
+ kind: 'http',
221
+ sha256: remoteHash,
222
+ };
223
+ }
224
+ // ============================================================================
225
+ // Public entry point
226
+ // ============================================================================
227
+ /**
228
+ * Resolve an input spec to a concrete local file path. Dispatches by kind:
229
+ * local files are read directly, npm packages are resolved through Node
230
+ * module resolution, and HTTP URLs are fetched, cached, and pinned in the
231
+ * lockfile.
232
+ *
233
+ * The `fetcher` parameter is exposed only for tests; production callers
234
+ * should leave it at the default (`globalThis.fetch`).
235
+ */
236
+ export async function resolveInput(spec, opts, fetcher = defaultFetcher) {
237
+ const kind = sniffInputKind(spec);
238
+ switch (kind) {
239
+ case 'local':
240
+ return resolveLocal(spec, opts.configDir);
241
+ case 'npm':
242
+ return resolveNpm(spec, opts.configDir);
243
+ case 'http':
244
+ return resolveHttp(spec, opts, fetcher);
245
+ }
246
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * @omnify/ts — Form Field Metadata Generator (issue omnify-jp/omnify-go#54)
3
+ *
4
+ * Emits a `<modelName>Metadata` constant alongside the existing generated
5
+ * `base/<Model>.ts`. The constant is a flat description of every field's
6
+ * type, validation rules, translatable flag, enum binding, label, and
7
+ * placeholder dictionaries.
8
+ *
9
+ * Downstream tools (forms, stories, smoke tests, validation, headless form
10
+ * runners) read from this single source of truth instead of each project
11
+ * rolling its own per-model lookup tables.
12
+ *
13
+ * The metadata is a *new* artifact emitted in addition to the existing
14
+ * interface, zod schemas, and i18n object — none of those are modified.
15
+ */
16
+ import type { GeneratorOptions, PropertyDefinition, SchemaDefinition, SchemaDisplayNames } from './types.js';
17
+ /** Coarse field-type taxonomy that downstream form runners switch on. */
18
+ export type MetadataFieldType = 'string' | 'text' | 'number' | 'boolean' | 'date' | 'datetime' | 'time' | 'json' | 'enum' | 'uuid' | 'file' | 'compound';
19
+ /** Per-field entry in the metadata constant. */
20
+ export interface MetadataField {
21
+ /** Coarse field type (see taxonomy above). */
22
+ type: MetadataFieldType;
23
+ /** Whether the field is required by the create schema. */
24
+ required: boolean;
25
+ /** Whether the YAML declared the field nullable. */
26
+ nullable: boolean;
27
+ /** Whether the YAML declared the field translatable. */
28
+ translatable: boolean;
29
+ /** Validation rules that apply to this field type. Empty object if none. */
30
+ validation: Record<string, unknown>;
31
+ /** Enum name (only for `type: 'enum'`). */
32
+ enum?: string;
33
+ /** Enum value list (only for `type: 'enum'`); always emitted with `as const`. */
34
+ enumValues?: readonly string[];
35
+ /** Relation info (only for `type: 'uuid'`/'number' association FK). */
36
+ relation?: {
37
+ model: string;
38
+ table?: string;
39
+ onDelete?: string;
40
+ };
41
+ /** Compound type identifier (only for `type: 'compound'`). */
42
+ compoundType?: string;
43
+ /** Localized label dictionary (locale → string). */
44
+ label: Record<string, string>;
45
+ /** Localized placeholder dictionary (locale → string). Optional. */
46
+ placeholder?: Record<string, string>;
47
+ }
48
+ /** Aggregations exported alongside the per-field entries for ergonomics. */
49
+ export interface MetadataAggregations {
50
+ translatableFields: string[];
51
+ requiredFields: string[];
52
+ enumFields: string[];
53
+ relationFields: string[];
54
+ }
55
+ /** Resolved metadata for a single schema. */
56
+ export interface SchemaMetadata {
57
+ modelName: string;
58
+ table?: string;
59
+ fields: Record<string, MetadataField>;
60
+ aggregations: MetadataAggregations;
61
+ }
62
+ /**
63
+ * Map a YAML property to the coarse metadata type taxonomy. Used by both
64
+ * #54 (metadata generator) and #55 (payload builder) so the two stay in
65
+ * lockstep.
66
+ */
67
+ export declare function classifyFieldType(prop: PropertyDefinition, allSchemas: Record<string, SchemaDefinition>, customSimpleTypes: Record<string, {
68
+ mapsTo: string;
69
+ }>): MetadataFieldType;
70
+ /**
71
+ * Build the metadata IR for a single schema. Pure function — formatter
72
+ * does the string work.
73
+ */
74
+ export declare function buildSchemaMetadata(schema: SchemaDefinition, allSchemas: Record<string, SchemaDefinition>, options: GeneratorOptions, displayNames: SchemaDisplayNames): SchemaMetadata;
75
+ /** Render the SchemaMetadata IR to a TypeScript const literal block. */
76
+ export declare function formatMetadataConst(meta: SchemaMetadata): string;
@@ -0,0 +1,329 @@
1
+ /**
2
+ * @omnify/ts — Form Field Metadata Generator (issue omnify-jp/omnify-go#54)
3
+ *
4
+ * Emits a `<modelName>Metadata` constant alongside the existing generated
5
+ * `base/<Model>.ts`. The constant is a flat description of every field's
6
+ * type, validation rules, translatable flag, enum binding, label, and
7
+ * placeholder dictionaries.
8
+ *
9
+ * Downstream tools (forms, stories, smoke tests, validation, headless form
10
+ * runners) read from this single source of truth instead of each project
11
+ * rolling its own per-model lookup tables.
12
+ *
13
+ * The metadata is a *new* artifact emitted in addition to the existing
14
+ * interface, zod schemas, and i18n object — none of those are modified.
15
+ */
16
+ import { toSnakeCase } from './interface-generator.js';
17
+ // ---------------------------------------------------------------------------
18
+ // Type classification — single source of truth so #55 (payload builder)
19
+ // can reuse the same field-type decisions.
20
+ // ---------------------------------------------------------------------------
21
+ const TEXT_TYPES = new Set(['Text', 'MediumText', 'LongText']);
22
+ const STRING_TYPES = new Set([
23
+ 'String', 'Email', 'Password', 'Phone', 'Slug', 'Url', 'Uuid',
24
+ ]);
25
+ const NUMBER_TYPES = new Set([
26
+ 'TinyInt', 'Int', 'BigInt', 'Float', 'Decimal',
27
+ ]);
28
+ /**
29
+ * Map a YAML property to the coarse metadata type taxonomy. Used by both
30
+ * #54 (metadata generator) and #55 (payload builder) so the two stay in
31
+ * lockstep.
32
+ */
33
+ export function classifyFieldType(prop, allSchemas, customSimpleTypes) {
34
+ // File / image
35
+ if (prop.type === 'File')
36
+ return 'file';
37
+ // Owning-side association → FK column. Resolve target id type.
38
+ if (prop.type === 'Association' &&
39
+ (prop.relation === 'ManyToOne' || prop.relation === 'OneToOne') &&
40
+ !prop.mappedBy) {
41
+ const targetSchema = prop.target ? allSchemas[prop.target] : undefined;
42
+ const idType = (() => {
43
+ const id = targetSchema?.options?.id;
44
+ return typeof id === 'string' ? id : 'BigInt';
45
+ })();
46
+ if (idType === 'Uuid' || idType === 'Ulid' || idType === 'String') {
47
+ return 'uuid';
48
+ }
49
+ return 'number';
50
+ }
51
+ // Inverse-side association doesn't carry a column → not represented in
52
+ // the form metadata.
53
+ if (prop.type === 'Association')
54
+ return 'json';
55
+ // Enum
56
+ if (prop.type === 'Enum' || prop.type === 'EnumRef' || prop.type === 'Select') {
57
+ return 'enum';
58
+ }
59
+ // Compound type (japan.address etc.) — caller provides the simple-types
60
+ // table; if the property type isn't there, fall through to base map.
61
+ if (customSimpleTypes[prop.type]) {
62
+ const mapsTo = customSimpleTypes[prop.type].mapsTo;
63
+ if (TEXT_TYPES.has(mapsTo))
64
+ return 'text';
65
+ if (NUMBER_TYPES.has(mapsTo))
66
+ return 'number';
67
+ return 'string';
68
+ }
69
+ if (TEXT_TYPES.has(prop.type))
70
+ return 'text';
71
+ if (NUMBER_TYPES.has(prop.type))
72
+ return 'number';
73
+ if (STRING_TYPES.has(prop.type))
74
+ return 'string';
75
+ if (prop.type === 'Boolean')
76
+ return 'boolean';
77
+ if (prop.type === 'Date')
78
+ return 'date';
79
+ if (prop.type === 'DateTime' || prop.type === 'Timestamp')
80
+ return 'datetime';
81
+ if (prop.type === 'Time')
82
+ return 'time';
83
+ if (prop.type === 'Json')
84
+ return 'json';
85
+ // Lookup is a FK to an enum-like lookup table; treat as enum reference.
86
+ if (prop.type === 'Lookup')
87
+ return 'enum';
88
+ return 'string';
89
+ }
90
+ // ---------------------------------------------------------------------------
91
+ // Build the SchemaMetadata IR from a SchemaDefinition.
92
+ // ---------------------------------------------------------------------------
93
+ function pickValidation(prop) {
94
+ const out = {};
95
+ // Source of truth: prop.rules (the validation rules block) takes
96
+ // precedence over the legacy top-level length / min / max fields.
97
+ const rules = prop.rules ?? {};
98
+ if (rules.maxLength != null)
99
+ out.maxLength = rules.maxLength;
100
+ else if (prop.maxLength != null)
101
+ out.maxLength = prop.maxLength;
102
+ else if (prop.length != null)
103
+ out.maxLength = prop.length;
104
+ if (rules.minLength != null)
105
+ out.minLength = rules.minLength;
106
+ else if (prop.minLength != null)
107
+ out.minLength = prop.minLength;
108
+ if (rules.max != null)
109
+ out.max = rules.max;
110
+ else if (prop.max != null)
111
+ out.max = prop.max;
112
+ if (rules.min != null)
113
+ out.min = rules.min;
114
+ else if (prop.min != null)
115
+ out.min = prop.min;
116
+ if (prop.pattern != null)
117
+ out.pattern = prop.pattern;
118
+ return out;
119
+ }
120
+ function isFieldRequired(prop) {
121
+ if (prop.rules?.required === false)
122
+ return false;
123
+ if (prop.rules?.required === true)
124
+ return true;
125
+ return !(prop.nullable ?? false);
126
+ }
127
+ function resolveEnumValues(prop, pluginEnums, allSchemas) {
128
+ if (typeof prop.enum === 'string') {
129
+ // First check plugin enums (e.g. Prefecture from compound types).
130
+ const fromPlugin = pluginEnums[prop.enum];
131
+ if (fromPlugin)
132
+ return fromPlugin;
133
+ // Fall back to schema-defined enums (kind: 'enum' schemas with values).
134
+ const schemaEnum = allSchemas[prop.enum];
135
+ if (schemaEnum?.values && schemaEnum.values.length > 0) {
136
+ return schemaEnum.values.map((v) => v.value);
137
+ }
138
+ return undefined;
139
+ }
140
+ if (Array.isArray(prop.enum)) {
141
+ return prop.enum.map((v) => typeof v === 'string' ? v : v.value);
142
+ }
143
+ return undefined;
144
+ }
145
+ /**
146
+ * Build the metadata IR for a single schema. Pure function — formatter
147
+ * does the string work.
148
+ */
149
+ export function buildSchemaMetadata(schema, allSchemas, options, displayNames) {
150
+ const fields = {};
151
+ const translatableFields = [];
152
+ const requiredFields = [];
153
+ const enumFields = [];
154
+ const relationFields = [];
155
+ if (schema.properties) {
156
+ const propNames = schema.propertyOrder ?? Object.keys(schema.properties);
157
+ for (const propName of propNames) {
158
+ const prop = schema.properties[propName];
159
+ if (!prop)
160
+ continue;
161
+ // Skip inverse associations — they don't carry a column.
162
+ const isInverseAssoc = prop.type === 'Association' &&
163
+ (Boolean(prop.mappedBy) ||
164
+ prop.relation === 'OneToMany' ||
165
+ prop.relation === 'ManyToMany' ||
166
+ prop.relation === 'MorphMany' ||
167
+ prop.relation === 'MorphToMany' ||
168
+ prop.relation === 'MorphedByMany' ||
169
+ prop.relation === 'MorphTo');
170
+ if (isInverseAssoc)
171
+ continue;
172
+ // Owning ManyToOne / OneToOne → FK column key has `_id` suffix.
173
+ const isOwningAssoc = prop.type === 'Association' &&
174
+ (prop.relation === 'ManyToOne' || prop.relation === 'OneToOne') &&
175
+ !prop.mappedBy;
176
+ const fieldKey = isOwningAssoc
177
+ ? `${toSnakeCase(propName)}_id`
178
+ : toSnakeCase(propName);
179
+ const fieldType = classifyFieldType(prop, allSchemas, options.customTypes.simple);
180
+ const required = isFieldRequired(prop);
181
+ const nullable = prop.nullable ?? false;
182
+ const translatable = prop.translatable === true;
183
+ const meta = {
184
+ type: fieldType,
185
+ required,
186
+ nullable,
187
+ translatable,
188
+ validation: pickValidation(prop),
189
+ label: displayNames.propertyDisplayNames[fieldKey] ?? {},
190
+ };
191
+ const placeholder = displayNames.propertyPlaceholders[fieldKey];
192
+ if (placeholder)
193
+ meta.placeholder = placeholder;
194
+ if (fieldType === 'enum') {
195
+ if (typeof prop.enum === 'string') {
196
+ meta.enum = prop.enum;
197
+ }
198
+ const values = resolveEnumValues(prop, options.customTypes.enums, allSchemas);
199
+ if (values)
200
+ meta.enumValues = values;
201
+ enumFields.push(fieldKey);
202
+ }
203
+ if (isOwningAssoc) {
204
+ const targetName = prop.target ?? '';
205
+ const targetSchema = targetName ? allSchemas[targetName] : undefined;
206
+ const targetTable = targetSchema?.tableName ?? toSnakeCase(targetName) + 's';
207
+ meta.relation = {
208
+ model: targetName,
209
+ table: targetTable,
210
+ onDelete: prop.onDelete,
211
+ };
212
+ relationFields.push(fieldKey);
213
+ }
214
+ if (translatable)
215
+ translatableFields.push(fieldKey);
216
+ if (required)
217
+ requiredFields.push(fieldKey);
218
+ fields[fieldKey] = meta;
219
+ }
220
+ }
221
+ return {
222
+ modelName: schema.name,
223
+ table: schema.tableName,
224
+ fields,
225
+ aggregations: {
226
+ translatableFields,
227
+ requiredFields,
228
+ enumFields,
229
+ relationFields,
230
+ },
231
+ };
232
+ }
233
+ // ---------------------------------------------------------------------------
234
+ // Formatter — turns the IR into a TypeScript const literal.
235
+ // ---------------------------------------------------------------------------
236
+ function jsLiteral(value, indent = 0) {
237
+ if (value === null)
238
+ return 'null';
239
+ if (value === undefined)
240
+ return 'undefined';
241
+ if (typeof value === 'string')
242
+ return JSON.stringify(value);
243
+ if (typeof value === 'number' || typeof value === 'boolean')
244
+ return String(value);
245
+ if (Array.isArray(value)) {
246
+ if (value.length === 0)
247
+ return '[]';
248
+ const inner = value.map((v) => jsLiteral(v, indent + 2)).join(', ');
249
+ return `[${inner}]`;
250
+ }
251
+ if (typeof value === 'object') {
252
+ const entries = Object.entries(value);
253
+ if (entries.length === 0)
254
+ return '{}';
255
+ const pad = ' '.repeat(indent + 2);
256
+ const closingPad = ' '.repeat(indent);
257
+ const inner = entries
258
+ .map(([k, v]) => `${pad}${formatKey(k)}: ${jsLiteral(v, indent + 2)}`)
259
+ .join(',\n');
260
+ return `{\n${inner},\n${closingPad}}`;
261
+ }
262
+ return JSON.stringify(value);
263
+ }
264
+ function formatKey(key) {
265
+ // Use bare identifier when possible (preserves nice JS look); fall back
266
+ // to quoted string for keys with hyphens, dots, or starting with a digit.
267
+ if (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key))
268
+ return key;
269
+ return JSON.stringify(key);
270
+ }
271
+ /** Render the SchemaMetadata IR to a TypeScript const literal block. */
272
+ export function formatMetadataConst(meta) {
273
+ const lowerName = meta.modelName.charAt(0).toLowerCase() + meta.modelName.slice(1);
274
+ const constName = `${lowerName}Metadata`;
275
+ const parts = [];
276
+ parts.push(`// ============================================================================\n`);
277
+ parts.push(`// Form Field Metadata (issue #54)\n`);
278
+ parts.push(`// ============================================================================\n\n`);
279
+ parts.push(`/**\n`);
280
+ parts.push(` * Form field metadata for ${meta.modelName}.\n`);
281
+ parts.push(` *\n`);
282
+ parts.push(` * Single source of truth for downstream tools (form runners, stories,\n`);
283
+ parts.push(` * smoke tests, headless validation). Includes per-field type taxonomy,\n`);
284
+ parts.push(` * validation rules, translatable / required / nullable flags, enum value\n`);
285
+ parts.push(` * lists, relation pointers, and i18n labels — all flat, all in one const.\n`);
286
+ parts.push(` */\n`);
287
+ parts.push(`export const ${constName} = {\n`);
288
+ parts.push(` modelName: ${JSON.stringify(meta.modelName)},\n`);
289
+ if (meta.table) {
290
+ parts.push(` table: ${JSON.stringify(meta.table)},\n`);
291
+ }
292
+ parts.push(`\n`);
293
+ parts.push(` fields: {\n`);
294
+ for (const [fieldName, field] of Object.entries(meta.fields)) {
295
+ parts.push(` ${formatKey(fieldName)}: {\n`);
296
+ parts.push(` type: ${JSON.stringify(field.type)},\n`);
297
+ parts.push(` required: ${field.required},\n`);
298
+ parts.push(` nullable: ${field.nullable},\n`);
299
+ parts.push(` translatable: ${field.translatable},\n`);
300
+ parts.push(` validation: ${jsLiteral(field.validation, 6)},\n`);
301
+ if (field.enum) {
302
+ parts.push(` enum: ${JSON.stringify(field.enum)},\n`);
303
+ }
304
+ if (field.enumValues) {
305
+ parts.push(` enumValues: ${jsLiteral([...field.enumValues], 6)} as const,\n`);
306
+ }
307
+ if (field.relation) {
308
+ parts.push(` relation: ${jsLiteral(field.relation, 6)},\n`);
309
+ }
310
+ if (field.compoundType) {
311
+ parts.push(` compoundType: ${JSON.stringify(field.compoundType)},\n`);
312
+ }
313
+ parts.push(` label: ${jsLiteral(field.label, 6)},\n`);
314
+ if (field.placeholder) {
315
+ parts.push(` placeholder: ${jsLiteral(field.placeholder, 6)},\n`);
316
+ }
317
+ parts.push(` },\n`);
318
+ }
319
+ parts.push(` },\n\n`);
320
+ // Aggregations — emit as `as const` arrays so the literal types narrow.
321
+ parts.push(` translatableFields: ${jsLiteral(meta.aggregations.translatableFields, 2)} as const,\n`);
322
+ parts.push(` requiredFields: ${jsLiteral(meta.aggregations.requiredFields, 2)} as const,\n`);
323
+ parts.push(` enumFields: ${jsLiteral(meta.aggregations.enumFields, 2)} as const,\n`);
324
+ parts.push(` relationFields: ${jsLiteral(meta.aggregations.relationFields, 2)} as const,\n`);
325
+ parts.push(`} as const;\n\n`);
326
+ // Field-name type alias for ergonomic keying in downstream consumers.
327
+ parts.push(`export type ${meta.modelName}FieldName = keyof typeof ${constName}.fields;\n\n`);
328
+ return parts.join('');
329
+ }