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

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 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 (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.
  • 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.