# @omnifyjp/ts

TypeScript / Zod / form-metadata / payload-builder generator for Omnify
schemas. Reads a `schemas.json` produced by `omnify generate` and emits a
single, type-safe data layer that frontend / consumer projects import
directly.

## What it generates

Per model (in `<output>/base/<Model>.ts`):

- TypeScript **interface** for the row shape
- **Zod schemas** for create / update with validation rules
- **Per-locale Zod sub-objects** for `translatable: true` fields (issue #53)
- **`<modelName>Metadata`** constant — single source of truth for form
  runners (field type taxonomy, validation, label + description + placeholder
  dictionaries, enum values, relation pointers, aggregations) — issues #54 + #59
- **`<Model>CreateFormState` / `<Model>UpdateFormState`** interfaces and
  **`empty<Model>CreateForm()` / `build<Model>CreatePayload()` /
  `build<Model>UpdatePayload()`** helpers (issue #55)

Per model with `options.service` or `options.api` (in `<output>/base/`):

- **`<Model>Service.ts`** — service factory with typed CRUD methods,
  auto-generated `<Model>Filters` interface from filterable fields (issue #58)
- **`<Model>QueryKeys.ts`** — TanStack Query key factory per model
- **`<Model>Hooks.ts`** — React Query hooks (`useList`, `useDetail`,
  `useCreate`, `useUpdate`, `useDelete`, `useRestore`) with auto-invalidation
  and toast notifications via `sonner`

Plus shared infrastructure at the codegen output root:

- `common.ts` — `LocaleMap`, `Locale`, `DateTimeString`, `OmnifyFile`
- `payload-helpers.ts` — `buildI18nPayload`, `emptyLocaleMap`,
  `SUPPORTED_LOCALES`, `DEFAULT_LOCALE`
- `i18n.ts` — validation messages + locale helpers
- `enum/<Enum>.ts` — every enum + helpers
- `index.ts` — barrel re-exports
- `<Model>.ts` — editable wrapper (created once, never overwritten)

## Two ways to invoke

### Backend mode (auto)

When the backend's `omnify.yaml` sets `codegen.typescript.enable: true`,
the Go binary `omnify generate` shells out to `omnify-ts` automatically.
You don't run anything yourself.

### Consumer mode (frontend repo, no Go binary)

Install:

```bash
npm install --save-dev @omnifyjp/ts
```

Create `omnify.yaml` at the consumer project root:

```yaml
# yaml-language-server: $schema=https://cdn.jsdelivr.net/npm/@omnifyjp/omnify@latest/omnify-config-schema.json

# Top-level `input` is the consumer-mode shortcut. Three forms accepted:
#   1. Local path           → ../backend/.omnify/schemas.json
#   2. http(s):// URL       → https://raw.githubusercontent.com/org/repo/v1.2.3/.omnify/schemas.json
#   3. @org/pkg/file path   → @famgia/dxs-product-schemas/schemas.json
input: ../backend/.omnify/schemas.json

codegen:
  typescript:
    enable: true
    output: src/types/models      # `output` is preferred; `modelsPath` still accepted
```

Add to `package.json` scripts:

```json
{
  "scripts": {
    "codegen": "omnify-ts",
    "codegen:check": "omnify-ts && git diff --exit-code src/types/models/base src/types/models/enum"
  }
}
```

Run:

```bash
npm run codegen
```

## CLI flags

```
omnify-ts                                  read omnify.yaml in cwd
omnify-ts -c, --config <path>              specify a different config file
omnify-ts -i, --input <spec>               override input (path / URL / npm spec)
omnify-ts -o, --output <dir>               override output directory
omnify-ts --force                          regenerate auto files
omnify-ts --update                         force re-fetch remote, refresh lockfile
omnify-ts --frozen-lockfile                CI mode: fail on upstream drift
```

`<spec>` (the value of `input:` or `--input`) is sniffed by prefix:

| Prefix | Resolved as |
|---|---|
| `http://` / `https://` | HTTP fetch + cache + lockfile pin |
| `@<scope>/<pkg>...` | Node module resolution from cwd |
| anything else | Local file path (relative to `omnify.yaml`) |

## Three input strategies

| Strategy | Use when | Example |
|---|---|---|
| **Local path** | Monorepo, sibling repos, git submodules — frontend can read backend over the filesystem | `input: ../backend/.omnify/schemas.json` |
| **HTTP(S) URL** | Backend and frontend are independent repos. Pin a tag/commit SHA in the URL for reproducible builds | `input: https://raw.githubusercontent.com/famgia/dxs-product/v3.13.0/.omnify/schemas.json` |
| **NPM package** | Backend publishes a tiny `@org/<project>-schemas` package containing only `schemas.json`. The frontend pins via `package-lock.json` | `input: "@famgia/dxs-product-schemas/schemas.json"` |

Same `omnify.yaml` shape for all three. Switching strategies is a one-line
change to the `input:` field — never a config restructure.

## Reproducibility & lockfile (HTTP only)

For HTTP-sourced inputs, omnify-ts maintains:

- `.omnify/cache/schemas.json` — most recently fetched copy
- `.omnify/input.lock.json` — pins the input URL + sha256

**Commit `.omnify/input.lock.json`** so CI runs `omnify-ts --frozen-lockfile`
and refuses to silently fetch a different hash. Local dev runs without the
flag and gets a warning when upstream changes; the lockfile is auto-updated
and the dev commits it alongside the regenerated types.

| Mode | Behavior on upstream drift |
|---|---|
| Default (local dev) | Re-fetch + warn + auto-update lockfile |
| `--frozen-lockfile` (CI) | Fail loudly; refuse to overwrite the lockfile |
| `--update` | Force re-fetch even if cache + lockfile match |

Local files and npm packages don't need a separate omnify lockfile —
local files are read every run with no fetch step, and npm packages are
already version-pinned by `package-lock.json` / `pnpm-lock.yaml`.

## Idiomatic CRUD form (with the generated builders)

```tsx
import { useState } from 'react';
import {
  emptyProductCreateForm,
  buildProductCreatePayload,
  type ProductCreateFormState,
} from '@/types/models/Product';

export function ProductCreateForm() {
  const [form, setForm] = useState<ProductCreateFormState>(emptyProductCreateForm());

  async function submit() {
    await api.create(buildProductCreatePayload(form));
  }

  return (
    <form onSubmit={submit}>
      <Input translatable value={form.name} onChange={(v) => setForm({ ...form, name: v })} />
      <Input value={form.slug} onChange={(e) => setForm({ ...form, slug: e.target.value })} />
      {/* … */}
    </form>
  );
}
```

The builder handles trimming, locale-mirroring (top-level field set to
default-locale value), nested per-locale sub-object construction, and
dropping empty optional fields. None of that boilerplate lives in the
form anymore.

## Versioning

`@omnifyjp/ts` ships in lockstep with the `omnify` Go binary in the
`@omnifyjp/omnify` npm package. Bump both together; the version is the
single source of truth for the `schemas.json` shape they exchange.

In a sibling-repo / submodule layout the lockfiles take care of this
automatically. In the published-package layout (HTTP URL or `@org/pkg`),
pin the upstream version explicitly in the `input:` URL or npm dep.
