84 lines
4.4 KiB
Markdown
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.
|