# Storing your own data

Your plugin has its own database. Almost every plugin needs one, and the
decisions you make in the first hour of building it are the ones that are
expensive to change later.

This guide is about that side of the line: what belongs to you, what stays in
Orbit, and the handful of rules that keep a plugin serving many merchants from
becoming a plugin that leaks between them.

## What goes where

**Orbit's data stays in Orbit.** Products, orders, customers, inventory — read
them through the API when you need them. Copying the catalogue into your
database and treating that copy as the truth is the most common way plugins go
wrong: the merchant edits a product, your copy does not know, and you write
stale values back over their work.

Caching for performance is fine. Caching and then writing back from the cache
is not.

**Your database holds what only you know:**

- The credential for each store (see [Authentication](/guides/authentication), or a [store API key](/guides/api-keys) for a single-store integration)
- Per-store settings
- Sync state — checkpoints, cursors, last-run timestamps
- Records the merchant's own dashboard has no concept of
- Webhook events you have received, so you can process them once

## Everything is per store

One plugin serves **every merchant who installs it**. There is no global
configuration, no "the store", and no single set of credentials.

So the store id is a column on nearly every table you own, and the first thing
you resolve on every request and every background pass. In practice:

```prisma
model Connection {
  storeId      String   @id
  accessToken  String   // encrypted
  refreshToken String   // encrypted
  settings     String   @default("{}")
}

model Record {
  id      String @id @default(uuid())
  storeId String
  // ...
  @@index([storeId])
}
```

This holds even if you are building for one client. It costs a column, and it
means the second merchant is a non-event rather than a rewrite.

## Filter every query by store

The rule that matters most, and the one that is easiest to break in a hurry:
**every read and every write is scoped to the store from the verified token.**
Never to a store id the caller sent you.

```ts
// The store comes from the token, which Orbit verified.
const { storeId } = await verifyRequest(request.headers.get('Authorization'))

const records = await db.record.findMany({
  where: { storeId },        // <- not optional
  orderBy: { createdAt: 'desc' }
})
```

Deletes and updates are where this is most often missed, because an id already
looks unique:

```ts
// WRONG — a valid id from another store deletes another merchant's record
await db.record.delete({ where: { id } })

// RIGHT — the store is part of the condition, so a foreign id matches nothing
await db.record.deleteMany({ where: { id, storeId } })
```

Forgetting the filter on one query does not produce a bug that a merchant
reports. It produces one merchant quietly seeing another's data, and you find
out from them.

## Encrypt credentials at rest

A refresh token is a ninety-day key to a merchant's store. Encrypt access and
refresh tokens before they touch your database, and keep the key out of the
database that holds them.

```ts
await db.connection.upsert({
  where: { storeId },
  create: { storeId, accessToken: encrypt(tokens.accessToken), refreshToken: encrypt(tokens.refreshToken) },
  update: { accessToken: encrypt(tokens.accessToken), refreshToken: encrypt(tokens.refreshToken) }
})
```

Two consequences worth planning for:

- **Rotating the encryption key makes every stored credential unreadable.**
  There is no recovery path except every merchant reopening your plugin to
  reconnect. Decide how you would do that before you need to.
- **Never log a token**, including inside error objects. A stack trace that
  echoes a request body is the usual leak.

## Record webhook events before you process them

Webhook delivery is at-least-once: a network problem on our side means you see
the same event twice. Make that harmless by recording the event id with a
unique constraint, and doing the work afterwards.

```ts
try {
  await db.webhookEvent.create({ data: { eventId: envelope.id, storeId, topic } })
} catch {
  // Already seen. Acknowledge and stop — reprocessing helps nobody.
  return Response.json({ received: true, duplicate: true })
}
```

Answering quickly matters too. Orbit retries slow deliveries and eventually
gives up, so a handler that does real work inline turns a slow database into
lost events. Record, return `200`, and let a background pass do the work — see
[Background jobs](/guides/background-jobs).

## When a merchant uninstalls

Their credential is revoked immediately and your webhook subscriptions are
removed. Anything your plugin was doing for that store stops working, which is
the intended behaviour: uninstalling is how a merchant withdraws consent.

**Do not rely on the `plugin.uninstalled` webhook to notice.** It is emitted,
but the subscriptions it would be delivered through are removed as part of the
same uninstall, so whether you receive it is a race you will usually lose.
Detect it the reliable way instead: a refresh that returns `401` and does not
recover means the credential is gone for good.

What to do with the data you still hold is your decision, and worth making
deliberately rather than by default:

- Keeping it means a merchant who reinstalls picks up where they left off.
- Keeping it also means you are storing data for someone who has withdrawn
  consent, which your privacy policy needs to cover.

A common middle path is to keep it for a stated period, then delete it. Say
which you do.

## Choosing a database

Anything you already run. Plugins in our own catalogue use Postgres with
Prisma, but nothing about the platform cares.

Two practical notes:

- **One store's failure must not stop the others.** Whatever you use, a
  background pass should handle a single store erroring without abandoning the
  remaining ones.
- **Only one process may hold a store's refresh token.** If you run redundant
  workers, elect a leader — two processes refreshing the same credential means
  one of them ends up holding a dead one.

## A worked example

[`plugins/starter`](https://github.com/Orbit-Web-Solutions/orbit-commerce-samples/tree/main/plugins/starter)
implements all of this: a per-store schema in
[`prisma/schema.prisma`](https://github.com/Orbit-Web-Solutions/orbit-commerce-samples/blob/main/plugins/starter/prisma/schema.prisma),
store-scoped CRUD in `app/api/notes/route.ts`, encryption in `lib/orbit-auth.ts`,
and duplicate-safe webhook recording in `app/api/webhooks/orbit/route.ts`.

## Next steps

- Where credentials come from, and how they are kept alive, in
  [Authentication](/guides/authentication).
- Processing what you stored, in [Background jobs](/guides/background-jobs).
- Verifying deliveries before you trust them, in [Webhooks](/guides/webhooks).
