feat(api): export user domain data as a tar.gz archive #322
No reviewers
Labels
No labels
area:auth
area:ci
area:db
area:infra
area:native
area:pwa
area:service
epic
feature
foundation
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
james/carol!322
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/data-export"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What
Adds
GET /api/export, the read side of #286 — the first slice of import/export. The authenticated user downloads all of their domain data as a portablecarol-export-<date>.tar.gz: YAML docs at the archive root plus amedia/dir for blobs.How
apps/api/lib/export/serialize.ts—buildExportData(db, userId): gathers every domain entity through the existing repositories, nests children under their parents (parent UUID is the nesting key), and omitsuser_idfrom every emitted row (it is re-scoped to the importer on import, and omitting it keeps the archive instance-portable). Pure data — no YAML/tar here. Resolves the profile-picture blob intomedia/(skips silently if absent).apps/api/lib/export/archive.ts—buildExportArchive(data): YAML-serializes each doc (yaml), packs withtar-stream, gzips (zlib), and builds the manifest (formatVersion: 1,exportedAt,appVersionfromapps/api/package.json, per-entity counts).apps/api/app/api/export/route.ts— authedGET(getAuthIdentity→unauthorized()on null), returnsapplication/gzipwithContent-Disposition: attachment; filename="carol-export-<YYYY-MM-DD>.tar.gz". Mirrors the binary response shape ofGET /api/profile/picture. Not added to the public-routes allowlist — auth required.datatag;openapi.jsonregenerated;openapi:check+openapi:coveragepass.user_idre-scoping, and the transaction decision (realdb.transaction()+ a file-backed SQLite test for the future import apply path, superseding the libsql in-memory caveat atapps/api/db/repositories/skill-sections.ts).tests/db/export-serialize.test.ts) and an API-level route test (tests/api/export.test.ts) that gunzips + untars the response and verifies manifest counts, YAML contents, media bytes, and a 401 unauth case.Corrections to #286's sketch
projects.yaml+ contributions is a ticket error.contributionsare children ofpositions(contributions.position_id → positions → jobs), so they nest underexperience.yaml.projects.yamlholds projects only (projects have no child rows).jobstable (is_contract), butJobsRepository.listByUserIdfilters them out. They are domain data and their positions would otherwise be orphaned, so the serializer reads every job for the user directly and keeps theis_contractflag on each emitted row.user_id), so the single avatar is stored at the fixedmedia/profile-avatar.webprather thanmedia/<uuid>-<name>. Documented in ADR-0028.Out of scope (filed as follow-ups)
Import preview (#317), import apply with three modes (#318), settings UI (#319), and the cross-cutting i18n + remaining import tests (#320). This PR also lands ADR-0028 and the export tests (tracked under #320).
Verification
pnpm -F @carol/api test— full suite green; new export tests pass (SQLite leg locally; the Postgres leg runs in CI).pnpm -F @carol/api openapi:check && openapi:coverage— no drift, route covered.pnpm -F @carol/api build— compiles;/api/exportregistered.pnpm -F @carol/api lint— clean.Closes #316
Refs #286
🤖 Generated with Claude Code
📊 Test coverage
Patch coverage: no testable lines changed.
Overall (
app/,lib/,db/, excluding UI per ADR-0019):Soft thresholds per ADR-0019. Coverage is informational and does not block merge.
a04e59bd63966ca62988pnpm formatdoesn't churn unrelated files #393