4.4 KiB
4.4 KiB
| name | description |
|---|---|
| save-load-json | 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
RUNTIMElist (meshes, DOM nodes, caches, selection). A forgotten field is then saved, not lost.
Design (do this before writing the app)
- 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.
- 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 mapruntime.get(id)or in fields named inRUNTIME. Never store a live object inside the data record if you can avoid it. - One constructor path.
fromRecord(rec)creates a runtime object from a data record. The "add shape" button, paste, duplicate AND load all callfromRecord- so load can't behave differently from add. - Format:
{ "version": 1, "app": "<name>", "state": {...} }. Bumpversionwhen the shape changes;loadmigrates older versions (migrate(data)) instead of failing. Numbers as numbers (not strings), arrays for vectors ([x, y, z]), ISO strings for dates, noNaN/Infinity(JSON has none - clamp or null them). - 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 (
toData/loadwith RUNTIME subtraction, migrate, Set/Map/typed arrays, the completeness check, fixture generation).
Python / Node programs
- Python: dataclasses +
dataclasses.asdictfor the data side, aRUNTIMEtuple of field names to drop,json.dump(..., indent=2, ensure_ascii=False), write to a temp file andos.replaceso a crash never leaves a half file. Snippets and the round-trip test: 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
and python.md.
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.