Features Apograph CMS on GitHub

Transfer plugin

Moving content out of one Apograph and into another — new in 0.4.0.

Documents 0.5.2 Updated Edit this page Report a problem

On this page

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-admin

Register

// 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: {}
}
SettingWhat it does
identityWhich field identifies a record of each type
limitsCeilings 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

FormatCarries filesLossless
JSONnoyes
NDJSONnoyes
ZIPyesyes
CSVnono

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.

LimitDefault
maxEntries5,000
maxAssets2,000
maxBytes512 MB
maxImportRecords5,000
maxColumns200
maxUploadBytes256 MB
maxArchiveEntries10,000
maxArchiveEntryBytes128 MB
maxArchiveTotalBytes1 GB
maxCompressionRatio200

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.