Data Export & Portability — Technical Reference
← Back to Data Export & Portability
Where this lives
Backend
apps/backend/graphql/resolvers/user-data-export.resolver.js- resolvesrequestDataExport,dataExportStatus,myDataExports,cancelDataExport,deleteDataExport,downloadDataExport,processDataExport,getDataPortabilityInfo, and thedataExportStatusChangedsubscription. (The old monolithicuser.resolver.jsno longer exists — its contents were split into per-feature files like this one.)apps/backend/graphql/types/data-export.type.js- data export schema types (DataExportStatus,DataExportResponse,DataExportDownloadResponse,ProcessDataExportResponse,DataExportRequestInput, thedataExportStatusChangedsubscription). TheDataPortabilityInfotype itself is declared separately inapps/backend/graphql/types/user-presence.type.js.apps/backend/managers/user-managers/data-export.manager.js- builds and tracks the async export job, collects the user's data, writes the export file (S3, falling back to local/uploads), and emails the user when it's readyapps/backend/data-access-services/user/data-export.access-service.js- Sequelize wrapper around thedata_exporttable (create,findById,findLatestByUserId,findByUserId,update,delete)apps/backend/database/models/DataExport.js- theDataExportSequelize model backing the table belowapps/backend/database/migrations/20260719080000-create-data-export.js- creates thedata_exporttable (one row per export request; see Data model below);apps/backend/database/migrations/20260731200000-add-columns-to-data-export.js- addsexport_type,date_range_start/date_range_end,include_profile/include_messages/include_media,download_token, andprogress_percentageas real columns, replacing what used to be an opaqueoptionsJSONB blob for most of these
Frontend
apps/frontend-nextjs/src/page-components/settings/DownloadDataPage.tsx, routed atapps/frontend-nextjs/src/app/settings/download-data/page.tsx(Settings → Download data). Requests an export, shows pending/processing/ready/failed state, downloads, cancels, and deletes the record, and shows the data-portability footer. It has no generated Apollo hooks (no matching.graphqloperation files), so the queries/mutations/subscription are declared inline in the component.
Technical implementation checklist
-
requestDataExport— resolver wired inuser-data-export.resolver.js, callsdata-export.manager.js#requestDataExport; persists apendingrow to thedata_exporttable (now with realexport_type/date_range_start/date_range_end/include_profile/include_messages/include_media/download_tokencolumns, not just an opaque blob) and kicks offprocessDataExportin the background viasetImmediate. Wired to the "Request download" button onSettings → Download data. -
dataExportStatus— resolver wired inuser-data-export.resolver.js. Fixed this session: it previously ignored theexportIdargument entirely and always returned the caller's latest export, and the returned object didn't line up with theDataExportStatusschema type (missingid/exportType, so querying those non-null fields errored). It now passesexportIdthrough togetDataExportStatus, looks up that specific export (verifying it belongs to the caller), and maps it onto the full schema shape via_toStatusShape; it returnsnull(a valid result for this nullable field) when the export doesn't exist or isn't the caller's. The frontend still usesmyDataExportson mount (noexportIdto pass until a request exists), butdataExportStatus(exportId)now works correctly for direct lookups. -
cancelDataExport— resolver wired inuser-data-export.resolver.js; only works while status ispending/processing, publishesdataExportStatusChanged. Wired to the "Cancel export" button. -
myDataExports— resolver wired inuser-data-export.resolver.js, callsdata-export.manager.js#getUserDataExports. Fixed this session: it used to wrap the single most-recent export (getDataExportStatus) in a 0-or-1-item array despite the name; it now queriesdata_exportfor the caller's real history (findByUserId, newest first, capped at 50) and maps each row through the same_toStatusShapeused bydataExportStatus.DownloadDataPage.tsxstill only renders the first entry (data?.myDataExports?.[0]) though — the UI hasn't been updated to show a history list, so this fix isn't yet visible to users beyond correct single-export polling. -
deleteDataExport— resolver wired inuser-data-export.resolver.js, callsdata-export.manager.js#deleteDataExport. Fixed this session: it used to have no independent delete logic and just calledcancelDataExportunder the hood, so it only worked while the export was stillpending/processing(and just flipped the row tocancelled). It's now a real, distinct operation: it works on terminal exports (ready/failed/cancelled) — rejecting active ones with a "cancel first" error — hard-deletes thedata_exportrow, and best-effort removes the locally-stored file whendownloadUrlpoints at/uploads/exports/...(S3-backed exports rely on the bucket's own lifecycle policy instead). This matches how the UI's "Delete record" button is actually used, onready/failedexports. -
downloadDataExport— resolver wired inuser-data-export.resolver.js, callsdata-export.manager.js#downloadDataExport; checks the export isreadyand not expired. Fixed this session:DataExportDownloadResponse.downloadTokenis non-null in the schema, but nothing used to generate one.requestDataExportnow generates and persists adownloadToken(crypto.randomBytes(32)) when the row is created, anddownloadDataExportlazily generates + persists one for any older row that predates the fix, so the field always resolves. -
processDataExport— new mutation exposing the background job (data-export.manager.js#processDataExport) directly; also invoked internally byrequestDataExport. Collects posts/comments/messages/follow connections, writes the file, and flips statusprocessing→ready(orfailed). Fixed this session: now respectsincludeProfile/includeMessages/includeMediaand thedateRangeStart/dateRangeEndfilter (see Requesting an export below) instead of collecting everything unconditionally. -
dataExportStatusChanged— new subscription (DATA_EXPORT_STATUS_${userId}viaservices/pubsub.service.js), published on every status transition (processing/ready/failed/cancelled).DownloadDataPage.tsxsubscribes to it and refetches instead of polling. -
getDataPortabilityInfo(canExport,exportFormats,retentionPeriod,lastExport) — resolver wired inuser-data-export.resolver.js, butuserManager.getDataPortabilityInfojust returnsdataExportManager.getDataExportStatus(...), which has none of those keys. Since all butlastExportare non-null in theDataPortabilityInfotype, querying this field will fail rather than return real retention/format policy data.
Requesting an export
requestDataExport persists a pending row to the data_export table and kicks off data-export.manager.js#processDataExport in the background (setImmediate), so the mutation returns right away. Fixed this session: the manager's response object used to be keyed estimatedTime (a fixed string, "a few minutes") instead of estimatedCompletion as the schema field is named, so estimatedCompletion always resolved to null. It now builds and returns a real estimatedCompletion DateTime (~5 minutes from the request), alongside exportId.
Of the DataExportRequestInput toggles below, all except exportType now actually gate what gets collected in collectUserData. Fixed this session: includeMessages and includeMedia used to be accepted but ignored — messages and media were hard-coded as empty regardless of the flags; they're now real data_export columns (include_messages/include_media) and are honored (messages are collected via messageAccessService.getBySender, and media is stripped from posts/messages when includeMedia is false). includeProfile is likewise now a real column and gates whether the sanitized profile blob is included. dateRangeStart/dateRangeEnd are now real columns (date_range_start/date_range_end) and are applied as a createdAt filter on posts, comments, and messages. exportType is now persisted (export_type column) but is metadata only — it doesn't change what's collected or the output format. The output format is still always json: the manager's format column defaults to json, DataExportRequestInput has no format field, and nothing in the current request flow sets a non-default value, so the manager's CSV/XML conversion exists but stays unreachable via GraphQL.
mutation RequestDataExport($input: DataExportRequestInput!) {
requestDataExport(input: $input) {
success
message
exportId
estimatedCompletion
}
}
DataExportRequestInput fields
| Field | Type | Description |
|---|---|---|
exportType | String | Persisted (export_type column) as metadata; doesn't change what's collected — output format is always json |
includePosts | Boolean | Include the user's posts (applied; stored in the options JSONB blob) |
includeComments | Boolean | Include comments (applied; stored in the options JSONB blob) |
includeMessages | Boolean | Include messages (include_messages column). Fixed this session — previously accepted but ignored |
includeMedia | Boolean | Include media on posts/messages (include_media column). Fixed this session — previously accepted but ignored |
includeProfile | Boolean | Include the sanitized profile blob (include_profile column). Fixed this session — previously accepted but unused |
dateRangeStart | DateTime | Filters posts/comments/messages by createdAt (date_range_start column). Fixed this session — previously accepted but unused |
dateRangeEnd | DateTime | Filters posts/comments/messages by createdAt (date_range_end column). Fixed this session — previously accepted but unused |
Tracking export progress
myDataExports is what DownloadDataPage.tsx actually uses to load and poll the current export: it takes no arguments, so it's usable before the client has an exportId to pass to dataExportStatus. Fixed this session: it used to wrap the single most-recent export in a 0-or-1-item array despite the name; it now returns real history (newest first, capped at 50). The page itself hasn't been updated to render that history yet — it still reads data?.myDataExports?.[0], i.e. just the latest export.
dataExportStatus(exportId) also exists, for looking up a specific export by id. Fixed this session: it used to ignore the exportId argument entirely (always returning the caller's latest export) and its underlying object was missing the schema's id/exportType keys, so selecting those non-null fields errored. It now honors exportId, verifies ownership, returns the full DataExportStatus shape, and returns null if the export doesn't exist or belongs to someone else. The frontend still defaults to myDataExports for the initial load (there's no exportId yet at that point), but dataExportStatus is now safe to use for direct lookups.
subscription DataExportStatusChanged($userId: ID!) {
dataExportStatusChanged(userId: $userId) {
id status
}
}
query MyExports {
myDataExports {
id status exportType requestedAt completedAt expiresAt
downloadUrl fileSize progressPercentage errorMessage
}
}
The status field cycles through: pending → processing → ready (or failed), and can also become cancelled. Once status is ready, downloadUrl is populated (S3 pre-signed URL if services/s3.service.js is configured, otherwise a local /uploads/exports/... path) and stays valid until expiresAt (30 days after the request).
Managing exports
cancelDataExport aborts an in-progress export before it completes. Only works when status is pending or processing; it publishes dataExportStatusChanged. downloadDataExport validates the export is ready and not expired and returns the download URL/file info, including DataExportDownloadResponse.downloadToken (fixed this session — requestDataExport now generates and persists a downloadToken up front, and downloadDataExport lazily backfills one for any pre-existing row that doesn't have it yet, so the non-null field always resolves). deleteDataExport is now (fixed this session) a real, independent operation distinct from cancelDataExport: it hard-deletes the data_export row and best-effort removes the locally-stored file, and it works on terminal exports (ready/failed/cancelled) rather than only pending/processing ones — it rejects active exports and tells the caller to cancel first. This matches the "Delete record" UI, which is shown for ready/failed exports.
# Abort a pending or in-progress export
mutation CancelDataExport($exportId: ID!) {
cancelDataExport(exportId: $exportId) { success message }
}
# Prepare a ready export for download
mutation DownloadDataExport($exportId: ID!) {
downloadDataExport(exportId: $exportId) { success downloadUrl fileSize expiresAt fileName }
}
# Remove the export record
mutation DeleteDataExport($exportId: ID!) {
deleteDataExport(exportId: $exportId) { success message }
}
Data portability info
getDataPortabilityInfo is declared to return the platform's data retention and format policies (canExport, exportFormats, retentionPeriod, lastExport — the DataPortabilityInfo type lives in graphql/types/user-presence.type.js), but its resolver (userManager.getDataPortabilityInfo) just forwards to the same single-export getDataExportStatus used by dataExportStatus/myDataExports, which has none of those keys. Since canExport, exportFormats and retentionPeriod are non-null in the schema, querying this field currently fails instead of returning real policy data — this needs a dedicated implementation.
query DataPortabilityInfo {
getDataPortabilityInfo {
canExport
exportFormats
retentionPeriod # days data is kept after account deletion
lastExport
}
}
Data model
The data_export table (migration 20260719080000-create-data-export.js, plus 20260731200000-add-columns-to-data-export.js — added this session) holds one row per export request:
| Column | Type | Notes |
|---|---|---|
id | UUID (PK) | |
user_id | UUID | |
status | STRING(20) | pending (default) → processing → ready / failed / cancelled |
format | STRING(10) | defaults to json; csv/xml conversion exists in the manager but nothing in the current request flow sets a non-default format |
export_type | STRING(20) | added this session; the DataExportRequestInput.exportType value (defaults to full) — metadata only, doesn't affect what's collected |
options | JSONB | the includePosts/includeComments/includeAnalytics object captured at request time (the toggles that don't have dedicated columns) |
date_range_start, date_range_end | DATE | added this session; DataExportRequestInput.dateRangeStart/dateRangeEnd, now actually applied as a createdAt filter on posts/comments/messages |
include_profile, include_messages, include_media | BOOLEAN | added this session, all default true; now actually gate what collectUserData includes |
download_token | STRING(64) | added this session; generated with crypto.randomBytes(32) at request time (or lazily by downloadDataExport for older rows) so DataExportDownloadResponse.downloadToken always resolves |
progress_percentage | INTEGER | added this session; persisted at each status transition (0 → 10 on processing → 100 on ready), in addition to the live-estimated progress computed on the fly for an in-flight export |
requested_at, started_at, completed_at, expires_at | DATE | expires_at is set to 30 days after the request |
download_url, file_path, file_size | TEXT / BIGINT | populated once status is ready |
error_message | TEXT | populated on failed |
Indexed on (user_id, created_at).