@pyreon/lathe — API Reference
Generated from
lathe'ssrc/manifest.ts— the same source that powersllms.txtand MCPget_api. Do not edit this page by hand; edit the manifest. For the conceptual guide, see lathe.
Lathe reads an OpenAPI 3.x document and emits a client for the Pyreon stack: @pyreon/validate schemas, @pyreon/http endpoint declarations, @pyreon/query hooks, deterministic mock fixtures, and @pyreon/atlas scenarios derived from the spec's own enums and examples. The spec parser is first-party — a YAML reader scoped to the OpenAPI subset that REFUSES anchors, tags and tab indentation rather than mis-reading them — so there is no third-party spec dependency to trust. What separates it from a conventional generator is target: 'multiplatform': the native compiler (PMTC) lowers only a SUBSET of TypeScript and has no module graph, so it recognises a client, a schema and a call only when they share ONE file's top level. Hand-written code drifts out of that constantly; generated code need not, so Lathe emits a self-contained module per tag — a layout no human would maintain and exactly the one the compiler wants — then runs the real compiler over its own output and checks for the POSITIVE marker (PyreonQuery<, PyreonZodSchema_), because zero warnings is not evidence of lowering.
Features
First-party OpenAPI 3.x reader: JSON or YAML, local
$refresolution,allOfflattening through refs (the inheritance idiom),oneOf/anyOfwithdiscriminator, 3.1type: [string, null], path-level parameters, and{id}→:idconversion to the@pyreon/httpendpoint formOwn YAML parser scoped to the OpenAPI subset — block maps/sequences, flow collections, block scalars, quote-aware colon splitting (a URL value and a quoted key both contain one) — that REFUSES anchors, merge keys, explicit tags and tab indentation with a line number rather than producing a subtly wrong document
Loss is REPORTED, never silent: every spec feature the IR cannot represent becomes a
notewith a stable greppablecode(unsupported-schema,unsupported-ref,missing-operation-id,multiple-content-types,no-servers) and a JSON-pointer locationtarget: 'multiplatform'emits an additional self-contained module per tag — client, schemas, endpoints and calls sharing one top level — because PMTC resolves nothing across file boundaries; the web output is unchanged, so enabling it can never make the web build worseThe multiplatform claim is MEASURED:
verifyNativeruns the real@pyreon/native-compileron both targets and asserts the positive marker plus the absence of leaked web-only symbols. Adoes NOT compilewarning is treated as broken, not advisory, and an absent compiler SKIPS loudly rather than passingPer-operation native reach with a reason in spec terms: a path parameter is supplied at runtime and PMTC bakes URLs at compile time, so that operation is reported
web-onlyby name instead of silently degradingPlugin selection is expanded along the IMPORT EDGES of the emitted code, not refused:
componentspulls inqueries->client->schemasbecausecomponents.tsximports the hooks, and the report names what came along. Selecting a plugin without what its output imports previously produced files referencing modules that were never written - output that looks complete and does not resolve.componentsitself is independent of Atlas: the previews are ordinary Pyreon components over the generated hooks, so a project that wants them without a workbench gets exactly thatEvery emitter is opt-in via
plugins(types/schemas/client/queries/mocks/faker/components/atlas/docs) — schemas alone is a first-class use, andtargetis ADDITIVE on top of the selection rather than a separate output, so asking for schemas gets schemas on both targetsThe output is a LAYERED graph, not one barrel:
index.tscarries the production surface,dev.tsthe fixtures/factories/previews, andendpoints/index.ts+queries/index.tsone layer each, so a consumer can take exactly the layer it needs. An emittedpackage.jsondeclares the output side-effect-free (an ARRAY namingatlas.wrapper.tsx, which really does callinstallMocks()at module scope, rather than a blanketfalsethat would be a lie) — measured with Vite on a 120-operation spec, importing one hook went from 30.7 kB to 5.7 kB, and the barrel now costs exactly what the per-tag import costsfakeremits one factory per model (createBook(overrides?)), and its rule is that a factory must produce data its OWN schema accepts:min/max/pattern/enumchoose the generator and the field-name guess only applies where the spec states nothing. Depth is threaded explicitly so a recursive model terminates.docsrenders Markdown with frontmatter — the generated HOOK name and its import site next to the HTTP contract, plus the one column a rendering of the spec cannot produce: whether the operation reaches iOS and Android, and when it does not, whySeveral specs in ONE pass via
projects: [{ name, input, output }]— each to its own path (typically another package in the workspace, which is the intended use), withtarget/pluginswritten once at the top level and overridable per project.lathe checkcovers them all and fails if any is stale; a CLI--outalongsideprojectsis REFUSED rather than applied to every oneA generated
index.tsbarrel and akeys.tsquery-key registry: one import site regardless of how operations were tagged, and invalidation keys derived from the endpoints rather than hand-written literals that drift the moment a path changes (keys.books.listBooks.allmatches every call;.of(args)matches one)The Atlas story is FULLY generated:
components.tsxemits one browsable preview per read operation whose variant axis is the DATA STATE (a real prop, so Atlas infers a control),atlas.scenarios.tskeys those exact component names, andatlas.wrapper.tsxsupplies the QueryClientProvider with the generated mocks installed so every card renders with NO server. Measured on the example:atlas scanreports 2 components, 8 scenarios, 8 verified, 0 failing, from anatlas.config.tsthat names no component, scenario or providerAutomation: a
@pyreon/lathe/viteplugin regenerates on dev-server start and on every spec change, withcheckOnBuildturning a stale client into a BUILD ERROR rather than a warning; pluslathe generate --watch. The watcher is on the containing DIRECTORY with a filename filter, because editors write via rename and a watch on the inode dies the first time one replaces the file; an unparseable mid-save spec prints and keeps watching rather than exitingDeterministic by construction: sorted models, sorted operations, sorted imports, no randomness in fixtures — an unchanged spec regenerates byte-identically, so a regeneration diff is reviewable
lathe checkis the CI half — regenerates in memory and fails when committed output has drifted from the spec, the same contract asgen-docs --checkThe SCHEMA LIBRARY is selectable —
validator: 'pyreon' | 'zod'. One walk with a different binding, not two renderers that can drift; both satisfy Standard Schema, so the endpoint layer accepts either without knowing. Every zod spelling was verified against the INSTALLED zod (4.4.3) rather than inferred:z.string().email()is deprecated there in favour ofz.email(), and the deprecated form is emitted deliberately because it works in zod 3 AND 4 while the newer one exists only in 4MEASURED, and the opposite of what you would assume: on the native target PMTC's zod recogniser STRICTLY DOMINATES its first-party
s.*one — a nested object and an array of objects lower under zod (via@pyreon/validation'szodSchema(...)wrapper) and are DROPPED unders.*. Sovalidator: 'zod'lowers strictly more of a real spec. Pinned by a test running the real compiler, so a PMTC change corrects the claim rather than leaving it staleA field NAMING another model — what every OpenAPI document of any size is full of — is dropped by both recognisers, and under zod that gap CLOSES: refs are inlined on the native path, and an inlined ref is a nested object. A
$refcycle falls back to naming the target (no finite nesting exists), so the compiler drops that one field with a warning and the generator stays boundedThe HTTP runtime is SELECTABLE —
client: 'pyreon' | 'fetch' | 'axios' | 'ky'. Onlyclient.tschanges: every other emitted file reads an endpoint's callable /.key/.query()shape and nothing else, so endpoints, hooks, keys and the barrel are BYTE-IDENTICAL across all four. An adapter emits a self-contained endpoint factory over that library rather than wrapping@pyreon/http, so choosing axios means genuinely not depending on itAdapter URL and key semantics are pinned to
@pyreon/httpby a DIFFERENTIAL test that uses its ownbuildUrlas the ORACLE, over the shapes these libraries disagree on: a leading-slash path under a based URL (axios and ky RESOLVE, discarding the base's path; Pyreon PREFIXES), nullish query values, array values, and a path parameter containing/. A duplicated URL builder drifts, and drift there means the same generated call issues a different request depending on one config wordFailure is normalised across adapters into one
LatheHttpErrorcarryingstatusand the parsed body —fetchresolves a 500, axios rejects with anAxiosError, ky with anHTTPError, and a generated query'serrormust not change shape when the transport is swapped. Retry policy is deliberately NOT normalised (ky retries 5xx GETs, the others do not) and is asserted rather than papered overtarget: 'multiplatform'with a non-Pyreon client is REFUSED, not silently downgraded: PMTC lowerscreateHttp+api.endpoint(...)by NAME, so emitting native modules over axios would produce exactly the silent regression to web-only that the target exists to catchMocks ride on
@pyreon/http's ownmock()middleware rather than MSW: no service worker, no extra install, identical in node and the browser. A parameterised route emits a bounded RegExp — the declared/books/:idis not a SUFFIX of the resolved/v1/books/b1, so a plain string matched nothing and every such fixture fell through to the real network. Adapter clients need no pattern at all: their seam is handed the declared path alongside the resolved oneAtlas scenarios generated from the spec — one per enum value on a response field, so a variant axis the API declares is one the workbench actually exercises, and it regenerates when the API changes instead of drifting
Complete example
A full, end-to-end usage of the package:
$ pyreon lathe generate ./openapi.yaml --target multiplatform
lathe / Bookshelf 1.2.0
4 models 4 operations target=multiplatform
+ src/gen/schemas.ts
+ src/gen/client.ts
+ src/gen/endpoints/books.ts
+ src/gen/queries/books.ts
+ src/gen/books.native.tsx
5 file(s) written
native reach 3/4 operations
web-only 1 op(s): createBook
POST lowers through mutations, which PMTC does not yet recognise.
lowers books.native.tsx swift [PyreonQuery< PyreonZodSchema_]
lowers books.native.tsx kotlin [PyreonQuery< PyreonZodSchema_]Exports
| Symbol | Kind | Summary |
|---|---|---|
generate | function | The whole pipeline, pure: spec text in, file CONTENTS out. |
resolveConfig | function | Fills defaults and validates one project's settings, and is where the whole option surface lives: plugins (which emitt |
verifyNative | function | Runs the real native compiler over the generated .native.tsx modules on both targets and returns a per-file verdict. |
loadOpenApi | function | Parses an OpenAPI 3.x document (JSON or YAML text) into the spec-agnostic IR. |
API
generate function
generate(specText: string, config: ResolvedConfig): GenerateResultThe whole pipeline, pure: spec text in, file CONTENTS out. Touches no filesystem, which is what makes the generator testable without a temp directory and lets lathe check diff before writing. Returns the IR document, the generated files, and a per-operation reach map explaining in spec terms which operations can run natively and why the others cannot.
Example
import { generate, resolveConfig } from '@pyreon/lathe'
const config = resolveConfig({ input: './openapi.yaml', target: 'multiplatform' })
const { doc, files, reach } = generate(specText, config)
for (const [id, r] of reach) {
if (r.reach === 'web-only') console.warn(id, r.reason)
}Common mistakes
Passing a relative
baseUrl(or omittingserversfrom the spec) and expecting native output — PMTC bakes the request URL at compile time, so a relative base makes EVERY operation web-only. The reach report names this, but only if you read it.Assuming the
.native.tsxmodules replace the web output. They are ADDITIVE: the web files are byte-identical whether the target iswebormultiplatform.Editing generated files. Every file carries a DO-NOT-EDIT banner and is overwritten on the next run; change the spec or the emitter.
Expecting
s.enumin native output. Enums do not lower, so the native path narrows them tos.string()— the constraint is genuinely lost there, which is why the two layouts are emitted separately rather than shared.
resolveConfig function
resolveConfig(section: LatheSection | undefined): ResolvedConfigFills defaults and validates one project's settings, and is where the whole option surface lives: plugins (which emitters run), client (pyreon | fetch | axios | ky), validator (pyreon | zod), target (web | multiplatform), baseUrl and strictNative. A plugin selection is EXPANDED to cover what its output imports rather than refused -- asking for components gets queries, client and schemas too, and the CLI report says what came along. Use resolveProjects instead when the config may declare projects: [...]; it always returns a LIST, so a single-project config is a one-element list rather than a special case.
Example
import { generate, resolveConfig } from '@pyreon/lathe'
const config = resolveConfig({
input: './openapi.yaml',
output: './src/gen',
// Nine emitters. Omit one and it does not run; schemas alone is a first-class use.
plugins: ['schemas', 'client', 'queries', 'mocks', 'faker', 'docs'],
client: 'pyreon',
validator: 'zod',
})
const { files } = generate(specText, config)Common mistakes
Expecting
plugins: ['faker']to emit ONLY factories. It expands to includeschemas, because the factories exist to produce data the schema accepts and are typed against the model types it exports.Combining
target: 'multiplatform'with a non-Pyreonclient. It is REFUSED, not downgraded: PMTC lowerscreateHttpandapi.endpoint(...)by name and cannot see through axios or ky, so native modules over one would lower to nothing -- the exact silent regression that target exists to catch.Importing
installMocks,mockRoutesor the faker factories from the generatedindex.ts. They are NOT there by design -- they live in./dev, so a page bundle has no import edge that could reach a fixture table or@faker-js/faker.Assuming
validator: 'pyreon'lowers more natively thanzodbecause it is first-party. Measured against the real compiler it is the OPPOSITE: nested objects and arrays of objects lower under zod and are DROPPED unders.*, sozodis the better native choice for any spec with nested models.Setting
pluginsand expecting the generatedpackage.jsonto change. ThesideEffectsmarker is emitted unconditionally -- it is a statement ABOUT the output rather than a plugin's output -- and it names./atlas.wrapper.tsxonly whenatlasis selected, because that file alone has a module-scope side effect.
verifyNative function
verifyNative(files: GeneratedFile[], transform: TransformFn | undefined): VerifyReportRuns the real native compiler over the generated .native.tsx modules on both targets and returns a per-file verdict. The check is POSITIVE — it asserts the emitted Swift/Kotlin contains PyreonQuery< / PyreonZodSchema_ and contains no leaked web-only symbol — because zero warnings is not evidence: a standalone hook wrapping useQuery produces no warnings and emits Swift that cannot find the symbol. Passing undefined for transform yields ran: false with a reason, never a pass.
Example
import { generate, resolveConfig, resolveTransform, verifyNative, worstVerdict } from '@pyreon/lathe'
const { files } = generate(specText, resolveConfig({ input: 'spec', target: 'multiplatform' }))
const report = verifyNative(files, await resolveTransform())
if (!report.ran) console.warn('not verified:', report.reason)
if (worstVerdict(report) !== 'lowers') process.exitCode = 1Common mistakes
Reading
warnings.length === 0as success. That is exactly the shape this function exists to catch — PMTC reproduces an unrecognised call verbatim and says nothing, so the native build fails later with "cannot find useQuery in scope".Treating
ran: falseas a pass. A verification that could not run is not one that ran and succeeded;--strict-nativefails on it deliberately.Bundling a copy of
@pyreon/native-compilerinstead of resolving the project's. A verdict from a different compiler version than the one that will build the app is worse than no verdict.
loadOpenApi function
loadOpenApi(source: string): { doc: IrDocument }Parses an OpenAPI 3.x document (JSON or YAML text) into the spec-agnostic IR. Every reduction the IR cannot represent is recorded in doc.notes with a stable code and a location, so a loss is reported once at the boundary instead of being rediscovered differently by each emitter. Deterministic: models and operations are sorted, so the same spec always produces the same IR.
Example
import { loadOpenApi } from '@pyreon/lathe'
const { doc } = loadOpenApi(await readFile('./openapi.yaml', 'utf8'))
console.log(doc.models.length, 'models', doc.operations.length, 'operations')
for (const note of doc.notes) console.warn(note.code, note.at, note.message)Common mistakes
Ignoring
doc.notes. A spec with a remote$refor a non-JSON media type still produces output — with those pieces typedunknown. The note is the only signal.Expecting anchors or merge keys to work. The YAML reader refuses them by design with a line number, because silently ignoring an anchor produces a document that is wrong everywhere it was used.