Skip to content

Migrating existing data

Switching an existing app onto dropin has a cold-start problem: your users already follow each other and already have history, but a fresh tenant is empty — every timeline renders blank on day one. The batch API fixes that. Three routes, each taking up to 100 items per call, let you backfill everything before launch:

  1. batch.users — the people (and any other actors).
  2. batch.follows — the follow graph, so fan-out has edges to fan out along.
  3. batch.activities — the history, which fans out to the followers you just created.

That order matters: follows reference users, and an activity posted before its author has followers reaches nobody’s timeline. Users → follows → activities, always.

Batch is server-token only — it lives in @dropinnodex/server, never in the browser SDKs. A user-token call to any batch route returns FORBIDDEN.

Imported history fills feeds but doesn’t announce itself:

  • No notification-feed events are created for batch follows.
  • No live-mode “new activity” pings fire for batch activities.
  • No webhooks are delivered for any batch operation — your backend already has this data; an import must not fire thousands of deliveries back at it.

Fan-out itself runs normally (that’s the point — the rows land in your users’ timelines), it just doesn’t scream while doing it.

Importing history vs mirroring live writes

Section titled “Importing history vs mirroring live writes”

Quiet is right for backfilling and wrong for anything happening now. The two paths write identical rows and differ only in whether anyone is told:

You are… Use Notification Webhook
Backfilling an existing social graph batch.userFollows([…]) no no
Mirroring a follow a user just made userFollow({ follower, following }) yes follow.added
Importing old posts batch.activities([…]) no no
Mirroring a post just created feed(…).addActivity(…) yes activity.added

Both columns are server-token calls from your backend — the loud path does not need a user token. If you wire a database trigger or a webhook from your own system to mirror ongoing writes, that is the loud path, not batch.

The loud path is idempotent too: re-following an existing edge writes nothing and notifies nobody, so an at-least-once trigger can safely deliver twice.

A batch call is not atomic. One bad row never rolls back the rest: the response is HTTP 200 whenever the envelope was valid, with one result per input item, in order:

{ "results": [
{ "index": 0, "ok": true, "id": "b3f0…" },
{ "index": 1, "ok": false, "code": "VALIDATION_FAILED" }
] }

Check every results[i].ok. A failed item carries only a stable error code (never a message) — collect the failed indexes, fix the source rows, and retry just those.

Import scripts crash halfway. Design for the rerun:

  • Users are upserts — running the same batch twice is a no-op.
  • Follows deduplicate on the edge — a duplicate follow is ok: true, not an error.
  • Activities deduplicate on foreign_id + time. Supply both on every imported activity — foreign_id from your own primary key, time from the original timestamp — and a rerun is a no-op. Without them, a rerun duplicates the history. Don’t skip this.

This is the same (foreign_id, time) dedupe rule that governs every activity write — the Idempotency & foreign_id section of the activities guide spells out the full semantics (including the burned-identity 409).

time doing double duty is a bonus: it carries the historical timestamp, so imported activities sort into feeds where they actually happened, not at the top of everyone’s timeline on import day.

Batch requests charge a separate window — 600 batch requests per minute per tenant (a 60,000-item/min ceiling) — so a running import never starves your app’s normal traffic, and vice versa. A 429 carries Retry-After as usual; a few thousand users’ worth of history won’t get near the limit.

That works out to 60,000 items/min. For the arithmetic on a specific backfill size, and every other limit the API enforces, see Limits.

FC Urban-shaped example: a five-a-side football app migrating players and their played games. Each player has a user:<id> feed (their own posts) and a timeline:<id> feed (who they follow); a finished game becomes a played activity on the organizer’s feed.

import { DropInServer, type BatchResultItem } from '@dropinnodex/server'
import { players, followEdges, games } from './legacy-db.js' // your existing data
const server = new DropInServer({
tenantId: 'fc-urban',
apiKey: process.env.DROPIN_API_KEY!,
apiSecret: process.env.DROPIN_API_SECRET!,
})
function chunk<T>(items: T[], size = 100): T[][] {
const out: T[][] = []
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size))
return out
}
// Run one chunked import; return the source items whose rows failed, for retry.
async function importAll<T>(
label: string,
items: T[],
send: (batch: T[]) => Promise<{ results: BatchResultItem[] }>,
): Promise<T[]> {
const failed: T[] = []
for (const batch of chunk(items)) {
const { results } = await send(batch)
for (const r of results) {
if (!r.ok) {
console.error(`${label}[${r.index}] failed: ${r.code}`)
failed.push(batch[r.index])
}
}
}
console.log(`${label}: ${items.length - failed.length}/${items.length} ok`)
return failed
}
// 1. Users first — everything else references them.
const failedUsers = await importAll('users',
players.map((p) => ({ id: p.id, custom: { name: p.name, position: p.position } })),
(batch) => server.batch.users(batch),
)
// 2. Follows next — fan-out needs the graph before history is posted.
// `userFollows` takes plain user ids and expands the feed convention for you
// ("maya follows diego" → timeline:maya pulls from user:diego). Reach for the
// raw `batch.follows({ source, target })` form only for non-user feed graphs.
const failedFollows = await importAll('follows',
followEdges.map((e) => ({ follower: e.follower, following: e.followed })),
(batch) => server.batch.userFollows(batch),
)
// 3. Activities last — they fan out to the followers created in step 2.
// foreign_id + time make every activity a safe rerun (and sort it correctly).
const failedActivities = await importAll('activities',
games.map((g) => ({
feed: `user:${g.organizerId}`,
activity: {
actor: g.organizerId,
verb: 'played',
object: `game:${g.id}`,
foreign_id: `game:${g.id}`, // your primary key — dedupes reruns
time: g.playedAt.toISOString(), // the real date — sorts into history
custom: { venue: g.venue, score: g.score },
},
})),
(batch) => server.batch.activities(batch),
)
if (failedUsers.length || failedFollows.length || failedActivities.length) {
// Fix the source rows and re-run — every step is idempotent, so a full
// rerun is also safe. Nothing needs cleaning up first.
process.exit(1)
}

Because every step is idempotent, the simplest retry strategy is often the best one: fix whatever produced the failed rows and run the whole script again. Rows that already landed come back ok: true and nothing duplicates.

No batch deletes, no batch reactions, no CSV upload, no async job tracking — batch is a cold-start import tool, not a sync pipeline. Once you’re live, writes go through the normal single-op routes and SDKs — including from your backend: server.userFollow(…) and server.feed(…).addActivity(…) are server-token calls that behave exactly like a user’s own write.

  • Follow — the loud counterpart for follows that happen live.
  • Activities — the (foreign_id, time) identity that makes a rerun safe, and the 409 a deleted identity returns.
  • Limits — the batch rate window and the arithmetic for a given backfill size.
  • Webhooks — why an import stays silent.