Enforces user-facing data portability in an offline-first app — backup/restore (exact, machine round-trippable) kept strictly separate from export/report (human, lossy, never a restore source); a versioned envelope carrying formatVersion, schemaVersion, appVersion, exportedAtUtc and a payload checksum so a restore refuses a file from a newer app instead of corrupting the database; restore as an all-or-nothing import into a staging database swapped in only after validation; canonical values in machine formats (integer minor units, SI integers, ISO-8601 UTC, stable ids) and never localized numerals; RFC 4180 CSV quoting plus the =/+/-/@ formula-injection escape; streaming writes published by atomic rename; the share sheet behind an injected Gateway; and an export→import→export round-trip test on a hostile fixture. Use when adding or changing export, backup, import, restore, share, or CSV/JSON/PDF output, writing an import validator or merge-vs-replace policy, or handling a picked file.
In an app with no server, the export file is the only copy of a user's data that can
outlive the phone, and restore is the only recovery path they can invoke themselves.
Both are dangerous in opposite directions: a lossy export silently becomes someone's
backup, and a half-applied restore destroys the very database it was meant to protect.
This skill governs the portable artifacts; the on-device VACUUM INTO snapshot and
WAL rules belong to persistence-drift.
Depth lives in references/formats-and-encoding.md (CSV/JSON/PDF mechanics) and
references/restore-and-merge-policy.md (validation ladder, merge strategies).
Non-negotiable rules
Backup and export are two different features — never conflate them.Backup is
exact, machine-read, round-trippable, and the only thing restore accepts. Export
(CSV for a spreadsheet, PDF for a human) is an interop artifact, lossy by design,
and must be refused as a restore source. WHY: the moment a CSV is restorable, a
format built for readability becomes the file someone's records depend on.
Every backup carries an envelope, and restore reads it first.formatVersion,
schemaVersion, appVersion, exportedAtUtc, a checksum over the payload, then the
payload. Restore verifies the checksum, refuses schemaVersiongreater than the
app's with a plain message, and migrates an older payload through the same
forward-only path as the database (run-migration). WHY: an unversioned file cannot
be refused, only misread.
Restore is all-or-nothing and never edits the live database in place. Import
into a fresh staging database, validate everything, close every handle, then publish
by atomic rename with the previous file kept as a rollback until the new one opens
cleanly. WHY: a partial restore is worse than a failed one — it leaves a state the
user cannot describe and you cannot reproduce.
Machine formats carry canonical values only. Integer minor units, SI integers,
ISO-8601 UTC instants, stable ids, untranslated enum codes. Never a localized
numeral, a formatted date, a currency symbol, or a translated label in a file that
will be parsed. Localize only in human-facing exports, which are never re-imported.
WHY: ١٢٫٥ and 12.5 and 12,5 are the same quantity and three different parses.
Identity is content-assigned, never a rowid. Every exportable row carries a
stable id (UUID/ULID) minted at creation. WHY: re-importing the same file must be
idempotent — with autoincrement ids it duplicates every record instead.
State the merge policy and show it before it runs. Replace-all (wipe then
import) and merge-by-id (last-write-wins on a stored updatedAtUtc) are different
promises; the confirmation names which one is about to happen and what will be lost.
WHY: "Restore" reads as "add my data back" to a user who is about to lose a week.
Stream to a temp file, publish by rename. Build the artifact through an IOSink
into the app's own temp directory and rename it into place only when the last byte
is flushed. WHY: a 100k-row export assembled in a String OOMs a cheap phone, and a
half-written file must never be shareable.
Nothing leaves the sandbox without an explicit user action, through an injected
Gateway. No auto-upload, no background sync, no "helpful" cloud copy. The share
sheet and file-save picker sit behind a ShareGateway/FileExportGateway
(service-boundary-and-native), faked in tests.
CSV is escaped twice: RFC 4180 and against formula injection. Quote any field
containing the delimiter, a quote, CR or LF, doubling embedded quotes; and neutralize
a leading =, +, -, @, tab or CR so a spreadsheet renders the text instead of
executing it. WHY: an unescaped cell is a code-execution vector in the recipient's
spreadsheet, and a lost row in yours.
Export and restore return typed results, never throw at the UI.Result<ExportArtifact, ExportFailure> / Result<RestoreReport, RestoreFailure>
with a distinct failure per refusal reason (checksum, unsupported version,
malformed payload, no space, permission denied). WHY: "Restore failed" with no
reason is indistinguishable from data loss to the person reading it.
A failed export leaves no artifact. Delete the temp file on every failure path
and never publish a partial one. WHY: a truncated file that looks like a backup is
the failure mode that surfaces months later.
Say what an export is. Exports are plaintext and unencrypted unless you built
encryption on purpose; the UI must say so at the moment of sharing, and the claim
must match the code (see release-and-store-shipping for claim wording).
The envelope
/// The only shape restore accepts. Version fields come FIRST so a truncated or
/// foreign file is rejected by the header rather than by a mid-parse exception.
({
int formatVersion, // this envelope's own shape — bump independently of the DB
int schemaVersion, // the DB schema the payload was written from
String appVersion, // provenance for support, never a compatibility check
DateTime exportedAtUtc, // UTC, from the injected Clock
String payloadSha256, // checksum over the payload bytes, verified before parsing
Map<String, Object?> payload,
});
formatVersion and schemaVersion are separate on purpose: the envelope can gain a
field without a database migration, and the database can migrate without changing the
file shape. A single "version" number conflates them and forces a false choice on the
next change. Complete, runnable shape: examples/backup_envelope.dart.
Restore: validate, stage, swap
Future<Result<RestoreReport, RestoreFailure>> restore(File picked) async {
// 1. Header and integrity BEFORE anything is parsed or opened.
final envelope = readEnvelope(picked);
switch (envelope) {
case Err(:final failure): return Err(failure); // malformed / bad checksum
case Ok(:final value) when value.schemaVersion > kSchemaVersion:
return const Err(RestoreFailure.newerThanApp()); // refuse, never guess
case Ok():
break;
}
// 2. Import into a STAGING database — the live one is never touched yet.
final staging = await openStagingDatabase();
final imported = await staging.transaction(() => _importAll(envelope.value.payload));
if (imported case Err(:final failure)) {
await staging.close();
await deleteStagingFiles(); // user's data still intact
return Err(failure);
}
// 3. Migrate the staged copy forward through the SAME path as the app database.
// 4. Close every handle, then publish by rename, keeping the old file until the
// new one opens cleanly. See references/restore-and-merge-policy.md.
return publishStagedDatabase(staging);
}
The staging file, the migration path, and the rename ordering are the whole
correctness argument — a restore that writes directly into the open app database has
no failure mode that leaves the user where they started.
Choosing a format
Need
Format
Restorable?
Full-fidelity backup the app reads back
Envelope + JSON payload (or a copied DB file)
Yes — the only one
Rows for a spreadsheet or another tool
CSV, RFC 4180, canonical values
No
Something a human reads, prints, or files
PDF, fully localized and formatted
No
Human-facing exports localize at the edge like any other rendering
(value-objects-money-and-units, i18n-rtl-l10n); machine formats never do. Copying
the live database file directly is a valid backup only through the WAL-safe primitive
in persistence-drift — never File.copy on an open database.
Tests that must exist
Round trip on a hostile fixture. Export → import into an empty database → export
again → byte-identical. The fixture carries apostrophes, quotes, commas, embedded
newlines, emoji, RTL text with bidi marks, whitespace-only strings, the largest
supported number, a DST-ambiguous local instant, and a null-vs-empty pair. 'test1'
survives everything and proves nothing.
Idempotent re-import. Importing the same file twice produces the same state under
merge-by-id — not duplicates.
Every refusal is a test: truncated file, flipped checksum byte, schemaVersion
one higher than the app, an older schemaVersion that must migrate, empty payload,
and a file that is not this format at all. Each asserts the typed failure and that
the live database is byte-unchanged.
Failure leaves no artifact. Force a mid-write failure; assert no publishable file
exists in the export directory.
CSV escaping. A cell containing =cmd(), one containing a,b"c\nd, and an RTL
cell each survive a write→read round trip through a real parser.
Anti-patterns
CSV as the backup format — no envelope, no types, no versioning; it will be
someone's only copy.
An unversioned export file — the first schema change makes every existing file
either unreadable or, worse, silently misread.
Restoring straight into the live database — no rollback exists; a mid-import
failure leaves a state nobody can describe.
Autoincrement rowids as export identity — re-import duplicates everything.
Formatting numbers or dates in a machine format — locale-dependent output that
round-trips only on the machine that wrote it.
Building the export in a String/StringBuffer — fine for 100 rows, an OOM
crash at 100k, on the cheapest device you support.
Writing directly to the shareable path — a crash mid-write publishes a truncated
file that still looks like a backup.
Auto-uploading or auto-backing-up "for safety" — it converts an offline app into
a data processor, changing the store privacy declaration and breaking the claim.
Trusting a picked file's extension or name — validate the envelope, not the path.
A generic "Import failed" message — the user cannot tell a wrong file from lost
data; name the reason from the typed failure.
Silently merging when the user expected replace (or vice versa) — state the
policy in the confirmation, in the same words as the code.
Definition of done
Backup and export are separate paths; export artifacts are refused by restore.
Every backup carries formatVersion, schemaVersion, appVersion,
exportedAtUtc, and a payload checksum, in that header-first order.
Restore verifies the checksum, refuses a newer schemaVersion, and migrates an
older payload through the same forward-only path as the database.
Restore imports into a staging database and publishes by rename; a failure leaves
the live database byte-unchanged.
Machine formats carry canonical values and stable content ids only.
The merge-vs-replace policy is explicit in code and named in the confirmation UI.
Writes stream to a temp file and are published by atomic rename; failures delete
the temp file and publish nothing.
Share/save goes through an injected Gateway on an explicit user action; nothing
uploads automatically.
CSV output is RFC 4180 quoted and formula-injection escaped.
Export/restore return typed Results with one failure per refusal reason.
Round-trip, idempotent-re-import, every-refusal, no-partial-artifact, and CSV
escaping tests pass on a hostile fixture.
Related skills
persistence-drift — the WAL-safe on-device snapshot primitive and the DAO layer the
importer writes through; never File.copy a live database.
run-migration — the forward-only path an older payload is migrated through.
error-handling-typed-results — the Result/Failure spine and the never-lose-data
guarantees this feature is the user-facing end of.
value-objects-money-and-units — canonical storage (minor units, SI, UTC) that makes
a machine format round-trip.
i18n-rtl-l10n — localize-at-render, and why localized numerals never enter a file.
service-boundary-and-native — the ShareGateway/file-picker seam and its fake.
release-and-store-shipping — the privacy claims an export feature must not break.
1---2name: data-export-and-restore3description: Enforces user-facing data portability in an offline-first app — backup/restore (exact, machine round-trippable) kept strictly separate from export/report (human, lossy, never a restore source); a versioned envelope carrying formatVersion, schemaVersion, appVersion, exportedAtUtc and a payload checksum so a restore refuses a file from a newer app instead of corrupting the database; restore as an all-or-nothing import into a staging database swapped in only after validation; canonical values in machine formats (integer minor units, SI integers, ISO-8601 UTC, stable ids) and never localized numerals; RFC 4180 CSV quoting plus the =/+/-/@ formula-injection escape; streaming writes published by atomic rename; the share sheet behind an injected Gateway; and an export→import→export round-trip test on a hostile fixture. Use when adding or changing export, backup, import, restore, share, or CSV/JSON/PDF output, writing an import validator or merge-vs-replace policy, or handling a picked file.4---56# Data export and restore78In an app with no server, the export file is the only copy of a user's data that can9outlive the phone, and restore is the only recovery path they can invoke themselves.10Both are dangerous in opposite directions: a lossy export silently becomes someone's11backup, and a half-applied restore destroys the very database it was meant to protect.12This skill governs the *portable* artifacts; the on-device `VACUUM INTO` snapshot and13WAL rules belong to `persistence-drift`.1415Depth lives in `references/formats-and-encoding.md` (CSV/JSON/PDF mechanics) and16`references/restore-and-merge-policy.md` (validation ladder, merge strategies).1718## Non-negotiable rules19201. **Backup and export are two different features — never conflate them.** *Backup* is21 exact, machine-read, round-trippable, and the only thing restore accepts. *Export*22 (CSV for a spreadsheet, PDF for a human) is an interop artifact, lossy by design,23 and must be refused as a restore source. WHY: the moment a CSV is restorable, a24 format built for readability becomes the file someone's records depend on.252. **Every backup carries an envelope, and restore reads it first.** `formatVersion`,26 `schemaVersion`, `appVersion`, `exportedAtUtc`, a checksum over the payload, then the27 payload. Restore verifies the checksum, refuses `schemaVersion` **greater** than the28 app's with a plain message, and migrates an older payload through the *same*29 forward-only path as the database (`run-migration`). WHY: an unversioned file cannot30 be refused, only misread.313. **Restore is all-or-nothing and never edits the live database in place.** Import32 into a fresh staging database, validate everything, close every handle, then publish33 by atomic rename with the previous file kept as a rollback until the new one opens34 cleanly. WHY: a partial restore is worse than a failed one — it leaves a state the35 user cannot describe and you cannot reproduce.364. **Machine formats carry canonical values only.** Integer minor units, SI integers,37 ISO-8601 UTC instants, stable ids, untranslated enum *codes*. Never a localized38 numeral, a formatted date, a currency symbol, or a translated label in a file that39 will be parsed. Localize only in human-facing exports, which are never re-imported.40 WHY: `١٢٫٥` and `12.5` and `12,5` are the same quantity and three different parses.415. **Identity is content-assigned, never a rowid.** Every exportable row carries a42 stable id (UUID/ULID) minted at creation. WHY: re-importing the same file must be43 idempotent — with autoincrement ids it duplicates every record instead.446. **State the merge policy and show it before it runs.** Replace-all (wipe then45 import) and merge-by-id (last-write-wins on a stored `updatedAtUtc`) are different46 promises; the confirmation names which one is about to happen and what will be lost.47 WHY: "Restore" reads as "add my data back" to a user who is about to lose a week.487. **Stream to a temp file, publish by rename.** Build the artifact through an `IOSink`49 into the app's own temp directory and rename it into place only when the last byte50 is flushed. WHY: a 100k-row export assembled in a `String` OOMs a cheap phone, and a51 half-written file must never be shareable.528. **Nothing leaves the sandbox without an explicit user action, through an injected53 Gateway.** No auto-upload, no background sync, no "helpful" cloud copy. The share54 sheet and file-save picker sit behind a `ShareGateway`/`FileExportGateway`55 (`service-boundary-and-native`), faked in tests.569. **CSV is escaped twice: RFC 4180 *and* against formula injection.** Quote any field57 containing the delimiter, a quote, CR or LF, doubling embedded quotes; and neutralize58 a leading `=`, `+`, `-`, `@`, tab or CR so a spreadsheet renders the text instead of59 executing it. WHY: an unescaped cell is a code-execution vector in the recipient's60 spreadsheet, and a lost row in yours.6110. **Export and restore return typed results, never throw at the UI.**62 `Result<ExportArtifact, ExportFailure>` / `Result<RestoreReport, RestoreFailure>`63 with a distinct failure per refusal reason (checksum, unsupported version,64 malformed payload, no space, permission denied). WHY: "Restore failed" with no65 reason is indistinguishable from data loss to the person reading it.6611. **A failed export leaves no artifact.** Delete the temp file on every failure path67 and never publish a partial one. WHY: a truncated file that looks like a backup is68 the failure mode that surfaces months later.6912. **Say what an export is.** Exports are plaintext and unencrypted unless you built70 encryption on purpose; the UI must say so at the moment of sharing, and the claim71 must match the code (see `release-and-store-shipping` for claim wording).7273## The envelope7475```dart76/// The only shape restore accepts. Version fields come FIRST so a truncated or77/// foreign file is rejected by the header rather than by a mid-parse exception.78({79 int formatVersion, // this envelope's own shape — bump independently of the DB80 int schemaVersion, // the DB schema the payload was written from81 String appVersion, // provenance for support, never a compatibility check82 DateTime exportedAtUtc, // UTC, from the injected Clock83 String payloadSha256, // checksum over the payload bytes, verified before parsing84 Map<String, Object?> payload,85});86```8788`formatVersion` and `schemaVersion` are separate on purpose: the envelope can gain a89field without a database migration, and the database can migrate without changing the90file shape. A single "version" number conflates them and forces a false choice on the91next change. Complete, runnable shape: `examples/backup_envelope.dart`.9293## Restore: validate, stage, swap9495```dart96Future<Result<RestoreReport, RestoreFailure>> restore(File picked) async {97 // 1. Header and integrity BEFORE anything is parsed or opened.98 final envelope = readEnvelope(picked);99 switch (envelope) {100 case Err(:final failure): return Err(failure); // malformed / bad checksum101 case Ok(:final value) when value.schemaVersion > kSchemaVersion:102 return const Err(RestoreFailure.newerThanApp()); // refuse, never guess103 case Ok():104 break;105 }106107 // 2. Import into a STAGING database — the live one is never touched yet.108 final staging = await openStagingDatabase();109 final imported = await staging.transaction(() => _importAll(envelope.value.payload));110 if (imported case Err(:final failure)) {111 await staging.close();112 await deleteStagingFiles(); // user's data still intact113 return Err(failure);114 }115116 // 3. Migrate the staged copy forward through the SAME path as the app database.117 // 4. Close every handle, then publish by rename, keeping the old file until the118 // new one opens cleanly. See references/restore-and-merge-policy.md.119 return publishStagedDatabase(staging);120}121```122123The staging file, the migration path, and the rename ordering are the whole124correctness argument — a restore that writes directly into the open app database has125no failure mode that leaves the user where they started.126127## Choosing a format128129| Need | Format | Restorable? |130|---|---|---|131| Full-fidelity backup the app reads back | Envelope + JSON payload (or a copied DB file) | **Yes** — the only one |132| Rows for a spreadsheet or another tool | CSV, RFC 4180, canonical values | No |133| Something a human reads, prints, or files | PDF, fully localized and formatted | No |134135Human-facing exports localize at the edge like any other rendering136(`value-objects-money-and-units`, `i18n-rtl-l10n`); machine formats never do. Copying137the live database file directly is a valid backup *only* through the WAL-safe primitive138in `persistence-drift` — never `File.copy` on an open database.139140## Tests that must exist141142- **Round trip on a hostile fixture.** Export → import into an empty database → export143 again → **byte-identical**. The fixture carries apostrophes, quotes, commas, embedded144 newlines, emoji, RTL text with bidi marks, whitespace-only strings, the largest145 supported number, a DST-ambiguous local instant, and a null-vs-empty pair. `'test1'`146 survives everything and proves nothing.147- **Idempotent re-import.** Importing the same file twice produces the same state under148 merge-by-id — not duplicates.149- **Every refusal is a test:** truncated file, flipped checksum byte, `schemaVersion`150 one higher than the app, an older `schemaVersion` that must migrate, empty payload,151 and a file that is not this format at all. Each asserts the *typed failure* and that152 the live database is byte-unchanged.153- **Failure leaves no artifact.** Force a mid-write failure; assert no publishable file154 exists in the export directory.155- **CSV escaping.** A cell containing `=cmd()`, one containing `a,b"c\nd`, and an RTL156 cell each survive a write→read round trip through a real parser.157158## Anti-patterns159160- **CSV as the backup format** — no envelope, no types, no versioning; it will be161 someone's only copy.162- **An unversioned export file** — the first schema change makes every existing file163 either unreadable or, worse, silently misread.164- **Restoring straight into the live database** — no rollback exists; a mid-import165 failure leaves a state nobody can describe.166- **Autoincrement rowids as export identity** — re-import duplicates everything.167- **Formatting numbers or dates in a machine format** — locale-dependent output that168 round-trips only on the machine that wrote it.169- **Building the export in a `String`/`StringBuffer`** — fine for 100 rows, an OOM170 crash at 100k, on the cheapest device you support.171- **Writing directly to the shareable path** — a crash mid-write publishes a truncated172 file that still looks like a backup.173- **Auto-uploading or auto-backing-up "for safety"** — it converts an offline app into174 a data processor, changing the store privacy declaration and breaking the claim.175- **Trusting a picked file's extension or name** — validate the envelope, not the path.176- **A generic "Import failed" message** — the user cannot tell a wrong file from lost177 data; name the reason from the typed failure.178- **Silently merging when the user expected replace (or vice versa)** — state the179 policy in the confirmation, in the same words as the code.180181## Definition of done182183- [ ] Backup and export are separate paths; export artifacts are refused by restore.184- [ ] Every backup carries `formatVersion`, `schemaVersion`, `appVersion`,185 `exportedAtUtc`, and a payload checksum, in that header-first order.186- [ ] Restore verifies the checksum, refuses a newer `schemaVersion`, and migrates an187 older payload through the same forward-only path as the database.188- [ ] Restore imports into a staging database and publishes by rename; a failure leaves189 the live database byte-unchanged.190- [ ] Machine formats carry canonical values and stable content ids only.191- [ ] The merge-vs-replace policy is explicit in code and named in the confirmation UI.192- [ ] Writes stream to a temp file and are published by atomic rename; failures delete193 the temp file and publish nothing.194- [ ] Share/save goes through an injected Gateway on an explicit user action; nothing195 uploads automatically.196- [ ] CSV output is RFC 4180 quoted and formula-injection escaped.197- [ ] Export/restore return typed `Result`s with one failure per refusal reason.198- [ ] Round-trip, idempotent-re-import, every-refusal, no-partial-artifact, and CSV199 escaping tests pass on a hostile fixture.200201## Related skills202203- `persistence-drift` — the WAL-safe on-device snapshot primitive and the DAO layer the204 importer writes through; never `File.copy` a live database.205- `run-migration` — the forward-only path an older payload is migrated through.206- `error-handling-typed-results` — the `Result`/`Failure` spine and the never-lose-data207 guarantees this feature is the user-facing end of.208- `value-objects-money-and-units` — canonical storage (minor units, SI, UTC) that makes209 a machine format round-trip.210- `i18n-rtl-l10n` — localize-at-render, and why localized numerals never enter a file.211- `service-boundary-and-native` — the `ShareGateway`/file-picker seam and its fake.212- `release-and-store-shipping` — the privacy claims an export feature must not break.213- `testing-strategy` — hostile fixtures, property/round-trip tests, injected `Clock`.214215## References216217- RFC 4180 — Common Format and MIME Type for CSV Files: https://www.rfc-editor.org/rfc/rfc4180218- Dart — `dart:io` `IOSink`, `File.rename`: https://api.dart.dev/stable/dart-io/File-class.html219- `path_provider` — app directories: https://pub.dev/packages/path_provider220- `share_plus` — share sheet (incl. `sharePositionOrigin`): https://pub.dev/packages/share_plus221- `file_selector` — save/open dialogs: https://pub.dev/packages/file_selector222- `crypto` — SHA-256 for the payload checksum: https://pub.dev/packages/crypto223- OWASP — CSV Injection: https://owasp.org/www-community/attacks/CSV_Injection
Run npx skillmds@latest add zakariaf/data-export-and-restore in your terminal (requires Node.js), paste this page's agent-chat prompt into Claude, Cursor, or any MCP-connected agent, or download the SKILL.md file and copy it into your agent's skills directory.
Enforces user-facing data portability in an offline-first app — backup/restore (exact, machine round-trippable) kept strictly separate from export/report (human, lossy, never a restore source); a versioned envelope carrying formatVersion, schemaVersion, appVersion, exportedAtUtc and a payload checksum so a restore refuses a file from a newer app instead of corrupting the database; restore as an all-or-nothing import into a staging database swapped in only after validation; canonical values in machine formats (integer minor units, SI integers, ISO-8601 UTC, stable ids) and never localized numerals; RFC 4180 CSV quoting plus the =/+/-/@ formula-injection escape; streaming writes published by atomic rename; the share sheet behind an injected Gateway; and an export→import→export round-trip test on a hostile fixture. Use when adding or changing export, backup, import, restore, share, or CSV/JSON/PDF output, writing an import validator or merge-vs-replace policy, or handling a picked file. It is listed under Data & Analytics on SkillMD.
This skill has not completed SkillMD's automated safety review yet. SkillMD never runs a skill's scripts for you; review the SKILL.md before installing.
This skill is tagged as working with Claude Code, Claude.ai, OpenAI Codex. SKILL.md is an open format, so most agents that read a skills directory can load it too.
Yes. Installing skills from SkillMD is free, and the skill stays under its author's original license.
zakariaf (@zakariaf) published this skill. Their other Agent Skills are listed on their SkillMD profile.