# 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.

Source: https://voxgig.com/howto/rebuild-artifacts-on-model-change

- Audience: platform-team
- Level: intermediate
- Languages: typescript
- Verified: 2026-10-15
- Published: 2026-10-15

## 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](https://github.com/voxgig/model#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

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [@voxgig/model `--watch`](https://github.com/voxgig/model/blob/main/docs/reference.md) | The model is aontu, and actions generate the artifacts | Sees the model, its imports and its config only, and keeps running old action code | Your generator reads files you will not list for it |
| [nodemon](https://nodemon.io/) | Any generate command, rerun when any listed path changes | A fresh process per change, 0.6 to 1.7 s here, and an `npm audit` finding through `chokidar` 3 | The loop must stay under a few hundred milliseconds |
| [Nx `nx watch`](https://nx.dev/docs/reference/nx-commands) | A monorepo already on Nx, where generation is one project's task | Nx itself, plus a project graph to define before the watch means anything | The repository has no Nx and one generator |
| [Prisma `prisma generate --watch`](https://www.prisma.io/docs/cli/v7/generate) | The model is a Prisma schema and the artifact is Prisma Client | Watches the schema and runs the generator blocks it declares, so your templates need a generator of their own | You generate SQL, types or docs from your own templates |
| [watchexec](https://watchexec.github.io/) | One language-neutral watcher for any command | A separate binary on every machine and in CI, outside the npm lockfile | Your 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](https://github.com/advisories/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.

```aontu title="model/.model-config/model-config.aontu"
# 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/`.

```js title="build/generate.cjs"
// 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.

```bash
node watch-demo.mjs
```

```text output
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](https://github.com/voxgig/model/blob/main/docs/reference.md)
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`:

```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.

```bash
node compare.mjs
```

```text output
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.

```js title="watch.test.mjs"
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()
  }
})
```

```bash
node --test watch.test.mjs
```

```text output
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:

```bash
node pitfall.mjs
```

```text output
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.

## Related how-tos

- [Preview a regeneration as a diff before writing files](/howto/preview-a-regeneration-as-a-diff)

- [Make a Handlebars template produce byte-identical output](/howto/handlebars-byte-identical-output-every-run)

## 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.

[^1]: The [`rename(2)` manual page](https://man7.org/linux/man-pages/man2/rename.2.html) 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`](https://nodejs.org/api/fs.html) takes two paths and no flags.

[^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`](https://nodejs.org/api/fs.html#fswatchfilename-options-listener), which on Linux uses
`inotify`. Its
[manual page](https://man7.org/linux/man-pages/man7/inotify.7.html) 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.