# How idempotency works



## The question [#the-question]

A code generator pipeline that runs multiple generators against
the same schema typically has to answer: &#x2A;*what if two generators
want to produce the same thing?** In SKMTC, two generators producing
`Foo` schemas in the same file converge on one definition — the
file ends up with one `export const foo = ...`, not two.

Even more strongly: **the order in which the generators run
doesn't matter**. Running `gen-zod` before `gen-shadcn-form`
produces the same output as running them in the reverse order.

How does this work? And what invariants does it rest on?

## The short answer [#the-short-answer]

Cross-generator coordination is **memoization**. Every
`insertOperation` / `insertModel` / `insertNormalizedModel` call
is keyed by `(identifier.name, exportPath)`. If an entry with
that key already exists, the existing entry is returned; if not,
a new one is created.

For this to work, two invariants must hold:

1. **`identifier.name` and `exportPath` are pure functions of
   input.** Two generators producing a `Foo` schema in the same
   destination derive the same key.
2. **`register`'s side effects are idempotent** — `Set` for
   imports, `Map.has` for definitions. Repeated calls converge on
   the same state.

These two together make order irrelevant. The system *can't*
allow order to matter.

## The invariants that combine [#the-invariants-that-combine]

### Identifier and exportPath are pure functions [#identifier-and-exportpath-are-pure-functions]

The cache key is `(identifier.name, exportPath)`. Both come from
config fields supplied to the projection-base factory. From the
stock `gen-zod/src/base.ts`:

```ts
import { camelCase, decapitalize } from '@skmtc/core'
import { toTsModelProjectionBase } from '@skmtc/lang-typescript'
import { join } from '@std/path'
import denoJson from '../deno.json' with { type: 'json' }

export const ZodBase = toTsModelProjectionBase({
  id: denoJson.name,

  toIdentifierName({ refName }): string {
    return decapitalize(camelCase(refName))
  },

  toIdentifierType: () => ({ type: 'variable' }),

  toExportPath({ refName, enrichments, variant }): string {
    const name = this.toIdentifierName({ refName, enrichments, variant })
    return join('@', 'types', `${decapitalize(name)}.generated.ts`)
  },
})
```

`ZodProjection` (in a separate file) extends `ZodBase` — the
factory result — and provides the per-instance `toString()`. The
factory wires `toIdentifierName` and `toExportPath` onto the class
as statics so the cache key can be computed without
instantiating.

`toIdentifierName` and `toExportPath` are **pure functions** of
their inputs. Given the same `refName`, they return identical
identifier names and paths. No mutation, no random suffixes, no
timestamps.

This is the load-bearing property. If `toIdentifierName` were
non-deterministic — say, by including a timestamp or a random
ID — the cache would never hit. Each call would produce a fresh
key; each entry would be unique; the file would accumulate
duplicates.

The purity invariant is enforced by convention, not by the type
system. A generator author *could* write a non-pure
`toIdentifierName`. Doing so would silently break idempotency.

### Cache key uses both deterministically [#cache-key-uses-both-deterministically]

`GenerateContext.findDefinition({ name, exportPath })` searches
by exactly those two fields. The check is:

```ts
findDefinition({ name, exportPath }): Definition | undefined {
  const file = this.#files.get(exportPath)
  if (!file) return undefined
  return file.definitions.get(name)
}
```

A `Map.get` lookup, nothing more. The first registration with a
given key wins; subsequent ones return the existing entry.

The `(name, exportPath)` pair is the **only** dimension along
which definitions can collide. Two definitions with the same
name but different paths are independent. Two definitions with
different names in the same path are independent. Same name,
same path → same definition.

### `register`'s side effects are idempotent [#registers-side-effects-are-idempotent]

Generators contribute to a file via `register({ imports,
destinationPath, definitions })`. Each piece of state register
manages is idempotent:

* **Imports** accumulate into `Map<module, Set<importNameKey>>`.
  A `Set` is order-insensitive and duplicate-rejecting. Registering
  the same import twice produces one entry.
* **Definitions** are added via `Map.set`, gated by `Map.has`.
  If a definition with the given identifier name already exists,
  the register call doesn't overwrite it. First-writer-wins.

The two together mean **register is safe to call multiple times
with the same arguments**. The file's state converges on one
correct value regardless of how many generators contribute it.

## Walking through a concrete scenario [#walking-through-a-concrete-scenario]

Two generators, two orderings, identical output.

The setup: `gen-shadcn-form` produces a form for `CreateUser`.
Inside its `toString()`, it calls
`insertNormalizedModel(ZodProjection, { schema: userBodySchema })`.
`gen-zod` independently iterates all schemas and produces the same
`userBody` Zod schema. We want the file to end up with one
`userBody` definition either way.

### Order A: gen-zod first [#order-a-gen-zod-first]

1. The engine iterates generators. `gen-zod` runs first.
2. `gen-zod`'s `transform({ refName: 'User' })` calls
   `context.insertModel(ZodProjection, 'User')`.
3. Driver calls `findDefinition({ name: 'user', exportPath:
   '@/types/user.generated.ts' })`. Not found.
4. Driver creates a `ZodProjection` instance, wraps it in a
   `Definition`, and registers it. File map now contains
   `user.generated.ts → { user: Definition }`.
5. `gen-shadcn-form` runs. Its `transform` calls `insertOperation`,
   which constructs the form Projection.
6. Form Projection's `toString()` calls
   `insertNormalizedModel(ZodProjection, { schema: userBodySchema,
   fallbackName: 'createUserBody' })`.
7. The schema *is* a ref to the `User` component. Driver derives
   key `('user', '@/types/user.generated.ts')`.
8. `findDefinition` returns the existing entry from step 4. No
   new Projection is constructed.
9. Form Projection records the import (`import { user } from
   '@/types/user.generated.ts'`) and references `user` in its
   output.

Final state: one `userBody` definition, imported by the form.

### Order B: gen-shadcn-form first [#order-b-gen-shadcn-form-first]

1. The engine iterates. `gen-shadcn-form` runs first.
2. `gen-shadcn-form`'s `transform` constructs the form Projection.
3. Form Projection's `toString()` calls
   `insertNormalizedModel(ZodProjection, { schema: userBodySchema })`.
4. `findDefinition({ name: 'user', exportPath:
   '@/types/user.generated.ts' })`. Not found.
5. Driver creates a `ZodProjection` instance, wraps it in a
   `Definition`, registers it. File map now contains
   `user.generated.ts → { user: Definition }`.
6. Form Projection records the import.
7. `gen-zod` runs. Its `transform({ refName: 'User' })` calls
   `context.insertModel(ZodProjection, 'User')`.
8. Driver derives key `('user', '@/types/user.generated.ts')`.
9. `findDefinition` returns the existing entry from step 5. No
   new Projection is constructed.

Final state: one `userBody` definition, imported by the form.

### Result: identical `#files` map [#result-identical-files-map]

Both orderings produce the same `#files` map. The Render phase
walks the map and produces the same strings. The only observable
difference between the runs is in log line ordering, not output.

The cache makes order irrelevant *by construction*. Generators
don't need to know about each other's existence. They can be
written and tested in isolation.

## Edge cases [#edge-cases]

### Same-name collisions across generators [#same-name-collisions-across-generators]

What if two unrelated generators independently produce a `Foo`
definition in the same file? It depends on the insertion path:

* **Driver path** (`insertModel`, `insertOperation`,
  `insertNormalizedModel`): the second writer throws
  `Registered definition mismatch` via `affirmDefinition`
  (`core/dsl/operation/oas/OasOperationDriver.ts:129`,
  `GqlOperationDriver.ts:129`, `model/ModelDriver.ts:137`).
  The collision is loud.
* **Bare `register({ definitions })` path**: silent
  first-write-wins via `Map.has`. The second is dropped.

This is a known sharp edge. Two scenarios where it happens:

1. **Two generators with overlapping scope** (e.g., two different
   schema generators producing the same `User` definition). The
   first to run wins. The user typically doesn't want both
   anyway, so the collision is harmless if benign.
2. **Two generators using `fallbackName` for unrelated inline
   schemas** (see below).

The engine doesn't warn about same-name overwrites. A future
improvement would add a diagnostic for this case.

### Inline-schema fallback names [#inline-schema-fallback-names]

`insertNormalizedModel(ZodProjection, { schema, fallbackName })`
accepts either an `OasSchema | OasRef | OasVoid`. The two cases
differ:

* **Ref**: the schema *is* a ref. The Driver routes through the
  model cache using the ref's name. Strict integrity — multiple
  generators referring to the same component converge.
* **Inline schema** (no ref): there's no canonical name. The
  Driver uses `fallbackName` to derive the cache key.

The integrity gap: two generators independently passing different
`fallbackName`s for the *same* inline schema will produce two
separate definitions. The system can't tell that they're "the
same" without a name.

This is tracked as `#SKM-47` in the [status and roadmap](/docs/explanation/status-and-roadmap).
The mitigation: generator authors should prefer refs when
possible; the spec author should hoist commonly-used inline
schemas to components.

### Pure functions that aren't really pure [#pure-functions-that-arent-really-pure]

A generator's `toIdentifierName` could depend on context state
(e.g., reading `this.context.someState`), accidentally violating
purity. The cache would then miss inconsistently — sometimes
hitting, sometimes missing, depending on what state had been
mutated when.

Anti-pattern; the operational principles in
`llms.md` call this out explicitly. Generator
authors should treat `toIdentifierName` and `toExportPath` as pure
functions of their inputs.

### Memoization can't work if `toString()` is non-pure [#memoization-cant-work-if-tostring-is-non-pure]

The cache returns existing `Definition` instances. Their `value`
is a Projection. The Projection's `toString()` is called once at
Render time. If a generator's `toString()` reads state that
changed between calls, the output could vary.

In practice this is rare — Projection state is initialized at
construction (via the constructor args) and immutable thereafter.
But the failure mode exists for generator authors who write
`this.someField = newValue` in `toString()`.

## See also [#see-also]

* [Cross-generator coordination concept](/docs/concepts/cross-generator-coordination) —
  the practical walkthrough
* [Why three phases](/docs/explanation/why-three-phases) — Generate's invariant
  is one of three
* [API: GenerateContext](/docs/reference/api/generate-context) — the
  `register`, `findDefinition`, `insertModel`, `insertOperation`
  surface
* [API: ContentSettings](/docs/reference/api/content-settings) — what the
  cache key is derived from
* [Status and roadmap](/docs/explanation/status-and-roadmap) — `#SKM-47` and
  related known limitations
* [Design philosophy](/docs/explanation/design-philosophy) — idempotency as a
  load-bearing principle
