How-to › Model, configure and generate

How to rebuild artifacts on model change with a watch loop#

Regenerate TypeScript types and SQL within a second of a model edit, keep the last good output when a generator fails partway, and make the watch loop log why.

Audience
Platform team
Level
intermediate
Topic
Model an application and generate from it
Languages
TypeScript
Verified

You add a field to the model, the watcher regenerates the TypeScript types, and then the SQL renderer throws on the field’s type. gen/types.ts has the field and gen/schema.sql does not. The error scrolled past in a terminal you were not watching, and the next test run fails against a schema that disagrees with the types.

What you get

You will end up with a watch loop that regenerates every artifact within a second of a model edit, keeps the last good output when generation fails, and logs the reason. This is for you if your generated files come from a model you edit by hand.

Short answer

Run voxgig-model --watch model/model.aontu, or Model.start() from code, and write the action to render every artifact in memory before it writes any of them. A render that throws then leaves the last good output in place, and the watcher logs the error. The watcher sees only the model, its imports, and its config, so list template directories under sys.model.watch.

You will need

Node 22 or later and @voxgig/model, which brings aontu with it. Verified 2026-10-15 against Node 22.22.0, @voxgig/model 12.0.1, aontu 0.76.0 and nodemon 3.1.14. The model and its action follow the page on generating TypeScript types and SQL from one model, and this page adds the loop. voxgig-model --help in 12.0.1 does not list --watch. The CLI accepts it anyway, and -w too, and the README uses it in its quick start. The help’s “See also” address, https://voxgig.com/model, answered 404 on the day of verification.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
@voxgig/model --watchThe model is aontu, and actions generate the artifactsSees the model, its imports and its config only, and keeps running old action codeYour generator reads files you will not list for it
nodemonAny generate command, rerun when any listed path changesA fresh process per change, 0.6 to 1.7 s here, and an npm audit finding through chokidar 3The loop must stay under a few hundred milliseconds
Nx nx watchA monorepo already on Nx, where generation is one project’s taskNx itself, plus a project graph to define before the watch means anythingThe repository has no Nx and one generator
Prisma prisma generate --watchThe model is a Prisma schema and the artifact is Prisma ClientWatches the schema and runs the generator blocks it declares, so your templates need a generator of their ownYou generate SQL, types or docs from your own templates
watchexecOne language-neutral watcher for any commandA separate binary on every machine and in CI, outside the npm lockfileYour team installs every tool through npm

The built-in watcher regenerated both files 120 to 200 ms after a model edit on the container that verified this page, because it rebuilds inside the process that is already running. nodemon took 640 to 770 ms on a quiet machine and 1.1 to 1.7 s on a busy one. Every change starts Node, unifies the config and the model, and loads the action from scratch. That fresh start is also its strength: an edited action runs as edited. Prisma and Nx fit when their tool already owns the schema or the workspace.

nodemon 3.1.14 is the latest release. It depends on chokidar 3, which depends on braces 3.0.3, and npm audit reports the braces advisory GHSA-vfj7-8cjw-p6xm, a high-severity denial of service, against all three packages. The advisory lists no patched braces version, and the only fix npm offers is a downgrade to nodemon 1.14.10. The patterns braces expands come from your own nodemon flags, so the exposure in a local loop is small, but npm audit exits 1 and fails a CI job that gates on it. @voxgig/model uses chokidar 5, which does not depend on braces.

Voxgig maintains @voxgig/model. It is the subject of this page, and nodemon runs beside it in every comparison below.

Write the action so a failure writes nothing

The model config names one action, which @voxgig/model runs after every successful unification.

# One action, run after every successful unification of model/model.aontu.
sys: model: action: { generate: load: 'build/generate.cjs' }
sys: model: order: action: 'generate'

The action renders both artifacts into strings first. A missing SQL type, a bad identifier, or an unreadable template throws there, before the first byte reaches gen/.

// Write every changed file beside its target, then rename each into place, so a reader never
// sees half a file. A full disk or a permission error stops the writes before the first rename:
// gen/ keeps the last good output and the temporary files go. A file whose bytes are unchanged
// is skipped, so nothing downstream wakes for nothing.
function write(dir, files) {
  Fs.mkdirSync(dir, { recursive: true })
  const staged = []
  try {
    for (const [name, text] of Object.entries(files)) {
      const target = Path.join(dir, name)
      let current
      try { current = Fs.readFileSync(target, 'utf8') } catch { current = undefined }
      if (current === text) continue
      const temp = Path.join(dir, `.${name}.${process.pid}.tmp`)
      staged.push([temp, target])
      Fs.writeFileSync(temp, text)
    }
  } catch (err) {
    for (const [temp] of staged) Fs.rmSync(temp, { force: true })
    throw err
  }
  for (const [temp, target] of staged) Fs.renameSync(temp, target)
}

// The @voxgig/model action. Render every artifact first, then write them all. If any render
// throws, nothing has been written: gen/ keeps the last good output, and the error propagates
// to the model, which logs it.
module.exports = async function generate(model, build) {
  const root = Path.resolve(build.path, '..', '..')
  const files = {}
  for (const [name, render] of Object.entries(RENDER)) files[name] = render(model, root)
  write(Path.join(root, 'gen'), files)
  return { ok: true }
}

Throwing is the part that makes the loop report. An action that returns { ok: false } fails the build, but @voxgig/model 12.0.1 logs nothing about why, and a test in the suite pins that.

Watch the model

watch-demo.mjs runs the watcher through Model.start() on a scratch copy, edits files the way you would, and reports each effect. It hands the model a logger that keeps records, so a failure is printed here rather than scrolling by.

node watch-demo.mjs
start           gen/types.ts and gen/schema.sql written
edit model      regenerated: invoice.due is in types.ts and schema.sql
edit template   nothing rebuilt after 1.5 s: templates/ is not a dependency
watch templates config rebuilt, and the waiting template edit reached types.ts
edit template   regenerated: schema.sql has the new header
break model     error: invoice.tax: no SQL type for "decimal"
                gen/ kept the last good output
fix model       regenerated: invoice.tax is in types.ts and schema.sql

The template edit is the gap. The reference says only tracked files rebuild: the root model, its imports and the config files. In 12.0.1 the config can extend that list, and the demo adds this line to model-config.aontu:

sys: model: watch: { templates: true }

A config edit rebuilds the config and then the model, which is why the template change that was ignored earlier reached types.ts in the same step.

Compare it with a generic watcher

The same four edits went to the built-in watcher, to the built-in watcher with templates and build listed, and to nodemon running the one-shot voxgig-model command.

node compare.mjs
edit                      Model.start()           + sys.model.watch       nodemon
model/model.aontu         regenerated             regenerated             regenerated
templates/types.ts.tmpl   ignored                 regenerated             regenerated
build/generate.cjs        ignored                 ran, old output         regenerated
notes.md                  ignored                 ignored                 ignored

The third row is the one to remember. With build listed, the watcher notices the edited action and rebuilds, but Node’s module cache hands it the action it loaded at startup, so the output does not change. nodemon starts a fresh process and runs the edit. After changing an action, restart the built-in watcher.

Check it worked

The suite covers the action on its own and the watcher end to end.

test('a render failure in the safe action writes nothing and throws', async () => {
  const p = project()
  try {
    await assert.rejects(generate(withTax('decimal'), p.build), /invoice\.tax: no SQL type for "decimal"/)
    assert.equal(readFileSync(join(p.dir, 'gen', 'types.ts'), 'utf8'), 'last good types\n')
    assert.equal(readFileSync(join(p.dir, 'gen', 'schema.sql'), 'utf8'), 'last good schema\n')
  } finally {
    p.done()
  }
})
node --test watch.test.mjs
1..11
# tests 11
# suites 0
# pass 11
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 1871.316386

When it goes wrong

The generated files disagree with each other. An action that writes each artifact as soon as it renders leaves the first one new when the second one throws:

node pitfall.mjs
action                build   gen/types.ts  gen/schema.sql  model/model.json
generate-naive.cjs    failed  new           old             new
generate.cjs          failed  old           old             new

logged by the model: invoice.tax: no SQL type for "decimal"

model.json is new in both rows, because 12.0.1 writes it before any action runs. Anything that reads model.json directly sees the new model while gen/ holds the old one.

A write that fails partway does not leave a mix. write puts every file under its temporary name before the first rename, so a full disk on the second file fails the action with gen/ untouched. A test in the suite simulates the full disk to prove it. A crash or a failed rename between the renames still leaves a mix. rename replaces one file atomically, which is why each write goes through a temporary file.1 For a whole directory, render into a fresh folder, then swap it in.

A model that does not parse or unify fails before any action runs, so gen/ and model.json both stay as they were. The error is logged, but as an aontu value whose report is in msg, coloured for a terminal, with message undefined. A logger that prints err.message prints nothing useful, which is why recorder() in watch-demo.mjs reads both, and two tests pin the reason it extracts.

Edits made right after start() are missed. start() resolves before the file watcher is running, so a script that edits at once loses the edit. watch-demo.mjs waits until the watcher lists the file. A person typing never notices the gap.

The logger and the delay before a rebuild ignore their settings. The reference documents log and idle options, but 12.0.1 reads its logger from a pino option and always waits 111 ms after the last change. A test pins the wait.2

When not to do this

Do not run a watcher in CI. Run voxgig-model model/model.aontu once, which exits 1 when an action fails, and check that gen/ matches what is committed.

Do not expect @voxgig/model to regenerate only what changed. It tracks imports to decide what to watch, but every rebuild runs every action, so skipping unchanged files is the action’s job, as write does here.

Do not leave the built-in watcher running across an edit to an action. It rebuilds with the code it loaded first, and the third row of the comparison shows the output staying put.

Do not reach for nodemon if the loop has to feel instant. A fresh process per edit cost 0.6 to 1.7 s here, and the rebuild inside a running process stayed under 200 ms.

Last verified

Verified 2026-10-15 against Node 22.22.0, @voxgig/model 12.0.1, aontu 0.76.0 and nodemon 3.1.14, installed from npm in the sample directory. Every output block is what the command preceding it printed. The latencies were measured on the container and are left out of the captured output. Prisma, Nx and watchexec were not run.

Footnotes

  1. The rename(2) manual page promises that an existing newpath is replaced atomically, with no moment at which another process finds it missing. It also describes RENAME_EXCHANGE, which swaps two paths atomically, a non-empty directory included, and would make a whole-directory swap a single step. Node’s fs.renameSync takes two paths and no flags. ↩︎ Back to text

  2. The 111 is in the source as bspec.idle || 111, and the loop that checks it runs every (111 * 1.1 / 2) | 0 milliseconds, which is 61. Underneath, this watcher uses chokidar 5 and nodemon uses chokidar 3, and both call Node’s fs.watch, which on Linux uses inotify. Its manual page sets an upper limit on the number of watches each real user may create, so every path either watcher adds is drawn from the same allowance. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.