Export and import, added in 0.4.0. It owns no tables and no migrations: a transfer reads and writes content that already exists, and the only thing it adds is two audit events riding the shared outbox.
Install
npm install @apograph/transfer-server @apograph/transfer-adminRegister
// apps/server/src/plugins.ts
import { TransferPlugin } from '@apograph/transfer-server';
TransferPlugin(config.plugins.transfer);// apps/admin/src/plugins.ts
import { transferAdminPlugin } from '@apograph/transfer-admin';
transferAdminPlugin();Register the admin half after contentAdminPlugin(), which declares the
three slots it hangs on.
A scaffolded app installs it but does not register it
As of 0.4.0, create-apograph-app lists the transfer packages in a generated
app’s manifest but its plugins.ts templates register neither half. Add the
two lines above to get the Export and Import menu items.
Configuration
transfer: {
identity: { product: ['sku'], author: ['email'] },
limits: {}
}| Setting | What it does |
|---|---|
identity | Which field identifies a record of each type |
limits | Ceilings on one transfer. Empty takes the shipped defaults |
It reads no environment variables.
identity is the setting that decides whether importing the same file twice
updates records or duplicates them. Left out, a type falls back to a derived
guess — a field named like an identifier (slug, sku, email), then the
first required text field. That guess is usually right, and it is reported in
every export’s manifest, but it is still a guess. Name the fields for any type
where being wrong would be expensive.
Why a natural key
A row id names one database. Everything an import can do rests on the natural key instead, so a document written on staging means something on production.
A localized field is a candidate: a transfer record is one row, not one
record across languages, so a per-locale slug identifies exactly the row it
belongs to. The locale is folded into the key’s fingerprint, which is what
keeps en/hello apart from de/hello.
A record is matched two ways, in that order: the natural key first, then the
source row id — and only when the key found nothing. A $id that names a
live row of this type in this workspace is not a guess, it is the row the
document was written from. That fallback is what lets a type with no derivable
identity field be linked to rather than copied on every import.
Formats
| Format | Carries files | Lossless |
|---|---|---|
| JSON | no | yes |
| NDJSON | no | yes |
| ZIP | yes | yes |
| CSV | no | no |
Only ZIP carries asset bytes, and the export dialog disables the “include files” toggle from that table rather than accepting it and ignoring it.
CSV is lossy on purpose, and predictably
Media cells are deliberately not reconstructed on the way back in. A filename in a spreadsheet is not a file, and inventing an asset reference from one relinks records to whatever happened to share a name.
Two policies, because depth asks two questions
An export walks one hop: the records you selected, plus the depth-1 records they point at. On the way back in, those two groups are governed separately.
- Conflict policy answers “this record is already here” for the records somebody selected.
- Relation policy answers it for the depth-1 records those point at. It defaults to Link, which writes nothing to a related record that already exists.
They share a value (update) and nothing else. Collapsing them into one answer
would make “duplicate this article” also mean “duplicate its author”.
Limits
An export is bounded so a careless filter cannot ask one request to stream a whole library into memory. An import is bounded because the file is untrusted.
| Limit | Default |
|---|---|
maxEntries | 5,000 |
maxAssets | 2,000 |
maxBytes | 512 MB |
maxImportRecords | 5,000 |
maxColumns | 200 |
maxUploadBytes | 256 MB |
maxArchiveEntries | 10,000 |
maxArchiveEntryBytes | 128 MB |
maxArchiveTotalBytes | 1 GB |
maxCompressionRatio | 200 |
The compression ratio is not redundant with the byte caps. A zip bomb’s whole trick is that the compressed file is small enough to sail past an upload limit — the ratio is the signal that arrives before the bytes do.
Permissions
Two keys, both granted to contributor as well as admin:
content:export and content:import. Downloading an import template needs
only content:read.
What it guarantees
Every write goes through the same writer service the editor does — not “mostly”, every one. Field validation, the workspace scope, relation-target checks, the same-locale rule, the media-asset check, revisions, the outbox and the localization extension all run. An import that reached a content table directly would be a supported way to write rows none of those rules ever saw.
Nothing in an uploaded file names a workspace. The manifest carries the source workspace and the importer never reads it; the request’s workspace is stamped on every write.