# Convex

Source: https://docs.mirafive.io/sdks/convex

> Record MIRA FIVE events from Convex mutations and actions, sent only after the transaction commits.

`@mirafive/sdk-convex` records events from Convex mutations and actions. A mutation never sends: it schedules the delivery, which Convex runs only if the mutation commits, so an order that rolled back is never counted. Delivery goes through [`@mirafive/sdk-server`](https://docs.mirafive.io/sdks/node). For the browser side of a Convex app, use the [browser SDK](https://docs.mirafive.io/sdks/browser) or your framework's package.

## Install

```bash
npm install @mirafive/sdk-convex @mirafive/sdk-server
```

Peers: `convex` 1.25 or newer and `@mirafive/sdk-server` 1.0. It runs in Convex's default runtime; you do not need `"use node"`.

## Set up

You need the **secret key** of a server source (`mf_…`). See [Keys](https://docs.mirafive.io/keys) for where to find it. Set it on the deployment, never in code:

```sh
npx convex env set MIRAFIVE_SECRET_KEY mf_…
```

Create the client and export its delivery action:

```ts title="convex/mirafive.ts"
import { MiraConvex } from '@mirafive/sdk-convex'

import { internal } from './_generated/api'

export const mira: MiraConvex = new MiraConvex({
  key: process.env.MIRAFIVE_SECRET_KEY,
  deliver: internal.mirafive.deliver,
})

export const deliver = mira.deliverAction()
```

Keep the `: MiraConvex` annotation. `mira` refers to `internal.mirafive.deliver`, whose generated type comes from this module, so without it TypeScript reports TS7022 (`'mira' implicitly has type 'any'`).

Track where things happen:

```ts title="convex/orders.ts"
import { v } from 'convex/values'

import { mutation } from './_generated/server'
import { mira } from './mirafive'

export const pay = mutation({
  args: { orderId: v.id('orders') },
  handler: async (ctx, { orderId }) => {
    const order = await ctx.db.get(orderId)

    if (!order) {
      throw new Error('order not found')
    }

    await ctx.db.patch(orderId, { paidAt: Date.now() })

    await mira.track(ctx, 'order paid', {
      userId: order.customerId,
      properties: { revenue: order.total, currency: 'EUR' },
      idempotencyKey: `order-${orderId}`,
    })
  },
})
```

That is the whole install. A missing key never breaks the deployment: events are dropped with one warning in the logs.

## Verify

1. Run the delivery action once with an install check. It proves the key and host work and is never stored or billed:

   ```sh
   npx convex run mirafive:deliver '{"events":"[{\"name\":\"$install_check\"}]","idempotencyKey":"install-check"}'
   ```

   It prints `null` when MIRA FIVE accepted the batch, and fails with the server's error otherwise. `events` is a JSON string, as the scheduler stores it.
2. Run a mutation that tracks, then open the source's live view in MIRA FIVE and find the event. The Convex dashboard shows the `mirafive:deliver` run.

Nothing arriving? See [Troubleshooting](#troubleshooting).

## Track events

`track(ctx, name, options)` works in mutations and actions. `ctx` is any mutation or action context; queries cannot schedule. `await` every call.

In a mutation, `track()` schedules `deliver` with `ctx.scheduler.runAfter(0, …)`. Convex runs a scheduled function only if the mutation commits, and a mutation cannot reach the network, so nothing is sent from it. Each call is one action run and one batch. To put several events in one delivery, use `trackMany()`:

```ts
await mira.trackMany(ctx, [
  { name: 'order paid', userId, properties: { revenue: 49.9, currency: 'EUR' } },
  { name: 'invoice created', userId, properties: { invoiceId } },
])
```

`time` (a `Date`, epoch milliseconds or an ISO string) defaults to the start of the calling function. An unparseable `time` throws a `TypeError` at the call.

Events travel as JSON strings, so property keys Convex refuses as field names (`$…`, `_…`, non-ASCII) are fine. The protocol's own limits still apply (keys up to 128 characters, at most 32 KB of properties). An event that is not JSON (a `BigInt`, a cycle) is dropped with a warning; it never fails your transaction.

To have TypeScript check event names and properties, pass an event map. It is types only:

```ts title="convex/mirafive.ts"
type Events = { 'order paid': { revenue: number; currency: string } }

export const mira: MiraConvex<Events> = new MiraConvex<Events>({
  key: process.env.MIRAFIVE_SECRET_KEY,
  deliver: internal.mirafive.deliver,
})
```

Event names, properties and revenue are covered in [Track events](https://docs.mirafive.io/guides/track-events).

## Identify users

`identify(ctx, userId, traits?, { anonymousId? })` records the person's traits. With the browser's anonymous id, it also links the visitor's earlier events to the user:

```ts
await mira.identify(ctx, userId, { plan: 'pro' }, { anonymousId })
```

Use your internal user id, never an email address. See [Identify users](https://docs.mirafive.io/guides/identify-users).

## Idempotent deliveries

Every delivery carries an idempotency key, fixed when it is queued: the `idempotencyKey` you pass to `track()` or `trackMany()`, or a fresh UUID. The delivery's `sentAt` and every event's `time` are fixed at the same moment. A rerun of the action therefore sends the byte-identical batch, and MIRA FIVE stores it once. Pass your own key when a mutation can run twice for the same thing, such as a webhook. An empty key throws a `TypeError`.

## The outbox

By default every `track()` or `trackMany()` call is one action run. That is immediate and needs no setup, and it suits up to roughly one event per second. Above that, turn on the outbox: mutations write events to a table in your schema, and a cron sends them in batches of up to 1000 events.

```ts title="convex/schema.ts"
import { miraOutboxTable } from '@mirafive/sdk-convex'
import { defineSchema } from 'convex/server'

export default defineSchema({
  miraOutbox: miraOutboxTable,
})
```

```ts title="convex/mirafive.ts"
import { MiraConvex } from '@mirafive/sdk-convex'

import { internal } from './_generated/api'

export const mira: MiraConvex = new MiraConvex({
  key: process.env.MIRAFIVE_SECRET_KEY,
  outbox: true,
  deliver: internal.mirafive.deliver,
})

export const deliver = mira.deliverAction()
export const { flushOutbox, outboxRows } = mira.flushOutbox()
```

```ts title="convex/crons.ts"
import { cronJobs } from 'convex/server'

import { internal } from './_generated/api'

const crons = cronJobs()

crons.interval('mirafive outbox', { seconds: 30 }, internal.mirafive.flushOutbox)

export default crons
```

The table must be named `miraOutbox`. `flushOutbox` and `outboxRows` must be exported under those names from the module that exports `deliver`: the drain finds `outboxRows` next to `deliver`.

| | Per-call scheduling (default) | Outbox |
| --- | --- | --- |
| Latency | Immediate | Up to one cron interval |
| Convex cost | One action run per call | An insert and a delete per event, one action run per flush |
| Setup | None | A schema table, two exports, a cron |
| When a send fails | The action fails after 3 retries; use a [workpool](#retries-with-a-workpool) to retry it | Rows stay and go with the next flush |
| Where queued events are | `_scheduled_functions` | Your `miraOutbox` table |

MIRA FIVE meters per event either way: batching saves Convex action time, not MIRA FIVE usage.

How the drain works:

- A flush reads only rows created before it started; rows written during a flush go with the next one. Convex runs one instance of a cron at a time, so no lock is needed.
- A batch is named after its row ids. A batch that was sent but not yet deleted is resent under the same batch id and stored once.
- One run drains the whole backlog, at most 1000 events or 1 MiB per batch.
- An action has no table to write to, so `track()` in an action schedules a delivery even with `outbox: true`. `idempotencyKey` is ignored for events that go to the outbox.

## Retries with a workpool

The scheduler does not retry a failed action. To retry deliveries, hand them to a [Convex workpool](https://www.convex.dev/components/workpool) with `enqueue`. Install `@convex-dev/workpool` and register the component:

```ts title="convex/convex.config.ts"
import workpool from '@convex-dev/workpool/convex.config'
import { defineApp } from 'convex/server'

const app = defineApp()

app.use(workpool, { name: 'mirafivePool' })

export default app
```

```ts title="convex/mirafive.ts"
import { Workpool } from '@convex-dev/workpool'
import { MiraConvex } from '@mirafive/sdk-convex'

import { components, internal } from './_generated/api'
import type { MutationCtx } from './_generated/server'

const pool = new Workpool(components.mirafivePool, { maxParallelism: 2, retryActionsByDefault: true })

export const mira: MiraConvex = new MiraConvex({
  key: process.env.MIRAFIVE_SECRET_KEY,
  deliver: internal.mirafive.deliver,
  enqueue: (ctx, deliver, args) => pool.enqueueAction(ctx as MutationCtx, deliver, args),
})

export const deliver = mira.deliverAction()
```

Because the idempotency key is fixed when the delivery is queued, a retried delivery is stored once.

## Consent

- **Full mode** (default) sends the identifiers you pass: `userId`, `anonymousId`, `sessionId`. You hold the consent or other lawful basis for them.
- **Consentless mode** (`mode: 'consentless'`) counts without identifiers. Passing `userId`, `anonymousId` or `sessionId`, or calling `identify()`, then throws a `TypeError` in the calling function.
- Do Not Track and Global Privacy Control reach an `httpAction` as `DNT: 1` and `Sec-GPC: 1` headers. Leave the identifiers out of `track()` for such requests.
- Outbox rows are your data in your own `miraOutbox` table, one event per row as a JSON string in `event`. A row lives until the next flush sends it. Without the outbox, a queued delivery's arguments sit in Convex's `_scheduled_functions` table, which Convex keeps for a while after the run.

See [Consent](https://docs.mirafive.io/guides/consent).

## API reference

```ts
import { MiraConvex, MiraError, miraOutboxTable } from '@mirafive/sdk-convex'
import type {
  DeliverArgs,
  DeliverReference,
  Enqueue,
  Events,
  MiraConvexOptions,
  Mode,
  OutboxFunctions,
  OutboxRow,
  OutboxRowsArgs,
  Page,
  QueuedEvent,
  Receipt,
  SchedulingCtx,
  TrackEvent,
  TrackOptions,
} from '@mirafive/sdk-convex'
```

### `new MiraConvex<Events>(options)`

The constructor never throws, so a module that creates the client cannot take the deployment down.

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `deliver` | `DeliverReference` | | **Required.** The exported `deliverAction()`, e.g. `internal.mirafive.deliver`. |
| `key` | `string \| undefined` | | The source's secret key. Missing: events are dropped with one warning. |
| `host` | `string` | `https://events.mirafive.io` | With `https://`. An empty string means the default. |
| `mode` | `'full' \| 'consentless'` | `'full'` | Collection mode of every batch. |
| `outbox` | `boolean` | `false` | Mutations write to `miraOutbox` instead of scheduling. |
| `enqueue` | `(ctx, deliver, args) => Promise<unknown>` | `ctx.scheduler.runAfter(0, deliver, args)` | Queues one delivery, e.g. through a workpool. |
| `disabled` | `boolean` | `false` | Drops every event, e.g. on preview deployments. Programming errors still throw. |

| Member | Description |
| --- | --- |
| `track(ctx, name, options?)` | `Promise<void>`. Queues one event. `options`: `userId`, `anonymousId`, `sessionId`, `properties`, `time`, `page` (`{ url?, title?, referrer? }`), `idempotencyKey`. |
| `trackMany(ctx, events, { idempotencyKey? }?)` | `Promise<void>`. Queues 1–1000 events (`{ name, …track options without idempotencyKey }`) as one delivery. |
| `identify(ctx, userId, traits?, { anonymousId? }?)` | `Promise<void>`. Queues `$identify`. |
| `deliverAction()` | The internal action that sends one delivery. Export it as `deliver`. |
| `flushOutbox()` | `{ flushOutbox, outboxRows }`: the cron action and its internal mutation. Export both. |

| Export | Description |
| --- | --- |
| `miraOutboxTable` | The outbox table: `defineSchema({ miraOutbox: miraOutboxTable })`. |
| `MiraError` | Re-exported from [`@mirafive/sdk-server`](https://docs.mirafive.io/sdks/node#errors). |

The calls never throw for transport reasons. They throw a `TypeError` only for programming errors: identifiers in consentless mode, an unparseable `time`, an empty `idempotencyKey`, or an empty or over-1000 `trackMany()`.

### What `deliver` does

`deliver` sends with `@mirafive/sdk-server`: 3 retries on 408, 429, 5xx, network errors and timeouts. When every retry fails, the action fails and shows in the Convex logs, and a workpool retries it.

- Events the server refuses (`400 validation_failed`, or refused before sending as `invalid_event` or `payload_too_large`) are found by halving the batch, dropped one by one with a warning, and the rest is sent. A malformed event never blocks the outbox.
- Other refusals (`collection_mode_not_allowed`, `invalid_json`, a wrong key) concern the whole source: the run fails and outbox rows are kept until you fix it.
- A receipt that reports dropped events (`ingestion_paused`, `allowance_exhausted`) is logged as a warning.
- Deliveries report `mirafive-server` as their SDK.

## Troubleshooting

| Symptom | Cause and fix |
| --- | --- |
| Nothing arrives | Check the Convex logs. `[mirafive] no key`: run `npx convex env set MIRAFIVE_SECRET_KEY …`. A failed `deliver` shows the server's error. With the outbox, check that `convex/crons.ts` is deployed. |
| `Could not find function …:outboxRows` | Export `flushOutbox` and `outboxRows` from the module that exports `deliver`, under those names. |
| `miraOutbox` table or validator errors | Add `miraOutbox: miraOutboxTable` to `convex/schema.ts`. |
| TS7022 `'mira' implicitly has type 'any'` | Annotate the client: `export const mira: MiraConvex = new MiraConvex({ … })`. |
| `403 website_key_as_bearer` | You set the website key. Server sources need the secret key. |
| `400 collection_mode_not_allowed` | The source is consentless. Pass `mode: 'consentless'` and no identifiers. Outbox rows wait until then. |
| `TypeError: consentless mode …` | Identifiers in consentless mode. Remove them or switch the mode. |
| `TypeError: time must be …` | `time` is not a `Date`, epoch milliseconds or a parseable date string. |
| `[mirafive] dropped an event the server refuses` | A `$` name that is not reserved, a blank id, a `sessionId` that is not a UUID, or properties over the limits. |
| `[mirafive] the server dropped N events: …` | `allowance_exhausted` or `ingestion_paused`: the organization is out of allowance or paused. |
| The outbox grows | Deliveries fail, typically for a wrong key. See the `flushOutbox` runs in the logs; rows are kept until it is fixed. |

## Set up with an AI agent

Paste this into your coding agent:

```text
Add MIRA FIVE server-side analytics to this Convex project with @mirafive/sdk-convex.
Docs: https://docs.mirafive.io/sdks/convex.md

1. Install @mirafive/sdk-convex and @mirafive/sdk-server with the project's package manager.
2. The secret key goes in the Convex deployment's environment, never in code or client bundles.
   Tell the user to run: npx convex env set MIRAFIVE_SECRET_KEY <secret key of a server source>
3. Create convex/mirafive.ts:
     import { MiraConvex } from '@mirafive/sdk-convex'
     import { internal } from './_generated/api'
     export const mira: MiraConvex = new MiraConvex({ key: process.env.MIRAFIVE_SECRET_KEY, deliver: internal.mirafive.deliver })
     export const deliver = mira.deliverAction()
   Keep the ": MiraConvex" annotation; without it TypeScript reports TS7022.
4. In the mutations where business events happen (signup, order paid, subscription started), add
     await mira.track(ctx, 'order paid', { userId, properties: { revenue, currency: 'EUR' } })
   and after signup: await mira.identify(ctx, userId, { plan }, { anonymousId }) (anonymousId only if the
   browser sent it). Use the internal user id, never an email. Several events in one mutation: mira.trackMany(ctx, [...]).
5. Only if the app records more than about one event per second: pass outbox: true, add
   miraOutbox: miraOutboxTable to convex/schema.ts, export
     export const { flushOutbox, outboxRows } = mira.flushOutbox()
   from convex/mirafive.ts, and add
     crons.interval('mirafive outbox', { seconds: 30 }, internal.mirafive.flushOutbox)
   to convex/crons.ts.
6. Keep the default full mode (the app holds consent). For anonymous counts only, use
   mode: 'consentless' and pass no userId, anonymousId or sessionId.
7. Verify: npx convex run mirafive:deliver '{"events":"[{\"name\":\"$install_check\"}]","idempotencyKey":"install-check"}'
   must print null. Report what you changed.
Do not add other analytics libraries, cookies or consent banners, and do not call fetch from mutations.
```
