jcode/skills/save-load-json/SKILL.md

84 lines
4.4 KiB
Markdown

---
name: save-load-json
description: >
Save and load an application's complete state as JSON (browser apps with
download/upload, Python/Node programs with files): one state object, a
serializer that saves EVERYTHING except an explicit runtime-only list, a
loader that rebuilds through the normal constructors, a version field, and a
round-trip check that fails when any field is lost. Use whenever a task says
save/load, export/import, persist, project file, settings file, or when
save/load "loses" data.
---
# Save / load with JSON
The bug this skill prevents: a hand-written `toJSON` that copies a few fields
(`id`, `type`) and silently drops the rest (`position`, `children`, ...). The
user saves, loads, and the scene is wrong. The fix is a rule, not care:
> **Save everything by default.** Serialize the whole state object and
> subtract an explicit `RUNTIME` list (meshes, DOM nodes, caches, selection).
> A forgotten field is then saved, not lost.
## Design (do this before writing the app)
1. **One state object** holds all data the user would expect to get back:
objects, their parameters and transforms, nesting, settings (grid size,
units, colors), view (camera) if restoring it is useful.
2. **Data vs runtime.** Each object is a plain record (`{id, type, params,
position, rotation, scale, color, hole, children}`); runtime things
(`mesh`, `element`, `texture`, listeners) live in a side map
`runtime.get(id)` or in fields named in `RUNTIME`. Never store a live object
inside the data record if you can avoid it.
3. **One constructor path.** `fromRecord(rec)` creates a runtime object from a
data record. The "add shape" button, paste, duplicate AND load all call
`fromRecord` - so load can't behave differently from add.
4. **Format:** `{ "version": 1, "app": "<name>", "state": {...} }`. Bump
`version` when the shape changes; `load` migrates older versions
(`migrate(data)`) instead of failing. Numbers as numbers (not strings),
arrays for vectors (`[x, y, z]`), ISO strings for dates, no `NaN`/`Infinity`
(JSON has none - clamp or null them).
5. **Round trip is the test:** `deepEqual(toData(fromData(toData(state))),
toData(state))`, and the field-completeness check below. Run both before
wiring any button.
## Browser app
- `toData()` returns plain data; `download('x.json', JSON.stringify(toData(), null, 2))`
is the ENTIRE save handler. `pickFile('.json', true, text => load(JSON.parse(text)))`
is the entire load handler. No second serializer in a handler, ever.
- Test the real buttons with the browser-test skill (`h.download('#save')`,
`h.upload('#load', 'fixtures/scene.json')`) and compare the file's fields to
the live state - that catches the handler calling the wrong function.
- Snippets: [js.md](./js.md) (`toData`/`load` with RUNTIME subtraction,
migrate, Set/Map/typed arrays, the completeness check, fixture generation).
## Python / Node programs
- Python: dataclasses + `dataclasses.asdict` for the data side, a `RUNTIME`
tuple of field names to drop, `json.dump(..., indent=2, ensure_ascii=False)`,
write to a temp file and `os.replace` so a crash never leaves a half file.
Snippets and the round-trip test: [python.md](./python.md).
- Node: same rules; `JSON.stringify(data, null, 2)` + `fs.writeFileSync(tmp)` +
`fs.renameSync`.
## Completeness check (run it, don't eyeball it)
`check_roundtrip.py` compares a saved JSON file against a list of the fields
the live state has (dumped from the app via the test hook or a one-liner) and
prints every field present in the state but missing from the file:
```
python <skill-dir>/check_roundtrip.py saved.json state_fields.json
```
Details and how to produce `state_fields.json` in [js.md](./js.md#completeness)
and [python.md](./python.md#completeness).
## Pitfalls
- `JSON.stringify(state)` with live objects: cyclic structure error, or megabytes
of mesh data - that's the signal to split data from runtime (rule 2).
- Loading replaces the whole state; clear selection/undo history or they point
at ids that no longer exist.
- Nested groups/children: recursion in both directions, and ids must stay
unique after load (`nextId = max(ids) + 1`).
- Floats: don't round on save; round only for display.
- Binary data (images, STL buffers): base64 in the JSON only if small; otherwise
a zip or separate files, with file names in the JSON.
- Don't let a load failure wipe the current state: parse and validate first,
then swap.