Imports and exports
A module does not write its own importer. The kernel provides one, and it enforces the thing nobody enforces on themselves: a dry run before any write.
Getting data out
Every list exports, with nothing for a module to declare:
curl -H "Authorization: Bearer $TOKEN" \ "/api/v1/library_title/export?format=csv"id,name,author,isbn,published_year,genre,summary,cover1,Dune,Frank Herbert,9780441013593,1965,fiction,"A desert planet, a spice, and an empire...",8Four formats: csv, json, jsonl, xml. The export goes through the gated session: a
reader exports only what they may read, and a forbidden row is not “hidden”, it does not exist
in the file.
Bookkeeping columns (version, created_by_id, deleted_at…) stay out. Not fussiness: the
import this export feeds refuses them, so shipping them would break the loop. id stays,
because it is how an edited row finds its record again.
To pick, and to fix the order:
"/api/v1/library_title/export?format=csv&fields=isbn,name,genre"isbn,name,genre9780441013593,Dune,fictionAn unknown name is refused rather than ignored:
422 {"detail":"unknown export field(s): titel"}A typo that exports an empty column looks exactly like a model that holds nothing.
Getting data in
An import is a plan you approve, not a button. Four states, and none is skipped:
analyzed → approved → (dry run) → executed1. Upload. The file goes up, the kernel reads it and proposes a column → field mapping.
mode=ai guesses it; mode=manual leaves it empty for you to fill.
curl -X POST /api/v1/admin/imports \ -H "Authorization: Bearer $TOKEN" \ -F "file=@new_titles.csv" -F "mode=manual"2. Correct the mapping, with a PATCH on the plan: each source column points at a field
of the target model.
3. Approve. A plan that is only analyzed does not execute:
409 {"detail":"cannot move import plan from 'analyzed' to 'executed'"}4. Dry run. This is the step that justifies all the rest:
curl -X POST "/api/v1/admin/imports/$PLAN/execute?dry_run=true" \ -H "Authorization: Bearer $TOKEN"{ "dry_run": true, "sources": [{ "target_model": "library_title", "created": 1, "skipped": 0, "errors": [{ "row": 1, "error": "duplicate key value violates unique constraint \"library_title_isbn_key\"\nDETAIL: Key (isbn)=(9780062316097) already exists." }] }]}That report comes from a real run: the file’s second line carried an ISBN that already existed. The table did not move by a row. Fix the file and start again: the error is per row, with its number, so you know which one to take back.
5. Execute, the same call without dry_run.
Replaying does not duplicate
Every written row is registered under a hash of its content, in the external-id registry. Running the same file again:
0 created, 1 skippedWhat concerns you as a module author
Almost nothing, which is the point. Three things only:
- A non-nullable field with no default will make every incomplete row refused. That is correct, but say so in your onboarding rather than letting a customer find out on a 4,000-row file.
- A relation is given by its name, not by an id: the kernel resolves the value against the target model and creates the missing record if needed. A genre or a category therefore imports in plain text.
- A unique constraint is your friend: it is what stopped the dry run above. A model with no uniqueness accepts duplicates in silence, and nobody sees them before the first wrong report.