Skip to main content

Data Export / Import

Export synced data for backups, GDPR compliance, or account migration. Import data into a new account or device. All patterns work with or without encryption.

Prerequisites: SyncManager, Encryption, Integration Patterns

Built-In Export/Import Helpers​

Since v1.5.0, the SDK ships built-in export/import utilities:

import { exportData, importData, exportToBlob } from "@drakkar.software/starfish-client"

// Export to JSON string
const json = exportData(data, { format: "json", pretty: true })

// Export to CSV (top-level keys become columns)
const csv = exportData(data, { format: "csv" })

// Export to downloadable Blob
const blob = exportToBlob(data, { format: "json" })
const url = URL.createObjectURL(blob)

// Import from JSON string
const imported = importData(jsonString)

// Import from CSV
const importedCsv = importData(csvString, "csv")

Note: CSV export flattens top-level keys into a single row. Nested objects/arrays are JSON-encoded in their cells. CSV import only reads the first data row after the header.

Exporting Data​

From a Zustand store​

The store holds decrypted data in memory. Read it and trigger a download:

import type { StoreApi } from "zustand/vanilla"
import type { StarfishStore } from "@drakkar.software/starfish-client/zustand"

function exportFromStore(store: StoreApi<StarfishStore>, filename: string) {
const { data } = store.getState()
const json = JSON.stringify(data, null, 2)
const blob = new Blob([json], { type: "application/json" })
const url = URL.createObjectURL(blob)

const a = document.createElement("a")
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}

// Usage
exportFromStore(settingsStore, "settings-export.json")

From SyncManager directly​

If you're not using a state binding, pull first then read:

await sync.pull()
const data = sync.getData()
const json = JSON.stringify(data, null, 2)

Export with Encryption​

Exporting decrypted data​

When SyncManager has encryption configured, getData() and store.getState().data already return decrypted data. The export examples above produce plaintext JSON — no extra steps needed.

Exporting the encrypted blob​

To export the raw encrypted data (e.g., for backup without exposing the key), bypass SyncManager and use StarfishClient directly:

import { StarfishClient } from "@drakkar.software/starfish-client"

const client = new StarfishClient({
baseUrl: "https://api.example.com/v1",
capProvider: { getCap: async () => ({ cap: creds.capCert, devEdPrivHex: creds.device.edPriv }) },
})

// This returns the raw server data, including { _encrypted: "...", _epoch: N }
const result = await client.pull(`/pull/users/${userId}/notes`)
const encryptedJson = JSON.stringify(result.data, null, 2)

Re-encrypting for sharing​

In v3 the typical "share" flow is to add the recipient's X25519 pubkey to the collection's keyring rather than re-encrypting documents:

import {
addCollectionRecipient,
type RecipientRef,
} from "@drakkar.software/starfish-client"

const recipient: RecipientRef = { subKem: bobDeviceKemPubHex }
await addCollectionRecipient(client, "notes", recipient, {
edPriv: creds.device.edPriv,
edPub: creds.device.edPub,
kemPriv: creds.device.kemPriv,
})
// Bob can now decrypt every document under the current epoch.

If you need a one-off re-encrypt under a separate CEK (e.g., to hand a single dump to an external party without modifying the live keyring), build a second keyring + encryptor for the external recipient and re-seal each document. See 23. Multi-Recipient Delegated Encryption for the keyring lifecycle.

Importing Data​

Read a JSON file, validate it, and push to the store:

async function importToStore(
store: StoreApi<StarfishStore>,
file: File,
) {
const text = await file.text()
const data = JSON.parse(text) as Record<string, unknown>

// Basic validation
if (typeof data !== "object" || data === null || Array.isArray(data)) {
throw new Error("Import file must contain a JSON object")
}

// Push to store — marks dirty and auto-flushes to server
store.getState().set(() => data)
}

With schema validation​

Check the schema version before importing to avoid loading incompatible data:

async function importWithValidation(
store: StoreApi<StarfishStore>,
file: File,
expectedVersion: number,
) {
const text = await file.text()
const data = JSON.parse(text) as Record<string, unknown>

const version = (data._schemaVersion as number) ?? 1
if (version > expectedVersion) {
throw new Error(
`Import file uses schema v${version}, but this app supports up to v${expectedVersion}. ` +
`Update the app before importing.`
)
}

// Run migrations if needed (see Schema Versioning)
const migrated = migrateIfNeeded(data, expectedVersion)
store.getState().set(() => migrated)
}

See Schema Versioning for the migrateIfNeeded implementation and Pre-Push Validation for additional validation patterns.

React file input​

function ImportButton({ store }: { store: StoreApi<StarfishStore> }) {
const handleFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
try {
await importToStore(store, file)
} catch (err) {
alert(`Import failed: ${(err as Error).message}`)
}
}

return <input type="file" accept=".json" onChange={handleFile} />
}

GDPR Data Export​

Export all user data across multiple collections into a single bundle:

interface ExportBundle {
_exportVersion: 1
_exportedAt: string
collections: Record<string, {
data: Record<string, unknown>
schemaVersion?: number
}>
}

async function exportAllUserData(
managers: Record<string, SyncManager>,
): Promise<ExportBundle> {
const collections: ExportBundle["collections"] = {}

for (const [name, sync] of Object.entries(managers)) {
await sync.pull()
const data = sync.getData()
collections[name] = {
data,
schemaVersion: (data._schemaVersion as number) ?? undefined,
}
}

return {
_exportVersion: 1,
_exportedAt: new Date().toISOString(),
collections,
}
}

// Usage
const bundle = await exportAllUserData({
settings: settingsSync,
notes: notesSync,
tasks: tasksSync,
})

const json = JSON.stringify(bundle, null, 2)
// Trigger download...

The _exportVersion field lets you evolve the export format independently from individual collection schemas.

Account Migration​

Move data from one v3 account to another by pulling with the old user's encryptor and pushing with the new user's encryptor:

import {
StarfishClient,
SyncManager,
bootstrapRootIdentity,
createKeyringEncryptor,
type Keyring,
} from "@drakkar.software/starfish-client"

async function migrateCollection(
oldClient: StarfishClient,
newClient: StarfishClient,
oldEnc: Awaited<ReturnType<typeof createKeyringEncryptor>>,
newEnc: Awaited<ReturnType<typeof createKeyringEncryptor>>,
oldPath: string,
newPath: string,
) {
// Pull from old account, decrypt with old encryptor.
const oldSync = new SyncManager({
client: oldClient,
pullPath: `/pull/${oldPath}`,
pushPath: `/push/${oldPath}`,
encryptor: oldEnc,
})
await oldSync.pull()
const data = oldSync.getData()

// Push to the new account's path, encrypted under the new keyring.
const newSync = new SyncManager({
client: newClient,
pullPath: `/pull/${newPath}`,
pushPath: `/push/${newPath}`,
encryptor: newEnc,
})
await newSync.push(data)
}

// Usage
const oldCreds = await loadOldCreds() // your existing persisted DeviceCredentials
const newCreds = await bootstrapRootIdentity(newPassphrase)

const oldClient = new StarfishClient({
baseUrl: "https://api.example.com/v1",
capProvider: { getCap: async () => ({ cap: oldCreds.capCert, devEdPrivHex: oldCreds.device.edPriv }) },
})
const newClient = new StarfishClient({
baseUrl: "https://api.example.com/v1",
capProvider: { getCap: async () => ({ cap: newCreds.capCert, devEdPrivHex: newCreds.device.edPriv }) },
})

const collections = ["settings", "notes", "tasks"]
for (const name of collections) {
const oldKr = (await oldClient.pull(`${name}/_keyring`)).data as Keyring
const newKr = (await newClient.pull(`${name}/_keyring`)).data as Keyring
const oldEnc = await createKeyringEncryptor(oldKr, {
kemPubHex: oldCreds.device.kemPub,
kemPrivHex: oldCreds.device.kemPriv,
})
const newEnc = await createKeyringEncryptor(newKr, {
kemPubHex: newCreds.device.kemPub,
kemPrivHex: newCreds.device.kemPriv,
})
await migrateCollection(
oldClient,
newClient,
oldEnc,
newEnc,
`users/${oldCreds.userId}/${name}`,
`users/${newCreds.userId}/${name}`,
)
}

Note: the old account's data remains on the server. Server-side cleanup (deletion) is a separate operation not covered by the client SDK.

Next Steps​