@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omnifyjp/omnify",
3
- "version": "3.12.5",
3
+ "version": "3.13.1",
4
4
  "description": "Schema-driven code generation for Laravel, TypeScript, and SQL",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -36,10 +36,10 @@
36
36
  "zod": "^3.24.0"
37
37
  },
38
38
  "optionalDependencies": {
39
- "@omnifyjp/omnify-darwin-arm64": "3.12.5",
40
- "@omnifyjp/omnify-darwin-x64": "3.12.5",
41
- "@omnifyjp/omnify-linux-x64": "3.12.5",
42
- "@omnifyjp/omnify-linux-arm64": "3.12.5",
43
- "@omnifyjp/omnify-win32-x64": "3.12.5"
39
+ "@omnifyjp/omnify-darwin-arm64": "3.13.1",
40
+ "@omnifyjp/omnify-darwin-x64": "3.13.1",
41
+ "@omnifyjp/omnify-linux-x64": "3.13.1",
42
+ "@omnifyjp/omnify-linux-arm64": "3.13.1",
43
+ "@omnifyjp/omnify-win32-x64": "3.13.1"
44
44
  }
45
45
  }
package/ts-dist/cli.d.ts CHANGED
@@ -5,9 +5,22 @@
5
5
  * Reads omnify.yaml to resolve schemas.json input and TypeScript output paths.
6
6
  * Falls back to explicit --input / --output flags.
7
7
  *
8
+ * Two omnify.yaml shapes are accepted:
9
+ *
10
+ * 1. Backend shape (legacy / source-of-truth): the schemas.json path is
11
+ * resolved indirectly via `connections.<default>.migrations[type=laravel].schemasPath`.
12
+ * The same file the omnify-go CLI reads.
13
+ *
14
+ * 2. Consumer shape (frontend): a top-level `input` field that accepts
15
+ * a local path, an http(s) URL, or an npm package specifier (e.g.
16
+ * `@org/pkg/schemas.json`). No connections / migrations needed —
17
+ * consumers don't have a database.
18
+ *
8
19
  * Usage:
9
20
  * omnify-ts # reads omnify.yaml in cwd
10
21
  * omnify-ts --config path/to/omnify.yaml
11
22
  * omnify-ts --input schemas.json --output ./types # explicit override
23
+ * omnify-ts --frozen-lockfile # CI mode: fail if upstream drift
24
+ * omnify-ts --update # force re-fetch remote input
12
25
  */
13
26
  export {};
package/ts-dist/cli.js CHANGED
@@ -5,10 +5,23 @@
5
5
  * Reads omnify.yaml to resolve schemas.json input and TypeScript output paths.
6
6
  * Falls back to explicit --input / --output flags.
7
7
  *
8
+ * Two omnify.yaml shapes are accepted:
9
+ *
10
+ * 1. Backend shape (legacy / source-of-truth): the schemas.json path is
11
+ * resolved indirectly via `connections.<default>.migrations[type=laravel].schemasPath`.
12
+ * The same file the omnify-go CLI reads.
13
+ *
14
+ * 2. Consumer shape (frontend): a top-level `input` field that accepts
15
+ * a local path, an http(s) URL, or an npm package specifier (e.g.
16
+ * `@org/pkg/schemas.json`). No connections / migrations needed —
17
+ * consumers don't have a database.
18
+ *
8
19
  * Usage:
9
20
  * omnify-ts # reads omnify.yaml in cwd
10
21
  * omnify-ts --config path/to/omnify.yaml
11
22
  * omnify-ts --input schemas.json --output ./types # explicit override
23
+ * omnify-ts --frozen-lockfile # CI mode: fail if upstream drift
24
+ * omnify-ts --update # force re-fetch remote input
12
25
  */
13
26
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
14
27
  import { resolve, dirname, join } from 'node:path';
@@ -16,33 +29,57 @@ import { Command } from 'commander';
16
29
  import { parse as parseYaml } from 'yaml';
17
30
  import { generateTypeScript } from './generator.js';
18
31
  import { generatePhp } from './php/index.js';
32
+ import { resolveInput, sniffInputKind } from './input-resolver.js';
19
33
  function resolveFromConfig(configPath) {
20
34
  const raw = readFileSync(configPath, 'utf-8');
21
35
  const config = parseYaml(raw);
22
36
  const configDir = dirname(configPath);
23
- // Find default connection
24
- const defaultName = config.default ?? 'default';
25
- const connections = config.connections ?? {};
26
- const conn = connections[defaultName];
27
- if (!conn) {
28
- throw new Error(`Connection "${defaultName}" not found in ${configPath}`);
29
- }
30
- // Resolve schemas.json input path from migrations[type=laravel].schemasPath
31
- let schemasPath;
32
- for (const m of conn.migrations ?? []) {
33
- if (m.type === 'laravel' && m.schemasPath) {
34
- schemasPath = m.schemasPath;
35
- break;
37
+ // Determine the input spec. Two shapes are accepted; consumer shape wins
38
+ // when both are present (top-level `input` is the explicit signal that
39
+ // this is a consumer project).
40
+ let inputSpec;
41
+ if (config.input) {
42
+ // Consumer shape accept the spec as-is. The input resolver will
43
+ // sniff it (path / URL / npm) and translate it to a local file later.
44
+ // For local relative paths we still need to anchor them to configDir
45
+ // before handing off, so the resolver doesn't see a cwd-relative path.
46
+ if (sniffInputKind(config.input) === 'local' && !config.input.startsWith('/')) {
47
+ inputSpec = resolve(configDir, config.input);
48
+ }
49
+ else {
50
+ inputSpec = config.input;
36
51
  }
37
52
  }
38
- if (!schemasPath) {
39
- throw new Error(`No schemasPath found in laravel migration for connection "${defaultName}" in ${configPath}.\n` +
40
- `Either add schemasPath to your laravel migration config, or use --input explicitly.`);
53
+ else {
54
+ // Backend / legacy shape walk connections.<default>.migrations to
55
+ // find the laravel target's schemasPath.
56
+ const defaultName = config.default ?? 'default';
57
+ const connections = config.connections ?? {};
58
+ const conn = connections[defaultName];
59
+ if (!conn) {
60
+ throw new Error(`Connection "${defaultName}" not found in ${configPath}.\n` +
61
+ `For consumer projects (frontend), add a top-level "input:" field instead.`);
62
+ }
63
+ let schemasPath;
64
+ for (const m of conn.migrations ?? []) {
65
+ if (m.type === 'laravel' && m.schemasPath) {
66
+ schemasPath = m.schemasPath;
67
+ break;
68
+ }
69
+ }
70
+ if (!schemasPath) {
71
+ throw new Error(`No schemasPath found in laravel migration for connection "${defaultName}" in ${configPath}.\n` +
72
+ `Either add schemasPath to your laravel migration config, set top-level "input:", ` +
73
+ `or use --input explicitly.`);
74
+ }
75
+ inputSpec = resolve(configDir, schemasPath);
41
76
  }
42
- // TypeScript config (enable defaults to false)
77
+ // TypeScript config (enable defaults to false). Accept both `output`
78
+ // (new) and `modelsPath` (legacy) — `output` wins if both are set.
43
79
  const tsConfig = config.codegen?.typescript;
44
- const tsEnabled = tsConfig?.enable === true && !!tsConfig?.modelsPath;
45
- const tsOutput = tsEnabled ? resolve(configDir, tsConfig.modelsPath) : undefined;
80
+ const tsOutputRaw = tsConfig?.output ?? tsConfig?.modelsPath;
81
+ const tsEnabled = tsConfig?.enable === true && !!tsOutputRaw;
82
+ const tsOutput = tsEnabled ? resolve(configDir, tsOutputRaw) : undefined;
46
83
  // Laravel config (enable defaults to false)
47
84
  const laravelConfig = config.codegen?.laravel;
48
85
  const laravelEnabled = laravelConfig?.enable === true;
@@ -62,34 +99,34 @@ function resolveFromConfig(configPath) {
62
99
  nestedset: laravelConfig?.nestedset,
63
100
  } : undefined;
64
101
  return {
65
- input: resolve(configDir, schemasPath),
102
+ inputSpec,
66
103
  tsEnabled,
67
104
  tsOutput,
68
105
  laravelEnabled,
69
106
  laravelOverrides,
70
107
  };
71
108
  }
72
- // ============================================================================
73
- // CLI
74
- // ============================================================================
75
109
  const program = new Command();
76
110
  program
77
111
  .name('omnify-ts')
78
112
  .description('Generate TypeScript types and Laravel PHP code from Omnify schemas.json')
79
113
  .option('-c, --config <path>', 'Path to omnify.yaml (default: ./omnify.yaml)')
80
- .option('-i, --input <path>', 'Path to schemas.json (overrides config)')
114
+ .option('-i, --input <spec>', 'schemas.json source: local path, http(s) URL, or npm package spec (overrides config)')
81
115
  .option('-o, --output <path>', 'Output directory for TypeScript (overrides config)')
82
116
  .option('--force', 'Force regeneration of auto-generated files', false)
83
- .action((opts) => {
84
- let inputPath;
117
+ .option('--frozen-lockfile', 'Fail if remote input differs from .omnify/input.lock.json (CI mode)', false)
118
+ .option('--update', 'Force re-fetch of remote input and refresh the lockfile', false)
119
+ .action(async (opts) => {
120
+ let inputSpec;
85
121
  let tsEnabled = true;
86
122
  let tsOutput;
87
123
  let laravelEnabled = false;
88
124
  let laravelOverrides;
89
125
  let configDir = process.cwd();
90
126
  if (opts.input && opts.output) {
91
- // Explicit flags — skip config, only TS generation
92
- inputPath = resolve(opts.input);
127
+ // Explicit flags — skip config, only TS generation. The flag is
128
+ // passed through unchanged so URLs/npm specs work too.
129
+ inputSpec = opts.input;
93
130
  tsOutput = resolve(opts.output);
94
131
  }
95
132
  else {
@@ -102,17 +139,27 @@ program
102
139
  process.exit(1);
103
140
  }
104
141
  const resolved = resolveFromConfig(configPath);
105
- inputPath = opts.input ? resolve(opts.input) : resolved.input;
142
+ inputSpec = opts.input ?? resolved.inputSpec;
106
143
  tsEnabled = resolved.tsEnabled;
107
144
  tsOutput = opts.output ? resolve(opts.output) : resolved.tsOutput;
108
145
  laravelEnabled = resolved.laravelEnabled;
109
146
  laravelOverrides = resolved.laravelOverrides;
110
147
  }
111
- // Read schemas.json
112
- if (!existsSync(inputPath)) {
113
- console.error(`Error: schemas.json not found: ${inputPath}\nRun "omnify generate" first.`);
148
+ // Resolve the input spec to a local file path. This is where http
149
+ // fetch + lockfile + cache happen for remote sources.
150
+ let resolvedInput;
151
+ try {
152
+ resolvedInput = await resolveInput(inputSpec, {
153
+ configDir,
154
+ frozen: opts.frozenLockfile,
155
+ forceUpdate: opts.update,
156
+ });
157
+ }
158
+ catch (err) {
159
+ console.error(err.message);
114
160
  process.exit(1);
115
161
  }
162
+ const inputPath = resolvedInput.localPath;
116
163
  const raw = readFileSync(inputPath, 'utf-8');
117
164
  const input = JSON.parse(raw);
118
165
  console.log(`Reading schemas from ${inputPath}`);
@@ -185,4 +232,7 @@ program
185
232
  }
186
233
  console.log(`\nGeneration complete.`);
187
234
  });
188
- program.parse();
235
+ program.parseAsync().catch((err) => {
236
+ console.error(err);
237
+ process.exit(1);
238
+ });
@@ -16,6 +16,8 @@ import { schemaToInterface, formatInterface, needsDateTimeImports, } from './int
16
16
  import { generateEnums, generatePluginEnums, formatEnum, formatTypeAlias, extractInlineEnums, } from './enum-generator.js';
17
17
  import { generateZodSchemas, generateDisplayNames, getExcludedFields, formatZodSchemasSection, formatZodModelFile, } from './zod-generator.js';
18
18
  import { generateI18nFileContent } from './i18n-generator.js';
19
+ import { buildSchemaMetadata, formatMetadataConst, } from './metadata-generator.js';
20
+ import { buildFormShape, formatPayloadBuilderSection, } from './payload-builder-generator.js';
19
21
  /** Auto-generated file header. */
20
22
  function generateBaseHeader() {
21
23
  return `/**
@@ -90,7 +92,21 @@ function generateBaseInterfaceFile(schemaName, schemas, options) {
90
92
  const displayNames = generateDisplayNames(schema, options);
91
93
  const excludedFields = getExcludedFields(schema);
92
94
  parts.push('\n');
93
- parts.push(formatZodSchemasSection(schemaName, zodSchemas, displayNames, excludedFields));
95
+ parts.push(formatZodSchemasSection(schemaName, zodSchemas, displayNames, excludedFields, schema, options));
96
+ // Form field metadata constant (issue #54)
97
+ const metadata = buildSchemaMetadata(schema, schemas, options, displayNames);
98
+ parts.push(formatMetadataConst(metadata));
99
+ // Form-state interfaces + payload builders (issue #55). Only emit when
100
+ // the schema has at least one form-relevant field; otherwise the helpers
101
+ // would be empty noise.
102
+ const formShape = buildFormShape(schema, schemas, options);
103
+ if (formShape.fields.length > 0) {
104
+ // The builders depend on `Locale` (from common.ts) and the shared
105
+ // `buildI18nPayload` / `emptyLocaleMap` helpers (from payload-helpers.ts).
106
+ // Inject the imports near the top of the file.
107
+ insertPayloadImports(parts);
108
+ parts.push(formatPayloadBuilderSection(formShape, options.defaultLocale));
109
+ }
94
110
  return {
95
111
  filePath: `base/${schemaName}.ts`,
96
112
  content: parts.join(''),
@@ -99,6 +115,24 @@ function generateBaseInterfaceFile(schemaName, schemas, options) {
99
115
  category: 'base',
100
116
  };
101
117
  }
118
+ /**
119
+ * Inject the imports the payload builder section needs into the parts
120
+ * array. Has to run BEFORE rendering the builder section but AFTER the
121
+ * existing import block. We rely on the fact that the file content is
122
+ * still string fragments in `parts` and we can splice in the imports near
123
+ * the top.
124
+ */
125
+ function insertPayloadImports(parts) {
126
+ // The first part is the header; subsequent parts include `import { z }`
127
+ // and other imports. Find the index of the first non-import / non-header
128
+ // part (where the interface or `\n` block begins) and inject before it.
129
+ // For simplicity, we just append the imports immediately after the
130
+ // header (index 1) since the existing imports below it don't conflict
131
+ // and the TS compiler is happy with imports interleaved at the top.
132
+ const importBlock = `import type { Locale } from '../common';\n` +
133
+ `import { buildI18nPayload, emptyLocaleMap } from '../payload-helpers';\n`;
134
+ parts.splice(1, 0, importBlock);
135
+ }
102
136
  /** Generate an enum file. */
103
137
  function generateEnumFile(enumDef, isPlugin) {
104
138
  const parts = [generateBaseHeader()];
@@ -212,6 +246,70 @@ ${fileSection}`;
212
246
  overwrite: true,
213
247
  };
214
248
  }
249
+ /** Generate the shared payload-helpers.ts file (used by per-model builders). */
250
+ function generatePayloadHelpersFile(options) {
251
+ const localesArrayLiteral = `[${options.locales.map((l) => `'${l}'`).join(', ')}]`;
252
+ const content = `${generateBaseHeader()}/**
253
+ * Shared utilities used by every model's payload builder. The per-model
254
+ * \`build<Model>CreatePayload\` / \`build<Model>UpdatePayload\` helpers
255
+ * import from this file rather than re-emitting the same code per model.
256
+ *
257
+ * Issue omnify-jp/omnify-go#55.
258
+ */
259
+
260
+ import type { Locale } from './common';
261
+
262
+ /** Locales the project supports — kept in sync with omnify.yaml's locale.locales. */
263
+ export const SUPPORTED_LOCALES: readonly Locale[] = ${localesArrayLiteral};
264
+
265
+ /** Default locale (mirrors omnify.yaml's locale.defaultLocale). */
266
+ export const DEFAULT_LOCALE: Locale = '${options.defaultLocale}';
267
+
268
+ /** Build a fresh locale map with every supported locale present and empty. */
269
+ export function emptyLocaleMap(): Record<Locale, string> {
270
+ const map = {} as Record<Locale, string>;
271
+ for (const locale of SUPPORTED_LOCALES) {
272
+ map[locale] = '';
273
+ }
274
+ return map;
275
+ }
276
+
277
+ /**
278
+ * Convert a flat \`{ fieldName: { ja: 'x', en: 'y', vi: 'z' } }\` form-state
279
+ * map into the nested locale-keyed wire shape that the create / update zod
280
+ * schemas accept (issue #53). Empty per-locale strings are preserved (the
281
+ * server treats them as "no translation"); fields where the entire dict is
282
+ * undefined are skipped so update payloads only include touched fields.
283
+ */
284
+ export function buildI18nPayload(
285
+ fields: Record<string, Record<string, string> | undefined>,
286
+ ): Record<Locale, Record<string, string>> {
287
+ const out = {} as Record<Locale, Record<string, string>>;
288
+ for (const locale of SUPPORTED_LOCALES) {
289
+ const sub: Record<string, string> = {};
290
+ let hasAny = false;
291
+ for (const [fieldName, dict] of Object.entries(fields)) {
292
+ if (!dict) continue;
293
+ const value = dict[locale];
294
+ if (value !== undefined) {
295
+ sub[fieldName] = value.trim();
296
+ hasAny = true;
297
+ }
298
+ }
299
+ if (hasAny) {
300
+ out[locale] = sub;
301
+ }
302
+ }
303
+ return out;
304
+ }
305
+ `;
306
+ return {
307
+ filePath: 'payload-helpers.ts',
308
+ content,
309
+ types: ['SUPPORTED_LOCALES', 'DEFAULT_LOCALE', 'emptyLocaleMap', 'buildI18nPayload'],
310
+ overwrite: true,
311
+ };
312
+ }
215
313
  /** Generate i18n.ts with validation messages. */
216
314
  function generateI18nFile(options) {
217
315
  return {
@@ -391,6 +489,8 @@ export function generateTypeScript(input) {
391
489
  });
392
490
  // Common types
393
491
  files.push(generateCommonFile(options, hasFiles));
492
+ // Shared payload-builder helpers (issue #55)
493
+ files.push(generatePayloadHelpersFile(options));
394
494
  // I18n
395
495
  files.push(generateI18nFile(options));
396
496
  // Index re-exports
@@ -0,0 +1,84 @@
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
+ export type InputKind = 'local' | 'http' | 'npm';
28
+ export interface ResolveOptions {
29
+ /**
30
+ * Directory containing the omnify.yaml that declared the input. Used as
31
+ * the base for resolving relative paths and as the root for the
32
+ * `.omnify/cache/` and `.omnify/input.lock.json` files.
33
+ */
34
+ configDir: string;
35
+ /**
36
+ * CI mode. When true, refuse to fetch if the lockfile is missing or its
37
+ * hash doesn't match upstream. Caller should set this from
38
+ * `--frozen-lockfile`.
39
+ */
40
+ frozen?: boolean;
41
+ /**
42
+ * Force re-fetch even when the cache is valid and the lockfile matches.
43
+ * Use this when the user explicitly asked for an update (e.g.
44
+ * `omnify-ts --update`).
45
+ */
46
+ forceUpdate?: boolean;
47
+ }
48
+ export interface ResolveResult {
49
+ /** Absolute path to a local file the caller can read with fs APIs. */
50
+ localPath: string;
51
+ kind: InputKind;
52
+ /** SHA-256 of the resolved file. Always set for http/npm; set for local
53
+ * too as a courtesy for diagnostics. */
54
+ sha256: string;
55
+ }
56
+ /**
57
+ * Subset of the WHATWG fetch API used by the resolver. Injectable so tests
58
+ * can stub it without monkey-patching globalThis.
59
+ */
60
+ export interface HttpFetcher {
61
+ (url: string): Promise<{
62
+ ok: boolean;
63
+ status: number;
64
+ statusText: string;
65
+ text(): Promise<string>;
66
+ }>;
67
+ }
68
+ /**
69
+ * Decide which kind of source a spec string is. Heuristic — explicit
70
+ * prefixes win, ambiguous bare names default to local. Unscoped npm
71
+ * packages are intentionally NOT supported because they collide with
72
+ * relative path semantics; users should publish under a scope.
73
+ */
74
+ export declare function sniffInputKind(spec: string): InputKind;
75
+ /**
76
+ * Resolve an input spec to a concrete local file path. Dispatches by kind:
77
+ * local files are read directly, npm packages are resolved through Node
78
+ * module resolution, and HTTP URLs are fetched, cached, and pinned in the
79
+ * lockfile.
80
+ *
81
+ * The `fetcher` parameter is exposed only for tests; production callers
82
+ * should leave it at the default (`globalThis.fetch`).
83
+ */
84
+ export declare function resolveInput(spec: string, opts: ResolveOptions, fetcher?: HttpFetcher): Promise<ResolveResult>;